@@ -6138,6 +6157,7 @@ export function EmailViewer({
onClose={() => setInAppPreview(null)}
onDownload={downloadInAppPreview}
getFileContent={getInAppPreviewContent}
+ linksOff={phishingMarked}
/>
)}
>
diff --git a/components/email/message-actions.ts b/components/email/message-actions.ts
index 8b853ab15..fdfa78da4 100644
--- a/components/email/message-actions.ts
+++ b/components/email/message-actions.ts
@@ -7,6 +7,7 @@ import { emailLinkSource } from "@/stores/email-store";
import { useArchiveActionEnabled } from "@/lib/archive-gate";
import { buildMailPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
+import { usePhishingReport } from "@/hooks/use-phishing-report";
import type { Email } from "@/lib/jmap/types";
import {
Reply,
@@ -36,6 +37,7 @@ import {
Download,
Printer,
Code,
+ Fish,
} from "@/components/icons";
type Icon = ComponentType<{ className?: string }>;
@@ -61,6 +63,7 @@ export type MessageActionKey =
| "star"
| "pin"
| "spam"
+ | "report_phishing"
| "find_from_sender"
| "export"
| "print"
@@ -145,10 +148,14 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
const t = useTranslations("context_menu");
const tViewer = useTranslations("email_viewer");
const tDeepLink = useTranslations("deep_link");
+ const tPhishing = useTranslations("phishing_report");
const copyLink = useCopyLink();
const archiveEnabled = useArchiveActionEnabled();
const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view");
const { email } = o;
+ // Report phishing (enterprise pack): while the admin set an address for
+ // the message's account. It sends mail, so it opens a confirm first.
+ const phishing = usePhishingReport([email], o.currentMailboxRole);
const isUnread = !email.keywords?.$seen;
const isStarred = !!email.keywords?.$flagged;
@@ -248,6 +255,9 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
testId: inJunk ? "ctx-not-spam" : "ctx-spam",
})]
: [];
+ const reportPhishing = phishing.available
+ ? [item("report_phishing", Fish, tPhishing("action"), phishing.report, { testId: "ctx-report-phishing" })]
+ : [];
const sender = email.from?.[0];
const find = o.onFindFromSender && sender?.email
? [SEPARATOR, item("find_from_sender", Search, t("find_emails_from", { name: sender.name || sender.email }), o.onFindFromSender, { testId: "ctx-find-from" })]
@@ -263,12 +273,12 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
? [
...draft, ...replies, ...archive, del, read, SEPARATOR,
...move, tag, ...rules, ...copy, SEPARATOR,
- star, ...pin, ...spam, ...find, SEPARATOR,
+ star, ...pin, ...spam, ...reportPhishing, ...find, SEPARATOR,
...links, ...file,
]
: [
...draft, ...replies, ...links, ...archive, del, SEPARATOR,
- ...move, ...copy, ...rules, star, ...pin, tag, ...spam, SEPARATOR,
+ ...move, ...copy, ...rules, star, ...pin, tag, ...spam, ...reportPhishing, SEPARATOR,
read, SEPARATOR, ...file,
];
}
diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx
index c95c8dcab..e3ddc9dcf 100644
--- a/components/email/mv-list-toolbar.tsx
+++ b/components/email/mv-list-toolbar.tsx
@@ -18,7 +18,9 @@ import {
Tag,
Trash2,
RefreshCw,
+ Fish,
} from "@/components/icons";
+import { usePhishingReport } from "@/hooks/use-phishing-report";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { useShallow } from "zustand/react/shallow";
@@ -202,6 +204,10 @@ export function MvListToolbar({
const isInTrash = currentRole === "trash";
// Marking your own drafts or sent mail as spam is meaningless.
const spamApplicable = !["sent", "drafts", "scheduled"].includes(currentRole ?? "");
+ // Report phishing for the selection, one report per message (enterprise pack).
+ const selectionForPhishing = useEmailStore(useShallow((state) => resolveSelectedEmails(state)));
+ const phishing = usePhishingReport(selectionForPhishing, currentRole);
+ const tPhishing = useTranslations("phishing_report");
// Every action reports its outcome the way the default toolbar does: a
// rejected promise or a store error turns into an error toast instead of
@@ -516,6 +522,14 @@ export function MvListToolbar({
disabled={isProcessing}
/>
)}
+ {phishing.available && (
+
+ )}
{selectionCanLeaveFolder && (
{
if (p.hasAttribute(SIGNATURE_RANGE_MARKER)) return;
@@ -110,7 +114,7 @@ export function keepLoadedParagraphSpacing(html: string): string {
p.setAttribute("style", MAIL_PARAGRAPH_MARGIN + style);
stamped = true;
});
- return stamped ? root.innerHTML : html;
+ return stamped ? template.innerHTML : html;
}
/** A paragraph's `style` without the margin addParagraphSpacing put in front. */
diff --git a/components/email/phishing-mark-banner.tsx b/components/email/phishing-mark-banner.tsx
new file mode 100644
index 000000000..b8cd02896
--- /dev/null
+++ b/components/email/phishing-mark-banner.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+import { Fish } from '@/components/icons';
+import { cn } from '@/lib/utils';
+
+/** "You reported this message as phishing. Its links are turned off." above a message with $phishing. */
+export function PhishingMarkBanner({ className }: { className?: string }) {
+ const t = useTranslations('phishing_report');
+ return (
+
+
+ {t('banner')}
+
+ );
+}
diff --git a/components/email/phishing-report-host.tsx b/components/email/phishing-report-host.tsx
new file mode 100644
index 000000000..06d31e925
--- /dev/null
+++ b/components/email/phishing-report-host.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { useId, type KeyboardEvent } from 'react';
+import { useTranslations } from 'next-intl';
+import { Loader2, ShieldAlert } from '@/components/icons';
+import { Button } from '@/components/ui/button';
+import { useFocusTrap } from '@/hooks/use-focus-trap';
+import { reportEmailsAsPhishing, undoPhishingReports, type PhishingReportOutcome } from '@/lib/phishing-report-flow';
+import { usePhishingReportStore } from '@/stores/phishing-report-store';
+import { toast } from '@/stores/toast-store';
+
+/**
+ * "Report phishing?" with the address the report goes to, then the
+ * reports, one per message, and a toast with Undo. Mounted once in the app
+ * layout; every menu asks through usePhishingReportStore.
+ */
+export function PhishingReportHost() {
+ const t = useTranslations('phishing_report');
+ const pending = usePhishingReportStore((s) => s.pending);
+ const busy = usePhishingReportStore((s) => s.busy);
+ const id = useId();
+ const dialogRef = useFocusTrap({ isActive: !!pending, restoreFocus: false });
+
+ const cancel = () => {
+ if (!busy) usePhishingReportStore.getState().close();
+ };
+
+ const showOutcome = (outcome: PhishingReportOutcome) => {
+ const { reported, failed, notMoved, moved } = outcome;
+ const address = outcome.addresses.join(', ');
+ if (reported.length > 0) {
+ const undo = moved.length > 0
+ ? {
+ label: t('undo'),
+ onClick: () => {
+ void undoPhishingReports(outcome).then((back) => {
+ if (back > 0) toast.info(t('undone', { count: back }));
+ else toast.error(t('undo_failed'));
+ });
+ },
+ }
+ : undefined;
+ if (notMoved.length > 0) {
+ toast.warning(t('not_moved', { address, count: notMoved.length }), { action: undo });
+ } else {
+ toast.success(t('done', { address, count: reported.length }), { action: undo });
+ }
+ }
+ if (failed.length > 0) {
+ toast.error(t('failed', { count: failed.length }), {
+ message: failed.map((email) => email.subject || t('no_subject')).join(', '),
+ });
+ }
+ };
+
+ const confirm = async () => {
+ const state = usePhishingReportStore.getState();
+ if (!state.pending || state.busy) return;
+ state.setBusy(true);
+ let outcome: PhishingReportOutcome | null = null;
+ try {
+ outcome = await reportEmailsAsPhishing(state.pending.emails);
+ } catch {
+ outcome = null;
+ }
+ const emails = state.pending.emails;
+ usePhishingReportStore.getState().close();
+ if (outcome) showOutcome(outcome);
+ else toast.error(t('failed', { count: emails.length }));
+ };
+
+ // Keys stay in the dialog (Escape must not close the message behind it,
+ // j/k must not move the list); Enter and Space press the focused button.
+ const onKeyDown = (e: KeyboardEvent) => {
+ e.stopPropagation();
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ cancel();
+ return;
+ }
+ if ((e.key === 'Enter' || e.key === ' ') && e.target instanceof HTMLButtonElement) {
+ e.preventDefault();
+ e.target.click();
+ }
+ };
+
+ if (!pending) return null;
+ const count = pending.emails.length;
+
+ return (
+ {
+ if (e.target === e.currentTarget) cancel();
+ }}
+ onKeyDown={onKeyDown}
+ >
+
+
+
+
+
+
+
+
{t('confirm_title')}
+
+ {t('confirm_message', { address: pending.addresses.join(', '), count })}
+
+
+
+
+
+
+ {t('cancel')}
+
+ void confirm()} disabled={busy} data-testid="phishing-report-confirm">
+ {busy && }
+ {busy ? t('sending') : t('confirm')}
+
+
+
+
+ );
+}
diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts
index 426d6c7f4..d1aa476df 100644
--- a/components/email/quoted-html.ts
+++ b/components/email/quoted-html.ts
@@ -9,7 +9,7 @@ import { addParagraphSpacing, addTrailingParagraphBreaks } from "@/components/em
import { buildSignatureBlock } from "@/components/email/signature-block";
import { parkRemoteResources, restoreRemoteResources, sanitizeComposerIslandHtml } from "@/lib/email-sanitization";
import { useSettingsStore } from "@/stores/settings-store";
-import { remoteContentHeldBack } from "@/hooks/use-third-party-mode";
+import { remoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode";
// Marker attribute that identifies the quoted-original wrapper in serialized
// HTML, so parseHTML can recognise it on the way back in.
@@ -170,7 +170,9 @@ export const QuotedHtml = TiptapNode.create({
// A draft re-opened after the organisation blocked remote content (or
// before its policy loaded) keeps its quote parked, whatever the
// wrapper remembered from when the draft was started.
- const blockRemote = remoteContentHeldBack()
+ // The draft doesn't say which account it quotes: held back in any
+ // signed-in account keeps it parked.
+ const blockRemote = remoteContentHeldBackAnywhere()
|| (!node.attrs.remoteAllowed && useSettingsStore.getState().externalContentPolicy !== "allow");
let renderedHtml = "";
const render = (html: string) => {
@@ -257,6 +259,10 @@ export const QuotedHtml = TiptapNode.create({
*/
export function serializeEditorContent(editor: Editor): string {
const serializer = DOMSerializer.fromSchema(editor.schema);
+ // Built in an inert document: an made in the app document starts
+ // loading at once, so serialising (on every change) fired every remote
+ // image of the content, also while remote content is blocked.
+ const inert = document.implementation.createHTMLDocument("");
const parts: string[] = [];
// Empty paragraphs at the very end (the editor keeps one after a quote)
// stay invisible; every other empty line gets a so it keeps its
@@ -280,8 +286,8 @@ export function serializeEditorContent(editor: Editor): string {
parts.push(buildSignatureBlock((node.attrs.html as string) || ""));
return;
}
- const fragment = serializer.serializeNode(node);
- const tmp = document.createElement("div");
+ const fragment = serializer.serializeNode(node, { document: inert });
+ const tmp = inert.createElement("div");
tmp.appendChild(fragment);
if (index <= lastLine) addTrailingParagraphBreaks(tmp);
addParagraphSpacing(tmp);
diff --git a/components/email/received-label-chip.tsx b/components/email/received-label-chip.tsx
new file mode 100644
index 000000000..f28d02b54
--- /dev/null
+++ b/components/email/received-label-chip.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { useMemo } from 'react';
+import { useTranslations } from 'next-intl';
+import { LabelIcon } from '@/components/icons';
+import type { Email } from '@/lib/jmap/types';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { receivedLabel, textColorOn } from '@/lib/classification/labels';
+import { useAccountPolicy } from '@/hooks/use-account-policy';
+import { cn } from '@/lib/utils';
+
+/**
+ * The classification label a received message carries, when it is one of
+ * the labels of the message's own account (SIO-Label marking, else the
+ * admin's extra header). Information only: anyone can write the header,
+ * so nothing is relaxed because of it. List rows don't show it (they don't
+ * fetch the headers).
+ */
+export function ReceivedLabelChip({ email, className }: { email: Pick; className?: string }) {
+ const t = useTranslations('classification');
+ const policy = useAccountPolicy(email.sourceClientAccountId);
+ const labels = policy.organisation?.labels ?? DEFAULT_ORGANISATION_POLICY.labels;
+ const label = useMemo(() => receivedLabel(email.headers, labels), [email.headers, labels]);
+ if (!label) return null;
+ const name = t('chip_label', { name: label.name });
+ return (
+
+
+ {label.name}
+
+ );
+}
diff --git a/components/email/resizable-image.tsx b/components/email/resizable-image.tsx
index 906b441a5..bbed84d3c 100644
--- a/components/email/resizable-image.tsx
+++ b/components/email/resizable-image.tsx
@@ -4,6 +4,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import { Node, mergeAttributes } from "@tiptap/core";
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
+import { useRemoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode";
+import { TRANSPARENT_BLOCKED_PIXEL } from "@/lib/email-sanitization";
export interface ResizableImageOptions {
/** Open the alt text dialog for the image at this position. */
@@ -16,6 +18,12 @@ function ResizableImageView({ node, updateAttributes, selected, extension, getPo
const { onEditAlt, editAltLabel } = extension.options as ResizableImageOptions;
const imgRef = useRef(null);
const [resizing, setResizing] = useState(false);
+ // A remote image (a draft from another client, pasted web content) stays
+ // parked while the organisation blocks remote content or its policy isn't
+ // known yet; the address stays in the message.
+ const heldBack = useRemoteContentHeldBackAnywhere();
+ const src = typeof node.attrs.src === "string" ? node.attrs.src : "";
+ const parked = heldBack && /^(https?:)?\/\//i.test(src.trim());
const startState = useRef<{ x: number; y: number; width: number; height: number; handle: string }>({
x: 0, y: 0, width: 0, height: 0, handle: "",
});
@@ -78,7 +86,8 @@ function ResizableImageView({ node, updateAttributes, selected, extension, getPo
>
state.addToTrustedSendersBook);
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
const [allowedIds, setAllowedIds] = useState>(new Set());
- // No third-party traffic (#903), or the organisation locked "block":
- // nothing remote loads and nothing offers to.
- const remoteBlockedByOrg = useRemoteContentBlockedByOrg();
- // Also true until the policy has loaded: trust can't unblock before it.
- const remoteHeldBack = useRemoteContentHeldBack();
+ // No third-party traffic (#903), or the organisation locked "block" for
+ // the message's own account: nothing remote loads and nothing offers to.
+ // Also held back until that account's policy is known.
+ const remoteContent = useRemoteContentChecks();
const allow = useCallback((emailId: string) => {
setAllowedIds(prev => new Set(prev).add(emailId));
@@ -122,13 +125,13 @@ export function useConversationExternalContent() {
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
- if (remoteHeldBack) return false;
+ if (remoteContent.heldBack(email.sourceClientAccountId)) return false;
return externalContentPolicy === 'allow' || !!senderIsTrusted || allowedIds.has(email.id);
};
const trustSender = (email: Email) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
- if (!senderEmail || remoteBlockedByOrg) return undefined;
+ if (!senderEmail || remoteContent.blockedByOrg(email.sourceClientAccountId)) return undefined;
return () => {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
@@ -395,6 +398,7 @@ function EmailCard({
{sender?.name || (sender?.email && formatAddress(sender.email)) || t('email_viewer.unknown_sender')}
+
{email.isScheduled && (
@@ -545,7 +549,7 @@ export function ConversationEmailBody({
bodyStyle,
className,
}: ConversationEmailBodyProps) {
- const remoteBlockedByOrg = useRemoteContentBlockedByOrg();
+ const remoteBlockedByOrg = useRemoteContentBlockedByOrg(email.sourceClientAccountId);
const t = useTranslations();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
@@ -572,8 +576,11 @@ export function ConversationEmailBody({
const fallbackSource = useEmailStore(useShallow(() => resolveEmailBlobContext(email, client)));
const source = blobSource ?? fallbackSource;
const { client: blobClient, accountId } = source;
+ // Reported as phishing: a banner, links off, no cards (as in the reader).
+ const phishingMarked = usePhishingMarked(email);
+ const tPhishing = useTranslations('phishing_report');
// Booking, parcel and order cards; only an expanded message mounts this body.
- const structuredCards = useStructuredMail(email, source);
+ const structuredCards = useStructuredMail(phishingMarked ? null : email, source);
// Local CID URLs belong to a particular client/owner/message/part list.
// Hide the previous set during render, before the new fetch's effect runs.
const cidRequest = useMemo(() => ({ blobClient, accountId, emailId: email.id, attachments: email.attachments }),
@@ -740,7 +747,7 @@ export function ConversationEmailBody({
// "Show mail as plain text" (Settings > Accessibility): the message's own
// text/plain part, or a readable conversion of the HTML.
const mailViewMode = useSettingsStore((state) => state.mailViewMode);
- const emailContent = useMemo(() => {
+ const shownContent = useMemo(() => {
if (mailViewMode !== 'plain' || !formattedContent.isHtml) return formattedContent;
const textPart = !pluginBody.html ? email.textBody?.[0] : undefined;
let text = textPart?.partId && textPart.partId !== email.htmlBody?.[0]?.partId && (textPart.type || 'text/plain').toLowerCase() === 'text/plain'
@@ -755,6 +762,10 @@ export function ConversationEmailBody({
isHtml: false,
};
}, [mailViewMode, formattedContent, pluginBody.html, email, t]);
+ const emailContent = useMemo(
+ () => (phishingMarked ? { ...shownContent, html: turnOffLinks(shownContent.html, { title: tPhishing('link_off'), note: tPhishing('link_off_note') }) } : shownContent),
+ [shownContent, phishingMarked, tPhishing],
+ );
// Parts the body embeds via cid: stay out of the attachment row while the
// user hides inline images - the desktop viewer's rule, shared through
@@ -897,6 +908,7 @@ export function ConversationEmailBody({
return (
+ {phishingMarked &&
}
{/* External content warning */}
{hasBlockedContent && !allowExternal && emailContent.isHtml && (
diff --git a/components/email/use-composer-classification.ts b/components/email/use-composer-classification.ts
new file mode 100644
index 000000000..86850bb19
--- /dev/null
+++ b/components/email/use-composer-classification.ts
@@ -0,0 +1,55 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
+import { useAccountSubjects } from '@/lib/org/account-subjects';
+import { labelsPolicyFor, resolveLabel, labelMissing, type OriginalMessage } from '@/lib/outgoing/classification';
+import type { LabelsPolicy } from '@/lib/classification/labels';
+import { usePolicyStore } from '@/stores/policy-store';
+
+export interface ComposerClassification {
+ policy: LabelsPolicy;
+ /** The label the message goes out with as things stand. */
+ label: ClassificationLabel | null;
+ /** The user's pick: an id, null for none, undefined for "not picked" (original's or default). */
+ choice: string | null | undefined;
+ setChoice: (choice: string | null) => void;
+ /** Sending needs a label and there is none. */
+ missing: boolean;
+ /** What a saved draft keeps (X-Bulwark-Label); undefined while labels are off. */
+ draftChoice: string | null | undefined;
+}
+
+/**
+ * The composer's classification label: the labels of the account and
+ * identity the message goes out from, the user's pick, else the original's
+ * label (replies and forwards), else the default. Follows the policy as it
+ * loads and the From as it changes.
+ */
+export function useComposerClassification(input: {
+ accountId: string | null | undefined;
+ senderEmail: string | undefined;
+ /** The original of a reply or forward. */
+ original?: OriginalMessage | null;
+ initialChoice?: string | null;
+}): ComposerClassification {
+ const policyState = usePolicyStore((s) => s.policy);
+ const rawPolicy = usePolicyStore((s) => s.rawPolicy);
+ const subjects = useAccountSubjects((s) => s.subjects);
+ const [choice, setChoice] = useState
(input.initialChoice);
+ const policy = useMemo(
+ () => labelsPolicyFor({ email: input.senderEmail, accountId: input.accountId }),
+ // Re-read from the stores these follow.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [input.senderEmail, input.accountId, policyState, rawPolicy, subjects],
+ );
+ const label = useMemo(() => resolveLabel(policy, choice, input.original), [policy, choice, input.original]);
+ return {
+ policy,
+ label,
+ choice,
+ setChoice,
+ missing: labelMissing(policy, label),
+ draftChoice: policy.list.length === 0 ? undefined : (label?.id ?? null),
+ };
+}
diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx
index 0ff73b06e..c41622166 100644
--- a/components/files/eml-preview.tsx
+++ b/components/files/eml-preview.tsx
@@ -3,11 +3,12 @@
import { useTranslations } from "next-intl";
import { Paperclip, Download } from "@/components/icons";
import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization";
+import { turnOffLinks } from "@/lib/phishing-links";
import { getEffectiveTimeZone } from "@/lib/timezone";
import { stripBidiControls } from "@/lib/bidi";
import { isFilePreviewable } from "@/lib/file-preview";
import { useSettingsStore } from "@/stores/settings-store";
-import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
+import { useRemoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode";
// A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields
// this preview renders are typed.
@@ -48,20 +49,27 @@ function escapeHtml(s: string): string {
// can show - often another message: a certified-mail (PEC) envelope wraps the
// original, and a forwarded PEC wraps that envelope - opens through
// onOpenAttachment, with download beside it.
-export function EmlPreview({ message, onOpenAttachment }: {
+export function EmlPreview({ message, onOpenAttachment, linksOff }: {
message: ParsedEml;
onOpenAttachment?: (attachment: EmlAttachment) => void;
+ /** Attached to a message reported as phishing: links are off. */
+ linksOff?: boolean;
}) {
const t = useTranslations("email_viewer");
- // Always blocked with no third-party traffic (#903).
- const remoteBlockedByOrg = useNoThirdPartyTraffic();
+ const tPhishing = useTranslations("phishing_report");
+ // Always blocked with no third-party traffic (#903), while an
+ // organisation locks "block" in any signed-in account (the attachment's
+ // account isn't known here), and until the policy is known.
+ const remoteBlockedByOrg = useRemoteContentHeldBackAnywhere();
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
const tFiles = useTranslations("files");
// dir="auto" on a wrapper we control lets the first strong character pick
// the direction, matching the main viewer.
const body = message.html
- ? `${sanitizeEmailBodyForIframe(message.html, blockRemote).html}
`
+ ? `${linksOff
+ ? turnOffLinks(sanitizeEmailBodyForIframe(message.html, blockRemote).html, { title: tPhishing("link_off"), note: tPhishing("link_off_note") })
+ : sanitizeEmailBodyForIframe(message.html, blockRemote).html}
`
: message.text
? `${escapeHtml(message.text)} `
: "";
diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx
index b1bbc10c1..d8442635f 100644
--- a/components/files/file-preview-modal.tsx
+++ b/components/files/file-preview-modal.tsx
@@ -14,10 +14,11 @@ import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin
import { canOpenUrlInNewWindow } from "@/lib/standalone-display";
import { stripBidiControls } from "@/lib/bidi";
import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization";
+import { turnOffLinks, type LinksOffText } from "@/lib/phishing-links";
import { useSettingsStore } from "@/stores/settings-store";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import type { FileResourceView } from "@/lib/plugin-types";
-import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
+import { useRemoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode";
/**
* An HTML attachment as a document for a fully locked frame (sandbox="",
@@ -29,8 +30,10 @@ import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
* (the mechanism of GHSA-xvjh-v9c6-qcvc), while nothing sanitised it or
* stopped its remote images and meta refreshes.
*/
-export function htmlAttachmentDocument(html: string, blockRemote: boolean): string {
- const body = sanitizeEmailBodyForIframe(html, blockRemote).html;
+export function htmlAttachmentDocument(html: string, blockRemote: boolean, linksOff?: LinksOffText): string {
+ const clean = sanitizeEmailBodyForIframe(html, blockRemote).html;
+ // An attachment of a message reported as phishing: its links are off too.
+ const body = linksOff ? turnOffLinks(clean, linksOff) : clean;
return ` ${body}`;
}
@@ -51,6 +54,8 @@ interface FilePreviewModalProps {
file?: FileResourceView | null;
/** The previous and next file of the folder, for ←/→. */
neighbours?: PreviewNeighbours;
+ /** The file is an attachment of a message reported as phishing: links in it are off. */
+ linksOff?: boolean;
}
function SimpleMarkdown({ content }: { content: string }) {
@@ -144,8 +149,13 @@ function processInline(text: string): React.ReactNode {
return parts.length === 1 ? parts[0] : <>{parts}>;
}
-export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours }: FilePreviewModalProps) {
+export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours, linksOff }: FilePreviewModalProps) {
const t = useTranslations("files");
+ const tPhishing = useTranslations("phishing_report");
+ const linksOffText = useMemo(
+ () => (linksOff ? { title: tPhishing("link_off"), note: tPhishing("link_off_note") } : undefined),
+ [linksOff, tPhishing],
+ );
const [content, setContent] = useState(null);
const [objectUrl, setObjectUrl] = useState(null);
const [loading, setLoading] = useState(true);
@@ -163,8 +173,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
// open-in-new-tab fallback use.
const [pdfBlob, setPdfBlob] = useState(null);
const [htmlDoc, setHtmlDoc] = useState(null);
- // Always blocked with no third-party traffic (#903).
- const remoteBlockedByOrg = useNoThirdPartyTraffic();
+ // Always blocked with no third-party traffic (#903), while an
+ // organisation locks "block" in any signed-in account (the attachment's
+ // account isn't known here), and until the policy is known.
+ const remoteBlockedByOrg = useRemoteContentHeldBackAnywhere();
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
// An attachment of the previewed .eml, shown in a preview of its own in
// place of this one. It holds a Back entry of its own, so Escape and Back
@@ -225,7 +237,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
// Sanitised text in a srcdoc frame, like the .eml preview; never a
// blob: URL of the sender's bytes (see htmlAttachmentDocument).
const text = await blob.text();
- if (!cancelled) setHtmlDoc(htmlAttachmentDocument(text, blockRemote));
+ if (!cancelled) setHtmlDoc(htmlAttachmentDocument(text, blockRemote, linksOffText));
} else if (previewType === "eml") {
// Parse the embedded message (postal-mime, dynamic-imported so it
// stays off the bundle) and render it like an email via EmlPreview.
@@ -269,7 +281,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
URL.revokeObjectURL(revokeUrl);
}
};
- }, [getFileContent, name, blockRemote]);
+ }, [getFileContent, name, blockRemote, linksOffText]);
const openEmlAttachment = (att: EmlAttachment) => {
if (!att.content) return;
@@ -308,6 +320,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
onClose={() => setNestedPreview(null)}
onDownload={downloadNested}
getFileContent={nestedContent}
+ linksOff={linksOff}
/>
);
}
@@ -352,7 +365,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
)}
{!loading && !error && fileType === "eml" && emlContent && (
-
+
)}
{!loading && !error && fileType === "image" && objectUrl && (
diff --git a/components/icons/index.ts b/components/icons/index.ts
index a110750dd..ad4e220f4 100644
--- a/components/icons/index.ts
+++ b/components/icons/index.ts
@@ -102,6 +102,7 @@ export {
IconFileText as FileText,
IconVideo as FileVideo,
IconFilter as Filter,
+ IconFish as Fish,
IconFlag as Flag,
IconFlask as FlaskConical,
IconFold as FoldVertical,
@@ -134,6 +135,7 @@ export {
IconKey as KeyRound,
IconKeyboard as Keyboard,
IconLanguage as Languages,
+ IconLabel as LabelIcon,
IconStack as Layers,
IconLayoutDashboard as LayoutDashboard,
IconLayoutGrid as LayoutGrid,
@@ -141,6 +143,7 @@ export {
IconTemplate as LayoutTemplate,
IconBulb as Lightbulb,
IconLink as Link,
+ IconLinkOff as LinkOff,
IconList as List,
IconListNumbers as ListOrdered,
IconChecklist as ListTodo,
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 3718674e6..cecc35ceb 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -31,7 +31,7 @@ import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_MAILBOX_IDS, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, CROSS_VIEW_IDS, isCrossViewId } from "@/lib/jmap/types";
import { useAccountStore, waitForConnectedAccount } from "@/stores/account-store";
import { useShallow } from "zustand/react/shallow";
-import { usePolicyStore } from "@/stores/policy-store";
+import { usePolicyStore, whenPolicySettled } from "@/stores/policy-store";
import { isArchiveActionEnabled, useArchiveActionEnabled } from "@/lib/archive-gate";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
@@ -121,6 +121,9 @@ import { WopiEditor } from "@/components/files/wopi-editor";
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 { isPhishingMarked } from "@/lib/phishing-links";
+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";
@@ -300,7 +303,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const [conversationEmails, setConversationEmails] = useState([]);
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(null);
- const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; client: NonNullable } | null>(null);
+ // linksOff: the attachment of a message reported as phishing.
+ const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; client: NonNullable; linksOff?: boolean } | null>(null);
// Office attachments open read-only in the configured WOPI editor (#1047).
const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null);
const wopiStatus = useWopiStatus(true);
@@ -1333,6 +1337,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
messageId: selectedEmail.messageId,
inReplyTo: selectedEmail.inReplyTo,
references: selectedEmail.references,
+ // Its label, for the reply's or forward's classification label.
+ headers: labelSourceHeaders(selectedEmail.headers),
quoteHeaderHtml: composerQuoteHeader?.html,
quoteHeaderText: composerQuoteHeader?.text,
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
@@ -2158,6 +2164,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
requireTls?: boolean;
priority?: MessagePriority;
headers?: Record;
+ labelHeaders?: Record;
composerId?: string;
}) => {
// Nothing is sent: the composer keeps what only goes once the mail is out.
@@ -2182,7 +2189,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = composerSourceEmail?.id;
- const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt, requestDsn: data.requestDsn, requireTls: data.requireTls, priority: data.priority, headers: data.headers, localAccountId: data.localAccountId, draftAccountId: data.draftAccountId });
+ const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, composerSendOptions(data));
submitted = true;
announceSent(result, data);
setShowComposer(false);
@@ -2495,6 +2502,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
inReplyTo: draft.inReplyTo ?? undefined,
references: draft.references ?? undefined,
priority: messagePriorityFromHeaders(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).
@@ -2846,6 +2856,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
replyTo: {
subject: email.subject,
attachments: [payload.attachment],
+ headers: labelSourceHeaders(email.headers),
+ originalFrom: email.from,
},
});
setComposerMode('forward');
@@ -3959,7 +3971,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
try {
const { mailAttachmentAction } = useSettingsStore.getState();
if (mailAttachmentAction === 'preview' && isFilePreviewable(name, attachment.type)) {
- setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, client: blobClient });
+ setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, client: blobClient, linksOff: isPhishingMarked(email) });
return;
}
if (mailAttachmentAction === 'preview' && openOfficeAttachment(
@@ -4019,7 +4031,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const { mailAttachmentAction } = useSettingsStore.getState();
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
- setPreviewAttachment({ blobId, name, type, accountId, client: blobClient });
+ setPreviewAttachment({ blobId, name, type, accountId, client: blobClient, linksOff: isPhishingMarked(selectedEmail) });
return;
}
if (!forceDownload && mailAttachmentAction === 'preview' && openOfficeAttachment(
@@ -4084,6 +4096,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const signatureIdentity = (sendingIdentity?.htmlSignature || sendingIdentity?.textSignature)
? sendingIdentity
: primaryIdentity;
+ // The classification label (enterprise pack): the original's, else the
+ // 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 }, {
+ headers: selectedEmail.headers,
+ from: selectedEmail.from,
+ accountId: selectedEmail.sourceClientAccountId,
+ });
+ if (quickLabel.missing) {
+ toast.info(t('classification.quick_reply_needs_label'));
+ await handleReply(body);
+ return true;
+ }
+
// The same warnings, checks and finalisers as the composer's send
// (lib/outgoing/send-pipeline.ts): a quick reply is no way around them.
const settings = useSettingsStore.getState();
@@ -4155,11 +4182,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
references: selectedEmail.references,
});
+ // The label goes on last, as in the composer.
+ const labelled = classifyOutgoing(quickLabel.policy, quickLabel.label, {
+ subject: buildReplySubject(selectedEmail.subject || t('email_viewer.no_subject'), t('email_composer.prefix.reply')),
+ });
+
// Send reply with just the body text
const result = await sendEmail(
client,
recipients,
- buildReplySubject(selectedEmail.subject || t('email_viewer.no_subject'), t('email_composer.prefix.reply')),
+ labelled.subject,
finalBody,
undefined,
undefined,
@@ -4173,6 +4205,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
threading?.references,
delayedUntil,
undefined,
+ labelled.labelHeaders ? { labelHeaders: labelled.labelHeaders } : undefined,
);
// As after the composer's send: the server refused some recipients, held
// reply or not, and nothing else would say they never get it.
@@ -4822,6 +4855,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
messageId: composerSourceEmail.messageId,
inReplyTo: composerSourceEmail.inReplyTo,
references: composerSourceEmail.references,
+ // Its label, for the reply's or forward's classification label.
+ headers: labelSourceHeaders(composerSourceEmail.headers),
quoteHeaderHtml: composerQuoteHeader?.html,
quoteHeaderText: composerQuoteHeader?.text,
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
@@ -5765,6 +5800,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onClose={() => setPreviewAttachment(null)}
onDownload={handlePreviewAttachmentDownload}
getFileContent={getPreviewAttachmentContent}
+ linksOff={previewAttachment.linksOff}
/>
)}
diff --git a/components/pro/__tests__/cross-account-send-routing.test.tsx b/components/pro/__tests__/cross-account-send-routing.test.tsx
index 90dc2ad4c..abe6edab4 100644
--- a/components/pro/__tests__/cross-account-send-routing.test.tsx
+++ b/components/pro/__tests__/cross-account-send-routing.test.tsx
@@ -180,3 +180,35 @@ describe('Pro compose tab - cross-account send routing (#461)', () => {
}
});
});
+
+describe('Pro compose tab - what the composer chose', () => {
+ beforeEach(() => {
+ sendEmail.mockClear();
+ });
+
+ it('sends with the priority, read receipt, DSN, REQUIRETLS, plugin headers and label the composer chose', async () => {
+ payloadRef.current = {
+ ...basePayload,
+ requestReadReceipt: true,
+ requestDsn: true,
+ requireTls: true,
+ priority: 'high',
+ headers: { 'X-Plugin': '1' },
+ labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"' },
+ };
+ render( );
+
+ fireEvent.click(screen.getByText('send'));
+
+ await waitFor(() => expect(sendEmail).toHaveBeenCalled());
+ const args = sendEmail.mock.calls[0] as unknown[];
+ expect(args[args.length - 1]).toMatchObject({
+ requestReadReceipt: true,
+ requestDsn: true,
+ requireTls: true,
+ priority: 'high',
+ headers: { 'X-Plugin': '1' },
+ labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"' },
+ });
+ });
+});
diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx
index a3f1c72ce..a49e0f2df 100644
--- a/components/pro/pro-compose-tab-body.tsx
+++ b/components/pro/pro-compose-tab-body.tsx
@@ -14,6 +14,7 @@ import { debug } from "@/lib/debug";
import { threadKeyFor } from "@/lib/thread-utils";
import { formatRejectedRecipients } from "@/lib/jmap/client";
import { announceSent } from "@/lib/plugin-after-send";
+import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
interface ProComposeTabBodyProps {
tabId: string;
@@ -89,7 +90,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
sendData.references,
sendData.delayedUntil,
sendData.envelopeMailFrom,
- { localAccountId: sendData.localAccountId, draftAccountId: sendData.draftAccountId },
+ // The composer's whole options, as the mail page passes them: the
+ // tab used to send without priority, read receipt, DSN, REQUIRETLS,
+ // plugin headers and the classification label.
+ composerSendOptions(sendData),
);
submitted = true;
if (result.rejectedRecipients?.length) {
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index d5a6bea15..7a9537028 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -24,6 +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, 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';
@@ -67,6 +68,25 @@ interface ProEmailViewProps {
className?: string;
}
+/**
+ * A message the tab read through another login, or from a shared or group
+ * account, carries where it came from (sourceClientAccountId,
+ * sourceAccountId), as rows of the unified inbox do: then actions that
+ * route by the message (Report phishing, the remote-content lock of that
+ * account) reach its own account. A message of the active login's own
+ * account stays as it is.
+ */
+export function stampProSource(email: Email, source: EmailBlobContext, loginId: string | undefined): Email {
+ if (email.sourceClientAccountId && email.sourceAccountId) return email;
+ const activeLogin = useAuthStore.getState().activeAccountId ?? undefined;
+ const login = loginId ?? source.clientAccountId ?? activeLogin;
+ const ownAccount = source.client?.getAccountId?.();
+ const account = source.accountId ?? ownAccount;
+ const foreign = (!!login && login !== activeLogin) || (!!account && !!ownAccount && account !== ownAccount);
+ if (!foreign || !login || !account) return email;
+ return { ...email, sourceClientAccountId: login, sourceAccountId: account };
+}
+
function buildReplyContext(email: Email): ProReplyContext {
return {
from: email.from,
@@ -83,6 +103,7 @@ function buildReplyContext(email: Email): ProReplyContext {
messageId: email.messageId,
inReplyTo: email.inReplyTo,
references: email.references,
+ headers: labelSourceHeaders(email.headers),
};
}
@@ -200,7 +221,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
.then((loaded) => {
if (cancelled) return;
pending.applied = true;
- setEmail(loaded);
+ setEmail(loaded ? stampProSource(loaded, pending.blobSource, clientOverride ? clientAccountId : undefined) : loaded);
setBlobSource(pending.blobSource);
if (loaded) onLoadedRef.current?.(loaded);
})
@@ -213,7 +234,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
return () => {
cancelled = true;
};
- }, [client, clientOverride, emailId, accountId, fetchEmailContent]);
+ }, [client, clientOverride, clientAccountId, emailId, accountId, fetchEmailContent]);
const currentMailboxRole = useMemo(() => {
if (!email) return undefined;
@@ -287,6 +308,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
replyTo: {
subject: email.subject,
attachments: [payload.attachment],
+ headers: labelSourceHeaders(email.headers),
+ originalFrom: email.from,
},
sourceEmailId: email.id,
sourceEmailSource: writeSource,
@@ -467,6 +490,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: 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/hooks/use-phishing-report.ts b/hooks/use-phishing-report.ts
new file mode 100644
index 000000000..4cdd5801f
--- /dev/null
+++ b/hooks/use-phishing-report.ts
@@ -0,0 +1,72 @@
+'use client';
+
+import { useCallback, useMemo } from 'react';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { isReportable } from '@/lib/phishing-report';
+import { isPhishingMarked } from '@/lib/phishing-links';
+import { phishingSetupForEmail } from '@/lib/phishing-report-flow';
+import { useAccountSubjects } from '@/lib/org/account-subjects';
+import { emailKeyFor } from '@/lib/thread-utils';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useEmailStore } from '@/stores/email-store';
+import { usePhishingReportStore } from '@/stores/phishing-report-store';
+
+/**
+ * The roles of the folders a message is in, from the mailboxes the store
+ * knows: in search results or All mail the open folder says nothing about
+ * whether a message is the user's own sent mail.
+ */
+function folderRoles(email: Email, mailboxes: Mailbox[]): string[] {
+ const roles: string[] = [];
+ for (const id of Object.keys(email.mailboxIds ?? {})) {
+ const box = mailboxes.find((m) => m.id === id || m.originalId === id);
+ if (box?.role) roles.push(box.role);
+ }
+ return roles;
+}
+
+function reportable(email: Email, mailboxRole: string | null | undefined, mailboxes: Mailbox[]): boolean {
+ return isReportable(email, mailboxRole, folderRoles(email, mailboxes)) && !!phishingSetupForEmail(email);
+}
+
+/** Asks "Report phishing?" for the messages of `emails` that can be reported. */
+export function requestPhishingReport(emails: Email[], mailboxRole?: string | null): void {
+ const mailboxes = useEmailStore.getState().mailboxes;
+ const list = emails.filter((email) => reportable(email, mailboxRole, mailboxes));
+ if (list.length === 0) return;
+ // A selection across accounts may go to more than one address: name each.
+ const addresses = [...new Set(list.map((email) => phishingSetupForEmail(email)!.address))];
+ usePhishingReportStore.getState().ask(list, addresses);
+}
+
+/**
+ * "Report phishing" for a set of messages in a folder of `mailboxRole`:
+ * available while the admin set an address for at least one of their
+ * accounts and one of them can be reported. Follows the policy as it loads.
+ */
+export function usePhishingReport(emails: ReadonlyArray, mailboxRole?: string | null) {
+ const policy = usePolicyStore((s) => s.policy);
+ const rawPolicy = usePolicyStore((s) => s.rawPolicy);
+ const subjects = useAccountSubjects((s) => s.subjects);
+ const mailboxes = useEmailStore((s) => s.mailboxes);
+ const reported = usePhishingReportStore((s) => s.reported);
+ const list = useMemo(() => emails.filter((e): e is Email => !!e), [emails]);
+ const candidates = useMemo(
+ () => list.filter((email) => !reported[emailKeyFor(email)] && reportable(email, mailboxRole, mailboxes)),
+ // The setup is read from the stores these follow.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [list, mailboxRole, mailboxes, reported, policy, rawPolicy, subjects],
+ );
+ const report = useCallback(() => requestPhishingReport(candidates, mailboxRole), [candidates, mailboxRole]);
+ return { available: candidates.length > 0, count: candidates.length, report };
+}
+
+/**
+ * Whether a message shows as reported: its $phishing keyword, or reported
+ * in this session (a view that holds its own copy of the message, like a
+ * Pro tab, doesn't see the new keyword until it loads the message again).
+ */
+export function usePhishingMarked(email: Email | null | undefined): boolean {
+ const reported = usePhishingReportStore((s) => (email ? s.reported[emailKeyFor(email)] === true : false));
+ return isPhishingMarked(email) || reported;
+}
diff --git a/hooks/use-third-party-mode.ts b/hooks/use-third-party-mode.ts
index f021184b4..5937513e4 100644
--- a/hooks/use-third-party-mode.ts
+++ b/hooks/use-third-party-mode.ts
@@ -2,7 +2,10 @@
import { useSyncExternalStore } from 'react';
import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode';
+import type { SettingsPolicy } from '@/lib/admin/types';
+import { accountSubjectPending, subjectForAccount, useAccountSubjects } from '@/lib/org/account-subjects';
import { usePolicyStore } from '@/stores/policy-store';
+import { useAuthStore } from '@/stores/auth-store';
/**
* Whether the browser must stay off built-in outside hosts (#903), as React
@@ -15,12 +18,22 @@ export function useNoThirdPartyTraffic(): boolean {
type PolicyState = ReturnType;
+/**
+ * The effective policy of a signed-in account (AccountEntry id): the active
+ * one's from the store, another one's (a message of it in the unified
+ * inbox or a Pro tab) resolved for its own domain and group scopes.
+ */
+function policyForAccount(state: PolicyState, accountId: string | null | undefined): SettingsPolicy {
+ const subject = subjectForAccount(accountId ?? null);
+ return subject === state.subject ? state.policy : state.resolveFor(subject);
+}
+
/**
* The external content setting locked to "block" by the organisation, or
* limited to "block" as its only allowed value.
*/
-function externalContentLockedToBlock(state: PolicyState): boolean {
- const restriction = state.getRestriction('externalContentPolicy');
+function externalContentLockedToBlock(policy: SettingsPolicy): boolean {
+ const restriction = policy.restrictions?.externalContentPolicy;
const allowed = restriction?.allowedValues;
if (Array.isArray(allowed) && allowed.length > 0 && !allowed.includes('allow') && !allowed.includes('ask')) return true;
if (restriction?.locked !== true) return false;
@@ -28,43 +41,103 @@ function externalContentLockedToBlock(state: PolicyState): boolean {
// settings store applies it).
const value = Object.prototype.hasOwnProperty.call(restriction, 'value')
? restriction.value
- : state.getEffectiveDefault('externalContentPolicy');
+ : policy.defaults?.externalContentPolicy;
return value === 'block';
}
+/** Another signed-in account than the active one. */
+function isOtherAccount(accountId: string | null | undefined): accountId is string {
+ const auth = useAuthStore.getState();
+ return !!accountId && accountId !== auth.activeAccountId && !!auth.getClientForAccount?.(accountId);
+}
+
/**
- * Whether the organisation keeps remote content in mail from loading at all:
- * no third-party traffic, or the external content setting locked to "block".
- * Then neither a one-off load nor a trusted sender lets it through.
+ * Whether the policy that may lock remote content for `accountId` isn't
+ * known yet: the first fetch hasn't answered, only the public subset is in
+ * (just after sign-in), or the policy has domain or group scopes and the
+ * account's groups are still being worked out. A later refetch keeps the
+ * policy it already has, so it doesn't count.
*/
-export function useRemoteContentBlockedByOrg(): boolean {
- const noThirdParty = useNoThirdPartyTraffic();
- const lockedToBlock = usePolicyStore(externalContentLockedToBlock);
- return noThirdParty || lockedToBlock;
+function policyPending(state: PolicyState, accountId: string | null | undefined): boolean {
+ if (state.fetching && !state.loaded) return true;
+ if (state.partial) return true;
+ if ((state.rawPolicy.scopes ?? []).length === 0) return false;
+ return isOtherAccount(accountId) ? accountSubjectPending(accountId) : state.subjectPending;
}
-/** useRemoteContentBlockedByOrg, read once outside React. */
-export function remoteContentBlockedByOrg(): boolean {
- return noThirdPartyTraffic() || externalContentLockedToBlock(usePolicyStore.getState());
+/** Re-renders on anything the remote-content decisions read. */
+function usePolicyInputs(): void {
+ useSyncExternalStore(subscribeThirdPartyMode, noThirdPartyTraffic, () => true);
+ usePolicyStore((s) => s.policy);
+ usePolicyStore((s) => s.rawPolicy);
+ usePolicyStore((s) => s.subject);
+ usePolicyStore((s) => s.subjectPending);
+ usePolicyStore((s) => s.fetching);
+ usePolicyStore((s) => s.loaded);
+ usePolicyStore((s) => s.partial);
+ useAccountSubjects((s) => s.subjects);
+ useAccountSubjects((s) => s.settled);
+ useAuthStore((s) => s.activeAccountId);
}
-// The first policy fetch hasn't answered yet (a later refetch keeps the
-// policy it already has, so it doesn't count).
-const policyPending = (state: PolicyState) => state.fetching && !state.loaded;
+/**
+ * Whether the organisation keeps remote content in mail from loading at all
+ * for the account a message belongs to (the active one when left out): no
+ * third-party traffic, or the external content setting locked to "block" in
+ * that account's policy. Then neither a one-off load nor a trusted sender
+ * lets it through.
+ */
+export function remoteContentBlockedByOrg(accountId?: string | null): boolean {
+ return noThirdPartyTraffic() || externalContentLockedToBlock(policyForAccount(usePolicyStore.getState(), accountId));
+}
/**
* Whether remote content stays blocked whatever the user chose: blocked by
- * the organisation, or the policy that may lock it hasn't loaded yet. Until
- * it has, neither a trusted sender nor a one-off load lets anything through,
+ * the organisation, or the policy that may lock it isn't known yet. Until
+ * it is, neither a trusted sender nor a one-off load lets anything through,
* so the first paint can't fire a tracker the organisation blocks.
*/
-export function useRemoteContentHeldBack(): boolean {
- const blockedByOrg = useRemoteContentBlockedByOrg();
- const pending = usePolicyStore(policyPending);
- return blockedByOrg || pending;
+export function remoteContentHeldBack(accountId?: string | null): boolean {
+ return remoteContentBlockedByOrg(accountId) || policyPending(usePolicyStore.getState(), accountId);
}
-/** useRemoteContentHeldBack, read once outside React. */
-export function remoteContentHeldBack(): boolean {
- return remoteContentBlockedByOrg() || policyPending(usePolicyStore.getState());
+/**
+ * remoteContentHeldBack for every signed-in account: for content whose
+ * account isn't known (a re-opened draft's quote, which may come from any).
+ */
+export function remoteContentHeldBackAnywhere(): boolean {
+ const connected = useAuthStore.getState().getAllConnectedClients?.();
+ const ids = connected ? [...connected.keys()] : [];
+ return remoteContentHeldBack() || ids.some((id) => remoteContentHeldBack(id));
+}
+
+/** remoteContentHeldBackAnywhere as React state (attachment previews, whose account isn't known). */
+export function useRemoteContentHeldBackAnywhere(): boolean {
+ usePolicyInputs();
+ useAuthStore((s) => s.connectedAccountsRevision);
+ return remoteContentHeldBackAnywhere();
+}
+
+/** remoteContentBlockedByOrg as React state, for the account a message belongs to. */
+export function useRemoteContentBlockedByOrg(accountId?: string | null): boolean {
+ usePolicyInputs();
+ return remoteContentBlockedByOrg(accountId);
+}
+
+/** remoteContentHeldBack as React state, for the account a message belongs to. */
+export function useRemoteContentHeldBack(accountId?: string | null): boolean {
+ usePolicyInputs();
+ return remoteContentHeldBack(accountId);
+}
+
+/**
+ * For views with messages of several accounts (a conversation): re-renders
+ * on what the decisions read and hands back the per-account checks.
+ */
+export function useRemoteContentChecks(): {
+ heldBack: (accountId?: string | null) => boolean;
+ blockedByOrg: (accountId?: string | null) => boolean;
+} {
+ usePolicyInputs();
+ return { heldBack: remoteContentHeldBack, blockedByOrg: remoteContentBlockedByOrg };
}
diff --git a/integration/tests/22-ent-security.spec.ts b/integration/tests/22-ent-security.spec.ts
new file mode 100644
index 000000000..051556ec5
--- /dev/null
+++ b/integration/tests/22-ent-security.spec.ts
@@ -0,0 +1,641 @@
+import { test, expect, type Browser, type Page } from '@playwright/test';
+import { folderRow } from './helpers/app';
+import {
+ ENT,
+ ENT_PASSWORD,
+ adminContext,
+ contextOptions,
+ deliver,
+ getPolicy,
+ jmapAs,
+ putPolicy,
+ rawMessage,
+ requireEntEnv,
+ shot,
+ userPage,
+} from './helpers/ent';
+import { neutralizeDevOverlay, accountSwitcher } from './helpers/app';
+
+/**
+ * Enterprise pack, package A (mail security core), through the real UI on a
+ * VM 106 test Stalwart with the ent-* accounts:
+ * - item 1: the organisation's locked "block remote content" holds for
+ * trusted senders (list and address book; passes on main since
+ * f8805b8d5), the trusted-senders list offers no Add under the lock, and
+ * a reply draft started before the lock loads no remote image when
+ * reopened;
+ * - item 2: Report phishing sends the message to ent-sec as an attachment
+ * (and as ARF), with no copy in Sent, and moves it to Junk with $phishing;
+ * - item 3: a required classification label blocks sending until picked,
+ * SIO-Label and Sensitivity arrive, quick reply and a Pro compose tab keep
+ * it, a group scope gets its own scheme.
+ * Setup goes through the admin API and JMAP; the policy is put back at the end.
+ */
+
+test.describe.configure({ mode: 'serial' });
+test.setTimeout(180_000);
+
+const RUN = Date.now().toString(36);
+const PIXEL = `https://ent-pixel.example.org/${RUN}.png`;
+const LOCK = { externalContentPolicy: { locked: true, value: 'block' } };
+const GENERIC_LABELS = [
+ { id: 'public', name: 'Public', color: '#16a34a', marking: 'PUBLIC', description: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' },
+ { id: 'internal', name: 'Internal', color: '#2563eb', marking: 'INTERNAL', description: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' },
+ { id: 'confidential', name: 'Confidential', color: '#d97706', marking: 'CONFIDENTIAL', description: '', sensitivity: 'Company-Confidential', subjectPrefix: '[Confidential]', extraHeaderValue: '' },
+];
+const GERMAN_LABELS = [
+ { id: 'offen', name: 'OFFEN', color: '#6b7280', marking: 'OFFEN', description: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' },
+ { id: 'vs-nfd', name: 'VS-NfD', color: '#b91c1c', marking: 'VS-NUR FÜR DEN DIENSTGEBRAUCH', description: '', sensitivity: 'Company-Confidential', subjectPrefix: 'VS-NfD', extraHeaderValue: '' },
+];
+
+let original: Record;
+
+interface Msg {
+ id: string;
+ subject: string;
+ blobId: string;
+ keywords: Record;
+ mailboxIds: Record;
+ attachments?: Array<{ type: string; name?: string }>;
+}
+
+/** Messages of `account` whose text has `needle` (the subject filter doesn't find "Re:" subjects). */
+async function messages(account: string, needle: string): Promise {
+ const client = await jmapAs(account);
+ const r = await client.request([
+ ['Email/query', { accountId: client.accountId, filter: { text: needle }, limit: 50 }, '0'],
+ ['Email/get', {
+ accountId: client.accountId,
+ '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
+ properties: ['id', 'subject', 'blobId', 'keywords', 'mailboxIds', 'attachments'],
+ }, '1'],
+ ]);
+ return r.methodResponses[1][1].list as Msg[];
+}
+
+async function waitForMessage(account: string, needle: string, match: (m: Msg) => boolean, timeoutMs = 60_000): Promise {
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ const found = (await messages(account, needle)).find(match);
+ if (found) return found;
+ if (Date.now() > deadline) {
+ throw new Error(`${account}: nothing for "${needle}" (has: ${(await messages(account, needle)).map((m) => m.subject).join(' | ')})`);
+ }
+ await new Promise((r) => setTimeout(r, 1000));
+ }
+}
+
+async function roleMailboxId(account: string, role: string): Promise {
+ return (await (await jmapAs(account)).mailboxByRole(role))!.id;
+}
+
+async function policy(patch: { organisation?: Record; restrictions?: Record; scopes?: unknown[] }, browser: Browser) {
+ const admin = await adminContext(browser);
+ await putPolicy(admin, {
+ ...original,
+ organisation: { ...((original.organisation as object) ?? {}), ...(patch.organisation ?? {}) },
+ restrictions: { ...((original.restrictions as object) ?? {}), ...(patch.restrictions ?? {}) },
+ scopes: patch.scopes ?? [],
+ });
+ await admin.close();
+}
+
+/** Signs in with settings seeded before the app boots. */
+async function seededPage(browser: Browser, email: string, settings: Record, phone = false): Promise {
+ const context = await browser.newContext(contextOptions(phone));
+ const page = await context.newPage();
+ await neutralizeDevOverlay(page);
+ await page.addInitScript((s) => {
+ if (!sessionStorage.getItem('ent-seeded')) {
+ localStorage.setItem('settings-storage', JSON.stringify({ state: s, version: 7 }));
+ sessionStorage.setItem('ent-seeded', '1');
+ }
+ }, settings);
+ await page.goto('/');
+ await page.fill('#username', email);
+ await page.fill('#password', ENT_PASSWORD);
+ await page.click('button[type="submit"]');
+ await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
+ return page;
+}
+
+async function openMessage(page: Page, subject: string): Promise {
+ // By text: a thread row carries its newest message's subject.
+ const row = page.locator('[data-testid="email-list-item"], [data-thread-keys]', { hasText: subject }).first();
+ await expect(row).toBeVisible({ timeout: 60_000 }).catch(async (error) => {
+ const rows = await page.locator('[data-testid="email-list-item"]').evaluateAll((els) => els.map((e) => `${e.getAttribute('data-subject')} | ${(e as HTMLElement).innerText.replace(/\s+/g, ' ').slice(0, 80)}`));
+ console.log('rows:', JSON.stringify(rows), 'url:', page.url());
+ await page.screenshot({ path: `C:/Users/info/AppData/Local/Temp/claude/ent-a-fail-${Date.now()}.png` }).catch(() => {});
+ throw error;
+ });
+ await row.click();
+ await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 20_000 });
+}
+
+/** Counts the requests the page makes to the test pixel. */
+function pixelRequests(page: Page): { count: () => number } {
+ let n = 0;
+ void page.context().route('https://ent-pixel.example.org/**', (route) => {
+ n++;
+ void route.fulfill({ status: 200, contentType: 'image/gif', body: Buffer.from('R0lGODlhAQABAAAAACw=', 'base64') });
+ });
+ return { count: () => n };
+}
+
+/** Delivers and waits until every recipient has it, so the list shows it on sign-in. */
+async function deliverAndWait(opts: Parameters[0]): Promise {
+ await deliver(opts);
+ for (const to of opts.to) await waitForMessage(to, RUN, (m) => m.subject === opts.subject);
+}
+
+/**
+ * The message/rfc822 part of a raw report, decoded. Stalwart writes a
+ * message/rfc822 attachment base64-encoded (RFC 2046 §5.2.1 allows only
+ * 7bit, 8bit or binary there); readers decode it either way.
+ */
+function attachedMessage(raw: string): string {
+ const m = /Content-Type: message\/rfc822[\s\S]*?\r?\n\r?\n([\s\S]*?)\r?\n--/i.exec(raw);
+ if (!m) return '';
+ const head = raw.slice(m.index, m.index + m[0].length - m[1].length);
+ return /Content-Transfer-Encoding:\s*base64/i.test(head) ? Buffer.from(m[1].replace(/\s+/g, ''), 'base64').toString('utf8') : m[1];
+}
+
+/** Trusts the open message's sender (a sender trusted in an earlier run already is). */
+async function trustSender(page: Page): Promise {
+ const button = page.getByRole('button', { name: 'Always trust this sender' });
+ await page.waitForTimeout(1500);
+ if (await button.isVisible()) await button.click();
+ await expect(page.getByTestId('external-content-notice')).toBeHidden({ timeout: 20_000 });
+}
+
+const html = (text: string) => `${text}
Log in here
`;
+
+async function composeAndSend(page: Page, to: string, subject: string, beforeSend?: () => Promise): Promise {
+ await page.keyboard.press('c');
+ const composer = page.locator('[data-testid="email-composer"]');
+ await composer.waitFor({ state: 'visible', timeout: 15_000 });
+ const input = page.locator('[data-testid="composer-to"] input').first();
+ await input.click();
+ await input.fill(to);
+ await input.press('Enter');
+ await page.locator('[data-testid="composer-subject"]').first().fill(subject);
+ await composer.locator('[contenteditable="true"]').first().click();
+ await page.keyboard.type(`Body of ${subject}`);
+ if (beforeSend) await beforeSend();
+ await page.locator('[data-testid="composer-subject"]').first().focus();
+ await page.keyboard.press('Control+Enter');
+}
+
+test.beforeAll(async ({ browser }) => {
+ requireEntEnv();
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ await putPolicy(admin, { ...original, scopes: [] });
+ await admin.close();
+});
+
+test.afterAll(async ({ browser }) => {
+ if (!original) return;
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.close();
+});
+
+// ─── Item 1: trusted senders vs the organisation's lock ──────────────────
+
+test('item 1: a sender trusted in the list stays blocked once the organisation locks remote content (passes on main)', async ({ browser }) => {
+ const subject = `Trust list ${RUN}`;
+ await policy({}, browser);
+ await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, html: html('Newsletter') });
+ const alice = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block', trustedSendersAddressBook: false });
+ const pixel = pixelRequests(alice);
+ await openMessage(alice, subject);
+ await trustSender(alice);
+ await expect.poll(pixel.count, { timeout: 15_000 }).toBeGreaterThan(0);
+
+ // Before the lock: a reply draft whose quote may load the image.
+ await alice.keyboard.press('r');
+ await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
+ await alice.locator('[data-testid="email-composer"] [contenteditable="true"]').first().click();
+ await alice.keyboard.type(`Draft before the lock ${RUN}`);
+ await expect(alice.locator('[data-testid="composer-save-status"]')).toHaveAttribute('data-status', 'saved', { timeout: 30_000 });
+ await alice.keyboard.press('Escape');
+ await alice.context().close();
+
+ await policy({ restrictions: LOCK }, browser);
+ const locked = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block' });
+ const seen = await locked.evaluate(async () => (await (await fetch('/api/admin/policy')).json()).restrictions?.externalContentPolicy);
+ expect(seen, 'the user gets the lock').toMatchObject({ locked: true, value: 'block' });
+ const lockedPixel = pixelRequests(locked);
+ await openMessage(locked, subject);
+ await expect(locked.getByTestId('external-content-notice')).toContainText('Your organisation blocks content from other servers');
+ await expect(locked.getByRole('button', { name: 'Always trust this sender' })).toHaveCount(0);
+ const srcdoc = await locked.locator('iframe').first().getAttribute('srcdoc');
+ // Blocked images keep the address only in data-blocked-src.
+ expect(srcdoc ?? '').not.toMatch(/\ssrc="https:\/\/ent-pixel/);
+ expect(lockedPixel.count()).toBe(0);
+ await shot(locked, 'a-item1-locked-trusted');
+ await locked.context().close();
+});
+
+test('item 1: under the lock the trusted senders list offers no Add (fails on main)', async ({ browser }) => {
+ await policy({ restrictions: LOCK }, browser);
+ const alice = await userPage(browser, ENT.alice);
+ await alice.locator('a[href$="/settings"]').first().click();
+ await alice.locator('[data-testid="settings-tab-reading"]').first().click();
+ await expect(alice.getByText('Trusted senders').first()).toBeVisible();
+ await alice.getByRole('button', { name: /^\d+ senders?$|None/ }).first().click();
+ await expect(alice.getByText('Add sender manually')).toHaveCount(0);
+ await shot(alice, 'a-item1-trusted-list-locked');
+ await alice.context().close();
+});
+
+test('item 1: a reply draft from before the lock loads no remote image when reopened (fails on main)', async ({ browser }) => {
+ await policy({ restrictions: LOCK }, browser);
+ const alice = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block' });
+ const pixel = pixelRequests(alice);
+ await folderRow(alice, { role: 'drafts' }).first().click();
+ const row = alice.locator('[data-testid="email-list-item"], [data-thread-keys]', { hasText: `Trust list ${RUN}` }).first();
+ await expect(row).toBeVisible({ timeout: 30_000 });
+ await row.click();
+ await alice.getByTestId('edit-draft').waitFor({ state: 'visible' });
+ await alice.waitForTimeout(2000);
+ // Viewing the draft loads nothing either.
+ expect(pixel.count(), 'requests while the draft is shown in the reader').toBe(0);
+ await alice.getByTestId('edit-draft').click();
+ await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 20_000 });
+ await alice.waitForTimeout(3000);
+ expect(pixel.count()).toBe(0);
+ const remote = await alice.locator(`[data-testid="email-composer"] img[src="${PIXEL}"]`).count();
+ expect(remote).toBe(0);
+ await alice.context().close();
+});
+
+test('item 1: a sender trusted in the address book stays blocked under the lock (passes on main)', async ({ browser }) => {
+ const subject = `Trust book ${RUN}`;
+ await policy({}, browser);
+ await deliverAndWait({ from: ENT.carol, to: [ENT.alice], subject, html: html('From the book') });
+ const alice = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block', trustedSendersAddressBook: true });
+ await openMessage(alice, subject);
+ await trustSender(alice);
+ await alice.context().close();
+
+ await policy({ restrictions: LOCK }, browser);
+ const locked = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block', trustedSendersAddressBook: true });
+ const pixel = pixelRequests(locked);
+ await openMessage(locked, subject);
+ await expect(locked.getByTestId('external-content-notice')).toContainText('Your organisation blocks content');
+ expect(pixel.count()).toBe(0);
+ await locked.context().close();
+});
+
+// ─── Item 2: Report phishing ─────────────────────────────────────────────
+
+test('item 1: a lock set in a group scope holds for its members only, also before their groups are known', async ({ browser }) => {
+ const subject = `Scoped lock ${RUN}`;
+ await policy({ scopes: [{ id: 'team-lock', label: 'Team lock', match: { groups: [ENT.team] }, policy: { restrictions: LOCK } }] }, browser);
+ await deliverAndWait({ from: ENT.bob, to: [ENT.alice, ENT.carol], subject, html: html('Scoped') });
+ // ent-alice is in ent-team: blocked, with ent-bob trusted in the list.
+ const alice = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block', trustedSenders: [ENT.bob] });
+ const pixel = pixelRequests(alice);
+ await openMessage(alice, subject);
+ await expect(alice.getByTestId('external-content-notice')).toContainText('Your organisation blocks content');
+ expect(pixel.count()).toBe(0);
+ await alice.context().close();
+ // ent-carol isn't: her own choice applies, no organisation note.
+ const carol = await seededPage(browser, ENT.carol, { externalContentPolicy: 'ask' });
+ await openMessage(carol, subject);
+ await expect(carol.getByTestId('external-content-notice')).toBeVisible();
+ await expect(carol.getByTestId('external-content-notice')).not.toContainText('Your organisation blocks content');
+ await carol.context().close();
+});
+
+test('item 2: the admin sets the report address on Organisation > Security', async ({ browser }) => {
+ await policy({}, browser);
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-security');
+ const field = page.getByTestId('org-phishing-address');
+ await field.scrollIntoViewIfNeeded();
+ await field.fill('ent-sec@example.org');
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await shot(page, 'a-phishing-admin');
+ const stored = await getPolicy(admin);
+ expect((stored.organisation as { phishingReport: unknown }).phishingReport).toEqual({ address: 'ent-sec@example.org', format: 'forward' });
+ await admin.close();
+});
+
+test('item 2: Report phishing sends the message to ent-sec as an attachment and moves it to Junk with $phishing', async ({ browser }) => {
+ const subject = `Phish one ${RUN}`;
+ await policy({ organisation: { phishingReport: { address: ENT.sec, format: 'forward' } } }, browser);
+ await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, html: html('Your account is locked') });
+ const alice = await userPage(browser, ENT.alice);
+ await openMessage(alice, subject);
+ await alice.getByRole('button', { name: 'More actions' }).first().click();
+ await alice.getByTestId('ctx-report-phishing').click();
+ const dialog = alice.getByTestId('phishing-report-dialog');
+ await expect(dialog).toContainText(ENT.sec);
+ await shot(alice, 'a-phishing-confirm');
+ await alice.getByTestId('phishing-report-confirm').click();
+ await expect(alice.getByText(`Reported to ${ENT.sec} and moved to Junk.`).first()).toBeVisible({ timeout: 30_000 });
+
+ const report = await waitForMessage(ENT.sec, RUN, (m) => m.subject === `Phishing: ${subject}`);
+ expect(report.attachments?.map((a) => a.type)).toEqual(['message/rfc822']);
+ const raw = await rawMessage(ENT.sec, report.blobId);
+ expect(raw).toContain('Reported as phishing by');
+ expect(attachedMessage(raw)).toContain(`Subject: ${subject}`);
+
+ const junk = await roleMailboxId(ENT.alice, 'junk');
+ const moved = await waitForMessage(ENT.alice, RUN, (m) => m.subject === subject && !!m.mailboxIds[junk] && m.keywords.$phishing === true);
+ expect(moved.keywords.$junk).toBe(true);
+ // No copy in Sent.
+ const sent = await roleMailboxId(ENT.alice, 'sent');
+ expect((await messages(ENT.alice, RUN)).filter((m) => m.subject.startsWith('Phishing:') && m.mailboxIds[sent])).toEqual([]);
+
+ // In Junk: the banner, and the link is off.
+ await folderRow(alice, { role: 'junk' }).first().click();
+ await openMessage(alice, subject);
+ await expect(alice.getByTestId('phishing-mark-banner')).toContainText('You reported this message as phishing');
+ const srcdoc = await alice.locator('iframe').first().getAttribute('srcdoc');
+ expect(srcdoc ?? '').toContain('Log in here');
+ expect(srcdoc ?? '').not.toContain('https://ent-link.example.org/login');
+ await shot(alice, 'a-phishing-banner');
+ await alice.context().close();
+});
+
+test('item 2: Undo after a report moves the message back and clears $phishing', async ({ browser }) => {
+ const subject = `Phish undo ${RUN}`;
+ await policy({ organisation: { phishingReport: { address: ENT.sec, format: 'forward' } } }, browser);
+ await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, text: 'Undo check' });
+ const alice = await userPage(browser, ENT.alice);
+ await openMessage(alice, subject);
+ await alice.getByRole('button', { name: 'More actions' }).first().click();
+ await alice.getByTestId('ctx-report-phishing').click();
+ await alice.getByTestId('phishing-report-confirm').click();
+ const toast = alice.getByText(`Reported to ${ENT.sec} and moved to Junk.`).first();
+ await expect(toast).toBeVisible({ timeout: 30_000 });
+ await alice.getByRole('button', { name: 'Undo', exact: true }).first().click();
+ await expect(alice.getByText('Moved back. The report was already sent.').first()).toBeVisible({ timeout: 30_000 });
+ const inbox = await roleMailboxId(ENT.alice, 'inbox');
+ const back = await waitForMessage(ENT.alice, RUN, (m) => m.subject === subject && !!m.mailboxIds[inbox]);
+ expect(back.keywords.$phishing).toBeUndefined();
+ expect(back.keywords.$junk).toBeUndefined();
+ // The report itself went out.
+ await waitForMessage(ENT.sec, RUN, (m) => m.subject === `Phishing: ${subject}`);
+ await alice.context().close();
+});
+
+test('item 2: the ARF format arrives as a feedback report', async ({ browser }) => {
+ const subject = `Phish arf ${RUN}`;
+ await policy({ organisation: { phishingReport: { address: ENT.sec, format: 'arf' } } }, browser);
+ await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, text: 'Click here' });
+ const alice = await userPage(browser, ENT.alice);
+ await alice.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`).first().click({ button: 'right', timeout: 60_000 });
+ await alice.getByTestId('ctx-report-phishing').click();
+ await alice.getByTestId('phishing-report-confirm').click();
+ await expect(alice.getByText(`Reported to ${ENT.sec} and moved to Junk.`).first()).toBeVisible({ timeout: 30_000 });
+ const report = await waitForMessage(ENT.sec, RUN, (m) => m.subject === `Phishing: ${subject}`);
+ const raw = await rawMessage(ENT.sec, report.blobId);
+ expect(raw).toMatch(/Content-Type: multipart\/report; report-type=feedback-report/);
+ expect(raw).toContain('Feedback-Type: fraud');
+ expect(raw).toContain('Version: 1');
+ expect(raw).toMatch(/User-Agent: Bulwark\//);
+ expect(raw).toContain('Content-Type: message/rfc822');
+ expect(attachedMessage(raw)).toContain(`Subject: ${subject}`);
+ await alice.context().close();
+});
+
+test('item 2: a group scope reports for ent-team only; ent-carol has no action', async ({ browser }) => {
+ const subject = `Phish scope ${RUN}`;
+ await policy({
+ organisation: { phishingReport: { address: '', format: 'forward' } },
+ scopes: [{ id: 'team', label: 'Team', match: { groups: [ENT.team] }, policy: { organisation: { phishingReport: { address: ENT.sec } } } }],
+ }, browser);
+ await deliverAndWait({ from: ENT.bob, to: [ENT.carol, ENT.alice], subject, text: 'Scope check' });
+ const carol = await userPage(browser, ENT.carol);
+ await openMessage(carol, subject);
+ await carol.getByRole('button', { name: 'More actions' }).first().click();
+ await expect(carol.getByTestId('ctx-report-phishing')).toHaveCount(0);
+ await carol.context().close();
+ const alice = await userPage(browser, ENT.alice);
+ await openMessage(alice, subject);
+ await alice.getByRole('button', { name: 'More actions' }).first().click();
+ await expect(alice.getByTestId('ctx-report-phishing')).toBeVisible();
+ await alice.context().close();
+});
+
+test('item 2: the reader action and the confirm fit a phone', async ({ browser }) => {
+ const subject = `Phish phone ${RUN}`;
+ await policy({ organisation: { phishingReport: { address: ENT.sec, format: 'forward' } } }, browser);
+ await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, text: 'Phone check' });
+ const alice = await userPage(browser, ENT.alice, true);
+ await openMessage(alice, subject);
+ await alice.getByRole('button', { name: 'More actions' }).first().click();
+ const action = alice.getByText('Report phishing…').first();
+ await expect(action).toBeVisible();
+ await shot(alice, 'a-phishing-phone-menu');
+ await action.click();
+ const dialog = alice.getByTestId('phishing-report-dialog');
+ const box = await dialog.boundingBox();
+ expect(box && box.x).toBeGreaterThanOrEqual(0);
+ expect(box && box.x + box.width).toBeLessThanOrEqual(390);
+ await shot(alice, 'a-phishing-phone-confirm');
+ await alice.getByTestId('phishing-report-cancel').click();
+ await expect(dialog).toHaveCount(0);
+ expect(await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
+ await alice.context().close();
+});
+
+// ─── Item 3: classification labels ───────────────────────────────────────
+
+test('item 3: the admin applies a preset and requires a label on Organisation > Classification', async ({ browser }) => {
+ await policy({}, browser);
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-labels');
+ await expect(page.getByTestId('org-labels-vsnfd-warning')).toBeVisible();
+ await page.getByTestId('org-labels-preset').selectOption('generic');
+ await expect(page.getByTestId('org-label-item')).toHaveCount(4);
+ await page.getByTestId('org-labels-required').click();
+ await page.getByTestId('org-labels-default').selectOption('');
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await shot(page, 'a-labels-admin');
+ const stored = (await getPolicy(admin)).organisation as { labels: { list: unknown[]; required: boolean; defaultId: string } };
+ expect(stored.labels.list).toHaveLength(4);
+ expect(stored.labels).toMatchObject({ required: true, defaultId: '' });
+ await admin.close();
+});
+
+test('item 3: a required label blocks sending until one is picked; SIO-Label and Sensitivity arrive', async ({ browser }) => {
+ const subject = `Label send ${RUN}`;
+ await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: '', required: true, extraHeader: '' } } }, browser);
+ const alice = await userPage(browser, ENT.alice);
+ await composeAndSend(alice, ENT.bob, subject);
+ await expect(alice.getByText('Pick a classification label before sending.').first()).toBeVisible();
+ await expect(alice.getByTestId('composer-classification-options')).toBeVisible();
+ await expect(alice.locator('[data-testid="email-composer"]')).toBeVisible();
+ await shot(alice, 'a-labels-required');
+ await alice.getByTestId('composer-classification-option-confidential').click();
+ await expect(alice.getByTestId('composer-classification-trigger')).toContainText('Confidential');
+ await alice.locator('[data-testid="composer-subject"]').first().focus();
+ await alice.keyboard.press('Control+Enter');
+ await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30_000 });
+
+ const got = await waitForMessage(ENT.bob, RUN, (m) => m.subject === `[Confidential] ${subject}`);
+ const raw = await rawMessage(ENT.bob, got.blobId);
+ expect(raw).toMatch(/^SIO-Label: marking="CONFIDENTIAL"; fgcolor=#[0-9a-f]{6}; bgcolor=#d97706\r?$/m);
+ expect(raw).toMatch(/^Sensitivity: Company-Confidential\r?$/m);
+ await alice.context().close();
+
+ // The recipient sees the label.
+ const bob = await userPage(browser, ENT.bob);
+ await openMessage(bob, `[Confidential] ${subject}`);
+ await expect(bob.getByTestId('received-label-chip').first()).toHaveText('Confidential');
+ await shot(bob, 'a-labels-received-chip');
+ await bob.context().close();
+});
+
+test('item 3: Undo send brings the message back with its label', async ({ browser }) => {
+ const subject = `Undo label ${RUN}`;
+ await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: 'internal', required: true, extraHeader: '' } } }, browser);
+ const alice = await userPage(browser, ENT.alice);
+ // Undo send on, through Settings (a synced setting).
+ await alice.locator('a[href$="/settings"]').first().click();
+ await alice.locator('[data-testid="settings-tab-composing"]').first().click();
+ const delay = alice.locator('[data-search-label="Undo send / send delay"] select').first();
+ await delay.selectOption('30');
+ await alice.getByText('Back to mail').first().click();
+ await folderRow(alice, { role: 'inbox' }).first().waitFor({ state: 'visible', timeout: 30_000 });
+ await composeAndSend(alice, ENT.bob, subject, async () => {
+ await alice.getByTestId('composer-classification-trigger').click();
+ await alice.getByTestId('composer-classification-option-confidential').click();
+ });
+ await alice.getByRole('button', { name: 'Undo send' }).first().click({ timeout: 20_000 });
+ await expect(alice.locator('[data-testid="email-composer"]')).toBeVisible({ timeout: 20_000 });
+ // Not the default (Internal): the label it was sent with.
+ await expect(alice.getByTestId('composer-classification-trigger')).toContainText('Confidential');
+ await alice.locator('[data-testid="composer-subject"]').first().focus();
+ await alice.keyboard.press('Control+Enter');
+ await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30_000 });
+ const got = await waitForMessage(ENT.bob, RUN, (m) => m.subject === `[Confidential] ${subject}`, 90_000);
+ expect(await rawMessage(ENT.bob, got.blobId)).toMatch(/^SIO-Label: marking="CONFIDENTIAL"/m);
+ // Undo send off again, for the other tests.
+ await alice.locator('a[href$="/settings"]').first().click();
+ await alice.locator('[data-testid="settings-tab-composing"]').first().click();
+ await alice.locator('[data-search-label="Undo send / send delay"] select').first().selectOption('0');
+ await alice.waitForTimeout(1500);
+ await alice.context().close();
+});
+
+test('item 3: a quick reply keeps the original’s label', async ({ browser }) => {
+ const subject = `[Confidential] Label send ${RUN}`;
+ await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: '', required: true, extraHeader: '' } } }, browser);
+ const bob = await userPage(browser, ENT.bob);
+ await openMessage(bob, subject);
+ const box = bob.getByPlaceholder('Write a quick reply...');
+ await box.fill(`Quick reply ${RUN}`);
+ await box.press('Control+Enter');
+ await expect(box).toHaveValue('', { timeout: 30_000 });
+ const reply = await waitForMessage(ENT.alice, RUN, (m) => m.subject === `[Confidential] Re: Label send ${RUN}`);
+ expect(await rawMessage(ENT.alice, reply.blobId)).toMatch(/^SIO-Label: marking="CONFIDENTIAL"/m);
+ await bob.context().close();
+});
+
+test('item 3: a quick reply with no label to keep opens the composer to pick one', async ({ browser }) => {
+ const subject = `Unlabelled ${RUN}`;
+ await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: '', required: true, extraHeader: '' } } }, browser);
+ await deliverAndWait({ from: ENT.carol, to: [ENT.bob], subject, text: 'No label' });
+ const bob = await userPage(browser, ENT.bob);
+ await openMessage(bob, subject);
+ const box = bob.getByPlaceholder('Write a quick reply...');
+ await box.fill(`Needs a label ${RUN}`);
+ await box.press('Control+Enter');
+ await expect(bob.locator('[data-testid="email-composer"]')).toBeVisible({ timeout: 20_000 });
+ await expect(bob.getByTestId('composer-classification-trigger')).toContainText('Pick a label');
+ // Nothing went out.
+ await bob.waitForTimeout(3000);
+ expect((await messages(ENT.carol, RUN)).filter((m) => m.subject.includes(`Re: ${subject}`))).toEqual([]);
+ await bob.context().close();
+});
+
+test('item 3: a group scope gets its own scheme (VS-NfD, RFC 2231 marking); ent-carol keeps Everyone’s', async ({ browser }) => {
+ const subject = `Scheme ${RUN}`;
+ await policy({
+ organisation: { labels: { list: GENERIC_LABELS, defaultId: 'internal', required: true, extraHeader: '' } },
+ scopes: [{ id: 'team', label: 'Team', match: { groups: [ENT.team] }, policy: { organisation: { labels: { list: GERMAN_LABELS, defaultId: 'offen', required: true, extraHeader: '' } } } }],
+ }, browser);
+ const carol = await userPage(browser, ENT.carol);
+ await carol.keyboard.press('c');
+ await carol.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
+ await expect(carol.getByTestId('composer-classification-trigger')).toContainText('Internal');
+ await carol.keyboard.press('Escape');
+ await carol.context().close();
+
+ const alice = await userPage(browser, ENT.alice);
+ await composeAndSend(alice, ENT.bob, subject, async () => {
+ await expect(alice.getByTestId('composer-classification-trigger')).toContainText('OFFEN');
+ await alice.getByTestId('composer-classification-trigger').click();
+ // Arrow keys move the pick; Enter closes.
+ await alice.keyboard.press('ArrowDown');
+ await alice.keyboard.press('Enter');
+ await expect(alice.getByTestId('composer-classification-trigger')).toContainText('VS-NfD');
+ });
+ await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30_000 });
+ const got = await waitForMessage(ENT.bob, RUN, (m) => m.subject === `VS-NfD ${subject}`);
+ const raw = await rawMessage(ENT.bob, got.blobId);
+ expect(raw).toContain("marking*=utf-8''VS-NUR%20F%C3%9CR%20DEN%20DIENSTGEBRAUCH");
+ await alice.context().close();
+ const bob = await userPage(browser, ENT.bob);
+ await openMessage(bob, `VS-NfD ${subject}`);
+ await expect(bob.getByTestId('received-label-chip').first()).toHaveText('VS-NfD');
+ await bob.context().close();
+});
+
+test('item 3: a Pro compose tab sends with its label and priority', async ({ browser }) => {
+ const subject = `Pro tab ${RUN}`;
+ await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: 'confidential', required: true, extraHeader: '' } } }, browser);
+ const alice = await seededPage(browser, ENT.alice, { proInterface: true, advancedSettings: true });
+ await alice.goto('/pro').catch(() => {});
+ await accountSwitcher(alice).waitFor({ state: 'visible', timeout: 60_000 }).catch(() => {});
+ await composeAndSend(alice, ENT.bob, subject, async () => {
+ await alice.getByTestId('composer-options-menu').first().click();
+ await alice.getByTestId('composer-priority-high').click();
+ await alice.keyboard.press('Escape');
+ });
+ const got = await waitForMessage(ENT.bob, RUN, (m) => m.subject === `[Confidential] ${subject}`);
+ const raw = await rawMessage(ENT.bob, got.blobId);
+ expect(raw).toMatch(/^SIO-Label: marking="CONFIDENTIAL"/m);
+ expect(raw).toMatch(/^X-Priority: 1/m);
+ await alice.context().close();
+});
+
+test('item 3: the picker and the admin pages fit a phone', async ({ browser }) => {
+ await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: '', required: true, extraHeader: '' }, phishingReport: { address: ENT.sec, format: 'forward' } } }, browser);
+ const alice = await userPage(browser, ENT.alice, true);
+ await alice.getByRole('button', { name: /Compose|New message|Write/ }).first().click().catch(async () => { await alice.keyboard.press('c'); });
+ await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
+ const trigger = alice.getByTestId('composer-classification-trigger');
+ await expect(trigger).toBeVisible();
+ await trigger.click();
+ const options = alice.getByTestId('composer-classification-options');
+ const box = await options.boundingBox();
+ expect(box && box.x).toBeGreaterThanOrEqual(0);
+ expect(box && box.x + box.width).toBeLessThanOrEqual(390);
+ await shot(alice, 'a-labels-phone-picker');
+ await alice.keyboard.press('Escape');
+ expect(await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
+ await alice.context().close();
+
+ const admin = await adminContext(browser, true);
+ for (const [path, name] of [['/admin/org-labels', 'a-labels-admin-phone'], ['/admin/org-security', 'a-phishing-admin-phone']] as const) {
+ const page = await admin.newPage();
+ await page.goto(path);
+ await page.waitForLoadState('networkidle').catch(() => {});
+ expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
+ await shot(page, name);
+ await page.close();
+ }
+ await admin.close();
+});
diff --git a/lib/__tests__/classification-labels.test.ts b/lib/__tests__/classification-labels.test.ts
new file mode 100644
index 000000000..25a7ebb6c
--- /dev/null
+++ b/lib/__tests__/classification-labels.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, it } from 'vitest';
+import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
+import { LABEL_PRESETS } from '@/lib/classification/presets';
+import {
+ applyLabelPrefix,
+ draftLabelChoice,
+ draftLabelProperty,
+ labelHeaderLines,
+ labelHeaderProperties,
+ parseSioLabelMarking,
+ receivedLabel,
+ sioLabelValue,
+ textColorOn,
+} from '@/lib/classification/labels';
+
+// The headers a classification label writes (SIO-Label per RFC 7444 with
+// RFC 2231 for non-ASCII markings, Sensitivity per RFC 2156, the admin's
+// extra header), reading them back, and the subject prefix without doubling.
+
+const generic = LABEL_PRESETS.find((p) => p.id === 'generic')!;
+const german = LABEL_PRESETS.find((p) => p.id === 'german-public-sector')!;
+const byId = (list: ClassificationLabel[], id: string) => list.find((l) => l.id === id)!;
+const confidential = byId(generic.list, 'confidential');
+const vsnfd = byId(german.list, 'vs-nfd');
+
+describe('SIO-Label', () => {
+ it('writes an ASCII marking quoted, with readable colours', () => {
+ expect(sioLabelValue(confidential)).toBe(' marking="CONFIDENTIAL"; fgcolor=#000000; bgcolor=#d97706');
+ expect(textColorOn('#1e3a8a')).toBe('#ffffff');
+ expect(textColorOn('#fde68a')).toBe('#000000');
+ });
+
+ it('writes a non-ASCII marking with RFC 2231, never an encoded word, folded under 78 columns', () => {
+ const value = sioLabelValue(vsnfd);
+ expect(value).toContain("marking*=utf-8''VS-NUR%20F%C3%9CR%20DEN%20DIENSTGEBRAUCH;");
+ expect(value).not.toContain('=?');
+ for (const line of `SIO-Label:${value}`.split('\r\n')) expect(line.length).toBeLessThanOrEqual(78);
+ expect(parseSioLabelMarking(value)).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH');
+ });
+
+ it('continues a long encoded marking over several parameters and reads it back', () => {
+ const long = { ...vsnfd, marking: 'ÄÖÜ äöü ß – sehr lange Kennzeichnung für einen Test der Fortsetzung' };
+ const value = sioLabelValue(long);
+ expect(value).toMatch(/marking\*0\*=utf-8''/);
+ expect(value).toMatch(/marking\*1\*=/);
+ expect(value).not.toMatch(/%[0-9A-F]?(;|\r|$)/);
+ expect(parseSioLabelMarking(value)).toBe(long.marking);
+ });
+
+ it('reads the forms servers hand back: raw, refolded, a token, RFC 2047 inside quotes', () => {
+ expect(parseSioLabelMarking(' marking="INTERNAL"; fgcolor=#ffffff; bgcolor=#2563eb')).toBe('INTERNAL');
+ expect(parseSioLabelMarking(' marking="STRICTLY\r\n CONFIDENTIAL"; bgcolor=red')).toBe('STRICTLY CONFIDENTIAL');
+ expect(parseSioLabelMarking('marking=PUBLIC;fgcolor=black')).toBe('PUBLIC');
+ expect(parseSioLabelMarking(' marking="=?UTF-8?B?VlMtTlVSIEbDnFIgREVOIERJRU5TVEdFQlJBVUNI?="')).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH');
+ expect(parseSioLabelMarking(' type=":ess"; label="MQYGASkCAQM="')).toBeNull();
+ expect(parseSioLabelMarking(undefined)).toBeNull();
+ });
+
+ it('writes Sensitivity and the extra header only when the label sets them', () => {
+ expect(labelHeaderProperties(confidential, 'X-Classification')).toEqual({
+ // Without the leading space: the server writes "SIO-Label: " itself.
+ 'header:SIO-Label:asRaw': 'marking="CONFIDENTIAL"; fgcolor=#000000; bgcolor=#d97706',
+ 'header:Sensitivity:asText': 'Company-Confidential',
+ 'header:X-Classification:asText': 'Confidential',
+ });
+ expect(Object.keys(labelHeaderProperties(byId(generic.list, 'public'), ''))).toEqual(['header:SIO-Label:asRaw']);
+ expect(labelHeaderLines(vsnfd, 'X-Classification')).toEqual([
+ `SIO-Label:${sioLabelValue(vsnfd)}`,
+ 'Sensitivity: Company-Confidential',
+ 'X-Classification: VS-NfD',
+ ]);
+ expect(labelHeaderLines({ ...vsnfd, extraHeaderValue: 'Geheim für alle' }, 'X-Class')[2]).toMatch(/^X-Class: =\?UTF-8\?B\?/);
+ });
+});
+
+describe('received labels', () => {
+ const policy = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' };
+
+ it('are matched by marking first, case and spacing aside', () => {
+ expect(receivedLabel({ 'SIO-Label': ' marking="confidential "' }, policy)?.id).toBe('confidential');
+ expect(receivedLabel({ 'sio-label': [' marking="PUBLIC"'] }, policy)?.id).toBe('public');
+ });
+
+ it('then by the extra header value', () => {
+ expect(receivedLabel({ 'X-Classification': ' Strictly confidential' }, policy)?.id).toBe('strictly-confidential');
+ });
+
+ it('show nothing for an unknown marking or without labels', () => {
+ expect(receivedLabel({ 'SIO-Label': ' marking="TOP SECRET"' }, policy)).toBeNull();
+ expect(receivedLabel({ 'SIO-Label': ' marking="PUBLIC"' }, { ...policy, list: [] })).toBeNull();
+ });
+});
+
+describe('the subject prefix', () => {
+ const list = generic.list;
+ const strictly = byId(list, 'strictly-confidential');
+
+ it('is added once', () => {
+ expect(applyLabelPrefix('Budget', confidential, list)).toBe('[Confidential] Budget');
+ expect(applyLabelPrefix('[Confidential] Budget', confidential, list)).toBe('[Confidential] Budget');
+ });
+
+ it('is not doubled across reply and forward chains', () => {
+ expect(applyLabelPrefix('Re: [Confidential] Budget', confidential, list)).toBe('[Confidential] Re: Budget');
+ expect(applyLabelPrefix('AW: WG: [Confidential] Re: [Confidential] Budget', confidential, list)).toBe('[Confidential] AW: WG: Re: Budget');
+ });
+
+ it('changes with the label, and goes with no label', () => {
+ expect(applyLabelPrefix('Re: [Confidential] Budget', strictly, list)).toBe('[Strictly confidential] Re: Budget');
+ expect(applyLabelPrefix('[Confidential] Re: Budget', null, list)).toBe('Re: Budget');
+ expect(applyLabelPrefix('Re: Budget', byId(list, 'internal'), list)).toBe('Re: Budget');
+ });
+
+ it('knows prefixes without brackets, like VS-NfD', () => {
+ expect(applyLabelPrefix('AW: VS-NfD Lagebericht', vsnfd, german.list)).toBe('VS-NfD AW: Lagebericht');
+ expect(applyLabelPrefix('VS-NfD: Lagebericht', vsnfd, german.list)).toBe('VS-NfD Lagebericht');
+ // A word that only starts like a prefix stays.
+ expect(applyLabelPrefix('VS-NfDx test', vsnfd, german.list)).toBe('VS-NfD VS-NfDx test');
+ });
+});
+
+describe('drafts', () => {
+ it('keep the pick, "none" and nothing apart', () => {
+ expect(draftLabelProperty('confidential')).toEqual({ 'header:X-Bulwark-Label:asText': 'confidential' });
+ expect(draftLabelProperty(null)).toEqual({ 'header:X-Bulwark-Label:asText': 'none' });
+ expect(draftLabelProperty(undefined)).toEqual({});
+ expect(draftLabelChoice({ 'X-Bulwark-Label': ' confidential' })).toBe('confidential');
+ expect(draftLabelChoice({ 'x-bulwark-label': 'none' })).toBeNull();
+ expect(draftLabelChoice({})).toBeUndefined();
+ });
+});
diff --git a/lib/__tests__/jmap-label-headers.test.ts b/lib/__tests__/jmap-label-headers.test.ts
new file mode 100644
index 000000000..ef44b4d95
--- /dev/null
+++ b/lib/__tests__/jmap-label-headers.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+
+// The client writes a classification label's headers on send (over any
+// plugin header of the same name) and keeps the pick in a draft.
+
+function createClient(): JMAPClient {
+ const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
+ Object.assign(client, { apiUrl: 'https://jmap.example.com/api', accountId: 'account-1', username: 'user@example.com' });
+ return client;
+}
+
+type Call = [string, Record, string];
+
+function mockServer() {
+ const captured: Call[][] = [];
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
+ const calls = (JSON.parse((init as { body: string }).body) as { methodCalls: Call[] }).methodCalls;
+ captured.push(calls);
+ const name = calls[0][0];
+ let responses: unknown[];
+ if (name === 'Mailbox/get') responses = [['Mailbox/get', { list: [{ id: 'mb-drafts', role: 'drafts' }, { id: 'mb-sent', role: 'sent' }] }, '0']];
+ else if (name === 'Identity/get') responses = [['Identity/get', { list: [{ id: 'identity-1', email: 'user@example.com' }] }, '0']];
+ else if (name === 'Email/set' && calls[0][1].create) {
+ const id = Object.keys(calls[0][1].create as object)[0];
+ responses = [['Email/set', { created: { [id]: { id: 'new-1' } } }, '0'], ['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1']];
+ } else responses = calls.map(([method, , id]) => [method, {}, id]);
+ const payload = { methodResponses: responses };
+ return { ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(payload)), json: () => Promise.resolve(payload) } as Response;
+ });
+ return captured;
+}
+
+function created(captured: Call[][]): Record {
+ for (const calls of captured) {
+ const set = calls.find(([name, args]) => name === 'Email/set' && args.create);
+ if (set) return Object.values(set[1].create as Record>)[0];
+ }
+ throw new Error('no create');
+}
+
+describe('label headers', () => {
+ beforeEach(() => vi.restoreAllMocks());
+
+ it('are written on send, and win over a plugin header of the same name', async () => {
+ const captured = mockServer();
+ await createClient().sendEmail(
+ ['bob@example.org'], '[Confidential] x', 'body', undefined, undefined, 'identity-1', 'user@example.com',
+ undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
+ {
+ headers: { Sensitivity: 'Personal', 'X-Other': 'kept' },
+ labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"', 'header:Sensitivity:asText': 'Company-Confidential' },
+ },
+ );
+ const email = created(captured);
+ expect(email['header:SIO-Label:asRaw']).toBe(' marking="CONFIDENTIAL"');
+ expect(email['header:Sensitivity:asText']).toBe('Company-Confidential');
+ expect(email['header:X-Other:asText']).toBe('kept');
+ expect(Object.keys(email).filter((k) => k.toLowerCase().startsWith('header:sensitivity'))).toHaveLength(1);
+ });
+
+ it('are kept in a draft as the pick', async () => {
+ const captured = mockServer();
+ await createClient().createDraft(['bob@example.org'], 'x', 'body', undefined, undefined, 'identity-1', 'user@example.com',
+ undefined, undefined, undefined, undefined, undefined, undefined, { classification: 'confidential' });
+ expect(created(captured)['header:X-Bulwark-Label:asText']).toBe('confidential');
+ const again = mockServer();
+ await createClient().createDraft(['bob@example.org'], 'x', 'body', undefined, undefined, 'identity-1', 'user@example.com',
+ undefined, undefined, undefined, undefined, undefined, undefined, { classification: null });
+ expect(created(again)['header:X-Bulwark-Label:asText']).toBe('none');
+ });
+});
diff --git a/lib/__tests__/jmap-phishing-report-send.test.ts b/lib/__tests__/jmap-phishing-report-send.test.ts
new file mode 100644
index 000000000..7a82e9232
--- /dev/null
+++ b/lib/__tests__/jmap-phishing-report-send.test.ts
@@ -0,0 +1,89 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+
+// The client pieces of a phishing report: a send that leaves no copy in
+// Sent (onSuccessDestroyEmail instead of the move to Sent), and the move
+// to Junk and back with $phishing in the same patch.
+
+function createClient(): JMAPClient {
+ const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
+ Object.assign(client, { apiUrl: 'https://jmap.example.com/api', accountId: 'account-1', username: 'user@example.com' });
+ return client;
+}
+
+type Call = [string, Record, string];
+
+function mockServer(answer: (calls: Call[]) => unknown[]) {
+ const captured: Call[][] = [];
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
+ const body = JSON.parse((init as { body: string }).body) as { methodCalls: Call[] };
+ captured.push(body.methodCalls);
+ const payload = { methodResponses: answer(body.methodCalls) };
+ return { ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(payload)), json: () => Promise.resolve(payload) } as Response;
+ });
+ return captured;
+}
+
+function sendAnswers(calls: Call[]): unknown[] {
+ const name = calls[0][0];
+ if (name === 'Mailbox/get') {
+ return [['Mailbox/get', { list: [{ id: 'mb-drafts', role: 'drafts' }, { id: 'mb-sent', role: 'sent' }, { id: 'mb-junk', role: 'junk' }] }, '0']];
+ }
+ if (name === 'Identity/get') return [['Identity/get', { list: [{ id: 'identity-1', email: 'user@example.com' }] }, '0']];
+ if (name === 'Email/set' && calls[0][1].create) {
+ const id = Object.keys(calls[0][1].create as object)[0];
+ return [
+ ['Email/set', { created: { [id]: { id: 'copy-1' } } }, '0'],
+ ['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'],
+ ];
+ }
+ return calls.map(([method, , id]) => [method, { updated: {}, list: [], destroyed: [] }, id]);
+}
+
+function submission(captured: Call[][]): Record {
+ for (const calls of captured) {
+ const found = calls.find(([name]) => name === 'EmailSubmission/set');
+ if (found) return found[1];
+ }
+ throw new Error('no EmailSubmission/set');
+}
+
+describe('sendEmail without a Sent copy', () => {
+ beforeEach(() => vi.restoreAllMocks());
+
+ it('files a normal send in Sent', async () => {
+ const captured = mockServer(sendAnswers);
+ await createClient().sendEmail(['soc@example.org'], 'Phishing: x', 'body', undefined, undefined, 'identity-1', 'user@example.com');
+ const sub = submission(captured);
+ expect(sub.onSuccessUpdateEmail).toBeTruthy();
+ expect(sub.onSuccessDestroyEmail).toBeUndefined();
+ });
+
+ it('destroys the copy once submitted with noSentCopy', async () => {
+ const captured = mockServer(sendAnswers);
+ await createClient().sendEmail(
+ ['soc@example.org'], 'Phishing: x', 'body', undefined, undefined, 'identity-1', 'user@example.com',
+ undefined, undefined, undefined, [{ blobId: 'orig', name: 'x.eml', type: 'message/rfc822', size: 10, disposition: 'attachment' }],
+ undefined, undefined, undefined, undefined, { noSentCopy: true },
+ );
+ const sub = submission(captured);
+ expect(sub.onSuccessDestroyEmail).toEqual(['#1']);
+ expect(sub.onSuccessUpdateEmail).toBeUndefined();
+ });
+});
+
+describe('markAsSpam and undoSpam with more keywords', () => {
+ beforeEach(() => vi.restoreAllMocks());
+
+ it('sets $phishing with the move to Junk, and clears it with the move back', async () => {
+ const client = createClient();
+ vi.spyOn(client, 'getMailboxes').mockResolvedValue([{ id: 'mb-junk', role: 'junk', isShared: false }] as never);
+ const captured = mockServer((calls) => calls.map(([method, args, id]) => [method, { updated: Object.fromEntries(Object.keys((args.update as object) ?? {}).map((k) => [k, null])) }, id]));
+ await client.markAsSpam('e1', undefined, true, ['$phishing']);
+ const patch = (captured[0][0][1].update as Record>).e1;
+ expect(patch).toMatchObject({ mailboxIds: { 'mb-junk': true }, 'keywords/$junk': true, 'keywords/$seen': true, 'keywords/$phishing': true });
+ await client.undoSpam('e1', 'mb-inbox', undefined, ['$phishing']);
+ const back = (captured[1][0][1].update as Record>).e1;
+ expect(back).toMatchObject({ mailboxIds: { 'mb-inbox': true }, 'keywords/$junk': null, 'keywords/$notjunk': true, 'keywords/$phishing': null });
+ });
+});
diff --git a/lib/__tests__/phishing-report-arf.test.ts b/lib/__tests__/phishing-report-arf.test.ts
new file mode 100644
index 000000000..007d5ede3
--- /dev/null
+++ b/lib/__tests__/phishing-report-arf.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it, vi } from 'vitest';
+import type { Email, Identity } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import { buildArfParts, buildArfReportBlob, rawEncoding } from '../phishing-report-arf';
+import { sendPhishingReport } from '../phishing-report';
+
+// The ARF report (RFC 5965): multipart/report with report-type
+// feedback-report, a text part, message/feedback-report with
+// Feedback-Type: fraud, User-Agent and Version: 1, and the original whole
+// as message/rfc822; submitted raw without a Sent copy.
+
+const email = {
+ id: 'e1', blobId: 'blob-1', size: 40, headers: { 'Delivered-To': 'alice@example.org' }, subject: 'Konto gesperrt – jetzt prüfen', receivedAt: '2026-10-01T10:00:00Z',
+ from: [{ email: 'bad@phish.example' }], keywords: {}, mailboxIds: { inbox: true },
+} as unknown as Email;
+const reporter = { id: 'id-me', name: 'Alice', email: 'alice@example.org' } as Identity;
+const ORIGINAL = 'From: bad@phish.example\r\nSubject: x\r\n\r\nclick here\r\n';
+
+describe('buildArfParts', () => {
+ it('has the three parts and the required feedback fields', () => {
+ const { head, tail } = buildArfParts({ email, reporter, to: 'abuse@example.org', encoding: '7bit', recipient: 'alice@example.org', now: new Date('2026-11-01T08:00:00Z') });
+ expect(head).toMatch(/^Date: Sun, 01 Nov 2026 08:00:00 \+0000\r\n/);
+ expect(head).toContain('From: Alice \r\n');
+ expect(head).toContain('To: abuse@example.org\r\n');
+ // Non-ASCII subject as an encoded word, after "Phishing:".
+ expect(head).toMatch(/\r\nSubject: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=\r\n/);
+ const subject = /Subject: =\?UTF-8\?B\?([^?]+)\?=/.exec(head)![1];
+ expect(new TextDecoder().decode(Uint8Array.from(atob(subject), (c) => c.charCodeAt(0)))).toBe('Phishing: Konto gesperrt – jetzt prüfen');
+ expect(head).toContain('Content-Type: multipart/report; report-type=feedback-report;');
+ const boundary = /boundary="([^"]+)"/.exec(head)![1];
+ expect(head.split(`--${boundary}\r\n`)).toHaveLength(4);
+ expect(head).toContain('Content-Type: message/feedback-report\r\n');
+ expect(head).toContain('\r\nFeedback-Type: fraud\r\n');
+ expect(head).toMatch(/\r\nUser-Agent: Bulwark\/\S+\r\n/);
+ expect(head).toContain('\r\nVersion: 1\r\n');
+ expect(head).toContain('\r\nArrival-Date: Thu, 01 Oct 2026 10:00:00 +0000\r\n');
+ expect(head).toContain('\r\nOriginal-Rcpt-To: alice@example.org\r\n');
+ expect(head).toContain('\r\nReported-Domain: phish.example\r\n');
+ expect(head).toMatch(/Content-Type: message\/rfc822\r\n[\s\S]*Content-Transfer-Encoding: 7bit\r\n\r\n$/);
+ expect(tail).toBe(`\r\n--${boundary}--\r\n`);
+ });
+
+ it('names the address the message was delivered to, and leaves the field out when unknown', () => {
+ const { head } = buildArfParts({ email, reporter, to: 'abuse@example.org', encoding: '7bit' });
+ expect(head).not.toContain('Original-Rcpt-To');
+ });
+
+ it('says binary for bare line breaks or long lines, 8bit for 8-bit bytes, never base64', () => {
+ const enc = (text: string) => rawEncoding(new TextEncoder().encode(text));
+ expect(enc('a\r\nb\r\n')).toBe('7bit');
+ expect(enc('Grüße\r\n')).toBe('8bit');
+ expect(enc('a\nb')).toBe('binary');
+ expect(enc('a\rb')).toBe('binary');
+ expect(enc(`${'x'.repeat(999)}\r\n`)).toBe('binary');
+ expect(enc('a\u0000b')).toBe('binary');
+ });
+
+ it('marks an 8-bit original as 8bit, never base64', () => {
+ const { head } = buildArfParts({ email, reporter, to: 'abuse@example.org', encoding: '8bit' });
+ expect(head).toMatch(/Content-Transfer-Encoding: 8bit\r\n\r\n$/);
+ });
+});
+
+describe('an ARF report', () => {
+ it('carries the original byte for byte and is submitted raw, from the original account', async () => {
+ const client = {
+ getMaxSizeUpload: () => 50_000_000,
+ getIdentities: vi.fn(async () => [reporter]),
+ getUsername: () => 'alice@example.org',
+ getAccountId: () => 'acc-own',
+ fetchBlobArrayBuffer: vi.fn(async () => new TextEncoder().encode(ORIGINAL).buffer),
+ submitRawEmail: vi.fn(async (..._args: unknown[]) => ({ scheduled: false })),
+ sendEmail: vi.fn(),
+ };
+ await sendPhishingReport(email, {
+ client: client as unknown as IJMAPClient,
+ sourceAccountId: 'acc-group',
+ setup: { address: 'abuse@example.org', format: 'arf' },
+ buildArf: buildArfReportBlob,
+ });
+ expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'message.eml', 'message/rfc822', 'acc-group');
+ expect(client.sendEmail).not.toHaveBeenCalled();
+ const [blob, identityId, delayed, envelope] = client.submitRawEmail.mock.calls[0] as [Blob, string, unknown, string[]];
+ expect(identityId).toBe('id-me');
+ expect(delayed).toBeUndefined();
+ expect(envelope).toEqual(['abuse@example.org']);
+ const text = await blob.text();
+ const boundary = /boundary="([^"]+)"/.exec(text)![1];
+ expect(text).toContain(`message/rfc822\r\nContent-Disposition: attachment; filename="reported-message.eml"\r\nContent-Transfer-Encoding: 7bit\r\n\r\n${ORIGINAL}--${boundary}--\r\n`);
+ });
+});
+
+describe('a message too big for an ARF report', () => {
+ it('is refused before it is downloaded', async () => {
+ const client = { getMaxSizeUpload: () => 1000, fetchBlobArrayBuffer: vi.fn() };
+ await expect(buildArfReportBlob({
+ client: client as unknown as IJMAPClient, email: { ...email, size: 5000 } as Email, sourceAccountId: 'a', reporter, to: 'abuse@example.org',
+ })).rejects.toThrow(/too big/);
+ expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/__tests__/phishing-report.test.ts b/lib/__tests__/phishing-report.test.ts
new file mode 100644
index 000000000..fae89b7ab
--- /dev/null
+++ b/lib/__tests__/phishing-report.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it, vi } from 'vitest';
+import type { Email, Identity } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import {
+ PHISHING_KEYWORD,
+ isReportable,
+ phishingReportSetup,
+ reportSubject,
+ sendPhishingReport,
+} from '../phishing-report';
+import { isPhishingMarked, turnOffLinks } from '../phishing-links';
+
+// Report phishing: one report per message, the original attached by its
+// blobId (no upload), no copy in Sent, the blob copied over for a message
+// of another account, and which messages can be reported at all.
+
+const me: Identity = { id: 'id-me', name: 'Alice', email: 'alice@example.org' } as Identity;
+const email = {
+ id: 'e1', blobId: 'blob-1', threadId: 't1', size: 2048, subject: 'Your parcel',
+ receivedAt: '2026-10-01T10:00:00Z', keywords: {}, mailboxIds: { inbox: true },
+ from: [{ email: 'bad@phish.example' }],
+} as unknown as Email;
+
+function fakeClient(over: Partial> = {}) {
+ const client = {
+ getIdentities: vi.fn(async () => [me]),
+ getUsername: () => 'alice@example.org',
+ getAccountId: () => 'acc-own',
+ resolveSendAccountId: vi.fn(async () => 'acc-own'),
+ copyBlobs: vi.fn(async () => ({ 'blob-1': 'blob-copied' })),
+ sendEmail: vi.fn(async () => ({ scheduled: false })),
+ submitRawEmail: vi.fn(async () => ({ scheduled: false })),
+ ...over,
+ };
+ return client as typeof client & IJMAPClient;
+}
+
+const setup = { address: 'soc@example.org', format: 'forward' as const };
+
+describe('the forwarded report', () => {
+ it('attaches the original by blobId, to the admin address, with "Phishing:" and no Sent copy', async () => {
+ const client = fakeClient();
+ await sendPhishingReport(email, { client, sourceAccountId: 'acc-own', setup });
+ expect(client.sendEmail).toHaveBeenCalledTimes(1);
+ const args = client.sendEmail.mock.calls[0] as unknown[];
+ expect(args[0]).toEqual(['soc@example.org']);
+ expect(args[1]).toBe('Phishing: Your parcel');
+ expect(String(args[2])).toContain('Reported as phishing by Alice with Bulwark');
+ expect(args[5]).toBe('id-me');
+ const attachments = args[10] as Array>;
+ expect(attachments).toHaveLength(1);
+ expect(attachments[0]).toMatchObject({ blobId: 'blob-1', type: 'message/rfc822', size: 2048, disposition: 'attachment' });
+ expect(String(attachments[0].name)).toMatch(/\.eml$/);
+ expect(args[15]).toEqual({ noSentCopy: true });
+ expect(client.copyBlobs).not.toHaveBeenCalled();
+ expect(client.submitRawEmail).not.toHaveBeenCalled();
+ });
+
+ it('copies the blob first when the message is in another account (a group mailbox)', async () => {
+ const client = fakeClient();
+ await sendPhishingReport(email, { client, sourceAccountId: 'acc-group', setup });
+ expect(client.copyBlobs).toHaveBeenCalledWith('acc-group', ['blob-1'], 'acc-own');
+ const attachments = (client.sendEmail.mock.calls[0] as unknown[])[10] as Array<{ blobId: string }>;
+ expect(attachments[0].blobId).toBe('blob-copied');
+ });
+
+ it('sends nothing when the copy fails', async () => {
+ const client = fakeClient({ copyBlobs: vi.fn(async () => ({})) });
+ await expect(sendPhishingReport(email, { client, sourceAccountId: 'acc-group', setup })).rejects.toThrow();
+ expect(client.sendEmail).not.toHaveBeenCalled();
+ });
+
+ it('names an empty subject plainly', () => {
+ expect(reportSubject('')).toBe('Phishing');
+ expect(reportSubject('a\r\nBcc: x@evil.example')).toBe('Phishing: a Bcc: x@evil.example');
+ });
+});
+
+describe('which messages can be reported', () => {
+ it('not your own mail, drafts, scheduled mail, or a message reported already', () => {
+ expect(isReportable(email, 'inbox')).toBe(true);
+ expect(isReportable(email, 'junk')).toBe(true);
+ for (const role of ['sent', 'drafts', 'scheduled']) expect(isReportable(email, role)).toBe(false);
+ expect(isReportable({ ...email, keywords: { $draft: true } }, 'inbox')).toBe(false);
+ expect(isReportable({ ...email, isScheduled: true }, 'inbox')).toBe(false);
+ expect(isReportable({ ...email, blobId: '' }, 'inbox')).toBe(false);
+ expect(isReportable({ ...email, keywords: { [PHISHING_KEYWORD]: true } }, 'junk')).toBe(false);
+ });
+
+ it('only while the admin set an address', () => {
+ expect(phishingReportSetup(DEFAULT_POLICY)).toBeNull();
+ const on = { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address: 'soc@example.org', format: 'arf' as const } } };
+ expect(phishingReportSetup(on)).toEqual({ address: 'soc@example.org', format: 'arf' });
+ });
+});
+
+describe('links of a reported message', () => {
+ it('are turned off: no address, not reachable by Tab, and say so', () => {
+ const html = 'Hi log in
';
+ const out = turnOffLinks(html, { title: 'link turned off', note: '(link turned off)' });
+ expect(out).not.toContain('href=');
+ expect(out).not.toContain('phish.example');
+ expect(out).toContain('data-link-off');
+ expect(out).toContain('(link turned off)');
+ const doc = new DOMParser().parseFromString(out, 'text/html');
+ expect(doc.querySelectorAll('a[href], area[href]')).toHaveLength(0);
+ });
+
+ it('follow the $phishing keyword', () => {
+ expect(isPhishingMarked({ keywords: { [PHISHING_KEYWORD]: true } })).toBe(true);
+ expect(isPhishingMarked({ keywords: { $junk: true } })).toBe(false);
+ });
+});
diff --git a/lib/__tests__/plugin-host-labels.test.ts b/lib/__tests__/plugin-host-labels.test.ts
new file mode 100644
index 000000000..18bc81c2d
--- /dev/null
+++ b/lib/__tests__/plugin-host-labels.test.ts
@@ -0,0 +1,150 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+import type { IJMAPClient } from '../jmap/client-interface';
+import type { Email } from '../jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useAccountStore } from '@/stores/account-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useIdentityStore } from '@/stores/identity-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { LABEL_PRESETS } from '@/lib/classification/presets';
+import { head, resolveHead, resetDialogLimits } from '../plugin-sandbox/host-dialog';
+import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
+import { beginLabelTakeover, endLabelTakeover, resetLabelTakeovers } from '../outgoing/classification-raw';
+
+// Classification labels on the plugin paths: api.email.forward carries the
+// original's label (else the default) and refuses when a required one is
+// missing; a crypto plugin's raw send during a labelled takeover gets the
+// label headers in its top-level header block.
+
+const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
+vi.mock('@/stores/toast-store', () => ({ toast }));
+
+import { dispatchApiCall } from '../plugin-sandbox/host-api';
+
+function plugin(perms: string[], id: string): InstalledPlugin {
+ return {
+ id, name: id, version: '1.0.0', author: 'test', description: '',
+ type: 'ui-extension', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+}
+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, sender = 'colleague@example.com') => ({
+ id: 'E1', threadId: 'T1', mailboxIds: { inbox: true }, keywords: {}, size: 900, receivedAt: '2026-10-20T08:00:00Z',
+ 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;
+
+function useLabels(labels: Partial) {
+ const org = { ...DEFAULT_ORGANISATION_POLICY, labels: { ...DEFAULT_ORGANISATION_POLICY.labels, list: LABEL_PRESETS[0].list, ...labels } };
+ usePolicyStore.setState({ loaded: true, policy: { ...DEFAULT_POLICY, organisation: org }, rawPolicy: { ...DEFAULT_POLICY, organisation: org } });
+}
+
+describe('labels on the plugin paths', () => {
+ let client: Record>;
+ let sendEmail: ReturnType;
+ let sendRawEmail: ReturnType;
+ let headers: Record = {};
+ let sender = 'colleague@example.com';
+
+ beforeEach(() => {
+ resetPluginRateLimits();
+ resetDialogLimits();
+ localStorage.clear();
+ // The forward to this address is approved already: no dialog.
+ localStorage.setItem('plugin-forward-approvals', '{}');
+ client = {
+ getAccountId: vi.fn(() => 'own'),
+ getEmail: vi.fn(async () => original(headers, sender)),
+ getMailboxes: vi.fn(async () => []),
+ resolveSendAccountId: vi.fn(async () => 'own'),
+ copyBlobs: vi.fn(async () => ({})),
+ hasDelayedSend: vi.fn(() => false),
+ batchUpdateKeywords: vi.fn(async () => undefined),
+ submitRawEmail: vi.fn(async () => ({ scheduled: false })),
+ };
+ sendEmail = vi.fn(async () => ({ emailId: 'S1', scheduled: false }));
+ sendRawEmail = vi.fn(async () => ({ scheduled: false }));
+ useAuthStore.setState({ client: client as unknown as IJMAPClient });
+ 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(() => {
+ while (head()) resolveHead(false);
+ useAuthStore.setState({ client: null });
+ usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY });
+ resetLabelTakeovers();
+ headers = {};
+ sender = 'colleague@example.com';
+ vi.clearAllMocks();
+ });
+
+ async function forward() {
+ const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['acc@example.com'] });
+ await vi.waitFor(() => expect(head()).not.toBeNull());
+ resolveHead(true);
+ return pending;
+ }
+
+ it('forwards with the original’s label', async () => {
+ useLabels({ defaultId: 'internal' });
+ headers = { 'SIO-Label': ' marking="CONFIDENTIAL"' };
+ expect(await forward()).toMatchObject({ sent: true });
+ const args = sendEmail.mock.calls[0] as unknown[];
+ expect(args[2]).toBe('[Confidential] Fwd: Invoice');
+ 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' });
+ expect(sendEmail).not.toHaveBeenCalled();
+ expect(toast.error).toHaveBeenCalled();
+ });
+
+ it('puts the takeover’s label into its raw sends and takes forged ones out of every raw send', async () => {
+ useLabels({});
+ const forged = new TextEncoder().encode('From: ann@example.com\r\nSubject: [Confidential] Hi\r\nSIO-Label: marking="PUBLIC"\r\n\r\nENCRYPTED\r\n');
+ // Outside a takeover, no default label: nothing stamped, the forged header gone.
+ await call(SMIME, 'jmap.sendRaw', forged, 'id-a', {});
+ expect(await (sendRawEmail.mock.calls[0][1] as Blob).text()).not.toContain('SIO-Label');
+
+ const token = beginLabelTakeover(usePolicyStore.getState().policy.organisation!.labels, LABEL_PRESETS[0].list[2], 'id-a')!;
+ await call(SMIME, 'jmap.sendRaw', forged, 'id-a', { labelToken: token });
+ await call(SMIME, 'jmap.submitRaw', forged, 'id-a', { labelToken: token });
+ endLabelTakeover(token);
+ const sent = await (sendRawEmail.mock.calls[1][1] as Blob).text();
+ expect(sent).toMatch(/^From: ann@example.com\r\nSubject: \[Confidential\] Hi\r\nSIO-Label: marking="CONFIDENTIAL"[^\r]*\r\nSensitivity: Company-Confidential\r\n\r\nENCRYPTED\r\n$/);
+ expect(await (client.submitRawEmail.mock.calls[0][0] as Blob).text()).toContain('SIO-Label: marking="CONFIDENTIAL"');
+ });
+
+ it('refuses a raw send outside the composer when a label is required', async () => {
+ useLabels({ required: true });
+ const bytes = new TextEncoder().encode('Subject: Hi\r\n\r\nX\r\n');
+ await expect(call(SMIME, 'jmap.sendRaw', bytes, 'id-a', {})).rejects.toThrow(/requires a classification label/);
+ await expect(call(SMIME, 'jmap.submitRaw', bytes, 'id-a', {})).rejects.toThrow(/requires a classification label/);
+ expect(sendRawEmail).not.toHaveBeenCalled();
+ expect(client.submitRawEmail).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/admin/__tests__/organisation-policy-enterprise.test.ts b/lib/admin/__tests__/organisation-policy-enterprise.test.ts
index 91a5395ef..35f7339e2 100644
--- a/lib/admin/__tests__/organisation-policy-enterprise.test.ts
+++ b/lib/admin/__tests__/organisation-policy-enterprise.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
+ isLabelExtraHeaderName,
DEFAULT_ORGANISATION_POLICY,
MIN_FEED_REFRESH_MINUTES,
mergeOrganisation,
@@ -58,6 +59,21 @@ describe('enterprise sections: defaults and sanitising', () => {
}
});
+ it('takes any RFC 5322 header name, msip_labels too, but not one the server refuses', () => {
+ expect(sanitizeOrganisationPolicy({ labels: { extraHeader: 'msip_labels' } }).labels.extraHeader).toBe('msip_labels');
+ expect(isLabelExtraHeaderName('msip_labels')).toBe(true);
+ for (const name of ['Content-Classification', 'List-Id', 'Resent-From', 'ARC-Seal', 'Auto-Submitted', 'X:Bad', 'X Bad']) {
+ expect(isLabelExtraHeaderName(name)).toBe(false);
+ expect(sanitizeOrganisationPolicy({ labels: { extraHeader: name } }).labels.extraHeader).toBe('');
+ }
+ });
+
+ it('refuses the header Bulwark keeps the label of a draft in', () => {
+ expect(sanitizeOrganisationPolicy({ labels: { extraHeader: 'X-Bulwark-Label' } }).labels.extraHeader).toBe('');
+ expect(isLabelExtraHeaderName('X-Bulwark-Label')).toBe(false);
+ expect(isLabelExtraHeaderName('X-Classification')).toBe(true);
+ });
+
it('keeps announcement text per locale and normalises times', () => {
const { announcement } = sanitizeOrganisationPolicy({ announcement: {
id: 'maint-1', severity: 'warning', text: { en: 'Maintenance tonight', 'pt-BR': 'Manutenção', 'x y': 'no', de: ' ' },
diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts
index ad9a9f5a9..8467c42c8 100644
--- a/lib/admin/organisation-policy.ts
+++ b/lib/admin/organisation-policy.ts
@@ -287,14 +287,17 @@ const EMAIL_RE = /^[^\s@<>"(),;:]+@[^\s@<>"(),;:]+\.[^\s@<>"(),;:]+$/;
const COLOR_RE = /^#[0-9a-f]{6}$/i;
const ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
const LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/;
-const HEADER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,75}$/;
+// RFC 5322 field-name: printable ASCII but the colon (msip_labels has an underscore).
+const HEADER_NAME_RE = /^[\x21-\x39\x3b-\x7e]{1,76}$/;
+// Prefixes JMAP refuses at the top level or that say how mail travelled.
+const RESERVED_HEADER_PREFIXES = ['content-', 'list-', 'resent-', 'arc-'];
// Headers the extra classification header may not be: they carry the
// message itself, or the labels Bulwark writes anyway.
const RESERVED_HEADERS = new Set([
'from', 'to', 'cc', 'bcc', 'subject', 'date', 'message-id', 'sender', 'reply-to', 'in-reply-to',
'references', 'mime-version', 'content-type', 'content-transfer-encoding', 'content-disposition',
'received', 'return-path', 'dkim-signature', 'arc-seal', 'arc-message-signature', 'authentication-results',
- 'sio-label', 'sio-label-history', 'sensitivity', 'importance', 'x-priority', 'disposition-notification-to',
+ 'sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label', 'auto-submitted', 'precedence', 'importance', 'x-priority', 'disposition-notification-to',
]);
const MAX_LABELS = 50;
const MAX_FEEDS = 50;
@@ -312,6 +315,12 @@ function email(value: unknown): string | undefined {
return v === '' || EMAIL_RE.test(v) ? v.slice(0, 320) : undefined;
}
+/** Whether the admin page may store this as `phishingReport.address` (the sanitiser's rule). */
+export function isPhishingReportAddress(value: string): boolean {
+ const v = value.trim().toLowerCase();
+ return v.length <= 320 && EMAIL_RE.test(v);
+}
+
function color(value: unknown, fallback: string): string {
return typeof value === 'string' && COLOR_RE.test(value.trim()) ? value.trim().toLowerCase() : fallback;
}
@@ -368,11 +377,22 @@ function sanitizeLabels(value: unknown): ClassificationLabel[] | undefined {
});
}
+function reservedHeader(name: string): boolean {
+ const lower = name.toLowerCase();
+ return RESERVED_HEADERS.has(lower) || RESERVED_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix));
+}
+
+/** Whether the admin page may store this as `labels.extraHeader` (the sanitiser's rule). */
+export function isLabelExtraHeaderName(value: string): boolean {
+ const v = value.trim();
+ return HEADER_NAME_RE.test(v) && !reservedHeader(v);
+}
+
function headerName(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const v = value.trim();
if (v === '') return '';
- return HEADER_NAME_RE.test(v) && !RESERVED_HEADERS.has(v.toLowerCase()) ? v : undefined;
+ return HEADER_NAME_RE.test(v) && !reservedHeader(v) ? v : undefined;
}
function sanitizeLocaleText(value: unknown): Record | undefined {
diff --git a/lib/classification/labels.ts b/lib/classification/labels.ts
new file mode 100644
index 000000000..851c4c595
--- /dev/null
+++ b/lib/classification/labels.ts
@@ -0,0 +1,337 @@
+/**
+ * Classification labels on the wire (enterprise pack item 3). Pure
+ * functions: the headers a label writes, reading a received label back,
+ * the subject prefix, and the draft marker.
+ *
+ * Headers written:
+ * - `SIO-Label` (RFC 7444) in the marking-only form with colours, as a raw
+ * header: `marking="CONFIDENTIAL"; fgcolor=#000000; bgcolor=#d97706`.
+ * A marking with non-ASCII characters uses RFC 2231
+ * (`marking*=utf-8''VS-NUR%20F%C3%9CR...`): an RFC 2047 encoded word
+ * inside a quoted parameter is invalid (RFC 2047 §5), which is what the
+ * server's text form would produce.
+ * - `Sensitivity` (RFC 2156 §5.3.4), what Outlook shows, when the label
+ * sets one.
+ * - The admin's extra header (X-Classification, msip_labels) when the label
+ * has a value for it.
+ * No SIO-Label-History: it needs who changed it and when, and RFC 7444
+ * says it may leak.
+ *
+ * Drafts keep the user's choice in `X-Bulwark-Label` (the label id, or
+ * `none` for a choice of no label), the way they keep the priority.
+ */
+
+import type { ClassificationLabel, OrganisationPolicy } from '@/lib/admin/organisation-policy';
+import { stripSubjectPrefixes } from '@/lib/subject-prefix';
+
+export type LabelsPolicy = OrganisationPolicy['labels'];
+
+export const SIO_LABEL_HEADER = 'SIO-Label';
+export const SENSITIVITY_HEADER = 'Sensitivity';
+export const DRAFT_LABEL_HEADER = 'X-Bulwark-Label';
+/** A draft whose sender picked "no label". */
+export const NO_LABEL = 'none';
+
+/** The marking a label writes: its own, else its name. */
+export function labelMarking(label: Pick): string {
+ return (label.marking || label.name).trim();
+}
+
+/** Black or white, whichever reads better on `bg` (#rrggbb). */
+export function textColorOn(bg: string): '#000000' | '#ffffff' {
+ const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(bg);
+ if (!m) return '#ffffff';
+ const [r, g, b] = [m[1], m[2], m[3]].map((h) => {
+ const c = parseInt(h, 16) / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ return luminance > 0.179 ? '#000000' : '#ffffff';
+}
+
+// RFC 2231 attribute-char: anything but specials, space, controls, *, ' and %.
+const ATTRIBUTE_CHAR = /[A-Za-z0-9!#$&+\-.^_`|~]/;
+
+function percentEncode(value: string): string {
+ let out = '';
+ for (const byte of new TextEncoder().encode(value)) {
+ const ch = String.fromCharCode(byte);
+ out += byte < 0x80 && ATTRIBUTE_CHAR.test(ch) ? ch : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
+ }
+ return out;
+}
+
+/** The marking parameter(s): quoted when ASCII, RFC 2231 (with continuations when long) otherwise. */
+function markingParameters(marking: string): string[] {
+ const clean = marking.replace(/["\\\r\n]/g, '');
+
+ if (/^[\x20-\x7e]*$/.test(clean)) return [`marking="${clean}"`];
+ const encoded = percentEncode(clean);
+ const MAX = 60;
+ if (encoded.length + 15 <= MAX) return [`marking*=utf-8''${encoded}`];
+ // Split between encoded characters, never inside a %XX.
+ const units = encoded.match(/%[0-9A-F]{2}|[^%]/g) ?? [];
+ const pieces: string[] = [];
+ let piece = '';
+ for (const unit of units) {
+ if (piece.length + unit.length > MAX - 15) {
+ pieces.push(piece);
+ piece = '';
+ }
+ piece += unit;
+ }
+ if (piece) pieces.push(piece);
+ return pieces.map((piece, i) => (i === 0 ? `marking*0*=utf-8''${piece}` : `marking*${i}*=${piece}`));
+}
+
+/** The SIO-Label value after "SIO-Label:", folded (leading space included). */
+export function sioLabelValue(label: Pick): string {
+ const params = [...markingParameters(labelMarking(label)), `fgcolor=${textColorOn(label.color)}`, `bgcolor=${label.color}`];
+ // "SIO-Label: " is 11 characters; fold between parameters past 78.
+ let line = 11;
+ let out = '';
+ params.forEach((param, i) => {
+ const piece = i === params.length - 1 ? param : `${param};`;
+ if (i > 0 && line + 1 + piece.length > 78) {
+ out += `\r\n ${piece}`;
+ line = 1 + piece.length;
+ } else {
+ out += ` ${piece}`;
+ line += 1 + piece.length;
+ }
+ });
+ return out;
+}
+
+/** The label's headers as [name, value] pairs, the extra header's value as plain text. */
+function labelHeaderPairs(label: ClassificationLabel, extraHeader: string): Array<[string, string, 'raw' | 'text']> {
+ const pairs: Array<[string, string, 'raw' | 'text']> = [[SIO_LABEL_HEADER, sioLabelValue(label), 'raw']];
+ if (label.sensitivity) pairs.push([SENSITIVITY_HEADER, label.sensitivity, 'text']);
+ const extraValue = label.extraHeaderValue.trim();
+ if (extraHeader && extraValue) pairs.push([extraHeader, extraValue, 'text']);
+ return pairs;
+}
+
+/** The label's headers as JMAP Email/set create properties (RFC 8621 §4.1.3). */
+export function labelHeaderProperties(label: ClassificationLabel, extraHeader: string): Record {
+ const out: Record = {};
+ for (const [name, value, form] of labelHeaderPairs(label, extraHeader)) {
+ // The server writes "Name: " itself; a raw value keeps only its own folds.
+ out[form === 'raw' ? `header:${name}:asRaw` : `header:${name}:asText`] = form === 'raw' ? value.trimStart() : value;
+ }
+ return out;
+}
+
+/**
+ * A header value for raw MIME, folded under 78 columns: ASCII as it is,
+ * folded between words; anything else as RFC 2047 encoded words of at
+ * most 75 characters each, split between characters.
+ */
+function foldedValue(name: string, value: string): string {
+ if (/^[\x20-\x7e]*$/.test(value)) {
+ let line = name.length + 1;
+ let out = '';
+ for (const word of value.split(' ')) {
+ if (out && line + 1 + word.length > 78) {
+ out += `\r\n ${word}`;
+ line = 1 + word.length;
+ } else {
+ out += ` ${word}`;
+ line += 1 + word.length;
+ }
+ }
+ return out;
+ }
+ const encoder = new TextEncoder();
+ const words: string[] = [];
+ let chunk: number[] = [];
+ const flush = () => {
+ if (chunk.length === 0) return;
+ words.push(`=?UTF-8?B?${btoa(String.fromCharCode(...chunk))}?=`);
+ chunk = [];
+ };
+ // 45 bytes are 60 base64 characters: 72 with "=?UTF-8?B?" and "?=",
+ // which fits a folded line. The first word shares its line with the name.
+ const firstLimit = Math.max(3, Math.min(45, Math.floor((78 - name.length - 2 - 12) / 4) * 3));
+ for (const char of value) {
+ const bytes = encoder.encode(char);
+ if (chunk.length + bytes.length > (words.length === 0 ? firstLimit : 45)) flush();
+ chunk.push(...bytes);
+ }
+ flush();
+ return ` ${words.join('\r\n ')}`;
+}
+
+/** The label's headers as raw header lines (folded with CRLF and a space), for raw MIME. */
+export function labelHeaderLines(label: ClassificationLabel, extraHeader: string): string[] {
+ return labelHeaderPairs(label, extraHeader).map(([name, value, form]) =>
+ form === 'raw' ? `${name}:${value}` : `${name}:${foldedValue(name, value)}`);
+}
+
+/** The draft marker property: the label id, or `none` for no label. */
+export function draftLabelProperty(choice: string | null | undefined): Record {
+ if (choice === undefined) return {};
+ return { [`header:${DRAFT_LABEL_HEADER}:asText`]: choice ?? NO_LABEL };
+}
+
+type HeaderRecord = Record | null | undefined;
+
+function header(headers: HeaderRecord, name: string): string | undefined {
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return undefined;
+ const lower = name.toLowerCase();
+ for (const [key, value] of Object.entries(headers)) {
+ if (key.toLowerCase() !== lower) continue;
+ const first = Array.isArray(value) ? value[0] : value;
+ if (typeof first === 'string') return first;
+ }
+ return undefined;
+}
+
+/**
+ * The choice a re-opened draft carries: a label id, null for "no label",
+ * undefined when the draft says nothing (a draft of another client, or one
+ * saved before labels).
+ */
+export function draftLabelChoice(headers: HeaderRecord): string | null | undefined {
+ const value = header(headers, DRAFT_LABEL_HEADER)?.trim();
+ if (!value) return undefined;
+ return value === NO_LABEL ? null : value;
+}
+
+function decodePercent(value: string, charset: string): string {
+ const bytes: number[] = [];
+ for (let i = 0; i < value.length; i++) {
+ if (value[i] === '%' && /^[0-9a-f]{2}$/i.test(value.slice(i + 1, i + 3))) {
+ bytes.push(parseInt(value.slice(i + 1, i + 3), 16));
+ i += 2;
+ } else {
+ bytes.push(value.charCodeAt(i) & 0xff);
+ }
+ }
+ try {
+ return new TextDecoder(charset || 'utf-8').decode(Uint8Array.from(bytes));
+ } catch {
+ return new TextDecoder('utf-8').decode(Uint8Array.from(bytes));
+ }
+}
+
+/** Decodes RFC 2047 encoded words a server may have put into a value. */
+function decodeEncodedWords(value: string): string {
+ return value.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (whole, charset: string, enc: string, text: string) => {
+ try {
+ let bytes: Uint8Array;
+ if (enc.toLowerCase() === 'b') bytes = Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
+ else bytes = Uint8Array.from(text.replace(/_/g, ' ').replace(/=([0-9a-f]{2})/gi, (_, h: string) => String.fromCharCode(parseInt(h, 16))), (c) => c.charCodeAt(0));
+ return new TextDecoder(charset).decode(bytes);
+ } catch {
+ return whole;
+ }
+ });
+}
+
+/**
+ * The `marking` of a raw SIO-Label value (RFC 7444 §2): a quoted or token
+ * value, or the RFC 2231 forms (encoded and continued). Null without one.
+ */
+export function parseSioLabelMarking(raw: string | undefined | null): string | null {
+ if (!raw) return null;
+ const value = raw.replace(/\r?\n[ \t]+/g, ' ');
+ const params = new Map();
+ const re = /([A-Za-z0-9*-]+)\s*=\s*("(?:[^"\\]|\\.)*"|[^;\s]*)/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(value))) {
+ let v = m[2];
+ if (v.startsWith('"')) v = v.slice(1, -1).replace(/\\(.)/g, '$1');
+ params.set(m[1].toLowerCase(), v);
+ }
+ if (params.has('marking')) return decodeEncodedWords(params.get('marking')!).trim() || null;
+ if (params.has('marking*')) {
+ const [charset, , text] = splitExtended(params.get('marking*')!);
+ return decodePercent(text, charset).trim() || null;
+ }
+ // Continuations: marking*0*, marking*1, ... joined before decoding (a
+ // character's bytes may be split across two of them, RFC 2231 §4.1).
+ let joinedEncoded = '';
+ let charset = 'utf-8';
+ for (let i = 0; i < 100; i++) {
+ const encoded = params.get(`marking*${i}*`);
+ const plain = params.get(`marking*${i}`);
+ if (encoded === undefined && plain === undefined) break;
+ if (encoded !== undefined) {
+ let text = encoded;
+ if (i === 0) [charset, , text] = splitExtended(encoded);
+ joinedEncoded += text;
+ } else {
+ joinedEncoded += percentEncode(plain!);
+ }
+ }
+ const joined = decodePercent(joinedEncoded, charset).trim();
+ return joined || null;
+}
+
+function splitExtended(value: string): [string, string, string] {
+ const first = value.indexOf("'");
+ const second = first >= 0 ? value.indexOf("'", first + 1) : -1;
+ if (first < 0 || second < 0) return ['utf-8', '', value];
+ return [value.slice(0, first), value.slice(first + 1, second), value.slice(second + 1)];
+}
+
+const normalise = (s: string) => s.normalize('NFC').replace(/\s+/g, ' ').trim().toLowerCase();
+
+/**
+ * The known label a received message carries: by its SIO-Label marking
+ * first, then by the admin's extra header value. Unknown values show
+ * nothing. Shown as information only: anyone can write these headers.
+ */
+export function receivedLabel(headers: HeaderRecord, policy: LabelsPolicy | null | undefined): ClassificationLabel | null {
+ if (!policy || policy.list.length === 0) return null;
+ const marking = parseSioLabelMarking(header(headers, SIO_LABEL_HEADER));
+ if (marking) {
+ const wanted = normalise(marking);
+ const byMarking = policy.list.find((label) => normalise(labelMarking(label)) === wanted);
+ if (byMarking) return byMarking;
+ }
+ if (policy.extraHeader) {
+ const raw = header(headers, policy.extraHeader);
+ if (raw) {
+ const wanted = normalise(decodeEncodedWords(raw.replace(/\r?\n[ \t]+/g, ' ')));
+ const byExtra = policy.list.find((label) => label.extraHeaderValue && normalise(label.extraHeaderValue) === wanted);
+ if (byExtra) return byExtra;
+ }
+ }
+ return null;
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * The subject with `label`'s prefix in front, once: every known label
+ * prefix in the leading chain of reply and forward prefixes is taken out
+ * first ("Re: [Confidential] x" → "[Strictly confidential] Re: x"), so a
+ * reply doesn't double it and a changed label replaces the old one.
+ */
+export function applyLabelPrefix(subject: string, label: ClassificationLabel | null, labels: ClassificationLabel[]): string {
+ const prefixes = [...new Set(labels.map((l) => l.subjectPrefix.trim()).filter(Boolean))].sort((a, b) => b.length - a.length);
+ let rest = subject.trimStart();
+ const kept: string[] = [];
+ const labelRes = prefixes.map((p) => new RegExp(`^${escapeRegExp(p)}(?:\\s*:)?(?:\\s+|$)`, 'i'));
+ for (let i = 0; i < 40; i++) {
+ const labelMatch = labelRes.map((re) => re.exec(rest)).find(Boolean);
+ if (labelMatch) {
+ rest = rest.slice(labelMatch[0].length).trimStart();
+ continue;
+ }
+ const stripped = stripSubjectPrefixes(rest);
+ if (stripped === rest) break;
+ // Keep the reply/forward prefix as written, one token at a time.
+ const token = rest.slice(0, rest.length - stripped.length);
+ const one = /^\s*[^::]+[::]\s*/.exec(token)?.[0] ?? token;
+ kept.push(one.trim());
+ rest = rest.slice(one.length).trimStart();
+ }
+ const chain = [...kept, rest].filter(Boolean).join(' ');
+ const prefix = label?.subjectPrefix.trim();
+ return prefix ? (chain ? `${prefix} ${chain}` : prefix) : chain;
+}
diff --git a/lib/classification/presets.ts b/lib/classification/presets.ts
new file mode 100644
index 000000000..f4703e46a
--- /dev/null
+++ b/lib/classification/presets.ts
@@ -0,0 +1,63 @@
+/**
+ * The two label schemes the admin page offers to start from (enterprise
+ * pack item 3). Markings follow RFC 7444 (SIO-Label), Sensitivity values
+ * RFC 2156 (what Outlook shows).
+ *
+ * The German public-sector preset stops at VS-NfD on purpose: higher grades
+ * (VS-VERTRAULICH and up) need IT approved for them (VSA § 50 Abs. 4) and
+ * must not be offered in a mail client that isn't. Even VS-NfD needs
+ * approved IT and BSI-approved encryption outside an approved network
+ * (§ 50, § 55); the label only marks the message.
+ */
+
+import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
+
+export interface LabelPreset {
+ id: 'generic' | 'german-public-sector';
+ name: string;
+ defaultId: string;
+ list: ClassificationLabel[];
+}
+
+const label = (l: Partial & Pick): ClassificationLabel => ({
+ description: '',
+ sensitivity: '',
+ subjectPrefix: '',
+ extraHeaderValue: '',
+ ...l,
+});
+
+export const LABEL_PRESETS: LabelPreset[] = [
+ {
+ id: 'generic',
+ name: 'Generic: Public / Internal / Confidential / Strictly confidential',
+ defaultId: 'internal',
+ list: [
+ label({ id: 'public', name: 'Public', color: '#16a34a', marking: 'PUBLIC', description: 'Can be shared with anyone.', extraHeaderValue: 'Public' }),
+ label({ id: 'internal', name: 'Internal', color: '#2563eb', marking: 'INTERNAL', description: 'For people in the organisation and those working with it.', extraHeaderValue: 'Internal' }),
+ label({
+ id: 'confidential', name: 'Confidential', color: '#d97706', marking: 'CONFIDENTIAL', sensitivity: 'Company-Confidential',
+ subjectPrefix: '[Confidential]', description: 'Only for the people it is meant for.', extraHeaderValue: 'Confidential',
+ }),
+ label({
+ id: 'strictly-confidential', name: 'Strictly confidential', color: '#dc2626', marking: 'STRICTLY CONFIDENTIAL',
+ sensitivity: 'Company-Confidential', subjectPrefix: '[Strictly confidential]',
+ description: 'Only for the named recipients. Don’t forward.', extraHeaderValue: 'Strictly confidential',
+ }),
+ ],
+ },
+ {
+ id: 'german-public-sector',
+ name: 'German public sector: OFFEN / VS-NfD',
+ defaultId: 'offen',
+ list: [
+ label({ id: 'offen', name: 'OFFEN', color: '#6b7280', marking: 'OFFEN', description: 'Nicht eingestuft.', extraHeaderValue: 'OFFEN' }),
+ label({
+ id: 'vs-nfd', name: 'VS-NfD', color: '#b91c1c', marking: 'VS-NUR FÜR DEN DIENSTGEBRAUCH', sensitivity: 'Company-Confidential',
+ subjectPrefix: 'VS-NfD',
+ description: 'Verschlusssache – nur für den Dienstgebrauch. Nur nach der VSA behandeln und versenden.',
+ extraHeaderValue: 'VS-NfD',
+ }),
+ ],
+ },
+];
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index 90d95dc90..dcbaecc48 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -383,8 +383,10 @@ export interface IJMAPClient {
moveMailboxContents(fromMailboxId: string, toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise;
markMailboxAsRead(mailboxId: string, accountId?: string): Promise;
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise;
- markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise;
- undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise;
+ /** `keywords`: more keywords set with the move ($phishing). */
+ markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean, keywords?: string[]): Promise;
+ /** `clearKeywords`: more keywords removed with the move back ($phishing). */
+ undoSpam(emailId: string, originalMailboxId: string, accountId?: string, clearKeywords?: string[]): Promise;
// ── Threads ───────────────────────────────────────────────────
getThread(threadId: string, accountId?: string): Promise;
@@ -413,8 +415,10 @@ export interface IJMAPClient {
* resolveSendAccountId). `previousDraftAccountId`: where `draftId` lives,
* so the replaced version is destroyed there (default: the primary
* account). Draft ids are only unique within one account.
+ * `classification`: the label choice (an id, null for none) kept in
+ * X-Bulwark-Label.
*/
- options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string },
+ options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string; classification?: string | null },
): Promise;
/**
@@ -448,8 +452,9 @@ export interface IJMAPClient {
// and need the matching `submissionExtensions` entry (see
// supportsSubmissionExtension).
// `draftAccountId`: the account `draftId` lives in, so the sent draft is
- // destroyed there (default: the primary account).
- options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record; draftAccountId?: string },
+ // destroyed there (default: the primary account). `noSentCopy`: no copy
+ // stays in Sent (a phishing report).
+ options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record; draftAccountId?: string; noSentCopy?: boolean; labelHeaders?: Record },
): Promise;
/** Whether the submission account advertises an SMTP extension ("DSN", "REQUIRETLS", …). */
supportsSubmissionExtension?(extension: string, accountId?: string): boolean;
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index eb659cb27..838e2ab0b 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -44,7 +44,8 @@ import { parseAuthenticationResults } from "@/lib/email-headers";
import { IS_LITE_STALWART } from "@/lib/lite";
import { toStorageQuota, type JMAPQuota, type StorageQuota } from "@/lib/quota";
import { messagePriorityHeaders, type MessagePriority } from "@/lib/message-priority";
-import { customHeaderProperties } from "@/lib/outgoing/custom-headers";
+import { customHeaderProperties, withLabelHeaders } from "@/lib/outgoing/custom-headers";
+import { draftLabelProperty } from "@/lib/classification/labels";
import { attachmentParts, HTML_PART_ID, relatedBodyStructure, TEXT_PART_ID, type OutgoingBodyPart } from "./outgoing-body";
import { addBodyMediaToAttachments } from "./body-media-attachments";
@@ -1426,6 +1427,17 @@ export interface SubmissionSendOptions {
headers?: Record;
/** The account the replaced draft (`draftId`) lives in; default the primary. */
draftAccountId?: string;
+ /**
+ * Leave no copy in Sent: the copy made for the send is destroyed once it
+ * has gone (a phishing report, which the user doesn't keep).
+ */
+ noSentCopy?: boolean;
+ /**
+ * Classification label headers (lib/outgoing/classification.ts) as
+ * Email/set create properties. Written after `headers`; a plugin header
+ * of the same name is dropped, so a plugin can't change the label.
+ */
+ labelHeaders?: Record;
}
/** The MAIL FROM / RCPT TO parameters the options translate to. */
@@ -3931,7 +3943,8 @@ export class JMAPClient implements IJMAPClient {
return totalMarked;
}
- async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise {
+ /** `keywords`: more keywords to set in the same patch ($phishing for a phishing report). */
+ async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean, keywords?: string[]): Promise {
const targetAccountId = accountId || this.accountId;
const mailboxes = await this.getMailboxes(accountId);
@@ -3958,6 +3971,7 @@ export class JMAPClient implements IJMAPClient {
"keywords/$notjunk": null,
};
if (markAsRead) patch["keywords/$seen"] = true;
+ for (const keyword of keywords ?? []) patch[`keywords/${keyword}`] = true;
const response = await this.request([
["Email/set", {
@@ -3968,21 +3982,22 @@ export class JMAPClient implements IJMAPClient {
this.assertEmailSetSucceeded(response, "mark as spam");
}
- async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise {
+ /** `clearKeywords`: more keywords to remove in the same patch ($phishing when a report is undone). */
+ async undoSpam(emailId: string, originalMailboxId: string, accountId?: string, clearKeywords?: string[]): Promise {
const targetAccountId = accountId || this.accountId;
+ const patch: Record = {
+ mailboxIds: { [originalMailboxId]: true },
+ // Moving alone leaves $junk set, so the message shows up in the
+ // Inbox while still flagged as junk for every other client. (#850)
+ "keywords/$junk": null,
+ "keywords/$notjunk": true,
+ };
+ for (const keyword of clearKeywords ?? []) patch[`keywords/${keyword}`] = null;
const response = await this.request([
["Email/set", {
accountId: targetAccountId,
- update: {
- [emailId]: {
- mailboxIds: { [originalMailboxId]: true },
- // Moving alone leaves $junk set, so the message shows up in the
- // Inbox while still flagged as junk for every other client. (#850)
- "keywords/$junk": null,
- "keywords/$notjunk": true,
- },
- },
+ update: { [emailId]: patch },
}, "0"],
]);
this.assertEmailSetSucceeded(response, "mark as not spam");
@@ -4689,7 +4704,9 @@ export class JMAPClient implements IJMAPClient {
htmlBody?: string,
inReplyTo?: string[],
references?: string[],
- options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string },
+ // `classification`: the composer's label choice (an id, null for none),
+ // kept in X-Bulwark-Label so a re-opened draft restores it.
+ options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string; classification?: string | null },
): Promise {
// A draft for a group identity belongs in the group's Drafts, like its
// Sent copy (#1090).
@@ -4748,6 +4765,7 @@ export class JMAPClient implements IJMAPClient {
...(draftReferences?.length ? { references: draftReferences } : {}),
// A re-opened draft reads its priority back from these (#970).
...messagePriorityHeaders(options?.priority),
+ ...draftLabelProperty(options?.classification),
};
// Images the HTML shows by cid: go into a multipart/related beside it
@@ -4951,7 +4969,7 @@ export class JMAPClient implements IJMAPClient {
}
Object.assign(emailCreate, messagePriorityHeaders(options?.priority));
- Object.assign(emailCreate, customHeaderProperties(options?.headers));
+ Object.assign(emailCreate, withLabelHeaders(customHeaderProperties(options?.headers), options?.labelHeaders));
// Images the HTML shows by cid: go into a multipart/related beside it,
// or recipients don't see them (#1451).
@@ -4992,6 +5010,10 @@ export class JMAPClient implements IJMAPClient {
"keywords/$draft": null,
},
};
+ // Without a Sent copy the copy goes once the submission is made.
+ const onSuccess = options?.noSentCopy
+ ? { onSuccessDestroyEmail: ["#1"] }
+ : { onSuccessUpdateEmail };
// An explicit envelope is only sent when MAIL FROM should differ from the
// identity's address (a From override, e.g. replying from a catch-all
@@ -5034,7 +5056,7 @@ export class JMAPClient implements IJMAPClient {
methodCalls.push(["EmailSubmission/set", {
accountId: submissionAccountId,
create: buildSubmissionCreate(`#${emailId}`, firstMailFrom),
- onSuccessUpdateEmail,
+ ...onSuccess,
}, "1"]);
// Which recipients the server took. A refused one is lost without a
// bounce while the create succeeds and files the message in Sent.
@@ -5073,7 +5095,7 @@ export class JMAPClient implements IJMAPClient {
const retry = await this.request([["EmailSubmission/set", {
accountId: submissionAccountId,
create: buildSubmissionCreate(draftCopyId, mailFrom),
- onSuccessUpdateEmail,
+ ...onSuccess,
}, "1"], deliveryStatusCall(submissionAccountId, "1")]);
response = {
...retry,
diff --git a/lib/mdn.ts b/lib/mdn.ts
index 5e05ab0d6..c255e4944 100644
--- a/lib/mdn.ts
+++ b/lib/mdn.ts
@@ -40,7 +40,7 @@ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** RFC 5322 date in UTC, e.g. "Thu, 28 May 2026 14:23:00 +0000". */
-function rfc5322Date(d: Date = new Date()): string {
+export function rfc5322Date(d: Date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${DAYS[d.getUTCDay()]}, ${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` +
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`;
@@ -55,7 +55,7 @@ function utf8ToBase64(value: string): string {
}
/** UTF-8 base64 body, wrapped at 76 chars per RFC 2045. */
-function base64Body(text: string): string {
+export function base64Body(text: string): string {
return (utf8ToBase64(text).match(/.{1,76}/g) || []).join("\r\n");
}
@@ -71,13 +71,13 @@ function base64Body(text: string): string {
* append headers and a body of their choosing to a message the victim's own
* account submits (GHSA-w38p-hpqv-g89c).
*/
-function stripHeaderControls(value: string): string {
+export function stripHeaderControls(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\x00-\x1F\x7F]+/g, " ").trim();
}
/** Address-like atoms (mailboxes, message-ids) may contain no whitespace at all. */
-function headerAtom(value: string): string {
+export function headerAtom(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\x00-\x20\x7F]+/g, "");
}
@@ -86,14 +86,14 @@ function headerAtom(value: string): string {
* Header value safe to place after "Name: ". Control characters are stripped
* first; the result is RFC 2047-encoded when it contains non-ASCII.
*/
-function encodeHeaderWord(value: string): string {
+export function encodeHeaderWord(value: string): string {
const clean = stripHeaderControls(value);
if (!/[^\x20-\x7E]/.test(clean)) return clean;
return `=?UTF-8?B?${utf8ToBase64(clean)}?=`;
}
/** Display-name for a mailbox: encoded-word if non-ASCII, quoted if it uses specials. */
-function displayName(value: string): string {
+export function displayName(value: string): string {
const encoded = encodeHeaderWord(value);
if (encoded.startsWith("=?") || !/[()<>[\]:;@\\,."]/.test(encoded)) return encoded;
return `"${encoded.replace(/["\\]/g, "\\$&")}"`;
@@ -109,7 +109,7 @@ function ensureAngles(messageId: string | string[] | undefined): string {
return trimmed.startsWith("<") ? trimmed : `<${trimmed}>`;
}
-function randomToken(): string {
+export function randomToken(): string {
const rnd = Math.random().toString(36).slice(2);
return `${Date.now().toString(36)}.${rnd}`;
}
diff --git a/lib/org/account-subjects.ts b/lib/org/account-subjects.ts
index 74885eaf6..e6332b7f6 100644
--- a/lib/org/account-subjects.ts
+++ b/lib/org/account-subjects.ts
@@ -17,9 +17,11 @@ import { usePolicyStore } from '@/stores/policy-store';
interface AccountSubjectsState {
/** Resolved subjects by local account id. */
subjects: Record;
+ /** Accounts whose subject was worked out, found or not. */
+ settled: Record;
}
-export const useAccountSubjects = create()(() => ({ subjects: {} }));
+export const useAccountSubjects = create()(() => ({ subjects: {}, settled: {} }));
const pending = new Map();
@@ -29,10 +31,29 @@ type SubjectSource = Parameters[0];
function resolveSubject(accountId: string, client: NonNullable): void {
if (pending.get(accountId) === client) return;
pending.set(accountId, client);
- void policySubjectForClient(client).then((subject) => {
- if (pending.get(accountId) !== client || !subject) return;
- useAccountSubjects.setState((state) => ({ subjects: { ...state.subjects, [accountId]: subject } }));
- }).catch(() => {});
+ const settle = (subject: PolicySubject | null) => {
+ if (pending.get(accountId) !== client) return;
+ useAccountSubjects.setState((state) => ({
+ subjects: subject ? { ...state.subjects, [accountId]: subject } : state.subjects,
+ settled: { ...state.settled, [accountId]: true },
+ }));
+ };
+ void policySubjectForClient(client).then(settle, () => settle(null));
+}
+
+/**
+ * Whether another signed-in account's subject is still being worked out,
+ * so its group scopes may not apply yet. False for the active account
+ * (see the policy store's subjectPending) and for accounts not signed in.
+ */
+export function accountSubjectPending(accountId: string | null | undefined): boolean {
+ const auth = useAuthStore.getState();
+ if (!accountId || accountId === auth.activeAccountId) return false;
+ const client = auth.getClientForAccount?.(accountId);
+ if (!client) return false;
+ if (useAccountSubjects.getState().settled[accountId]) return false;
+ resolveSubject(accountId, client as unknown as NonNullable);
+ return !useAccountSubjects.getState().settled[accountId];
}
/**
@@ -57,5 +78,5 @@ export function subjectForAccount(accountId: string | null | undefined): PolicyS
/** For tests: forget every subject worked out. */
export function resetAccountSubjects(): void {
pending.clear();
- useAccountSubjects.setState({ subjects: {} });
+ useAccountSubjects.setState({ subjects: {}, settled: {} });
}
diff --git a/lib/outgoing/__tests__/classification-raw.test.ts b/lib/outgoing/__tests__/classification-raw.test.ts
new file mode 100644
index 000000000..b1be8b538
--- /dev/null
+++ b/lib/outgoing/__tests__/classification-raw.test.ts
@@ -0,0 +1,121 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { LABEL_PRESETS } from '@/lib/classification/presets';
+import { usePolicyStore } from '@/stores/policy-store';
+import { beginLabelTakeover, endLabelTakeover, labelRawSend, resetLabelTakeovers } from '../classification-raw';
+
+// Labels on raw sends (api.jmap.sendRaw / submitRaw), the way S/MIME and PGP
+// plugins send after a takeover: the takeover's label goes into the outer
+// header block, matched by the token the request carries, or by identity
+// when the plugin doesn't pass it (refused when that is ambiguous). Label
+// headers a plugin wrote are always taken out, and a raw send outside a
+// takeover gets the default or is refused when a label is required.
+
+const generic = LABEL_PRESETS[0];
+const labels = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' };
+const label = (id: string) => generic.list.find((l) => l.id === id)!;
+
+function useLabels(over: Partial = {}) {
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: { ...labels, ...over } } } });
+}
+
+const raw = (head: string) => new TextEncoder().encode(`${head}\r\n\r\nContent-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n`);
+const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
+const headOf = (bytes: Uint8Array) => decode(bytes).split('\r\n\r\n')[0];
+const refusal = (fn: () => unknown) => {
+ try { fn(); } catch (error) { return error as Error; }
+ return null;
+};
+
+afterEach(() => {
+ usePolicyStore.setState({ policy: DEFAULT_POLICY });
+ resetLabelTakeovers();
+});
+
+describe('a raw send during a takeover', () => {
+ it('gets the label in the outer header block, the signed part untouched', () => {
+ useLabels();
+ const token = beginLabelTakeover(labels, label('confidential'), 'id-1')!;
+ const out = decode(labelRawSend(raw('From: a@example.org\r\nSubject: [Confidential] Budget'), 'id-1', token));
+ const [head, ...rest] = out.split('\r\n\r\n');
+ expect(head).toContain('SIO-Label: marking="CONFIDENTIAL"');
+ expect(head).toContain('Sensitivity: Company-Confidential');
+ expect(head).toContain('X-Classification: Confidential');
+ expect(rest.join('\r\n\r\n')).toBe('Content-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n');
+ endLabelTakeover(token);
+ });
+
+ it('matches two takeovers at once by their tokens, whatever the subjects are', () => {
+ useLabels();
+ const first = beginLabelTakeover(labels, label('public'), 'id-1')!;
+ const second = beginLabelTakeover(labels, label('confidential'), 'id-1')!;
+ // Subjects as plugins write them: raw UTF-8 and an encoded word.
+ const a = headOf(labelRawSend(raw('Subject: Grüße aus Köln'), 'id-1', second));
+ const b = headOf(labelRawSend(raw('Subject: =?UTF-8?B?R3LDvMOfZQ==?='), 'id-1', first));
+ expect(a).toContain('marking="CONFIDENTIAL"');
+ expect(a).toContain('Subject: Grüße aus Köln');
+ expect(b).toContain('marking="PUBLIC"');
+ });
+
+ it('matches a plugin that passes no token by its identity, and refuses when two are open', () => {
+ useLabels();
+ const one = beginLabelTakeover(labels, label('confidential'), 'acct-2::id-1')!;
+ // The request's identity carries the other account's prefix; the plugin passes the raw id.
+ expect(headOf(labelRawSend(raw('Subject: Grüße'), 'id-1'))).toContain('marking="CONFIDENTIAL"');
+ beginLabelTakeover(labels, label('public'), 'id-1');
+ expect(refusal(() => labelRawSend(raw('Subject: Grüße'), 'id-1'))?.name).toBe('RawSendLabelError');
+ endLabelTakeover(one);
+ });
+
+ it('takes out label headers the plugin wrote, also for "no label"', () => {
+ useLabels();
+ const token = beginLabelTakeover(labels, null, 'id-1')!;
+ const out = headOf(labelRawSend(raw('Subject: x\r\nSIO-Label: marking="FORGED";\r\n bgcolor=red\r\nSensitivity: Private\r\nX-Classification: Secret'), 'id-1', token));
+ expect(out).toBe('Subject: x');
+ });
+
+ it('refuses a token that is no longer open', () => {
+ useLabels();
+ const token = beginLabelTakeover(labels, label('public'), 'id-1')!;
+ endLabelTakeover(token);
+ expect(refusal(() => labelRawSend(raw('Subject: x'), 'id-1', token))?.name).toBe('RawSendLabelError');
+ });
+});
+
+describe('a raw send outside any takeover', () => {
+ it('gets the default label and loses forged ones', () => {
+ useLabels();
+ const out = headOf(labelRawSend(raw('Subject: x\r\nSIO-Label: marking="PUBLIC"'), 'id-1'));
+ expect(out).toContain('marking="INTERNAL"');
+ expect(out).not.toContain('PUBLIC');
+ });
+
+ it('is refused when a label is required', () => {
+ useLabels({ required: true, defaultId: '' });
+ const error = refusal(() => labelRawSend(raw('Subject: x'), 'id-1'));
+ expect(error?.name).toBe('RawSendLabelError');
+ expect(error?.message).toContain('requires a classification label');
+ });
+
+ it('is left alone while the organisation has no labels', () => {
+ const bytes = raw('Subject: x\r\nSensitivity: Private');
+ expect(labelRawSend(bytes, 'id-1')).toBe(bytes);
+ });
+});
+
+describe('the extra header on raw sends', () => {
+ it('is folded, non-ASCII as encoded words of at most 75 characters', () => {
+ useLabels();
+ const long = { ...label('confidential'), extraHeaderValue: 'Vertraulich – nur für den internen Gebrauch der Abteilung Öffentlichkeitsarbeit und Presse' };
+ const token = beginLabelTakeover(labels, long, 'id-1')!;
+ const head = headOf(labelRawSend(raw('Subject: x'), 'id-1', token));
+ const extra = head.slice(head.indexOf('X-Classification:')).split('\r\n').filter((line, i) => i === 0 || line.startsWith(' '));
+ expect(extra.length).toBeGreaterThan(1);
+ for (const line of extra) expect(line.length).toBeLessThanOrEqual(78);
+ for (const word of head.match(/=\?UTF-8\?B\?[^?]*\?=/g) ?? []) expect(word.length).toBeLessThanOrEqual(75);
+ const decoded = (head.match(/=\?UTF-8\?B\?([^?]*)\?=/g) ?? [])
+ .map((w) => Buffer.from(w.slice(10, -2), 'base64'));
+ expect(Buffer.concat(decoded).toString('utf8')).toBe(long.extraHeaderValue);
+ });
+});
diff --git a/lib/outgoing/__tests__/classification.test.ts b/lib/outgoing/__tests__/classification.test.ts
new file mode 100644
index 000000000..7a277d10d
--- /dev/null
+++ b/lib/outgoing/__tests__/classification.test.ts
@@ -0,0 +1,145 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { useAuthStore } from '@/stores/auth-store';
+import { useAccountSubjects } from '@/lib/org/account-subjects';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { LABEL_PRESETS } from '@/lib/classification/presets';
+import { usePolicyStore } from '@/stores/policy-store';
+import { customHeaderProperties, withLabelHeaders } from '../custom-headers';
+import {
+ automaticLabel,
+ classifyOutgoing,
+ isLabelRequiredError,
+ labelSourceHeaders,
+ labelsPolicyFor,
+ reopenedLabelChoice,
+ resolveLabel,
+} from '../classification';
+
+// The label stage of sending: which label goes out, the required label
+// failing closed, plugin headers that can't override it, the policy of the
+// sending account (raw sends: classification-raw.test.ts).
+
+const generic = LABEL_PRESETS[0];
+const policy = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' };
+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);
+});
+
+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, fromColleague('CONFIDENTIAL'))?.id).toBe('confidential');
+ expect(resolveLabel(policy, undefined)?.id).toBe('internal');
+ 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');
+ });
+});
+
+describe('classifyOutgoing', () => {
+ it('fails closed when a label is required and there is none', () => {
+ let error: unknown;
+ try { classifyOutgoing(required, null, { subject: 'x' }); } catch (e) { error = e; }
+ expect(isLabelRequiredError(error)).toBe(true);
+ });
+
+ it('prefixes the subject and adds the headers', () => {
+ const out = classifyOutgoing(policy, label('confidential'), { subject: 'Re: Budget' });
+ expect(out.subject).toBe('[Confidential] Re: Budget');
+ expect(out.labelHeaders).toMatchObject({ 'header:Sensitivity:asText': 'Company-Confidential', 'header:X-Classification:asText': 'Confidential' });
+ });
+
+ it('keeps a plugin from setting or dropping the label', () => {
+ const out = classifyOutgoing(policy, label('internal'), {
+ subject: 'Budget',
+ headers: { 'SIO-Label': 'marking="PUBLIC"', Sensitivity: 'Personal', 'x-classification': 'Public', 'X-Mailer-Note': 'ok' },
+ });
+ expect(out.headers).toEqual({ 'X-Mailer-Note': 'ok' });
+ const props = withLabelHeaders(customHeaderProperties({ ...out.headers, 'SIO-Label': 'forged' }), out.labelHeaders);
+ expect(props['header:SIO-Label:asRaw']).toContain('INTERNAL');
+ expect(Object.keys(props).filter((k) => k.toLowerCase().startsWith('header:sio-label'))).toHaveLength(1);
+ expect(props['header:X-Mailer-Note:asText']).toBe('ok');
+ });
+
+ it('changes nothing while the organisation has no labels', () => {
+ const headers = { Sensitivity: 'Personal' };
+ expect(classifyOutgoing({ ...policy, list: [] }, null, { subject: 'x', headers })).toEqual({ subject: 'x', headers });
+ });
+});
+
+describe('the policy of the sending account', () => {
+ it('quick reply and forward take the original’s label, and report a missing required one', () => {
+ usePolicyStore.setState({
+ policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: required } },
+ subject: { email: 'me@example.org', groups: [] },
+ });
+ 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', () => {
+ usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy } } });
+ expect(labelSourceHeaders({ 'SIO-Label': 'x', 'X-Classification': 'y', Received: 'z', Subject: 's' })).toEqual({ 'SIO-Label': 'x', 'X-Classification': 'y' });
+ expect(labelSourceHeaders({ Received: 'z' })).toBeUndefined();
+ });
+});
+
+describe('the labels of another account', () => {
+ it('come from that account’s own scopes, not the active one’s', () => {
+ const team = { ...policy, list: [label('confidential')], defaultId: 'confidential' };
+ const other = { getUsername: () => 'b@team.example', getSharedAccounts: () => [] };
+ useAuthStore.setState({ activeAccountId: 'acct-a', getClientForAccount: (id: string) => (id === 'acct-b' ? other : undefined) } as never);
+ usePolicyStore.setState({
+ policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy } },
+ rawPolicy: {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy },
+ scopes: [{ id: 'team', label: 'Team', match: { domains: ['team.example'] }, policy: { organisation: { labels: team } } }],
+ } as never,
+ subject: { email: 'me@example.org', groups: [] },
+ });
+ useAccountSubjects.setState({ subjects: { 'acct-b': { email: 'b@team.example', groups: [] } }, settled: { 'acct-b': true } });
+ expect(labelsPolicyFor({ accountId: 'acct-b', email: 'b@team.example' }).defaultId).toBe('confidential');
+ expect(labelsPolicyFor({}).defaultId).toBe('internal');
+ useAuthStore.setState({ activeAccountId: null, getClientForAccount: () => undefined } as never);
+ });
+});
diff --git a/lib/outgoing/classification-raw.ts b/lib/outgoing/classification-raw.ts
new file mode 100644
index 000000000..e9d27e31c
--- /dev/null
+++ b/lib/outgoing/classification-raw.ts
@@ -0,0 +1,134 @@
+/**
+ * Classification labels on raw sends (api.jmap.sendRaw and submitRaw), the
+ * way S/MIME and PGP plugins send after they took the send over from the
+ * composer (onComposeSend). They build the MIME themselves, so the host
+ * puts the label into the top-level header block of what they hand it,
+ * outside the signed or encrypted part.
+ *
+ * - The composer registers each takeover before it runs
+ * (beginLabelTakeover) and hands the plugin its `labelToken` in the
+ * request. A raw send that passes the token back gets that takeover's
+ * label. Plugins that don't pass it yet are matched by their sending
+ * identity; with more than one takeover open for it, the send is refused
+ * rather than guessed.
+ * - While the organisation uses labels, label headers a plugin wrote itself
+ * are always taken out.
+ * - A raw send outside any takeover gets the default label, and is refused
+ * when a label is required: it can't carry the user's pick.
+ */
+
+import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
+import { labelHeaderLines, type LabelsPolicy } from '@/lib/classification/labels';
+import { stripCrossAccountIdentityPrefix } from '@/hooks/use-pro-multi-account-identities';
+import { generateUUID } from '@/lib/utils';
+import { labelsPolicyFor } from './classification';
+import { LABEL_HEADER_NAMES } from './custom-headers';
+
+interface Takeover {
+ identityId: string;
+ /** The label's header lines; empty for "no label". */
+ lines: string[];
+}
+
+const takeovers = new Map();
+
+/** An identity id without the "::" prefix of another signed-in account's identities. */
+function rawIdentityId(identityId: string): string {
+ return stripCrossAccountIdentityPrefix(identityId).rawId ?? identityId;
+}
+
+/** A raw send the host refuses; the plugin gets the message. */
+export class RawSendLabelError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'RawSendLabelError';
+ }
+}
+
+/**
+ * Registers a takeover about to run, with the label the message goes out
+ * with (null: none). Returns the token to put in the request and to pass
+ * to endLabelTakeover when the takeover ended; null while labels are off.
+ */
+export function beginLabelTakeover(
+ policy: LabelsPolicy,
+ label: ClassificationLabel | null,
+ identityId: string,
+): string | null {
+ if (policy.list.length === 0) return null;
+ const token = generateUUID();
+ takeovers.set(token, { identityId: rawIdentityId(identityId), lines: label ? labelHeaderLines(label, policy.extraHeader) : [] });
+ return token;
+}
+
+export function endLabelTakeover(token: string | null): void {
+ if (token) takeovers.delete(token);
+}
+
+/** For tests. */
+export function resetLabelTakeovers(): void {
+ takeovers.clear();
+}
+
+/** The header lines a raw send gets: its takeover's, or the default's outside one. */
+function linesFor(policy: LabelsPolicy, identityId: string, token: string | undefined): string[] {
+ if (token) {
+ const takeover = takeovers.get(token);
+ if (!takeover) throw new RawSendLabelError('This send is no longer open in the composer. Send the message again.');
+ return takeover.lines;
+ }
+ const id = rawIdentityId(identityId);
+ const open = [...takeovers.values()].filter((t) => t.identityId === id);
+ if (open.length === 1) return open[0].lines;
+ if (open.length > 1) {
+ throw new RawSendLabelError('Several messages are being sent from this address at once, and this one can\'t be matched to its classification label. Send it again.');
+ }
+ if (policy.required) {
+ throw new RawSendLabelError('Your organisation requires a classification label, and a message sent outside the composer can\'t carry one.');
+ }
+ const fallback = policy.list.find((label) => label.id === policy.defaultId);
+ return fallback ? labelHeaderLines(fallback, policy.extraHeader) : [];
+}
+
+/** Where the header block of raw message bytes ends, and its line break. */
+function headerEnd(bytes: Uint8Array): { end: number; eol: string } | null {
+ for (let i = 0; i < bytes.length - 1; i++) {
+ if (bytes[i] === 0x0d && bytes[i + 1] === 0x0a && bytes[i + 2] === 0x0d && bytes[i + 3] === 0x0a) return { end: i, eol: '\r\n' };
+ if (bytes[i] === 0x0a && bytes[i + 1] === 0x0a) return { end: i, eol: '\n' };
+ }
+ return null;
+}
+
+/**
+ * The raw message a plugin hands to sendRaw or submitRaw, with the label
+ * the organisation's policy (of the active account, which sends it) asks
+ * for. The same bytes while labels are off. Throws RawSendLabelError when
+ * the send has to be refused.
+ */
+export function labelRawSend(bytes: Uint8Array, identityId: string, token?: string): Uint8Array {
+ const policy = labelsPolicyFor({});
+ if (policy.list.length === 0) return bytes;
+ const lines = linesFor(policy, identityId, typeof token === 'string' && token ? token : undefined);
+ const block = headerEnd(bytes);
+ if (!block) throw new RawSendLabelError('The message has no header block.');
+ // Byte for byte, so 8-bit header bytes (a raw UTF-8 subject) come back unchanged.
+ let head = '';
+ for (let i = 0; i < block.end; i++) head += String.fromCharCode(bytes[i]);
+ const reserved = new Set([...LABEL_HEADER_NAMES, ...(policy.extraHeader ? [policy.extraHeader.toLowerCase()] : [])]);
+ const kept: string[] = [];
+ let skipping = false;
+ for (const line of head.split(/\r?\n/)) {
+ if (/^[ \t]/.test(line)) {
+ if (!skipping) kept.push(line);
+ continue;
+ }
+ skipping = reserved.has(line.slice(0, line.indexOf(':')).trim().toLowerCase());
+ if (!skipping) kept.push(line);
+ }
+ const text = [...kept, ...lines.map((line) => line.replace(/\r\n/g, block.eol))].join(block.eol);
+ const newHead = Uint8Array.from(text, (ch) => ch.charCodeAt(0) & 0xff);
+ const out = new Uint8Array(newHead.length + (bytes.length - block.end));
+ out.set(newHead, 0);
+ out.set(bytes.subarray(block.end), newHead.length);
+ return out;
+}
diff --git a/lib/outgoing/classification.ts b/lib/outgoing/classification.ts
new file mode 100644
index 000000000..ec3b89e10
--- /dev/null
+++ b/lib/outgoing/classification.ts
@@ -0,0 +1,197 @@
+/**
+ * The classification label stage of sending (enterprise pack item 3).
+ * Every surface that sends a user's mail runs it last, after plugins'
+ * onTransformOutgoingEmail, so a plugin can't drop or change the label:
+ *
+ * - the composer (inline, pop-out and Pro tab), with the user's pick;
+ * - quick reply, with the original's label, else the default;
+ * - a plugin's forward (api.email.forward), the same way.
+ *
+ * A required label fails closed: with none, the stage throws
+ * LabelRequiredError and nothing is sent. Labels come from the policy of
+ * the account and identity the mail goes out from, never just the active
+ * account. Automatic mail carries none: calendar invitations and RSVP
+ * replies, read receipts, List-Unsubscribe mails and phishing reports.
+ *
+ * S/MIME and PGP plugins take over the send (onComposeSend) and build raw
+ * MIME themselves: the composer hands them the prefixed subject, and
+ * lib/outgoing/classification-raw.ts puts the label into what they send.
+ */
+
+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 { withoutLabelHeaders } from './custom-headers';
+import {
+ applyLabelPrefix,
+ labelHeaderProperties,
+ draftLabelChoice,
+ receivedLabel,
+ type LabelsPolicy,
+} from '@/lib/classification/labels';
+
+/** A send stopped because the organisation requires a label and none was picked. */
+export class LabelRequiredError extends Error {
+ constructor() {
+ super('A classification label is required');
+ this.name = 'LabelRequiredError';
+ }
+}
+
+export function isLabelRequiredError(error: unknown): boolean {
+ return (error as { name?: string } | null)?.name === 'LabelRequiredError';
+}
+
+export interface LabelSender {
+ /** The From identity's address (a group's own picks the group's scopes). */
+ email?: string;
+ /** The signed-in account (AccountEntry id) it goes out through; the active one when left out. */
+ accountId?: string | null;
+}
+
+/** The labels section that applies to mail from `sender`, read from the stores now. */
+export function labelsPolicyFor(sender: LabelSender): LabelsPolicy {
+ const policy = policyForSender(usePolicyStore.getState(), sender.email, subjectForAccount(sender.accountId));
+ 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 (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,
+ 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;
+ 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. */
+export function labelMissing(policy: LabelsPolicy, label: ClassificationLabel | null): boolean {
+ return policy.required && policy.list.length > 0 && !label;
+}
+
+export interface LabelledMessage {
+ subject: string;
+ /** The plugins' headers, without any the label writes. */
+ headers?: Record;
+ /** The label's headers as JMAP create properties (SubmissionSendOptions.labelHeaders). */
+ labelHeaders?: Record;
+}
+
+/**
+ * The stage: the subject with the label's prefix (once), the plugins'
+ * headers without the label's names, and the label's headers. Throws
+ * LabelRequiredError when the policy requires a label and there is none.
+ */
+export function classifyOutgoing(
+ policy: LabelsPolicy,
+ label: ClassificationLabel | null,
+ message: { subject: string; headers?: Record },
+): LabelledMessage {
+ if (policy.list.length === 0) return { subject: message.subject, headers: message.headers };
+ if (labelMissing(policy, label)) throw new LabelRequiredError();
+ const reserved = policy.extraHeader ? [policy.extraHeader] : [];
+ return {
+ subject: applyLabelPrefix(message.subject, label, policy.list),
+ headers: withoutLabelHeaders(message.headers, reserved),
+ labelHeaders: label ? labelHeaderProperties(label, policy.extraHeader) : undefined,
+ };
+}
+
+/** The label of a quick reply or a plugin's forward: the original's (originalLabel), else the default. */
+export function automaticLabel(
+ sender: LabelSender,
+ original?: OriginalMessage | null,
+): { policy: LabelsPolicy; label: ClassificationLabel | null; missing: boolean } {
+ const policy = labelsPolicyFor(sender);
+ const label = resolveLabel(policy, undefined, original);
+ return { policy, label, missing: labelMissing(policy, label) };
+}
+
+/**
+ * The original's headers a reply or forward needs for its label: SIO-Label
+ * and every extra header name any scope of the policy uses. Small enough
+ * to keep in a Pro tab's saved state.
+ */
+export function labelSourceHeaders(headers: Record | null | undefined): Record | undefined {
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return undefined;
+ const raw = usePolicyStore.getState().rawPolicy;
+ const names = new Set(['sio-label']);
+ const add = (name: string | undefined) => { if (name) names.add(name.toLowerCase()); };
+ add(raw.organisation?.labels?.extraHeader);
+ for (const scope of raw.scopes ?? []) add(scope.policy.organisation?.labels?.extraHeader);
+ const out: Record = {};
+ for (const [name, value] of Object.entries(headers)) if (names.has(name.toLowerCase())) out[name] = value;
+ return Object.keys(out).length > 0 ? out : undefined;
+}
diff --git a/lib/outgoing/composer-send-options.ts b/lib/outgoing/composer-send-options.ts
new file mode 100644
index 000000000..61245e74e
--- /dev/null
+++ b/lib/outgoing/composer-send-options.ts
@@ -0,0 +1,32 @@
+import type { MessagePriority } from '@/lib/message-priority';
+
+/** What the composer's onSend hands its host beyond the message itself. */
+export interface ComposerSendExtras {
+ requestReadReceipt?: boolean;
+ requestDsn?: boolean;
+ requireTls?: boolean;
+ priority?: MessagePriority;
+ headers?: Record;
+ labelHeaders?: Record;
+ localAccountId?: string;
+ draftAccountId?: string;
+}
+
+/**
+ * The options object of useEmailStore.sendEmail for a composer send. Every
+ * host of the composer (the mail page, a Pro compose tab) passes it whole,
+ * so none drops the read receipt, DSN, REQUIRETLS, priority, plugin headers
+ * or the classification label: the Pro tab used to send without them.
+ */
+export function composerSendOptions(data: ComposerSendExtras): ComposerSendExtras {
+ return {
+ requestReadReceipt: data.requestReadReceipt,
+ requestDsn: data.requestDsn,
+ requireTls: data.requireTls,
+ priority: data.priority,
+ headers: data.headers,
+ labelHeaders: data.labelHeaders,
+ localAccountId: data.localAccountId,
+ draftAccountId: data.draftAccountId,
+ };
+}
diff --git a/lib/outgoing/custom-headers.ts b/lib/outgoing/custom-headers.ts
index 0058d8aee..c1a4ca5d8 100644
--- a/lib/outgoing/custom-headers.ts
+++ b/lib/outgoing/custom-headers.ts
@@ -65,3 +65,53 @@ export function customHeaderProperties(headers: unknown): Record
}
return out;
}
+
+/**
+ * The headers a classification label writes (lib/classification/labels.ts).
+ * While the organisation uses labels, plugins can't set them: the label
+ * stage drops them from the plugins' headers (withoutLabelHeaders), along
+ * with the admin's extra header, and withLabelHeaders below lets the
+ * label's own win over anything left.
+ */
+export const LABEL_HEADER_NAMES = ['sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label'] as const;
+
+/** `headers` (OutgoingEmail.headers) without the label headers and `extra` names. */
+export function withoutLabelHeaders(
+ headers: Record | undefined,
+ extra: readonly string[] = [],
+): Record | undefined {
+ if (!headers) return headers;
+ const reserved = new Set([...LABEL_HEADER_NAMES, ...extra.map((name) => name.toLowerCase())]);
+ const out: Record = {};
+ for (const [name, value] of Object.entries(headers)) {
+ if (reserved.has(name.toLowerCase())) {
+ debug.warn(`Outgoing header "${name}" from a plugin was not sent: the classification label sets it`);
+ continue;
+ }
+ out[name] = value;
+ }
+ return out;
+}
+
+/** The name in a JMAP header property (`header:SIO-Label:asRaw` → `sio-label`). */
+function propertyHeaderName(property: string): string {
+ return property.split(':')[1]?.toLowerCase() ?? '';
+}
+
+/**
+ * Plugin header properties with the label's added on top: a plugin header
+ * of a name the label writes is dropped, since JMAP refuses a create that
+ * sets one header twice and the label must win.
+ */
+export function withLabelHeaders(
+ custom: Record,
+ label: Record | undefined,
+): Record {
+ if (!label || Object.keys(label).length === 0) return custom;
+ const taken = new Set(Object.keys(label).map(propertyHeaderName));
+ const out: Record = {};
+ for (const [property, value] of Object.entries(custom)) {
+ if (!taken.has(propertyHeaderName(property))) out[property] = value;
+ }
+ return { ...out, ...label };
+}
diff --git a/lib/outgoing/send-pipeline.ts b/lib/outgoing/send-pipeline.ts
index 5ef1ecc9e..957f1b38f 100644
--- a/lib/outgoing/send-pipeline.ts
+++ b/lib/outgoing/send-pipeline.ts
@@ -20,7 +20,11 @@ import { debug } from '@/lib/debug';
import { whenPolicySettled } from '@/stores/policy-store';
// 'plugin-forward': a plugin forwarding a message (api.email.forward).
-export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation' | 'plugin-forward';
+// 'report-phishing': a phishing report (lib/phishing-report.ts). It goes to
+// the admin's address and is not the user's mail, so it never runs the
+// checks, the finalisers, the classification label or plugins'
+// onBeforeEmailSend; the surface names it for code that has to tell.
+export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation' | 'plugin-forward' | 'report-phishing';
export interface OutgoingContext {
surface: SendSurface;
diff --git a/lib/phishing-links.ts b/lib/phishing-links.ts
new file mode 100644
index 000000000..e899bbf7b
--- /dev/null
+++ b/lib/phishing-links.ts
@@ -0,0 +1,50 @@
+/**
+ * Links in a message the user reported as phishing ($phishing, RFC 9051
+ * §2.3.2: clients should warn and disable links) are turned off: each
+ * `` and ` ` loses its address, so it can't be clicked, opened in
+ * a new tab or reached with Tab, and says that it is off. Runs on the
+ * sanitised body, before it goes into the reader's frame.
+ */
+
+import type { Email } from '@/lib/jmap/types';
+import { PHISHING_KEYWORD } from '@/lib/phishing-report';
+
+/** Whether the message carries the phishing mark. */
+export function isPhishingMarked(email: Pick | null | undefined): boolean {
+ return email?.keywords?.[PHISHING_KEYWORD] === true;
+}
+
+const LINK_OFF_STYLE = 'color:inherit !important;text-decoration:underline dotted !important;cursor:not-allowed !important';
+
+/** What a turned-off link says: its tooltip, and the note a screen reader hears after its text. */
+export interface LinksOffText {
+ title: string;
+ note: string;
+}
+
+/** `html` with every link turned off. */
+export function turnOffLinks(html: string, text: LinksOffText): string {
+ if (!html || typeof DOMParser === 'undefined') return html;
+ if (!/<(a|area)\b/i.test(html)) return html;
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ const links = doc.querySelectorAll('a, area');
+ if (links.length === 0) return html;
+ links.forEach((link) => {
+ for (const name of ['href', 'xlink:href', 'target', 'rel', 'ping', 'download', 'tabindex']) link.removeAttribute(name);
+ link.setAttribute('data-link-off', '');
+ link.setAttribute('aria-disabled', 'true');
+ link.setAttribute('title', text.title);
+ if (link.tagName === 'A') {
+ // Looks like text that was a link; a screen reader hears the note.
+ const style = link.getAttribute('style');
+ link.setAttribute('style', `${style ? `${style};` : ''}${LINK_OFF_STYLE}`);
+ const note = doc.createElement('span');
+ note.setAttribute('data-link-off-note', '');
+ note.setAttribute('style', 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap');
+ note.textContent = ` ${text.note}`;
+ link.appendChild(note);
+ }
+ });
+ // A full document keeps its (styles); a fragment stays a fragment.
+ return /]/i.test(html) ? doc.documentElement.outerHTML : doc.body.innerHTML;
+}
diff --git a/lib/phishing-report-arf.ts b/lib/phishing-report-arf.ts
new file mode 100644
index 000000000..7950a5885
--- /dev/null
+++ b/lib/phishing-report-arf.ts
@@ -0,0 +1,155 @@
+/**
+ * The ARF format of a phishing report (RFC 5965, an admin option of
+ * Organisation > Security > Phishing reports): a multipart/report with
+ * report-type=feedback-report and three parts, a short text for people, the
+ * machine-readable message/feedback-report (Feedback-Type: fraud, which
+ * RFC 5965 §7.3 defines as "fraud or phishing"), and the original whole and
+ * unchanged as message/rfc822.
+ *
+ * JMAP can't set the report-type parameter through Email/set, so this is
+ * raw MIME: the original's bytes are downloaded, wrapped and uploaded, then
+ * submitted with submitRawEmail (no Sent copy). The upload counts against
+ * the server's upload quota and is limited by maxSizeUpload.
+ */
+
+import type { Email } from '@/lib/jmap/types';
+import { base64Body, displayName, encodeHeaderWord, headerAtom, randomToken, rfc5322Date } from '@/lib/mdn';
+import { deliveryAddresses } from '@/lib/reply-identity';
+import { reportSubject, type ArfInput } from './phishing-report';
+
+export interface ArfParts {
+ /** Everything before the original's bytes. */
+ head: string;
+ /** Everything after them. */
+ tail: string;
+}
+
+function appVersion(): string {
+ return (process.env.NEXT_PUBLIC_APP_VERSION || '').trim() || 'unknown';
+}
+
+/** "Reported-Domain": the domain of the original's From, when it has one. */
+function senderDomain(email: Pick): string | null {
+ const address = email.from?.[0]?.email ?? '';
+ const domain = address.slice(address.lastIndexOf('@') + 1);
+ return address.includes('@') && /^[A-Za-z0-9.-]+$/.test(domain) ? domain.toLowerCase() : null;
+}
+
+/** How the original goes in: message/rfc822 may not be base64-encoded (RFC 2046 §5.2.1). */
+export type RawEncoding = '7bit' | '8bit' | 'binary';
+
+/**
+ * The report around the original. `encoding`: what the original's bytes
+ * are (rawEncoding). `recipient`: the address the original was delivered
+ * to, for Original-Rcpt-To; left out when unknown.
+ */
+export function buildArfParts(input: {
+ email: Pick;
+ reporter: { name?: string; email: string };
+ to: string;
+ encoding: RawEncoding;
+ recipient?: string;
+ now?: Date;
+}): ArfParts {
+ const { email, reporter } = input;
+ const fromEmail = headerAtom(reporter.email);
+ const domain = fromEmail.split('@')[1] || 'localhost';
+ const boundary = `----=_ARF_${randomToken()}`;
+ const fromName = reporter.name?.trim() ? displayName(reporter.name.trim()) : '';
+ const arrival = email.receivedAt ? new Date(email.receivedAt) : null;
+ const reportedDomain = senderDomain(email);
+ const human = [
+ `This is a phishing report (RFC 5965) sent by ${fromEmail} with Bulwark.`,
+ '',
+ 'The reported message is attached unchanged.',
+ ].join('\r\n');
+ const report = [
+ 'Feedback-Type: fraud',
+ `User-Agent: Bulwark/${appVersion()}`,
+ 'Version: 1',
+ ...(arrival && !Number.isNaN(arrival.getTime()) ? [`Arrival-Date: ${rfc5322Date(arrival)}`] : []),
+ ...(input.recipient ? [`Original-Rcpt-To: ${headerAtom(input.recipient)}`] : []),
+ ...(reportedDomain ? [`Reported-Domain: ${reportedDomain}`] : []),
+ ].join('\r\n');
+
+ const head = [
+ `Date: ${rfc5322Date(input.now ?? new Date())}`,
+ `From: ${fromName ? `${fromName} <${fromEmail}>` : fromEmail}`,
+ `To: ${headerAtom(input.to)}`,
+ `Subject: ${encodeHeaderWord(reportSubject(email.subject))}`,
+ `Message-ID: `,
+ 'MIME-Version: 1.0',
+ 'Content-Type: multipart/report; report-type=feedback-report;',
+ `\tboundary="${boundary}"`,
+ '',
+ `--${boundary}`,
+ 'Content-Type: text/plain; charset=utf-8',
+ 'Content-Transfer-Encoding: base64',
+ '',
+ base64Body(human),
+ '',
+ `--${boundary}`,
+ 'Content-Type: message/feedback-report',
+ 'Content-Transfer-Encoding: 7bit',
+ '',
+ report,
+ '',
+ `--${boundary}`,
+ 'Content-Type: message/rfc822',
+ 'Content-Disposition: attachment; filename="reported-message.eml"',
+ `Content-Transfer-Encoding: ${input.encoding}`,
+ '',
+ '',
+ ].join('\r\n');
+ const tail = `\r\n--${boundary}--\r\n`;
+ return { head, tail };
+}
+
+/**
+ * 7bit or 8bit lines of at most 998 octets ending in CRLF (RFC 2045 §2.7,
+ * §2.8); anything else (a NUL, a bare CR or LF, a longer line) is binary.
+ */
+export function rawEncoding(bytes: Uint8Array): RawEncoding {
+ let eightBit = false;
+ let line = 0;
+ for (let i = 0; i < bytes.length; i++) {
+ const b = bytes[i];
+ if (b === 0x00) return 'binary';
+ if (b === 0x0d) {
+ if (bytes[i + 1] !== 0x0a) return 'binary';
+ i++;
+ line = 0;
+ continue;
+ }
+ if (b === 0x0a) return 'binary';
+ if (b > 0x7f) eightBit = true;
+ if (++line > 998) return 'binary';
+ }
+ return eightBit ? '8bit' : '7bit';
+}
+
+/** The whole report as a Blob, the original downloaded from its own account. */
+export async function buildArfReportBlob(input: ArfInput): Promise {
+ const { client, email, sourceAccountId } = input;
+ if (!email.blobId) throw new Error('The message has no content to report');
+ // The report is uploaded: one bigger than the server takes fails anyway,
+ // so it isn't downloaded either.
+ const maxUpload = client.getMaxSizeUpload?.() ?? 0;
+ if (maxUpload > 0 && email.size + 8192 > maxUpload) {
+ throw new Error('The message is too big for an ARF report on this server');
+ }
+ const original = new Uint8Array(await client.fetchBlobArrayBuffer(email.blobId, 'message.eml', 'message/rfc822', sourceAccountId));
+ const parts = buildArfParts({
+ email,
+ reporter: input.reporter,
+ to: input.to,
+ encoding: rawEncoding(original),
+ recipient: deliveryAddresses(email.headers)[0]
+ ?? email.to?.find((addr) => addr.email?.toLowerCase() === input.reporter.email.toLowerCase())?.email,
+ });
+ // The original goes in byte for byte; the closing boundary needs a line break before it.
+ const endsWithBreak = original.length >= 2 && original[original.length - 2] === 0x0d && original[original.length - 1] === 0x0a;
+ const report = new Blob([parts.head, original, endsWithBreak ? parts.tail.slice(2) : parts.tail], { type: 'message/rfc822' });
+ if (maxUpload > 0 && report.size > maxUpload) throw new Error('The message is too big for an ARF report on this server');
+ return report;
+}
diff --git a/lib/phishing-report-flow.ts b/lib/phishing-report-flow.ts
new file mode 100644
index 000000000..e5abf4be0
--- /dev/null
+++ b/lib/phishing-report-flow.ts
@@ -0,0 +1,156 @@
+/**
+ * The app side of "Report phishing" (lib/phishing-report.ts sends one
+ * report): which policy applies to a message, sending one report per
+ * message, moving each to Junk with $phishing, and undoing the move.
+ *
+ * The policy is the one of the account the message is reached through
+ * (its own login in the unified inbox), so a domain or group scope with its
+ * own report address applies to its own mail.
+ */
+
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Email } from '@/lib/jmap/types';
+import { subjectForAccount } from '@/lib/org/account-subjects';
+import { emailKeyFor } from '@/lib/thread-utils';
+import { debug } from '@/lib/debug';
+import { useAuthStore } from '@/stores/auth-store';
+import { resolveEmailActionContext, useEmailStore } from '@/stores/email-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { usePhishingReportStore } from '@/stores/phishing-report-store';
+import { emailHooks } from '@/lib/plugin-hooks';
+import {
+ PHISHING_KEYWORD,
+ phishingReportSetup,
+ sendPhishingReport,
+ type ArfInput,
+ type PhishingReportSetup,
+} from './phishing-report';
+
+/** The report setup for a message, from the policy of the account it is in. */
+export function phishingSetupForEmail(email: Pick | null | undefined): PhishingReportSetup | null {
+ const view = usePolicyStore.getState();
+ const subject = subjectForAccount(email?.sourceClientAccountId ?? null);
+ const policy = subject === view.subject ? view.policy : view.resolveFor(subject);
+ return phishingReportSetup(policy);
+}
+
+interface MovedReport {
+ email: Email;
+ /** Moved through the store (its undo cache knows the folder) or straight through the client. */
+ via: 'store' | { client: IJMAPClient; accountId?: string; mailboxId: string };
+}
+
+export interface PhishingReportOutcome {
+ /** Every address the reports went to (accounts with different policies differ). */
+ addresses: string[];
+ /** Reports that went out. */
+ reported: Email[];
+ /** Messages whose report didn't go out. */
+ failed: Email[];
+ /** Reported, but the move to Junk failed or a plugin stopped it. */
+ notMoved: Email[];
+ /** What undo moves back. */
+ moved: MovedReport[];
+}
+
+// Loaded only when an admin picked the ARF format.
+async function loadArfBuilder(): Promise<(input: ArfInput) => Promise> {
+ const { buildArfReportBlob } = await import('./phishing-report-arf');
+ return buildArfReportBlob;
+}
+
+/** The folder a message is in, for a move back that the store can't do. */
+function firstMailboxId(email: Email): string | null {
+ const ids = Object.keys(email.mailboxIds ?? {}).filter((id) => email.mailboxIds[id]);
+ return ids[0] ?? null;
+}
+
+/**
+ * Reports each message on its own, then moves it to Junk. Messages that
+ * can't be reported (reporting off for their account) count as failed.
+ */
+export async function reportEmailsAsPhishing(emails: Email[]): Promise {
+ const active = useAuthStore.getState().client;
+ if (!active) throw new Error('Not signed in');
+ const outcome: PhishingReportOutcome = { addresses: [], reported: [], failed: [], notMoved: [], moved: [] };
+ let buildArf: ((input: ArfInput) => Promise) | undefined;
+
+ for (const email of emails) {
+ const setup = phishingSetupForEmail(email);
+ if (!setup) {
+ outcome.failed.push(email);
+ continue;
+ }
+ if (!outcome.addresses.includes(setup.address)) outcome.addresses.push(setup.address);
+ const { client, accountId } = resolveEmailActionContext(email, active);
+ try {
+ if (setup.format === 'arf' && !buildArf) buildArf = await loadArfBuilder();
+ await sendPhishingReport(email, { client, sourceAccountId: accountId ?? client.getAccountId(), setup, buildArf });
+ } catch (error) {
+ debug.error('Phishing report failed:', error);
+ outcome.failed.push(email);
+ continue;
+ }
+ outcome.reported.push(email);
+ // Every open view of it shows the banner and turns its links off now.
+ usePhishingReportStore.getState().markReported([emailKeyFor(email)], true);
+
+ // Like "Mark as spam" (plugins' onBeforeEmailSpamToggle still runs),
+ // with $phishing, and read: it shouldn't count as new mail in Junk.
+ const store = useEmailStore.getState();
+ const known = store.emails.some((e) => emailKeyFor(e) === emailKeyFor(email))
+ || (!!store.selectedEmail && emailKeyFor(store.selectedEmail) === emailKeyFor(email));
+ try {
+ if (known) {
+ const moved = await store.markAsSpam(active, email.id, emailKeyFor(email), { keywords: [PHISHING_KEYWORD], markRead: true });
+ if (moved) outcome.moved.push({ email, via: 'store' });
+ else outcome.notMoved.push(email);
+ } else {
+ // A message the list doesn't hold (a Pro tab, a conversation's older
+ // message): straight to its account, with the plugins' spam hooks
+ // as "Mark as spam" runs them.
+ const toggle = { emailIds: [email.id], spam: true };
+ if (emailHooks.onBeforeEmailSpamToggle.size > 0 && !(await emailHooks.onBeforeEmailSpamToggle.intercept(toggle))) {
+ outcome.notMoved.push(email);
+ continue;
+ }
+ const mailboxId = firstMailboxId(email);
+ await client.markAsSpam(email.id, accountId, true, [PHISHING_KEYWORD]);
+ void emailHooks.onEmailSpamToggle.emit(toggle);
+ if (mailboxId) outcome.moved.push({ email, via: { client, accountId, mailboxId } });
+ }
+ } catch (error) {
+ debug.error('Moving a reported message to Junk failed:', error);
+ outcome.notMoved.push(email);
+ }
+ }
+ return outcome;
+}
+
+/** Moves the reported messages back and clears $junk and $phishing. Resolves to how many came back. */
+export async function undoPhishingReports(outcome: PhishingReportOutcome): Promise {
+ const active = useAuthStore.getState().client;
+ if (!active) return 0;
+ let back = 0;
+ for (const { email, via } of outcome.moved) {
+ try {
+ let done = false;
+ if (via === 'store') {
+ done = await useEmailStore.getState().undoSpam(active, email.id, emailKeyFor(email), { clearKeywords: [PHISHING_KEYWORD] });
+ } else {
+ const toggle = { emailIds: [email.id], spam: false };
+ if (emailHooks.onBeforeEmailSpamToggle.size > 0 && !(await emailHooks.onBeforeEmailSpamToggle.intercept(toggle))) continue;
+ await via.client.undoSpam(email.id, via.mailboxId, via.accountId, [PHISHING_KEYWORD]);
+ void emailHooks.onEmailSpamToggle.emit(toggle);
+ done = true;
+ }
+ if (done) {
+ back++;
+ usePhishingReportStore.getState().markReported([emailKeyFor(email)], false);
+ }
+ } catch (error) {
+ debug.error('Moving a reported message back failed:', error);
+ }
+ }
+ return back;
+}
diff --git a/lib/phishing-report.ts b/lib/phishing-report.ts
new file mode 100644
index 000000000..add09c241
--- /dev/null
+++ b/lib/phishing-report.ts
@@ -0,0 +1,155 @@
+/**
+ * "Report phishing" (enterprise pack item 2): sends a message to the
+ * address the admin set (policy.json `organisation.phishingReport`) and
+ * moves it to Junk with $junk and $phishing. A sibling of
+ * lib/plugin-sandbox/host-api-forward.ts: it sends without the composer.
+ *
+ * - One report per message, also for a selection: report desks (Microsoft
+ * Defender among them) discard reports with several messages attached,
+ * and RFC 5965 §2(e) says one message per report.
+ * - No copy in Sent: a live phishing message shouldn't sit there to be
+ * clicked again; the admin's mailbox is the record.
+ * - The forward format attaches the original by its blobId (Email/set in
+ * Drafts, then EmailSubmission with onSuccessDestroyEmail), so nothing is
+ * uploaded and nothing counts against the upload quota. A message of
+ * another account (a shared or group mailbox) has its blob copied to the
+ * sending account first (Blob/copy).
+ * - The report skips the pre-send checks and finalisers of the send
+ * pipeline (external-recipient confirm, organisation signature and
+ * disclaimer), classification labels and plugins' onBeforeEmailSend: the
+ * address is the admin's, and the report is not the user's mail (send
+ * surface 'report-phishing', never passed to the pipeline).
+ */
+
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Email, Identity } from '@/lib/jmap/types';
+import type { SettingsPolicy } from '@/lib/admin/types';
+import { DEFAULT_ORGANISATION_POLICY, type PhishingReportFormat } from '@/lib/admin/organisation-policy';
+import { emailExportFilename } from '@/lib/download-filename';
+import { sortIdentities } from '@/lib/identity-order';
+
+/** RFC 9051 §2.3.2: the user reported the message as phishing. */
+export const PHISHING_KEYWORD = '$phishing';
+
+export interface PhishingReportSetup {
+ address: string;
+ format: PhishingReportFormat;
+}
+
+/** The setup in a policy; null while reporting is off (no address). */
+export function phishingReportSetup(policy: SettingsPolicy | null | undefined): PhishingReportSetup | null {
+ const section = policy?.organisation?.phishingReport ?? DEFAULT_ORGANISATION_POLICY.phishingReport;
+ const address = section.address.trim();
+ return address ? { address, format: section.format === 'arf' ? 'arf' : 'forward' } : null;
+}
+
+/** Folders whose messages are the user's own: nothing to report there. */
+const OWN_MAIL_ROLES = new Set(['sent', 'drafts', 'scheduled']);
+
+/**
+ * Whether `email`, shown in a folder of `mailboxRole`, can be reported.
+ * `folderRoles`: the roles of the folders the message itself is in (search
+ * results and All mail show the user's own sent mail too).
+ */
+export function isReportable(
+ email: Pick,
+ mailboxRole?: string | null,
+ folderRoles: readonly string[] = [],
+): boolean {
+ if (!email.blobId || email.isScheduled) return false;
+ if (mailboxRole && OWN_MAIL_ROLES.has(mailboxRole)) return false;
+ if (folderRoles.some((role) => OWN_MAIL_ROLES.has(role))) return false;
+ if (email.keywords?.$draft) return false;
+ // Reported already: it is in Junk with its links off.
+ return email.keywords?.[PHISHING_KEYWORD] !== true;
+}
+
+/** The subject of the report: Microsoft's reporting mailboxes read "Phishing:" as the verdict. */
+export function reportSubject(subject: string | undefined | null): string {
+ const clean = (subject ?? '').replace(/[\r\n]+/g, ' ').trim();
+ return clean ? `Phishing: ${clean}` : 'Phishing';
+}
+
+/** The short text of a forwarded report. English: it is read by the security team, not the user. */
+export function reportText(reporter: { name?: string; email: string }): string {
+ const who = reporter.name?.trim() ? `${reporter.name.trim()} <${reporter.email}>` : reporter.email;
+ return `Reported as phishing by ${who} with Bulwark.\r\nThe reported message is attached unchanged.\r\n`;
+}
+
+/** The identity a report goes out from: the login's own address first. */
+export function reporterIdentity(identities: Identity[], username: string): Identity | null {
+ const usable = identities.filter((identity) => identity.email && !identity.email.includes('*'));
+ return sortIdentities(usable, username)[0] ?? null;
+}
+
+export interface ReportContext {
+ /** The login the message is reachable through. */
+ client: IJMAPClient;
+ /** The JMAP account the message lives in (a shared one, for a group mailbox). */
+ sourceAccountId: string;
+ setup: PhishingReportSetup;
+ /** Builds the raw ARF report (lib/phishing-report-arf.ts); needed for format 'arf'. */
+ buildArf?: (input: ArfInput) => Promise;
+}
+
+export interface ArfInput {
+ client: IJMAPClient;
+ email: Email;
+ sourceAccountId: string;
+ reporter: Identity;
+ to: string;
+}
+
+/**
+ * Sends one report for `email`. Throws when nothing went out; resolves once
+ * the server took the submission.
+ */
+export async function sendPhishingReport(email: Email, ctx: ReportContext): Promise {
+ const { client, setup } = ctx;
+ if (!email.blobId) throw new Error('The message has no content to report');
+ const identity = reporterIdentity(await client.getIdentities(), client.getUsername());
+ if (!identity) throw new Error('No sender address to report from');
+
+ if (setup.format === 'arf') {
+ if (!ctx.buildArf) throw new Error('ARF reports are not available');
+ const raw = await ctx.buildArf({ client, email, sourceAccountId: ctx.sourceAccountId, reporter: identity, to: setup.address });
+ // Imported into Drafts and destroyed once submitted: no Sent copy.
+ await client.submitRawEmail(raw, identity.id, undefined, [setup.address]);
+ return;
+ }
+
+ // The original's own blob, attached by id; copied over when the message
+ // lives in another account than the one the report goes out from.
+ const sendAccountId = (await client.resolveSendAccountId?.(identity.email)) ?? client.getAccountId();
+ let blobId = email.blobId;
+ if (ctx.sourceAccountId !== sendAccountId) {
+ const copied = await client.copyBlobs(ctx.sourceAccountId, [blobId], sendAccountId);
+ const copy = copied[blobId];
+ if (!copy) throw new Error('The message could not be copied to the account that sends the report');
+ blobId = copy;
+ }
+ await client.sendEmail(
+ [setup.address],
+ reportSubject(email.subject),
+ reportText(identity),
+ undefined,
+ undefined,
+ identity.id,
+ identity.email,
+ undefined,
+ identity.name || undefined,
+ undefined,
+ [{
+ blobId,
+ name: emailExportFilename(email, { template: '{date}-{subject}' }),
+ type: 'message/rfc822',
+ size: email.size,
+ disposition: 'attachment',
+ }],
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ { noSentCopy: true },
+ );
+}
diff --git a/lib/plugin-sandbox/host-api-forward.ts b/lib/plugin-sandbox/host-api-forward.ts
index 09d2cbb53..98ae6c4ef 100644
--- a/lib/plugin-sandbox/host-api-forward.ts
+++ b/lib/plugin-sandbox/host-api-forward.ts
@@ -11,7 +11,9 @@
// quick reply (external recipients, the organisation's disclaimer) and
// other plugins' onBeforeEmailSend, and is held for the user's undo delay;
// - the copy lands in Sent and the original is marked $forwarded, as after
-// a forward from the composer.
+// a forward from the composer;
+// - it carries the original's classification label, else the default; when
+// the organisation requires one and there is neither, nothing is sent.
import type { EmailForwardResult, InstalledPlugin, OutgoingEmail } from '../plugin-types';
import type { Attachment, Email } from '../jmap/types';
@@ -24,6 +26,9 @@ import { emailHooks } from '../plugin-hooks';
import { announceSent } from '../plugin-after-send';
import { buildForwardSubject } from '../subject-prefix';
import { finalizeOutgoing, runPreSendChecks } from '../outgoing/send-pipeline';
+import { automaticLabel, classifyOutgoing } from '../outgoing/classification';
+import { whenPolicySettled } from '@/stores/policy-store';
+import { toast } from '@/stores/toast-store';
// Registers the organisation signature check and the disclaimer.
import '@/lib/org/outgoing';
import { plainTextFromEmail } from '../plugin-projection';
@@ -139,6 +144,15 @@ export async function doEmailForward(
const messages = await hostMessages();
const subject = buildForwardSubject(email.subject || text(messages, 'email_viewer.no_subject'), text(messages, 'email_composer.prefix.forward'));
+ // 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 }, { headers: email.headers, from: email.from });
+ if (label.missing) {
+ toast.error(text(messages, 'classification.forward_needs_label'));
+ return { sent: false, reason: 'stopped' };
+ }
+
// Ask before the first forward to an address.
const addresses = to.map((entry) => addressOf(entry).toLowerCase());
const account = approvalAccount();
@@ -198,12 +212,15 @@ export async function doEmailForward(
});
}
+ // Last, after the plugins' checks: the label's prefix and headers.
+ const labelled = classifyOutgoing(label.policy, label.label, { subject });
+
const delaySeconds = useSettingsStore.getState().sendDelaySeconds;
const delayedUntil = delaySeconds > 0 && client.hasDelayedSend()
? new Date(Date.now() + delaySeconds * 1000).toISOString()
: undefined;
const result = await useEmailStore.getState().sendEmail(
- client, to, subject, body, undefined, undefined, identity.id, identity.email, undefined,
+ client, to, labelled.subject, body, undefined, undefined, identity.id, identity.email, undefined,
identity.name || undefined, undefined,
attachments.map((a, i) => ({
blobId: blobIds[i],
@@ -212,9 +229,10 @@ export async function doEmailForward(
size: a.size,
disposition: 'attachment' as const,
})),
- undefined, undefined, delayedUntil,
+ undefined, undefined, delayedUntil, undefined,
+ labelled.labelHeaders ? { labelHeaders: labelled.labelHeaders } : undefined,
);
- announceSent(result, { to: addresses, cc: [], bcc: [], subject });
+ announceSent(result, { to: addresses, cc: [], bcc: [], subject: labelled.subject });
try {
await deps.markForwarded(emailId, sourceAccountId);
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index 70b4551e1..4b727dc50 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -3,6 +3,7 @@
// structured-cloneable data back to the iframe.
import type { AppInfoView, InstalledPlugin, Permission } from '../plugin-types';
+import { labelRawSend } from '@/lib/outgoing/classification-raw';
import { fetchConfig } from '@/hooks/use-config';
import { IS_LITE } from '../lite';
import { doAppGetSettings, doAppSearchSettings } from './host-api-app';
@@ -800,6 +801,8 @@ async function doJmapUploadBlob(content: unknown, name: unknown, type: unknown):
interface JmapSubmitRawOptions {
delayedUntil?: string;
envelopeRecipients?: string[];
+ /** The `labelToken` of the onComposeSend request this send completes. */
+ labelToken?: string;
}
/**
@@ -822,7 +825,10 @@ async function doJmapSendRaw(
// ArrayBuffer (not SharedArrayBuffer) — also detaches from the caller's view.
const copy = new Uint8Array(view.byteLength);
copy.set(view);
- const blob = new Blob([copy.buffer], { type: 'message/rfc822' });
+ // The classification label goes into the top-level header block, outside
+ // what the plugin signed or encrypted (lib/outgoing/classification-raw.ts).
+ const labelled = labelRawSend(copy, identityId, opts?.labelToken);
+ const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' });
return useEmailStore.getState().sendRawEmail(
client,
blob,
@@ -856,7 +862,8 @@ async function doJmapSubmitRaw(
const copy = new Uint8Array(view.byteLength);
copy.set(view);
- const blob = new Blob([copy.buffer], { type: 'message/rfc822' });
+ const labelled = labelRawSend(copy, identityId, opts?.labelToken);
+ const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' });
return client.submitRawEmail(
blob,
@@ -2040,12 +2047,12 @@ export async function dispatchApiCall(
case 'jmap.sendRaw': return doJmapSendRaw(
args[0] as ArrayBuffer | ArrayBufferView,
args[1] as string,
- args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
+ args[2] as JmapSubmitRawOptions | undefined,
);
case 'jmap.submitRaw': return doJmapSubmitRaw(
args[0] as ArrayBuffer | ArrayBufferView,
args[1] as string,
- args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
+ args[2] as JmapSubmitRawOptions | undefined,
);
case 'jmap.importRaw': return doJmapImportRaw(
args[0] as ArrayBuffer | ArrayBufferView,
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 4cf40542a..0d3b61d88 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -883,6 +883,35 @@
"send_anyway": "الإرسال على أي حال",
"back": "رجوع"
},
+ "phishing_report": {
+ "action": "الإبلاغ عن تصيّد احتيالي…",
+ "confirm_title": "الإبلاغ عن تصيّد احتيالي؟",
+ "confirm_message": "{count, plural, zero {لا توجد رسائل لإرسالها إلى {address}.} one {ستُرسل الرسالة إلى {address} وتُنقل إلى البريد المزعج.} two {ستُرسل كل من الرسالتين إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.} few {ستُرسل كل رسالة من # رسائل إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.} many {ستُرسل كل رسالة من # رسالة إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.} other {ستُرسل كل رسالة من # رسالة إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.}}",
+ "confirm": "إبلاغ",
+ "cancel": "إلغاء",
+ "sending": "جارٍ الإبلاغ…",
+ "done": "{count, plural, zero {لم تُرسل أي رسالة إلى {address}.} one {تم إبلاغ {address} عن الرسالة ونُقلت إلى البريد المزعج.} two {تم إبلاغ {address} عن رسالتين ونُقلتا إلى البريد المزعج.} few {تم إبلاغ {address} عن # رسائل ونُقلت إلى البريد المزعج.} many {تم إبلاغ {address} عن # رسالة ونُقلت إلى البريد المزعج.} other {تم إبلاغ {address} عن # رسالة ونُقلت إلى البريد المزعج.}}",
+ "not_moved": "{count, plural, zero {تم إبلاغ {address} ونُقلت كل الرسائل إلى البريد المزعج.} one {تم إبلاغ {address}، لكن تعذّر نقل الرسالة إلى البريد المزعج.} two {تم إبلاغ {address}، لكن تعذّر نقل رسالتين إلى البريد المزعج.} few {تم إبلاغ {address}، لكن تعذّر نقل # رسائل إلى البريد المزعج.} many {تم إبلاغ {address}، لكن تعذّر نقل # رسالة إلى البريد المزعج.} other {تم إبلاغ {address}، لكن تعذّر نقل # رسالة إلى البريد المزعج.}}",
+ "failed": "{count, plural, zero {تم الإبلاغ عن كل الرسائل.} one {تعذّر الإبلاغ عن الرسالة.} two {تعذّر الإبلاغ عن رسالتين.} few {تعذّر الإبلاغ عن # رسائل.} many {تعذّر الإبلاغ عن # رسالة.} other {تعذّر الإبلاغ عن # رسالة.}}",
+ "undo": "تراجع",
+ "undone": "{count, plural, zero {تمت الإعادة إلى مكانها.} one {أُعيدت الرسالة إلى مكانها. البلاغ أُرسل بالفعل.} two {أُعيدت الرسالتان إلى مكانهما. البلاغان أُرسلا بالفعل.} few {أُعيدت الرسائل إلى مكانها. البلاغات أُرسلت بالفعل.} many {أُعيدت الرسائل إلى مكانها. البلاغات أُرسلت بالفعل.} other {أُعيدت الرسائل إلى مكانها. البلاغات أُرسلت بالفعل.}}",
+ "undo_failed": "تعذّرت الإعادة إلى المكان السابق.",
+ "banner": "أبلغت عن هذه الرسالة كتصيّد احتيالي. تم تعطيل روابطها.",
+ "link_off": "الرابط معطّل",
+ "link_off_note": "(الرابط معطّل)",
+ "no_subject": "(بلا موضوع)"
+ },
+ "classification": {
+ "picker_label": "التصنيف الأمني",
+ "chip_label": "التصنيف الأمني: {name}",
+ "none": "بدون تصنيف أمني",
+ "pick_required": "اختر تصنيفًا أمنيًا",
+ "required_hint": "تشترط مؤسستك تصنيفًا أمنيًا.",
+ "required_toast": "اختر تصنيفًا أمنيًا قبل الإرسال.",
+ "quick_reply_needs_label": "يحتاج هذا الرد إلى تصنيف أمني. اختر واحدًا هنا، ثم أرسل.",
+ "forward_needs_label": "التصنيف الأمني مطلوب. أعد توجيه الرسالة من محرر الرسائل.",
+ "received_hint": "عيّنه برنامج البريد لدى المرسل. يمكن لأي شخص تعيين تصنيف أمني، لذا فهو لا يثبت شيئًا."
+ },
"email_composer": {
"select_from_directory": "الاختيار من الدليل",
"minimize": "تصغير",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 788b0ccbf..1301700b9 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Envia igualment",
"back": "Enrere"
},
+ "phishing_report": {
+ "action": "Denuncia com a pesca de credencials…",
+ "confirm_title": "Voleu denunciar-ho com a pesca de credencials?",
+ "confirm_message": "{count, plural, one {El missatge s'envia a {address} i es mou al correu brossa.} other {Cadascun dels # missatges s'envia a {address} com a denúncia independent i es mou al correu brossa.}}",
+ "confirm": "Denuncia",
+ "cancel": "Cancel·la",
+ "sending": "S'està denunciant…",
+ "done": "{count, plural, one {S'ha denunciat a {address} i s'ha mogut al correu brossa.} other {S'han denunciat # missatges a {address} i s'han mogut al correu brossa.}}",
+ "not_moved": "{count, plural, one {S'ha denunciat a {address}, però el missatge no s'ha pogut moure al correu brossa.} other {S'ha denunciat a {address}, però # missatges no s'han pogut moure al correu brossa.}}",
+ "failed": "{count, plural, one {No s'ha pogut denunciar el missatge.} other {No s'han pogut denunciar # missatges.}}",
+ "undo": "Desfés",
+ "undone": "{count, plural, one {S'ha tornat al lloc. La denúncia ja s'havia enviat.} other {S'han tornat al lloc. Les denúncies ja s'havien enviat.}}",
+ "undo_failed": "No s'ha pogut tornar al lloc.",
+ "banner": "Heu denunciat aquest missatge com a pesca de credencials. Els enllaços estan desactivats.",
+ "link_off": "enllaç desactivat",
+ "link_off_note": "(enllaç desactivat)",
+ "no_subject": "(sense assumpte)"
+ },
+ "classification": {
+ "picker_label": "Classificació",
+ "chip_label": "Classificació: {name}",
+ "none": "Sense classificació",
+ "pick_required": "Trieu una classificació",
+ "required_hint": "La vostra organització exigeix una classificació.",
+ "required_toast": "Trieu una classificació abans d'enviar.",
+ "quick_reply_needs_label": "Aquesta resposta necessita una classificació. Trieu-ne una aquí i després envieu-la.",
+ "forward_needs_label": "Cal una classificació. Reenvieu el missatge des del redactor.",
+ "received_hint": "La defineix el programa de correu del remitent. Qualsevol pot definir una classificació, així que no demostra res."
+ },
"email_composer": {
"select_from_directory": "Selecciona del directori",
"minimize": "Minimitza",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 1169c5353..3d7cbfdfd 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Přesto odeslat",
"back": "Zpět"
},
+ "phishing_report": {
+ "action": "Nahlásit phishing…",
+ "confirm_title": "Nahlásit phishing?",
+ "confirm_message": "{count, plural, one {Zpráva bude odeslána na {address} a přesunuta do složky Spam.} few {# zprávy budou odeslány na {address}, každá jako samostatné hlášení, a přesunuty do složky Spam.} many {# zprávy bude odesláno na {address}, každá jako samostatné hlášení, a přesunuto do složky Spam.} other {# zpráv bude odesláno na {address}, každá jako samostatné hlášení, a přesunuto do složky Spam.}}",
+ "confirm": "Nahlásit",
+ "cancel": "Zrušit",
+ "sending": "Nahlašování…",
+ "done": "{count, plural, one {Nahlášeno na {address} a přesunuto do složky Spam.} few {# zprávy nahlášeny na {address} a přesunuty do složky Spam.} many {# zprávy nahlášeno na {address} a přesunuto do složky Spam.} other {# zpráv nahlášeno na {address} a přesunuto do složky Spam.}}",
+ "not_moved": "{count, plural, one {Nahlášeno na {address}, ale zprávu se nepodařilo přesunout do složky Spam.} few {Nahlášeno na {address}, ale # zprávy se nepodařilo přesunout do složky Spam.} many {Nahlášeno na {address}, ale # zprávy se nepodařilo přesunout do složky Spam.} other {Nahlášeno na {address}, ale # zpráv se nepodařilo přesunout do složky Spam.}}",
+ "failed": "{count, plural, one {Zprávu se nepodařilo nahlásit.} few {# zprávy se nepodařilo nahlásit.} many {# zprávy se nepodařilo nahlásit.} other {# zpráv se nepodařilo nahlásit.}}",
+ "undo": "Zpět",
+ "undone": "{count, plural, one {Vráceno zpět. Hlášení už bylo odesláno.} few {Vráceno zpět. Hlášení už byla odeslána.} many {Vráceno zpět. Hlášení už byla odeslána.} other {Vráceno zpět. Hlášení už byla odeslána.}}",
+ "undo_failed": "Nepodařilo se vrátit zpět.",
+ "banner": "Tuto zprávu jste nahlásili jako phishing. Odkazy v ní jsou vypnuté.",
+ "link_off": "odkaz vypnut",
+ "link_off_note": "(odkaz vypnut)",
+ "no_subject": "(bez předmětu)"
+ },
+ "classification": {
+ "picker_label": "Klasifikace",
+ "chip_label": "Klasifikace: {name}",
+ "none": "Bez klasifikace",
+ "pick_required": "Vyberte klasifikaci",
+ "required_hint": "Vaše organizace vyžaduje klasifikaci.",
+ "required_toast": "Před odesláním vyberte klasifikaci.",
+ "quick_reply_needs_label": "Tato odpověď potřebuje klasifikaci. Vyberte ji zde a pak odešlete.",
+ "forward_needs_label": "Klasifikace je povinná. Přepošlete zprávu z editoru zpráv.",
+ "received_hint": "Nastavil ji poštovní program odesílatele. Klasifikaci může nastavit kdokoli, takže nic nedokazuje."
+ },
"email_composer": {
"select_from_directory": "Vybrat z adresáře",
"minimize": "Minimalizovat",
diff --git a/locales/da/common.json b/locales/da/common.json
index 587317f1e..2dfb3fa80 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Send alligevel",
"back": "Tilbage"
},
+ "phishing_report": {
+ "action": "Rapportér phishing…",
+ "confirm_title": "Rapportér phishing?",
+ "confirm_message": "{count, plural, one {Beskeden sendes til {address} og flyttes til Spam.} other {Hver af de # beskeder sendes til {address} som en separat rapport og flyttes til Spam.}}",
+ "confirm": "Rapportér",
+ "cancel": "Annuller",
+ "sending": "Rapporterer…",
+ "done": "{count, plural, one {Rapporteret til {address} og flyttet til Spam.} other {# beskeder rapporteret til {address} og flyttet til Spam.}}",
+ "not_moved": "{count, plural, one {Rapporteret til {address}, men beskeden kunne ikke flyttes til Spam.} other {Rapporteret til {address}, men # beskeder kunne ikke flyttes til Spam.}}",
+ "failed": "{count, plural, one {Beskeden kunne ikke rapporteres.} other {# beskeder kunne ikke rapporteres.}}",
+ "undo": "Fortryd",
+ "undone": "{count, plural, one {Flyttet tilbage. Rapporten var allerede sendt.} other {Flyttet tilbage. Rapporterne var allerede sendt.}}",
+ "undo_failed": "Kunne ikke flyttes tilbage.",
+ "banner": "Du har rapporteret denne besked som phishing. Links i den er slået fra.",
+ "link_off": "link slået fra",
+ "link_off_note": "(link slået fra)",
+ "no_subject": "(intet emne)"
+ },
+ "classification": {
+ "picker_label": "Klassificering",
+ "chip_label": "Klassificering: {name}",
+ "none": "Ingen klassificering",
+ "pick_required": "Vælg en klassificering",
+ "required_hint": "Din organisation kræver en klassificering.",
+ "required_toast": "Vælg en klassificering, før du sender.",
+ "quick_reply_needs_label": "Dette svar skal have en klassificering. Vælg en her, og send derefter.",
+ "forward_needs_label": "En klassificering er påkrævet. Videresend beskeden fra skrivevinduet.",
+ "received_hint": "Sat af afsenderens mailprogram. Alle kan sætte en klassificering, så den beviser intet."
+ },
"email_composer": {
"select_from_directory": "Vælg fra adressefortegnelse",
"minimize": "Minimer",
diff --git a/locales/de/common.json b/locales/de/common.json
index 5d939cba5..99226d759 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Trotzdem senden",
"back": "Zurück"
},
+ "phishing_report": {
+ "action": "Phishing melden…",
+ "confirm_title": "Phishing melden?",
+ "confirm_message": "{count, plural, one {Die Nachricht geht an {address} und wird in den Spam-Ordner verschoben.} other {Jede der # Nachrichten geht als eigene Meldung an {address} und wird in den Spam-Ordner verschoben.}}",
+ "confirm": "Melden",
+ "cancel": "Abbrechen",
+ "sending": "Wird gemeldet…",
+ "done": "{count, plural, one {An {address} gemeldet und in den Spam-Ordner verschoben.} other {# Nachrichten an {address} gemeldet und in den Spam-Ordner verschoben.}}",
+ "not_moved": "{count, plural, one {An {address} gemeldet, aber die Nachricht konnte nicht in den Spam-Ordner verschoben werden.} other {An {address} gemeldet, aber # Nachrichten konnten nicht in den Spam-Ordner verschoben werden.}}",
+ "failed": "{count, plural, one {Die Nachricht konnte nicht gemeldet werden.} other {# Nachrichten konnten nicht gemeldet werden.}}",
+ "undo": "Rückgängig",
+ "undone": "{count, plural, one {Zurückverschoben. Die Meldung wurde bereits gesendet.} other {Zurückverschoben. Die Meldungen wurden bereits gesendet.}}",
+ "undo_failed": "Konnte nicht zurückverschoben werden.",
+ "banner": "Sie haben diese Nachricht als Phishing gemeldet. Die Links darin sind deaktiviert.",
+ "link_off": "Link deaktiviert",
+ "link_off_note": "(Link deaktiviert)",
+ "no_subject": "(kein Betreff)"
+ },
+ "classification": {
+ "picker_label": "Einstufung",
+ "chip_label": "Einstufung: {name}",
+ "none": "Keine Einstufung",
+ "pick_required": "Einstufung wählen",
+ "required_hint": "Ihre Organisation verlangt eine Einstufung.",
+ "required_toast": "Wählen Sie vor dem Senden eine Einstufung.",
+ "quick_reply_needs_label": "Diese Antwort braucht eine Einstufung. Wählen Sie sie hier aus und senden Sie dann.",
+ "forward_needs_label": "Eine Einstufung ist vorgeschrieben. Leiten Sie die Nachricht im Editor weiter.",
+ "received_hint": "Vom E-Mail-Programm des Absenders gesetzt. Jeder kann eine Einstufung setzen, sie beweist also nichts."
+ },
"email_composer": {
"select_from_directory": "Aus Verzeichnis auswählen",
"minimize": "Minimieren",
diff --git a/locales/en/common.json b/locales/en/common.json
index 5f5507dfe..af8c1afdd 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Send anyway",
"back": "Back"
},
+ "phishing_report": {
+ "action": "Report phishing…",
+ "confirm_title": "Report phishing?",
+ "confirm_message": "{count, plural, one {The message goes to {address} and moves to Junk.} other {Each of the # messages goes to {address} as its own report and moves to Junk.}}",
+ "confirm": "Report",
+ "cancel": "Cancel",
+ "sending": "Reporting…",
+ "done": "{count, plural, one {Reported to {address} and moved to Junk.} other {# messages reported to {address} and moved to Junk.}}",
+ "not_moved": "{count, plural, one {Reported to {address}, but the message couldn't be moved to Junk.} other {Reported to {address}, but # messages couldn't be moved to Junk.}}",
+ "failed": "{count, plural, one {The message couldn't be reported.} other {# messages couldn't be reported.}}",
+ "undo": "Undo",
+ "undone": "{count, plural, one {Moved back. The report was already sent.} other {Moved back. The reports were already sent.}}",
+ "undo_failed": "Couldn't move it back.",
+ "banner": "You reported this message as phishing. Its links are turned off.",
+ "link_off": "link turned off",
+ "link_off_note": "(link turned off)",
+ "no_subject": "(no subject)"
+ },
+ "classification": {
+ "picker_label": "Classification",
+ "chip_label": "Classification: {name}",
+ "none": "No label",
+ "pick_required": "Pick a label",
+ "required_hint": "Your organisation requires a label.",
+ "required_toast": "Pick a classification label before sending.",
+ "quick_reply_needs_label": "This reply needs a classification label. Pick one here, then send.",
+ "forward_needs_label": "A classification label is required. Forward the message from the composer.",
+ "received_hint": "Set by the sender's mail program. Anyone can set a label, so it proves nothing."
+ },
"email_composer": {
"select_from_directory": "Select from directory",
"minimize": "Minimize",
diff --git a/locales/es/common.json b/locales/es/common.json
index 9882934e1..dd2838d92 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Enviar de todos modos",
"back": "Atrás"
},
+ "phishing_report": {
+ "action": "Reportar phishing…",
+ "confirm_title": "¿Reportar phishing?",
+ "confirm_message": "{count, plural, one {El mensaje se enviará a {address} y se moverá a Spam.} other {Cada uno de los # mensajes se enviará a {address} como un reporte aparte y se moverá a Spam.}}",
+ "confirm": "Reportar",
+ "cancel": "Cancelar",
+ "sending": "Reportando…",
+ "done": "{count, plural, one {Reportado a {address} y movido a Spam.} other {# mensajes reportados a {address} y movidos a Spam.}}",
+ "not_moved": "{count, plural, one {Reportado a {address}, pero el mensaje no se pudo mover a Spam.} other {Reportados a {address}, pero # mensajes no se pudieron mover a Spam.}}",
+ "failed": "{count, plural, one {No se pudo reportar el mensaje.} other {No se pudieron reportar # mensajes.}}",
+ "undo": "Deshacer",
+ "undone": "{count, plural, one {Devuelto a su sitio. El reporte ya se había enviado.} other {Devueltos a su sitio. Los reportes ya se habían enviado.}}",
+ "undo_failed": "No se pudo devolver a su sitio.",
+ "banner": "Has reportado este mensaje como phishing. Sus enlaces están desactivados.",
+ "link_off": "enlace desactivado",
+ "link_off_note": "(enlace desactivado)",
+ "no_subject": "(sin asunto)"
+ },
+ "classification": {
+ "picker_label": "Clasificación",
+ "chip_label": "Clasificación: {name}",
+ "none": "Sin clasificación",
+ "pick_required": "Elige una clasificación",
+ "required_hint": "Tu organización exige una clasificación.",
+ "required_toast": "Elige una clasificación antes de enviar.",
+ "quick_reply_needs_label": "Esta respuesta necesita una clasificación. Elige una aquí y luego envía.",
+ "forward_needs_label": "Se requiere una clasificación. Reenvía el mensaje desde el redactor.",
+ "received_hint": "La pone el programa de correo del remitente. Cualquiera puede poner una clasificación, así que no demuestra nada."
+ },
"email_composer": {
"select_from_directory": "Seleccionar del directorio",
"minimize": "Minimizar",
diff --git a/locales/et/common.json b/locales/et/common.json
index 39bbc270f..9c29e3278 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Saada ikkagi",
"back": "Tagasi"
},
+ "phishing_report": {
+ "action": "Teata õngitsusest…",
+ "confirm_title": "Teatada õngitsusest?",
+ "confirm_message": "{count, plural, one {Kiri saadetakse aadressile {address} ja teisaldatakse rämpsposti.} other {Kõik # kirja saadetakse aadressile {address} eraldi teadetena ja teisaldatakse rämpsposti.}}",
+ "confirm": "Teata",
+ "cancel": "Tühista",
+ "sending": "Teatamine…",
+ "done": "{count, plural, one {Teatatud aadressile {address} ja teisaldatud rämpsposti.} other {# kirjast teatatud aadressile {address} ja teisaldatud rämpsposti.}}",
+ "not_moved": "{count, plural, one {Teatatud aadressile {address}, kuid kirja ei õnnestunud rämpsposti teisaldada.} other {Teatatud aadressile {address}, kuid # kirja ei õnnestunud rämpsposti teisaldada.}}",
+ "failed": "{count, plural, one {Kirjast ei õnnestunud teatada.} other {# kirjast ei õnnestunud teatada.}}",
+ "undo": "Võta tagasi",
+ "undone": "{count, plural, one {Tõstetud tagasi. Teade oli juba saadetud.} other {Tõstetud tagasi. Teated olid juba saadetud.}}",
+ "undo_failed": "Tagasi tõstmine ei õnnestunud.",
+ "banner": "Teatasid sellest kirjast kui õngitsusest. Selle lingid on välja lülitatud.",
+ "link_off": "link välja lülitatud",
+ "link_off_note": "(link välja lülitatud)",
+ "no_subject": "(teema puudub)"
+ },
+ "classification": {
+ "picker_label": "Klassifikatsioon",
+ "chip_label": "Klassifikatsioon: {name}",
+ "none": "Klassifikatsioonita",
+ "pick_required": "Vali klassifikatsioon",
+ "required_hint": "Sinu organisatsioon nõuab klassifikatsiooni.",
+ "required_toast": "Vali enne saatmist klassifikatsioon.",
+ "quick_reply_needs_label": "See vastus vajab klassifikatsiooni. Vali see siin ja saada siis.",
+ "forward_needs_label": "Klassifikatsioon on nõutud. Saada kiri edasi kirjakoostajast.",
+ "received_hint": "Selle määras saatja meiliprogramm. Klassifikatsiooni võib määrata igaüks, seega ei tõesta see midagi."
+ },
"email_composer": {
"select_from_directory": "Vali kataloogist",
"minimize": "Minimeeri",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index ad8328a19..83e789a49 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -883,6 +883,35 @@
"send_anyway": "ارسال به هر حال",
"back": "بازگشت"
},
+ "phishing_report": {
+ "action": "گزارش فیشینگ…",
+ "confirm_title": "بهعنوان فیشینگ گزارش شود؟",
+ "confirm_message": "{count, plural, one {پیام به {address} فرستاده و به هرزنامه منتقل میشود.} other {هر یک از # پیام بهصورت گزارشی جداگانه به {address} فرستاده و به هرزنامه منتقل میشود.}}",
+ "confirm": "گزارش",
+ "cancel": "انصراف",
+ "sending": "در حال گزارش…",
+ "done": "{count, plural, one {به {address} گزارش و به هرزنامه منتقل شد.} other {# پیام به {address} گزارش و به هرزنامه منتقل شد.}}",
+ "not_moved": "{count, plural, one {به {address} گزارش شد، اما پیام به هرزنامه منتقل نشد.} other {به {address} گزارش شد، اما # پیام به هرزنامه منتقل نشد.}}",
+ "failed": "{count, plural, one {گزارش پیام ممکن نشد.} other {گزارش # پیام ممکن نشد.}}",
+ "undo": "بازگردانی",
+ "undone": "{count, plural, one {به جای قبلی برگردانده شد. گزارش قبلاً فرستاده شده است.} other {به جای قبلی برگردانده شدند. گزارشها قبلاً فرستاده شدهاند.}}",
+ "undo_failed": "برگرداندن ممکن نشد.",
+ "banner": "شما این پیام را بهعنوان فیشینگ گزارش کردید. پیوندهای آن غیرفعال شدهاند.",
+ "link_off": "پیوند غیرفعال شد",
+ "link_off_note": "(پیوند غیرفعال شد)",
+ "no_subject": "(بدون موضوع)"
+ },
+ "classification": {
+ "picker_label": "طبقهبندی",
+ "chip_label": "طبقهبندی: {name}",
+ "none": "بدون طبقهبندی",
+ "pick_required": "انتخاب طبقهبندی",
+ "required_hint": "سازمان شما طبقهبندی را الزامی کرده است.",
+ "required_toast": "پیش از ارسال یک طبقهبندی انتخاب کنید.",
+ "quick_reply_needs_label": "این پاسخ به طبقهبندی نیاز دارد. همینجا یکی انتخاب کنید و سپس ارسال کنید.",
+ "forward_needs_label": "طبقهبندی الزامی است. پیام را از ویرایشگر ارسال مجدد کنید.",
+ "received_hint": "برنامه ایمیل فرستنده آن را تعیین کرده است. هر کسی میتواند طبقهبندی تعیین کند، پس چیزی را ثابت نمیکند."
+ },
"email_composer": {
"select_from_directory": "انتخاب از فهرست",
"minimize": "کوچک کردن",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index bd0edd0a4..fe68ab68e 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Envoyer quand même",
"back": "Retour"
},
+ "phishing_report": {
+ "action": "Signaler comme hameçonnage…",
+ "confirm_title": "Signaler comme hameçonnage ?",
+ "confirm_message": "{count, plural, one {Le message sera envoyé à {address} et déplacé dans le dossier Spam.} other {Chacun des # messages sera envoyé à {address} dans un signalement distinct et déplacé dans le dossier Spam.}}",
+ "confirm": "Signaler",
+ "cancel": "Annuler",
+ "sending": "Signalement en cours…",
+ "done": "{count, plural, one {Signalé à {address} et déplacé dans le dossier Spam.} other {# messages signalés à {address} et déplacés dans le dossier Spam.}}",
+ "not_moved": "{count, plural, one {Signalé à {address}, mais le message n'a pas pu être déplacé dans le dossier Spam.} other {Signalés à {address}, mais # messages n'ont pas pu être déplacés dans le dossier Spam.}}",
+ "failed": "{count, plural, one {Le message n'a pas pu être signalé.} other {# messages n'ont pas pu être signalés.}}",
+ "undo": "Annuler",
+ "undone": "{count, plural, one {Remis à sa place. Le signalement a déjà été envoyé.} other {Remis à leur place. Les signalements ont déjà été envoyés.}}",
+ "undo_failed": "Impossible de le remettre à sa place.",
+ "banner": "Vous avez signalé ce message comme hameçonnage. Ses liens sont désactivés.",
+ "link_off": "lien désactivé",
+ "link_off_note": "(lien désactivé)",
+ "no_subject": "(sans objet)"
+ },
+ "classification": {
+ "picker_label": "Classification",
+ "chip_label": "Classification : {name}",
+ "none": "Aucune classification",
+ "pick_required": "Choisir une classification",
+ "required_hint": "Votre organisation exige une classification.",
+ "required_toast": "Choisissez une classification avant d'envoyer.",
+ "quick_reply_needs_label": "Cette réponse nécessite une classification. Choisissez-en une ici, puis envoyez.",
+ "forward_needs_label": "Une classification est obligatoire. Transférez le message depuis l'éditeur.",
+ "received_hint": "Définie par le programme de messagerie de l'expéditeur. N'importe qui peut définir une classification, elle ne prouve donc rien."
+ },
"email_composer": {
"select_from_directory": "Choisir dans l'annuaire",
"minimize": "Réduire",
diff --git a/locales/he/common.json b/locales/he/common.json
index 4eaa76960..78919a882 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -848,6 +848,35 @@
"send_anyway": "שלח בכל זאת",
"back": "חזרה"
},
+ "phishing_report": {
+ "action": "דווח על פישינג…",
+ "confirm_title": "לדווח על פישינג?",
+ "confirm_message": "{count, plural, one {ההודעה תישלח אל {address} ותועבר לספאם.} two {כל אחת משתי ההודעות תישלח אל {address} כדיווח נפרד ותועבר לספאם.} other {כל אחת מ-# ההודעות תישלח אל {address} כדיווח נפרד ותועבר לספאם.}}",
+ "confirm": "דווח",
+ "cancel": "ביטול",
+ "sending": "שולח דיווח…",
+ "done": "{count, plural, one {ההודעה דווחה אל {address} והועברה לספאם.} two {שתי הודעות דווחו אל {address} והועברו לספאם.} other {# הודעות דווחו אל {address} והועברו לספאם.}}",
+ "not_moved": "{count, plural, one {דווח אל {address}, אבל לא ניתן היה להעביר את ההודעה לספאם.} two {דווח אל {address}, אבל לא ניתן היה להעביר שתי הודעות לספאם.} other {דווח אל {address}, אבל לא ניתן היה להעביר # הודעות לספאם.}}",
+ "failed": "{count, plural, one {לא ניתן היה לדווח על ההודעה.} two {לא ניתן היה לדווח על שתי הודעות.} other {לא ניתן היה לדווח על # הודעות.}}",
+ "undo": "בטל",
+ "undone": "{count, plural, one {הוחזרה למקומה. הדיווח כבר נשלח.} two {הוחזרו למקומן. הדיווחים כבר נשלחו.} other {הוחזרו למקומן. הדיווחים כבר נשלחו.}}",
+ "undo_failed": "לא ניתן היה להחזיר למקום.",
+ "banner": "דיווחת על ההודעה הזו כפישינג. הקישורים שבה מושבתים.",
+ "link_off": "הקישור מושבת",
+ "link_off_note": "(הקישור מושבת)",
+ "no_subject": "(ללא נושא)"
+ },
+ "classification": {
+ "picker_label": "סיווג",
+ "chip_label": "סיווג: {name}",
+ "none": "ללא סיווג",
+ "pick_required": "בחירת סיווג",
+ "required_hint": "הארגון שלך מחייב סיווג.",
+ "required_toast": "יש לבחור סיווג לפני השליחה.",
+ "quick_reply_needs_label": "התשובה הזו צריכה סיווג. יש לבחור סיווג כאן ואז לשלוח.",
+ "forward_needs_label": "נדרש סיווג. יש להעביר את ההודעה מהעורך.",
+ "received_hint": "נקבע על ידי תוכנת הדואר של השולח. כל אחד יכול לקבוע סיווג, ולכן הוא לא מוכיח דבר."
+ },
"email_composer": {
"select_from_directory": "בחירה מהספרייה",
"minimize": "מזעור",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index ba382be6a..ca54bcb2c 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Küldés így is",
"back": "Vissza"
},
+ "phishing_report": {
+ "action": "Adathalászat jelentése…",
+ "confirm_title": "Jelented adathalászatként?",
+ "confirm_message": "{count, plural, one {Az üzenet a(z) {address} címre megy, és a Spam mappába kerül.} other {Mind a(z) # üzenet külön jelentésként megy a(z) {address} címre, és a Spam mappába kerül.}}",
+ "confirm": "Jelentés",
+ "cancel": "Mégse",
+ "sending": "Jelentés folyamatban…",
+ "done": "{count, plural, one {Jelentve a(z) {address} címre, és áthelyezve a Spam mappába.} other {# üzenet jelentve a(z) {address} címre, és áthelyezve a Spam mappába.}}",
+ "not_moved": "{count, plural, one {Jelentve a(z) {address} címre, de az üzenetet nem sikerült áthelyezni a Spam mappába.} other {Jelentve a(z) {address} címre, de # üzenetet nem sikerült áthelyezni a Spam mappába.}}",
+ "failed": "{count, plural, one {Az üzenetet nem sikerült jelenteni.} other {# üzenetet nem sikerült jelenteni.}}",
+ "undo": "Visszavonás",
+ "undone": "{count, plural, one {Visszahelyezve. A jelentés már elment.} other {Visszahelyezve. A jelentések már elmentek.}}",
+ "undo_failed": "Nem sikerült visszahelyezni.",
+ "banner": "Ezt az üzenetet adathalászatként jelentetted. A benne lévő linkek ki vannak kapcsolva.",
+ "link_off": "link kikapcsolva",
+ "link_off_note": "(link kikapcsolva)",
+ "no_subject": "(nincs tárgy)"
+ },
+ "classification": {
+ "picker_label": "Besorolás",
+ "chip_label": "Besorolás: {name}",
+ "none": "Nincs besorolás",
+ "pick_required": "Válassz besorolást",
+ "required_hint": "A szervezeted besorolást ír elő.",
+ "required_toast": "Küldés előtt válassz besorolást.",
+ "quick_reply_needs_label": "Ehhez a válaszhoz besorolás kell. Itt válassz egyet, aztán küldd el.",
+ "forward_needs_label": "A besorolás kötelező. Az üzenetet a szerkesztőből továbbítsd.",
+ "received_hint": "A feladó levelezőprogramja állította be. Bárki megadhat besorolást, így semmit sem bizonyít."
+ },
"email_composer": {
"select_from_directory": "Kiválasztás a címtárból",
"minimize": "Kis méret",
diff --git a/locales/it/common.json b/locales/it/common.json
index 337d6c200..918f8d378 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Invia comunque",
"back": "Indietro"
},
+ "phishing_report": {
+ "action": "Segnala come phishing…",
+ "confirm_title": "Segnalare come phishing?",
+ "confirm_message": "{count, plural, one {Il messaggio viene inviato a {address} e spostato in Spam.} other {Ognuno dei # messaggi viene inviato a {address} come segnalazione separata e spostato in Spam.}}",
+ "confirm": "Segnala",
+ "cancel": "Annulla",
+ "sending": "Segnalazione in corso…",
+ "done": "{count, plural, one {Segnalato a {address} e spostato in Spam.} other {# messaggi segnalati a {address} e spostati in Spam.}}",
+ "not_moved": "{count, plural, one {Segnalato a {address}, ma non è stato possibile spostare il messaggio in Spam.} other {Segnalati a {address}, ma non è stato possibile spostare # messaggi in Spam.}}",
+ "failed": "{count, plural, one {Non è stato possibile segnalare il messaggio.} other {Non è stato possibile segnalare # messaggi.}}",
+ "undo": "Annulla",
+ "undone": "{count, plural, one {Rimesso al suo posto. La segnalazione era già stata inviata.} other {Rimessi al loro posto. Le segnalazioni erano già state inviate.}}",
+ "undo_failed": "Impossibile rimetterlo al suo posto.",
+ "banner": "Hai segnalato questo messaggio come phishing. I suoi link sono disattivati.",
+ "link_off": "link disattivato",
+ "link_off_note": "(link disattivato)",
+ "no_subject": "(nessun oggetto)"
+ },
+ "classification": {
+ "picker_label": "Classificazione",
+ "chip_label": "Classificazione: {name}",
+ "none": "Nessuna classificazione",
+ "pick_required": "Scegli una classificazione",
+ "required_hint": "La tua organizzazione richiede una classificazione.",
+ "required_toast": "Scegli una classificazione prima di inviare.",
+ "quick_reply_needs_label": "Questa risposta richiede una classificazione. Sceglila qui, poi invia.",
+ "forward_needs_label": "È richiesta una classificazione. Inoltra il messaggio dall'editor.",
+ "received_hint": "Impostata dal programma di posta del mittente. Chiunque può impostare una classificazione, quindi non prova nulla."
+ },
"email_composer": {
"select_from_directory": "Seleziona dalla rubrica aziendale",
"minimize": "Riduci a icona",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index ae2774b8f..abc5b5956 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -883,6 +883,35 @@
"send_anyway": "そのまま送信",
"back": "戻る"
},
+ "phishing_report": {
+ "action": "フィッシングを報告…",
+ "confirm_title": "フィッシングとして報告しますか?",
+ "confirm_message": "{count, plural, =1 {メールを {address} に送信し、迷惑メールに移動します。} other {# 件のメールをそれぞれ個別の報告として {address} に送信し、迷惑メールに移動します。}}",
+ "confirm": "報告",
+ "cancel": "キャンセル",
+ "sending": "報告中…",
+ "done": "{count, plural, =1 {{address} に報告し、迷惑メールに移動しました。} other {# 件のメールを {address} に報告し、迷惑メールに移動しました。}}",
+ "not_moved": "{count, plural, =1 {{address} に報告しましたが、メールを迷惑メールに移動できませんでした。} other {{address} に報告しましたが、# 件のメールを迷惑メールに移動できませんでした。}}",
+ "failed": "{count, plural, =1 {メールを報告できませんでした。} other {# 件のメールを報告できませんでした。}}",
+ "undo": "元に戻す",
+ "undone": "{count, plural, =1 {元の場所に戻しました。報告はすでに送信済みです。} other {元の場所に戻しました。報告はすでに送信済みです。}}",
+ "undo_failed": "元に戻せませんでした。",
+ "banner": "このメールをフィッシングとして報告しました。メール内のリンクは無効になっています。",
+ "link_off": "リンクは無効です",
+ "link_off_note": "(リンク無効)",
+ "no_subject": "(件名なし)"
+ },
+ "classification": {
+ "picker_label": "機密区分",
+ "chip_label": "機密区分:{name}",
+ "none": "機密区分なし",
+ "pick_required": "機密区分を選択",
+ "required_hint": "組織では機密区分の指定が必要です。",
+ "required_toast": "送信する前に機密区分を選択してください。",
+ "quick_reply_needs_label": "この返信には機密区分が必要です。ここで選択してから送信してください。",
+ "forward_needs_label": "機密区分の指定が必要です。メール作成画面から転送してください。",
+ "received_hint": "送信者のメールプログラムが設定したものです。機密区分は誰でも設定できるため、何の証明にもなりません。"
+ },
"email_composer": {
"select_from_directory": "ディレクトリから選択",
"minimize": "最小化",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index c0c174b28..c9faa5ecf 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -883,6 +883,35 @@
"send_anyway": "그래도 보내기",
"back": "뒤로"
},
+ "phishing_report": {
+ "action": "피싱 신고…",
+ "confirm_title": "피싱으로 신고할까요?",
+ "confirm_message": "{count, plural, =1 {메시지를 {address} 주소로 보내고 스팸함으로 옮겨요.} other {메시지 #개를 각각 별도의 신고로 {address} 주소로 보내고 스팸함으로 옮겨요.}}",
+ "confirm": "신고",
+ "cancel": "취소",
+ "sending": "신고하는 중…",
+ "done": "{count, plural, =1 {{address} 주소로 신고하고 스팸함으로 옮겼어요.} other {메시지 #개를 {address} 주소로 신고하고 스팸함으로 옮겼어요.}}",
+ "not_moved": "{count, plural, =1 {{address} 주소로 신고했지만 메시지를 스팸함으로 옮기지 못했어요.} other {{address} 주소로 신고했지만 메시지 #개를 스팸함으로 옮기지 못했어요.}}",
+ "failed": "{count, plural, =1 {메시지를 신고하지 못했어요.} other {메시지 #개를 신고하지 못했어요.}}",
+ "undo": "실행 취소",
+ "undone": "{count, plural, =1 {원래 위치로 되돌렸어요. 신고는 이미 보내졌어요.} other {원래 위치로 되돌렸어요. 신고는 이미 보내졌어요.}}",
+ "undo_failed": "되돌리지 못했어요.",
+ "banner": "이 메시지를 피싱으로 신고했어요. 메시지의 링크는 꺼져 있어요.",
+ "link_off": "링크 꺼짐",
+ "link_off_note": "(링크 꺼짐)",
+ "no_subject": "(제목 없음)"
+ },
+ "classification": {
+ "picker_label": "보안 등급",
+ "chip_label": "보안 등급: {name}",
+ "none": "보안 등급 없음",
+ "pick_required": "보안 등급 선택",
+ "required_hint": "조직에서 보안 등급을 지정하도록 요구해요.",
+ "required_toast": "보내기 전에 보안 등급을 선택하세요.",
+ "quick_reply_needs_label": "이 답장에는 보안 등급이 필요해요. 여기서 선택한 다음 보내세요.",
+ "forward_needs_label": "보안 등급이 필요해요. 메일 쓰기 창에서 메시지를 전달하세요.",
+ "received_hint": "보낸 사람의 메일 프로그램이 지정한 값이에요. 누구나 보안 등급을 지정할 수 있어서 아무것도 증명하지 않아요."
+ },
"email_composer": {
"select_from_directory": "디렉터리에서 선택",
"minimize": "최소화",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index aa9237d5a..c3f93c784 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Sūtīt tik un tā",
"back": "Atpakaļ"
},
+ "phishing_report": {
+ "action": "Ziņot par pikšķerēšanu…",
+ "confirm_title": "Ziņot par pikšķerēšanu?",
+ "confirm_message": "{count, plural, =1 {Ziņojums tiks nosūtīts uz {address} un pārvietots uz mapi Mēstules.} zero {# ziņojumi tiks nosūtīti uz {address}, katrs atsevišķi, un pārvietoti uz mapi Mēstules.} one {# ziņojums tiks nosūtīts uz {address}, katrs atsevišķi, un pārvietots uz mapi Mēstules.} other {# ziņojumi tiks nosūtīti uz {address}, katrs atsevišķi, un pārvietoti uz mapi Mēstules.}}",
+ "confirm": "Ziņot",
+ "cancel": "Atcelt",
+ "sending": "Notiek ziņošana…",
+ "done": "{count, plural, =1 {Nosūtīts uz {address} un pārvietots uz mapi Mēstules.} zero {# ziņojumi nosūtīti uz {address} un pārvietoti uz mapi Mēstules.} one {# ziņojums nosūtīts uz {address} un pārvietots uz mapi Mēstules.} other {# ziņojumi nosūtīti uz {address} un pārvietoti uz mapi Mēstules.}}",
+ "not_moved": "{count, plural, =1 {Nosūtīts uz {address}, bet ziņojumu neizdevās pārvietot uz mapi Mēstules.} zero {Nosūtīti uz {address}, bet # ziņojumus neizdevās pārvietot uz mapi Mēstules.} one {Nosūtīti uz {address}, bet # ziņojumu neizdevās pārvietot uz mapi Mēstules.} other {Nosūtīti uz {address}, bet # ziņojumus neizdevās pārvietot uz mapi Mēstules.}}",
+ "failed": "{count, plural, =1 {Neizdevās ziņot par ziņojumu.} zero {Neizdevās ziņot par # ziņojumiem.} one {Neizdevās ziņot par # ziņojumu.} other {Neizdevās ziņot par # ziņojumiem.}}",
+ "undo": "Atsaukt",
+ "undone": "{count, plural, =1 {Pārvietots atpakaļ. Ziņa par pikšķerēšanu jau ir nosūtīta.} zero {Pārvietoti atpakaļ. Ziņas par pikšķerēšanu jau ir nosūtītas.} one {Pārvietoti atpakaļ. Ziņas par pikšķerēšanu jau ir nosūtītas.} other {Pārvietoti atpakaļ. Ziņas par pikšķerēšanu jau ir nosūtītas.}}",
+ "undo_failed": "Neizdevās pārvietot atpakaļ.",
+ "banner": "Jūs ziņojāt par šo ziņojumu kā par pikšķerēšanu. Tā saites ir izslēgtas.",
+ "link_off": "saite izslēgta",
+ "link_off_note": "(saite izslēgta)",
+ "no_subject": "(bez temata)"
+ },
+ "classification": {
+ "picker_label": "Klasifikācija",
+ "chip_label": "Klasifikācija: {name}",
+ "none": "Bez klasifikācijas",
+ "pick_required": "Izvēlieties klasifikāciju",
+ "required_hint": "Jūsu organizācija pieprasa klasifikāciju.",
+ "required_toast": "Pirms sūtīšanas izvēlieties klasifikāciju.",
+ "quick_reply_needs_label": "Šai atbildei vajadzīga klasifikācija. Izvēlieties to šeit un tad sūtiet.",
+ "forward_needs_label": "Klasifikācija ir obligāta. Pārsūtiet vēstuli no redaktora.",
+ "received_hint": "To iestatīja sūtītāja pasta programma. Klasifikāciju var iestatīt ikviens, tāpēc tā neko nepierāda."
+ },
"email_composer": {
"select_from_directory": "Izvēlēties no kataloga",
"minimize": "Minimizēt",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 308fc4af1..ec7e567db 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Ямар ч байсан илгээх",
"back": "Буцах"
},
+ "phishing_report": {
+ "action": "Фишинг мэдээлэх…",
+ "confirm_title": "Фишинг гэж мэдээлэх үү?",
+ "confirm_message": "{count, plural, one {Мессеж {address} хаяг руу илгээгдэж, Спам руу шилжинэ.} other {# мессеж тус бүр тусдаа мэдээлэл болж {address} хаяг руу илгээгдэж, Спам руу шилжинэ.}}",
+ "confirm": "Мэдээлэх",
+ "cancel": "Цуцлах",
+ "sending": "Мэдээлж байна…",
+ "done": "{count, plural, one {{address} хаяг руу мэдээлж, Спам руу шилжүүллээ.} other {# мессежийг {address} хаяг руу мэдээлж, Спам руу шилжүүллээ.}}",
+ "not_moved": "{count, plural, one {{address} хаяг руу мэдээлсэн ч мессежийг Спам руу шилжүүлж чадсангүй.} other {{address} хаяг руу мэдээлсэн ч # мессежийг Спам руу шилжүүлж чадсангүй.}}",
+ "failed": "{count, plural, one {Мессежийг мэдээлж чадсангүй.} other {# мессежийг мэдээлж чадсангүй.}}",
+ "undo": "Буцаах",
+ "undone": "{count, plural, one {Буцааж шилжүүллээ. Мэдээлэл аль хэдийн илгээгдсэн.} other {Буцааж шилжүүллээ. Мэдээллүүд аль хэдийн илгээгдсэн.}}",
+ "undo_failed": "Буцааж шилжүүлж чадсангүй.",
+ "banner": "Та энэ мессежийг фишинг гэж мэдээлсэн. Үүний холбоосуудыг идэвхгүй болгосон.",
+ "link_off": "холбоосыг идэвхгүй болгосон",
+ "link_off_note": "(холбоосыг идэвхгүй болгосон)",
+ "no_subject": "(сэдэв байхгүй)"
+ },
+ "classification": {
+ "picker_label": "Нууцын зэрэглэл",
+ "chip_label": "Нууцын зэрэглэл: {name}",
+ "none": "Зэрэглэлгүй",
+ "pick_required": "Зэрэглэл сонгох",
+ "required_hint": "Таны байгууллага нууцын зэрэглэл шаарддаг.",
+ "required_toast": "Илгээхээсээ өмнө нууцын зэрэглэл сонгоно уу.",
+ "quick_reply_needs_label": "Энэ хариунд нууцын зэрэглэл шаардлагатай. Энд сонгоод илгээнэ үү.",
+ "forward_needs_label": "Нууцын зэрэглэл заавал шаардлагатай. Захидлыг бичих цонхноос дамжуулна уу.",
+ "received_hint": "Илгээгчийн шуудангийн програм тохируулсан. Хэн ч зэрэглэл тохируулж болох тул энэ юуг ч нотлохгүй."
+ },
"email_composer": {
"select_from_directory": "Лавлахаас сонгох",
"minimize": "Багасгах",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 7ebbdf1d1..3c7951466 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Send likevel",
"back": "Tilbake"
},
+ "phishing_report": {
+ "action": "Rapporter nettfisking…",
+ "confirm_title": "Rapportere nettfisking?",
+ "confirm_message": "{count, plural, one {Meldingen sendes til {address} og flyttes til Søppelpost.} other {Hver av de # meldingene sendes til {address} som en egen rapport og flyttes til Søppelpost.}}",
+ "confirm": "Rapporter",
+ "cancel": "Avbryt",
+ "sending": "Rapporterer…",
+ "done": "{count, plural, one {Rapportert til {address} og flyttet til Søppelpost.} other {# meldinger rapportert til {address} og flyttet til Søppelpost.}}",
+ "not_moved": "{count, plural, one {Rapportert til {address}, men meldingen kunne ikke flyttes til Søppelpost.} other {Rapportert til {address}, men # meldinger kunne ikke flyttes til Søppelpost.}}",
+ "failed": "{count, plural, one {Meldingen kunne ikke rapporteres.} other {# meldinger kunne ikke rapporteres.}}",
+ "undo": "Angre",
+ "undone": "{count, plural, one {Flyttet tilbake. Rapporten var allerede sendt.} other {Flyttet tilbake. Rapportene var allerede sendt.}}",
+ "undo_failed": "Kunne ikke flytte den tilbake.",
+ "banner": "Du har rapportert denne meldingen som nettfisking. Lenkene i den er slått av.",
+ "link_off": "lenke slått av",
+ "link_off_note": "(lenke slått av)",
+ "no_subject": "(uten emne)"
+ },
+ "classification": {
+ "picker_label": "Klassifisering",
+ "chip_label": "Klassifisering: {name}",
+ "none": "Ingen klassifisering",
+ "pick_required": "Velg en klassifisering",
+ "required_hint": "Organisasjonen din krever en klassifisering.",
+ "required_toast": "Velg en klassifisering før du sender.",
+ "quick_reply_needs_label": "Dette svaret trenger en klassifisering. Velg en her, og send deretter.",
+ "forward_needs_label": "En klassifisering er påkrevd. Videresend meldingen fra meldingsvinduet.",
+ "received_hint": "Satt av avsenderens e-postprogram. Hvem som helst kan sette en klassifisering, så den beviser ingenting."
+ },
"email_composer": {
"select_from_directory": "Velg fra katalog",
"minimize": "Minimer",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index b3145d86d..6c2701c7e 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Toch verzenden",
"back": "Terug"
},
+ "phishing_report": {
+ "action": "Phishing melden…",
+ "confirm_title": "Phishing melden?",
+ "confirm_message": "{count, plural, one {Het bericht gaat naar {address} en wordt naar Spam verplaatst.} other {Elk van de # berichten gaat als aparte melding naar {address} en wordt naar Spam verplaatst.}}",
+ "confirm": "Melden",
+ "cancel": "Annuleren",
+ "sending": "Bezig met melden…",
+ "done": "{count, plural, one {Gemeld bij {address} en naar Spam verplaatst.} other {# berichten gemeld bij {address} en naar Spam verplaatst.}}",
+ "not_moved": "{count, plural, one {Gemeld bij {address}, maar het bericht kon niet naar Spam worden verplaatst.} other {Gemeld bij {address}, maar # berichten konden niet naar Spam worden verplaatst.}}",
+ "failed": "{count, plural, one {Het bericht kon niet worden gemeld.} other {# berichten konden niet worden gemeld.}}",
+ "undo": "Ongedaan maken",
+ "undone": "{count, plural, one {Teruggezet. De melding was al verstuurd.} other {Teruggezet. De meldingen waren al verstuurd.}}",
+ "undo_failed": "Kon niet worden teruggezet.",
+ "banner": "Je hebt dit bericht als phishing gemeld. De links erin zijn uitgeschakeld.",
+ "link_off": "link uitgeschakeld",
+ "link_off_note": "(link uitgeschakeld)",
+ "no_subject": "(geen onderwerp)"
+ },
+ "classification": {
+ "picker_label": "Classificatie",
+ "chip_label": "Classificatie: {name}",
+ "none": "Geen classificatie",
+ "pick_required": "Kies een classificatie",
+ "required_hint": "Je organisatie vereist een classificatie.",
+ "required_toast": "Kies een classificatie voordat je verzendt.",
+ "quick_reply_needs_label": "Dit antwoord heeft een classificatie nodig. Kies er hier een en verzend het dan.",
+ "forward_needs_label": "Een classificatie is verplicht. Stuur het bericht door vanuit de opsteller.",
+ "received_hint": "Ingesteld door het e-mailprogramma van de afzender. Iedereen kan een classificatie instellen, dus het bewijst niets."
+ },
"email_composer": {
"select_from_directory": "Kiezen uit adresboek van de organisatie",
"minimize": "Minimaliseren",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 25a74acaf..7538eddb7 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Wyślij mimo to",
"back": "Wstecz"
},
+ "phishing_report": {
+ "action": "Zgłoś phishing…",
+ "confirm_title": "Zgłosić phishing?",
+ "confirm_message": "{count, plural, one {Wiadomość trafi do {address} i zostanie przeniesiona do folderu Spam.} few {# wiadomości trafią do {address}, każda jako osobne zgłoszenie, i zostaną przeniesione do folderu Spam.} many {# wiadomości trafi do {address}, każda jako osobne zgłoszenie, i zostanie przeniesionych do folderu Spam.} other {# wiadomości trafi do {address}, każda jako osobne zgłoszenie, i zostanie przeniesionych do folderu Spam.}}",
+ "confirm": "Zgłoś",
+ "cancel": "Anuluj",
+ "sending": "Zgłaszanie…",
+ "done": "{count, plural, one {Zgłoszono do {address} i przeniesiono do folderu Spam.} few {Zgłoszono # wiadomości do {address} i przeniesiono je do folderu Spam.} many {Zgłoszono # wiadomości do {address} i przeniesiono je do folderu Spam.} other {Zgłoszono # wiadomości do {address} i przeniesiono je do folderu Spam.}}",
+ "not_moved": "{count, plural, one {Zgłoszono do {address}, ale nie udało się przenieść wiadomości do folderu Spam.} few {Zgłoszono do {address}, ale nie udało się przenieść # wiadomości do folderu Spam.} many {Zgłoszono do {address}, ale nie udało się przenieść # wiadomości do folderu Spam.} other {Zgłoszono do {address}, ale nie udało się przenieść # wiadomości do folderu Spam.}}",
+ "failed": "{count, plural, one {Nie udało się zgłosić wiadomości.} few {Nie udało się zgłosić # wiadomości.} many {Nie udało się zgłosić # wiadomości.} other {Nie udało się zgłosić # wiadomości.}}",
+ "undo": "Cofnij",
+ "undone": "{count, plural, one {Przeniesiono z powrotem. Zgłoszenie zostało już wysłane.} few {Przeniesiono z powrotem. Zgłoszenia zostały już wysłane.} many {Przeniesiono z powrotem. Zgłoszenia zostały już wysłane.} other {Przeniesiono z powrotem. Zgłoszenia zostały już wysłane.}}",
+ "undo_failed": "Nie udało się przenieść z powrotem.",
+ "banner": "Ta wiadomość została zgłoszona jako phishing. Linki w niej są wyłączone.",
+ "link_off": "link wyłączony",
+ "link_off_note": "(link wyłączony)",
+ "no_subject": "(brak tematu)"
+ },
+ "classification": {
+ "picker_label": "Klasyfikacja",
+ "chip_label": "Klasyfikacja: {name}",
+ "none": "Bez klasyfikacji",
+ "pick_required": "Wybierz klasyfikację",
+ "required_hint": "Twoja organizacja wymaga klasyfikacji.",
+ "required_toast": "Przed wysłaniem wybierz klasyfikację.",
+ "quick_reply_needs_label": "Ta odpowiedź wymaga klasyfikacji. Wybierz ją tutaj, a potem wyślij.",
+ "forward_needs_label": "Klasyfikacja jest wymagana. Przekaż wiadomość dalej z edytora wiadomości.",
+ "received_hint": "Ustawiona przez program pocztowy nadawcy. Każdy może ustawić klasyfikację, więc niczego nie dowodzi."
+ },
"email_composer": {
"select_from_directory": "Wybierz z katalogu",
"minimize": "Minimalizuj",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 06b42881a..a6f4b27df 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Enviar mesmo assim",
"back": "Voltar"
},
+ "phishing_report": {
+ "action": "Reportar phishing…",
+ "confirm_title": "Reportar phishing?",
+ "confirm_message": "{count, plural, one {A mensagem vai para {address} e é movida para Spam.} other {Cada uma das # mensagens vai para {address} como uma denúncia separada e é movida para Spam.}}",
+ "confirm": "Reportar",
+ "cancel": "Cancelar",
+ "sending": "Reportando…",
+ "done": "{count, plural, one {Reportada para {address} e movida para Spam.} other {# mensagens reportadas para {address} e movidas para Spam.}}",
+ "not_moved": "{count, plural, one {Reportada para {address}, mas não foi possível mover a mensagem para Spam.} other {Reportadas para {address}, mas não foi possível mover # mensagens para Spam.}}",
+ "failed": "{count, plural, one {Não foi possível reportar a mensagem.} other {Não foi possível reportar # mensagens.}}",
+ "undo": "Desfazer",
+ "undone": "{count, plural, one {Devolvida ao lugar. A denúncia já foi enviada.} other {Devolvidas ao lugar. As denúncias já foram enviadas.}}",
+ "undo_failed": "Não foi possível devolver ao lugar.",
+ "banner": "Você reportou esta mensagem como phishing. Os links dela estão desativados.",
+ "link_off": "link desativado",
+ "link_off_note": "(link desativado)",
+ "no_subject": "(sem assunto)"
+ },
+ "classification": {
+ "picker_label": "Classificação",
+ "chip_label": "Classificação: {name}",
+ "none": "Sem classificação",
+ "pick_required": "Escolha uma classificação",
+ "required_hint": "Sua organização exige uma classificação.",
+ "required_toast": "Escolha uma classificação antes de enviar.",
+ "quick_reply_needs_label": "Esta resposta precisa de uma classificação. Escolha uma aqui e depois envie.",
+ "forward_needs_label": "É obrigatória uma classificação. Encaminhe a mensagem pelo editor.",
+ "received_hint": "Definida pelo programa de e-mail do remetente. Qualquer pessoa pode definir uma classificação, então ela não prova nada."
+ },
"email_composer": {
"select_from_directory": "Selecionar do diretório",
"minimize": "Minimizar",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 2636c6532..d928de7c6 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Trimite oricum",
"back": "Înapoi"
},
+ "phishing_report": {
+ "action": "Raportează ca phishing…",
+ "confirm_title": "Raportați ca phishing?",
+ "confirm_message": "{count, plural, one {Mesajul ajunge la {address} și este mutat în Spam.} few {Fiecare dintre cele # mesaje ajunge la {address} ca raport separat și este mutat în Spam.} other {Fiecare dintre cele # de mesaje ajunge la {address} ca raport separat și este mutat în Spam.}}",
+ "confirm": "Raportează",
+ "cancel": "Anulează",
+ "sending": "Se raportează…",
+ "done": "{count, plural, one {Raportat la {address} și mutat în Spam.} few {# mesaje raportate la {address} și mutate în Spam.} other {# de mesaje raportate la {address} și mutate în Spam.}}",
+ "not_moved": "{count, plural, one {Raportat la {address}, dar mesajul nu a putut fi mutat în Spam.} few {Raportate la {address}, dar # mesaje nu au putut fi mutate în Spam.} other {Raportate la {address}, dar # de mesaje nu au putut fi mutate în Spam.}}",
+ "failed": "{count, plural, one {Mesajul nu a putut fi raportat.} few {# mesaje nu au putut fi raportate.} other {# de mesaje nu au putut fi raportate.}}",
+ "undo": "Anulează",
+ "undone": "{count, plural, one {Mutat înapoi. Raportul a fost deja trimis.} few {Mutate înapoi. Rapoartele au fost deja trimise.} other {Mutate înapoi. Rapoartele au fost deja trimise.}}",
+ "undo_failed": "Nu a putut fi mutat înapoi.",
+ "banner": "Ați raportat acest mesaj ca phishing. Linkurile din el sunt dezactivate.",
+ "link_off": "link dezactivat",
+ "link_off_note": "(link dezactivat)",
+ "no_subject": "(fără subiect)"
+ },
+ "classification": {
+ "picker_label": "Clasificare",
+ "chip_label": "Clasificare: {name}",
+ "none": "Fără clasificare",
+ "pick_required": "Alegeți o clasificare",
+ "required_hint": "Organizația dvs. cere o clasificare.",
+ "required_toast": "Alegeți o clasificare înainte de trimitere.",
+ "quick_reply_needs_label": "Acest răspuns are nevoie de o clasificare. Alegeți una aici, apoi trimiteți.",
+ "forward_needs_label": "Este necesară o clasificare. Redirecționați mesajul din fereastra de redactare.",
+ "received_hint": "Setată de programul de e-mail al expeditorului. Oricine poate seta o clasificare, deci nu dovedește nimic."
+ },
"email_composer": {
"select_from_directory": "Selectează din director",
"minimize": "Minimizează",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index ffd7f4e89..2bbd5bdf3 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Всё равно отправить",
"back": "Назад"
},
+ "phishing_report": {
+ "action": "Сообщить о фишинге…",
+ "confirm_title": "Сообщить о фишинге?",
+ "confirm_message": "{count, plural, =1 {Письмо будет отправлено на {address} и перемещено в папку «Спам».} one {# письмо будет отправлено на {address}, каждое отдельным отчётом, и перемещено в папку «Спам».} few {# письма будут отправлены на {address}, каждое отдельным отчётом, и перемещены в папку «Спам».} many {# писем будут отправлены на {address}, каждое отдельным отчётом, и перемещены в папку «Спам».} other {# письма будут отправлены на {address}, каждое отдельным отчётом, и перемещены в папку «Спам».}}",
+ "confirm": "Сообщить",
+ "cancel": "Отмена",
+ "sending": "Отправка отчёта…",
+ "done": "{count, plural, =1 {Отправлено на {address} и перемещено в папку «Спам».} one {# письмо отправлено на {address} и перемещено в папку «Спам».} few {# письма отправлены на {address} и перемещены в папку «Спам».} many {# писем отправлено на {address} и перемещено в папку «Спам».} other {# письма отправлены на {address} и перемещены в папку «Спам».}}",
+ "not_moved": "{count, plural, =1 {Отчёт отправлен на {address}, но письмо не удалось переместить в папку «Спам».} one {Отчёты отправлены на {address}, но # письмо не удалось переместить в папку «Спам».} few {Отчёты отправлены на {address}, но # письма не удалось переместить в папку «Спам».} many {Отчёты отправлены на {address}, но # писем не удалось переместить в папку «Спам».} other {Отчёты отправлены на {address}, но # письма не удалось переместить в папку «Спам».}}",
+ "failed": "{count, plural, =1 {Не удалось сообщить о письме.} one {Не удалось сообщить о # письме.} few {Не удалось сообщить о # письмах.} many {Не удалось сообщить о # письмах.} other {Не удалось сообщить о # письмах.}}",
+ "undo": "Отменить",
+ "undone": "{count, plural, =1 {Возвращено на место. Отчёт уже был отправлен.} one {Возвращено на место. Отчёты уже были отправлены.} few {Возвращено на место. Отчёты уже были отправлены.} many {Возвращено на место. Отчёты уже были отправлены.} other {Возвращено на место. Отчёты уже были отправлены.}}",
+ "undo_failed": "Не удалось вернуть на место.",
+ "banner": "Вы сообщили, что это письмо фишинговое. Ссылки в нём отключены.",
+ "link_off": "ссылка отключена",
+ "link_off_note": "(ссылка отключена)",
+ "no_subject": "(без темы)"
+ },
+ "classification": {
+ "picker_label": "Классификация",
+ "chip_label": "Классификация: {name}",
+ "none": "Без классификации",
+ "pick_required": "Выберите классификацию",
+ "required_hint": "Ваша организация требует указывать классификацию.",
+ "required_toast": "Выберите классификацию перед отправкой.",
+ "quick_reply_needs_label": "Для этого ответа нужна классификация. Выберите её здесь, затем отправьте.",
+ "forward_needs_label": "Классификация обязательна. Перешлите письмо из редактора.",
+ "received_hint": "Указана почтовой программой отправителя. Классификацию может указать кто угодно, поэтому она ничего не доказывает."
+ },
"email_composer": {
"select_from_directory": "Выбрать из справочника",
"minimize": "Свернуть",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 631d88822..88a52ff71 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Odoslať aj tak",
"back": "Späť"
},
+ "phishing_report": {
+ "action": "Nahlásiť phishing…",
+ "confirm_title": "Nahlásiť phishing?",
+ "confirm_message": "{count, plural, one {Správa sa odošle na {address} a presunie do priečinka Spam.} few {# správy sa odošlú na {address}, každá ako samostatné hlásenie, a presunú do priečinka Spam.} many {# správy sa odošle na {address}, každá ako samostatné hlásenie, a presunie do priečinka Spam.} other {# správ sa odošle na {address}, každá ako samostatné hlásenie, a presunie do priečinka Spam.}}",
+ "confirm": "Nahlásiť",
+ "cancel": "Zrušiť",
+ "sending": "Nahlasuje sa…",
+ "done": "{count, plural, one {Nahlásené na {address} a presunuté do priečinka Spam.} few {# správy nahlásené na {address} a presunuté do priečinka Spam.} many {# správy nahlásené na {address} a presunuté do priečinka Spam.} other {# správ nahlásených na {address} a presunutých do priečinka Spam.}}",
+ "not_moved": "{count, plural, one {Nahlásené na {address}, ale správu sa nepodarilo presunúť do priečinka Spam.} few {Nahlásené na {address}, ale # správy sa nepodarilo presunúť do priečinka Spam.} many {Nahlásené na {address}, ale # správy sa nepodarilo presunúť do priečinka Spam.} other {Nahlásené na {address}, ale # správ sa nepodarilo presunúť do priečinka Spam.}}",
+ "failed": "{count, plural, one {Správu sa nepodarilo nahlásiť.} few {# správy sa nepodarilo nahlásiť.} many {# správy sa nepodarilo nahlásiť.} other {# správ sa nepodarilo nahlásiť.}}",
+ "undo": "Späť",
+ "undone": "{count, plural, one {Presunuté späť. Hlásenie už bolo odoslané.} few {Presunuté späť. Hlásenia už boli odoslané.} many {Presunuté späť. Hlásenia už boli odoslané.} other {Presunuté späť. Hlásenia už boli odoslané.}}",
+ "undo_failed": "Nepodarilo sa presunúť späť.",
+ "banner": "Túto správu ste nahlásili ako phishing. Odkazy v nej sú vypnuté.",
+ "link_off": "odkaz vypnutý",
+ "link_off_note": "(odkaz vypnutý)",
+ "no_subject": "(bez predmetu)"
+ },
+ "classification": {
+ "picker_label": "Klasifikácia",
+ "chip_label": "Klasifikácia: {name}",
+ "none": "Bez klasifikácie",
+ "pick_required": "Vyberte klasifikáciu",
+ "required_hint": "Vaša organizácia vyžaduje klasifikáciu.",
+ "required_toast": "Pred odoslaním vyberte klasifikáciu.",
+ "quick_reply_needs_label": "Táto odpoveď potrebuje klasifikáciu. Vyberte ju tu a potom odošlite.",
+ "forward_needs_label": "Klasifikácia je povinná. Prepošlite správu z editora správ.",
+ "received_hint": "Nastavil ju poštový program odosielateľa. Klasifikáciu môže nastaviť ktokoľvek, takže nič nedokazuje."
+ },
"email_composer": {
"select_from_directory": "Vybrať z adresára",
"minimize": "Minimalizovať",
diff --git a/locales/th/common.json b/locales/th/common.json
index b6b89d9f5..ad299d215 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -1820,6 +1820,35 @@
"send_anyway": "ส่งต่อไป",
"back": "ย้อนกลับ"
},
+ "phishing_report": {
+ "action": "รายงานฟิชชิง…",
+ "confirm_title": "รายงานเป็นฟิชชิงหรือไม่",
+ "confirm_message": "{count, plural, =1 {ข้อความจะถูกส่งไปยัง {address} และย้ายไปที่จดหมายขยะ} other {ข้อความแต่ละฉบับจากทั้งหมด # ฉบับจะถูกส่งไปยัง {address} เป็นรายงานแยกกัน และย้ายไปที่จดหมายขยะ}}",
+ "confirm": "รายงาน",
+ "cancel": "ยกเลิก",
+ "sending": "กำลังรายงาน…",
+ "done": "{count, plural, =1 {รายงานไปยัง {address} และย้ายไปที่จดหมายขยะแล้ว} other {รายงานข้อความ # ฉบับไปยัง {address} และย้ายไปที่จดหมายขยะแล้ว}}",
+ "not_moved": "{count, plural, =1 {รายงานไปยัง {address} แล้ว แต่ไม่สามารถย้ายข้อความไปที่จดหมายขยะได้} other {รายงานไปยัง {address} แล้ว แต่ไม่สามารถย้ายข้อความ # ฉบับไปที่จดหมายขยะได้}}",
+ "failed": "{count, plural, =1 {ไม่สามารถรายงานข้อความได้} other {ไม่สามารถรายงานข้อความ # ฉบับได้}}",
+ "undo": "เลิกทำ",
+ "undone": "{count, plural, =1 {ย้ายกลับแล้ว รายงานถูกส่งไปแล้ว} other {ย้ายกลับแล้ว รายงานถูกส่งไปแล้ว}}",
+ "undo_failed": "ไม่สามารถย้ายกลับได้",
+ "banner": "คุณรายงานข้อความนี้ว่าเป็นฟิชชิง ลิงก์ในข้อความถูกปิดใช้งานแล้ว",
+ "link_off": "ปิดใช้งานลิงก์แล้ว",
+ "link_off_note": "(ปิดใช้งานลิงก์แล้ว)",
+ "no_subject": "(ไม่มีเรื่อง)"
+ },
+ "classification": {
+ "picker_label": "ระดับความลับ",
+ "chip_label": "ระดับความลับ: {name}",
+ "none": "ไม่มีระดับความลับ",
+ "pick_required": "เลือกระดับความลับ",
+ "required_hint": "องค์กรของคุณกำหนดให้ต้องระบุระดับความลับ",
+ "required_toast": "เลือกระดับความลับก่อนส่ง",
+ "quick_reply_needs_label": "การตอบกลับนี้ต้องมีระดับความลับ เลือกที่นี่ แล้วกดส่ง",
+ "forward_needs_label": "ต้องระบุระดับความลับ ส่งต่อข้อความจากหน้าต่างเขียนจดหมาย",
+ "received_hint": "ตั้งโดยโปรแกรมอีเมลของผู้ส่ง ใครก็ตั้งระดับความลับได้ จึงพิสูจน์อะไรไม่ได้"
+ },
"welcome": {
"title": "ยินดีต้อนรับสู่กล่องจดหมายของคุณ",
"tip_compose": "กด c เพื่อเขียนอีเมลใหม่",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 0492d468d..fd1d098a7 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Yine de gönder",
"back": "Geri"
},
+ "phishing_report": {
+ "action": "Kimlik avı bildir…",
+ "confirm_title": "Kimlik avı olarak bildirilsin mi?",
+ "confirm_message": "{count, plural, one {İleti {address} adresine gönderilir ve Spam klasörüne taşınır.} other {# iletinin her biri ayrı bir bildirim olarak {address} adresine gönderilir ve Spam klasörüne taşınır.}}",
+ "confirm": "Bildir",
+ "cancel": "İptal",
+ "sending": "Bildiriliyor…",
+ "done": "{count, plural, one {{address} adresine bildirildi ve Spam klasörüne taşındı.} other {# ileti {address} adresine bildirildi ve Spam klasörüne taşındı.}}",
+ "not_moved": "{count, plural, one {{address} adresine bildirildi ancak ileti Spam klasörüne taşınamadı.} other {{address} adresine bildirildi ancak # ileti Spam klasörüne taşınamadı.}}",
+ "failed": "{count, plural, one {İleti bildirilemedi.} other {# ileti bildirilemedi.}}",
+ "undo": "Geri al",
+ "undone": "{count, plural, one {Geri taşındı. Bildirim zaten gönderilmişti.} other {Geri taşındı. Bildirimler zaten gönderilmişti.}}",
+ "undo_failed": "Geri taşınamadı.",
+ "banner": "Bu iletiyi kimlik avı olarak bildirdiniz. İçindeki bağlantılar devre dışı.",
+ "link_off": "bağlantı devre dışı",
+ "link_off_note": "(bağlantı devre dışı)",
+ "no_subject": "(konu yok)"
+ },
+ "classification": {
+ "picker_label": "Sınıflandırma",
+ "chip_label": "Sınıflandırma: {name}",
+ "none": "Sınıflandırma yok",
+ "pick_required": "Sınıflandırma seçin",
+ "required_hint": "Kuruluşunuz bir sınıflandırma gerektiriyor.",
+ "required_toast": "Göndermeden önce bir sınıflandırma seçin.",
+ "quick_reply_needs_label": "Bu yanıt için sınıflandırma gerekiyor. Burada bir tane seçin, sonra gönderin.",
+ "forward_needs_label": "Sınıflandırma zorunlu. İletiyi ileti düzenleyiciden iletin.",
+ "received_hint": "Gönderenin posta programı tarafından ayarlandı. Herkes sınıflandırma ayarlayabilir, bu yüzden hiçbir şeyi kanıtlamaz."
+ },
"email_composer": {
"select_from_directory": "Dizinden seç",
"minimize": "Simge durumuna küçült",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 1245df517..970d2d85d 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Усе одно надіслати",
"back": "Назад"
},
+ "phishing_report": {
+ "action": "Повідомити про фішинг…",
+ "confirm_title": "Повідомити про фішинг?",
+ "confirm_message": "{count, plural, =1 {Лист буде надіслано на {address} і переміщено до папки «Спам».} one {# лист буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».} few {# листи буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».} many {# листів буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».} other {# листа буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».}}",
+ "confirm": "Повідомити",
+ "cancel": "Скасувати",
+ "sending": "Надсилання звіту…",
+ "done": "{count, plural, =1 {Надіслано на {address} і переміщено до папки «Спам».} one {# лист надіслано на {address} і переміщено до папки «Спам».} few {# листи надіслано на {address} і переміщено до папки «Спам».} many {# листів надіслано на {address} і переміщено до папки «Спам».} other {# листа надіслано на {address} і переміщено до папки «Спам».}}",
+ "not_moved": "{count, plural, =1 {Звіт надіслано на {address}, але лист не вдалося перемістити до папки «Спам».} one {Звіти надіслано на {address}, але # лист не вдалося перемістити до папки «Спам».} few {Звіти надіслано на {address}, але # листи не вдалося перемістити до папки «Спам».} many {Звіти надіслано на {address}, але # листів не вдалося перемістити до папки «Спам».} other {Звіти надіслано на {address}, але # листа не вдалося перемістити до папки «Спам».}}",
+ "failed": "{count, plural, =1 {Не вдалося повідомити про лист.} one {Не вдалося повідомити про # лист.} few {Не вдалося повідомити про # листи.} many {Не вдалося повідомити про # листів.} other {Не вдалося повідомити про # листа.}}",
+ "undo": "Скасувати",
+ "undone": "{count, plural, =1 {Повернуто на місце. Звіт уже надіслано.} one {Повернуто на місце. Звіти вже надіслано.} few {Повернуто на місце. Звіти вже надіслано.} many {Повернуто на місце. Звіти вже надіслано.} other {Повернуто на місце. Звіти вже надіслано.}}",
+ "undo_failed": "Не вдалося повернути на місце.",
+ "banner": "Ви повідомили, що цей лист фішинговий. Посилання в ньому вимкнено.",
+ "link_off": "посилання вимкнено",
+ "link_off_note": "(посилання вимкнено)",
+ "no_subject": "(без теми)"
+ },
+ "classification": {
+ "picker_label": "Класифікація",
+ "chip_label": "Класифікація: {name}",
+ "none": "Без класифікації",
+ "pick_required": "Виберіть класифікацію",
+ "required_hint": "Ваша організація вимагає вказувати класифікацію.",
+ "required_toast": "Виберіть класифікацію перед надсиланням.",
+ "quick_reply_needs_label": "Для цієї відповіді потрібна класифікація. Виберіть її тут, а потім надішліть.",
+ "forward_needs_label": "Класифікація обов'язкова. Перешліть лист із редактора.",
+ "received_hint": "Вказано поштовою програмою відправника. Класифікацію може вказати будь-хто, тому вона нічого не доводить."
+ },
"email_composer": {
"select_from_directory": "Вибрати з довідника",
"minimize": "Згорнути",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index d207d1ef2..c594d51f7 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -883,6 +883,35 @@
"send_anyway": "Vẫn gửi",
"back": "Quay lại"
},
+ "phishing_report": {
+ "action": "Báo cáo lừa đảo…",
+ "confirm_title": "Báo cáo lừa đảo?",
+ "confirm_message": "{count, plural, =1 {Thư sẽ được gửi đến {address} và chuyển vào Thư rác.} other {Mỗi thư trong số # thư sẽ được gửi đến {address} thành một báo cáo riêng và chuyển vào Thư rác.}}",
+ "confirm": "Báo cáo",
+ "cancel": "Hủy",
+ "sending": "Đang báo cáo…",
+ "done": "{count, plural, =1 {Đã báo cáo đến {address} và chuyển vào Thư rác.} other {Đã báo cáo # thư đến {address} và chuyển vào Thư rác.}}",
+ "not_moved": "{count, plural, =1 {Đã báo cáo đến {address}, nhưng không thể chuyển thư vào Thư rác.} other {Đã báo cáo đến {address}, nhưng không thể chuyển # thư vào Thư rác.}}",
+ "failed": "{count, plural, =1 {Không thể báo cáo thư.} other {Không thể báo cáo # thư.}}",
+ "undo": "Hoàn tác",
+ "undone": "{count, plural, =1 {Đã chuyển về. Báo cáo đã được gửi trước đó.} other {Đã chuyển về. Các báo cáo đã được gửi trước đó.}}",
+ "undo_failed": "Không thể chuyển về.",
+ "banner": "Bạn đã báo cáo thư này là lừa đảo. Các liên kết trong thư đã bị tắt.",
+ "link_off": "đã tắt liên kết",
+ "link_off_note": "(đã tắt liên kết)",
+ "no_subject": "(không có tiêu đề)"
+ },
+ "classification": {
+ "picker_label": "Phân loại bảo mật",
+ "chip_label": "Phân loại bảo mật: {name}",
+ "none": "Chưa chọn phân loại",
+ "pick_required": "Chọn phân loại",
+ "required_hint": "Tổ chức của bạn yêu cầu chọn phân loại bảo mật.",
+ "required_toast": "Hãy chọn phân loại bảo mật trước khi gửi.",
+ "quick_reply_needs_label": "Thư trả lời này cần phân loại bảo mật. Hãy chọn ở đây rồi gửi.",
+ "forward_needs_label": "Cần có phân loại bảo mật. Hãy chuyển tiếp thư từ khung soạn thư.",
+ "received_hint": "Do chương trình thư của người gửi đặt. Ai cũng có thể đặt phân loại, nên nó không chứng minh được gì."
+ },
"email_composer": {
"select_from_directory": "Chọn từ danh bạ",
"minimize": "Thu nhỏ",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 327767e44..75cb21dca 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -883,6 +883,35 @@
"send_anyway": "仍然傳送",
"back": "返回"
},
+ "phishing_report": {
+ "action": "回報網路釣魚…",
+ "confirm_title": "要回報為網路釣魚嗎?",
+ "confirm_message": "{count, plural, =1 {這封郵件會傳送至 {address},並移至垃圾郵件。} other {# 封郵件會各自作為單獨的回報傳送至 {address},並移至垃圾郵件。}}",
+ "confirm": "回報",
+ "cancel": "取消",
+ "sending": "正在回報…",
+ "done": "{count, plural, =1 {已回報至 {address},並移至垃圾郵件。} other {已將 # 封郵件回報至 {address},並移至垃圾郵件。}}",
+ "not_moved": "{count, plural, =1 {已回報至 {address},但無法將郵件移至垃圾郵件。} other {已回報至 {address},但有 # 封郵件無法移至垃圾郵件。}}",
+ "failed": "{count, plural, =1 {無法回報這封郵件。} other {有 # 封郵件無法回報。}}",
+ "undo": "復原",
+ "undone": "{count, plural, =1 {已移回。回報已送出。} other {已移回。回報已送出。}}",
+ "undo_failed": "無法移回。",
+ "banner": "您已將這封郵件回報為網路釣魚。其中的連結已停用。",
+ "link_off": "連結已停用",
+ "link_off_note": "(連結已停用)",
+ "no_subject": "(無主題)"
+ },
+ "classification": {
+ "picker_label": "機密等級",
+ "chip_label": "機密等級:{name}",
+ "none": "未設定機密等級",
+ "pick_required": "選擇機密等級",
+ "required_hint": "您的組織要求設定機密等級。",
+ "required_toast": "傳送前請選擇機密等級。",
+ "quick_reply_needs_label": "此回覆需要設定機密等級。請在這裡選擇,然後傳送。",
+ "forward_needs_label": "必須設定機密等級。請從撰寫視窗轉寄此郵件。",
+ "received_hint": "由寄件者的郵件程式設定。任何人都能設定機密等級,因此無法證明任何事。"
+ },
"email_composer": {
"select_from_directory": "從目錄中選取",
"minimize": "最小化",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 5233ea14d..60216697e 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -883,6 +883,35 @@
"send_anyway": "仍然发送",
"back": "返回"
},
+ "phishing_report": {
+ "action": "举报网络钓鱼…",
+ "confirm_title": "举报为网络钓鱼?",
+ "confirm_message": "{count, plural, =1 {该邮件将发送至 {address},并移至垃圾邮件。} other {# 封邮件将分别作为单独的举报发送至 {address},并移至垃圾邮件。}}",
+ "confirm": "举报",
+ "cancel": "取消",
+ "sending": "正在举报…",
+ "done": "{count, plural, =1 {已举报至 {address},并移至垃圾邮件。} other {已将 # 封邮件举报至 {address},并移至垃圾邮件。}}",
+ "not_moved": "{count, plural, =1 {已举报至 {address},但无法将邮件移至垃圾邮件。} other {已举报至 {address},但有 # 封邮件无法移至垃圾邮件。}}",
+ "failed": "{count, plural, =1 {无法举报该邮件。} other {有 # 封邮件无法举报。}}",
+ "undo": "撤销",
+ "undone": "{count, plural, =1 {已移回。举报已发出。} other {已移回。举报已发出。}}",
+ "undo_failed": "无法移回。",
+ "banner": "您已将此邮件举报为网络钓鱼。其中的链接已停用。",
+ "link_off": "链接已停用",
+ "link_off_note": "(链接已停用)",
+ "no_subject": "(无主题)"
+ },
+ "classification": {
+ "picker_label": "密级",
+ "chip_label": "密级:{name}",
+ "none": "未设置密级",
+ "pick_required": "选择密级",
+ "required_hint": "您的组织要求设置密级。",
+ "required_toast": "发送前请选择密级。",
+ "quick_reply_needs_label": "此回复需要设置密级。请在这里选择,然后发送。",
+ "forward_needs_label": "必须设置密级。请从撰写窗口转发此邮件。",
+ "received_hint": "由发件人的邮件程序设置。任何人都可以设置密级,因此它不能证明任何事情。"
+ },
"email_composer": {
"select_from_directory": "从目录中选择",
"minimize": "最小化",
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index c78734dc5..f68bcd660 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -3291,6 +3291,7 @@ useAuthStore.subscribe((state, prev) => {
usePolicyStore.getState().setPolicySubject(null);
return;
}
+ usePolicyStore.getState().setSubjectPending(true);
void policySubjectForClient(state.client).then(async (subject) => {
if (request !== policySubjectRequest) return;
// The server sends only the scopes of the domains signed in when the
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 902cb5428..03db84e8e 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -444,7 +444,7 @@ interface EmailStore {
/** `ownerAccountId`: the JMAP account a link names for the message (#1351). */
fetchEmailContent: (client: IJMAPClient, emailId: string, ownerAccountId?: string) => Promise;
fetchQuota: (client: IJMAPClient) => Promise;
- sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record; localAccountId?: string; draftAccountId?: string }) => Promise;
+ sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record; labelHeaders?: Record; localAccountId?: string; draftAccountId?: string }) => Promise;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise;
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean, emailKey?: string) => Promise;
@@ -580,10 +580,15 @@ interface EmailStore {
// owning JMAP account (passed to JMAP), `sourceClientAccountId` is the login the
// email is reachable through (used to pick the right client). (#281)
spamUndoCache: Map;
- /** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
- markAsSpam: (client: IJMAPClient, emailId: string, emailKey?: string) => Promise;
- /** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
- undoSpam: (client: IJMAPClient, emailId: string, emailKey?: string) => Promise;
+ /**
+ * `emailKey` (emailKeyFor of the row) picks the right row when the id
+ * repeats across accounts. `options.keywords`: more keywords set with the
+ * move, `options.markRead` marks it read too ($phishing and $seen for a
+ * phishing report, lib/phishing-report.ts).
+ */
+ markAsSpam: (client: IJMAPClient, emailId: string, emailKey?: string, options?: { keywords?: string[]; markRead?: boolean }) => Promise;
+ /** `emailKey` as for markAsSpam; `options.clearKeywords` are removed with the move back. */
+ undoSpam: (client: IJMAPClient, emailId: string, emailKey?: string, options?: { clearKeywords?: string[] }) => Promise;
batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise;
batchUndoSpam: (client: IJMAPClient, emailIds: string[]) => Promise;
@@ -6367,7 +6372,7 @@ export const useEmailStore = create((set, get) => ({
},
// Spam operations
- markAsSpam: async (client, emailId, emailKey): Promise => {
+ markAsSpam: async (client, emailId, emailKey, options): Promise => {
const isTarget = singleEmailMatcher(emailId, emailKey);
// The viewer may show an email the list no longer holds (e.g. the unread
// quick-filter dropped it once it was read), so fall back to the selected
@@ -6410,8 +6415,10 @@ export const useEmailStore = create((set, get) => ({
try {
const isUnread = !email.keywords?.$seen;
- const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread;
- await actionClient.markAsSpam(emailId, accountId, alsoMarkRead);
+ const alsoMarkRead = isUnread && (options?.markRead === true || useSettingsStore.getState().deleteAction === 'trash-and-read');
+ // Extra keywords only when asked for: the plain call stays as it was.
+ if (options?.keywords?.length) await actionClient.markAsSpam(emailId, accountId, alsoMarkRead, options.keywords);
+ else await actionClient.markAsSpam(emailId, accountId, alsoMarkRead);
// Same junk resolution the client used for the move itself.
const junkMailbox = mailboxes.find(m =>
@@ -6459,7 +6466,7 @@ export const useEmailStore = create((set, get) => ({
return true;
},
- undoSpam: async (client, emailId, emailKey): Promise => {
+ undoSpam: async (client, emailId, emailKey, options): Promise => {
const isTarget = singleEmailMatcher(emailId, emailKey);
if (emailHooks.onBeforeEmailSpamToggle.size > 0
&& !(await emailHooks.onBeforeEmailSpamToggle.intercept({ emailIds: [emailId], spam: false }))) {
@@ -6515,7 +6522,8 @@ export const useEmailStore = create((set, get) => ({
}
try {
- await undoClient.undoSpam(emailId, targetMailboxId, accountId);
+ if (options?.clearKeywords?.length) await undoClient.undoSpam(emailId, targetMailboxId, accountId, options.clearKeywords);
+ else await undoClient.undoSpam(emailId, targetMailboxId, accountId);
// Drop the restored mail from the current junk view and advance the open
// message to the next one, mirroring markAsSpam. Without this the viewer
diff --git a/stores/phishing-report-store.ts b/stores/phishing-report-store.ts
new file mode 100644
index 000000000..0096237da
--- /dev/null
+++ b/stores/phishing-report-store.ts
@@ -0,0 +1,64 @@
+import { create } from 'zustand';
+import type { Email } from '@/lib/jmap/types';
+
+/**
+ * The "Report phishing?" question waiting for an answer. Any menu or
+ * toolbar asks through `requestPhishingReport`; the one host mounted in the
+ * app layout (components/email/phishing-report-host.tsx) shows the dialog,
+ * sends the reports and shows the outcome.
+ */
+interface PhishingReportState {
+ /** The messages and every address their reports go to (one per account policy). */
+ pending: { emails: Email[]; addresses: string[] } | null;
+ /** The reports are on their way; the dialog stays open and can't be sent twice. */
+ busy: boolean;
+ /** Where the focus goes back to when the dialog closes. */
+ opener: HTMLElement | null;
+ /**
+ * Messages reported in this session (emailKeyFor), so every open view
+ * of them (a Pro tab, a conversation) shows the banner and turns its
+ * links off at once, before the server's $phishing reaches it.
+ */
+ reported: Record;
+ ask: (emails: Email[], addresses: string[]) => void;
+ close: () => void;
+ setBusy: (busy: boolean) => void;
+ markReported: (keys: string[], reported: boolean) => void;
+}
+
+/**
+ * The control to give the focus back to. A menu item unmounts when the
+ * menu closes: then the button that opened the menu, else none (the
+ * page's focus rescue takes over).
+ */
+function openerOf(active: Element | null): HTMLElement | null {
+ if (!(active instanceof HTMLElement)) return null;
+ if (!active.closest('[role="menu"], [role="menubar"]')) return active;
+ return document.querySelector('[aria-haspopup="menu"][aria-expanded="true"], [aria-haspopup="true"][aria-expanded="true"]');
+}
+
+export const usePhishingReportStore = create()((set, get) => ({
+ pending: null,
+ busy: false,
+ opener: null,
+ reported: {},
+ ask: (emails, addresses) => {
+ if (get().busy || emails.length === 0) return;
+ const active = typeof document !== 'undefined' ? document.activeElement : null;
+ set({ pending: { emails, addresses }, opener: openerOf(active) });
+ },
+ close: () => {
+ const { opener } = get();
+ set({ pending: null, busy: false, opener: null });
+ if (opener?.isConnected) opener.focus();
+ },
+ setBusy: (busy) => set({ busy }),
+ markReported: (keys, reported) => set((state) => {
+ const next = { ...state.reported };
+ for (const key of keys) {
+ if (reported) next[key] = true;
+ else delete next[key];
+ }
+ return { reported: next };
+ }),
+}));
diff --git a/stores/policy-store.ts b/stores/policy-store.ts
index 864436af4..64a9ecc21 100644
--- a/stores/policy-store.ts
+++ b/stores/policy-store.ts
@@ -18,6 +18,12 @@ interface PolicyState {
rawPolicy: SettingsPolicy;
/** The account `policy` was resolved for; null before one is known. */
subject: PolicySubject | null;
+ /**
+ * The active account's subject is being worked out (signed in, its groups
+ * not known yet). Until it is, a group scope's lock may not be applied yet.
+ */
+ subjectPending: boolean;
+ setSubjectPending: (pending: boolean) => void;
/** Ids of the scopes that apply to `subject`. */
matchedScopes: string[];
/** What set each non-default value of `policy` (see ResolvedPolicy.sources). */
@@ -150,6 +156,7 @@ const INITIAL = resolved({ ...DEFAULT_POLICY }, null);
export const usePolicyStore = create()((set, get) => ({
...INITIAL,
subject: null,
+ subjectPending: false,
scopeDomains: null,
loaded: false,
fetching: false,
@@ -210,7 +217,10 @@ export const usePolicyStore = create()((set, get) => ({
}
},
+ setSubjectPending: (pending) => { if (get().subjectPending !== pending) set({ subjectPending: pending }); },
+
setPolicySubject: (subject) => {
+ if (get().subjectPending) set({ subjectPending: false });
if (sameSubject(subject, get().subject)) return;
set({ ...resolved(get().rawPolicy, subject), subject });
},
diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts
index d5d08a364..ecc393c05 100644
--- a/stores/pro-tab-store.ts
+++ b/stores/pro-tab-store.ts
@@ -51,6 +51,10 @@ export interface ProReplyContext {
quoteHeaderHtml?: string;
quoteHeaderText?: string;
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 {