Compare commits

..
Author SHA1 Message Date
jcoffey-dev 7e06a3b1f6 Merge pull request 'Release 2026.9.29.1' (#123) from release/2026.9.29.1-pr into main
ci / fork-checks (push) Successful in 48s
publish / version (push) Successful in 11s
ci / build (push) Successful in 30m5s
publish / publish-amd64 (push) Successful in 32m52s
publish / release (push) Successful in 10s
publish / publish-arm64 (push) Successful in 43m6s
publish / binaries (push) Successful in 36s
publish / announce (push) Successful in 10s
2026-09-29 15:40:42 +00:00
jcoffey-dev e147206e82 Release 2026.9.29.1
ci / fork-checks (pull_request) Successful in 50s
ci / build (pull_request) Successful in 13m48s
2026-09-29 08:25:13 -07:00
jcoffey-dev ca6484c356 Honor the client registration override only in setup and recovery
The recovery administrator signs in before any OAuth client is
registered, so it needs to skip the registration check. Outside
bootstrap and recovery mode, every account now signs in through a
registered client and one of its redirect URIs.
2026-09-29 08:25:13 -07:00
jcoffey-dev ffcfde0b5a Merge pull request 'Release 2026.9.29' (#121) from release/2026.9.29-pr into main
publish / version (push) Successful in 11s
ci / fork-checks (push) Successful in 1m6s
ci / build (push) Successful in 32m20s
publish / publish-amd64 (push) Successful in 39m12s
publish / release (push) Successful in 5s
publish / publish-arm64 (push) Successful in 45m10s
publish / binaries (push) Successful in 41s
publish / announce (push) Successful in 22s
2026-09-29 05:43:53 +00:00
jcoffey-dev f1f05db790 Release 2026.9.29
ci / fork-checks (pull_request) Successful in 46s
ci / build (pull_request) Successful in 4m19s
2026-09-28 22:38:59 -07:00
jcoffey-dev e1076a04b2 Merge pull request 'Journaling spec: built, and the console as built' (#120) from spec/journaling-built into main
ci / fork-checks (push) Successful in 32s
ci / build (push) Canceled after 19m51s
2026-09-29 05:23:58 +00:00
jcoffey-dev 8fc8d94bbc Merge pull request 'Journaling: a Journal link in Management › Compliance' (#119) from feature/journal-menu into main
ci / fork-checks (push) Canceled after 22s
ci / build (push) Canceled after 22s
2026-09-29 05:23:36 +00:00
jcoffey-dev 0c600a63fa Journaling spec: built, and the console as built
ci / fork-checks (pull_request) Successful in 19s
ci / build (pull_request) Successful in 8m0s
2026-09-28 22:09:07 -07:00
jcoffey-dev f78925b316 Journaling: a Journal link in Management › Compliance
ci / fork-checks (pull_request) Successful in 56s
ci / build (pull_request) Successful in 17m1s
The console's journal page (CustomComponent/Journal), after Data Loss
Prevention; the console shows it to those who may see journals.
2026-09-28 22:06:12 -07:00
jcoffey-dev 6ee7ba1b7e Merge pull request 'Logs: a total only when it's known, not the query cap' (#117) from fix/log-query-total into main
ci / fork-checks (push) Successful in 17s
ci / build (push) Canceled after 23m14s
2026-09-29 05:00:18 +00:00
jcoffey-dev a992caf810 Merge pull request 'Journaling: search, read and export over JMAP, and the chain check' (#118) from feature/journal-search into main
ci / fork-checks (push) Successful in 17s
ci / build (push) Canceled after 6m46s
2026-09-29 04:53:28 +00:00
jcoffey-dev daa486f7e7 Journaling: search, read and export over JMAP, and the chain check
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 8m0s
Phase 4 of the journaling spec.

- inbuxa:JournalEntry/query and /get (sysJournalSearch): filter by time,
  sender, recipient, either, direction, subject words, Message-ID and
  journal, newest first; the whole report only when asked for.
- inbuxa:JournalExport/set (sysJournalExport): a reason is required; a
  ZIP of the matching reports with manifest.csv, exceptions.csv and
  manifest.sha256, up to 10,000 reports and 1 GB.
- inbuxa:JournalVerification/set (sysJournalGet): chains and reports
  rechecked.
- Every search, listing, read, export and check is written to the audit
  log before anything is returned, with existing actions only.
- Catalog entries for the three objects; spec as-built notes.

journal_tests: administrators can't search; a Compliance Officer searches,
lists, reads a report, exports (reason required) and checks the chain;
the officer can't change journals; each of those is in the audit log.
2026-09-28 21:45:09 -07:00
jcoffey-dev 9c29fb2bea Logs: a total only when it's known, not the query cap
ci / fork-checks (pull_request) Successful in 55s
ci / build (pull_request) Successful in 17m3s
2026-09-28 21:42:53 -07:00
jcoffey-dev abd5811420 Merge pull request 'Journaling: outside archives, and Journal it in mail flow rules' (#116) from feature/journal-archive into main
ci / fork-checks (push) Successful in 50s
ci / build (push) Canceled after 19m30s
2026-09-29 04:33:59 +00:00
jcoffey-dev 80051539d5 Merge pull request 'Security to-do list: accepted items, kept on the server' (#114) from feature/security-acceptances into main
ci / fork-checks (push) Canceled after 11s
ci / build (push) Canceled after 10s
2026-09-29 04:33:46 +00:00
jcoffey-dev 64550ebbd0 Journaling: outside archives, and Journal it in mail flow rules
ci / fork-checks (pull_request) Successful in 1m19s
ci / build (pull_request) Successful in 5m44s
Phase 3 of the journaling spec.

- A journal's destination: builtIn (true for journals stored before) and
  archiveAddress, at least one. Reports to an archive are queued from the
  empty sender, one per address, flagged so they're never journaled.
- A pending record per report. When the queue lets go of one without
  delivering it (refused, expired, deleted), it becomes its own entry in
  the built-in journal under the sending journals' retention, the
  journal's archiveFailures (count, last time, reason) goes up, and the
  audit log records it; if that can't be written it stays queued.
- Journal it: a rule action naming a journal, on mail flow rules and
  beside a DLP rule's block, warn or hold. A journal whose scope chooses
  nobody takes only what rules send it.
- The report lists recipients a rule added or redirected to under
  "Added by rule", by rule name.
- A rule's route is cleared between messages in one SMTP session, with the
  new journal marks; a second message used to keep the first one's route.

tests/src/system/journal.rs: destination validation, a rule-only journal
fed by a rule that also adds a recipient, an unreachable archive's report
kept in the built-in journal with the failure counted, a report delivered
to an archive here and not journaled itself.
2026-09-28 21:27:44 -07:00
jcoffey-dev eea96e8674 Security to-do list: accepted items, kept on the server
ci / fork-checks (pull_request) Successful in 19s
ci / build (pull_request) Successful in 8m5s
2026-09-28 21:22:43 -07:00
jcoffey-dev 4c5583e725 Merge pull request 'Journaling: capture at the queue, the built-in journal, retention' (#115) from feature/journal-capture into main
ci / fork-checks (push) Successful in 46s
ci / build (push) Canceled after 22m4s
2026-09-29 04:11:40 +00:00
jcoffey-dev 441ad0b18e Journaling: capture at the queue, the built-in journal, retention
ci / fork-checks (pull_request) Successful in 41s
ci / build (pull_request) Successful in 8m2s
Phase 2 of the journaling spec.

- A copy of each message is taken in MessageWrapper::queue, after DLP and
  transport rules, for every enabled journal that takes it (direction and
  scope: everyone, or accounts, groups, domains, tenants). If the copy
  can't be taken the message isn't queued (temporary failure).
- The journal report: the envelope one field a line (sender, To, Cc, Bcc
  from the envelope, list members from their ORCPT, direction, held for
  review), then the queued message byte for byte as message/rfc822.
- The built-in journal under J in the inbuxa subspace: one chain per node
  whose links name each entry by SHA-256, so entries can expire out of
  chain order; purge leaves a marker, and verify catches an entry changed
  or removed early and a report that doesn't match.
- Retention per journal (30 to 3650 days); an entry keeps what it was
  written with. The daily maintenance purges what's due, keeping entries
  whose people a legal hold covers (deleted accounts a hold keeps too),
  and records the counts in the audit log.
- inbuxa:Journal get/set, audited by the request layer. Permissions
  680-683: administrators see and change journals; the Compliance Officer
  sees, searches and exports. Whoever changes journals may grant search and
  export without holding them, so officers can still be appointed.
- Catalog entries (inbuxa:Journal, source "journal"); spec as-built notes.

tests/src/system/journal.rs: validation, internal mail with a Bcc,
outgoing into two journals, incoming over LMTP, the report and its
original, tamper and early removal caught, hold-aware purge, retention
changes leave entries alone, disabled and removed journals take nothing.
2026-09-28 20:46:04 -07:00
jcoffey-dev 792ff9d1ee Merge pull request 'Spec: journaling' (#113) from spec/journaling into main
ci / fork-checks (push) Successful in 48s
ci / build (push) Canceled after 54m15s
2026-09-29 03:17:22 +00:00
jcoffey-dev af49e94d97 Journaling spec: approved, with the answers
ci / fork-checks (pull_request) Successful in 32s
ci / build (pull_request) Successful in 3m56s
2026-09-28 20:13:15 -07:00
jcoffey-dev 37c00b609c Spec: journaling
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Successful in 23m6s
2026-09-28 19:45:37 -07:00
jcoffey-dev 94a3a762b0 Merge pull request 'Mail rules: group and tenant ids in their JMAP form' (#112) from feature/rule-ids-as-jmap-ids into main
ci / fork-checks (push) Successful in 17s
ci / build (push) Canceled after 40m32s
2026-09-29 02:36:50 +00:00
jcoffey-dev 9a7d678532 Merge pull request 'DLP: how long held mail waits is a setting' (#111) from feature/dlp-hold-days into main
ci / fork-checks (push) Successful in 14s
ci / build (push) Canceled after 4m36s
2026-09-29 02:32:12 +00:00
jcoffey-dev 823d42d528 Mail rules: group and tenant ids in their JMAP form
ci / fork-checks (pull_request) Successful in 2m26s
ci / build (pull_request) Successful in 5m47s
senderGroup, senderTenant and recipientGroup conditions now read and write group and tenant ids as JMAP ids ("b", "c"…), like legal hold scopes and the rest of the API, so the console can use its object pickers; plain numbers are still read. Held as numbers for matching. Unit test for both forms and a bad id; mail_rules_tests round-trips a tenant condition over JMAP.
2026-09-28 19:30:28 -07:00
jcoffey-dev de514115dd DLP: how long held mail waits is a setting
ci / fork-checks (pull_request) Successful in 51s
ci / build (pull_request) Successful in 4m32s
inbuxa:DlpSettings (singleton, urn:inbuxa:jmap): keepHeldDays, 1 to 90,
7 by default (settled answer 5 made it a setting). sysDlpPolicyGet reads
it, sysDlpPolicyUpdate changes it, server-level, audited by the request
layer. Each held message keeps the days it was given, and the sender's
notices say that number. Privacy catalog entry; spec §2.6 updated.

mail_rules_tests: 7 by default, 0 refused, 3 set and a message held
afterwards expires 3 days after it was held, the expiry notice says 3.
2026-09-28 19:27:27 -07:00
jcoffey-dev 0f8816f659 Merge pull request 'Submissions say when DLP held the message' (#110) from feature/submission-held-flag into main
ci / fork-checks (push) Successful in 1m11s
ci / build (push) Successful in 30m38s
2026-09-29 01:54:00 +00:00
jcoffey-dev b59eebf1e7 Submissions say when DLP held the message
ci / fork-checks (pull_request) Successful in 44s
ci / build (pull_request) Successful in 4m44s
An EmailSubmission create's response carries inbuxa:held (dlp-and-mail-flow-rules spec, §2.6, §4): true when the message is held for review, false otherwise, so the webmail can say so at once. A sender can't read the review queue, and a held message's sendAt is its real send time, not the century-off release, so this is how the sender learns. mail_rules_tests checks both values.
2026-09-28 18:48:39 -07:00
jcoffey-dev f4061f542c Merge pull request 'Menus: Held Mail, Data Loss Prevention and Mail Flow Rules' (#109) from feature/dlp-console-nav into main
ci / fork-checks (push) Successful in 55s
ci / build (push) Canceled after 5m54s
2026-09-29 01:48:07 +00:00
jcoffey-dev e99f84bd01 Merge pull request 'DLP phase 3: hold for review' (#108) from feature/dlp-hold into main
ci / fork-checks (push) Successful in 19s
ci / build (push) Canceled after 4m22s
2026-09-29 01:43:44 +00:00
jcoffey-dev 9653219c53 Menus: Held Mail, Data Loss Prevention and Mail Flow Rules
ci / fork-checks (pull_request) Successful in 1m31s
ci / build (pull_request) Successful in 13m30s
Schema layout entries for the console pages of the DLP and mail flow rules spec (§3): Held Mail and Data Loss Prevention under Management > Compliance after Legal Holds, and Mail Flow Rules beside the server Sieve scripts (the console's nine-group Settings bar places it under Mail flow). An older console shows these as unknown pages, so this ships with the console that has them.
2026-09-28 18:34:18 -07:00
jcoffey-dev f44382fb09 DLP phase 3: hold for review
ci / fork-checks (pull_request) Successful in 33s
ci / build (pull_request) Successful in 9m58s
The hold action now holds (dlp-and-mail-flow-rules spec, §2.6), where
until now it blocked.

- At DATA a hold decision queues the message with its release a century
  off (the queue's future-release mechanism, so the stored format is
  unchanged and an older node just never sends it), transport rules
  still applied, and replies 250 Held for review. A review record under
  R/h + queue id keeps the sender, recipients, subject, size, rules and
  detector counts. The sender is told when the rule asks.
- smtp/queue/held.rs: release (each recipient due now, its next notice
  as far off as it was, its lifetime counted from the release), reject
  (removed from the queue, the sender told, with the reviewer's note),
  and expiry: the daily clean-up rejects what nobody reviewed in 7 days,
  recorded as the server's doing.
- inbuxa:HeldMessage get/set: the review queue, sysDlpReviewGet to list
  and read (preview, 64 KB of text, only when asked for and recorded as
  blobAccess), sysDlpReviewUpdate to release or reject, a reason
  required and audited by the request layer; no create or destroy;
  server-level only.
- Guards: Emails > Queue refuses to change or delete held mail; the
  sender can't unsend it.
- Privacy catalog entry for inbuxa:HeldMessage; spec §2.6 as built.

Tests: mail_rules_tests gains the whole flow (held and listed with
counts, sender notified and nothing delivered, queue and unsend
refused, preview recorded, reject needs a reason and tells the sender
the note, release delivers, expiry returns it, decisions audited with
reasons). smtp inbound, system_tests (after one BlobNotFound in
antispam, the known flake, then clean), features and common unit tests.
2026-09-28 18:33:12 -07:00
jcoffey-dev dd73e0ad74 Merge pull request 'Mail flow rules: carry out the transport actions' (#106) from feature/mailflow-actions into main
ci / fork-checks (push) Successful in 14s
ci / build (push) Canceled after 26m39s
2026-09-29 01:17:03 +00:00
jcoffey-dev 7f045c626a Merge pull request 'DLP at DATA: block, warn and override over SMTP and JMAP' (#104) from feature/dlp-data-stage into main
ci / fork-checks (push) Successful in 15s
ci / build (push) Canceled after 16s
2026-09-29 01:16:45 +00:00
jcoffey-dev e99d26de89 Merge pull request 'Ports: each node checks the others' ports from outside' (#105) from feature/port-reachability into main
ci / fork-checks (push) Successful in 15s
ci / build (push) Canceled after 57s
2026-09-29 01:15:47 +00:00
jcoffey-dev 7f22006e97 Mail flow rules: carry out the transport actions
ci / fork-checks (pull_request) Successful in 50s
ci / build (pull_request) Successful in 4m52s
Phase 2g of the DLP and mail flow rules spec: transport rules now act,
on outgoing and incoming mail.

- features/mailflow/rewrite.rs: add or remove a header, prefix or set the
  subject (an RFC 2047 word when not ASCII), add a disclaimer. A
  disclaimer edits the message's main text and HTML bodies only, each
  decoded, changed and written back as UTF-8 quoted-printable with its
  other headers kept, top or bottom (after <body> or before </body> in
  HTML); attachments and attached messages are left alone, and a
  disclaimer already present isn't added again.
- smtp/inbound/mailflow.rs: the check runs for incoming mail too
  (transport rules only; DLP stays outgoing). After DLP passes, each
  matched transport rule's actions run in order: message edits,
  add-recipient and redirect (envelope changes DATA applies), route (a
  per-message queue ahead of the queue strategy), refuse (550 5.7.1
  with the rule's text). The override tag is stripped with the same
  subject writer, so a non-ASCII subject stays valid.
- Audit: refusals and changes to where mail goes are recorded (sender,
  or system:mail-flow for incoming mail); wording and header changes
  aren't, or a banner rule would record every message (spec §2.7).

Tests: rewrite unit tests (headers, encoded subjects, disclaimers on a
single part and on multipart/alternative with an attachment, once
only); mail_rules_tests gains the actions end to end: disclaimer,
header and subject prefix on a delivered message, a redirect, a
refusal, a banner on incoming LMTP mail that outgoing rules leave
alone, and which of those are audited.
2026-09-28 18:08:40 -07:00
jcoffey-dev e35fc3e6d6 Ports: each node checks the others' ports from outside
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 7m26s
2026-09-28 18:07:49 -07:00
jcoffey-dev 5f52dad5f1 Merge pull request 'Explain: don't prepare answers for date fields' (#101) from fix/explain-skip-date-fields into main
ci / fork-checks (push) Successful in 37s
ci / build (push) Canceled after 8m58s
2026-09-29 01:06:46 +00:00
jcoffey-dev 15064d6fd5 Merge pull request 'Webhooks: send one sample event to a saved webhook' (#100) from feature/webhook-test into main
ci / fork-checks (push) Canceled after 34s
ci / build (push) Canceled after 33s
2026-09-29 01:06:13 +00:00
jcoffey-dev e0060c9e6e DLP at DATA: block, warn and override over SMTP and JMAP
ci / fork-checks (pull_request) Successful in 49s
ci / build (pull_request) Successful in 23m36s
Phase 2f of the DLP and mail flow rules spec: the rules now run on mail
an authenticated sender submits, after the DATA system script and
before headers and DKIM signing (§2.1).

- smtp/inbound/mailflow.rs: builds what the rules look at from the
  message (subject, the text version of each body, one level of attached
  messages, attachment text via the extractor, 10 MB of text at most)
  and the envelope (sender's groups and tenant; each recipient local or
  not, and its groups). Skipped entirely when no enabled rule applies to
  outgoing mail. Rules that can't be loaded refuse with a 451: nothing
  unchecked leaves.
- Block: 550 5.7.1 with the rule's notice. Warn: 550 5.7.1 with the
  notice and how to override: "[override: reason]" at the start of the
  subject, taken out before the message goes on (settled answer 1).
  Until phase 3, a hold rule blocks rather than let mail through.
- JMAP: EmailSubmission takes inbuxa:dlpOverride {reason}; a refusal
  comes back as inbuxa:dlpWarning or inbuxa:dlpBlocked with each rule's
  name and notice (description too, for older clients).
- Audit: one record per DLP match, the sender as actor, action create,
  target a message: the recipient domains, each rule with its detectors'
  counts, the outcome, an override's reason. Never the matched text. No
  new audit action: an older node that meets one fails its daily
  clean-up, which would make rolling back unsafe (spec §2.7 updated).

Tests: mail_rules_tests gains the DLP flow over JMAP (no rules, warning
with rule and notice, local recipient not warned, override with a
reason, block that no reason passes, the subject tag stripped from the
delivered message, audit records with no card or key text). smtp
inbound tests pass; system_tests passed twice after one timeout in the
email delivery tests that didn't recur.
2026-09-28 17:52:37 -07:00
jcoffey-dev a3a36cd5d7 Merge pull request 'DLP and mail flow rules: rules, engine, and inbuxa:MailRule over JMAP' (#103) from feature/dlp-rules into main
ci / fork-checks (push) Successful in 2m10s
ci / build (push) Canceled after 29m46s
2026-09-29 00:36:26 +00:00
jcoffey-dev 8afaee7d21 DLP and mail flow rules: inbuxa:MailRule over JMAP, and its permissions
ci / fork-checks (pull_request) Successful in 15s
ci / build (pull_request) Successful in 4m41s
Phase 2e of the DLP and mail flow rules spec, the API half.

- inbuxa:MailRule/get and /set under urn:inbuxa:jmap. Rules convert
  through serde, so what a client sends is the stored format. A create
  or change is validated whole (Rule::validate) and refused with the
  property at fault; id, createdBy, createdAt and updatedAt are the
  server's. Every change goes through the request layer's audit record.
- Six permissions, ids 674-679 (enum and schema labels): mail flow rules
  (sysMailRuleGet/Update), DLP rules (sysDlpPolicyGet/Update) and held
  mail (sysDlpReviewGet/Update, for phase 3). Either kind's permission
  gets through the gate; the handler shows and changes each rule only
  with its own kind's. All server-level: a tenant is refused (settled
  answer 3).
- Administrators get all six; the server-level Compliance Officer gets
  DLP rules to see and held mail to review (settled answer 4), added
  once to an existing server's officer role by the grant mechanism,
  which gains an officer audience.
- Privacy catalog entry for inbuxa:MailRule.

tests/src/system/mail_rules.rs: create, list in order, validation,
server-set properties refused, update, kind-separated permissions for
an officer, destroy, audit records.
2026-09-28 17:29:35 -07:00
jcoffey-dev c8280de9c3 DLP and mail flow rules: the rule model, the engine and the node cache
Phase 2e of the DLP and mail flow rules spec, in the features crate.

- rules.rs: a rule (§2.2) with its conditions (§2.3) and actions (§2.4),
  as JSON under R/r in the fork's subspace. validate() enforces the
  spec's shape: DLP rules check outgoing mail and have exactly one of
  block, warn or hold; transport rules have neither those nor
  detectors; lists, header names, header values (one line), addresses,
  texts, word lists, patterns and detector ids are checked.
- engine.rs: rules compiled once (word lists to automata, patterns to
  size-limited regexes) and run in priority order with exceptions and
  stop processing. Each detector runs at most once per message and
  only when a rule asks for it. The outcome lists what matched with
  each detector's count, and decides DLP strictest first: block, hold,
  warn; an override answers warnings only (§2.5).
- cache.rs: each node's compiled copy, refreshed after 30 seconds or at
  once when this node changes a rule.

Nothing calls this yet: the JMAP object and the check at DATA follow.
55 unit tests in mailflow.
2026-09-28 17:29:35 -07:00
jcoffey-dev f8b9df6438 Merge pull request 'DLP: regional identifiers and templates' (#102) from feature/dlp-detectors-us-uk-ca-au into main
ci / fork-checks (push) Successful in 59s
ci / build (push) Canceled after 14m34s
2026-09-29 00:21:51 +00:00
jcoffey-dev 92d14fbd60 DLP: regional identifiers and templates
ci / fork-checks (pull_request) Successful in 1m47s
ci / build (pull_request) Successful in 7m42s
Phase 2b of the DLP and mail flow rules spec: every identifier in the
§2.3 catalog, each implemented from its issuer's published rules and
tested against published examples.

US (SSN, ITIN, EIN, ABA routing, driver's licenses, MBI, NPI, DEA), UK
(NI number, NHS number, UTR), Canada (SIN), Australia (TFN, Medicare),
the EU (Germany's tax ID and ID card, France's NIR, Spain's DNI/NIE,
Italy's codice fiscale, the Dutch BSN, Belgium's national number,
Poland's PESEL, Sweden's personnummer, Denmark's CPR, Finland's HETU,
Ireland's PPS, Portugal's NIF, Austria's SVNR), Norway, Switzerland,
India (Aadhaar, PAN), China, Japan, Singapore, South Korea, Brazil (CPF,
CNPJ), Mexico (CURP) and South Africa. 49 detectors in all, plus seven
templates named for what they find.

An identifier that is only digits and whose check about one random
number in ten passes counts alone only in its written form
(536-22-1234, 943 476 5919) and as bare digits only beside a word; ABA
routing numbers and NPIs always need one. Spec §2.3 records this.

A test runs every detector over an ordinary business email (order,
invoice and tracking numbers, dates, amounts, an address) and requires
nothing to fire but the contact detectors. 47 unit tests.
2026-09-28 17:13:42 -07:00
jcoffey-dev 01f6b99631 Merge pull request 'DLP: the detector framework, the region-free detectors, word lists and attachment text' (#99) from feature/dlp-detectors into main
ci / fork-checks (push) Successful in 2m37s
ci / build (push) Canceled after 8m29s
2026-09-29 00:13:20 +00:00
jcoffey-dev 8d5e4ee052 Explain: don't prepare answers for date fields
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 3m54s
2026-09-28 17:11:32 -07:00
jcoffey-dev 9e0aab6b6a Webhooks: send one sample event to a saved webhook
ci / fork-checks (pull_request) Successful in 52s
ci / build (pull_request) Successful in 18m39s
2026-09-28 17:02:47 -07:00
jcoffey-dev 3eb5a454fd Cargo.lock: the features crate's new dependencies
ci / fork-checks (pull_request) Successful in 2m23s
ci / build (pull_request) Successful in 11m50s
2026-09-28 17:01:00 -07:00
jcoffey-dev dc49bf4d14 DLP: the detector framework, the region-free detectors, word lists and attachment text
ci / fork-checks (pull_request) Canceled after 8s
ci / build (pull_request) Canceled after 8s
Phase 2a of the DLP and mail flow rules spec: pure functions in
crates/features/src/mailflow, nothing wired into the mail path yet.

- Detectors report distinct values found, each either checked by its
  published check digit or counted only beside a corroborating word
  within 50 characters. This PR adds the region-free ones: payment
  cards (issuer prefixes, Luhn), IBAN (registry lengths, mod 97),
  SWIFT/BIC, email addresses and phone numbers in bulk, dates of birth,
  passport numbers, private keys and published service-token formats.
  Regional identifiers follow, a region per PR.
- Word lists (Aho-Corasick, whole words, any case) and patterns (regex
  with a compiled-size limit) count occurrences.
- Attachment text: text files with or without a UTF-16 mark, HTML,
  DOCX/XLSX/PPTX, ODT/ODS/ODP and ZIP archives one level deep, read
  with the zip and quick-xml crates the workspace already has.
  Encrypted files, PDF, legacy binary Office files, nested archives
  and anything past the limits come back as not inspectable, with why.

21 unit tests, against the networks' test card numbers and the IBAN
registry's own examples among others.
2026-09-28 17:00:49 -07:00
94 changed files with 17452 additions and 83 deletions
Generated
+6
View File
@@ -3947,9 +3947,14 @@ name = "inbuxa-features"
version = "0.16.22"
dependencies = [
"ahash",
"aho-corasick",
"base64 0.23.1",
"flate2",
"jmap_proto",
"mail-builder 1.0.0",
"mail-parser",
"quick-xml 0.41.0",
"regex",
"registry",
"serde",
"serde_json",
@@ -3961,6 +3966,7 @@ dependencies = [
"types",
"utils",
"xxhash-rust",
"zip",
]
[[package]]
+29
View File
@@ -165,6 +165,14 @@ impl AccessToken {
mut requested_permissions: Permissions,
) -> Result<(), Vec<Permission>> {
requested_permissions.difference(self.permissions_bits());
// inbuxa: journaling, JR-18: whoever sets up journals may give
// others (or, through a role, themselves) the reading of them,
// which administrators don't hold by default; the role change is
// in the audit log
if self.has_permission(Permission::SysJournalUpdate) {
requested_permissions.clear(Permission::SysJournalSearch as usize);
requested_permissions.clear(Permission::SysJournalExport as usize);
}
if requested_permissions.is_empty() {
Ok(())
} else {
@@ -296,6 +304,27 @@ impl Default for DefaultPermissions {
default.superuser.push(permission);
default.tenant.push(permission);
}
// inbuxa: DLP and mail flow rules, and held mail, are the
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
// settled answer 3)
Permission::SysMailRuleGet
| Permission::SysMailRuleUpdate
| Permission::SysDlpPolicyGet
| Permission::SysDlpPolicyUpdate
| Permission::SysDlpReviewGet
| Permission::SysDlpReviewUpdate
// inbuxa: every security check is server-wide (security
// to-do list spec)
| Permission::SysSecurityAccept => {
default.superuser.push(permission);
}
// inbuxa: journals are the server's; administrators set them
// up but read what's journaled only if granted it
// (journaling spec, JR-18, settled answer 5)
Permission::SysJournalGet | Permission::SysJournalUpdate => {
default.superuser.push(permission);
}
Permission::SysJournalSearch | Permission::SysJournalExport => {}
// inbuxa: AL-12: tenant administrators lock and delegate
// within their tenant
Permission::SysAccountLockGet
+3
View File
@@ -70,6 +70,7 @@ pub mod cache;
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
pub mod hold; // inbuxa: legal holds (audit-hold-lock spec, LH)
pub mod privacy; // inbuxa: the personal-data catalog, evaluated
pub mod reachability; // inbuxa: whether the outside world reaches each node's ports
pub mod config;
pub mod expr;
pub mod i18n;
@@ -129,6 +130,8 @@ pub const KV_LOCK_QUEUE_MESSAGE: u8 = 21;
pub const KV_LOCK_TASK: u8 = 23;
pub const KV_LOCK_DAV: u8 = 25;
pub const KV_SIEVE_ID: u8 = 26;
// inbuxa: far above upstream's prefixes, so a new one of theirs never collides
pub const KV_PORT_REACHABILITY: u8 = 200;
#[derive(Clone)]
pub struct Server {
+38 -7
View File
@@ -65,6 +65,14 @@ const OFFICER: &[Permission] = &[
Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldExport,
Permission::SysAccountLockGet,
// dlp-and-mail-flow-rules spec, §2.8: see DLP rules, review held mail
Permission::SysDlpPolicyGet,
Permission::SysDlpReviewGet,
Permission::SysDlpReviewUpdate,
// journaling spec, JR-18: see journals, search and export them
Permission::SysJournalGet,
Permission::SysJournalSearch,
Permission::SysJournalExport,
];
/// What a tenant's officer holds besides [`READS`].
@@ -113,6 +121,11 @@ fn created_key(tenant: Option<Id>) -> ValueClass {
})
}
/// The server-level Compliance Officer role the server made, if it has.
pub async fn server_role(data: &Store) -> trc::Result<Option<Id>> {
recorded(data, None).await
}
async fn recorded(data: &Store, tenant: Option<Id>) -> trc::Result<Option<Id>> {
Ok(data
.get_value::<u64>(ValueKey::from(created_key(tenant)))
@@ -172,13 +185,19 @@ pub async fn ensure_compliance_roles(registry: &RegistryStore, data: &Store) ->
/// A new tenant gets its Compliance Officer role.
pub async fn tenant_created(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<()> {
create_once(registry, data, Some(tenant), tenant_role(tenant)).await.map(|_| ())
create_once(registry, data, Some(tenant), tenant_role(tenant))
.await
.map(|_| ())
}
/// Before a tenant is deleted: removes its Compliance Officer role if nobody
/// holds it, so the role doesn't block the delete. Returns whether it did,
/// so a delete refused for another reason can put it back.
pub async fn tenant_deleting(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<bool> {
pub async fn tenant_deleting(
registry: &RegistryStore,
data: &Store,
tenant: Id,
) -> trc::Result<bool> {
let Some(role) = recorded(data, Some(tenant)).await? else {
return Ok(false);
};
@@ -220,7 +239,9 @@ mod tests {
// Beyond what any user holds for their own account
for permission in all.into_iter().filter(|p| !user.contains(p)) {
let name = permission.as_str();
let holds = name.starts_with("sysLegalHold");
// Placing holds and reviewing held mail are the officer's
// job, not settings (settled answers 2 and 4)
let holds = name.starts_with("sysLegalHold") || name.starts_with("sysDlpReview");
assert!(
!(name.ends_with("Update") && !holds)
&& !(name.ends_with("Create") && !holds)
@@ -249,7 +270,11 @@ mod tests {
assert!(officer.contains(&hold));
assert!(!tenant.contains(&hold));
}
for both in [Permission::SysComplianceGet, Permission::SysAuditGet, Permission::SysAccountGet] {
for both in [
Permission::SysComplianceGet,
Permission::SysAuditGet,
Permission::SysAccountGet,
] {
assert!(officer.contains(&both) && tenant.contains(&both));
}
assert!(!officer.contains(&Permission::SysAuditSettingsUpdate));
@@ -257,9 +282,15 @@ mod tests {
#[test]
fn records_are_per_place() {
let ValueClass::Any(server) = created_key(None) else { panic!() };
let ValueClass::Any(a) = created_key(Some(Id::from(1u64))) else { panic!() };
let ValueClass::Any(b) = created_key(Some(Id::from(2u64))) else { panic!() };
let ValueClass::Any(server) = created_key(None) else {
panic!()
};
let ValueClass::Any(a) = created_key(Some(Id::from(1u64))) else {
panic!()
};
let ValueClass::Any(b) = created_key(Some(Id::from(2u64))) else {
panic!()
};
assert_eq!(server.key, b"Pc");
assert_ne!(a.key, b.key);
assert!(a.key.starts_with(b"Pc"));
@@ -31,7 +31,8 @@ use types::id::Id;
/// Granted to the default administrator roles: "Explain this"
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
/// the data inventory (personal-data catalog spec).
/// the data inventory (personal-data catalog spec), and accepting security
/// to-do items (security to-do list spec).
const ADMIN_GRANTS: &[Permission] = &[
Permission::SysAiExplain,
Permission::SysAuditGet,
@@ -46,6 +47,28 @@ const ADMIN_GRANTS: &[Permission] = &[
Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldExport,
Permission::SysComplianceGet,
Permission::SysMailRuleGet,
Permission::SysMailRuleUpdate,
Permission::SysDlpPolicyGet,
Permission::SysDlpPolicyUpdate,
Permission::SysDlpReviewGet,
Permission::SysDlpReviewUpdate,
Permission::SysJournalGet,
Permission::SysJournalUpdate,
Permission::SysSecurityAccept,
];
/// Granted to the server-level Compliance Officer role once it exists:
/// seeing DLP rules and reviewing held mail (dlp-and-mail-flow-rules spec,
/// §2.8, settled answer 4). A new install's role has them from the start.
const OFFICER_GRANTS: &[Permission] = &[
Permission::SysDlpPolicyGet,
Permission::SysDlpReviewGet,
Permission::SysDlpReviewUpdate,
// journaling spec, JR-18: see journals, search and export them
Permission::SysJournalGet,
Permission::SysJournalSearch,
Permission::SysJournalExport,
];
/// Granted to the default tenant administrator roles: reading and exporting
@@ -65,13 +88,16 @@ const TENANT_GRANTS: &[Permission] = &[
enum Audience {
Admin,
Tenant,
Officer,
}
fn granted_key(permission: Permission, audience: Audience) -> ValueClass {
let mut key = b"Pg".to_vec();
// Admin grants keep the key they were first recorded under
if audience == Audience::Tenant {
key.extend_from_slice(b"tenant:");
match audience {
Audience::Admin => {}
Audience::Tenant => key.extend_from_slice(b"tenant:"),
Audience::Officer => key.extend_from_slice(b"officer:"),
}
key.extend_from_slice(permission.as_str().as_bytes());
ValueClass::Any(AnyClass {
@@ -82,7 +108,8 @@ fn granted_key(permission: Permission, audience: Audience) -> ValueClass {
pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Result<()> {
grant(bp, Audience::Admin, ADMIN_GRANTS).await?;
grant(bp, Audience::Tenant, TENANT_GRANTS).await
grant(bp, Audience::Tenant, TENANT_GRANTS).await?;
grant(bp, Audience::Officer, OFFICER_GRANTS).await
}
async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> trc::Result<()> {
@@ -101,10 +128,17 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
if pending.is_empty() {
return Ok(());
}
// The officer role is the one the server made, if it has made it yet: a
// new install makes it after this, with the permissions already in it
let admin_roles: Vec<Id> = if audience == Audience::Officer {
super::compliance_roles::server_role(&bp.data_store)
.await?
.into_iter()
.collect()
} else {
// An administrator's default roles include the plain User role, which
// every user also holds; only roles that are the audience's alone get it
let admin_roles: Vec<Id> = bp
.registry
bp.registry
.object::<Authentication>(Id::singleton())
.await?
.map(|auth| {
@@ -118,7 +152,7 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
]
.concat(),
),
Audience::Tenant => (
Audience::Tenant | Audience::Officer => (
auth.default_tenant_role_ids.as_slice(),
[
auth.default_user_role_ids.as_slice(),
@@ -133,7 +167,8 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
.copied()
.collect()
})
.unwrap_or_default();
.unwrap_or_default()
};
// Fetched by id: the registry's listing doesn't reach stored roles
for role_id in admin_roles {
let Some(stored) = bp
+293
View File
@@ -0,0 +1,293 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Whether the outside world can reach each node's ports (settings-reorg,
//! Ports: the reachability check).
//!
//! A server can't answer this about itself: a connection to its own public
//! address never leaves the machine, so it passes whatever the firewall in
//! front says. In a cluster the other nodes are outside that machine. Every
//! ten minutes each node resolves every other active node's hostname, as a
//! sender would, and tries a TCP connection to each listener port on each
//! address. What it saw goes in the shared in-memory store for an hour, under
//! (target, prober), so whichever node the admin asks can report it all.
//!
//! A single server has no one outside to ask. It reports only whether each
//! port is listening, and says so.
//!
//! A connection is all that's tried: nothing is sent, so no protocol logs a
//! session and no rate limit counts it.
use crate::{KV_PORT_REACHABILITY, Server};
use registry::schema::{enums::ClusterNodeStatus, structs::NetworkListener};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{
collections::BTreeSet,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
time::{Duration, Instant},
};
use store::{dispatch::lookup::KeyValue, write::now};
/// How often each node probes the others.
pub const PROBE_INTERVAL: Duration = Duration::from_secs(600);
/// How long one node's view of another is kept: long enough to span a missed round.
const KEEP_FOR: u64 = 3600;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Probe {
pub port: u16,
pub address: String,
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Report {
/// Unix seconds.
pub checked_at: u64,
pub probes: Vec<Probe>,
/// The hostname didn't resolve, so nothing could be tried.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// The ports a sender or client could reach: every listener's port, leaving
/// out listeners bound only to loopback, which are private by design.
pub fn public_ports<'x>(listeners: impl IntoIterator<Item = &'x NetworkListener>) -> Vec<u16> {
listeners
.into_iter()
.flat_map(|l| l.bind.iter())
.map(|addr| addr.0)
.filter(|addr| !addr.ip().is_loopback())
.map(|addr| addr.port())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn key(target: &str, prober: &str) -> Vec<u8> {
format!("{target}\n{prober}").into_bytes()
}
async fn connect(address: SocketAddr) -> Result<(), String> {
match tokio::time::timeout(CONNECT_TIMEOUT, tokio::net::TcpStream::connect(address)).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(err)) => Err(err.to_string()),
Err(_) => Err("no answer within 5 seconds".into()),
}
}
/// Tries each port on each address `hostname` resolves to.
pub async fn probe_host(hostname: &str, ports: &[u16]) -> Report {
let checked_at = now();
let addresses = match tokio::net::lookup_host((hostname, 0)).await {
Ok(found) => found.map(|a| a.ip()).collect::<BTreeSet<_>>(),
Err(err) => {
return Report {
checked_at,
probes: vec![],
error: Some(format!("{hostname} doesn't resolve: {err}")),
};
}
};
let tries = addresses.iter().flat_map(|ip| {
ports.iter().map(move |port| {
let address = SocketAddr::new(*ip, *port);
async move {
let result = connect(address).await;
Probe {
port: *port,
address: ip.to_string(),
ok: result.is_ok(),
error: result.err(),
}
}
})
});
Report {
checked_at,
probes: futures::future::join_all(tries).await,
error: None,
}
}
async fn listeners(server: &Server) -> trc::Result<Vec<NetworkListener>> {
Ok(server
.registry()
.list::<NetworkListener>()
.await?
.into_iter()
.map(|l| l.object)
.collect())
}
/// Where to knock to see a port listening on this machine: the bound
/// address, or loopback of the same family for a wildcard bind.
pub fn local_targets<'x>(
listeners: impl IntoIterator<Item = &'x NetworkListener>,
) -> Vec<SocketAddr> {
listeners
.into_iter()
.flat_map(|l| l.bind.iter())
.map(|addr| addr.0)
.filter(|addr| !addr.ip().is_loopback())
.map(|addr| match addr.ip() {
IpAddr::V4(ip) if ip.is_unspecified() => {
SocketAddr::new(Ipv4Addr::LOCALHOST.into(), addr.port())
}
IpAddr::V6(ip) if ip.is_unspecified() => {
SocketAddr::new(Ipv6Addr::LOCALHOST.into(), addr.port())
}
_ => addr,
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
/// One round: this node probes every other active node and records what it saw.
pub async fn probe_peers(server: &Server) -> trc::Result<()> {
let nodes = server.registry().cluster_node_list().await?;
let me = server.registry().node_id() as u64;
let Some(prober) = nodes
.iter()
.find(|n| n.node_id == me)
.map(|n| n.hostname.clone())
else {
return Ok(());
};
let ports = public_ports(&listeners(server).await?);
for target in nodes.iter().filter(|n| {
n.node_id != me && n.status == ClusterNodeStatus::Active && n.hostname != prober
}) {
let report = probe_host(&target.hostname, &ports).await;
server
.in_memory_store()
.key_set(
KeyValue::with_prefix(
KV_PORT_REACHABILITY,
key(&target.hostname, &prober),
serde_json::to_vec(&report).unwrap_or_default(),
)
.expires(KEEP_FOR),
)
.await?;
}
Ok(())
}
/// What `GET /api/ports/check` answers.
pub async fn report(server: &Server) -> trc::Result<Value> {
let listeners = listeners(server).await?;
let ports = public_ports(&listeners);
let nodes = if server.core.storage.coordinator.is_enabled() {
server.registry().cluster_node_list().await?
} else {
vec![]
};
let active = nodes
.iter()
.filter(|n| n.status == ClusterNodeStatus::Active)
.collect::<Vec<_>>();
if active.len() < 2 {
// No one outside to ask: only whether each port is listening here.
let started = Instant::now();
let listening = futures::future::join_all(local_targets(&listeners).into_iter().map(
|address| async move {
let result = connect(address).await;
json!({ "port": address.port(), "address": address.ip().to_string(), "listening": result.is_ok() })
},
))
.await;
return Ok(json!({
"mode": "local",
"ports": ports,
"listening": listening,
"ms": started.elapsed().as_millis() as u64,
}));
}
let mut out = Vec::new();
for target in &active {
let mut seen_by = Vec::new();
for prober in active.iter().filter(|p| p.node_id != target.node_id) {
let stored = server
.in_memory_store()
.key_get::<String>(KeyValue::<()>::build_key(
KV_PORT_REACHABILITY,
key(&target.hostname, &prober.hostname),
))
.await?;
let report = stored.and_then(|raw| serde_json::from_str::<Report>(&raw).ok());
seen_by.push(json!({ "prober": prober.hostname, "report": report }));
}
out.push(json!({ "hostname": target.hostname, "seenBy": seen_by }));
}
Ok(json!({
"mode": "cluster",
"ports": ports,
"intervalSeconds": PROBE_INTERVAL.as_secs(),
"nodes": out,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn listener(binds: &[&str]) -> NetworkListener {
NetworkListener {
bind: registry::schema::prelude::Map::new(
binds.iter().map(|b| b.parse().unwrap()).collect(),
),
..Default::default()
}
}
#[test]
fn public_ports_leave_out_loopback_only_listeners() {
let listeners = [
listener(&["[::]:25"]),
listener(&["0.0.0.0:993", "[::]:993"]),
listener(&["127.0.0.1:8080"]),
listener(&["203.0.113.5:465"]),
];
assert_eq!(public_ports(listeners.iter()), vec![25, 465, 993]);
assert_eq!(
local_targets(listeners.iter())
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
vec!["127.0.0.1:993", "203.0.113.5:465", "[::1]:25", "[::1]:993"]
);
}
#[tokio::test]
async fn probe_host_reports_open_and_closed_ports() {
let open = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let open_port = open.local_addr().unwrap().port();
let closed_port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let report = probe_host("127.0.0.1", &[open_port, closed_port]).await;
assert_eq!(report.error, None);
let ok = |port| report.probes.iter().find(|p| p.port == port).unwrap().ok;
assert!(ok(open_port));
assert!(!ok(closed_port));
}
#[tokio::test]
async fn probe_host_says_when_a_name_does_not_resolve() {
let report = probe_host("does-not-exist.invalid", &[25]).await;
assert!(report.probes.is_empty());
assert!(report.error.unwrap().contains("doesn't resolve"));
}
}
+148 -9
View File
@@ -156,15 +156,7 @@ async fn post_webhook_events(
// Add HMAC-SHA256 signature
let mut headers = settings.headers.clone();
if !settings.key.is_empty() {
let key = hmac::Key::new(hmac::HMAC_SHA256, settings.key.as_bytes());
let tag = hmac::sign(&key, body.as_bytes());
headers.insert(
"X-Signature",
STANDARD.encode(tag.as_ref()).parse().unwrap(),
);
}
sign(&mut headers, &settings.key, &body);
// Send request
let response = settings
@@ -188,3 +180,150 @@ async fn post_webhook_events(
))
}
}
/// Adds the HMAC-SHA256 `X-Signature` a receiver checks, when the webhook has a key.
fn sign(headers: &mut hyper::HeaderMap, key: &str, body: &str) {
if !key.is_empty() {
let key = hmac::Key::new(hmac::HMAC_SHA256, key.as_bytes());
let tag = hmac::sign(&key, body.as_bytes());
headers.insert(
"X-Signature",
STANDARD.encode(tag.as_ref()).parse().unwrap(),
);
}
}
/// inbuxa: "Send test" for a saved webhook (settings-reorg, Webhooks). One
/// sample event, sent the way a real batch is: the same URL, headers, sign-in,
/// signature, timeout and certificate checks. The event's type,
/// `webhook.test`, is none the server raises, and an `X-Inbuxa-Test` header
/// marks it, so a receiver can tell it apart. Answers the HTTP status, or why
/// nothing came back.
pub async fn send_test(hook: &registry::schema::structs::WebHook) -> Result<u16, String> {
let mut headers = hook
.http_auth
.build_headers(hook.http_headers.clone(), "application/json".into())
.await
.map_err(|err| format!("Unable to build HTTP headers: {err}"))?;
let key = hook
.signature_key
.secret()
.await
.map_err(|err| format!("Unable to retrieve signature key: {err}"))?
.unwrap_or_default()
.into_owned();
let created = now();
let body = serde_json::json!({
"events": [{
"id": format!("test-{created}"),
"createdAt": mail_parser::DateTime::from_timestamp(created as i64).to_rfc3339(),
"type": "webhook.test",
"data": { "details": "A test from inbuxa Admin. Nothing happened on the server." },
}]
})
.to_string();
sign(&mut headers, &key, &body);
headers.insert("X-Inbuxa-Test", "true".parse().unwrap());
let response = utils::http::http_client_builder(hook.allow_invalid_certs)
.build()
.map_err(|err| format!("Unable to build an HTTP client: {err}"))?
.post(&hook.url)
.timeout(hook.timeout.into_inner())
.headers(headers)
.body(body)
.send()
.await
.map_err(|err| format!("Webhook request to {} failed: {err}", hook.url))?;
Ok(response.status().as_u16())
}
#[cfg(test)]
mod tests {
use super::*;
use registry::schema::structs::{SecretKeyOptional, SecretKeyValue, WebHook};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// One request in, the given status out; hands back what was received.
async fn receiver(status: &'static str) -> (String, tokio::task::JoinHandle<String>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/hook", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
loop {
let n = socket.read(&mut chunk).await.unwrap();
buf.extend_from_slice(&chunk[..n]);
let text = String::from_utf8_lossy(&buf);
if let Some(end) = text.find("\r\n\r\n") {
let length = text[..end]
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
if buf.len() >= end + 4 + length || n == 0 {
break;
}
}
}
socket
.write_all(
format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
.as_bytes(),
)
.await
.unwrap();
String::from_utf8_lossy(&buf).into_owned()
});
(url, task)
}
#[tokio::test]
async fn send_test_signs_and_marks_the_sample() {
let (url, task) = receiver("204 No Content").await;
let hook = WebHook {
url,
enable: false,
signature_key: SecretKeyOptional::Value(SecretKeyValue { secret: "k".into() }),
..Default::default()
};
assert_eq!(send_test(&hook).await, Ok(204));
let request = task.await.unwrap();
let (head, body) = request.split_once("\r\n\r\n").unwrap();
let head = head.to_ascii_lowercase();
assert!(head.contains("x-inbuxa-test: true"), "{head}");
let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(parsed["events"][0]["type"], "webhook.test");
let tag = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, b"k"), body.as_bytes());
assert!(
head.contains(&format!(
"x-signature: {}",
STANDARD.encode(tag.as_ref()).to_ascii_lowercase()
)),
"{head}"
);
}
#[tokio::test]
async fn send_test_reports_what_came_back() {
let (url, _task) = receiver("403 Forbidden").await;
let hook = WebHook {
url,
..Default::default()
};
assert_eq!(send_test(&hook).await, Ok(403));
let hook = WebHook {
url: "http://127.0.0.1:9/hook".into(),
..Default::default()
};
assert!(send_test(&hook).await.unwrap_err().contains("failed"));
}
}
+7
View File
@@ -21,6 +21,13 @@ base64 = "0.23"
sha2 = "0.11"
flate2 = "1.1"
tokio = { version = "1.53", features = ["sync", "rt"] }
# inbuxa: DLP detectors and attachment text (dlp-and-mail-flow-rules spec)
regex = "1.13.1"
aho-corasick = "1.1"
zip = "8.6"
quick-xml = "0.41"
mail-parser = { version = "0.11", features = ["full_encoding"] }
mail-builder = { version = "1.0" }
[dev-dependencies]
tokio = { version = "1.53", features = ["macros", "rt"] }
+120
View File
@@ -0,0 +1,120 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Reports on their way to an outside archive (JR-7). Keys, after `J`:
//!
//! - `o` + the report's queue id: what goes into the built-in journal if
//! the archive never takes the report, as JSON. Cleared once it's
//! delivered or kept.
//! - `w` + journal id (u32): how often that journal's archive didn't take a
//! report, and the last time and reason, for the console's warning.
use super::{FEATURE, Json, entries::Entry};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
const KIND_PENDING: u8 = b'o';
const KIND_FAILURES: u8 = b'w';
/// A report queued to an archive.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Pending {
pub address: String,
/// The entry, should the archive not take it: its own, with the
/// sending journals' retention, whatever else the built-in journal has.
pub entry: Entry,
}
/// How a journal's archive has been taking its reports.
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Failures {
pub count: u64,
/// Seconds.
pub last_at: u64,
pub last_reason: String,
}
fn class(kind: u8, id: &[u8]) -> ValueClass {
let mut key = Vec::with_capacity(2 + id.len());
key.push(FEATURE);
key.push(kind);
key.extend_from_slice(id);
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
pub async fn set_pending(data: &Store, queue_id: u64, pending: &Pending) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(
class(KIND_PENDING, &queue_id.to_be_bytes()),
Json(pending).serialize()?,
);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
pub async fn pending(data: &Store, queue_id: u64) -> trc::Result<Option<Pending>> {
Ok(data
.get_value::<Json<Pending>>(ValueKey::from(class(KIND_PENDING, &queue_id.to_be_bytes())))
.await
.caused_by(trc::location!())?
.map(|Json(pending)| pending))
}
pub async fn clear_pending(data: &Store, queue_id: u64) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.clear(class(KIND_PENDING, &queue_id.to_be_bytes()));
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
pub async fn failures(data: &Store, journal_id: u32) -> trc::Result<Failures> {
Ok(data
.get_value::<Json<Failures>>(ValueKey::from(class(
KIND_FAILURES,
&journal_id.to_be_bytes(),
)))
.await
.caused_by(trc::location!())?
.map(|Json(failures)| failures)
.unwrap_or_default())
}
/// Counts one report an archive didn't take, for each of `journals`.
pub async fn record_failure(
data: &Store,
journals: &[u32],
at: u64,
reason: &str,
) -> trc::Result<()> {
for journal_id in journals {
let mut failures = failures(data, *journal_id).await?;
failures.count += 1;
failures.last_at = at;
failures.last_reason = reason.chars().take(500).collect();
let mut batch = BatchBuilder::new();
batch.set(
class(KIND_FAILURES, &journal_id.to_be_bytes()),
Json(&failures).serialize()?,
);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
}
Ok(())
}
+869
View File
@@ -0,0 +1,869 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The built-in journal (JR-5, JR-6, JR-13). Keys, after `J`:
//!
//! - `e` + node + seq: a chain link: its seq, the hash of the link before
//! it, and the SHA-256 of its entry. One chain per node, as the audit log
//! keeps (AU-6), but a link names its entry by hash instead of holding it,
//! so an entry can go at the end of its own retention without breaking
//! the chain: entries don't expire in chain order.
//! - `c` + node + seq: the entry, as JSON; its bytes are what the link's
//! hash names.
//! - `p` + node + seq: when an entry past its retention was purged. A link
//! whose entry is gone without this marker is a broken chain.
//! - `t` + time + node + seq: the time index, for search.
//! - `x` + expiry + node + seq: the expiry index, for purge.
//! - `h` + node: the chain's head: its hash, then its seq as the last eight
//! bytes, which each append asserts.
//! - `f` + node: where the chain starts after purged links at its start
//! were cleared, and the hash the first kept link names.
//!
//! The report itself is a blob, kept by a temporary link that lasts until
//! its entry is purged. Nothing here changes or removes an entry before
//! its time; nothing in JMAP can.
use super::{Direction, FEATURE, Json};
use crate::hold::HELD_UNTIL;
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use sha2::{Digest, Sha256};
use std::fmt;
use store::{
BlobStore, Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, BlobLink, BlobOp, ValueClass, assert::AssertValue},
};
use tokio::sync::Mutex;
use trc::AddContext;
use types::blob_hash::BlobHash;
const KIND_LINK: u8 = b'e';
const KIND_CONTENT: u8 = b'c';
const KIND_PURGED: u8 = b'p';
const KIND_TIME: u8 = b't';
const KIND_EXPIRY: u8 = b'x';
const KIND_HEAD: u8 = b'h';
const KIND_FLOOR: u8 = b'f';
const APPEND_ATTEMPTS: usize = 5;
/// Entries purged per batch.
const PURGE_BATCH: usize = 100;
/// Where one entry sits: its node's chain and its place in it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct EntryId {
pub node: u64,
pub seq: u64,
}
impl EntryId {
/// As one number, for JMAP ids: the node in the top 16 bits.
pub fn to_u64(&self) -> u64 {
(self.node << 48) | (self.seq & ((1 << 48) - 1))
}
pub fn from_u64(id: u64) -> Self {
EntryId {
node: id >> 48,
seq: id & ((1 << 48) - 1),
}
}
}
impl fmt::Display for EntryId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}-{}", self.node, self.seq)
}
}
/// One journaled message (JR-5).
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Entry {
pub queue_id: u64,
/// Seconds.
pub at: u64,
pub direction: Direction,
pub sender: String,
pub authenticated: bool,
pub recipients: Vec<String>,
pub subject: String,
pub message_id: String,
/// The people here on either side, whose holds keep the entry.
pub accounts: Vec<u32>,
pub tenants: Vec<u32>,
/// The journals that took it.
pub journals: Vec<u32>,
pub held: bool,
/// The report's blob, hex.
pub blob: String,
pub size: u64,
/// SHA-256 of the report, hex.
pub sha256: String,
/// Seconds.
pub expires_at: u64,
}
impl Entry {
pub fn blob_hash(&self) -> Option<BlobHash> {
let bytes = unhex(&self.blob)?;
BlobHash::try_from_hash_slice(&bytes).ok()
}
}
#[derive(Debug, Clone, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
struct Link {
seq: u64,
prev: String,
content: String,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
struct Floor {
seq: u64,
prev: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
struct Head {
seq: u64,
hash: String,
}
impl Head {
fn to_bytes(&self) -> Vec<u8> {
let mut bytes = self.hash.as_bytes().to_vec();
bytes.extend_from_slice(&self.seq.to_be_bytes());
bytes
}
}
impl Deserialize for Head {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
let split = bytes.len().checked_sub(8).ok_or_else(|| {
trc::StoreEvent::DataCorruption
.into_err()
.details("Invalid journal chain head")
})?;
Ok(Head {
seq: u64::from_be_bytes(bytes[split..].try_into().unwrap()),
hash: String::from_utf8_lossy(&bytes[..split]).into_owned(),
})
}
}
struct Raw(Vec<u8>);
impl Deserialize for Raw {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
Ok(Raw(bytes.to_vec()))
}
}
fn class(kind: u8, parts: &[u64]) -> ValueClass {
let mut key = Vec::with_capacity(2 + parts.len() * 8);
key.push(FEATURE);
key.push(kind);
for part in parts {
key.extend_from_slice(&part.to_be_bytes());
}
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
fn key(kind: u8, parts: &[u64]) -> ValueKey<ValueClass> {
ValueKey::from(class(kind, parts))
}
/// Where an entry's content is kept, for tests that check tampering shows.
pub fn content_key(id: EntryId) -> ValueKey<ValueClass> {
key(KIND_CONTENT, &[id.node, id.seq])
}
/// The numbers after the kind byte, from the key's tail.
fn parse_key(key: &[u8], kind: u8, parts: usize) -> Option<Vec<u64>> {
let len = 2 + parts * 8;
let tail = key.get(key.len().checked_sub(len)?..)?;
(tail[0] == FEATURE && tail[1] == kind).then_some(())?;
Some(
tail[2..]
.chunks_exact(8)
.map(|chunk| u64::from_be_bytes(chunk.try_into().unwrap()))
.collect(),
)
}
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn unhex(value: &str) -> Option<Vec<u8>> {
(value.len() % 2 == 0).then_some(())?;
(0..value.len())
.step_by(2)
.map(|i| u8::from_str_radix(value.get(i..i + 2)?, 16).ok())
.collect()
}
pub fn sha256(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}
async fn head(data: &Store, node: u64) -> trc::Result<Option<Head>> {
data.get_value::<Head>(key(KIND_HEAD, &[node]))
.await
.caused_by(trc::location!())
}
async fn floor(data: &Store, node: u64) -> trc::Result<Floor> {
Ok(data
.get_value::<Json<Floor>>(key(KIND_FLOOR, &[node]))
.await
.caused_by(trc::location!())?
.map(|Json(floor)| floor)
.unwrap_or(Floor {
seq: 1,
prev: String::new(),
}))
}
async fn nodes(data: &Store) -> trc::Result<Vec<u64>> {
let mut nodes = Vec::new();
data.iterate(
IterateParams::new(key(KIND_HEAD, &[0]), key(KIND_HEAD, &[u64::MAX])).no_values(),
|key, _| {
if let Some(parts) = parse_key(key, KIND_HEAD, 1) {
nodes.push(parts[0]);
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
Ok(nodes)
}
/// Lines up this process's appends; the store's assert settles the rest.
static APPENDING: Mutex<()> = Mutex::const_new(());
/// Adds an entry to this node's chain, and links its report's blob (already
/// written) until the entry is purged. An error means nothing was written.
pub async fn append(data: &Store, node: u64, entry: &Entry) -> trc::Result<EntryId> {
let blob = entry.blob_hash().ok_or_else(|| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Journal entry without a blob")
})?;
let content = Json(entry).serialize()?;
let content_hash = sha256(&content);
let _appending = APPENDING.lock().await;
let mut attempt = 0;
loop {
attempt += 1;
let current = head(data, node).await?;
let (seq, prev) = current
.as_ref()
.map_or((1, String::new()), |head| (head.seq + 1, head.hash.clone()));
let link = Json(&Link {
seq,
prev,
content: content_hash.clone(),
})
.serialize()?;
let new_head = Head {
seq,
hash: sha256(&link),
};
let mut batch = BatchBuilder::new();
batch.assert_value(
class(KIND_HEAD, &[node]),
current.map_or(AssertValue::None, |head| AssertValue::U64(head.seq)),
);
batch
.set(class(KIND_LINK, &[node, seq]), link)
.set(class(KIND_CONTENT, &[node, seq]), content.clone())
.set(class(KIND_TIME, &[entry.at, node, seq]), vec![])
.set(class(KIND_EXPIRY, &[entry.expires_at, node, seq]), vec![])
.set(class(KIND_HEAD, &[node]), new_head.to_bytes())
.set(
BlobOp::Link {
hash: blob.clone(),
to: BlobLink::Temporary { until: HELD_UNTIL },
},
vec![],
)
.set(BlobOp::Commit { hash: blob.clone() }, vec![]);
match data.write(batch.build_all()).await {
Ok(_) => return Ok(EntryId { node, seq }),
Err(err)
if attempt < APPEND_ATTEMPTS
&& matches!(
err.as_ref(),
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
) => {}
Err(err) => return Err(err.caused_by(trc::location!())),
}
}
}
/// One entry, unless it was purged.
pub async fn get(data: &Store, id: EntryId) -> trc::Result<Option<Entry>> {
Ok(data
.get_value::<Json<Entry>>(key(KIND_CONTENT, &[id.node, id.seq]))
.await
.caused_by(trc::location!())?
.map(|Json(entry)| entry))
}
/// Entries written in `[after, before)` (seconds), newest first, up to
/// `limit`.
pub async fn list(
data: &Store,
after: u64,
before: u64,
limit: usize,
) -> trc::Result<Vec<(EntryId, Entry)>> {
let mut ids = Vec::new();
data.iterate(
IterateParams::new(
key(KIND_TIME, &[after, 0, 0]),
key(KIND_TIME, &[before.saturating_sub(1), u64::MAX, u64::MAX]),
)
.descending()
.no_values(),
|key, _| {
if let Some(parts) = parse_key(key, KIND_TIME, 3) {
ids.push(EntryId {
node: parts[1],
seq: parts[2],
});
}
Ok(ids.len() < limit)
},
)
.await
.caused_by(trc::location!())?;
let mut out = Vec::with_capacity(ids.len());
for id in ids {
if let Some(entry) = get(data, id).await? {
out.push((id, entry));
}
}
Ok(out)
}
/// Most results one search page returns.
pub const MAX_QUERY_LIMIT: usize = 500;
/// A search of the journal (JR-15): conditions that must all hold.
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize)]
#[serde(rename_all = "camelCase")]
pub struct Filter {
/// From this time on, in seconds.
#[serde(skip_serializing_if = "Option::is_none")]
pub after: Option<u64>,
/// Before this time, in seconds.
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<u64>,
/// Part of the sender's address, ignoring case.
#[serde(skip_serializing_if = "Option::is_none")]
pub sender: Option<String>,
/// Part of any recipient's address, ignoring case.
#[serde(skip_serializing_if = "Option::is_none")]
pub recipient: Option<String>,
/// Part of the sender's or any recipient's address.
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub direction: Option<Direction>,
/// Words that must all appear in the subject, ignoring case.
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub journal_id: Option<u32>,
}
impl Filter {
pub fn matches(&self, entry: &Entry) -> bool {
let has = |value: &str, part: &str| value.to_lowercase().contains(&part.to_lowercase());
self.after.is_none_or(|after| entry.at >= after)
&& self.before.is_none_or(|before| entry.at < before)
&& self.sender.as_deref().is_none_or(|s| has(&entry.sender, s))
&& self
.recipient
.as_deref()
.is_none_or(|r| entry.recipients.iter().any(|a| has(a, r)))
&& self
.address
.as_deref()
.is_none_or(|a| has(&entry.sender, a) || entry.recipients.iter().any(|r| has(r, a)))
&& self
.direction
.is_none_or(|d| d == Direction::Any || d == entry.direction)
&& self.text.as_deref().is_none_or(|text| {
let subject = entry.subject.to_lowercase();
text.to_lowercase()
.split_whitespace()
.all(|word| subject.contains(word))
})
&& self.message_id.as_deref().is_none_or(|id| {
entry.message_id.trim_matches(['<', '>']) == id.trim_matches(['<', '>'])
})
&& self.journal_id.is_none_or(|j| entry.journals.contains(&j))
}
}
/// Entries matching `filter`, newest first: a page from `position`, up to
/// `limit`, and, when asked, how many match in all.
pub async fn query(
data: &Store,
filter: &Filter,
position: usize,
limit: usize,
count_all: bool,
) -> trc::Result<(Vec<EntryId>, usize)> {
let after = filter.after.unwrap_or(0);
let before = filter.before.unwrap_or(u64::MAX);
let mut ids = Vec::new();
data.iterate(
IterateParams::new(
key(KIND_TIME, &[after, 0, 0]),
key(KIND_TIME, &[before.saturating_sub(1), u64::MAX, u64::MAX]),
)
.descending()
.no_values(),
|key, _| {
if let Some(parts) = parse_key(key, KIND_TIME, 3) {
ids.push(EntryId {
node: parts[1],
seq: parts[2],
});
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
let mut page = Vec::new();
let mut total = 0;
for id in ids {
let Some(entry) = get(data, id).await? else {
continue;
};
if !filter.matches(&entry) {
continue;
}
if total >= position && page.len() < limit {
page.push(id);
}
total += 1;
if !count_all && page.len() >= limit {
break;
}
}
Ok((page, total))
}
/// What a purge did.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Purged {
pub removed: usize,
/// Past their time, kept for a legal hold.
pub kept_for_hold: usize,
}
/// Removes entries past their retention (JR-13), except those `held` keeps:
/// the entry, its indexes and its blob's link go; the chain link stays,
/// with a purge marker. Then each chain's start moves past purged links.
pub async fn purge(
data: &Store,
now: u64,
held: impl Fn(&Entry) -> bool + Sync + Send,
) -> trc::Result<Purged> {
let mut due = Vec::new();
data.iterate(
IterateParams::new(
key(KIND_EXPIRY, &[0, 0, 0]),
key(KIND_EXPIRY, &[now, u64::MAX, u64::MAX]),
)
.ascending()
.no_values(),
|key, _| {
if let Some(parts) = parse_key(key, KIND_EXPIRY, 3) {
due.push((
parts[0],
EntryId {
node: parts[1],
seq: parts[2],
},
));
}
Ok(due.len() < 100_000)
},
)
.await
.caused_by(trc::location!())?;
let mut purged = Purged::default();
for chunk in due.chunks(PURGE_BATCH) {
let mut batch = BatchBuilder::new();
for (expires_at, id) in chunk {
let parts = [id.node, id.seq];
let Some(entry) = get(data, *id).await? else {
// Its entry is already gone: only the index is left
batch.clear(class(KIND_EXPIRY, &[*expires_at, id.node, id.seq]));
continue;
};
if held(&entry) {
purged.kept_for_hold += 1;
continue;
}
batch
.clear(class(KIND_CONTENT, &parts))
.clear(class(KIND_TIME, &[entry.at, id.node, id.seq]))
.clear(class(KIND_EXPIRY, &[*expires_at, id.node, id.seq]))
.set(class(KIND_PURGED, &parts), now.to_be_bytes().to_vec());
if let Some(blob) = entry.blob_hash() {
batch.clear(BlobOp::Link {
hash: blob,
to: BlobLink::Temporary { until: HELD_UNTIL },
});
}
purged.removed += 1;
}
if !batch.is_empty() {
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
}
}
for node in nodes(data).await? {
advance_floor(data, node).await?;
}
Ok(purged)
}
/// Clears the purged links at the start of a node's chain, recording where
/// it now starts and the hash that start names.
async fn advance_floor(data: &Store, node: u64) -> trc::Result<()> {
let start = floor(data, node).await?;
let mut cleared: Vec<u64> = Vec::new();
let mut next = start.clone();
let mut purged_seqs = Vec::new();
data.iterate(
IterateParams::new(
key(KIND_PURGED, &[node, start.seq]),
key(KIND_PURGED, &[node, u64::MAX]),
)
.ascending()
.no_values(),
|key, _| {
if let Some(parts) = parse_key(key, KIND_PURGED, 2) {
purged_seqs.push(parts[1]);
}
Ok(purged_seqs.len() < 100_000)
},
)
.await
.caused_by(trc::location!())?;
for seq in purged_seqs {
if seq != next.seq {
break;
}
let Some(Raw(link)) = data
.get_value::<Raw>(key(KIND_LINK, &[node, seq]))
.await
.caused_by(trc::location!())?
else {
break;
};
next = Floor {
seq: seq + 1,
prev: sha256(&link),
};
cleared.push(seq);
}
if cleared.is_empty() {
return Ok(());
}
// The floor moves first: a run cut short leaves links before it, which
// the next run clears, never a chain that looks broken
let mut batch = BatchBuilder::new();
batch.set(class(KIND_FLOOR, &[node]), Json(&next).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
for chunk in cleared.chunks(PURGE_BATCH) {
let mut batch = BatchBuilder::new();
for seq in chunk {
batch
.clear(class(KIND_LINK, &[node, *seq]))
.clear(class(KIND_PURGED, &[node, *seq]));
}
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
}
Ok(())
}
/// One node's chain, as [`verify`] found it.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize)]
#[serde(rename_all = "camelCase")]
pub struct ChainReport {
pub node: u64,
pub entries: u64,
pub purged: u64,
pub first_seq: u64,
pub last_seq: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub broken_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
/// Rechecks every node's chain (JR-6): each link names the hash of the one
/// before it, seqs run without gaps, the head matches the last link, each
/// entry hashes to what its link names or was purged, and, with `blobs`,
/// each report is there and hashes to what its entry names.
pub async fn verify(data: &Store, blobs: Option<&BlobStore>) -> trc::Result<Vec<ChainReport>> {
let mut reports = Vec::new();
for node in nodes(data).await? {
let start = floor(data, node).await?;
let head = head(data, node).await?.unwrap_or_default();
let mut report = ChainReport {
node,
entries: 0,
purged: 0,
first_seq: start.seq,
last_seq: start.seq.saturating_sub(1),
broken_at: None,
reason: None,
};
let mut links = Vec::new();
data.iterate(
IterateParams::new(
key(KIND_LINK, &[node, start.seq]),
key(KIND_LINK, &[node, u64::MAX]),
)
.ascending(),
|key, value| {
if let Some(parts) = parse_key(key, KIND_LINK, 2) {
links.push((parts[1], value.to_vec()));
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
let mut expected_seq = start.seq;
let mut expected_prev = start.prev.clone();
for (seq, bytes) in links {
let broken = |report: &mut ChainReport, reason: &str| {
report.broken_at = Some(EntryId { node, seq }.to_string());
report.reason = Some(reason.to_string());
};
let Ok(Json(link)) = Json::<Link>::deserialize(&bytes) else {
broken(&mut report, "The link can't be read.");
break;
};
if seq != expected_seq || link.seq != seq {
report.broken_at = Some(EntryId { node, seq }.to_string());
report.reason = Some(format!(
"Entry {expected_seq} is missing; the next one found is {seq}."
));
break;
}
if link.prev != expected_prev {
broken(
&mut report,
"The link doesn't follow from the one before it: one of them was changed.",
);
break;
}
match data
.get_value::<Raw>(key(KIND_CONTENT, &[node, seq]))
.await
.caused_by(trc::location!())?
{
Some(Raw(content)) => {
if sha256(&content) != link.content {
broken(&mut report, "The entry was changed after it was written.");
break;
}
if let Some(blobs) = blobs {
let Ok(Json(entry)) = Json::<Entry>::deserialize(&content) else {
broken(&mut report, "The entry can't be read.");
break;
};
let report_bytes = match entry.blob_hash() {
Some(hash) => blobs
.get_blob(hash.as_slice(), 0..usize::MAX)
.await
.caused_by(trc::location!())?,
None => None,
};
match report_bytes {
Some(bytes) if sha256(&bytes) == entry.sha256 => {}
Some(_) => {
broken(&mut report, "The report doesn't match its entry.");
break;
}
None => {
broken(&mut report, "The report is missing.");
break;
}
}
}
report.entries += 1;
}
None => {
if data
.get_value::<Raw>(key(KIND_PURGED, &[node, seq]))
.await
.caused_by(trc::location!())?
.is_none()
{
broken(&mut report, "The entry was removed before its time.");
break;
}
report.purged += 1;
}
}
expected_prev = sha256(&bytes);
expected_seq = seq + 1;
report.last_seq = seq;
}
if report.broken_at.is_none()
&& (head.seq != report.last_seq
|| (report.last_seq >= report.first_seq && head.hash != expected_prev))
{
report.broken_at = Some(
EntryId {
node,
seq: report.last_seq,
}
.to_string(),
);
report.reason = Some(
"The chain's recorded end doesn't match its last link: entries were removed \
or changed at the end."
.into(),
);
}
reports.push(report);
}
Ok(reports)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_read_back() {
let ValueClass::Any(any) = class(KIND_EXPIRY, &[5, 3, 9]) else {
panic!()
};
assert_eq!(parse_key(&any.key, KIND_EXPIRY, 3), Some(vec![5, 3, 9]));
let mut with_subspace = vec![SUBSPACE_INBUXA];
with_subspace.extend_from_slice(&any.key);
assert_eq!(
parse_key(&with_subspace, KIND_EXPIRY, 3),
Some(vec![5, 3, 9])
);
assert_eq!(parse_key(&any.key, KIND_TIME, 3), None);
}
#[test]
fn filters_match() {
let entry = Entry {
queue_id: 1,
at: 100,
direction: Direction::Outgoing,
sender: "[email protected]".into(),
authenticated: true,
recipients: vec!["[email protected]".into()],
subject: "Q3 figures, final".into(),
message_id: "<[email protected]>".into(),
accounts: vec![3],
tenants: vec![],
journals: vec![2],
held: false,
blob: String::new(),
size: 0,
sha256: String::new(),
expires_at: 0,
};
let yes = |f: Filter| assert!(f.matches(&entry), "{f:?}");
let no = |f: Filter| assert!(!f.matches(&entry), "{f:?}");
yes(Filter::default());
yes(Filter {
sender: Some("alice@".into()),
..Default::default()
});
yes(Filter {
address: Some("BANK".into()),
..Default::default()
});
yes(Filter {
text: Some("final q3".into()),
..Default::default()
});
yes(Filter {
message_id: Some("[email protected]".into()),
..Default::default()
});
yes(Filter {
direction: Some(Direction::Any),
..Default::default()
});
no(Filter {
direction: Some(Direction::Incoming),
..Default::default()
});
no(Filter {
recipient: Some("alice".into()),
..Default::default()
});
no(Filter {
before: Some(100),
..Default::default()
});
yes(Filter {
after: Some(100),
journal_id: Some(2),
..Default::default()
});
no(Filter {
journal_id: Some(5),
..Default::default()
});
}
#[test]
fn hex_round_trips() {
let bytes = [0u8, 1, 0xab, 0xff];
assert_eq!(unhex(&hex(&bytes)), Some(bytes.to_vec()));
assert_eq!(unhex("abc"), None);
assert_eq!(unhex("zz"), None);
}
#[test]
fn ids_read_back() {
let id = EntryId { node: 3, seq: 77 };
assert_eq!(EntryId::from_u64(id.to_u64()), id);
assert_eq!(id.to_string(), "3-77");
}
}
+512
View File
@@ -0,0 +1,512 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Journaling (journaling spec, JR-1 to JR-18): a copy of each message the
//! server queues, with its envelope, kept where nothing in the product
//! changes or removes it before its retention ends.
//!
//! - this module: journals, what makes one valid, and where they're kept;
//! - [`report`]: the journal report around the untouched message (JR-3);
//! - [`entries`]: the built-in journal and its chain (JR-5, JR-6, JR-13).
//!
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
//! with `J`; journals are `j` + id (u32), as JSON. There are few, so they're
//! read whole.
pub mod archive;
pub mod entries;
pub mod report;
use crate::{hold::Member, mailflow::rules::jmap_ids};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned};
use std::{
sync::{Arc, RwLock},
time::{Duration, Instant},
};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
};
use trc::AddContext;
pub(crate) const FEATURE: u8 = b'J';
const KIND_JOURNAL: u8 = b'j';
const CREATE_ATTEMPTS: usize = 5;
/// Retention a journal may be given, in days (settled answer 3).
pub const MIN_RETENTION_DAYS: u32 = 30;
pub const MAX_RETENTION_DAYS: u32 = 3650;
/// Most entries in one scope list.
const MAX_LIST: usize = 5_000;
/// Which way a message goes, from this server's side (JR-9).
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum Direction {
/// From someone here to at least one recipient elsewhere.
Outgoing,
/// From elsewhere to someone here.
Incoming,
/// From someone here, to people here only.
Internal,
Any,
}
impl Direction {
pub fn as_str(&self) -> &'static str {
match self {
Direction::Outgoing => "outgoing",
Direction::Incoming => "incoming",
Direction::Internal => "internal",
Direction::Any => "any",
}
}
/// A message's direction: `Any` is never one.
pub fn of(sender_local: bool, any_remote: bool, any_local: bool) -> Direction {
match (sender_local, any_remote) {
(true, true) => Direction::Outgoing,
(true, false) => Direction::Internal,
(false, _) if any_local => Direction::Incoming,
// Nobody here on either side: relayed mail counts as outgoing
(false, _) => Direction::Outgoing,
}
}
fn includes(&self, direction: Direction) -> bool {
*self == Direction::Any || *self == direction
}
}
/// Whose mail a journal takes (JR-9): everyone, or people reached through
/// their account, domain, group or tenant. Ids are in the JMAP form.
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Scope {
#[serde(default)]
pub everyone: bool,
#[serde(default, with = "jmap_ids")]
pub accounts: Vec<u32>,
#[serde(default, with = "jmap_ids")]
pub groups: Vec<u32>,
#[serde(default, with = "jmap_ids")]
pub domains: Vec<u32>,
#[serde(default, with = "jmap_ids")]
pub tenants: Vec<u32>,
}
impl Scope {
fn lists(&self) -> [&Vec<u32>; 4] {
[&self.accounts, &self.groups, &self.domains, &self.tenants]
}
/// Whether this scope reaches one person here.
pub fn covers(&self, member: &Member) -> bool {
self.everyone
|| self.accounts.contains(&member.account)
|| member.domains.iter().any(|d| self.domains.contains(d))
|| member.groups.iter().any(|g| self.groups.contains(g))
|| member.tenant.is_some_and(|t| self.tenants.contains(&t))
}
}
/// A journal (JR-9): what it takes, and how long its entries are kept.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Journal {
#[serde(default)]
pub id: u32,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub enabled: bool,
pub direction: Direction,
pub scope: Scope,
/// How long an entry this journal writes is kept. An entry keeps the
/// retention it was written with (JR-12).
pub retention_days: u32,
/// Whether entries go into the built-in journal (JR-5).
#[serde(default = "yes")]
pub built_in: bool,
/// An outside archive's journal address, sent each report (JR-7).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive_address: Option<String>,
#[serde(default)]
pub created_by: String,
#[serde(default)]
pub created_at: u64,
#[serde(default)]
pub updated_at: u64,
}
/// Why a journal was refused: the property, and what to do.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invalid {
pub property: &'static str,
pub reason: String,
}
fn invalid(property: &'static str, reason: impl Into<String>) -> Result<(), Invalid> {
Err(Invalid {
property,
reason: reason.into(),
})
}
impl Journal {
pub fn validate(&self) -> Result<(), Invalid> {
if self.name.trim().is_empty() {
return invalid("name", "Give the journal a name.");
}
if self.name.len() > 200 || self.description.len() > 2_000 {
return invalid("name", "The name or description is too long.");
}
if !(MIN_RETENTION_DAYS..=MAX_RETENTION_DAYS).contains(&self.retention_days) {
return invalid(
"retentionDays",
format!("Keep entries between {MIN_RETENTION_DAYS} and {MAX_RETENTION_DAYS} days."),
);
}
// Neither is a journal only rules send mail to (JR-10)
let chosen = self.scope.lists().iter().any(|list| !list.is_empty());
if self.scope.everyone && chosen {
return invalid(
"scope",
"Journal everyone, or choose accounts, groups, domains or tenants; not both.",
);
}
if !self.built_in && self.archive_address.is_none() {
return invalid(
"builtIn",
"Keep entries in the built-in journal, send them to an archive, or both.",
);
}
if let Some(address) = &self.archive_address
&& !is_address(address)
{
return invalid(
"archiveAddress",
format!("\"{address}\" isn't an email address."),
);
}
if self.scope.lists().iter().any(|list| list.len() > MAX_LIST) {
return invalid("scope", format!("Choose at most {MAX_LIST} of each."));
}
Ok(())
}
/// Whether this journal takes a message going `direction` with these
/// people here on either side.
/// Whether only rules send this journal mail (JR-10).
pub fn rules_only(&self) -> bool {
!self.scope.everyone && self.scope.lists().iter().all(|list| list.is_empty())
}
pub fn takes(&self, direction: Direction, members: &[Member]) -> bool {
self.enabled
&& self.direction.includes(direction)
&& (self.scope.everyone || members.iter().any(|m| self.scope.covers(m)))
}
}
fn yes() -> bool {
true
}
/// An address an archive can be sent to: one `@`, something either side,
/// nothing that would break an envelope.
fn is_address(address: &str) -> bool {
address.len() <= 320
&& address.split_once('@').is_some_and(|(local, domain)| {
!local.is_empty() && domain.contains('.') && !domain.contains('@')
})
&& !address
.chars()
.any(|c| c.is_whitespace() || c.is_control() || matches!(c, '<' | '>' | ',' | ';'))
}
/// A value stored as JSON.
pub(crate) struct Json<T>(pub T);
impl<T: SerdeSerialize> Serialize for Json<T> {
fn serialize(&self) -> trc::Result<Vec<u8>> {
serde_json::to_vec(&self.0).map_err(|err| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to serialize a journal record")
.reason(err)
})
}
}
impl<T: DeserializeOwned + Sync + Send> Deserialize for Json<T> {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.into_err()
.details("Invalid journal record")
.reason(err)
})
}
}
fn class(id: u32) -> ValueClass {
let mut key = Vec::with_capacity(6);
key.push(FEATURE);
key.push(KIND_JOURNAL);
key.extend_from_slice(&id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
fn key(id: u32) -> ValueKey<ValueClass> {
ValueKey::from(class(id))
}
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Journal>> {
Ok(data
.get_value::<Json<Journal>>(key(id))
.await
.caused_by(trc::location!())?
.map(|Json(journal)| journal))
}
/// Every journal, oldest first.
pub async fn all(data: &Store) -> trc::Result<Vec<Journal>> {
let mut journals = Vec::new();
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
if let Ok(Json(journal)) = Json::<Journal>::deserialize(value) {
journals.push(journal);
}
Ok(true)
})
.await
.caused_by(trc::location!())?;
journals.sort_by_key(|journal| journal.id);
Ok(journals)
}
/// Writes a new journal under the next free id, which it returns.
pub async fn create(data: &Store, journal: &Journal) -> trc::Result<u32> {
let mut attempt = 0;
loop {
attempt += 1;
let id = all(data).await?.iter().map(|j| j.id).max().unwrap_or(0) + 1;
let stored = Journal {
id,
..journal.clone()
};
let mut batch = BatchBuilder::new();
batch.assert_value(class(id), AssertValue::None);
batch.set(class(id), Json(&stored).serialize()?);
match data.write(batch.build_all()).await {
Ok(_) => {
invalidate();
return Ok(id);
}
Err(err)
if attempt < CREATE_ATTEMPTS
&& matches!(
err.as_ref(),
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
) => {}
Err(err) => return Err(err.caused_by(trc::location!())),
}
}
}
/// Replaces a stored journal (same id).
pub async fn update(data: &Store, journal: &Journal) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(class(journal.id), Json(journal).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
invalidate();
Ok(())
}
/// Removes a journal. Its entries stay, each until its own time.
pub async fn delete(data: &Store, id: u32) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.clear(class(id));
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
invalidate();
Ok(())
}
/// How long a node keeps its copy of the journals before reading them again.
pub const TTL: Duration = Duration::from_secs(30);
type Cached = Option<(Instant, Arc<Vec<Journal>>)>;
static CACHE: RwLock<Cached> = RwLock::new(None);
/// Forgets this node's copy, so the next message reads the journals again.
pub fn invalidate() {
if let Ok(mut cache) = CACHE.write() {
*cache = None;
}
}
/// The enabled journals, from this node's copy (refreshed every [`TTL`]).
pub async fn enabled(data: &Store) -> trc::Result<Arc<Vec<Journal>>> {
if let Ok(cache) = CACHE.read()
&& let Some((at, journals)) = cache.as_ref()
&& at.elapsed() < TTL
{
return Ok(journals.clone());
}
let journals = Arc::new(
all(data)
.await?
.into_iter()
.filter(|journal| journal.enabled)
.collect::<Vec<_>>(),
);
if let Ok(mut cache) = CACHE.write() {
*cache = Some((Instant::now(), journals.clone()));
}
Ok(journals)
}
#[cfg(test)]
mod tests {
use super::*;
fn journal(scope: Scope) -> Journal {
Journal {
id: 1,
name: "Finance".into(),
description: String::new(),
enabled: true,
direction: Direction::Any,
scope,
retention_days: 365,
built_in: true,
archive_address: None,
created_by: String::new(),
created_at: 0,
updated_at: 0,
}
}
fn member(account: u32, groups: Vec<u32>) -> Member {
Member {
account,
domains: vec![1],
groups,
tenant: None,
}
}
#[test]
fn scope_is_everyone_or_chosen() {
assert!(
journal(Scope {
everyone: true,
..Default::default()
})
.validate()
.is_ok()
);
// Nobody chosen: only rules send it mail
let rules_only = journal(Scope::default());
assert!(rules_only.validate().is_ok());
assert!(rules_only.rules_only());
assert!(!rules_only.takes(Direction::Any, &[member(3, vec![7])]));
let both = Scope {
everyone: true,
groups: vec![4],
..Default::default()
};
assert_eq!(journal(both).validate().unwrap_err().property, "scope");
}
#[test]
fn destinations() {
let mut j = journal(Scope {
everyone: true,
..Default::default()
});
j.built_in = false;
assert_eq!(j.validate().unwrap_err().property, "builtIn");
j.archive_address = Some("[email protected]".into());
assert!(j.validate().is_ok());
for bad in [
"archive",
"a@b",
"a [email protected]",
"<[email protected]>",
"a@[email protected]",
] {
j.archive_address = Some(bad.into());
assert_eq!(
j.validate().unwrap_err().property,
"archiveAddress",
"{bad}"
);
}
// Stored before destinations existed: the built-in journal
let old: Journal = serde_json::from_str(
r#"{"name":"Old","direction":"any","scope":{"everyone":true},"retentionDays":30}"#,
)
.unwrap();
assert!(old.built_in && old.archive_address.is_none());
}
#[test]
fn retention_has_bounds() {
let mut j = journal(Scope {
everyone: true,
..Default::default()
});
j.retention_days = 29;
assert_eq!(j.validate().unwrap_err().property, "retentionDays");
j.retention_days = 3651;
assert!(j.validate().is_err());
j.retention_days = 3650;
assert!(j.validate().is_ok());
}
#[test]
fn takes_by_direction_and_member() {
let mut j = journal(Scope {
groups: vec![7],
..Default::default()
});
assert!(j.takes(Direction::Outgoing, &[member(3, vec![7])]));
assert!(!j.takes(Direction::Outgoing, &[member(3, vec![8])]));
assert!(!j.takes(Direction::Outgoing, &[]));
j.direction = Direction::Incoming;
assert!(!j.takes(Direction::Outgoing, &[member(3, vec![7])]));
j.enabled = false;
assert!(!j.takes(Direction::Incoming, &[member(3, vec![7])]));
}
#[test]
fn directions() {
assert_eq!(Direction::of(true, true, true), Direction::Outgoing);
assert_eq!(Direction::of(true, false, true), Direction::Internal);
assert_eq!(Direction::of(false, false, true), Direction::Incoming);
assert_eq!(Direction::of(false, true, true), Direction::Incoming);
}
#[test]
fn scope_ids_are_jmap_ids() {
let scope: Scope = serde_json::from_str(r#"{"groups":["b"],"tenants":[7]}"#).unwrap();
assert_eq!(scope.groups, vec![1]);
assert_eq!(scope.tenants, vec![7]);
assert_eq!(
serde_json::to_value(&scope).unwrap()["tenants"],
serde_json::json!(["h"])
);
}
}
+385
View File
@@ -0,0 +1,385 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The journal report (JR-3, JR-4): a message whose first part lists the
//! envelope, one field a line, and whose second part is the message as it
//! was queued, byte for byte, as `message/rfc822`. Field names are fixed
//! English: a report is a record, and scripts read it.
use super::Direction;
use mail_builder::headers::{Header, date::Date, text::Text};
use mail_parser::MessageParser;
use sha2::{Digest, Sha256};
/// One envelope recipient, with the address it was given as (a list's, for
/// the list's members).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Recipient {
pub address: String,
pub orcpt: Option<String>,
/// The mail flow rule that added or redirected to it.
pub added_by: Option<String>,
}
/// What the queue knows about a message.
#[derive(Debug, Clone)]
pub struct Envelope<'x> {
pub sender: &'x str,
pub authenticated: bool,
pub recipients: &'x [Recipient],
pub queue_id: u64,
/// Seconds.
pub received: u64,
pub direction: Direction,
pub held: bool,
}
/// What a report says, besides the envelope's own fields.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Fields {
pub subject: String,
pub message_id: String,
pub to: Vec<String>,
pub cc: Vec<String>,
/// Envelope recipients in neither To nor Cc, nor reached through a list.
pub bcc: Vec<String>,
/// A list's address, and its members among the recipients.
pub expanded: Vec<(String, Vec<String>)>,
/// A rule's name, and the recipients it added.
pub added: Vec<(String, Vec<String>)>,
}
/// One line's worth of a value: no line breaks, no control characters.
fn line(value: &str) -> String {
value
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect::<String>()
.trim()
.to_string()
}
/// The address an ORCPT names, without its `rfc822;` type.
fn orcpt_address(orcpt: &str) -> String {
let orcpt = orcpt.trim();
let bare = match orcpt.split_once(';') {
Some((kind, address)) if kind.eq_ignore_ascii_case("rfc822") => address,
_ => orcpt,
};
bare.trim().to_lowercase()
}
/// Sorts the envelope's recipients by how they were addressed.
pub fn fields(envelope: &Envelope<'_>, original: &[u8]) -> Fields {
let parsed = MessageParser::default().parse_headers(original);
let headed = |which: Option<&mail_parser::Address<'_>>| -> Vec<String> {
which
.map(|list| {
list.iter()
.filter_map(|addr| addr.address())
.map(|address| address.to_lowercase())
.collect()
})
.unwrap_or_default()
};
let (subject, message_id, header_to, header_cc) = match &parsed {
Some(message) => (
message.subject().map(line).unwrap_or_default(),
message
.message_id()
.map(|id| format!("<{}>", line(id)))
.unwrap_or_default(),
headed(message.to()),
headed(message.cc()),
),
None => Default::default(),
};
let mut fields = Fields {
subject,
message_id,
..Default::default()
};
for rcpt in envelope.recipients {
let address = rcpt.address.to_lowercase();
let via = rcpt
.orcpt
.as_deref()
.map(orcpt_address)
.filter(|via| !via.is_empty() && *via != address);
if let Some(rule) = &rcpt.added_by {
match fields.added.iter_mut().find(|(name, _)| name == rule) {
Some((_, added)) => added.push(line(&rcpt.address)),
None => fields.added.push((line(rule), vec![line(&rcpt.address)])),
}
} else if header_to.contains(&address) {
fields.to.push(line(&rcpt.address));
} else if header_cc.contains(&address) {
fields.cc.push(line(&rcpt.address));
} else if let Some(via) = via {
match fields.expanded.iter_mut().find(|(list, _)| *list == via) {
Some((_, members)) => members.push(line(&rcpt.address)),
None => fields
.expanded
.push((line(&via), vec![line(&rcpt.address)])),
}
} else {
fields.bcc.push(line(&rcpt.address));
}
}
fields
}
/// The report's first part.
pub fn text(envelope: &Envelope<'_>, fields: &Fields) -> String {
let mut out = String::new();
let mut field = |name: &str, value: &str| {
if !value.is_empty() {
out.push_str(name);
out.push_str(": ");
out.push_str(value);
out.push_str("\r\n");
}
};
let sender = if envelope.sender.is_empty() {
"<>".to_string()
} else {
line(envelope.sender)
};
field("Sender", &sender);
field(
"Authenticated",
if envelope.authenticated { "yes" } else { "no" },
);
field("Subject", &fields.subject);
field("Message-ID", &fields.message_id);
field("Queue ID", &format!("{:x}", envelope.queue_id));
field(
"Received",
&mail_parser::DateTime::from_timestamp(envelope.received as i64).to_rfc3339(),
);
field("Direction", envelope.direction.as_str());
field("To", &fields.to.join(", "));
field("Cc", &fields.cc.join(", "));
field("Bcc", &fields.bcc.join(", "));
for (list, members) in &fields.expanded {
field("Expanded", &format!("{list} -> {}", members.join(", ")));
}
for (rule, added) in &fields.added {
field("Added by rule", &format!("{rule} -> {}", added.join(", ")));
}
if envelope.held {
field("Held for review", "yes");
}
out
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Whether a message can travel as 8bit: no NULs, no line past 998 bytes.
fn fits_8bit(message: &[u8]) -> bool {
!message.contains(&0) && message.split(|b| *b == b'\n').all(|l| l.len() <= 998)
}
/// The whole report: headers, the fields, then the original untouched.
/// `from` is the address the report is from; `host` names the server in its
/// Message-ID.
pub fn build(
envelope: &Envelope<'_>,
original: &[u8],
from: &str,
host: &str,
) -> (Vec<u8>, Fields) {
let fields = fields(envelope, original);
let body = text(envelope, &fields);
// A boundary that can't occur in the original
let mut boundary = format!("journal-{}", &hex(&Sha256::digest(original))[..32]);
while original
.windows(boundary.len())
.any(|window| window == boundary.as_bytes())
{
boundary.push('x');
}
let mut out: Vec<u8> = Vec::with_capacity(original.len() + body.len() + 1024);
out.extend_from_slice(format!("From: Journal <{}>\r\n", line(from)).as_bytes());
out.extend_from_slice(b"Date: ");
out.extend_from_slice(Date::new(envelope.received as i64).to_rfc822().as_bytes());
out.extend_from_slice(b"\r\n");
out.extend_from_slice(b"Subject: ");
let subject = if fields.subject.is_empty() {
"Journal report".to_string()
} else {
format!("Journal report: {}", fields.subject)
};
Text::new(subject).write_header(&mut out, "Subject: ".len());
out.extend_from_slice(
format!(
"Message-ID: <journal.{:x}.{}@{}>\r\n",
envelope.queue_id,
envelope.received,
line(host)
)
.as_bytes(),
);
out.extend_from_slice(format!("X-Inbuxa-Journal: {:x}\r\n", envelope.queue_id).as_bytes());
out.extend_from_slice(b"MIME-Version: 1.0\r\n");
out.extend_from_slice(
format!("Content-Type: multipart/mixed; boundary=\"{boundary}\"\r\n\r\n").as_bytes(),
);
out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
out.extend_from_slice(
b"Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n",
);
out.extend_from_slice(body.as_bytes());
out.extend_from_slice(format!("\r\n--{boundary}\r\n").as_bytes());
out.extend_from_slice(b"Content-Type: message/rfc822\r\n");
out.extend_from_slice(b"Content-Disposition: attachment; filename=\"original.eml\"\r\n");
out.extend_from_slice(if fits_8bit(original) {
b"Content-Transfer-Encoding: 8bit\r\n\r\n".as_slice()
} else {
b"Content-Transfer-Encoding: binary\r\n\r\n".as_slice()
});
out.extend_from_slice(original);
// The line break before a boundary belongs to the boundary: the
// original keeps its own last one
out.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
(out, fields)
}
/// Where the original starts and ends inside a report [`build`] made.
pub fn original(report: &[u8]) -> Option<&[u8]> {
let parsed = MessageParser::default().parse(report)?;
let part = parsed.attachment(0)?;
let start = part.raw_body_offset() as usize;
let end = part.raw_end_offset() as usize;
report.get(start..end)
}
#[cfg(test)]
mod tests {
use super::*;
const ORIGINAL: &[u8] = b"From: [email protected]\r\n\
To: Bank <[email protected]>\r\n\
Cc: [email protected]\r\n\
Subject: Q3 figures\r\n\
Message-ID: <[email protected]>\r\n\
\r\n\
The figures.\r\n";
fn rcpt(address: &str, orcpt: Option<&str>) -> Recipient {
Recipient {
address: address.into(),
orcpt: orcpt.map(Into::into),
added_by: None,
}
}
fn envelope(recipients: &[Recipient]) -> Envelope<'_> {
Envelope {
sender: "[email protected]",
authenticated: true,
recipients,
queue_id: 0x1a2b,
received: 1_790_000_000,
direction: Direction::Outgoing,
held: false,
}
}
#[test]
fn recipients_sorted_by_how_they_were_addressed() {
let recipients = [
rcpt("[email protected]", None),
rcpt("[email protected]", Some("rfc822;[email protected]")),
rcpt("[email protected]", None),
rcpt("[email protected]", Some("[email protected]")),
rcpt("[email protected]", Some("rfc822;[email protected]")),
];
let fields = fields(&envelope(&recipients), ORIGINAL);
assert_eq!(fields.subject, "Q3 figures");
assert_eq!(fields.message_id, "<[email protected]>");
assert_eq!(fields.to, vec!["[email protected]"]);
assert_eq!(fields.cc, vec!["[email protected]"]);
assert_eq!(fields.bcc, vec!["[email protected]"]);
assert_eq!(
fields.expanded,
vec![(
"[email protected]".to_string(),
vec![
"[email protected]".to_string(),
"[email protected]".to_string()
]
)]
);
}
#[test]
fn report_carries_the_original_untouched() {
let recipients = [
rcpt("[email protected]", None),
rcpt("[email protected]", None),
];
let (report, _) = build(
&envelope(&recipients),
ORIGINAL,
"[email protected]",
"mx.example.com",
);
let text = String::from_utf8_lossy(&report);
assert!(text.contains("Sender: [email protected]\r\n"));
assert!(text.contains("Bcc: [email protected]\r\n"));
assert!(text.contains("Queue ID: 1a2b\r\n"));
assert!(text.contains("Direction: outgoing\r\n"));
assert!(text.contains("Subject: Journal report: Q3 figures\r\n"));
assert!(!text.contains("Held for review"));
assert_eq!(original(&report), Some(ORIGINAL));
let unterminated = &ORIGINAL[..ORIGINAL.len() - 2];
let (report, _) = build(
&envelope(&recipients),
unterminated,
"[email protected]",
"mx.example.com",
);
assert_eq!(original(&report), Some(unterminated));
}
#[test]
fn rule_added_recipients_say_so() {
let mut copied = rcpt("[email protected]", None);
copied.added_by = Some("Copy finance".into());
let recipients = [rcpt("[email protected]", None), copied];
let env = envelope(&recipients);
let fields = fields(&env, ORIGINAL);
assert!(fields.bcc.is_empty(), "{fields:?}");
assert!(
text(&env, &fields).contains("Added by rule: Copy finance -> [email protected]\r\n")
);
}
#[test]
fn values_stay_on_one_line() {
let recipients = [rcpt("[email protected]", None)];
let mut env = envelope(&recipients);
env.sender = "[email protected]\r\nBcc: [email protected]";
env.held = true;
let body = text(&env, &Fields::default());
assert_eq!(body.matches("\r\n").count(), body.lines().count());
assert!(body.contains("Sender: [email protected] Bcc: [email protected]\r\n"));
assert!(body.contains("Held for review: yes\r\n"));
}
#[test]
fn an_empty_sender_is_shown_as_such() {
let recipients = [rcpt("[email protected]", None)];
let mut env = envelope(&recipients);
env.sender = "";
assert!(text(&env, &Fields::default()).starts_with("Sender: <>\r\n"));
}
}
+2
View File
@@ -22,7 +22,9 @@ pub mod ai;
pub mod audit;
pub mod branding;
pub mod hold;
pub mod journal;
pub mod lock;
pub mod mailflow;
pub mod masked_email;
pub mod privacy;
pub mod security;
+53
View File
@@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The compiled rules, kept per node so a message doesn't read the store.
//! A change made on this node applies at once; one made on another node
//! within [`TTL`], when the copy here is next refreshed.
use super::{engine::Compiled, rules};
use std::{
sync::{Arc, RwLock},
time::{Duration, Instant},
};
use store::Store;
/// How long a node keeps its copy before reading the rules again.
pub const TTL: Duration = Duration::from_secs(30);
static CACHE: RwLock<Option<(Instant, Arc<Compiled>)>> = RwLock::new(None);
/// Forgets the copy, so the next message reads the rules again.
pub fn invalidate() {
if let Ok(mut cache) = CACHE.write() {
*cache = None;
}
}
/// The enabled rules, compiled. A rule that no longer compiles is left out
/// and reported, once per refresh.
pub async fn compiled(data: &Store) -> trc::Result<Arc<Compiled>> {
if let Ok(cache) = CACHE.read()
&& let Some((at, compiled)) = cache.as_ref()
&& at.elapsed() < TTL
{
return Ok(compiled.clone());
}
let (compiled, skipped) = Compiled::new(&rules::all(data).await?);
for (id, reason) in skipped {
trc::event!(
Store(trc::StoreEvent::DataCorruption),
Id = u64::from(id),
Reason = reason,
Details = "Mail rule skipped: it no longer compiles"
);
}
let compiled = Arc::new(compiled);
if let Ok(mut cache) = CACHE.write() {
*cache = Some((Instant::now(), compiled.clone()));
}
Ok(compiled)
}
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! African identifiers (§2.3): South Africa's ID number.
use super::{Detector, Findings, Region, Strength, checks, valid_short_date};
use regex::Regex;
use std::sync::LazyLock;
pub static DETECTORS: &[Detector] = &[Detector::new(
"za-id",
"South Africa: ID number",
Region::Africa,
Strength::Checked,
za_id,
)];
/// Birth date `YYMMDD`, four digits, citizenship (0, 1 or 2), 8 or 9, a Luhn
/// check digit. The date and the two fixed digits make it strong enough to
/// count alone.
static ZA_ID: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\b(\d{2})(\d{2})(\d{2})\d{4}[012][89]\d\b").expect("detector pattern")
});
fn za_id(text: &str, findings: &mut Findings) {
for c in ZA_ID.captures_iter(text) {
let n = &c[0];
let num = |s: &str| s.parse::<u32>().unwrap_or(0);
if valid_short_date(num(&c[1]), num(&c[2]), num(&c[3])) && checks::luhn(n) {
findings.insert(n);
}
}
}
#[cfg(test)]
mod tests {
use crate::mailflow::detectors::by_id;
#[test]
fn south_africa() {
let detector = by_id("za-id").unwrap();
assert_eq!(detector.count("ID 8001015009087"), 1);
assert_eq!(detector.count("8001015009088"), 0);
assert_eq!(detector.count("8013015009087"), 0);
}
}
@@ -0,0 +1,172 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Identifiers from the Americas outside the US and Canada (§2.3): Brazil's
//! CPF and CNPJ, and Mexico's CURP.
use super::{Detector, Findings, Region, Strength, digit_values, valid_short_date, word_near};
use regex::Regex;
use std::sync::LazyLock;
pub static DETECTORS: &[Detector] = &[
Detector::new(
"br-cpf",
"Brazil: CPF",
Region::Americas,
Strength::Checked,
br_cpf,
),
Detector::new(
"br-cnpj",
"Brazil: CNPJ",
Region::Americas,
Strength::Checked,
br_cnpj,
),
Detector::new(
"mx-curp",
"Mexico: CURP",
Region::Americas,
Strength::Checked,
mx_curp,
),
];
fn re(pattern: &str) -> Regex {
Regex::new(pattern).expect("detector pattern")
}
/// Brazil's mod 11 check digit over `digits` with `weights`.
fn br_check(digits: &[u32], weights: &[u32]) -> u32 {
match digits.iter().zip(weights).map(|(a, w)| a * w).sum::<u32>() % 11 {
0 | 1 => 0,
r => 11 - r,
}
}
/// `111.444.777-35`, or eleven bare digits.
static CPF: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{3}(\.?)\d{3}(\.?)\d{3}(-?)\d{2}\b"));
pub fn cpf_valid(n: &str) -> bool {
let d = digit_values(n);
// A run of one digit passes the arithmetic but is never issued
d.len() == 11
&& d.iter().any(|x| *x != d[0])
&& br_check(&d[..9], &[10, 9, 8, 7, 6, 5, 4, 3, 2]) == d[9]
&& br_check(&d[..10], &[11, 10, 9, 8, 7, 6, 5, 4, 3, 2]) == d[10]
}
const CPF_WORDS: &[&str] = &[
"cpf",
"cadastro de pessoas físicas",
"cadastro de pessoa física",
];
fn br_cpf(text: &str, findings: &mut Findings) {
for c in CPF.captures_iter(text) {
let whole = c.get(0).unwrap();
let written = &c[1] == "." && &c[2] == "." && &c[3] == "-";
let n: String = whole
.as_str()
.chars()
.filter(char::is_ascii_digit)
.collect();
if cpf_valid(&n) && (written || word_near(text, whole.start(), whole.end(), CPF_WORDS)) {
findings.insert(n);
}
}
}
/// `11.222.333/0001-81`, or fourteen bare digits.
static CNPJ: LazyLock<Regex> =
LazyLock::new(|| re(r"\b\d{2}(\.?)\d{3}(\.?)\d{3}(/?)\d{4}(-?)\d{2}\b"));
pub fn cnpj_valid(n: &str) -> bool {
let d = digit_values(n);
d.len() == 14
&& d.iter().any(|x| *x != d[0])
&& br_check(&d[..12], &[5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]) == d[12]
&& br_check(&d[..13], &[6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]) == d[13]
}
const CNPJ_WORDS: &[&str] = &["cnpj", "cadastro nacional da pessoa jurídica"];
fn br_cnpj(text: &str, findings: &mut Findings) {
for c in CNPJ.captures_iter(text) {
let whole = c.get(0).unwrap();
let written = &c[1] == "." && &c[2] == "." && &c[3] == "/" && &c[4] == "-";
let n: String = whole
.as_str()
.chars()
.filter(char::is_ascii_digit)
.collect();
if cnpj_valid(&n) && (written || word_near(text, whole.start(), whole.end(), CNPJ_WORDS)) {
findings.insert(n);
}
}
}
/// Four letters, the birth date, sex (H, M or X), the state, three
/// consonants, a character that tells the century apart, the check digit.
static CURP: LazyLock<Regex> = LazyLock::new(|| {
re(r"(?i)\b[A-Z]{4}(\d{2})(\d{2})(\d{2})[HMX][A-Z]{2}[B-DF-HJ-NP-TV-Z]{3}[A-Z0-9]\d\b")
});
/// RENAPO's check: each character's place in `0-9 A-N Ñ O-Z`, weighted 18
/// down to 2; the digit is 10 minus the sum mod 10 (10 becomes 0).
pub fn curp_valid(curp: &str) -> bool {
const ALPHABET: &str = "0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ";
let mut sum = 0u32;
for (i, c) in curp.chars().take(17).enumerate() {
let Some(value) = ALPHABET.chars().position(|a| a == c) else {
return false;
};
sum += value as u32 * (18 - i as u32);
}
curp.chars().nth(17).and_then(|c| c.to_digit(10)) == Some((10 - sum % 10) % 10)
}
fn mx_curp(text: &str, findings: &mut Findings) {
for c in CURP.captures_iter(text) {
let curp = c[0].to_ascii_uppercase();
if valid_short_date(num(&c[1]), num(&c[2]), num(&c[3])) && curp_valid(&curp) {
findings.insert(curp);
}
}
}
fn num(s: &str) -> u32 {
s.parse().unwrap_or(0)
}
#[cfg(test)]
mod tests {
use crate::mailflow::detectors::by_id;
fn count(id: &str, text: &str) -> usize {
by_id(id).unwrap().count(text)
}
#[test]
fn brazil() {
assert_eq!(count("br-cpf", "CPF 111.444.777-35"), 1);
assert_eq!(count("br-cpf", "111.444.777-36"), 0);
assert_eq!(count("br-cpf", "pedido 11144477735"), 0);
assert_eq!(count("br-cpf", "cpf: 11144477735"), 1);
assert_eq!(count("br-cpf", "CPF 111.111.111-11"), 0);
assert_eq!(count("br-cnpj", "11.222.333/0001-81"), 1);
assert_eq!(count("br-cnpj", "11.222.333/0001-82"), 0);
assert_eq!(count("br-cnpj", "CNPJ 11222333000181"), 1);
}
#[test]
fn mexico() {
// python-stdnum's documented example
assert_eq!(count("mx-curp", "CURP BOXW310820HNERXN09"), 1);
assert_eq!(count("mx-curp", "BOXW310820HNERXN08"), 0);
assert_eq!(count("mx-curp", "BOXW311320HNERXN09"), 0);
}
}
@@ -0,0 +1,511 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Detectors that aren't tied to one country (§2.3, region "Any").
use super::{
Detector, Findings, Region, Strength, checks, digits, stands_alone, valid_date, word_near,
};
use regex::Regex;
use std::sync::LazyLock;
pub static DETECTORS: &[Detector] = &[
Detector::new(
"payment-card",
"Payment card number",
Region::Any,
Strength::Checked,
payment_card,
),
Detector::new("iban", "IBAN", Region::Any, Strength::Checked, iban),
Detector::new(
"swift-bic",
"SWIFT/BIC code",
Region::Any,
Strength::NeedsWord,
swift_bic,
),
Detector::new(
"email-addresses",
"Email addresses",
Region::Any,
Strength::Checked,
email_addresses,
),
Detector::new(
"phone-numbers",
"Phone numbers",
Region::Any,
Strength::NeedsWord,
phone_numbers,
),
Detector::new(
"date-of-birth",
"Date of birth",
Region::Any,
Strength::NeedsWord,
date_of_birth,
),
Detector::new(
"passport",
"Passport number",
Region::Any,
Strength::NeedsWord,
passport,
),
Detector::new(
"private-key",
"Private key",
Region::Any,
Strength::Checked,
private_key,
),
Detector::new(
"credentials",
"Cloud and service credentials",
Region::Any,
Strength::Checked,
credentials,
),
];
fn re(pattern: &str) -> Regex {
Regex::new(pattern).expect("detector pattern")
}
// --- Payment cards --------------------------------------------------------
/// Issuer prefixes (ISO/IEC 7812 IINs) and the lengths each network issues.
fn card_network(number: &str) -> bool {
let len = number.len();
let prefix = |n: usize| number[..n].parse::<u32>().unwrap_or(0);
match number.as_bytes()[0] {
// Visa
b'4' => matches!(len, 13 | 16 | 19),
b'5' => {
// Mastercard 51–55; Maestro 50, 56–58
(51..=55).contains(&prefix(2)) && len == 16
|| matches!(prefix(2), 50 | 56..=58) && (12..=19).contains(&len)
}
// Mastercard 2221–2720
b'2' => (2221..=2720).contains(&prefix(4)) && len == 16,
b'3' => {
// American Express 34, 37; JCB 3528–3589; Diners 300–305, 36, 38, 39
matches!(prefix(2), 34 | 37) && len == 15
|| (3528..=3589).contains(&prefix(4)) && (16..=19).contains(&len)
|| ((300..=305).contains(&prefix(3)) || matches!(prefix(2), 36 | 38 | 39))
&& (14..=19).contains(&len)
}
// Discover 6011, 644–649, 65; UnionPay 62; Maestro 6x
b'6' => (12..=19).contains(&len),
_ => false,
}
}
fn is_card(number: &str) -> bool {
(12..=19).contains(&number.len()) && card_network(number) && checks::luhn(number)
}
static CARD: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d(?:[ -]?\d){11,18}\b"));
fn payment_card(text: &str, findings: &mut Findings) {
for m in CARD.find_iter(text) {
if !stands_alone(text, m.start(), m.end()) {
continue;
}
let whole = digits(m.as_str());
if is_card(&whole) {
findings.insert(whole);
continue;
}
// Two numbers side by side ("4242 4242 4242 4242 2031"): try each
// run of whole groups
let groups: Vec<String> = m.as_str().split([' ', '-']).map(digits).collect();
'runs: for from in 0..groups.len() {
let mut number = String::new();
for group in &groups[from..] {
number.push_str(group);
if is_card(&number) {
findings.insert(number);
break 'runs;
}
}
}
}
}
// --- IBAN -----------------------------------------------------------------
static IBAN: LazyLock<Regex> =
LazyLock::new(|| re(r"\b[A-Za-z]{2}\d{2}(?:[ ]?[A-Za-z0-9]){11,30}"));
fn iban(text: &str, findings: &mut Findings) {
// The pattern can run on into the next words, even the next IBAN: after
// each hit, look again from where that IBAN ended
let mut from = 0;
while let Some(m) = IBAN.find_at(text, from) {
from = m.start() + 1;
let compact = m.as_str().replace(' ', "").to_ascii_uppercase();
let Some(len) = checks::iban_length(&compact[..2]) else {
continue;
};
if compact.len() < len {
continue;
}
// Where the country's length ends in the text, spaces counted
let mut seen = 0;
let Some(end) = m
.as_str()
.char_indices()
.find(|(_, c)| {
if *c != ' ' {
seen += 1;
}
seen == len
})
.map(|(i, c)| m.start() + i + c.len_utf8())
else {
continue;
};
let candidate = &compact[..len];
if stands_alone(text, m.start(), end) && checks::iban(candidate) {
findings.insert(candidate);
from = end;
}
}
}
// --- SWIFT/BIC ------------------------------------------------------------
static BIC: LazyLock<Regex> =
LazyLock::new(|| re(r"\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b"));
const BIC_WORDS: &[&str] = &[
"swift",
"bic",
"swift/bic",
"bank",
"banque",
"bankverbindung",
];
fn swift_bic(text: &str, findings: &mut Findings) {
for m in BIC.find_iter(text) {
let code = m.as_str();
if checks::is_country(&code[4..6]) && word_near(text, m.start(), m.end(), BIC_WORDS) {
findings.insert(code);
}
}
}
// --- Contact lists --------------------------------------------------------
static EMAIL: LazyLock<Regex> =
LazyLock::new(|| re(r"(?i)\b[a-z0-9._%+-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}\b"));
fn email_addresses(text: &str, findings: &mut Findings) {
for m in EMAIL.find_iter(text) {
findings.insert(m.as_str().to_lowercase());
}
}
/// International form: found alone. National form: only with a word.
static PHONE_INTL: LazyLock<Regex> = LazyLock::new(|| re(r"\+\d{1,3}(?:[ .-]?\(?\d{1,4}\)?){2,5}"));
static PHONE_NATIONAL: LazyLock<Regex> =
LazyLock::new(|| re(r"\(?\d{2,4}\)?[ .-]\d{3,4}[ .-]\d{3,4}"));
const PHONE_WORDS: &[&str] = &[
"phone",
"tel",
"telephone",
"mobile",
"cell",
"fax",
"telefon",
"téléphone",
"teléfono",
"telefono",
"handy",
"portable",
"móvil",
"cellulare",
"mobiel",
];
fn phone_numbers(text: &str, findings: &mut Findings) {
let mut international = Vec::new();
for m in PHONE_INTL.find_iter(text) {
let number = digits(m.as_str());
if (8..=15).contains(&number.len()) && stands_alone(text, m.start() + 1, m.end()) {
findings.insert(number);
international.push(m.range());
}
}
for m in PHONE_NATIONAL.find_iter(text) {
let number = digits(m.as_str());
// Not the tail of an international number already counted
if international.iter().any(|r| r.contains(&m.start())) {
continue;
}
if (9..=11).contains(&number.len())
&& stands_alone(text, m.start(), m.end())
&& !text[..m.start()].ends_with('+')
&& word_near(text, m.start(), m.end(), PHONE_WORDS)
{
findings.insert(number);
}
}
}
// --- Date of birth --------------------------------------------------------
static DATE_ISO: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{4})-(\d{2})-(\d{2})\b"));
static DATE_NUMERIC: LazyLock<Regex> =
LazyLock::new(|| re(r"\b(\d{1,2})[./-](\d{1,2})[./-](\d{4})\b"));
static DATE_WORDS: LazyLock<Regex> = LazyLock::new(|| {
re(
r"(?i)\b(?:(\d{1,2})\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?,?\s+(\d{4})|(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(\d{1,2}),?\s+(\d{4}))\b",
)
});
const BIRTH_WORDS: &[&str] = &[
"born",
"birth",
"dob",
"d.o.b",
"birthday",
"birthdate",
"geburtsdatum",
"geboren",
"naissance",
"né le",
"née le",
"nacimiento",
"nacido",
"nacida",
"nascita",
"nato il",
"nata il",
"geboortedatum",
"födelsedatum",
"fødselsdato",
"syntymäaika",
"urodzenia",
"nascimento",
];
fn month_number(name: &str) -> u32 {
const MONTHS: [&str; 12] = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
];
let name = name.to_lowercase();
MONTHS
.iter()
.position(|m| *m == name)
.map_or(0, |i| i as u32 + 1)
}
fn date_of_birth(text: &str, findings: &mut Findings) {
let mut add = |start: usize, end: usize, key: String| {
if word_near(text, start, end, BIRTH_WORDS) {
findings.insert(key);
}
};
let num = |s: &str| s.parse::<u32>().unwrap_or(0);
for c in DATE_ISO.captures_iter(text) {
let (y, m, d) = (num(&c[1]), num(&c[2]), num(&c[3]));
let whole = c.get(0).unwrap();
if valid_date(y, m, d) {
add(whole.start(), whole.end(), format!("{y:04}{m:02}{d:02}"));
}
}
for c in DATE_NUMERIC.captures_iter(text) {
let (a, b, y) = (num(&c[1]), num(&c[2]), num(&c[3]));
let whole = c.get(0).unwrap();
// Day first or month first: either reading that is a real date
if valid_date(y, b, a) || valid_date(y, a, b) {
add(whole.start(), whole.end(), whole.as_str().to_string());
}
}
for c in DATE_WORDS.captures_iter(text) {
let whole = c.get(0).unwrap();
let (d, m, y) = match (c.get(1), c.get(4)) {
(Some(d), _) => (num(d.as_str()), month_number(&c[2]), num(&c[3])),
(_, Some(m)) => (num(&c[5]), month_number(m.as_str()), num(&c[6])),
_ => continue,
};
if valid_date(y, m, d) {
add(whole.start(), whole.end(), format!("{y:04}{m:02}{d:02}"));
}
}
}
// --- Passport -------------------------------------------------------------
static PASSPORT: LazyLock<Regex> = LazyLock::new(|| re(r"\b[A-Z0-9]{6,9}\b"));
const PASSPORT_WORDS: &[&str] = &[
"passport",
"passeport",
"reisepass",
"pasaporte",
"passaporto",
"paspoort",
"passnummer",
"pass-nr",
"passport no",
"pasaporte n.º",
"passaporte",
];
fn passport(text: &str, findings: &mut Findings) {
for m in PASSPORT.find_iter(text) {
let value = m.as_str();
if value.bytes().filter(u8::is_ascii_digit).count() >= 5
&& word_near(text, m.start(), m.end(), PASSPORT_WORDS)
{
findings.insert(value);
}
}
}
// --- Keys and credentials -------------------------------------------------
static PRIVATE_KEY: LazyLock<Regex> = LazyLock::new(|| {
re(
r"-----BEGIN (?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP) )?PRIVATE KEY(?: BLOCK)?-----\s*([A-Za-z0-9+/=:\s-]{0,64})",
)
});
fn private_key(text: &str, findings: &mut Findings) {
for c in PRIVATE_KEY.captures_iter(text) {
// Each key once, by the start of its body
let body: String = c[1].chars().filter(|c| !c.is_whitespace()).collect();
let whole = c.get(0).unwrap();
findings.insert(if body.is_empty() {
format!("@{}", whole.start())
} else {
body
});
}
}
/// Published token formats: AWS access key IDs, GitHub tokens, Slack
/// tokens, Stripe live secret and restricted keys, Google API keys.
static CREDENTIAL: LazyLock<Regex> = LazyLock::new(|| {
re(concat!(
r"\b(?:",
r"(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}",
r"|gh[pousr]_[A-Za-z0-9]{36}",
r"|github_pat_[A-Za-z0-9_]{82}",
r"|xox[abposr]-[A-Za-z0-9-]{10,72}",
r"|(?:sk|rk)_live_[A-Za-z0-9]{24,99}",
r"|AIza[0-9A-Za-z_-]{35}",
r")\b"
))
});
fn credentials(text: &str, findings: &mut Findings) {
for m in CREDENTIAL.find_iter(text) {
findings.insert(m.as_str());
}
}
#[cfg(test)]
mod tests {
use crate::mailflow::detectors::by_id;
fn count(id: &str, text: &str) -> usize {
by_id(id).unwrap().count(text)
}
#[test]
fn payment_cards() {
// Networks' and processors' published test numbers
let text = "Visa 4242 4242 4242 4242, MC 5555-5555-5555-4444, Amex 378282246310005, \
Discover 6011111111111117, JCB 3566002020360505, Diners 30569309025904, \
UnionPay 6200000000000005, Mastercard 2-series 2223003122003222";
assert_eq!(count("payment-card", text), 8);
// Luhn fails, wrong network length, inside a longer number
assert_eq!(count("payment-card", "4242424242424241"), 0);
assert_eq!(count("payment-card", "378282246310005 0"), 1);
assert_eq!(count("payment-card", "order 94242424242424242 shipped"), 0);
// The same number twice counts once
assert_eq!(
count("payment-card", "4242424242424242 and 4242-4242-4242-4242"),
1
);
// A card followed by a year
assert_eq!(count("payment-card", "card 4242 4242 4242 4242 2031"), 1);
}
#[test]
fn ibans() {
let text =
"Pay GB29 NWBK 6016 1331 9268 19 or de89370400440532013000 (NL91ABNA0417164300).";
assert_eq!(count("iban", text), 3);
assert_eq!(count("iban", "GB29 NWBK 6016 1331 9268 18"), 0);
// Runs into the next word: still found at the country's length
assert_eq!(count("iban", "IBAN NL91ABNA0417164300 BIC ABNANL2A"), 1);
}
#[test]
fn swift_codes_need_a_word() {
assert_eq!(count("swift-bic", "SWIFT: DEUTDEFF500"), 1);
assert_eq!(count("swift-bic", "BIC NWBKGB2L"), 1);
assert_eq!(count("swift-bic", "HAPPYDAYS DEUTDEFF"), 0);
// Not a country in positions 5–6
assert_eq!(count("swift-bic", "BIC DEUTZZFF"), 0);
}
#[test]
fn email_and_phone_lists() {
let list = "[email protected], [email protected], [email protected], [email protected]";
assert_eq!(count("email-addresses", list), 3);
assert_eq!(
count("phone-numbers", "+44 20 7946 0958, +1 (415) 555-2671"),
2
);
assert_eq!(count("phone-numbers", "call 020 7946 0958"), 0);
assert_eq!(count("phone-numbers", "Tel: 020 7946 0958"), 1);
assert_eq!(count("phone-numbers", "invoice 020 7946 0958"), 0);
// One number, not also its national tail
assert_eq!(count("phone-numbers", "Tel: +44 20 7946 0958"), 1);
}
#[test]
fn dates_of_birth() {
assert_eq!(count("date-of-birth", "DOB: 1984-02-29"), 1);
assert_eq!(count("date-of-birth", "Geburtsdatum 31.12.1970"), 1);
assert_eq!(count("date-of-birth", "born on March 3, 1962"), 1);
assert_eq!(count("date-of-birth", "date of birth 3 Mar 1962"), 1);
// Not a real date, no word, a meeting
assert_eq!(count("date-of-birth", "DOB: 1985-02-29"), 0);
assert_eq!(count("date-of-birth", "invoice 1984-02-29"), 0);
assert_eq!(count("date-of-birth", "Meeting on 12/05/2026"), 0);
}
#[test]
fn passports_need_a_word() {
assert_eq!(count("passport", "Passport number: 533380006"), 1);
assert_eq!(count("passport", "Reisepass C01X00T47"), 1);
assert_eq!(count("passport", "Order 533380006 shipped"), 0);
// Mostly letters: a word, not a number
assert_eq!(count("passport", "passport PASSWORD"), 0);
}
#[test]
fn keys_and_credentials() {
let key = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ\n-----END OPENSSH PRIVATE KEY-----";
assert_eq!(count("private-key", key), 1);
assert_eq!(count("private-key", "-----BEGIN PUBLIC KEY-----\nMFkw"), 0);
// Documentation examples of each format
let tokens = "AKIAIOSFODNN7EXAMPLE ghp_0123456789abcdefghijklmnopqrstuvwxyz \
AIzaSyA-0123456789abcdefghijklmnopqrstu";
assert_eq!(count("credentials", tokens), 3);
assert_eq!(count("credentials", "AKIA123 ghp_short"), 0);
}
}
@@ -0,0 +1,281 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Asian identifiers (§2.3): India's Aadhaar and PAN, China's resident ID,
//! Japan's My Number, Singapore's NRIC and FIN, and South Korea's resident
//! registration number.
use super::{
Detector, Findings, Region, Strength, digit_values, valid_date, valid_short_date, word_near,
};
use regex::Regex;
use std::sync::LazyLock;
pub static DETECTORS: &[Detector] = &[
Detector::new(
"in-aadhaar",
"India: Aadhaar",
Region::Asia,
Strength::Checked,
in_aadhaar,
),
Detector::new(
"in-pan",
"India: PAN",
Region::Asia,
Strength::NeedsWord,
in_pan,
),
Detector::new(
"cn-resident-id",
"China: resident ID",
Region::Asia,
Strength::Checked,
cn_resident_id,
),
Detector::new(
"jp-my-number",
"Japan: My Number",
Region::Asia,
Strength::Checked,
jp_my_number,
),
Detector::new(
"sg-nric",
"Singapore: NRIC and FIN",
Region::Asia,
Strength::Checked,
sg_nric,
),
Detector::new(
"kr-rrn",
"South Korea: resident registration number",
Region::Asia,
Strength::NeedsWord,
kr_rrn,
),
];
fn re(pattern: &str) -> Regex {
Regex::new(pattern).expect("detector pattern")
}
/// Twelve digits written in fours, or bare.
static TWELVE: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{4})( ?)(\d{4})( ?)(\d{4})\b"));
const VERHOEFF_D: [[u8; 10]; 10] = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
];
const VERHOEFF_P: [[u8; 10]; 8] = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
];
/// The Verhoeff check (dihedral group D5).
pub fn verhoeff(n: &str) -> bool {
let mut c = 0u8;
for (i, b) in n.bytes().rev().enumerate() {
c = VERHOEFF_D[c as usize][VERHOEFF_P[i % 8][(b - b'0') as usize] as usize];
}
c == 0
}
const AADHAAR_WORDS: &[&str] = &["aadhaar", "aadhar", "uidai", "uid"];
fn in_aadhaar(text: &str, findings: &mut Findings) {
for c in TWELVE.captures_iter(text) {
let whole = c.get(0).unwrap();
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
let written = &c[2] == " " && &c[4] == " ";
// Never starts with 0 or 1
if !n.starts_with(['0', '1'])
&& verhoeff(&n)
&& (written || word_near(text, whole.start(), whole.end(), AADHAAR_WORDS))
{
findings.insert(n);
}
}
}
/// Five letters (the fourth names the holder's type), four digits, a letter.
static PAN: LazyLock<Regex> = LazyLock::new(|| re(r"\b[A-Z]{3}[ABCFGHLJPTK][A-Z]\d{4}[A-Z]\b"));
const PAN_WORDS: &[&str] = &["pan", "pan card", "permanent account number", "income tax"];
fn in_pan(text: &str, findings: &mut Findings) {
for m in PAN.find_iter(text) {
if word_near(text, m.start(), m.end(), PAN_WORDS) {
findings.insert(m.as_str());
}
}
}
/// Region, birth date `YYYYMMDD`, sequence, then the ISO 7064 MOD 11-2
/// check (0–9 or X).
static CN_ID: LazyLock<Regex> =
LazyLock::new(|| re(r"(?i)\b[1-8]\d{5}(\d{4})(\d{2})(\d{2})\d{3}[\dX]\b"));
pub fn cn_id_valid(id: &str) -> bool {
const WEIGHTS: [u32; 17] = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const CHECKS: &[u8] = b"10X98765432";
let sum: u32 = digit_values(&id[..17])
.iter()
.zip(WEIGHTS)
.map(|(a, w)| a * w)
.sum();
CHECKS[(sum % 11) as usize] == id.as_bytes()[17].to_ascii_uppercase()
}
fn cn_resident_id(text: &str, findings: &mut Findings) {
for c in CN_ID.captures_iter(text) {
let id = c[0].to_ascii_uppercase();
let (y, m, d) = (num(&c[1]), num(&c[2]), num(&c[3]));
if valid_date(y, m, d) && cn_id_valid(&id) {
findings.insert(id);
}
}
}
/// My Number: weights 2–7 then 2–6 from the right; a remainder of 0 or 1
/// gives 0, else 11 minus it.
pub fn my_number_valid(n: &str) -> bool {
let d = digit_values(n);
let sum: u32 = (1..=11)
.map(|i| d[11 - i] * if i <= 6 { i as u32 + 1 } else { i as u32 - 5 })
.sum();
let check = match sum % 11 {
0 | 1 => 0,
r => 11 - r,
};
check == d[11]
}
const MY_NUMBER_WORDS: &[&str] = &[
"my number",
"mynumber",
"マイナンバー",
"個人番号",
"kojin bango",
];
fn jp_my_number(text: &str, findings: &mut Findings) {
for c in TWELVE.captures_iter(text) {
let whole = c.get(0).unwrap();
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
let written = &c[2] == " " && &c[4] == " ";
if my_number_valid(&n)
&& (written || word_near(text, whole.start(), whole.end(), MY_NUMBER_WORDS))
{
findings.insert(n);
}
}
}
static NRIC: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b([STFGM])(\d{7})([A-Z])\b"));
/// Weights 2, 7, 6, 5, 4, 3, 2; T and G add 4, M adds 3; each series has its
/// own table of check letters.
fn nric_valid(prefix: u8, digits: &str, check: u8) -> bool {
let sum: u32 = digit_values(digits)
.iter()
.zip([2, 7, 6, 5, 4, 3, 2])
.map(|(a, w)| a * w)
.sum::<u32>()
+ match prefix {
b'T' | b'G' => 4,
b'M' => 3,
_ => 0,
};
let table: &[u8] = match prefix {
b'S' | b'T' => b"JZIHGFEDCBA",
b'F' | b'G' => b"XWUTRQPNMLK",
_ => b"KLJNPQRTUWX",
};
table[(sum % 11) as usize] == check
}
fn sg_nric(text: &str, findings: &mut Findings) {
for c in NRIC.captures_iter(text) {
let id = c[0].to_ascii_uppercase();
let bytes = id.as_bytes();
if nric_valid(bytes[0], &c[2], bytes[8]) {
findings.insert(id);
}
}
}
/// `YYMMDD-GNNNNNN`, the seventh digit giving sex and century.
static RRN: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{2})(\d{2})(\d{2})-?([1-8])\d{6}\b"));
const RRN_WORDS: &[&str] = &["주민등록번호", "주민번호", "resident registration", "rrn"];
fn kr_rrn(text: &str, findings: &mut Findings) {
for c in RRN.captures_iter(text) {
let whole = c.get(0).unwrap();
if valid_short_date(num(&c[1]), num(&c[2]), num(&c[3]))
&& word_near(text, whole.start(), whole.end(), RRN_WORDS)
{
findings.insert(whole.as_str().replace('-', ""));
}
}
}
fn num(s: &str) -> u32 {
s.parse().unwrap_or(0)
}
#[cfg(test)]
mod tests {
use crate::mailflow::detectors::by_id;
fn count(id: &str, text: &str) -> usize {
by_id(id).unwrap().count(text)
}
#[test]
fn india() {
assert_eq!(count("in-aadhaar", "2345 6789 0124"), 1);
assert_eq!(count("in-aadhaar", "2345 6789 0125"), 0);
assert_eq!(count("in-aadhaar", "order 234567890124"), 0);
assert_eq!(count("in-aadhaar", "Aadhaar 234567890124"), 1);
assert_eq!(count("in-pan", "PAN: ABCPE1234F"), 1);
assert_eq!(count("in-pan", "ABCPE1234F"), 0);
}
#[test]
fn china_japan() {
assert_eq!(count("cn-resident-id", "11010519491231002X"), 1);
assert_eq!(count("cn-resident-id", "110105194912310021"), 0);
assert_eq!(count("cn-resident-id", "11010519491331002X"), 0);
assert_eq!(count("jp-my-number", "1234 5678 9018"), 1);
assert_eq!(count("jp-my-number", "1234 5678 9017"), 0);
assert_eq!(count("jp-my-number", "マイナンバー 123456789018"), 1);
}
#[test]
fn singapore_korea() {
assert_eq!(count("sg-nric", "S1234567D and T1234567J"), 2);
assert_eq!(count("sg-nric", "S1234567E"), 0);
assert_eq!(count("kr-rrn", "주민등록번호 800101-1234567"), 1);
assert_eq!(count("kr-rrn", "800101-1234567"), 0);
assert_eq!(count("kr-rrn", "RRN 801301-1234567"), 0);
}
}
@@ -0,0 +1,128 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Australian identifiers (§2.3): the ATO's Tax File Number and the Medicare
//! card number.
use super::{Detector, Findings, Region, Strength, word_near};
use regex::Regex;
use std::sync::LazyLock;
pub static DETECTORS: &[Detector] = &[
Detector::new(
"au-tfn",
"Australian Tax File Number",
Region::Australia,
Strength::Checked,
tfn,
),
Detector::new(
"au-medicare",
"Australian Medicare number",
Region::Australia,
Strength::Checked,
medicare,
),
];
fn re(pattern: &str) -> Regex {
Regex::new(pattern).expect("detector pattern")
}
/// `NNN NNN NNN` stands alone; bare digits (eight or nine) need a word.
static TFN: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{3})( ?)(\d{3})( ?)(\d{2,3})\b"));
/// Weighted sum mod 11, with the ATO's weights for 9- and 8-digit numbers.
pub fn tfn_valid(n: &str) -> bool {
let weights: &[u32] = match n.len() {
9 => &[1, 4, 3, 7, 5, 8, 6, 9, 10],
8 => &[10, 7, 8, 4, 6, 3, 5, 1],
_ => return false,
};
n.bytes()
.zip(weights)
.map(|(b, w)| u32::from(b - b'0') * w)
.sum::<u32>()
% 11
== 0
}
const TFN_WORDS: &[&str] = &["tfn", "tax file number", "tax file no"];
fn tfn(text: &str, findings: &mut Findings) {
for c in TFN.captures_iter(text) {
let whole = c.get(0).unwrap();
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
let written = n.len() == 9 && c[2] == *" " && c[4] == *" ";
if tfn_valid(&n) && (written || word_near(text, whole.start(), whole.end(), TFN_WORDS)) {
findings.insert(n);
}
}
}
/// `NNNN NNNNN N` (and an optional issue number) stands alone; bare digits
/// need a word.
static MEDICARE: LazyLock<Regex> =
LazyLock::new(|| re(r"\b([2-6]\d{3})( ?)(\d{5})( ?)(\d)(?:[ -]?\d)?\b"));
/// The ninth digit is the weighted sum (1, 3, 7, 9, 1, 3, 7, 9) of the first
/// eight, mod 10.
pub fn medicare_valid(n: &str) -> bool {
let d: Vec<u32> = n.bytes().map(|b| u32::from(b - b'0')).collect();
d.len() >= 9
&& d[..8]
.iter()
.zip([1, 3, 7, 9, 1, 3, 7, 9])
.map(|(a, w)| a * w)
.sum::<u32>()
% 10
== d[8]
}
const MEDICARE_WORDS: &[&str] = &[
"medicare",
"medicare card",
"medicare no",
"medicare number",
];
fn medicare(text: &str, findings: &mut Findings) {
for c in MEDICARE.captures_iter(text) {
let whole = c.get(0).unwrap();
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
let written = c[2] == *" " && c[4] == *" ";
if medicare_valid(&n)
&& (written || word_near(text, whole.start(), whole.end(), MEDICARE_WORDS))
{
findings.insert(n);
}
}
}
#[cfg(test)]
mod tests {
use crate::mailflow::detectors::by_id;
fn count(id: &str, text: &str) -> usize {
by_id(id).unwrap().count(text)
}
#[test]
fn tax_file_numbers() {
assert_eq!(count("au-tfn", "TFN 123 456 782"), 1);
assert_eq!(count("au-tfn", "123 456 789"), 0);
assert_eq!(count("au-tfn", "order 123456782"), 0);
assert_eq!(count("au-tfn", "tax file number 123456782"), 1);
}
#[test]
fn medicare_numbers() {
assert_eq!(count("au-medicare", "2123 45670 1"), 1);
assert_eq!(count("au-medicare", "2123 45671 1"), 0);
assert_eq!(count("au-medicare", "ref 2123456701"), 0);
assert_eq!(count("au-medicare", "Medicare 2123456701"), 1);
}
}
@@ -0,0 +1,66 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Canadian identifiers (§2.3): the Social Insurance Number.
use super::{Detector, Findings, Region, Strength, checks, word_near};
use regex::Regex;
use std::sync::LazyLock;
pub static DETECTORS: &[Detector] = &[Detector::new(
"ca-sin",
"Canadian Social Insurance Number",
Region::Canada,
Strength::Checked,
sin,
)];
/// `NNN NNN NNN` or `NNN-NNN-NNN` stands alone; nine bare digits need a word.
static SIN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\b(\d{3})([ -]?)(\d{3})([ -]?)(\d{3})\b").expect("detector pattern")
});
const SIN_WORDS: &[&str] = &[
"sin",
"social insurance",
"nas",
"numéro d'assurance sociale",
"assurance sociale",
];
fn sin(text: &str, findings: &mut Findings) {
for c in SIN.captures_iter(text) {
let whole = c.get(0).unwrap();
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
let written = !c[2].is_empty() && c[2] == c[4];
// 0 and 8 are never issued as a first digit
if !n.starts_with(['0', '8'])
&& checks::luhn(&n)
&& (written || word_near(text, whole.start(), whole.end(), SIN_WORDS))
{
findings.insert(n);
}
}
}
#[cfg(test)]
mod tests {
use crate::mailflow::detectors::by_id;
fn count(text: &str) -> usize {
by_id("ca-sin").unwrap().count(text)
}
#[test]
fn social_insurance_numbers() {
assert_eq!(count("130 692 544 and 193-456-787"), 2);
assert_eq!(count("130 692 545"), 0);
// The government's printed example starts with 0, never issued
assert_eq!(count("046 454 286"), 0);
assert_eq!(count("order 130692544"), 0);
assert_eq!(count("SIN: 130692544"), 1);
}
}
@@ -0,0 +1,227 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Check-digit algorithms, each from its public definition.
/// The Luhn check (ISO/IEC 7812-1, Annex B) over a string of ASCII digits.
pub fn luhn(digits: &str) -> bool {
if digits.len() < 2 || !digits.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
let sum: u32 = digits
.bytes()
.rev()
.enumerate()
.map(|(i, b)| {
let d = u32::from(b - b'0');
if i % 2 == 1 {
let d = d * 2;
if d > 9 { d - 9 } else { d }
} else {
d
}
})
.sum();
sum.is_multiple_of(10)
}
/// ISO 13616 IBAN lengths, by country, from the IBAN registry.
const IBAN_LENGTHS: &[(&str, usize)] = &[
("AD", 24),
("AE", 23),
("AL", 28),
("AT", 20),
("AZ", 28),
("BA", 20),
("BE", 16),
("BG", 22),
("BH", 22),
("BI", 27),
("BR", 29),
("BY", 28),
("CH", 21),
("CR", 22),
("CY", 28),
("CZ", 24),
("DE", 22),
("DJ", 27),
("DK", 18),
("DO", 28),
("EE", 20),
("EG", 29),
("ES", 24),
("FI", 18),
("FK", 18),
("FO", 18),
("FR", 27),
("GB", 22),
("GE", 22),
("GI", 23),
("GL", 18),
("GR", 27),
("GT", 28),
("HN", 28),
("HR", 21),
("HU", 28),
("IE", 22),
("IL", 23),
("IQ", 23),
("IS", 26),
("IT", 27),
("JO", 30),
("KW", 30),
("KZ", 20),
("LB", 28),
("LC", 32),
("LI", 21),
("LT", 20),
("LU", 20),
("LV", 21),
("LY", 25),
("MC", 27),
("MD", 24),
("ME", 22),
("MK", 19),
("MN", 20),
("MR", 27),
("MT", 31),
("MU", 30),
("NI", 28),
("NL", 18),
("NO", 15),
("OM", 23),
("PK", 24),
("PL", 28),
("PS", 29),
("PT", 25),
("QA", 29),
("RO", 24),
("RS", 22),
("RU", 33),
("SA", 24),
("SC", 31),
("SD", 18),
("SE", 24),
("SI", 19),
("SK", 24),
("SM", 27),
("SO", 23),
("ST", 25),
("SV", 28),
("TL", 23),
("TN", 24),
("TR", 26),
("UA", 29),
("VA", 22),
("VG", 24),
("XK", 20),
("YE", 30),
];
/// The IBAN length for a country code, if the country uses IBANs.
pub fn iban_length(country: &str) -> Option<usize> {
IBAN_LENGTHS
.iter()
.find(|(code, _)| *code == country)
.map(|(_, len)| *len)
}
/// ISO 13616 / ISO 7064 MOD 97-10 over an IBAN with no spaces, upper case:
/// move the first four characters to the end, turn letters into 10–35, and
/// the number mod 97 must be 1. Also checks the country's length.
pub fn iban(iban: &str) -> bool {
if iban.len() < 5
|| !iban
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
{
return false;
}
if iban_length(&iban[..2]) != Some(iban.len())
|| !iban[2..4].bytes().all(|b| b.is_ascii_digit())
{
return false;
}
let mut remainder: u32 = 0;
for b in iban[4..].bytes().chain(iban[..4].bytes()) {
let value = if b.is_ascii_digit() {
u32::from(b - b'0')
} else {
u32::from(b - b'A') + 10
};
remainder = if value >= 10 {
(remainder * 100 + value) % 97
} else {
(remainder * 10 + value) % 97
};
}
remainder == 1
}
/// ISO 3166-1 alpha-2 country codes, for SWIFT/BIC positions 5–6.
const COUNTRIES: &str = "AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ \
BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ \
DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT \
GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY \
KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX \
MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS \
RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN \
TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS XK YE YT ZA ZM ZW";
pub fn is_country(code: &str) -> bool {
code.len() == 2 && COUNTRIES.split(' ').any(|c| c == code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn luhn_known_numbers() {
// Published test card numbers
for good in [
"4242424242424242",
"5555555555554444",
"378282246310005",
"79927398713",
] {
assert!(luhn(good), "{good}");
}
for bad in ["4242424242424241", "79927398710", "1", "12a4"] {
assert!(!luhn(bad), "{bad}");
}
}
#[test]
fn iban_registry_examples() {
// The IBAN registry's own examples
for good in [
"GB29NWBK60161331926819",
"DE89370400440532013000",
"FR1420041010050500013M02606",
"NL91ABNA0417164300",
"BE68539007547034",
"NO9386011117947",
"CH9300762011623852957",
] {
assert!(iban(good), "{good}");
}
for bad in [
"GB29NWBK60161331926818", // check fails
"GB29NWBK6016133192681", // too short for GB
"ZZ29NWBK60161331926819", // no such country
"DE8937040044053201300A", // letters where DE has none still fail mod 97
] {
assert!(!iban(bad), "{bad}");
}
}
#[test]
fn countries() {
assert!(is_country("DE") && is_country("US") && is_country("XK"));
assert!(!is_country("ZZ") && !is_country("D"));
}
}
@@ -0,0 +1,646 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! European Union national identifiers (§2.3), each from its issuer's
//! published rules. An identifier that is only digits and whose check a
//! random number passes often (mod 10, mod 11) counts alone only in its
//! written form, and as bare digits only beside a word.
use super::{
Detector, Findings, Region, Strength, checks, digit_values, stands_alone, valid_short_date,
word_near,
};
use regex::Regex;
use std::sync::LazyLock;
pub static DETECTORS: &[Detector] = &[
Detector::new(
"de-tax-id",
"Germany: tax ID (Steuer-ID)",
Region::Eu,
Strength::Checked,
de_tax_id,
),
Detector::new(
"de-id-card",
"Germany: ID card number",
Region::Eu,
Strength::Checked,
de_id_card,
),
Detector::new(
"fr-nir",
"France: social security number (NIR)",
Region::Eu,
Strength::Checked,
fr_nir,
),
Detector::new(
"es-dni-nie",
"Spain: DNI and NIE",
Region::Eu,
Strength::Checked,
es_dni_nie,
),
Detector::new(
"it-codice-fiscale",
"Italy: codice fiscale",
Region::Eu,
Strength::Checked,
it_codice_fiscale,
),
Detector::new(
"nl-bsn",
"Netherlands: BSN",
Region::Eu,
Strength::Checked,
nl_bsn,
),
Detector::new(
"be-national-number",
"Belgium: national number",
Region::Eu,
Strength::Checked,
be_national_number,
),
Detector::new(
"pl-pesel",
"Poland: PESEL",
Region::Eu,
Strength::Checked,
pl_pesel,
),
Detector::new(
"se-personnummer",
"Sweden: personnummer",
Region::Eu,
Strength::Checked,
se_personnummer,
),
Detector::new(
"dk-cpr",
"Denmark: CPR number",
Region::Eu,
Strength::NeedsWord,
dk_cpr,
),
Detector::new(
"fi-hetu",
"Finland: personal identity code",
Region::Eu,
Strength::Checked,
fi_hetu,
),
Detector::new(
"ie-pps",
"Ireland: PPS number",
Region::Eu,
Strength::Checked,
ie_pps,
),
Detector::new(
"pt-nif",
"Portugal: NIF",
Region::Eu,
Strength::Checked,
pt_nif,
),
Detector::new(
"at-svnr",
"Austria: social insurance number",
Region::Eu,
Strength::Checked,
at_svnr,
),
];
fn re(pattern: &str) -> Regex {
Regex::new(pattern).expect("detector pattern")
}
fn num(s: &str) -> u32 {
s.parse().unwrap_or(0)
}
// --- Germany --------------------------------------------------------------
/// Eleven digits, written `86 095 742 719` on the BZSt's letters.
static DE_TAX: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{2}( ?)\d{3}( ?)\d{3}( ?)\d{3}\b"));
/// ISO 7064 MOD 11,10; no leading zero; in the first ten digits one digit
/// appears two or three times and every other at most once.
pub fn de_tax_id_valid(n: &str) -> bool {
let d = digit_values(n);
if d.len() != 11 || d[0] == 0 {
return false;
}
let mut counts = [0u8; 10];
for &x in &d[..10] {
counts[x as usize] += 1;
}
let repeated = counts.iter().filter(|&&c| c >= 2).count();
if repeated != 1 || counts.iter().any(|&c| c > 3) {
return false;
}
let mut product = 10;
for &x in &d[..10] {
let mut sum = (x + product) % 10;
if sum == 0 {
sum = 10;
}
product = (2 * sum) % 11;
}
let check = match 11 - product {
10 => 0,
c => c,
};
check == d[10]
}
const DE_TAX_WORDS: &[&str] = &[
"steuer-id",
"steueridentifikationsnummer",
"steuerliche identifikationsnummer",
"idnr",
"identifikationsnummer",
"tax id",
];
fn de_tax_id(text: &str, findings: &mut Findings) {
for c in DE_TAX.captures_iter(text) {
let whole = c.get(0).unwrap();
let written = [&c[1], &c[2], &c[3]].iter().all(|s| *s == " ");
let n: String = whole.as_str().replace(' ', "");
if de_tax_id_valid(&n)
&& (written || word_near(text, whole.start(), whole.end(), DE_TAX_WORDS))
{
findings.insert(n);
}
}
}
/// The ID card's document number: a letter from the card's alphabet, eight
/// more characters from it, then the check digit.
static DE_ID: LazyLock<Regex> =
LazyLock::new(|| re(r"\b[CFGHJKLMNPRTVWXYZ][CFGHJKLMNPRTVWXYZ0-9]{8}\d\b"));
/// ICAO 9303 check digit: weights 7, 3, 1; letters A=10 … Z=35.
pub fn icao_check(chars: &str, check: u32) -> bool {
let value = |c: char| c.to_digit(10).unwrap_or_else(|| c as u32 - 'A' as u32 + 10);
let sum: u32 = chars
.chars()
.zip([7, 3, 1].iter().cycle())
.map(|(c, w)| value(c) * w)
.sum();
sum % 10 == check
}
fn de_id_card(text: &str, findings: &mut Findings) {
for m in DE_ID.find_iter(text) {
let s = m.as_str();
if icao_check(&s[..9], num(&s[9..])) {
findings.insert(s);
}
}
}
// --- France ---------------------------------------------------------------
/// Sex, year, month, department (with Corsica's 2A and 2B), commune, order,
/// then the two-digit key, spaces allowed between groups.
static FR_NIR: LazyLock<Regex> = LazyLock::new(|| {
re(r"\b([1-478]) ?(\d{2}) ?(\d{2}) ?(\d{2}|2[AB]) ?(\d{3}) ?(\d{3}) ?(\d{2})\b")
});
fn fr_nir(text: &str, findings: &mut Findings) {
for c in FR_NIR.captures_iter(text) {
let month = num(&c[3]);
if !(matches!(month, 1..=12 | 20..=42 | 50..=99)) {
continue;
}
let department = match &c[4] {
"2A" => "19",
"2B" => "18",
d => d,
};
let body = format!(
"{}{}{}{}{}{}",
&c[1], &c[2], &c[3], department, &c[5], &c[6]
);
let Ok(value) = body.parse::<u64>() else {
continue;
};
if 97 - value % 97 == u64::from(num(&c[7])) {
findings.insert(format!(
"{}{}{}{}{}{}{}",
&c[1], &c[2], &c[3], &c[4], &c[5], &c[6], &c[7]
));
}
}
}
// --- Spain ----------------------------------------------------------------
static ES_ID: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b([XYZ]?)[ -]?(\d{7,8})[ -]?([A-Z])\b"));
const DNI_LETTERS: &[u8] = b"TRWAGMYFPDXBNJZSQVHLCKE";
fn es_dni_nie(text: &str, findings: &mut Findings) {
for c in ES_ID.captures_iter(text) {
let prefix = c[1].to_ascii_uppercase();
let digits = &c[2];
// DNI: eight digits; NIE: X, Y or Z and seven digits
let number = match (prefix.as_str(), digits.len()) {
("", 8) => digits.to_string(),
("X", 7) => format!("0{digits}"),
("Y", 7) => format!("1{digits}"),
("Z", 7) => format!("2{digits}"),
_ => continue,
};
let letter = c[3].to_ascii_uppercase();
if DNI_LETTERS[(num(&number) % 23) as usize] == letter.as_bytes()[0] {
findings.insert(format!("{prefix}{digits}{letter}"));
}
}
}
// --- Italy ----------------------------------------------------------------
/// Surname and name letters, year, month letter, day, place code, check
/// letter; digits may be replaced by letters (omocodia).
static IT_CF: LazyLock<Regex> = LazyLock::new(|| {
let d = "[0-9LMNPQRSTUV]";
re(&format!(
r"(?i)\b[A-Z]{{6}}{d}{{2}}[ABCDEHLMPRST]{d}{{2}}[A-Z]{d}{{3}}[A-Z]\b"
))
});
/// The Ministry's odd-position values for 0–9 and A–Z.
const CF_ODD: [u32; 36] = [
1, 0, 5, 7, 9, 13, 15, 17, 19, 21, // 0-9
1, 0, 5, 7, 9, 13, 15, 17, 19, 21, 2, 4, 18, 20, 11, 3, 6, 8, 12, 14, 16, 10, 22, 25, 24,
23, // A-Z
];
pub fn codice_fiscale_valid(cf: &str) -> bool {
let index = |c: u8| {
if c.is_ascii_digit() {
(c - b'0') as usize
} else {
(c - b'A') as usize + 10
}
};
let even = |c: u8| {
if c.is_ascii_digit() {
u32::from(c - b'0')
} else {
u32::from(c - b'A')
}
};
let bytes = cf.as_bytes();
let sum: u32 = bytes[..15]
.iter()
.enumerate()
.map(|(i, &c)| {
if i % 2 == 0 {
CF_ODD[index(c)]
} else {
even(c)
}
})
.sum();
u32::from(bytes[15] - b'A') == sum % 26
}
fn it_codice_fiscale(text: &str, findings: &mut Findings) {
for m in IT_CF.find_iter(text) {
let cf = m.as_str().to_ascii_uppercase();
if codice_fiscale_valid(&cf) {
findings.insert(cf);
}
}
}
// --- Netherlands ----------------------------------------------------------
/// Nine digits, sometimes written `1112.22.333`.
static NL_BSN: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{4})(\.?)(\d{2})(\.?)(\d{3})\b"));
/// The eleven test: weights 9 down to 2, and −1 for the last digit.
pub fn bsn_valid(n: &str) -> bool {
let d = digit_values(n);
let sum: i64 = d[..8]
.iter()
.zip((2..=9).rev())
.map(|(a, w)| i64::from(a * w))
.sum::<i64>()
- i64::from(d[8]);
sum != 0 && sum % 11 == 0
}
const BSN_WORDS: &[&str] = &[
"bsn",
"burgerservicenummer",
"sofinummer",
"sofi-nummer",
"citizen service number",
];
fn nl_bsn(text: &str, findings: &mut Findings) {
for c in NL_BSN.captures_iter(text) {
let whole = c.get(0).unwrap();
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
let written = &c[2] == "." && &c[4] == ".";
if bsn_valid(&n) && (written || word_near(text, whole.start(), whole.end(), BSN_WORDS)) {
findings.insert(n);
}
}
}
// --- Belgium --------------------------------------------------------------
/// `YY.MM.DD-XXX.CC` or eleven digits.
static BE_NN: LazyLock<Regex> =
LazyLock::new(|| re(r"\b(\d{2})\.?(\d{2})\.?(\d{2})-?(\d{3})\.?(\d{2})\b"));
fn be_national_number(text: &str, findings: &mut Findings) {
for c in BE_NN.captures_iter(text) {
let (month, day) = (num(&c[2]), num(&c[3]));
// Month 0 and day 0 mean unknown; bis numbers add 20 or 40 to the month
if !(month <= 12 || (20..=32).contains(&month) || (40..=52).contains(&month)) || day > 31 {
continue;
}
let body = format!("{}{}{}{}", &c[1], &c[2], &c[3], &c[4]);
let check = u64::from(num(&c[5]));
let before_2000 = 97 - body.parse::<u64>().unwrap_or(0) % 97;
let since_2000 = 97 - format!("2{body}").parse::<u64>().unwrap_or(0) % 97;
if check == before_2000 || check == since_2000 {
findings.insert(format!("{body}{}", &c[5]));
}
}
}
// --- Poland ---------------------------------------------------------------
static ELEVEN: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{11}\b"));
/// Weights 1, 3, 7, 9 repeating; the birth date encodes the century in the
/// month (+80 for the 1800s, +20 for the 2000s, and so on).
pub fn pesel_valid(n: &str) -> bool {
let d = digit_values(n);
let sum: u32 = d[..10]
.iter()
.zip([1, 3, 7, 9].iter().cycle())
.map(|(a, w)| a * w)
.sum();
let month = d[2] * 10 + d[3];
let (century, month) = match month {
81..=92 => (1800, month - 80),
1..=12 => (1900, month),
21..=32 => (2000, month - 20),
41..=52 => (2100, month - 40),
_ => return false,
};
let year = century + d[0] * 10 + d[1];
(10 - sum % 10) % 10 == d[10] && (1..=super::days_in(year, month)).contains(&(d[4] * 10 + d[5]))
}
const PESEL_WORDS: &[&str] = &["pesel", "numer pesel", "nr pesel"];
fn pl_pesel(text: &str, findings: &mut Findings) {
for m in ELEVEN.find_iter(text) {
if pesel_valid(m.as_str()) && word_near(text, m.start(), m.end(), PESEL_WORDS) {
findings.insert(m.as_str());
}
}
}
// --- Sweden ---------------------------------------------------------------
/// `YYMMDD-NNNN`, `YYYYMMDD-NNNN` (`+` after 100), or the bare digits.
static SE_PNR: LazyLock<Regex> =
LazyLock::new(|| re(r"\b(?:\d{2})?(\d{2})(\d{2})(\d{2})([-+]?)(\d{4})\b"));
const SE_WORDS: &[&str] = &[
"personnummer",
"personnr",
"person nr",
"samordningsnummer",
"pnr",
];
fn se_personnummer(text: &str, findings: &mut Findings) {
for c in SE_PNR.captures_iter(text) {
let whole = c.get(0).unwrap();
let (yy, month, day) = (num(&c[1]), num(&c[2]), num(&c[3]));
// Coordination numbers add 60 to the day
let day = if day > 60 { day - 60 } else { day };
let ten = format!("{}{}{}{}", &c[1], &c[2], &c[3], &c[5]);
let written = !c[4].is_empty();
if valid_short_date(yy, month, day)
&& checks::luhn(&ten)
&& (written || word_near(text, whole.start(), whole.end(), SE_WORDS))
{
findings.insert(ten);
}
}
}
// --- Denmark --------------------------------------------------------------
static DK_CPR: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{2})(\d{2})(\d{2})-?(\d{4})\b"));
const CPR_WORDS: &[&str] = &["cpr", "cpr-nr", "cpr nr", "cpr-nummer", "personnummer"];
fn dk_cpr(text: &str, findings: &mut Findings) {
for c in DK_CPR.captures_iter(text) {
let whole = c.get(0).unwrap();
if valid_short_date(num(&c[3]), num(&c[2]), num(&c[1]))
&& word_near(text, whole.start(), whole.end(), CPR_WORDS)
{
findings.insert(whole.as_str().replace('-', ""));
}
}
}
// --- Finland --------------------------------------------------------------
static FI_HETU: LazyLock<Regex> =
LazyLock::new(|| re(r"(?i)\b(\d{2})(\d{2})(\d{2})[-+ABCDEFYXWVU](\d{3})([0-9A-Y])\b"));
const HETU_CHECK: &[u8] = b"0123456789ABCDEFHJKLMNPRSTUVWXY";
fn fi_hetu(text: &str, findings: &mut Findings) {
for c in FI_HETU.captures_iter(text) {
let (day, month, yy) = (num(&c[1]), num(&c[2]), num(&c[3]));
let n: u64 = format!("{}{}{}{}", &c[1], &c[2], &c[3], &c[4])
.parse()
.unwrap_or(0);
let check = c[5].to_ascii_uppercase().as_bytes()[0];
if valid_short_date(yy, month, day) && HETU_CHECK[(n % 31) as usize] == check {
findings.insert(c[0].to_ascii_uppercase());
}
}
}
// --- Ireland --------------------------------------------------------------
static IE_PPS: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b(\d{7})([A-W])([ABHW]?)\b"));
const PPS_CHECK: &[u8] = b"WABCDEFGHIJKLMNOPQRSTUV";
fn ie_pps(text: &str, findings: &mut Findings) {
for c in IE_PPS.captures_iter(text) {
let mut sum: u32 = digit_values(&c[1])
.iter()
.zip((2..=8).rev())
.map(|(a, w)| a * w)
.sum();
// The second letter counts, times 9; W (the old form) counts as 0
let second = c[3].to_ascii_uppercase();
if let Some(&letter) = second.as_bytes().first()
&& letter != b'W'
{
sum += u32::from(letter - b'A' + 1) * 9;
}
let check = c[2].to_ascii_uppercase().as_bytes()[0];
if PPS_CHECK[(sum % 23) as usize] == check {
findings.insert(c[0].to_ascii_uppercase());
}
}
}
// --- Portugal -------------------------------------------------------------
static NINE: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{9}\b"));
/// Mod 11 over weights 9 down to 2; a check of 10 or 11 becomes 0.
pub fn nif_valid(n: &str) -> bool {
let d = digit_values(n);
let sum: u32 = d[..8].iter().zip((2..=9).rev()).map(|(a, w)| a * w).sum();
let check = match 11 - sum % 11 {
10 | 11 => 0,
c => c,
};
matches!(d[0], 1 | 2 | 3 | 5 | 6 | 8 | 9) && check == d[8]
}
const NIF_WORDS: &[&str] = &[
"nif",
"contribuinte",
"número de identificação fiscal",
"numero de contribuinte",
];
fn pt_nif(text: &str, findings: &mut Findings) {
for m in NINE.find_iter(text) {
if nif_valid(m.as_str()) && word_near(text, m.start(), m.end(), NIF_WORDS) {
findings.insert(m.as_str());
}
}
}
// --- Austria --------------------------------------------------------------
/// A serial and check digit, then the birth date: `1237 010180`.
static AT_SVNR: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{3})(\d)( ?)(\d{2})(\d{2})(\d{2})\b"));
const SVNR_WORDS: &[&str] = &[
"sozialversicherungsnummer",
"svnr",
"sv-nr",
"sv-nummer",
"versicherungsnummer",
];
fn at_svnr(text: &str, findings: &mut Findings) {
for c in AT_SVNR.captures_iter(text) {
let whole = c.get(0).unwrap();
let n = format!("{}{}{}{}{}", &c[1], &c[2], &c[4], &c[5], &c[6]);
let d = digit_values(&n);
let sum: u32 = d
.iter()
.zip([3, 7, 9, 0, 5, 8, 4, 2, 1, 6])
.map(|(a, w)| a * w)
.sum();
let written = &c[3] == " ";
if d[0] != 0
&& sum % 11 == d[3]
&& valid_short_date(num(&c[6]), num(&c[5]), num(&c[4]))
&& (written || word_near(text, whole.start(), whole.end(), SVNR_WORDS))
&& stands_alone(text, whole.start(), whole.end())
{
findings.insert(n);
}
}
}
#[cfg(test)]
mod tests {
use crate::mailflow::detectors::by_id;
fn count(id: &str, text: &str) -> usize {
by_id(id).unwrap().count(text)
}
#[test]
fn germany() {
assert_eq!(count("de-tax-id", "86 095 742 719"), 1);
assert_eq!(count("de-tax-id", "Steuer-ID: 86095742719"), 1);
assert_eq!(count("de-tax-id", "Rechnung 86095742719"), 0);
assert_eq!(count("de-tax-id", "86 095 742 718"), 0);
// ICAO 9303's German specimen card
assert_eq!(count("de-id-card", "Ausweis T220001293"), 1);
assert_eq!(count("de-id-card", "T220001294"), 0);
}
#[test]
fn france_spain_italy() {
assert_eq!(count("fr-nir", "2 55 08 14 168 025 38"), 1);
assert_eq!(count("fr-nir", "255081416802539"), 0);
assert_eq!(count("es-dni-nie", "DNI 12345678Z, NIE X-1234567-L"), 2);
assert_eq!(count("es-dni-nie", "12345678A"), 0);
assert_eq!(count("it-codice-fiscale", "CF: RSSMRA85T10A562S"), 1);
assert_eq!(count("it-codice-fiscale", "RSSMRA85T10A562T"), 0);
}
#[test]
fn benelux() {
assert_eq!(count("nl-bsn", "1112.22.333"), 1);
assert_eq!(count("nl-bsn", "BSN 111222333"), 1);
assert_eq!(count("nl-bsn", "order 111222333"), 0);
assert_eq!(count("nl-bsn", "BSN 111222334"), 0);
assert_eq!(count("be-national-number", "85.07.30-033.28"), 1);
assert_eq!(count("be-national-number", "85073003329"), 0);
}
#[test]
fn nordics() {
assert_eq!(count("se-personnummer", "811218-9876"), 1);
assert_eq!(count("se-personnummer", "811218-9875"), 0);
assert_eq!(count("se-personnummer", "order 8112189876"), 0);
assert_eq!(count("se-personnummer", "personnummer 198112189876"), 1);
assert_eq!(count("dk-cpr", "CPR-nr: 010170-1234"), 1);
assert_eq!(count("dk-cpr", "010170-1234"), 0);
assert_eq!(count("dk-cpr", "CPR 320170-1234"), 0);
assert_eq!(count("fi-hetu", "131052-308T"), 1);
assert_eq!(count("fi-hetu", "131052-308U"), 0);
}
#[test]
fn poland_ireland_portugal_austria() {
assert_eq!(count("pl-pesel", "PESEL 44051401359, pesel 02070803628"), 2);
assert_eq!(count("pl-pesel", "PESEL 44051401358"), 0);
assert_eq!(count("pl-pesel", "44051401359"), 0);
assert_eq!(count("ie-pps", "PPS 1234567T and 1234567FA"), 2);
assert_eq!(count("ie-pps", "1234567U"), 0);
assert_eq!(count("pt-nif", "NIF 123456789"), 1);
assert_eq!(count("pt-nif", "NIF 123456788"), 0);
assert_eq!(count("at-svnr", "1237 010180"), 1);
assert_eq!(count("at-svnr", "SVNR 1237010180"), 1);
assert_eq!(count("at-svnr", "1238 010180"), 0);
}
}
@@ -0,0 +1,116 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! European identifiers outside the EU (§2.3): Norway's national identity
//! number and Switzerland's AHV number.
use super::{Detector, Findings, Region, Strength, digit_values, valid_short_date};
use regex::Regex;
use std::sync::LazyLock;
pub static DETECTORS: &[Detector] = &[
Detector::new(
"no-fnr",
"Norway: national identity number",
Region::Europe,
Strength::Checked,
no_fnr,
),
Detector::new(
"ch-ahv",
"Switzerland: AHV number",
Region::Europe,
Strength::Checked,
ch_ahv,
),
];
static ELEVEN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b\d{6} ?\d{5}\b").expect("detector pattern"));
/// Two mod 11 check digits over a birth date (D-numbers add 40 to the day,
/// H-numbers 40 to the month): strong enough to count alone.
pub fn fnr_valid(n: &str) -> bool {
let d = digit_values(n);
if d.len() != 11 {
return false;
}
let check =
|weights: &[u32]| match 11 - d.iter().zip(weights).map(|(a, w)| a * w).sum::<u32>() % 11 {
11 => Some(0),
10 => None,
c => Some(c),
};
let day = d[0] * 10 + d[1];
let month = d[2] * 10 + d[3];
let day = if day > 40 { day - 40 } else { day };
let month = if month > 40 { month - 40 } else { month };
valid_short_date(d[4] * 10 + d[5], month, day)
&& check(&[3, 7, 6, 1, 8, 9, 4, 5, 2]) == Some(d[9])
&& check(&[5, 4, 3, 2, 7, 6, 5, 4, 3, 2]) == Some(d[10])
}
fn no_fnr(text: &str, findings: &mut Findings) {
for m in ELEVEN.find_iter(text) {
let n = m.as_str().replace(' ', "");
if fnr_valid(&n) {
findings.insert(n);
}
}
}
/// `756.1234.5678.97`: the country prefix, then an EAN-13 check digit.
static AHV: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\b756[. ]?\d{4}[. ]?\d{4}[. ]?\d{2}\b").expect("detector pattern")
});
pub fn ean13_valid(n: &str) -> bool {
let d = digit_values(n);
if d.len() != 13 {
return false;
}
let sum: u32 = d[..12]
.iter()
.enumerate()
.map(|(i, x)| if i % 2 == 0 { *x } else { x * 3 })
.sum();
(10 - sum % 10) % 10 == d[12]
}
fn ch_ahv(text: &str, findings: &mut Findings) {
for m in AHV.find_iter(text) {
let n: String = m.as_str().chars().filter(char::is_ascii_digit).collect();
if ean13_valid(&n) {
findings.insert(n);
}
}
}
#[cfg(test)]
mod tests {
use crate::mailflow::detectors::by_id;
fn count(id: &str, text: &str) -> usize {
by_id(id).unwrap().count(text)
}
#[test]
fn norway() {
assert_eq!(count("no-fnr", "01019000083"), 1);
assert_eq!(count("no-fnr", "010190 00083"), 1);
assert_eq!(count("no-fnr", "01019000084"), 0);
// Not a date
assert_eq!(count("no-fnr", "32019000083"), 0);
}
#[test]
fn switzerland() {
// The federal example
assert_eq!(count("ch-ahv", "AHV 756.9217.0769.85"), 1);
assert_eq!(count("ch-ahv", "7569217076985"), 1);
assert_eq!(count("ch-ahv", "756.9217.0769.86"), 0);
}
}
@@ -0,0 +1,278 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Detectors (dlp-and-mail-flow-rules spec, §2.3): each finds one kind of
//! identifier in text and reports the distinct ones it found.
//!
//! A detector is one of two strengths:
//!
//! - **Checked**: the identifier carries a published check digit or
//! checksum, so a random number rarely passes; found on its own.
//! - **Needs a word**: the format alone is too common, so a candidate counts
//! only with a corroborating word within [`WINDOW`] characters either
//! side.
//!
//! Findings are distinct normalized values (digits only, upper case), so the
//! same card number pasted twice counts once. They stay in memory: callers
//! read only [`Findings::len`].
pub mod africa;
pub mod americas;
pub mod any;
pub mod asia;
pub mod australia;
pub mod canada;
pub mod checks;
pub mod eu;
pub mod europe;
pub mod templates;
pub mod uk;
pub mod us;
use ahash::AHashSet;
/// How far, in characters, a corroborating word may be from a candidate.
pub const WINDOW: usize = 50;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strength {
Checked,
NeedsWord,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Region {
Any,
Us,
Uk,
Canada,
Australia,
Eu,
Europe,
Asia,
Americas,
Africa,
}
/// The distinct values one detector found.
#[derive(Debug, Default)]
pub struct Findings(AHashSet<String>);
impl Findings {
pub fn insert(&mut self, value: impl Into<String>) {
self.0.insert(value.into());
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
pub struct Detector {
/// Stable id, stored in rules: `payment-card`, `iban`, `us-ssn`.
pub id: &'static str,
pub name: &'static str,
pub region: Region,
pub strength: Strength,
find: fn(&str, &mut Findings),
}
impl Detector {
pub const fn new(
id: &'static str,
name: &'static str,
region: Region,
strength: Strength,
find: fn(&str, &mut Findings),
) -> Self {
Self {
id,
name,
region,
strength,
find,
}
}
/// Adds what this detector finds in `text` to `findings`. Call once per
/// piece of text (subject, each part, each attachment) with the same
/// `findings`, then read its length.
pub fn find(&self, text: &str, findings: &mut Findings) {
(self.find)(text, findings)
}
/// The distinct values found in one text.
pub fn count(&self, text: &str) -> usize {
let mut findings = Findings::default();
self.find(text, &mut findings);
findings.len()
}
}
/// Every detector, in the order the console lists them.
pub fn all() -> impl Iterator<Item = &'static Detector> {
[
any::DETECTORS,
us::DETECTORS,
uk::DETECTORS,
canada::DETECTORS,
australia::DETECTORS,
eu::DETECTORS,
europe::DETECTORS,
asia::DETECTORS,
americas::DETECTORS,
africa::DETECTORS,
]
.into_iter()
.flatten()
}
pub fn by_id(id: &str) -> Option<&'static Detector> {
all().find(|detector| detector.id == id)
}
/// Whether one of `words` appears, as a whole word and ignoring case, within
/// [`WINDOW`] characters before `start` or after `end` (byte offsets of the
/// candidate in `text`). The window is widened by the longest word, so a
/// word that reaches into it still counts whole.
pub fn word_near(text: &str, start: usize, end: usize, words: &[&str]) -> bool {
let reach = WINDOW + words.iter().map(|w| w.chars().count()).max().unwrap_or(0);
let before = text[..start]
.char_indices()
.rev()
.nth(reach - 1)
.map_or(0, |(i, _)| i);
let after = text[end..]
.char_indices()
.nth(reach)
.map_or(text.len(), |(i, _)| end + i);
let window = text[before..after].to_lowercase();
words.iter().any(|word| contains_word(&window, word))
}
/// Whether `word` (lower case) appears in `haystack` (lower case) with no
/// letter or digit on either side.
pub fn contains_word(haystack: &str, word: &str) -> bool {
haystack.match_indices(word).any(|(i, _)| {
let before_ok = haystack[..i]
.chars()
.next_back()
.is_none_or(|c| !c.is_alphanumeric());
let after_ok = haystack[i + word.len()..]
.chars()
.next()
.is_none_or(|c| !c.is_alphanumeric());
before_ok && after_ok
})
}
/// Whether the match at `start..end` stands alone: no digit or letter
/// directly before or after it, so `123-45-6789` isn't found inside a
/// longer run of digits.
pub fn stands_alone(text: &str, start: usize, end: usize) -> bool {
let before = text[..start].chars().next_back();
let after = text[end..].chars().next();
before.is_none_or(|c| !c.is_alphanumeric()) && after.is_none_or(|c| !c.is_alphanumeric())
}
/// Days in `month` of `year` (0 for a month that doesn't exist).
pub fn days_in(year: u32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) => {
29
}
2 => 28,
_ => 0,
}
}
/// Whether `year`-`month`-`day` is a real date between 1900 and 2100.
pub fn valid_date(year: u32, month: u32, day: u32) -> bool {
(1900..=2100).contains(&year) && (1..=days_in(year, month)).contains(&day)
}
/// Whether a two-digit year, month and day make a real date in either the
/// 1900s or the 2000s.
pub fn valid_short_date(yy: u32, month: u32, day: u32) -> bool {
valid_date(1900 + yy, month, day) || valid_date(2000 + yy, month, day)
}
/// The value of each digit in `s`.
pub fn digit_values(s: &str) -> Vec<u32> {
s.bytes()
.filter(u8::is_ascii_digit)
.map(|b| u32::from(b - b'0'))
.collect()
}
/// The ASCII digits of `s`.
pub fn digits(s: &str) -> String {
s.chars().filter(char::is_ascii_digit).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn words_are_whole_and_near() {
let text = "Your passport number is X1234567, thanks";
let start = text.find("X123").unwrap();
assert!(word_near(text, start, start + 8, &["passport"]));
assert!(!word_near(text, start, start + 8, &["pass"]));
let far = format!("passport{}X1234567", " ".repeat(60));
let start = far.find("X123").unwrap();
assert!(!word_near(&far, start, start + 8, &["passport"]));
}
#[test]
fn near_counts_characters_not_bytes() {
// 45 two-byte characters between the word and the candidate: within
// 50 characters, though over 50 bytes
let text = format!("passport {} X1234567", "é".repeat(45));
let start = text.find("X123").unwrap();
assert!(word_near(&text, start, start + 8, &["passport"]));
}
/// An ordinary business email: order, invoice and tracking numbers,
/// dates, amounts, a street address. Nothing here is an identifier, so
/// no detector may fire, except the contact ones on the signature.
#[test]
fn ordinary_mail_finds_nothing() {
let text = "Hi Dana,\n\nThanks for order 4471-2290 placed 2026-09-14. Invoice INV-2026-00917 \
for $12,480.00 is due 10/31/2026; PO 7731902 covers lines 1-14. Tracking \
1Z999AA10123456784, parcel 3 of 5, 12.5 kg, box 40x30x20 cm. Meeting moved to \
Tuesday 9:30-10:15 in room 2B, building 1177. Ticket #5520318, case 20260914-0042. \
Version 2026.9.28.4, build 118822, commit 5a73a118. Serial SN-88213-X. \
Ship to 1600 Amphitheatre Pkwy, Mountain View, CA 94043. Revenue grew 18% to \
1,204,332 units; see figures 3.1-3.4 and table 12.\n\nBest,\nSam\n\
Sam Rivera | +1 (415) 555-2671 | [email protected]";
let quiet = ["email-addresses", "phone-numbers"];
for detector in all().filter(|d| !quiet.contains(&d.id)) {
assert_eq!(
detector.count(text),
0,
"{} fired on ordinary mail",
detector.id
);
}
}
#[test]
fn ids_are_unique() {
let mut seen = AHashSet::new();
for detector in all() {
assert!(seen.insert(detector.id), "duplicate id {}", detector.id);
assert!(by_id(detector.id).is_some());
}
}
}
@@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Templates (§2.3): named sets of detectors, so a policy doesn't pick forty
//! one at a time. Each is named for what it finds, never for a law, and is a
//! starting point: once added to a rule, its detectors can be changed.
pub struct Template {
pub id: &'static str,
pub name: &'static str,
pub detectors: &'static [&'static str],
}
pub static TEMPLATES: &[Template] = &[
Template {
id: "payment-and-bank",
name: "Payment cards and bank accounts",
detectors: &["payment-card", "iban", "swift-bic", "us-aba-routing"],
},
Template {
id: "us-personal",
name: "US personal identifiers",
detectors: &[
"us-ssn",
"us-itin",
"us-ein",
"us-drivers-license",
"passport",
"date-of-birth",
],
},
Template {
id: "uk-personal",
name: "UK personal identifiers",
detectors: &["uk-nino", "uk-utr", "uk-nhs", "passport", "date-of-birth"],
},
Template {
id: "eu-national",
name: "EU national identifiers",
detectors: &[
"de-tax-id",
"de-id-card",
"fr-nir",
"es-dni-nie",
"it-codice-fiscale",
"nl-bsn",
"be-national-number",
"pl-pesel",
"se-personnummer",
"dk-cpr",
"fi-hetu",
"ie-pps",
"pt-nif",
"at-svnr",
],
},
Template {
id: "health",
name: "Health identifiers",
detectors: &["uk-nhs", "us-mbi", "us-npi", "us-dea", "au-medicare"],
},
Template {
id: "credentials",
name: "Credentials and keys",
detectors: &["private-key", "credentials"],
},
Template {
id: "contact-lists",
name: "Contact lists",
detectors: &["email-addresses", "phone-numbers"],
},
];
pub fn by_id(id: &str) -> Option<&'static Template> {
TEMPLATES.iter().find(|template| template.id == id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_template_names_real_detectors() {
for template in TEMPLATES {
for id in template.detectors {
assert!(
super::super::by_id(id).is_some(),
"{}: no detector {id}",
template.id
);
}
}
}
}
@@ -0,0 +1,155 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! United Kingdom identifiers (§2.3): HMRC's National Insurance number and
//! Unique Taxpayer Reference, and the NHS number.
use super::{Detector, Findings, Region, Strength, word_near};
use regex::Regex;
use std::sync::LazyLock;
pub static DETECTORS: &[Detector] = &[
Detector::new(
"uk-nino",
"UK National Insurance number",
Region::Uk,
Strength::Checked,
nino,
),
Detector::new(
"uk-nhs",
"UK NHS number",
Region::Uk,
Strength::Checked,
nhs,
),
Detector::new(
"uk-utr",
"UK Unique Taxpayer Reference",
Region::Uk,
Strength::NeedsWord,
utr,
),
];
fn re(pattern: &str) -> Regex {
Regex::new(pattern).expect("detector pattern")
}
/// Two letters, six digits (often in pairs), a suffix A–D.
static NINO: LazyLock<Regex> =
LazyLock::new(|| re(r"(?i)\b([A-Z])([A-Z]) ?(\d{2}) ?(\d{2}) ?(\d{2}) ?([A-D])\b"));
/// HMRC's rules: D, F, I, Q, U and V are never used; O never second; and
/// BG, GB, KN, NK, NT, TN and ZZ are never allocated.
fn nino_prefix(first: char, second: char) -> bool {
const NEVER: &str = "DFIQUV";
let pair: String = [first, second].iter().collect();
!NEVER.contains(first)
&& !NEVER.contains(second)
&& second != 'O'
&& !["BG", "GB", "KN", "NK", "NT", "TN", "ZZ"].contains(&pair.as_str())
}
fn nino(text: &str, findings: &mut Findings) {
for c in NINO.captures_iter(text) {
let first = c[1].to_ascii_uppercase().chars().next().unwrap();
let second = c[2].to_ascii_uppercase().chars().next().unwrap();
if nino_prefix(first, second) {
findings.insert(format!(
"{first}{second}{}{}{}{}",
&c[3],
&c[4],
&c[5],
c[6].to_ascii_uppercase()
));
}
}
}
/// `NNN NNN NNNN` stands alone; ten bare digits need a word.
static NHS: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{3})([ -]?)(\d{3})([ -]?)(\d{4})\b"));
/// Mod 11: weights 10 down to 2 over the first nine digits; the check digit
/// is 11 minus the remainder (11 becomes 0; 10 is never issued).
pub fn nhs_valid(n: &str) -> bool {
let d: Vec<u32> = n.bytes().map(|b| u32::from(b - b'0')).collect();
let sum: u32 = d[..9].iter().zip((2..=10).rev()).map(|(a, w)| a * w).sum();
match 11 - sum % 11 {
11 => d[9] == 0,
10 => false,
check => d[9] == check,
}
}
const NHS_WORDS: &[&str] = &["nhs", "nhs number", "nhs no"];
fn nhs(text: &str, findings: &mut Findings) {
for c in NHS.captures_iter(text) {
let whole = c.get(0).unwrap();
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
let written = !c[2].is_empty() && c[2] == c[4];
if nhs_valid(&n) && (written || word_near(text, whole.start(), whole.end(), NHS_WORDS)) {
findings.insert(n);
}
}
}
static UTR: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{5} ?\d{5}\b"));
const UTR_WORDS: &[&str] = &[
"utr",
"unique taxpayer reference",
"tax reference",
"self assessment",
];
fn utr(text: &str, findings: &mut Findings) {
for m in UTR.find_iter(text) {
if word_near(text, m.start(), m.end(), UTR_WORDS) {
findings.insert(m.as_str().replace(' ', ""));
}
}
}
#[cfg(test)]
mod tests {
use crate::mailflow::detectors::by_id;
fn count(id: &str, text: &str) -> usize {
by_id(id).unwrap().count(text)
}
#[test]
fn national_insurance() {
assert_eq!(count("uk-nino", "NI: AB 12 34 56 C, ce123456d"), 2);
// Letters never used, pairs never allocated, a suffix past D
for bad in [
"QQ123456C",
"AO123456C",
"GB123456A",
"AB123456E",
"DA123456A",
] {
assert_eq!(count("uk-nino", bad), 0, "{bad}");
}
}
#[test]
fn nhs_numbers() {
// The NHS's own example
assert_eq!(count("uk-nhs", "943 476 5919"), 1);
assert_eq!(count("uk-nhs", "943 476 5918"), 0);
assert_eq!(count("uk-nhs", "order 9434765919"), 0);
assert_eq!(count("uk-nhs", "NHS number 9434765919"), 1);
}
#[test]
fn utr() {
assert_eq!(count("uk-utr", "UTR 12345 67890"), 1);
assert_eq!(count("uk-utr", "order 1234567890"), 0);
}
}
@@ -0,0 +1,304 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! United States identifiers (§2.3), each from its issuer's published rules:
//! the SSA (SSN), the IRS (ITIN, EIN), the ABA (routing numbers), CMS (MBI,
//! NPI) and the DEA.
use super::{Detector, Findings, Region, Strength, checks, digits, stands_alone, word_near};
use regex::Regex;
use std::sync::LazyLock;
pub static DETECTORS: &[Detector] = &[
Detector::new(
"us-ssn",
"US Social Security number",
Region::Us,
Strength::Checked,
ssn,
),
Detector::new("us-itin", "US ITIN", Region::Us, Strength::Checked, itin),
Detector::new("us-ein", "US EIN", Region::Us, Strength::NeedsWord, ein),
Detector::new(
"us-aba-routing",
"US bank routing number",
Region::Us,
Strength::NeedsWord,
aba_routing,
),
Detector::new(
"us-drivers-license",
"US driver's license",
Region::Us,
Strength::NeedsWord,
drivers_license,
),
Detector::new(
"us-mbi",
"US Medicare Beneficiary Identifier",
Region::Us,
Strength::Checked,
mbi,
),
Detector::new(
"us-npi",
"US National Provider Identifier",
Region::Us,
Strength::NeedsWord,
npi,
),
Detector::new(
"us-dea",
"US DEA registration number",
Region::Us,
Strength::Checked,
dea,
),
];
fn re(pattern: &str) -> Regex {
Regex::new(pattern).expect("detector pattern")
}
/// `AAA-GG-SSSS` (dashes or spaces), or nine bare digits.
static NINE: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{3})([ -]?)(\d{2})([ -]?)(\d{4})\b"));
/// Numbers the SSA has published as never valid: widely printed examples.
const SSN_EXAMPLES: &[&str] = &["078051120", "219099999"];
fn ssn_rules(area: u32, group: u32, serial: u32) -> bool {
area != 0 && area != 666 && area < 900 && group != 0 && serial != 0
}
const SSN_WORDS: &[&str] = &["ssn", "social security", "soc sec", "ss#", "ss no"];
fn ssn(text: &str, findings: &mut Findings) {
for c in NINE.captures_iter(text) {
let whole = c.get(0).unwrap();
let (area, group, serial) = (num(&c[1]), num(&c[3]), num(&c[5]));
let number = format!("{}{}{}", &c[1], &c[3], &c[5]);
// Written form (with both separators, the same one) stands alone;
// nine bare digits need a word
let written = !c[2].is_empty() && c[2] == c[4];
if ssn_rules(area, group, serial)
&& !SSN_EXAMPLES.contains(&number.as_str())
&& (written || word_near(text, whole.start(), whole.end(), SSN_WORDS))
{
findings.insert(number);
}
}
}
/// ITINs: 9XX, then a group in the IRS's ranges.
fn itin_group(group: u32) -> bool {
matches!(group, 50..=65 | 70..=88 | 90..=92 | 94..=99)
}
const ITIN_WORDS: &[&str] = &["itin", "taxpayer identification", "tax id"];
fn itin(text: &str, findings: &mut Findings) {
for c in NINE.captures_iter(text) {
let whole = c.get(0).unwrap();
let written = !c[2].is_empty() && c[2] == c[4];
if c[1].starts_with('9')
&& itin_group(num(&c[3]))
&& (written || word_near(text, whole.start(), whole.end(), ITIN_WORDS))
{
findings.insert(format!("{}{}{}", &c[1], &c[3], &c[5]));
}
}
}
static EIN: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{2})-?(\d{7})\b"));
/// The prefixes the IRS assigns to its campuses and internet EINs.
fn ein_prefix(prefix: u32) -> bool {
matches!(prefix, 1..=6 | 10..=16 | 20..=27 | 30..=48 | 50..=68 | 71..=77 | 80..=88 | 90..=95 | 98 | 99)
}
const EIN_WORDS: &[&str] = &[
"ein",
"fein",
"employer identification",
"tax id",
"tin",
"federal tax",
];
fn ein(text: &str, findings: &mut Findings) {
for c in EIN.captures_iter(text) {
let whole = c.get(0).unwrap();
if ein_prefix(num(&c[1])) && word_near(text, whole.start(), whole.end(), EIN_WORDS) {
findings.insert(format!("{}{}", &c[1], &c[2]));
}
}
}
static ROUTING: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{9}\b"));
/// The ABA check: 3, 7 and 1 weights, mod 10; and a Federal Reserve prefix.
pub fn aba_valid(n: &str) -> bool {
let d: Vec<u32> = n.bytes().map(|b| u32::from(b - b'0')).collect();
let prefix = d[0] * 10 + d[1];
matches!(prefix, 0..=12 | 21..=32 | 61..=72 | 80)
&& (3 * (d[0] + d[3] + d[6]) + 7 * (d[1] + d[4] + d[7]) + (d[2] + d[5] + d[8]))
.is_multiple_of(10)
}
const ROUTING_WORDS: &[&str] = &["routing", "aba", "rtn", "routing number", "transit"];
fn aba_routing(text: &str, findings: &mut Findings) {
for m in ROUTING.find_iter(text) {
// One random number in ten passes the check: always needs a word
if aba_valid(m.as_str()) && word_near(text, m.start(), m.end(), ROUTING_WORDS) {
findings.insert(m.as_str());
}
}
}
/// The shapes states issue: up to two letters, then 5–14 digits, dashes
/// allowed (Florida and Illinois print them).
static LICENSE: LazyLock<Regex> = LazyLock::new(|| re(r"\b[A-Z]{0,2}\d[\d-]{3,16}\d\b"));
const LICENSE_WORDS: &[&str] = &[
"driver's license",
"drivers license",
"driver license",
"driver's licence",
"dl",
"dl#",
"license number",
"lic no",
"dmv",
];
fn drivers_license(text: &str, findings: &mut Findings) {
for m in LICENSE.find_iter(text) {
let n = digits(m.as_str());
if (5..=14).contains(&n.len()) && word_near(text, m.start(), m.end(), LICENSE_WORDS) {
findings.insert(m.as_str().replace('-', ""));
}
}
}
/// CMS's MBI: 11 characters in a fixed pattern of digits, letters and
/// either, the letters S, L, O, I, B and Z never used; dashes may follow the
/// 4th and 7th.
static MBI: LazyLock<Regex> = LazyLock::new(|| {
let c = "[AC-HJKMNP-RT-Y]";
let an = "[AC-HJKMNP-RT-Y0-9]";
re(&format!(
r"\b[1-9]{c}{an}[0-9]-?{c}{an}[0-9]-?{c}{c}[0-9][0-9]\b"
))
});
fn mbi(text: &str, findings: &mut Findings) {
for m in MBI.find_iter(text) {
findings.insert(m.as_str().replace('-', ""));
}
}
static TEN: LazyLock<Regex> = LazyLock::new(|| re(r"\b[12]\d{9}\b"));
const NPI_WORDS: &[&str] = &["npi", "national provider", "provider id", "provider number"];
/// NPI: Luhn over the ISO card-issuer prefix 80840 and the number.
fn npi(text: &str, findings: &mut Findings) {
for m in TEN.find_iter(text) {
if checks::luhn(&format!("80840{}", m.as_str()))
&& word_near(text, m.start(), m.end(), NPI_WORDS)
{
findings.insert(m.as_str());
}
}
}
static DEA: LazyLock<Regex> = LazyLock::new(|| re(r"\b([ABCDEFGHJKLMPRSTUX][A-Z9])(\d{7})\b"));
/// DEA: (1st + 3rd + 5th) + 2 × (2nd + 4th + 6th) ends in the 7th digit.
fn dea(text: &str, findings: &mut Findings) {
for c in DEA.captures_iter(text) {
let d: Vec<u32> = c[2].bytes().map(|b| u32::from(b - b'0')).collect();
if ((d[0] + d[2] + d[4]) + 2 * (d[1] + d[3] + d[5])) % 10 == d[6] {
let whole = c.get(0).unwrap();
if stands_alone(text, whole.start(), whole.end()) {
findings.insert(whole.as_str());
}
}
}
}
fn num(s: &str) -> u32 {
s.parse().unwrap_or(0)
}
#[cfg(test)]
mod tests {
use crate::mailflow::detectors::by_id;
fn count(id: &str, text: &str) -> usize {
by_id(id).unwrap().count(text)
}
#[test]
fn ssn() {
assert_eq!(count("us-ssn", "SSN 536-22-1234, also 536 22 1235"), 2);
// Bare digits: only with a word
assert_eq!(count("us-ssn", "ref 536221234"), 0);
assert_eq!(count("us-ssn", "social security: 536221234"), 1);
// Never issued, the SSA's printed examples, mixed separators
for bad in [
"000-12-3456",
"666-12-3456",
"912-12-3456",
"123-00-4567",
"123-45-0000",
"078-05-1120",
"536-22 1234",
] {
assert_eq!(count("us-ssn", bad), 0, "{bad}");
}
}
#[test]
fn itin_and_ein() {
assert_eq!(count("us-itin", "912-70-1234"), 1);
assert_eq!(count("us-itin", "912-69-1234"), 0);
assert_eq!(count("us-ssn", "912-70-1234"), 0);
assert_eq!(count("us-ein", "EIN: 12-3456789"), 1);
assert_eq!(count("us-ein", "part 12-3456789"), 0);
assert_eq!(count("us-ein", "EIN 07-3456789"), 0);
}
#[test]
fn routing_needs_a_word() {
assert_eq!(count("us-aba-routing", "Routing number 011000015"), 1);
assert_eq!(count("us-aba-routing", "ABA 021000021"), 1);
assert_eq!(count("us-aba-routing", "invoice 011000015"), 0);
assert_eq!(count("us-aba-routing", "routing 011000016"), 0);
}
#[test]
fn licenses() {
assert_eq!(count("us-drivers-license", "Driver's license: D1234567"), 1);
assert_eq!(count("us-drivers-license", "DL# S123-456-78-901-0"), 1);
assert_eq!(count("us-drivers-license", "Order D1234567"), 0);
}
#[test]
fn health_identifiers() {
// CMS's own MBI example
assert_eq!(count("us-mbi", "Medicare 1EG4-TE5-MK73"), 1);
assert_eq!(count("us-mbi", "1EG4TE5MK73"), 1);
assert_eq!(count("us-mbi", "1EG4-TE5-MK7S"), 0);
// CMS's NPI example
assert_eq!(count("us-npi", "NPI 1234567893"), 1);
assert_eq!(count("us-npi", "NPI 1234567894"), 0);
assert_eq!(count("us-npi", "call 1234567893"), 0);
assert_eq!(count("us-dea", "DEA AB1234563"), 1);
assert_eq!(count("us-dea", "AB1234564"), 0);
}
}
+697
View File
@@ -0,0 +1,697 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Evaluating rules against a message (§2.1–§2.4). Rules are compiled once,
//! when they change: word lists become automata, patterns regexes. A message
//! is then checked against every enabled rule in order; each detector runs
//! at most once per message, and only when some rule asks for it.
//!
//! Pure: the caller parses the message, extracts attachment text
//! ([`super::extract`]) and knows the sender's groups and tenant. What comes
//! back is which rules matched, with each detector's count, and what DLP
//! decided; the matched text itself never leaves here (§2.7).
use super::{
detectors::{self, Findings},
extract::Extracted,
rules::{Action, Condition, Direction, Kind, Rule},
words::{Pattern, WordList},
};
use ahash::AHashMap;
use std::borrow::Cow;
/// Who sent a message, and to whom.
#[derive(Debug, Clone, Default)]
pub struct Envelope<'a> {
/// Outgoing (an authenticated sender) or incoming.
pub outgoing: bool,
pub sender: &'a str,
pub sender_groups: &'a [u32],
pub sender_tenant: Option<u32>,
pub recipients: Vec<Recipient<'a>>,
}
#[derive(Debug, Clone, Default)]
pub struct Recipient<'a> {
pub address: &'a str,
/// At a domain this server hosts.
pub local: bool,
pub groups: &'a [u32],
}
#[derive(Debug, Clone)]
pub struct Attachment<'a> {
pub name: Option<&'a str>,
/// Declared type, or detected where the caller knows better.
pub content_type: Cow<'a, str>,
pub size: u64,
pub extracted: Extracted,
}
/// What rules look at.
#[derive(Debug, Clone, Default)]
pub struct Content<'a> {
pub subject: &'a str,
/// Each text and HTML part, as text.
pub bodies: Vec<Cow<'a, str>>,
pub headers: Vec<(&'a str, &'a str)>,
pub attachments: Vec<Attachment<'a>>,
pub size: u64,
/// Text past the inspection limit wasn't read.
pub truncated: bool,
}
impl Content<'_> {
fn texts(&self) -> impl Iterator<Item = &str> {
std::iter::once(self.subject)
.chain(self.bodies.iter().map(|b| b.as_ref()))
.chain(self.attachments.iter().filter_map(|a| match &a.extracted {
Extracted::Text(text) => Some(text.as_str()),
_ => None,
}))
}
fn cant_be_inspected(&self) -> bool {
self.truncated
|| self
.attachments
.iter()
.any(|a| matches!(a.extracted, Extracted::NotInspectable(_)))
}
}
enum Check {
Plain(Condition),
Words(WordList, u32),
Pattern(Pattern, u32),
Header {
name: String,
contains: Option<String>,
matches: Option<Pattern>,
},
AttachmentName(Pattern),
}
struct CompiledRule {
rule: Rule,
conditions: Vec<Check>,
exceptions: Vec<Check>,
}
/// The enabled rules, ready to run.
pub struct Compiled {
rules: Vec<CompiledRule>,
}
/// A rule reference, for notices and the audit record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleRef {
pub id: u32,
pub name: String,
pub notice: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Match {
pub rule_id: u32,
pub name: String,
pub kind: Kind,
pub actions: Vec<Action>,
/// Each detector (or `words`, `pattern`) that counted, and its count.
pub counts: Vec<(String, usize)>,
}
#[derive(Debug, Default)]
pub struct Outcome {
pub matched: Vec<Match>,
pub blocks: Vec<RuleRef>,
pub holds: Vec<(RuleRef, bool)>,
pub warns: Vec<RuleRef>,
}
/// What DLP decided, strictest first (§2.4).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Pass,
Block(Vec<RuleRef>),
Hold {
rules: Vec<RuleRef>,
notify_sender: bool,
},
Warn(Vec<RuleRef>),
}
impl Outcome {
/// Block beats hold beats warn. An override (§2.5) answers the warnings
/// only: a block or hold still applies.
pub fn decision(&self, overridden: bool) -> Decision {
if !self.blocks.is_empty() {
Decision::Block(self.blocks.clone())
} else if !self.holds.is_empty() {
Decision::Hold {
rules: self.holds.iter().map(|(r, _)| r.clone()).collect(),
notify_sender: self.holds.iter().any(|(_, notify)| *notify),
}
} else if !self.warns.is_empty() && !overridden {
Decision::Warn(self.warns.clone())
} else {
Decision::Pass
}
}
}
fn compile_check(condition: &Condition) -> Result<Check, String> {
Ok(match condition {
Condition::Words { words, at_least } => Check::Words(WordList::new(words)?, *at_least),
Condition::Pattern { pattern, at_least } => {
Check::Pattern(Pattern::new(pattern)?, *at_least)
}
Condition::Header {
name,
contains,
matches,
} => Check::Header {
name: name.to_ascii_lowercase(),
contains: contains.as_ref().map(|c| c.to_lowercase()),
matches: matches.as_deref().map(Pattern::new).transpose()?,
},
Condition::AttachmentName { pattern } => Check::AttachmentName(Pattern::new(pattern)?),
other => Check::Plain(other.clone()),
})
}
impl Compiled {
/// Compiles the enabled rules; one that no longer compiles (a detector
/// renamed since it was saved) is skipped and named in the second list.
pub fn new(rules: &[Rule]) -> (Self, Vec<(u32, String)>) {
let mut compiled = Vec::new();
let mut skipped = Vec::new();
for rule in rules.iter().filter(|r| r.enabled) {
let result = rule.validate().map_err(|e| e.reason).and_then(|_| {
Ok(CompiledRule {
rule: rule.clone(),
conditions: rule
.conditions
.iter()
.map(compile_check)
.collect::<Result<_, _>>()?,
exceptions: rule
.exceptions
.iter()
.map(compile_check)
.collect::<Result<_, _>>()?,
})
});
match result {
Ok(c) => compiled.push(c),
Err(reason) => skipped.push((rule.id, reason)),
}
}
compiled.sort_by_key(|c| (c.rule.priority, c.rule.id));
(Self { rules: compiled }, skipped)
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
/// Whether any rule could apply to mail going this way, so a caller can
/// skip parsing when none can.
pub fn applies_to(&self, outgoing: bool) -> bool {
self.rules
.iter()
.any(|c| direction_matches(c.rule.direction, outgoing))
}
pub fn evaluate(&self, envelope: &Envelope<'_>, content: &Content<'_>) -> Outcome {
let mut state = State {
content,
detected: AHashMap::new(),
};
let mut outcome = Outcome::default();
for compiled in &self.rules {
let rule = &compiled.rule;
if !direction_matches(rule.direction, envelope.outgoing) {
continue;
}
let mut counts = Vec::new();
let all_match = compiled
.conditions
.iter()
.all(|check| state.check(check, envelope, &mut counts));
if !all_match {
continue;
}
let mut ignored = Vec::new();
if compiled
.exceptions
.iter()
.any(|check| state.check(check, envelope, &mut ignored))
{
continue;
}
for action in &rule.actions {
let reference = |notice: &str| RuleRef {
id: rule.id,
name: rule.name.clone(),
notice: notice.to_string(),
};
match action {
Action::Block { notice } => outcome.blocks.push(reference(notice)),
Action::Hold {
notice,
notify_sender,
} => outcome.holds.push((reference(notice), *notify_sender)),
Action::Warn { notice } => outcome.warns.push(reference(notice)),
_ => {}
}
}
outcome.matched.push(Match {
rule_id: rule.id,
name: rule.name.clone(),
kind: rule.kind,
actions: rule.actions.clone(),
counts,
});
if rule.stop_processing {
break;
}
}
outcome
}
}
fn direction_matches(direction: Direction, outgoing: bool) -> bool {
match direction {
Direction::Any => true,
Direction::Outgoing => outgoing,
Direction::Incoming => !outgoing,
}
}
fn domain_of(address: &str) -> &str {
address.rsplit_once('@').map_or("", |(_, d)| d)
}
fn in_list(value: &str, list: &[String]) -> bool {
list.iter().any(|v| v.eq_ignore_ascii_case(value))
}
struct State<'c, 'a> {
content: &'c Content<'a>,
/// Each detector's count, run once per message.
detected: AHashMap<&'static str, usize>,
}
impl State<'_, '_> {
fn detector_count(&mut self, id: &str) -> usize {
let Some(detector) = detectors::by_id(id) else {
return 0;
};
if let Some(count) = self.detected.get(detector.id) {
return *count;
}
let mut findings = Findings::default();
for text in self.content.texts() {
detector.find(text, &mut findings);
}
self.detected.insert(detector.id, findings.len());
findings.len()
}
fn check(
&mut self,
check: &Check,
envelope: &Envelope<'_>,
counts: &mut Vec<(String, usize)>,
) -> bool {
let content = self.content;
match check {
Check::Words(list, at_least) => {
let n: usize = content.texts().map(|t| list.count(t)).sum();
counts.push(("words".into(), n));
n >= *at_least as usize
}
Check::Pattern(pattern, at_least) => {
let n: usize = content.texts().map(|t| pattern.count(t)).sum();
counts.push(("pattern".into(), n));
n >= *at_least as usize
}
Check::Header {
name,
contains,
matches,
} => content
.headers
.iter()
.filter(|(n, _)| n.eq_ignore_ascii_case(name))
.any(|(_, value)| match (contains, matches) {
(Some(needle), _) => value.to_lowercase().contains(needle.as_str()),
(_, Some(pattern)) => pattern.count(value) > 0,
_ => true,
}),
Check::AttachmentName(pattern) => content
.attachments
.iter()
.any(|a| a.name.is_some_and(|n| pattern.count(n) > 0)),
Check::Plain(condition) => match condition {
Condition::SenderAddress { addresses } => in_list(envelope.sender, addresses),
Condition::SenderDomain { domains } => in_list(domain_of(envelope.sender), domains),
Condition::SenderGroup { groups } => {
envelope.sender_groups.iter().any(|g| groups.contains(g))
}
Condition::SenderTenant { tenants } => {
envelope.sender_tenant.is_some_and(|t| tenants.contains(&t))
}
Condition::RecipientAddress { addresses } => envelope
.recipients
.iter()
.any(|r| in_list(r.address, addresses)),
Condition::RecipientDomain { domains } => envelope
.recipients
.iter()
.any(|r| in_list(domain_of(r.address), domains)),
Condition::RecipientGroup { groups } => envelope
.recipients
.iter()
.any(|r| r.groups.iter().any(|g| groups.contains(g))),
Condition::RecipientOutside => envelope.recipients.iter().any(|r| !r.local),
Condition::AttachmentType { types } => content.attachments.iter().any(|a| {
let ct = a.content_type.to_ascii_lowercase();
types
.iter()
.any(|t| ct.starts_with(&t.to_ascii_lowercase()))
}),
Condition::AttachmentExtension { extensions } => {
content.attachments.iter().any(|a| {
a.name
.and_then(|n| n.rsplit_once('.'))
.is_some_and(|(_, ext)| {
extensions
.iter()
.any(|e| e.trim_start_matches('.').eq_ignore_ascii_case(ext))
})
})
}
Condition::AttachmentSizeOver { bytes } => {
content.attachments.iter().any(|a| a.size > *bytes)
}
Condition::AttachmentCountOver { count } => {
content.attachments.len() > *count as usize
}
Condition::CantBeInspected => content.cant_be_inspected(),
Condition::MessageSizeOver { bytes } => content.size > *bytes,
Condition::Detected { detectors } => {
let mut any = false;
for d in detectors {
let n = self.detector_count(&d.id);
counts.push((d.id.clone(), n));
any |= n >= d.at_least as usize;
}
any
}
// Compiled into their own checks
Condition::Words { .. }
| Condition::Pattern { .. }
| Condition::Header { .. }
| Condition::AttachmentName { .. } => false,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mailflow::{
extract::Why,
rules::{DetectorMin, Position},
};
fn rule(id: u32, kind: Kind, conditions: Vec<Condition>, action: Action) -> Rule {
Rule {
id,
name: format!("rule {id}"),
description: String::new(),
kind,
enabled: true,
priority: id as i32,
direction: if kind == Kind::Dlp {
Direction::Outgoing
} else {
Direction::Any
},
conditions,
exceptions: vec![],
actions: vec![action],
stop_processing: false,
created_by: String::new(),
created_at: 0,
updated_at: 0,
}
}
fn envelope(outside: bool) -> Envelope<'static> {
Envelope {
outgoing: true,
sender: "[email protected]",
sender_groups: &[7],
sender_tenant: None,
recipients: vec![Recipient {
address: if outside {
"[email protected]"
} else {
"[email protected]"
},
local: !outside,
groups: &[],
}],
}
}
fn cards(n: usize) -> Content<'static> {
let body: String = [
"4242 4242 4242 4242",
"5555-5555-5555-4444",
"378282246310005",
"6011111111111117",
"3566002020360505",
]
.iter()
.take(n)
.map(|c| format!("card {c}\n"))
.collect();
Content {
subject: "Numbers",
bodies: vec![body.into()],
..Default::default()
}
}
fn five_cards_outside(action: Action) -> Rule {
rule(
1,
Kind::Dlp,
vec![
Condition::RecipientOutside,
Condition::Detected {
detectors: vec![DetectorMin {
id: "payment-card".into(),
at_least: 5,
}],
},
],
action,
)
}
#[test]
fn detector_threshold_and_recipients() {
let (rules, skipped) = Compiled::new(&[five_cards_outside(Action::Hold {
notice: "Held".into(),
notify_sender: true,
})]);
assert!(skipped.is_empty());
let outcome = rules.evaluate(&envelope(true), &cards(5));
assert_eq!(
outcome.matched[0].counts,
vec![("payment-card".to_string(), 5)]
);
assert!(matches!(
outcome.decision(false),
Decision::Hold {
notify_sender: true,
..
}
));
// Four cards, or everyone inside: nothing
assert_eq!(
rules.evaluate(&envelope(true), &cards(4)).decision(false),
Decision::Pass
);
assert_eq!(
rules.evaluate(&envelope(false), &cards(5)).decision(false),
Decision::Pass
);
}
#[test]
fn strictest_wins_and_override_answers_warnings_only() {
let warn = five_cards_outside(Action::Warn {
notice: "Sure?".into(),
});
let mut block = five_cards_outside(Action::Block {
notice: "No".into(),
});
block.id = 2;
let (rules, _) = Compiled::new(&[warn.clone(), block]);
let outcome = rules.evaluate(&envelope(true), &cards(5));
assert!(matches!(outcome.decision(true), Decision::Block(_)));
let (rules, _) = Compiled::new(&[warn]);
let outcome = rules.evaluate(&envelope(true), &cards(5));
assert!(matches!(outcome.decision(false), Decision::Warn(ref w) if w[0].notice == "Sure?"));
assert_eq!(outcome.decision(true), Decision::Pass);
}
#[test]
fn exceptions_order_and_stop_processing() {
let disclaimer = |id| {
rule(
id,
Kind::Transport,
vec![Condition::RecipientOutside],
Action::AddDisclaimer {
text: "t".into(),
html: None,
position: Position::Bottom,
},
)
};
let mut first = disclaimer(1);
first.stop_processing = true;
let (rules, _) = Compiled::new(&[disclaimer(2), first.clone()]);
let outcome = rules.evaluate(&envelope(true), &cards(0));
assert_eq!(
outcome
.matched
.iter()
.map(|m| m.rule_id)
.collect::<Vec<_>>(),
vec![1]
);
first.stop_processing = false;
first.exceptions = vec![Condition::SenderGroup { groups: vec![7] }];
let (rules, _) = Compiled::new(&[disclaimer(2), first]);
let outcome = rules.evaluate(&envelope(true), &cards(0));
assert_eq!(
outcome
.matched
.iter()
.map(|m| m.rule_id)
.collect::<Vec<_>>(),
vec![2]
);
}
#[test]
fn content_conditions() {
let content = Content {
subject: "Project Falcon",
bodies: vec!["see attached".into()],
headers: vec![("X-Class", "Internal only")],
attachments: vec![
Attachment {
name: Some("plan.docx"),
content_type:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
.into(),
size: 40_000,
extracted: Extracted::Text("IBAN GB29 NWBK 6016 1331 9268 19".into()),
},
Attachment {
name: Some("scan.pdf"),
content_type: "application/pdf".into(),
size: 900_000,
extracted: Extracted::NotInspectable(Why::Pdf),
},
],
size: 1_000_000,
truncated: false,
};
let block = || Action::Block { notice: "n".into() };
let checks = [
(
Condition::Words {
words: vec!["project falcon".into()],
at_least: 1,
},
true,
),
(
Condition::Header {
name: "x-class".into(),
contains: Some("internal".into()),
matches: None,
},
true,
),
(
Condition::AttachmentExtension {
extensions: vec![".PDF".into()],
},
true,
),
(
Condition::AttachmentType {
types: vec!["image/".into()],
},
false,
),
(Condition::AttachmentSizeOver { bytes: 500_000 }, true),
(Condition::AttachmentCountOver { count: 2 }, false),
(Condition::CantBeInspected, true),
(Condition::MessageSizeOver { bytes: 2_000_000 }, false),
(
Condition::Detected {
detectors: vec![DetectorMin {
id: "iban".into(),
at_least: 1,
}],
},
true,
),
(
Condition::SenderDomain {
domains: vec!["EXAMPLE.com".into()],
},
true,
),
];
for (condition, expected) in checks {
let (rules, skipped) =
Compiled::new(&[rule(1, Kind::Dlp, vec![condition.clone()], block())]);
assert!(skipped.is_empty(), "{condition:?}");
let matched = !rules.evaluate(&envelope(true), &content).matched.is_empty();
assert_eq!(matched, expected, "{condition:?}");
}
}
#[test]
fn direction_and_disabled_rules() {
let mut r = five_cards_outside(Action::Block { notice: "n".into() });
let (rules, _) = Compiled::new(std::slice::from_ref(&r));
assert!(rules.applies_to(true) && !rules.applies_to(false));
let mut incoming = envelope(true);
incoming.outgoing = false;
assert_eq!(
rules.evaluate(&incoming, &cards(5)).decision(false),
Decision::Pass
);
r.enabled = false;
assert!(Compiled::new(&[r]).0.is_empty());
}
}
+694
View File
@@ -0,0 +1,694 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The text of an attachment, for the detectors (§2.3), or why there isn't
//! one.
//!
//! Read: text files (plain, CSV, JSON, XML, HTML), Office Open XML (DOCX,
//! XLSX, PPTX) and OpenDocument (ODT, ODS, ODP) documents, and ZIP archives
//! one level deep. **Can't be inspected**: encrypted or password-protected
//! files, PDF (settled answer 2), the older binary Office formats, archives
//! inside archives, and anything past the limits. Everything else (images,
//! audio, programs) has no text to read and is neither.
//!
//! Office files are ZIP archives of XML, read here with the `zip` and
//! `quick-xml` crates the server already uses: no outside converter runs.
use quick_xml::{Reader, XmlVersion, events::Event};
use std::io::{Cursor, Read};
/// How much may be unpacked from one attachment, and from how many entries.
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub max_unpacked: u64,
pub max_entries: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_unpacked: 50 * 1024 * 1024,
max_entries: 10_000,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Extracted {
/// The text to check.
Text(String),
/// A kind of file with no text in it: nothing to check, nothing missed.
NoText,
/// A file that may hold text the detectors couldn't read.
NotInspectable(Why),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Why {
Encrypted,
Pdf,
LegacyOffice,
NestedArchive,
TooLarge,
Damaged,
}
impl Why {
pub fn as_str(&self) -> &'static str {
match self {
Why::Encrypted => "encrypted",
Why::Pdf => "pdf",
Why::LegacyOffice => "legacy-office",
Why::NestedArchive => "nested-archive",
Why::TooLarge => "too-large",
Why::Damaged => "damaged",
}
}
}
const OLE_MAGIC: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
const ZIP_MAGIC: &[u8] = b"PK\x03\x04";
/// What an attachment says, from its declared type, its file name and, above
/// all, its first bytes.
pub fn extract(
content_type: &str,
file_name: Option<&str>,
data: &[u8],
limits: &Limits,
) -> Extracted {
extract_at(content_type, file_name, data, limits, 0)
}
fn extract_at(
content_type: &str,
file_name: Option<&str>,
data: &[u8],
limits: &Limits,
depth: u8,
) -> Extracted {
let content_type = content_type.to_ascii_lowercase();
let extension = file_name
.and_then(|name| name.rsplit_once('.'))
.map(|(_, ext)| ext.to_ascii_lowercase())
.unwrap_or_default();
if data.len() as u64 > limits.max_unpacked {
return Extracted::NotInspectable(Why::TooLarge);
}
if data.starts_with(b"%PDF-") || content_type == "application/pdf" || extension == "pdf" {
return Extracted::NotInspectable(Why::Pdf);
}
if data.starts_with(OLE_MAGIC) {
// An encrypted OOXML file is an OLE container holding the encrypted
// package; any other OLE file is a legacy .doc, .xls or .ppt
return Extracted::NotInspectable(if has_utf16(data, "EncryptedPackage") {
Why::Encrypted
} else {
Why::LegacyOffice
});
}
if data.starts_with(ZIP_MAGIC) {
if depth > 0 {
return Extracted::NotInspectable(Why::NestedArchive);
}
return zip(data, limits);
}
if is_text(&content_type, &extension) {
let text = decode_text(data);
return Extracted::Text(
if content_type == "text/html" || matches!(extension.as_str(), "html" | "htm") {
strip_html(&text)
} else {
text
},
);
}
Extracted::NoText
}
fn is_text(content_type: &str, extension: &str) -> bool {
content_type.starts_with("text/")
|| matches!(
content_type,
"application/json"
| "application/xml"
| "application/csv"
| "application/x-csv"
| "message/rfc822"
)
|| matches!(
extension,
"txt"
| "csv"
| "tsv"
| "json"
| "xml"
| "md"
| "log"
| "html"
| "htm"
| "eml"
| "ics"
| "vcf"
)
}
/// UTF-16 with a byte order mark, else UTF-8 (lossy).
fn decode_text(data: &[u8]) -> String {
let utf16 = |bytes: &[u8], big: bool| {
let units: Vec<u16> = bytes
.as_chunks::<2>()
.0
.iter()
.map(|&c| {
if big {
u16::from_be_bytes(c)
} else {
u16::from_le_bytes(c)
}
})
.collect();
String::from_utf16_lossy(&units)
};
match data {
[0xFF, 0xFE, rest @ ..] => utf16(rest, false),
[0xFE, 0xFF, rest @ ..] => utf16(rest, true),
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
_ => String::from_utf8_lossy(data).into_owned(),
}
}
fn has_utf16(data: &[u8], needle: &str) -> bool {
let needle: Vec<u8> = needle.encode_utf16().flat_map(u16::to_le_bytes).collect();
data.windows(needle.len()).any(|w| w == needle.as_slice())
}
/// Tags out, the common entities decoded, block ends as new lines.
fn strip_html(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut in_tag = false;
let mut skip_until: Option<&str> = None;
let lower = html.to_ascii_lowercase();
let mut i = 0;
let bytes = html.as_bytes();
while i < bytes.len() {
if let Some(end) = skip_until {
match lower[i..].find(end) {
Some(at) => {
i += at + end.len();
skip_until = None;
}
None => break,
}
continue;
}
let c = bytes[i];
if in_tag {
if c == b'>' {
in_tag = false;
}
i += 1;
continue;
}
if c == b'<' {
if lower[i..].starts_with("<script") {
skip_until = Some("</script>");
} else if lower[i..].starts_with("<style") {
skip_until = Some("</style>");
} else {
if [
"<br", "<p", "</p", "<div", "</div", "<tr", "<li", "<td", "<th",
]
.iter()
.any(|t| lower[i..].starts_with(t))
{
out.push(
if lower[i..].starts_with("<td") || lower[i..].starts_with("<th") {
'\t'
} else {
'\n'
},
);
}
in_tag = true;
}
i += 1;
continue;
}
// Copy up to the next tag
let next = html[i..].find('<').map_or(html.len(), |at| i + at);
out.push_str(&html[i..next]);
i = next;
}
for (entity, text) in [
("&nbsp;", " "),
("&lt;", "<"),
("&gt;", ">"),
("&quot;", "\""),
("&#39;", "'"),
("&amp;", "&"),
] {
out = out.replace(entity, text);
}
out
}
/// A ZIP file: an Office document, an OpenDocument, or an archive.
fn zip(data: &[u8], limits: &Limits) -> Extracted {
let Ok(mut archive) = zip::ZipArchive::new(Cursor::new(data)) else {
return Extracted::NotInspectable(Why::Damaged);
};
if archive.len() > limits.max_entries {
return Extracted::NotInspectable(Why::TooLarge);
}
let mut names = Vec::with_capacity(archive.len());
let mut declared: u64 = 0;
for i in 0..archive.len() {
let Ok(entry) = archive.by_index_raw(i) else {
return Extracted::NotInspectable(Why::Damaged);
};
if entry.encrypted() {
return Extracted::NotInspectable(Why::Encrypted);
}
declared = declared.saturating_add(entry.size());
names.push(entry.name().to_string());
}
if declared > limits.max_unpacked {
return Extracted::NotInspectable(Why::TooLarge);
}
let mut budget = limits.max_unpacked;
let mut read =
|archive: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str| -> Result<Vec<u8>, Why> {
let entry = archive.by_name(name).map_err(|_| Why::Damaged)?;
let mut bytes = Vec::new();
// Declared sizes can lie: stop at the budget whatever they say
entry
.take(budget + 1)
.read_to_end(&mut bytes)
.map_err(|_| Why::Damaged)?;
if bytes.len() as u64 > budget {
return Err(Why::TooLarge);
}
budget -= bytes.len() as u64;
Ok(bytes)
};
let has = |name: &str| names.iter().any(|n| n == name);
let mut text = String::new();
let result: Result<(), Why> = (|| {
if has("[Content_Types].xml") {
// Office Open XML: the parts that hold what a person wrote
let mut shared = Vec::new();
if has("xl/sharedStrings.xml") {
shared = xml_strings(&read(&mut archive, "xl/sharedStrings.xml")?, "si");
}
for name in names.iter().filter(|n| ooxml_text_part(n)) {
let xml = read(&mut archive, name)?;
if name.starts_with("xl/worksheets/") {
xlsx_sheet(&xml, &mut text);
} else {
xml_text(&xml, &mut text);
}
text.push('\n');
}
text.extend(shared.iter().map(|s| format!("{s}\n")));
} else if names.first().is_some_and(|n| n == "mimetype")
&& read(&mut archive, "mimetype")?.starts_with(b"application/vnd.oasis.opendocument")
{
// OpenDocument: an encrypted one says so in its manifest
if has("META-INF/manifest.xml")
&& contains(
&read(&mut archive, "META-INF/manifest.xml")?,
b"encryption-data",
)
{
return Err(Why::Encrypted);
}
for name in ["content.xml", "styles.xml"] {
if has(name) {
xml_text(&read(&mut archive, name)?, &mut text);
text.push('\n');
}
}
} else {
// An archive: each file inside, one level deep
for name in names.iter().filter(|n| !n.ends_with('/')) {
let bytes = read(&mut archive, name)?;
match extract_at("", Some(name), &bytes, limits, 1) {
Extracted::Text(inner) => {
text.push_str(&inner);
text.push('\n');
}
Extracted::NoText => {}
Extracted::NotInspectable(why) => return Err(why),
}
}
}
Ok(())
})();
match result {
Ok(()) => Extracted::Text(text),
Err(why) => Extracted::NotInspectable(why),
}
}
fn ooxml_text_part(name: &str) -> bool {
let xml = name.ends_with(".xml");
xml && (name == "word/document.xml"
|| [
"word/header",
"word/footer",
"word/footnotes",
"word/endnotes",
"word/comments",
]
.iter()
.any(|p| name.starts_with(p))
|| name.starts_with("xl/worksheets/sheet")
|| name.starts_with("ppt/slides/slide")
|| name.starts_with("ppt/notesSlides/"))
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
/// The local name of a tag, without its namespace prefix.
fn local(name: &[u8]) -> &[u8] {
name.rsplit(|b| *b == b':').next().unwrap_or(name)
}
fn push_entity(entity: &[u8], out: &mut String) {
match entity {
b"lt" => out.push('<'),
b"gt" => out.push('>'),
b"amp" => out.push('&'),
b"apos" => out.push('\''),
b"quot" => out.push('"'),
_ => {
let code = match entity {
[b'#', b'x' | b'X', hex @ ..] => std::str::from_utf8(hex)
.ok()
.and_then(|h| u32::from_str_radix(h, 16).ok()),
[b'#', dec @ ..] => std::str::from_utf8(dec).ok().and_then(|d| d.parse().ok()),
_ => None,
};
if let Some(c) = code.and_then(char::from_u32) {
out.push(c);
}
}
}
}
/// Every text node, runs joined as written, a new line after each paragraph
/// or row and a tab after each cell, so a number split across runs is whole
/// again.
fn xml_text(xml: &[u8], out: &mut String) {
let mut reader = Reader::from_reader(xml);
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Text(t)) => {
if let Ok(text) = t.xml_content(XmlVersion::Implicit1_0) {
out.push_str(&text);
}
}
Ok(Event::CData(t)) => out.push_str(&String::from_utf8_lossy(&t)),
Ok(Event::GeneralRef(entity)) => push_entity(&entity, out),
Ok(Event::End(e)) => match local(e.name().as_ref()) {
b"p" | b"h" | b"tr" | b"row" | b"table-row" | b"br" => out.push('\n'),
b"tc" | b"c" | b"table-cell" | b"tab" => out.push('\t'),
_ => {}
},
Ok(Event::Empty(e)) => match local(e.name().as_ref()) {
b"br" | b"line-break" => out.push('\n'),
b"tab" | b"s" => out.push(' '),
_ => {}
},
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
}
/// The text of each `item` element (a shared string in XLSX).
fn xml_strings(xml: &[u8], item: &str) -> Vec<String> {
let mut reader = Reader::from_reader(xml);
let mut buf = Vec::new();
let mut items = Vec::new();
let mut current: Option<String> = None;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) if local(e.name().as_ref()) == item.as_bytes() => {
current = Some(String::new())
}
Ok(Event::End(e)) if local(e.name().as_ref()) == item.as_bytes() => {
items.extend(current.take());
}
Ok(Event::Text(t)) => {
if let (Some(s), Ok(text)) =
(current.as_mut(), t.xml_content(XmlVersion::Implicit1_0))
{
s.push_str(&text);
}
}
Ok(Event::GeneralRef(entity)) => {
if let Some(s) = current.as_mut() {
push_entity(&entity, s);
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
items
}
/// A worksheet's cell values: numbers and inline strings. Cells holding a
/// shared string are skipped here; the shared strings are read whole.
fn xlsx_sheet(xml: &[u8], out: &mut String) {
let mut reader = Reader::from_reader(xml);
let mut buf = Vec::new();
let mut shared_cell = false;
let mut in_value = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => match local(e.name().as_ref()) {
b"c" => {
shared_cell = e
.attributes()
.flatten()
.any(|a| a.key.as_ref() == b"t" && a.value.as_ref() == b"s");
}
b"v" | b"t" => in_value = true,
_ => {}
},
Ok(Event::End(e)) => match local(e.name().as_ref()) {
b"v" | b"t" => in_value = false,
b"c" => out.push('\t'),
b"row" => out.push('\n'),
_ => {}
},
// A shared string's cell holds only its index: the string itself
// is added with the shared strings
Ok(Event::Text(t)) if in_value && !shared_cell => {
if let Ok(text) = t.xml_content(XmlVersion::Implicit1_0) {
out.push_str(&text);
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use zip::{ZipWriter, write::SimpleFileOptions};
fn zip_of(files: &[(&str, &str)]) -> Vec<u8> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
for (name, body) in files {
zip.start_file(*name, SimpleFileOptions::default()).unwrap();
zip.write_all(body.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn text_of(extracted: Extracted) -> String {
match extracted {
Extracted::Text(text) => text,
other => panic!("expected text, got {other:?}"),
}
}
#[test]
fn plain_text_and_html() {
let limits = Limits::default();
assert_eq!(
text_of(extract("text/plain", None, b"card 4242", &limits)),
"card 4242"
);
let utf16: Vec<u8> = [0xFF, 0xFE]
.into_iter()
.chain("héllo".encode_utf16().flat_map(u16::to_le_bytes))
.collect();
assert_eq!(
text_of(extract(
"application/octet-stream",
Some("a.csv"),
&utf16,
&limits
)),
"héllo"
);
let html = "<html><style>p{}</style><p>Card&nbsp;4242</p><script>x()</script><td>a</td><td>b</td></html>";
let text = text_of(extract("text/html", None, html.as_bytes(), &limits));
assert!(
text.contains("Card 4242") && !text.contains("x()") && !text.contains("p{}"),
"{text:?}"
);
assert_eq!(
extract("image/png", Some("a.png"), b"\x89PNG....", &limits),
Extracted::NoText
);
}
#[test]
fn docx_joins_split_runs() {
let doc = r#"<w:document xmlns:w="w"><w:body><w:p><w:r><w:t>Card 4242 42</w:t></w:r><w:r><w:t>42 4242 4242</w:t></w:r></w:p><w:p><w:r><w:t>A &amp; B</w:t></w:r></w:p></w:body></w:document>"#;
let docx = zip_of(&[
("[Content_Types].xml", "<Types/>"),
("word/document.xml", doc),
]);
let text = text_of(extract(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Some("a.docx"),
&docx,
&Limits::default(),
));
assert!(text.contains("Card 4242 4242 4242 4242\nA & B"), "{text:?}");
}
#[test]
fn xlsx_numbers_and_shared_strings() {
let sheet = r#"<worksheet><sheetData><row><c r="A1" t="s"><v>0</v></c><c r="B1"><v>4242424242424242</v></c></row></sheetData></worksheet>"#;
let shared = r#"<sst><si><t>IBAN GB29 NWBK 6016 1331 9268 19</t></si></sst>"#;
let xlsx = zip_of(&[
("[Content_Types].xml", "<Types/>"),
("xl/sharedStrings.xml", shared),
("xl/worksheets/sheet1.xml", sheet),
]);
let text = text_of(extract("", Some("book.xlsx"), &xlsx, &Limits::default()));
assert!(
text.contains("4242424242424242") && text.contains("GB29 NWBK 6016 1331 9268 19"),
"{text:?}"
);
// The shared string's index isn't read as a value
assert!(
!text.contains("\t0\t") && !text.starts_with('0'),
"{text:?}"
);
}
#[test]
fn opendocument_and_encrypted_opendocument() {
let content = r#"<office:document-content xmlns:text="t"><text:p>SSN 078-05-1120</text:p></office:document-content>"#;
let odt = zip_of(&[
("mimetype", "application/vnd.oasis.opendocument.text"),
("content.xml", content),
]);
assert!(
text_of(extract("", Some("a.odt"), &odt, &Limits::default()))
.contains("SSN 078-05-1120")
);
let manifest = r#"<manifest:manifest><manifest:file-entry><manifest:encryption-data/></manifest:file-entry></manifest:manifest>"#;
let locked = zip_of(&[
("mimetype", "application/vnd.oasis.opendocument.text"),
("META-INF/manifest.xml", manifest),
("content.xml", "x"),
]);
assert_eq!(
extract("", Some("a.odt"), &locked, &Limits::default()),
Extracted::NotInspectable(Why::Encrypted)
);
}
#[test]
fn archives() {
let limits = Limits::default();
let archive = zip_of(&[
("notes/a.txt", "card 4242424242424242"),
("b.png", "\u{89}PNG"),
]);
assert!(
text_of(extract("application/zip", Some("x.zip"), &archive, &limits))
.contains("4242424242424242")
);
let nested = zip_of(&[(
"inner.zip",
std::str::from_utf8(&[b'P', b'K', 3, 4]).unwrap(),
)]);
assert_eq!(
extract("application/zip", Some("x.zip"), &nested, &limits),
Extracted::NotInspectable(Why::NestedArchive)
);
// Password-protected
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file(
"secret.txt",
SimpleFileOptions::default().with_aes_encryption(zip::AesMode::Aes256, "pw"),
)
.unwrap();
zip.write_all(b"4242424242424242").unwrap();
let locked = zip.finish().unwrap().into_inner();
assert_eq!(
extract("application/zip", Some("x.zip"), &locked, &limits),
Extracted::NotInspectable(Why::Encrypted)
);
// Past the limits
let small = Limits {
max_unpacked: 10,
max_entries: 1,
};
assert_eq!(
extract("application/zip", Some("x.zip"), &archive, &small),
Extracted::NotInspectable(Why::TooLarge)
);
assert_eq!(
extract("application/zip", None, b"PK\x03\x04garbage", &limits),
Extracted::NotInspectable(Why::Damaged)
);
}
#[test]
fn not_inspectable_kinds() {
let limits = Limits::default();
assert_eq!(
extract("application/octet-stream", None, b"%PDF-1.7 ...", &limits),
Extracted::NotInspectable(Why::Pdf)
);
let mut ole = OLE_MAGIC.to_vec();
ole.extend(std::iter::repeat_n(0, 64));
assert_eq!(
extract("", Some("old.doc"), &ole, &limits),
Extracted::NotInspectable(Why::LegacyOffice)
);
ole.extend("EncryptedPackage".encode_utf16().flat_map(u16::to_le_bytes));
assert_eq!(
extract("", Some("new.docx"), &ole, &limits),
Extracted::NotInspectable(Why::Encrypted)
);
}
}
+253
View File
@@ -0,0 +1,253 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Mail held for review (dlp-and-mail-flow-rules spec, §2.6).
//!
//! A held message is queued as any other, but released [`HOLD_SECONDS`]
//! from now, the queue's own future-release mechanism: nothing about the
//! queue's stored format changes, so a node on an older version reads it
//! and simply never sends it. Beside it, a review record under `R` `h` +
//! queue id (u64) says why it's held, for the review queue.
//!
//! A reviewer releases it (it's rescheduled from the queue's settings and
//! delivered) or rejects it (it's removed, and the sender told). Unreviewed
//! mail is rejected after [`KEEP_DAYS`].
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
const FEATURE: u8 = b'R';
const KIND_HELD: u8 = b'h';
const KIND_SETTINGS: u8 = b's';
/// How far off a held message's release is set: a century, so it never
/// comes due on its own.
pub const HOLD_SECONDS: u64 = 100 * 365 * 24 * 60 * 60;
/// How long unreviewed mail waits before it's rejected, unless the setting
/// says otherwise (settled answer 5).
pub const KEEP_DAYS: u64 = 7;
/// `inbuxa:DlpSettings`: how many days held mail waits for a reviewer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
pub keep_held_days: u64,
}
impl Default for Settings {
fn default() -> Self {
Settings {
keep_held_days: KEEP_DAYS,
}
}
}
impl Settings {
/// The property at fault and why, or fine.
pub fn check(&self) -> Result<(), (&'static str, &'static str)> {
if (1..=90).contains(&self.keep_held_days) {
Ok(())
} else {
Err(("keepHeldDays", "must be from 1 to 90 days"))
}
}
}
/// A rule that held the message, with its notice.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
pub struct HeldRule {
pub name: String,
pub notice: String,
}
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Held {
pub queue_id: u64,
pub sender: String,
#[serde(default)]
pub account_id: Option<u32>,
#[serde(default)]
pub tenant_id: Option<u32>,
pub recipients: Vec<String>,
pub subject: String,
pub size: u64,
pub rules: Vec<HeldRule>,
/// Each detector that counted, and its count.
#[serde(default)]
pub counts: Vec<(String, usize)>,
/// Seconds since the epoch.
pub held_at: u64,
pub expires_at: u64,
/// The days it was given, for what the sender is told.
#[serde(default = "default_keep_days")]
pub keep_days: u64,
}
fn default_keep_days() -> u64 {
KEEP_DAYS
}
impl Held {
pub fn is_expired(&self, now: u64) -> bool {
now >= self.expires_at
}
}
struct Json<T>(T);
impl<T: SerdeSerialize> Serialize for Json<T> {
fn serialize(&self) -> trc::Result<Vec<u8>> {
serde_json::to_vec(&self.0).map_err(|err| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to serialize held message")
.reason(err)
})
}
}
impl<T: DeserializeOwned + Sync + Send> Deserialize for Json<T> {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.into_err()
.details("Invalid held message")
.reason(err)
})
}
}
fn class(queue_id: u64) -> ValueClass {
let mut key = Vec::with_capacity(10);
key.push(FEATURE);
key.push(KIND_HELD);
key.extend_from_slice(&queue_id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
fn key(queue_id: u64) -> ValueKey<ValueClass> {
ValueKey::from(class(queue_id))
}
fn settings_class() -> ValueClass {
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: vec![FEATURE, KIND_SETTINGS],
})
}
pub async fn settings(data: &Store) -> trc::Result<Settings> {
Ok(data
.get_value::<Json<Settings>>(ValueKey::from(settings_class()))
.await
.caused_by(trc::location!())?
.map(|Json(settings)| settings)
.unwrap_or_default())
}
pub async fn set_settings(data: &Store, settings: &Settings) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(settings_class(), Json(settings).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
pub async fn get(data: &Store, queue_id: u64) -> trc::Result<Option<Held>> {
Ok(data
.get_value::<Json<Held>>(key(queue_id))
.await
.caused_by(trc::location!())?
.map(|Json(held)| held))
}
pub async fn is_held(data: &Store, queue_id: u64) -> trc::Result<bool> {
get(data, queue_id).await.map(|held| held.is_some())
}
/// Every held message, oldest first.
pub async fn all(data: &Store) -> trc::Result<Vec<Held>> {
let mut held = Vec::new();
data.iterate(IterateParams::new(key(0), key(u64::MAX)), |_, value| {
if let Ok(Json(record)) = Json::<Held>::deserialize(value) {
held.push(record);
}
Ok(true)
})
.await
.caused_by(trc::location!())?;
held.sort_by_key(|h| (h.held_at, h.queue_id));
Ok(held)
}
pub async fn create(data: &Store, held: &Held) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(class(held.queue_id), Json(held).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
pub async fn delete(data: &Store, queue_id: u64) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.clear(class(queue_id));
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_format_and_expiry() {
let held = Held {
queue_id: 42,
sender: "[email protected]".into(),
account_id: Some(7),
tenant_id: None,
recipients: vec!["[email protected]".into()],
subject: "Numbers".into(),
size: 900,
rules: vec![HeldRule {
name: "Cards".into(),
notice: "Held for review".into(),
}],
counts: vec![("payment-card".into(), 5)],
held_at: 1_000,
expires_at: 1_000 + KEEP_DAYS * 86_400,
keep_days: KEEP_DAYS,
};
let json = serde_json::to_value(&held).unwrap();
assert_eq!(json["heldAt"], 1_000);
assert_eq!(serde_json::from_value::<Held>(json).unwrap(), held);
assert!(!held.is_expired(1_000 + KEEP_DAYS * 86_400 - 1));
assert!(held.is_expired(1_000 + KEEP_DAYS * 86_400));
assert!(HOLD_SECONDS > 90 * 365 * 86_400);
}
#[test]
fn settings_range() {
assert_eq!(Settings::default().keep_held_days, 7);
assert!(Settings { keep_held_days: 1 }.check().is_ok());
assert!(Settings { keep_held_days: 90 }.check().is_ok());
assert!(Settings { keep_held_days: 0 }.check().is_err());
assert!(Settings { keep_held_days: 91 }.check().is_err());
}
}
+32
View File
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Data loss prevention and mail flow rules (dlp-and-mail-flow-rules spec).
//!
//! Mostly pure functions over text and attachment bytes, unit-tested
//! without a server:
//!
//! - [`detectors`]: find identifiers in text (payment cards, IBANs,
//! national ID numbers, keys), each by its published format and check
//! (§2.3);
//! - [`words`]: an organization's own word lists and patterns;
//! - [`extract`]: the text of an attachment, or why it can't be read;
//! - [`rules`]: what a rule is, its checks, and where rules are kept;
//! - [`engine`]: rules compiled and run against a message;
//! - [`cache`]: each node's compiled copy;
//! - [`rewrite`]: the actions that change a message.
//!
//! Nothing here writes what it finds anywhere: callers get counts, and the
//! matched text never leaves the evaluation (§2.7).
pub mod cache;
pub mod detectors;
pub mod engine;
pub mod extract;
pub mod held;
pub mod rewrite;
pub mod rules;
pub mod words;
+306
View File
@@ -0,0 +1,306 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Transport actions that change a message (§2.4): headers, the subject,
//! disclaimers. Each takes the raw message and returns the new one, or
//! `None` when there's nothing to change.
//!
//! Only what the action names changes. A disclaimer edits the message's
//! main text and HTML bodies (not attachments, not attached messages):
//! each is decoded, changed and written back as UTF-8 quoted-printable,
//! with its other headers kept. A disclaimer already there isn't added
//! again, so a reply thread carries it once.
use base64::{Engine, engine::general_purpose::STANDARD};
use mail_builder::encoders::quoted_printable::QuotedPrintableEncoder;
use mail_parser::{HeaderName, MessageParser, PartType};
use super::rules::Position;
/// A header value, as an RFC 2047 encoded word when it isn't plain ASCII.
pub fn header_value(value: &str) -> String {
if value.is_ascii() {
value.to_string()
} else {
format!("=?utf-8?B?{}?=", STANDARD.encode(value))
}
}
/// `Name: value` added at the top of the message.
pub fn add_header(message: &[u8], name: &str, value: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(message.len() + name.len() + value.len() + 4);
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(b": ");
out.extend_from_slice(header_value(value).as_bytes());
out.extend_from_slice(b"\r\n");
out.extend_from_slice(message);
out
}
/// Every top-level header called `name` taken out.
pub fn remove_header(message: &[u8], name: &str) -> Option<Vec<u8>> {
let parsed = MessageParser::new().parse_headers(message)?;
let mut ranges: Vec<(usize, usize)> = parsed
.headers()
.iter()
.filter(|h| h.name.as_str().eq_ignore_ascii_case(name))
.map(|h| (h.offset_field as usize, h.offset_end as usize))
.collect();
if ranges.is_empty() {
return None;
}
ranges.sort_unstable();
let mut out = Vec::with_capacity(message.len());
let mut at = 0;
for (start, end) in ranges {
out.extend_from_slice(&message[at..start]);
at = end;
}
out.extend_from_slice(&message[at..]);
Some(out)
}
/// The Subject header replaced by `subject` (added if there was none).
pub fn set_subject(message: &[u8], subject: &str) -> Vec<u8> {
let line = format!("Subject: {}\r\n", header_value(subject));
let parsed = MessageParser::new().parse_headers(message);
match parsed
.as_ref()
.and_then(|p| p.headers().iter().find(|h| h.name == HeaderName::Subject))
{
Some(header) => {
let mut out = Vec::with_capacity(message.len() + line.len());
out.extend_from_slice(&message[..header.offset_field as usize]);
out.extend_from_slice(line.as_bytes());
out.extend_from_slice(&message[header.offset_end as usize..]);
out
}
None => {
let mut out = line.into_bytes();
out.extend_from_slice(message);
out
}
}
}
/// `prefix` put before the subject, unless it's already there.
pub fn prefix_subject(message: &[u8], prefix: &str) -> Option<Vec<u8>> {
let parsed = MessageParser::new().parse_headers(message)?;
let subject = parsed.subject().unwrap_or_default();
if subject.trim_start().starts_with(prefix.trim()) {
return None;
}
Some(set_subject(
message,
&format!("{} {}", prefix.trim(), subject.trim_start()),
))
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('\n', "<br>\n")
}
fn with_text_disclaimer(body: &str, text: &str, position: Position) -> String {
let text = text.trim_end();
match position {
Position::Top => format!("{text}\r\n\r\n{body}"),
Position::Bottom => format!("{}\r\n\r\n{text}\r\n", body.trim_end()),
}
}
fn with_html_disclaimer(body: &str, html: &str, position: Position) -> String {
let lower = body.to_ascii_lowercase();
match position {
Position::Top => match lower
.find("<body")
.and_then(|at| lower[at..].find('>').map(|end| at + end + 1))
{
Some(at) => format!("{}{html}{}", &body[..at], &body[at..]),
None => format!("{html}{body}"),
},
Position::Bottom => match lower.rfind("</body>") {
Some(at) => format!("{}{html}{}", &body[..at], &body[at..]),
None => format!("{body}{html}"),
},
}
}
/// The disclaimer added to each main text and HTML body. `html` is the HTML
/// version, or the text escaped when there's none.
pub fn add_disclaimer(
message: &[u8],
text: &str,
html: Option<&str>,
position: Position,
) -> Option<Vec<u8>> {
let parsed = MessageParser::new().parse(message)?;
let html = html
.map(str::to_string)
.unwrap_or_else(|| format!("<p>{}</p>", escape_html(text.trim())));
let marker = text.trim();
let mut body_parts: Vec<u32> = parsed
.text_body
.iter()
.chain(parsed.html_body.iter())
.copied()
.collect();
body_parts.sort_unstable();
body_parts.dedup();
// (start, end, replacement) for each part, applied from the last
let mut edits: Vec<(usize, usize, Vec<u8>)> = Vec::new();
for id in body_parts {
let Some(part) = parsed.parts.get(id as usize) else {
continue;
};
let (new_body, content_type) = match &part.body {
PartType::Text(body) => {
if body.contains(marker) {
continue;
}
(with_text_disclaimer(body, text, position), "text/plain")
}
PartType::Html(body) => {
if body.contains(marker) || body.contains(html.as_str()) {
continue;
}
(with_html_disclaimer(body, &html, position), "text/html")
}
_ => continue,
};
// The part's own headers, less the two this changes
let mut headers = Vec::new();
for header in part.headers() {
if matches!(
header.name,
HeaderName::ContentType | HeaderName::ContentTransferEncoding
) {
continue;
}
headers.extend_from_slice(
&message[header.offset_field as usize..header.offset_end as usize],
);
}
headers.extend_from_slice(
format!("Content-Type: {content_type}; charset=utf-8\r\n").as_bytes(),
);
headers.extend_from_slice(b"Content-Transfer-Encoding: quoted-printable\r\n\r\n");
let encoded = QuotedPrintableEncoder::new()
.preserve_line_breaks()
.encode(new_body.as_bytes())
.ok()?;
headers.extend_from_slice(&encoded);
// A single-part message's headers are the message's: its first
// header is where the part starts
let start = part.headers().first().map_or(part.offset_header, |h| {
h.offset_field.min(part.offset_header)
}) as usize;
edits.push((start, part.offset_end as usize, headers));
}
if edits.is_empty() {
return None;
}
edits.sort_by_key(|(start, _, _)| std::cmp::Reverse(*start));
let mut out = message.to_vec();
for (start, end, replacement) in edits {
out.splice(start..end.min(out.len()), replacement);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(message: &[u8]) -> mail_parser::Message<'_> {
MessageParser::new().parse(message).expect("parses")
}
const PLAIN: &[u8] = b"From: [email protected]\r\nTo: [email protected]\r\nSubject: Hello\r\nContent-Type: text/plain; charset=iso-8859-1\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nCaf=E9 at noon.\r\n";
const ALTERNATIVE: &[u8] = b"From: [email protected]\r\nSubject: Plans\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"outer\"\r\n\r\n--outer\r\nContent-Type: multipart/alternative; boundary=\"inner\"\r\n\r\n--inner\r\nContent-Type: text/plain\r\n\r\nSee you.\r\n--inner\r\nContent-Type: text/html\r\nContent-Transfer-Encoding: base64\r\n\r\nPGh0bWw+PGJvZHk+PHA+U2VlIHlvdS48L3A+PC9ib2R5PjwvaHRtbD4=\r\n--inner--\r\n--outer\r\nContent-Type: text/plain; name=\"notes.txt\"\r\nContent-Disposition: attachment; filename=\"notes.txt\"\r\n\r\nAttachment text.\r\n--outer--\r\n";
#[test]
fn headers() {
let added = add_header(PLAIN, "X-Mail-Rule", "External");
assert_eq!(
parse(&added).header_raw("X-Mail-Rule").map(str::trim),
Some("External")
);
let removed = remove_header(&added, "x-mail-rule").unwrap();
assert_eq!(removed, PLAIN);
assert!(remove_header(PLAIN, "X-Absent").is_none());
let utf8 = add_header(PLAIN, "X-Note", "Überprüft");
// An RFC 2047 word: mail readers decode it, the wire stays ASCII
assert_eq!(
parse(&utf8).header_raw("X-Note").map(str::trim),
Some("=?utf-8?B?w5xiZXJwcsO8ZnQ=?=")
);
}
#[test]
fn subjects() {
let prefixed = prefix_subject(PLAIN, "[External]").unwrap();
assert_eq!(parse(&prefixed).subject(), Some("[External] Hello"));
assert!(prefix_subject(&prefixed, "[External]").is_none());
let accented = set_subject(PLAIN, "Réunion à midi");
assert_eq!(parse(&accented).subject(), Some("Réunion à midi"));
assert!(accented.is_ascii(), "encoded as an RFC 2047 word");
let none = set_subject(b"From: [email protected]\r\n\r\nBody\r\n", "New");
assert_eq!(parse(&none).subject(), Some("New"));
}
#[test]
fn disclaimer_on_a_single_part() {
let out = add_disclaimer(PLAIN, "Sent by Example Co.", None, Position::Bottom).unwrap();
let parsed = parse(&out);
let body = parsed.body_text(0).unwrap();
assert!(body.starts_with("Café at noon."), "{body:?}");
assert!(body.trim_end().ends_with("Sent by Example Co."), "{body:?}");
assert_eq!(parsed.subject(), Some("Hello"));
assert_eq!(
parsed.header_raw("To").map(str::trim),
Some("[email protected]")
);
// Once only
assert!(add_disclaimer(&out, "Sent by Example Co.", None, Position::Bottom).is_none());
}
#[test]
fn disclaimer_on_alternatives_leaves_attachments() {
let out = add_disclaimer(
ALTERNATIVE,
"Confidential.",
Some("<p><i>Confidential.</i></p>"),
Position::Top,
)
.unwrap();
let parsed = parse(&out);
assert!(
parsed
.body_text(0)
.unwrap()
.starts_with("Confidential.\r\n\r\nSee you."),
"{:?}",
parsed.body_text(0)
);
let html = parsed.body_html(0).unwrap();
assert!(
html.contains("<body><p><i>Confidential.</i></p><p>See you.</p>"),
"{html}"
);
assert_eq!(parsed.attachment_count(), 1);
assert_eq!(
parsed.attachment(0).unwrap().text_contents(),
Some("Attachment text.")
);
assert!(!String::from_utf8_lossy(&out).contains("Confidential.\r\n\r\nAttachment"));
}
}
+841
View File
@@ -0,0 +1,841 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Mail flow rules and DLP rules (dlp-and-mail-flow-rules spec, §2.2–§2.4):
//! what a rule is, what makes one valid, and where it's kept.
//!
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`), never in the
//! registry, so an upstream schema import never touches them. Every key
//! starts with `R`, then one byte for the kind:
//!
//! - `r` + rule id (u32): the rule, as JSON.
//!
//! Numbers are big-endian. There are few rules, so they're read whole.
use super::{detectors, words};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
};
use trc::AddContext;
const FEATURE: u8 = b'R';
const KIND_RULE: u8 = b'r';
const CREATE_ATTEMPTS: usize = 5;
/// Longest text a rule may carry (a notice, a disclaimer), in bytes.
const MAX_TEXT: usize = 16 * 1024;
/// Most entries in one list (words, addresses, domains).
const MAX_LIST: usize = 5_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum Kind {
Dlp,
Transport,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum Direction {
/// Mail an authenticated sender submits, over SMTP or JMAP.
Outgoing,
/// Everything else the server accepts.
Incoming,
Any,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum Position {
Top,
Bottom,
}
fn one() -> u32 {
1
}
/// Group and tenant ids in the JMAP form clients use (`"b"`, `"c"`…), held
/// as numbers for matching. Plain numbers are read too.
pub(crate) mod jmap_ids {
use serde::{Deserialize, Deserializer, Serializer, de::Error, ser::SerializeSeq};
use std::str::FromStr;
use types::id::Id;
pub fn serialize<S: Serializer>(ids: &[u32], serializer: S) -> Result<S::Ok, S::Error> {
let mut seq = serializer.serialize_seq(Some(ids.len()))?;
for id in ids {
seq.serialize_element(&Id::from(*id).to_string())?;
}
seq.end()
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Either {
Text(String),
Number(u32),
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u32>, D::Error> {
Vec::<Either>::deserialize(deserializer)?
.into_iter()
.map(|id| match id {
Either::Number(n) => Ok(n),
Either::Text(text) => Id::from_str(&text)
.map(|id| id.document_id())
.map_err(|_| D::Error::custom(format!("\"{text}\" isn't an id"))),
})
.collect()
}
}
/// One id in the same form.
pub(crate) mod jmap_id {
use serde::{Deserialize, Deserializer, Serializer, de::Error};
use std::str::FromStr;
use types::id::Id;
pub fn serialize<S: Serializer>(id: &u32, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&Id::from(*id).to_string())
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Either {
Text(String),
Number(u32),
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
match Either::deserialize(deserializer)? {
Either::Number(n) => Ok(n),
Either::Text(text) => Id::from_str(&text)
.map(|id| id.document_id())
.map_err(|_| D::Error::custom(format!("\"{text}\" isn't an id"))),
}
}
}
/// A detector and the least it must find.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct DetectorMin {
pub id: String,
#[serde(default = "one")]
pub at_least: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum Condition {
SenderAddress {
addresses: Vec<String>,
},
SenderDomain {
domains: Vec<String>,
},
SenderGroup {
#[serde(with = "jmap_ids")]
groups: Vec<u32>,
},
SenderTenant {
#[serde(with = "jmap_ids")]
tenants: Vec<u32>,
},
/// Any recipient is one of these.
RecipientAddress {
addresses: Vec<String>,
},
RecipientDomain {
domains: Vec<String>,
},
RecipientGroup {
#[serde(with = "jmap_ids")]
groups: Vec<u32>,
},
/// Any recipient isn't at a domain this server hosts.
RecipientOutside,
/// Words or phrases in the subject, body or readable attachments.
Words {
words: Vec<String>,
#[serde(default = "one")]
at_least: u32,
},
/// The organization's regular expression, in the same places.
Pattern {
pattern: String,
#[serde(default = "one")]
at_least: u32,
},
/// A header exists, or its value contains or matches.
Header {
name: String,
#[serde(default)]
contains: Option<String>,
#[serde(default)]
matches: Option<String>,
},
/// An attachment's declared or detected type starts with one of these.
AttachmentType {
types: Vec<String>,
},
AttachmentExtension {
extensions: Vec<String>,
},
AttachmentName {
pattern: String,
},
AttachmentSizeOver {
bytes: u64,
},
AttachmentCountOver {
count: u32,
},
/// An attachment is encrypted, a PDF, a legacy Office file, an archive
/// inside an archive, or past the inspection limit.
CantBeInspected,
MessageSizeOver {
bytes: u64,
},
/// Any of these detectors finds at least its minimum (DLP rules only).
Detected {
detectors: Vec<DetectorMin>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum Action {
// Transport actions
AddDisclaimer {
text: String,
#[serde(default)]
html: Option<String>,
position: Position,
},
AddHeader {
name: String,
value: String,
},
RemoveHeader {
name: String,
},
PrefixSubject {
text: String,
},
AddRecipient {
address: String,
},
Redirect {
addresses: Vec<String>,
},
Refuse {
text: String,
},
Route {
queue: String,
},
/// Journaling spec, JR-10: a copy into this journal, whatever its scope.
Journal {
#[serde(with = "jmap_id")]
journal: u32,
},
// DLP actions
Block {
notice: String,
},
Warn {
notice: String,
},
Hold {
notice: String,
#[serde(default)]
notify_sender: bool,
},
}
impl Action {
pub fn is_dlp(&self) -> bool {
matches!(
self,
Action::Block { .. } | Action::Warn { .. } | Action::Hold { .. }
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rule {
#[serde(default)]
pub id: u32,
pub name: String,
#[serde(default)]
pub description: String,
pub kind: Kind,
#[serde(default = "enabled")]
pub enabled: bool,
#[serde(default)]
pub priority: i32,
pub direction: Direction,
#[serde(default)]
pub conditions: Vec<Condition>,
#[serde(default)]
pub exceptions: Vec<Condition>,
pub actions: Vec<Action>,
#[serde(default)]
pub stop_processing: bool,
#[serde(default)]
pub created_by: String,
#[serde(default)]
pub created_at: u64,
#[serde(default)]
pub updated_at: u64,
}
fn enabled() -> bool {
true
}
/// Why a rule can't be saved: the property at fault, and a sentence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invalid {
pub property: &'static str,
pub reason: String,
}
fn invalid(property: &'static str, reason: impl Into<String>) -> Invalid {
Invalid {
property,
reason: reason.into(),
}
}
impl Rule {
/// Everything that can be checked without the rest of the server: the
/// shape (§2.2, §2.4), the detectors, word lists and patterns.
pub fn validate(&self) -> Result<(), Invalid> {
if self.name.trim().is_empty() {
return Err(invalid("name", "A rule needs a name."));
}
if self.name.len() > 200 || self.description.len() > MAX_TEXT {
return Err(invalid("name", "The name or description is too long."));
}
if self.actions.is_empty() {
return Err(invalid("actions", "A rule needs something to do."));
}
let dlp_actions = self.actions.iter().filter(|a| a.is_dlp()).count();
match self.kind {
Kind::Dlp => {
if self.direction != Direction::Outgoing {
return Err(invalid("direction", "DLP rules check outgoing mail only."));
}
// One of block, warn or hold; journaling may go with it
if dlp_actions != 1
|| self
.actions
.iter()
.any(|a| !a.is_dlp() && !matches!(a, Action::Journal { .. }))
{
return Err(invalid(
"actions",
"A DLP rule has exactly one action: block, warn or hold, and may also journal the message.",
));
}
}
Kind::Transport => {
if dlp_actions > 0 {
return Err(invalid(
"actions",
"Block, warn and hold belong to DLP rules.",
));
}
if self
.conditions
.iter()
.chain(&self.exceptions)
.any(|c| matches!(c, Condition::Detected { .. }))
{
return Err(invalid("conditions", "Detectors belong to DLP rules."));
}
}
}
for (property, list) in [
("conditions", &self.conditions),
("exceptions", &self.exceptions),
] {
for condition in list {
validate_condition(condition).map_err(|reason| invalid(property, reason))?;
}
}
for action in &self.actions {
validate_action(action).map_err(|reason| invalid("actions", reason))?;
}
Ok(())
}
}
fn nonempty_list<T>(list: &[T], what: &str) -> Result<(), String> {
if list.is_empty() {
Err(format!("The {what} list is empty."))
} else if list.len() > MAX_LIST {
Err(format!(
"The {what} list is longer than {MAX_LIST} entries."
))
} else {
Ok(())
}
}
fn header_name(name: &str) -> Result<(), String> {
if !name.is_empty()
&& name.len() <= 100
&& name.bytes().all(|b| b.is_ascii_graphic() && b != b':')
{
Ok(())
} else {
Err(format!("\"{name}\" isn't a header name."))
}
}
fn text(value: &str, what: &str) -> Result<(), String> {
if value.trim().is_empty() {
Err(format!("The {what} is empty."))
} else if value.len() > MAX_TEXT {
Err(format!("The {what} is longer than {MAX_TEXT} bytes."))
} else {
Ok(())
}
}
fn validate_condition(condition: &Condition) -> Result<(), String> {
match condition {
Condition::SenderAddress { addresses } | Condition::RecipientAddress { addresses } => {
nonempty_list(addresses, "address")
}
Condition::SenderDomain { domains } | Condition::RecipientDomain { domains } => {
nonempty_list(domains, "domain")
}
Condition::SenderGroup { groups } | Condition::RecipientGroup { groups } => {
nonempty_list(groups, "group")
}
Condition::SenderTenant { tenants } => nonempty_list(tenants, "tenant"),
Condition::Words { words, at_least } => {
nonempty_list(words, "word")?;
if *at_least == 0 {
return Err("The least number of words must be 1 or more.".into());
}
words::WordList::new(words).map(|_| ())
}
Condition::Pattern { pattern, at_least } => {
if *at_least == 0 {
return Err("The least number of matches must be 1 or more.".into());
}
words::Pattern::new(pattern).map(|_| ())
}
Condition::Header {
name,
contains,
matches,
} => {
header_name(name)?;
if let Some(pattern) = matches {
words::Pattern::new(pattern)?;
}
if contains.is_some() && matches.is_some() {
return Err("A header condition is either contains or matches.".into());
}
Ok(())
}
Condition::AttachmentType { types } => nonempty_list(types, "type"),
Condition::AttachmentExtension { extensions } => nonempty_list(extensions, "extension"),
Condition::AttachmentName { pattern } => words::Pattern::new(pattern).map(|_| ()),
Condition::Detected { detectors } => {
nonempty_list(detectors, "detector")?;
for d in detectors {
if detectors::by_id(&d.id).is_none() {
return Err(format!("There is no detector \"{}\".", d.id));
}
if d.at_least == 0 {
return Err("A detector's least count must be 1 or more.".into());
}
}
Ok(())
}
Condition::RecipientOutside
| Condition::AttachmentSizeOver { .. }
| Condition::AttachmentCountOver { .. }
| Condition::CantBeInspected
| Condition::MessageSizeOver { .. } => Ok(()),
}
}
fn validate_action(action: &Action) -> Result<(), String> {
match action {
Action::AddDisclaimer { text: t, html, .. } => {
text(t, "disclaimer")?;
html.as_deref()
.map_or(Ok(()), |h| text(h, "disclaimer's HTML"))
}
Action::AddHeader { name, value } => {
header_name(name)?;
if value.len() > 998 || value.contains(['\r', '\n']) {
Err("A header value is one line of at most 998 characters.".into())
} else {
Ok(())
}
}
Action::RemoveHeader { name } => header_name(name),
Action::PrefixSubject { text: t } => text(t, "subject prefix"),
Action::AddRecipient { address } => {
if address.contains('@') {
Ok(())
} else {
Err(format!("\"{address}\" isn't an address."))
}
}
Action::Redirect { addresses } => {
nonempty_list(addresses, "address")?;
match addresses.iter().find(|a| !a.contains('@')) {
Some(a) => Err(format!("\"{a}\" isn't an address.")),
None => Ok(()),
}
}
Action::Refuse { text: t } => text(t, "refusal text"),
Action::Route { queue } => text(queue, "queue"),
Action::Journal { .. } => Ok(()),
Action::Block { notice } | Action::Warn { notice } | Action::Hold { notice, .. } => {
text(notice, "notice")
}
}
}
// --- Storage --------------------------------------------------------------
struct Json<T>(T);
impl<T: SerdeSerialize> Serialize for Json<T> {
fn serialize(&self) -> trc::Result<Vec<u8>> {
serde_json::to_vec(&self.0).map_err(|err| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to serialize mail rule")
.reason(err)
})
}
}
impl<T: DeserializeOwned + Sync + Send> Deserialize for Json<T> {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.into_err()
.details("Invalid mail rule")
.reason(err)
})
}
}
fn class(id: u32) -> ValueClass {
let mut key = Vec::with_capacity(6);
key.push(FEATURE);
key.push(KIND_RULE);
key.extend_from_slice(&id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
fn key(id: u32) -> ValueKey<ValueClass> {
ValueKey::from(class(id))
}
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Rule>> {
Ok(data
.get_value::<Json<Rule>>(key(id))
.await
.caused_by(trc::location!())?
.map(|Json(rule)| rule))
}
/// Every rule, in the order they run: by priority, then oldest first.
pub async fn all(data: &Store) -> trc::Result<Vec<Rule>> {
let mut rules = Vec::new();
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
if let Ok(Json(rule)) = Json::<Rule>::deserialize(value) {
rules.push(rule);
}
Ok(true)
})
.await
.caused_by(trc::location!())?;
rules.sort_by_key(|rule| (rule.priority, rule.id));
Ok(rules)
}
/// Writes a new rule under the next free id, which it returns. Two nodes
/// creating rules at once can't take the same id: the key must be absent.
pub async fn create(data: &Store, rule: &Rule) -> trc::Result<u32> {
let mut attempt = 0;
loop {
attempt += 1;
let id = all(data).await?.iter().map(|r| r.id).max().unwrap_or(0) + 1;
let stored = Rule { id, ..rule.clone() };
let mut batch = BatchBuilder::new();
batch.assert_value(class(id), AssertValue::None);
batch.set(class(id), Json(&stored).serialize()?);
match data.write(batch.build_all()).await {
Ok(_) => {
super::cache::invalidate();
return Ok(id);
}
Err(err)
if attempt < CREATE_ATTEMPTS
&& matches!(
err.as_ref(),
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
) => {}
Err(err) => return Err(err.caused_by(trc::location!())),
}
}
}
/// Replaces a stored rule (same id).
pub async fn update(data: &Store, rule: &Rule) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(class(rule.id), Json(rule).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
super::cache::invalidate();
Ok(())
}
pub async fn delete(data: &Store, id: u32) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.clear(class(id));
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
super::cache::invalidate();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(kind: Kind, actions: Vec<Action>) -> Rule {
Rule {
id: 0,
name: "Cards outside".into(),
description: String::new(),
kind,
enabled: true,
priority: 0,
direction: Direction::Outgoing,
conditions: vec![Condition::RecipientOutside],
exceptions: vec![],
actions,
stop_processing: false,
created_by: String::new(),
created_at: 0,
updated_at: 0,
}
}
#[test]
fn journal_action_goes_with_either_kind() {
let hold = Action::Hold {
notice: "Held.".into(),
notify_sender: false,
};
let journal = Action::Journal { journal: 3 };
assert!(
rule(Kind::Dlp, vec![hold.clone(), journal.clone()])
.validate()
.is_ok()
);
assert!(rule(Kind::Dlp, vec![journal.clone()]).validate().is_err());
assert!(
rule(
Kind::Dlp,
vec![hold, Action::PrefixSubject { text: "x".into() }]
)
.validate()
.is_err()
);
assert!(
rule(Kind::Transport, vec![journal.clone()])
.validate()
.is_ok()
);
let json = serde_json::to_value(&journal).unwrap();
assert_eq!(json, serde_json::json!({"type": "journal", "journal": "d"}));
let back: Action = serde_json::from_value(json).unwrap();
assert_eq!(back, journal);
}
#[test]
fn wire_format() {
let json = r#"{"name":"Cards","kind":"dlp","direction":"outgoing",
"conditions":[{"type":"recipientOutside"},{"type":"detected","detectors":[{"id":"payment-card","atLeast":5}]}],
"actions":[{"type":"hold","notice":"Held for review","notifySender":true}]}"#;
let parsed: Rule = serde_json::from_str(json).unwrap();
assert!(parsed.enabled);
assert_eq!(
parsed.conditions[1],
Condition::Detected {
detectors: vec![DetectorMin {
id: "payment-card".into(),
at_least: 5
}]
}
);
assert_eq!(
parsed.actions[0],
Action::Hold {
notice: "Held for review".into(),
notify_sender: true
}
);
assert!(parsed.validate().is_ok());
let back = serde_json::to_value(&parsed).unwrap();
assert_eq!(back["actions"][0]["notifySender"], true);
}
#[test]
fn group_and_tenant_ids_are_jmap_ids() {
let condition: Condition =
serde_json::from_str(r#"{"type":"senderGroup","groups":["b", 7]}"#).unwrap();
assert_eq!(condition, Condition::SenderGroup { groups: vec![1, 7] });
assert_eq!(
serde_json::to_value(&condition).unwrap()["groups"],
serde_json::json!(["b", "h"])
);
assert!(
serde_json::from_str::<Condition>(r#"{"type":"senderTenant","tenants":["!!"]}"#)
.is_err()
);
}
#[test]
fn dlp_rules_have_one_dlp_action_on_outgoing_mail() {
let block = Action::Block {
notice: "No.".into(),
};
assert!(rule(Kind::Dlp, vec![block.clone()]).validate().is_ok());
let two = rule(
Kind::Dlp,
vec![
block.clone(),
Action::Warn {
notice: "Hm.".into(),
},
],
);
assert_eq!(two.validate().unwrap_err().property, "actions");
let mixed = rule(
Kind::Dlp,
vec![block.clone(), Action::PrefixSubject { text: "[x]".into() }],
);
assert_eq!(mixed.validate().unwrap_err().property, "actions");
let mut inbound = rule(Kind::Dlp, vec![block.clone()]);
inbound.direction = Direction::Incoming;
assert_eq!(inbound.validate().unwrap_err().property, "direction");
assert_eq!(
rule(Kind::Transport, vec![block])
.validate()
.unwrap_err()
.property,
"actions"
);
}
#[test]
fn conditions_and_actions_are_checked() {
let disclaimer = Action::AddDisclaimer {
text: "Sent from Example Co.".into(),
html: None,
position: Position::Bottom,
};
let mut r = rule(Kind::Transport, vec![disclaimer]);
assert!(r.validate().is_ok());
r.conditions.push(Condition::Detected {
detectors: vec![DetectorMin {
id: "iban".into(),
at_least: 1,
}],
});
assert_eq!(r.validate().unwrap_err().property, "conditions");
let mut r = rule(
Kind::Dlp,
vec![Action::Block {
notice: "No.".into(),
}],
);
r.conditions = vec![Condition::Detected {
detectors: vec![DetectorMin {
id: "nope".into(),
at_least: 1,
}],
}];
assert!(r.validate().unwrap_err().reason.contains("nope"));
r.conditions = vec![Condition::Pattern {
pattern: "(".into(),
at_least: 1,
}];
assert!(r.validate().is_err());
r.conditions = vec![Condition::Words {
words: vec![],
at_least: 1,
}];
assert!(r.validate().is_err());
r.exceptions = vec![Condition::Header {
name: "X-Bad: yes".into(),
contains: None,
matches: None,
}];
r.conditions = vec![];
assert_eq!(r.validate().unwrap_err().property, "exceptions");
let header = rule(
Kind::Transport,
vec![Action::AddHeader {
name: "X-Tag".into(),
value: "a\r\nBcc: x@y".into(),
}],
);
assert!(header.validate().is_err());
let redirect = rule(
Kind::Transport,
vec![Action::Redirect {
addresses: vec!["nobody".into()],
}],
);
assert!(redirect.validate().is_err());
let mut unnamed = rule(
Kind::Transport,
vec![Action::RemoveHeader {
name: "X-Tag".into(),
}],
);
unnamed.name = " ".into();
assert_eq!(unnamed.validate().unwrap_err().property, "name");
}
}
+109
View File
@@ -0,0 +1,109 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! An organization's own word lists and patterns (§2.3). Both count
//! occurrences, not distinct values: "confidential" three times is three.
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
use regex::{Regex, RegexBuilder};
/// How large a compiled pattern may grow. Keeps a rule someone writes from
/// making every message slow to send.
const PATTERN_SIZE_LIMIT: usize = 1 << 20;
/// Words and phrases, matched whole and ignoring case.
#[derive(Debug, Clone)]
pub struct WordList {
matcher: AhoCorasick,
}
impl WordList {
/// Builds a list from words or phrases; empty entries are skipped.
pub fn new<I, S>(words: I) -> Result<Self, String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let words: Vec<String> = words
.into_iter()
.map(|w| w.as_ref().trim().to_lowercase())
.filter(|w| !w.is_empty())
.collect();
if words.is_empty() {
return Err("The list has no words".into());
}
AhoCorasickBuilder::new()
.match_kind(MatchKind::LeftmostLongest)
.build(&words)
.map(|matcher| Self { matcher })
.map_err(|err| err.to_string())
}
/// How many times any word of the list appears in `text`.
pub fn count(&self, text: &str) -> usize {
let text = text.to_lowercase();
self.matcher
.find_iter(&text)
.filter(|m| super::detectors::stands_alone(&text, m.start(), m.end()))
.count()
}
}
/// An organization's regular expression.
#[derive(Debug, Clone)]
pub struct Pattern {
regex: Regex,
}
impl Pattern {
/// Compiles `pattern`, or says why it can't be used. Matching ignores
/// case unless the pattern turns that off with `(?-i)`.
pub fn new(pattern: &str) -> Result<Self, String> {
RegexBuilder::new(pattern)
.case_insensitive(true)
.size_limit(PATTERN_SIZE_LIMIT)
.build()
.map(|regex| Self { regex })
.map_err(|err| err.to_string())
}
/// How many times the pattern matches in `text`.
pub fn count(&self, text: &str) -> usize {
self.regex.find_iter(text).filter(|m| !m.is_empty()).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn words_whole_and_any_case() {
let list = WordList::new(["Project Falcon", "confidential", " "]).unwrap();
assert_eq!(
list.count(
"CONFIDENTIAL: project falcon notes. Not confidentiality, not projectfalcon."
),
2
);
assert_eq!(list.count("Confidential, confidential and confidential"), 3);
// Non-ASCII case folding
let list = WordList::new(["GEHEIM", "Straße"]).unwrap();
assert_eq!(list.count("streng geheim, STRASSE ist nicht Straße"), 2);
assert!(WordList::new(["", " "]).is_err());
}
#[test]
fn patterns() {
let pattern = Pattern::new(r"\bPRJ-\d{4}\b").unwrap();
assert_eq!(pattern.count("prj-1234 and PRJ-5678, not PRJ-12"), 2);
assert!(Pattern::new("(unclosed").is_err());
// Too large to compile within the limit
assert!(Pattern::new(r"\w{1000}\w{1000}\w{1000}").is_err());
// Empty matches don't count
assert_eq!(Pattern::new("x*").unwrap().count("abc"), 0);
}
}
+280
View File
@@ -0,0 +1,280 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Accepted security to-do items (security to-do list spec, SS-23 to SS-26).
//!
//! The console runs the checks; the server only keeps what an administrator
//! accepted, so every administrator sees the same accepted risks. An
//! acceptance names the check, what within it (a domain, a certificate…),
//! the value the check saw, and why. It holds only while the check still
//! sees that value, which the console compares. Acceptances are created and
//! removed, never edited.
//!
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
//! with `Q`, then one byte for the kind:
//!
//! - `a` + acceptance id (u32): the acceptance, as JSON.
//!
//! Numbers are big-endian. There are at most [`MAX_ACCEPTANCES`], so
//! they're read whole.
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
};
use trc::AddContext;
const FEATURE: u8 = b'Q';
const KIND_ACCEPTANCE: u8 = b'a';
const CREATE_ATTEMPTS: usize = 5;
pub const MAX_ACCEPTANCES: usize = 200;
/// The checks are SS-1 to SS-18; a few spare for checks added later.
const MAX_CHECK: u32 = 40;
const MAX_SUBJECT: usize = 255;
const MAX_VALUE_BYTES: usize = 4096;
const MAX_NOTE: usize = 500;
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Acceptance {
#[serde(default)]
pub id: u32,
/// Which check: `SS-1`, `SS-2`…
pub check: String,
/// What within the check: empty for a server-wide setting, else the
/// domain, strategy or certificate it names.
#[serde(default)]
pub subject: String,
/// The value the check saw when it was accepted.
#[serde(default)]
pub accepted_value: serde_json::Value,
/// Why. Required.
pub note: String,
#[serde(default)]
pub accepted_by: String,
/// Seconds since the epoch.
#[serde(default)]
pub accepted_at: u64,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Invalid {
pub property: &'static str,
pub reason: String,
}
fn invalid(property: &'static str, reason: impl Into<String>) -> Invalid {
Invalid {
property,
reason: reason.into(),
}
}
impl Acceptance {
/// What an administrator sends is checked whole before it's kept.
pub fn validate(&self) -> Result<(), Invalid> {
let check_ok = self
.check
.strip_prefix("SS-")
.and_then(|n| n.parse::<u32>().ok())
.is_some_and(|n| (1..=MAX_CHECK).contains(&n));
if !check_ok {
return Err(invalid("check", "A check is named SS-1, SS-2 and so on."));
}
if self.subject.chars().count() > MAX_SUBJECT {
return Err(invalid(
"subject",
format!("At most {MAX_SUBJECT} characters."),
));
}
let value_bytes = serde_json::to_vec(&self.accepted_value)
.map(|v| v.len())
.unwrap_or(usize::MAX);
if value_bytes > MAX_VALUE_BYTES {
return Err(invalid(
"acceptedValue",
format!("At most {MAX_VALUE_BYTES} bytes."),
));
}
let note = self.note.trim();
if note.is_empty() {
return Err(invalid("note", "Say why this is accepted."));
}
if note.chars().count() > MAX_NOTE {
return Err(invalid("note", format!("At most {MAX_NOTE} characters.")));
}
Ok(())
}
}
// --- Storage --------------------------------------------------------------
struct Json<T>(T);
impl<T: SerdeSerialize> Serialize for Json<T> {
fn serialize(&self) -> trc::Result<Vec<u8>> {
serde_json::to_vec(&self.0).map_err(|err| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to serialize a security acceptance")
.reason(err)
})
}
}
impl Deserialize for Json<Acceptance> {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.into_err()
.details("Invalid security acceptance")
.reason(err)
})
}
}
fn class(id: u32) -> ValueClass {
let mut key = Vec::with_capacity(6);
key.push(FEATURE);
key.push(KIND_ACCEPTANCE);
key.extend_from_slice(&id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
fn key(id: u32) -> ValueKey<ValueClass> {
ValueKey::from(class(id))
}
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Acceptance>> {
Ok(data
.get_value::<Json<Acceptance>>(key(id))
.await
.caused_by(trc::location!())?
.map(|Json(acceptance)| acceptance))
}
/// Every acceptance, oldest first.
pub async fn all(data: &Store) -> trc::Result<Vec<Acceptance>> {
let mut out = Vec::new();
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
if let Ok(Json(acceptance)) = Json::<Acceptance>::deserialize(value) {
out.push(acceptance);
}
Ok(true)
})
.await
.caused_by(trc::location!())?;
out.sort_by_key(|a| a.id);
Ok(out)
}
pub enum Created {
Id(u32),
/// There are already [`MAX_ACCEPTANCES`].
Full,
}
/// Keeps a new acceptance under the next free id. Two nodes creating at
/// once can't take the same id: the key must be absent.
pub async fn create(data: &Store, acceptance: &Acceptance) -> trc::Result<Created> {
let mut attempt = 0;
loop {
attempt += 1;
let existing = all(data).await?;
if existing.len() >= MAX_ACCEPTANCES {
return Ok(Created::Full);
}
let id = existing.iter().map(|a| a.id).max().unwrap_or(0) + 1;
let stored = Acceptance {
id,
..acceptance.clone()
};
let mut batch = BatchBuilder::new();
batch.assert_value(class(id), AssertValue::None);
batch.set(class(id), Json(&stored).serialize()?);
match data.write(batch.build_all()).await {
Ok(_) => return Ok(Created::Id(id)),
Err(err)
if attempt < CREATE_ATTEMPTS
&& matches!(
err.as_ref(),
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
) => {}
Err(err) => return Err(err.caused_by(trc::location!())),
}
}
}
pub async fn delete(data: &Store, id: u32) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.clear(class(id));
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn acceptance() -> Acceptance {
Acceptance {
id: 0,
check: "SS-1".into(),
subject: String::new(),
accepted_value: serde_json::json!(true),
note: "Old clients on the LAN; closed by 2027.".into(),
accepted_by: String::new(),
accepted_at: 0,
}
}
#[test]
fn a_note_is_required() {
assert!(acceptance().validate().is_ok());
let blank = Acceptance {
note: " ".into(),
..acceptance()
};
assert_eq!(blank.validate().unwrap_err().property, "note");
let long = Acceptance {
note: "x".repeat(501),
..acceptance()
};
assert_eq!(long.validate().unwrap_err().property, "note");
}
#[test]
fn only_named_checks() {
for bad in ["", "SS-0", "SS-41", "ss-1", "SS-x", "1"] {
let a = Acceptance {
check: bad.into(),
..acceptance()
};
assert_eq!(a.validate().unwrap_err().property, "check", "{bad}");
}
}
#[test]
fn subject_and_value_are_bounded() {
let a = Acceptance {
subject: "d".repeat(256),
..acceptance()
};
assert_eq!(a.validate().unwrap_err().property, "subject");
let a = Acceptance {
accepted_value: serde_json::json!("v".repeat(4096)),
..acceptance()
};
assert_eq!(a.validate().unwrap_err().property, "acceptedValue");
}
}
+1
View File
@@ -10,6 +10,7 @@
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
//! `legacy-protocols.md`.
pub mod acceptance;
pub mod legacy_use;
pub mod log_files;
pub mod listeners;
+23
View File
@@ -131,6 +131,29 @@ impl ManagementApi for Server {
let answer = jmap::inbuxa::directory_test::test(self, &request).await?;
Ok(JsonResponse::new(answer).no_cache().into_http_response())
}
// inbuxa: send one sample event to a saved webhook
"webhook" if is_post && path.get(1).copied() == Some("test") => {
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
jmap::inbuxa::webhook_test::assert_allowed(&access_token)?;
let request = body
.as_deref()
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())
.unwrap_or_default();
let answer = jmap::inbuxa::webhook_test::test(self, &request).await?;
Ok(JsonResponse::new(answer).no_cache().into_http_response())
}
// inbuxa: whether the outside world reaches each node's ports
"ports" if path.get(1).copied() == Some("check") => {
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
if access_token.tenant_id().is_some() {
return Err(trc::JmapEvent::Forbidden
.into_err()
.details("Port checks are for server-level administrators."));
}
access_token.enforce_permission(Permission::SysNetworkListenerGet)?;
let answer = common::reachability::report(self).await?;
Ok(JsonResponse::new(answer).no_cache().into_http_response())
}
"account" => {
// Authenticate request
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
+6 -2
View File
@@ -270,8 +270,12 @@ impl ClientRegistrationHandler for Server {
false
};
// Check if the account is allowed to override client registration
if self
// Check if the account is allowed to override client registration.
// inbuxa: only while setting up or recovering, when the recovery
// administrator signs in before any client is registered (contract C-5)
let registry = self.registry();
if (registry.is_bootstrap_mode() || registry.is_recovery_mode())
&& self
.access_token(account_id)
.await
.caused_by(trc::location!())?
+28
View File
@@ -47,6 +47,18 @@ struct SetErrorInner<P: Property> {
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(rename = "validationErrors")]
validation_errors: Vec<ValidationError>,
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): each rule that
// warned or blocked, with its notice
#[serde(skip_serializing_if = "Vec::is_empty")]
rules: Vec<DlpRule>,
}
/// inbuxa: a DLP rule named in an `inbuxa:dlpWarning` or `inbuxa:dlpBlocked`.
#[derive(Debug, Clone, serde::Serialize)]
pub struct DlpRule {
pub name: String,
pub notice: String,
}
#[derive(Debug, Clone)]
@@ -127,6 +139,12 @@ pub enum SetErrorType {
// inbuxa: a create that couldn't run (ai-explain spec: busy, timeout, …)
#[serde(rename = "serverFail")]
ServerFail,
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): a warning the
// sender may answer with inbuxa:dlpOverride, and a block
#[serde(rename = "inbuxa:dlpWarning")]
DlpWarning,
#[serde(rename = "inbuxa:dlpBlocked")]
DlpBlocked,
}
impl SetErrorType {
@@ -166,6 +184,8 @@ impl SetErrorType {
SetErrorType::PrimaryKeyViolation => "primaryKeyViolation",
SetErrorType::ValidationFailed => "validationFailed",
SetErrorType::ServerFail => "serverFail",
SetErrorType::DlpWarning => "inbuxa:dlpWarning",
SetErrorType::DlpBlocked => "inbuxa:dlpBlocked",
}
}
}
@@ -180,9 +200,16 @@ impl<T: Property> SetError<T> {
object_id: None,
linked_objects: Vec::new(),
validation_errors: Vec::new(),
rules: Vec::new(),
}))
}
/// inbuxa: the DLP rules behind a warning or block.
pub fn with_dlp_rules(mut self, rules: Vec<DlpRule>) -> Self {
self.0.rules = rules;
self
}
pub fn with_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.0.description = description.into().into();
self
@@ -353,6 +380,7 @@ impl From<PatchError> for SetError<registry::schema::properties::Property> {
object_id: None,
linked_objects: Vec::new(),
validation_errors: Vec::new(),
rules: Vec::new(),
}))
}
}
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
@@ -40,6 +42,12 @@ pub enum EmailSubmissionProperty {
Displayed,
DsnBlobIds,
MdnBlobIds,
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): `{"reason": ...}`
// to send despite a warning
DlpOverride,
// inbuxa: in a create's response, true when DLP held the message for
// review (§2.6)
DlpHeld,
Pointer(JsonPointer<EmailSubmissionProperty>),
}
@@ -90,6 +98,8 @@ impl Property for EmailSubmissionProperty {
EmailSubmissionProperty::Id => "id",
EmailSubmissionProperty::IdentityId => "identityId",
EmailSubmissionProperty::MdnBlobIds => "mdnBlobIds",
EmailSubmissionProperty::DlpOverride => "inbuxa:dlpOverride",
EmailSubmissionProperty::DlpHeld => "inbuxa:held",
EmailSubmissionProperty::SendAt => "sendAt",
EmailSubmissionProperty::ThreadId => "threadId",
EmailSubmissionProperty::UndoStatus => "undoStatus",
@@ -181,6 +191,8 @@ impl EmailSubmissionProperty {
"displayed" => EmailSubmissionProperty::Displayed,
"dsnBlobIds" => EmailSubmissionProperty::DsnBlobIds,
"mdnBlobIds" => EmailSubmissionProperty::MdnBlobIds,
"inbuxa:dlpOverride" => EmailSubmissionProperty::DlpOverride,
"inbuxa:held" => EmailSubmissionProperty::DlpHeld,
)
.or_else(|| {
if allow_patch && value.contains('/') {
@@ -0,0 +1,153 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:DlpSettings/get` and `/set` under `urn:inbuxa:jmap`: the DLP
//! settings singleton (dlp-and-mail-flow-rules spec, §2.6): how many days
//! held mail waits for a reviewer before it goes back to the sender.
use crate::object::{AnyId, JmapObject, JmapObjectId};
use jmap_tools::{Element, Key, Property};
use std::{borrow::Cow, str::FromStr};
use types::id::Id;
#[derive(Debug, Clone, Default)]
pub struct DlpSettings;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DlpSettingsProperty {
Id,
KeepHeldDays,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DlpSettingsValue {
Id(Id),
}
impl Property for DlpSettingsProperty {
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
DlpSettingsProperty::parse(value)
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
DlpSettingsProperty::Id => "id",
DlpSettingsProperty::KeepHeldDays => "keepHeldDays",
}
.into()
}
}
impl DlpSettingsProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => DlpSettingsProperty::Id,
b"keepHeldDays" => DlpSettingsProperty::KeepHeldDays,
)
}
}
impl FromStr for DlpSettingsProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
DlpSettingsProperty::parse(s).ok_or(())
}
}
impl Element for DlpSettingsValue {
type Property = DlpSettingsProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(DlpSettingsProperty::Id) => {
Id::from_str(value).ok().map(DlpSettingsValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
DlpSettingsValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for DlpSettings {
type Property = DlpSettingsProperty;
type Element = DlpSettingsValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = DlpSettingsProperty::Id;
}
impl From<Id> for DlpSettingsValue {
fn from(id: Id) -> Self {
DlpSettingsValue::Id(id)
}
}
impl JmapObjectId for DlpSettingsValue {
fn as_id(&self) -> Option<Id> {
match self {
DlpSettingsValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
DlpSettingsValue::Id(id) => Some(AnyId::Id(*id)),
}
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, new_id: AnyId) -> bool {
if let AnyId::Id(id) = new_id {
*self = DlpSettingsValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for DlpSettingsProperty {
fn as_id(&self) -> Option<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
@@ -0,0 +1,213 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:HeldMessage/get` and `/set` under `urn:inbuxa:jmap`: mail held
//! for review (dlp-and-mail-flow-rules spec, §2.6). Get lists it; `preview`
//! (the text, only when asked for) is recorded as access to someone's mail.
//! Set only updates: `{"decision": "release"}`, or `"reject"` with an
//! optional `note` for the sender. The call's `reason` goes into the audit
//! log and is required.
use crate::{
object::{AnyId, JmapObject, JmapObjectId},
request::deserialize::DeserializeArguments,
};
use jmap_tools::{Element, Key, Property};
use std::{borrow::Cow, str::FromStr};
use types::id::Id;
#[derive(Debug, Clone, Default)]
pub struct HeldMessage;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HeldMessageProperty {
Id,
Sender,
Recipients,
Subject,
Size,
Rules,
Counts,
HeldAt,
ExpiresAt,
Preview,
Decision,
Note,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HeldMessageValue {
Id(Id),
}
impl Property for HeldMessageProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
// Keys inside rules and counts stay plain keys
match parent {
None => HeldMessageProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
HeldMessageProperty::Id => "id",
HeldMessageProperty::Sender => "sender",
HeldMessageProperty::Recipients => "recipients",
HeldMessageProperty::Subject => "subject",
HeldMessageProperty::Size => "size",
HeldMessageProperty::Rules => "rules",
HeldMessageProperty::Counts => "counts",
HeldMessageProperty::HeldAt => "heldAt",
HeldMessageProperty::ExpiresAt => "expiresAt",
HeldMessageProperty::Preview => "preview",
HeldMessageProperty::Decision => "decision",
HeldMessageProperty::Note => "note",
}
.into()
}
}
impl HeldMessageProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => HeldMessageProperty::Id,
b"sender" => HeldMessageProperty::Sender,
b"recipients" => HeldMessageProperty::Recipients,
b"subject" => HeldMessageProperty::Subject,
b"size" => HeldMessageProperty::Size,
b"rules" => HeldMessageProperty::Rules,
b"counts" => HeldMessageProperty::Counts,
b"heldAt" => HeldMessageProperty::HeldAt,
b"expiresAt" => HeldMessageProperty::ExpiresAt,
b"preview" => HeldMessageProperty::Preview,
b"decision" => HeldMessageProperty::Decision,
b"note" => HeldMessageProperty::Note,
)
}
}
impl FromStr for HeldMessageProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
HeldMessageProperty::parse(s).ok_or(())
}
}
impl Element for HeldMessageValue {
type Property = HeldMessageProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(HeldMessageProperty::Id) => {
Id::from_str(value).ok().map(HeldMessageValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
HeldMessageValue::Id(id) => id.to_string().into(),
}
}
}
/// The set call's own argument: why, for the audit log (required).
#[derive(Debug, Clone, Default)]
pub struct HeldMessageSetArguments {
pub reason: Option<String>,
}
impl<'de> DeserializeArguments<'de> for HeldMessageSetArguments {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
if key == "reason" {
self.reason = map.next_value()?;
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(())
}
}
impl JmapObject for HeldMessage {
type Property = HeldMessageProperty;
type Element = HeldMessageValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = HeldMessageSetArguments;
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = HeldMessageProperty::Id;
}
impl From<Id> for HeldMessageValue {
fn from(id: Id) -> Self {
HeldMessageValue::Id(id)
}
}
impl JmapObjectId for HeldMessageValue {
fn as_id(&self) -> Option<Id> {
match self {
HeldMessageValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
HeldMessageValue::Id(id) => Some(AnyId::Id(*id)),
}
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, new_id: AnyId) -> bool {
if let AnyId::Id(id) = new_id {
*self = HeldMessageValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for HeldMessageProperty {
fn as_id(&self) -> Option<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
@@ -0,0 +1,217 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:Journal/get` and `/set` under `urn:inbuxa:jmap`: journals
//! (journaling spec, JR-9, JR-12). What a journal has taken stays when the
//! journal changes or goes; each entry keeps its own retention.
use crate::{
object::{AnyId, JmapObject, JmapObjectId},
request::deserialize::DeserializeArguments,
};
use jmap_tools::{Element, Key, Property};
use std::{borrow::Cow, str::FromStr};
use types::id::Id;
#[derive(Debug, Clone, Default)]
pub struct Journal;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum JournalProperty {
Id,
Name,
Description,
Enabled,
/// `outgoing`, `incoming`, `internal` or `any`.
Direction,
/// Everyone, or chosen accounts, groups, domains and tenants.
Scope,
/// How long an entry is kept; each keeps what it was written with.
RetentionDays,
/// Whether entries go into the built-in journal.
BuiltIn,
/// An outside archive's journal address.
ArchiveAddress,
/// Reports the archive didn't take: how many, when and why last.
ArchiveFailures,
CreatedBy,
CreatedAt,
UpdatedAt,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum JournalValue {
Id(Id),
}
impl Property for JournalProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
// Keys inside the scope stay plain keys
match parent {
None => JournalProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
JournalProperty::Id => "id",
JournalProperty::Name => "name",
JournalProperty::Description => "description",
JournalProperty::Enabled => "enabled",
JournalProperty::Direction => "direction",
JournalProperty::Scope => "scope",
JournalProperty::RetentionDays => "retentionDays",
JournalProperty::BuiltIn => "builtIn",
JournalProperty::ArchiveAddress => "archiveAddress",
JournalProperty::ArchiveFailures => "archiveFailures",
JournalProperty::CreatedBy => "createdBy",
JournalProperty::CreatedAt => "createdAt",
JournalProperty::UpdatedAt => "updatedAt",
}
.into()
}
}
impl JournalProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => JournalProperty::Id,
b"name" => JournalProperty::Name,
b"description" => JournalProperty::Description,
b"enabled" => JournalProperty::Enabled,
b"direction" => JournalProperty::Direction,
b"scope" => JournalProperty::Scope,
b"retentionDays" => JournalProperty::RetentionDays,
b"builtIn" => JournalProperty::BuiltIn,
b"archiveAddress" => JournalProperty::ArchiveAddress,
b"archiveFailures" => JournalProperty::ArchiveFailures,
b"createdBy" => JournalProperty::CreatedBy,
b"createdAt" => JournalProperty::CreatedAt,
b"updatedAt" => JournalProperty::UpdatedAt,
)
}
}
impl FromStr for JournalProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
JournalProperty::parse(s).ok_or(())
}
}
impl Element for JournalValue {
type Property = JournalProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(JournalProperty::Id) => Id::from_str(value).ok().map(JournalValue::Id),
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
JournalValue::Id(id) => id.to_string().into(),
}
}
}
/// The set call's own argument: why, for the audit log.
#[derive(Debug, Clone, Default)]
pub struct JournalSetArguments {
pub reason: Option<String>,
}
impl<'de> DeserializeArguments<'de> for JournalSetArguments {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
if key == "reason" {
self.reason = map.next_value()?;
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(())
}
}
impl JmapObject for Journal {
type Property = JournalProperty;
type Element = JournalValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = JournalSetArguments;
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = JournalProperty::Id;
}
impl From<Id> for JournalValue {
fn from(id: Id) -> Self {
JournalValue::Id(id)
}
}
impl JmapObjectId for JournalValue {
fn as_id(&self) -> Option<Id> {
match self {
JournalValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
JournalValue::Id(id) => Some(AnyId::Id(*id)),
}
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, new_id: AnyId) -> bool {
if let AnyId::Id(id) = new_id {
*self = JournalValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for JournalProperty {
fn as_id(&self) -> Option<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
@@ -0,0 +1,340 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The journal's JMAP objects under `urn:inbuxa:jmap` (journaling spec,
//! JR-6, JR-15 to JR-17):
//!
//! - `inbuxa:JournalEntry/get` and `/query`: what was journaled, read-only.
//! `report` (the whole journal report) comes only when asked for.
//! - `inbuxa:JournalExport/set`: create one to get a ZIP of the reports a
//! filter matches.
//! - `inbuxa:JournalVerification/set`: create one to recheck every chain.
//!
//! They share one set of properties. Nested values (an export's filter, a
//! verification's chains) are plain JSON objects.
use crate::{
object::{AnyId, JmapObject, JmapObjectId},
request::deserialize::DeserializeArguments,
};
use jmap_tools::{Element, Key, Property};
use std::{borrow::Cow, str::FromStr};
use types::id::Id;
#[derive(Debug, Clone, Default)]
pub struct JournalEntry;
#[derive(Debug, Clone, Default)]
pub struct JournalExport;
#[derive(Debug, Clone, Default)]
pub struct JournalVerification;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum JournalEntryProperty {
Id,
ReceivedAt,
Direction,
Sender,
Authenticated,
Recipients,
Subject,
MessageId,
JournalIds,
Held,
Size,
Sha256,
ExpiresAt,
Report,
Filter,
Reason,
BlobId,
Count,
Verified,
Chains,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum JournalEntryValue {
Id(Id),
}
impl Property for JournalEntryProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
// Keys inside a filter or a chain report stay plain keys
match parent {
None => JournalEntryProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
JournalEntryProperty::Id => "id",
JournalEntryProperty::ReceivedAt => "receivedAt",
JournalEntryProperty::Direction => "direction",
JournalEntryProperty::Sender => "sender",
JournalEntryProperty::Authenticated => "authenticated",
JournalEntryProperty::Recipients => "recipients",
JournalEntryProperty::Subject => "subject",
JournalEntryProperty::MessageId => "messageId",
JournalEntryProperty::JournalIds => "journalIds",
JournalEntryProperty::Held => "held",
JournalEntryProperty::Size => "size",
JournalEntryProperty::Sha256 => "sha256",
JournalEntryProperty::ExpiresAt => "expiresAt",
JournalEntryProperty::Report => "report",
JournalEntryProperty::Filter => "filter",
JournalEntryProperty::Reason => "reason",
JournalEntryProperty::BlobId => "blobId",
JournalEntryProperty::Count => "count",
JournalEntryProperty::Verified => "verified",
JournalEntryProperty::Chains => "chains",
}
.into()
}
}
impl JournalEntryProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => JournalEntryProperty::Id,
b"receivedAt" => JournalEntryProperty::ReceivedAt,
b"direction" => JournalEntryProperty::Direction,
b"sender" => JournalEntryProperty::Sender,
b"authenticated" => JournalEntryProperty::Authenticated,
b"recipients" => JournalEntryProperty::Recipients,
b"subject" => JournalEntryProperty::Subject,
b"messageId" => JournalEntryProperty::MessageId,
b"journalIds" => JournalEntryProperty::JournalIds,
b"held" => JournalEntryProperty::Held,
b"size" => JournalEntryProperty::Size,
b"sha256" => JournalEntryProperty::Sha256,
b"expiresAt" => JournalEntryProperty::ExpiresAt,
b"report" => JournalEntryProperty::Report,
b"filter" => JournalEntryProperty::Filter,
b"reason" => JournalEntryProperty::Reason,
b"blobId" => JournalEntryProperty::BlobId,
b"count" => JournalEntryProperty::Count,
b"verified" => JournalEntryProperty::Verified,
b"chains" => JournalEntryProperty::Chains,
)
}
}
impl FromStr for JournalEntryProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
JournalEntryProperty::parse(s).ok_or(())
}
}
impl Element for JournalEntryValue {
type Property = JournalEntryProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(JournalEntryProperty::Id) => {
Id::from_str(value).ok().map(JournalEntryValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
JournalEntryValue::Id(id) => id.to_string().into(),
}
}
}
/// One condition of an `inbuxa:JournalEntry/query` filter. Several in one
/// filter object must all hold.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JournalFilter {
/// From this time on (UTC date).
After(String),
/// Before this time (UTC date).
Before(String),
/// Part of the sender's address.
Sender(String),
/// Part of a recipient's address.
Recipient(String),
/// Part of the sender's or a recipient's address.
Address(String),
/// `outgoing`, `incoming` or `internal`.
Direction(String),
/// Words that must all be in the subject.
Text(String),
MessageId(String),
JournalId(Id),
_T(String),
}
impl Default for JournalFilter {
fn default() -> Self {
JournalFilter::_T(String::new())
}
}
impl<'de> DeserializeArguments<'de> for JournalFilter {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
hashify::fnc_map!(key.as_bytes(),
b"after" => {
*self = JournalFilter::After(map.next_value()?);
},
b"before" => {
*self = JournalFilter::Before(map.next_value()?);
},
b"sender" => {
*self = JournalFilter::Sender(map.next_value()?);
},
b"recipient" => {
*self = JournalFilter::Recipient(map.next_value()?);
},
b"address" => {
*self = JournalFilter::Address(map.next_value()?);
},
b"direction" => {
*self = JournalFilter::Direction(map.next_value()?);
},
b"text" => {
*self = JournalFilter::Text(map.next_value()?);
},
b"messageId" => {
*self = JournalFilter::MessageId(map.next_value()?);
},
b"journalId" => {
*self = JournalFilter::JournalId(map.next_value()?);
},
_ => {
*self = JournalFilter::_T(key.to_string());
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
);
Ok(())
}
}
/// Entries sort newest first, by `receivedAt`; nothing else.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JournalComparator {
ReceivedAt,
_T(String),
}
impl Default for JournalComparator {
fn default() -> Self {
JournalComparator::_T(String::new())
}
}
impl<'de> DeserializeArguments<'de> for JournalComparator {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
if key == "property" {
let value = map.next_value::<Cow<str>>()?;
*self = if value == "receivedAt" {
JournalComparator::ReceivedAt
} else {
JournalComparator::_T(value.into_owned())
};
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(())
}
}
macro_rules! journal_object {
($object:ty, $filter:ty, $comparator:ty) => {
impl JmapObject for $object {
type Property = JournalEntryProperty;
type Element = JournalEntryValue;
type Id = Id;
type Filter = $filter;
type Comparator = $comparator;
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = JournalEntryProperty::Id;
}
};
}
journal_object!(JournalEntry, JournalFilter, JournalComparator);
journal_object!(JournalExport, (), ());
journal_object!(JournalVerification, (), ());
impl From<Id> for JournalEntryValue {
fn from(id: Id) -> Self {
JournalEntryValue::Id(id)
}
}
impl JmapObjectId for JournalEntryValue {
fn as_id(&self) -> Option<Id> {
match self {
JournalEntryValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
JournalEntryValue::Id(id) => Some(AnyId::Id(*id)),
}
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, new_id: AnyId) -> bool {
if let AnyId::Id(id) = new_id {
*self = JournalEntryValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for JournalEntryProperty {
fn as_id(&self) -> Option<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
@@ -0,0 +1,218 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:MailRule/get` and `/set` under `urn:inbuxa:jmap`: mail flow rules
//! and DLP rules (dlp-and-mail-flow-rules spec, §2.2). `kind` says which,
//! and which permissions reach it. The set call's `reason` argument, if
//! given, goes into the audit log with the change.
use crate::{
object::{AnyId, JmapObject, JmapObjectId},
request::deserialize::DeserializeArguments,
};
use jmap_tools::{Element, Key, Property};
use std::{borrow::Cow, str::FromStr};
use types::id::Id;
#[derive(Debug, Clone, Default)]
pub struct MailRule;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum MailRuleProperty {
Id,
Name,
Description,
/// `dlp` or `transport`.
Kind,
Enabled,
/// Lower runs first.
Priority,
/// `outgoing`, `incoming` or `any`.
Direction,
Conditions,
Exceptions,
Actions,
StopProcessing,
CreatedBy,
CreatedAt,
UpdatedAt,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum MailRuleValue {
Id(Id),
}
impl Property for MailRuleProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
// Keys inside conditions and actions stay plain keys
match parent {
None => MailRuleProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
MailRuleProperty::Id => "id",
MailRuleProperty::Name => "name",
MailRuleProperty::Description => "description",
MailRuleProperty::Kind => "kind",
MailRuleProperty::Enabled => "enabled",
MailRuleProperty::Priority => "priority",
MailRuleProperty::Direction => "direction",
MailRuleProperty::Conditions => "conditions",
MailRuleProperty::Exceptions => "exceptions",
MailRuleProperty::Actions => "actions",
MailRuleProperty::StopProcessing => "stopProcessing",
MailRuleProperty::CreatedBy => "createdBy",
MailRuleProperty::CreatedAt => "createdAt",
MailRuleProperty::UpdatedAt => "updatedAt",
}
.into()
}
}
impl MailRuleProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => MailRuleProperty::Id,
b"name" => MailRuleProperty::Name,
b"description" => MailRuleProperty::Description,
b"kind" => MailRuleProperty::Kind,
b"enabled" => MailRuleProperty::Enabled,
b"priority" => MailRuleProperty::Priority,
b"direction" => MailRuleProperty::Direction,
b"conditions" => MailRuleProperty::Conditions,
b"exceptions" => MailRuleProperty::Exceptions,
b"actions" => MailRuleProperty::Actions,
b"stopProcessing" => MailRuleProperty::StopProcessing,
b"createdBy" => MailRuleProperty::CreatedBy,
b"createdAt" => MailRuleProperty::CreatedAt,
b"updatedAt" => MailRuleProperty::UpdatedAt,
)
}
}
impl FromStr for MailRuleProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
MailRuleProperty::parse(s).ok_or(())
}
}
impl Element for MailRuleValue {
type Property = MailRuleProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(MailRuleProperty::Id) => Id::from_str(value).ok().map(MailRuleValue::Id),
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
MailRuleValue::Id(id) => id.to_string().into(),
}
}
}
/// The set call's own argument: why, for the audit log.
#[derive(Debug, Clone, Default)]
pub struct MailRuleSetArguments {
pub reason: Option<String>,
}
impl<'de> DeserializeArguments<'de> for MailRuleSetArguments {
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
where
A: serde::de::MapAccess<'de>,
{
if key == "reason" {
self.reason = map.next_value()?;
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(())
}
}
impl JmapObject for MailRule {
type Property = MailRuleProperty;
type Element = MailRuleValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = MailRuleSetArguments;
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = MailRuleProperty::Id;
}
impl From<Id> for MailRuleValue {
fn from(id: Id) -> Self {
MailRuleValue::Id(id)
}
}
impl JmapObjectId for MailRuleValue {
fn as_id(&self) -> Option<Id> {
match self {
MailRuleValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
MailRuleValue::Id(id) => Some(AnyId::Id(*id)),
}
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, new_id: AnyId) -> bool {
if let AnyId::Id(id) = new_id {
*self = MailRuleValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for MailRuleProperty {
fn as_id(&self) -> Option<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
@@ -0,0 +1,173 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:SecurityAcceptance/get` and `/set` under `urn:inbuxa:jmap`: the
//! security to-do items an administrator accepted, with why (security
//! to-do list spec, SS-23 to SS-26). Created and destroyed, never updated.
use crate::object::{AnyId, JmapObject, JmapObjectId};
use jmap_tools::{Element, Key, Property};
use std::{borrow::Cow, str::FromStr};
use types::id::Id;
#[derive(Debug, Clone, Default)]
pub struct SecurityAcceptance;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SecurityAcceptanceProperty {
Id,
/// `SS-1` to `SS-18`.
Check,
Subject,
AcceptedValue,
Note,
AcceptedBy,
AcceptedAt,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SecurityAcceptanceValue {
Id(Id),
}
impl Property for SecurityAcceptanceProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
// Keys inside acceptedValue stay plain keys
match parent {
None => SecurityAcceptanceProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
SecurityAcceptanceProperty::Id => "id",
SecurityAcceptanceProperty::Check => "check",
SecurityAcceptanceProperty::Subject => "subject",
SecurityAcceptanceProperty::AcceptedValue => "acceptedValue",
SecurityAcceptanceProperty::Note => "note",
SecurityAcceptanceProperty::AcceptedBy => "acceptedBy",
SecurityAcceptanceProperty::AcceptedAt => "acceptedAt",
}
.into()
}
}
impl SecurityAcceptanceProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => SecurityAcceptanceProperty::Id,
b"check" => SecurityAcceptanceProperty::Check,
b"subject" => SecurityAcceptanceProperty::Subject,
b"acceptedValue" => SecurityAcceptanceProperty::AcceptedValue,
b"note" => SecurityAcceptanceProperty::Note,
b"acceptedBy" => SecurityAcceptanceProperty::AcceptedBy,
b"acceptedAt" => SecurityAcceptanceProperty::AcceptedAt,
)
}
}
impl FromStr for SecurityAcceptanceProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
SecurityAcceptanceProperty::parse(s).ok_or(())
}
}
impl Element for SecurityAcceptanceValue {
type Property = SecurityAcceptanceProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(SecurityAcceptanceProperty::Id) => {
Id::from_str(value).ok().map(SecurityAcceptanceValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
SecurityAcceptanceValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for SecurityAcceptance {
type Property = SecurityAcceptanceProperty;
type Element = SecurityAcceptanceValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = SecurityAcceptanceProperty::Id;
}
impl From<Id> for SecurityAcceptanceValue {
fn from(id: Id) -> Self {
SecurityAcceptanceValue::Id(id)
}
}
impl JmapObjectId for SecurityAcceptanceValue {
fn as_id(&self) -> Option<Id> {
match self {
SecurityAcceptanceValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
SecurityAcceptanceValue::Id(id) => Some(AnyId::Id(*id)),
}
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, new_id: AnyId) -> bool {
if let AnyId::Id(id) = new_id {
*self = SecurityAcceptanceValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for SecurityAcceptanceProperty {
fn as_id(&self) -> Option<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
+6
View File
@@ -24,10 +24,16 @@ pub mod fastmail_masked_email; // inbuxa: masked email
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
pub mod inbuxa_log_settings; // inbuxa: personal-data catalog, D1
pub mod inbuxa_dlp_settings; // inbuxa: DLP settings
pub mod inbuxa_data_inventory; // inbuxa: personal-data catalog
pub mod inbuxa_inventory_snapshot; // inbuxa: personal-data catalog
pub mod inbuxa_audit; // inbuxa: the audit log
pub mod inbuxa_legal_hold; // inbuxa: legal hold
pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules
pub mod inbuxa_security_acceptance; // inbuxa: accepted security to-do items
pub mod inbuxa_journal; // inbuxa: journaling
pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export
pub mod inbuxa_held_message; // inbuxa: mail held for review
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
+18
View File
@@ -64,6 +64,9 @@ impl Response<'_> {
GetResponseMethod::LogSettings(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::DlpSettings(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::DataInventory(response) => {
response.eval_jptr(path, &mut results)
}
@@ -82,6 +85,21 @@ impl Response<'_> {
GetResponseMethod::LegalHold(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::MailRule(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::SecurityAcceptance(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::Journal(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::JournalEntry(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::HeldMessage(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::HoldExport(response) => {
response.eval_jptr(path, &mut results)
}
@@ -47,12 +47,18 @@ impl Response<'_> {
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
GetRequestMethod::LogSettings(request) => request.resolve_references(self)?,
GetRequestMethod::DlpSettings(request) => request.resolve_references(self)?,
GetRequestMethod::DataInventory(request) => request.resolve_references(self)?,
GetRequestMethod::InventorySnapshot(request) => request.resolve_references(self)?,
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
GetRequestMethod::MailRule(request) => request.resolve_references(self)?,
GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?,
GetRequestMethod::Journal(request) => request.resolve_references(self)?,
GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?,
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
GetRequestMethod::HoldExport(request) => request.resolve_references(self)?,
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
GetRequestMethod::TenantProtocolPolicy(request) => {
@@ -104,6 +110,9 @@ impl Response<'_> {
SetRequestMethod::LogSettings(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::DlpSettings(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::Explanation(request) => {
request.resolve_references(self, 1, false)?
}
@@ -122,6 +131,24 @@ impl Response<'_> {
SetRequestMethod::LegalHold(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::MailRule(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::SecurityAcceptance(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::Journal(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::JournalExport(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::JournalVerification(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::HeldMessage(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::HoldExport(request) => {
request.resolve_references(self, 1, false)?
}
+58 -1
View File
@@ -50,6 +50,7 @@ pub enum MethodObject {
// inbuxa: AI call limits
AiLimits,
LogSettings,
DlpSettings,
DataInventory,
InventorySnapshot,
// inbuxa: "Explain this" with the local model
@@ -65,6 +66,16 @@ pub enum MethodObject {
LegalHold,
HoldExport,
ProtocolPolicy,
// inbuxa: DLP and mail flow rules
MailRule,
// inbuxa: accepted security to-do items
SecurityAcceptance,
HeldMessage,
// inbuxa: journaling
Journal,
JournalEntry,
JournalExport,
JournalVerification,
TenantProtocolPolicy,
}
@@ -93,6 +104,7 @@ impl MethodObject {
MethodObject::DeletedAccount => Capability::Inbuxa,
MethodObject::AiLimits => Capability::Inbuxa,
MethodObject::LogSettings => Capability::Inbuxa,
MethodObject::DlpSettings => Capability::Inbuxa,
MethodObject::DataInventory => Capability::Inbuxa,
MethodObject::InventorySnapshot => Capability::Inbuxa,
MethodObject::Explanation => Capability::Inbuxa,
@@ -102,7 +114,14 @@ impl MethodObject {
| MethodObject::AuditVerification
| MethodObject::AccountLock
| MethodObject::LegalHold
| MethodObject::HoldExport => Capability::Inbuxa,
| MethodObject::HoldExport
| MethodObject::MailRule
| MethodObject::SecurityAcceptance
| MethodObject::HeldMessage
| MethodObject::Journal
| MethodObject::JournalEntry
| MethodObject::JournalExport
| MethodObject::JournalVerification => Capability::Inbuxa,
MethodObject::ProtocolPolicy => Capability::Inbuxa,
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
}
@@ -283,9 +302,11 @@ impl MethodName {
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
(MethodFunction::Get, MethodObject::LogSettings) => "inbuxa:LogSettings/get",
(MethodFunction::Get, MethodObject::DlpSettings) => "inbuxa:DlpSettings/get",
(MethodFunction::Get, MethodObject::DataInventory) => "inbuxa:DataInventory/get",
(MethodFunction::Get, MethodObject::InventorySnapshot) => "inbuxa:InventorySnapshot/get",
(MethodFunction::Set, MethodObject::LogSettings) => "inbuxa:LogSettings/set",
(MethodFunction::Set, MethodObject::DlpSettings) => "inbuxa:DlpSettings/set",
(MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set",
(MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get",
(MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query",
@@ -296,6 +317,20 @@ impl MethodName {
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
(MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
(MethodFunction::Get, MethodObject::MailRule) => "inbuxa:MailRule/get",
(MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set",
(MethodFunction::Get, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/get",
(MethodFunction::Set, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/set",
(MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get",
(MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set",
(MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get",
(MethodFunction::Query, MethodObject::JournalEntry) => "inbuxa:JournalEntry/query",
(MethodFunction::Set, MethodObject::JournalExport) => "inbuxa:JournalExport/set",
(MethodFunction::Set, MethodObject::JournalVerification) => {
"inbuxa:JournalVerification/set"
}
(MethodFunction::Get, MethodObject::HeldMessage) => "inbuxa:HeldMessage/get",
(MethodFunction::Set, MethodObject::HeldMessage) => "inbuxa:HeldMessage/set",
(MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get",
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
(MethodFunction::Set, MethodObject::AuditVerification) => {
@@ -435,9 +470,11 @@ impl MethodName {
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
"inbuxa:LogSettings/get" => (MethodObject::LogSettings, MethodFunction::Get),
"inbuxa:DlpSettings/get" => (MethodObject::DlpSettings, MethodFunction::Get),
"inbuxa:DataInventory/get" => (MethodObject::DataInventory, MethodFunction::Get),
"inbuxa:InventorySnapshot/get" => (MethodObject::InventorySnapshot, MethodFunction::Get),
"inbuxa:LogSettings/set" => (MethodObject::LogSettings, MethodFunction::Set),
"inbuxa:DlpSettings/set" => (MethodObject::DlpSettings, MethodFunction::Set),
"inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set),
"inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get),
"inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query),
@@ -448,6 +485,18 @@ impl MethodName {
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
"inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
"inbuxa:MailRule/get" => (MethodObject::MailRule, MethodFunction::Get),
"inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set),
"inbuxa:SecurityAcceptance/get" => (MethodObject::SecurityAcceptance, MethodFunction::Get),
"inbuxa:SecurityAcceptance/set" => (MethodObject::SecurityAcceptance, MethodFunction::Set),
"inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get),
"inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set),
"inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get),
"inbuxa:JournalEntry/query" => (MethodObject::JournalEntry, MethodFunction::Query),
"inbuxa:JournalExport/set" => (MethodObject::JournalExport, MethodFunction::Set),
"inbuxa:JournalVerification/set" => (MethodObject::JournalVerification, MethodFunction::Set),
"inbuxa:HeldMessage/get" => (MethodObject::HeldMessage, MethodFunction::Get),
"inbuxa:HeldMessage/set" => (MethodObject::HeldMessage, MethodFunction::Set),
"inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get),
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
@@ -509,6 +558,7 @@ impl Display for MethodObject {
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
MethodObject::AiLimits => "inbuxa:AiLimits",
MethodObject::LogSettings => "inbuxa:LogSettings",
MethodObject::DlpSettings => "inbuxa:DlpSettings",
MethodObject::DataInventory => "inbuxa:DataInventory",
MethodObject::InventorySnapshot => "inbuxa:InventorySnapshot",
MethodObject::Explanation => "inbuxa:Explanation",
@@ -518,6 +568,13 @@ impl Display for MethodObject {
MethodObject::AuditVerification => "inbuxa:AuditVerification",
MethodObject::AccountLock => "inbuxa:AccountLock",
MethodObject::LegalHold => "inbuxa:LegalHold",
MethodObject::MailRule => "inbuxa:MailRule",
MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance",
MethodObject::Journal => "inbuxa:Journal",
MethodObject::JournalEntry => "inbuxa:JournalEntry",
MethodObject::JournalExport => "inbuxa:JournalExport",
MethodObject::JournalVerification => "inbuxa:JournalVerification",
MethodObject::HeldMessage => "inbuxa:HeldMessage",
MethodObject::HoldExport => "inbuxa:HoldExport",
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
+16
View File
@@ -117,12 +117,18 @@ pub enum GetRequestMethod {
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
LogSettings(Box<GetRequest<crate::object::inbuxa_log_settings::LogSettings>>),
DlpSettings(Box<GetRequest<crate::object::inbuxa_dlp_settings::DlpSettings>>),
DataInventory(Box<GetRequest<crate::object::inbuxa_data_inventory::DataInventory>>),
InventorySnapshot(Box<GetRequest<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>>),
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>),
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy(
@@ -152,12 +158,21 @@ pub enum SetRequestMethod<'x> {
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
LogSettings(Box<SetRequest<'x, crate::object::inbuxa_log_settings::LogSettings>>),
DlpSettings(Box<SetRequest<'x, crate::object::inbuxa_dlp_settings::DlpSettings>>),
Explanation(Box<SetRequest<'x, crate::object::inbuxa_explanation::Explanation>>),
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
MailRule(Box<SetRequest<'x, crate::object::inbuxa_mail_rule::MailRule>>),
SecurityAcceptance(
Box<SetRequest<'x, crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
),
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>),
JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>),
HeldMessage(Box<SetRequest<'x, crate::object::inbuxa_held_message::HeldMessage>>),
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
TenantProtocolPolicy(
@@ -191,6 +206,7 @@ pub enum QueryRequestMethod {
ShareNotification(Box<QueryRequest<ShareNotification>>),
Registry(Box<QueryRequest<Registry>>),
AuditEvent(Box<QueryRequest<crate::object::inbuxa_audit::AuditEvent>>),
JournalEntry(Box<QueryRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
}
#[derive(Debug)]
+102
View File
@@ -176,6 +176,13 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::DlpSettings) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DlpSettings(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::DataInventory) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DataInventory(value)),
Err(err) => RequestMethod::invalid(err),
@@ -378,6 +385,13 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::DlpSettings) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DlpSettings(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::Explanation) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Explanation(value)),
Err(err) => RequestMethod::invalid(err),
@@ -609,6 +623,94 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self));
}
},
// inbuxa: mail held for review
(MethodFunction::Get, MethodObject::HeldMessage) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::HeldMessage(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::HeldMessage) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::HeldMessage(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
// inbuxa: DLP and mail flow rules
(MethodFunction::Get, MethodObject::MailRule) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::MailRule(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::MailRule) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::MailRule(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
// inbuxa: accepted security to-do items
(MethodFunction::Get, MethodObject::SecurityAcceptance) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::SecurityAcceptance(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::SecurityAcceptance) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::SecurityAcceptance(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
// inbuxa: journaling
(MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Query, MethodObject::JournalEntry) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::JournalEntry(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::JournalExport) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::JournalExport(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::JournalVerification) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::JournalVerification(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::Journal) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Journal(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::Journal) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Journal(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
// inbuxa: legal hold
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
+99
View File
@@ -104,12 +104,18 @@ pub enum GetResponseMethod {
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
LogSettings(GetResponse<crate::object::inbuxa_log_settings::LogSettings>),
DlpSettings(GetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>),
DataInventory(GetResponse<crate::object::inbuxa_data_inventory::DataInventory>),
InventorySnapshot(GetResponse<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>),
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
TenantProtocolPolicy(
@@ -140,11 +146,20 @@ pub enum SetResponseMethod {
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
LogSettings(Box<SetResponse<crate::object::inbuxa_log_settings::LogSettings>>),
DlpSettings(Box<SetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>>),
AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>),
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
MailRule(Box<SetResponse<crate::object::inbuxa_mail_rule::MailRule>>),
SecurityAcceptance(
Box<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
),
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>),
JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>),
HeldMessage(Box<SetResponse<crate::object::inbuxa_held_message::HeldMessage>>),
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
@@ -359,6 +374,12 @@ impl<'x> From<GetResponse<crate::object::inbuxa_log_settings::LogSettings>> for
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>) -> Self {
ResponseMethod::Get(GetResponseMethod::DlpSettings(value))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_data_inventory::DataInventory>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_data_inventory::DataInventory>) -> Self {
ResponseMethod::Get(GetResponseMethod::DataInventory(value))
@@ -383,6 +404,12 @@ impl<'x> From<SetResponse<crate::object::inbuxa_log_settings::LogSettings>> for
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>) -> Self {
ResponseMethod::Set(SetResponseMethod::DlpSettings(Box::new(value)))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_explanation::Explanation>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_explanation::Explanation>) -> Self {
ResponseMethod::Set(SetResponseMethod::Explanation(Box::new(value)))
@@ -799,6 +826,78 @@ impl<'x> From<SetResponse<crate::object::inbuxa_account_lock::AccountLock>> for
}
// inbuxa: legal hold
impl<'x> From<GetResponse<crate::object::inbuxa_held_message::HeldMessage>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_held_message::HeldMessage>) -> Self {
ResponseMethod::Get(GetResponseMethod::HeldMessage(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_held_message::HeldMessage>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_held_message::HeldMessage>) -> Self {
ResponseMethod::Set(SetResponseMethod::HeldMessage(Box::new(value)))
}
}
// inbuxa: accepted security to-do items
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
for ResponseMethod<'x>
{
fn from(value: GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>) -> Self {
ResponseMethod::Get(GetResponseMethod::SecurityAcceptance(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
for ResponseMethod<'x>
{
fn from(value: SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>) -> Self {
ResponseMethod::Set(SetResponseMethod::SecurityAcceptance(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_mail_rule::MailRule>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_mail_rule::MailRule>) -> Self {
ResponseMethod::Get(GetResponseMethod::MailRule(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_mail_rule::MailRule>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_mail_rule::MailRule>) -> Self {
ResponseMethod::Set(SetResponseMethod::MailRule(Box::new(value)))
}
}
// inbuxa: journaling
impl<'x> From<GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>) -> Self {
ResponseMethod::Get(GetResponseMethod::JournalEntry(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_journal_entry::JournalExport>) -> Self {
ResponseMethod::Set(SetResponseMethod::JournalExport(Box::new(value)))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>) -> Self {
ResponseMethod::Set(SetResponseMethod::JournalVerification(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_journal::Journal>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_journal::Journal>) -> Self {
ResponseMethod::Get(GetResponseMethod::Journal(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_journal::Journal>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_journal::Journal>) -> Self {
ResponseMethod::Set(SetResponseMethod::Journal(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
ResponseMethod::Get(GetResponseMethod::LegalHold(value))
+94
View File
@@ -92,6 +92,7 @@ impl JmapAuthorization for AccessToken {
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
// inbuxa: log file retention, with the tracers' permissions
GetRequestMethod::LogSettings(_) => Permission::SysTracerGet,
GetRequestMethod::DlpSettings(_) => Permission::SysDlpPolicyGet,
// inbuxa: personal-data catalog, the inventory and its history
GetRequestMethod::DataInventory(_) | GetRequestMethod::InventorySnapshot(_) => {
Permission::SysComplianceGet
@@ -103,7 +104,25 @@ impl JmapAuthorization for AccessToken {
// inbuxa: account lock (AL-12)
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet,
// inbuxa: DLP and mail flow rules share an object; either
// permission reaches it, and the handler shows each kind
// only to those who may see it
// inbuxa: mail held for review (§2.8)
GetRequestMethod::HeldMessage(_) => Permission::SysDlpReviewGet,
GetRequestMethod::MailRule(_) => {
if self.has_permission(Permission::SysMailRuleGet) {
Permission::SysMailRuleGet
} else {
Permission::SysDlpPolicyGet
}
}
// inbuxa: journaling (JR-18)
GetRequestMethod::Journal(_) => Permission::SysJournalGet,
GetRequestMethod::JournalEntry(_) => Permission::SysJournalSearch,
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
// inbuxa: accepted security items are read by whoever may
// see the server's security settings
GetRequestMethod::SecurityAcceptance(_) => Permission::SysSecurityGet,
// inbuxa: legacy protocols off. It takes listeners away and
// puts them back, so it takes the listener's permissions
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
@@ -215,6 +234,13 @@ impl JmapAuthorization for AccessToken {
Permission::SysTracerUpdate,
Permission::SysTracerUpdate,
),
SetRequestMethod::DlpSettings(s) => validate_set(
s,
self,
Permission::SysDlpPolicyUpdate,
Permission::SysDlpPolicyUpdate,
Permission::SysDlpPolicyUpdate,
),
// inbuxa: the audit log (AU-7, AU-9, AU-11)
SetRequestMethod::AuditSettings(s) => validate_set(
s,
@@ -247,6 +273,64 @@ impl JmapAuthorization for AccessToken {
Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldUpdate,
),
// inbuxa: releasing or rejecting held mail (§2.8)
SetRequestMethod::HeldMessage(s) => validate_set(
s,
self,
Permission::SysDlpReviewUpdate,
Permission::SysDlpReviewUpdate,
Permission::SysDlpReviewUpdate,
),
// inbuxa: DLP and mail flow rules: either change
// permission gets in; the handler checks each rule's kind
SetRequestMethod::MailRule(_) => {
if self.has_permission(Permission::SysMailRuleUpdate)
|| self.has_permission(Permission::SysDlpPolicyUpdate)
{
Ok(())
} else {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("You are not authorized to change mail rules"))
}
}
// inbuxa: journaling (JR-18)
SetRequestMethod::Journal(s) => validate_set(
s,
self,
Permission::SysJournalUpdate,
Permission::SysJournalUpdate,
Permission::SysJournalUpdate,
),
SetRequestMethod::JournalExport(s) => validate_set(
s,
self,
Permission::SysJournalExport,
Permission::SysJournalExport,
Permission::SysJournalExport,
),
SetRequestMethod::JournalVerification(s) => validate_set(
s,
self,
Permission::SysJournalGet,
Permission::SysJournalGet,
Permission::SysJournalGet,
),
// inbuxa: accepting a security to-do item, or removing
// an acceptance; nothing is ever edited
SetRequestMethod::SecurityAcceptance(s) => {
if s.update.as_ref().is_some_and(|u| !u.is_empty()) {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("An acceptance is replaced, not edited"))
} else if self.has_permission(Permission::SysSecurityAccept) {
Ok(())
} else {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("You are not authorized to accept security items"))
}
}
// inbuxa: LH-12, exporting held data
SetRequestMethod::HoldExport(s) => validate_set(
s,
@@ -397,6 +481,7 @@ impl JmapAuthorization for AccessToken {
| MethodObject::DeletedAccount
| MethodObject::AiLimits
| MethodObject::LogSettings
| MethodObject::DlpSettings
| MethodObject::DataInventory
| MethodObject::InventorySnapshot
| MethodObject::Explanation
@@ -407,6 +492,13 @@ impl JmapAuthorization for AccessToken {
| MethodObject::AccountLock
| MethodObject::LegalHold
| MethodObject::HoldExport
| MethodObject::MailRule
| MethodObject::SecurityAcceptance
| MethodObject::HeldMessage
| MethodObject::Journal
| MethodObject::JournalEntry
| MethodObject::JournalExport
| MethodObject::JournalVerification
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
// inbuxa: x:MaskedEmail/changes reads what /get reads
@@ -465,6 +557,8 @@ impl JmapAuthorization for AccessToken {
QueryRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationQuery,
// inbuxa: the audit log (AU-9)
QueryRequestMethod::AuditEvent(_) => Permission::SysAuditGet,
// inbuxa: journaling (JR-15)
QueryRequestMethod::JournalEntry(_) => Permission::SysJournalSearch,
QueryRequestMethod::Registry(_) => {
let MethodObject::Registry(object_type) = object else {
unreachable!()
+164
View File
@@ -264,6 +264,9 @@ impl RequestHandler for Server {
SetResponseMethod::LogSettings(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::DlpSettings(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AuditSettings(set_response) => {
set_response.update_created_ids(&mut response);
}
@@ -279,6 +282,24 @@ impl RequestHandler for Server {
SetResponseMethod::LegalHold(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::MailRule(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::SecurityAcceptance(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::Journal(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::JournalExport(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::JournalVerification(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::HeldMessage(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::HoldExport(set_response) => {
set_response.update_created_ids(&mut response);
}
@@ -455,6 +476,13 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: inbuxa:DlpSettings/get
GetRequestMethod::DlpSettings(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::dlp_settings::get(self, access_token, *req)
.await?
.into()
}
// inbuxa: inbuxa:DataInventory/get
GetRequestMethod::DataInventory(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
@@ -486,6 +514,34 @@ impl RequestHandler for Server {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::legal_hold::get(self, *req).await?.into()
}
// inbuxa: mail held for review
GetRequestMethod::HeldMessage(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::held_message::get(self, access_token, *req).await?.into()
}
// inbuxa: DLP and mail flow rules
GetRequestMethod::MailRule(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::mail_rule::get(self, access_token, *req).await?.into()
}
// inbuxa: accepted security to-do items
GetRequestMethod::SecurityAcceptance(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::security_acceptance::get(self, access_token, *req)
.await?
.into()
}
// inbuxa: journaling
GetRequestMethod::Journal(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::journal::get(self, access_token, *req).await?.into()
}
GetRequestMethod::JournalEntry(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::journal_entry::get(self, access_token, session, *req)
.await?
.into()
}
// inbuxa: the audit log (AU-9)
GetRequestMethod::AuditEvent(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
@@ -682,6 +738,13 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: journaling (JR-15)
QueryRequestMethod::JournalEntry(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::journal_entry::query(self, access_token, session, *req)
.await?
.into()
}
QueryRequestMethod::Registry(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
assert_registry_account(self, method_name.obj, access_token, req.account_id)
@@ -810,6 +873,23 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: inbuxa:DlpSettings/set
SetRequestMethod::DlpSettings(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
// inbuxa: AU-1.2, AU-3
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
None,
*req,
|req| Box::pin(crate::inbuxa::dlp_settings::set(self, access_token, req)),
)
.await?
.into()
}
// inbuxa: the audit log (AU-7, AU-11, AU-6)
SetRequestMethod::AuditSettings(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
@@ -910,6 +990,77 @@ impl RequestHandler for Server {
.await?
.into()
}
SetRequestMethod::HeldMessage(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
let reason = req.arguments.reason.clone();
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
reason,
*req,
|req| Box::pin(crate::inbuxa::held_message::set(self, access_token, req)),
)
.await?
.into()
}
SetRequestMethod::MailRule(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
let reason = req.arguments.reason.clone();
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
reason,
*req,
|req| Box::pin(crate::inbuxa::mail_rule::set(self, access_token, req)),
)
.await?
.into()
}
// inbuxa: SS-26, every acceptance made or removed is in the
// audit log
SetRequestMethod::SecurityAcceptance(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
None,
*req,
|req| {
Box::pin(crate::inbuxa::security_acceptance::set(
self,
access_token,
req,
))
},
)
.await?
.into()
}
SetRequestMethod::Journal(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
let reason = req.arguments.reason.clone();
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
reason,
*req,
|req| Box::pin(crate::inbuxa::journal::set(self, access_token, req)),
)
.await?
.into()
}
SetRequestMethod::AuditExport(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
@@ -922,6 +1073,19 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: journaling (JR-6, JR-16)
SetRequestMethod::JournalExport(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::journal_entry::export_set(self, access_token, session, *req)
.await?
.into()
}
SetRequestMethod::JournalVerification(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::journal_entry::verification_set(self, access_token, session, *req)
.await?
.into()
}
// inbuxa: inbuxa:Explanation/set ("Explain this")
SetRequestMethod::Explanation(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
+8
View File
@@ -419,6 +419,7 @@ impl IntermediateChangesResponse {
| MethodObject::DeletedAccount
| MethodObject::AiLimits
| MethodObject::LogSettings
| MethodObject::DlpSettings
| MethodObject::DataInventory
| MethodObject::InventorySnapshot
| MethodObject::Explanation
@@ -429,6 +430,13 @@ impl IntermediateChangesResponse {
| MethodObject::AccountLock
| MethodObject::LegalHold
| MethodObject::HoldExport
| MethodObject::MailRule
| MethodObject::SecurityAcceptance
| MethodObject::Journal
| MethodObject::JournalEntry
| MethodObject::JournalExport
| MethodObject::JournalVerification
| MethodObject::HeldMessage
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy
| MethodObject::Registry(_) => unreachable!(),
+25 -1
View File
@@ -217,7 +217,11 @@ async fn before<T: JmapObject>(
if let Some(MaybeResultReference::Value(destroy)) = &request.destroy {
for id in destroy {
let before = stored(server, registry, id).await;
// inbuxa: a fork object is named from its own store, as an update is
let before = match registry {
Some(_) => stored(server, registry, id).await,
None => fork_current(server, object, id).await,
};
let mut described = before.as_ref().map(diff::describe).unwrap_or_default();
if let Some(before) = &before {
described.name = full_name(server, object, before, described.name).await;
@@ -434,6 +438,26 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
}
MaybeInvalid::Invalid(_) => None,
},
// SS-26: an acceptance named by its check and subject
"inbuxa:SecurityAcceptance" => match id {
MaybeInvalid::Value(id) => {
let acceptance =
security::acceptance::get(data, u32::try_from(id.id()).ok()?)
.await
.ok()??;
let name = match acceptance.subject.as_str() {
"" => acceptance.check.clone(),
subject => format!("{} {subject}", acceptance.check),
};
Some(serde_json::json!({
"name": name,
"check": acceptance.check,
"subject": acceptance.subject,
"note": acceptance.note,
}))
}
MaybeInvalid::Invalid(_) => None,
},
"inbuxa:TenantProtocolPolicy" => match id {
MaybeInvalid::Value(id) => {
security::tenant_protocol_policy::get(data, id.document_id())
+154
View File
@@ -0,0 +1,154 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:DlpSettings/get` and `/set`: how many days held mail waits for a
//! reviewer (dlp-and-mail-flow-rules spec, §2.6), 1 to 90, 7 by default.
//! Server-level, like the rules; applies to mail held from then on.
use common::{Server, auth::AccessToken};
use inbuxa_features::mailflow::held::{self, Settings};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_dlp_settings::{DlpSettings, DlpSettingsProperty as P, DlpSettingsValue},
request::IntoValid,
};
use jmap_tools::{Key, Map, Value};
use types::id::Id;
type LValue = Value<'static, P, DlpSettingsValue>;
const ALL: &[P] = &[P::Id, P::KeepHeldDays];
fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("DLP settings are server-level."))
} else {
Ok(())
}
}
fn to_value(settings: &Settings, properties: &[P]) -> LValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(DlpSettingsValue::Id(Id::singleton())),
P::KeepHeldDays => Value::Number(settings.keep_held_days.into()),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:DlpSettings/get`.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<DlpSettings>,
) -> trc::Result<GetResponse<DlpSettings>> {
assert_server_level(access_token)?;
let properties = request.unwrap_properties(ALL);
let (ids, not_found) = request.unwrap_ids(1)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let settings = held::settings(server.store()).await?;
match ids {
None => response.list.push(to_value(&settings, &properties)),
Some(ids) => {
for id in ids {
if id.is_singleton() {
response.list.push(to_value(&settings, &properties));
} else {
response.push_not_found(id);
}
}
}
}
Ok(response)
}
fn apply(
settings: &mut Settings,
property: &P,
value: &Value<'_, P, DlpSettingsValue>,
) -> Result<(), String> {
match property {
P::KeepHeldDays => {
settings.keep_held_days = value
.as_u64()
.ok_or_else(|| "must be a whole number of days".to_string())?
}
P::Id => return Err("is immutable".to_string()),
}
Ok(())
}
/// `inbuxa:DlpSettings/set`: updates the singleton.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, DlpSettings>,
) -> trc::Result<SetResponse<DlpSettings>> {
assert_server_level(access_token)?;
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
for (client_id, _) in request.unwrap_create() {
response
.not_created
.append(client_id, SetError::singleton());
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(id, SetError::singleton());
}
let data = server.store();
for (id, value) in request.unwrap_update().into_valid() {
if !id.is_singleton() {
response.not_updated.append(id, SetError::not_found());
continue;
}
let mut settings = held::settings(data).await?;
let mut error = None;
for (key, value) in value.into_expanded_object() {
let Key::Property(property) = &key else {
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
break;
};
if let Err(why) = apply(&mut settings, property, &value) {
error = Some(
SetError::invalid_properties()
.with_property(property.clone())
.with_description(why),
);
break;
}
}
if error.is_none()
&& let Err((property, why)) = settings.check()
{
error = Some(
SetError::invalid_properties()
.with_property(property.parse::<P>().unwrap_or(P::Id))
.with_description(format!("{property} {why}.")),
);
}
match error {
Some(error) => response.not_updated.append(id, error),
None => {
held::set_settings(data, &settings).await?;
response.updated.append(id, None);
}
}
}
Ok(response)
}
+11
View File
@@ -798,6 +798,10 @@ mod tests {
assert!(delivery_facts(&mut Facts::default(), &message, "[email protected]").is_err());
}
fn is_timestamp(value: &str) -> bool {
chrono::DateTime::parse_from_rfc3339(value).is_ok()
}
/// The settings questions a release prepares answers for (EX-26): every
/// non-secret property of every settings object, at the object's own
/// default, built exactly as a live question is.
@@ -842,6 +846,12 @@ mod tests {
if info.secret {
continue;
}
// A date's default is the moment the object is built, so its
// question changes every run and no live question ever
// matches it: nothing worth preparing.
if matches!(map[&property].as_str(), Some(v) if is_timestamp(v)) {
continue;
}
let mut facts = Facts::default();
push_setting(&mut facts, &object, &property, &info, &map[&property]);
out.push((object.clone(), property, facts));
@@ -856,6 +866,7 @@ mod tests {
assert!(questions.len() > 500, "found {}", questions.len());
assert!(questions.iter().any(|(o, p, _)| o == "x:Domain" && p == "dnsManagement"));
assert!(!questions.iter().any(|(o, p, _)| o == "x:AiModel" && p == "httpAuth"));
assert!(!questions.iter().any(|(o, p, _)| o == "x:Account" && p == "createdAt"));
}
/// Writes `resources/explain/settings.json.gz` (EX-26). Run before a
+325
View File
@@ -0,0 +1,325 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:HeldMessage` (dlp-and-mail-flow-rules spec, §2.6, §2.8): the
//! review queue. `sysDlpReviewGet` lists held mail and reads it;
//! `sysDlpReviewUpdate` releases or rejects it, with a reason the request
//! layer records. Reading a held message's text is recorded as access to
//! the sender's mail. Nobody in a tenant reaches this (settled answer 3).
use common::{Server, auth::AccessToken, config::smtp::queue::QueueName};
use inbuxa_features::{
audit::{Action, Outcome, Record, Target},
mailflow::held::{self, Held},
};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_held_message::{
HeldMessage, HeldMessageProperty as P, HeldMessageSetArguments, HeldMessageValue,
},
request::IntoValid,
types::date::UTCDate,
};
use jmap_tools::{Key, Map, Value};
use mail_parser::{MessageParser, MimeHeaders, PartType};
use smtp::queue::spool::SmtpSpool;
use std::borrow::Cow;
use types::id::Id;
type HValue = Value<'static, P, HeldMessageValue>;
const ALL: &[P] = &[
P::Id,
P::Sender,
P::Recipients,
P::Subject,
P::Size,
P::Rules,
P::Counts,
P::HeldAt,
P::ExpiresAt,
];
/// How much of a held message's text a preview shows.
const PREVIEW_LIMIT: usize = 64 * 1024;
fn server_level(access_token: &AccessToken) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("Held mail is the server's to review."))
} else {
Ok(())
}
}
fn date(seconds: u64) -> HValue {
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
}
fn text(s: &str) -> HValue {
Value::Str(Cow::Owned(s.to_string()))
}
/// The text a reviewer reads: the subject, each body as text, and the
/// attachments' names; at most [`PREVIEW_LIMIT`].
async fn preview(server: &Server, queue_id: u64) -> trc::Result<Option<String>> {
let Some(message) = server.read_message(queue_id, QueueName::default()).await else {
return Ok(None);
};
let Some(raw) = server
.blob_store()
.get_blob(message.message.blob_hash.as_slice(), 0..usize::MAX)
.await?
else {
return Ok(None);
};
let Some(parsed) = MessageParser::new().parse(&raw) else {
return Ok(Some(
String::from_utf8_lossy(&raw[..raw.len().min(PREVIEW_LIMIT)]).into_owned(),
));
};
let mut out = String::new();
for part in parsed.text_bodies() {
match &part.body {
PartType::Text(text) => out.push_str(text),
PartType::Html(html) => out.push_str(&mail_parser::decoders::html::html_to_text(html)),
_ => {}
}
out.push_str("\n\n");
}
let attachments: Vec<&str> = parsed
.attachments()
.filter_map(|a| a.attachment_name())
.collect();
if !attachments.is_empty() {
out.push_str(&format!("Attachments: {}\n", attachments.join(", ")));
}
if out.len() > PREVIEW_LIMIT {
let mut cut = PREVIEW_LIMIT;
while !out.is_char_boundary(cut) {
cut -= 1;
}
out.truncate(cut);
}
Ok(Some(out))
}
fn to_value(record: &Held, properties: &[P], preview: Option<&str>) -> HValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(HeldMessageValue::Id(Id::from(record.queue_id))),
P::Sender => text(&record.sender),
P::Recipients => Value::Array(record.recipients.iter().map(|r| text(r)).collect()),
P::Subject => text(&record.subject),
P::Size => Value::Number(record.size.into()),
P::Rules => Value::Array(
record
.rules
.iter()
.map(|rule| {
let mut map = Map::with_capacity(2);
map.insert_unchecked(Key::Borrowed("name"), text(&rule.name));
map.insert_unchecked(Key::Borrowed("notice"), text(&rule.notice));
Value::Object(map)
})
.collect(),
),
P::Counts => Value::Array(
record
.counts
.iter()
.map(|(detector, count)| {
let mut map = Map::with_capacity(2);
map.insert_unchecked(Key::Borrowed("detector"), text(detector));
map.insert_unchecked(
Key::Borrowed("count"),
Value::Number((*count as u64).into()),
);
Value::Object(map)
})
.collect(),
),
P::HeldAt => date(record.held_at),
P::ExpiresAt => date(record.expires_at),
P::Preview => preview.map_or(Value::Null, text),
P::Decision | P::Note => Value::Null,
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:HeldMessage/get`: held mail, oldest first.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<HeldMessage>,
) -> trc::Result<GetResponse<HeldMessage>> {
server_level(access_token)?;
let properties = request.unwrap_properties(ALL);
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let all = held::all(server.store()).await?;
let wanted: Vec<&Held> = match &ids {
None => all.iter().collect(),
Some(ids) => {
let mut found = Vec::new();
for id in ids {
match all.iter().find(|h| h.queue_id == id.id()) {
Some(record) => found.push(record),
None => response.push_not_found(*id),
}
}
found
}
};
let with_preview = properties.contains(&P::Preview);
for record in wanted {
let text = if with_preview {
let text = preview(server, record.queue_id).await?;
// Reading someone's mail is recorded, as any access is
server
.audit_note(Record {
at: store::write::now() * 1000,
actor: server.audit_actor(access_token).await,
via: access_token.origin().cloned(),
remote_ip: None,
action: Action::BlobAccess,
target: Target {
kind: "inbuxa:HeldMessage".into(),
id: Some(Id::from(record.queue_id).to_string()),
name: Some(record.subject.clone()),
account_id: record.account_id,
tenant_id: record.tenant_id,
},
changes: vec![],
details: Some(format!(
"Read a message held for review, from {}",
record.sender
)),
reason: None,
outcome: Outcome::success(),
})
.await;
text
} else {
None
};
response
.list
.push(to_value(record, &properties, text.as_deref()));
}
Ok(response)
}
fn invalid(property: P, why: &str) -> SetError<P> {
SetError::invalid_properties()
.with_property(property)
.with_description(why.to_string())
}
/// `inbuxa:HeldMessage/set`: update with `decision` release or reject (and
/// an optional `note` for the sender). There is no create or destroy.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, HeldMessage>,
) -> trc::Result<SetResponse<HeldMessage>> {
server_level(access_token)?;
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
let arguments: HeldMessageSetArguments = std::mem::take(&mut request.arguments);
let has_reason = arguments
.reason
.as_deref()
.is_some_and(|r| !r.trim().is_empty());
for (client_id, _) in request.unwrap_create() {
response.not_created.append(
client_id,
SetError::forbidden().with_description("Mail is held by DLP rules, not created."),
);
}
'update: for (id, value) in request.unwrap_update().into_valid() {
let Some(record) = held::get(server.store(), id.id()).await? else {
response.not_updated.append(id, SetError::not_found());
continue;
};
if !has_reason {
response.not_updated.append(
id,
SetError::invalid_properties().with_description(
"Say why: a reason is required and is kept in the audit log.",
),
);
continue;
}
let mut decision = None;
let mut note = None;
for (key, value) in value.into_expanded_object() {
match (&key, value) {
(Key::Property(P::Decision), Value::Str(s)) if s == "release" || s == "reject" => {
decision = Some(s.to_string());
}
(Key::Property(P::Note), Value::Str(s)) => {
let s = s.trim();
if !s.is_empty() {
note = Some(s.chars().take(1000).collect::<String>());
}
}
(Key::Property(P::Note), Value::Null) => {}
_ => {
response.not_updated.append(
id,
invalid(
P::Decision,
"Send decision: \"release\" or \"reject\", and an optional note.",
),
);
continue 'update;
}
}
}
let done = match decision.as_deref() {
Some("release") => smtp::queue::held::release(server, record.queue_id).await?,
Some("reject") => smtp::queue::held::reject(server, &record, note.as_deref()).await?,
_ => {
response
.not_updated
.append(id, invalid(P::Decision, "Say release or reject."));
continue;
}
};
if done {
response.updated.append(id, None);
} else {
response.not_updated.append(
id,
SetError::not_found().with_description("The message is no longer in the queue."),
);
}
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(
id,
SetError::forbidden().with_description("Release or reject it instead."),
);
}
Ok(response)
}
+318
View File
@@ -0,0 +1,318 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:Journal` (journaling spec, JR-9, JR-12, JR-18): journals, seen
//! with `sysJournalGet` and changed with `sysJournalUpdate`, which the
//! request layer checks. Journals are the server's: nobody in a tenant
//! reaches them. The request layer records every change in the audit log.
//! Changing or removing a journal never touches what it has taken.
use common::{Server, auth::AccessToken};
use inbuxa_features::journal::{
self, Journal as Stored,
archive::{self, Failures},
};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_journal::{Journal, JournalProperty as P, JournalValue},
request::IntoValid,
types::date::UTCDate,
};
use jmap_tools::{Key, Map, Property, Value};
use std::borrow::Cow;
use store::write::now;
use types::id::Id;
type JValue = Value<'static, P, JournalValue>;
const ALL: &[P] = &[
P::Id,
P::Name,
P::Description,
P::Enabled,
P::Direction,
P::Scope,
P::RetentionDays,
P::BuiltIn,
P::ArchiveAddress,
P::ArchiveFailures,
P::CreatedBy,
P::CreatedAt,
P::UpdatedAt,
];
/// Properties the server sets; a client that sends them is refused.
const SERVER_SET: &[P] = &[
P::Id,
P::ArchiveFailures,
P::CreatedBy,
P::CreatedAt,
P::UpdatedAt,
];
fn server_level(access_token: &AccessToken) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("Journals are the server's."))
} else {
Ok(())
}
}
fn json_to_value(json: serde_json::Value) -> JValue {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => {
if let Some(n) = n.as_u64() {
Value::Number(n.into())
} else if let Some(n) = n.as_i64() {
Value::Number(n.into())
} else {
Value::Number(n.as_f64().unwrap_or_default().into())
}
}
serde_json::Value::String(s) => Value::Str(Cow::Owned(s)),
serde_json::Value::Array(items) => {
Value::Array(items.into_iter().map(json_to_value).collect())
}
serde_json::Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
for (key, value) in map {
out.insert_unchecked(Key::Owned(key), json_to_value(value));
}
Value::Object(out)
}
}
}
fn date(seconds: u64) -> JValue {
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
}
fn to_value(journal: &Stored, failures: &Failures, properties: &[P]) -> JValue {
let json = serde_json::to_value(journal).unwrap_or_default();
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(JournalValue::Id(Id::from(journal.id))),
P::CreatedAt => date(journal.created_at),
P::UpdatedAt => date(journal.updated_at),
P::ArchiveAddress => journal
.archive_address
.as_ref()
.map_or(Value::Null, |a| Value::Str(a.clone().into())),
// JR-7: what the console warns about
P::ArchiveFailures => {
let mut out = Map::with_capacity(3);
out.insert_unchecked(Key::Borrowed("count"), Value::Number(failures.count.into()));
out.insert_unchecked(
Key::Borrowed("lastAt"),
if failures.count > 0 {
date(failures.last_at)
} else {
Value::Null
},
);
out.insert_unchecked(
Key::Borrowed("lastReason"),
if failures.count > 0 {
Value::Str(failures.last_reason.clone().into())
} else {
Value::Null
},
);
Value::Object(out)
}
other => json
.get(other.to_cow().as_ref())
.cloned()
.map_or(Value::Null, json_to_value),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// A journal as sent: its JSON object, top-level keys only those a client
/// may set.
fn client_json(
value: Value<'_, P, JournalValue>,
) -> Result<serde_json::Map<String, serde_json::Value>, SetError<P>> {
let mut map = serde_json::Map::new();
for (key, value) in value.into_expanded_object() {
match &key {
Key::Property(p) if SERVER_SET.contains(p) => {
return Err(SetError::invalid_properties()
.with_property(p.clone())
.with_description("The server sets this."));
}
Key::Property(p) => {
map.insert(p.to_cow().into_owned(), value.into());
}
_ => {
return Err(SetError::invalid_properties().with_property(key.clone().into_owned()));
}
}
}
Ok(map)
}
fn parse(json: serde_json::Map<String, serde_json::Value>) -> Result<Stored, SetError<P>> {
let journal: Stored =
serde_json::from_value(serde_json::Value::Object(json)).map_err(|err| {
SetError::invalid_properties().with_description(format!("Not a valid journal: {err}"))
})?;
journal.validate().map_err(|invalid| {
let property = invalid.property.parse::<P>().unwrap_or(P::Name);
SetError::invalid_properties()
.with_property(property)
.with_description(invalid.reason)
})?;
Ok(journal)
}
fn journal_id(id: Id) -> Option<u32> {
u32::try_from(id.id()).ok()
}
/// `inbuxa:Journal/get`: every journal, oldest first.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<Journal>,
) -> trc::Result<GetResponse<Journal>> {
server_level(access_token)?;
let properties = request.unwrap_properties(ALL);
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let journals = journal::all(server.store()).await?;
let wanted: Vec<&Stored> = match ids {
None => journals.iter().collect(),
Some(ids) => {
let mut wanted = Vec::with_capacity(ids.len());
for id in ids {
match journal_id(id).and_then(|id| journals.iter().find(|j| j.id == id)) {
Some(journal) => wanted.push(journal),
None => response.push_not_found(id),
}
}
wanted
}
};
for journal in wanted {
let failures = if properties.contains(&P::ArchiveFailures) {
archive::failures(server.store(), journal.id).await?
} else {
Failures::default()
};
response
.list
.push(to_value(journal, &failures, &properties));
}
Ok(response)
}
/// `inbuxa:Journal/set`: create, change or remove journals.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, Journal>,
) -> trc::Result<SetResponse<Journal>> {
server_level(access_token)?;
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
let data = server.store();
let actor = server.audit_actor(access_token).await;
for (client_id, value) in request.unwrap_create() {
let stored = match client_json(value).and_then(parse) {
Ok(stored) => stored,
Err(error) => {
response.not_created.append(client_id, error);
continue;
}
};
let at = now();
let stored = Stored {
created_by: actor.name.clone(),
created_at: at,
updated_at: at,
..stored
};
let id = journal::create(data, &stored).await?;
let mut out = Map::with_capacity(1);
out.insert_unchecked(
Key::Property(P::Id),
Value::Element(JournalValue::Id(Id::from(id))),
);
response.created.insert(client_id, Value::Object(out));
}
for (id, value) in request.unwrap_update().into_valid() {
let Some(current) = (match journal_id(id) {
Some(journal_id) => journal::get(data, journal_id).await?,
None => None,
}) else {
response.not_updated.append(id, SetError::not_found());
continue;
};
// The stored journal, with each property sent replacing its own
let mut json = match serde_json::to_value(&current) {
Ok(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
let changes = match client_json(value) {
Ok(changes) => changes,
Err(error) => {
response.not_updated.append(id, error);
continue;
}
};
json.extend(changes);
let next = match parse(json) {
Ok(next) => next,
Err(error) => {
response.not_updated.append(id, error);
continue;
}
};
let next = Stored {
id: current.id,
created_by: current.created_by.clone(),
created_at: current.created_at,
updated_at: now(),
..next
};
if next != current {
journal::update(data, &next).await?;
}
response.updated.append(id, None);
}
for id in request.unwrap_destroy().into_valid() {
let Some(current) = (match journal_id(id) {
Some(journal_id) => journal::get(data, journal_id).await?,
None => None,
}) else {
response.not_destroyed.append(id, SetError::not_found());
continue;
};
journal::delete(data, current.id).await?;
response.destroyed.push(id);
}
Ok(response)
}
+791
View File
@@ -0,0 +1,791 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The journal over JMAP (journaling spec, JR-6, JR-15 to JR-17):
//!
//! - `inbuxa:JournalEntry/query` and `/get`: searching and reading what was
//! journaled (`sysJournalSearch`). `report` is the whole journal report,
//! only when asked for.
//! - `inbuxa:JournalExport/set`: a ZIP of the reports a filter matches, in
//! the hold export's shape (`sysJournalExport`).
//! - `inbuxa:JournalVerification/set`: rechecks every chain and every report
//! (`sysJournalGet`).
//!
//! Every search, read and export is written to the audit log first; if it
//! can't be, nothing is returned (JR-17). All of it is the server's: nobody
//! in a tenant reaches it.
use common::{Server, auth::AccessToken};
use http_proto::HttpSessionData;
use inbuxa_features::{
audit::{Action, Outcome, Record, Target},
journal::{
Direction,
entries::{self, ChainReport, Entry, EntryId, Filter, MAX_QUERY_LIMIT},
},
};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
query::{Filter as QueryFilter, QueryRequest, QueryResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_journal_entry::{
JournalEntry, JournalEntryProperty as P, JournalEntryValue, JournalExport, JournalFilter,
JournalVerification,
},
request::IntoValid,
types::{date::UTCDate, state::State},
};
use jmap_tools::{Key, Map, Value};
use sha2::{Digest, Sha256};
use std::{
borrow::Cow,
io::{Cursor, Write},
str::FromStr,
};
use types::id::Id;
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
type JValue = Value<'static, P, JournalEntryValue>;
/// Properties a get returns unless asked otherwise: all but the report.
const LISTED: &[P] = &[
P::Id,
P::ReceivedAt,
P::Direction,
P::Sender,
P::Authenticated,
P::Recipients,
P::Subject,
P::MessageId,
P::JournalIds,
P::Held,
P::Size,
P::Sha256,
P::ExpiresAt,
];
/// Most reports one export holds, and most bytes.
const MAX_EXPORT_ENTRIES: usize = 10_000;
const MAX_EXPORT_BYTES: u64 = 1024 * 1024 * 1024;
/// Most of one report `get` returns as text.
const MAX_REPORT_TEXT: usize = 10 * 1024 * 1024;
fn server_level(access_token: &AccessToken) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("The journal is the server's."))
} else {
Ok(())
}
}
fn date(seconds: u64) -> JValue {
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
}
fn text(value: &str) -> JValue {
Value::Str(value.to_string().into())
}
fn json_to_value(json: serde_json::Value) -> JValue {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => match n.as_u64() {
Some(n) => Value::Number(n.into()),
None => Value::Number(n.as_i64().unwrap_or_default().into()),
},
serde_json::Value::String(s) => Value::Str(Cow::Owned(s)),
serde_json::Value::Array(items) => {
Value::Array(items.into_iter().map(json_to_value).collect())
}
serde_json::Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
for (key, value) in map {
out.insert_unchecked(Key::Owned(key), json_to_value(value));
}
Value::Object(out)
}
}
}
fn entry_value(id: EntryId, entry: &Entry, report: Option<&str>, properties: &[P]) -> JValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(JournalEntryValue::Id(Id::new(id.to_u64()))),
P::ReceivedAt => date(entry.at),
P::Direction => text(entry.direction.as_str()),
P::Sender => text(&entry.sender),
P::Authenticated => Value::Bool(entry.authenticated),
P::Recipients => Value::Array(entry.recipients.iter().map(|r| text(r)).collect()),
P::Subject => text(&entry.subject),
P::MessageId => text(&entry.message_id),
P::JournalIds => Value::Array(
entry
.journals
.iter()
.map(|j| text(&Id::from(*j).to_string()))
.collect(),
),
P::Held => Value::Bool(entry.held),
P::Size => Value::Number(entry.size.into()),
P::Sha256 => text(&entry.sha256),
P::ExpiresAt => date(entry.expires_at),
P::Report => report.map_or(Value::Null, text),
_ => Value::Null,
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// Writes a record before anything is returned; an error means nothing
/// may be (JR-17).
async fn record(
server: &Server,
access_token: &AccessToken,
session: &HttpSessionData,
action: Action,
target_id: Option<String>,
target_name: Option<String>,
details: String,
reason: Option<String>,
) -> trc::Result<()> {
server
.audit_append(&Record {
at: store::write::now() * 1000,
actor: server.audit_actor(access_token).await,
via: access_token.origin().cloned(),
remote_ip: Some(session.remote_ip),
action,
target: Target {
kind: "inbuxa:JournalEntry".into(),
id: target_id,
name: target_name,
..Default::default()
},
changes: vec![],
details: Some(details),
reason,
outcome: Outcome::success(),
})
.await
.map(|_| ())
.map_err(|err| {
err.details("The audit log couldn't be written, so the journal wasn't read.")
})
}
async fn report_bytes(server: &Server, entry: &Entry) -> trc::Result<Option<Vec<u8>>> {
match entry.blob_hash() {
Some(hash) => {
server
.blob_store()
.get_blob(hash.as_slice(), 0..usize::MAX)
.await
}
None => Ok(None),
}
}
/// `inbuxa:JournalEntry/get`: the entries named. Listing them is recorded
/// once; each report read is recorded on its own.
pub async fn get(
server: &Server,
access_token: &AccessToken,
session: &HttpSessionData,
mut request: GetRequest<JournalEntry>,
) -> trc::Result<GetResponse<JournalEntry>> {
server_level(access_token)?;
let properties = request.unwrap_properties(LISTED);
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let Some(ids) = ids else {
return Err(trc::JmapEvent::RequestTooLarge
.into_err()
.details("Name the entries to get; use inbuxa:JournalEntry/query to find them."));
};
let mut found = Vec::with_capacity(ids.len());
for id in ids {
let entry_id = EntryId::from_u64(id.id());
match entries::get(server.store(), entry_id).await? {
Some(entry) => found.push((entry_id, entry)),
None => response.push_not_found(id),
}
}
if found.is_empty() {
return Ok(response);
}
let with_report = properties.contains(&P::Report);
if !with_report {
record(
server,
access_token,
session,
Action::BlobAccess,
None,
None,
format!("Listed {} journal entries", found.len()),
None,
)
.await?;
}
for (entry_id, entry) in found {
let report = if with_report {
record(
server,
access_token,
session,
Action::BlobAccess,
Some(Id::new(entry_id.to_u64()).to_string()),
Some(entry.subject.clone()),
format!("Read a journaled message from {}", entry.sender),
None,
)
.await?;
report_bytes(server, &entry).await?.map(|bytes| {
let end = bytes.len().min(MAX_REPORT_TEXT);
String::from_utf8_lossy(&bytes[..end]).into_owned()
})
} else {
None
};
response.list.push(entry_value(
entry_id,
&entry,
report.as_deref(),
&properties,
));
}
Ok(response)
}
fn seconds(value: &str) -> Result<u64, String> {
UTCDate::from_str(value)
.map(|date| date.timestamp().max(0) as u64)
.map_err(|_| format!("{value} isn't a UTC date."))
}
fn direction(value: &str) -> Result<Direction, String> {
match value {
"outgoing" => Ok(Direction::Outgoing),
"incoming" => Ok(Direction::Incoming),
"internal" => Ok(Direction::Internal),
"any" => Ok(Direction::Any),
other => Err(format!("{other} isn't a direction.")),
}
}
/// The conditions of a query filter, all of which must hold. `Or` and
/// `Not` aren't supported.
fn build_filter(conditions: Vec<QueryFilter<JournalFilter>>) -> trc::Result<Filter> {
let unsupported = |why: String| trc::JmapEvent::UnsupportedFilter.into_err().details(why);
let mut filter = Filter::default();
for condition in conditions {
match condition {
QueryFilter::Property(condition) => match condition {
JournalFilter::After(date) => {
filter.after = Some(seconds(&date).map_err(unsupported)?)
}
JournalFilter::Before(date) => {
filter.before = Some(seconds(&date).map_err(unsupported)?)
}
JournalFilter::Sender(s) => filter.sender = Some(s),
JournalFilter::Recipient(r) => filter.recipient = Some(r),
JournalFilter::Address(a) => filter.address = Some(a),
JournalFilter::Direction(d) => {
filter.direction = Some(direction(&d).map_err(unsupported)?)
}
JournalFilter::Text(t) => filter.text = Some(t),
JournalFilter::MessageId(m) => filter.message_id = Some(m),
JournalFilter::JournalId(id) => filter.journal_id = Some(id.document_id()),
JournalFilter::_T(other) => {
return Err(unsupported(format!("Unknown filter property {other}.")));
}
},
QueryFilter::And | QueryFilter::Close => {}
QueryFilter::Or | QueryFilter::Not => {
return Err(unsupported(
"Journal searches take conditions that must all hold; OR and NOT aren't \
supported."
.into(),
));
}
}
}
Ok(filter)
}
fn filter_text(filter: &Filter) -> String {
serde_json::to_string(filter).unwrap_or_default()
}
/// `inbuxa:JournalEntry/query`: newest first. The search is recorded, with
/// its terms, before anything is returned.
pub async fn query(
server: &Server,
access_token: &AccessToken,
session: &HttpSessionData,
request: QueryRequest<JournalEntry>,
) -> trc::Result<QueryResponse> {
server_level(access_token)?;
let filter = build_filter(request.filter)?;
let position = request.position.unwrap_or(0);
if position < 0 || request.anchor.is_some() {
return Err(trc::JmapEvent::UnsupportedFilter
.into_err()
.details("Journal searches page by a position from the start."));
}
let limit = request
.limit
.unwrap_or(MAX_QUERY_LIMIT)
.min(MAX_QUERY_LIMIT);
let count_all = request.calculate_total.unwrap_or(false);
record(
server,
access_token,
session,
Action::BlobAccess,
None,
None,
format!("Searched the journal: {}", filter_text(&filter)),
None,
)
.await?;
let (ids, total) =
entries::query(server.store(), &filter, position as usize, limit, count_all).await?;
Ok(QueryResponse {
account_id: request.account_id,
query_state: State::Initial,
can_calculate_changes: false,
position,
ids: ids.into_iter().map(|id| Id::new(id.to_u64())).collect(),
total: count_all.then_some(total),
limit: Some(limit),
})
}
/// An export's filter, as sent: the query's conditions in one object.
fn export_filter(value: Option<Value<'_, P, JournalEntryValue>>) -> Result<Filter, String> {
let json: serde_json::Value = value
.map(Into::into)
.unwrap_or(serde_json::Value::Object(Default::default()));
let serde_json::Value::Object(map) = json else {
return Err("The filter is an object of conditions.".into());
};
let mut filter = Filter::default();
for (key, value) in map {
let text = || {
value
.as_str()
.map(str::to_string)
.ok_or_else(|| format!("{key} is text."))
};
match key.as_str() {
"after" => filter.after = Some(seconds(&text()?)?),
"before" => filter.before = Some(seconds(&text()?)?),
"sender" => filter.sender = Some(text()?),
"recipient" => filter.recipient = Some(text()?),
"address" => filter.address = Some(text()?),
"direction" => filter.direction = Some(direction(&text()?)?),
"text" => filter.text = Some(text()?),
"messageId" => filter.message_id = Some(text()?),
"journalId" => {
filter.journal_id = Some(
Id::from_str(&text()?)
.map_err(|_| "journalId is a journal's id.".to_string())?
.document_id(),
)
}
other => return Err(format!("Unknown filter property {other}.")),
}
}
Ok(filter)
}
fn csv(field: &str) -> String {
if field.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// A ZIP of reports in the hold export's shape: each report as `.eml`,
/// `manifest.csv` with the envelope and a SHA-256 per file, the entries
/// whose report couldn't be read in `exceptions.csv`, and
/// `manifest.sha256` over both. Returns its bytes and how many reports went
/// in.
pub(crate) fn build_zip(
items: &[(EntryId, Entry, Option<Vec<u8>>)],
) -> trc::Result<(Vec<u8>, usize)> {
let fail = |err: zip::result::ZipError| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to write the export")
.reason(err)
};
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let mut manifest = String::from(
"path,receivedAt,direction,sender,recipients,subject,messageId,queueId,size,sha256\n",
);
let mut exceptions = String::from("entry,receivedAt,sender,subject,reason\n");
let mut written = 0u64;
let mut count = 0;
for (id, entry, bytes) in items {
let received = UTCDate::from_timestamp(entry.at as i64).to_string();
let Some(bytes) = bytes else {
exceptions.push_str(&format!(
"{},{},{},{},{}\n",
Id::new(id.to_u64()),
received,
csv(&entry.sender),
csv(&entry.subject),
"The report couldn't be read."
));
continue;
};
written += bytes.len() as u64;
if written > MAX_EXPORT_BYTES {
return Err(trc::StoreEvent::UnexpectedError.into_err().details(
"The reports are larger than one export can hold (1 GB). Narrow the search.",
));
}
let path = format!(
"reports/{}-{:x}.eml",
received.replace(':', ""),
entry.queue_id
);
zip.start_file(path.as_str(), options).map_err(fail)?;
zip.write_all(bytes).map_err(|e| fail(e.into()))?;
manifest.push_str(&format!(
"{},{},{},{},{},{},{},{:x},{},{}\n",
csv(&path),
received,
entry.direction.as_str(),
csv(&entry.sender),
csv(&entry.recipients.join(" ")),
csv(&entry.subject),
csv(&entry.message_id),
entry.queue_id,
bytes.len(),
hex(&Sha256::digest(bytes))
));
count += 1;
}
let manifest_hash = hex(&Sha256::digest(manifest.as_bytes()));
let exceptions_hash = hex(&Sha256::digest(exceptions.as_bytes()));
zip.start_file("manifest.csv", options).map_err(fail)?;
zip.write_all(manifest.as_bytes())
.map_err(|e| fail(e.into()))?;
zip.start_file("exceptions.csv", options).map_err(fail)?;
zip.write_all(exceptions.as_bytes())
.map_err(|e| fail(e.into()))?;
zip.start_file("manifest.sha256", options).map_err(fail)?;
zip.write_all(
format!("{manifest_hash} manifest.csv\n{exceptions_hash} exceptions.csv\n").as_bytes(),
)
.map_err(|e| fail(e.into()))?;
Ok((zip.finish().map_err(fail)?.into_inner(), count))
}
/// `inbuxa:JournalExport/set`: create `{filter, reason}`; the created
/// object names the ZIP's blob (the caller's), its size, how many reports it
/// holds and its SHA-256. A reason is required; the export is recorded
/// before it's built.
pub async fn export_set(
server: &Server,
access_token: &AccessToken,
session: &HttpSessionData,
mut request: SetRequest<'_, JournalExport>,
) -> trc::Result<SetResponse<JournalExport>> {
server_level(access_token)?;
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
for (id, _) in request.unwrap_update().into_valid() {
response.not_updated.append(
id,
SetError::forbidden().with_description("Exports can't be changed."),
);
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(
id,
SetError::forbidden().with_description("Exports aren't kept to destroy."),
);
}
for (client_id, value) in request.unwrap_create() {
let mut filter_value = None;
let mut reason = None;
let mut invalid = None;
for (key, value) in value.into_expanded_object() {
match (&key, value) {
(Key::Property(P::Filter), value) => filter_value = Some(value.into_owned()),
(Key::Property(P::Reason), Value::Str(r)) => {
reason = Some(r.trim().chars().take(500).collect::<String>())
}
_ => {
invalid = Some(SetError::invalid_properties().with_property(key.into_owned()));
break;
}
}
}
if let Some(error) = invalid {
response.not_created.append(client_id, error);
continue;
}
let Some(reason) = reason.filter(|r| !r.is_empty()) else {
response.not_created.append(
client_id,
SetError::invalid_properties()
.with_property(P::Reason)
.with_description(
"Say why: a reason is required and is kept in the audit log.",
),
);
continue;
};
let filter = match export_filter(filter_value) {
Ok(filter) => filter,
Err(why) => {
response.not_created.append(
client_id,
SetError::invalid_properties()
.with_property(P::Filter)
.with_description(why),
);
continue;
}
};
let (ids, total) =
entries::query(server.store(), &filter, 0, MAX_EXPORT_ENTRIES, true).await?;
if total > MAX_EXPORT_ENTRIES {
response.not_created.append(
client_id,
SetError::invalid_properties()
.with_property(P::Filter)
.with_description(format!(
"{total} entries match; one export holds {MAX_EXPORT_ENTRIES}. Narrow the search."
)),
);
continue;
}
// Recorded first: no export leaves without its record
record(
server,
access_token,
session,
Action::Export,
None,
None,
format!(
"Exported {} journal entries: {}",
ids.len(),
filter_text(&filter)
),
Some(reason),
)
.await?;
let mut items = Vec::with_capacity(ids.len());
for id in ids {
if let Some(entry) = entries::get(server.store(), id).await? {
let bytes = report_bytes(server, &entry).await?;
items.push((id, entry, bytes));
}
}
let (bytes, count) = build_zip(&items)?;
let blob = server
.put_jmap_blob(access_token.account_id(), &bytes)
.await?;
let mut created = Map::with_capacity(5);
created.insert_unchecked(
Key::Property(P::Id),
Value::Element(JournalEntryValue::Id(Id::new(store::write::now()))),
);
created.insert_unchecked(
Key::Property(P::BlobId),
Value::Str(blob.to_string().into()),
);
created.insert_unchecked(
Key::Property(P::Size),
Value::Number((bytes.len() as u64).into()),
);
created.insert_unchecked(
Key::Property(P::Count),
Value::Number((count as u64).into()),
);
created.insert_unchecked(
Key::Property(P::Sha256),
Value::Str(hex(&Sha256::digest(&bytes)).into()),
);
response.created.insert(client_id, Value::Object(created));
}
Ok(response)
}
fn summary(chains: &[ChainReport]) -> String {
if chains.is_empty() {
return "The journal is empty.".into();
}
chains
.iter()
.map(|chain| match (&chain.broken_at, &chain.reason) {
(Some(at), Some(reason)) => format!("node {}: broken at {at}: {reason}", chain.node),
_ => format!(
"node {}: {} entries and {} purged verified ({} to {})",
chain.node, chain.entries, chain.purged, chain.first_seq, chain.last_seq
),
})
.collect::<Vec<_>>()
.join("; ")
}
/// `inbuxa:JournalVerification/set`: create `{}` to recheck every node's
/// chain and every report against its entry (JR-6). Recorded, with what it
/// found.
pub async fn verification_set(
server: &Server,
access_token: &AccessToken,
session: &HttpSessionData,
mut request: SetRequest<'_, JournalVerification>,
) -> trc::Result<SetResponse<JournalVerification>> {
server_level(access_token)?;
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
for (id, _) in request.unwrap_update().into_valid() {
response.not_updated.append(id, SetError::forbidden());
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(id, SetError::forbidden());
}
for (client_id, _) in request.unwrap_create() {
let chains = entries::verify(server.store(), Some(server.blob_store())).await?;
let verified = chains.iter().all(|chain| chain.broken_at.is_none());
let entry = server
.audit_append(&Record {
at: store::write::now() * 1000,
actor: server.audit_actor(access_token).await,
via: access_token.origin().cloned(),
remote_ip: Some(session.remote_ip),
action: Action::Verify,
target: Target {
kind: "inbuxa:JournalEntry".into(),
..Default::default()
},
changes: vec![],
details: Some(summary(&chains)),
reason: None,
outcome: if verified {
Outcome::success()
} else {
Outcome::refused("chainBroken", None)
},
})
.await
.ok();
let mut created = Map::with_capacity(3);
created.insert_unchecked(
Key::Property(P::Id),
Value::Element(JournalEntryValue::Id(Id::new(
entry.map_or(0, |entry| entry.to_u64()),
))),
);
created.insert_unchecked(Key::Property(P::Verified), Value::Bool(verified));
created.insert_unchecked(
Key::Property(P::Chains),
json_to_value(serde_json::to_value(&chains).unwrap_or_default()),
);
response.created.insert(client_id, Value::Object(created));
}
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(queue_id: u64) -> Entry {
Entry {
queue_id,
at: 1_790_000_000,
direction: Direction::Outgoing,
sender: "[email protected]".into(),
authenticated: true,
recipients: vec!["[email protected]".into()],
subject: "Q3, final".into(),
message_id: "<[email protected]>".into(),
accounts: vec![],
tenants: vec![],
journals: vec![1],
held: false,
blob: String::new(),
size: 0,
sha256: String::new(),
expires_at: 0,
}
}
#[test]
fn exports_list_every_report_and_what_was_missing() {
let items = vec![
(
EntryId { node: 1, seq: 1 },
entry(0x1a),
Some(b"report one".to_vec()),
),
(EntryId { node: 1, seq: 2 }, entry(0x1b), None),
];
let (bytes, count) = build_zip(&items).unwrap();
assert_eq!(count, 1);
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut read = |name: &str| {
let mut out = String::new();
std::io::Read::read_to_string(&mut zip.by_name(name).unwrap(), &mut out).unwrap();
out
};
let manifest = read("manifest.csv");
assert!(manifest.contains("\"Q3, final\""), "{manifest}");
assert!(manifest.contains(&hex(&Sha256::digest(b"report one"))));
assert!(read("exceptions.csv").contains("couldn't be read"));
let sums = read("manifest.sha256");
assert!(sums.contains(&hex(&Sha256::digest(manifest.as_bytes()))));
}
#[test]
fn export_filters_parse() {
let filter: Value<'_, P, JournalEntryValue> = json_to_value(serde_json::json!({
"sender": "alice", "direction": "outgoing", "journalId": "b",
"after": "2026-09-01T00:00:00Z"
}));
let filter = export_filter(Some(filter)).unwrap();
assert_eq!(filter.sender.as_deref(), Some("alice"));
assert_eq!(filter.direction, Some(Direction::Outgoing));
assert_eq!(filter.journal_id, Some(1));
assert!(filter.after.is_some());
let bad: Value<'_, P, JournalEntryValue> =
json_to_value(serde_json::json!({"colour": "red"}));
assert!(export_filter(Some(bad)).is_err());
}
}
+327
View File
@@ -0,0 +1,327 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:MailRule` (dlp-and-mail-flow-rules spec, §2.2, §2.8): mail flow
//! rules and DLP rules. One object, two kinds, each with its own
//! permissions: `sysMailRuleGet`/`Update` for transport rules,
//! `sysDlpPolicyGet`/`Update` for DLP rules. Rules are the server's: nobody
//! in a tenant reaches them (settled answer 3). The request layer records
//! every change in the audit log.
use common::{Server, auth::AccessToken};
use inbuxa_features::mailflow::rules::{self, Kind, Rule};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_mail_rule::{MailRule, MailRuleProperty as P, MailRuleValue},
request::IntoValid,
types::date::UTCDate,
};
use jmap_tools::{Key, Map, Property, Value};
use registry::schema::enums::Permission;
use std::borrow::Cow;
use store::write::now;
use types::id::Id;
type RValue = Value<'static, P, MailRuleValue>;
const ALL: &[P] = &[
P::Id,
P::Name,
P::Description,
P::Kind,
P::Enabled,
P::Priority,
P::Direction,
P::Conditions,
P::Exceptions,
P::Actions,
P::StopProcessing,
P::CreatedBy,
P::CreatedAt,
P::UpdatedAt,
];
/// Properties the server sets; a client that sends them is refused.
const SERVER_SET: &[P] = &[P::Id, P::CreatedBy, P::CreatedAt, P::UpdatedAt];
fn can_see(access_token: &AccessToken, kind: Kind) -> bool {
access_token.has_permission(match kind {
Kind::Dlp => Permission::SysDlpPolicyGet,
Kind::Transport => Permission::SysMailRuleGet,
})
}
fn can_change(access_token: &AccessToken, kind: Kind) -> bool {
access_token.has_permission(match kind {
Kind::Dlp => Permission::SysDlpPolicyUpdate,
Kind::Transport => Permission::SysMailRuleUpdate,
})
}
fn server_level(access_token: &AccessToken) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("Mail rules are the server's."))
} else {
Ok(())
}
}
fn json_to_value(json: serde_json::Value) -> RValue {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => {
if let Some(n) = n.as_u64() {
Value::Number(n.into())
} else if let Some(n) = n.as_i64() {
Value::Number(n.into())
} else {
Value::Number(n.as_f64().unwrap_or_default().into())
}
}
serde_json::Value::String(s) => Value::Str(Cow::Owned(s)),
serde_json::Value::Array(items) => {
Value::Array(items.into_iter().map(json_to_value).collect())
}
serde_json::Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
for (key, value) in map {
out.insert_unchecked(Key::Owned(key), json_to_value(value));
}
Value::Object(out)
}
}
}
fn date(seconds: u64) -> RValue {
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
}
fn to_value(rule: &Rule, properties: &[P]) -> RValue {
let json = serde_json::to_value(rule).unwrap_or_default();
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(MailRuleValue::Id(Id::from(rule.id))),
P::CreatedAt => date(rule.created_at),
P::UpdatedAt => date(rule.updated_at),
other => json
.get(other.to_cow().as_ref())
.cloned()
.map_or(Value::Null, json_to_value),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// A rule as sent: its JSON object, top-level keys only those a client may
/// set.
fn client_json(value: Value<'_, P, MailRuleValue>) -> Result<serde_json::Map<String, serde_json::Value>, SetError<P>> {
let mut map = serde_json::Map::new();
for (key, value) in value.into_expanded_object() {
match &key {
Key::Property(p) if SERVER_SET.contains(p) => {
return Err(SetError::invalid_properties()
.with_property(p.clone())
.with_description("The server sets this."));
}
Key::Property(p) => {
map.insert(p.to_cow().into_owned(), value.into());
}
_ => {
return Err(SetError::invalid_properties().with_property(key.clone().into_owned()));
}
}
}
Ok(map)
}
fn parse(json: serde_json::Map<String, serde_json::Value>) -> Result<Rule, SetError<P>> {
let rule: Rule = serde_json::from_value(serde_json::Value::Object(json)).map_err(|err| {
SetError::invalid_properties().with_description(format!("Not a valid rule: {err}"))
})?;
rule.validate().map_err(|invalid| {
let property = invalid.property.parse::<P>().unwrap_or(P::Name);
SetError::invalid_properties()
.with_property(property)
.with_description(invalid.reason)
})?;
Ok(rule)
}
fn forbidden(kind: Kind) -> SetError<P> {
SetError::forbidden().with_description(match kind {
Kind::Dlp => "Changing DLP rules needs the permission to change DLP rules.",
Kind::Transport => "Changing mail flow rules needs the permission to change them.",
})
}
fn rule_id(id: Id) -> Option<u32> {
u32::try_from(id.id()).ok()
}
/// `inbuxa:MailRule/get`: the rules the caller may see, in the order they
/// run.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<MailRule>,
) -> trc::Result<GetResponse<MailRule>> {
server_level(access_token)?;
let properties = request.unwrap_properties(ALL);
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let visible: Vec<Rule> = rules::all(server.store())
.await?
.into_iter()
.filter(|rule| can_see(access_token, rule.kind))
.collect();
match ids {
None => {
response.list = visible
.iter()
.map(|rule| to_value(rule, &properties))
.collect()
}
Some(ids) => {
for id in ids {
match rule_id(id).and_then(|id| visible.iter().find(|r| r.id == id)) {
Some(rule) => response.list.push(to_value(rule, &properties)),
None => response.push_not_found(id),
}
}
}
}
Ok(response)
}
/// `inbuxa:MailRule/set`: create, change or delete rules, each checked
/// against the permissions for its kind (and, on a change of kind, both).
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, MailRule>,
) -> trc::Result<SetResponse<MailRule>> {
server_level(access_token)?;
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
let data = server.store();
let actor = server.audit_actor(access_token).await;
for (client_id, value) in request.unwrap_create() {
let rule = match client_json(value).and_then(parse) {
Ok(rule) => rule,
Err(error) => {
response.not_created.append(client_id, error);
continue;
}
};
if !can_change(access_token, rule.kind) {
response.not_created.append(client_id, forbidden(rule.kind));
continue;
}
let at = now();
let rule = Rule {
created_by: actor.name.clone(),
created_at: at,
updated_at: at,
..rule
};
let id = rules::create(data, &rule).await?;
let mut out = Map::with_capacity(1);
out.insert_unchecked(
Key::Property(P::Id),
Value::Element(MailRuleValue::Id(Id::from(id))),
);
response.created.insert(client_id, Value::Object(out));
}
for (id, value) in request.unwrap_update().into_valid() {
let Some(current) = (match rule_id(id) {
Some(rule_id) => rules::get(data, rule_id).await?,
None => None,
}) else {
response.not_updated.append(id, SetError::not_found());
continue;
};
if !can_see(access_token, current.kind) {
response.not_updated.append(id, SetError::not_found());
continue;
}
if !can_change(access_token, current.kind) {
response.not_updated.append(id, forbidden(current.kind));
continue;
}
// The stored rule, with each property sent replacing its own
let mut json = match serde_json::to_value(&current) {
Ok(serde_json::Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
let changes = match client_json(value) {
Ok(changes) => changes,
Err(error) => {
response.not_updated.append(id, error);
continue;
}
};
json.extend(changes);
let next = match parse(json) {
Ok(next) => next,
Err(error) => {
response.not_updated.append(id, error);
continue;
}
};
if next.kind != current.kind && !can_change(access_token, next.kind) {
response.not_updated.append(id, forbidden(next.kind));
continue;
}
let next = Rule {
id: current.id,
created_by: current.created_by.clone(),
created_at: current.created_at,
updated_at: now(),
..next
};
if next != current {
rules::update(data, &next).await?;
}
response.updated.append(id, None);
}
for id in request.unwrap_destroy().into_valid() {
let Some(current) = (match rule_id(id) {
Some(rule_id) => rules::get(data, rule_id).await?,
None => None,
}) else {
response.not_destroyed.append(id, SetError::not_found());
continue;
};
if !can_see(access_token, current.kind) {
response.not_destroyed.append(id, SetError::not_found());
continue;
}
if !can_change(access_token, current.kind) {
response.not_destroyed.append(id, forbidden(current.kind));
continue;
}
rules::delete(data, current.id).await?;
response.destroyed.push(id);
}
Ok(response)
}
+7
View File
@@ -10,6 +10,12 @@
pub mod access;
pub mod account_lock;
pub mod legal_hold;
pub mod mail_rule;
pub mod security_acceptance;
pub mod journal;
pub mod journal_entry;
pub mod held_message;
pub mod dlp_settings;
pub mod hold_export;
pub mod hold_export_api;
pub mod audit;
@@ -18,6 +24,7 @@ pub mod ai_limits;
pub mod log_settings;
pub mod data_inventory;
pub mod directory_test;
pub mod webhook_test;
pub mod explanation;
pub mod protocol_policy;
pub mod tenant_protocol_policy;
@@ -0,0 +1,257 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:SecurityAcceptance` (security to-do list spec, SS-23 to SS-26):
//! the security to-do items an administrator accepted, with why, so every
//! administrator sees the same accepted risks. Created and destroyed, never
//! updated (the request gate refuses an update). Seeing them needs what the
//! security page needs; changing them needs `sysSecurityAccept`. Every
//! check is server-wide, so nobody in a tenant reaches them. The request
//! layer records every change in the audit log (SS-26).
use common::{Server, auth::AccessToken};
use inbuxa_features::security::acceptance::{self, Acceptance, Created};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_security_acceptance::{
SecurityAcceptance, SecurityAcceptanceProperty as P, SecurityAcceptanceValue,
},
request::IntoValid,
types::date::UTCDate,
};
use jmap_tools::{Key, Map, Property, Value};
use std::borrow::Cow;
use store::write::now;
use types::id::Id;
type RValue = Value<'static, P, SecurityAcceptanceValue>;
const ALL: &[P] = &[
P::Id,
P::Check,
P::Subject,
P::AcceptedValue,
P::Note,
P::AcceptedBy,
P::AcceptedAt,
];
/// Properties the server sets; a client that sends them is refused.
const SERVER_SET: &[P] = &[P::Id, P::AcceptedBy, P::AcceptedAt];
fn server_level(access_token: &AccessToken) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("Security checks are the server's."))
} else {
Ok(())
}
}
fn json_to_value(json: serde_json::Value) -> RValue {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => {
if let Some(n) = n.as_u64() {
Value::Number(n.into())
} else if let Some(n) = n.as_i64() {
Value::Number(n.into())
} else {
Value::Number(n.as_f64().unwrap_or_default().into())
}
}
serde_json::Value::String(s) => Value::Str(Cow::Owned(s)),
serde_json::Value::Array(items) => {
Value::Array(items.into_iter().map(json_to_value).collect())
}
serde_json::Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
for (key, value) in map {
out.insert_unchecked(Key::Owned(key), json_to_value(value));
}
Value::Object(out)
}
}
}
fn to_value(acceptance: &Acceptance, properties: &[P]) -> RValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(SecurityAcceptanceValue::Id(Id::from(acceptance.id))),
P::Check => Value::Str(acceptance.check.clone().into()),
P::Subject => Value::Str(acceptance.subject.clone().into()),
P::AcceptedValue => json_to_value(acceptance.accepted_value.clone()),
P::Note => Value::Str(acceptance.note.clone().into()),
P::AcceptedBy => Value::Str(acceptance.accepted_by.clone().into()),
P::AcceptedAt => Value::Str(
UTCDate::from_timestamp(acceptance.accepted_at as i64)
.to_string()
.into(),
),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// An acceptance as sent, checked whole.
fn parse(value: Value<'_, P, SecurityAcceptanceValue>) -> Result<Acceptance, SetError<P>> {
let mut map = serde_json::Map::new();
for (key, value) in value.into_expanded_object() {
match &key {
Key::Property(p) if SERVER_SET.contains(p) => {
return Err(SetError::invalid_properties()
.with_property(p.clone())
.with_description("The server sets this."));
}
Key::Property(p) => {
map.insert(p.to_cow().into_owned(), value.into());
}
_ => {
return Err(SetError::invalid_properties().with_property(key.clone().into_owned()));
}
}
}
let acceptance: Acceptance =
serde_json::from_value(serde_json::Value::Object(map)).map_err(|err| {
SetError::invalid_properties()
.with_description(format!("Not a valid acceptance: {err}"))
})?;
acceptance.validate().map_err(|invalid| {
let property = invalid.property.parse::<P>().unwrap_or(P::Note);
SetError::invalid_properties()
.with_property(property)
.with_description(invalid.reason)
})?;
Ok(Acceptance {
note: acceptance.note.trim().to_string(),
..acceptance
})
}
fn acceptance_id(id: Id) -> Option<u32> {
u32::try_from(id.id()).ok()
}
/// `inbuxa:SecurityAcceptance/get`: every acceptance, oldest first.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<SecurityAcceptance>,
) -> trc::Result<GetResponse<SecurityAcceptance>> {
server_level(access_token)?;
let properties = request.unwrap_properties(ALL);
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let all = acceptance::all(server.store()).await?;
match ids {
None => {
response.list = all.iter().map(|a| to_value(a, &properties)).collect();
}
Some(ids) => {
for id in ids {
match acceptance_id(id).and_then(|id| all.iter().find(|a| a.id == id)) {
Some(a) => response.list.push(to_value(a, &properties)),
None => response.push_not_found(id),
}
}
}
}
Ok(response)
}
/// `inbuxa:SecurityAcceptance/set`: accept an item, or remove an acceptance.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, SecurityAcceptance>,
) -> trc::Result<SetResponse<SecurityAcceptance>> {
server_level(access_token)?;
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
let data = server.store();
let actor = server.audit_actor(access_token).await;
for (client_id, value) in request.unwrap_create() {
let parsed = match parse(value) {
Ok(parsed) => parsed,
Err(error) => {
response.not_created.append(client_id, error);
continue;
}
};
let accepted = Acceptance {
accepted_by: actor.name.clone(),
accepted_at: now(),
..parsed
};
match acceptance::create(data, &accepted).await? {
Created::Id(id) => {
let mut out = Map::with_capacity(3);
out.insert_unchecked(
Key::Property(P::Id),
Value::Element(SecurityAcceptanceValue::Id(Id::from(id))),
);
out.insert_unchecked(
Key::Property(P::AcceptedBy),
Value::Str(accepted.accepted_by.clone().into()),
);
out.insert_unchecked(
Key::Property(P::AcceptedAt),
Value::Str(
UTCDate::from_timestamp(accepted.accepted_at as i64)
.to_string()
.into(),
),
);
response.created.insert(client_id, Value::Object(out));
}
Created::Full => {
response.not_created.append(
client_id,
SetError::over_quota().with_description(format!(
"There are already {} acceptances. Remove some first.",
acceptance::MAX_ACCEPTANCES
)),
);
}
}
}
for (id, _) in request.unwrap_update().into_valid() {
response.not_updated.append(
id,
SetError::forbidden().with_description("An acceptance is replaced, not edited."),
);
}
for id in request.unwrap_destroy().into_valid() {
let found = match acceptance_id(id) {
Some(acceptance_id) => acceptance::get(data, acceptance_id).await?,
None => None,
};
match found {
Some(found) => {
acceptance::delete(data, found.id).await?;
response.destroyed.push(id);
}
None => response.not_destroyed.append(id, SetError::not_found()),
}
}
Ok(response)
}
+61
View File
@@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `POST /api/webhook/test`: send one sample event to a saved webhook
//! (settings-reorg, Webhooks "Send test").
//!
//! ```json
//! {"webhookId": "b"}
//! ```
//!
//! The answer is `{"sent": true, "status": 200, "ms": 84}` when the receiver
//! answered 2xx, `{"sent": false, "status": 403, …}` when it answered
//! otherwise, and `{"sent": false, "error": "…"}` when nothing came back. The
//! webhook is used as saved, even when it's off, so it can be tried before
//! it's switched on. The request goes where the saved webhook already sends,
//! so this gives nobody a reach they didn't have.
//!
//! For server-level administrators who may change webhooks.
use common::{Server, auth::AccessToken};
use registry::schema::{enums::Permission, structs::WebHook};
use serde_json::{Value, json};
use std::{str::FromStr, time::Instant};
use types::id::Id;
pub fn assert_allowed(access_token: &AccessToken) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
return Err(trc::JmapEvent::Forbidden
.into_err()
.details("Webhook tests are for server-level administrators."));
}
access_token.enforce_permission(Permission::SysWebHookUpdate)
}
pub async fn test(server: &Server, body: &Value) -> trc::Result<Value> {
let webhook_id = body
.get("webhookId")
.and_then(Value::as_str)
.and_then(|id| Id::from_str(id).ok())
.ok_or_else(|| {
trc::ResourceEvent::BadParameters
.into_err()
.details("Expected {\"webhookId\": …}")
})?;
let Some(hook) = server.registry().object::<WebHook>(webhook_id).await? else {
return Ok(json!({ "sent": false, "error": "There's no such webhook. Save it first." }));
};
let started = Instant::now();
Ok(match common::telemetry::webhooks::send_test(&hook).await {
Ok(status) => json!({
"sent": (200..300).contains(&status),
"status": status,
"ms": started.elapsed().as_millis() as u64,
}),
Err(error) => json!({ "sent": false, "error": error }),
})
}
+9
View File
@@ -146,6 +146,15 @@ pub(crate) async fn log_query(
})?;
response.anchor_found = true;
// inbuxa: the total is only known when the first page reached the end
// of the logs; counting them all would mean reading every file on every
// page. Upstream answered the query cap (5000) as the total, so a
// two-line log read "of 5000".
response.response.total = (req.request.calculate_total.unwrap_or(false)
&& anchor == 0
&& response.response.ids.len() < limit)
.then_some(response.response.ids.len());
Ok(response)
}
@@ -49,6 +49,12 @@ use trc::AddContext;
use types::{blob::BlobId, blob_hash::BlobHash, id::Id};
use utils::map::vec_map::VecMap;
/// inbuxa: held mail is the review queue's to decide.
fn held_refusal() -> SetError<Property> {
SetError::forbidden()
.with_description("This message is held for review: release or reject it under Compliance, Held mail.")
}
pub(crate) async fn queued_message_set(
mut set: RegistrySetResponse<'_>,
) -> trc::Result<RegistrySetResponse<'_>> {
@@ -66,6 +72,12 @@ pub(crate) async fn queued_message_set(
let mut refresh_queue = false;
'outer: for (id, value) in set.update.drain(..) {
let queue_id = id.id();
// inbuxa: held mail is released or rejected by review, not here
// (dlp-and-mail-flow-rules spec, §2.6)
if inbuxa_features::mailflow::held::is_held(set.server.store(), queue_id).await? {
set.response.not_updated.append(id, held_refusal());
continue;
}
let Some(archive) = set.server.read_message_archive(queue_id).await? else {
set.response.not_updated.append(id, SetError::not_found());
continue;
@@ -238,6 +250,11 @@ pub(crate) async fn queued_message_set(
// Process destroy operations
for id in set.destroy.drain(..) {
// inbuxa: §2.6, as above
if inbuxa_features::mailflow::held::is_held(set.server.store(), id.id()).await? {
set.response.not_destroyed.append(id, held_refusal());
continue;
}
let Some(message) = set.server.read_message(id.id(), QueueName::default()).await else {
set.response.not_destroyed.append(id, SetError::not_found());
continue;
+66 -2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use common::{
@@ -16,7 +18,7 @@ use email::{
submission::{Address, Delivered, DeliveryStatus, EmailSubmission, UndoStatus},
};
use jmap_proto::{
error::set::{SetError, SetErrorType},
error::set::{DlpRule, SetError, SetErrorType},
method::set::{SetRequest, SetResponse},
object::email_submission::{self, EmailSubmissionProperty, EmailSubmissionValue},
references::resolve::ResolveCreatedReference,
@@ -89,6 +91,13 @@ impl EmailSubmissionSet for Server {
);
let send_at = submission.send_at;
// inbuxa: DLP (§2.6): the sender learns it's held
let held = match submission.queue_id {
Some(queue_id) => {
inbuxa_features::mailflow::held::is_held(self.store(), queue_id).await?
}
None => false,
};
let undo_status = match submission.undo_status {
UndoStatus::Pending => email_submission::UndoStatus::Pending,
UndoStatus::Final => email_submission::UndoStatus::Final,
@@ -126,7 +135,8 @@ impl EmailSubmissionSet for Server {
.with_key_value(
EmailSubmissionProperty::UndoStatus,
Value::Element(EmailSubmissionValue::UndoStatus(undo_status)),
),
)
.with_key_value(EmailSubmissionProperty::DlpHeld, Value::Bool(held)),
),
);
}
@@ -212,6 +222,17 @@ impl EmailSubmissionSet for Server {
}
match undo_status {
// inbuxa: held for review: the review decides, not an unsend
// (dlp-and-mail-flow-rules spec, §2.6)
Some(email_submission::UndoStatus::Canceled)
if inbuxa_features::mailflow::held::is_held(self.store(), queue_id).await? =>
{
response.not_updated.append(
id,
SetError::new(SetErrorType::CannotUnsend)
.with_description("The message is held for review and can't be unsent."),
);
}
Some(email_submission::UndoStatus::Canceled) => {
if let Some(queue_message) =
self.read_message(queue_id, QueueName::default()).await
@@ -379,6 +400,8 @@ impl EmailSubmissionSet for Server {
};
let mut mail_from: Option<MailFrom<Cow<'_, str>>> = None;
let mut rcpt_to: Vec<RcptTo<Cow<'_, str>>> = Vec::new();
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5)
let mut dlp_override: Option<String> = None;
for (property, mut value) in object.into_expanded_object() {
if let Err(err) = response.resolve_self_references(&mut value, 0, false) {
@@ -493,6 +516,25 @@ impl EmailSubmissionSet for Server {
(Key::Property(EmailSubmissionProperty::UndoStatus), Value::Element(_)) => {
continue;
}
// inbuxa: the sender's reason to send despite a DLP warning
(Key::Property(EmailSubmissionProperty::DlpOverride), Value::Object(value)) => {
let reason = value
.iter()
.find(|(key, _)| key.to_string() == "reason")
.and_then(|(_, value)| value.as_str().map(|r| r.trim().to_string()))
.filter(|r| !r.is_empty());
match reason {
Some(reason) => dlp_override = Some(reason.chars().take(500).collect()),
None => {
return Ok(Err(SetError::invalid_properties()
.with_property(EmailSubmissionProperty::DlpOverride)
.with_description("An override needs a reason.")));
}
}
}
(Key::Property(EmailSubmissionProperty::DlpOverride), Value::Null) => {
continue;
}
_ => {
return Ok(Err(SetError::invalid_properties()
.with_property(property.into_owned())
@@ -700,6 +742,7 @@ impl EmailSubmissionSet for Server {
0,
),
);
session.data.dlp_override = dlp_override;
// Spawn SMTP session to avoid overflowing the stack
let handle = tokio::spawn(async move {
@@ -730,6 +773,27 @@ impl EmailSubmissionSet for Server {
let response = session.queue_message().await;
if let smtp::core::State::Accepted(queue_id) = session.state {
Ok((responses, Some(queue_id)))
} else if let Some(refusal) = session.data.dlp_refusal.take() {
// inbuxa: DLP (§2.5): which rules, and what they say
let description = refusal
.rules
.iter()
.map(|(_, notice)| notice.as_str())
.collect::<Vec<_>>()
.join(" ");
Err(SetError::new(if refusal.blocked {
SetErrorType::DlpBlocked
} else {
SetErrorType::DlpWarning
})
.with_description(description)
.with_dlp_rules(
refusal
.rules
.into_iter()
.map(|(name, notice)| DlpRule { name, notice })
.collect(),
))
} else {
Err(
SetError::new(SetErrorType::ForbiddenToSend).with_description(format!(
+14
View File
@@ -1748,6 +1748,20 @@ pub enum Permission {
SysLegalHoldExport = 672,
// inbuxa: personal-data catalog, the data inventory and compliance overview
SysComplianceGet = 673,
// inbuxa: DLP and mail flow rules
SysMailRuleGet = 674,
SysMailRuleUpdate = 675,
SysDlpPolicyGet = 676,
SysDlpPolicyUpdate = 677,
SysDlpReviewGet = 678,
SysDlpReviewUpdate = 679,
// inbuxa: journaling
SysJournalGet = 680,
SysJournalUpdate = 681,
SysJournalSearch = 682,
SysJournalExport = 683,
// inbuxa: the security to-do list, accepting an item
SysSecurityAccept = 684,
SysAccountGet = 219,
SysAccountCreate = 220,
SysAccountUpdate = 221,
+34 -1
View File
@@ -7091,6 +7091,17 @@ impl EnumImpl for Permission {
b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate,
b"sysLegalHoldExport" => Permission::SysLegalHoldExport,
b"sysComplianceGet" => Permission::SysComplianceGet,
b"sysMailRuleGet" => Permission::SysMailRuleGet,
b"sysMailRuleUpdate" => Permission::SysMailRuleUpdate,
b"sysDlpPolicyGet" => Permission::SysDlpPolicyGet,
b"sysDlpPolicyUpdate" => Permission::SysDlpPolicyUpdate,
b"sysDlpReviewGet" => Permission::SysDlpReviewGet,
b"sysDlpReviewUpdate" => Permission::SysDlpReviewUpdate,
b"sysJournalGet" => Permission::SysJournalGet,
b"sysJournalUpdate" => Permission::SysJournalUpdate,
b"sysJournalSearch" => Permission::SysJournalSearch,
b"sysJournalExport" => Permission::SysJournalExport,
b"sysSecurityAccept" => Permission::SysSecurityAccept,
b"sysAccountGet" => Permission::SysAccountGet,
b"sysAccountCreate" => Permission::SysAccountCreate,
b"sysAccountUpdate" => Permission::SysAccountUpdate,
@@ -7781,6 +7792,17 @@ impl EnumImpl for Permission {
Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate",
Permission::SysLegalHoldExport => "sysLegalHoldExport",
Permission::SysComplianceGet => "sysComplianceGet",
Permission::SysMailRuleGet => "sysMailRuleGet",
Permission::SysMailRuleUpdate => "sysMailRuleUpdate",
Permission::SysDlpPolicyGet => "sysDlpPolicyGet",
Permission::SysDlpPolicyUpdate => "sysDlpPolicyUpdate",
Permission::SysDlpReviewGet => "sysDlpReviewGet",
Permission::SysDlpReviewUpdate => "sysDlpReviewUpdate",
Permission::SysJournalGet => "sysJournalGet",
Permission::SysJournalUpdate => "sysJournalUpdate",
Permission::SysJournalSearch => "sysJournalSearch",
Permission::SysJournalExport => "sysJournalExport",
Permission::SysSecurityAccept => "sysSecurityAccept",
Permission::SysAccountGet => "sysAccountGet",
Permission::SysAccountCreate => "sysAccountCreate",
Permission::SysAccountUpdate => "sysAccountUpdate",
@@ -8464,6 +8486,17 @@ impl EnumImpl for Permission {
671 => Some(Permission::SysLegalHoldUpdate),
672 => Some(Permission::SysLegalHoldExport),
673 => Some(Permission::SysComplianceGet),
674 => Some(Permission::SysMailRuleGet),
675 => Some(Permission::SysMailRuleUpdate),
676 => Some(Permission::SysDlpPolicyGet),
677 => Some(Permission::SysDlpPolicyUpdate),
678 => Some(Permission::SysDlpReviewGet),
679 => Some(Permission::SysDlpReviewUpdate),
680 => Some(Permission::SysJournalGet),
681 => Some(Permission::SysJournalUpdate),
682 => Some(Permission::SysJournalSearch),
683 => Some(Permission::SysJournalExport),
684 => Some(Permission::SysSecurityAccept),
219 => Some(Permission::SysAccountGet),
220 => Some(Permission::SysAccountCreate),
221 => Some(Permission::SysAccountUpdate),
@@ -8908,7 +8941,7 @@ impl EnumImpl for Permission {
}
}
const COUNT: usize = 674;
const COUNT: usize = 685;
}
impl serde::Serialize for Permission {
@@ -286,6 +286,17 @@ async fn store_maintenance(
trc::error!(err.details("Failed to purge expired IP bans"));
}
// inbuxa: DLP, §2.6: mail nobody reviewed in time goes back
if let Err(err) = smtp::queue::held::expire(server).await {
trc::error!(err.details("Failed to return unreviewed held mail"));
}
// inbuxa: journaling, JR-13: entries past their retention go,
// except those a legal hold keeps
if let Err(err) = purge_journal(server).await {
trc::error!(err.details("Failed to purge journal entries"));
}
// inbuxa: AU-7: audit records past their retention go; a
// failure leaves them for the next run
if let Err(err) = server.audit_purge().await {
@@ -404,6 +415,54 @@ async fn store_maintenance(
Ok(TaskResult::Success(vec![]))
}
/// inbuxa: journaling, JR-13: removes journal entries past their
/// retention, keeping any whose sender or recipients a legal hold covers
/// (deleted accounts a hold keeps included), and records how many went.
async fn purge_journal(server: &Server) -> trc::Result<()> {
use inbuxa_features::audit::{Action, Actor, Outcome, Record, Target};
let mut held = server.held_accounts().await?;
if !held.is_empty() {
for (account_id, kept) in
inbuxa_features::undelete::data::kept_accounts(server.store()).await?
{
if server.is_kept_held(account_id, &kept).await? {
held.insert(account_id);
}
}
}
let at = store::write::now();
let purged = inbuxa_features::journal::entries::purge(server.store(), at, |entry| {
entry.accounts.iter().any(|account| held.contains(account))
})
.await?;
if purged.removed > 0 || purged.kept_for_hold > 0 {
server
.audit_note(Record {
at: at * 1000,
actor: Actor::system("Journal"),
via: None,
remote_ip: None,
action: Action::Destroy,
target: Target {
kind: "inbuxa:JournalEntry".into(),
id: None,
name: None,
account_id: None,
tenant_id: None,
},
changes: vec![],
details: Some(format!(
"{} past their retention removed; {} kept for a legal hold",
purged.removed, purged.kept_for_hold
)),
reason: None,
outcome: Outcome::success(),
})
.await;
}
Ok(())
}
async fn account_maintenance(
server: &Server,
task: &TaskAccountMaintenance,
@@ -46,6 +46,8 @@ enum Event {
StoreMetrics,
// inbuxa: MON-25: alert evaluation
EvaluateAlerts,
// inbuxa: settings-reorg: probe the other nodes' ports
ProbePeerPorts,
}
/// When the next metric-history tick is due (MON-4), read from the registry
@@ -95,6 +97,11 @@ pub fn spawn_task_scheduler(inner: Arc<Inner>) {
Instant::now() + server.registry().refresh_node_id_interval(),
Event::RenewNodeIdLease,
);
// inbuxa: first round a minute after start, once the others have a lease
queue.schedule(
Instant::now() + Duration::from_secs(60),
Event::ProbePeerPorts,
);
}
// Spam classifier training
@@ -229,6 +236,19 @@ pub fn spawn_task_scheduler(inner: Arc<Inner>) {
}
});
}
Event::ProbePeerPorts => {
queue.schedule(
Instant::now() + common::reachability::PROBE_INTERVAL,
Event::ProbePeerPorts,
);
let server = server.clone();
tokio::spawn(async move {
if let Err(err) = common::reachability::probe_peers(&server).await {
trc::error!(err.details("Failed to probe the other nodes' ports"));
}
});
}
Event::OtelMetrics => {
if let Some(otel) = &server.core.metrics.otel {
queue.schedule(Instant::now() + otel.interval, Event::OtelMetrics);
@@ -476,6 +496,7 @@ impl Event {
Event::RenewNodeIdLease => "renewNodeIdLease",
Event::StoreMetrics => "storeMetrics",
Event::EvaluateAlerts => "evaluateAlerts",
Event::ProbePeerPorts => "probePeerPorts",
}
}
}
+32
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{inbound::auth::SaslToken, queue::QueueId};
@@ -92,6 +94,26 @@ pub struct SessionData {
pub spf_ehlo: Option<SpfOutput>,
pub spf_mail_from: Option<SpfOutput>,
pub dnsbl_error: Option<Vec<u8>>,
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): the reason a JMAP
// sender gave to send despite a warning, and why DATA refused a
// message, for the submission to report
pub dlp_override: Option<String>,
pub dlp_refusal: Option<DlpRefusal>,
// inbuxa: a mail flow rule's route for this message
pub mailflow_queue: Option<String>,
// inbuxa: journaling (JR-3, JR-10): journals rules sent this message
// to, and recipients rules added, by rule name
pub journal_marks: Vec<u32>,
pub journal_added: Vec<(String, String)>,
}
/// inbuxa: a DATA refusal by DLP rules: blocked, or a warning the sender
/// may override, with each rule's name and notice.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DlpRefusal {
pub blocked: bool,
pub rules: Vec<(String, String)>,
}
#[derive(Clone, Debug)]
@@ -168,6 +190,11 @@ impl SessionData {
spf_ehlo: None,
spf_mail_from: None,
dnsbl_error: None,
dlp_override: None,
dlp_refusal: None,
mailflow_queue: None,
journal_marks: Vec::new(),
journal_added: Vec::new(),
}
}
}
@@ -291,6 +318,11 @@ impl SessionData {
spf_ehlo: None,
spf_mail_from: None,
dnsbl_error: None,
dlp_override: None,
dlp_refusal: None,
mailflow_queue: None,
journal_marks: Vec::new(),
journal_added: Vec::new(),
}
}
}
+86 -5
View File
@@ -738,6 +738,58 @@ impl<T: SessionStream> Session<T> {
}
}
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.1): after the system
// script, before headers and signing
let mut held_draft = None;
let (message, envelope) = match self
.check_mail_rules(edited_message.as_deref().unwrap_or(raw_message.as_slice()))
.await
{
super::mailflow::Checked::Accept => (None, Vec::new()),
super::mailflow::Checked::Changed { message, envelope } => (message, envelope),
// §2.6: queued, but not due for a century; a reviewer releases it
super::mailflow::Checked::Hold { draft, message, envelope } => {
self.data.future_release = inbuxa_features::mailflow::held::HOLD_SECONDS;
held_draft = Some(draft);
(message, envelope)
}
super::mailflow::Checked::Refuse(reply, refusal) => {
self.data.dlp_refusal = refusal;
return reply.into();
}
};
if let Some(message) = message {
edited_message = Some(message);
}
for change in envelope {
match change {
super::mailflow::EnvelopeChange::AddRecipient(address, rule) => {
if !self
.data
.rcpt_to
.iter()
.any(|r| r.address_lcase.eq_ignore_ascii_case(&address))
{
self.data.journal_added.push((address.to_lowercase(), rule));
self.data.rcpt_to.push(SessionAddress::new(address));
}
}
super::mailflow::EnvelopeChange::Redirect(addresses, rule) => {
self.data.journal_added = addresses
.iter()
.map(|a| (a.to_lowercase(), rule.clone()))
.collect();
self.data.rcpt_to = addresses.into_iter().map(SessionAddress::new).collect();
}
super::mailflow::EnvelopeChange::Journal(journal) => {
self.data.journal_marks.push(journal);
}
super::mailflow::EnvelopeChange::Route(queue) => {
self.data.mailflow_queue = Some(queue);
}
}
}
// Build message
let mail_from = self.data.mail_from.clone().unwrap();
let rcpt_to = std::mem::take(&mut self.data.rcpt_to);
@@ -817,6 +869,19 @@ impl<T: SessionStream> Session<T> {
.server
.eval_signers(&ac.dkim.sign, self, self.data.session_id)
.await;
// inbuxa: §2.6, who the held message is from and to
let held_envelope = held_draft.as_ref().map(|_| {
(
message.message.return_path.to_string(),
message
.message
.recipients
.iter()
.map(|r| r.address.to_string())
.collect::<Vec<_>>(),
message.message.size,
)
});
if message
.queue(
QueueParams::new(raw_message, self.data.session_id, &self.server)
@@ -825,15 +890,27 @@ impl<T: SessionStream> Session<T> {
.with_dkim_signers(dkim_signers)
.with_original_raw_message(original_message)
.with_original_authenticated_message(auth_message)
.with_metadata(metadata),
.with_metadata(metadata)
.with_journal(
std::mem::take(&mut self.data.journal_marks),
std::mem::take(&mut self.data.journal_added),
),
)
.await
{
self.state = State::Accepted(queue_id);
self.data.messages_sent += 1;
if let (Some(draft), Some((sender, recipients, size))) = (held_draft, held_envelope)
{
self.record_held(queue_id, draft, sender, recipients, size).await;
format!("250 2.0.0 Held for review, id {queue_id:x}.\r\n")
.into_bytes()
.into()
} else {
format!("250 2.0.0 Message queued with id {queue_id:x}.\r\n")
.into_bytes()
.into()
}
} else {
(b"451 4.3.5 Unable to accept message at this time.\r\n"[..]).into()
}
@@ -903,8 +980,10 @@ impl<T: SessionStream> Session<T> {
};
// Resolve queue
let queue = self.server.get_queue_or_default(
&self
// inbuxa: a mail flow rule's route comes before the strategy
let queue_name = match &self.data.mailflow_queue {
Some(queue) => queue.clone(),
None => self
.server
.eval_if::<String, _>(
&self.server.core.smtp.queue.queue,
@@ -913,8 +992,10 @@ impl<T: SessionStream> Session<T> {
)
.await
.unwrap_or_else(|| "default".to_string()),
self.data.session_id,
);
};
let queue = self
.server
.get_queue_or_default(&queue_name, self.data.session_id);
// Set expiration and notification times
let num_intervals = std::cmp::max(queue.notify.len(), 1);
+674
View File
@@ -0,0 +1,674 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: DLP at DATA (dlp-and-mail-flow-rules spec, §2.1, §2.4–§2.7).
//!
//! Runs after the DATA system script and before headers and DKIM signing,
//! on mail an authenticated sender submits over SMTP or JMAP. The rules and
//! the detectors are `inbuxa_features::mailflow`; this is the glue: build
//! what they look at from the message, apply the decision, record it.
use crate::core::{DlpRefusal, Session};
use common::network::SessionStream;
use inbuxa_features::{
audit::{Action, Actor, Outcome, Record, Target},
mailflow::{
cache,
engine::{
Attachment, Content, Decision, Envelope, Outcome as RulesOutcome, Recipient, RuleRef,
},
extract::{self, Extracted, Limits},
held::{self, Held, HeldRule, KEEP_DAYS},
rewrite,
rules::{Action as RuleAction, Kind},
},
};
use mail_parser::{Message, MessageParser, MimeHeaders, PartType};
use std::{borrow::Cow, time::SystemTime};
/// How much text one message is read for; past it, the rest counts as
/// "can't be inspected" (§2.3).
const INSPECTION_LIMIT: usize = 10 * 1024 * 1024;
/// What the check decided.
pub enum Checked {
/// Go on, with the message unchanged.
Accept,
/// Go on, with a changed message (the override tag taken out, a
/// disclaimer, headers, the subject) and envelope.
Changed {
message: Option<Vec<u8>>,
envelope: Vec<EnvelopeChange>,
},
/// Refuse, with this SMTP reply, and for a JMAP submission, why.
Refuse(Vec<u8>, Option<DlpRefusal>),
/// Queue it held for review (§2.6), with any transport changes.
Hold {
draft: HeldDraft,
message: Option<Vec<u8>>,
envelope: Vec<EnvelopeChange>,
},
}
/// What the review record will say, once the message has a queue id.
pub struct HeldDraft {
pub subject: String,
pub rules: Vec<HeldRule>,
pub counts: Vec<(String, usize)>,
pub notify_sender: bool,
}
/// What a transport rule changes about where a message goes.
pub enum EnvelopeChange {
/// An address, and the rule that added it.
AddRecipient(String, String),
Redirect(Vec<String>, String),
Route(String),
/// Journaling spec, JR-10: a journal the message goes to.
Journal(u32),
}
/// `[override: reason]` at the start of a subject: the reason, and the
/// subject without it.
pub fn override_tag(subject: &str) -> Option<(String, String)> {
let trimmed = subject.trim_start();
let head = trimmed.get(..10)?;
if !head.eq_ignore_ascii_case("[override:") {
return None;
}
let close = trimmed.find(']')?;
let reason = trimmed[10..close].trim();
if reason.is_empty() {
return None;
}
Some((
reason.chars().take(500).collect(),
trimmed[close + 1..].trim_start().to_string(),
))
}
/// One line of an SMTP reply: no line breaks, a sane length.
fn reply_text(text: &str) -> String {
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(400)
.collect()
}
fn notices(rules: &[RuleRef]) -> String {
let mut seen = Vec::new();
for rule in rules {
let notice = reply_text(&rule.notice);
if !seen.contains(&notice) {
seen.push(notice);
}
}
seen.join(" ")
}
fn refusal(blocked: bool, rules: &[RuleRef]) -> DlpRefusal {
DlpRefusal {
blocked,
rules: rules
.iter()
.map(|r| (r.name.clone(), r.notice.clone()))
.collect(),
}
}
/// A message and the messages attached to it, one level down.
fn collect<'x>(message: &'x Message<'x>, content: &mut Content<'x>, budget: &mut usize, depth: u8) {
let add = |text: Cow<'x, str>, content: &mut Content<'x>, budget: &mut usize| {
if *budget == 0 {
content.truncated = true;
return;
}
if text.len() > *budget {
let mut cut = *budget;
while !text.is_char_boundary(cut) {
cut -= 1;
}
content.bodies.push(Cow::Owned(text[..cut].to_string()));
content.truncated = true;
*budget = 0;
} else {
*budget -= text.len();
content.bodies.push(text);
}
};
// The text version of each body (an HTML-only one converted), not both
// versions of the same alternative, so words aren't counted twice
for part in message.text_bodies() {
match &part.body {
PartType::Text(text) => add(Cow::Borrowed(text.as_ref()), content, budget),
PartType::Html(html) => add(
Cow::Owned(mail_parser::decoders::html::html_to_text(html)),
content,
budget,
),
_ => {}
}
}
for part in message.attachments() {
if let (Some(inner), true) = (part.message(), depth == 0) {
if let Some(subject) = inner.subject() {
add(Cow::Borrowed(subject), content, budget);
}
collect(inner, content, budget, depth + 1);
continue;
}
let content_type = part
.content_type()
.map(|ct| match ct.subtype() {
Some(sub) => format!("{}/{}", ct.ctype(), sub),
None => ct.ctype().to_string(),
})
.unwrap_or_default();
let bytes = part.contents();
let mut extracted = extract::extract(
&content_type,
part.attachment_name(),
bytes,
&Limits::default(),
);
if let Extracted::Text(text) = &extracted {
if text.len() > *budget {
extracted = Extracted::NotInspectable(extract::Why::TooLarge);
} else {
*budget -= text.len();
}
}
content.attachments.push(Attachment {
name: part.attachment_name(),
content_type: content_type.into(),
size: bytes.len() as u64,
extracted,
});
}
}
impl<T: SessionStream> Session<T> {
/// DLP on an outgoing message (§2.4). `message` is what the DATA stage
/// has so far (the script's replacement, if it made one).
pub async fn check_mail_rules(&self, message: &[u8]) -> Checked {
// Outgoing: an authenticated sender. DLP rules check outgoing mail
// only (settled); transport rules may check either
let sender = self
.data
.authenticated_as
.as_ref()
.map(|s| (s.account_id, s.account.clone()));
let outgoing = sender.is_some();
let rules = match cache::compiled(self.server.store()).await {
Ok(rules) => rules,
Err(err) => {
trc::error!(
err.span_id(self.data.session_id)
.caused_by(trc::location!())
.details("Failed to load mail rules")
);
// Fail closed: a message nobody could check doesn't leave
return Checked::Refuse(
b"451 4.3.0 This message couldn't be checked against the server's rules. Try again later.\r\n"
.to_vec(),
None,
);
}
};
if !rules.applies_to(outgoing) {
return Checked::Accept;
}
let parsed = MessageParser::new().parse(message);
let subject = parsed
.as_ref()
.and_then(|m| m.subject())
.unwrap_or_default();
let jmap_override = self.data.dlp_override.clone();
// Only a sender of ours can override
let tag = if outgoing {
override_tag(subject)
} else {
None
};
let checked_subject = tag.as_ref().map_or(subject, |(_, rest)| rest.as_str());
let held_subject = checked_subject.to_string();
let mut content = Content {
subject: checked_subject,
size: message.len() as u64,
..Default::default()
};
let mut budget = INSPECTION_LIMIT;
match &parsed {
Some(parsed) => {
content.headers = parsed
.headers()
.iter()
.filter_map(|h| h.value.as_text().map(|v| (h.name.as_str(), v)))
.collect();
collect(parsed, &mut content, &mut budget, 0);
}
// Nothing a rule could read: say so, rather than pass it
None => content.truncated = true,
}
let mut recipient_groups = Vec::with_capacity(self.data.rcpt_to.len());
for rcpt in &self.data.rcpt_to {
let local = self
.server
.domain(&rcpt.domain)
.await
.ok()
.flatten()
.is_some();
let groups = if local {
match self
.server
.account_id_from_email(&rcpt.address_lcase, false)
.await
{
Ok(Some(id)) => self
.server
.account(id)
.await
.map(|a| a.id_member_of.to_vec())
.unwrap_or_default(),
_ => Vec::new(),
}
} else {
Vec::new()
};
recipient_groups.push((local, groups));
}
let sender_address = self
.data
.mail_from
.as_ref()
.map(|m| m.address_lcase.clone())
.unwrap_or_default();
let envelope = Envelope {
outgoing,
sender: &sender_address,
sender_groups: sender
.as_ref()
.map_or(&[][..], |(_, a)| &a.id_member_of[..]),
sender_tenant: sender.as_ref().and_then(|(_, a)| a.id_tenant),
recipients: self
.data
.rcpt_to
.iter()
.zip(&recipient_groups)
.map(|(rcpt, (local, groups))| Recipient {
address: &rcpt.address_lcase,
local: *local,
groups,
})
.collect(),
};
let outcome = rules.evaluate(&envelope, &content);
let override_reason =
jmap_override.or_else(|| tag.as_ref().map(|(reason, _)| reason.clone()));
let decision = outcome.decision(override_reason.is_some());
let mut domains: Vec<&str> = self
.data
.rcpt_to
.iter()
.map(|r| r.domain.as_str())
.collect();
domains.sort_unstable();
domains.dedup();
let domains = domains.join(", ");
drop(envelope);
if let Some((account_id, account)) = &sender {
self.record_dlp(
*account_id,
account,
&outcome,
&decision,
override_reason.as_deref(),
&domains,
)
.await;
}
let hold = match decision {
Decision::Block(rules) => {
let refusal = refusal(true, &rules);
return Checked::Refuse(
format!("550 5.7.1 {}\r\n", notices(&rules)).into_bytes(),
Some(refusal),
);
}
Decision::Warn(rules) => {
return Checked::Refuse(
format!(
"550 5.7.1 {} To send anyway, start the subject with [override: your reason]\r\n",
notices(&rules)
)
.into_bytes(),
Some(refusal(false, &rules)),
);
}
// Accepted and queued, but not sent until a reviewer says so
// (§2.6); the transport rules still apply, so what's released
// is what would have gone out
Decision::Hold {
rules,
notify_sender,
} => Some(HeldDraft {
subject: held_subject,
rules: rules
.iter()
.map(|r| HeldRule {
name: r.name.clone(),
notice: r.notice.clone(),
})
.collect(),
counts: outcome
.matched
.iter()
.filter(|m| m.kind == Kind::Dlp)
.flat_map(|m| m.counts.iter().cloned())
.collect(),
notify_sender,
}),
Decision::Pass => None,
};
{
{
// The tag was an instruction to the server, not part of the
// subject: it doesn't go out
let mut current: Option<Vec<u8>> =
tag.map(|(_, rest)| rewrite::set_subject(message, &rest));
let mut changes = Vec::new();
for matched in outcome.matched.iter().filter(|m| m.kind == Kind::Transport) {
for action in &matched.actions {
let now = current.as_deref().unwrap_or(message);
let next = match action {
RuleAction::AddDisclaimer {
text,
html,
position,
} => rewrite::add_disclaimer(now, text, html.as_deref(), *position),
RuleAction::AddHeader { name, value } => {
Some(rewrite::add_header(now, name, value))
}
RuleAction::RemoveHeader { name } => rewrite::remove_header(now, name),
RuleAction::PrefixSubject { text } => {
rewrite::prefix_subject(now, text)
}
RuleAction::AddRecipient { address } => {
changes.push(EnvelopeChange::AddRecipient(
address.clone(),
matched.name.clone(),
));
None
}
RuleAction::Redirect { addresses } => {
changes.push(EnvelopeChange::Redirect(
addresses.clone(),
matched.name.clone(),
));
None
}
RuleAction::Route { queue } => {
changes.push(EnvelopeChange::Route(queue.clone()));
None
}
RuleAction::Refuse { text } => {
self.record_transport(&sender, &matched.name, "refused", &domains)
.await;
return Checked::Refuse(
format!("550 5.7.1 {}\r\n", reply_text(text)).into_bytes(),
None,
);
}
RuleAction::Block { .. }
| RuleAction::Warn { .. }
| RuleAction::Hold { .. }
| RuleAction::Journal { .. } => None,
};
if next.is_some() {
current = next;
}
}
// Where mail goes is recorded; wording and headers aren't,
// or a banner rule would write a record for every message
let routed: Vec<String> = matched
.actions
.iter()
.filter_map(|a| match a {
RuleAction::AddRecipient { address } => {
Some(format!("copied to {address}"))
}
RuleAction::Redirect { addresses } => {
Some(format!("redirected to {}", addresses.join(", ")))
}
RuleAction::Route { queue } => Some(format!("routed through {queue}")),
_ => None,
})
.collect();
if !routed.is_empty() {
self.record_transport(&sender, &matched.name, &routed.join(", "), &domains)
.await;
}
}
// JR-10: journals any matched rule sends the message to,
// DLP rules included
for matched in &outcome.matched {
for action in &matched.actions {
if let RuleAction::Journal { journal } = action
&& !changes
.iter()
.any(|c| matches!(c, EnvelopeChange::Journal(j) if j == journal))
{
changes.push(EnvelopeChange::Journal(*journal));
}
}
}
match hold {
Some(draft) => Checked::Hold {
draft,
message: current,
envelope: changes,
},
None if current.is_none() && changes.is_empty() => Checked::Accept,
None => Checked::Changed {
message: current,
envelope: changes,
},
}
}
}
}
/// Writes the review record for a message just queued held (§2.6),
/// and tells the sender when the rule asks. A failure to write it is
/// logged: the message stays held, never sent unreviewed.
pub async fn record_held(
&self,
queue_id: u64,
draft: HeldDraft,
sender: String,
recipients: Vec<String>,
size: u64,
) {
let at = store::write::now();
let keep_days = held::settings(self.server.store())
.await
.map_or(KEEP_DAYS, |s| s.keep_held_days);
let account = self.data.authenticated_as.as_ref();
let record = Held {
queue_id,
sender,
account_id: account.map(|a| a.account_id),
tenant_id: account.and_then(|a| a.account.id_tenant),
recipients,
subject: draft.subject,
size,
rules: draft.rules,
counts: draft.counts,
held_at: at,
expires_at: at + keep_days * 86_400,
keep_days,
};
if let Err(err) = held::create(self.server.store(), &record).await {
trc::error!(
err.span_id(self.data.session_id)
.caused_by(trc::location!())
.details("Failed to write the review record of a held message")
);
return;
}
if draft.notify_sender {
crate::queue::held::notify_held(&self.server, &record).await;
}
}
/// A transport rule that refused a message or changed where it goes
/// (§2.7): who sent it (or the server, for incoming mail), the rule,
/// what it did.
async fn record_transport(
&self,
sender: &Option<(u32, std::sync::Arc<common::auth::AccountCache>)>,
rule: &str,
what: &str,
domains: &str,
) {
let (actor, account_id, tenant_id) = match sender {
Some((id, account)) => (
Actor::account(*id, account.name.to_string(), account.id_tenant),
Some(*id),
account.id_tenant,
),
None => (Actor::system("mail-flow"), None, None),
};
let at = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64);
self.server
.audit_note(Record {
at,
actor,
via: None,
remote_ip: Some(self.data.remote_ip),
action: Action::Create,
target: Target {
kind: "message".into(),
id: None,
name: None,
account_id,
tenant_id,
},
changes: vec![],
details: Some(format!("Mail flow rule \"{rule}\" {what}, to {domains}")),
reason: None,
outcome: if what == "refused" {
Outcome::refused("forbidden", None)
} else {
Outcome::success()
},
})
.await;
}
/// One audit record per message a DLP rule matched (§2.7): who sent it,
/// where to, which rules and each detector's count, what happened, and
/// an override's reason. Never the matched text.
async fn record_dlp(
&self,
account_id: u32,
account: &common::auth::AccountCache,
outcome: &RulesOutcome,
decision: &Decision,
override_reason: Option<&str>,
domains: &str,
) {
let dlp: Vec<_> = outcome
.matched
.iter()
.filter(|m| m.kind == Kind::Dlp)
.collect();
if dlp.is_empty() {
return;
}
let rules = dlp
.iter()
.map(|m| {
let counts = m
.counts
.iter()
.map(|(id, n)| format!("{id} {n}"))
.collect::<Vec<_>>()
.join(", ");
if counts.is_empty() {
format!("\"{}\"", m.name)
} else {
format!("\"{}\" ({counts})", m.name)
}
})
.collect::<Vec<_>>()
.join("; ");
let (what, outcome, reason) = match decision {
Decision::Hold { .. } => ("held for review", Outcome::success(), None),
Decision::Block(_) => ("blocked", Outcome::refused("inbuxa:dlpBlocked", None), None),
Decision::Warn(_) => ("warned", Outcome::refused("inbuxa:dlpWarning", None), None),
Decision::Pass => (
"sent after a warning",
Outcome::success(),
override_reason.map(str::to_string),
),
};
let at = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64);
self.server
.audit_note(Record {
at,
actor: Actor::account(account_id, account.name.to_string(), account.id_tenant),
via: None,
remote_ip: Some(self.data.remote_ip),
action: Action::Create,
target: Target {
kind: "message".into(),
id: None,
name: None,
account_id: Some(account_id),
tenant_id: account.id_tenant,
},
changes: vec![],
details: Some(format!("DLP {what}, to {domains}: {rules}")),
reason,
outcome,
})
.await;
}
}
#[cfg(test)]
mod tests {
use super::override_tag;
#[test]
fn override_tags() {
assert_eq!(
override_tag("[override: client asked for it] Card details"),
Some(("client asked for it".into(), "Card details".into()))
);
assert_eq!(
override_tag(" [OVERRIDE:yes]x"),
Some(("yes".into(), "x".into()))
);
assert_eq!(override_tag("[override: ] x"), None);
assert_eq!(override_tag("Re: [override: no] x"), None);
assert_eq!(override_tag("[override: unclosed"), None);
assert_eq!(override_tag(""), None);
}
}
+3
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use mail_auth::{DkimResult, DmarcResult, IprevResult, SpfResult, dmarc::Policy};
@@ -13,6 +15,7 @@ pub mod dkim;
pub mod ehlo;
pub mod hooks;
pub mod mail;
pub mod mailflow; // inbuxa: DLP and mail flow rules
pub mod milter;
pub mod rcpt;
pub mod session;
+4
View File
@@ -494,6 +494,10 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
self.data.delivery_by = 0;
self.data.future_release = 0;
self.data.rcpt_oks = 0;
// inbuxa: what mail flow rules decided was for the last message only
self.data.mailflow_queue = None;
self.data.journal_marks.clear();
self.data.journal_added.clear();
}
pub fn reset_tls(&mut self) {
+180
View File
@@ -0,0 +1,180 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: mail held for review (dlp-and-mail-flow-rules spec, §2.6):
//! releasing it, rejecting it, rejecting what nobody reviewed in time, and
//! the notices the sender gets.
//!
//! A held message sits in the queue with its release [`HOLD_SECONDS`] off.
//! Releasing it undoes exactly that: each recipient due now, its next
//! notice as far from now as it was from its retry, its lifetime counted
//! from the release.
use crate::{
queue::{Message, MessageWrapper, Status, spool::SmtpSpool},
reporting::send::MtaReportSend,
};
use common::{
Server,
config::smtp::queue::{QueueExpiry, QueueName},
ipc::QueueEvent,
};
use inbuxa_features::{
audit::{Action, Actor, Outcome, Record, Target},
mailflow::held::{self, HOLD_SECONDS, Held},
};
use mail_builder::{
MessageBuilder,
headers::{HeaderType, address::Address},
};
use store::{ahash::AHashSet, write::now};
/// Puts a held message back on its way. False when it's no longer queued.
pub async fn release(server: &Server, queue_id: u64) -> trc::Result<bool> {
let Some(archive) = server.read_message_archive(queue_id).await? else {
held::delete(server.store(), queue_id).await?;
return Ok(false);
};
let mut message: Message = archive.to_unarchived::<Message>()?.deserialize()?;
let prev_events = message.next_events();
let at = now();
let mut modified = AHashSet::new();
for (idx, rcpt) in message.recipients.iter_mut().enumerate() {
if !matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) {
continue;
}
let notify_gap = rcpt.notify.due.saturating_sub(rcpt.retry.due);
rcpt.retry.due = at;
rcpt.notify.due = at + notify_gap;
if let QueueExpiry::Ttl(ttl) = rcpt.expires {
rcpt.expires = QueueExpiry::Ttl(
ttl.saturating_sub(HOLD_SECONDS) + at.saturating_sub(message.created),
);
}
modified.insert(idx);
}
let saved = MessageWrapper::new(message, queue_id, QueueName::default())
.save_registry_changes(server, prev_events, modified)
.await;
held::delete(server.store(), queue_id).await?;
let _ = server.inner.ipc.queue_tx.send(QueueEvent::Refresh).await;
Ok(saved)
}
/// Takes a held message out of the queue and tells its sender, with the
/// reviewer's note if there is one. False when it's no longer queued.
pub async fn reject(server: &Server, record: &Held, note: Option<&str>) -> trc::Result<bool> {
let removed = match server
.read_message(record.queue_id, QueueName::default())
.await
{
Some(message) => message.remove(server, None).await,
None => false,
};
held::delete(server.store(), record.queue_id).await?;
let mut text = format!(
"Your message \"{}\" to {} was held for review under this server's rules, and wasn't sent.\r\n",
record.subject,
record.recipients.join(", ")
);
match note {
Some(note) => text.push_str(&format!("\r\nThe reviewer's note: {note}\r\n")),
None => text.push_str(&format!(
"\r\nNobody reviewed it within {} days, so it was returned.\r\n",
record.keep_days
)),
}
notify(
server,
record,
&format!("Not sent: {}", record.subject),
text,
)
.await;
let _ = server.inner.ipc.queue_tx.send(QueueEvent::Refresh).await;
Ok(removed)
}
/// Tells the sender their message is held (when the rule asks).
pub async fn notify_held(server: &Server, record: &Held) {
let notices = record
.rules
.iter()
.map(|r| r.notice.as_str())
.collect::<Vec<_>>()
.join(" ");
let text = format!(
"Your message \"{}\" to {} is held for review under this server's rules: {notices}\r\n\r\n\
It will be sent if a reviewer releases it, and returned otherwise within {} days.\r\n",
record.subject,
record.recipients.join(", "),
record.keep_days,
);
notify(
server,
record,
&format!("Held for review: {}", record.subject),
text,
)
.await;
}
async fn notify(server: &Server, record: &Held, subject: &str, text: String) {
let domain = record
.sender
.rsplit_once('@')
.map_or("localhost", |(_, d)| d);
let from = format!("postmaster@{domain}");
let message = MessageBuilder::new()
.from(Address::new_address(Some("Mail review"), from.clone()))
.to(Address::new_address(None::<String>, record.sender.clone()))
.subject(subject)
.header("Auto-Submitted", HeaderType::Text("auto-replied".into()))
.text_body(text)
.write_to_vec()
.unwrap_or_default();
server
.send_autogenerated(from, [record.sender.as_str()].into_iter(), message, None, 0)
.await;
}
/// Rejects every held message nobody reviewed in time (§2.6), each
/// recorded as the server's doing. Returns how many.
pub async fn expire(server: &Server) -> trc::Result<usize> {
let at = now();
let mut count = 0;
for record in held::all(server.store()).await? {
if !record.is_expired(at) {
continue;
}
reject(server, &record, None).await?;
count += 1;
server
.audit_note(Record {
at: at * 1000,
actor: Actor::system("DLP"),
via: None,
remote_ip: None,
action: Action::Destroy,
target: Target {
kind: "inbuxa:HeldMessage".into(),
id: Some(record.queue_id.to_string()),
name: Some(record.subject.clone()),
account_id: record.account_id,
tenant_id: record.tenant_id,
},
changes: vec![],
details: Some(format!(
"Rejected: nobody reviewed it within {} days; the sender was told",
record.keep_days
)),
reason: None,
outcome: Outcome::success(),
})
.await;
}
Ok(count)
}
+280
View File
@@ -0,0 +1,280 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: journaling (journaling spec, JR-1 to JR-11): the copy taken as a
//! message is queued, after DLP and transport rules, so it has the envelope
//! the message actually leaves or arrives with; reports to outside archives,
//! and what happens when an archive doesn't take one.
use crate::queue::{
FROM_AUTHENTICATED, FROM_AUTOGENERATED, FROM_DSN, FROM_REPORT, Message, MessageSource, Status,
spool::{QueueParams, SmtpSpool},
};
use common::Server;
use inbuxa_features::{
audit::{Action, Actor, Outcome, Record, Target},
hold::Member,
journal::{
self, Direction,
archive::{self, Pending},
entries::{self, Entry},
report::{self, Envelope, Recipient},
},
mailflow::held::HOLD_SECONDS,
};
use store::write::{BatchBuilder, BlobLink, BlobOp, now};
use types::blob_hash::BlobHash;
/// What mail flow rules decided about a message at DATA (JR-3, JR-10).
#[derive(Debug, Clone, Default)]
pub struct Hints {
/// Journals a rule sent it to.
pub marks: Vec<u32>,
/// Recipients a rule added (lowercase), and the rule's name.
pub added: Vec<(String, String)>,
}
/// Marks a journal report the server queued itself, so it's never
/// journaled (JR-2). Free in the message flags (the MAIL parameters use
/// the low bits, the sources bits 32 to 37).
pub const FROM_JOURNAL: u64 = 1 << 48;
/// Journals `message`, whose queued bytes are `raw`, into every enabled
/// journal that takes it. An error means it may not have been journaled,
/// and the caller must not queue it.
pub async fn capture(
server: &Server,
queue_id: u64,
message: &Message,
raw: &[u8],
hints: &Hints,
) -> trc::Result<()> {
if message.flags & (FROM_JOURNAL | FROM_REPORT) != 0 {
return Ok(());
}
let journals = journal::enabled(server.store()).await?;
if journals.is_empty() {
return Ok(());
}
// Who's here on either side, and which way it goes
let mut members: Vec<Member> = Vec::new();
let mut sender_local = message.flags & FROM_AUTHENTICATED != 0
|| (message.return_path.is_empty() && message.flags & (FROM_DSN | FROM_AUTOGENERATED) != 0);
if !message.return_path.is_empty()
&& let Some(id) = server
.account_id_from_email(&message.return_path, false)
.await?
{
sender_local = true;
if let Some(member) = server.member_of(id).await {
members.push(member);
}
}
let (mut any_local, mut any_remote) = (false, false);
for rcpt in &message.recipients {
let address = rcpt.address.to_lowercase();
let domain = address.rsplit_once('@').map_or("", |(_, d)| d);
let local_domain = server.domain(domain).await.ok().flatten().is_some();
match server.account_id_from_email(&address, false).await? {
Some(id) => {
any_local = true;
if !members.iter().any(|m| m.account == id)
&& let Some(member) = server.member_of(id).await
{
members.push(member);
}
}
None if local_domain => any_local = true,
None => any_remote = true,
}
}
let direction = Direction::of(sender_local, any_remote, any_local);
// A journal takes it through its scope, or because a rule sent it there
let taken: Vec<&journal::Journal> = journals
.iter()
.filter(|j| j.takes(direction, &members) || hints.marks.contains(&j.id))
.collect();
if taken.is_empty() {
return Ok(());
}
// DLP holds a message by putting its release a century off
let at = now();
let held = !message.recipients.is_empty()
&& message
.recipients
.iter()
.all(|rcpt| rcpt.retry.due >= at + HOLD_SECONDS / 2);
let recipients: Vec<Recipient> = message
.recipients
.iter()
.map(|rcpt| Recipient {
address: rcpt.address.to_string(),
orcpt: rcpt.orcpt.as_deref().map(Into::into),
added_by: hints
.added
.iter()
.find(|(address, _)| address.eq_ignore_ascii_case(&rcpt.address))
.map(|(_, rule)| rule.clone()),
})
.collect();
let envelope = Envelope {
sender: &message.return_path,
authenticated: message.flags & FROM_AUTHENTICATED != 0,
recipients: &recipients,
queue_id,
received: message.created,
direction,
held,
};
let host = server.core.network.server_name.as_str();
let (bytes, fields) = report::build(&envelope, raw, &format!("postmaster@{host}"), host);
let mut tenants: Vec<u32> = members.iter().filter_map(|m| m.tenant).collect();
tenants.sort_unstable();
tenants.dedup();
let hash = BlobHash::generate(&bytes);
let entry_for = |journals: &[&journal::Journal]| {
let retention_days = journals
.iter()
.map(|j| j.retention_days)
.max()
.unwrap_or_default();
Entry {
queue_id,
at,
direction,
sender: message.return_path.to_string(),
authenticated: envelope.authenticated,
recipients: recipients.iter().map(|r| r.address.clone()).collect(),
subject: fields.subject.clone(),
message_id: fields.message_id.clone(),
accounts: members.iter().map(|m| m.account).collect(),
tenants: tenants.clone(),
journals: journals.iter().map(|j| j.id).collect(),
held,
blob: entries::hex(hash.as_slice()),
size: bytes.len() as u64,
sha256: entries::sha256(&bytes),
expires_at: at + u64::from(retention_days) * 86_400,
}
};
// The built-in journal: one entry, however many journals keep it there
let built_in: Vec<&journal::Journal> = taken.iter().copied().filter(|j| j.built_in).collect();
if !built_in.is_empty() {
// The report's blob, reserved until the entry links it
let mut batch = BatchBuilder::new();
batch.set(
BlobOp::Link {
hash: hash.clone(),
to: BlobLink::Temporary { until: at + 120 },
},
vec![],
);
server.store().write(batch.build_all()).await?;
server
.blob_store()
.put_blob(hash.as_slice(), &bytes, server.core.email.compression)
.await?;
entries::append(
server.store(),
server.core.network.node_id,
&entry_for(&built_in),
)
.await?;
}
// Outside archives: one report per address (JR-4, JR-7)
let mut addresses: Vec<(String, Vec<&journal::Journal>)> = Vec::new();
for journal in &taken {
if let Some(address) = &journal.archive_address {
let address = address.to_lowercase();
match addresses.iter_mut().find(|(a, _)| *a == address) {
Some((_, journals)) => journals.push(journal),
None => addresses.push((address, vec![journal])),
}
}
}
for (address, journals) in addresses {
// From nobody: an archive's refusal comes back to no one, and the
// queue's own record of it is what counts (settle, below)
let mut report = server.new_message("", MessageSource::Autogenerated, 0);
report.message.flags |= FROM_JOURNAL;
report.add_expanded_recipient(&address, server).await;
let pending = Pending {
address,
entry: entry_for(&journals),
};
archive::set_pending(server.store(), report.queue_id, &pending).await?;
let report_id = report.queue_id;
// Boxed: queueing the report comes back through this function
let queued = Box::pin(report.queue(QueueParams::new(&bytes, 0, server))).await;
if !queued {
archive::clear_pending(server.store(), report_id).await?;
return Err(trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to queue a journal report"));
}
}
Ok(())
}
/// JR-7: a journal report is leaving the queue. Delivered, its pending
/// record goes; not delivered (refused, expired, or deleted from the
/// queue), it goes into the built-in journal instead, and the journals
/// that sent it count a failure. An error means nothing was settled, and
/// the report must stay queued.
pub async fn settle(server: &Server, queue_id: u64, message: &Message) -> trc::Result<()> {
let store = server.store();
let Some(pending) = archive::pending(store, queue_id).await? else {
return Ok(());
};
let delivered = !message.recipients.is_empty()
&& message
.recipients
.iter()
.all(|rcpt| matches!(rcpt.status, Status::Completed(_)));
if !delivered {
let reason = if message
.recipients
.iter()
.any(|rcpt| matches!(rcpt.status, Status::PermanentFailure(_)))
{
"the archive refused it"
} else {
"it wasn't delivered before leaving the queue"
};
entries::append(store, server.core.network.node_id, &pending.entry).await?;
let at = now();
archive::record_failure(store, &pending.entry.journals, at, reason).await?;
server
.audit_note(Record {
at: at * 1000,
actor: Actor::system("Journal"),
via: None,
remote_ip: None,
action: Action::Create,
target: Target {
kind: "inbuxa:JournalEntry".into(),
id: Some(format!("{:x}", pending.entry.queue_id)),
name: None,
account_id: None,
tenant_id: None,
},
changes: vec![],
details: Some(format!(
"A journal report to {} wasn't delivered ({reason}); kept in the built-in journal",
pending.address
)),
reason: None,
outcome: Outcome::success(),
})
.await;
}
archive::clear_pending(store, queue_id).await
}
+4
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use common::{
@@ -21,6 +23,8 @@ use types::blob_hash::BlobHash;
use utils::DomainPart;
pub mod dsn;
pub mod held; // inbuxa: mail held for review
pub mod journal; // inbuxa: journaling
pub mod manager;
pub mod quota;
pub mod spool;
+60
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use super::{
@@ -368,6 +370,8 @@ pub(crate) struct QueueParams<'x, 'y> {
pub session_id: u64,
pub server: &'y Server,
pub train_spam: Option<(bool, String)>,
// inbuxa: journaling, JR-3, JR-10
pub journal: crate::queue::journal::Hints,
}
impl MessageWrapper {
@@ -388,6 +392,7 @@ impl MessageWrapper {
server,
train_spam,
metadata,
journal,
..
} = params;
let event = self.message.queued_event();
@@ -453,6 +458,26 @@ impl MessageWrapper {
return false;
}
// inbuxa: journaling, JR-1: the copy is taken before the message is
// queued; if it can't be, the message isn't queued either
if let Err(err) = crate::queue::journal::capture(
server,
self.queue_id,
&self.message,
message.as_ref(),
&journal,
)
.await
{
trc::error!(
err.details("Failed to journal a message.")
.span_id(session_id)
.caused_by(trc::location!())
);
return false;
}
trc::event!(
Queue(event),
SpanId = session_id,
@@ -792,6 +817,20 @@ impl MessageWrapper {
}
pub async fn remove(self, server: &Server, prev_event: Option<u64>) -> bool {
// inbuxa: journaling, JR-7: a journal report the archive never took
// goes into the built-in journal before it leaves the queue
if self.message.flags & crate::queue::journal::FROM_JOURNAL != 0
&& let Err(err) =
crate::queue::journal::settle(server, self.queue_id, &self.message).await
{
trc::error!(
err.details("Failed to settle a journal report; it stays queued.")
.span_id(self.span_id)
.caused_by(trc::location!())
);
return false;
}
let mut batch = BatchBuilder::new();
if let Some(prev_event) = prev_event {
@@ -966,6 +1005,19 @@ impl MessageWrapper {
server: &Server,
prev_events: AHashMap<QueueName, u64>,
) -> bool {
// inbuxa: journaling, JR-7, as in `remove`
if self.message.flags & crate::queue::journal::FROM_JOURNAL != 0
&& let Err(err) =
crate::queue::journal::settle(server, self.queue_id, &self.message).await
{
trc::error!(
err.details("Failed to settle a journal report; it stays queued.")
.span_id(self.span_id)
.caused_by(trc::location!())
);
return false;
}
let mut batch = BatchBuilder::new();
for (queue_name, due) in prev_events {
@@ -1121,9 +1173,17 @@ impl<'x, 'y> QueueParams<'x, 'y> {
original_raw_message: None,
original_authenticated_message: None,
metadata: Vec::new(),
journal: Default::default(),
}
}
/// inbuxa: journals mail flow rules sent the message to, and the
/// recipients they added, by rule name.
pub fn with_journal(mut self, marks: Vec<u32>, added: Vec<(String, String)>) -> Self {
self.journal = crate::queue::journal::Hints { marks, added };
self
}
pub fn with_train_spam(mut self, train_spam: Option<(bool, String)>) -> Self {
self.train_spam = train_spam;
self
+1 -1
View File
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
#[macro_export]
macro_rules! brand_version {
() => {
"2026.9.28.5"
"2026.9.29.1"
};
}
+2
View File
@@ -362,6 +362,8 @@ is written.
| 9 | Per-domain directories | A domain signs in against its own LDAP, SQL or OIDC directory | Added 2026-09-18. Signing in through an OIDC provider as the server's directory is already AGPL; only the per-domain choice is Enterprise. Built 2026-09-19 in `crates/common/src/auth` and `crates/directory`; status in `features/per-domain-directories.md`. |
| — | Seat limits, license keys | Nothing: there's no license | Removed, not rebuilt. |
Not a rebuild: the **security to-do list** is INBUXA's own design (inbuxa-drafts `specs/security-score.md`). The console runs its checks; the server's part is `inbuxa:SecurityAcceptance`, the accepted items (`crates/jmap/src/inbuxa/security_acceptance.rs`), and the `sysSecurityAccept` permission.
## 5. The web front ends
**Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs,
+45 -9
View File
@@ -139,6 +139,14 @@ DLP adds **detectors**. Each counts what it finds, and a rule sets a minimum
either side, in the languages where the identifier is used ("passport",
"Reisepass", "pasaporte"...).
A check that about one random number in ten passes (Luhn, mod 10, mod 11) is
too weak for a bare run of digits: invoice and phone numbers would match. So
a checked identifier that is only digits (SSN, SIN, NHS, TFN, Medicare…)
counts alone in the written form it's issued in (`536-22-1234`,
`130 692 544`, `943 476 5919`), and as bare digits only beside a word. ABA
routing numbers and NPIs are never written with separators, so they always
need a word. (Refinement made while building phase 2, 2026-09-28.)
The catalog (settled answer 6: the recognized, protected identifiers, not a
chosen few). Each row is one table entry and one check function in
`crates/features/src/mailflow/detectors/`:
@@ -157,10 +165,10 @@ chosen few). Each row is one table entry and one check function in
| US | Social Security number | Checked | `AAA-GG-SSSS`, or nine digits with a word; never area 000, 666 or 9xx, group 00, serial 0000 |
| US | ITIN | Checked | 9XX-GG-SSSS with the IRS's group ranges |
| US | EIN | Needs a word | a valid IRS prefix and seven digits |
| US | Bank routing number (ABA) | Checked | nine digits, a valid Federal Reserve prefix, the 3-7-1 checksum |
| US | Bank routing number (ABA) | Needs a word | nine digits, a valid Federal Reserve prefix, the 3-7-1 checksum |
| US | Driver's license | Needs a word | each state's published format |
| US | Medicare Beneficiary Identifier | Checked | CMS's 11-character pattern and excluded letters |
| US | National Provider Identifier | Checked | ten digits, Luhn over the `80840` prefix |
| US | National Provider Identifier | Needs a word | ten digits, Luhn over the `80840` prefix |
| US | DEA registration number | Checked | two letters, seven digits, DEA's check digit |
| UK | National Insurance number | Checked | two letters (HMRC's excluded prefixes), six digits, A–D |
| UK | NHS number | Checked | ten digits, mod 11 |
@@ -285,16 +293,44 @@ once it's held (the webmail says so).
Held messages count against no one's quota. Each held message and each
decision is in the audit log.
**As built (phase 3).** Holding uses the queue's own future-release
mechanism: the message is queued with its release a century off, every
recipient's retry, notice and expiry pushed with it, so the stored format
doesn't change. Release puts each recipient due now, keeps the gap to its
next notice, and counts its lifetime from the release. The review record
(`inbuxa:HeldMessage`, under `R` `h` + queue id) holds the sender,
recipients, subject, size, rules and counts. Transport rules still apply to
held mail, so what's released is what would have gone out. The daily
clean-up rejects what's past its 7 days (recorded as the server's doing).
`preview` returns the text (64 KB) only when asked for, and each read is
recorded as `blobAccess`. Emails › Queue refuses to change or delete held
mail, and the sender can't unsend it. How many days held mail waits is
`inbuxa:DlpSettings.keepHeldDays`, 1 to 90, 7 by default; each held message
keeps the days it was given.
### 2.7 What's recorded
Every DLP match writes one audit record: actor **DLP** (a system actor),
target the message (queue id, sender, recipient domains), the rules and each
detector's count, the action, and for an override the sender's reason.
**Never the matched text**: the log would otherwise become a second copy of
what the policy was keeping in. A card number isn't written, even masked.
Every DLP match writes one audit record, and **never the matched text**:
the log would otherwise become a second copy of what the policy was keeping
in. A card number isn't written, even masked.
Transport rules that change a message record the rule and action the same way.
Unmatched mail writes nothing.
**As built (phase 2f).** The actor is the sender (they sent it; filtering by
sender is what a reviewer wants), the action `create`, the target kind
`message`. The details say what happened, where to, and each rule with its
detectors' counts: `DLP warned, to elsewhere.org: "Cards leaving"
(payment-card 1)`. A block or an unanswered warning is recorded as refused
(`inbuxa:dlpBlocked`, `inbuxa:dlpWarning`); an override as a success, with
the sender's reason. No new audit action was added: an older node reading a
record with an action it doesn't know fails its daily clean-up, so a new
action would make rolling back unsafe.
Transport rules that refuse a message or change where it goes (redirect, add
a recipient, route) record the rule and what it did the same way, the actor
being the sender, or `system:mail-flow` for incoming mail. **As built
(phase 2g)**, rules that only change wording or headers (a disclaimer, a
header, a subject prefix) write nothing: a banner rule would otherwise write
a record for every message, kept for the audit log's two years. Unmatched
mail writes nothing.
### 2.8 Permissions and who does what
+378
View File
@@ -0,0 +1,378 @@
# Feature spec: journaling
Status: **approved 2026-09-28**, with the answers under [Settled](#settled);
**built 2026-09-29** (phases 2–5, see [As built](#as-built)), not yet released.
Not a rebuild of an upstream feature, so it has no line in SPEC.md §4's table.
Rule IDs: **JR-**.
## Provenance
Written for the record SPEC.md §3 rule 3 asks for. Sources, and nothing else:
| Source | License | Used for |
|---|---|---|
| This repository at `94a3a76` (2026-09-28): `crates/smtp/src/inbound/data.rs`, `inbound/rcpt.rs`, `queue/spool.rs`, `outbound/delivery.rs`, `crates/common/src/network/mta.rs`, `crates/features/src/{hold,audit,mailflow,undelete}`, `crates/store/src/write/{mod,blob}.rs`, `crates/jmap/src/inbuxa/hold_export.rs` | AGPL-3.0-only | Where every message passes, what the envelope holds, how holds keep blobs, how the audit chain and hold export work |
| `inbuxa-drafts/queue/journaling.md` | Own | What John asked for, and the gaps to settle |
| The DLP and mail flow rules spec, the audit-hold-lock spec, the personal-data catalog spec | Own | Conditions, the audit log, legal holds, roles, the catalog check |
| RFC 5321, RFC 3461 (DSN, ORCPT), RFC 2046 (`message/rfc822`), RFC 5322 | Public | The envelope, the original recipient of an expanded list, the report's shape |
No Enterprise-only file or snippet was used, and no third-party journaling
product or report format was consulted: the journal report below is our own
layout of the SMTP envelope around the untouched message.
## What it is
A **journal** is a copy of each message the server handles, captured in
transit with its **envelope** (the real sender and every recipient, including
Bcc and the members of lists), kept where nobody can change or remove it
until its retention ends, or sent to an outside archive. It sits beside two
things that exist:
- **Legal hold** keeps what's in chosen mailboxes, including what their owners
delete. It starts when a hold is placed and can't see Bcc or what was sent
from a mailbox that no longer exists.
- **The audit log** records what people and the server did, never the mail.
A journal answers the question neither can: *what went through, to whom,
from the day it was turned on*.
**Out of scope**: journaling mail stored before it's turned on, files,
calendar and contacts, IMAP APPEND (a mail app saving to its own Sent folder
sends nothing), and mail a mail app sends through another server.
Nothing in code, docs, UI text or output claims the product meets a legal or
regulatory standard. The pages say what's captured, where it's kept and for
how long.
## 1. What exists today
Checked by reading the code at `94a3a76`:
| Need | Today |
|---|---|
| One place all mail passes | `MessageWrapper::queue` (`queue/spool.rs` ~L375). SMTP, JMAP submission (`jmap/src/submission/set.rs` builds a local session and runs `queue_message`), inbound mail, Sieve redirects and vacation replies, and DSNs all queue through it. Local and remote delivery both start from the queue. |
| The envelope | At queue time: `mail_from`, every `rcpt_to` (Bcc included), the authenticated account with its groups and tenant, the queue id. Lists are **already expanded** at RCPT (`rcpt_resolve` → `RcptResolution::Expand`, `inbound/rcpt.rs`); the list address survives as each member's ORCPT (`dsn_info`). |
| A copy out | Sieve at DATA, milters and MTA hooks can send one, but all run **before** DLP and transport rules, so they miss recipients the rules add, and a Sieve copy carries no envelope. |
| Keeping a blob nobody can delete | No "undeletable" flag. Blobs are content-addressed (can't be edited); a `BlobLink::Temporary { until }` keeps one until `until`. Legal hold uses `until` = year 9999. |
| A record nobody can quietly change | The audit log's per-node SHA-256 chain (`features/src/audit/log.rs`): each entry carries `prev`, the head is asserted on append, purge leaves a floor hash, `verify` walks it. |
| Export | Hold export (LH-12): a ZIP of `.eml` files, `manifest.csv` with a SHA-256 per file, `manifest.sha256`, capped at 2 GiB. |
| Conditions by sender, recipient, group, tenant | The mail flow engine (`features/src/mailflow/engine.rs`), at DATA. |
## 2. Design
### 2.1 Where the copy is taken (JR-1, JR-2)
**JR-1.** The journal is taken in `MessageWrapper::queue`, after the message
is spooled, behind one `// inbuxa:` marked block. That's after DLP and
transport rules, so the envelope is the one the message actually leaves or
arrives with, and it covers every path that queues mail.
**JR-2.** What isn't journaled: journal reports themselves (they carry a
queue flag, so a report to an outside archive can't journal itself), and the
server's own DMARC and TLS reports. DSNs and Sieve redirects and vacation
replies are journaled (question 4). A message **refused** at DATA (DLP block,
a transport rule's refusal) was never accepted and isn't journaled; the
audit log already records it. A message **held** for DLP review is journaled
when it's queued, which is when it's held, with the hold noted in the entry.
### 2.2 The journal report (JR-3, JR-4)
**JR-3.** Each copy is a **journal report**: a new message whose first part
is `text/plain`, one field a line:
```
Sender: alice@example.com
Signed in as: alice@example.com
Subject: Q3 figures
Message-ID: <…>
Queue ID: 1a2b3c…
Received: 2026-09-28T14:03:11Z
Direction: outgoing
To: bank@elsewhere.example
Cc: bob@example.com
Bcc: carol@example.com
Expanded: finance@example.com -> dan@example.com, erin@example.com
Held for review: yes
```
and whose second part is the message as queued, **byte for byte**, as
`message/rfc822`. `Bcc:` lists envelope recipients that aren't in the
message's To or Cc headers. `Expanded:` groups the members of a list under
the list address, from their ORCPT. Recipients a transport rule added say so
(`Added by rule: <name>`). The field names are fixed English (they're a
record, not interface text), so a script can read them.
**JR-4.** One report per queued message, with the whole envelope, whatever
the scope matched on (§2.4). A message to 40 recipients is one report, not
40.
### 2.3 Where reports go (JR-5 to JR-8)
Each journal has a **destination** (question 1):
**JR-5. The built-in journal.** Records under a new prefix `J` in
`SUBSPACE_INBUXA`: queue id, received time, direction, sender, recipients,
the tenant(s), which journal matched, the report's blob hash and size, its
SHA-256, and the time it may be purged. The report blob is kept by a
`BlobLink::Temporary { until }` set to the end of its retention. There is no
JMAP `set` or `destroy` for entries: nothing in the product changes or
removes one before its time.
**JR-6. The chain.** Each entry carries the SHA-256 of the entry before it,
one chain per node, the same construction as the audit log (and its code,
generalized rather than copied). The console's **Check the journal** walks
it, and every blob's hash against its entry, and says what it found. Someone
with the server's disks can still remove data, and the chain is how that
shows; the docs say exactly that, and don't say it can't happen.
**JR-7. An outside archive.** The report is queued to an address (the
archive's journal mailbox) like any mail, with the queue's retries. A report
the archive refuses permanently, or can't take within the queue's limit, goes
into the built-in journal instead and raises a warning on the Overview
(question 6). Delivery is by the ordinary queue, so TLS and routing settings
apply; a queue route can be chosen for it.
**JR-8. Both**: the built-in journal and an outside archive.
### 2.4 Which mail: journals and their scope (JR-9 to JR-11)
**JR-9.** A **journal** is a named object (`inbuxa:Journal`): on or off, a
destination, a retention, and a scope. The scope is who: **everyone**, or
senders and recipients in chosen **accounts, groups, domains or tenants**,
and which **direction**: outgoing, incoming, internal, any. A message is
journaled once per journal whose scope any sender or recipient is in; two
journals with the same destination never write the same message twice.
**JR-10.** **By what's in it**: a new mail flow rule action, **Journal it**,
names a journal. The rule's conditions (detectors, words, attachments,
headers) decide; the copy is still taken at queue time (the rule only marks
the message). This is the rule-based journaling the queue note called
premium; here it's one more action, not a separate tier (question 2).
**JR-11.** Scope is evaluated from the envelope and directory membership at
queue time (`Server::member_of`), no message parsing, so journaling
everything costs a lookup per recipient and one blob write per message.
### 2.5 Retention and legal hold (JR-12 to JR-14)
**JR-12.** Each journal has a retention in days (question 3). An entry keeps
the retention it was written with: shortening a journal's retention applies
to new entries only, so nobody can empty the journal by editing a number.
Lengthening it applies to new entries too, and the console says so.
**JR-13.** Purge runs in the daily maintenance, removes entries past their
time and drops their blob link, and leaves a floor hash so the chain still
verifies, as the audit log does. An entry whose sender or any recipient is
under a **legal hold** isn't purged while the hold lasts (`holds_on`, read
uncached, as holds are everywhere).
**JR-14.** Deleting an account doesn't remove its journal entries; they end
with their retention (question 7). The privacy catalog says so.
### 2.6 Search, reading, export (JR-15 to JR-17)
**JR-15.** **Management › Compliance › Journal**: search by sender,
recipient, date range, direction, subject words (from the report's header
fields, not the body: no full-text index of the journal in this version).
Results list the envelope; **Read…** opens the report.
**JR-16.** **Export** a search as a ZIP in the hold export's shape: the
reports as `.eml`, `manifest.csv` with the envelope columns and a SHA-256
per file, `manifest.sha256`, the same 2 GiB cap. Export runs as a task and
the result is a blob owned by the person who asked for it.
**JR-17.** Every search, read and export is in the audit log, with who and
the search terms; so is every change to a journal.
### 2.7 Permissions (JR-18)
**JR-18.** New permissions after the DLP set (680 onward):
`sysJournalGet` / `sysJournalUpdate` (see and change journals),
`sysJournalSearch` (search and read entries), `sysJournalExport`. Superuser
only, by default. The officer grant audience adds Get, Search and Export to
the Compliance Officer; administrators configure journals but don't read
them unless granted Search (question 5). Journals are server-level, with a
tenant scope, as DLP rules are; nobody in a tenant reaches them.
### 2.8 Privacy catalog
New objects get catalog entries (`resources/privacy/catalog.toml`):
`inbuxa:Journal` (none), `inbuxa:JournalEntry` (mail content and envelope,
kept for the journal's retention, access audited, not erased with the
account). `privacy-check.py` enforces it.
### 2.9 Mixed versions, clusters, rollback
- Entries and journals live in the shared data store; report blobs in the
blob store. A **node-local** blob store (FileSystem, or RocksDB/SQLite as
the blob store) on a cluster means a node's journal lives on that node;
the console warns when journaling is on and the blob store isn't shared.
- During a rolling upgrade a node on the old version doesn't journal. The
console says so when nodes report different versions; the release notes
say to turn journals on after every node is upgraded.
- Rollback: the new prefix and the queue flag are ignored by an older
version; nothing in the queue's archived format changes (the "journal
report" flag rides in the existing message flags if one is free, else in
a side key by queue id; checked in phase 2 before writing code).
### 2.10 Cost
One extra blob per journaled message (the report wraps the original, so it
doesn't share its hash), plus one small record. The console shows the
journal's size and growth per day on the journal page, from the entries.
## 3. Console
- **Management › Compliance › Journaling**: journals (name, scope,
destination, retention, on/off), described in words like DLP rules
("Journal all mail to and from Finance into the built-in journal, kept
7 years"); **Check the journal**.
- **Management › Compliance › Journal**: search, read, export.
- The mail flow rule editor gains **Journal it**.
- The Overview warns about undelivered outside reports (JR-7) and a
node-local blob store (§2.9).
## 4. Webmail
Nothing. People aren't told a message was journaled, as they aren't told
about legal hold; the docs say journaling exists and what it captures.
## 5. Tests
Unit: the report's fields (Bcc computed from headers, list expansion from
ORCPT, rule-added recipients), scope matching, retention arithmetic, the
chain. Integration (`tests/src/system/`): SMTP and JMAP sends, inbound
mail, internal mail, a list and a Bcc recipient, a DLP-held message, a
Sieve redirect; the report equals the queued bytes; no `set`/`destroy`;
shortening retention doesn't touch existing entries; a hold stops purge;
account deletion leaves entries; an outside archive that refuses falls back
to the built-in journal; export manifest hashes; audit records for search,
read, export.
## 6. Phases
1. This spec, approved.
2. Capture at the queue, the report, the built-in journal with its chain,
retention and purge, holds; `inbuxa:Journal` and `inbuxa:JournalEntry`;
catalog entries; tests.
3. Outside archive and the fallback; **Journal it** in mail flow rules.
4. Search, read and export (a task), audit records.
5. Console pages; docs; a row in `inbuxa-drafts/divergence-log.md`.
Each phase is its own PR with tests; releases as John decides. Like DLP, it
stays out of production until John says.
## As built
Phase 2 (`feature/journal-capture`), where it differs from the design or
fills in what it left open:
- **The chain** is the journal's own (`crates/features/src/journal/
entries.rs`), not the audit log's code shared. Entries expire out of chain
order (each keeps its journal's retention, and holds keep some longer), so
a link names its entry by SHA-256 instead of holding it: purging removes
the entry, its indexes and its report's blob link, and writes a purge
marker; the link stays. An entry missing without a marker is a broken
chain. Purged links at a chain's start are cleared and a floor recorded,
as the audit log does.
- **If the copy can't be taken**, the message isn't queued: the sender gets
a temporary failure and tries again. Nothing leaves unjournaled.
- **The report** says `Authenticated: yes|no` instead of the signed-in
account (the queue doesn't keep which account it was). `Added by rule`
comes with **Journal it** in phase 3. A recipient given with an ORCPT
that names another address counts as expanded from that address.
- **Journal reports** the server queues carry message flag bit 48
(`FROM_JOURNAL`); an older version ignores the bit.
- **Permissions 680–683**: administrators get `sysJournalGet`/`Update`; the
Compliance Officer gets `Get`, `Search` and `Export`. So that an
administrator can still appoint an officer (and grant reading as settled
answer 5 describes), whoever holds `sysJournalUpdate` may grant `Search`
and `Export` without holding them; the role change is in the audit log.
- **`inbuxa:JournalEntry`** (get, query) and **Check the journal** over
JMAP come in phase 4 with search, so every read is audited from the first
version that allows one. Phase 2 has `inbuxa:Journal` only.
Phase 3 (`feature/journal-archive`):
- **Destinations** are two properties of a journal: `builtIn` (true for
journals stored before phase 3) and `archiveAddress`. At least one.
- **Journals only rules use**: a journal whose scope chooses nobody takes
only what a **Journal it** action sends it. The action goes on mail flow
rules, and on a DLP rule beside its block, warn or hold (a blocked
message isn't queued, so it isn't journaled).
- **Reports to an archive** are queued from the empty sender, so a refusal
comes back to no one; a pending record per report says what to keep.
When the queue lets go of a report without delivering it (refused,
expired, or deleted from the queue), the report becomes its own entry in
the built-in journal under the sending journals' retention, even when
another journal already kept the message there, the journal's
`archiveFailures` (count, last time, reason) goes up, and the audit log
records it. If that can't be written, the report stays queued.
- **Added by rule** lists recipients a transport rule added or redirected
to, by rule name, instead of counting them as Bcc.
- A rule's route (and now its journal marks) is cleared between messages
in one SMTP session; before, a second message in the same session kept
the first one's route.
Phase 4 (`feature/journal-search`):
- `inbuxa:JournalEntry/query` (after, before, sender, recipient, address,
direction, subject words, Message-ID, journal; newest first, pages of up
to 500) and `/get` (`report`, the whole journal report up to 10 MB of
text, only when asked for), with `sysJournalSearch`.
- Recording (JR-17) happens before anything is returned, and nothing is
returned if it can't be written: a search with its terms, a listing
once per call, each report read on its own (as `blobAccess`, the
action reads of someone's mail already use), each export with its
reason (`export`), each check (`verify`). No new audit actions, so an
older version reads every record.
- `inbuxa:JournalExport/set` builds the ZIP in the request, like the audit
log's export, rather than as a task: at most 10,000 reports and 1 GB,
and a search that matches more is refused with the count, to narrow.
The ZIP has the reports as `.eml`, `manifest.csv` with the envelope and
a SHA-256 per file, `exceptions.csv` for reports that couldn't be read,
and `manifest.sha256`.
- `inbuxa:JournalVerification/set` rechecks the chains and every report
against its entry, with `sysJournalGet`.
Phase 5 (inbuxa-admin #62, server #119 for the menu, docs inbuxa.org #32):
- One console page, **Management › Compliance › Journal**, with two tabs
instead of the two pages §3 named: **Search** (for those who may search)
and **Journals** (the editor, on/off, delete, archive warnings, and Check
the journal).
- The warnings §3 put on the Overview (undelivered archive reports, a
node-local blob store) aren't there: archive failures show on each
journal, and there's no blob-store warning yet.
- The rule editor's **Journal it**, on mail flow rules and as an optional
second action on DLP rules.
## Known gaps
- A message a person saves to Sent over IMAP, or sends through another
server, never reaches the queue.
- Mail stored before journaling is on isn't journaled (legal hold covers
mailboxes).
- Search reads envelope and header fields, not bodies.
- Group accounts (`GroupAccount`) resolve as one account, not members; their
mail is journaled under the group's address.
## Settled
John, 2026-09-28, all seven as recommended:
1. **Destinations**: the built-in journal, an outside archive by address, or
both, per journal (JR-5, JR-7, JR-8).
2. **Scope**: everyone, or chosen accounts, groups, domains and tenants by
direction, plus a **Journal it** rule action; no standard/premium split
(JR-9, JR-10).
3. **Retention**: no default; 30 days to 10 years, picked when a journal is
turned on; existing entries keep theirs (JR-12).
4. **Which mail**: everything queued, including DSNs, Sieve redirects and
vacation replies, except DMARC/TLS reports and journal reports (JR-2).
5. **Who reads it**: administrators configure; Compliance Officers search,
read and export; administrators read only if granted Search (JR-18).
6. **An outside archive that won't take a report**: kept in the built-in
journal, with a warning (JR-7).
7. **Deleted accounts**: journal entries stay until their retention ends,
and the catalog says so (JR-14).
Binary file not shown.
+100
View File
@@ -79,6 +79,93 @@ lockedAt = ["metadata"]
lockedBy = ["identifier"]
delegates = ["identifier"]
[object."inbuxa:DlpSettings"]
file = "inbuxa_dlp_settings.rs"
default = "none"
[object."inbuxa:HeldMessage"]
file = "inbuxa_held_message.rs"
default = "none"
whose = ["holder", "correspondent"]
where = ["data-store", "blob-store"]
scope = "server"
retention = "object-life"
[object."inbuxa:HeldMessage".properties]
sender = ["identifier", "contact"]
recipients = ["identifier", "contact"]
subject = ["content"]
preview = ["content"]
note = ["content"]
counts = ["metadata"]
[object."inbuxa:MailRule"]
file = "inbuxa_mail_rule.rs"
default = "none"
whose = ["administrator", "holder", "correspondent"]
where = ["data-store"]
scope = "server"
retention = "unbounded"
[object."inbuxa:MailRule".properties]
name = ["content"]
description = ["content"]
conditions = ["contact", "content"]
exceptions = ["contact", "content"]
actions = ["contact", "content"]
createdBy = ["identifier"]
[object."inbuxa:Journal"]
file = "inbuxa_journal.rs"
default = "none"
whose = ["administrator"]
where = ["data-store"]
scope = "server"
retention = "unbounded"
[object."inbuxa:Journal".properties]
name = ["content"]
description = ["content"]
createdBy = ["identifier"]
[object."inbuxa:SecurityAcceptance"]
file = "inbuxa_security_acceptance.rs"
default = "none"
whose = ["administrator"]
where = ["data-store"]
scope = "server"
retention = "object-life"
[object."inbuxa:SecurityAcceptance".properties]
note = ["content"]
acceptedBy = ["identifier"]
[object."inbuxa:JournalEntry"]
file = "inbuxa_journal_entry.rs"
default = "none"
whose = ["holder", "correspondent"]
where = ["data-store", "blob-store"]
scope = "server"
retention = { setting = "inbuxa:Journal.retentionDays" }
[object."inbuxa:JournalEntry".properties]
sender = ["identifier", "contact"]
recipients = ["identifier", "contact"]
subject = ["content"]
messageId = ["identifier"]
report = ["content", "identifier", "contact", "metadata"]
[object."inbuxa:JournalExport"]
file = "inbuxa_journal_entry.rs"
default = "none"
whose = ["holder", "correspondent"]
where = ["blob-store"]
scope = "server"
retention = { setting = "x:Jmap.uploadTtl" }
[object."inbuxa:JournalExport".properties]
blobId = ["identifier", "contact", "content"]
filter = ["identifier", "contact"]
reason = ["content"]
[object."inbuxa:JournalVerification"]
file = "inbuxa_journal_entry.rs"
default = "none"
[object."inbuxa:LegalHold"]
file = "inbuxa_legal_hold.rs"
default = "none"
@@ -374,6 +461,19 @@ captures = ["x:Email.maxMaskedAddresses"]
leaves_host = false
written_by = ["crates/features/src/masked_email/data.rs"]
# Journaling (journaling spec, JR-5, JR-14): a copy of each message a
# journal takes, with its envelope, kept for the journal's retention even
# after the account is deleted, and longer while a legal hold covers
# someone on it.
[source."journal"]
categories = ["content", "identifier", "contact", "metadata"]
whose = ["holder", "correspondent"]
where = ["data-store", "blob-store"]
scope = "server"
retention = { setting = "inbuxa:Journal.retentionDays" }
leaves_host = false
written_by = ["crates/features/src/journal/entries.rs", "crates/smtp/src/queue/journal.rs"]
[source."outbound-reports"]
categories = ["network", "identifier", "content"]
whose = ["correspondent"]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
k496pjVWlQ2p4bkZCh8agzaCKMLD4c3Z9WtoxpckYDU
D8e0s1e4Umau4gRh5MEW24KtsawKGPNvS-6LWrIFbtQ
+931
View File
@@ -0,0 +1,931 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Journaling (journaling spec, phase 2): journals over JMAP, the copy
//! taken as mail is queued with its whole envelope, the report around the
//! untouched message, retention, purge, and a chain that shows tampering.
use crate::utils::{
account::Account,
server::{TestServer, TestServerBuilder},
smtp::SmtpConnection,
};
use inbuxa_features::journal::{
Direction,
entries::{self, Entry, EntryId},
report,
};
use registry::schema::{
prelude::{ObjectType, Property},
structs::{CustomRoles, Expression, MtaStageAuth, Role, UserRoles},
};
use registry::types::map::Map;
use serde_json::{Value, json};
use std::str::FromStr;
use store::{Deserialize, write::BatchBuilder};
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
"urn:inbuxa:jmap",
"urn:inbuxa:jmap:registry",
];
async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) {
if arguments.get("accountId").is_none() {
arguments["accountId"] = account.id_string().into();
}
let response = account
.jmap_request(USING, json!([[method, arguments, "0"]]))
.await;
let call = response
.0
.pointer("/methodResponses/0")
.cloned()
.unwrap_or_else(|| panic!("{method}: {}", response.0));
(
call[0].as_str().unwrap_or_default().to_string(),
call[1].clone(),
)
}
/// Sends a message whose headers name `to`, to the envelope `rcpt_to`.
async fn send(
sender: &Account,
identity: &str,
mailbox: &str,
to: &[&str],
rcpt_to: &[&str],
subject: &str,
) -> Value {
let (_, response) = call(
sender,
"Email/set",
json!({"create": {"e": {
"mailboxIds": {mailbox: true},
"from": [{"email": sender.name()}],
"to": to.iter().map(|a| json!({"email": a})).collect::<Vec<_>>(),
"subject": subject,
"bodyValues": {"b": {"value": "The body."}},
"textBody": [{"partId": "b", "type": "text/plain"}]
}}}),
)
.await;
let email = response["created"]["e"]["id"]
.as_str()
.unwrap_or_else(|| panic!("draft: {response}"))
.to_string();
call(
sender,
"EmailSubmission/set",
json!({"create": {"s": {
"emailId": email,
"identityId": identity,
"envelope": {
"mailFrom": {"email": sender.name()},
"rcptTo": rcpt_to.iter().map(|a| json!({"email": a})).collect::<Vec<_>>()
}
}}}),
)
.await
.1
}
async fn all_entries(test: &TestServer) -> Vec<(EntryId, Entry)> {
entries::list(test.server.store(), 0, u64::MAX, 10_000)
.await
.unwrap()
}
async fn entry_for(test: &TestServer, subject: &str) -> Option<(EntryId, Entry)> {
all_entries(test)
.await
.into_iter()
.find(|(_, e)| e.subject == subject)
}
async fn report_of(test: &TestServer, entry: &Entry) -> Vec<u8> {
let hash = entry.blob_hash().expect("blob hash");
test.server
.blob_store()
.get_blob(hash.as_slice(), 0..usize::MAX)
.await
.unwrap()
.expect("report blob")
}
pub async fn test(test: &mut TestServer) {
println!("Running journaling tests...");
let admin = test.account("[email protected]");
let sender = admin
.create_user_account(
"[email protected]",
"journal-sender-secret-7101",
"Journal sender",
&[],
vec![],
)
.await;
let other = admin
.create_user_account(
"[email protected]",
"journal-other-secret-7102",
"Journal other",
&[],
vec![],
)
.await;
let (_, response) = call(
&sender,
"Identity/set",
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
)
.await;
let identity = response["created"]["i"]["id"].as_str().unwrap().to_string();
let (_, response) = call(
&sender,
"Mailbox/set",
json!({"create": {"m": {"name": "Journal drafts"}}}),
)
.await;
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
// Nothing is journaled while there are no journals
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]"],
"Before any journal",
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
assert!(all_entries(test).await.is_empty());
// Journals: checked when written, the server's own properties refused
let (_, response) = call(
&admin,
"inbuxa:Journal/set",
json!({"create": {
"short": {"name": "Short", "enabled": true, "direction": "any",
"scope": {"everyone": true}, "retentionDays": 29},
"both": {"name": "Both", "enabled": true, "direction": "any",
"scope": {"everyone": true, "accounts": [sender.id_string()]},
"retentionDays": 365},
"none": {"name": "Nowhere", "enabled": true, "direction": "any",
"scope": {"everyone": true}, "retentionDays": 365, "builtIn": false},
"badaddr": {"name": "Bad archive", "enabled": true, "direction": "any",
"scope": {"everyone": true}, "retentionDays": 365,
"archiveAddress": "not an address"},
"server": {"name": "Mine", "enabled": true, "direction": "any",
"scope": {"everyone": true}, "retentionDays": 365,
"createdBy": "me"},
"all": {"name": "Everything", "enabled": true, "direction": "any",
"scope": {"everyone": true}, "retentionDays": 365},
"out": {"name": "Sender's outgoing", "enabled": true, "direction": "outgoing",
"scope": {"accounts": [sender.id_string()]}, "retentionDays": 3650}
}}),
)
.await;
for refused in ["short", "both", "none", "badaddr", "server"] {
assert_eq!(
response["notCreated"][refused]["type"], "invalidProperties",
"{refused}: {response}"
);
}
assert_eq!(
response["notCreated"]["short"]["properties"],
json!(["retentionDays"])
);
assert_eq!(
response["notCreated"]["both"]["properties"],
json!(["scope"])
);
assert_eq!(
response["notCreated"]["none"]["properties"],
json!(["builtIn"])
);
assert_eq!(
response["notCreated"]["badaddr"]["properties"],
json!(["archiveAddress"])
);
let everything = response["created"]["all"]["id"]
.as_str()
.unwrap_or_else(|| panic!("{response}"))
.to_string();
let outgoing = response["created"]["out"]["id"]
.as_str()
.unwrap()
.to_string();
let (_, response) = call(&admin, "inbuxa:Journal/get", json!({"ids": null})).await;
let list = response["list"].as_array().unwrap();
assert_eq!(list.len(), 2, "{response}");
assert_eq!(list[0]["name"], "Everything");
assert_eq!(list[0]["createdBy"], "[email protected]");
assert_eq!(list[1]["scope"]["accounts"], json!([sender.id_string()]));
// Each node reads journals again within 30 seconds; this one at once
inbuxa_features::journal::invalidate();
// Internal mail with a Bcc recipient: one entry, the whole envelope
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]", "[email protected]"],
"Internal with Bcc",
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
let (_, entry) = entry_for(test, "Internal with Bcc")
.await
.expect("journaled");
assert_eq!(entry.direction, Direction::Internal);
assert_eq!(entry.sender, "[email protected]");
assert!(entry.authenticated);
assert_eq!(entry.recipients.len(), 2, "{entry:?}");
assert_eq!(
entry.journals.len(),
1,
"internal isn't outgoing: {entry:?}"
);
assert!(!entry.held);
assert_eq!(entry.expires_at, entry.at + 365 * 86_400);
let bytes = report_of(test, &entry).await;
assert_eq!(entries::sha256(&bytes), entry.sha256);
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("Direction: internal\r\n"), "{text}");
assert!(
text.contains("To: [email protected]\r\n"),
"{text}"
);
assert!(
text.contains("Bcc: [email protected]\r\n"),
"{text}"
);
let original = report::original(&bytes).expect("original part");
let original = String::from_utf8_lossy(original);
assert!(
original.contains("Subject: Internal with Bcc"),
"{original}"
);
assert!(original.contains("The body."), "{original}");
assert!(!original.contains("Bcc:"), "the original is as sent");
// Outgoing: both journals take it, and it's kept for the longer
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]"],
"Leaving",
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
let (leaving_id, entry) = entry_for(test, "Leaving").await.expect("journaled");
assert_eq!(entry.direction, Direction::Outgoing);
assert_eq!(entry.journals.len(), 2, "{entry:?}");
assert_eq!(entry.expires_at, entry.at + 3650 * 86_400);
// Incoming from outside
admin
.registry_create_object(MtaStageAuth {
require: Expression {
else_: "false".to_string(),
..Default::default()
},
..Default::default()
})
.await;
let mut lmtp = SmtpConnection::connect().await;
lmtp.ingest(
"[email protected]",
&["[email protected]"],
"From: [email protected]\r\nTo: [email protected]\r\nSubject: Arriving\r\n\r\nHi.\r\n",
)
.await;
let (_, entry) = entry_for(test, "Arriving").await.expect("journaled");
assert_eq!(entry.direction, Direction::Incoming);
assert!(!entry.authenticated);
assert_eq!(entry.accounts, vec![other.id().document_id()]);
// The chain checks out, reports included
let store = test.server.store();
let blobs = test.server.blob_store();
let reports = entries::verify(store, Some(blobs)).await.unwrap();
assert!(reports.iter().all(|r| r.broken_at.is_none()), "{reports:?}");
let journaled = all_entries(test).await.len() as u64;
assert!(reports.iter().map(|r| r.entries).sum::<u64>() >= journaled);
// An entry changed in the store shows; put back, it checks out again
let key = entries::content_key(leaving_id);
let stored = store
.get_value::<Raw>(key.clone())
.await
.unwrap()
.expect("stored entry")
.0;
let mut forged: Entry = serde_json::from_slice(&stored).unwrap();
forged.recipients = vec!["[email protected]".into()];
let mut batch = BatchBuilder::new();
batch.set(key.class.clone(), serde_json::to_vec(&forged).unwrap());
store.write(batch.build_all()).await.unwrap();
let reports = entries::verify(store, None).await.unwrap();
let broken = reports
.iter()
.find(|r| r.broken_at.is_some())
.expect("broken");
assert_eq!(
broken.broken_at.as_deref(),
Some(leaving_id.to_string().as_str())
);
assert!(
broken
.reason
.as_deref()
.unwrap_or_default()
.contains("changed")
);
let mut batch = BatchBuilder::new();
batch.set(key.class.clone(), stored.clone());
store.write(batch.build_all()).await.unwrap();
assert!(
entries::verify(store, None)
.await
.unwrap()
.iter()
.all(|r| r.broken_at.is_none())
);
// An entry removed without a purge shows too
let mut batch = BatchBuilder::new();
batch.clear(key.class.clone());
store.write(batch.build_all()).await.unwrap();
let reports = entries::verify(store, None).await.unwrap();
assert!(
reports.iter().any(|r| r
.reason
.as_deref()
.unwrap_or_default()
.contains("before its time")),
"{reports:?}"
);
let mut batch = BatchBuilder::new();
batch.set(key.class.clone(), stored);
store.write(batch.build_all()).await.unwrap();
// Retention: nothing is due yet; a year on, what's kept for a hold
// stays, the rest goes, and the chain still checks out
let now = store::write::now();
let purged = entries::purge(store, now, |_| false).await.unwrap();
assert_eq!(purged.removed, 0);
let sender_id = sender.id().document_id();
let later = now + 400 * 86_400;
let purged = entries::purge(store, later, |e| e.accounts.contains(&sender_id))
.await
.unwrap();
assert!(purged.removed >= 1, "{purged:?}");
assert!(purged.kept_for_hold >= 1, "{purged:?}");
assert!(entry_for(test, "Arriving").await.is_none(), "purged");
assert!(entry_for(test, "Internal with Bcc").await.is_some(), "held");
assert!(entry_for(test, "Leaving").await.is_some(), "ten years");
let reports = entries::verify(store, Some(blobs)).await.unwrap();
assert!(reports.iter().all(|r| r.broken_at.is_none()), "{reports:?}");
assert!(reports.iter().map(|r| r.purged).sum::<u64>() >= 1);
// Once the hold is gone the held entry goes too
let purged = entries::purge(store, later, |_| false).await.unwrap();
assert!(purged.removed >= 1, "{purged:?}");
assert!(entry_for(test, "Internal with Bcc").await.is_none());
assert!(
entries::verify(store, Some(blobs))
.await
.unwrap()
.iter()
.all(|r| r.broken_at.is_none())
);
// Changing a journal's retention doesn't touch what it has taken
let before = entry_for(test, "Leaving").await.unwrap().1.expires_at;
let (_, response) = call(
&admin,
"inbuxa:Journal/set",
json!({"update": {outgoing.clone(): {"retentionDays": 30}}}),
)
.await;
assert!(response["updated"].get(&outgoing).is_some(), "{response}");
assert_eq!(
entry_for(test, "Leaving").await.unwrap().1.expires_at,
before
);
// Journals turned off or removed take nothing more; entries stay
let (_, response) = call(
&admin,
"inbuxa:Journal/set",
json!({"update": {everything.clone(): {"enabled": false}}, "destroy": [outgoing]}),
)
.await;
assert!(response["updated"].get(&everything).is_some(), "{response}");
assert_eq!(response["destroyed"].as_array().map(|d| d.len()), Some(1));
inbuxa_features::journal::invalidate();
let count = all_entries(test).await.len();
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]"],
"After the journals",
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
assert_eq!(all_entries(test).await.len(), count);
assert!(entry_for(test, "Leaving").await.is_some());
// Every change to a journal is in the audit log
let (_, response) = call(
&admin,
"inbuxa:AuditEvent/query",
json!({"filter": {"targetKind": "inbuxa:Journal"}}),
)
.await;
assert!(
response["ids"].as_array().map_or(0, |ids| ids.len()) >= 4,
"{response}"
);
}
/// Phase 3: journals only rules send mail to, recipients a rule added,
/// reports sent to an outside archive, and what happens when the archive
/// doesn't take one.
pub async fn archive(test: &mut TestServer) {
println!("Running journal archive tests...");
let admin = test.account("[email protected]");
let sender = admin
.create_user_account(
"[email protected]",
"archive-sender-secret-7201",
"Archive sender",
&[],
vec![],
)
.await;
let vault = admin
.create_user_account(
"[email protected]",
"journal-vault-secret-7202",
"Journal vault",
&[],
vec![],
)
.await;
let (_, response) = call(
&sender,
"Identity/set",
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
)
.await;
let identity = response["created"]["i"]["id"].as_str().unwrap().to_string();
let (_, response) = call(
&sender,
"Mailbox/set",
json!({"create": {"m": {"name": "Archive drafts"}}}),
)
.await;
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
let (_, response) = call(
&admin,
"inbuxa:Journal/set",
json!({"create": {
"rules": {"name": "Only what rules send", "enabled": true, "direction": "any",
"scope": {}, "retentionDays": 30},
"local": {"name": "To the vault", "enabled": true, "direction": "outgoing",
"scope": {"accounts": [sender.id_string()]}, "retentionDays": 30,
"builtIn": false, "archiveAddress": "[email protected]"},
"remote": {"name": "To an outside archive", "enabled": true, "direction": "internal",
"scope": {"accounts": [sender.id_string()]}, "retentionDays": 30,
"builtIn": false, "archiveAddress": "[email protected]"}
}}),
)
.await;
let id = |name: &str| {
response["created"][name]["id"]
.as_str()
.unwrap_or_else(|| panic!("{name}: {response}"))
.to_string()
};
let (rules_only, local, remote) = (id("rules"), id("local"), id("remote"));
let number = |id: &str| types::id::Id::from_str(id).unwrap().document_id();
let (_, response) = call(
&admin,
"inbuxa:MailRule/set",
json!({"create": {"r": {
"name": "Copy and journal", "kind": "transport", "direction": "outgoing",
"conditions": [{"type": "words", "words": ["journal-me"]}],
"actions": [
{"type": "addRecipient", "address": "[email protected]"},
{"type": "journal", "journal": rules_only.clone()}
]
}}}),
)
.await;
let rule = response["created"]["r"]["id"]
.as_str()
.unwrap_or_else(|| panic!("{response}"))
.to_string();
inbuxa_features::journal::invalidate();
// A rule sends it to a journal whose scope takes nobody, and says who
// it added
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]"],
"Marked journal-me",
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
let entry = all_entries(test)
.await
.into_iter()
.map(|(_, e)| e)
.find(|e| e.subject == "Marked journal-me" && e.journals.contains(&number(&rules_only)))
.expect("journaled by the rule");
assert_eq!(entry.journals, vec![number(&rules_only)], "{entry:?}");
let text = String::from_utf8_lossy(&report_of(test, &entry).await).into_owned();
assert!(
text.contains("Added by rule: Copy and journal -> [email protected]\r\n"),
"{text}"
);
assert!(!text.contains("Bcc:"), "{text}");
// The same message went to the outside archive, which can't be reached
// from here: once it leaves the queue (given up on, or deleted), it's
// kept in the built-in journal
let fallback = |entries: &[(EntryId, Entry)]| {
entries
.iter()
.any(|(_, e)| e.subject == "Marked journal-me" && e.journals == vec![number(&remote)])
};
let mut deleted = false;
for _ in 0..100 {
if fallback(&all_entries(test).await) {
break;
}
let (_, response) = call(&admin, "x:QueuedMessage/get", json!({"ids": null})).await;
if let Some(queued) = response["list"]
.as_array()
.unwrap()
.iter()
.find(|m| m.to_string().contains("[email protected]"))
{
let queued_id = queued["id"].as_str().unwrap().to_string();
let (_, response) = call(
&admin,
"x:QueuedMessage/set",
json!({"destroy": [queued_id.clone()]}),
)
.await;
deleted = response["destroyed"] == json!([queued_id]);
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let kept: Vec<Entry> = all_entries(test)
.await
.into_iter()
.map(|(_, e)| e)
.filter(|e| e.subject == "Marked journal-me")
.collect();
assert_eq!(kept.len(), 2, "{kept:?}");
assert!(kept.iter().any(|e| e.journals == vec![number(&remote)]));
let (_, response) = call(
&admin,
"inbuxa:Journal/get",
json!({"ids": [remote.clone()]}),
)
.await;
let failures = &response["list"][0]["archiveFailures"];
assert_eq!(failures["count"], 1, "{response}");
assert_eq!(
failures["lastReason"],
if deleted {
"it wasn't delivered before leaving the queue"
} else {
"the archive refused it"
},
"{response}"
);
let (_, response) = call(
&admin,
"inbuxa:Journal/get",
json!({"ids": [local.clone()]}),
)
.await;
assert_eq!(response["list"][0]["archiveFailures"]["count"], 0);
// Delivered to an archive here: the report arrives, and nothing goes
// into the built-in journal for that journal
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]"],
"To the vault",
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
let mut arrived = Vec::new();
for _ in 0..100 {
let (_, response) = call(
&vault,
"Email/query",
json!({"filter": {"subject": "Journal report: To the vault"}}),
)
.await;
arrived = response["ids"].as_array().cloned().unwrap_or_default();
if !arrived.is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
assert_eq!(arrived.len(), 1, "the report arrived");
assert!(entry_for(test, "To the vault").await.is_none());
assert!(
all_entries(test)
.await
.iter()
.all(|(_, e)| !e.subject.starts_with("Journal report")),
"reports aren't journaled"
);
let (_, response) = call(
&admin,
"inbuxa:Journal/get",
json!({"ids": [local.clone()]}),
)
.await;
assert_eq!(response["list"][0]["archiveFailures"]["count"], 0);
call(&admin, "inbuxa:MailRule/set", json!({"destroy": [rule]})).await;
call(
&admin,
"inbuxa:Journal/set",
json!({"destroy": [rules_only, local, remote]}),
)
.await;
inbuxa_features::journal::invalidate();
}
/// Phase 4: searching, reading and exporting over JMAP, by a Compliance
/// Officer, each recorded; administrators set journals up but don't read
/// them; the chain check.
pub async fn search(test: &mut TestServer) {
println!("Running journal search tests...");
let admin = test.account("[email protected]");
let sender = admin
.create_user_account(
"[email protected]",
"search-sender-secret-7301",
"Search sender",
&[],
vec![],
)
.await;
let (_, response) = call(
&sender,
"Identity/set",
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
)
.await;
let identity = response["created"]["i"]["id"].as_str().unwrap().to_string();
let (_, response) = call(
&sender,
"Mailbox/set",
json!({"create": {"m": {"name": "Search drafts"}}}),
)
.await;
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
let (_, response) = call(
&admin,
"inbuxa:Journal/set",
json!({"create": {"s": {"name": "Search sender", "enabled": true, "direction": "any",
"scope": {"accounts": [sender.id_string()]}, "retentionDays": 30}}}),
)
.await;
let journal_id = response["created"]["s"]["id"]
.as_str()
.unwrap_or_else(|| panic!("{response}"))
.to_string();
inbuxa_features::journal::invalidate();
for subject in ["Budget draft", "Budget final", "Lunch"] {
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]"],
subject,
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
}
// Administrators set journals up but don't read them
let (name, response) = call(
&admin,
"inbuxa:JournalEntry/query",
json!({"filter": {"sender": "search-sender"}}),
)
.await;
assert_eq!(name, "error", "{response}");
// A Compliance Officer does
let mut officer_role = None;
for id in admin
.registry_query_ids(
ObjectType::Role,
Vec::<(&str, &str)>::new(),
Vec::<&str>::new(),
)
.await
{
let role = admin.registry_get::<Role>(id).await;
if role.description == "Compliance Officer" && role.member_tenant_id.is_none() {
officer_role = Some(id);
}
}
let officer = admin
.create_user_account(
"[email protected]",
"journal-officer-secret-7302",
"Officer",
&[],
vec![],
)
.await;
admin
.registry_update_object(
ObjectType::Account,
officer.id(),
json!({Property::Roles: UserRoles::Custom(CustomRoles {
role_ids: Map::new(vec![officer_role.expect("the officer role")]),
})}),
)
.await;
let (_, response) = call(
&officer,
"inbuxa:JournalEntry/query",
json!({"filter": {"sender": "search-sender", "text": "budget"}, "calculateTotal": true}),
)
.await;
assert_eq!(response["total"], 2, "{response}");
let ids = response["ids"].clone();
let (_, response) = call(&officer, "inbuxa:JournalEntry/get", json!({"ids": ids})).await;
let list = response["list"].as_array().unwrap();
assert_eq!(list.len(), 2, "{response}");
assert_eq!(list[0]["subject"], "Budget final", "newest first");
assert_eq!(list[0]["direction"], "outgoing");
assert_eq!(list[0]["journalIds"], json!([journal_id]));
assert!(list[0]["report"].is_null(), "only when asked for");
let first = list[0]["id"].as_str().unwrap().to_string();
let (_, response) = call(
&officer,
"inbuxa:JournalEntry/get",
json!({"ids": [first.clone()], "properties": ["subject", "report"]}),
)
.await;
let report = response["list"][0]["report"].as_str().unwrap_or_default();
assert!(
report.contains("Subject: Journal report: Budget final"),
"{response}"
);
assert!(report.contains("Sender: [email protected]\r\n"));
let (_, response) = call(
&officer,
"inbuxa:JournalEntry/query",
json!({"filter": {"journalId": journal_id, "direction": "incoming"}}),
)
.await;
assert_eq!(response["ids"], json!([]), "{response}");
let (name, _) = call(
&officer,
"inbuxa:JournalEntry/query",
json!({"filter": {"colour": "red"}}),
)
.await;
assert_eq!(name, "error");
// Exports need a reason, and hold every report the filter matches
let (_, response) = call(
&officer,
"inbuxa:JournalExport/set",
json!({"create": {"x": {"filter": {"sender": "search-sender"}}}}),
)
.await;
assert_eq!(
response["notCreated"]["x"]["properties"],
json!(["reason"]),
"{response}"
);
let (_, response) = call(
&officer,
"inbuxa:JournalExport/set",
json!({"create": {"x": {"filter": {"sender": "search-sender"}, "reason": "Case 12"}}}),
)
.await;
let export = &response["created"]["x"];
assert_eq!(export["count"], 3, "{response}");
assert!(export["blobId"].as_str().is_some());
assert_eq!(export["sha256"].as_str().map(str::len), Some(64));
// The chain check, which the officer may run too
let (_, response) = call(
&officer,
"inbuxa:JournalVerification/set",
json!({"create": {"v": {}}}),
)
.await;
assert_eq!(response["created"]["v"]["verified"], true, "{response}");
// The officer changes no journals
let (name, _) = call(
&officer,
"inbuxa:Journal/set",
json!({"destroy": [journal_id.clone()]}),
)
.await;
assert_eq!(name, "error");
// Every search, listing, read, export and check is recorded
let (_, response) = call(
&admin,
"inbuxa:AuditEvent/query",
json!({"filter": {"targetKind": "inbuxa:JournalEntry", "actorId": officer.id_string()}}),
)
.await;
let ids = response["ids"].clone();
let (_, response) = call(&admin, "inbuxa:AuditEvent/get", json!({"ids": ids})).await;
let details: Vec<String> = response["list"]
.as_array()
.unwrap()
.iter()
.map(|e| format!("{} {}", e["action"], e["details"]))
.collect();
for expected in [
"Searched the journal",
"Listed 2 journal entries",
"Read a journaled message from [email protected]",
"Exported 3 journal entries",
"verify",
] {
assert!(
details.iter().any(|d| d.contains(expected)),
"{expected}: {details:?}"
);
}
call(
&admin,
"inbuxa:Journal/set",
json!({"destroy": [journal_id]}),
)
.await;
inbuxa_features::journal::invalidate();
}
struct Raw(Vec<u8>);
impl Deserialize for Raw {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
Ok(Raw(bytes.to_vec()))
}
}
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn journal_tests() {
let mut test = TestServerBuilder::new("journal_tests")
.await
.with_default_listeners()
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
test.insert_account(admin);
self::test(&mut test).await;
self::archive(&mut test).await;
self::search(&mut test).await;
if test.is_reset() {
test.temp_dir.delete();
}
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -14,6 +14,9 @@ pub mod ai_explain;
pub mod account_lock; // inbuxa: account lock with delegation
pub mod legal_hold; // inbuxa: legal hold
pub mod compliance; // inbuxa: the compliance roles
pub mod mail_rules; // inbuxa: DLP and mail flow rules
pub mod security_acceptances; // inbuxa: accepted security to-do items
pub mod journal; // inbuxa: journaling
pub mod audit; // inbuxa: the audit log
pub mod authorization;
pub mod auto_reload; // inbuxa: registry writes apply at once
+233
View File
@@ -0,0 +1,233 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:SecurityAcceptance` (security to-do list spec, SS-23 to SS-26):
//! an accepted item is kept with who, when and why, a note is required,
//! nothing is edited, only administrators may accept, and every acceptance
//! made or removed is in the audit log.
use crate::utils::{
account::Account,
server::{TestServer, TestServerBuilder},
};
use serde_json::{Value, json};
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:inbuxa:jmap",
"urn:inbuxa:jmap:registry",
];
async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) {
if arguments.get("accountId").is_none() {
arguments["accountId"] = account.id_string().into();
}
let response = account
.jmap_request(USING, json!([[method, arguments, "0"]]))
.await;
let call = response
.0
.pointer("/methodResponses/0")
.cloned()
.unwrap_or_else(|| panic!("{method}: {}", response.0));
(
call[0].as_str().unwrap_or_default().to_string(),
call[1].clone(),
)
}
async fn list(account: &Account) -> Vec<Value> {
let (name, response) = call(
account,
"inbuxa:SecurityAcceptance/get",
json!({"ids": null}),
)
.await;
assert_eq!(name, "inbuxa:SecurityAcceptance/get", "{response}");
response["list"].as_array().unwrap().clone()
}
pub async fn test(test: &mut TestServer) {
println!("Running security acceptance tests...");
let admin = test.account("[email protected]");
// Accepted, with the server's who and when
let (_, response) = call(
&admin,
"inbuxa:SecurityAcceptance/set",
json!({"create": {
"plain": {
"check": "SS-1",
"subject": "",
"acceptedValue": true,
"note": " Old scanners on the LAN; replaced in March. "
},
"relay": {
"check": "SS-2",
"acceptedValue": {"match": {}, "else": "is_local_ip(remote_ip)"},
"note": "The office printer relays through us."
}
}}),
)
.await;
let plain_id = response["created"]["plain"]["id"]
.as_str()
.unwrap_or_else(|| panic!("accepted: {response}"))
.to_string();
assert_eq!(
response["created"]["plain"]["acceptedBy"], "[email protected]",
"{response}"
);
let relay_id = response["created"]["relay"]["id"]
.as_str()
.unwrap()
.to_string();
let all = list(&admin).await;
assert_eq!(all.len(), 2, "{all:?}");
let plain = all.iter().find(|a| a["id"] == plain_id.as_str()).unwrap();
assert_eq!(plain["check"], "SS-1");
assert_eq!(plain["subject"], "");
assert_eq!(plain["acceptedValue"], true);
assert_eq!(plain["note"], "Old scanners on the LAN; replaced in March.");
assert!(
plain["acceptedAt"]
.as_str()
.is_some_and(|d| d.ends_with('Z')),
"{plain}"
);
let relay = all.iter().find(|a| a["id"] == relay_id.as_str()).unwrap();
assert_eq!(relay["acceptedValue"]["else"], "is_local_ip(remote_ip)");
// A note is required, the check must be one of ours, and what the
// server sets can't be sent
let (_, response) = call(
&admin,
"inbuxa:SecurityAcceptance/set",
json!({"create": {
"nonote": {"check": "SS-1", "acceptedValue": true, "note": " "},
"nocheck": {"check": "SS-99", "acceptedValue": true, "note": "x"},
"by": {"check": "SS-1", "acceptedValue": true, "note": "x", "acceptedBy": "someone"}
}}),
)
.await;
assert_eq!(
response["notCreated"]["nonote"]["properties"][0], "note",
"{response}"
);
assert_eq!(
response["notCreated"]["nocheck"]["properties"][0], "check",
"{response}"
);
assert_eq!(
response["notCreated"]["by"]["properties"][0], "acceptedBy",
"{response}"
);
assert_eq!(list(&admin).await.len(), 2);
// Replaced, never edited
let (name, response) = call(
&admin,
"inbuxa:SecurityAcceptance/set",
json!({"update": {plain_id.as_str(): {"note": "changed"}}}),
)
.await;
assert_eq!(name, "error", "an acceptance was edited: {response}");
// Only administrators: someone without the permissions neither sees
// nor accepts
let user = admin
.create_user_account(
"[email protected]",
"user-secret-8812",
"User",
&[],
vec![],
)
.await;
let (name, response) = call(
&user,
"inbuxa:SecurityAcceptance/set",
json!({"create": {"x": {"check": "SS-1", "acceptedValue": true, "note": "mine"}}}),
)
.await;
assert_eq!(name, "error", "a user accepted an item: {response}");
let (name, response) = call(&user, "inbuxa:SecurityAcceptance/get", json!({"ids": null})).await;
assert_eq!(name, "error", "a user read acceptances: {response}");
// Removed
let (_, response) = call(
&admin,
"inbuxa:SecurityAcceptance/set",
json!({"destroy": [plain_id, "zzzzzz"]}),
)
.await;
assert_eq!(response["destroyed"], json!([plain_id]), "{response}");
assert!(
response["notDestroyed"].get("zzzzzz").is_some(),
"{response}"
);
let remaining = list(&admin).await;
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0]["check"], "SS-2");
// SS-26: accepted and removed are both in the audit log, with who and
// what
let (_, response) = call(
&admin,
"inbuxa:AuditEvent/query",
json!({"filter": {"targetKind": "inbuxa:SecurityAcceptance"}}),
)
.await;
let ids = response["ids"].clone();
let (_, response) = call(&admin, "inbuxa:AuditEvent/get", json!({"ids": ids})).await;
let events = response["list"].as_array().unwrap();
let created = events
.iter()
.filter(|e| e["action"] == "create" && e["outcome"]["status"] == "success")
.count();
assert_eq!(created, 2, "{response}");
let removed = events
.iter()
.find(|e| e["action"] == "destroy" && e["outcome"]["status"] == "success")
.unwrap_or_else(|| panic!("no removal recorded: {response}"));
assert_eq!(removed["target"]["name"], "SS-1", "{removed}");
assert!(
events
.iter()
.all(|e| e["actor"]["name"] == "[email protected]"),
"{response}"
);
assert!(
response.to_string().contains("Old scanners on the LAN"),
"the note isn't in the record: {response}"
);
// Cleared for the tests that follow
call(
&admin,
"inbuxa:SecurityAcceptance/set",
json!({"destroy": [relay_id]}),
)
.await;
}
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn security_acceptance_tests() {
let mut test = TestServerBuilder::new("security_acceptance_tests")
.await
.with_default_listeners()
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
test.insert_account(admin);
self::test(&mut test).await;
if test.is_reset() {
test.temp_dir.delete();
}
}