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 { 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<string | null>(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 */}
<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
variant="ghost"
size="sm"
+3 -1
View File
@@ -1425,7 +1425,9 @@ export class JMAPClient {
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
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;
}
}
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identität erfolgreich erstellt",
"identity_updated": "Identität erfolgreich aktualisiert",
"identity_deleted": "Identität gelöscht",
"identity_set_primary": "Primäre Identität aktualisiert",
"identity_create_failed": "Identität erstellen fehlgeschlagen: {error}",
"identity_update_failed": "Identität aktualisieren 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.",
"cannot_delete": "Diese Identität kann nicht gelöscht werden",
"primary_identity": "Primär",
"set_as_primary": "Als primär festlegen",
"no_identities": "Keine Identitäten gefunden",
"display": {
"reply_to": "Antwort an:",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identity created successfully",
"identity_updated": "Identity updated successfully",
"identity_deleted": "Identity deleted",
"identity_set_primary": "Primary identity updated",
"identity_create_failed": "Failed to create identity: {error}",
"identity_update_failed": "Failed to update identity: {error}",
"identity_delete_failed": "Failed to delete identity: {error}",
@@ -1367,6 +1368,7 @@
"delete_confirm": "Delete this identity? This cannot be undone.",
"cannot_delete": "This identity cannot be deleted",
"primary_identity": "Primary",
"set_as_primary": "Set as primary",
"no_identities": "No identities found",
"display": {
"reply_to": "Reply-To:",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identidad creada exitosamente",
"identity_updated": "Identidad actualizada exitosamente",
"identity_deleted": "Identidad eliminada",
"identity_set_primary": "Identidad principal actualizada",
"identity_create_failed": "Error al crear identidad: {error}",
"identity_update_failed": "Error al actualizar identidad: {error}",
"identity_delete_failed": "Error al eliminar identidad: {error}",
@@ -1367,6 +1368,7 @@
"delete_confirm": "¿Eliminar esta identidad? Esto no se puede deshacer.",
"cannot_delete": "Esta identidad no se puede eliminar",
"primary_identity": "Principal",
"set_as_primary": "Establecer como principal",
"no_identities": "No se encontraron identidades",
"display": {
"reply_to": "Responder a:",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identité créée avec succès",
"identity_updated": "Identité mise à jour avec succès",
"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_update_failed": "Échec de la mise à jour 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.",
"cannot_delete": "Cette identité ne peut pas être supprimée",
"primary_identity": "Principale",
"set_as_primary": "Définir comme principale",
"no_identities": "Aucune identité trouvée",
"display": {
"reply_to": "Répondre à :",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identità creata con successo",
"identity_updated": "Identità aggiornata con successo",
"identity_deleted": "Identità eliminata",
"identity_set_primary": "Identità principale aggiornata",
"identity_create_failed": "Impossibile creare l'identità: {error}",
"identity_update_failed": "Impossibile aggiornare 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.",
"cannot_delete": "Questa identità non può essere eliminata",
"primary_identity": "Principale",
"set_as_primary": "Imposta come principale",
"no_identities": "Nessuna identità trovata",
"display": {
"reply_to": "Rispondi a:",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "送信者情報を作成しました",
"identity_updated": "送信者情報を更新しました",
"identity_deleted": "送信者情報を削除しました",
"identity_set_primary": "プライマリ送信者情報を更新しました",
"identity_create_failed": "送信者情報の作成に失敗しました: {error}",
"identity_update_failed": "送信者情報の更新に失敗しました: {error}",
"identity_delete_failed": "送信者情報の削除に失敗しました: {error}",
@@ -1367,6 +1368,7 @@
"delete_confirm": "この送信者情報を削除しますか?この操作は元に戻せません。",
"cannot_delete": "この送信者情報は削除できません",
"primary_identity": "プライマリ",
"set_as_primary": "プライマリに設定",
"no_identities": "送信者情報が見つかりません",
"display": {
"reply_to": "返信先:",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identiteit succesvol aangemaakt",
"identity_updated": "Identiteit succesvol bijgewerkt",
"identity_deleted": "Identiteit verwijderd",
"identity_set_primary": "Primaire identiteit bijgewerkt",
"identity_create_failed": "Kan identiteit niet aanmaken: {error}",
"identity_update_failed": "Kan identiteit niet bijwerken: {error}",
"identity_delete_failed": "Kan identiteit niet verwijderen: {error}",
@@ -1367,6 +1368,7 @@
"delete_confirm": "Deze identiteit verwijderen? Dit kan niet ongedaan worden gemaakt.",
"cannot_delete": "Deze identiteit kan niet worden verwijderd",
"primary_identity": "Primair",
"set_as_primary": "Instellen als primair",
"no_identities": "Geen identiteiten gevonden",
"display": {
"reply_to": "Antwoord naar:",
+2
View File
@@ -519,6 +519,7 @@
"identity_created": "Identidade criada com sucesso",
"identity_updated": "Identidade atualizada com sucesso",
"identity_deleted": "Identidade excluída",
"identity_set_primary": "Identidade principal atualizada",
"identity_create_failed": "Falha ao criar identidade: {error}",
"identity_update_failed": "Falha ao atualizar 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.",
"cannot_delete": "Esta identidade não pode ser excluída",
"primary_identity": "Principal",
"set_as_primary": "Definir como principal",
"no_identities": "Nenhuma identidade encontrada",
"display": {
"reply_to": "Responder para:",
+34 -5
View File
@@ -52,12 +52,41 @@ function classifyLoginError(error: unknown): string {
return 'generic';
}
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } {
const identities = [...rawIdentities].sort((a, b) => {
const aMatch = a.email === username ? -1 : 0;
const bMatch = b.email === username ? -1 : 0;
return aMatch - bMatch;
function emailMatchesUsername(email: string, username: string): boolean {
if (email === username) return true;
// Handle local-part login: username "user" should match "user@domain.tld"
if (!username.includes('@') && email.split('@')[0] === username) return true;
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;
useIdentityStore.getState().setIdentities(identities);
return { identities, primaryIdentity };
+7 -1
View File
@@ -15,6 +15,7 @@ interface IdentityStore {
// Identity state (from server)
identities: Identity[];
selectedIdentityId: string | null;
preferredPrimaryId: string | null;
isLoading: boolean;
error: string | null;
@@ -27,6 +28,7 @@ interface IdentityStore {
updateIdentityLocal: (identityId: string, updates: Partial<Identity>) => void;
removeIdentity: (identityId: string) => void;
selectIdentity: (identityId: string | null) => void;
setPreferredPrimary: (identityId: string | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
clearIdentities: () => void;
@@ -43,6 +45,7 @@ export const useIdentityStore = create<IdentityStore>()(
(set, get) => ({
identities: [],
selectedIdentityId: null,
preferredPrimaryId: null,
isLoading: false,
error: null,
subAddress: {
@@ -71,6 +74,8 @@ export const useIdentityStore = create<IdentityStore>()(
selectIdentity: (identityId) => set({ selectedIdentityId: identityId }),
setPreferredPrimary: (identityId) => set({ preferredPrimaryId: identityId }),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
@@ -120,7 +125,8 @@ export const useIdentityStore = create<IdentityStore>()(
name: 'identity-storage',
// Only persist sub-addressing data, not identities (they're server-side)
partialize: (state) => ({
subAddress: state.subAddress
subAddress: state.subAddress,
preferredPrimaryId: state.preferredPrimaryId,
}),
}
)