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:
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 à :",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "返信先:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user