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
This commit is contained in:
Linus Rath
2026-03-18 20:05:35 +01:00
parent b844b88733
commit fc79bf4f9b
12 changed files with 107 additions and 11 deletions
+47 -4
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { useTranslations } from 'next-intl'; 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 { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog'; 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 { useFocusTrap } from '@/hooks/use-focus-trap';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; 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 { interface IdentityFormData {
name: string; name: string;
email: string; email: string;
@@ -39,6 +45,8 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const client = useAuthStore((state) => state.client); const client = useAuthStore((state) => state.client);
const identities = useIdentityStore((state) => state.identities); const identities = useIdentityStore((state) => state.identities);
const preferredPrimaryId = useIdentityStore((state) => state.preferredPrimaryId);
const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary);
const syncIdentities = useSyncIdentities(); const syncIdentities = useSyncIdentities();
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
@@ -52,11 +60,26 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
try { try {
const serverIdentities = await client.getIdentities(); const serverIdentities = await client.getIdentities();
const username = useAuthStore.getState().username; const username = useAuthStore.getState().username;
const preferredPrimaryId = useIdentityStore.getState().preferredPrimaryId;
const sorted = [...serverIdentities].sort((a, b) => { const sorted = [...serverIdentities].sort((a, b) => {
const aMatch = a.email === username ? -1 : 0; const aMatch = emailMatchesUsername(a.email, username || '');
const bMatch = b.email === username ? -1 : 0; const bMatch = emailMatchesUsername(b.email, username || '');
return aMatch - bMatch; 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); useIdentityStore.getState().setIdentities(sorted);
syncIdentities(); syncIdentities();
} catch (error) { } catch (error) {
@@ -168,6 +191,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
} }
}, [client, refreshIdentities, t, tNotif, confirmDialog]); }, [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; if (!isOpen) return null;
return ( return (
@@ -281,6 +313,17 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{identities[0]?.id !== identity.id && identities.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => handleSetPrimary(identity)}
disabled={!!editingId || isCreating}
title={t('set_as_primary')}
>
<Star className="w-4 h-4" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
+3 -1
View File
@@ -1425,7 +1425,9 @@ export class JMAPClient {
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") { if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[]; const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
if (identities.length > 0) { if (identities.length > 0) {
const matchingIdentity = identities.find((id) => id.email === (fromEmail || this.username)); const target = fromEmail || this.username;
const matchingIdentity = identities.find((id) => id.email === target)
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
finalIdentityId = matchingIdentity?.id || identities[0].id; finalIdentityId = matchingIdentity?.id || identities[0].id;
} }
} }
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identität erfolgreich erstellt", "identity_created": "Identität erfolgreich erstellt",
"identity_updated": "Identität erfolgreich aktualisiert", "identity_updated": "Identität erfolgreich aktualisiert",
"identity_deleted": "Identität gelöscht", "identity_deleted": "Identität gelöscht",
"identity_set_primary": "Primäre Identität aktualisiert",
"identity_create_failed": "Identität erstellen fehlgeschlagen: {error}", "identity_create_failed": "Identität erstellen fehlgeschlagen: {error}",
"identity_update_failed": "Identität aktualisieren fehlgeschlagen: {error}", "identity_update_failed": "Identität aktualisieren fehlgeschlagen: {error}",
"identity_delete_failed": "Identität löschen fehlgeschlagen: {error}", "identity_delete_failed": "Identität löschen fehlgeschlagen: {error}",
@@ -1367,6 +1368,7 @@
"delete_confirm": "Diese Identität löschen? Dies kann nicht rückgängig gemacht werden.", "delete_confirm": "Diese Identität löschen? Dies kann nicht rückgängig gemacht werden.",
"cannot_delete": "Diese Identität kann nicht gelöscht werden", "cannot_delete": "Diese Identität kann nicht gelöscht werden",
"primary_identity": "Primär", "primary_identity": "Primär",
"set_as_primary": "Als primär festlegen",
"no_identities": "Keine Identitäten gefunden", "no_identities": "Keine Identitäten gefunden",
"display": { "display": {
"reply_to": "Antwort an:", "reply_to": "Antwort an:",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identity created successfully", "identity_created": "Identity created successfully",
"identity_updated": "Identity updated successfully", "identity_updated": "Identity updated successfully",
"identity_deleted": "Identity deleted", "identity_deleted": "Identity deleted",
"identity_set_primary": "Primary identity updated",
"identity_create_failed": "Failed to create identity: {error}", "identity_create_failed": "Failed to create identity: {error}",
"identity_update_failed": "Failed to update identity: {error}", "identity_update_failed": "Failed to update identity: {error}",
"identity_delete_failed": "Failed to delete identity: {error}", "identity_delete_failed": "Failed to delete identity: {error}",
@@ -1367,6 +1368,7 @@
"delete_confirm": "Delete this identity? This cannot be undone.", "delete_confirm": "Delete this identity? This cannot be undone.",
"cannot_delete": "This identity cannot be deleted", "cannot_delete": "This identity cannot be deleted",
"primary_identity": "Primary", "primary_identity": "Primary",
"set_as_primary": "Set as primary",
"no_identities": "No identities found", "no_identities": "No identities found",
"display": { "display": {
"reply_to": "Reply-To:", "reply_to": "Reply-To:",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identidad creada exitosamente", "identity_created": "Identidad creada exitosamente",
"identity_updated": "Identidad actualizada exitosamente", "identity_updated": "Identidad actualizada exitosamente",
"identity_deleted": "Identidad eliminada", "identity_deleted": "Identidad eliminada",
"identity_set_primary": "Identidad principal actualizada",
"identity_create_failed": "Error al crear identidad: {error}", "identity_create_failed": "Error al crear identidad: {error}",
"identity_update_failed": "Error al actualizar identidad: {error}", "identity_update_failed": "Error al actualizar identidad: {error}",
"identity_delete_failed": "Error al eliminar identidad: {error}", "identity_delete_failed": "Error al eliminar identidad: {error}",
@@ -1367,6 +1368,7 @@
"delete_confirm": "¿Eliminar esta identidad? Esto no se puede deshacer.", "delete_confirm": "¿Eliminar esta identidad? Esto no se puede deshacer.",
"cannot_delete": "Esta identidad no se puede eliminar", "cannot_delete": "Esta identidad no se puede eliminar",
"primary_identity": "Principal", "primary_identity": "Principal",
"set_as_primary": "Establecer como principal",
"no_identities": "No se encontraron identidades", "no_identities": "No se encontraron identidades",
"display": { "display": {
"reply_to": "Responder a:", "reply_to": "Responder a:",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identité créée avec succès", "identity_created": "Identité créée avec succès",
"identity_updated": "Identité mise à jour avec succès", "identity_updated": "Identité mise à jour avec succès",
"identity_deleted": "Identité supprimée", "identity_deleted": "Identité supprimée",
"identity_set_primary": "Identité principale mise à jour",
"identity_create_failed": "Échec de la création de l'identité: {error}", "identity_create_failed": "Échec de la création de l'identité: {error}",
"identity_update_failed": "Échec de la mise à jour de l'identité: {error}", "identity_update_failed": "Échec de la mise à jour de l'identité: {error}",
"identity_delete_failed": "Échec de la suppression de l'identité: {error}", "identity_delete_failed": "Échec de la suppression de l'identité: {error}",
@@ -1367,6 +1368,7 @@
"delete_confirm": "Supprimer cette identité ? Cette action est irréversible.", "delete_confirm": "Supprimer cette identité ? Cette action est irréversible.",
"cannot_delete": "Cette identité ne peut pas être supprimée", "cannot_delete": "Cette identité ne peut pas être supprimée",
"primary_identity": "Principale", "primary_identity": "Principale",
"set_as_primary": "Définir comme principale",
"no_identities": "Aucune identité trouvée", "no_identities": "Aucune identité trouvée",
"display": { "display": {
"reply_to": "Répondre à :", "reply_to": "Répondre à :",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identità creata con successo", "identity_created": "Identità creata con successo",
"identity_updated": "Identità aggiornata con successo", "identity_updated": "Identità aggiornata con successo",
"identity_deleted": "Identità eliminata", "identity_deleted": "Identità eliminata",
"identity_set_primary": "Identità principale aggiornata",
"identity_create_failed": "Impossibile creare l'identità: {error}", "identity_create_failed": "Impossibile creare l'identità: {error}",
"identity_update_failed": "Impossibile aggiornare l'identità: {error}", "identity_update_failed": "Impossibile aggiornare l'identità: {error}",
"identity_delete_failed": "Impossibile eliminare l'identità: {error}", "identity_delete_failed": "Impossibile eliminare l'identità: {error}",
@@ -1367,6 +1368,7 @@
"delete_confirm": "Eliminare questa identità? Questa azione non può essere annullata.", "delete_confirm": "Eliminare questa identità? Questa azione non può essere annullata.",
"cannot_delete": "Questa identità non può essere eliminata", "cannot_delete": "Questa identità non può essere eliminata",
"primary_identity": "Principale", "primary_identity": "Principale",
"set_as_primary": "Imposta come principale",
"no_identities": "Nessuna identità trovata", "no_identities": "Nessuna identità trovata",
"display": { "display": {
"reply_to": "Rispondi a:", "reply_to": "Rispondi a:",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "送信者情報を作成しました", "identity_created": "送信者情報を作成しました",
"identity_updated": "送信者情報を更新しました", "identity_updated": "送信者情報を更新しました",
"identity_deleted": "送信者情報を削除しました", "identity_deleted": "送信者情報を削除しました",
"identity_set_primary": "プライマリ送信者情報を更新しました",
"identity_create_failed": "送信者情報の作成に失敗しました: {error}", "identity_create_failed": "送信者情報の作成に失敗しました: {error}",
"identity_update_failed": "送信者情報の更新に失敗しました: {error}", "identity_update_failed": "送信者情報の更新に失敗しました: {error}",
"identity_delete_failed": "送信者情報の削除に失敗しました: {error}", "identity_delete_failed": "送信者情報の削除に失敗しました: {error}",
@@ -1367,6 +1368,7 @@
"delete_confirm": "この送信者情報を削除しますか?この操作は元に戻せません。", "delete_confirm": "この送信者情報を削除しますか?この操作は元に戻せません。",
"cannot_delete": "この送信者情報は削除できません", "cannot_delete": "この送信者情報は削除できません",
"primary_identity": "プライマリ", "primary_identity": "プライマリ",
"set_as_primary": "プライマリに設定",
"no_identities": "送信者情報が見つかりません", "no_identities": "送信者情報が見つかりません",
"display": { "display": {
"reply_to": "返信先:", "reply_to": "返信先:",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identiteit succesvol aangemaakt", "identity_created": "Identiteit succesvol aangemaakt",
"identity_updated": "Identiteit succesvol bijgewerkt", "identity_updated": "Identiteit succesvol bijgewerkt",
"identity_deleted": "Identiteit verwijderd", "identity_deleted": "Identiteit verwijderd",
"identity_set_primary": "Primaire identiteit bijgewerkt",
"identity_create_failed": "Kan identiteit niet aanmaken: {error}", "identity_create_failed": "Kan identiteit niet aanmaken: {error}",
"identity_update_failed": "Kan identiteit niet bijwerken: {error}", "identity_update_failed": "Kan identiteit niet bijwerken: {error}",
"identity_delete_failed": "Kan identiteit niet verwijderen: {error}", "identity_delete_failed": "Kan identiteit niet verwijderen: {error}",
@@ -1367,6 +1368,7 @@
"delete_confirm": "Deze identiteit verwijderen? Dit kan niet ongedaan worden gemaakt.", "delete_confirm": "Deze identiteit verwijderen? Dit kan niet ongedaan worden gemaakt.",
"cannot_delete": "Deze identiteit kan niet worden verwijderd", "cannot_delete": "Deze identiteit kan niet worden verwijderd",
"primary_identity": "Primair", "primary_identity": "Primair",
"set_as_primary": "Instellen als primair",
"no_identities": "Geen identiteiten gevonden", "no_identities": "Geen identiteiten gevonden",
"display": { "display": {
"reply_to": "Antwoord naar:", "reply_to": "Antwoord naar:",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identidade criada com sucesso", "identity_created": "Identidade criada com sucesso",
"identity_updated": "Identidade atualizada com sucesso", "identity_updated": "Identidade atualizada com sucesso",
"identity_deleted": "Identidade excluída", "identity_deleted": "Identidade excluída",
"identity_set_primary": "Identidade principal atualizada",
"identity_create_failed": "Falha ao criar identidade: {error}", "identity_create_failed": "Falha ao criar identidade: {error}",
"identity_update_failed": "Falha ao atualizar identidade: {error}", "identity_update_failed": "Falha ao atualizar identidade: {error}",
"identity_delete_failed": "Falha ao excluir identidade: {error}", "identity_delete_failed": "Falha ao excluir identidade: {error}",
@@ -1367,6 +1368,7 @@
"delete_confirm": "Excluir esta identidade? Isso não pode ser desfeito.", "delete_confirm": "Excluir esta identidade? Isso não pode ser desfeito.",
"cannot_delete": "Esta identidade não pode ser excluída", "cannot_delete": "Esta identidade não pode ser excluída",
"primary_identity": "Principal", "primary_identity": "Principal",
"set_as_primary": "Definir como principal",
"no_identities": "Nenhuma identidade encontrada", "no_identities": "Nenhuma identidade encontrada",
"display": { "display": {
"reply_to": "Responder para:", "reply_to": "Responder para:",
+34 -5
View File
@@ -52,12 +52,41 @@ function classifyLoginError(error: unknown): string {
return 'generic'; return 'generic';
} }
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } { function emailMatchesUsername(email: string, username: string): boolean {
const identities = [...rawIdentities].sort((a, b) => { if (email === username) return true;
const aMatch = a.email === username ? -1 : 0; // Handle local-part login: username "user" should match "user@domain.tld"
const bMatch = b.email === username ? -1 : 0; if (!username.includes('@') && email.split('@')[0] === username) return true;
return aMatch - bMatch; return false;
}
function sortIdentities(rawIdentities: Identity[], username: string): Identity[] {
return [...rawIdentities].sort((a, b) => {
const aMatch = emailMatchesUsername(a.email, username);
const bMatch = emailMatchesUsername(b.email, username);
if (aMatch && !bMatch) return -1;
if (!aMatch && bMatch) return 1;
// Among matching identities, prefer canonical (non-deletable) over aliases
if (aMatch && bMatch) {
if (!a.mayDelete && b.mayDelete) return -1;
if (a.mayDelete && !b.mayDelete) return 1;
}
return 0;
}); });
}
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } {
const preferredPrimaryId = useIdentityStore.getState().preferredPrimaryId;
const identities = sortIdentities(rawIdentities, username);
// If user has a preferred primary, move it to front
if (preferredPrimaryId) {
const idx = identities.findIndex((id) => id.id === preferredPrimaryId);
if (idx > 0) {
const [preferred] = identities.splice(idx, 1);
identities.unshift(preferred);
}
}
const primaryIdentity = identities[0] ?? null; const primaryIdentity = identities[0] ?? null;
useIdentityStore.getState().setIdentities(identities); useIdentityStore.getState().setIdentities(identities);
return { identities, primaryIdentity }; return { identities, primaryIdentity };
+7 -1
View File
@@ -15,6 +15,7 @@ interface IdentityStore {
// Identity state (from server) // Identity state (from server)
identities: Identity[]; identities: Identity[];
selectedIdentityId: string | null; selectedIdentityId: string | null;
preferredPrimaryId: string | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
@@ -27,6 +28,7 @@ interface IdentityStore {
updateIdentityLocal: (identityId: string, updates: Partial<Identity>) => void; updateIdentityLocal: (identityId: string, updates: Partial<Identity>) => void;
removeIdentity: (identityId: string) => void; removeIdentity: (identityId: string) => void;
selectIdentity: (identityId: string | null) => void; selectIdentity: (identityId: string | null) => void;
setPreferredPrimary: (identityId: string | null) => void;
setLoading: (loading: boolean) => void; setLoading: (loading: boolean) => void;
setError: (error: string | null) => void; setError: (error: string | null) => void;
clearIdentities: () => void; clearIdentities: () => void;
@@ -43,6 +45,7 @@ export const useIdentityStore = create<IdentityStore>()(
(set, get) => ({ (set, get) => ({
identities: [], identities: [],
selectedIdentityId: null, selectedIdentityId: null,
preferredPrimaryId: null,
isLoading: false, isLoading: false,
error: null, error: null,
subAddress: { subAddress: {
@@ -71,6 +74,8 @@ export const useIdentityStore = create<IdentityStore>()(
selectIdentity: (identityId) => set({ selectedIdentityId: identityId }), selectIdentity: (identityId) => set({ selectedIdentityId: identityId }),
setPreferredPrimary: (identityId) => set({ preferredPrimaryId: identityId }),
setLoading: (loading) => set({ isLoading: loading }), setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }), setError: (error) => set({ error }),
@@ -120,7 +125,8 @@ export const useIdentityStore = create<IdentityStore>()(
name: 'identity-storage', name: 'identity-storage',
// Only persist sub-addressing data, not identities (they're server-side) // Only persist sub-addressing data, not identities (they're server-side)
partialize: (state) => ({ partialize: (state) => ({
subAddress: state.subAddress subAddress: state.subAddress,
preferredPrimaryId: state.preferredPrimaryId,
}), }),
} }
) )