Compare commits

..
Author SHA1 Message Date
jcoffey-dev 88e756bc3a Merge pull request 'Release 2026.10.6' (#154) from release/2026.10.6-pr into main
ci / fork-checks (push) Skipped
ci / build (push) 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 (push) Successful in 39m48s
announce / announce (release) Successful in 10s
github/ci (tag) GitHub Actions
publish / github (push) Successful in 1h6m10s
publish / announce (push) Failing after 8s
2026-10-06 03:27:45 +00:00
jcoffey-dev f77d171063 Release 2026.10.6
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 8m6s
2026-10-05 20:19:28 -07:00
jcoffey-dev 79db6c537b Merge pull request 'Domains menu: Deliverability' (#153) from feat/deliverability-menu into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 40m48s
2026-10-06 01:28:09 +00:00
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 ca4bf75c1b Domains menu: Deliverability, after DKIM Signatures
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m50s
The console's Domains › Deliverability page (deliverability spec, DL-17),
for the reports #150 added.
2026-10-05 16:33:17 -07:00
jcoffey-dev 8a596c44ac Merge pull request 'SPEC §2.4: allow factual comparisons, never with Stalwart' (#151) from docs/spec-comparisons into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 4m50s
2026-10-05 23:32:44 +00:00
jcoffey-dev c50d5eb109 Merge pull request 'Deliverability check: each node asks what the internet sees of it' (#150) from feat/deliverability into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 3m13s
2026-10-05 23:29:27 +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 c1702a00bb SPEC §2.4: allow factual comparisons, never with Stalwart
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m5s
The last bullet forbade comparison of any kind. Public material may now
compare inbuxa with the hosted suites organizations choose between and
with other self-hosted mail stacks, when the comparison is factual,
dated, names no price and says when the other choice is better.
Stalwart is still never compared: no editions, no pricing, no
commentary on Stalwart Labs or other forks.

The lineage is now told in the past tense ("started as a fork of
Stalwart"), once, with the clean-room provenance on one documentation
page that everything else links to.
2026-10-05 16:26:01 -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 f791c78d17 Merge pull request 'Don't let a group's members share its calendars, address books or files' (#147) from fix/group-collections-no-onward-share into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 12m49s
2026-10-05 23:16:39 +00: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 4f25927d18 Merge pull request 'Who may share mail: a server switch, and a tenant's that can only be stricter' (#149) from feat/sharing-policy into main
ci / github (push) Skipped
github/ci (branch) GitHub Actions
ci / fork-checks (push) Successful in 1m24s
ci / build (push) Canceled after 9m34s
2026-10-05 23:07:10 +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 50a03df30b Merge pull request 'Shared mailboxes: a second kind of account lock' (#148) from feat/shared-mailbox-lock-kind into main
ci / github (push) Skipped
github/ci (branch) GitHub Actions
ci / fork-checks (push) Successful in 1m0s
ci / build (push) Canceled after 34m19s
2026-10-05 22:32:53 +00: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 1aedc77791 Merge pull request 'Audit mail sent from an address that isn't the sender's own' (#144) from fix/audit-send-as into main
ci / github (push) Skipped
github/ci (branch) GitHub Actions
ci / fork-checks (push) Successful in 1m13s
ci / build (push) Canceled after 5m47s
2026-10-05 21:21:16 +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 d7bebd454d Merge pull request 'Release 2026.10.5' (#143) from release/2026.10.5-pr into main
ci / fork-checks (push) Skipped
ci / build (push) 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 (push) Successful in 41m48s
publish / github (push) Failing after 1h27m32s
publish / announce (push) Skipped
announce / announce (release) Successful in 21s
github/ci (tag) GitHub Actions
2026-10-05 05:25:14 +00: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 133d41df36 Merge pull request 'Check TLSA lookups for false bogus verdicts too' (#142) from fix/tlsa-false-bogus into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 6m40s
2026-10-05 05:18:42 +00: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 f59a9de4dc Merge pull request 'Call the webmail inbuxa-webmail in docs and comments' (#141) from docs/inbuxa-webmail-name into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 1h2m11s
2026-10-05 04:02:07 +00: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 c43abef8ab Merge pull request 'Release 2026.9.30.2' (#140) from release/2026.9.30.2-pr into main
ci / fork-checks (push) Skipped
ci / build (push) 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 (push) Successful in 42m10s
publish / github (push) Successful in 1h9m32s
publish / announce (push) Failing after 22s
announce / announce (release) Successful in 21s
github/ci (tag) GitHub Actions
2026-10-01 02:09:11 +00: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 cd7a0f4163 Merge pull request 'Metric history: only the calculating node stores cluster-wide gauges' (#139) from fix/cluster-gauges-one-node into main
ci / fork-checks (push) Skipped
github/ci (branch) GitHub Actions
ci / build (push) Skipped
ci / github (push) Canceled after 9m58s
2026-10-01 01:59:10 +00: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 6c1eeea038 Merge pull request 'ci: retry release file uploads over HTTP/1.1' (#138) from ci/release-upload-retry into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 49m48s
2026-09-30 22:24:27 +00:00
jcoffey-dev ea9a6f0c58 ci: retry release file uploads over HTTP/1.1
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m45s
The v2026.9.30.1 binaries job lost a 50 MB upload to Gitea's release
API on each of its two runs (curl 92, HTTP/2 PROTOCOL_ERROR; the origin
logged 400 with no body), arm64 the first time and amd64 the second.
The uploads cross Cloudflare. A failed run also left the release short
of the file it had just deleted.

Uploads now go over HTTP/1.1, and every API call retries 5 times.
2026-09-30 15:17:05 -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 78c9490b1e Merge pull request 'ci: give the release link swap on GitHub runners' (#136) from ci/release-link-swap into main
github/ci (branch) GitHub Actions
ci / github (push) Successful in 41m9s
ci / build (push) Skipped
ci / fork-checks (push) Skipped
2026-09-30 20:45:24 +00:00
jcoffey-dev ce2742fc80 ci: give the release link swap on GitHub runners
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m25s
The v2026.9.30 tag build's arm64 publish job was killed linking the
inbuxa binary (fat LTO, one codegen unit): cannot allocate memory on the
16 GB ubuntu-24.04-arm runner. index, ghcr, release, binaries and
announce were skipped. amd64 got through on the same size of runner.

Each publish job now adds a 16 GB swap file before the build; buildx's
container has no memory limit of its own, so the linker can use it.
2026-09-30 13:37:27 -07:00
jcoffey-dev 81deaa69c4 Merge pull request 'Release 2026.9.30' (#134) from release/2026.9.30-pr into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 28m9s
2026-09-30 20:17:09 +00: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 4481279f1c Merge pull request 'x:Metric: say which node wrote each sample' (#133) from fix/metric-node-id into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 45m8s
Reviewed-on: #133
2026-09-30 18:56:20 +00: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 69ef48239a Merge pull request 'ci: copy each release image to GHCR as a replica' (#132) from ci/ghcr-replica into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 44m28s
2026-09-30 16:34:53 +00:00
jcoffey-dev 00f00d6d75 ci: copy each release image to GHCR as a replica
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m27s
The Gitea registry stays authoritative; GHCR becomes a copy of it, the way
the GitHub repository is a copy of the Gitea one. After the tag build has
pushed the release image to the registry, a new ghcr job copies it to
ghcr.io under the same version tag and :latest with `imagetools create` --
a copy, not a rebuild, so the digest on GHCR is the digest on the registry.

Anything still pulling the old ghcr.io name, including the TrueNAS app
submission, keeps receiving releases. The job uses the run's own token and is
left out of the status reported to Gitea, so a GHCR problem cannot fail a
release.
2026-09-30 09:27:55 -07:00
jcoffey-dev 68d3ad795e Merge pull request 'ci: copy each release to GitHub after the tag build' (#131) from ci/github-release-copy into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 50m7s
2026-09-30 13:59:22 +00:00
jcoffey-dev d486747c11 Merge pull request 'ci: drop the build cache from tag image builds' (#130) from ci/tag-path-hardening into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 6m56s
2026-09-30 13:52:24 +00:00
jcoffey-dev e69df1ae8d ci: copy each release to GitHub after the tag build
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m47s
The mirror carries tags to GitHub but not releases, so the replica's
Releases page -- and anyone watching the repository there -- stopped at the
last release made on GitHub. After the tag build has published, a new
github-release job copies the tag's Gitea release to a GitHub release: the
same notes, with PR and issue numbers rewritten to Gitea links, the same
files, and a line pointing back to the Gitea release.

It uses the run's own token and is left out of the status reported to
Gitea, so it cannot fail a release. With no Gitea release for the tag it
does nothing.
2026-09-30 06:52:24 -07:00
jcoffey-dev 031d028ba4 ci: drop the build cache from tag image builds
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m28s
GitHub scopes a run's Actions cache to its ref, so the cache a tag build
wrote could only ever be read by that same tag: the next release built cold
anyway. Each release also parked several GB of Rust layers in the
repository's 10 GB cache, enough to evict main's cargo cache and slow
everyday builds too. The image builds now run without a cache.
2026-09-30 06:44:41 -07:00
jcoffey-dev 6d7afc3c06 Merge pull request 'ci: run the github wait job on its own runner label' (#129) from ci/wait-runner into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 47m28s
2026-09-30 07:44:00 +00:00
jcoffey-dev 3d5a1692ab Merge pull request 'docs: point issues and discussions at Gitea and the forum' (#127) from docs/mirror-note into main
ci / build (push) Skipped
ci / fork-checks (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 33s
2026-09-30 07:43:27 +00:00
jcoffey-dev 1de77316f0 ci: run the github wait job on its own runner label
ci / build (pull_request) Skipped
ci / fork-checks (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m30s
The github job only polls Gitea for GitHub's commit status, but it holds a
runner slot for as long as the GitHub build takes -- the better part of an
hour for a cold build. On the shared build runners a handful of those
could take every slot and stall real work, so it now runs on the `wait`
label: a runner of its own, with many slots, no docker socket and a small
CPU and memory cap.
2026-09-30 00:36:20 -07:00
jcoffey-dev 26c7c6a897 Merge pull request 'ci: a cancelled GitHub run no longer reports failure to Gitea' (#128) from fix/ci-report-cancelled into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 9m46s
2026-09-30 07:33:39 +00:00
jcoffey-dev 4ba1896eb1 ci: a cancelled GitHub run no longer reports failure to Gitea
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m5s
The mirror can push one commit twice in quick succession. GitHub then
starts two runs and cancels the older, and that run's report job posted
"failure" for the commit. Gitea's github job, seeing the newest status,
failed the check while the surviving run was still building and later
passed.

A cancelled run now posts nothing and leaves the result to the run that
superseded it. A real failure still reports failure.
2026-09-30 00:26:48 -07:00
jcoffey-dev b0e53ef966 docs: point issues and discussions at Gitea and the forum
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 26m44s
This repository is now push-mirrored to GitHub, where issues and pull
requests would never reach the maintainers. A note under the title says
where development happens, and sends issues to git.coffeylabs.org and
discussions to community.coffeylabs.org.
2026-09-30 00:16:15 -07:00
jcoffey-dev dd57709522 Merge pull request 'ci: build on GitHub via the mirror, switchable with BUILD_ON' (#126) from ci/build-on-github into main
ci / fork-checks (push) Successful in 1m36s
ci / github (push) Skipped
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
ci / github (pull_request) Canceled after 5m7s
ci / build (push) Canceled after 39m39s
github/ci (branch) GitHub Actions
Reviewed-on: #126
2026-09-30 06:52:16 +00:00
jcoffey-dev 3450c31345 Build on the GitHub mirror when BUILD_ON=github
ci / build (pull_request) Successful in 7m46s
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 2m4s
Gitea stays where the project lives and push-mirrors every branch and tag
to GitHub. With the Actions variable BUILD_ON set to 'github' on both
forges, the GitHub copy does the building and reports back to Gitea as a
commit status; unset, nothing changes and Gitea builds as before.

.github/workflows/ci.yml replaces the GitHub-era files. Branch pushes run
what Gitea's ci.yml checks (fork checks, dev build, test targets, the
release profile on main). v* tags run what publish.yml does, with the same
two guards: the image per architecture on native runners side by side,
the multi-arch index and :latest, the Gitea Release if the tag has none,
and the host-install binaries taken out of the image. A final job posts
"github/ci (branch)" or "github/ci (tag)" to the commit on Gitea.

On Gitea, the heavy jobs skip under BUILD_ON=github and a `github` job
waits for that status and passes or fails with it, so pull requests and
merges still look at a Gitea run. The weekly release, the upstream watch
and the announcement stay on Gitea.

Removed: cleanup.yml and publish.yml (GHCR), release.yml (a second weekly
schedule), and dependabot.yml, whose pull request branches every mirror
sync would delete.
2026-09-29 23:06:52 -07:00
jcoffey-dev 29d3a5f779 Merge pull request 'Release 2026.9.29.2' (#125) from release/2026.9.29.2-pr into main
ci / fork-checks (push) Successful in 48s
publish / version (push) Successful in 58s
ci / build (push) Successful in 29m10s
publish / publish-amd64 (push) Successful in 32m14s
publish / release (push) Successful in 9s
publish / publish-arm64 (push) Successful in 40m42s
publish / binaries (push) Successful in 51s
publish / announce (push) Successful in 22s
2026-09-29 17:27:07 +00: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 96be849976 Merge pull request 'Document why the client registration override is setup-only' (#124) from fix/client-override-recovery-only into main
ci / fork-checks (push) Successful in 34s
ci / build (push) Canceled after 22m54s
2026-09-29 17:04:07 +00: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 ad09eeeefb Contract and end-to-end check for the client registration override
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Successful in 4m38s
Documents under C-5 why oAuthClientOverride counts only in bootstrap
and recovery mode, and adds tests/e2e/client_override.py: the
recovery administrator keeps the override in both modes; after setup,
an administrator gets no code for an unregistered client or a
redirect URI its client didn't register, and a device code approved
for an unregistered client can't be exchanged. The script fails
against a build without the change (3 of 8) and passes with it.
2026-09-29 09:46:31 -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
104 changed files with 6056 additions and 695 deletions

No files matched your search

+44 -1
View File
@@ -7,7 +7,12 @@
# instance resolves short `uses:` against itself, never GitHub, so nothing # instance resolves short `uses:` against itself, never GitHub, so nothing
# unreviewed can be pulled in. # unreviewed can be pulled in.
# #
# Not ported, as on GitLab: publish.yml and release.yml still need doing. # BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo),
# fork-checks and build skip here and the `github` job below waits for the
# same work done by .github/workflows/ci.yml on the GitHub mirror, passing or
# failing with it -- so this run still carries the answer pull requests and
# merges look at. Unset, everything builds here as before. If GitHub is
# unavailable, unset BUILD_ON and nothing else has to change.
name: ci name: ci
on: on:
@@ -25,6 +30,7 @@ jobs:
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice # without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
# check diffs against the upstream snapshot branch, hence the full fetch. # check diffs against the upstream snapshot branch, hence the full fetch.
fork-checks: fork-checks:
if: ${{ vars.BUILD_ON != 'github' }}
runs-on: light runs-on: light
container: container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
@@ -52,6 +58,7 @@ jobs:
run: python3 -m unittest discover -s tools/fork/tests run: python3 -m unittest discover -s tools/fork/tests
build: build:
if: ${{ vars.BUILD_ON != 'github' }}
# Either runner (host1 or host2): the build needs no docker socket. # Either runner (host1 or host2): the build needs no docker socket.
runs-on: light runs-on: light
container: container:
@@ -101,3 +108,39 @@ jobs:
used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1) used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1)
echo "target dir: ${used:-0} GB" echo "target dir: ${used:-0} GB"
if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi
# BUILD_ON=github: the GitHub mirror builds this commit and posts the result
# back as the commit status "github/ci (branch)". This waits for that status
# and takes its answer. The mirror pushes on every commit, so a missing
# status means GitHub has not got the push or is not running: after the
# timeout this fails, which is the cue to unset BUILD_ON.
github:
if: ${{ vars.BUILD_ON == 'github' }}
# Its own runner label with plenty of slots: this job only polls, but holds a slot
# for as long as the GitHub build takes, and must not starve the build runners.
runs-on: wait
timeout-minutes: 150
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
steps:
- env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
CONTEXT: github/ci (branch)
run: |
python3 - <<'EOF'
import json, os, time, urllib.request
url = (f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}"
f"/commits/{os.environ['SHA']}/statuses?limit=50")
req = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['TOKEN']}"})
ctx, last = os.environ["CONTEXT"], None
print(f"waiting for '{ctx}' on {os.environ['SHA']}", flush=True)
while True:
mine = [s for s in json.load(urllib.request.urlopen(req)) if s["context"] == ctx]
state = max(mine, key=lambda s: s["id"]) if mine else None
if state and state["status"] != last:
last = state["status"]; print(f"{ctx}: {last} {state.get('target_url', '')}", flush=True)
if last == "success": raise SystemExit(0)
if last in ("failure", "error"): raise SystemExit(1)
time.sleep(20)
EOF
+54 -1
View File
@@ -42,6 +42,14 @@
# #
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's # The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
# own token is refused by the container registry. # own token is refused by the container registry.
#
# BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo), every
# job here but the announcement skips, and the tag is published by
# .github/workflows/ci.yml on the GitHub mirror instead -- same guards, same
# tags, the same Release and binaries, created here through the API. The
# `github` job waits for that run's commit status, "github/ci (tag)", and the
# announcement follows it as it follows the binaries here. Unset, everything
# runs here as before.
name: publish name: publish
on: on:
@@ -50,6 +58,7 @@ on:
jobs: jobs:
version: version:
if: ${{ vars.BUILD_ON != 'github' }}
runs-on: light runs-on: light
container: container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
@@ -88,6 +97,7 @@ jobs:
echo "version $V" echo "version $V"
publish-amd64: publish-amd64:
if: ${{ vars.BUILD_ON != 'github' }}
needs: [version] needs: [version]
runs-on: docker runs-on: docker
container: container:
@@ -128,6 +138,7 @@ jobs:
run: docker logout "$REGISTRY" || true run: docker logout "$REGISTRY" || true
publish-arm64: publish-arm64:
if: ${{ vars.BUILD_ON != 'github' }}
needs: [version, publish-amd64] needs: [version, publish-amd64]
runs-on: docker runs-on: docker
container: container:
@@ -162,11 +173,46 @@ jobs:
- if: always() - if: always()
run: docker logout "$REGISTRY" || true run: docker logout "$REGISTRY" || true
# BUILD_ON=github: waits for the GitHub mirror's run for this tag, which
# posts its result back as the commit status "github/ci (tag)", and takes
# its answer. Fails after the timeout if no answer comes.
github:
if: ${{ vars.BUILD_ON == 'github' }}
# Its own runner label with plenty of slots: this job only polls, but holds a slot
# for as long as the GitHub build takes, and must not starve the build runners.
runs-on: wait
timeout-minutes: 240
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
steps:
- env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ github.sha }}
CONTEXT: github/ci (tag)
run: |
python3 - <<'EOF'
import json, os, time, urllib.request
url = (f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}"
f"/commits/{os.environ['SHA']}/statuses?limit=50")
req = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['TOKEN']}"})
ctx, last = os.environ["CONTEXT"], None
print(f"waiting for '{ctx}' on {os.environ['SHA']}", flush=True)
while True:
mine = [s for s in json.load(urllib.request.urlopen(req)) if s["context"] == ctx]
state = max(mine, key=lambda s: s["id"]) if mine else None
if state and state["status"] != last:
last = state["status"]; print(f"{ctx}: {last} {state.get('target_url', '')}", flush=True)
if last == "success": raise SystemExit(0)
if last in ("failure", "error"): raise SystemExit(1)
time.sleep(20)
EOF
# The weekly release creates its Release (and so the tag) first; a tag # The weekly release creates its Release (and so the tag) first; a tag
# pushed by hand has none. Either way the tag ends up with exactly one # pushed by hand has none. Either way the tag ends up with exactly one
# Release, created once the amd64 image exists so its pull instructions # Release, created once the amd64 image exists so its pull instructions
# work; arm64 and the binaries follow. # work; arm64 and the binaries follow.
release: release:
if: ${{ vars.BUILD_ON != 'github' }}
needs: [version, publish-amd64] needs: [version, publish-amd64]
runs-on: light runs-on: light
container: container:
@@ -216,6 +262,7 @@ jobs:
# `docker create` does not start anything, so pulling an arm64 image on an # `docker create` does not start anything, so pulling an arm64 image on an
# amd64 runner and copying a file out of it needs no emulation. # amd64 runner and copying a file out of it needs no emulation.
binaries: binaries:
if: ${{ vars.BUILD_ON != 'github' }}
needs: [version, publish-arm64, release] needs: [version, publish-arm64, release]
runs-on: docker runs-on: docker
container: container:
@@ -289,8 +336,14 @@ jobs:
# The release above is made with the job's own token, and Gitea starts no # The release above is made with the job's own token, and Gitea starts no
# workflow for events the Actions bot causes -- announce.yml's # workflow for events the Actions bot causes -- announce.yml's
# 'on: release' never fires for it -- so announce it from here. # 'on: release' never fires for it -- so announce it from here.
#
# With BUILD_ON=github the release and binaries come from the GitHub run,
# so the announcement waits for the `github` job instead. The Release that
# run creates for a hand-pushed tag is made with a user token, so
# announce.yml fires for it too; discourse-release keeps one topic per tag.
announce: announce:
needs: [release, binaries] needs: [release, binaries, github]
if: ${{ always() && ((needs.release.result == 'success' && needs.binaries.result == 'success') || needs.github.result == 'success') }}
runs-on: light runs-on: light
steps: steps:
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be - uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
-42
View File
@@ -1,42 +0,0 @@
version: 2
updates:
# Cargo. One entry: the workspace has a single lockfile at the root, and
# ~30 manifests that upstream bumps on every release -- pointing entries at
# individual crates would find manifests with no lockfile beside them.
#
# Minor and patch arrive as one pull request a week. Majors are left out of
# the group on purpose: they are migrations rather than bumps, and each one
# deserves its own pull request and its own CI run.
- package-ecosystem: cargo
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
open-pull-requests-limit: 5
groups:
minor-and-patch:
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
groups:
actions:
patterns:
- "*"
# The Dockerfiles pin their base images, so this is what keeps a published
# image off a stale base between releases.
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
+469 -38
View File
@@ -1,51 +1,482 @@
# What CI can check without a mail server's worth of infrastructure. # CI and publishing on GitHub, for the repository Gitea mirrors here.
# #
# The build, and that every test target compiles. It deliberately does not # Gitea (git.coffeylabs.org) is where this project lives: pull requests,
# *run* the test suites: the unit tests only build with the integration crate # issues, releases and the container registry are all there, and it pushes
# in the graph, because that is what switches on the `test_mode` features they # every branch and tag to this GitHub copy as it changes. GitHub's hosted
# rely on (docs/spec/SPEC.md 2.2b), and the integration suites need a `STORE`, # runners are faster than the self-hosted ones -- and have native arm64 -- so
# fixed ports, and in most cases a container apiece (docs/spec/ # the building happens here, and the answer goes back to Gitea as a commit
# container-tests.md). Running them here would mean either a green tick that # status that Gitea's own ci.yml / publish.yml wait on.
# skipped everything, or a red one that means "the runner has no Redis".
# #
# So this catches what it can honestly catch -- code that does not compile, # One switch decides which side builds: the Actions variable BUILD_ON, set on
# including test code -- and the suites are run by hand, one at a time, as # both forges. BUILD_ON=github runs every job below and turns Gitea's heavy
# that page describes. If that changes, it changes because someone made the # jobs into a wait for this one; anything else leaves Gitea building exactly
# suites runnable unattended, not because CI started ignoring failures. # as before and every job here skips. If GitHub is ever unavailable, unset it
name: CI # on Gitea and nothing else has to change.
#
# Needs, as organization settings rather than anything in this file:
# variables BUILD_ON=github, REGISTRY (the Gitea container registry),
# GITEA_URL (the Gitea base URL)
# secret GITEA_TOKEN -- jcoffey-dev, write:repository + write:package:
# commit statuses, the release and its assets, the registry push
#
# There is no pull_request trigger: pull requests happen on Gitea, and their
# branch arrives here as an ordinary push. Branch pushes get what Gitea's
# ci.yml checks; v* tags get what its publish.yml does. Schedules (the weekly
# release, the upstream watch) and the release announcement stay on Gitea.
#
# Every `uses:` is pinned to a full commit SHA with the release in the
# trailing comment. A tag is a mutable pointer; do not "simplify" a pin back
# to one. Only GitHub's own actions and the three docker/* ones are used.
name: ci
on: on:
push: push:
branches: [main] branches: ['**']
pull_request: tags: ['**']
# Lets CI be run by hand against any ref, including one that predates a CI
# change, without pushing an empty commit to move it.
workflow_dispatch: workflow_dispatch:
# A second push to a branch cancels the run still going for the first: the # A newer push to a branch cancels the run for the older one, whose answer is
# older run's answer is about code nobody is looking at any more. # about code nobody is looking at any more. A tag run is never cancelled: it
# publishes.
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: ${{ github.ref_type == 'branch' }}
permissions:
contents: read
env:
GITEA_URL: ${{ vars.GITEA_URL }}
# The Gitea status this run answers for. Gitea waits on the one matching
# its own event: "(branch)" from ci.yml, "(tag)" from publish.yml.
STATUS_CONTEXT: github/ci (${{ github.ref_type }})
jobs: jobs:
build: # Tells Gitea a run has started, so a pull request shows it as pending
# rather than missing while the build is still going.
start:
if: ${{ vars.BUILD_ON == 'github' }}
runs-on: ubuntu-latest
steps:
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
jq -n --arg c "$STATUS_CONTEXT" \
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}' |
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
-H 'Content-Type: application/json' --data @- \
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
# ----------------------------------------------------------- branches ------
# What an upstream merge can bring in or leave behind without a conflict:
# the upstream name in a new string literal, and a changed upstream file
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
# check diffs against the upstream snapshot in the history, hence the full
# fetch.
fork-checks:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# Every `uses:` here is pinned to a full commit SHA, with the release it
# belongs to in the trailing comment. A tag is a mutable pointer, so
# trusting `@v7` is trusting every future version of that action,
# including one pushed by whoever compromises the account. Dependabot
# updates both halves together -- do not "simplify" a pin back to a tag.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with:
- name: System dependencies fetch-depth: 0
# foundationdb and the search backends are off by default, but the - run: python3 tools/fork/name-check.py
# default feature set still links against the system's C libraries. - if: always()
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang run: python3 tools/fork/notice-check.py
- name: Build the server # Cargo can patch a dependency to a directory in this repository, and
run: cargo build -p inbuxa --locked # the image builds from a context .dockerignore prunes to almost
- name: Compile every test target # nothing. CI never sees the difference; a release does.
# `--no-run` is the point: it builds the unit tests and the integration - if: always()
# crate together, which is the combination that resolves the test run: python3 tools/fork/context-check.py
# features, and stops short of running anything that wants a store. # The personal-data catalog must classify every object and field the
run: cargo test --workspace --locked --no-run # schema has, and name nothing that is gone.
- if: always()
run: python3 tools/fork/privacy-check.py
# The admin reads each expression field's allowed values and variables
# from the schema; they're generated from the registry and must match it.
- if: always()
run: python3 tools/fork/expr-schema.py --check
- if: always()
run: python3 -m unittest discover -s tools/fork/tests
# The build, and that every test target compiles. The suites are not run:
# they need a store, fixed ports and containers (docs/spec/
# container-tests.md), and are run by hand.
build:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }}
runs-on: ubuntu-latest
env:
CARGO_INCREMENTAL: "0"
# Debug info is most of a dev target dir, and nothing here runs a
# debugger. Without it the dev and test builds fit the runner's disk and
# the cache below stays small enough to be worth restoring.
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
steps:
# The hosted image carries toolchains this build never touches; a dev,
# test and release build of RocksDB and the workspace needs the room.
- run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
df -h /
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Current stable, as Gitea's rust:1 image is.
- id: rust
run: |
rustup toolchain install stable --profile minimal
rustup default stable
echo "version=$(rustc -V | cut -d' ' -f2)" >> "$GITHUB_OUTPUT"
- run: sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends clang >/dev/null
# Cargo's download cache and the dev/test target dir, keyed on the
# lockfile and the compiler. Saved from main only, so the one cache
# every branch restores is main's, and branches cannot evict it.
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target/debug
key: cargo-${{ steps.rust.outputs.version }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-${{ steps.rust.outputs.version }}-
- run: cargo build -p inbuxa --locked
# --no-run: compiles every test target without running them, which
# catches a test that no longer builds without needing a store.
- run: cargo test --workspace --locked --no-run
- if: github.ref == 'refs/heads/main'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target/debug
key: cargo-${{ steps.rust.outputs.version }}-${{ hashFiles('Cargo.lock') }}
# The release profile, on main only. It is the profile the image is
# built with, and it fails in ways the dev profile does not: v2026.9.24
# was tagged on a commit whose CI was green and whose release build
# could not compile the scim crate at all.
- if: github.ref == 'refs/heads/main'
run: cargo build -p inbuxa --locked --release
# --------------------------------------------------------------- tags ------
# Two guards before anything is pushed, the same as Gitea's publish.yml:
# * the tag must be v<brand_version!>. The version is a string in
# crates/types/src/branding.rs, not Cargo.toml, and the image is tagged
# with it, so a tag beside an unbumped macro would publish an image that
# reports a different version from its tag.
# * the tag must be on main or on a release/* branch, so an image never
# describes code that was never reviewed onto one of them. A release/*
# branch carries a hotfix cut from an earlier release tag.
version:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }}
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
steps:
# Full history, and every branch as origin/*: the ancestry check cannot
# be answered from a shallow clone.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- id: v
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Scoped to the macro body: branding.rs holds other string literals,
# and tagging an image from one of those would be worse than failing.
V="$(awk '/macro_rules! brand_version /,/^}/' crates/types/src/branding.rs \
| grep -om1 '"[0-9][^"]*"' | tr -d '"')"
[ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; }
if [ "$TAG" != "v$V" ]; then
echo "Tag $TAG names a commit whose brand_version! says $V." >&2
echo "Refusing to publish an image that would report the wrong version." >&2
exit 1
fi
commit="$(git rev-parse "${TAG}^{commit}")"
on=""
for ref in origin/main $(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/*'); do
if git merge-base --is-ancestor "$commit" "$ref"; then on="$ref"; break; fi
done
[ -n "$on" ] || { echo "$TAG is not on main or a release/* branch" >&2; exit 1; }
echo "$TAG is on $on"
echo "version=$V" >> "$GITHUB_OUTPUT"
# Each architecture on its own native runner, side by side. The Dockerfile
# cross-compiles from the build platform, and on the self-hosted runners one
# machine built both one after the other; here two machines build at once,
# each natively (the builder stage picks the matching target, and the
# aarch64 toolchain it installs exists on arm64 too), and the small final
# stage needs no QEMU. amd64 also moves :<version> as soon as it is done, so
# a production deploy can start from it; :latest waits for the index below,
# so it never names an image without arm64.
publish:
needs: [version]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
- run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
# The release link (fat LTO, one codegen unit) outgrows the runner's
# 16 GB: v2026.9.30's arm64 link was killed for memory. Swap gives it
# room; buildx's container has no memory limit of its own, so it
# reaches the host's swap.
- run: |
sudo fallocate -l 16G /swap.release
sudo chmod 600 /swap.release
sudo mkswap /swap.release >/dev/null
sudo swapon /swap.release
free -g
df -h /
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ vars.REGISTRY }}
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
# Attestations off: they add manifests of their own, and the index
# should hold the two images and nothing else. No build cache: GitHub
# scopes a tag run's cache to that tag, so the next release could never
# read it, and each one would park several GB in the repository's 10 GB
# cache and evict main's cargo cache.
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/${{ matrix.arch }}
provenance: false
sbom: false
push: true
tags: |
${{ env.IMAGE }}:${{ env.VERSION }}-${{ matrix.arch }}
${{ matrix.arch == 'amd64' && format('{0}:{1}', env.IMAGE, env.VERSION) || '' }}
# Joins the two per-architecture tags into :<version> and :latest. Built
# from the per-architecture tags rather than :<version>, which by now is
# the amd64 image and would be read as such.
index:
needs: [version, publish]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
- run: echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ vars.REGISTRY }}
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
- run: |
docker buildx imagetools create \
--tag "$IMAGE:$VERSION" \
--tag "$IMAGE:latest" \
"$IMAGE:$VERSION-amd64" "$IMAGE:$VERSION-arm64"
docker buildx imagetools inspect "$IMAGE:$VERSION"
# Gitea keeps a container package on its owner; linking it shows it on
# the repository's Packages tab. Idempotent.
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
owner="${GITHUB_REPOSITORY%%/*}"; name="${GITHUB_REPOSITORY#*/}"
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/packages/${owner,,}/container/$name/-/link/$name" \
|| echo "package already linked (or link refused); not fatal"
# The weekly release creates its Release (and so the tag) on Gitea first; a
# tag pushed by hand has none. Either way the tag ends up with exactly one
# Release there, created once the image exists so its pull instructions
# work.
release:
needs: [version, index]
runs-on: ubuntu-latest
steps:
- env:
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.version.outputs.version }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY: ${{ vars.REGISTRY }}
run: |
set -euo pipefail
api="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY"
code="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$api/releases/tags/$TAG")"
if [ "$code" = 200 ]; then echo "$TAG already has a release"; exit 0; fi
[ "$code" = 404 ] || { echo "looking up the release for $TAG answered $code" >&2; exit 1; }
image="$REGISTRY/${GITHUB_REPOSITORY,,}:$VERSION"
body="Container image: \`$image\` (linux/amd64, linux/arm64); also \`:latest\`.
Binaries for a host install are attached: \`inbuxa-linux-amd64.tar.gz\` and \`inbuxa-linux-arm64.tar.gz\`, with \`SHA256SUMS\`. Each is the binary out of this release's image for that architecture, so it is the same build. The image grants it \`cap_net_bind_service\`; a host install has to grant that itself (\`setcap\`, or \`AmbientCapabilities\` in the unit) to bind port 25."
jq -n --arg tag "$TAG" --arg name "INBUXA $VERSION" --arg body "$body" \
'{tag_name:$tag, name:$name, body:$body}' |
curl -fsS -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \
--data @- "$api/releases" | jq -r '"created release " + .tag_name'
# The binaries for a host install, taken out of the image that was just
# pushed rather than compiled again: the binary in the tarball is the file
# the image runs. `docker create` starts nothing, so copying a file out of
# the arm64 image on an amd64 runner needs no emulation.
binaries:
needs: [version, index, release]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.version.outputs.version }}
TAG: ${{ github.ref_name }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
steps:
- run: echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ vars.REGISTRY }}
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
- name: take the binaries out of the image
run: |
set -euo pipefail
mkdir -p out && cd out
for arch in amd64 arm64; do
docker pull -q --platform "linux/$arch" "$IMAGE:$VERSION"
id="$(docker create --platform "linux/$arch" "$IMAGE:$VERSION")"
docker cp "$id:/usr/local/bin/inbuxa" inbuxa
docker rm -f "$id" >/dev/null
chmod 0755 inbuxa
tar -czf "inbuxa-linux-$arch.tar.gz" inbuxa
rm inbuxa
done
sha256sum inbuxa-linux-*.tar.gz > SHA256SUMS
cat SHA256SUMS
# A re-run of a tag replaces its assets rather than leaving two files
# with the same name and different contents.
#
# The uploads cross Cloudflare, which dropped 50 MB HTTP/2 uploads
# part-way for v2026.9.30.1 (curl 92, PROTOCOL_ERROR; origin logged
# 400), once on each of two runs. Uploads go over HTTP/1.1 and retry.
- name: attach them to the release
run: |
set -euo pipefail
api="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY"
auth="Authorization: token $GITEA_TOKEN"
retry=(--retry 5 --retry-all-errors --retry-delay 15)
rel="$(curl -fsS "${retry[@]}" -H "$auth" "$api/releases/tags/$TAG" | jq -r .id)"
assets="$(curl -fsS "${retry[@]}" -H "$auth" "$api/releases/$rel/assets")"
for f in out/inbuxa-linux-amd64.tar.gz out/inbuxa-linux-arm64.tar.gz out/SHA256SUMS; do
name="$(basename "$f")"
old="$(jq -r --arg n "$name" '.[] | select(.name == $n) | .id' <<<"$assets")"
for id in $old; do curl -fsS "${retry[@]}" -o /dev/null -X DELETE -H "$auth" "$api/releases/$rel/assets/$id"; done
curl -fsS --http1.1 "${retry[@]}" -o /dev/null -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$rel/assets?name=$name"
echo "attached $name"
done
# ------------------------------------------------------ ghcr replica ------
# Copies the release image from the Gitea registry, which stays the
# authoritative one, to ghcr.io under the same version tag and :latest. It is
# a copy, not a second build: the digest on GHCR is the digest on the
# registry, so `docker pull ghcr.io/...` gets exactly the same image. Left
# out of the report to Gitea, like the release copy, so a GHCR problem
# cannot fail a release.
ghcr:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
needs: [version, index]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
src="${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}"
dst="ghcr.io/${GITHUB_REPOSITORY,,}"
tag="$TAG"
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
docker buildx imagetools create -t "$dst:$tag" -t "$dst:latest" "$src:$tag"
want="$(docker buildx imagetools inspect "$src:$tag" --format '{{json .Manifest.Digest}}')"
got="$(docker buildx imagetools inspect "$dst:$tag" --format '{{json .Manifest.Digest}}')"
echo "registry $src:$tag = $want"
echo "ghcr $dst:$tag = $got"
[ "$want" = "$got" ] || echo "::warning::GHCR digest differs from the registry's"
docker logout ghcr.io
# ---------------------------------------------------- github release ------
# Copies this tag's Gitea release -- notes and files -- to a GitHub release,
# so the replica's Releases page, and anyone watching it, keeps up. Gitea's
# release is the real one; this is left out of the report to Gitea, so a
# failure here cannot fail a release. PR and issue numbers in the notes are
# rewritten to Gitea links: on GitHub a bare #16 is some other PR.
github-release:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
needs: [binaries]
runs-on: ubuntu-latest
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL }}
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
steps:
- run: |
set -euo pipefail
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "GitHub already has a release for $TAG"; exit 0
fi
# The Gitea release exists by now if this run made it; if the weekly
# release job made it, it came before the tag. Allow a few minutes.
code=0
for _ in $(seq 1 15); do
code="$(curl -sS -o rel.json -w '%{http_code}' "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")"
[ "$code" = 200 ] && break
sleep 20
done
if [ "$code" != 200 ]; then echo "No Gitea release for $TAG; nothing to copy"; exit 0; fi
if [ "$(jq -r .draft rel.json)" = true ]; then echo "The Gitea release is a draft; not copying"; exit 0; fi
export BASE="$(jq -r '.html_url | sub("/releases/tag/.*$"; "")' rel.json)"
jq -r '.body // ""' rel.json | perl -pe 's{(?<![\w/&\[])#(\d+)\b}{[#$1]($ENV{BASE}/pulls/$1)}g' > notes.md
printf '\n\n_Mirrored from [the Gitea release](%s); report issues on [Gitea](%s/issues)._\n' \
"$(jq -r .html_url rel.json)" "$BASE" >> notes.md
files=()
mkdir -p files
while IFS=$'\t' read -r name url; do
curl -fsSL -o "files/$name" "$url"; files+=("files/$name")
done < <(jq -r '.assets[]? | [.name, .browser_download_url] | @tsv' rel.json)
title="$(jq -r '.name // ""' rel.json)"; [ -n "$title" ] || title="$TAG"
if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; else kind=--latest; fi
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$title" \
--notes-file notes.md "$kind" "${files[@]}"
echo "created the GitHub release for $TAG with ${#files[@]} file(s)"
# ------------------------------------------------------------- report ------
# One commit status on Gitea for the whole run: what Gitea's ci.yml and
# publish.yml wait on. Skipped jobs (the tag jobs on a branch, and the other
# way round) count as passing; a failed or cancelled one does not.
report:
if: ${{ always() && vars.BUILD_ON == 'github' }}
needs: [start, fork-checks, build, version, publish, index, release, binaries]
runs-on: ubuntu-latest
steps:
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
STATE: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }}
run: |
# A cancelled run was superseded by a newer run for the same commit (the
# mirror can push one commit twice); that run reports. Posting "failure"
# here would fail the Gitea check while the real build is still going.
if [ "$STATE" = cancelled ]; then echo "cancelled: leaving the result to the newer run"; exit 0; fi
jq -n --arg s "$STATE" --arg c "$STATUS_CONTEXT" \
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}' |
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
-H 'Content-Type: application/json' --data @- \
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
echo "$STATUS_CONTEXT: $STATE"
-69
View File
@@ -1,69 +0,0 @@
# Prune old image versions from GHCR.
#
# Releases are kept forever -- they carry no assets and their generated notes
# are this project's only changelog, so deleting one destroys history that
# cannot be reconstructed for nothing saved. Images are the opposite: a
# multi-arch build a week, and the by-digest push in publish.yml leaves two
# untagged per-architecture manifests behind each time on top of the tagged
# index. Those accumulate and nobody wants fifty of them.
#
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
# `delete-only-untagged-versions` -- will happily delete the per-architecture
# manifests that a multi-arch tag points *at*, because they are untagged by
# design. Nothing appears to break: the tag still exists, and pulls simply
# start failing for one architecture. This action understands manifest lists
# and will not orphan a retained index, and `validate` re-checks every
# multi-arch manifest against the registry afterwards.
#
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
# exactly what would be deleted without rebuilding and re-pushing an image to
# find out.
name: Prune images
on:
workflow_call:
inputs:
dry_run:
type: boolean
default: false
workflow_dispatch:
inputs:
dry_run:
description: "List what would be deleted, delete nothing"
type: boolean
default: true
jobs:
prune:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
# The only third-party action here that is not published by GitHub or
# Docker, and the one with the most to lose: it is handed
# `packages: write` and its whole job is deletion, so a ref repointed at
# something else -- by a compromise or a mistake upstream -- is a bad
# day. It was pinned to a commit long before the rest of them were.
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
with:
owner: inbuxa
package: inbuxa-server
token: ${{ secrets.GITHUB_TOKEN }}
# Ten weekly releases is roughly a quarter of history, which is more
# than enough to roll back to and far less than the year's worth that
# would otherwise pile up. Older *releases* stay either way; this
# only removes the images.
keep-n-tagged: 10
# Belt and braces on top of the action's own manifest awareness:
# `latest` is never a candidate for deletion under any counting.
exclude-tags: latest
delete-untagged: true
# Sweeps the wreckage of a half-failed run: an index whose platform
# images did not all land, and referrers whose parent is gone.
delete-partial-images: true
delete-orphaned-images: true
# Checks every remaining multi-architecture manifest still resolves
# in the registry. This is the step that would catch the footgun
# above rather than leaving a reader to discover it on `docker pull`.
validate: true
dry-run: ${{ inputs.dry_run }}
-198
View File
@@ -1,198 +0,0 @@
# Publish the container image to GHCR.
#
# The README and the docs site have told people to run
# `ghcr.io/inbuxa/inbuxa-server:latest` for a long time, and nothing ever
# pushed it: `docker pull` answered `denied`, because the package did not
# exist. This is the workflow that makes those instructions true. It is also
# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid
# both install by pulling an image and neither builds from source.
#
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
# a public repository, and an anonymous `docker pull` will still answer
# `denied`. Nothing in a workflow can change that -- the visibility is set once
# by hand under the package's settings, and until it is, this looks like it
# worked while the docs stay just as wrong as before. Check with a logged-out
# pull, not with one from a machine that has credentials.
#
# Two architectures, each built on its own native runner rather than under
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
# instruction translation, which takes tens of minutes and occasionally runs
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
# the same work at native speed. The cost is the by-digest dance below: each
# runner pushes an untagged image, and a final job joins the two digests into
# one multi-arch tag.
name: Publish image
on:
release:
types: [published]
# Callable, so release.yml can build the release it just cut. This is not a
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
# `release` event -- GitHub refuses to let a token trigger another workflow,
# to stop a workflow looping on its own output. A scheduled job that cut a
# release and expected this file to notice would silently never publish. The
# alternatives are a personal access token kept as a secret, or calling the
# workflow directly. This is the one that needs no credential.
workflow_call:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
type: string
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
# orphans can be neither rerun nor canceled, and this workflow otherwise
# only fires on a release -- which is not something to cut twice because a
# runner died. `ref` also allows publishing an image for a tag that predates
# this workflow, which is how the first one gets built.
workflow_dispatch:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
default: main
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
env:
# Hardcoded rather than derived from github.repository, which would have to
# be lowercased to be a legal registry path. This is the string the docs name.
IMAGE: ghcr.io/inbuxa/inbuxa-server
jobs:
# The version is read once and handed to both builds, so the two
# architectures cannot disagree about what they are. It is read from the
# macro the binary itself compiles in, which the weekly release commits
# before this runs -- so the image is tagged with the version it reports.
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
- id: v
run: |
set -euo pipefail
# Scoped to the macro body: branding.rs holds other string literals,
# and tagging an image from one of those would be worse than failing.
V="$(awk '/macro_rules! brand_version/,/^}/' crates/types/src/branding.rs \
| grep -om1 '"[0-9][^"]*"' | tr -d '"')"
[ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; }
# A date version carries nothing a Docker tag objects to, so there is
# no second, sanitized form of it here.
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "version $V"
build:
needs: version
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
# Attestations are off deliberately: they add manifests of their own
# to the index, and `imagetools create` below expects the two entries
# it pushed rather than four.
provenance: false
sbom: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Save the digest
run: |
mkdir -p /tmp/digests
# The prefix is stripped here and put back in the merge job, so the
# filename is the bare hash. Leaving it on produces
# `image@sha256:sha256:...` when the reference is rebuilt.
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
# One artifact per platform; the merge job globs them back together.
name: digest-${{ strategy.job-index }}
path: /tmp/digests/*
retention-days: 1
if-no-files-found: error
# Joins the per-architecture digests into a single tagged manifest, so
# `docker pull ghcr.io/inbuxa/inbuxa-server:<tag>` resolves on both.
publish:
needs: [version, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create the manifest
run: |
# Arrays rather than a string: the tags and the digest references
# have to reach docker as separate arguments, and building them by
# word-splitting an unquoted variable is the version of this that
# breaks the day a value contains a space.
tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}")
# :latest follows real releases only. A prerelease that moved it
# would hand every `:latest` deployment an unfinished build, and a
# dispatch run has to ask for it on purpose.
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
tags+=(-t "${IMAGE}:latest")
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
tags+=(-t "${IMAGE}:latest")
fi
refs=()
for f in /tmp/digests/*; do
refs+=("${IMAGE}@sha256:$(basename "$f")")
done
echo "tags: ${tags[*]}"
echo "refs: ${refs[*]}"
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
- name: Show what landed
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}"
# Runs only after a successful publish, because that is the only moment the
# package grows. See cleanup.yml for why this is not the obvious one-liner.
prune:
needs: publish
permissions:
packages: write
uses: ./.github/workflows/cleanup.yml
-246
View File
@@ -1,246 +0,0 @@
# Cut a release once a week, but only if there is something in it.
#
# It does nothing on a quiet week. A release with no commits in it is worse
# than no release: it moves `:latest` to an identical build, spends a version
# number, and mails everybody watching the repository about nothing.
#
# INBUXA's version is a string in crates/types/src/branding.rs, deliberately
# not in Cargo.toml so that upstream's version bumps merge without conflicts.
# So this writes it: the bump is committed to main, and the tag names that
# commit. The tree a tag points at therefore reports the version the tag
# claims, which a tag placed beside an unbumped macro cannot promise.
name: Weekly release
on:
schedule:
# Mondays, 10:07 UTC, and last of the three: INBUXA Admin and the webmail
# release ahead of the server they talk to. Staggered rather than
# simultaneous so three releases do not compete for runners, and so a bad
# Monday names one repository instead of three. GitHub runs scheduled jobs
# best-effort and can delay a run considerably, so the exact minute is not
# a promise; the odd minute keeps it off the crowded top of the hour.
#
# Note also that GitHub disables scheduled workflows in a repository with
# no activity for 60 days, which is worth checking for before assuming
# this file is broken.
- cron: "7 10 * * 1"
workflow_dispatch:
inputs:
dry_run:
description: "Work out what would be released, then stop"
type: boolean
default: false
# One at a time. Two overlapping runs would race to write the same version and
# create the same tag, and the loser fails noisily for a reason that has
# nothing to do with the code.
concurrency:
group: weekly-release
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_release: ${{ steps.decide.outputs.should_release }}
version: ${{ steps.decide.outputs.version }}
tag: ${{ steps.decide.outputs.tag }}
previous: ${{ steps.decide.outputs.previous }}
count: ${{ steps.decide.outputs.count }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- id: decide
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# The newest published release, or empty on a repository that has
# never had one -- in which case everything counts as new. Drafts are
# excluded: an unpublished draft is not a release anybody has, so
# counting from it would hide commits that have never shipped.
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
# A tag named by a release is normally present after a full checkout,
# but a release can outlive its tag. Falling back to the whole
# history is the safe direction to be wrong in: it over-counts, which
# cuts a release that was due anyway, where under-counting would skip
# one that was.
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
count="$(git rev-list --count "${previous}..HEAD")"
else
count="$(git rev-list --count HEAD)"
fi
# INBUXA's version is the date: YYYY.M.D, unpadded, as branding.rs
# documents. A second release on one day takes a `.N` suffix,
# counting from 2, which is why this asks the tags rather than
# assuming today is free.
today="$(date -u +%Y.%-m.%-d)"
version="$today"
n=2
while git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; do
version="${today}.${n}"
n=$((n + 1))
done
should_release=true
reason=""
if [ "$count" -eq 0 ]; then
should_release=false
reason="no commits since ${previous}"
fi
{
echo "should_release=$should_release"
echo "version=$version"
echo "tag=v${version}"
echo "previous=$previous"
echo "count=$count"
} >> "$GITHUB_OUTPUT"
# Written to the run summary so a skipped week reads as a decision
# rather than as a workflow that quietly did nothing.
{
echo "### Weekly release"
echo
if [ "$should_release" = "true" ]; then
echo "Releasing **v${version}** — ${count} commit(s) since ${previous:-the beginning}."
else
echo "Nothing to release: ${reason}."
fi
} >> "$GITHUB_STEP_SUMMARY"
cut:
needs: check
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
sha: ${{ steps.land.outputs.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- id: bump
env:
VERSION: ${{ needs.check.outputs.version }}
BRANCH: release/v${{ needs.check.outputs.version }}
run: |
set -euo pipefail
# Scoped to the macro body rather than replacing the first quoted
# string in the file, and asserted to have matched exactly once.
# branding.rs holds other string literals, and a bump that silently
# edited one of those -- or none -- would ship a build whose version
# disagrees with its tag.
python3 - <<'PY'
import os, re
path = "crates/types/src/branding.rs"
src = open(path, encoding="utf-8").read()
pattern = re.compile(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")')
out, n = pattern.subn(lambda m: m.group(1) + os.environ["VERSION"] + m.group(2), src, count=1)
assert n == 1, f"brand_version! not found in {path}"
open(path, "w", encoding="utf-8").write(out)
PY
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add crates/types/src/branding.rs
git commit -m "Version ${VERSION}"
git push origin "HEAD:refs/heads/${BRANCH}"
# main is protected: it takes a pull request with a green build, and
# GITHUB_TOKEN is not among the bypass actors. So the bump lands the way
# every other change does. The alternative was to hand the release a
# credential that outranks the rule, which is a worse thing to own than
# a slower Monday.
- id: land
env:
VERSION: ${{ needs.check.outputs.version }}
BRANCH: release/v${{ needs.check.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
url="$(gh pr create --base main --head "${BRANCH}" \
--title "Version ${VERSION}" \
--body "Weekly release. Bumps \`brand_version!\` to ${VERSION} so the tag names a tree that reports the version the tag claims.")"
# The number, not the branch: the branch is deleted on merge, and a
# deleted branch no longer resolves to its pull request.
pr="${url##*/}"
echo "Opened #${pr}"
# The build is what the rule actually requires, and it is also the
# thing worth waiting for: a release cut from a tree that does not
# compile is the failure this whole arrangement exists to prevent.
# A full build of this tree is long, so the deadline is generous.
deadline=$(( SECONDS + 3600 ))
while :; do
state="$(gh pr view "${pr}" --json statusCheckRollup \
--jq '[.statusCheckRollup[]? | .conclusion // "PENDING"] | join(",")')"
case "${state}" in
*FAILURE*|*CANCELLED*|*TIMED_OUT*)
echo "::error::CI failed on ${BRANCH} (${state}); no release cut. PR #${pr} is left open."
exit 1 ;;
*SUCCESS*) break ;;
esac
if [ "${SECONDS}" -ge "${deadline}" ]; then
echo "::error::timed out waiting for CI on ${BRANCH}. PR #${pr} is left open."
exit 1
fi
sleep 30
done
gh pr merge "${pr}" --rebase --delete-branch
# A rebase merge rewrites the commit, so the sha to tag is the one
# GitHub recorded for the merge, not the tip that was pushed. It can
# take a moment to appear.
sha=""
for _ in $(seq 1 30); do
sha="$(gh pr view "${pr}" --json mergeCommit --jq '.mergeCommit.oid // ""')"
[ -n "${sha}" ] && break
sleep 5
done
if [ -z "${sha}" ]; then
echo "::error::#${pr} merged but GitHub reported no merge commit; nothing safe to tag."
exit 1
fi
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
args=(--target "${{ steps.land.outputs.sha }}"
--title "INBUXA ${{ needs.check.outputs.version }}"
--generate-notes)
# Bound the notes to what is actually new. Without a start tag the
# generator reaches back to whatever it decides is previous, which on
# a repository carrying upstream's tag shapes is not always the last
# release.
if [ -n "${{ needs.check.outputs.previous }}" ]; then
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
fi
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
# Called rather than left to the `release` trigger on purpose: see the note
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
# event, so without this the tag would exist and no image would follow it.
publish:
needs: [check, cut]
permissions:
contents: read
packages: write
uses: ./.github/workflows/publish.yml
with:
ref: ${{ needs.cut.outputs.sha }}
tag_latest: true
Generated
+2
View File
@@ -7762,11 +7762,13 @@ dependencies = [
"common", "common",
"dns-update", "dns-update",
"email", "email",
"futures",
"groupware", "groupware",
"hkdf 0.13.0", "hkdf 0.13.0",
"inbuxa-features", "inbuxa-features",
"jmap-tools", "jmap-tools",
"jmap_proto", "jmap_proto",
"mail-auth",
"mail-builder 1.0.0", "mail-builder 1.0.0",
"mail-parser", "mail-parser",
"memory-stats", "memory-stats",
+4
View File
@@ -8,6 +8,10 @@
--- ---
> [!NOTE]
> Development happens on [git.coffeylabs.org/inbuxa/inbuxa-server](https://git.coffeylabs.org/inbuxa/inbuxa-server); the copy on GitHub is a read-only mirror.
> Report issues at **[git.coffeylabs.org/inbuxa/inbuxa-server/issues](https://git.coffeylabs.org/inbuxa/inbuxa-server/issues)**, and join discussions at **[community.coffeylabs.org](https://community.coffeylabs.org)**.
**inbuxa** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP, **inbuxa** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart). end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
+1 -1
View File
@@ -36,7 +36,7 @@ to Stalwart Labs with credit to you, and you'll be told that has happened.
This repository is the mail server. The web front ends have their own: This repository is the mail server. The web front ends have their own:
- [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin) - [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin)
- [ihasmail-inbuxa](https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa) - [inbuxa-webmail](https://git.coffeylabs.org/inbuxa/inbuxa-webmail)
Upstream's own security documents are kept in `.github-upstream/` for Upstream's own security documents are kept in `.github-upstream/` for
reference. They describe Stalwart Labs' process, not this project's. reference. They describe Stalwart Labs' process, not this project's.
+57
View File
@@ -445,6 +445,63 @@ impl Server {
} }
} }
/// MA-D0a: a message sent from an address that isn't the sender's own:
/// a group's or a shared mailbox's. The message itself only says
/// `From:` that address, so the audit log is where the person who sent
/// it is named. A locked account's delegate's send is AL-9's record, not
/// this one.
pub async fn audit_send_as(
&self,
token: &AccessToken,
submission_account_id: u32,
submission_id: u32,
address: &str,
) {
let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else {
return;
};
if as_account_id == token.account_id()
|| token
.delegation(as_account_id)
.is_some_and(|delegation| delegation.kind.is_lock())
{
return;
}
let actor = self.audit_actor(token).await;
let tenant_id = self
.account(as_account_id)
.await
.ok()
.and_then(|account| account.id_tenant);
let details = if submission_account_id == as_account_id {
format!("Sent as {address}")
} else {
format!(
"Sent as {address}, from {}",
self.audit_account_name(submission_account_id).await
)
};
self.audit_note(Record {
at: ms(),
actor,
via: token.origin().cloned(),
remote_ip: None,
action: Action::Create,
target: Target {
kind: "EmailSubmission".into(),
id: Some(Id::from(submission_id).to_string()),
name: Some(address.to_string()),
account_id: Some(as_account_id),
tenant_id,
},
changes: vec![],
details: Some(details),
reason: None,
outcome: Outcome::success(),
})
.await;
}
/// AU-7: removes entries past the retention period. /// AU-7: removes entries past the retention period.
pub async fn audit_purge(&self) -> trc::Result<usize> { pub async fn audit_purge(&self) -> trc::Result<usize> {
let settings = log::settings(self.store()).await?; let settings = log::settings(self.store()).await?;
+84 -4
View File
@@ -36,6 +36,32 @@ use utils::map::bitmap::{Bitmap, BitmapItem};
use xxhash_rust::xxh3; use xxhash_rust::xxh3;
impl Server { impl Server {
/// inbuxa: MA-C: whether people in `owner`'s tenant may share their mail
/// (the server's switch, narrowed by the tenant's).
pub async fn mail_sharing_allowed(&self, owner: u32) -> trc::Result<bool> {
let tenant_id = self.account(owner).await.ok().and_then(|account| account.id_tenant);
Ok(
inbuxa_features::security::sharing_policy::effective_for(self.store(), tenant_id)
.await
.caused_by(trc::location!())?
.mail_sharing,
)
}
/// inbuxa: MA-C: whether `owner`'s mail shares give access now. A locked
/// account's or shared mailbox's grants are an administrator's and always
/// do; anyone else's only while their tenant allows mail sharing.
pub async fn mail_shares_honored(&self, owner: u32) -> trc::Result<bool> {
if inbuxa_features::lock::get(self.store(), owner)
.await
.caused_by(trc::location!())?
.is_some()
{
return Ok(true);
}
self.mail_sharing_allowed(owner).await
}
async fn build_access_token( async fn build_access_token(
&self, &self,
account: Account, account: Account,
@@ -46,19 +72,22 @@ impl Server {
// inbuxa: AL-2, AL-5: whether this account is locked, and which // inbuxa: AL-2, AL-5: whether this account is locked, and which
// locked accounts are handed to it. The token is their cache: every // locked accounts are handed to it. The token is their cache: every
// change to a lock invalidates the tokens it touches. // change to a lock invalidates the tokens it touches.
let locked = inbuxa_features::lock::get(self.store(), account_id) let lock_kind = inbuxa_features::lock::get(self.store(), account_id)
.await .await
.caused_by(trc::location!())? .caused_by(trc::location!())?
.is_some(); .map(|lock| lock.kind);
let locked = lock_kind.is_some();
let shared_mailbox = lock_kind == Some(inbuxa_features::lock::Kind::SharedMailbox);
let now_secs = now(); let now_secs = now();
let delegations: Box<[super::Delegation]> = let delegations: Box<[super::Delegation]> =
inbuxa_features::lock::delegated_to(self.store(), account_id) inbuxa_features::lock::delegated_to(self.store(), account_id)
.await .await
.caused_by(trc::location!())? .caused_by(trc::location!())?
.into_iter() .into_iter()
.filter(|(_, delegate)| delegate.is_current(now_secs)) .filter(|(_, delegate, _)| delegate.is_current(now_secs))
.map(|(locked_id, delegate)| super::Delegation { .map(|(locked_id, delegate, kind)| super::Delegation {
account_id: locked_id, account_id: locked_id,
kind,
access: delegate.access, access: delegate.access,
send_as: delegate.send_as, send_as: delegate.send_as,
until: delegate.until, until: delegate.until,
@@ -97,6 +126,9 @@ impl Server {
.map(|m| m.id() as u32) .map(|m| m.id() as u32)
.collect::<TinyVec<[u32; 3]>>(); .collect::<TinyVec<[u32; 3]>>();
let mut access_to: Vec<AccessTo> = Vec::new(); let mut access_to: Vec<AccessTo> = Vec::new();
// inbuxa: MA-C: whether an owner's mail shares are honored,
// looked up once per owner
let mut mail_shares_honored: Vec<(u32, bool)> = Vec::new();
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) { for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
for acl_item in self for acl_item in self
.store() .store()
@@ -117,6 +149,27 @@ impl Server {
.caused_by(trc::location!())); .caused_by(trc::location!()));
} }
// inbuxa: MA-C: a mail share from an account whose
// tenant (or server) has mail sharing off gives
// nothing while it is off. It stays stored, so it
// comes back when sharing does. A lock's and a
// shared mailbox's grants are an administrator's,
// and always count.
if collection == Collection::Mailbox {
let owner = acl_item.to_account_id;
let honored = match mail_shares_honored.iter().find(|(id, _)| *id == owner) {
Some((_, honored)) => *honored,
None => {
let honored = self.mail_shares_honored(owner).await?;
mail_shares_honored.push((owner, honored));
honored
}
};
if !honored {
continue;
}
}
let mut collections: Bitmap<Collection> = Bitmap::new(); let mut collections: Bitmap<Collection> = Bitmap::new();
if acl.contains(Acl::Read) { if acl.contains(Acl::Read) {
collections.insert(collection); collections.insert(collection);
@@ -247,6 +300,7 @@ impl Server {
.map(ConcurrencyLimiter::new), .map(ConcurrencyLimiter::new),
obj_size: 0, obj_size: 0,
locked, locked,
shared_mailbox,
delegations: delegations.clone(), delegations: delegations.clone(),
revision, revision,
revision_account, revision_account,
@@ -300,6 +354,7 @@ impl Server {
.map(ConcurrencyLimiter::new), .map(ConcurrencyLimiter::new),
obj_size: 0, obj_size: 0,
locked, locked,
shared_mailbox,
delegations: delegations.clone(), delegations: delegations.clone(),
revision, revision,
revision_account, revision_account,
@@ -553,6 +608,16 @@ impl AccessToken {
|| self.inner.access_to.iter().any(|a| a.account_id == account_id) || self.inner.access_to.iter().any(|a| a.account_id == account_id)
} }
/// inbuxa: MA-D0: in the account only because it is a group this token
/// belongs to. Such a member has the group's mailbox but may not share it
/// on: who is in a group is an administrator's decision, and a share
/// would let anyone in.
pub fn is_group_member_only(&self, account_id: u32) -> bool {
self.inner.account_id != account_id
&& self.inner.member_of.contains(&account_id)
&& !self.has_permission(Permission::Impersonate)
}
pub fn is_account_id(&self, account_id: u32) -> bool { pub fn is_account_id(&self, account_id: u32) -> bool {
self.inner.account_id == account_id self.inner.account_id == account_id
} }
@@ -648,6 +713,7 @@ impl AccessToken {
credential_version: old_inner.credential_version, credential_version: old_inner.credential_version,
obj_size: old_inner.obj_size, obj_size: old_inner.obj_size,
locked: old_inner.locked, locked: old_inner.locked,
shared_mailbox: old_inner.shared_mailbox,
delegations: old_inner.delegations.clone(), delegations: old_inner.delegations.clone(),
}; };
@@ -838,6 +904,18 @@ impl AccessToken {
self.inner.locked self.inner.locked
} }
/// inbuxa: MA-S: the account is a shared mailbox (a lock of that kind).
pub fn is_shared_mailbox(&self) -> bool {
self.inner.shared_mailbox
}
/// inbuxa: MA-S: this account's delegation into `account_id` is to a
/// shared mailbox, not a locked account.
pub fn delegated_shared_mailbox(&self, account_id: u32) -> bool {
self.delegation(account_id)
.is_some_and(|d| d.kind == inbuxa_features::lock::Kind::SharedMailbox)
}
/// inbuxa: AL-5: this account's delegation into a locked account, if it /// inbuxa: AL-5: this account's delegation into a locked account, if it
/// has one that hasn't ended. /// has one that hasn't ended.
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to /// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
@@ -918,6 +996,7 @@ impl AccessToken {
credential_version: Default::default(), credential_version: Default::default(),
obj_size: Default::default(), obj_size: Default::default(),
locked: false, locked: false,
shared_mailbox: false,
delegations: Default::default(), delegations: Default::default(),
}), }),
} }
@@ -978,6 +1057,7 @@ impl AccessTokenInner {
credential_version: Default::default(), credential_version: Default::default(),
obj_size: Default::default(), obj_size: Default::default(),
locked: false, locked: false,
shared_mailbox: false,
delegations: Default::default(), delegations: Default::default(),
} }
} }
+4
View File
@@ -152,6 +152,8 @@ pub struct AccessTokenInner {
pub(crate) obj_size: u64, pub(crate) obj_size: u64,
// inbuxa: AL-2: the account is locked; it may not authenticate // inbuxa: AL-2: the account is locked; it may not authenticate
pub(crate) locked: bool, pub(crate) locked: bool,
// inbuxa: MA-S: the lock is a shared mailbox
pub(crate) shared_mailbox: bool,
// inbuxa: AL-5: locked accounts handed to this one // inbuxa: AL-5: locked accounts handed to this one
pub(crate) delegations: Box<[Delegation]>, pub(crate) delegations: Box<[Delegation]>,
} }
@@ -165,6 +167,8 @@ pub struct Delegation {
pub send_as: bool, pub send_as: bool,
/// Seconds since the epoch. /// Seconds since the epoch.
pub until: Option<u64>, pub until: Option<u64>,
/// MA-S: a locked account, or a shared mailbox.
pub kind: inbuxa_features::lock::Kind,
} }
#[derive(Debug, Default, Hash, Clone)] #[derive(Debug, Default, Hash, Clone)]
+13
View File
@@ -111,6 +111,9 @@ impl Server {
Permission::SysLegalHoldCreate, Permission::SysLegalHoldCreate,
Permission::SysLegalHoldUpdate, Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldExport, Permission::SysLegalHoldExport,
// inbuxa: DL-20: the lists and the check are the server's
Permission::SysDeliverabilityUpdate,
Permission::SysDeliverabilityCheck,
] { ] {
permissions.disabled.set(permission as usize); permissions.disabled.set(permission as usize);
} }
@@ -304,6 +307,16 @@ impl Default for DefaultPermissions {
default.superuser.push(permission); default.superuser.push(permission);
default.tenant.push(permission); default.tenant.push(permission);
} }
// inbuxa: deliverability spec, DL-20: a tenant administrator
// reads its own domains' findings; the lists and the check
// itself are the server's
Permission::SysDeliverabilityGet => {
default.superuser.push(permission);
default.tenant.push(permission);
}
Permission::SysDeliverabilityUpdate | Permission::SysDeliverabilityCheck => {
default.superuser.push(permission);
}
// inbuxa: DLP and mail flow rules, and held mail, are the // inbuxa: DLP and mail flow rules, and held mail, are the
// server's: never a tenant's (dlp-and-mail-flow-rules spec, // server's: never a tenant's (dlp-and-mail-flow-rules spec,
// settled answer 3) // settled answer 3)
+34
View File
@@ -72,6 +72,10 @@ pub struct Http {
pub cors_origins: Vec<hyper::header::HeaderValue>, pub cors_origins: Vec<hyper::header::HeaderValue>,
pub use_forwarded: bool, pub use_forwarded: bool,
pub redirect_root: Option<String>, pub redirect_root: Option<String>,
/// inbuxa: HTTP Basic accepted on every endpoint, not only DAV (contract
/// C-23). True in bootstrap and recovery mode, or with
/// `INBUXA_HTTP_BASIC_AUTH=all`.
pub basic_auth_everywhere: bool,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -453,6 +457,35 @@ impl Http {
.collect() .collect()
}; };
// inbuxa: outside DAV, HTTP sign-in is a token unless the operator
// says otherwise (contract C-23). The integration suites sign in with
// passwords over JMAP and the API, so test builds accept Basic
// everywhere.
#[cfg(feature = "test_mode")]
let basic_auth_everywhere = true;
#[cfg(not(feature = "test_mode"))]
let basic_auth_everywhere = bp.registry.is_recovery_mode()
|| bp.registry.is_bootstrap_mode()
|| match types::branding::env_var("HTTP_BASIC_AUTH") {
Ok(value) if value.trim().eq_ignore_ascii_case("all") => true,
Ok(value)
if value.trim().is_empty() || value.trim().eq_ignore_ascii_case("dav") =>
{
false
}
Ok(value) => {
bp.build_warning(
ObjectType::Http.singleton(),
format!(
"INBUXA_HTTP_BASIC_AUTH is {value:?}; expected \"dav\" or \"all\". Basic authentication stays on DAV only."
),
);
false
}
Err(_) => false,
};
if use_permissive_cors { if use_permissive_cors {
http_headers.push(( http_headers.push((
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN, hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
@@ -512,6 +545,7 @@ impl Http {
cors_origins, cors_origins,
use_forwarded: http.use_x_forwarded, use_forwarded: http.use_x_forwarded,
redirect_root: http.redirect_root, redirect_root: http.redirect_root,
basic_auth_everywhere,
} }
} }
} }
+2
View File
@@ -88,6 +88,8 @@ pub enum BroadcastEvent {
QueueRefresh, QueueRefresh,
// inbuxa: AL-3: end an account's open sessions on every node // inbuxa: AL-3: end an account's open sessions on every node
EndSessions(u32), EndSessions(u32),
// inbuxa: deliverability spec, DL-15: every node checks itself now
DeliverabilityCheck,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
+2 -2
View File
@@ -14,7 +14,7 @@
//! application names another; //! application names another;
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL` //! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
//! is set; //! is set;
//! - ihasmail-inbuxa, as the confidential client `ihasmail-inbuxa`, when //! - inbuxa-webmail, as the confidential client `ihasmail-inbuxa`, when
//! `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` are set. //! `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` are set.
//! //!
//! inbuxa: the environment variables stand in for `x:FrontEnds` (C-4) until //! inbuxa: the environment variables stand in for `x:FrontEnds` (C-4) until
@@ -22,7 +22,7 @@
//! it instead. //! it instead.
//! //!
//! A missing client is created. An existing one gains any redirect URI it //! A missing client is created. An existing one gains any redirect URI it
//! lacks and, for ihasmail-inbuxa, the configured secret; nothing an operator //! lacks and, for inbuxa-webmail, the configured secret; nothing an operator
//! added is removed. //! added is removed.
use directory::core::secret::{hash_secret, verify_secret_hash}; use directory::core::secret::{hash_secret, verify_secret_hash};
@@ -31,8 +31,9 @@ use types::id::Id;
/// Granted to the default administrator roles: "Explain this" /// Granted to the default administrator roles: "Explain this"
/// (ai-explain spec, EX-4: superuser by default), the audit log, account /// (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 /// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
/// the data inventory (personal-data catalog spec), and accepting security /// the data inventory (personal-data catalog spec), accepting security
/// to-do items (security to-do list spec). /// to-do items (security to-do list spec), and the deliverability check
/// (deliverability spec).
const ADMIN_GRANTS: &[Permission] = &[ const ADMIN_GRANTS: &[Permission] = &[
Permission::SysAiExplain, Permission::SysAiExplain,
Permission::SysAuditGet, Permission::SysAuditGet,
@@ -56,6 +57,9 @@ const ADMIN_GRANTS: &[Permission] = &[
Permission::SysJournalGet, Permission::SysJournalGet,
Permission::SysJournalUpdate, Permission::SysJournalUpdate,
Permission::SysSecurityAccept, Permission::SysSecurityAccept,
Permission::SysDeliverabilityGet,
Permission::SysDeliverabilityUpdate,
Permission::SysDeliverabilityCheck,
]; ];
/// Granted to the server-level Compliance Officer role once it exists: /// Granted to the server-level Compliance Officer role once it exists:
@@ -73,7 +77,8 @@ const OFFICER_GRANTS: &[Permission] = &[
/// Granted to the default tenant administrator roles: reading and exporting /// Granted to the default tenant administrator roles: reading and exporting
/// the tenant's audit log (AU-9), locking and delegating its accounts /// the tenant's audit log (AU-9), locking and delegating its accounts
/// (AL-12), and the tenant's slice of the data inventory. /// (AL-12), the tenant's slice of the data inventory, and its own domains'
/// deliverability findings (DL-20).
const TENANT_GRANTS: &[Permission] = &[ const TENANT_GRANTS: &[Permission] = &[
Permission::SysAuditGet, Permission::SysAuditGet,
Permission::SysAuditExport, Permission::SysAuditExport,
@@ -82,6 +87,7 @@ const TENANT_GRANTS: &[Permission] = &[
Permission::SysAccountLockUpdate, Permission::SysAccountLockUpdate,
Permission::SysAccountLockDestroy, Permission::SysAccountLockDestroy,
Permission::SysComplianceGet, Permission::SysComplianceGet,
Permission::SysDeliverabilityGet,
]; ];
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
+61 -3
View File
@@ -104,14 +104,31 @@ impl StoredMetric {
pub fn timestamp(&self) -> u64 { pub fn timestamp(&self) -> u64 {
SnowflakeIdGenerator::to_timestamp(self.id) SnowflakeIdGenerator::to_timestamp(self.id)
} }
/// The node that wrote the sample. Histogram totals are per node, so a
/// reader diffs them per node.
pub fn node_id(&self) -> u64 {
SnowflakeIdGenerator::to_node_id(self.id)
}
} }
/// What the node wrote last, so counters and histograms are written as /// What the node wrote last, so counters and histograms are written as
/// changes (MON-4). Per process: a restart counts from the start. /// changes (MON-4). Per process: a restart counts from the start.
static LAST: Mutex<Option<AHashMap<MetricType, (u64, u64)>>> = Mutex::new(None); static LAST: Mutex<Option<AHashMap<MetricType, (u64, u64)>>> = Mutex::new(None);
/// One tick's samples (MON-4 to MON-6). /// Gauges that count the whole cluster's data, not this node's. Only the node
pub fn sample() -> Vec<Metric> { /// that computes them (the metrics-calculation role) has a true reading; on
/// the others the queue gauge only moves with local queue events and drifts
/// below zero, and the account and domain counts stay at 0.
const CLUSTER_GAUGES: [MetricType; 3] = [
MetricType::QueueCount,
MetricType::UserCount,
MetricType::DomainCount,
];
/// One tick's samples (MON-4 to MON-6). `calculates` is whether this node
/// computes the cluster-wide gauges; a node that doesn't leaves them out.
pub fn sample(calculates: bool) -> Vec<Metric> {
let mut last_guard = LAST.lock().unwrap(); let mut last_guard = LAST.lock().unwrap();
let last = last_guard.get_or_insert_with(AHashMap::new); let last = last_guard.get_or_insert_with(AHashMap::new);
let mut samples = Vec::new(); let mut samples = Vec::new();
@@ -134,6 +151,9 @@ pub fn sample() -> Vec<Metric> {
// Gauges: the reading, always (MON-5) // Gauges: the reading, always (MON-5)
for gauge in Collector::collect_gauges() { for gauge in Collector::collect_gauges() {
if !calculates && CLUSTER_GAUGES.contains(&gauge.id()) {
continue;
}
samples.push(Metric::Gauge(MetricCount { samples.push(Metric::Gauge(MetricCount {
count: gauge.get(), count: gauge.get(),
metric: gauge.id(), metric: gauge.id(),
@@ -175,7 +195,7 @@ impl Server {
if store.is_none() { if store.is_none() {
return; return;
} }
let samples = sample(); let samples = sample(self.core.network.roles.metrics_calculate);
let count = samples.len(); let count = samples.len();
let started = std::time::Instant::now(); let started = std::time::Instant::now();
match store.write_metrics(samples, now()).await { match store.write_metrics(samples, now()).await {
@@ -265,3 +285,41 @@ impl Server {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn gauges(samples: &[Metric]) -> Vec<MetricType> {
samples
.iter()
.filter_map(|m| match m {
Metric::Gauge(g) => Some(g.metric),
_ => None,
})
.collect()
}
#[test]
fn only_the_calculating_node_stores_cluster_gauges() {
let all = gauges(&sample(true));
let local = gauges(&sample(false));
for metric in CLUSTER_GAUGES {
assert!(
all.contains(&metric),
"{metric:?} missing on the calculating node"
);
assert!(
!local.contains(&metric),
"{metric:?} stored by a node that doesn't compute it"
);
}
// Per-node gauges are stored either way
for metric in [MetricType::ServerMemory, MetricType::HttpActiveConnections] {
assert!(
all.contains(&metric) && local.contains(&metric),
"{metric:?}"
);
}
}
}
+11 -1
View File
@@ -133,6 +133,10 @@ impl DavAclHandler for Server {
{ {
return Err(DavError::Code(StatusCode::FORBIDDEN)); return Err(DavError::Code(StatusCode::FORBIDDEN));
} }
// inbuxa: MA-D0: a group's members don't share what it owns on.
if access_token.is_group_member_only(account_id) {
return Err(DavError::Code(StatusCode::FORBIDDEN));
}
// Validate ACEs // Validate ACEs
let grants = self let grants = self
@@ -565,7 +569,13 @@ impl Privileges for AccessToken {
grants: &ArchivedVec<ArchivedAclGrant>, grants: &ArchivedVec<ArchivedAclGrant>,
is_calendar: bool, is_calendar: bool,
) -> Vec<Privilege> { ) -> Vec<Privilege> {
if self.is_member(account_id) { if self.is_group_member_only(account_id) {
// inbuxa: MA-D0: everything but sharing it on.
Privilege::all(is_calendar)
.into_iter()
.filter(|privilege| !matches!(privilege, Privilege::All | Privilege::WriteAcl))
.collect()
} else if self.is_member(account_id) {
Privilege::all(is_calendar) Privilege::all(is_calendar)
} else { } else {
current_user_privilege_set(grants.effective_acl(self)) current_user_privilege_set(grants.effective_acl(self))
+10 -2
View File
@@ -290,7 +290,11 @@ impl SieveScriptIngest for Server {
// inbuxa: AL-4: a locked account answers no sender, so a // inbuxa: AL-4: a locked account answers no sender, so a
// rejection is kept instead; sieve has already cleared // rejection is kept instead; sieve has already cleared
// the implicit keep, so it is filed here // the implicit keep, so it is filed here
Event::Reject { .. } if access_token.is_locked() => { // A shared mailbox (MA-S) is a role address and answers
// as one: its Sieve script runs as written
Event::Reject { .. }
if access_token.is_locked() && !access_token.is_shared_mailbox() =>
{
if let Some(message) = messages.get_mut(0) if let Some(message) = messages.get_mut(0)
&& !message.file_into.contains(&INBOX_ID) && !message.file_into.contains(&INBOX_ID)
{ {
@@ -403,7 +407,11 @@ impl SieveScriptIngest for Server {
// inbuxa: AL-4: a locked account sends nothing on its // inbuxa: AL-4: a locked account sends nothing on its
// own: no redirect, vacation reply or notification. An // own: no redirect, vacation reply or notification. An
// unsent redirect leaves the message to be kept. // unsent redirect leaves the message to be kept.
Event::SendMessage { .. } if access_token.is_locked() => { // A shared mailbox's acknowledgements and redirects go
// out (MA-S).
Event::SendMessage { .. }
if access_token.is_locked() && !access_token.is_shared_mailbox() =>
{
trc::event!( trc::event!(
Sieve(SieveEvent::ActionReject), Sieve(SieveEvent::ActionReject),
Details = "Account is locked: nothing is sent", Details = "Account is locked: nothing is sent",
+282
View File
@@ -0,0 +1,282 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The blocklists a node asks about itself (deliverability spec, DL-6), and
//! how to read each one's answer.
//!
//! A list answers with an address in 127.0.0.0/8. Each list says which of
//! those mean "listed" and which mean "I won't answer you": Spamhaus, for
//! one, answers `127.255.255.254` to a query that came through a public
//! resolver. A refusal is never read as a listing (DL-4).
use std::net::{IpAddr, Ipv4Addr};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
/// Looked up by the reversed address: `2.0.0.127.zen.spamhaus.org`.
Ip,
/// Looked up by name: `example.org.dbl.spamhaus.org`.
Domain,
}
#[derive(Debug, Clone, Copy)]
pub struct BlockList {
/// What the page and the settings call it.
pub name: &'static str,
pub zone: &'static str,
pub scope: Scope,
/// Where an administrator looks the address up and asks for removal.
pub lookup: &'static str,
/// Something the page says beside the list.
pub note: Option<&'static str>,
read: fn(Ipv4Addr) -> Answer,
}
/// What a list's answer means.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer {
Listed(&'static str),
/// The list won't answer this resolver, or not now.
Refused(&'static str),
/// A code the list doesn't define: neither listed nor clean.
Unknown,
}
impl BlockList {
pub fn read(&self, answer: Ipv4Addr) -> Answer {
(self.read)(answer)
}
/// The name to look up for `subject`, or None when the subject doesn't
/// suit the list (a domain on an IP list, or an IPv6 address: none of
/// these lists publish IPv6 zones worth asking).
pub fn query(&self, subject: &Subject<'_>) -> Option<String> {
match (self.scope, subject) {
(Scope::Ip, Subject::Ip(IpAddr::V4(ip))) => {
let [a, b, c, d] = ip.octets();
Some(format!("{d}.{c}.{b}.{a}.{}.", self.zone))
}
(Scope::Domain, Subject::Domain(domain)) => {
Some(format!("{}.{}.", domain.trim_end_matches('.'), self.zone))
}
_ => None,
}
}
}
pub enum Subject<'x> {
Ip(IpAddr),
Domain(&'x str),
}
/// Spamhaus' error codes, the same on every Spamhaus zone.
fn spamhaus_refusal(ip: Ipv4Addr) -> Option<Answer> {
match ip.octets() {
[127, 255, 255, 252] => Some(Answer::Refused("The query was malformed")),
[127, 255, 255, 254] => Some(Answer::Refused(
"Spamhaus doesn't answer public resolvers; use the server's own",
)),
[127, 255, 255, 255] => Some(Answer::Refused("Too many queries from this resolver")),
_ => None,
}
}
fn zen(ip: Ipv4Addr) -> Answer {
if let Some(refused) = spamhaus_refusal(ip) {
return refused;
}
match ip.octets() {
[127, 0, 0, 2] => Answer::Listed("SBL: a known spam source"),
[127, 0, 0, 3] => Answer::Listed("CSS: sent spam recently"),
[127, 0, 0, 4..=7] => Answer::Listed("XBL: a compromised or infected host"),
[127, 0, 0, 9] => Answer::Listed("DROP: a hijacked or criminal network"),
[127, 0, 0, 10 | 11] => {
Answer::Listed("PBL: an address that isn't meant to send mail directly")
}
_ => Answer::Unknown,
}
}
fn dbl(ip: Ipv4Addr) -> Answer {
if let Some(refused) = spamhaus_refusal(ip) {
return refused;
}
match ip.octets() {
[127, 0, 1, 2] => Answer::Listed("A spam domain"),
[127, 0, 1, 4] => Answer::Listed("A phishing domain"),
[127, 0, 1, 5] => Answer::Listed("A malware domain"),
[127, 0, 1, 6] => Answer::Listed("A botnet controller"),
[127, 0, 1, 102..=106] => Answer::Listed("A legitimate domain being abused"),
[127, 0, 1, 255] => Answer::Refused("The query was malformed"),
_ => Answer::Unknown,
}
}
/// Most lists answer 127.0.0.2 for "listed" and define nothing else.
fn just_two(ip: Ipv4Addr) -> Answer {
match ip.octets() {
[127, 0, 0, 2] => Answer::Listed("Listed"),
_ => Answer::Unknown,
}
}
fn surbl(ip: Ipv4Addr) -> Answer {
match ip.octets() {
[127, 0, 0, 1] => Answer::Refused("SURBL doesn't answer this resolver"),
[127, 0, 0, bits] if bits & (8 | 16 | 64 | 128) != 0 => {
Answer::Listed("Seen in phishing, malware, abuse or cracked sites")
}
_ => Answer::Unknown,
}
}
fn uribl(ip: Ipv4Addr) -> Answer {
match ip.octets() {
[127, 0, 0, 1] => Answer::Refused("URIBL doesn't answer public resolvers"),
[127, 0, 0, bits] if bits & (2 | 8) != 0 => Answer::Listed("Seen in spam"),
[127, 0, 0, bits] if bits & 4 != 0 => {
Answer::Listed("Grey: seen in bulk mail some people don't want")
}
_ => Answer::Unknown,
}
}
pub const LISTS: &[BlockList] = &[
BlockList {
name: "Spamhaus ZEN",
zone: "zen.spamhaus.org",
scope: Scope::Ip,
lookup: "https://check.spamhaus.org/",
note: None,
read: zen,
},
BlockList {
name: "SpamCop",
zone: "bl.spamcop.net",
scope: Scope::Ip,
lookup: "https://www.spamcop.net/bl.shtml",
note: None,
read: just_two,
},
BlockList {
name: "Barracuda",
zone: "b.barracudacentral.org",
scope: Scope::Ip,
lookup: "https://www.barracudacentral.org/lookups",
note: Some(
"Barracuda answers only resolvers whose address is registered with it (free, at barracudacentral.org/rbl). Until then its lookups can't be checked.",
),
read: just_two,
},
BlockList {
name: "UCEPROTECT level 1",
zone: "dnsbl-1.uceprotect.net",
scope: Scope::Ip,
lookup: "https://www.uceprotect.net/en/rblcheck.php",
note: None,
read: just_two,
},
BlockList {
name: "Mailspike",
zone: "bl.mailspike.net",
scope: Scope::Ip,
lookup: "https://mailspike.org/iplookup.html",
note: None,
read: just_two,
},
BlockList {
name: "PSBL",
zone: "psbl.surriel.com",
scope: Scope::Ip,
lookup: "https://psbl.org/",
note: None,
read: just_two,
},
BlockList {
name: "Spamhaus DBL",
zone: "dbl.spamhaus.org",
scope: Scope::Domain,
lookup: "https://check.spamhaus.org/",
note: None,
read: dbl,
},
BlockList {
name: "SURBL",
zone: "multi.surbl.org",
scope: Scope::Domain,
lookup: "https://surbl.org/surbl-analysis",
note: None,
read: surbl,
},
BlockList {
name: "URIBL",
zone: "multi.uribl.com",
scope: Scope::Domain,
lookup: "https://admin.uribl.com/",
note: None,
read: uribl,
},
];
pub fn by_name(name: &str) -> Option<&'static BlockList> {
LISTS.iter().find(|list| list.name == name)
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> Ipv4Addr {
s.parse().unwrap()
}
#[test]
fn a_refusal_is_not_a_listing() {
let zen = by_name("Spamhaus ZEN").unwrap();
assert!(matches!(
zen.read(ip("127.255.255.254")),
Answer::Refused(_)
));
assert!(matches!(zen.read(ip("127.0.0.2")), Answer::Listed(_)));
assert!(matches!(zen.read(ip("127.0.0.10")), Answer::Listed(_)));
assert_eq!(zen.read(ip("127.0.0.200")), Answer::Unknown);
let uribl = by_name("URIBL").unwrap();
assert!(matches!(uribl.read(ip("127.0.0.1")), Answer::Refused(_)));
assert!(matches!(uribl.read(ip("127.0.0.2")), Answer::Listed(_)));
}
#[test]
fn queries_are_built_per_scope() {
let zen = by_name("Spamhaus ZEN").unwrap();
let dbl = by_name("Spamhaus DBL").unwrap();
let v4 = Subject::Ip("192.0.2.10".parse().unwrap());
let v6 = Subject::Ip("2001:db8::1".parse().unwrap());
let domain = Subject::Domain("example.org");
assert_eq!(
zen.query(&v4).as_deref(),
Some("10.2.0.192.zen.spamhaus.org.")
);
assert_eq!(zen.query(&v6), None);
assert_eq!(zen.query(&domain), None);
assert_eq!(
dbl.query(&domain).as_deref(),
Some("example.org.dbl.spamhaus.org.")
);
assert_eq!(dbl.query(&v4), None);
}
#[test]
fn names_are_unique() {
for (i, a) in LISTS.iter().enumerate() {
assert!(
LISTS[i + 1..].iter().all(|b| b.name != a.name),
"{}",
a.name
);
}
}
}
+410
View File
@@ -0,0 +1,410 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The deliverability check (deliverability spec): what other mail servers
//! see when this one sends. Not a rebuild of anything upstream ships.
//!
//! Every node that sends mail checks itself, because only it knows which
//! address it leaves from, and keeps one report. The report holds facts: an
//! address's reverse DNS, what each blocklist answered, what SPF said for
//! each address, whether a DKIM key in DNS matches the one signing. The
//! console grades them, so its wording can change without a server release.
//!
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
//! with `D`, then one byte for the kind:
//!
//! - `r` + node id (u64): that node's last report, as JSON.
//! - `s`: the settings, as JSON.
//!
//! Numbers are big-endian.
pub mod lists;
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
const FEATURE: u8 = b'D';
const KIND_REPORT: u8 = b'r';
const KIND_SETTINGS: u8 = b's';
/// DL-15: **Check now** runs a node again only this long after its last run.
pub const MIN_INTERVAL_SECS: u64 = 600;
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Report {
/// The node's cluster id, as metric samples carry it.
pub node_id: u64,
pub hostname: String,
/// Seconds since the epoch.
pub checked_at: u64,
pub addresses: Vec<Address>,
pub domains: Vec<DomainReport>,
pub certificates: Vec<Certificate>,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Address {
pub ip: String,
/// DL-2: how the node came by the address.
pub source: AddressSource,
/// The connection strategy that sends from it.
pub strategy: String,
/// The name the node greets with from this address.
pub ehlo: String,
/// The PTR names, empty when there's none.
pub ptr: Vec<String>,
/// Some PTR name resolves back to the address.
pub forward_confirmed: bool,
/// The forward-confirmed name is the EHLO name.
pub ehlo_matches: bool,
/// Set when the reverse lookup itself failed, rather than found nothing.
pub ptr_error: Option<String>,
pub listings: Vec<Listing>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum AddressSource {
/// Set in the connection strategy's source addresses.
#[default]
Configured,
/// What the EHLO name resolves to.
Ehlo,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Listing {
/// The list's name, as in [`lists::LISTS`].
pub list: String,
pub state: ListingState,
/// The address the list answered, when it answered one.
pub code: Option<String>,
/// What the list says the answer means.
pub meaning: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum ListingState {
#[default]
Clean,
Listed,
/// The list wouldn't answer, or the lookup failed: neither listed nor clean.
Refused,
Error,
/// Switched off in the settings, so not asked.
Off,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DomainReport {
pub domain: String,
/// DL-20: a tenant administrator sees only their tenant's domains.
pub tenant_id: Option<u32>,
/// DL-7: what SPF says for each of the node's addresses.
pub spf: Vec<SpfResult>,
/// DL-8: each DKIM key the domain signs with.
pub dkim: Vec<DkimKey>,
/// DL-9: the DMARC record, if there's one.
pub dmarc: Option<Dmarc>,
/// DL-10.
pub mta_sts: MtaSts,
/// DL-11: there's a `_smtp._tls` record.
pub tls_rpt: bool,
/// DL-12.
pub listings: Vec<Listing>,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct SpfResult {
pub ip: String,
/// `pass`, `fail`, `softFail`, `neutral`, `none`, `tempError` or `permError`.
pub result: String,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DkimKey {
pub selector: String,
pub state: DkimState,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum DkimState {
#[default]
Matches,
/// Nothing published at `<selector>._domainkey.<domain>`.
Missing,
/// Published, but a different key.
Different,
/// The lookup failed.
Error,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Dmarc {
/// `none`, `quarantine` or `reject`.
pub policy: String,
/// DKIM alignment: `relaxed` or `strict`.
pub adkim: String,
/// SPF alignment: `relaxed` or `strict`.
pub aspf: String,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct MtaSts {
/// The `_mta-sts` record's id; None when there's no record.
pub record_id: Option<String>,
/// The policy was fetched and parsed. False with a record means the
/// fetch or the parse failed, and `error` says why.
pub fetched: bool,
pub error: Option<String>,
/// `enforce`, `testing` or `none`.
pub mode: Option<String>,
pub max_age: Option<u64>,
/// The domain's MX names no `mx:` line matches.
pub mx_not_covered: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Certificate {
/// The EHLO name, or an MX name that points at this node.
pub name: String,
/// The node holds a certificate for the name.
pub covered: bool,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Settings {
/// DL-6: lists not to ask, by name.
pub disabled_lists: Vec<String>,
}
impl Settings {
pub fn is_off(&self, list: &str) -> bool {
self.disabled_lists.iter().any(|name| name == list)
}
/// Only the built-in lists' names, once each.
pub fn validate(&self) -> Result<(), String> {
for (i, name) in self.disabled_lists.iter().enumerate() {
if lists::by_name(name).is_none() {
return Err(format!("There's no list called {name:?}."));
}
if self.disabled_lists[..i].contains(name) {
return Err(format!("{name:?} is named twice."));
}
}
Ok(())
}
}
impl Report {
/// DL-20: what a tenant administrator may see: their tenant's domains
/// and nothing about the node's addresses or certificates.
pub fn for_tenant(&self, tenant_id: u32) -> Report {
Report {
node_id: self.node_id,
hostname: self.hostname.clone(),
checked_at: self.checked_at,
addresses: Vec::new(),
domains: self
.domains
.iter()
.filter(|d| d.tenant_id == Some(tenant_id))
.cloned()
.collect(),
certificates: Vec::new(),
}
}
}
// --- 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 deliverability data")
.reason(err)
})
}
}
impl<T: for<'de> SerdeDeserialize<'de> + Send + Sync> 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 deliverability data")
.reason(err)
})
}
}
fn class(kind: u8, node_id: Option<u64>) -> ValueClass {
let mut key = Vec::with_capacity(10);
key.push(FEATURE);
key.push(kind);
if let Some(node_id) = node_id {
key.extend_from_slice(&node_id.to_be_bytes());
}
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
pub async fn report(data: &Store, node_id: u64) -> trc::Result<Option<Report>> {
Ok(data
.get_value::<Json<Report>>(ValueKey::from(class(KIND_REPORT, Some(node_id))))
.await
.caused_by(trc::location!())?
.map(|Json(report)| report))
}
/// Every node's report, by node id.
pub async fn reports(data: &Store) -> trc::Result<Vec<Report>> {
let mut out = Vec::new();
data.iterate(
IterateParams::new(
ValueKey::from(class(KIND_REPORT, Some(0))),
ValueKey::from(class(KIND_REPORT, Some(u64::MAX))),
),
|_, value| {
if let Ok(Json(report)) = Json::<Report>::deserialize(value) {
out.push(report);
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
out.sort_by_key(|r| r.node_id);
Ok(out)
}
/// Replaces the node's report.
pub async fn put_report(data: &Store, report: &Report) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(
class(KIND_REPORT, Some(report.node_id)),
Json(report).serialize()?,
);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
pub async fn settings(data: &Store) -> trc::Result<Settings> {
Ok(data
.get_value::<Json<Settings>>(ValueKey::from(class(KIND_SETTINGS, None)))
.await
.caused_by(trc::location!())?
.map(|Json(settings)| settings)
.unwrap_or_default())
}
pub async fn put_settings(data: &Store, settings: &Settings) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(class(KIND_SETTINGS, None), Json(settings).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_name_only_built_in_lists_once() {
let ok = Settings {
disabled_lists: vec!["Barracuda".into(), "URIBL".into()],
};
assert!(ok.validate().is_ok());
assert!(ok.is_off("Barracuda"));
assert!(!ok.is_off("SpamCop"));
let unknown = Settings {
disabled_lists: vec!["My list".into()],
};
assert!(unknown.validate().is_err());
let twice = Settings {
disabled_lists: vec!["URIBL".into(), "URIBL".into()],
};
assert!(twice.validate().is_err());
}
#[test]
fn a_tenant_sees_only_its_domains() {
let report = Report {
node_id: 2,
hostname: "mx2.example.org".into(),
checked_at: 1,
addresses: vec![Address {
ip: "192.0.2.10".into(),
..Default::default()
}],
domains: vec![
DomainReport {
domain: "a.example".into(),
tenant_id: Some(7),
..Default::default()
},
DomainReport {
domain: "b.example".into(),
tenant_id: Some(8),
..Default::default()
},
DomainReport {
domain: "server.example".into(),
tenant_id: None,
..Default::default()
},
],
certificates: vec![Certificate {
name: "mx2.example.org".into(),
covered: true,
}],
};
let seen = report.for_tenant(7);
assert!(seen.addresses.is_empty());
assert!(seen.certificates.is_empty());
assert_eq!(
seen.domains
.iter()
.map(|d| d.domain.as_str())
.collect::<Vec<_>>(),
["a.example"]
);
}
#[test]
fn a_report_reads_back_with_missing_fields() {
let report: Report = serde_json::from_str(r#"{"nodeId": 3}"#).unwrap();
assert_eq!(report.node_id, 3);
assert!(report.domains.is_empty());
}
}
+1
View File
@@ -21,6 +21,7 @@
pub mod ai; pub mod ai;
pub mod audit; pub mod audit;
pub mod branding; pub mod branding;
pub mod deliverability; // inbuxa: the deliverability check (not a rebuild)
pub mod hold; pub mod hold;
pub mod journal; pub mod journal;
pub mod lock; pub mod lock;
+72 -3
View File
@@ -66,6 +66,53 @@ const KIND_DELEGATE: u8 = b'd';
/// Most delegates one lock may have (AL-5). /// Most delegates one lock may have (AL-5).
pub const MAX_DELEGATES: usize = 10; pub const MAX_DELEGATES: usize = 10;
/// Most people one shared mailbox may have (MA-S): a help desk is bigger
/// than the handful a departed colleague's mail is handed to.
pub const MAX_SHARED_MAILBOX_DELEGATES: usize = 100;
/// What a lock is for (multi-account spec, MA-S).
///
/// Both kinds keep receiving mail, can't be signed in to, and are opened by
/// delegates through real grants. A shared mailbox is a role address such
/// as support@: it needs no reason, holds more people, runs its own Sieve
/// replies (an automatic acknowledgement), records only what is sent as it,
/// and may only send as its own addresses.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum Kind {
#[default]
Lock,
SharedMailbox,
}
impl Kind {
pub fn as_str(&self) -> &'static str {
match self {
Kind::Lock => "lock",
Kind::SharedMailbox => "sharedMailbox",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"lock" => Some(Kind::Lock),
"sharedMailbox" => Some(Kind::SharedMailbox),
_ => None,
}
}
pub fn is_lock(&self) -> bool {
matches!(self, Kind::Lock)
}
pub fn max_delegates(&self) -> usize {
match self {
Kind::Lock => MAX_DELEGATES,
Kind::SharedMailbox => MAX_SHARED_MAILBOX_DELEGATES,
}
}
}
/// What a delegate may do in the locked account (AL-6). /// What a delegate may do in the locked account (AL-6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -189,6 +236,9 @@ pub struct Replaced {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Lock { pub struct Lock {
pub account_id: u32, pub account_id: u32,
/// Absent on locks written before shared mailboxes existed: a lock.
#[serde(default, skip_serializing_if = "Kind::is_lock")]
pub kind: Kind,
pub reason: String, pub reason: String,
/// Seconds since the epoch. /// Seconds since the epoch.
pub locked_at: u64, pub locked_at: u64,
@@ -401,8 +451,9 @@ pub async fn all(data: &Store) -> trc::Result<Vec<Lock>> {
Ok(locks) Ok(locks)
} }
/// The accounts delegated to `delegate`, with its delegation in each. /// The accounts delegated to `delegate`, with its delegation in each and
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate)>> { /// the kind of lock it is in.
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate, Kind)>> {
let mut locked = Vec::new(); let mut locked = Vec::new();
data.iterate( data.iterate(
IterateParams::new( IterateParams::new(
@@ -425,7 +476,7 @@ pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32,
if let Some(lock) = get(data, account_id).await? if let Some(lock) = get(data, account_id).await?
&& let Some(delegation) = lock.delegate(delegate) && let Some(delegation) = lock.delegate(delegate)
{ {
delegations.push((account_id, delegation.clone())); delegations.push((account_id, delegation.clone(), lock.kind));
} }
} }
Ok(delegations) Ok(delegations)
@@ -472,6 +523,22 @@ pub async fn remove(data: &Store, lock: &Lock) -> trc::Result<()> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn kind_reads_back_and_defaults_to_lock() {
// MA-S: a lock stored before shared mailboxes existed has no kind
let stored = r#"{"accountId":1,"reason":"r","lockedAt":0,"lockedBy":"admin","delegates":[]}"#;
let lock: Lock = serde_json::from_str(stored).unwrap();
assert_eq!(lock.kind, Kind::Lock);
assert!(!serde_json::to_string(&lock).unwrap().contains("kind"), "a lock is written as before");
let shared = Lock { kind: Kind::SharedMailbox, ..lock };
let written = serde_json::to_string(&shared).unwrap();
assert!(written.contains(r#""kind":"sharedMailbox""#), "{written}");
assert_eq!(serde_json::from_str::<Lock>(&written).unwrap().kind, Kind::SharedMailbox);
assert_eq!(Kind::parse("sharedMailbox"), Some(Kind::SharedMailbox));
assert_eq!(Kind::SharedMailbox.max_delegates(), MAX_SHARED_MAILBOX_DELEGATES);
}
#[test] #[test]
fn keys_read_back() { fn keys_read_back() {
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else { let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
@@ -509,6 +576,7 @@ mod tests {
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock { fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
Lock { Lock {
account_id: 1, account_id: 1,
kind: Kind::Lock,
reason: "r".into(), reason: "r".into(),
locked_at: 0, locked_at: 0,
locked_by: "admin".into(), locked_by: "admin".into(),
@@ -623,6 +691,7 @@ mod tests {
fn expired_delegations_grant_nothing() { fn expired_delegations_grant_nothing() {
let lock = Lock { let lock = Lock {
account_id: 1, account_id: 1,
kind: Kind::Lock,
reason: "Left the company".into(), reason: "Left the company".into(),
locked_at: 100, locked_at: 100,
locked_by: "admin".into(), locked_by: "admin".into(),
+1
View File
@@ -15,4 +15,5 @@ pub mod legacy_use;
pub mod log_files; pub mod log_files;
pub mod listeners; pub mod listeners;
pub mod protocol_policy; pub mod protocol_policy;
pub mod sharing_policy;
pub mod tenant_protocol_policy; pub mod tenant_protocol_policy;
@@ -0,0 +1,188 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:SharingPolicy`, whether people may share their own mail and add
//! other accounts to the webmail (multi-account spec, MA-C, MA-10 to MA-14).
//!
//! Two levels, as the legacy-protocols switch has: the server's policy, and
//! one per tenant that can only be stricter. Stored as JSON in the fork's
//! subspace, `W` + `p` for the server and `W` + `t` + tenant for a tenant;
//! unset reads as the defaults, which are on, so a server keeps today's
//! behavior until someone turns it off.
//!
//! "Off" refuses new shares and stops honoring the ones already made, which
//! stay stored, so turning it back on restores them (John, 2026-10-05).
//! Group membership and shared mailboxes aren't users' shares and are never
//! affected.
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
/// One level's switches. `None` on a tenant means "as the server says".
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharingPolicy {
/// People may share their own mail folders (MA-11). Default on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mail_sharing: Option<bool>,
/// People may add their other accounts to the webmail (MA-B). Default on.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub add_accounts: Option<bool>,
/// Seconds since the epoch, and who: the console shows them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub changed_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub changed_by: Option<String>,
}
/// What applies to one account: the server's switch, narrowed by its
/// tenant's.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Effective {
pub mail_sharing: bool,
pub add_accounts: bool,
}
impl Default for Effective {
fn default() -> Self {
Effective {
mail_sharing: true,
add_accounts: true,
}
}
}
/// A tenant can be stricter than the server, never looser (MA-C).
pub fn effective(server: &SharingPolicy, tenant: Option<&SharingPolicy>) -> Effective {
let server_mail = server.mail_sharing.unwrap_or(true);
let server_add = server.add_accounts.unwrap_or(true);
Effective {
mail_sharing: server_mail && tenant.and_then(|t| t.mail_sharing).unwrap_or(true),
add_accounts: server_add && tenant.and_then(|t| t.add_accounts).unwrap_or(true),
}
}
/// Why a tenant can't turn a switch on: the server has it off.
pub fn looser_than_server(server: &SharingPolicy, tenant: &SharingPolicy) -> Option<&'static str> {
if tenant.mail_sharing == Some(true) && server.mail_sharing == Some(false) {
return Some("The server has mail sharing off; a tenant can only be stricter.");
}
if tenant.add_accounts == Some(true) && server.add_accounts == Some(false) {
return Some("The server has adding accounts off; a tenant can only be stricter.");
}
None
}
fn key(tenant_id: Option<u32>) -> ValueClass {
let mut key = Vec::with_capacity(6);
match tenant_id {
None => key.extend_from_slice(b"Wp"),
Some(tenant_id) => {
key.extend_from_slice(b"Wt");
key.extend_from_slice(&tenant_id.to_be_bytes());
}
}
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
struct Json(SharingPolicy);
impl Deserialize for Json {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.caused_by(trc::location!())
.reason(err)
})
}
}
/// The server's policy (`None`) or a tenant's.
pub async fn get(data: &Store, tenant_id: Option<u32>) -> trc::Result<SharingPolicy> {
Ok(data
.get_value::<Json>(ValueKey::from(key(tenant_id)))
.await
.caused_by(trc::location!())?
.map(|Json(policy)| policy)
.unwrap_or_default())
}
/// What applies to an account in `tenant_id`.
pub async fn effective_for(data: &Store, tenant_id: Option<u32>) -> trc::Result<Effective> {
let server = get(data, None).await?;
let tenant = match tenant_id {
Some(tenant_id) => Some(get(data, Some(tenant_id)).await?),
None => None,
};
Ok(effective(&server, tenant.as_ref()))
}
/// Stores a policy.
pub async fn set(data: &Store, tenant_id: Option<u32>, policy: &SharingPolicy) -> trc::Result<()> {
let bytes = serde_json::to_vec(policy).map_err(|err| {
trc::StoreEvent::UnexpectedError
.caused_by(trc::location!())
.reason(err)
})?;
let mut batch = BatchBuilder::new();
batch.set(key(tenant_id), bytes);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
fn on_off(mail: Option<bool>, add: Option<bool>) -> SharingPolicy {
SharingPolicy {
mail_sharing: mail,
add_accounts: add,
..Default::default()
}
}
#[test]
fn unset_is_on() {
assert_eq!(effective(&SharingPolicy::default(), None), Effective::default());
assert_eq!(
effective(&SharingPolicy::default(), Some(&SharingPolicy::default())),
Effective::default()
);
}
#[test]
fn a_tenant_is_only_ever_stricter() {
// The server off wins over a tenant on
let server = on_off(Some(false), None);
let tenant = on_off(Some(true), Some(false));
let e = effective(&server, Some(&tenant));
assert!(!e.mail_sharing);
assert!(!e.add_accounts, "the tenant's own off holds");
assert!(looser_than_server(&server, &tenant).is_some());
// A tenant off under a server on
let e = effective(&on_off(Some(true), Some(true)), Some(&on_off(Some(false), None)));
assert!(!e.mail_sharing && e.add_accounts);
assert!(looser_than_server(&on_off(None, None), &on_off(Some(true), Some(true))).is_none());
}
#[test]
fn keys_stay_apart() {
let ValueClass::Any(server) = key(None) else { panic!() };
let ValueClass::Any(tenant) = key(Some(7)) else { panic!() };
assert_eq!(server.key, b"Wp");
assert_eq!(tenant.key, [b'W', b't', 0, 0, 0, 7]);
}
}
+3
View File
@@ -2,8 +2,11 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
pub mod authenticate; pub mod authenticate;
pub mod oauth; pub mod oauth;
pub mod permissions; pub mod permissions;
pub mod token_only;
+88
View File
@@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Where HTTP Basic authentication is refused (contract C-23).
//!
//! Outside DAV, the HTTP endpoints take a token, never a password: JMAP, the
//! management API, and the OAuth endpoints that authenticate a user
//! (introspection, userinfo, authenticated client registration). CalDAV and
//! CardDAV keep Basic, since that's how calendar and contacts apps sign in.
//! The token endpoint's own client authentication isn't user sign-in and
//! isn't affected.
//!
//! Bootstrap and recovery mode accept Basic everywhere, as they keep
//! permissive CORS (C-16), and `INBUXA_HTTP_BASIC_AUTH=all` puts it back
//! everywhere for an operator who needs it.
use crate::auth::authenticate::HttpHeaders;
use http_proto::HttpRequest;
/// Whether `path` takes a token only when Basic isn't allowed everywhere.
pub fn is_token_only_path(path: &str) -> bool {
let mut segments = path.trim_start_matches('/').split('/');
match segments.next() {
Some("jmap" | "api") => true,
Some("auth") => matches!(
segments.next(),
Some("introspect" | "userinfo" | "register")
),
_ => false,
}
}
/// Whether this request signs in with a password where only a token is
/// accepted.
pub fn is_refused_basic(req: &HttpRequest, basic_auth_everywhere: bool) -> bool {
!basic_auth_everywhere
&& req.authorization_basic().is_some()
&& is_token_only_path(req.uri().path())
}
#[cfg(test)]
mod tests {
use super::is_token_only_path;
#[test]
fn token_only_paths() {
for path in [
"/jmap",
"/jmap/",
"/jmap/session",
"/jmap/upload/a/",
"/jmap/download/a/b/c",
"/jmap/eventsource/",
"/jmap/ws",
"/api",
"/api/account",
"/api/schema",
"/auth/introspect",
"/auth/userinfo",
"/auth/register",
] {
assert!(is_token_only_path(path), "{path} should take a token only");
}
}
#[test]
fn basic_stays_where_apps_need_it() {
for path in [
"/dav/cal/user/",
"/dav/card/user/",
"/.well-known/caldav",
"/.well-known/carddav",
"/.well-known/jmap",
"/auth/token",
"/auth/device",
"/scim/v2/Users",
"/",
"/login",
"/jmapx",
"/apis",
] {
assert!(!is_token_only_path(path), "{path} should be left alone");
}
}
}
+23 -2
View File
@@ -8,13 +8,14 @@
use crate::{ use crate::{
HttpSessionManager, HttpSessionManager,
api::{AuthChallenge, ManagementApi, ToManageHttpResponse}, api::{AuthChallenge, ManagementApi, ToManageHttpResponse, UnauthorizedResponse},
auth::{ auth::{
authenticate::{Authenticator, HttpHeaders}, authenticate::{Authenticator, HttpHeaders},
oauth::{ oauth::{
FormData, auth::OAuthApiHandler, openid::OpenIdHandler, FormData, auth::OAuthApiHandler, openid::OpenIdHandler,
registration::ClientRegistrationHandler, token::TokenHandler, registration::ClientRegistrationHandler, token::TokenHandler,
}, },
token_only::{is_refused_basic, is_token_only_path},
}, },
form::FormHandler, form::FormHandler,
}; };
@@ -92,6 +93,17 @@ impl ParseHttp for Server {
} }
} }
// inbuxa: outside DAV, sign in with a token, never a password (contract C-23)
if is_refused_basic(&req, self.core.network.http.basic_auth_everywhere) {
trc::event!(
Auth(trc::AuthEvent::Failed),
SpanId = session.session_id,
RemoteIp = session.remote_ip,
Reason = "Basic authentication is accepted on DAV only; use a bearer token",
);
return Ok(HttpResponse::unauthorized(AuthChallenge::Bearer));
}
match path.next().unwrap_or_default() { match path.next().unwrap_or_default() {
"jmap" => { "jmap" => {
match (path.next().unwrap_or_default(), req.method()) { match (path.next().unwrap_or_default(), req.method()) {
@@ -782,6 +794,15 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
// inbuxa: kept for the cross-origin allowlist (contract C-14) // inbuxa: kept for the cross-origin allowlist (contract C-14)
let origin = req.headers().get(hyper::header::ORIGIN).cloned(); let origin = req.headers().get(hyper::header::ORIGIN).cloned();
// inbuxa: offer Basic only where it's accepted (contract C-23)
let challenge = if server.core.network.http.basic_auth_everywhere
|| !is_token_only_path(req.uri().path())
{
AuthChallenge::BearerAndBasic
} else {
AuthChallenge::Bearer
};
// Parse HTTP request // Parse HTTP request
let response = match Box::pin(server.parse_http_request( let response = match Box::pin(server.parse_http_request(
req, req,
@@ -799,7 +820,7 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
{ {
Ok(response) => response, Ok(response) => response,
Err(err) => { Err(err) => {
let response = err.into_http_response(AuthChallenge::BearerAndBasic); let response = err.into_http_response(challenge);
trc::error!(err.span_id(session.session_id)); trc::error!(err.span_id(session.session_id));
response response
} }
+46 -4
View File
@@ -212,7 +212,7 @@ impl<T: SessionStream> Session<T> {
} }
rights rights
} else { } else {
vec![ let mut rights = vec![
Rights::Read, Rights::Read,
Rights::Lookup, Rights::Lookup,
Rights::Insert, Rights::Insert,
@@ -223,8 +223,12 @@ impl<T: SessionStream> Session<T> {
Rights::CreateMailbox, Rights::CreateMailbox,
Rights::DeleteMailbox, Rights::DeleteMailbox,
Rights::Post, Rights::Post,
Rights::Administer, ];
] // inbuxa: MA-D0: a group's members don't share its mailboxes on.
if !access_token.is_group_member_only(mailbox_id.account_id) {
rights.push(Rights::Administer);
}
rights
}; };
trc::event!( trc::event!(
@@ -266,10 +270,20 @@ impl<T: SessionStream> Session<T> {
spawn_op!(data, { spawn_op!(data, {
// Validate mailbox // Validate mailbox
let (mailbox_id, current_mailbox, _) = data let (mailbox_id, current_mailbox, access_token) = data
.get_acl_mailbox(&arguments, true) .get_acl_mailbox(&arguments, true)
.await .await
.imap_ctx(&arguments.tag, trc::location!())?; .imap_ctx(&arguments.tag, trc::location!())?;
// inbuxa: MA-D0: a group's members don't share its mailboxes on.
if access_token.is_group_member_only(mailbox_id.account_id) {
return Err(trc::ImapEvent::Error
.into_err()
.details("This mailbox belongs to a group. Only an administrator can change who has it.")
.code(ResponseCode::NoPerm)
.id(arguments.tag.to_string()));
}
let current_mailbox = current_mailbox let current_mailbox = current_mailbox
.into_deserialized::<email::mailbox::Mailbox>() .into_deserialized::<email::mailbox::Mailbox>()
.imap_ctx(&arguments.tag, trc::location!())?; .imap_ctx(&arguments.tag, trc::location!())?;
@@ -363,6 +377,34 @@ impl<T: SessionStream> Session<T> {
} }
} }
// inbuxa: MA-C: with mail sharing off, nobody here starts or
// widens a share (narrowing or ending one is always allowed)
let had = current_mailbox
.inner
.acls
.iter()
.find(|item| item.account_id == acl_account_id)
.map_or(0, |item| item.grants.clone().into_inner());
let has = mailbox
.acls
.iter()
.find(|item| item.account_id == acl_account_id)
.map_or(0, |item| item.grants.clone().into_inner());
if has & !had != 0
&& !access_token.has_permission(Permission::Impersonate)
&& !data
.server
.mail_sharing_allowed(mailbox_id.account_id)
.await
.imap_ctx(&arguments.tag, trc::location!())?
{
return Err(trc::ImapEvent::Error
.into_err()
.details("Your organization has turned off sharing mail folders.")
.code(ResponseCode::NoPerm)
.id(arguments.tag.to_string()));
}
if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item { if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item {
return Err(trc::ImapEvent::Error return Err(trc::ImapEvent::Error
.into_err() .into_err()
@@ -28,6 +28,8 @@ pub enum AccountLockProperty {
/// The locked account (on create; afterwards the same as `id`). /// The locked account (on create; afterwards the same as `id`).
AccountId, AccountId,
Name, Name,
/// MA-S: `lock` (the default) or `sharedMailbox`; set on create only.
Kind,
Reason, Reason,
LockedAt, LockedAt,
LockedBy, LockedBy,
@@ -53,6 +55,7 @@ impl Property for AccountLockProperty {
AccountLockProperty::Id => "id", AccountLockProperty::Id => "id",
AccountLockProperty::AccountId => "accountId", AccountLockProperty::AccountId => "accountId",
AccountLockProperty::Name => "name", AccountLockProperty::Name => "name",
AccountLockProperty::Kind => "kind",
AccountLockProperty::Reason => "reason", AccountLockProperty::Reason => "reason",
AccountLockProperty::LockedAt => "lockedAt", AccountLockProperty::LockedAt => "lockedAt",
AccountLockProperty::LockedBy => "lockedBy", AccountLockProperty::LockedBy => "lockedBy",
@@ -68,6 +71,7 @@ impl AccountLockProperty {
b"id" => AccountLockProperty::Id, b"id" => AccountLockProperty::Id,
b"accountId" => AccountLockProperty::AccountId, b"accountId" => AccountLockProperty::AccountId,
b"name" => AccountLockProperty::Name, b"name" => AccountLockProperty::Name,
b"kind" => AccountLockProperty::Kind,
b"reason" => AccountLockProperty::Reason, b"reason" => AccountLockProperty::Reason,
b"lockedAt" => AccountLockProperty::LockedAt, b"lockedAt" => AccountLockProperty::LockedAt,
b"lockedBy" => AccountLockProperty::LockedBy, b"lockedBy" => AccountLockProperty::LockedBy,
@@ -0,0 +1,173 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:DeliverabilityReport/get` and `/set` under `urn:inbuxa:jmap`:
//! each sending node's last deliverability check (deliverability spec).
//! One per node, written by the server. Creating one asks every node to
//! check itself now (DL-15); nothing is updated or destroyed.
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 DeliverabilityReport;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DeliverabilityReportProperty {
Id,
NodeId,
Hostname,
CheckedAt,
Addresses,
Domains,
Certificates,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DeliverabilityReportValue {
Id(Id),
}
impl Property for DeliverabilityReportProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
// Keys inside the addresses, domains and certificates stay plain keys
match parent {
None => DeliverabilityReportProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
DeliverabilityReportProperty::Id => "id",
DeliverabilityReportProperty::NodeId => "nodeId",
DeliverabilityReportProperty::Hostname => "hostname",
DeliverabilityReportProperty::CheckedAt => "checkedAt",
DeliverabilityReportProperty::Addresses => "addresses",
DeliverabilityReportProperty::Domains => "domains",
DeliverabilityReportProperty::Certificates => "certificates",
}
.into()
}
}
impl DeliverabilityReportProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => DeliverabilityReportProperty::Id,
b"nodeId" => DeliverabilityReportProperty::NodeId,
b"hostname" => DeliverabilityReportProperty::Hostname,
b"checkedAt" => DeliverabilityReportProperty::CheckedAt,
b"addresses" => DeliverabilityReportProperty::Addresses,
b"domains" => DeliverabilityReportProperty::Domains,
b"certificates" => DeliverabilityReportProperty::Certificates,
)
}
}
impl FromStr for DeliverabilityReportProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
DeliverabilityReportProperty::parse(s).ok_or(())
}
}
impl Element for DeliverabilityReportValue {
type Property = DeliverabilityReportProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(DeliverabilityReportProperty::Id) => {
Id::from_str(value).ok().map(DeliverabilityReportValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
DeliverabilityReportValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for DeliverabilityReport {
type Property = DeliverabilityReportProperty;
type Element = DeliverabilityReportValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = DeliverabilityReportProperty::Id;
}
impl From<Id> for DeliverabilityReportValue {
fn from(id: Id) -> Self {
DeliverabilityReportValue::Id(id)
}
}
impl JmapObjectId for DeliverabilityReportValue {
fn as_id(&self) -> Option<Id> {
match self {
DeliverabilityReportValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
DeliverabilityReportValue::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 = DeliverabilityReportValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for DeliverabilityReportProperty {
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,160 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:DeliverabilitySettings/get` and `/set` under `urn:inbuxa:jmap`:
//! which of the built-in blocklists the deliverability check leaves out
//! (deliverability spec, DL-6), and, read only, what the lists are.
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 DeliverabilitySettings;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DeliverabilitySettingsProperty {
Id,
DisabledLists,
Lists,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DeliverabilitySettingsValue {
Id(Id),
}
impl Property for DeliverabilitySettingsProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
// Keys inside the lists stay plain keys
match parent {
None => DeliverabilitySettingsProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
DeliverabilitySettingsProperty::Id => "id",
DeliverabilitySettingsProperty::DisabledLists => "disabledLists",
DeliverabilitySettingsProperty::Lists => "lists",
}
.into()
}
}
impl DeliverabilitySettingsProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => DeliverabilitySettingsProperty::Id,
b"disabledLists" => DeliverabilitySettingsProperty::DisabledLists,
b"lists" => DeliverabilitySettingsProperty::Lists,
)
}
}
impl FromStr for DeliverabilitySettingsProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
DeliverabilitySettingsProperty::parse(s).ok_or(())
}
}
impl Element for DeliverabilitySettingsValue {
type Property = DeliverabilitySettingsProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(DeliverabilitySettingsProperty::Id) => Id::from_str(value)
.ok()
.map(DeliverabilitySettingsValue::Id),
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
DeliverabilitySettingsValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for DeliverabilitySettings {
type Property = DeliverabilitySettingsProperty;
type Element = DeliverabilitySettingsValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = DeliverabilitySettingsProperty::Id;
}
impl From<Id> for DeliverabilitySettingsValue {
fn from(id: Id) -> Self {
DeliverabilitySettingsValue::Id(id)
}
}
impl JmapObjectId for DeliverabilitySettingsValue {
fn as_id(&self) -> Option<Id> {
match self {
DeliverabilitySettingsValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
DeliverabilitySettingsValue::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 = DeliverabilitySettingsValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for DeliverabilitySettingsProperty {
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,185 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:SharingPolicy/get` and `/set` under `urn:inbuxa:jmap`: whether
//! people may share their own mail and add other accounts to the webmail
//! (multi-account spec, MA-C). The server's policy has the singleton id;
//! each tenant's has the tenant's id.
//!
//! `tenantId`, `changedAt` and `changedBy` are the server's to say. A client
//! that sets them is answered with `invalidProperties`.
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 SharingPolicy;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SharingPolicyProperty {
Id,
/// Server-set: the tenant this is the policy of, or null for the server's.
TenantId,
/// `enabled` or `disabled`: people may share their own mail folders.
MailSharing,
/// `enabled` or `disabled`: people may add other accounts to the webmail.
AddAccounts,
ChangedAt,
ChangedBy,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SharingPolicyValue {
Id(Id),
}
impl Property for SharingPolicyProperty {
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
SharingPolicyProperty::parse(value)
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
SharingPolicyProperty::Id => "id",
SharingPolicyProperty::TenantId => "tenantId",
SharingPolicyProperty::MailSharing => "mailSharing",
SharingPolicyProperty::AddAccounts => "addAccounts",
SharingPolicyProperty::ChangedAt => "changedAt",
SharingPolicyProperty::ChangedBy => "changedBy",
}
.into()
}
}
impl SharingPolicyProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => SharingPolicyProperty::Id,
b"tenantId" => SharingPolicyProperty::TenantId,
b"mailSharing" => SharingPolicyProperty::MailSharing,
b"addAccounts" => SharingPolicyProperty::AddAccounts,
b"changedAt" => SharingPolicyProperty::ChangedAt,
b"changedBy" => SharingPolicyProperty::ChangedBy,
)
}
}
impl SharingPolicyProperty {
/// Whether this property is the server's to say. A client that sets one
/// is answered with `invalidProperties`.
pub fn is_server_set(&self) -> bool {
matches!(
self,
SharingPolicyProperty::TenantId
| SharingPolicyProperty::ChangedAt
| SharingPolicyProperty::ChangedBy
)
}
}
impl FromStr for SharingPolicyProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
SharingPolicyProperty::parse(s).ok_or(())
}
}
impl Element for SharingPolicyValue {
type Property = SharingPolicyProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(SharingPolicyProperty::Id) => {
Id::from_str(value).ok().map(SharingPolicyValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
SharingPolicyValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for SharingPolicy {
type Property = SharingPolicyProperty;
type Element = SharingPolicyValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = SharingPolicyProperty::Id;
}
impl From<Id> for SharingPolicyValue {
fn from(id: Id) -> Self {
SharingPolicyValue::Id(id)
}
}
impl JmapObjectId for SharingPolicyValue {
fn as_id(&self) -> Option<Id> {
match self {
SharingPolicyValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
SharingPolicyValue::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 = SharingPolicyValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for SharingPolicyProperty {
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
}
}
+3
View File
@@ -31,6 +31,8 @@ pub mod inbuxa_audit; // inbuxa: the audit log
pub mod inbuxa_legal_hold; // inbuxa: legal hold pub mod inbuxa_legal_hold; // inbuxa: legal hold
pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules 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_security_acceptance; // inbuxa: accepted security to-do items
pub mod inbuxa_deliverability_report; // inbuxa: the deliverability check
pub mod inbuxa_deliverability_settings; // inbuxa: the deliverability check
pub mod inbuxa_journal; // inbuxa: journaling pub mod inbuxa_journal; // inbuxa: journaling
pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export
pub mod inbuxa_held_message; // inbuxa: mail held for review pub mod inbuxa_held_message; // inbuxa: mail held for review
@@ -38,6 +40,7 @@ pub mod inbuxa_hold_export; // inbuxa: legal hold exports
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
pub mod inbuxa_sharing_policy; // inbuxa: MA-C, who may share mail
pub mod inbuxa_deleted_account; // inbuxa: undelete pub mod inbuxa_deleted_account; // inbuxa: undelete
pub mod file_node; pub mod file_node;
pub mod identity; pub mod identity;
+9
View File
@@ -91,6 +91,12 @@ impl Response<'_> {
GetResponseMethod::SecurityAcceptance(response) => { GetResponseMethod::SecurityAcceptance(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
GetResponseMethod::DeliverabilityReport(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::DeliverabilitySettings(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::Journal(response) => { GetResponseMethod::Journal(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
@@ -109,6 +115,9 @@ impl Response<'_> {
GetResponseMethod::TenantProtocolPolicy(response) => { GetResponseMethod::TenantProtocolPolicy(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
GetResponseMethod::SharingPolicy(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::Principal(response) => { GetResponseMethod::Principal(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
@@ -56,6 +56,8 @@ impl Response<'_> {
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?, GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
GetRequestMethod::MailRule(request) => request.resolve_references(self)?, GetRequestMethod::MailRule(request) => request.resolve_references(self)?,
GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?, GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?,
GetRequestMethod::DeliverabilityReport(request) => request.resolve_references(self)?,
GetRequestMethod::DeliverabilitySettings(request) => request.resolve_references(self)?,
GetRequestMethod::Journal(request) => request.resolve_references(self)?, GetRequestMethod::Journal(request) => request.resolve_references(self)?,
GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?, GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?,
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?, GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
@@ -64,6 +66,9 @@ impl Response<'_> {
GetRequestMethod::TenantProtocolPolicy(request) => { GetRequestMethod::TenantProtocolPolicy(request) => {
request.resolve_references(self)? request.resolve_references(self)?
} }
GetRequestMethod::SharingPolicy(request) => {
request.resolve_references(self)?
}
GetRequestMethod::Principal(request) => request.resolve_references(self)?, GetRequestMethod::Principal(request) => request.resolve_references(self)?,
GetRequestMethod::Quota(request) => request.resolve_references(self)?, GetRequestMethod::Quota(request) => request.resolve_references(self)?,
GetRequestMethod::Blob(request) => request.resolve_references(self)?, GetRequestMethod::Blob(request) => request.resolve_references(self)?,
@@ -137,6 +142,12 @@ impl Response<'_> {
SetRequestMethod::SecurityAcceptance(request) => { SetRequestMethod::SecurityAcceptance(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
SetRequestMethod::DeliverabilityReport(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::DeliverabilitySettings(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::Journal(request) => { SetRequestMethod::Journal(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
@@ -158,6 +169,9 @@ impl Response<'_> {
SetRequestMethod::TenantProtocolPolicy(request) => { SetRequestMethod::TenantProtocolPolicy(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
SetRequestMethod::SharingPolicy(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::AddressBook(request) => { SetRequestMethod::AddressBook(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
+12 -1
View File
@@ -148,8 +148,12 @@ pub struct InbuxaDelegatedCapabilities {
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
pub struct DelegationInfo { pub struct DelegationInfo {
/// Always true: only locked accounts are delegated. /// Always true: only locked accounts are delegated. A shared mailbox is
/// a lock too, so a front end that knows no `kind` still treats it as
/// one it may only reach as a delegate.
pub locked: bool, pub locked: bool,
/// MA-S: `lock` or `sharedMailbox`.
pub kind: &'static str,
/// `read`, `organize` or `full`. /// `read`, `organize` or `full`.
pub access: &'static str, pub access: &'static str,
#[serde(rename(serialize = "sendAs"))] #[serde(rename(serialize = "sendAs"))]
@@ -179,6 +183,13 @@ pub struct InbuxaAccountCapabilities {
/// spec, EX-1 to EX-4). /// spec, EX-1 to EX-4).
#[serde(rename(serialize = "aiExplain"))] #[serde(rename(serialize = "aiExplain"))]
pub ai_explain: bool, pub ai_explain: bool,
/// MA-C: whether the principal may share their own mail folders, and
/// add other accounts to the webmail: the stricter of the server's
/// switch and its tenant's.
#[serde(rename(serialize = "mailSharing"))]
pub mail_sharing: bool,
#[serde(rename(serialize = "addAccounts"))]
pub add_accounts: bool,
} }
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
+26
View File
@@ -70,6 +70,9 @@ pub enum MethodObject {
MailRule, MailRule,
// inbuxa: accepted security to-do items // inbuxa: accepted security to-do items
SecurityAcceptance, SecurityAcceptance,
// inbuxa: the deliverability check
DeliverabilityReport,
DeliverabilitySettings,
HeldMessage, HeldMessage,
// inbuxa: journaling // inbuxa: journaling
Journal, Journal,
@@ -77,6 +80,7 @@ pub enum MethodObject {
JournalExport, JournalExport,
JournalVerification, JournalVerification,
TenantProtocolPolicy, TenantProtocolPolicy,
SharingPolicy,
} }
impl MethodObject { impl MethodObject {
@@ -118,12 +122,15 @@ impl MethodObject {
| MethodObject::MailRule | MethodObject::MailRule
| MethodObject::SecurityAcceptance | MethodObject::SecurityAcceptance
| MethodObject::HeldMessage | MethodObject::HeldMessage
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::Journal | MethodObject::Journal
| MethodObject::JournalEntry | MethodObject::JournalEntry
| MethodObject::JournalExport | MethodObject::JournalExport
| MethodObject::JournalVerification => Capability::Inbuxa, | MethodObject::JournalVerification => Capability::Inbuxa,
MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa,
MethodObject::TenantProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
MethodObject::SharingPolicy => Capability::Inbuxa,
} }
} }
} }
@@ -321,6 +328,10 @@ impl MethodName {
(MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set", (MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set",
(MethodFunction::Get, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/get", (MethodFunction::Get, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/get",
(MethodFunction::Set, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/set", (MethodFunction::Set, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/set",
(MethodFunction::Get, MethodObject::DeliverabilityReport) => "inbuxa:DeliverabilityReport/get",
(MethodFunction::Set, MethodObject::DeliverabilityReport) => "inbuxa:DeliverabilityReport/set",
(MethodFunction::Get, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/get",
(MethodFunction::Set, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/set",
(MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get", (MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get",
(MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set", (MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set",
(MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get", (MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get",
@@ -344,6 +355,12 @@ impl MethodName {
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => { (MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
"inbuxa:TenantProtocolPolicy/set" "inbuxa:TenantProtocolPolicy/set"
} }
(MethodFunction::Get, MethodObject::SharingPolicy) => {
"inbuxa:SharingPolicy/get"
}
(MethodFunction::Set, MethodObject::SharingPolicy) => {
"inbuxa:SharingPolicy/set"
}
(method, MethodObject::Registry(obj)) => { (method, MethodObject::Registry(obj)) => {
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str())); return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
} }
@@ -489,6 +506,10 @@ impl MethodName {
"inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set), "inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set),
"inbuxa:SecurityAcceptance/get" => (MethodObject::SecurityAcceptance, MethodFunction::Get), "inbuxa:SecurityAcceptance/get" => (MethodObject::SecurityAcceptance, MethodFunction::Get),
"inbuxa:SecurityAcceptance/set" => (MethodObject::SecurityAcceptance, MethodFunction::Set), "inbuxa:SecurityAcceptance/set" => (MethodObject::SecurityAcceptance, MethodFunction::Set),
"inbuxa:DeliverabilityReport/get" => (MethodObject::DeliverabilityReport, MethodFunction::Get),
"inbuxa:DeliverabilityReport/set" => (MethodObject::DeliverabilityReport, MethodFunction::Set),
"inbuxa:DeliverabilitySettings/get" => (MethodObject::DeliverabilitySettings, MethodFunction::Get),
"inbuxa:DeliverabilitySettings/set" => (MethodObject::DeliverabilitySettings, MethodFunction::Set),
"inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get), "inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get),
"inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set), "inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set),
"inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get), "inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get),
@@ -504,6 +525,8 @@ impl MethodName {
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set), "inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get), "inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set), "inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
"inbuxa:SharingPolicy/get" => (MethodObject::SharingPolicy, MethodFunction::Get),
"inbuxa:SharingPolicy/set" => (MethodObject::SharingPolicy, MethodFunction::Set),
).or_else(|| { ).or_else(|| {
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
@@ -570,6 +593,8 @@ impl Display for MethodObject {
MethodObject::LegalHold => "inbuxa:LegalHold", MethodObject::LegalHold => "inbuxa:LegalHold",
MethodObject::MailRule => "inbuxa:MailRule", MethodObject::MailRule => "inbuxa:MailRule",
MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance", MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance",
MethodObject::DeliverabilityReport => "inbuxa:DeliverabilityReport",
MethodObject::DeliverabilitySettings => "inbuxa:DeliverabilitySettings",
MethodObject::Journal => "inbuxa:Journal", MethodObject::Journal => "inbuxa:Journal",
MethodObject::JournalEntry => "inbuxa:JournalEntry", MethodObject::JournalEntry => "inbuxa:JournalEntry",
MethodObject::JournalExport => "inbuxa:JournalExport", MethodObject::JournalExport => "inbuxa:JournalExport",
@@ -578,6 +603,7 @@ impl Display for MethodObject {
MethodObject::HoldExport => "inbuxa:HoldExport", MethodObject::HoldExport => "inbuxa:HoldExport",
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
MethodObject::SharingPolicy => "inbuxa:SharingPolicy",
MethodObject::Registry(obj) => { MethodObject::Registry(obj) => {
f.write_str("x:")?; f.write_str("x:")?;
return f.write_str(obj.as_str()); return f.write_str(obj.as_str());
+10
View File
@@ -126,6 +126,8 @@ pub enum GetRequestMethod {
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>), LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>), MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>),
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>), SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
DeliverabilityReport(Box<GetRequest<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
DeliverabilitySettings(Box<GetRequest<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>), Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>), JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>), HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
@@ -134,6 +136,9 @@ pub enum GetRequestMethod {
TenantProtocolPolicy( TenantProtocolPolicy(
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>, Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
), ),
SharingPolicy(
Box<GetRequest<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
} }
#[derive(Debug)] #[derive(Debug)]
@@ -169,6 +174,8 @@ pub enum SetRequestMethod<'x> {
SecurityAcceptance( SecurityAcceptance(
Box<SetRequest<'x, crate::object::inbuxa_security_acceptance::SecurityAcceptance>>, Box<SetRequest<'x, crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
), ),
DeliverabilityReport(Box<SetRequest<'x, crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
DeliverabilitySettings(Box<SetRequest<'x, crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>), Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>), JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>),
JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>), JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>),
@@ -178,6 +185,9 @@ pub enum SetRequestMethod<'x> {
TenantProtocolPolicy( TenantProtocolPolicy(
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>, Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
), ),
SharingPolicy(
Box<SetRequest<'x, crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
} }
#[derive(Debug)] #[derive(Debug)]
+47
View File
@@ -213,6 +213,15 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self)); return Err(de::Error::invalid_length(1, &self));
} }
}, },
(MethodFunction::Get, MethodObject::SharingPolicy) => match seq.next_element() {
Ok(Some(value)) => {
RequestMethod::Get(GetRequestMethod::SharingPolicy(value))
}
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() { (MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)), Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
Err(err) => RequestMethod::invalid(err), Err(err) => RequestMethod::invalid(err),
@@ -415,6 +424,15 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self)); return Err(de::Error::invalid_length(1, &self));
} }
}, },
(MethodFunction::Set, MethodObject::SharingPolicy) => match seq.next_element() {
Ok(Some(value)) => {
RequestMethod::Set(SetRequestMethod::SharingPolicy(value))
}
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() { (MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)), Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
Err(err) => RequestMethod::invalid(err), Err(err) => RequestMethod::invalid(err),
@@ -668,6 +686,35 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self)); return Err(de::Error::invalid_length(1, &self));
} }
}, },
// inbuxa: the deliverability check
(MethodFunction::Get, MethodObject::DeliverabilityReport) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DeliverabilityReport(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::DeliverabilityReport) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DeliverabilityReport(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::DeliverabilitySettings) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DeliverabilitySettings(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::DeliverabilitySettings) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DeliverabilitySettings(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
// inbuxa: journaling // inbuxa: journaling
(MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() { (MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)), Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)),
+55
View File
@@ -113,6 +113,8 @@ pub enum GetResponseMethod {
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>), LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>), MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>), SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
DeliverabilityReport(GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>),
DeliverabilitySettings(GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>),
Journal(GetResponse<crate::object::inbuxa_journal::Journal>), Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>), JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>), HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
@@ -121,6 +123,9 @@ pub enum GetResponseMethod {
TenantProtocolPolicy( TenantProtocolPolicy(
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>, GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
), ),
SharingPolicy(
GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
),
} }
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
@@ -156,6 +161,8 @@ pub enum SetResponseMethod {
SecurityAcceptance( SecurityAcceptance(
Box<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>, Box<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
), ),
DeliverabilityReport(Box<SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
DeliverabilitySettings(Box<SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>), Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>), JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>),
JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>), JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>),
@@ -166,6 +173,9 @@ pub enum SetResponseMethod {
TenantProtocolPolicy( TenantProtocolPolicy(
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>, Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
), ),
SharingPolicy(
Box<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
} }
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
@@ -352,6 +362,16 @@ impl<'x> From<GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantPr
} }
} }
impl<'x> From<GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
for ResponseMethod<'x>
{
fn from(
value: GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
) -> Self {
ResponseMethod::Get(GetResponseMethod::SharingPolicy(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>> impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
for ResponseMethod<'x> for ResponseMethod<'x>
{ {
@@ -362,6 +382,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantPr
} }
} }
impl<'x> From<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
for ResponseMethod<'x>
{
fn from(
value: SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
) -> Self {
ResponseMethod::Set(SetResponseMethod::SharingPolicy(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> { impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self { fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
ResponseMethod::Get(GetResponseMethod::AiLimits(value)) ResponseMethod::Get(GetResponseMethod::AiLimits(value))
@@ -838,6 +868,31 @@ impl<'x> From<SetResponse<crate::object::inbuxa_held_message::HeldMessage>> for
} }
} }
// inbuxa: the deliverability check
impl<'x> From<GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>) -> Self {
ResponseMethod::Get(GetResponseMethod::DeliverabilityReport(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>) -> Self {
ResponseMethod::Set(SetResponseMethod::DeliverabilityReport(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>) -> Self {
ResponseMethod::Get(GetResponseMethod::DeliverabilitySettings(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>) -> Self {
ResponseMethod::Set(SetResponseMethod::DeliverabilitySettings(Box::new(value)))
}
}
// inbuxa: accepted security to-do items // inbuxa: accepted security to-do items
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>> impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
for ResponseMethod<'x> for ResponseMethod<'x>
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{api::acl::JmapRights, changes::state::JmapCacheState}; use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
@@ -180,7 +182,7 @@ impl AddressBookGet for Server {
address_book.acls.effective_acl(access_token), address_book.acls.effective_acl(access_token),
) )
} else { } else {
JmapRights::all_rights::<addressbook::AddressBook>() JmapRights::owner_rights::<addressbook::AddressBook>(access_token, account_id)
}, },
); );
} }
+16
View File
@@ -101,6 +101,14 @@ impl AddressBookSet for Server {
continue 'create; continue 'create;
} }
// inbuxa: MA-D0: a group's members don't share what it owns on.
if !address_book.acls.is_empty() && access_token.is_group_member_only(account_id) {
response.not_created.append(
id,
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
);
continue 'create;
}
// Validate ACLs // Validate ACLs
if !address_book.acls.is_empty() { if !address_book.acls.is_empty() {
if let Err(err) = self.acl_validate(account_id, &address_book.acls).await { if let Err(err) = self.acl_validate(account_id, &address_book.acls).await {
@@ -203,6 +211,14 @@ impl AddressBookSet for Server {
continue 'update; continue 'update;
} }
} }
// inbuxa: MA-D0: a group's members don't share what it owns on.
if has_acl_changes && access_token.is_group_member_only(account_id) {
response.not_updated.append(
id,
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
);
continue 'update;
}
if has_acl_changes { if has_acl_changes {
if let Err(err) = self.acl_validate(account_id, &new_address_book.acls).await { if let Err(err) = self.acl_validate(account_id, &new_address_book.acls).await {
response.not_updated.append(id, err.into()); response.not_updated.append(id, err.into());
+15
View File
@@ -187,6 +187,21 @@ impl JmapRights {
Value::Object(obj) Value::Object(obj)
} }
/// inbuxa: MA-D0: an owner's rights, which for a group's member are
/// everything but sharing it on.
pub fn owner_rights<T: JmapSharedObject>(
access_token: &AccessToken,
account_id: u32,
) -> Value<'static, T::Property, T::Element> {
if access_token.is_group_member_only(account_id) {
let mut acl = Bitmap::<Acl>::all();
acl.remove(Acl::Share);
Self::rights::<T>(acl)
} else {
Self::all_rights::<T>()
}
}
pub fn rights<T: JmapSharedObject>( pub fn rights<T: JmapSharedObject>(
acls: Bitmap<Acl>, acls: Bitmap<Acl>,
) -> Value<'static, T::Property, T::Element> { ) -> Value<'static, T::Property, T::Element> {
+37 -1
View File
@@ -123,6 +123,10 @@ impl JmapAuthorization for AccessToken {
// inbuxa: accepted security items are read by whoever may // inbuxa: accepted security items are read by whoever may
// see the server's security settings // see the server's security settings
GetRequestMethod::SecurityAcceptance(_) => Permission::SysSecurityGet, GetRequestMethod::SecurityAcceptance(_) => Permission::SysSecurityGet,
// inbuxa: deliverability spec; the lists are named on the
// page that shows the findings, so they read the same way
GetRequestMethod::DeliverabilityReport(_)
| GetRequestMethod::DeliverabilitySettings(_) => Permission::SysDeliverabilityGet,
// inbuxa: legacy protocols off. It takes listeners away and // inbuxa: legacy protocols off. It takes listeners away and
// puts them back, so it takes the listener's permissions // puts them back, so it takes the listener's permissions
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
@@ -130,6 +134,10 @@ impl JmapAuthorization for AccessToken {
// sign-in on the tenant's domains, so it takes the domain's // sign-in on the tenant's domains, so it takes the domain's
// permissions, which a tenant administrator already holds. // permissions, which a tenant administrator already holds.
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet, GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
// inbuxa: MA-C, who may share mail: a tenant administrator
// manages their tenant's, so the domain's permissions; the
// server's own also needs sysSharingUpdate (see the method)
GetRequestMethod::SharingPolicy(_) => Permission::SysDomainGet,
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet, GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet, GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
GetRequestMethod::Blob(_) => Permission::JmapBlobGet, GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
@@ -331,6 +339,23 @@ impl JmapAuthorization for AccessToken {
.details("You are not authorized to accept security items")) .details("You are not authorized to accept security items"))
} }
} }
// inbuxa: DL-15: a create runs the check; the handler
// refuses the rest
SetRequestMethod::DeliverabilityReport(s) => validate_set(
s,
self,
Permission::SysDeliverabilityCheck,
Permission::SysDeliverabilityCheck,
Permission::SysDeliverabilityCheck,
),
// inbuxa: DL-6, which lists are asked
SetRequestMethod::DeliverabilitySettings(s) => validate_set(
s,
self,
Permission::SysDeliverabilityUpdate,
Permission::SysDeliverabilityUpdate,
Permission::SysDeliverabilityUpdate,
),
// inbuxa: LH-12, exporting held data // inbuxa: LH-12, exporting held data
SetRequestMethod::HoldExport(s) => validate_set( SetRequestMethod::HoldExport(s) => validate_set(
s, s,
@@ -370,6 +395,14 @@ impl JmapAuthorization for AccessToken {
Permission::SysDomainUpdate, Permission::SysDomainUpdate,
Permission::SysDomainUpdate, Permission::SysDomainUpdate,
), ),
// inbuxa: MA-C, who may share mail, with the domain's
SetRequestMethod::SharingPolicy(s) => validate_set(
s,
self,
Permission::SysDomainUpdate,
Permission::SysDomainUpdate,
Permission::SysDomainUpdate,
),
SetRequestMethod::VacationResponse(s) => validate_set( SetRequestMethod::VacationResponse(s) => validate_set(
s, s,
self, self,
@@ -494,13 +527,16 @@ impl JmapAuthorization for AccessToken {
| MethodObject::HoldExport | MethodObject::HoldExport
| MethodObject::MailRule | MethodObject::MailRule
| MethodObject::SecurityAcceptance | MethodObject::SecurityAcceptance
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::HeldMessage | MethodObject::HeldMessage
| MethodObject::Journal | MethodObject::Journal
| MethodObject::JournalEntry | MethodObject::JournalEntry
| MethodObject::JournalExport | MethodObject::JournalExport
| MethodObject::JournalVerification | MethodObject::JournalVerification
| MethodObject::ProtocolPolicy | MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, | MethodObject::TenantProtocolPolicy
| MethodObject::SharingPolicy => Permission::JmapEmailChanges,
// inbuxa: x:MaskedEmail/changes reads what /get reads // inbuxa: x:MaskedEmail/changes reads what /get reads
MethodObject::Registry(object_type) => object_type.get_permission(), MethodObject::Registry(object_type) => object_type.get_permission(),
}, },
+81 -1
View File
@@ -204,6 +204,10 @@ impl RequestHandler for Server {
// inbuxa: AL-9: a delegate's access, and what it // inbuxa: AL-9: a delegate's access, and what it
// changes, are recorded; anyone else here impersonated // changes, are recorded; anyone else here impersonated
if let Some(delegation) = access_token.delegation(account_id) { if let Some(delegation) = access_token.delegation(account_id) {
// MA-S: in a shared mailbox only what is sent as it
// is recorded (audit_send_as); every read and flag
// on a busy desk would bury the log
if delegation.kind.is_lock() {
let access = delegation.access.as_str(); let access = delegation.access.as_str();
self.audit_delegate( self.audit_delegate(
access_token, access_token,
@@ -213,6 +217,7 @@ impl RequestHandler for Server {
result.as_ref().err(), result.as_ref().err(),
) )
.await; .await;
}
if makes_containers if makes_containers
&& result.is_ok() && result.is_ok()
&& let Err(err) = && let Err(err) =
@@ -288,6 +293,12 @@ impl RequestHandler for Server {
SetResponseMethod::SecurityAcceptance(set_response) => { SetResponseMethod::SecurityAcceptance(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
SetResponseMethod::DeliverabilityReport(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::DeliverabilitySettings(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::Journal(set_response) => { SetResponseMethod::Journal(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
@@ -312,6 +323,9 @@ impl RequestHandler for Server {
SetResponseMethod::TenantProtocolPolicy(set_response) => { SetResponseMethod::TenantProtocolPolicy(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
SetResponseMethod::SharingPolicy(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AddressBook(set_response) => { SetResponseMethod::AddressBook(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
@@ -531,6 +545,19 @@ impl RequestHandler for Server {
.await? .await?
.into() .into()
} }
// inbuxa: the deliverability check
GetRequestMethod::DeliverabilityReport(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::deliverability::get_reports(self, access_token, *req)
.await?
.into()
}
GetRequestMethod::DeliverabilitySettings(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::deliverability::get_settings(self, access_token, *req)
.await?
.into()
}
// inbuxa: journaling // inbuxa: journaling
GetRequestMethod::Journal(mut req) => { GetRequestMethod::Journal(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
@@ -569,6 +596,13 @@ impl RequestHandler for Server {
.await? .await?
.into() .into()
} }
// inbuxa: inbuxa:SharingPolicy/get (MA-C, who may share mail)
GetRequestMethod::SharingPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::sharing_policy::get(self, access_token, *req)
.await?
.into()
}
GetRequestMethod::Principal(req) => { GetRequestMethod::Principal(req) => {
self.principal_get(*req, access_token).await?.into() self.principal_get(*req, access_token).await?.into()
} }
@@ -783,7 +817,7 @@ impl RequestHandler for Server {
// inbuxa: AL-8: a delegate may send as a locked account // inbuxa: AL-8: a delegate may send as a locked account
access_token.assert_can_send(req.account_id)?; access_token.assert_can_send(req.account_id)?;
self.email_submission_set(*req, &session.instance, next_call) self.email_submission_set(*req, access_token, &session.instance, next_call)
.await? .await?
.into() .into()
} }
@@ -1045,6 +1079,35 @@ impl RequestHandler for Server {
.await? .await?
.into() .into()
} }
// inbuxa: DL-15, Check now; nothing it changes needs recording
SetRequestMethod::DeliverabilityReport(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::deliverability::set_reports(self, access_token, *req)
.await?
.into()
}
// inbuxa: DL-6; which lists are asked is in the audit log
SetRequestMethod::DeliverabilitySettings(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::deliverability::set_settings(
self,
access_token,
req,
))
},
)
.await?
.into()
}
SetRequestMethod::Journal(mut req) => { SetRequestMethod::Journal(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
let reason = req.arguments.reason.clone(); let reason = req.arguments.reason.clone();
@@ -1127,6 +1190,23 @@ impl RequestHandler for Server {
.await? .await?
.into() .into()
} }
// inbuxa: inbuxa:SharingPolicy/set (MA-C, who may share mail)
SetRequestMethod::SharingPolicy(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::sharing_policy::set(self, access_token, req)),
)
.await?
.into()
}
SetRequestMethod::AddressBook(mut req) => { SetRequestMethod::AddressBook(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
access_token.assert_has_access(req.account_id, Collection::AddressBook)?; access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
+10
View File
@@ -80,6 +80,13 @@ impl SessionHandler for Server {
let ai_explain = access_token.has_permission(Permission::SysAiExplain) let ai_explain = access_token.has_permission(Permission::SysAiExplain)
&& access_token.tenant_id().is_none() && access_token.tenant_id().is_none()
&& self.ai_explain_model(&self.ai_limits().await).await.is_some(); && self.ai_explain_model(&self.ai_limits().await).await.is_some();
// inbuxa: MA-C: what the sharing switches leave this principal
let sharing = inbuxa_features::security::sharing_policy::effective_for(
self.store(),
access_token.tenant_id(),
)
.await
.caused_by(trc::location!())?;
account.account_capabilities.append( account.account_capabilities.append(
Capability::Inbuxa, Capability::Inbuxa,
Capabilities::Inbuxa(InbuxaAccountCapabilities { Capabilities::Inbuxa(InbuxaAccountCapabilities {
@@ -87,6 +94,8 @@ impl SessionHandler for Server {
legacy_protocols, legacy_protocols,
legacy_allowed, legacy_allowed,
ai_explain, ai_explain,
mail_sharing: sharing.mail_sharing,
add_accounts: sharing.add_accounts,
}), }),
); );
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks // inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
@@ -148,6 +157,7 @@ impl SessionHandler for Server {
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities { Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
delegation: DelegationInfo { delegation: DelegationInfo {
locked: true, locked: true,
kind: delegation.kind.as_str(),
access: delegation.access.as_str(), access: delegation.access.as_str(),
send_as: delegation.send_as, send_as: delegation.send_as,
until: delegation.until.map(|until| { until: delegation.until.map(|until| {
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{api::acl::JmapRights, calendar::Availability, changes::state::JmapCacheState}; use crate::{api::acl::JmapRights, calendar::Availability, changes::state::JmapCacheState};
@@ -253,7 +255,7 @@ impl CalendarGet for Server {
calendar.acls.effective_acl(access_token), calendar.acls.effective_acl(access_token),
) )
} else { } else {
JmapRights::all_rights::<calendar::Calendar>() JmapRights::owner_rights::<calendar::Calendar>(access_token, account_id)
}, },
); );
} }
+16
View File
@@ -105,6 +105,14 @@ impl CalendarSet for Server {
continue 'create; continue 'create;
} }
// inbuxa: MA-D0: a group's members don't share what it owns on.
if !calendar.acls.is_empty() && access_token.is_group_member_only(account_id) {
response.not_created.append(
id,
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
);
continue 'create;
}
// Validate ACLs // Validate ACLs
if !calendar.acls.is_empty() { if !calendar.acls.is_empty() {
if let Err(err) = self.acl_validate(account_id, &calendar.acls).await { if let Err(err) = self.acl_validate(account_id, &calendar.acls).await {
@@ -207,6 +215,14 @@ impl CalendarSet for Server {
continue 'update; continue 'update;
} }
} }
// inbuxa: MA-D0: a group's members don't share what it owns on.
if has_acl_changes && access_token.is_group_member_only(account_id) {
response.not_updated.append(
id,
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
);
continue 'update;
}
if has_acl_changes { if has_acl_changes {
if let Err(err) = self.acl_validate(account_id, &new_calendar.acls).await { if let Err(err) = self.acl_validate(account_id, &new_calendar.acls).await {
response.not_updated.append(id, err.into()); response.not_updated.append(id, err.into());
+3
View File
@@ -432,6 +432,8 @@ impl IntermediateChangesResponse {
| MethodObject::HoldExport | MethodObject::HoldExport
| MethodObject::MailRule | MethodObject::MailRule
| MethodObject::SecurityAcceptance | MethodObject::SecurityAcceptance
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::Journal | MethodObject::Journal
| MethodObject::JournalEntry | MethodObject::JournalEntry
| MethodObject::JournalExport | MethodObject::JournalExport
@@ -439,6 +441,7 @@ impl IntermediateChangesResponse {
| MethodObject::HeldMessage | MethodObject::HeldMessage
| MethodObject::ProtocolPolicy | MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy | MethodObject::TenantProtocolPolicy
| MethodObject::SharingPolicy
| MethodObject::Registry(_) => unreachable!(), | MethodObject::Registry(_) => unreachable!(),
}) })
} }
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{api::acl::JmapRights, changes::state::JmapCacheState}; use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
@@ -172,7 +174,7 @@ impl FileNodeGet for Server {
file_node.acls.effective_acl(access_token), file_node.acls.effective_acl(access_token),
) )
} else { } else {
JmapRights::all_rights::<file_node::FileNode>() JmapRights::owner_rights::<file_node::FileNode>(access_token, account_id)
}, },
); );
} }
+18
View File
@@ -250,6 +250,16 @@ impl FileNodeSet for Server {
}, },
}; };
// inbuxa: MA-D0: a group's members don't share what it owns on,
// at the top of its files as anywhere else
if has_acl_changes && access_token.is_group_member_only(account_id) {
response.not_created.append(
id,
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
);
continue 'create;
}
// Inherit ACLs from parent // Inherit ACLs from parent
if file_node.parent_id > 0 { if file_node.parent_id > 0 {
let parent_id = file_node.parent_id - 1; let parent_id = file_node.parent_id - 1;
@@ -509,6 +519,14 @@ impl FileNodeSet for Server {
continue 'update; continue 'update;
} }
} }
// inbuxa: MA-D0: a group's members don't share what it owns on.
if has_acl_changes && access_token.is_group_member_only(account_id) {
response.not_updated.append(
id,
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
);
continue 'update;
}
if has_acl_changes { if has_acl_changes {
if let Err(err) = self.acl_validate(account_id, &new_file_node.acls).await { if let Err(err) = self.acl_validate(account_id, &new_file_node.acls).await {
response.not_updated.append(id, err.into()); response.not_updated.append(id, err.into());
+36 -9
View File
@@ -15,7 +15,7 @@ use common::{
}; };
use email::inbuxa_lock::apply_grants; use email::inbuxa_lock::apply_grants;
use groupware::inbuxa_lock::invalidate; use groupware::inbuxa_lock::invalidate;
use inbuxa_features::lock::{self, Access, Delegate, Lock, MAX_DELEGATES}; use inbuxa_features::lock::{self, Access, Delegate, Kind, Lock};
use jmap_proto::{ use jmap_proto::{
error::set::SetError, error::set::SetError,
method::{ method::{
@@ -39,6 +39,7 @@ const ALL: &[P] = &[
P::Id, P::Id,
P::AccountId, P::AccountId,
P::Name, P::Name,
P::Kind,
P::Reason, P::Reason,
P::LockedAt, P::LockedAt,
P::LockedBy, P::LockedBy,
@@ -75,6 +76,7 @@ async fn parse_delegates(
server: &Server, server: &Server,
access_token: &AccessToken, access_token: &AccessToken,
locked_id: u32, locked_id: u32,
kind: Kind,
value: LValue, value: LValue,
) -> Result<Vec<Delegate>, SetError<P>> { ) -> Result<Vec<Delegate>, SetError<P>> {
let invalid = |why: String| { let invalid = |why: String| {
@@ -86,8 +88,10 @@ async fn parse_delegates(
let Some(items) = json.as_array() else { let Some(items) = json.as_array() else {
return Err(invalid("delegates must be a list.".into())); return Err(invalid("delegates must be a list.".into()));
}; };
if items.len() > MAX_DELEGATES { // MA-S: a shared mailbox holds more people than a lock hands over
return Err(invalid(format!("At most {MAX_DELEGATES} delegates."))); let max = kind.max_delegates();
if items.len() > max {
return Err(invalid(format!("At most {max} delegates.")));
} }
let locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant); let locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant);
let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len()); let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len());
@@ -173,6 +177,7 @@ async fn to_value(server: &Server, lock: &Lock, properties: &[P]) -> LValue {
let value = match property { let value = match property {
P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))), P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))),
P::Name => Value::Str(server.audit_account_name(lock.account_id).await.into()), P::Name => Value::Str(server.audit_account_name(lock.account_id).await.into()),
P::Kind => Value::Str(Cow::Borrowed(lock.kind.as_str())),
P::Reason => Value::Str(lock.reason.clone().into()), P::Reason => Value::Str(lock.reason.clone().into()),
P::LockedAt => date(lock.locked_at), P::LockedAt => date(lock.locked_at),
P::LockedBy => Value::Str(lock.locked_by.clone().into()), P::LockedBy => Value::Str(lock.locked_by.clone().into()),
@@ -283,6 +288,7 @@ pub async fn set(
for (client_id, value) in request.unwrap_create() { for (client_id, value) in request.unwrap_create() {
let mut account_id = None; let mut account_id = None;
let mut kind = Kind::Lock;
let mut reason = None; let mut reason = None;
let mut delegates_value = None; let mut delegates_value = None;
let mut invalid = None; let mut invalid = None;
@@ -291,6 +297,17 @@ pub async fn set(
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => { (Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
account_id = Some(id.document_id()) account_id = Some(id.document_id())
} }
(Key::Property(P::Kind), Value::Str(k)) => match Kind::parse(&k) {
Some(k) => kind = k,
None => {
invalid = Some(
SetError::invalid_properties()
.with_property(P::Kind)
.with_description("kind must be lock or sharedMailbox."),
);
break;
}
},
(Key::Property(P::Reason), Value::Str(r)) => reason = reason_of(Some(&r)), (Key::Property(P::Reason), Value::Str(r)) => reason = reason_of(Some(&r)),
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()), (Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
_ => { _ => {
@@ -310,9 +327,14 @@ pub async fn set(
); );
continue; continue;
}; };
let Some(reason) = reason.or_else(|| reason_of(arguments.reason.as_deref())) else { // MA-S: a shared mailbox needs no reason; a lock always does
let reason = match reason.or_else(|| reason_of(arguments.reason.as_deref())) {
Some(reason) => reason,
None if kind == Kind::SharedMailbox => String::new(),
None => {
response.not_created.append(client_id, reason_required()); response.not_created.append(client_id, reason_required());
continue; continue;
}
}; };
if let Err(error) = assert_reach(server, access_token, account_id).await { if let Err(error) = assert_reach(server, access_token, account_id).await {
response.not_created.append(client_id, error); response.not_created.append(client_id, error);
@@ -321,12 +343,13 @@ pub async fn set(
if lock::get(data, account_id).await?.is_some() { if lock::get(data, account_id).await?.is_some() {
response.not_created.append( response.not_created.append(
client_id, client_id,
SetError::already_exists().with_description("That account is already locked."), SetError::already_exists()
.with_description("That account is already locked or a shared mailbox."),
); );
continue; continue;
} }
let delegates = match delegates_value { let delegates = match delegates_value {
Some(value) => match parse_delegates(server, access_token, account_id, value).await { Some(value) => match parse_delegates(server, access_token, account_id, kind, value).await {
Ok(delegates) => delegates, Ok(delegates) => delegates,
Err(error) => { Err(error) => {
response.not_created.append(client_id, error); response.not_created.append(client_id, error);
@@ -337,6 +360,7 @@ pub async fn set(
}; };
let mut created = Lock { let mut created = Lock {
account_id, account_id,
kind,
reason, reason,
locked_at: now(), locked_at: now(),
locked_by: actor.name.clone(), locked_by: actor.name.clone(),
@@ -370,7 +394,7 @@ pub async fn set(
response.not_updated.append(id, SetError::not_found()); response.not_updated.append(id, SetError::not_found());
continue; continue;
}; };
if reason_of(arguments.reason.as_deref()).is_none() { if current.kind.is_lock() && reason_of(arguments.reason.as_deref()).is_none() {
response.not_updated.append(id, reason_required()); response.not_updated.append(id, reason_required());
continue; continue;
} }
@@ -379,7 +403,9 @@ pub async fn set(
for (key, value) in value.into_expanded_object() { for (key, value) in value.into_expanded_object() {
match (&key, value) { match (&key, value) {
(Key::Property(P::Delegates), value) => { (Key::Property(P::Delegates), value) => {
match parse_delegates(server, access_token, account_id, value.into_owned()).await { match parse_delegates(server, access_token, account_id, current.kind, value.into_owned())
.await
{
Ok(delegates) => updated.delegates = delegates, Ok(delegates) => updated.delegates = delegates,
Err(error) => { Err(error) => {
invalid = Some(error); invalid = Some(error);
@@ -389,6 +415,7 @@ pub async fn set(
} }
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) { (Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
Some(r) => updated.reason = r, Some(r) => updated.reason = r,
None if !current.kind.is_lock() => updated.reason = String::new(),
None => { None => {
invalid = Some(reason_required()); invalid = Some(reason_required());
break; break;
@@ -420,7 +447,7 @@ pub async fn set(
response.not_destroyed.append(id, SetError::not_found()); response.not_destroyed.append(id, SetError::not_found());
continue; continue;
}; };
if reason_of(arguments.reason.as_deref()).is_none() { if current.kind.is_lock() && reason_of(arguments.reason.as_deref()).is_none() {
response.not_destroyed.append(id, reason_required()); response.not_destroyed.append(id, reason_required());
continue; continue;
} }
+352
View File
@@ -0,0 +1,352 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:DeliverabilityReport` and `inbuxa:DeliverabilitySettings`
//! (deliverability spec).
//!
//! A report is one sending node's last check, written by that node. Reading
//! reports needs `sysDeliverabilityGet`; a tenant administrator gets only
//! their tenant's domains and nothing about the nodes (DL-20). Creating a
//! report asks every node to check itself now (DL-15): it needs
//! `sysDeliverabilityCheck`, returns at once with the node's last check
//! time, and the new report replaces the old one when it's done. The
//! settings say which built-in lists are left out (DL-6).
use common::{Server, auth::AccessToken, ipc::BroadcastEvent};
use inbuxa_features::deliverability::{
self as model, Report, Settings,
lists::{self, Scope},
};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::{
inbuxa_deliverability_report::{
DeliverabilityReport, DeliverabilityReportProperty as R, DeliverabilityReportValue,
},
inbuxa_deliverability_settings::{
DeliverabilitySettings, DeliverabilitySettingsProperty as S,
DeliverabilitySettingsValue,
},
},
request::IntoValid,
types::date::UTCDate,
};
use jmap_tools::{Element, Key, Map, Property, Value};
use std::borrow::Cow;
use types::id::Id;
const REPORT: &[R] = &[
R::Id,
R::NodeId,
R::Hostname,
R::CheckedAt,
R::Addresses,
R::Domains,
R::Certificates,
];
const SETTINGS: &[S] = &[S::Id, S::DisabledLists, S::Lists];
fn server_level(access_token: &AccessToken, what: &'static str) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
Err(trc::JmapEvent::Forbidden.into_err().details(what))
} else {
Ok(())
}
}
fn json_to_value<P: Property, E: Element>(json: serde_json::Value) -> Value<'static, P, E> {
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) -> Value<'static, R, DeliverabilityReportValue> {
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
}
fn report_value(report: &Report, properties: &[R]) -> Value<'static, R, DeliverabilityReportValue> {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
R::Id => Value::Element(DeliverabilityReportValue::Id(Id::from(report.node_id))),
R::NodeId => Value::Number(report.node_id.into()),
R::Hostname => Value::Str(report.hostname.clone().into()),
R::CheckedAt => date(report.checked_at),
R::Addresses => {
json_to_value(serde_json::to_value(&report.addresses).unwrap_or_default())
}
R::Domains => json_to_value(serde_json::to_value(&report.domains).unwrap_or_default()),
R::Certificates => {
json_to_value(serde_json::to_value(&report.certificates).unwrap_or_default())
}
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:DeliverabilityReport/get`: every sending node's last report.
pub async fn get_reports(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<DeliverabilityReport>,
) -> trc::Result<GetResponse<DeliverabilityReport>> {
let properties = request.unwrap_properties(REPORT);
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 mut reports = model::reports(server.store()).await?;
// DL-20
if let Some(tenant_id) = access_token.tenant_id() {
reports = reports.iter().map(|r| r.for_tenant(tenant_id)).collect();
}
match ids {
None => {
response.list = reports
.iter()
.map(|r| report_value(r, &properties))
.collect();
}
Some(ids) => {
for id in ids {
match reports.iter().find(|r| r.node_id == id.id()) {
Some(report) => response.list.push(report_value(report, &properties)),
None => response.push_not_found(id),
}
}
}
}
Ok(response)
}
/// `inbuxa:DeliverabilityReport/set`: a create asks every node to check
/// itself now (DL-15). Reports are the server's: nothing else is allowed.
pub async fn set_reports(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, DeliverabilityReport>,
) -> trc::Result<SetResponse<DeliverabilityReport>> {
server_level(access_token, "The deliverability check is the server's.")?;
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
let node_id = server.core.network.node_id;
let mut asked = false;
for (client_id, _) in request.unwrap_create() {
if !asked {
asked = true;
services::inbuxa_deliverability::CHECK_NOW.notify_one();
server
.cluster_broadcast(BroadcastEvent::DeliverabilityCheck)
.await;
}
// The node's last check, so the console knows when the new one lands
let last = model::report(server.store(), node_id).await?;
let mut out = Map::with_capacity(2);
out.insert_unchecked(
Key::Property(R::Id),
Value::Element(DeliverabilityReportValue::Id(Id::from(node_id))),
);
out.insert_unchecked(
Key::Property(R::CheckedAt),
last.map(|r| date(r.checked_at)).unwrap_or(Value::Null),
);
response.created.insert(client_id, Value::Object(out));
}
for (id, _) in request.unwrap_update().into_valid() {
response.not_updated.append(
id,
SetError::forbidden().with_description("Reports are written by the check."),
);
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(
id,
SetError::forbidden().with_description("Reports are written by the check."),
);
}
Ok(response)
}
fn lists_value() -> Value<'static, S, DeliverabilitySettingsValue> {
Value::Array(
lists::LISTS
.iter()
.map(|list| {
json_to_value(serde_json::json!({
"name": list.name,
"zone": list.zone,
"scope": match list.scope {
Scope::Ip => "ip",
Scope::Domain => "domain",
},
"lookup": list.lookup,
"note": list.note,
}))
})
.collect(),
)
}
fn settings_value(
settings: &Settings,
properties: &[S],
) -> Value<'static, S, DeliverabilitySettingsValue> {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
S::Id => Value::Element(DeliverabilitySettingsValue::Id(Id::singleton())),
S::DisabledLists => Value::Array(
settings
.disabled_lists
.iter()
.map(|name| Value::Str(name.clone().into()))
.collect(),
),
S::Lists => lists_value(),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:DeliverabilitySettings/get`: which lists are left out, and the lists.
pub async fn get_settings(
server: &Server,
_access_token: &AccessToken,
mut request: GetRequest<DeliverabilitySettings>,
) -> trc::Result<GetResponse<DeliverabilitySettings>> {
let properties = request.unwrap_properties(SETTINGS);
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 = model::settings(server.store()).await?;
match ids {
None => response.list.push(settings_value(&settings, &properties)),
Some(ids) => {
for id in ids {
if id.is_singleton() {
response.list.push(settings_value(&settings, &properties));
} else {
response.push_not_found(id);
}
}
}
}
Ok(response)
}
/// `inbuxa:DeliverabilitySettings/set`: updates the singleton.
pub async fn set_settings(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, DeliverabilitySettings>,
) -> trc::Result<SetResponse<DeliverabilitySettings>> {
server_level(access_token, "The blocklists checked are the server's.")?;
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 = model::settings(data).await?;
let mut error = None;
for (key, value) in value.into_expanded_object() {
match &key {
Key::Property(S::DisabledLists) => {
let names = value.as_array().map(|items| {
items
.iter()
.map(|item| item.as_str().map(|s| s.to_string()))
.collect::<Option<Vec<_>>>()
});
match names {
Some(Some(names)) => settings.disabled_lists = names,
_ => {
error = Some(
SetError::invalid_properties()
.with_property(S::DisabledLists)
.with_description("A list of list names."),
);
break;
}
}
}
Key::Property(property) => {
error = Some(
SetError::invalid_properties()
.with_property(property.clone())
.with_description("The server sets this."),
);
break;
}
_ => {
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
break;
}
}
}
if error.is_none()
&& let Err(why) = settings.validate()
{
error = Some(
SetError::invalid_properties()
.with_property(S::DisabledLists)
.with_description(why),
);
}
match error {
Some(error) => response.not_updated.append(id, error),
None => {
model::put_settings(data, &settings).await?;
response.updated.append(id, None);
}
}
}
Ok(response)
}
+2
View File
@@ -12,6 +12,7 @@ pub mod account_lock;
pub mod legal_hold; pub mod legal_hold;
pub mod mail_rule; pub mod mail_rule;
pub mod security_acceptance; pub mod security_acceptance;
pub mod deliverability; // inbuxa: the deliverability check
pub mod journal; pub mod journal;
pub mod journal_entry; pub mod journal_entry;
pub mod held_message; pub mod held_message;
@@ -28,6 +29,7 @@ pub mod webhook_test;
pub mod explanation; pub mod explanation;
pub mod protocol_policy; pub mod protocol_policy;
pub mod tenant_protocol_policy; pub mod tenant_protocol_policy;
pub mod sharing_policy;
pub mod deleted_account; pub mod deleted_account;
pub mod fastmail; pub mod fastmail;
pub mod masked_email; pub mod masked_email;
+225
View File
@@ -0,0 +1,225 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:SharingPolicy/get` and `/set`: whether people may share their own
//! mail and add other accounts to the webmail (multi-account spec, MA-C).
//!
//! The server's policy has the singleton id; each tenant's has the tenant's
//! id. At server level `/get` with no ids answers with the server's and every
//! tenant's; inside a tenant, with the server's (to read) and its own tenant's
//! (MT-1). Only a server administrator holding `sysSharingUpdate` changes the
//! server's; a tenant's administrator changes their tenant's, and can only be
//! stricter than the server.
//!
//! A change rebuilds every access token, here and on every node: what a share
//! still gives is worked out when a token is built.
use common::{Server, auth::AccessToken, ipc::BroadcastEvent};
use inbuxa_features::{
security::sharing_policy::{self, SharingPolicy as Policy, looser_than_server},
tenancy::quota::all_tenants,
};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_sharing_policy::{SharingPolicy, SharingPolicyProperty as P, SharingPolicyValue},
request::IntoValid,
};
use jmap_tools::{Key, Map, Value};
use registry::schema::enums::Permission;
use types::id::Id;
type PValue = Value<'static, P, SharingPolicyValue>;
const ALL: &[P] = &[P::Id, P::TenantId, P::MailSharing, P::AddAccounts, P::ChangedAt, P::ChangedBy];
fn switch_str(on: Option<bool>) -> &'static str {
if on.unwrap_or(true) { "enabled" } else { "disabled" }
}
fn to_value(tenant_id: Option<u32>, policy: &Policy, properties: &[P]) -> PValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(SharingPolicyValue::Id(
tenant_id.map_or_else(Id::singleton, Id::from),
)),
P::TenantId => tenant_id
.map(|t| Value::Element(SharingPolicyValue::Id(Id::from(t))))
.unwrap_or(Value::Null),
P::MailSharing => Value::Str(switch_str(policy.mail_sharing).into()),
P::AddAccounts => Value::Str(switch_str(policy.add_accounts).into()),
P::ChangedAt => policy
.changed_at
.map(|at| Value::Number(at.into()))
.unwrap_or(Value::Null),
P::ChangedBy => policy
.changed_by
.as_ref()
.map(|by| Value::Str(by.clone().into()))
.unwrap_or(Value::Null),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// The tenants this principal may reach: its own inside a tenant (MT-1),
/// every tenant at server level.
async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<Vec<u32>> {
match access_token.tenant_id() {
Some(tenant_id) => Ok(vec![tenant_id]),
None => all_tenants(server.registry()).await,
}
}
/// Which policy an id names: `None` for the server's.
fn target(id: Id) -> Option<u32> {
if id.is_singleton() { None } else { Some(id.document_id()) }
}
/// `inbuxa:SharingPolicy/get`.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<SharingPolicy>,
) -> trc::Result<GetResponse<SharingPolicy>> {
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 reachable = reachable(server, access_token).await?;
let wanted: Vec<Id> = match ids {
None => std::iter::once(Id::singleton())
.chain(reachable.iter().map(|t| Id::from(*t)))
.collect(),
Some(ids) => ids,
};
let data = &server.core.storage.data;
for id in wanted {
match target(id) {
None => {
let policy = sharing_policy::get(data, None).await?;
response.list.push(to_value(None, &policy, &properties));
}
Some(tenant_id) if reachable.contains(&tenant_id) => {
let policy = sharing_policy::get(data, Some(tenant_id)).await?;
response.list.push(to_value(Some(tenant_id), &policy, &properties));
}
Some(_) => response.push_not_found(id),
}
}
Ok(response)
}
/// `inbuxa:SharingPolicy/set`: turns switches. `null` puts one back to its
/// default, on (as far as the server allows).
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, SharingPolicy>,
) -> trc::Result<SetResponse<SharingPolicy>> {
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::forbidden().with_description("A sharing policy exists with the server or the tenant."),
);
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(
id,
SetError::forbidden().with_description("A sharing policy exists with the server or the tenant."),
);
}
let reachable = reachable(server, access_token).await?;
let data = &server.core.storage.data;
let mut changed = false;
for (id, value) in request.unwrap_update().into_valid() {
let tenant_id = target(id);
match tenant_id {
None if access_token.tenant_id().is_some()
|| !access_token.has_permission(Permission::SysSharingUpdate) =>
{
response.not_updated.append(
id,
SetError::forbidden()
.with_description("Only a server administrator changes the server's sharing policy."),
);
continue;
}
Some(tenant_id) if !reachable.contains(&tenant_id) => {
response.not_updated.append(id, SetError::not_found());
continue;
}
_ => {}
}
let previous = sharing_policy::get(data, tenant_id).await?;
let mut policy = previous.clone();
let mut error = None;
for (key, value) in value.into_expanded_object() {
let parsed = match value {
Value::Null => Ok(None),
Value::Str(s) if s == "enabled" => Ok(Some(true)),
Value::Str(s) if s == "disabled" => Ok(Some(false)),
_ => Err("must be enabled or disabled".to_string()),
};
let result = match &key {
Key::Property(P::MailSharing) => parsed.map(|v| policy.mail_sharing = v),
Key::Property(P::AddAccounts) => parsed.map(|v| policy.add_accounts = v),
Key::Property(P::Id) => Err("is immutable".to_string()),
Key::Property(_) => Err("is set by the server".to_string()),
_ => Err("is not a property of inbuxa:SharingPolicy".to_string()),
};
if let Err(why) = result {
error = Some(
SetError::invalid_properties()
.with_property(key.into_owned())
.with_description(why),
);
break;
}
}
if let Some(error) = error {
response.not_updated.append(id, error);
continue;
}
// A tenant can only be stricter than the server
if tenant_id.is_some()
&& let Some(why) = looser_than_server(&sharing_policy::get(data, None).await?, &policy)
{
response
.not_updated
.append(id, SetError::forbidden().with_description(why));
continue;
}
if policy.mail_sharing != previous.mail_sharing || policy.add_accounts != previous.add_accounts {
policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
sharing_policy::set(data, tenant_id, &policy).await?;
changed = true;
}
response.updated.append(id, None);
}
if changed {
// Shares are honored, or not, as tokens are built
server.invalidate_all_local_caches();
server.cluster_broadcast(BroadcastEvent::CacheInvalidateAll).await;
}
Ok(response)
}
+5 -1
View File
@@ -6,7 +6,7 @@
//! `x:Metric/get` and `/query` over the stored history (monitoring spec, //! `x:Metric/get` and `/query` over the stored history (monitoring spec,
//! "Interfaces"). Samples are server-level (MON-31) and read-only (MON-32). //! "Interfaces"). Samples are server-level (MON-31) and read-only (MON-32).
//! A sample's `timestamp` comes from its id. //! A sample's `timestamp` and `nodeId` come from its id.
use crate::{ use crate::{
api::query::QueryResponseBuilder, api::query::QueryResponseBuilder,
@@ -54,12 +54,16 @@ fn metric_type(metric: &Metric) -> MetricType {
fn to_value(sample: StoredMetric) -> JmapValue<'static> { fn to_value(sample: StoredMetric) -> JmapValue<'static> {
let timestamp = sample.timestamp(); let timestamp = sample.timestamp();
let node_id = sample.node_id();
let mut value = sample.metric.into_value(); let mut value = sample.metric.into_value();
if let JmapValue::Object(obj) = &mut value { if let JmapValue::Object(obj) = &mut value {
obj.insert_unchecked( obj.insert_unchecked(
Property::Timestamp, Property::Timestamp,
JmapValue::Str(UTCDateTime::from_timestamp(timestamp as i64).to_string().into()), JmapValue::Str(UTCDateTime::from_timestamp(timestamp as i64).to_string().into()),
); );
// Histograms are running totals per node; without this a reader
// diffs one node's total against another's
obj.insert_unchecked(Property::NodeId, JmapValue::Number(node_id.into()));
} }
value value
} }
+8
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; use common::{Server, auth::AccessToken, sharing::EffectiveAcl};
@@ -14,6 +16,7 @@ use jmap_tools::{Map, Value};
use std::future::Future; use std::future::Future;
use store::ahash::AHashSet; use store::ahash::AHashSet;
use types::{acl::Acl, collection::Collection, keyword::Keyword, special_use::SpecialUse}; use types::{acl::Acl, collection::Collection, keyword::Keyword, special_use::SpecialUse};
use utils::map::bitmap::Bitmap;
use crate::{api::acl::JmapRights, changes::state::JmapCacheState}; use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
@@ -138,6 +141,11 @@ impl MailboxGet for Server {
JmapRights::rights::<Mailbox>( JmapRights::rights::<Mailbox>(
cached_mailbox.acls.as_slice().effective_acl(access_token), cached_mailbox.acls.as_slice().effective_acl(access_token),
) )
} else if access_token.is_group_member_only(account_id) {
// inbuxa: MA-D0: everything but sharing it on.
let mut acl = Bitmap::<Acl>::all();
acl.remove(Acl::Share);
JmapRights::rights::<Mailbox>(acl)
} else { } else {
JmapRights::all_rights::<Mailbox>() JmapRights::all_rights::<Mailbox>()
} }
+31 -1
View File
@@ -31,7 +31,7 @@ use jmap_proto::{
types::state::State, types::state::State,
}; };
use jmap_tools::{JsonPointerItem, Key, Map, Value}; use jmap_tools::{JsonPointerItem, Key, Map, Value};
use registry::schema::enums::StorageQuota; use registry::schema::enums::{Permission, StorageQuota};
use std::future::Future; use std::future::Future;
use store::{ use store::{
ValueKey, ValueKey,
@@ -613,6 +613,36 @@ impl MailboxSet for Server {
// Refresh ACLs // Refresh ACLs
let current = update.map(|(_, current)| current); let current = update.map(|(_, current)| current);
if has_acl_changes { if has_acl_changes {
// inbuxa: MA-D0: a group's members don't share its mailboxes on.
if ctx.access_token.is_group_member_only(ctx.account_id) {
return Ok(Err(SetError::forbidden()
.with_property(MailboxProperty::ShareWith)
.with_description(
"This mailbox belongs to a group. Only an administrator can change who has it.",
)));
}
// inbuxa: MA-C: with mail sharing off, nobody here starts or
// widens a share (narrowing or ending one is always allowed)
let before = current.as_ref().map(|m| m.inner.acls.as_slice()).unwrap_or_default();
let widens = changes.acls.iter().any(|grant| {
let had = before
.iter()
.find(|old| old.account_id == grant.account_id)
.map_or(0, |old| old.grants.clone().into_inner());
grant.grants.clone().into_inner() & !had != 0
});
if widens
&& !ctx.access_token.has_permission(Permission::Impersonate)
&& !self.mail_sharing_allowed(ctx.account_id).await?
{
return Ok(Err(SetError::forbidden()
.with_property(MailboxProperty::ShareWith)
.with_description(
"Your organization has turned off sharing mail folders. A shared mailbox or a group can be set up by an administrator instead.",
)));
}
if !changes.acls.is_empty() if !changes.acls.is_empty()
&& let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await && let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await
{ {
+62 -1
View File
@@ -8,6 +8,7 @@
use common::{ use common::{
Server, Server,
auth::AccessToken,
config::smtp::queue::QueueName, config::smtp::queue::QueueName,
network::{ServerInstance, stream::NullIo}, network::{ServerInstance, stream::NullIo},
storage::index::ObjectIndexBuilder, storage::index::ObjectIndexBuilder,
@@ -49,6 +50,7 @@ pub trait EmailSubmissionSet: Sync + Send {
fn email_submission_set<'x>( fn email_submission_set<'x>(
&self, &self,
request: SetRequest<'x, email_submission::EmailSubmission>, request: SetRequest<'x, email_submission::EmailSubmission>,
access_token: &AccessToken,
instance: &Arc<ServerInstance>, instance: &Arc<ServerInstance>,
next_call: &mut Option<Call<RequestMethod<'x>>>, next_call: &mut Option<Call<RequestMethod<'x>>>,
) -> impl Future<Output = trc::Result<SetResponse<email_submission::EmailSubmission>>> + Send; ) -> impl Future<Output = trc::Result<SetResponse<email_submission::EmailSubmission>>> + Send;
@@ -56,6 +58,7 @@ pub trait EmailSubmissionSet: Sync + Send {
fn send_message( fn send_message(
&self, &self,
account_id: u32, account_id: u32,
own_addresses_only: bool,
response: &SetResponse<email_submission::EmailSubmission>, response: &SetResponse<email_submission::EmailSubmission>,
instance: &Arc<ServerInstance>, instance: &Arc<ServerInstance>,
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>, object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
@@ -68,6 +71,7 @@ impl EmailSubmissionSet for Server {
async fn email_submission_set<'x>( async fn email_submission_set<'x>(
&self, &self,
mut request: SetRequest<'x, email_submission::EmailSubmission>, mut request: SetRequest<'x, email_submission::EmailSubmission>,
access_token: &AccessToken,
instance: &Arc<ServerInstance>, instance: &Arc<ServerInstance>,
next_call: &mut Option<Call<RequestMethod<'x>>>, next_call: &mut Option<Call<RequestMethod<'x>>>,
) -> trc::Result<SetResponse<email_submission::EmailSubmission>> { ) -> trc::Result<SetResponse<email_submission::EmailSubmission>> {
@@ -80,7 +84,14 @@ impl EmailSubmissionSet for Server {
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
for (id, object) in request.unwrap_create() { for (id, object) in request.unwrap_create() {
match self match self
.send_message(account_id, &response, instance, object) .send_message(
account_id,
// inbuxa: MA-S3: a shared mailbox's people send only as it
access_token.delegated_shared_mailbox(account_id),
&response,
instance,
object,
)
.await? .await?
{ {
Ok(submission) => { Ok(submission) => {
@@ -110,6 +121,15 @@ impl EmailSubmissionSet for Server {
.assign_document_ids(account_id, Collection::EmailSubmission, 1) .assign_document_ids(account_id, Collection::EmailSubmission, 1)
.await .await
.caused_by(trc::location!())?; .caused_by(trc::location!())?;
// inbuxa: MA-D0a: who sent it, when it went out as someone else
self.audit_send_as(
access_token,
account_id,
document_id,
&submission.envelope.mail_from.email,
)
.await;
batch batch
.with_account_id(account_id) .with_account_id(account_id)
.with_collection(Collection::EmailSubmission) .with_collection(Collection::EmailSubmission)
@@ -388,6 +408,7 @@ impl EmailSubmissionSet for Server {
async fn send_message( async fn send_message(
&self, &self,
account_id: u32, account_id: u32,
own_addresses_only: bool,
response: &SetResponse<email_submission::EmailSubmission>, response: &SetResponse<email_submission::EmailSubmission>,
instance: &Arc<ServerInstance>, instance: &Arc<ServerInstance>,
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>, object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
@@ -617,6 +638,46 @@ impl EmailSubmissionSet for Server {
.unarchive::<MessageMetadata>() .unarchive::<MessageMetadata>()
.caused_by(trc::location!())?; .caused_by(trc::location!())?;
// inbuxa: MA-S3: mail that came to a shared mailbox goes out as it,
// so the answer comes back to the mailbox and not to whoever sent
// it: every From and Reply-To address must be the mailbox's own
if own_addresses_only {
let mut named = Vec::new();
for header in metadata.contents[0].parts[0].headers.iter() {
if !matches!(
header.name,
ArchivedMetadataHeaderName::From | ArchivedMetadataHeaderName::ReplyTo
) {
continue;
}
match &header.value {
ArchivedMetadataHeaderValue::AddressList(addr) => {
named.extend(addr.iter().filter_map(|a| a.address.as_ref().map(|v| v.to_string())));
}
ArchivedMetadataHeaderValue::AddressGroup(groups) => {
for group in groups.iter() {
named.extend(
group
.addresses
.iter()
.filter_map(|a| a.address.as_ref().map(|v| v.to_string())),
);
}
}
_ => {}
}
}
for address in named {
if self.account_id_from_email(&address, true).await? != Some(account_id) {
return Ok(Err(SetError::new(SetErrorType::ForbiddenFrom).with_description(
format!(
"A shared mailbox sends only as its own addresses, so replies come back to it; {address} isn't one."
),
)));
}
}
}
// Add recipients to envelope if missing // Add recipients to envelope if missing
let mut bcc_header = None; let mut bcc_header = None;
if rcpt_to.is_empty() { if rcpt_to.is_empty() {
+4
View File
@@ -1762,6 +1762,10 @@ pub enum Permission {
SysJournalExport = 683, SysJournalExport = 683,
// inbuxa: the security to-do list, accepting an item // inbuxa: the security to-do list, accepting an item
SysSecurityAccept = 684, SysSecurityAccept = 684,
// inbuxa: the deliverability check
SysDeliverabilityGet = 685,
SysDeliverabilityUpdate = 686,
SysDeliverabilityCheck = 687,
SysAccountGet = 219, SysAccountGet = 219,
SysAccountCreate = 220, SysAccountCreate = 220,
SysAccountUpdate = 221, SysAccountUpdate = 221,
+10 -1
View File
@@ -7102,6 +7102,9 @@ impl EnumImpl for Permission {
b"sysJournalSearch" => Permission::SysJournalSearch, b"sysJournalSearch" => Permission::SysJournalSearch,
b"sysJournalExport" => Permission::SysJournalExport, b"sysJournalExport" => Permission::SysJournalExport,
b"sysSecurityAccept" => Permission::SysSecurityAccept, b"sysSecurityAccept" => Permission::SysSecurityAccept,
b"sysDeliverabilityGet" => Permission::SysDeliverabilityGet,
b"sysDeliverabilityUpdate" => Permission::SysDeliverabilityUpdate,
b"sysDeliverabilityCheck" => Permission::SysDeliverabilityCheck,
b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountGet" => Permission::SysAccountGet,
b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountCreate" => Permission::SysAccountCreate,
b"sysAccountUpdate" => Permission::SysAccountUpdate, b"sysAccountUpdate" => Permission::SysAccountUpdate,
@@ -7803,6 +7806,9 @@ impl EnumImpl for Permission {
Permission::SysJournalSearch => "sysJournalSearch", Permission::SysJournalSearch => "sysJournalSearch",
Permission::SysJournalExport => "sysJournalExport", Permission::SysJournalExport => "sysJournalExport",
Permission::SysSecurityAccept => "sysSecurityAccept", Permission::SysSecurityAccept => "sysSecurityAccept",
Permission::SysDeliverabilityGet => "sysDeliverabilityGet",
Permission::SysDeliverabilityUpdate => "sysDeliverabilityUpdate",
Permission::SysDeliverabilityCheck => "sysDeliverabilityCheck",
Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountGet => "sysAccountGet",
Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountCreate => "sysAccountCreate",
Permission::SysAccountUpdate => "sysAccountUpdate", Permission::SysAccountUpdate => "sysAccountUpdate",
@@ -8497,6 +8503,9 @@ impl EnumImpl for Permission {
682 => Some(Permission::SysJournalSearch), 682 => Some(Permission::SysJournalSearch),
683 => Some(Permission::SysJournalExport), 683 => Some(Permission::SysJournalExport),
684 => Some(Permission::SysSecurityAccept), 684 => Some(Permission::SysSecurityAccept),
685 => Some(Permission::SysDeliverabilityGet),
686 => Some(Permission::SysDeliverabilityUpdate),
687 => Some(Permission::SysDeliverabilityCheck),
219 => Some(Permission::SysAccountGet), 219 => Some(Permission::SysAccountGet),
220 => Some(Permission::SysAccountCreate), 220 => Some(Permission::SysAccountCreate),
221 => Some(Permission::SysAccountUpdate), 221 => Some(Permission::SysAccountUpdate),
@@ -8941,7 +8950,7 @@ impl EnumImpl for Permission {
} }
} }
const COUNT: usize = 685; const COUNT: usize = 688;
} }
impl serde::Serialize for Permission { impl serde::Serialize for Permission {
+3
View File
@@ -34,6 +34,9 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls", "h
base64 = "0.23" base64 = "0.23"
dns-update = { version = "0.5" } dns-update = { version = "0.5" }
psl = "2" psl = "2"
# inbuxa: the deliverability check
mail-auth = { version = "0.13" }
futures = "0.3"
[dev-dependencies] [dev-dependencies]
+6
View File
@@ -146,6 +146,10 @@ impl BroadcastBatch<Vec<BroadcastEvent>> {
serialized.push(13u8); serialized.push(13u8);
let _ = serialized.write_leb128(*account_id); let _ = serialized.write_leb128(*account_id);
} }
// inbuxa: DL-15
BroadcastEvent::DeliverabilityCheck => {
serialized.push(14u8);
}
} }
} }
serialized serialized
@@ -284,6 +288,8 @@ where
let account_id = self.messages.next_leb128().ok_or(())?; let account_id = self.messages.next_leb128().ok_or(())?;
Ok(Some(BroadcastEvent::EndSessions(account_id))) Ok(Some(BroadcastEvent::EndSessions(account_id)))
} }
// inbuxa: DL-15
14 => Ok(Some(BroadcastEvent::DeliverabilityCheck)),
_ => Err(()), _ => Err(()),
} }
} else { } else {
@@ -189,6 +189,12 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
.send(PushEvent::Revoke { account_id }) .send(PushEvent::Revoke { account_id })
.await; .await;
} }
// inbuxa: DL-15: this node checks
// itself too
BroadcastEvent::DeliverabilityCheck => {
crate::inbuxa_deliverability::CHECK_NOW
.notify_one();
}
BroadcastEvent::QueueRefresh => { BroadcastEvent::QueueRefresh => {
if inner.shared_core.load().network.roles.outbound_mta { if inner.shared_core.load().network.roles.outbound_mta {
let _ = inner let _ = inner
@@ -278,6 +284,7 @@ fn log_event(event: &BroadcastEvent) -> trc::Value {
BroadcastEvent::EndSessions(account_id) => { BroadcastEvent::EndSessions(account_id) => {
trc::Value::Array(vec!["EndSessions".into(), (*account_id).into()]) trc::Value::Array(vec!["EndSessions".into(), (*account_id).into()])
} }
BroadcastEvent::DeliverabilityCheck => "DeliverabilityCheck".into(),
BroadcastEvent::RegistryChange(change) => match change { BroadcastEvent::RegistryChange(change) => match change {
RegistryChange::Insert(id) => trc::Value::Array(vec![ RegistryChange::Insert(id) => trc::Value::Array(vec![
"RegistryInsert".into(), "RegistryInsert".into(),
@@ -0,0 +1,566 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The deliverability check (deliverability spec, DL-1 to DL-16): every node
//! that sends mail asks what the rest of the internet sees of it, once a day
//! and when an administrator asks (**Check now**), and keeps one report.
//!
//! Each node checks itself, because only it knows which address it sends
//! from: a cluster's nodes can each leave from their own (DL-1, DL-2). The
//! report holds facts; the console grades them.
use common::{
BuildServer, Inner, Server, config::smtp::auth::Dkim1Signer, expr::functions::EmptyResolver,
};
use futures::future::join_all;
use inbuxa_features::deliverability::{
self as model, Address, AddressSource, Certificate, DkimKey, DkimState, Dmarc, DomainReport,
Listing, ListingState, MtaSts, Report, Settings, SpfResult,
lists::{self, Answer, BlockList, Subject},
};
use mail_auth::{
AuthenticatedMessage, DkimResult, DnsError, Error, SpfResult as Spf,
common::headers::HeaderWriter,
dmarc::{self, Alignment},
mta_sts::{MtaSts as MtaStsRecord, TlsRpt},
spf::verify::SpfParameters,
};
use registry::schema::{prelude::ObjectType, structs::Domain};
use smtp::outbound::mta_sts::{lookup::MtaStsLookup, verify::VerifyPolicy};
use std::{
collections::BTreeSet,
future::Future,
net::IpAddr,
sync::{Arc, LazyLock},
time::Duration,
};
use store::{registry::RegistryQuery, write::now};
use tokio::sync::Notify;
use types::id::Id;
/// DL-16: no single lookup holds a run up for longer than this.
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
/// DL-16: lookups in flight at once.
const PARALLEL: usize = 8;
const MTA_STS_TIMEOUT: Duration = Duration::from_secs(10);
const DAY: u64 = 86_400;
/// After a start, wait this long before a run that's overdue.
const SETTLE: Duration = Duration::from_secs(120);
/// DL-15: wakes this node's check, from **Check now** here or on another node.
pub static CHECK_NOW: LazyLock<Notify> = LazyLock::new(Notify::new);
pub fn spawn_deliverability(inner: Arc<Inner>) {
tokio::spawn(async move {
let mut first = true;
loop {
let server = inner.build_server();
let wait = match due_in(&server).await {
Ok(wait) => wait,
Err(err) => {
trc::error!(err.details("Failed to read the deliverability report"));
Duration::from_secs(3600)
}
};
let wait = if first { wait.max(SETTLE) } else { wait };
first = false;
let asked = tokio::select! {
_ = tokio::time::sleep(wait) => false,
_ = CHECK_NOW.notified() => true,
};
let server = inner.build_server();
if !server.core.network.roles.outbound_mta {
continue;
}
// DL-15: asked again within ten minutes, the last report stands
if asked
&& let Ok(Some(last)) =
model::report(server.store(), server.core.network.node_id).await
&& now().saturating_sub(last.checked_at) < model::MIN_INTERVAL_SECS
{
continue;
}
if let Err(err) = run(&server).await {
trc::error!(err.details("Failed to run the deliverability check"));
}
}
});
}
/// DL-14: once a day, at a minute in the first hour of the day (UTC) that's
/// the node's own, so nodes and servers don't all ask the lists at once.
async fn due_in(server: &Server) -> trc::Result<Duration> {
let node_id = server.core.network.node_id;
let last = model::report(server.store(), node_id)
.await?
.map(|r| r.checked_at)
.unwrap_or(0);
let slot = slot_for(&server.core.network.server_name, node_id);
let next = next_slot(last, slot);
Ok(Duration::from_secs(next.saturating_sub(now())))
}
fn slot_for(hostname: &str, node_id: u64) -> u64 {
let hash = hostname
.bytes()
.fold(node_id.wrapping_mul(0x9e37_79b9_7f4a_7c15), |h, b| {
h.rotate_left(5) ^ b as u64
});
hash % 3600
}
/// The first daily slot after `last`; 0 (never ran) is due now.
fn next_slot(last: u64, slot: u64) -> u64 {
if last == 0 {
return 0;
}
let mut next = last - last % DAY + slot;
if next <= last {
next += DAY;
}
next
}
/// Runs the check on this node and keeps the report.
pub async fn run(server: &Server) -> trc::Result<Report> {
let settings = model::settings(server.store()).await?;
let addresses = addresses(server, &settings).await;
let mut domains = Vec::new();
let ids = server
.registry()
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Domain))
.await?;
for id in ids {
if let Some(domain) = server.registry().object::<Domain>(id).await? {
domains.push(check_domain(server, &settings, &domain, &addresses).await?);
}
}
let certificates = certificates(server, &addresses).await;
let report = Report {
node_id: server.core.network.node_id,
hostname: server.core.network.server_name.clone(),
checked_at: now(),
addresses,
domains,
certificates,
};
model::put_report(server.store(), &report).await?;
Ok(report)
}
// --- DL-1, DL-2: the addresses ---------------------------------------------
async fn addresses(server: &Server, settings: &Settings) -> Vec<Address> {
let queue = &server.core.smtp.queue;
// The strategy the scheduler picks for a message it knows nothing about:
// what an expression on the node's own name, as a cluster uses, gives.
let strategy = server
.eval_if::<String, _>(&queue.connection, &EmptyResolver, 0)
.await
.unwrap_or_else(|| "default".to_string());
let connection = server.get_connection_or_default(&strategy, 0);
let ehlo = connection
.ehlo_hostname
.clone()
.unwrap_or_else(|| server.core.network.server_name.clone());
let mut found: Vec<(IpAddr, AddressSource, String)> = connection
.source_ipv4
.iter()
.chain(connection.source_ipv6.iter())
.map(|source| {
(
source.ip,
AddressSource::Configured,
source.host.clone().unwrap_or_else(|| ehlo.clone()),
)
})
.collect();
if found.is_empty() {
for ip in resolve_name(server, &ehlo).await {
found.push((ip, AddressSource::Ehlo, ehlo.clone()));
}
}
let mut out = Vec::with_capacity(found.len());
for (ip, source, ehlo) in found {
let mut address = Address {
ip: ip.to_string(),
source,
strategy: strategy.clone(),
ehlo: ehlo.clone(),
..Default::default()
};
reverse_dns(server, ip, &ehlo, &mut address).await;
address.listings = listings(server, settings, Subject::Ip(ip)).await;
out.push(address);
}
out
}
/// The IPv4 and IPv6 addresses `name` resolves to; none when it doesn't.
async fn resolve_name(server: &Server, name: &str) -> Vec<IpAddr> {
let dns = &server.core.smtp.resolvers.dns;
let cache = &server.inner.cache;
let fqdn = fqdn(name);
let mut ips = Vec::new();
if let Some(Ok(v4)) = timed(dns.ipv4_lookup(fqdn.as_str(), Some(&cache.dns_ipv4))).await {
ips.extend(v4.rrset.iter().copied().map(IpAddr::V4));
}
if let Some(Ok(v6)) = timed(dns.ipv6_lookup(fqdn.as_str(), Some(&cache.dns_ipv6))).await {
ips.extend(v6.rrset.iter().copied().map(IpAddr::V6));
}
ips
}
/// DL-5: the PTR names, whether one resolves back, and whether that one is
/// the EHLO name.
async fn reverse_dns(server: &Server, ip: IpAddr, ehlo: &str, address: &mut Address) {
let dns = &server.core.smtp.resolvers.dns;
match timed(dns.ptr_lookup(ip, Some(&server.inner.cache.dns_ptr))).await {
Some(Ok(names)) => {
address.ptr = names.rrset.iter().map(|n| bare(n)).collect();
}
Some(Err(Error::Dns(DnsError::RecordNotFound(_)))) => {}
Some(Err(err)) => address.ptr_error = Some(err.to_string()),
None => address.ptr_error = Some("No answer in 5 seconds".into()),
}
for name in address.ptr.clone() {
if resolve_name(server, &name).await.contains(&ip) {
address.forward_confirmed = true;
if name.eq_ignore_ascii_case(&bare(ehlo)) {
address.ehlo_matches = true;
}
}
}
}
// --- DL-4, DL-6, DL-12: blocklists ----------------------------------------
async fn listings(server: &Server, settings: &Settings, subject: Subject<'_>) -> Vec<Listing> {
let mut out = Vec::new();
let mut asked = Vec::new();
for list in lists::LISTS {
let Some(name) = list.query(&subject) else {
continue;
};
if settings.is_off(list.name) {
out.push(Listing {
list: list.name.into(),
state: ListingState::Off,
..Default::default()
});
} else {
asked.push((list, name));
}
}
for chunk in asked.chunks(PARALLEL) {
out.extend(join_all(chunk.iter().map(|(list, name)| ask(server, list, name))).await);
}
// In the lists' own order, whether asked or off
out.sort_by_key(|l| lists::LISTS.iter().position(|list| list.name == l.list));
out
}
async fn ask(server: &Server, list: &BlockList, name: &str) -> Listing {
let dns = &server.core.smtp.resolvers.dns;
let mut listing = Listing {
list: list.name.into(),
..Default::default()
};
match timed(dns.ipv4_lookup(name, Some(&server.inner.cache.dns_ipv4))).await {
Some(Ok(answer)) => {
let Some(code) = answer.rrset.first().copied() else {
return listing;
};
listing.code = Some(code.to_string());
match list.read(code) {
Answer::Listed(meaning) => {
listing.state = ListingState::Listed;
listing.meaning = Some(meaning.into());
}
Answer::Refused(meaning) => {
listing.state = ListingState::Refused;
listing.meaning = Some(meaning.into());
}
Answer::Unknown => {
listing.state = ListingState::Refused;
listing.meaning = Some("An answer this list doesn't define".into());
}
}
}
// Not on the list
Some(Err(Error::Dns(DnsError::RecordNotFound(_)))) => {}
// A list that refuses the resolver often answers REFUSED or SERVFAIL
Some(Err(err)) => {
listing.state = ListingState::Error;
listing.meaning = Some(err.to_string());
}
None => {
listing.state = ListingState::Error;
listing.meaning = Some("No answer in 5 seconds".into());
}
}
listing
}
// --- DL-7 to DL-12: per domain --------------------------------------------
async fn check_domain(
server: &Server,
settings: &Settings,
domain: &Domain,
addresses: &[Address],
) -> trc::Result<DomainReport> {
let name = domain.name.to_lowercase();
let mut report = DomainReport {
domain: name.clone(),
tenant_id: domain.member_tenant_id.map(|id| id.document_id()),
..Default::default()
};
let dns = &server.core.smtp.resolvers.dns;
let cache = &server.inner.cache;
// DL-7: SPF for every address the node sends from
for address in addresses {
let Ok(ip) = address.ip.parse::<IpAddr>() else {
continue;
};
let sender = format!("postmaster@{name}");
let output = dns
.check_host(cache.build_auth_parameters(SpfParameters::new(
ip,
&name,
&address.ehlo,
&server.core.network.server_name,
&sender,
)))
.await;
report.spf.push(SpfResult {
ip: address.ip.clone(),
result: spf_name(output.result()).into(),
});
}
// DL-8: each key the domain signs with is the one published
report.dkim = dkim_keys(server, &name).await?;
// DL-9: what DMARC asks of alignment; the console works it out
if let Some(Ok(record)) =
timed(dns.txt_lookup::<dmarc::Dmarc>(format!("_dmarc.{name}."), Some(&cache.dns_txt))).await
{
report.dmarc = Some(Dmarc {
policy: match record.p {
dmarc::Policy::None | dmarc::Policy::Unspecified => "none",
dmarc::Policy::Quarantine => "quarantine",
dmarc::Policy::Reject => "reject",
}
.into(),
adkim: alignment(&record.adkim).into(),
aspf: alignment(&record.aspf).into(),
});
}
// DL-10
report.mta_sts = mta_sts(server, &name).await;
// DL-11
report.tls_rpt = matches!(
timed(dns.txt_lookup::<TlsRpt>(format!("_smtp._tls.{name}."), Some(&cache.dns_txt))).await,
Some(Ok(_))
);
// DL-12
report.listings = listings(server, settings, Subject::Domain(&name)).await;
Ok(report)
}
/// Signs a message that's never sent with each of the domain's DKIM keys,
/// and verifies it as a receiver would: a key that's missing from DNS, or
/// published but different, fails here before it fails anyone's mail.
async fn dkim_keys(server: &Server, domain: &str) -> trc::Result<Vec<DkimKey>> {
let Some(signers) = server.dkim_signers(domain).await? else {
return Ok(Vec::new());
};
let message = format!(
"From: deliverability-check@{domain}\r\n\
To: deliverability-check@{domain}\r\n\
Subject: Deliverability check\r\n\
Date: Mon, 5 Oct 2026 00:00:00 +0000\r\n\
Message-ID: <deliverability-check@{domain}>\r\n\
\r\n\
This message is signed to check the DKIM keys in DNS. It is never sent.\r\n"
);
let mut keys = Vec::new();
for signer in &signers.dkim1 {
let signature = match signer {
Dkim1Signer::RsaSha256(signer) => signer.sign(message.as_bytes()),
Dkim1Signer::Ed25519Sha256(signer) => signer.sign(message.as_bytes()),
};
let Ok(signature) = signature else {
continue;
};
let selector = signature.s.clone();
let mut signed = Vec::with_capacity(message.len() + 512);
signature.write_header(&mut signed);
signed.extend_from_slice(message.as_bytes());
let state = match AuthenticatedMessage::parse(&signed) {
Some(parsed) => {
let outputs = server
.core
.smtp
.resolvers
.dns
.verify_dkim(server.inner.cache.build_auth_parameters(&parsed))
.await;
outputs
.first()
.map(|output| dkim_state(output.result()))
.unwrap_or(DkimState::Error)
}
None => DkimState::Error,
};
keys.push(DkimKey { selector, state });
}
Ok(keys)
}
fn dkim_state(result: &DkimResult) -> DkimState {
match result {
DkimResult::Pass => DkimState::Matches,
DkimResult::PermError(Error::Dns(DnsError::RecordNotFound(_)))
| DkimResult::TempError(Error::Dns(DnsError::RecordNotFound(_))) => DkimState::Missing,
DkimResult::TempError(_) => DkimState::Error,
_ => DkimState::Different,
}
}
async fn mta_sts(server: &Server, domain: &str) -> MtaSts {
let dns = &server.core.smtp.resolvers.dns;
let cache = &server.inner.cache;
let mut out = MtaSts::default();
let Some(Ok(record)) =
timed(dns.txt_lookup::<MtaStsRecord>(format!("_mta-sts.{domain}."), Some(&cache.dns_txt)))
.await
else {
return out;
};
out.record_id = Some(record.id.clone());
match server.lookup_mta_sts_policy(domain, MTA_STS_TIMEOUT).await {
Ok(policy) => {
out.fetched = true;
out.mode = Some(
match policy.mode {
common::config::smtp::resolver::Mode::Enforce => "enforce",
common::config::smtp::resolver::Mode::Testing => "testing",
common::config::smtp::resolver::Mode::None => "none",
}
.into(),
);
out.max_age = Some(policy.max_age);
if let Some(Ok(mxs)) = timed(dns.mx_lookup(domain, Some(&cache.dns_mx))).await {
for mx in mxs.rrset.iter() {
for exchange in mx.exchanges.iter() {
let host = bare(exchange);
if !policy.verify(&host) && !out.mx_not_covered.contains(&host) {
out.mx_not_covered.push(host);
}
}
}
}
}
Err(err) => out.error = Some(err.to_string()),
}
out
}
// --- DL-13: certificates ---------------------------------------------------
/// The EHLO names, and the server's MX names that point at this node, each
/// with whether the node holds a certificate for it.
async fn certificates(server: &Server, addresses: &[Address]) -> Vec<Certificate> {
let mine: Vec<IpAddr> = addresses.iter().filter_map(|a| a.ip.parse().ok()).collect();
let mut names: BTreeSet<String> = addresses.iter().map(|a| bare(&a.ehlo)).collect();
let default_host = server.core.network.server_name.as_str();
for mx in &server.core.network.info.mxs {
let name = bare(mx.hostname.as_deref().unwrap_or(default_host));
if !names.contains(&name)
&& resolve_name(server, &name)
.await
.iter()
.any(|ip| mine.contains(ip))
{
names.insert(name);
}
}
names
.into_iter()
.map(|name| Certificate {
covered: server.resolve_certificate(&name).is_some(),
name,
})
.collect()
}
// --- Helpers ---------------------------------------------------------------
async fn timed<T>(lookup: impl Future<Output = T>) -> Option<T> {
tokio::time::timeout(LOOKUP_TIMEOUT, lookup).await.ok()
}
fn fqdn(name: &str) -> String {
format!("{}.", name.trim_end_matches('.'))
}
fn bare(name: &str) -> String {
name.trim_end_matches('.').to_lowercase()
}
fn spf_name(result: Spf) -> &'static str {
match result {
Spf::Pass => "pass",
Spf::Fail => "fail",
Spf::SoftFail => "softFail",
Spf::Neutral => "neutral",
Spf::TempError => "tempError",
Spf::PermError => "permError",
Spf::None => "none",
}
}
fn alignment(alignment: &Alignment) -> &'static str {
match alignment {
Alignment::Relaxed => "relaxed",
Alignment::Strict => "strict",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_daily_slot_follows_the_last_run() {
let day = 20_000 * DAY;
// Never ran: due now
assert_eq!(next_slot(0, 600), 0);
// Ran at 14:00: next is tomorrow's slot
assert_eq!(next_slot(day + 14 * 3600, 600), day + DAY + 600);
// Ran just before today's slot: today's slot
assert_eq!(next_slot(day + 300, 600), day + 600);
// Ran at the slot: tomorrow's
assert_eq!(next_slot(day + 600, 600), day + DAY + 600);
}
#[test]
fn slots_fall_in_the_first_hour_and_differ_by_node() {
let a = slot_for("mx2.example.org", 2);
let b = slot_for("mx3.example.org", 3);
assert!(a < 3600 && b < 3600);
assert_ne!(a, b);
}
}
+4
View File
@@ -26,6 +26,7 @@ pub mod broadcast;
// inbuxa: AL-5, delegations end at their date // inbuxa: AL-5, delegations end at their date
pub mod inbuxa_lock_expiry; pub mod inbuxa_lock_expiry;
pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1 pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1
pub mod inbuxa_deliverability; // inbuxa: the deliverability check
pub mod state_manager; pub mod state_manager;
pub mod task_manager; pub mod task_manager;
@@ -74,6 +75,9 @@ impl SpawnServices for IpcReceivers {
// inbuxa: personal-data catalog, D1: old log files go, per node // inbuxa: personal-data catalog, D1: old log files go, per node
inbuxa_log_retention::spawn_log_retention(inner.clone()); inbuxa_log_retention::spawn_log_retention(inner.clone());
// inbuxa: deliverability spec, DL-14: each node checks itself daily
inbuxa_deliverability::spawn_deliverability(inner.clone());
// Spawn task scheduler // Spawn task scheduler
spawn_task_scheduler(inner); spawn_task_scheduler(inner);
} }
+69 -9
View File
@@ -176,16 +176,23 @@ impl TlsaLookup for Server {
return mail_auth::common::resolver::mock_resolve(key.as_ref()); return mail_auth::common::resolver::mock_resolve(key.as_ref());
} }
let tlsa_lookup = match self // Through `validated_lookup`, like the MX and address lookups: a TLSA
.core // name that is a signed CNAME to a name with no TLSA record (seen at
.smtp // `_25._tcp.mail.usefulinsight.com`, behind Hetzner's resolvers) is
.resolvers // otherwise called bogus, and the message waits on it until it
.dnssec // expires.
.resolver let tlsa_lookup = match validated_lookup(
.tlsa_lookup(Name::from_str_relaxed(key.as_ref())?) &self.core.smtp.resolvers.dnssec.resolver,
self.core.smtp.resolvers.dns.resolver(),
Name::from_str_relaxed(key.as_ref())?,
RecordType::TLSA,
)
.await .await
{ {
Ok(tlsa_lookup) => tlsa_lookup, // A TLSA record proved to sit in an unsigned zone is no DANE
// policy at all.
Ok(validated) if validated.insecure => return Ok(TlsaResult::Missing),
Ok(validated) => validated.lookup,
Err(err) => { Err(err) => {
if let Some(denial) = NegativeAnswer::from_error(&err) { if let Some(denial) = NegativeAnswer::from_error(&err) {
return Ok(if denial.dnssec_status == DnssecStatus::Bogus { return Ok(if denial.dnssec_status == DnssecStatus::Bogus {
@@ -436,7 +443,8 @@ impl TlsaLookup for Server {
// record in the DS reply, and public resolvers often send none. // record in the DS reply, and public resolvers often send none.
// - A signed CNAME to a signed name without the record type queried. Hickory // - A signed CNAME to a signed name without the record type queried. Hickory
// checks the denial of existence against the name first asked for, not the // checks the denial of existence against the name first asked for, not the
// target's, and rejects it. // target's, and rejects it. TLSA lookups hit this too: a TLSA name that is
// a CNAME to the zone apex, with no TLSA there, held mail to it for a week.
// //
// When hickory says bogus, check the answer again with lookups it gets right. // When hickory says bogus, check the answer again with lookups it gets right.
// A signed CNAME is followed and the lookup repeated at its target. Otherwise // A signed CNAME is followed and the lookup repeated at its target. Otherwise
@@ -869,4 +877,56 @@ mod tests {
assert!(validated.insecure); assert!(validated.insecure);
assert!(!validated.lookup.answers().is_empty()); assert!(!validated.lookup.answers().is_empty());
} }
// Needs the network: a TLSA name that is a signed CNAME to the zone apex,
// which has no TLSA record. Cloudflare's resolver answers with a compact
// denial at the name itself; Hetzner's (and others) follow the CNAME, and
// hickory then calls the answer bogus. Point the lookup at a resolver that
// follows it with INBUXA_TEST_DNS_TCP=<ip:port> (TCP), for instance over
// an SSH tunnel to 185.12.64.2:53 from a Hetzner host.
#[tokio::test]
#[ignore]
async fn validated_lookup_follows_signed_cname_for_tlsa() {
use mail_auth::hickory_resolver::{
config::{CLOUDFLARE, ConnectionConfig, NameServerConfig, ResolverConfig, ResolverOpts},
net::runtime::TokioRuntimeProvider,
};
let config = match std::env::var("INBUXA_TEST_DNS_TCP") {
Ok(addr) => {
let addr: std::net::SocketAddr = addr.parse().unwrap();
let mut ns = NameServerConfig::new(addr.ip(), true, vec![ConnectionConfig::tcp()]);
if let Some(c) = ns.connections.first_mut() {
c.port = addr.port();
} }
ResolverConfig::from_parts(None, vec![], vec![ns])
}
Err(_) => ResolverConfig::udp_and_tcp(&CLOUDFLARE),
};
let build = |validate: bool| {
let mut opts = ResolverOpts::default();
opts.validate = validate;
opts.num_concurrent_reqs = 1;
opts.cache_size = 0;
TokioResolver::builder_with_config(config.clone(), TokioRuntimeProvider::default())
.with_options(opts)
.build()
.unwrap()
};
let (dnssec, plain) = (build(true), build(false));
let query = name("_25._tcp.mail.usefulinsight.com.");
let direct = dnssec.lookup(query.clone(), RecordType::TLSA).await;
eprintln!("hickory alone: {:?}", direct.as_ref().err().map(|e| e.to_string()));
let err = match validated_lookup(&dnssec, &plain, query, RecordType::TLSA).await {
Ok(validated) => panic!("expected no TLSA record, got {:?}", validated.lookup.answers()),
Err(err) => err,
};
let denial = NegativeAnswer::from_error(&err).expect("a denial of existence");
assert_eq!(denial.response_code, ResponseCode::NoError);
assert_ne!(denial.dnssec_status, DnssecStatus::Bogus);
}
}
+1 -1
View File
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
#[macro_export] #[macro_export]
macro_rules! brand_version { macro_rules! brand_version {
() => { () => {
"2026.9.29.1" "2026.10.6"
}; };
} }
+15
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::DocumentId; use crate::DocumentId;
@@ -36,6 +38,13 @@ impl FromStr for Id {
type Err = (); type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
// inbuxa: an empty id is not id 0. RFC 8620 §1.2 ids are 1 to 255
// characters, and "" would otherwise name each collection's first
// document: `mailboxIds: {"": true}` filed a message in the Inbox.
if s.is_empty() {
return Err(());
}
let mut id = 0; let mut id = 0;
for &ch in s.as_bytes() { for &ch in s.as_bytes() {
@@ -261,4 +270,10 @@ mod tests {
Id::from_str("p333333333333p333333333333").unwrap(); Id::from_str("p333333333333p333333333333").unwrap();
} }
#[test]
fn empty_jmap_id_is_refused() {
assert!(Id::from_str("").is_err());
assert_eq!(Id::from_str("a").unwrap(), Id::from(0u64));
}
} }
+6
View File
@@ -108,6 +108,12 @@ impl SnowflakeIdGenerator {
(id >> (SEQUENCE_LEN + NODE_ID_LEN)) / 1000 + DEFAULT_EPOCH (id >> (SEQUENCE_LEN + NODE_ID_LEN)) / 1000 + DEFAULT_EPOCH
} }
// inbuxa: the node that made the id, so per-node history (metric
// totals) can be told apart
pub fn to_node_id(id: u64) -> u64 {
id & NODE_ID_MASK
}
#[inline(always)] #[inline(always)]
pub fn past_id(&self, period: Duration) -> Option<u64> { pub fn past_id(&self, period: Duration) -> Option<u64> {
self.epoch.elapsed().ok().map(|elapsed| { self.epoch.elapsed().ok().map(|elapsed| {
+25 -12
View File
@@ -209,9 +209,9 @@ depends on `store` and can't be called from it (`features/scale-out-storage.md`)
- No "Stalwart" in product names, binaries, images, UI text, packaging or - No "Stalwart" in product names, binaries, images, UI text, packaging or
domains. domains.
- Factual statements are allowed and required: "a fork of Stalwart", - Factual statements are allowed and required: "started as a fork of
"compatible with Stalwart 0.16 data". Upstream copyright notices stay on Stalwart", "compatible with Stalwart 0.16 data". Upstream copyright notices
every file they cover. stay on every file they cover.
- **Identifiers people meet carry the fork's name** (changed 2026-09-22; - **Identifiers people meet carry the fork's name** (changed 2026-09-22;
this bullet used to keep upstream's). Upstream's JMAP capability for the this bullet used to keep upstream's). Upstream's JMAP capability for the
registry (`x:`) objects is `urn:inbuxa:jmap:registry`, beside the fork's registry (`x:`) objects is `urn:inbuxa:jmap:registry`, beside the fork's
@@ -242,14 +242,25 @@ depends on `store` and can't be called from it (`features/scale-out-storage.md`)
- **Public material names it once, as fact, with the mark attributed** (added - **Public material names it once, as fact, with the mark attributed** (added
2026-09-19). Where the name appears outside the product — the site, release 2026-09-19). Where the name appears outside the product — the site, release
announcements, documentation — it carries the attribution: Stalwart is a announcements, documentation — it carries the attribution: Stalwart is a
trademark of Stalwart Labs LLC, and INBUXA is not affiliated with or trademark of Stalwart Labs LLC, and inbuxa is not affiliated with or
endorsed by them. The fork relationship is stated in the provenance or endorsed by them. The lineage is told in the past tense: inbuxa *started as*
license section, and the migration path names the server it migrates from, a fork of Stalwart and its server *descends from* it; never "built on
because an operator searching for it has to find it. The base *version* Stalwart" or "Stalwart with extras". It is told once, in the license or
belongs with the operator-facing material above — not in taglines, page about section, and the clean-room provenance lives on one documentation
titles, hero copy or social previews, where it reads as a source identifier page that everything else links to. The migration path names the server it
rather than a fact. No comparison, favorable or otherwise: what INBUXA migrates from, because an operator searching for it has to find it. The
offers is stated on its own terms. base *version* belongs with the operator-facing material above — not in
taglines, page titles, hero copy or social previews, where it reads as a
source identifier rather than a fact.
- **Comparisons: other products yes, Stalwart no** (changed 2026-10-05; this
bullet used to forbid comparison of any kind). Public material may compare
inbuxa with the hosted suites organizations choose between and with other
self-hosted mail stacks, when the comparison is factual, dated, names no
price, and says when the other choice is the better one. Self-hosted peers
are treated with respect. Stalwart is never compared: no editions, no
pricing, no commentary on Stalwart Labs or other forks. The rebuilt features
are described on their own merits, never as the features someone else
charges for.
### 2.5 Packaging ### 2.5 Packaging
@@ -364,6 +375,8 @@ is written.
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. 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.
Not a rebuild: the **deliverability check** is INBUXA's own design (inbuxa-drafts `specs/deliverability.md`). Each sending node checks what other servers see of it (blocklists, reverse DNS, SPF, DKIM, DMARC, MTA-STS, certificates) and keeps a report: `inbuxa:DeliverabilityReport` and `inbuxa:DeliverabilitySettings` (`crates/jmap/src/inbuxa/deliverability.rs`, `crates/services/src/inbuxa_deliverability.rs`), and the `sysDeliverabilityGet`, `sysDeliverabilityUpdate` and `sysDeliverabilityCheck` permissions.
## 5. The web front ends ## 5. The web front ends
**Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs, **Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs,
@@ -618,7 +631,7 @@ the tenant administrators' own view is not yet recorded
## 8. Open decisions ## 8. Open decisions
- The INBUXA fork of ihasmail is **ihasmail-inbuxa** (named 2026-09-18). Open: its repository, and how it tracks - The INBUXA fork of ihasmail is **inbuxa-webmail** (named ihasmail-inbuxa on 2026-09-18, renamed 2026-10-05). Open: how it tracks
public ihasmail (§5). public ihasmail (§5).
- Product name: whether the shipped product is called inbuxa-server or - Product name: whether the shipped product is called inbuxa-server or
something else inside the INBUXA brand. something else inside the INBUXA brand.
+75 -17
View File
@@ -7,11 +7,11 @@ Status: draft, 2026-09-18. Expands SPEC.md §5.2.
| Party | What it is | How it reaches the server | | Party | What it is | How it reaches the server |
|---|---|---| |---|---|---|
| **inbuxa-server** | The mail server | — | | **inbuxa-server** | The mail server | — |
| **ihasmail-inbuxa** | The INBUXA fork of ihasmail: a Node server and a web app. Public ihasmail stays Stalwart-facing and isn't party to this (SPEC.md §5) | Its **Node server** calls inbuxa-server, server to server. The browser only ever talks to ihasmail-inbuxa | | **inbuxa-webmail** | The INBUXA fork of ihasmail: a Node server and a web app. Public ihasmail stays Stalwart-facing and isn't party to this (SPEC.md §5) | Its **Node server** calls inbuxa-server, server to server. The browser only ever talks to inbuxa-webmail |
| **INBUXA Admin** (`inbuxa-admin`) | A static web app, a fork of Stalwart WebUI | The **browser** calls inbuxa-server directly, cross-origin | | **INBUXA Admin** (`inbuxa-admin`) | A static web app, a fork of Stalwart WebUI | The **browser** calls inbuxa-server directly, cross-origin |
That split decides most of what follows. Cross-origin rules matter only for That split decides most of what follows. Cross-origin rules matter only for
INBUXA Admin. Token custody matters most for ihasmail-inbuxa, which holds INBUXA Admin. Token custody matters most for inbuxa-webmail, which holds
tokens on its server for people who aren't there. tokens on its server for people who aren't there.
## What upstream does today ## What upstream does today
@@ -45,7 +45,7 @@ Observed in the source at `v0.16.22` and against a running inbuxa-server on
- **Endpoint gating:** `x:Http.allowedEndpoints` is an expression that can - **Endpoint gating:** `x:Http.allowedEndpoints` is an expression that can
refuse endpoints by path and client IP. JMAP administration shares `/jmap` refuse endpoints by path and client IP. JMAP administration shares `/jmap`
with everything else, so it can't separate admin calls on its own. with everything else, so it can't separate admin calls on its own.
- **ihasmail today** (public, and so the starting point for ihasmail-inbuxa) - **ihasmail today** (public, and so the starting point for inbuxa-webmail)
signs in with HTTP Basic auth and keeps the password sealed in its session signs in with HTTP Basic auth and keeps the password sealed in its session
store (`sealedCredentials: {username, password}`), sending it on every store (`sealedCredentials: {username, password}`), sending it on every
upstream call. It registers JMAP push subscriptions to its own URL, and reads upstream call. It registers JMAP push subscriptions to its own URL, and reads
@@ -76,7 +76,7 @@ Each has an ID, and tests name the IDs they check.
(LP-19). Added 2026-09-21. (LP-19). Added 2026-09-21.
- **C-2.** Each front end states the contract versions it supports and checks - **C-2.** Each front end states the contract versions it supports and checks
`contract` after signing in. Outside its range it stops, with a message `contract` after signing in. Outside its range it stops, with a message
naming both versions. For ihasmail-inbuxa this replaces public ihasmail's naming both versions. For inbuxa-webmail this replaces public ihasmail's
"Stalwart 0.16 or later" check. "Stalwart 0.16 or later" check.
- **C-3.** A breaking change to anything in this document bumps `contract`. - **C-3.** A breaking change to anything in this document bumps `contract`.
Adding optional fields doesn't. Adding optional fields doesn't.
@@ -98,12 +98,26 @@ Each has an ID, and tests name the IDs they check.
deliberate differences from upstream (see "Security note"). An operator who deliberate differences from upstream (see "Security note"). An operator who
wants open dynamic registration for third-party apps can turn it back on; wants open dynamic registration for third-party apps can turn it back on;
C-9's consent page still names every non-first-party client. C-9's consent page still names every non-first-party client.
**`oAuthClientOverride` only in bootstrap and recovery mode** (2026-09-29).
Upstream lets an account holding it skip the client and redirect URI checks
on the sign-in page, at the code exchange and in the device flow.
Administrators hold it, so a link naming a made-up client and an attacker's
redirect URI handed an administrator's code, and then a token, to the
attacker: registration protected everyone except the accounts most worth
phishing. Now the permission counts only in bootstrap and recovery mode,
where the recovery administrator signs in before any client is registered.
An administrator otherwise signs in like anyone else, through a registered
client and one of its redirect URIs. INBUXA's production server was checked
first: its front ends' clients are registered with the redirect URIs they
use (C-6). Released in 2026.9.29.1. Checked by `tests/e2e/client_override.py` against the debug
build, with the same script failing against the build before the change.
- **C-6.** Two first-party clients are registered as `x:OAuthClient` whenever - **C-6.** Two first-party clients are registered as `x:OAuthClient` whenever
`x:FrontEnds` is set or changed: `x:FrontEnds` is set or changed:
- **`inbuxa-admin`**: a public client (no secret), authorization code with - **`inbuxa-admin`**: a public client (no secret), authorization code with
PKCE S256, redirect URI `{adminUrl}/oauth/callback`. PKCE S256, redirect URI `{adminUrl}/oauth/callback`.
- **`ihasmail-inbuxa`**: a confidential client with a secret held by the - **`ihasmail-inbuxa`**: a confidential client with a secret held by the
ihasmail-inbuxa server, authorization code with PKCE S256, redirect URI inbuxa-webmail server, authorization code with PKCE S256, redirect URI
`{webmailUrl}/api/auth/callback`. `{webmailUrl}/api/auth/callback`.
INBUXA Admin's `<meta name="oauth-client-id">` is set to `inbuxa-admin`. INBUXA Admin's `<meta name="oauth-client-id">` is set to `inbuxa-admin`.
Served by the server itself it uses the web interface's client, Served by the server itself it uses the web interface's client,
@@ -145,7 +159,7 @@ Each has an ID, and tests name the IDs they check.
- **C-8.** People sign in on **the server's own sign-in page** (`/login`, - **C-8.** People sign in on **the server's own sign-in page** (`/login`,
already INBUXA-branded), never on a front end's form. Two-factor happens already INBUXA-branded), never on a front end's form. Two-factor happens
there, on the page's existing one-time-code step. Front ends never see a there, on the page's existing one-time-code step. Front ends never see a
password. ihasmail-inbuxa's own sign-in form is retired in favor of a password. inbuxa-webmail's own sign-in form is retired in favor of a
redirect. redirect.
- **C-9.** **Consent for anything that isn't first-party.** When a client other - **C-9.** **Consent for anything that isn't first-party.** When a client other
than the two first-party ones asks to sign someone in, the sign-in page names than the two first-party ones asks to sign someone in, the sign-in page names
@@ -156,10 +170,10 @@ Each has an ID, and tests name the IDs they check.
### Tokens ### Tokens
- **C-10.** ihasmail-inbuxa holds tokens, never passwords. It keeps the access - **C-10.** inbuxa-webmail holds tokens, never passwords. It keeps the access
and refresh token for each session sealed in its session store, where it now and refresh token for each session sealed in its session store, where it now
keeps sealed credentials, and refreshes the access token before it expires. keeps sealed credentials, and refreshes the access token before it expires.
The browser still holds only ihasmail-inbuxa's own session cookie. Public The browser still holds only inbuxa-webmail's own session cookie. Public
ihasmail's "the browser never holds a credential" property is kept. ihasmail's "the browser never holds a credential" property is kept.
- **C-11.** INBUXA Admin holds its tokens in the browser, as upstream WebUI - **C-11.** INBUXA Admin holds its tokens in the browser, as upstream WebUI
does, since it has no server of its own. So admin tokens are short-lived does, since it has no server of its own. So admin tokens are short-lived
@@ -179,9 +193,9 @@ Each has an ID, and tests name the IDs they check.
A revoked token stops working on its next use, and never later than one A revoked token stops working on its next use, and never later than one
access-token lifetime. access-token lifetime.
- **C-13.** Grants are listed per account (client, device description, created, - **C-13.** Grants are listed per account (client, device description, created,
last used, IP), so ihasmail-inbuxa's "your sessions" screen shows server-side last used, IP), so inbuxa-webmail's "your sessions" screen shows server-side
truth. Lifetimes, all configurable: access tokens 1 hour and refresh 30 days truth. Lifetimes, all configurable: access tokens 1 hour and refresh 30 days
for ihasmail-inbuxa; access tokens 15 minutes and refresh 8 hours for for inbuxa-webmail; access tokens 15 minutes and refresh 8 hours for
`inbuxa-admin`. `inbuxa-admin`.
### Cross-origin ### Cross-origin
@@ -223,7 +237,7 @@ Each has an ID, and tests name the IDs they check.
scope `inbuxa:admin`, which only that client is ever granted. An admin scope `inbuxa:admin`, which only that client is ever granted. An admin
account signing in through a mail client can't administer the server with account signing in through a mail client can't administer the server with
that token, even though the account could. that token, even though the account could.
- **C-19.** ihasmail-inbuxa's own administration (accounts, domains, groups, - **C-19.** inbuxa-webmail's own administration (accounts, domains, groups,
lists, roles, tenants, the dashboard) uses the scope `inbuxa:account-admin`, lists, roles, tenants, the dashboard) uses the scope `inbuxa:account-admin`,
granted only to `ihasmail-inbuxa`, and limited to those object types, plus granted only to `ihasmail-inbuxa`, and limited to those object types, plus
`x:Metric` get and query for the dashboard's message cards (monitoring `x:Metric` get and query for the dashboard's message cards (monitoring
@@ -236,18 +250,62 @@ Each has an ID, and tests name the IDs they check.
### Push ### Push
- **C-22.** Unchanged from public ihasmail: ihasmail-inbuxa registers JMAP push - **C-22.** Unchanged from public ihasmail: inbuxa-webmail registers JMAP push
subscriptions to its own URL, with VAPID for browser notifications. The only subscriptions to its own URL, with VAPID for browser notifications. The only
difference is that it authenticates with its token rather than the password. difference is that it authenticates with its token rather than the password.
### Passwords over HTTP
- **C-23.** **Outside DAV, HTTP sign-in is a token, never a password.** JMAP
(`/jmap`, with session, upload, download, event source and WebSocket), the
management API (`/api`), and the OAuth endpoints that authenticate a user
(`/auth/introspect`, `/auth/userinfo`, authenticated `/auth/register`)
refuse an `Authorization: Basic` header with a 401 whose only challenge is
`Bearer`, and don't check the password. CalDAV and CardDAV (`/dav`) keep
Basic, since that's how calendar and contacts apps sign in, and their 401s
still offer it. The sign-in page's own endpoint (`/api/auth`) takes the
password in its body, not a header, and isn't affected. Neither is the token
endpoint's client authentication. SCIM already takes an API key only.
Bootstrap and recovery mode accept Basic everywhere, as they keep
permissive CORS (C-16).
**Decision**: without this, anyone can put up a copy of a front end on a
server of their own that collects a person's password and replays it as
Basic. Cross-origin rules (C-14) don't stop that, because a server isn't a
browser, and neither does client registration (C-5), because Basic never
goes through OAuth. With C-23, the password only goes to the server's own
sign-in page (C-8), or to a DAV client or mail app the person set up
themselves.
An operator who needs Basic on every endpoint sets
`INBUXA_HTTP_BASIC_AUTH=all`; `dav`, the default, is this rule. Any other
value logs a warning and keeps the default. The setting moves to the
registry with `x:FrontEnds` (C-4).
inbuxa-webmail confirms a typed password, which it does before creating
an app password, on `/api/auth` as its own client, to its registered
redirect URI, with a PKCE challenge whose verifier it discards. A
"two-factor code needed" answer counts as confirmed, since the server gives
it only after the password matched.
**Built, 2026-09-29.** `crates/http/src/auth/token_only.rs` names the
paths; `request.rs` refuses before routing and picks the 401's challenge by
path; `Http.basic_auth_everywhere` holds the setting. Test builds
(`test_mode`) accept Basic everywhere, since the integration suites sign in
with passwords. Checked by `tests/e2e/http_basic_auth.py` against the debug
build, 26 checks: everything above, both front ends' sign-in path, a wrong
password answered exactly as the right one, and a redirect URI the webmail
didn't register refused.
Observed before the change, in INBUXA's production logs from 2026-09-20 to 2026-09-29:
every HTTPS password sign-in was the operator's own, apart from
inbuxa-webmail's password sign-in on 2026-09-22, before it moved to OAuth.
The logs don't say whether a sign-in used a Basic header or the sign-in
page.
## First boot ## First boot
1. The installer, or INBUXA Admin's setup wizard, completes bootstrap 1. The installer, or INBUXA Admin's setup wizard, completes bootstrap
(SPEC.md §6.2). In bootstrap mode, CORS is permissive (C-16) and the (SPEC.md §6.2). In bootstrap mode, CORS is permissive (C-16) and the
recovery administrator applies. recovery administrator applies.
2. It sets `x:FrontEnds` (webmail and admin URLs, the public URL), which 2. It sets `x:FrontEnds` (webmail and admin URLs, the public URL), which
registers both first-party clients (C-6). For ihasmail-inbuxa it returns the registers both first-party clients (C-6). For inbuxa-webmail it returns the
client secret once, for the installer to write into ihasmail-inbuxa's client secret once, for the installer to write into inbuxa-webmail's
environment. environment.
3. After the restart out of bootstrap, CORS follows C-14, registration is 3. After the restart out of bootstrap, CORS follows C-14, registration is
required (C-5), and the recovery administrator is ignored (SPEC.md §6.2). required (C-5), and the recovery administrator is ignored (SPEC.md §6.2).
@@ -318,7 +376,7 @@ client and reload settings. This is the state C-5 and C-6 make the default.
5. The phishing flow in the security note fails at step 1, and a registered 5. The phishing flow in the security note fails at step 1, and a registered
third-party client with a non-first-party redirect shows the consent page third-party client with a non-first-party redirect shows the consent page
(C-9). (C-9).
6. ihasmail-inbuxa signs in without ever handling a password. Its session 6. inbuxa-webmail signs in without ever handling a password. Its session
store holds tokens only (C-8, C-10). store holds tokens only (C-8, C-10).
7. Revoking one grant stops that session within one access-token lifetime, 7. Revoking one grant stops that session within one access-token lifetime,
leaves others working, and "sign out other sessions" keeps the current one leaves others working, and "sign out other sessions" keeps the current one
@@ -330,7 +388,7 @@ client and reload settings. This is the state C-5 and C-6 make the default.
10. In bootstrap mode, INBUXA Admin reaches the server from any origin (C-16). 10. In bootstrap mode, INBUXA Admin reaches the server from any origin (C-16).
11. An admin account's token from a third-party mail client can't read 11. An admin account's token from a third-party mail client can't read
`x:NetworkListener`. The same account through `inbuxa-admin` can (C-18). `x:NetworkListener`. The same account through `inbuxa-admin` can (C-18).
12. ihasmail-inbuxa's token can manage accounts and tenants but not listeners 12. inbuxa-webmail's token can manage accounts and tenants but not listeners
or certificates (C-19). or certificates (C-19).
13. With `adminNetworks` set, an `inbuxa:admin` request from outside is refused 13. With `adminNetworks` set, an `inbuxa:admin` request from outside is refused
(C-20). (C-20).
@@ -344,4 +402,4 @@ client and reload settings. This is the state C-5 and C-6 make the default.
2. The consent page's wording and whether it remembers a decision per client. 2. The consent page's wording and whether it remembers a decision per client.
3. API keys and app passwords with explicit scopes (C-21): what upstream's 3. API keys and app passwords with explicit scopes (C-21): what upstream's
`x:ApiKey` already supports, to observe before specifying. `x:ApiKey` already supports, to observe before specifying.
4. Whether ihasmail-inbuxa's secret should rotate, and how. 4. Whether inbuxa-webmail's secret should rotate, and how.
+1 -1
View File
@@ -584,7 +584,7 @@ Three consequences:
name whose DNS points at the mail addresses. name whose DNS points at the mail addresses.
- Whether the front ends need anything at cutover, or follow separately - Whether the front ends need anything at cutover, or follow separately
(SPEC.md §5). The rehearsal does not start them. (SPEC.md §5). The rehearsal does not start them.
- Whether ihasmail-inbuxa and INBUXA Admin behave under real use, rather - Whether inbuxa-webmail and INBUXA Admin behave under real use, rather
than at first sign-in. Both were verified as far as signing in and, for the than at first sign-in. Both were verified as far as signing in and, for the
webmail, mail flowing. webmail, mail flowing.
- The checks only users can make: the second account, the mailbox comparison - The checks only users can make: the second account, the mailbox comparison
+1 -1
View File
@@ -353,7 +353,7 @@ adds at most 2.
## ihasmail changes ## ihasmail changes
These go in the INBUXA fork of ihasmail, ihasmail-inbuxa, never in public These go in the INBUXA fork of ihasmail, inbuxa-webmail, never in public
ihasmail, which stays Stalwart-facing (SPEC.md §5). ihasmail, which stays Stalwart-facing (SPEC.md §5).
- **Reading:** when a message has an `X-Spam-LLM` header, the message details - **Reading:** when a message has an `X-Spam-LLM` header, the message details
+2 -2
View File
@@ -275,7 +275,7 @@ Each requirement has an ID, and tests name the IDs they check.
## ihasmail changes ## ihasmail changes
These go in the INBUXA fork of ihasmail, ihasmail-inbuxa, never in public These go in the INBUXA fork of ihasmail, inbuxa-webmail, never in public
ihasmail, which stays Stalwart-facing (SPEC.md §5). ihasmail, which stays Stalwart-facing (SPEC.md §5).
- **Administration, Domains:** a logo field on each domain. Upload a PNG, JPEG - **Administration, Domains:** a logo field on each domain. Upload a PNG, JPEG
@@ -389,7 +389,7 @@ files carry hooks marked `inbuxa:`. Acceptance tests 1 to 17 pass as
`tests/src/system/branding.rs`. `tests/src/system/branding.rs`.
- **BT-1 to BT-26:** built. - **BT-1 to BT-26:** built.
- **ihasmail changes** belong to ihasmail-inbuxa and aren't part of this - **ihasmail changes** belong to inbuxa-webmail and aren't part of this
repository. repository.
- **Test 18 (compat)** is written as `branding_compat`, ignored, and unrun - **Test 18 (compat)** is written as `branding_compat`, ignored, and unrun
until a copy of INBUXA's data is provided. INBUXA holds no logos or until a copy of INBUXA's data is provided. INBUXA holds no logos or
@@ -14,7 +14,7 @@ Written for the record SPEC.md §3 rule 3 asks for. Sources, and nothing else:
|---|---|---| |---|---|---|
| This repository at `0502eb4` (2026-09-28): `crates/smtp/src/inbound/data.rs`, `crates/smtp/src/queue/`, `crates/jmap/src/submission/set.rs`, `crates/common/src/scripts/`, `vendor/sieve-rs`, `resources/schema/schema.json.gz` | AGPL-3.0-only | Where a check can run, what the queue stores, what a sender sees on a refusal | | This repository at `0502eb4` (2026-09-28): `crates/smtp/src/inbound/data.rs`, `crates/smtp/src/queue/`, `crates/jmap/src/submission/set.rs`, `crates/common/src/scripts/`, `vendor/sieve-rs`, `resources/schema/schema.json.gz` | AGPL-3.0-only | Where a check can run, what the queue stores, what a sender sees on a refusal |
| inbuxa-admin at `b82904c` | AGPL-3.0-only | Where the pages go | | inbuxa-admin at `b82904c` | AGPL-3.0-only | Where the pages go |
| ihasmail-inbuxa (the webmail) at `290bc63` | AGPL-3.0-or-later | How a refused send reaches the person sending | | inbuxa-webmail (the webmail) at `290bc63` | AGPL-3.0-or-later | How a refused send reaches the person sending |
| `inbuxa-drafts/queue/dlp.md`, `rule-builder.md` | Own | What John asked for and settled | | `inbuxa-drafts/queue/dlp.md`, `rule-builder.md` | Own | What John asked for and settled |
| The personal-data catalog spec and the audit-hold-lock spec | Own | Roles, the audit log, legal holds, the catalog check | | The personal-data catalog spec and the audit-hold-lock spec | Own | Roles, the audit log, legal holds, the catalog check |
| RFC 5321, RFC 3463 (enhanced status codes), RFC 8620/8621 (JMAP) | Public | Refusal codes and the submission error shape | | RFC 5321, RFC 3463 (enhanced status codes), RFC 8620/8621 (JMAP) | Public | Refusal codes and the submission error shape |
@@ -385,7 +385,7 @@ first). The inspection limit caps the worst case.
Every form previews the rule in words ("If a recipient is outside and the Every form previews the rule in words ("If a recipient is outside and the
message contains 5 or more card numbers, hold it for review"). message contains 5 or more card numbers, hold it for review").
## 4. Webmail (ihasmail-inbuxa) ## 4. Webmail (inbuxa-webmail)
- A warning dialog: the notice, a reason field, **Send anyway** and **Edit - A warning dialog: the notice, a reason field, **Send anyway** and **Edit
message**. message**.
+1 -1
View File
@@ -291,7 +291,7 @@ upstream files carry hooks marked `inbuxa:`. Acceptance tests 1 to 11 pass as
`createdBy`. The server-set name is **deferred** until sign-in goes through `createdBy`. The server-set name is **deferred** until sign-in goes through
OAuth (contract C-8): with Basic auth there's no client name, so a mask OAuth (contract C-8): with Basic auth there's no client name, so a mask
created through the Fastmail API has none. created through the Fastmail API has none.
- **ihasmail changes** belong to ihasmail-inbuxa and aren't part of this - **ihasmail changes** belong to inbuxa-webmail and aren't part of this
repository. repository.
- **Test 12 (compat)** is written as `masked_email_compat`, ignored, and unrun - **Test 12 (compat)** is written as `masked_email_compat`, ignored, and unrun
until a copy of INBUXA's data with masks made on it is provided. Its doc until a copy of INBUXA's data with masks made on it is provided. Its doc
+1 -1
View File
@@ -411,7 +411,7 @@ unchanged.
## ihasmail changes ## ihasmail changes
These go in ihasmail-inbuxa, not public ihasmail, which stays Stalwart-facing These go in inbuxa-webmail, not public ihasmail, which stays Stalwart-facing
(SPEC.md §5). (SPEC.md §5).
- The dashboard already reads `x:Metric` for received, sent and memory. Keep - The dashboard already reads `x:Metric` for received, sent and memory. Keep
+2 -2
View File
@@ -336,9 +336,9 @@ called from `system_tests` with no gate.
- **MT-1 to MT-18, MT-20 to MT-23:** built. - **MT-1 to MT-18, MT-20 to MT-23:** built.
- **MT-19, MT-19a:** built, except the submission-time warning, **deferred** - **MT-19, MT-19a:** built, except the submission-time warning, **deferred**
(see MT-19a) until the contract defines a warnings shape. (see MT-19a) until the contract defines a warnings shape.
- **ihasmail changes** (the section above) belong to ihasmail-inbuxa and - **ihasmail changes** (the section above) belong to inbuxa-webmail and
aren't part of this repository. Its branding and quota warnings wait for aren't part of this repository. Its branding and quota warnings wait for
ihasmail-inbuxa. inbuxa-webmail.
- **Test 15 (compat)** is written as `tenant_compat`, ignored, and unrun until - **Test 15 (compat)** is written as `tenant_compat`, ignored, and unrun until
a copy of INBUXA's data is provided. Its doc comment says how to run it. a copy of INBUXA's data is provided. Its doc comment says how to run it.
- **Known limits, not requirements of this spec:** - **Known limits, not requirements of this spec:**
+1 -1
View File
@@ -412,7 +412,7 @@ check it and the fork keeps it.
## ihasmail changes ## ihasmail changes
These go in ihasmail-inbuxa, the INBUXA fork of ihasmail, never in public These go in inbuxa-webmail, the INBUXA fork of ihasmail, never in public
ihasmail, which stays Stalwart-facing (SPEC.md §5). ihasmail, which stays Stalwart-facing (SPEC.md §5).
- **Domain editor:** a directory picker offering "server default" and the - **Domain editor:** a directory picker offering "server default" and the
+1 -1
View File
@@ -679,7 +679,7 @@ SCIM error documents (RFC 7644 §3.12): `schemas`
## ihasmail changes ## ihasmail changes
These go in ihasmail-inbuxa, not public ihasmail, which stays These go in inbuxa-webmail, not public ihasmail, which stays
Stalwart-facing (SPEC.md §5). Stalwart-facing (SPEC.md §5).
- **Domains:** an "Allow SCIM provisioning" switch on the domain form. Turning - **Domains:** an "Allow SCIM provisioning" switch on the domain form. Turning
+1 -1
View File
@@ -276,7 +276,7 @@ marked `inbuxa:`. Acceptance tests 1 to 15 pass as
`tests/src/system/undelete.rs`, with `/changes` and the `/query` filters. `tests/src/system/undelete.rs`, with `/changes` and the `/query` filters.
- **UD-1 to UD-17a:** built. - **UD-1 to UD-17a:** built.
- **ihasmail changes** belong to ihasmail-inbuxa and aren't part of this - **ihasmail changes** belong to inbuxa-webmail and aren't part of this
repository. repository.
- **Test 16 (compat)** is written as `undelete_compat`, ignored, and unrun - **Test 16 (compat)** is written as `undelete_compat`, ignored, and unrun
until a copy of INBUXA's data with archived items made on it is provided. until a copy of INBUXA's data with archived items made on it is provided.
+1 -1
View File
@@ -103,7 +103,7 @@ conflicts.
1. Every requirement MT-1 to MT-23 is implemented, or deliberately deferred 1. Every requirement MT-1 to MT-23 is implemented, or deliberately deferred
with a line in the spec saying so. The branding and quota warnings for with a line in the spec saying so. The branding and quota warnings for
ihasmail can wait for ihasmail-inbuxa. ihasmail can wait for inbuxa-webmail.
2. Acceptance tests 1 to 14 pass as integration tests 2. Acceptance tests 1 to 14 pass as integration tests
(`tests/src/system/tenant.rs`), with the `pending-rebuild` gate removed (`tests/src/system/tenant.rs`), with the `pending-rebuild` gate removed
from the tenant call. from the tenant call.
+22
View File
@@ -166,6 +166,18 @@ reason = ["content"]
file = "inbuxa_journal_entry.rs" file = "inbuxa_journal_entry.rs"
default = "none" default = "none"
# The deliverability check (deliverability spec): facts about the server's own
# addresses, names and domains. The blocklists it asks see those addresses and
# domains, as they would whenever anyone checks mail from the server; nothing
# about people is sent or kept.
[object."inbuxa:DeliverabilityReport"]
file = "inbuxa_deliverability_report.rs"
default = "none"
[object."inbuxa:DeliverabilitySettings"]
file = "inbuxa_deliverability_settings.rs"
default = "none"
[object."inbuxa:LegalHold"] [object."inbuxa:LegalHold"]
file = "inbuxa_legal_hold.rs" file = "inbuxa_legal_hold.rs"
default = "none" default = "none"
@@ -244,6 +256,16 @@ retention = "unbounded"
changedBy = ["identifier"] changedBy = ["identifier"]
recentLegacyUse = ["identifier", "metadata"] recentLegacyUse = ["identifier", "metadata"]
[object."inbuxa:SharingPolicy"]
file = "inbuxa_sharing_policy.rs"
default = "none"
whose = ["administrator", "holder"]
where = ["data-store"]
scope = "tenant"
retention = "unbounded"
[object."inbuxa:SharingPolicy".properties]
changedBy = ["identifier"]
[object."inbuxa:AiLimits"] [object."inbuxa:AiLimits"]
file = "inbuxa_ai_limits.rs" file = "inbuxa_ai_limits.rs"
default = "none" default = "none"
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
D8e0s1e4Umau4gRh5MEW24KtsawKGPNvS-6LWrIFbtQ OUcXqvEO48gT6mdw9zVPWfZ-QfMKFj5xvxa-0iE4L9U
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""Local end-to-end check that an administrator gets no OAuth client bypass
outside bootstrap and recovery mode (contract C-5).
Run it with `python3 tests/e2e/client_override.py` after
`cargo build -p inbuxa`. Needs Docker. Working state goes under target/e2e.
Administrators hold OAuthClientOverride. Upstream lets it skip the client and
redirect URI checks everywhere, so a link naming a made-up client and an
attacker's redirect URI would hand an administrator's code to the attacker.
This boots the debug binary and checks that:
- in bootstrap mode, the recovery administrator still signs in through an
unregistered client, as the setup wizard needs;
- after setup, an administrator gets no code for an unregistered client, nor
for a registered one with a redirect URI it didn't register, while the
registered client and URI still work end to end;
- a device code an administrator approves for an unregistered client can't be
exchanged for a token;
- in recovery mode, the bypass is back for the recovery administrator.
Passwords are generated into files under target/e2e and never printed.
Everything is removed afterwards unless KEEP=1.
"""
import base64, hashlib, json, os, secrets, shutil, subprocess, sys, time, urllib.error, urllib.parse, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DIR = f"{ROOT}/target/e2e"
NAME = "inbuxa-client-override"
PORT = 18195
HTTP = f"http://127.0.0.1:{PORT}"
ADMIN_URL = "http://admin.override.test"
REDIRECT = f"{ADMIN_URL}/oauth/callback"
EVIL = "https://evil.example/cb"
# Another build to check, such as one from before the change.
BINARY = os.environ.get("INBUXA_BINARY", f"{ROOT}/target/debug/inbuxa")
failures = []
def check(cond, what):
print(("ok " if cond else "FAIL ") + what)
if not cond:
failures.append(what)
def secret_file(name, value=None):
path = f"{DIR}/secrets/{name}"
if value is None:
value = secrets.token_urlsafe(24)
with open(path, "w") as f:
f.write(value)
os.chmod(path, 0o600)
return value
def docker(*args, check_rc=True):
return subprocess.run(["docker", *args], capture_output=True, text=True, check=check_rc)
def start(env=None):
env_file = f"{DIR}/secrets/override-env"
with open(env_file, "w") as f:
for key, value in (env or {}).items():
f.write(f"{key}={value}\n")
os.chmod(env_file, 0o600)
docker("run", "-d", "--name", NAME, "--user", f"{os.getuid()}:{os.getgid()}",
"--entrypoint", "/usr/local/bin/inbuxa",
"-v", f"{BINARY}:/usr/local/bin/inbuxa:ro",
"-v", f"{DIR}/etc-override:/etc/inbuxa", "-v", f"{DIR}/data-override:/var/lib/inbuxa",
"-p", f"127.0.0.1:{PORT}:8080",
# A debug build's workers need more than the default stack.
"-e", "RUST_MIN_STACK=16777216",
# Registers inbuxa-admin, the one client this server knows (C-6).
"-e", f"INBUXA_ADMIN_URL={ADMIN_URL}",
"--env-file", env_file,
"stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json")
for _ in range(120):
try:
urllib.request.urlopen(f"{HTTP}/.well-known/jmap", timeout=2)
except urllib.error.HTTPError:
return
except Exception:
time.sleep(1)
continue
return
sys.exit("server didn't come up: " + docker("logs", "--tail", "40", NAME, check_rc=False).stderr)
def stop():
docker("rm", "-f", NAME, check_rc=False)
def restart(env=None):
stop()
start(env)
def request(path, method="GET", body=None, content_type=None, authorization=None):
req = urllib.request.Request(f"{HTTP}{path}", data=body, method=method)
if content_type:
req.add_header("Content-Type", content_type)
if authorization:
req.add_header("Authorization", authorization)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as err:
return err.code, err.read()
def jmap(user, password, calls):
body = json.dumps({"using": ["urn:ietf:params:jmap:core", "urn:inbuxa:jmap:registry"],
"methodCalls": calls}).encode()
auth = "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
status, raw = request("/jmap/", "POST", body, "application/json", auth)
if status != 200:
sys.exit(f"JMAP call failed: {status}")
return json.loads(raw)["methodResponses"]
def pkce():
verifier = secrets.token_urlsafe(48)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
return verifier, challenge
def sign_in(user, password, client_id, redirect_uri, challenge):
"""The sign-in page's request: what an authorization link leads to."""
status, raw = request("/api/auth", "POST", json.dumps({
"type": "authCode", "accountName": user, "accountSecret": password,
"clientId": client_id, "redirectUri": redirect_uri,
"codeChallenge": challenge, "codeChallengeMethod": "S256"}).encode(), "application/json")
return json.loads(raw) if status == 200 else {"type": status}
def exchange(client_id, code, redirect_uri, verifier):
status, raw = request("/auth/token", "POST", urllib.parse.urlencode({
"grant_type": "authorization_code", "client_id": client_id, "code": code,
"redirect_uri": redirect_uri, "code_verifier": verifier}).encode(),
"application/x-www-form-urlencoded")
return status, json.loads(raw or b"{}")
def phished(user, password, client_id, redirect_uri):
"""Whether a link naming this client and redirect URI ends in a token."""
verifier, challenge = pkce()
answer = sign_in(user, password, client_id, redirect_uri, challenge)
if answer.get("type") != "authenticated":
return False, answer.get("type")
status, body = exchange(client_id, answer["client_code"], redirect_uri, verifier)
return status == 200 and "access_token" in body, f"code issued, exchange {status}"
def main():
stop()
for sub in ("etc-override", "data-override"):
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
for sub in ("etc-override", "data-override", "secrets"):
os.makedirs(f"{DIR}/{sub}", exist_ok=True)
os.chmod(f"{DIR}/secrets", 0o700)
# Bootstrap mode: the recovery administrator keeps the bypass.
recovery = secret_file("override-recovery")
start({"INBUXA_RECOVERY_ADMIN": f"admin:{recovery}"})
got, how = phished("admin", recovery, "setup-wizard", EVIL)
check(got, f"bootstrap mode: the recovery administrator signs in through an unregistered client ({how})")
got = jmap("admin", recovery, [["x:Bootstrap/get", {"ids": None}, "0"]])
singleton = got[0][1]["list"][0]["id"]
res = jmap("admin", recovery, [["x:Bootstrap/set", {"update": {singleton: {
"serverHostname": "mail.override.test", "defaultDomain": "override.test",
"requestTlsCertificate": False}}}, "0"]])
updated = res[0][1].get("updated", {}).get(singleton)
check(bool(updated), "bootstrap completed")
if not updated:
sys.exit(json.dumps(res))
admin, admin_pw = updated["username"], secret_file("override-admin", updated["secret"])
# After setup: no bypass for an administrator.
restart()
got, how = phished(admin, admin_pw, "inbuxa-admin", REDIRECT)
check(got, f"the registered client and redirect URI still sign an administrator in ({how})")
got, how = phished(admin, admin_pw, "evil-client", EVIL)
check(not got, f"an unregistered client gets nothing for an administrator ({how})")
got, how = phished(admin, admin_pw, "inbuxa-admin", EVIL)
check(not got, f"a registered client with a foreign redirect URI gets nothing ({how})")
# Device flow: the administrator approves a code a made-up client asked for.
status, raw = request("/auth/device", "POST", b"client_id=evil-device", "application/x-www-form-urlencoded")
device = json.loads(raw) if status == 200 else {}
check("device_code" in device, f"a device code is issued to anyone ({status})")
if "device_code" in device:
status, raw = request("/api/auth", "POST", json.dumps({
"type": "authDevice", "accountName": admin, "accountSecret": admin_pw,
"code": device["user_code"]}).encode(), "application/json")
print(" approval:", json.loads(raw).get("type") if status == 200 else status)
status, raw = request("/auth/token", "POST", urllib.parse.urlencode({
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"client_id": "evil-device", "device_code": device["device_code"]}).encode(),
"application/x-www-form-urlencoded")
body = json.loads(raw or b"{}")
check("access_token" not in body,
f"but an administrator's approval can't be exchanged for a token ({status}, {body.get('error')})")
# Recovery mode: the bypass is back, for the recovery administrator.
restart({"INBUXA_RECOVERY_MODE": "1", "INBUXA_RECOVERY_ADMIN": f"admin:{recovery}"})
got, how = phished("admin", recovery, "recovery-tool", EVIL)
check(got, f"recovery mode: the recovery administrator signs in through an unregistered client ({how})")
if os.environ.get("KEEP") != "1":
stop()
for sub in ("etc-override", "data-override"):
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
for name in ("override-recovery", "override-admin", "override-env"):
try:
os.remove(f"{DIR}/secrets/{name}")
except FileNotFoundError:
pass
print()
if failures:
print(f"{len(failures)} failed")
sys.exit(1)
print("all passed")
if __name__ == "__main__":
main()
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""Local end-to-end check of contract C-23: outside DAV, HTTP sign-in is a
token, never a password.
Run it with `python3 tests/e2e/http_basic_auth.py` after
`cargo build -p inbuxa`. Needs Docker. Working state goes under target/e2e.
Boots the debug binary and checks that:
- in bootstrap mode, Basic works on JMAP (as permissive CORS does, C-16);
- after setup, Basic is refused on JMAP, the API, userinfo and introspection,
with a 401 that offers only Bearer, and the password isn't checked;
- DAV still takes Basic, and its 401 still offers it;
- a token from the sign-in endpoint (`/api/auth`, the password in the body)
and the token endpoint works on JMAP: the path the front ends use, and the
one inbuxa-webmail's password check relies on;
- INBUXA_HTTP_BASIC_AUTH=all puts Basic back everywhere, an unknown value
keeps the default with a warning, and recovery mode accepts Basic.
Passwords are generated into files under target/e2e and never printed.
Everything is removed afterwards unless KEEP=1.
"""
import base64, hashlib, json, os, secrets, shutil, subprocess, sys, time, urllib.error, urllib.parse, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DIR = f"{ROOT}/target/e2e"
NAME = "inbuxa-basic-auth"
PORT = 18180
HTTP = f"http://127.0.0.1:{PORT}"
ADMIN_URL = "http://admin.basic.test"
REDIRECT = f"{ADMIN_URL}/oauth/callback"
WEBMAIL_URL = "http://webmail.basic.test"
WEBMAIL_REDIRECT = f"{WEBMAIL_URL}/api/auth/callback"
failures = []
WEBMAIL_SECRET = secrets.token_urlsafe(24)
def check(cond, what):
print(("ok " if cond else "FAIL ") + what)
if not cond:
failures.append(what)
def secret_file(name, value=None):
path = f"{DIR}/secrets/{name}"
if value is None:
value = secrets.token_urlsafe(24)
with open(path, "w") as f:
f.write(value)
os.chmod(path, 0o600)
return value
def docker(*args, check_rc=True):
return subprocess.run(["docker", *args], capture_output=True, text=True, check=check_rc)
def start(env=None):
args = ["run", "-d", "--name", NAME, "--user", f"{os.getuid()}:{os.getgid()}",
"--entrypoint", "/usr/local/bin/inbuxa",
"-v", f"{ROOT}/target/debug/inbuxa:/usr/local/bin/inbuxa:ro",
"-v", f"{DIR}/etc-basic:/etc/inbuxa", "-v", f"{DIR}/data-basic:/var/lib/inbuxa",
"-p", f"127.0.0.1:{PORT}:8080",
# A debug build's workers need more than the default stack.
"-e", "RUST_MIN_STACK=16777216",
# Registers inbuxa-admin and inbuxa-webmail (C-6).
"-e", f"INBUXA_ADMIN_URL={ADMIN_URL}", "-e", f"INBUXA_WEBMAIL_URL={WEBMAIL_URL}"]
env_file = f"{DIR}/secrets/basic-env"
with open(env_file, "w") as f:
f.write(f"INBUXA_WEBMAIL_CLIENT_SECRET={WEBMAIL_SECRET}\n")
for key, value in (env or {}).items():
f.write(f"{key}={value}\n")
os.chmod(env_file, 0o600)
args += ["--env-file", env_file, "stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
docker(*args)
for _ in range(120):
try:
urllib.request.urlopen(f"{HTTP}/.well-known/jmap", timeout=2)
except urllib.error.HTTPError:
return
except Exception:
time.sleep(1)
continue
return
sys.exit("server didn't come up: " + docker("logs", "--tail", "40", NAME, check_rc=False).stderr)
def stop():
docker("rm", "-f", NAME, check_rc=False)
def restart(env=None):
stop()
start(env)
def basic(user, password):
return "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
def request(path, authorization=None, method="GET", body=None, content_type=None, headers=None):
"""(status, headers, body) for a request, whatever the status."""
req = urllib.request.Request(f"{HTTP}{path}", data=body, method=method)
if authorization:
req.add_header("Authorization", authorization)
if content_type:
req.add_header("Content-Type", content_type)
for key, value in (headers or {}).items():
req.add_header(key, value)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.headers, resp.read()
except urllib.error.HTTPError as err:
return err.code, err.headers, err.read()
def challenges(headers):
return sorted(value.split(" ", 1)[0] for value in headers.get_all("WWW-Authenticate") or [])
def jmap(authorization, calls):
body = json.dumps({"using": ["urn:ietf:params:jmap:core", "urn:inbuxa:jmap:registry"],
"methodCalls": calls}).encode()
status, _, raw = request("/jmap/", authorization, "POST", body, "application/json")
if status != 200:
sys.exit(f"JMAP call failed: {status}")
return json.loads(raw)["methodResponses"]
def sign_in(user, password, client_id, redirect_uri, verifier):
"""What the sign-in endpoint answers, the password in the request body."""
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
status, _, raw = request("/api/auth", method="POST", content_type="application/json", body=json.dumps({
"type": "authCode", "accountName": user, "accountSecret": password,
"clientId": client_id, "redirectUri": redirect_uri,
"codeChallenge": challenge, "codeChallengeMethod": "S256"}).encode())
return json.loads(raw) if status == 200 else {"type": status}
def token(user, password):
"""An access token the way a front end gets one: the sign-in endpoint, then
the token endpoint, with PKCE."""
verifier = secrets.token_urlsafe(48)
answer = sign_in(user, password, "inbuxa-admin", REDIRECT, verifier)
if answer.get("type") != "authenticated":
return None, answer.get("type") or status
status, _, raw = request("/auth/token", method="POST", content_type="application/x-www-form-urlencoded",
body=urllib.parse.urlencode({
"grant_type": "authorization_code", "client_id": "inbuxa-admin",
"code": answer["client_code"], "redirect_uri": REDIRECT,
"code_verifier": verifier}).encode())
if status != 200:
return None, status
return json.loads(raw)["access_token"], "authenticated"
def propfind(path, authorization):
return request(path, authorization, "PROPFIND", b'<?xml version="1.0"?><propfind xmlns="DAV:"><prop><resourcetype/></prop></propfind>',
"application/xml", {"Depth": "0"})
def main():
stop()
for sub in ("etc-basic", "data-basic"):
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
for sub in ("etc-basic", "data-basic", "secrets"):
os.makedirs(f"{DIR}/{sub}", exist_ok=True)
os.chmod(f"{DIR}/secrets", 0o700)
# Bootstrap mode: Basic works on JMAP, as it must for the setup wizard.
recovery = secret_file("basic-recovery")
start({"INBUXA_RECOVERY_ADMIN": f"admin:{recovery}"})
status, _, _ = request("/jmap/session", basic("admin", recovery))
check(status == 200, "bootstrap mode: Basic works on JMAP")
got = jmap(basic("admin", recovery), [["x:Bootstrap/get", {"ids": None}, "0"]])
singleton = got[0][1]["list"][0]["id"]
res = jmap(basic("admin", recovery), [["x:Bootstrap/set", {"update": {singleton: {
"serverHostname": "mail.basic.test", "defaultDomain": "basic.test",
"requestTlsCertificate": False}}}, "0"]])
updated = res[0][1].get("updated", {}).get(singleton)
check(bool(updated), "bootstrap completed")
if not updated:
sys.exit(json.dumps(res))
admin, admin_pw = updated["username"], secret_file("basic-admin", updated["secret"])
# After setup, the default: Basic on DAV only. What follows needs a
# tracer to stdout, to read warnings back, and a user account for the
# webmail's password check. Both are made with a token, since Basic no
# longer reaches JMAP.
restart()
admin_token, how = token(admin, admin_pw)
if not admin_token:
sys.exit(f"no token for the administrator: {how}")
domain = jmap(f"Bearer {admin_token}", [["x:Domain/get", {"ids": None}, "0"]])[0][1]["list"][0]["id"]
user, user_pw = "[email protected]", secret_file("basic-user")
res = jmap(f"Bearer {admin_token}", [
["x:Tracer/set", {"create": {"t": {"@type": "Stdout", "level": "info", "buffered": False, "ansi": False}}}, "0"],
["x:Account/set", {"create": {"a": {"@type": "User", "name": "u", "domainId": domain,
"credentials": {"0": {"@type": "Password", "secret": user_pw}}}}}, "1"]])
if not (res[0][1].get("created") or {}).get("t") or not (res[1][1].get("created") or {}).get("a"):
sys.exit("setup failed: " + json.dumps(res))
restart()
right, wrong = basic(admin, admin_pw), basic(admin, "not-the-password")
status, headers, _ = request("/jmap/session", right)
check(status == 401, "Basic with the right password is refused on /jmap/session")
check(challenges(headers) == ["Bearer"], f"that 401 offers only Bearer ({challenges(headers)})")
status, _, _ = request("/jmap/", right, "POST", b'{"using":[],"methodCalls":[]}', "application/json")
check(status == 401, "Basic is refused on a JMAP API call")
status, headers, _ = request("/jmap/", None, "POST", b'{"using":[],"methodCalls":[]}', "application/json")
check(status == 401 and challenges(headers) == ["Bearer"],
f"an unauthenticated JMAP call's 401 offers only Bearer ({challenges(headers)})")
for path in ("/api/account", "/auth/userinfo"):
status, headers, _ = request(path, right)
check(status == 401 and challenges(headers) == ["Bearer"], f"Basic is refused on {path}")
status, _, _ = request("/auth/introspect", right, "POST", b"token=x", "application/x-www-form-urlencoded")
check(status == 401, "Basic is refused on /auth/introspect")
# Refused before the password is looked at, so the answer is the same
# either way and can't be used to guess one.
status_right, headers_right, body_right = request("/jmap/session", right)
status_wrong, headers_wrong, body_wrong = request("/jmap/session", wrong)
check((status_wrong, challenges(headers_wrong), body_wrong) == (status_right, challenges(headers_right), body_right),
"a wrong password over Basic gets exactly the same answer as the right one")
# DAV keeps Basic.
status, _, _ = propfind(f"/dav/card/{admin}/", right)
check(status == 207, f"Basic works on CardDAV ({status})")
status, _, _ = propfind(f"/dav/cal/{admin}/", right)
check(status == 207, f"Basic works on CalDAV ({status})")
status, headers, _ = propfind(f"/dav/card/{admin}/", None)
check(status == 401 and "Basic" in challenges(headers),
f"DAV's 401 still offers Basic ({challenges(headers)})")
# The front ends' path: the sign-in endpoint and a token.
access, how = token(admin, admin_pw)
check(access is not None, f"the sign-in endpoint takes the password in its body ({how})")
_, how_wrong = token(admin, "not-the-password")
check(how_wrong == "failure", f"and says failure for a wrong one ({how_wrong})")
if access:
status, _, _ = request("/jmap/session", f"Bearer {access}")
check(status == 200, "a token works on /jmap/session")
status, _, _ = request("/api/account", f"Bearer {access}")
check(status == 200, f"a token works on /api/account ({status})")
# inbuxa-webmail's password check before an app password: its own client,
# its registered redirect URI, a verifier it throws away.
for password, want in ((user_pw, "authenticated"), ("not-the-password", "failure")):
got = sign_in(user, password, "ihasmail-inbuxa", WEBMAIL_REDIRECT, secrets.token_urlsafe(48))
check(got.get("type") == want, f"the webmail's password check answers {want} ({got.get('type')})")
got = sign_in(user, user_pw, "ihasmail-inbuxa", "https://evil.example/cb", secrets.token_urlsafe(48))
check(got.get("type") != "authenticated", f"but not to a redirect URI it didn't register ({got.get('type')})")
# The operator's switch.
restart({"INBUXA_HTTP_BASIC_AUTH": "all"})
status, _, _ = request("/jmap/session", right)
check(status == 200, "INBUXA_HTTP_BASIC_AUTH=all: Basic works on JMAP again")
status, headers, _ = request("/jmap/", None, "POST", b'{"using":[],"methodCalls":[]}', "application/json")
check("Basic" in challenges(headers), f"and JMAP's 401 offers it again ({challenges(headers)})")
restart({"INBUXA_HTTP_BASIC_AUTH": "sometimes"})
status, _, _ = request("/jmap/session", right)
check(status == 401, "an unknown INBUXA_HTTP_BASIC_AUTH keeps Basic refused")
logs = docker("logs", NAME, check_rc=False)
check("INBUXA_HTTP_BASIC_AUTH" in logs.stdout + logs.stderr, "and says so in the log")
restart({"INBUXA_HTTP_BASIC_AUTH": "dav"})
status, _, _ = request("/jmap/session", right)
check(status == 401, "INBUXA_HTTP_BASIC_AUTH=dav is the default")
# Recovery mode accepts Basic, for the recovery administrator.
restart({"INBUXA_RECOVERY_MODE": "1", "INBUXA_RECOVERY_ADMIN": f"admin:{recovery}"})
status, _, _ = request("/jmap/session", basic("admin", recovery))
check(status == 200, "recovery mode: Basic works on JMAP")
if os.environ.get("KEEP") != "1":
stop()
for sub in ("etc-basic", "data-basic"):
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
for name in ("basic-recovery", "basic-admin", "basic-user", "basic-env"):
try:
os.remove(f"{DIR}/secrets/{name}")
except FileNotFoundError:
pass
print()
if failures:
print(f"{len(failures)} failed")
sys.exit(1)
print("all passed")
if __name__ == "__main__":
main()
+3 -1
View File
@@ -90,7 +90,9 @@ def start(env_file=None):
"-p", f"127.0.0.1:{PORTS['submissions']}:465", "-p", f"127.0.0.1:{PORTS['submissions']}:465",
"-p", f"127.0.0.1:{PORTS['imap']}:993", "-p", f"127.0.0.1:{PORTS['imap']}:993",
"-p", f"127.0.0.1:{PORTS['pop3']}:995", "-p", f"127.0.0.1:{PORTS['pop3']}:995",
"-p", f"127.0.0.1:{PORTS['smtp']}:25"] "-p", f"127.0.0.1:{PORTS['smtp']}:25",
# This script signs in with passwords over JMAP (contract C-23).
"-e", "INBUXA_HTTP_BASIC_AUTH=all"]
if env_file: if env_file:
args += ["--env-file", env_file] args += ["--env-file", env_file]
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"] args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
+11
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::{AssertResult, ImapConnection, Type, append::assert_append_message}; use super::{AssertResult, ImapConnection, Type, append::assert_append_message};
@@ -69,6 +71,15 @@ pub async fn test(
.await; .await;
imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await;
// inbuxa: MA-D0: but she can't share the group's mailbox on
imap_jane
.send("SETACL \"Shared Folders/[email protected]/INBOX\" [email protected] lr")
.await;
imap_jane
.assert_read(Type::Tagged, ResponseType::No)
.await
.assert_contains("NOPERM");
// John should have no shared folders // John should have no shared folders
imap_john.send("LIST \"\" \"*\"").await; imap_john.send("LIST \"\" \"*\"").await;
imap_john imap_john
+131
View File
@@ -0,0 +1,131 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! MA-D0 (specs/multi-account.md): a group's members have its calendars,
//! address books and files, but can't share them on. Who is in a group is
//! an administrator's decision. Mailboxes are checked in `mail::acl`.
use crate::utils::{jmap::JmapUtils, server::TestServer};
use jmap_proto::request::method::MethodObject;
use registry::schema::prelude::ObjectType;
use serde_json::json;
pub async fn test(test: &TestServer) {
println!("Running group sharing tests...");
let admin = test.account("[email protected]");
let sales = test.account("[email protected]");
let bill = test.account("[email protected]");
let robert = test.account("[email protected]");
let robert_id = robert.id_string().to_string();
// Bill joins the group; Robert stays outside it
admin
.registry_update_object(
ObjectType::Account,
bill.id(),
json!({"memberGroupIds": {sales.id_string(): true}}),
)
.await;
// Each kind's own name for "may read"
for (object, read) in [
(MethodObject::Calendar, "mayReadItems"),
(MethodObject::AddressBook, "mayRead"),
(MethodObject::FileNode, "mayRead"),
] {
// Made with a share: refused
let response = bill
.jmap_create_account(
sales,
object,
[json!({
"name": "Shared on",
"shareWith": {&robert_id: {read: true}}
})],
Vec::<(&str, &str)>::new(),
)
.await;
assert_eq!(
response.pointer("/methodResponses/0/1/notCreated/i0/type"),
Some(&json!("forbidden")),
"MA-D0: {object} created with a share: {:?}",
response.pointer("/methodResponses/0")
);
// Made without one: fine, and it says it can't be shared
let id = bill
.jmap_create_account(
sales,
object,
[json!({"name": "The group's"})],
Vec::<(&str, &str)>::new(),
)
.await
.created(0)
.id()
.to_string();
let rights = bill
.jmap_get_account(sales, object, ["myRights"], [id.as_str()])
.await
.list()[0]["myRights"]
.clone();
assert_eq!(rights["mayShare"], false, "MA-D0: {object} myRights {rights}");
assert_eq!(rights["mayDelete"], true, "MA-D0: {object} myRights {rights}");
// Shared afterwards: refused
let response = bill
.jmap_update_account(
sales,
object,
[(
&id,
json!({format!("shareWith/{robert_id}"): {read: true}}),
)],
Vec::<(&str, &str)>::new(),
)
.await;
assert_eq!(
response.pointer(&format!("/methodResponses/0/1/notUpdated/{id}/type")),
Some(&json!("forbidden")),
"MA-D0: {object} shared on: {:?}",
response.pointer("/methodResponses/0")
);
// Robert still has nothing
assert_eq!(
robert
.jmap_get_account(sales, object, Vec::<&str>::new(), [id.as_str()])
.await
.method_response()
.typ(),
"forbidden",
"MA-D0: {object} reached from outside"
);
bill.jmap_destroy_account(sales, object, [id.as_str()], Vec::<(&str, &str)>::new())
.await;
}
// Reaching the group's calendars and address books made its defaults
let sales_id = sales.id_string();
bill.jmap_method_calls(json!([
["Calendar/get", {"accountId": sales_id, "ids": (), "properties": ["id"]}, "c"],
["Calendar/set", {"accountId": sales_id, "onDestroyRemoveEvents": true,
"#destroy": {"resultOf": "c", "name": "Calendar/get", "path": "/list/*/id"}}, "cd"],
["AddressBook/get", {"accountId": sales_id, "ids": (), "properties": ["id"]}, "a"],
["AddressBook/set", {"accountId": sales_id, "onDestroyRemoveContents": true,
"#destroy": {"resultOf": "a", "name": "AddressBook/get", "path": "/list/*/id"}}, "ad"]
]))
.await;
admin
.registry_update_object(
ObjectType::Account,
bill.id(),
json!({"memberGroupIds": {sales.id_string(): false}}),
)
.await;
}
+31
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::utils::server::TestServer; use crate::utils::server::TestServer;
@@ -713,6 +715,35 @@ pub async fn test(test: &TestServer) {
.await, .await,
); );
// inbuxa: MA-D0: a member can't share the group's mailbox on, and isn't
// told it may. Who is in a group is an administrator's decision.
assert_forbidden(
john_client
.set_default_account_id(sales.id_string())
.mailbox_update_acl(&inbox_id, bill.id_string(), [ACL::ReadItems])
.await,
);
assert!(
!john_client
.set_default_account_id(sales.id_string())
.mailbox_get(&inbox_id, [mailbox::Property::MyRights].into())
.await
.unwrap()
.unwrap()
.my_rights()
.unwrap()
.acl_list()
.contains(&ACL::Administer)
);
bill_client.refresh_session().await.unwrap();
assert!(bill_client.session().account(sales.id_string()).is_none());
assert_forbidden(
bill_client
.set_default_account_id(sales.id_string())
.email_get(&email_id, [Property::Subject].into())
.await,
);
// Remove John from the sales group // Remove John from the sales group
admin admin
.registry_update_object( .registry_update_object(
+4
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::utils::server::TestServerBuilder; use crate::utils::server::TestServerBuilder;
@@ -22,6 +24,7 @@ pub mod compliance;
pub mod contacts; pub mod contacts;
pub mod core; pub mod core;
pub mod files; pub mod files;
pub mod group_share;
pub mod mail; pub mod mail;
pub mod principal; pub mod principal;
@@ -219,6 +222,7 @@ pub async fn jmap_tests() {
calendar::identity::test(&test).await; calendar::identity::test(&test).await;
calendar::acl::test(&test).await; calendar::acl::test(&test).await;
group_share::test(&test).await;
principal::get::test(&test).await; principal::get::test(&test).await;
principal::availability::test(&test).await; principal::availability::test(&test).await;
+1 -1
View File
@@ -254,7 +254,7 @@ pub async fn test(test: &TestServer) {
// inbuxa: MT-22, the logo that applies to the account, and // inbuxa: MT-22, the logo that applies to the account, and
// LP-19, whether the legacy protocols are open to it, and // LP-19, whether the legacy protocols are open to it, and
// ai-explain EX-1, whether Explain can be offered // ai-explain EX-1, whether Explain can be offered
"urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled", "legacyAllowed": ["imap", "pop3", "manageSieve", "submission"], "aiExplain": false }, "urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled", "legacyAllowed": ["imap", "pop3", "manageSieve", "submission"], "aiExplain": false, "mailSharing": true, "addAccounts": true },
"https://www.fastmail.com/dev/maskedemail": {} "https://www.fastmail.com/dev/maskedemail": {}
} }
} }
+150
View File
@@ -447,6 +447,156 @@ pub async fn test(test: &mut TestServer) {
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2), query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
"AL-9: the delegate's access and changes weren't recorded: {query}" "AL-9: the delegate's access and changes weren't recorded: {query}"
); );
shared_mailbox(admin, &mut smtp_rx, &mut lmtp).await;
}
/// MA-S (specs/multi-account.md): a shared mailbox is a lock of its own
/// kind. No reason is needed, more people fit, its Sieve replies go out,
/// only what is sent as it is recorded, and it sends only as itself.
async fn shared_mailbox(
admin: &Account,
smtp_rx: &mut tokio::sync::mpsc::Receiver<crate::jmap::mail::submission::MockMessage>,
lmtp: &mut SmtpConnection,
) {
println!("Running shared mailbox tests...");
let support = admin
.create_user_account("[email protected]", "support-secret-3317", "Support", &[], vec![])
.await;
let agent = admin
.create_user_account("[email protected]", "agent-secret-5520", "Agent", &[], vec![])
.await;
let support_id = support.id_string().to_string();
// An automatic acknowledgement, set up while it could still sign in
support
.jmap_client()
.await
.vacation_response_enable("Received", "We'll get back to you.".into(), None::<String>)
.await
.unwrap();
// More people than a lock may have
let mut delegates = vec![json!({"accountId": agent.id_string(), "access": "organize", "sendAs": true})];
for n in 0..11 {
let name: &'static str = Box::leak(format!("desk{n}@example.com").into_boxed_str());
let desk = admin.create_user_account(name, "desk-secret-7781", "Desk", &[], vec![]).await;
delegates.push(json!({"accountId": desk.id_string(), "access": "read"}));
}
// No reason needed
let response = admin
.lock_set(json!({"create": {"s": {"accountId": support_id, "kind": "sharedMailbox",
"delegates": delegates}}}))
.await;
assert_eq!(response["created"]["s"]["id"], support_id.as_str(), "MA-S1: {response}");
let (_, got) = admin
.call("inbuxa:AccountLock/get", json!({"accountId": admin.id_string(), "ids": [support_id]}))
.await;
assert_eq!(got["list"][0]["kind"], "sharedMailbox", "MA-S1: {got}");
// Nobody signs in to it
assert_ne!(support.session_status().await, 200, "MA-S1: a shared mailbox signed in");
// It says what it is to the people in it
let session = agent.jmap_session_object().await.0;
let delegation = &session["accounts"][support_id.as_str()]["accountCapabilities"]["urn:inbuxa:jmap"]["delegation"];
assert_eq!(delegation["kind"], "sharedMailbox", "MA-S: {session}");
assert_eq!(delegation["locked"], true, "MA-S: front ends that know no kind still see a lock");
// Its Sieve replies go out, where a lock's are held back
lmtp.ingest(
"[email protected]",
&["[email protected]"],
// Addressed to it: a vacation reply answers only mail sent to it
"From: [email protected]\r\nTo: [email protected]\r\nSubject: My order\r\n\r\nHello.\r\n",
)
.await;
assert_message_delivery(
smtp_rx,
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Received"),
)
.await;
// The agent answers as support@: sent, and recorded as the agent
let (_, mailboxes) = agent
.call("Mailbox/get", json!({"accountId": support_id, "ids": null, "properties": ["role"]}))
.await;
let drafts = mailboxes["list"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "drafts")
.unwrap_or_else(|| panic!("no Drafts: {mailboxes}"))["id"]
.clone();
let (_, identities) = agent
.call("Identity/get", json!({"accountId": support_id, "ids": null}))
.await;
let identity = identities["list"][0]["id"].clone();
let send = |reply_to: Option<&str>, subject: &str| {
let mut email = json!({
"mailboxIds": {drafts.as_str().unwrap(): true},
"from": [{"email": "[email protected]"}],
"to": [{"email": "[email protected]"}],
"subject": subject,
"bodyValues": {"t": {"value": "Thanks for writing."}},
"textBody": [{"partId": "t", "type": "text/plain"}]
});
if let Some(reply_to) = reply_to {
email["replyTo"] = json!([{"email": reply_to}]);
}
json!([
["Email/set", {"accountId": support_id, "create": {"m": email}}, "e"],
["EmailSubmission/set", {"accountId": support_id,
"create": {"s": {"identityId": identity, "emailId": "#m"}}}, "s"]
])
};
let response = agent.jmap_request(USING, send(None, "Re: My order")).await.0;
assert!(
response.pointer("/methodResponses/1/1/created/s").is_some(),
"MA-S3: the answer didn't go out: {response}"
);
assert_message_delivery(
smtp_rx,
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Re: My order"),
)
.await;
// MA-S3: a reply can't be steered to the agent's own address
let response = agent
.jmap_request(USING, send(Some("[email protected]"), "Write to me directly"))
.await
.0;
assert_eq!(
response.pointer("/methodResponses/1/1/notCreated/s/type"),
Some(&json!("forbiddenFrom")),
"MA-S3: {response}"
);
expect_nothing(smtp_rx).await;
// MA-D0a: the send names the agent; AL-9's per-change records don't
// apply in a shared mailbox
let (_, query) = admin
.call(
"inbuxa:AuditEvent/query",
json!({"accountId": admin.id_string(),
"filter": {"actorId": agent.id_string(), "accountId": support_id}}),
)
.await;
let (_, records) = admin
.call("inbuxa:AuditEvent/get", json!({"accountId": admin.id_string(), "ids": query["ids"]}))
.await;
let kinds = records["list"]
.as_array()
.unwrap()
.iter()
.map(|r| r["target"]["kind"].as_str().unwrap_or_default().to_string())
.collect::<Vec<_>>();
assert_eq!(kinds, ["EmailSubmission"], "MA-D0a: {records}");
// Ending it needs no reason either
let response = admin.lock_set(json!({"destroy": [support_id]})).await;
assert_eq!(response["destroyed"][0], support_id.as_str(), "MA-S: {response}");
} }
/// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`. /// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`.
+100
View File
@@ -540,10 +540,110 @@ pub async fn test(test: &mut TestServer) {
"AU-7: continued" "AU-7: continued"
); );
// MA-D0a: a group member sending as the group is named in the log; the
// same person sending as themselves isn't recorded
let group = admin
.create_group_account("[email protected]", "Help desk", &[])
.await;
let agent = admin
.create_user_account(
"[email protected]",
"agent-secret-for-send-as",
"Agent",
&[],
vec![],
)
.await;
admin
.registry_update_object(
ObjectType::Account,
agent.id(),
json!({"memberGroupIds": {group.id_string(): true}}),
)
.await;
agent.send_as("[email protected]").await;
agent.send_as("[email protected]").await;
let sends = admin
.audit(json!({
"targetKind": "EmailSubmission",
"actorId": agent.id_string(),
}))
.await;
assert_eq!(sends.len(), 1, "MA-D0a: {sends:?}");
assert_eq!(sends[0]["target"]["name"], "[email protected]");
assert_eq!(
sends[0]["target"]["accountId"],
group.id_string(),
"MA-D0a: {}",
sends[0]
);
assert_eq!(
sends[0]["details"],
"Sent as [email protected], from [email protected]"
);
// Clean up what later suites could trip over // Clean up what later suites could trip over
admin.registry_destroy_all(ObjectType::BlockedIp).await; admin.registry_destroy_all(ObjectType::BlockedIp).await;
} }
impl Account {
/// Sends one message to itself from its own account, as `from`.
async fn send_as(&self, from: &str) {
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
];
let account_id = self.id_string();
let response = self
.jmap_request(
USING,
json!([
["Identity/get", {"accountId": account_id}, "i"],
["Mailbox/get", {"accountId": account_id, "properties": ["role"]}, "m"]
]),
)
.await;
let identity = response
.0
.pointer("/methodResponses/0/1/list")
.and_then(Value::as_array)
.and_then(|list| list.iter().find(|identity| identity["email"] == from))
.unwrap_or_else(|| panic!("no identity for {from}: {}", response.0))["id"]
.clone();
let drafts = response
.0
.pointer("/methodResponses/1/1/list")
.and_then(Value::as_array)
.and_then(|list| list.iter().find(|mailbox| mailbox["role"] == "drafts"))
.unwrap_or_else(|| panic!("no drafts: {}", response.0))["id"]
.clone();
let response = self
.jmap_request(
USING,
json!([
["Email/set", {"accountId": account_id, "create": {"m": {
"mailboxIds": {drafts.as_str().unwrap(): true},
"from": [{"email": from}],
"to": [{"email": self.name()}],
"subject": format!("Sent as {from}"),
"bodyValues": {"t": {"value": "MA-D0a"}},
"textBody": [{"partId": "t", "type": "text/plain"}]
}}}, "e"],
["EmailSubmission/set", {"accountId": account_id, "create": {"s": {
"identityId": identity, "emailId": "#m"
}}}, "s"]
]),
)
.await;
assert!(
response.0.pointer("/methodResponses/1/1/created/s").is_some(),
"send as {from}: {}",
response.0
);
}
}
/// Runs these tests alone: `cargo test -p tests audit_log_tests -- --ignored`. /// Runs these tests alone: `cargo test -p tests audit_log_tests -- --ignored`.
#[ignore] #[ignore]
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
+389
View File
@@ -0,0 +1,389 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The deliverability check (deliverability spec): what a node finds about
//! its own addresses and the domains it sends for, which lists it leaves out,
//! what a tenant administrator sees of it, and who may ask for a check.
use crate::utils::{
account::Account,
dns::DnsCache,
server::{TestServer, TestServerBuilder},
};
use inbuxa_features::deliverability::{AddressSource, DkimState, ListingState};
use mail_auth::{
DnssecStatus, MX, common::parse::TxtRecordParser, dmarc::Dmarc, mta_sts::MtaSts,
mta_sts::TlsRpt, spf::Spf,
};
use registry::schema::{
prelude::{ObjectType, Property},
structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles},
};
use serde_json::{Value, json};
use smtp::outbound::mta_sts::lookup::STS_TEST_POLICY;
use std::{
net::IpAddr,
time::{Duration, Instant},
};
use types::id::Id;
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 domain(admin: &Account, name: &str, tenant: Option<Id>) -> Id {
admin
.registry_create_object(Domain {
name: name.to_string(),
is_enabled: true,
member_tenant_id: tenant,
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
..Default::default()
})
.await
}
pub async fn test(test: &mut TestServer) {
println!("Running deliverability tests...");
let admin = test.account("[email protected]");
let server = test.server.clone();
let soon = Instant::now() + Duration::from_secs(600);
// --- The settings: the lists, and leaving one out (DL-6) -------------
let (_, response) = call(
&admin,
"inbuxa:DeliverabilitySettings/get",
json!({"ids": null}),
)
.await;
let settings = &response["list"][0];
assert_eq!(settings["disabledLists"], json!([]), "{response}");
let lists = settings["lists"].as_array().unwrap();
assert_eq!(lists.len(), 9, "{response}");
let barracuda = lists.iter().find(|l| l["name"] == "Barracuda").unwrap();
assert!(
barracuda["note"].as_str().unwrap().contains("registered"),
"{barracuda}"
);
let (_, response) = call(
&admin,
"inbuxa:DeliverabilitySettings/set",
json!({"update": {"singleton": {"disabledLists": ["My own list"]}}}),
)
.await;
assert!(
response["notUpdated"]["singleton"].is_object(),
"an unknown list was taken: {response}"
);
let (_, response) = call(
&admin,
"inbuxa:DeliverabilitySettings/set",
json!({"update": {"singleton": {"disabledLists": ["Barracuda"]}}}),
)
.await;
assert!(
response["updated"]["singleton"].is_null() && response["updated"].is_object(),
"{response}"
);
// --- What the world says about this node ------------------------------
let hostname = server.core.network.server_name.to_lowercase();
let ip: IpAddr = "192.0.2.10".parse().unwrap();
server.ipv4_add(hostname.as_str(), vec!["192.0.2.10".parse().unwrap()], soon);
server.ptr_add(ip, vec![format!("{hostname}.")], soon);
// Listed on ZEN, refused by SpamCop, an undefined answer from Mailspike
server.ipv4_add(
"10.2.0.192.zen.spamhaus.org",
vec!["127.0.0.2".parse().unwrap()],
soon,
);
server.ipv4_add(
"10.2.0.192.bl.spamcop.net",
vec!["127.255.255.254".parse().unwrap()],
soon,
);
server.ipv4_add(
"10.2.0.192.bl.mailspike.net",
vec!["127.0.0.200".parse().unwrap()],
soon,
);
// A tenant's domain that's in order, and the server's own that isn't
let tenant = admin
.registry_create_object(Tenant {
name: "Deliverability tenant".to_string(),
..Default::default()
})
.await;
domain(&admin, "good.example.org", Some(tenant)).await;
domain(&admin, "bad.example.org", None).await;
server.txt_add(
"good.example.org",
Spf::parse(b"v=spf1 ip4:192.0.2.10 -all").unwrap(),
soon,
);
server.txt_add(
"bad.example.org",
Spf::parse(b"v=spf1 ip4:198.51.100.1 -all").unwrap(),
soon,
);
server.txt_add(
"_dmarc.good.example.org",
Dmarc::parse(b"v=DMARC1; p=reject; adkim=s").unwrap(),
soon,
);
server.txt_add(
"_smtp._tls.good.example.org",
TlsRpt::parse(b"v=TLSRPTv1; rua=mailto:[email protected]").unwrap(),
soon,
);
server.txt_add(
"_mta-sts.good.example.org",
MtaSts::parse(b"v=STSv1; id=20261005").unwrap(),
soon,
);
{
let mut policy = STS_TEST_POLICY.lock();
policy.clear();
policy.extend_from_slice(
b"version: STSv1\nmode: enforce\nmx: mx1.good.example.org\nmax_age: 86400\n",
);
}
server.mx_add(
"good.example.org",
vec![
MX {
exchanges: vec!["mx1.good.example.org.".into()].into_boxed_slice(),
preference: 10,
},
MX {
exchanges: vec!["mx2.good.example.org.".into()].into_boxed_slice(),
preference: 20,
},
],
DnssecStatus::Insecure,
soon,
);
server.ipv4_add(
"bad.example.org.dbl.spamhaus.org",
vec!["127.0.1.2".parse().unwrap()],
soon,
);
let report = services::inbuxa_deliverability::run(&server)
.await
.expect("the check runs");
// DL-2: no addresses set, so what the EHLO name resolves to
assert_eq!(report.addresses.len(), 1, "{report:#?}");
let address = &report.addresses[0];
assert_eq!(address.ip, "192.0.2.10");
assert_eq!(address.source, AddressSource::Ehlo);
// DL-5
assert_eq!(address.ptr, [hostname.clone()]);
assert!(
address.forward_confirmed && address.ehlo_matches,
"{address:#?}"
);
// DL-4, DL-6
let state = |list: &str| {
address
.listings
.iter()
.find(|l| l.list == list)
.unwrap_or_else(|| panic!("{list} not asked: {address:#?}"))
.state
};
assert_eq!(state("Spamhaus ZEN"), ListingState::Listed);
assert_eq!(state("SpamCop"), ListingState::Refused);
assert_eq!(state("Mailspike"), ListingState::Refused);
assert_eq!(state("Barracuda"), ListingState::Off);
assert_eq!(state("PSBL"), ListingState::Clean);
assert!(
address.listings.iter().all(|l| l.list != "Spamhaus DBL"),
"a domain list was asked about an address"
);
let good = report
.domains
.iter()
.find(|d| d.domain == "good.example.org")
.unwrap();
let bad = report
.domains
.iter()
.find(|d| d.domain == "bad.example.org")
.unwrap();
// DL-7
assert_eq!(good.spf[0].result, "pass", "{good:#?}");
assert_eq!(bad.spf[0].result, "fail", "{bad:#?}");
// DL-8: no keys of its own, so nothing to compare
assert!(good.dkim.iter().all(|k| k.state != DkimState::Different));
// DL-9
let dmarc = good.dmarc.as_ref().expect("the DMARC record");
assert_eq!(
(dmarc.policy.as_str(), dmarc.adkim.as_str()),
("reject", "strict")
);
assert!(bad.dmarc.is_none());
// DL-10: the policy is fetched, and one MX isn't in it
assert_eq!(good.mta_sts.record_id.as_deref(), Some("20261005"));
assert!(good.mta_sts.fetched, "{:#?}", good.mta_sts);
assert_eq!(good.mta_sts.mode.as_deref(), Some("enforce"));
assert_eq!(good.mta_sts.mx_not_covered, ["mx2.good.example.org"]);
assert!(bad.mta_sts.record_id.is_none());
// DL-11
assert!(good.tls_rpt && !bad.tls_rpt);
// DL-12
let dbl = bad
.listings
.iter()
.find(|l| l.list == "Spamhaus DBL")
.unwrap();
assert_eq!(dbl.state, ListingState::Listed);
// DL-13: the EHLO name is checked
assert!(
report.certificates.iter().any(|c| c.name == hostname),
"{:#?}",
report.certificates
);
// --- Over JMAP ---------------------------------------------------------
let (_, response) = call(
&admin,
"inbuxa:DeliverabilityReport/get",
json!({"ids": null}),
)
.await;
let listed = response["list"].as_array().unwrap();
assert_eq!(listed.len(), 1, "{response}");
assert_eq!(listed[0]["addresses"][0]["ip"], "192.0.2.10", "{response}");
// The two above and the test server's own
assert_eq!(
listed[0]["domains"].as_array().unwrap().len(),
report.domains.len(),
"{response}"
);
assert!(listed[0]["checkedAt"].as_str().unwrap().ends_with('Z'));
// Check now: queued, with when the node last checked (DL-15)
let (_, response) = call(
&admin,
"inbuxa:DeliverabilityReport/set",
json!({"create": {"now": {}}}),
)
.await;
assert_eq!(
response["created"]["now"]["checkedAt"], listed[0]["checkedAt"],
"{response}"
);
let (_, response) = call(
&admin,
"inbuxa:DeliverabilityReport/set",
json!({"destroy": [listed[0]["id"]]}),
)
.await;
assert!(response["notDestroyed"].is_object(), "{response}");
// --- A tenant administrator (DL-20) -------------------------------------
let t_admin = admin
.create_user_account(
"[email protected]",
"tenant-admin-secret-5520",
"Tenant admin",
&[],
vec![],
)
.await;
admin
.registry_update_object(
ObjectType::Account,
t_admin.id(),
json!({Property::Roles: UserRoles::Admin}),
)
.await;
let (_, response) = call(
&t_admin,
"inbuxa:DeliverabilityReport/get",
json!({"ids": null}),
)
.await;
let seen = &response["list"][0];
assert_eq!(seen["addresses"], json!([]), "{response}");
assert_eq!(seen["certificates"], json!([]), "{response}");
let domains = seen["domains"].as_array().unwrap();
assert_eq!(domains.len(), 1, "{response}");
assert_eq!(domains[0]["domain"], "good.example.org");
let (name, response) = call(
&t_admin,
"inbuxa:DeliverabilityReport/set",
json!({"create": {"now": {}}}),
)
.await;
assert_eq!(
name, "error",
"a tenant administrator ran the check: {response}"
);
let (name, response) = call(
&t_admin,
"inbuxa:DeliverabilitySettings/set",
json!({"update": {"singleton": {"disabledLists": []}}}),
)
.await;
assert_eq!(
name, "error",
"a tenant administrator changed the lists: {response}"
);
// Cleared for the tests that follow
call(
&admin,
"inbuxa:DeliverabilitySettings/set",
json!({"update": {"singleton": {"disabledLists": []}}}),
)
.await;
}
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn deliverability_tests() {
let mut test = TestServerBuilder::new("deliverability_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();
}
}
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more