From 3ef16f602e7699c5f1fc4dce034cdb1e09a0f1e1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:48:01 +0100 Subject: [PATCH] fix: keep a message's label through Undo send and take over labels only from internal senders A sent message brought back by Undo, Cancel and edit or edit-as-new reopened with the default label, because only drafts carried the X-Bulwark-Label marker; it now falls back to the SIO-Label it went out with. A reply, quick reply or plugin forward takes over the original's label only when an internal sender set it and never one below the default, since anyone outside can write the header. The tests written for the earlier rule (any received label counts) now pass an internal sender, which is why their originals changed. --- .../composer-classification.test.tsx | 12 ++- components/email/email-composer.tsx | 16 +++- .../email/use-composer-classification.ts | 8 +- components/mail/mail-app.tsx | 14 +++- components/pro/pro-email-tab-body.tsx | 6 +- lib/__tests__/plugin-host-labels.test.ts | 19 ++++- lib/outgoing/__tests__/classification.test.ts | 47 +++++++++-- lib/outgoing/classification.ts | 79 ++++++++++++++++--- lib/plugin-sandbox/host-api-forward.ts | 2 +- stores/pro-tab-store.ts | 2 + 10 files changed, 170 insertions(+), 35 deletions(-) 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 {