Commit Graph
226 Commits
Author SHA1 Message Date
jcoffey-dev 1a23243cc1 Merge pull request 'Release 2026.10.5.1' (#152) from release/2026.10.5.1-pr into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
publish / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 47m10s
announce / announce (release) Successful in 23s
github/ci (tag) GitHub Actions
publish / github (push) Successful in 1h2m30s
publish / publish-amd64 (push) Skipped
publish / publish-arm64 (push) Skipped
publish / release (push) Skipped
publish / binaries (push) Skipped
publish / announce (push) Failing after 10s
2026-10-05 23:37:40 +00:00
jcoffey-dev 098abb102a Release 2026.10.5.1
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m46s
2026-10-05 16:29:17 -07:00
jcoffey-dev a24ed3b60a Deliverability check: each node asks what the internet sees of it
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m6s
Deliverability spec (inbuxa-drafts specs/deliverability.md), the server
side. Every node that sends mail checks itself once a day, at its own
minute in the first hour (UTC), and when an administrator asks:

- its outgoing addresses (the connection strategy's, or what its EHLO
  name resolves to), their reverse DNS and whether it resolves back,
  and nine blocklists, read by each list's own codes so a refused
  query is never taken for a listing (DL-1 to DL-6);
- for every domain: SPF for each address, each DKIM key (by signing a
  message that's never sent and verifying it as a receiver would),
  DMARC, the MTA-STS policy against the MX, TLS reporting, and the
  domain blocklists (DL-7 to DL-12);
- whether it holds a certificate for its EHLO and MX names (DL-13).

It keeps one report per node, facts only; the console grades them.

- inbuxa:DeliverabilityReport: /get, and a create that asks every node
  to check now, broadcast as DeliverabilityCheck (DL-15). A tenant
  administrator gets their own domains only (DL-20).
- inbuxa:DeliverabilitySettings: which built-in lists are left out, and
  the lists themselves (DL-6).
- sysDeliverabilityGet, sysDeliverabilityUpdate, sysDeliverabilityCheck;
  a tenant ceiling always turns the last two off.
2026-10-05 16:17:33 -07:00
jcoffey-dev 461f5fab3c Merge branch 'main' into fix/group-collections-no-onward-share
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 15s
github/ci (branch) GitHub Actions
ci / build (pull_request) Successful in 8m15s
2026-10-05 23:08:04 +00:00
jcoffey-dev fb785b8635 Who may share mail: a server switch, and a tenant's that can only be stricter
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 15s
ci / build (pull_request) Successful in 4m6s
github/ci (branch) GitHub Actions
A school, or any organization that doesn't want people's mailboxes
shared, can now turn that off (multi-account spec, MA-C). Two switches
at two levels, as the legacy-protocols switch has:

- mailSharing: people may share their own mail folders;
- addAccounts: people may add other accounts to the webmail (read by
  the webmail's account switcher, MA-B).

inbuxa:SharingPolicy/get and /set hold them: the server's policy has
the singleton id, each tenant's has the tenant's id. Both default to
on, so nothing changes until someone turns one off. A tenant's
administrator changes their own tenant's (the domain's permissions, as
for its protocols switch); only a server administrator with
sysSharingUpdate changes the server's; a tenant can never be looser
than the server (forbidden). Every change goes through the audit log,
and rebuilds every access token, here and on every node.

With mail sharing off for an account's tenant (or the server):

- Mailbox/set and IMAP SETACL refuse to start or widen a share
  (forbidden / NO [NOPERM]); narrowing or ending one is always allowed;
- shares already made give nothing while it is off: an access token
  leaves out mailbox grants from such an owner. They stay stored, so
  turning sharing back on restores them (John, 2026-10-05);
- a lock's and a shared mailbox's grants are an administrator's and
  always count, and group membership was never a share.

The session's own account says mailSharing and addAccounts, the
stricter of the two levels, so front ends can hide what is off.

Tests: a new sharing_policy suite with a school tenant, its own
administrator and two people outside it: on by default; the school's
administrator turns it off but can't touch the server's; an old share
stops working and a new one is refused while someone outside the school
is unaffected; a shared mailbox in the school keeps working; the server
off can't be loosened by the tenant; on again restores the old share;
ending a share works while off; and every change is audited. A unit
test covers the stricter-only rule. sharing_policy_tests, jmap_tests,
imap_tests, account_lock_tests and audit_log_tests pass (RocksDB).
2026-10-05 16:00:09 -07:00
jcoffey-dev 9976d52e29 Shared mailboxes: a second kind of account lock
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 1m8s
ci / build (pull_request) Successful in 4m30s
github/ci (branch) GitHub Actions
A shared mailbox (support@, legal@) belongs to no one person: nobody
signs in to it, and the people assigned open it beside their own mail
at an access level an administrator chose. An account lock already is
most of that: it keeps receiving mail, refuses every sign-in, and its
delegates reach it through real grants on every container (so IMAP,
DAV and JMAP honor them), never including Share. So a shared mailbox is
a lock of a second kind (multi-account spec, MA-S; John, 2026-10-05).

Lock gains kind: "lock" (the default, so stored locks read as before)
or "sharedMailbox", set on create and fixed after. A shared mailbox:

- needs no reason to make, change or end;
- holds up to 100 people, where a lock holds 10;
- runs its own Sieve replies and redirects, so an automatic
  acknowledgement goes out (a lock answers no one);
- records only what is sent as it (audit_send_as, which now covers it),
  not AL-9's access and per-change records, which would bury the log
  for a busy desk;
- sends only as itself (MA-S3): From and Reply-To must be its own
  addresses, so answers come back to the mailbox and not to whoever
  replied; anything else is forbiddenFrom.

The session marks it delegation: {locked: true, kind: "sharedMailbox"},
so a front end that knows no kind still treats it as a lock. The
console's layout gains Management › Directory › Shared Mailboxes
(CustomComponent/SharedMailboxes).

Tests: the account lock suite now goes on to a shared mailbox: made
without a reason with twelve people, sign-in refused, the session's
kind, its vacation reply delivered, an answer sent as it and recorded
as the agent with no per-change records, and a Reply-To naming the
agent refused; a lock unit test reads a stored lock without a kind.
account_lock_tests, jmap_tests, audit_log_tests and imap_tests pass
(RocksDB).
2026-10-05 15:27:52 -07:00
jcoffey-dev 58d2804278 Don't let a group's members share its calendars, address books or files
github/ci (branch) GitHub Actions
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Canceled after 25m26s
#146 stopped a group's members sharing its mailboxes on. The same
shortcut lets them through everywhere else a group owns things: a
member counts as the account's owner, so Calendar/set, AddressBook/set
and FileNode/set skip the share check, and so does the WebDAV ACL
method. Who has what a group owns is decided by who is in the group.

For a member through a group only (is_group_member_only):

- Calendar/set, AddressBook/set and FileNode/set refuse a shareWith
  change as forbidden, on create and update; for files at the top of
  the account too, not only inside a folder;
- the DAV ACL method answers 403 on the group's calendars, address
  books and files;
- myRights reports mayShare false (JmapRights::owner_rights), and the
  DAV current-user-privilege-set leaves out all and write-acl.

Reading who something is shared with is unchanged, as in JMAP.

Tests: a new jmap::group_share module has a member create with a
share, create without one (and check myRights), share afterwards, and
an outsider reach each kind; the WebDAV ACL test has a member try the
ACL method on the group's folders; the IMAP ACL test now checks #146's
SETACL refusal, which had no test of its own. jmap_tests, webdav_tests
and imap_tests pass (RocksDB). specs/multi-account.md MA-D0.
2026-10-05 15:03:15 -07:00
jcoffey-dev 5f6548bfdd Merge pull request 'Don't let a group's members share its mailboxes on' (#146) from fix/group-mailbox-no-onward-share into main
ci / github (push) Skipped
ci / fork-checks (push) Successful in 15s
github/ci (branch) GitHub Actions
ci / build (push) Successful in 48m50s
2026-10-05 21:26:52 +00:00
jcoffey-dev daa484efbc Merge pull request 'Refuse an empty JMAP id instead of reading it as id 0' (#145) from fix/empty-jmap-id into main
ci / fork-checks (push) Canceled after 0s
ci / build (push) Canceled after 0s
ci / github (push) Canceled after 0s
github/ci (branch) GitHub Actions
2026-10-05 21:26:51 +00:00
jcoffey-dev a19d9eec89 Add the modification notice to the files this changes
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 15s
ci / build (pull_request) Successful in 5m18s
github/ci (branch) GitHub Actions
2026-10-05 14:20:44 -07:00
jcoffey-dev 5c1c4c6248 Add the modification notice to the files this changes
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 15s
ci / build (pull_request) Successful in 5m38s
github/ci (branch) GitHub Actions
2026-10-05 14:20:38 -07:00
jcoffey-dev 2d8728793c Don't let a group's members share its mailboxes on
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Failing after 1m16s
ci / build (pull_request) Canceled after 5m6s
A group's members reach its mailbox through membership, which counts
as owning the account, so every ACL check was skipped: on a scratch
server a member gave an outsider read access to the group's Inbox with
one Mailbox/set shareWith, with no administrator involved and nothing
audited. Who is in a group is an administrator's decision.

AccessToken::is_group_member_only names that case (in the account
only through a group, without Impersonate). For such a member:

- Mailbox/set with a shareWith change, on create or update, is
  refused as forbidden;
- IMAP SETACL and DELETEACL answer NO [NOPERM];
- myRights reports mayShare false, and MYRIGHTS leaves out "a";
  every other right stays.

Administrators and the account itself are unchanged. The JMAP ACL
test's group section now checks all three for a member and that the
outsider still has nothing (specs/multi-account.md, MA-D0, G1).

jmap_tests and imap_tests pass (RocksDB). The IMAP refusal has no test
of its own yet; imap_tests passing shows the rest is unchanged.
2026-10-05 14:15:58 -07:00
jcoffey-dev 9429f1de00 Refuse an empty JMAP id instead of reading it as id 0
ci / github (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / fork-checks (pull_request) Failing after 50s
ci / build (pull_request) Canceled after 5m40s
An Email/set with mailboxIds {"": true} was accepted and filed the
message in the Inbox. Id::from_str returned 0 for an empty string, and
document 0 is each collection's first: the Inbox for mail. RFC 8620
§1.2 ids are 1 to 255 characters, so "" is refused now, and every
caller already treats a refused id as invalid or not found.

Over-long ids still parse as they did; upstream's test accepts them on
purpose. Found while probing group mailboxes on a scratch server
(specs/multi-account.md, G3).

types tests, jmap_tests and imap_tests pass (RocksDB).
2026-10-05 14:15:39 -07:00
jcoffey-dev 76c170db9d Audit mail sent from an address that isn't the sender's own
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 48s
ci / build (pull_request) Successful in 8m50s
github/ci (branch) GitHub Actions
A group's members can send as the group, and the message says only
From: the group, so nothing recorded which person sent it. Every
submission whose envelope sender belongs to another account now writes
an audit record: the person as actor, an EmailSubmission target named
by the address and owned by that account, and "Sent as <address>",
with ", from <account>" when it went out through the sender's own
account rather than the group's.

A delegate's send is left to AL-9's record, and a send from the
sender's own address writes nothing. No Sender: header is added: the
audit log is where the real sender is named. email_submission_set now
takes the access token, from its one caller.

The audit suite has a group member send once as the group (one
record, with the address, account and details) and once as themselves
(none) (specs/multi-account.md, MA-D0a, G2).
2026-10-05 14:07:25 -07:00
jcoffey-dev 282ad5fc13 Release 2026.10.5
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 5m45s
2026-10-04 22:18:55 -07:00
jcoffey-dev c4a6e4d117 Check TLSA lookups for false bogus verdicts too
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
ci / github (pull_request) Successful in 6m46s
github/ci (branch) GitHub Actions
Mail to chuckmckinnon.com sat in the queue for days with "Error fetching
TLSA record: DNSSEC validation failed". Its MX, mail.usefulinsight.com,
is on Cloudflare, and behind Hetzner's resolvers
_25._tcp.mail.usefulinsight.com answers TLSA with a signed CNAME to the
zone apex, which has no TLSA record. That is the second hickory 0.26.3
bug #72 works around: it checks the denial against the name first asked
for, not the CNAME's target, and calls a valid answer bogus.

#72 put MX and address lookups through validated_lookup but left the
TLSA lookup calling hickory directly. It goes through validated_lookup
now: a signed CNAME is followed, the denial at the target validates, and
the result is "no TLSA record", so delivery goes ahead without DANE as
it should. A TLSA record that rechecks as insecure is treated as no
policy, since DANE needs a signed one.

Cloudflare's own resolver answers that name with a compact denial at the
name itself, which hickory already accepts, so the new ignored test
takes a resolver from INBUXA_TEST_DNS_TCP. Run against 185.12.64.2 over
an SSH bridge from host1, hickory alone fails with "DNSSEC validation
failed", as in production, and validated_lookup returns a non-bogus
denial. smtp lib tests pass; check --all-targets is clean.
2026-10-04 22:11:39 -07:00
jcoffey-dev 083f22d6fb Call the webmail inbuxa-webmail in docs and comments
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 25m30s
The webmail repository was renamed from ihasmail-inbuxa to inbuxa-webmail
on 2026-10-05. The OAuth client id stays ihasmail-inbuxa: that is what the
server registers, so the backticked and quoted ids are unchanged.
2026-10-04 20:36:03 -07:00
jcoffey-dev c9f8028502 Release 2026.9.30.2
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m45s
2026-09-30 19:01:58 -07:00
jcoffey-dev 30d4cef0e7 Metric history: only the calculating node stores cluster-wide gauges
ci / build (pull_request) Skipped
ci / fork-checks (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m5s
queue.count, user.count and domain.count count the whole cluster, and
only the node with the metrics-calculation role works them out. Every
node still stored them. On the others the queue gauge only moves with
local queue events, so it had drifted below zero (production: node 0 at
18,446,744,073,709,551,596, node 1 at ...613, i.e. -20 and -3), and
the account and domain counts stayed at 0. A reader taking the latest
reading got whichever node wrote last.

sample() now takes whether the node calculates them and leaves them out
otherwise. A unit test covers both cases.
2026-09-30 18:51:22 -07:00
jcoffey-dev 1c1838af05 Release 2026.9.30.1
github/ci (branch) GitHub Actions
publish / version (push) Skipped
publish / publish-amd64 (push) Skipped
publish / publish-arm64 (push) Skipped
publish / release (push) Skipped
publish / binaries (push) Skipped
ci / github (pull_request) Successful in 6m45s
announce / announce (release) Successful in 10s
publish / github (push) Failing after 1h16m13s
publish / announce (push) Skipped
github/ci (tag) GitHub Actions
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
2026-09-30 13:45:36 -07:00
jcoffey-dev d9754c46a6 Release 2026.9.30
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
publish / version (push) Skipped
publish / publish-amd64 (push) Skipped
publish / publish-arm64 (push) Skipped
publish / release (push) Skipped
publish / binaries (push) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 11m5s
github/ci (tag) GitHub Actions
publish / github (push) Failing after 49m30s
publish / announce (push) Skipped
2026-09-30 12:04:16 -07:00
jcoffey-dev 20abf69d31 x:Metric: say which node wrote each sample
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m5s
Each node stores histograms as running totals since it started. A sample
didn't say which node wrote it (the node was only in the id's low bits),
so a reader couldn't diff totals per node, and the console diffed across
nodes: on the three-node production cluster the delivery attempt time
read 14.7 s over the last hour against 0.7 s from the nodes' own figures.

x:Metric/get now returns nodeId alongside timestamp, both from the id.
The telemetry suite checks every sample carries it.
2026-09-30 11:43:13 -07:00
jcoffey-dev f1f112fc38 Release 2026.9.29.2
ci / fork-checks (pull_request) Successful in 52s
ci / build (pull_request) Successful in 16m40s
2026-09-29 10:10:08 -07:00
jcoffey-dev a5c8927dbc Merge pull request 'Take a token, never a password, outside DAV' (#122) from feature/http-basic-dav-only into main
ci / fork-checks (push) Canceled after 7s
ci / build (push) Canceled after 7s
2026-09-29 17:04:01 +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 faf3d1e056 Take a token, never a password, outside DAV
ci / fork-checks (pull_request) Successful in 17s
ci / build (pull_request) Successful in 7m41s
Anyone could host a copy of a front end on a server of their own,
collect a person's password there, and replay it as HTTP Basic against
JMAP or the API. Cross-origin rules don't stop that, since a server
isn't a browser, and neither does client registration, since Basic
never goes through OAuth (contract C-23).

JMAP (session, API, upload, download, event source, WebSocket), /api,
/auth/introspect, /auth/userinfo and authenticated /auth/register now
refuse an Authorization: Basic header before looking at the password,
with a 401 whose only challenge is Bearer. A wrong password gets the
same answer as the right one. CalDAV and CardDAV keep Basic, and their
401s still offer it. The sign-in page's /api/auth takes the password in
its body and is unaffected, as is the token endpoint's client
authentication.

Bootstrap and recovery mode accept Basic everywhere, as they keep
permissive CORS. INBUXA_HTTP_BASIC_AUTH=all puts it back everywhere;
dav is the default, and any other value logs a warning and keeps it.
Test builds accept Basic everywhere, since the integration suites sign
in with passwords, and legacy_protocols.py sets the variable.

Tested: unit tests for the paths, and tests/e2e/http_basic_auth.py
against the debug build, 26 checks, including both front ends' sign-in
path and a refused unregistered redirect.
2026-09-29 07:02:05 -07: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 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 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 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 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 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 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 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 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 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 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