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:
+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