fix(email): restore the sender identity when reopening a draft

Reopening a draft reset the composer's From to the default identity. The
edit-draft handler matched the draft's saved From against the active-account
identity list by email only, so two identities sharing an address (a default +
an alias differing by name) collided — the wrong one was picked, or with
cross-account namespaced ids none was.

Add findDraftIdentityId (name+email, normalized, +tag fallback) and match against
the same list the composer renders (the flat cross-account list when multi-
account is on). Wired into both the classic and Pro edit-draft paths. Unit tests
plus the un-pinned 07-drafts integration spec.
This commit is contained in:
Stefan Hildebrandt
2026-07-23 19:36:33 +02:00
parent 457063a48b
commit 9c6292b4b7
5 changed files with 98 additions and 21 deletions
+31 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { findComposeIdentityId, findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
import type { Identity } from '../jmap/types';
const identities: Identity[] = [
@@ -17,6 +17,36 @@ const identities: Identity[] = [
},
];
describe('findDraftIdentityId', () => {
// Two identities on the SAME address, differing only by display name — the
// reopen-draft regression: an email-only match picks the default, not the one
// the draft was written with.
const sameAddress: Identity[] = [
{ id: 'default', name: 'Harry Primary', email: 'harry@primary.com', mayDelete: false },
{ id: 'team', name: 'Harry Team', email: 'harry@primary.com', mayDelete: false },
];
it('restores the exact identity by name when several share an address', () => {
expect(findDraftIdentityId(sameAddress, { name: 'Harry Team', email: 'harry@primary.com' })).toBe('team');
expect(findDraftIdentityId(sameAddress, { name: 'Harry Primary', email: 'harry@primary.com' })).toBe('default');
});
it('matches by email (normalized) when the name is absent or unique', () => {
expect(findDraftIdentityId(identities, { email: 'HARRY@Secondary.com' })).toBe('secondary');
expect(findDraftIdentityId(identities, { name: 'Whatever', email: 'harry@secondary.com' })).toBe('secondary');
});
it('falls back to the +tag-stripped base address', () => {
expect(findDraftIdentityId(identities, { email: 'harry+promo@secondary.com' })).toBe('secondary');
});
it('returns null when nothing matches or there is no From', () => {
expect(findDraftIdentityId(identities, { email: 'nobody@elsewhere.com' })).toBeNull();
expect(findDraftIdentityId(identities, null)).toBeNull();
expect(findDraftIdentityId([], { email: 'harry@primary.com' })).toBeNull();
});
});
describe('findReplyIdentityId', () => {
it('matches the identity that received the original message', () => {
const selected = findReplyIdentityId(identities, {
+39
View File
@@ -95,6 +95,45 @@ export function findComposeIdentityId(
return baseIdentity?.id ?? null;
}
/**
* Restore the identity a draft was composed with from its saved From. A draft
* stores only the From address+name, not an identityId, so when two identities
* share an address (a default + an alias with a different display name) the name
* has to disambiguate — an email-only match picks the wrong one. Falls back to
* email, then `+tag`-stripped email. Returns null when none match (caller keeps
* the default). Pass the same identity list the composer renders (the flat
* cross-account list when multi-account is on) so the returned id is usable
* there.
*/
export function findDraftIdentityId(
identities: Identity[],
from?: { email?: string | null; name?: string | null } | null,
): string | null {
const email = from?.email?.trim();
if (identities.length === 0 || !email) {
return null;
}
const wantEmail = normalizeEmailAddress(email);
const wantName = (from?.name ?? '').trim();
const nameAndEmail = identities.find(
(i) => normalizeEmailAddress(i.email) === wantEmail && (i.name ?? '').trim() === wantName,
);
if (nameAndEmail) {
return nameAndEmail.id;
}
const exact = identities.find((i) => normalizeEmailAddress(i.email) === wantEmail);
if (exact) {
return exact.id;
}
const wantBase = normalizeBaseEmailAddress(email);
const base = identities.find((i) => normalizeBaseEmailAddress(i.email) === wantBase);
return base?.id ?? null;
}
export interface ReplyFromResolution {
/** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */
identityId: string;