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:
committed by
Linus Rath
parent
6c49427c7c
commit
b716f95a73
@@ -3084,6 +3084,11 @@ export default function Home() {
|
|||||||
<EmailComposer
|
<EmailComposer
|
||||||
key={composerSessionId}
|
key={composerSessionId}
|
||||||
mode={pendingDraft?.mode ?? composerMode}
|
mode={pendingDraft?.mode ?? composerMode}
|
||||||
|
composeFromAccountEmail={
|
||||||
|
useAccountStore
|
||||||
|
.getState()
|
||||||
|
.getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email
|
||||||
|
}
|
||||||
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
|
||||||
from: selectedEmail.from,
|
from: selectedEmail.from,
|
||||||
replyToAddresses: selectedEmail.replyTo,
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
|||||||
import { TemplateForm } from "@/components/templates/template-form";
|
import { TemplateForm } from "@/components/templates/template-form";
|
||||||
import type { EmailTemplate } from "@/lib/template-types";
|
import type { EmailTemplate } from "@/lib/template-types";
|
||||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
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 { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
import {
|
import {
|
||||||
rewriteCidImagesForEditor,
|
rewriteCidImagesForEditor,
|
||||||
@@ -146,6 +146,15 @@ interface EmailComposerProps {
|
|||||||
initialDraftText?: string;
|
initialDraftText?: string;
|
||||||
initialData?: ComposerDraftData | null;
|
initialData?: ComposerDraftData | null;
|
||||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
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?: {
|
replyTo?: {
|
||||||
from?: { email?: string; name?: string }[];
|
from?: { email?: string; name?: string }[];
|
||||||
replyToAddresses?: { email?: string; name?: string }[];
|
replyToAddresses?: { email?: string; name?: string }[];
|
||||||
@@ -243,6 +252,7 @@ export function EmailComposer({
|
|||||||
initialDraftText,
|
initialDraftText,
|
||||||
initialData,
|
initialData,
|
||||||
mode = 'compose',
|
mode = 'compose',
|
||||||
|
composeFromAccountEmail,
|
||||||
replyTo
|
replyTo
|
||||||
}: EmailComposerProps) {
|
}: EmailComposerProps) {
|
||||||
const t = useTranslations('email_composer');
|
const t = useTranslations('email_composer');
|
||||||
@@ -675,6 +685,19 @@ export function EmailComposer({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoSelectReplyIdentity) return;
|
if (!autoSelectReplyIdentity) return;
|
||||||
if (selectedIdentityId || initialData?.selectedIdentityId) 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;
|
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||||
|
|
||||||
const resolved = resolveReplyFrom(identities, {
|
const resolved = resolveReplyFrom(identities, {
|
||||||
@@ -708,6 +731,7 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
autoSelectReplyIdentity,
|
autoSelectReplyIdentity,
|
||||||
|
composeFromAccountEmail,
|
||||||
fromOverrideEnabled,
|
fromOverrideEnabled,
|
||||||
identities,
|
identities,
|
||||||
initialData?.selectedIdentityId,
|
initialData?.selectedIdentityId,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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';
|
import type { Identity } from '../jmap/types';
|
||||||
|
|
||||||
const identities: Identity[] = [
|
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', () => {
|
describe('resolveReplyFrom', () => {
|
||||||
it('returns the matching identity with no override when exact match', () => {
|
it('returns the matching identity with no override when exact match', () => {
|
||||||
expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] }))
|
expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] }))
|
||||||
|
|||||||
@@ -67,6 +67,34 @@ export function findReplyIdentityId(
|
|||||||
return baseIdentity?.id ?? null;
|
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 {
|
export interface ReplyFromResolution {
|
||||||
/** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */
|
/** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */
|
||||||
identityId: string;
|
identityId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user