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
88 changed files with 4751 additions and 117 deletions

No files matched your search

+9 -4
View File
@@ -357,18 +357,23 @@ jobs:
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"
rel="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG" | jq -r .id)"
assets="$(curl -fsS -H "$auth" "$api/releases/$rel/assets")"
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 -o /dev/null -X DELETE -H "$auth" "$api/releases/$rel/assets/$id"; done
curl -fsS -o /dev/null -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$rel/assets?name=$name"
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
Generated
+2
View File
@@ -7762,11 +7762,13 @@ dependencies = [
"common",
"dns-update",
"email",
"futures",
"groupware",
"hkdf 0.13.0",
"inbuxa-features",
"jmap-tools",
"jmap_proto",
"mail-auth",
"mail-builder 1.0.0",
"mail-parser",
"memory-stats",
+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:
- [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
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.
pub async fn audit_purge(&self) -> trc::Result<usize> {
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;
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(
&self,
account: Account,
@@ -46,19 +72,22 @@ impl Server {
// inbuxa: AL-2, AL-5: whether this account is locked, and which
// locked accounts are handed to it. The token is their cache: every
// 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
.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 delegations: Box<[super::Delegation]> =
inbuxa_features::lock::delegated_to(self.store(), account_id)
.await
.caused_by(trc::location!())?
.into_iter()
.filter(|(_, delegate)| delegate.is_current(now_secs))
.map(|(locked_id, delegate)| super::Delegation {
.filter(|(_, delegate, _)| delegate.is_current(now_secs))
.map(|(locked_id, delegate, kind)| super::Delegation {
account_id: locked_id,
kind,
access: delegate.access,
send_as: delegate.send_as,
until: delegate.until,
@@ -97,6 +126,9 @@ impl Server {
.map(|m| m.id() as u32)
.collect::<TinyVec<[u32; 3]>>();
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 acl_item in self
.store()
@@ -117,6 +149,27 @@ impl Server {
.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();
if acl.contains(Acl::Read) {
collections.insert(collection);
@@ -247,6 +300,7 @@ impl Server {
.map(ConcurrencyLimiter::new),
obj_size: 0,
locked,
shared_mailbox,
delegations: delegations.clone(),
revision,
revision_account,
@@ -300,6 +354,7 @@ impl Server {
.map(ConcurrencyLimiter::new),
obj_size: 0,
locked,
shared_mailbox,
delegations: delegations.clone(),
revision,
revision_account,
@@ -553,6 +608,16 @@ impl AccessToken {
|| 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 {
self.inner.account_id == account_id
}
@@ -648,6 +713,7 @@ impl AccessToken {
credential_version: old_inner.credential_version,
obj_size: old_inner.obj_size,
locked: old_inner.locked,
shared_mailbox: old_inner.shared_mailbox,
delegations: old_inner.delegations.clone(),
};
@@ -838,6 +904,18 @@ impl AccessToken {
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
/// has one that hasn't ended.
/// 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(),
obj_size: Default::default(),
locked: false,
shared_mailbox: false,
delegations: Default::default(),
}),
}
@@ -978,6 +1057,7 @@ impl AccessTokenInner {
credential_version: Default::default(),
obj_size: Default::default(),
locked: false,
shared_mailbox: false,
delegations: Default::default(),
}
}
+4
View File
@@ -152,6 +152,8 @@ pub struct AccessTokenInner {
pub(crate) obj_size: u64,
// inbuxa: AL-2: the account is locked; it may not authenticate
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
pub(crate) delegations: Box<[Delegation]>,
}
@@ -165,6 +167,8 @@ pub struct Delegation {
pub send_as: bool,
/// Seconds since the epoch.
pub until: Option<u64>,
/// MA-S: a locked account, or a shared mailbox.
pub kind: inbuxa_features::lock::Kind,
}
#[derive(Debug, Default, Hash, Clone)]
+13
View File
@@ -111,6 +111,9 @@ impl Server {
Permission::SysLegalHoldCreate,
Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldExport,
// inbuxa: DL-20: the lists and the check are the server's
Permission::SysDeliverabilityUpdate,
Permission::SysDeliverabilityCheck,
] {
permissions.disabled.set(permission as usize);
}
@@ -304,6 +307,16 @@ impl Default for DefaultPermissions {
default.superuser.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
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
// settled answer 3)
+2
View File
@@ -88,6 +88,8 @@ pub enum BroadcastEvent {
QueueRefresh,
// inbuxa: AL-3: end an account's open sessions on every node
EndSessions(u32),
// inbuxa: deliverability spec, DL-15: every node checks itself now
DeliverabilityCheck,
}
#[derive(Debug, Clone, Copy)]
+2 -2
View File
@@ -14,7 +14,7 @@
//! application names another;
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
//! 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: the environment variables stand in for `x:FrontEnds` (C-4) until
@@ -22,7 +22,7 @@
//! it instead.
//!
//! 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.
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"
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
/// the data inventory (personal-data catalog spec), and accepting security
/// to-do items (security to-do list spec).
/// the data inventory (personal-data catalog spec), accepting security
/// to-do items (security to-do list spec), and the deliverability check
/// (deliverability spec).
const ADMIN_GRANTS: &[Permission] = &[
Permission::SysAiExplain,
Permission::SysAuditGet,
@@ -56,6 +57,9 @@ const ADMIN_GRANTS: &[Permission] = &[
Permission::SysJournalGet,
Permission::SysJournalUpdate,
Permission::SysSecurityAccept,
Permission::SysDeliverabilityGet,
Permission::SysDeliverabilityUpdate,
Permission::SysDeliverabilityCheck,
];
/// 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
/// 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] = &[
Permission::SysAuditGet,
Permission::SysAuditExport,
@@ -82,6 +87,7 @@ const TENANT_GRANTS: &[Permission] = &[
Permission::SysAccountLockUpdate,
Permission::SysAccountLockDestroy,
Permission::SysComplianceGet,
Permission::SysDeliverabilityGet,
];
#[derive(Clone, Copy, PartialEq, Eq)]
+55 -3
View File
@@ -116,8 +116,19 @@ impl StoredMetric {
/// changes (MON-4). Per process: a restart counts from the start.
static LAST: Mutex<Option<AHashMap<MetricType, (u64, u64)>>> = Mutex::new(None);
/// One tick's samples (MON-4 to MON-6).
pub fn sample() -> Vec<Metric> {
/// Gauges that count the whole cluster's data, not this node's. Only the node
/// 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 last = last_guard.get_or_insert_with(AHashMap::new);
let mut samples = Vec::new();
@@ -140,6 +151,9 @@ pub fn sample() -> Vec<Metric> {
// Gauges: the reading, always (MON-5)
for gauge in Collector::collect_gauges() {
if !calculates && CLUSTER_GAUGES.contains(&gauge.id()) {
continue;
}
samples.push(Metric::Gauge(MetricCount {
count: gauge.get(),
metric: gauge.id(),
@@ -181,7 +195,7 @@ impl Server {
if store.is_none() {
return;
}
let samples = sample();
let samples = sample(self.core.network.roles.metrics_calculate);
let count = samples.len();
let started = std::time::Instant::now();
match store.write_metrics(samples, now()).await {
@@ -271,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));
}
// 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
let grants = self
@@ -565,7 +569,13 @@ impl Privileges for AccessToken {
grants: &ArchivedVec<ArchivedAclGrant>,
is_calendar: bool,
) -> 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)
} else {
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
// rejection is kept instead; sieve has already cleared
// 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)
&& !message.file_into.contains(&INBOX_ID)
{
@@ -403,7 +407,11 @@ impl SieveScriptIngest for Server {
// inbuxa: AL-4: a locked account sends nothing on its
// own: no redirect, vacation reply or notification. An
// 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!(
Sieve(SieveEvent::ActionReject),
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 audit;
pub mod branding;
pub mod deliverability; // inbuxa: the deliverability check (not a rebuild)
pub mod hold;
pub mod journal;
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).
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).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
@@ -189,6 +236,9 @@ pub struct Replaced {
#[serde(rename_all = "camelCase")]
pub struct Lock {
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,
/// Seconds since the epoch.
pub locked_at: u64,
@@ -401,8 +451,9 @@ pub async fn all(data: &Store) -> trc::Result<Vec<Lock>> {
Ok(locks)
}
/// The accounts delegated to `delegate`, with its delegation in each.
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate)>> {
/// The accounts delegated to `delegate`, with its delegation in each and
/// 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();
data.iterate(
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?
&& let Some(delegation) = lock.delegate(delegate)
{
delegations.push((account_id, delegation.clone()));
delegations.push((account_id, delegation.clone(), lock.kind));
}
}
Ok(delegations)
@@ -472,6 +523,22 @@ pub async fn remove(data: &Store, lock: &Lock) -> trc::Result<()> {
mod tests {
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]
fn keys_read_back() {
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 {
Lock {
account_id: 1,
kind: Kind::Lock,
reason: "r".into(),
locked_at: 0,
locked_by: "admin".into(),
@@ -623,6 +691,7 @@ mod tests {
fn expired_delegations_grant_nothing() {
let lock = Lock {
account_id: 1,
kind: Kind::Lock,
reason: "Left the company".into(),
locked_at: 100,
locked_by: "admin".into(),
+1
View File
@@ -15,4 +15,5 @@ pub mod legacy_use;
pub mod log_files;
pub mod listeners;
pub mod protocol_policy;
pub mod sharing_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]);
}
}
+46 -4
View File
@@ -212,7 +212,7 @@ impl<T: SessionStream> Session<T> {
}
rights
} else {
vec![
let mut rights = vec![
Rights::Read,
Rights::Lookup,
Rights::Insert,
@@ -223,8 +223,12 @@ impl<T: SessionStream> Session<T> {
Rights::CreateMailbox,
Rights::DeleteMailbox,
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!(
@@ -266,10 +270,20 @@ impl<T: SessionStream> Session<T> {
spawn_op!(data, {
// Validate mailbox
let (mailbox_id, current_mailbox, _) = data
let (mailbox_id, current_mailbox, access_token) = data
.get_acl_mailbox(&arguments, true)
.await
.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
.into_deserialized::<email::mailbox::Mailbox>()
.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 {
return Err(trc::ImapEvent::Error
.into_err()
@@ -28,6 +28,8 @@ pub enum AccountLockProperty {
/// The locked account (on create; afterwards the same as `id`).
AccountId,
Name,
/// MA-S: `lock` (the default) or `sharedMailbox`; set on create only.
Kind,
Reason,
LockedAt,
LockedBy,
@@ -53,6 +55,7 @@ impl Property for AccountLockProperty {
AccountLockProperty::Id => "id",
AccountLockProperty::AccountId => "accountId",
AccountLockProperty::Name => "name",
AccountLockProperty::Kind => "kind",
AccountLockProperty::Reason => "reason",
AccountLockProperty::LockedAt => "lockedAt",
AccountLockProperty::LockedBy => "lockedBy",
@@ -68,6 +71,7 @@ impl AccountLockProperty {
b"id" => AccountLockProperty::Id,
b"accountId" => AccountLockProperty::AccountId,
b"name" => AccountLockProperty::Name,
b"kind" => AccountLockProperty::Kind,
b"reason" => AccountLockProperty::Reason,
b"lockedAt" => AccountLockProperty::LockedAt,
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_mail_rule; // inbuxa: DLP and mail flow rules
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_entry; // inbuxa: journaling, search and export
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_protocol_policy; // inbuxa: legacy protocols off
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 file_node;
pub mod identity;
+9
View File
@@ -91,6 +91,12 @@ impl Response<'_> {
GetResponseMethod::SecurityAcceptance(response) => {
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) => {
response.eval_jptr(path, &mut results)
}
@@ -109,6 +115,9 @@ impl Response<'_> {
GetResponseMethod::TenantProtocolPolicy(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::SharingPolicy(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::Principal(response) => {
response.eval_jptr(path, &mut results)
}
@@ -56,6 +56,8 @@ impl Response<'_> {
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
GetRequestMethod::MailRule(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::JournalEntry(request) => request.resolve_references(self)?,
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
@@ -64,6 +66,9 @@ impl Response<'_> {
GetRequestMethod::TenantProtocolPolicy(request) => {
request.resolve_references(self)?
}
GetRequestMethod::SharingPolicy(request) => {
request.resolve_references(self)?
}
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
@@ -137,6 +142,12 @@ impl Response<'_> {
SetRequestMethod::SecurityAcceptance(request) => {
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) => {
request.resolve_references(self, 1, false)?
}
@@ -158,6 +169,9 @@ impl Response<'_> {
SetRequestMethod::TenantProtocolPolicy(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::SharingPolicy(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::AddressBook(request) => {
request.resolve_references(self, 1, false)?
}
+12 -1
View File
@@ -148,8 +148,12 @@ pub struct InbuxaDelegatedCapabilities {
#[derive(Debug, Clone, serde::Serialize)]
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,
/// MA-S: `lock` or `sharedMailbox`.
pub kind: &'static str,
/// `read`, `organize` or `full`.
pub access: &'static str,
#[serde(rename(serialize = "sendAs"))]
@@ -179,6 +183,13 @@ pub struct InbuxaAccountCapabilities {
/// spec, EX-1 to EX-4).
#[serde(rename(serialize = "aiExplain"))]
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)]
+26
View File
@@ -70,6 +70,9 @@ pub enum MethodObject {
MailRule,
// inbuxa: accepted security to-do items
SecurityAcceptance,
// inbuxa: the deliverability check
DeliverabilityReport,
DeliverabilitySettings,
HeldMessage,
// inbuxa: journaling
Journal,
@@ -77,6 +80,7 @@ pub enum MethodObject {
JournalExport,
JournalVerification,
TenantProtocolPolicy,
SharingPolicy,
}
impl MethodObject {
@@ -118,12 +122,15 @@ impl MethodObject {
| MethodObject::MailRule
| MethodObject::SecurityAcceptance
| MethodObject::HeldMessage
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::Journal
| MethodObject::JournalEntry
| MethodObject::JournalExport
| MethodObject::JournalVerification => Capability::Inbuxa,
MethodObject::ProtocolPolicy => Capability::Inbuxa,
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
MethodObject::SharingPolicy => Capability::Inbuxa,
}
}
}
@@ -321,6 +328,10 @@ impl MethodName {
(MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set",
(MethodFunction::Get, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/get",
(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::Set, MethodObject::Journal) => "inbuxa:Journal/set",
(MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get",
@@ -344,6 +355,12 @@ impl MethodName {
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
"inbuxa:TenantProtocolPolicy/set"
}
(MethodFunction::Get, MethodObject::SharingPolicy) => {
"inbuxa:SharingPolicy/get"
}
(MethodFunction::Set, MethodObject::SharingPolicy) => {
"inbuxa:SharingPolicy/set"
}
(method, MethodObject::Registry(obj)) => {
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:SecurityAcceptance/get" => (MethodObject::SecurityAcceptance, MethodFunction::Get),
"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/set" => (MethodObject::Journal, MethodFunction::Set),
"inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get),
@@ -504,6 +525,8 @@ impl MethodName {
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
"inbuxa:SharingPolicy/get" => (MethodObject::SharingPolicy, MethodFunction::Get),
"inbuxa:SharingPolicy/set" => (MethodObject::SharingPolicy, MethodFunction::Set),
).or_else(|| {
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
@@ -570,6 +593,8 @@ impl Display for MethodObject {
MethodObject::LegalHold => "inbuxa:LegalHold",
MethodObject::MailRule => "inbuxa:MailRule",
MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance",
MethodObject::DeliverabilityReport => "inbuxa:DeliverabilityReport",
MethodObject::DeliverabilitySettings => "inbuxa:DeliverabilitySettings",
MethodObject::Journal => "inbuxa:Journal",
MethodObject::JournalEntry => "inbuxa:JournalEntry",
MethodObject::JournalExport => "inbuxa:JournalExport",
@@ -578,6 +603,7 @@ impl Display for MethodObject {
MethodObject::HoldExport => "inbuxa:HoldExport",
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
MethodObject::SharingPolicy => "inbuxa:SharingPolicy",
MethodObject::Registry(obj) => {
f.write_str("x:")?;
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>>),
MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>),
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>>),
JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
@@ -134,6 +136,9 @@ pub enum GetRequestMethod {
TenantProtocolPolicy(
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
),
SharingPolicy(
Box<GetRequest<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
}
#[derive(Debug)]
@@ -169,6 +174,8 @@ pub enum SetRequestMethod<'x> {
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>>),
JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>),
JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>),
@@ -178,6 +185,9 @@ pub enum SetRequestMethod<'x> {
TenantProtocolPolicy(
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
),
SharingPolicy(
Box<SetRequest<'x, crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
}
#[derive(Debug)]
+47
View File
@@ -213,6 +213,15 @@ impl<'de> Visitor<'de> for CallVisitor {
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() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
Err(err) => RequestMethod::invalid(err),
@@ -415,6 +424,15 @@ impl<'de> Visitor<'de> for CallVisitor {
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() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
Err(err) => RequestMethod::invalid(err),
@@ -668,6 +686,35 @@ impl<'de> Visitor<'de> for CallVisitor {
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
(MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() {
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>),
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
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>),
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
@@ -121,6 +123,9 @@ pub enum GetResponseMethod {
TenantProtocolPolicy(
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
),
SharingPolicy(
GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
),
}
#[derive(Debug, serde::Serialize)]
@@ -156,6 +161,8 @@ pub enum SetResponseMethod {
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>>),
JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>),
JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>),
@@ -166,6 +173,9 @@ pub enum SetResponseMethod {
TenantProtocolPolicy(
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
),
SharingPolicy(
Box<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
}
#[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>>
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> {
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
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
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
for ResponseMethod<'x>
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
@@ -180,7 +182,7 @@ impl AddressBookGet for Server {
address_book.acls.effective_acl(access_token),
)
} 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;
}
// 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
if !address_book.acls.is_empty() {
if let Err(err) = self.acl_validate(account_id, &address_book.acls).await {
@@ -203,6 +211,14 @@ impl AddressBookSet for Server {
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 let Err(err) = self.acl_validate(account_id, &new_address_book.acls).await {
response.not_updated.append(id, err.into());
+15
View File
@@ -187,6 +187,21 @@ impl JmapRights {
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>(
acls: Bitmap<Acl>,
) -> 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
// see the server's security settings
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
// puts them back, so it takes the listener's permissions
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
// permissions, which a tenant administrator already holds.
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::Quota(_) => Permission::JmapQuotaGet,
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
@@ -331,6 +339,23 @@ impl JmapAuthorization for AccessToken {
.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
SetRequestMethod::HoldExport(s) => validate_set(
s,
@@ -370,6 +395,14 @@ impl JmapAuthorization for AccessToken {
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(
s,
self,
@@ -494,13 +527,16 @@ impl JmapAuthorization for AccessToken {
| MethodObject::HoldExport
| MethodObject::MailRule
| MethodObject::SecurityAcceptance
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::HeldMessage
| MethodObject::Journal
| MethodObject::JournalEntry
| MethodObject::JournalExport
| MethodObject::JournalVerification
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
| MethodObject::TenantProtocolPolicy
| MethodObject::SharingPolicy => Permission::JmapEmailChanges,
// inbuxa: x:MaskedEmail/changes reads what /get reads
MethodObject::Registry(object_type) => object_type.get_permission(),
},
+90 -10
View File
@@ -204,15 +204,20 @@ impl RequestHandler for Server {
// inbuxa: AL-9: a delegate's access, and what it
// changes, are recorded; anyone else here impersonated
if let Some(delegation) = access_token.delegation(account_id) {
let access = delegation.access.as_str();
self.audit_delegate(
access_token,
account_id,
access,
is_write.then_some(call_name.as_str()),
result.as_ref().err(),
)
.await;
// 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();
self.audit_delegate(
access_token,
account_id,
access,
is_write.then_some(call_name.as_str()),
result.as_ref().err(),
)
.await;
}
if makes_containers
&& result.is_ok()
&& let Err(err) =
@@ -288,6 +293,12 @@ impl RequestHandler for Server {
SetResponseMethod::SecurityAcceptance(set_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) => {
set_response.update_created_ids(&mut response);
}
@@ -312,6 +323,9 @@ impl RequestHandler for Server {
SetResponseMethod::TenantProtocolPolicy(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::SharingPolicy(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AddressBook(set_response) => {
set_response.update_created_ids(&mut response);
}
@@ -531,6 +545,19 @@ impl RequestHandler for Server {
.await?
.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
GetRequestMethod::Journal(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
@@ -569,6 +596,13 @@ impl RequestHandler for Server {
.await?
.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) => {
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
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?
.into()
}
@@ -1045,6 +1079,35 @@ impl RequestHandler for Server {
.await?
.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) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
let reason = req.arguments.reason.clone();
@@ -1127,6 +1190,23 @@ impl RequestHandler for Server {
.await?
.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) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
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)
&& access_token.tenant_id().is_none()
&& 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(
Capability::Inbuxa,
Capabilities::Inbuxa(InbuxaAccountCapabilities {
@@ -87,6 +94,8 @@ impl SessionHandler for Server {
legacy_protocols,
legacy_allowed,
ai_explain,
mail_sharing: sharing.mail_sharing,
add_accounts: sharing.add_accounts,
}),
);
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
@@ -148,6 +157,7 @@ impl SessionHandler for Server {
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
delegation: DelegationInfo {
locked: true,
kind: delegation.kind.as_str(),
access: delegation.access.as_str(),
send_as: delegation.send_as,
until: delegation.until.map(|until| {
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{api::acl::JmapRights, calendar::Availability, changes::state::JmapCacheState};
@@ -253,7 +255,7 @@ impl CalendarGet for Server {
calendar.acls.effective_acl(access_token),
)
} 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;
}
// 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
if !calendar.acls.is_empty() {
if let Err(err) = self.acl_validate(account_id, &calendar.acls).await {
@@ -207,6 +215,14 @@ impl CalendarSet for Server {
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 let Err(err) = self.acl_validate(account_id, &new_calendar.acls).await {
response.not_updated.append(id, err.into());
+3
View File
@@ -432,6 +432,8 @@ impl IntermediateChangesResponse {
| MethodObject::HoldExport
| MethodObject::MailRule
| MethodObject::SecurityAcceptance
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::Journal
| MethodObject::JournalEntry
| MethodObject::JournalExport
@@ -439,6 +441,7 @@ impl IntermediateChangesResponse {
| MethodObject::HeldMessage
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy
| MethodObject::SharingPolicy
| MethodObject::Registry(_) => unreachable!(),
})
}
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
@@ -172,7 +174,7 @@ impl FileNodeGet for Server {
file_node.acls.effective_acl(access_token),
)
} 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
if file_node.parent_id > 0 {
let parent_id = file_node.parent_id - 1;
@@ -509,6 +519,14 @@ impl FileNodeSet for Server {
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 let Err(err) = self.acl_validate(account_id, &new_file_node.acls).await {
response.not_updated.append(id, err.into());
+38 -11
View File
@@ -15,7 +15,7 @@ use common::{
};
use email::inbuxa_lock::apply_grants;
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::{
error::set::SetError,
method::{
@@ -39,6 +39,7 @@ const ALL: &[P] = &[
P::Id,
P::AccountId,
P::Name,
P::Kind,
P::Reason,
P::LockedAt,
P::LockedBy,
@@ -75,6 +76,7 @@ async fn parse_delegates(
server: &Server,
access_token: &AccessToken,
locked_id: u32,
kind: Kind,
value: LValue,
) -> Result<Vec<Delegate>, SetError<P>> {
let invalid = |why: String| {
@@ -86,8 +88,10 @@ async fn parse_delegates(
let Some(items) = json.as_array() else {
return Err(invalid("delegates must be a list.".into()));
};
if items.len() > MAX_DELEGATES {
return Err(invalid(format!("At most {MAX_DELEGATES} delegates.")));
// MA-S: a shared mailbox holds more people than a lock hands over
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 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 {
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::Kind => Value::Str(Cow::Borrowed(lock.kind.as_str())),
P::Reason => Value::Str(lock.reason.clone().into()),
P::LockedAt => date(lock.locked_at),
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() {
let mut account_id = None;
let mut kind = Kind::Lock;
let mut reason = None;
let mut delegates_value = None;
let mut invalid = None;
@@ -291,6 +297,17 @@ pub async fn set(
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(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::Delegates), value) => delegates_value = Some(value.into_owned()),
_ => {
@@ -310,9 +327,14 @@ pub async fn set(
);
continue;
};
let Some(reason) = reason.or_else(|| reason_of(arguments.reason.as_deref())) else {
response.not_created.append(client_id, reason_required());
continue;
// 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());
continue;
}
};
if let Err(error) = assert_reach(server, access_token, account_id).await {
response.not_created.append(client_id, error);
@@ -321,12 +343,13 @@ pub async fn set(
if lock::get(data, account_id).await?.is_some() {
response.not_created.append(
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;
}
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,
Err(error) => {
response.not_created.append(client_id, error);
@@ -337,6 +360,7 @@ pub async fn set(
};
let mut created = Lock {
account_id,
kind,
reason,
locked_at: now(),
locked_by: actor.name.clone(),
@@ -370,7 +394,7 @@ pub async fn set(
response.not_updated.append(id, SetError::not_found());
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());
continue;
}
@@ -379,7 +403,9 @@ pub async fn set(
for (key, value) in value.into_expanded_object() {
match (&key, 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,
Err(error) => {
invalid = Some(error);
@@ -389,6 +415,7 @@ pub async fn set(
}
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
Some(r) => updated.reason = r,
None if !current.kind.is_lock() => updated.reason = String::new(),
None => {
invalid = Some(reason_required());
break;
@@ -420,7 +447,7 @@ pub async fn set(
response.not_destroyed.append(id, SetError::not_found());
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());
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 mail_rule;
pub mod security_acceptance;
pub mod deliverability; // inbuxa: the deliverability check
pub mod journal;
pub mod journal_entry;
pub mod held_message;
@@ -28,6 +29,7 @@ pub mod webhook_test;
pub mod explanation;
pub mod protocol_policy;
pub mod tenant_protocol_policy;
pub mod sharing_policy;
pub mod deleted_account;
pub mod fastmail;
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)
}
+8
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use common::{Server, auth::AccessToken, sharing::EffectiveAcl};
@@ -14,6 +16,7 @@ use jmap_tools::{Map, Value};
use std::future::Future;
use store::ahash::AHashSet;
use types::{acl::Acl, collection::Collection, keyword::Keyword, special_use::SpecialUse};
use utils::map::bitmap::Bitmap;
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
@@ -138,6 +141,11 @@ impl MailboxGet for Server {
JmapRights::rights::<Mailbox>(
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 {
JmapRights::all_rights::<Mailbox>()
}
+31 -1
View File
@@ -31,7 +31,7 @@ use jmap_proto::{
types::state::State,
};
use jmap_tools::{JsonPointerItem, Key, Map, Value};
use registry::schema::enums::StorageQuota;
use registry::schema::enums::{Permission, StorageQuota};
use std::future::Future;
use store::{
ValueKey,
@@ -613,6 +613,36 @@ impl MailboxSet for Server {
// Refresh ACLs
let current = update.map(|(_, current)| current);
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()
&& let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await
{
+62 -1
View File
@@ -8,6 +8,7 @@
use common::{
Server,
auth::AccessToken,
config::smtp::queue::QueueName,
network::{ServerInstance, stream::NullIo},
storage::index::ObjectIndexBuilder,
@@ -49,6 +50,7 @@ pub trait EmailSubmissionSet: Sync + Send {
fn email_submission_set<'x>(
&self,
request: SetRequest<'x, email_submission::EmailSubmission>,
access_token: &AccessToken,
instance: &Arc<ServerInstance>,
next_call: &mut Option<Call<RequestMethod<'x>>>,
) -> impl Future<Output = trc::Result<SetResponse<email_submission::EmailSubmission>>> + Send;
@@ -56,6 +58,7 @@ pub trait EmailSubmissionSet: Sync + Send {
fn send_message(
&self,
account_id: u32,
own_addresses_only: bool,
response: &SetResponse<email_submission::EmailSubmission>,
instance: &Arc<ServerInstance>,
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
@@ -68,6 +71,7 @@ impl EmailSubmissionSet for Server {
async fn email_submission_set<'x>(
&self,
mut request: SetRequest<'x, email_submission::EmailSubmission>,
access_token: &AccessToken,
instance: &Arc<ServerInstance>,
next_call: &mut Option<Call<RequestMethod<'x>>>,
) -> trc::Result<SetResponse<email_submission::EmailSubmission>> {
@@ -80,7 +84,14 @@ impl EmailSubmissionSet for Server {
let mut batch = BatchBuilder::new();
for (id, object) in request.unwrap_create() {
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?
{
Ok(submission) => {
@@ -110,6 +121,15 @@ impl EmailSubmissionSet for Server {
.assign_document_ids(account_id, Collection::EmailSubmission, 1)
.await
.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
.with_account_id(account_id)
.with_collection(Collection::EmailSubmission)
@@ -388,6 +408,7 @@ impl EmailSubmissionSet for Server {
async fn send_message(
&self,
account_id: u32,
own_addresses_only: bool,
response: &SetResponse<email_submission::EmailSubmission>,
instance: &Arc<ServerInstance>,
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
@@ -617,6 +638,46 @@ impl EmailSubmissionSet for Server {
.unarchive::<MessageMetadata>()
.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
let mut bcc_header = None;
if rcpt_to.is_empty() {
+4
View File
@@ -1762,6 +1762,10 @@ pub enum Permission {
SysJournalExport = 683,
// inbuxa: the security to-do list, accepting an item
SysSecurityAccept = 684,
// inbuxa: the deliverability check
SysDeliverabilityGet = 685,
SysDeliverabilityUpdate = 686,
SysDeliverabilityCheck = 687,
SysAccountGet = 219,
SysAccountCreate = 220,
SysAccountUpdate = 221,
+10 -1
View File
@@ -7102,6 +7102,9 @@ impl EnumImpl for Permission {
b"sysJournalSearch" => Permission::SysJournalSearch,
b"sysJournalExport" => Permission::SysJournalExport,
b"sysSecurityAccept" => Permission::SysSecurityAccept,
b"sysDeliverabilityGet" => Permission::SysDeliverabilityGet,
b"sysDeliverabilityUpdate" => Permission::SysDeliverabilityUpdate,
b"sysDeliverabilityCheck" => Permission::SysDeliverabilityCheck,
b"sysAccountGet" => Permission::SysAccountGet,
b"sysAccountCreate" => Permission::SysAccountCreate,
b"sysAccountUpdate" => Permission::SysAccountUpdate,
@@ -7803,6 +7806,9 @@ impl EnumImpl for Permission {
Permission::SysJournalSearch => "sysJournalSearch",
Permission::SysJournalExport => "sysJournalExport",
Permission::SysSecurityAccept => "sysSecurityAccept",
Permission::SysDeliverabilityGet => "sysDeliverabilityGet",
Permission::SysDeliverabilityUpdate => "sysDeliverabilityUpdate",
Permission::SysDeliverabilityCheck => "sysDeliverabilityCheck",
Permission::SysAccountGet => "sysAccountGet",
Permission::SysAccountCreate => "sysAccountCreate",
Permission::SysAccountUpdate => "sysAccountUpdate",
@@ -8497,6 +8503,9 @@ impl EnumImpl for Permission {
682 => Some(Permission::SysJournalSearch),
683 => Some(Permission::SysJournalExport),
684 => Some(Permission::SysSecurityAccept),
685 => Some(Permission::SysDeliverabilityGet),
686 => Some(Permission::SysDeliverabilityUpdate),
687 => Some(Permission::SysDeliverabilityCheck),
219 => Some(Permission::SysAccountGet),
220 => Some(Permission::SysAccountCreate),
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 {
+3
View File
@@ -34,6 +34,9 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls", "h
base64 = "0.23"
dns-update = { version = "0.5" }
psl = "2"
# inbuxa: the deliverability check
mail-auth = { version = "0.13" }
futures = "0.3"
[dev-dependencies]
+6
View File
@@ -146,6 +146,10 @@ impl BroadcastBatch<Vec<BroadcastEvent>> {
serialized.push(13u8);
let _ = serialized.write_leb128(*account_id);
}
// inbuxa: DL-15
BroadcastEvent::DeliverabilityCheck => {
serialized.push(14u8);
}
}
}
serialized
@@ -284,6 +288,8 @@ where
let account_id = self.messages.next_leb128().ok_or(())?;
Ok(Some(BroadcastEvent::EndSessions(account_id)))
}
// inbuxa: DL-15
14 => Ok(Some(BroadcastEvent::DeliverabilityCheck)),
_ => Err(()),
}
} else {
@@ -189,6 +189,12 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
.send(PushEvent::Revoke { account_id })
.await;
}
// inbuxa: DL-15: this node checks
// itself too
BroadcastEvent::DeliverabilityCheck => {
crate::inbuxa_deliverability::CHECK_NOW
.notify_one();
}
BroadcastEvent::QueueRefresh => {
if inner.shared_core.load().network.roles.outbound_mta {
let _ = inner
@@ -278,6 +284,7 @@ fn log_event(event: &BroadcastEvent) -> trc::Value {
BroadcastEvent::EndSessions(account_id) => {
trc::Value::Array(vec!["EndSessions".into(), (*account_id).into()])
}
BroadcastEvent::DeliverabilityCheck => "DeliverabilityCheck".into(),
BroadcastEvent::RegistryChange(change) => match change {
RegistryChange::Insert(id) => trc::Value::Array(vec![
"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
pub mod inbuxa_lock_expiry;
pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1
pub mod inbuxa_deliverability; // inbuxa: the deliverability check
pub mod state_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_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(inner);
}
+70 -10
View File
@@ -176,16 +176,23 @@ impl TlsaLookup for Server {
return mail_auth::common::resolver::mock_resolve(key.as_ref());
}
let tlsa_lookup = match self
.core
.smtp
.resolvers
.dnssec
.resolver
.tlsa_lookup(Name::from_str_relaxed(key.as_ref())?)
.await
// Through `validated_lookup`, like the MX and address lookups: a TLSA
// name that is a signed CNAME to a name with no TLSA record (seen at
// `_25._tcp.mail.usefulinsight.com`, behind Hetzner's resolvers) is
// otherwise called bogus, and the message waits on it until it
// expires.
let tlsa_lookup = match validated_lookup(
&self.core.smtp.resolvers.dnssec.resolver,
self.core.smtp.resolvers.dns.resolver(),
Name::from_str_relaxed(key.as_ref())?,
RecordType::TLSA,
)
.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) => {
if let Some(denial) = NegativeAnswer::from_error(&err) {
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.
// - 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
// 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.
// A signed CNAME is followed and the lookup repeated at its target. Otherwise
@@ -869,4 +877,56 @@ mod tests {
assert!(validated.insecure);
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_rules! brand_version {
() => {
"2026.9.30.1"
"2026.10.6"
};
}
+15
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::DocumentId;
@@ -36,6 +38,13 @@ impl FromStr for Id {
type 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;
for &ch in s.as_bytes() {
@@ -261,4 +270,10 @@ mod tests {
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));
}
}
+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
domains.
- Factual statements are allowed and required: "a fork of Stalwart",
"compatible with Stalwart 0.16 data". Upstream copyright notices stay on
every file they cover.
- Factual statements are allowed and required: "started as a fork of
Stalwart", "compatible with Stalwart 0.16 data". Upstream copyright notices
stay on every file they cover.
- **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
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
2026-09-19). Where the name appears outside the product — the site, release
announcements, documentation — it carries the attribution: Stalwart is a
trademark of Stalwart Labs LLC, and INBUXA is not affiliated with or
endorsed by them. The fork relationship is stated in the provenance or
license section, and the migration path names the server it migrates from,
because an operator searching for it has to find it. The 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. No comparison, favorable or otherwise: what INBUXA
offers is stated on its own terms.
trademark of Stalwart Labs LLC, and inbuxa is not affiliated with or
endorsed by them. The lineage is told in the past tense: inbuxa *started as*
a fork of Stalwart and its server *descends from* it; never "built on
Stalwart" or "Stalwart with extras". It is told once, in the license or
about section, and the clean-room provenance lives on one documentation
page that everything else links to. The migration path names the server it
migrates from, because an operator searching for it has to find it. The
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
@@ -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 **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
**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
- 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).
- Product name: whether the shipped product is called inbuxa-server or
something else inside the INBUXA brand.
+19 -19
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 |
|---|---|---|
| **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 |
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.
## 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
refuse endpoints by path and client IP. JMAP administration shares `/jmap`
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
store (`sealedCredentials: {username, password}`), sending it on every
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.
- **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
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.
- **C-3.** A breaking change to anything in this document bumps `contract`.
Adding optional fields doesn't.
@@ -117,7 +117,7 @@ Each has an ID, and tests name the IDs they check.
- **`inbuxa-admin`**: a public client (no secret), authorization code with
PKCE S256, redirect URI `{adminUrl}/oauth/callback`.
- **`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`.
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,
@@ -159,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`,
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
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.
- **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
@@ -170,10 +170,10 @@ Each has an ID, and tests name the IDs they check.
### 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
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.
- **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
@@ -193,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
access-token lifetime.
- **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
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`.
### Cross-origin
@@ -237,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
account signing in through a mail client can't administer the server with
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`,
granted only to `ihasmail-inbuxa`, and limited to those object types, plus
`x:Metric` get and query for the dashboard's message cards (monitoring
@@ -250,7 +250,7 @@ Each has an ID, and tests name the IDs they check.
### 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
difference is that it authenticates with its token rather than the password.
@@ -279,7 +279,7 @@ Each has an ID, and tests name the IDs they check.
`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).
ihasmail-inbuxa confirms a typed password, which it does before creating
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
@@ -294,7 +294,7 @@ Each has an ID, and tests name the IDs they check.
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
ihasmail-inbuxa's password sign-in on 2026-09-22, before it moved to OAuth.
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.
@@ -304,8 +304,8 @@ Each has an ID, and tests name the IDs they check.
(SPEC.md §6.2). In bootstrap mode, CORS is permissive (C-16) and the
recovery administrator applies.
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
client secret once, for the installer to write into ihasmail-inbuxa's
registers both first-party clients (C-6). For inbuxa-webmail it returns the
client secret once, for the installer to write into inbuxa-webmail's
environment.
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).
@@ -376,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
third-party client with a non-first-party redirect shows the consent page
(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).
7. Revoking one grant stops that session within one access-token lifetime,
leaves others working, and "sign out other sessions" keeps the current one
@@ -388,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).
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).
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).
13. With `adminNetworks` set, an `inbuxa:admin` request from outside is refused
(C-20).
@@ -402,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.
3. API keys and app passwords with explicit scopes (C-21): what upstream's
`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.
- Whether the front ends need anything at cutover, or follow separately
(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
webmail, mail flowing.
- 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
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).
- **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
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).
- **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`.
- **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.
- **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
@@ -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 |
| 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 |
| 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 |
@@ -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
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
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
OAuth (contract C-8): with Basic auth there's no client name, so a mask
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.
- **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
+1 -1
View File
@@ -411,7 +411,7 @@ unchanged.
## 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).
- 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-19, MT-19a:** built, except the submission-time warning, **deferred**
(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
ihasmail-inbuxa.
inbuxa-webmail.
- **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.
- **Known limits, not requirements of this spec:**
+1 -1
View File
@@ -412,7 +412,7 @@ check it and the fork keeps it.
## 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).
- **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
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).
- **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.
- **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.
- **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.
+1 -1
View File
@@ -103,7 +103,7 @@ conflicts.
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
ihasmail can wait for ihasmail-inbuxa.
ihasmail can wait for inbuxa-webmail.
2. Acceptance tests 1 to 14 pass as integration tests
(`tests/src/system/tenant.rs`), with the `pending-rebuild` gate removed
from the tenant call.
+22
View File
@@ -166,6 +166,18 @@ reason = ["content"]
file = "inbuxa_journal_entry.rs"
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"]
file = "inbuxa_legal_hold.rs"
default = "none"
@@ -244,6 +256,16 @@ retention = "unbounded"
changedBy = ["identifier"]
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"]
file = "inbuxa_ai_limits.rs"
default = "none"
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
D8e0s1e4Umau4gRh5MEW24KtsawKGPNvS-6LWrIFbtQ
OUcXqvEO48gT6mdw9zVPWfZ-QfMKFj5xvxa-0iE4L9U
+3 -3
View File
@@ -12,7 +12,7 @@ Boots the debug binary and checks that:
- 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 ihasmail-inbuxa's password check relies on;
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.
@@ -64,7 +64,7 @@ def start(env=None):
"-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 ihasmail-inbuxa (C-6).
# 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:
@@ -243,7 +243,7 @@ def main():
status, _, _ = request("/api/account", f"Bearer {access}")
check(status == 200, f"a token works on /api/account ({status})")
# ihasmail-inbuxa's password check before an app password: its own client,
# 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))
+11
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use super::{AssertResult, ImapConnection, Type, append::assert_append_message};
@@ -69,6 +71,15 @@ pub async fn test(
.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
imap_john.send("LIST \"\" \"*\"").await;
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-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::utils::server::TestServer;
@@ -713,6 +715,35 @@ pub async fn test(test: &TestServer) {
.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
admin
.registry_update_object(
+4
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::utils::server::TestServerBuilder;
@@ -22,6 +24,7 @@ pub mod compliance;
pub mod contacts;
pub mod core;
pub mod files;
pub mod group_share;
pub mod mail;
pub mod principal;
@@ -219,6 +222,7 @@ pub async fn jmap_tests() {
calendar::identity::test(&test).await;
calendar::acl::test(&test).await;
group_share::test(&test).await;
principal::get::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
// LP-19, whether the legacy protocols are open to it, and
// 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": {}
}
}
+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),
"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`.
+100
View File
@@ -540,10 +540,110 @@ pub async fn test(test: &mut TestServer) {
"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
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`.
#[ignore]
#[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();
}
}
+2
View File
@@ -12,10 +12,12 @@ pub mod ai;
pub mod ai_calibration;
pub mod ai_explain;
pub mod account_lock; // inbuxa: account lock with delegation
pub mod sharing_policy; // inbuxa: MA-C, who may share mail
pub mod legal_hold; // inbuxa: legal hold
pub mod compliance; // inbuxa: the compliance roles
pub mod mail_rules; // inbuxa: DLP and mail flow rules
pub mod security_acceptances; // inbuxa: accepted security to-do items
pub mod deliverability; // inbuxa: the deliverability check
pub mod journal; // inbuxa: journaling
pub mod audit; // inbuxa: the audit log
pub mod authorization;
+237
View File
@@ -0,0 +1,237 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Who may share mail (multi-account spec, MA-C): the server's switch and a
//! tenant's, which can only be stricter. Off refuses new shares and stops
//! honoring old ones, which come back when it's on again; locks and shared
//! mailboxes are never affected.
use crate::utils::{account::Account, server::TestServerBuilder};
use registry::schema::{
prelude::{ObjectType, Property},
structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles},
};
use serde_json::{Value, json};
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:inbuxa:jmap",
];
impl Account {
async fn one(&self, method: &str, arguments: Value) -> Value {
let response = self.jmap_request(USING, json!([[method, arguments, "0"]])).await;
response
.0
.pointer("/methodResponses/0")
.cloned()
.unwrap_or_else(|| panic!("{method}: {}", response.0))
}
async fn policy(&self, update: Value) -> Value {
self.one(
"inbuxa:SharingPolicy/set",
json!({"accountId": self.id_string(), "update": update}),
)
.await[1]
.clone()
}
async fn inbox(&self) -> String {
let response = self
.one(
"Mailbox/get",
json!({"accountId": self.id_string(), "ids": null, "properties": ["role"]}),
)
.await;
response[1]["list"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "inbox")
.unwrap_or_else(|| panic!("no Inbox: {response}"))["id"]
.as_str()
.unwrap()
.to_string()
}
/// Shares the Inbox with `with` (read), or stops with `None` rights.
async fn share_inbox(&self, with: &Account, read: bool) -> Value {
let inbox = self.inbox().await;
let rights = if read { json!({"mayReadItems": true}) } else { Value::Null };
self.one(
"Mailbox/set",
json!({"accountId": self.id_string(),
"update": {inbox: {format!("shareWith/{}", with.id_string()): rights}}}),
)
.await[1]
.clone()
}
async fn sees(&self, other: &Account) -> bool {
self.jmap_session_object().await.0["accounts"]
.get(other.id_string())
.is_some()
}
async fn mail_sharing(&self) -> Value {
self.jmap_session_object().await.0["accounts"][self.id_string()]["accountCapabilities"]
["urn:inbuxa:jmap"]["mailSharing"]
.clone()
}
}
/// Runs these tests alone: `cargo test -p tests sharing_policy_tests -- --ignored`.
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn sharing_policy_tests() {
let mut test = TestServerBuilder::new("sharing_policy_tests")
.await
.with_default_listeners()
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
println!("Running sharing policy tests...");
let tenant = admin
.registry_create_object(Tenant {
name: "School".to_string(),
..Default::default()
})
.await;
admin
.registry_create_object(Domain {
name: "school.example.org".to_string(),
is_enabled: true,
member_tenant_id: Some(tenant),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
..Default::default()
})
.await;
let ann = admin
.create_user_account("[email protected]", "ann-secret-6610", "Ann", &[], vec![])
.await;
let ben = admin
.create_user_account("[email protected]", "ben-secret-6611", "Ben", &[], vec![])
.await;
let head = admin
.create_user_account("[email protected]", "head-secret-6612", "Head", &[], vec![])
.await;
admin
.registry_update_object(
ObjectType::Account,
head.id(),
json!({Property::Roles: UserRoles::Admin}),
)
.await;
let carl = admin
.create_user_account("[email protected]", "carl-secret-6613", "Carl", &[], vec![])
.await;
// Shares never cross tenants (MT-3), so Carl's neighbour is outside too
let dan = admin
.create_user_account("[email protected]", "dan-secret-6614", "Dan", &[], vec![])
.await;
let tenant_id = tenant.to_string();
// MA-10: on by default
assert_eq!(ann.mail_sharing().await, true, "MA-10");
let response = ann.share_inbox(&ben, true).await;
assert!(response["updated"].is_object(), "MA-10: {response}");
assert!(ben.sees(&ann).await, "MA-10: the share gives nothing");
// The school turns mail sharing off, as its own administrator
let response = head
.policy(json!({tenant_id.as_str(): {"mailSharing": "disabled"}}))
.await;
assert!(response["updated"].is_object(), "MA-13: {response}");
// ...but can't touch the server's
let response = head
.policy(json!({"singleton": {"mailSharing": "disabled"}}))
.await;
assert_eq!(response["notUpdated"]["singleton"]["type"], "forbidden", "MA-13: {response}");
// MA-11: what was shared gives nothing now, and nothing new is shared
assert_eq!(ann.mail_sharing().await, false, "MA-11");
assert!(!ben.sees(&ann).await, "MA-11: an old share still honored");
let response = ann.share_inbox(&head, true).await;
assert_eq!(
response["notUpdated"].as_object().and_then(|o| o.values().next()).map(|e| e["type"].clone()),
Some(json!("forbidden")),
"MA-11: {response}"
);
// MA-12: a shared mailbox isn't anyone's share, and keeps working
let office = admin
.create_user_account("[email protected]", "office-secret-6615", "Office", &[], vec![])
.await;
let response = admin
.one(
"inbuxa:AccountLock/set",
json!({"accountId": admin.id_string(), "create": {"o": {"accountId": office.id_string(),
"kind": "sharedMailbox",
"delegates": [{"accountId": ben.id_string(), "access": "organize"}]}}}),
)
.await;
assert!(response[1]["created"]["o"].is_object(), "MA-12: {response}");
assert!(ben.sees(&office).await, "MA-12: the switch reached a shared mailbox");
// Outside the school nothing changed
let response = carl.share_inbox(&dan, true).await;
assert!(response["updated"].is_object(), "MA-C: another tenant's switch reached Carl: {response}");
assert!(dan.sees(&carl).await, "MA-C");
// A tenant can only be stricter than the server
let response = admin
.policy(json!({"singleton": {"mailSharing": "disabled"}}))
.await;
assert!(response["updated"].is_object(), "MA-C: {response}");
let response = head
.policy(json!({tenant_id.as_str(): {"mailSharing": "enabled"}}))
.await;
assert_eq!(
response["notUpdated"][tenant_id.as_str()]["type"],
"forbidden",
"MA-C: {response}"
);
assert!(!dan.sees(&carl).await, "MA-C: the server's switch didn't reach Carl's share");
// Turned back on, the old shares are honored again
admin
.policy(json!({"singleton": {"mailSharing": null}}))
.await;
head.policy(json!({tenant_id.as_str(): {"mailSharing": null}}))
.await;
assert!(ben.sees(&ann).await, "MA-C: an old share didn't come back");
assert!(dan.sees(&carl).await, "MA-C");
// Ending a share is always allowed, even while sharing is off
head.policy(json!({tenant_id.as_str(): {"mailSharing": "disabled"}}))
.await;
let response = ann.share_inbox(&ben, false).await;
assert!(response["updated"].is_object(), "MA-11: ending a share refused: {response}");
head.policy(json!({tenant_id.as_str(): {"mailSharing": null}}))
.await;
assert!(!ben.sees(&ann).await, "MA-11: the ended share came back");
// MA-14: every change is in the audit log
let query = admin
.one(
"inbuxa:AuditEvent/query",
json!({"accountId": admin.id_string(), "filter": {"targetKind": "inbuxa:SharingPolicy"}}),
)
.await;
assert!(
query[1]["ids"].as_array().is_some_and(|ids| ids.len() >= 5),
"MA-14: {query}"
);
if test.is_reset() {
test.temp_dir.delete();
}
}
+36
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::utils::{server::TestServer, webdav::GenerateTestDavResource};
@@ -407,6 +409,40 @@ pub async fn test(test: &TestServer) {
.with_status(StatusCode::NO_CONTENT);
}
// inbuxa: MA-D0: Jane, a member of the Support group, can make a folder in
// the group's account but can't share it on
let member_client = test.account("[email protected]").webdav_client();
let john_principal = format!(
"{}/john%40example.com/",
DavResourceName::Principal.base_path()
);
for resource_type in [
DavResourceName::File,
DavResourceName::Cal,
DavResourceName::Card,
] {
let group_folder = format!(
"{}/support%40example.com/group-folder/",
resource_type.base_path()
);
member_client
.request("MKCOL", &group_folder, "")
.await
.with_status(StatusCode::CREATED);
member_client
.acl(&group_folder, john_principal.as_str(), ["read"])
.await
.with_status(StatusCode::FORBIDDEN);
member_client
.request("DELETE", &group_folder, "")
.await
.with_status(StatusCode::NO_CONTENT);
}
// Reaching the group's calendars and address books made its defaults
member_client
.delete_default_containers_by_account("[email protected]")
.await;
sharee_client.delete_default_containers().await;
owner_client.delete_default_containers().await;
test.assert_is_empty().await;