mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
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.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<string, string | string[]>;
|
||||
/** 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<OriginalMessage | null>(
|
||||
() => (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 });
|
||||
|
||||
@@ -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<string, string | string[]> | 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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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<string, string>) => ({
|
||||
const original = (headers: Record<string, string>, 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<typeof vi.fn>;
|
||||
let sendRawEmail: ReturnType<typeof vi.fn>;
|
||||
let headers: Record<string, string> = {};
|
||||
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<string, string> }).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<string, string> }).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' });
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, string | string[]> | 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<string, string | string[]> | 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<string, string | string[]> | 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<string, string | string[]> | 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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface ProReplyContext {
|
||||
quoteWrapInBlockquote?: boolean;
|
||||
/** The original's label headers (lib/outgoing/classification.ts labelSourceHeaders). */
|
||||
headers?: Record<string, string | string[]>;
|
||||
/** The original's From for a forward as attachment, whose `from` stays empty. */
|
||||
originalFrom?: { email?: string; name?: string }[];
|
||||
}
|
||||
|
||||
export interface ProComposeTabData {
|
||||
|
||||
Reference in New Issue
Block a user