From fc79bf4f9b9b46d651785f4f833223a53f17a396 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 20:05:35 +0100 Subject: [PATCH] fix: resolve default sender to canonical identity on local-part login When authenticating with a local-part username (e.g. 'user' instead of 'user@domain.tld') on Stalwart 0.15.x, the default sender could resolve to an alias identity instead of the canonical mailbox address. - Add emailMatchesUsername() helper that matches local-part usernames against full email addresses (e.g. 'user' matches 'user@domain.tld') - Prefer canonical identities (mayDelete=false) over aliases as tiebreaker - Add preferredPrimaryId to identity store (persisted to localStorage) so users can explicitly set their default sender - Add 'Set as Primary' star button in identity manager modal - Fix sendEmail() fallback identity resolution for local-part usernames - Add i18n strings for all 8 supported locales Fixes #43 --- .../identity/identity-manager-modal.tsx | 51 +++++++++++++++++-- lib/jmap/client.ts | 4 +- locales/de/common.json | 2 + locales/en/common.json | 2 + locales/es/common.json | 2 + locales/fr/common.json | 2 + locales/it/common.json | 2 + locales/ja/common.json | 2 + locales/nl/common.json | 2 + locales/pt/common.json | 2 + stores/auth-store.ts | 39 ++++++++++++-- stores/identity-store.ts | 8 ++- 12 files changed, 107 insertions(+), 11 deletions(-) diff --git a/components/identity/identity-manager-modal.tsx b/components/identity/identity-manager-modal.tsx index e5479e39..1c7daefe 100644 --- a/components/identity/identity-manager-modal.tsx +++ b/components/identity/identity-manager-modal.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react'; import { useTranslations } from 'next-intl'; -import { X, Mail, Pencil, Trash2, Plus, AlertTriangle } from 'lucide-react'; +import { X, Mail, Pencil, Trash2, Plus, AlertTriangle, Star } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -19,6 +19,12 @@ import { toast } from '@/stores/toast-store'; import { useFocusTrap } from '@/hooks/use-focus-trap'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; +function emailMatchesUsername(email: string, username: string): boolean { + if (email === username) return true; + if (!username.includes('@') && email.split('@')[0] === username) return true; + return false; +} + interface IdentityFormData { name: string; email: string; @@ -39,6 +45,8 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr const client = useAuthStore((state) => state.client); const identities = useIdentityStore((state) => state.identities); + const preferredPrimaryId = useIdentityStore((state) => state.preferredPrimaryId); + const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary); const syncIdentities = useSyncIdentities(); const [editingId, setEditingId] = useState(null); @@ -52,11 +60,26 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr try { const serverIdentities = await client.getIdentities(); const username = useAuthStore.getState().username; + const preferredPrimaryId = useIdentityStore.getState().preferredPrimaryId; const sorted = [...serverIdentities].sort((a, b) => { - const aMatch = a.email === username ? -1 : 0; - const bMatch = b.email === username ? -1 : 0; - return aMatch - bMatch; + const aMatch = emailMatchesUsername(a.email, username || ''); + const bMatch = emailMatchesUsername(b.email, username || ''); + if (aMatch && !bMatch) return -1; + if (!aMatch && bMatch) return 1; + if (aMatch && bMatch) { + if (!a.mayDelete && b.mayDelete) return -1; + if (a.mayDelete && !b.mayDelete) return 1; + } + return 0; }); + // Move preferred primary to front if set + if (preferredPrimaryId) { + const idx = sorted.findIndex((id) => id.id === preferredPrimaryId); + if (idx > 0) { + const [preferred] = sorted.splice(idx, 1); + sorted.unshift(preferred); + } + } useIdentityStore.getState().setIdentities(sorted); syncIdentities(); } catch (error) { @@ -168,6 +191,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr } }, [client, refreshIdentities, t, tNotif, confirmDialog]); + const handleSetPrimary = useCallback((identity: Identity) => { + setPreferredPrimary(identity.id); + // Re-sort: move the preferred identity to the front + const reordered = [identity, ...identities.filter((id) => id.id !== identity.id)]; + useIdentityStore.getState().setIdentities(reordered); + syncIdentities(); + toast.success(tNotif('identity_set_primary')); + }, [identities, setPreferredPrimary, syncIdentities, tNotif]); + if (!isOpen) return null; return ( @@ -281,6 +313,17 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr {/* Actions */}
+ {identities[0]?.id !== identity.id && identities.length > 1 && ( + + )}