diff --git a/components/email/__tests__/composer-classification.test.tsx b/components/email/__tests__/composer-classification.test.tsx index 528cbbbf3..39fd0017e 100644 --- a/components/email/__tests__/composer-classification.test.tsx +++ b/components/email/__tests__/composer-classification.test.tsx @@ -198,7 +198,7 @@ describe('composer: classification label', () => { const onSend = renderComposer({ mode: 'reply', initialData: { ...DRAFT, mode: 'reply', subject: 'Re: [Confidential] Budget' }, - replyTo: { subject: '[Confidential] Budget', headers: { 'SIO-Label': ' marking="CONFIDENTIAL"; bgcolor=#d97706' } }, + replyTo: { subject: '[Confidential] Budget', from: [{ email: 'boss@example.com' }], headers: { 'SIO-Label': ' marking="CONFIDENTIAL"; bgcolor=#d97706' } }, }); expect(screen.getByTestId('composer-classification-trigger').textContent).toContain('Confidential'); await send(); @@ -206,6 +206,16 @@ describe('composer: classification label', () => { expect(onSend.mock.calls[0][0].subject).toBe('[Confidential] Re: Budget'); }); + it('ignores a label an outside sender wrote, and starts with the default', async () => { + useLabels({ defaultId: 'internal' }); + renderComposer({ + mode: 'reply', + initialData: { ...DRAFT, mode: 'reply', subject: 'Re: Offer' }, + replyTo: { subject: 'Offer', from: [{ email: 'shop@outside.example' }], headers: { 'SIO-Label': ' marking="PUBLIC"' } }, + }); + expect(screen.getByTestId('composer-classification-trigger').textContent).toContain('Internal'); + }); + it('lets the user take the label off when none is required', async () => { useLabels({ defaultId: 'confidential' }); const onSend = renderComposer(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index c1ad4145f..0173ba66c 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -111,7 +111,7 @@ import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils"; import { wantsSignatureSeparator } from "@/lib/signature-delimiter"; import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline"; -import { beginLabelTakeover, classifyOutgoing, endLabelTakeover, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel } from "@/lib/outgoing/classification"; +import { beginLabelTakeover, classifyOutgoing, endLabelTakeover, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification"; import { whenPolicySettled } from "@/stores/policy-store"; import { ClassificationLabelPicker, type ClassificationPickerHandle } from "./classification-label-picker"; import { useComposerClassification } from "./use-composer-classification"; @@ -392,6 +392,8 @@ interface EmailComposerProps { quoteWrapInBlockquote?: boolean; /** The original's headers: a reply or forward starts with its classification label. */ headers?: Record; + /** The original's From, when `from` is left out (a forward as attachment): whether its label counts. */ + originalFrom?: { email?: string; name?: string }[]; }; } @@ -1121,10 +1123,16 @@ export function EmailComposer({ // Classification label (enterprise pack): the labels of the account and // identity the message goes out from; replies and forwards start with // the original's label, re-opened drafts with theirs. + const labelOriginal = useMemo( + () => (mode === 'compose' || !replyTo?.headers + ? null + : { headers: replyTo.headers, from: replyTo.from ?? replyTo.originalFrom, accountId: replyTo.accountId }), + [mode, replyTo?.headers, replyTo?.from, replyTo?.originalFrom, replyTo?.accountId], + ); const classification = useComposerClassification({ accountId: composingAccountId, senderEmail: currentIdentity?.email, - originalHeaders: mode === 'compose' ? null : replyTo?.headers, + original: labelOriginal, initialChoice: initialData?.classification, }); const classificationRef = useRef(classification); @@ -3367,7 +3375,7 @@ export function EmailComposer({ { const labelSender = { email: currentIdentity?.email, accountId: composingAccountId }; const labelPolicy = labelsPolicyFor(labelSender); - const label = resolveLabel(labelPolicy, classificationRef.current.choice, mode === 'compose' ? null : replyTo?.headers); + const label = resolveLabel(labelPolicy, classificationRef.current.choice, labelOriginal); if (labelMissing(labelPolicy, label)) { setLabelInvalid(true); toast.error(tClassification('required_toast')); @@ -3637,7 +3645,7 @@ export function EmailComposer({ const sealed = lastStage(finalHtmlBody, finalBody); // The label as it stands now, from the policy of the sending account. const labelPolicy = labelsPolicyFor({ email: currentIdentity?.email, accountId: composingAccountId }); - const sendLabel = resolveLabel(labelPolicy, classificationRef.current.choice, mode === 'compose' ? null : replyTo?.headers); + const sendLabel = resolveLabel(labelPolicy, classificationRef.current.choice, labelOriginal); // A crypto plugin builds the MIME itself: it gets the labelled // subject, and its raw send gets the label's headers (keyed to the token). const takeoverLabelled = classifyOutgoing(labelPolicy, sendLabel, { subject }); diff --git a/components/email/use-composer-classification.ts b/components/email/use-composer-classification.ts index 82b9522f3..86850bb19 100644 --- a/components/email/use-composer-classification.ts +++ b/components/email/use-composer-classification.ts @@ -3,7 +3,7 @@ import { useMemo, useState } from 'react'; import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; import { useAccountSubjects } from '@/lib/org/account-subjects'; -import { labelsPolicyFor, resolveLabel, labelMissing } from '@/lib/outgoing/classification'; +import { labelsPolicyFor, resolveLabel, labelMissing, type OriginalMessage } from '@/lib/outgoing/classification'; import type { LabelsPolicy } from '@/lib/classification/labels'; import { usePolicyStore } from '@/stores/policy-store'; @@ -29,8 +29,8 @@ export interface ComposerClassification { export function useComposerClassification(input: { accountId: string | null | undefined; senderEmail: string | undefined; - /** The original's headers, for a reply or forward. */ - originalHeaders?: Record | null; + /** The original of a reply or forward. */ + original?: OriginalMessage | null; initialChoice?: string | null; }): ComposerClassification { const policyState = usePolicyStore((s) => s.policy); @@ -43,7 +43,7 @@ export function useComposerClassification(input: { // eslint-disable-next-line react-hooks/exhaustive-deps [input.senderEmail, input.accountId, policyState, rawPolicy, subjects], ); - const label = useMemo(() => resolveLabel(policy, choice, input.originalHeaders), [policy, choice, input.originalHeaders]); + const label = useMemo(() => resolveLabel(policy, choice, input.original), [policy, choice, input.original]); return { policy, label, diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index bc4c7e8c9..99ecfcba8 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -122,8 +122,7 @@ import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { isFilePreviewable } from "@/lib/file-preview"; import { prepareQuickReply, type QuickReplyQuestion } from "@/lib/outgoing/quick-reply"; import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; -import { automaticLabel, classifyOutgoing, labelSourceHeaders } from "@/lib/outgoing/classification"; -import { draftLabelChoice } from "@/lib/classification/labels"; +import { automaticLabel, classifyOutgoing, labelSourceHeaders, reopenedLabelChoice } from "@/lib/outgoing/classification"; import enMessages from "@/locales/en/common.json"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; @@ -2501,7 +2500,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { inReplyTo: draft.inReplyTo ?? undefined, references: draft.references ?? undefined, priority: messagePriorityFromHeaders(draft.headers), - classification: draftLabelChoice(draft.headers), + // A sent message brought back (Undo, Cancel and edit) has no draft + // marker: its SIO-Label says which label it went out with. + classification: reopenedLabelChoice(draft.headers, localAccountId), // Existing server-side attachments must ride along, or the composer // starts empty and the next save/send silently rebuilds the draft // without them (#849). @@ -2854,6 +2855,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { subject: email.subject, attachments: [payload.attachment], headers: labelSourceHeaders(email.headers), + originalFrom: email.from, }, }); setComposerMode('forward'); @@ -4096,7 +4098,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // default. A required label with neither: the full composer, with the // typed text, to pick one. Nothing is asked or sent before that. await whenPolicySettled(); - const quickLabel = automaticLabel({ email: headerFromEmail }, selectedEmail.headers); + const quickLabel = automaticLabel({ email: headerFromEmail }, { + headers: selectedEmail.headers, + from: selectedEmail.from, + accountId: selectedEmail.sourceClientAccountId, + }); if (quickLabel.missing) { toast.info(t('classification.quick_reply_needs_label')); await handleReply(body); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 5f5f34344..e336ba877 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -24,8 +24,7 @@ import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY, emailKeyFor } from "@/lib/thread import { cn } from "@/lib/utils"; import { getDraftTextBody } from '@/lib/email-body-selection'; import { messagePriorityFromHeaders } from '@/lib/message-priority'; -import { labelSourceHeaders } from '@/lib/outgoing/classification'; -import { draftLabelChoice } from '@/lib/classification/labels'; +import { labelSourceHeaders, reopenedLabelChoice } from '@/lib/outgoing/classification'; import { useArchiveActionEnabled } from '@/lib/archive-gate'; import { confirmMailRemoval } from '@/lib/mail-removal-confirm'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; @@ -291,6 +290,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, subject: email.subject, attachments: [payload.attachment], headers: labelSourceHeaders(email.headers), + originalFrom: email.from, }, sourceEmailId: email.id, sourceEmailSource: writeSource, @@ -471,7 +471,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, // composer without its files and the next save stripped them. attachments: email.attachments, priority: messagePriorityFromHeaders(email.headers), - classification: draftLabelChoice(email.headers), + classification: reopenedLabelChoice(email.headers, localAccountId), }, }, { to: addresses(email.to), cc: addresses(email.cc), subject: draftSubject, body: bodyText }, onClose); }, [email, accountId, localAccountId, identities, multiAccountIdentities, openComposeTab, onClose, t]); diff --git a/lib/__tests__/plugin-host-labels.test.ts b/lib/__tests__/plugin-host-labels.test.ts index f643ea0ed..414128714 100644 --- a/lib/__tests__/plugin-host-labels.test.ts +++ b/lib/__tests__/plugin-host-labels.test.ts @@ -36,9 +36,9 @@ const FORWARDER = plugin(['email:forward'], 'e-invoice'); const SMIME = plugin(['email:raw-send'], 'smime'); const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args, { privileged: p.id === 'smime' }); -const original = (headers: Record) => ({ +const original = (headers: Record, sender = 'colleague@example.com') => ({ id: 'E1', threadId: 'T1', mailboxIds: { inbox: true }, keywords: {}, size: 900, receivedAt: '2026-10-20T08:00:00Z', - from: [{ email: 'billing@supplier.example' }], to: [{ email: 'ann@example.com' }], subject: 'Invoice', + from: [{ email: sender }], to: [{ email: 'ann@example.com' }], subject: 'Invoice', textBody: [{ partId: '1', blobId: 'tb', size: 20, type: 'text/plain' }], bodyValues: { 1: { value: 'Hello' } }, attachments: [], headers, }) as unknown as Email; @@ -53,6 +53,7 @@ describe('labels on the plugin paths', () => { let sendEmail: ReturnType; let sendRawEmail: ReturnType; let headers: Record = {}; + let sender = 'colleague@example.com'; beforeEach(() => { resetPluginRateLimits(); @@ -62,7 +63,7 @@ describe('labels on the plugin paths', () => { localStorage.setItem('plugin-forward-approvals', '{}'); client = { getAccountId: vi.fn(() => 'own'), - getEmail: vi.fn(async () => original(headers)), + getEmail: vi.fn(async () => original(headers, sender)), getMailboxes: vi.fn(async () => []), resolveSendAccountId: vi.fn(async () => 'own'), copyBlobs: vi.fn(async () => ({})), @@ -76,6 +77,7 @@ describe('labels on the plugin paths', () => { useAccountStore.setState({ activeAccountId: 'acct-1' }); useEmailStore.setState({ sendEmail, sendRawEmail, emails: [], selectedEmail: null, mailboxes: [] } as never); useIdentityStore.setState({ identities: [{ id: 'id-a', name: 'Ann', email: 'ann@example.com' }] as never, preferredPrimaryId: 'id-a' }); + useAuthStore.setState({ identities: [{ id: 'id-a', name: 'Ann', email: 'ann@example.com' }] } as never); useSettingsStore.setState({ sendDelaySeconds: 0 }); }); afterEach(() => { @@ -84,6 +86,7 @@ describe('labels on the plugin paths', () => { usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY }); resetLabelTakeovers(); headers = {}; + sender = 'colleague@example.com'; vi.clearAllMocks(); }); @@ -103,6 +106,16 @@ describe('labels on the plugin paths', () => { expect((args[16] as { labelHeaders: Record }).labelHeaders['header:SIO-Label:asRaw']).toContain('CONFIDENTIAL'); }); + it('takes the default, not an outside sender’s label', async () => { + useLabels({ defaultId: 'internal' }); + headers = { 'SIO-Label': ' marking="PUBLIC"' }; + sender = 'billing@supplier.example'; + expect(await forward()).toMatchObject({ sent: true }); + expect((sendEmail.mock.calls[0] as unknown[])[2]).toBe('Fwd: Invoice'); + const labelHeaders = ((sendEmail.mock.calls[0] as unknown[])[16] as { labelHeaders: Record }).labelHeaders; + expect(labelHeaders['header:SIO-Label:asRaw']).toContain('INTERNAL'); + }); + it('refuses a forward that needs a label it has no way to get', async () => { useLabels({ required: true, defaultId: '' }); expect(await call(FORWARDER, 'email.forward', 'E1', { to: ['acc@example.com'] })).toEqual({ sent: false, reason: 'stopped' }); diff --git a/lib/outgoing/__tests__/classification.test.ts b/lib/outgoing/__tests__/classification.test.ts index 2762b7863..7e470eabf 100644 --- a/lib/outgoing/__tests__/classification.test.ts +++ b/lib/outgoing/__tests__/classification.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { useAuthStore } from '@/stores/auth-store'; import { DEFAULT_POLICY } from '@/lib/admin/types'; import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; import { LABEL_PRESETS } from '@/lib/classification/presets'; @@ -13,6 +14,7 @@ import { isLabelRequiredError, labelSourceHeaders, resetLabelTakeovers, + reopenedLabelChoice, resolveLabel, } from '../classification'; @@ -25,17 +27,50 @@ const policy = { list: generic.list, defaultId: 'internal', required: false, ext const required = { ...policy, defaultId: '', required: true }; const label = (id: string) => generic.list.find((l) => l.id === id)!; +const fromColleague = (marking: string) => ({ headers: { 'SIO-Label': ` marking="${marking}"` }, from: [{ email: 'colleague@example.org' }] }); +const fromOutsider = (marking: string) => ({ headers: { 'SIO-Label': ` marking="${marking}"` }, from: [{ email: 'news@outside.example' }] }); + +beforeEach(() => { + useAuthStore.setState({ identities: [{ id: 'id-me', name: 'Me', email: 'me@example.org' }] } as never); +}); + afterEach(() => { usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY, subject: null }); + useAuthStore.setState({ identities: [] } as never); resetLabelTakeovers(); }); +describe('the label of the original', () => { + it('counts only when an internal sender set it: an outsider can write any header', () => { + expect(resolveLabel(policy, undefined, fromOutsider('PUBLIC'))?.id).toBe('internal'); + expect(resolveLabel(policy, undefined, fromOutsider('CONFIDENTIAL'))?.id).toBe('internal'); + expect(resolveLabel(policy, undefined, { headers: { 'SIO-Label': ' marking="CONFIDENTIAL"' } })?.id).toBe('internal'); + }); + + it('never goes below the default', () => { + expect(resolveLabel(policy, undefined, fromColleague('PUBLIC'))?.id).toBe('internal'); + expect(resolveLabel(policy, undefined, fromColleague('STRICTLY CONFIDENTIAL'))?.id).toBe('strictly-confidential'); + // Without a default, an internal label counts whatever it is. + expect(resolveLabel({ ...policy, defaultId: '' }, undefined, fromColleague('PUBLIC'))?.id).toBe('public'); + }); +}); + +describe('a message opened again', () => { + it('keeps the label a sent copy went out with (Undo, Cancel and edit), a draft’s own pick first', () => { + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy } } }); + expect(reopenedLabelChoice({ 'SIO-Label': 'marking="CONFIDENTIAL"; bgcolor=#d97706' })).toBe('confidential'); + expect(reopenedLabelChoice({ 'X-Bulwark-Label': 'none', 'SIO-Label': 'marking="CONFIDENTIAL"' })).toBeNull(); + expect(reopenedLabelChoice({ 'X-Bulwark-Label': 'public' })).toBe('public'); + expect(reopenedLabelChoice({})).toBeUndefined(); + }); +}); + describe('resolveLabel', () => { it('takes the pick, then the original’s label, then the default', () => { expect(resolveLabel(policy, 'public')?.id).toBe('public'); - expect(resolveLabel(policy, undefined, { 'SIO-Label': ' marking="CONFIDENTIAL"' })?.id).toBe('confidential'); + expect(resolveLabel(policy, undefined, fromColleague('CONFIDENTIAL'))?.id).toBe('confidential'); expect(resolveLabel(policy, undefined)?.id).toBe('internal'); - expect(resolveLabel(policy, null, { 'SIO-Label': ' marking="CONFIDENTIAL"' })).toBeNull(); + expect(resolveLabel(policy, null, fromColleague('CONFIDENTIAL'))).toBeNull(); // A pick that isn't in this account's list counts as nothing picked. expect(resolveLabel(policy, 'gone')?.id).toBe('internal'); }); @@ -78,8 +113,10 @@ describe('the policy of the sending account', () => { policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: required } }, subject: { email: 'me@example.org', groups: [] }, }); - expect(automaticLabel({ email: 'me@example.org' }, { 'SIO-Label': ' marking="PUBLIC"' })).toMatchObject({ missing: false, label: { id: 'public' } }); - expect(automaticLabel({ email: 'me@example.org' }, {})).toMatchObject({ missing: true, label: null }); + expect(automaticLabel({ email: 'me@example.org' }, fromColleague('PUBLIC'))).toMatchObject({ missing: false, label: { id: 'public' } }); + expect(automaticLabel({ email: 'me@example.org' }, { headers: {} })).toMatchObject({ missing: true, label: null }); + // An outsider's label doesn't count: a required label is still missing. + expect(automaticLabel({ email: 'me@example.org' }, fromOutsider('PUBLIC'))).toMatchObject({ missing: true, label: null }); }); it('keeps only the headers a reply needs for its label', () => { diff --git a/lib/outgoing/classification.ts b/lib/outgoing/classification.ts index 5f5d93756..f93e3f529 100644 --- a/lib/outgoing/classification.ts +++ b/lib/outgoing/classification.ts @@ -24,6 +24,8 @@ import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; import { subjectForAccount } from '@/lib/org/account-subjects'; import { policyForSender } from '@/lib/org/signature-state'; +import { activeExternalMailContext, contextInternalDomains, externalSenders, isOtherAccount } from '@/lib/external-mail'; +import { cachedIdentitiesForAccount } from '@/hooks/use-pro-multi-account-identities'; import { usePolicyStore } from '@/stores/policy-store'; import { generateUUID } from '@/lib/utils'; import { withoutLabelHeaders } from './custom-headers'; @@ -31,6 +33,7 @@ import { applyLabelPrefix, labelHeaderLines, labelHeaderProperties, + draftLabelChoice, receivedLabel, type LabelsPolicy, } from '@/lib/classification/labels'; @@ -60,25 +63,81 @@ export function labelsPolicyFor(sender: LabelSender): LabelsPolicy { return policy.organisation?.labels ?? DEFAULT_ORGANISATION_POLICY.labels; } +/** The message a reply or forward answers, as far as its label goes. */ +export interface OriginalMessage { + /** Its headers (labelSourceHeaders keeps the ones that matter). */ + headers?: Record | null; + from?: ReadonlyArray<{ email?: string | null }> | null; + /** The signed-in account it was received in; the active one when left out. */ + accountId?: string | null; +} + +/** + * Whether every From address of a message is inside the organisation of + * the account that received it (its identities' domains and the admin's + * organisation domains). False without a From or without known domains. + */ +export function senderIsInternal( + from: OriginalMessage['from'], + accountId?: string | null, +): boolean { + if (!from || from.length === 0) return false; + const context = isOtherAccount(accountId) + ? { + policy: (usePolicyStore.getState().resolveFor(subjectForAccount(accountId)).organisation + ?? DEFAULT_ORGANISATION_POLICY).externalMail, + identityEmails: (cachedIdentitiesForAccount(accountId) ?? []).map((identity) => identity.email), + } + : activeExternalMailContext(); + const internal = contextInternalDomains(context); + return internal.length > 0 && externalSenders(from, internal).length === 0; +} + +/** + * The label a reply or forward takes over from its original: the + * original's known label, only when an internal sender set it (anyone + * outside can write the header) and never one below the default (the + * list runs from least to most protected). + */ +export function originalLabel(policy: LabelsPolicy, original: OriginalMessage | null | undefined): ClassificationLabel | null { + if (!original?.headers || policy.list.length === 0) return null; + const label = receivedLabel(original.headers, policy); + if (!label || !senderIsInternal(original.from, original.accountId)) return null; + const floor = policy.list.findIndex((l) => l.id === policy.defaultId); + return floor >= 0 && policy.list.indexOf(label) < floor ? null : label; +} + /** * The label a message goes out with. `choice`: the user's pick (an id), * null for "no label", undefined when they picked nothing, which takes the - * original's label (a reply or forward of `originalHeaders`), else the - * default. A pick that isn't in the list (another account's, an old one) - * counts as nothing picked. + * original's label (originalLabel), else the default. A pick that isn't in + * the list (another account's, an old one) counts as nothing picked. */ export function resolveLabel( policy: LabelsPolicy, choice: string | null | undefined, - originalHeaders?: Record | null, + original?: OriginalMessage | null, ): ClassificationLabel | null { if (policy.list.length === 0) return null; if (choice === null) return null; const picked = choice ? policy.list.find((label) => label.id === choice) : undefined; if (picked) return picked; - const original = originalHeaders ? receivedLabel(originalHeaders, policy) : null; - if (original) return original; - return policy.list.find((label) => label.id === policy.defaultId) ?? null; + return originalLabel(policy, original) ?? policy.list.find((label) => label.id === policy.defaultId) ?? null; +} + +/** + * The pick a message opened again in the composer starts with: a draft's + * X-Bulwark-Label, else the label its headers carry (a sent message + * brought back by Undo, "Cancel and edit" or edit-as-new carries SIO-Label + * only), read with the labels of the account it is in. + */ +export function reopenedLabelChoice( + headers: Record | null | undefined, + accountId?: string | null, +): string | null | undefined { + const marked = draftLabelChoice(headers); + if (marked !== undefined) return marked; + return receivedLabel(headers, labelsPolicyFor({ accountId }))?.id; } /** Whether sending needs a label that `label` doesn't give. */ @@ -114,13 +173,13 @@ export function classifyOutgoing( }; } -/** The label of a quick reply or a plugin's forward: the original's, else the default. */ +/** The label of a quick reply or a plugin's forward: the original's (originalLabel), else the default. */ export function automaticLabel( sender: LabelSender, - originalHeaders?: Record | null, + original?: OriginalMessage | null, ): { policy: LabelsPolicy; label: ClassificationLabel | null; missing: boolean } { const policy = labelsPolicyFor(sender); - const label = resolveLabel(policy, undefined, originalHeaders); + const label = resolveLabel(policy, undefined, original); return { policy, label, missing: labelMissing(policy, label) }; } diff --git a/lib/plugin-sandbox/host-api-forward.ts b/lib/plugin-sandbox/host-api-forward.ts index 37e828f43..98ae6c4ef 100644 --- a/lib/plugin-sandbox/host-api-forward.ts +++ b/lib/plugin-sandbox/host-api-forward.ts @@ -147,7 +147,7 @@ export async function doEmailForward( // The classification label: a required one that isn't there stops the // forward before anything is asked. await whenPolicySettled(); - const label = automaticLabel({ email: identity.email }, email.headers); + const label = automaticLabel({ email: identity.email }, { headers: email.headers, from: email.from }); if (label.missing) { toast.error(text(messages, 'classification.forward_needs_label')); return { sent: false, reason: 'stopped' }; diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index 4283af9be..ecc393c05 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -53,6 +53,8 @@ export interface ProReplyContext { quoteWrapInBlockquote?: boolean; /** The original's label headers (lib/outgoing/classification.ts labelSourceHeaders). */ headers?: Record; + /** The original's From for a forward as attachment, whose `from` stays empty. */ + originalFrom?: { email?: string; name?: string }[]; } export interface ProComposeTabData {