feat(compose): preselect identity of the active mailbox for new messages

Starting a new message while viewing a specific mailbox/account now defaults
the From identity to that mailbox instead of the global primary identity, so
composing from info@ sends as info@. Mirrors the existing reply-time identity
match and rides the same autoSelectReplyIdentity setting; reply/replyAll/forward
keep resolving from the original recipients. Matches exact then +tag-stripped.

Extracts findComposeIdentityId into lib/reply-identity.ts with unit tests.
This commit is contained in:
Patrick Rotter
2026-06-30 16:31:34 +02:00
committed by Linus Rath
parent 6c49427c7c
commit b716f95a73
4 changed files with 82 additions and 2 deletions
+5
View File
@@ -3084,6 +3084,11 @@ export default function Home() {
<EmailComposer
key={composerSessionId}
mode={pendingDraft?.mode ?? composerMode}
composeFromAccountEmail={
useAccountStore
.getState()
.getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email
}
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
+25 -1
View File
@@ -35,7 +35,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { resolveReplyFrom } from "@/lib/reply-identity";
import { findComposeIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import {
rewriteCidImagesForEditor,
@@ -146,6 +146,15 @@ interface EmailComposerProps {
initialDraftText?: string;
initialData?: ComposerDraftData | null;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
/**
* Email of the mailbox/account the user is viewing when they start a new
* message. When set (and `autoSelectReplyIdentity` is on), a fresh compose
* preselects the identity matching this address instead of the primary
* identity, so "New message" from info@ defaults its From to info@. Mirrors
* the reply-time identity match; ignored for reply/replyAll/forward (those
* resolve from the original recipients).
*/
composeFromAccountEmail?: string;
replyTo?: {
from?: { email?: string; name?: string }[];
replyToAddresses?: { email?: string; name?: string }[];
@@ -243,6 +252,7 @@ export function EmailComposer({
initialDraftText,
initialData,
mode = 'compose',
composeFromAccountEmail,
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
@@ -675,6 +685,19 @@ export function EmailComposer({
useEffect(() => {
if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return;
// New message started from a specific mailbox/account: default the From to
// that mailbox's identity instead of the primary one, so composing while
// viewing info@ sends as info@. Reply/forward fall through to the
// recipient-based resolution below.
if (mode === 'compose') {
const composeIdentityId = findComposeIdentityId(identities, composeFromAccountEmail);
if (composeIdentityId) {
setSelectedIdentityId(composeIdentityId);
}
return;
}
if (mode !== 'reply' && mode !== 'replyAll') return;
const resolved = resolveReplyFrom(identities, {
@@ -708,6 +731,7 @@ export function EmailComposer({
}
}, [
autoSelectReplyIdentity,
composeFromAccountEmail,
fromOverrideEnabled,
identities,
initialData?.selectedIdentityId,
+24 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
import { findComposeIdentityId, findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
import type { Identity } from '../jmap/types';
const identities: Identity[] = [
@@ -51,6 +51,29 @@ describe('findReplyIdentityId', () => {
});
});
describe('findComposeIdentityId', () => {
it('matches the identity of the active mailbox', () => {
expect(findComposeIdentityId(identities, 'harry@secondary.com')).toBe('secondary');
});
it('matches case-insensitively', () => {
expect(findComposeIdentityId(identities, 'HARRY@PRIMARY.COM')).toBe('primary');
});
it('strips +tag before matching', () => {
expect(findComposeIdentityId(identities, 'harry+news@secondary.com')).toBe('secondary');
});
it('returns null when the active mailbox has no matching identity', () => {
expect(findComposeIdentityId(identities, 'other@example.com')).toBeNull();
});
it('returns null when no active mailbox email is given', () => {
expect(findComposeIdentityId(identities, undefined)).toBeNull();
expect(findComposeIdentityId(identities, '')).toBeNull();
});
});
describe('resolveReplyFrom', () => {
it('returns the matching identity with no override when exact match', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] }))
+28
View File
@@ -67,6 +67,34 @@ export function findReplyIdentityId(
return baseIdentity?.id ?? null;
}
/**
* Pick the identity to use for a NEW message started while viewing a specific
* mailbox/account. Matches the active mailbox's address to a configured
* identity (exact, then `+tag`-stripped) so composing from info@ defaults its
* From to info@. Returns `null` when no address is given or none matches, so
* the caller keeps the primary identity.
*/
export function findComposeIdentityId(
identities: Identity[],
accountEmail?: string | null,
): string | null {
const email = accountEmail?.trim();
if (identities.length === 0 || !email) {
return null;
}
const exact = normalizeEmailAddress(email);
const exactIdentity = identities.find((identity) => normalizeEmailAddress(identity.email) === exact);
if (exactIdentity) {
return exactIdentity.id;
}
const base = normalizeBaseEmailAddress(email);
const baseIdentity = identities.find((identity) => normalizeBaseEmailAddress(identity.email) === base);
return baseIdentity?.id ?? null;
}
export interface ReplyFromResolution {
/** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */
identityId: string;