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
+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,
}),
}
)