Compare commits

...
Author SHA1 Message Date
jcoffey-dev 77404353d5 Merge pull request 'Release 2026.9.28.6' (#107) from hotfix/2026.9.28.6 into release/2026.9.28.6
publish / version (push) Successful in 11s
publish / publish-amd64 (push) Successful in 31m53s
publish / release (push) Successful in 1s
publish / publish-arm64 (push) Successful in 50m13s
publish / binaries (push) Successful in 42s
publish / announce (push) Successful in 22s
2026-09-29 01:29:48 +00:00
jcoffey-dev d2b0750e1d Release 2026.9.28.6
ci / fork-checks (pull_request) Successful in 45s
ci / build (pull_request) Successful in 6m10s
2026-09-28 18:23:26 -07:00
jcoffey-dev 0232e59283 Ports: each node checks the others' ports from outside 2026-09-28 18:16:33 -07:00
jcoffey-dev 9fd1d32799 Explain: don't prepare answers for date fields 2026-09-28 18:16:33 -07:00
jcoffey-dev eafe8bfbfd Webhooks: send one sample event to a saved webhook 2026-09-28 18:16:33 -07:00
jcoffey-dev 213c7f0362 Merge pull request 'Release 2026.9.28.5' (#98) from release/2026.9.28.5-pr into main
ci / fork-checks (push) Successful in 15s
publish / version (push) Successful in 12s
ci / build (push) Canceled after 7m39s
publish / publish-amd64 (push) Successful in 30m34s
publish / release (push) Successful in 30s
publish / publish-arm64 (push) Successful in 57m44s
publish / binaries (push) Successful in 51s
publish / announce (push) Successful in 23s
2026-09-29 00:05:39 +00:00
jcoffey-dev f7fb115a0f Merge pull request 'Spec: data loss prevention and mail flow rules' (#97) from spec/dlp-mail-flow-rules into main
ci / fork-checks (push) Successful in 44s
ci / build (push) Canceled after 6m41s
2026-09-28 23:58:53 +00:00
jcoffey-dev 3199a6f1fb Release 2026.9.28.5
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Successful in 7m37s
2026-09-28 16:57:47 -07:00
jcoffey-dev 2b45a2e412 Spec: approved
ci / fork-checks (pull_request) Successful in 46s
ci / build (pull_request) Successful in 16m58s
2026-09-28 16:41:38 -07:00
jcoffey-dev 3fadf82909 Spec: John's answers, and the detector catalog answer 6 asks for
ci / fork-checks (pull_request) Successful in 19s
ci / build (pull_request) Successful in 7m23s
All six settled as recommended. Answer 6 ("and any other recognized and protected PII") becomes a catalog of identifiers with published formats and checks, grouped by region, each either checked by its check digit or counted only beside a corroborating word, plus templates named for what they find. Data with no number to find is covered by word lists and not claimed as detection. Office documents are read; PDF counts as can't be inspected.
2026-09-28 16:04:31 -07:00
jcoffey-dev 2a851ea230 Spec: data loss prevention and mail flow rules
ci / fork-checks (pull_request) Successful in 46s
ci / build (pull_request) Successful in 4m52s
Phase 1: one native rule engine at DATA, after the system Sieve script,
for both DLP policies and transport rules. DLP checks outgoing mail
with counted detectors (payment cards, IBAN, US SSN, word lists,
patterns) and blocks, warns with an audited override, or holds for
review. Held mail stays in the queue unscheduled, with its own review
record, so the queue's stored format is unchanged. Matches go to the
audit log without the matched text. Six questions for John at the end.
2026-09-28 15:57:53 -07:00
jcoffey-dev 0502eb45ed Merge pull request 'DNS test: expect the account-configuration digest inbuxa publishes' (#96) from fix/dns-test-pacc-digest into main
ci / fork-checks (push) Successful in 34s
ci / build (push) Successful in 24m56s
2026-09-28 22:48:02 +00:00
jcoffey-dev 11ba361c8c DNS test: expect the account-configuration digest inbuxa publishes
ci / fork-checks (pull_request) Successful in 55s
ci / build (pull_request) Successful in 18m46s
The automation suite's DNS test compared the published zone with one
copied from upstream v0.16.22. Its _ua-auto-config record carries a
SHA-256 of the account-configuration (PACC) document, and that document
names the provider as the brand, which the rebrand changed. The digest
the server publishes is right; the expected zone still held upstream's.

With the new digest the whole automation suite passes: ACME (including
the not-due reschedule check), DKIM, DNS and RFC 2136. It had been
failing at this point on main since the rebrand.
2026-09-28 15:29:06 -07:00
jcoffey-dev ba75ab4ecc Merge pull request 'ACME: a renewal that isn't due yet is rescheduled, not failed for good' (#93) from fix/acme-not-due-reschedule into main
ci / fork-checks (push) Successful in 18s
ci / build (push) Successful in 42m1s
2026-09-28 21:52:17 +00:00
jcoffey-dev afffa0fc96 Merge pull request 'Try a directory before anything signs in through it' (#95) from feature/directory-test into main
ci / fork-checks (push) Canceled after 21s
ci / build (push) Canceled after 21s
2026-09-28 21:51:56 +00:00
jcoffey-dev 32b22d0828 Merge pull request 'Schema: each expression field says which values and variables it accepts' (#91) from feature/expression-schema into main
ci / fork-checks (push) Canceled after 20s
ci / build (push) Canceled after 20s
2026-09-28 21:51:34 +00:00
jcoffey-dev 558b776e9f Merge pull request 'Release 2026.9.28.4' (#94) from release/2026.9.28.4-pr into main
ci / fork-checks (push) Successful in 15s
publish / version (push) Successful in 2m18s
publish / publish-amd64 (push) Successful in 29m39s
publish / release (push) Successful in 1s
ci / build (push) Successful in 59m47s
publish / publish-arm64 (push) Successful in 44m48s
publish / binaries (push) Successful in 45s
publish / announce (push) Successful in 23s
2026-09-28 19:46:36 +00:00
jcoffey-dev ac3a63973d Try a directory before anything signs in through it
ci / fork-checks (pull_request) Successful in 59s
ci / build (pull_request) Successful in 4m5s
POST /api/directory/test takes a saved directory's id, an address and
optionally a password, and answers whether the directory opened, what a
recipient lookup of the address finds (account or group, with its
aliases, groups and name), and whether the password signs in. A wrong
password is told apart from a directory that can't be reached or is set
up wrong.

It calls the directory itself, below the sign-in path: a test never
creates or updates an account, never counts toward the sign-in ban and
doesn't depend on which domains use the directory. A password hash a
directory returns is never sent back. OIDC directories report their
discovered issuer; they take no passwords.

For server-level administrators with directory update permission. The
console's guided directory setup uses it to test a real person before
any domain is switched over.
2026-09-28 12:38:58 -07:00
jcoffey-dev e61a475859 Schema: each expression field says which values and variables it accepts
ci / fork-checks (pull_request) Successful in 52s
ci / build (pull_request) Successful in 5m24s
The registry knows, for every expression field, the constants it may
evaluate to and the variables its conditions may read, and enforces both.
The schema served to INBUXA Admin described every one as a bare
x:Expression, so the console could offer nothing better than free text.

tools/fork/expr-schema.py reads those contexts from the generated registry
code and writes them onto each field's type as
expression: {constants, variables}. All 124 expression fields are covered.
CI runs it with --check so the schema can't drift from the registry.
2026-09-28 12:32:28 -07:00
jcoffey-dev 6c862e4971 Release 2026.9.28.4
ci / fork-checks (pull_request) Successful in 15s
ci / build (pull_request) Successful in 23m29s
The personal-data catalog and what it feeds: the data inventory and its
snapshots (#83, #89), the Compliance Officer roles (#88), the Compliance
Overview and Data Inventory menu entries (#92). Log file retention
(#87), privacy defaults for new installs (#85, #90), webhooks that send
only the events they name (#82), and upstream v0.16.24 (#84), whose
spam rules updates keep what an admin edited.

Explain: 12 settings asked about (upstream's new createdAt fields, the
certificate dates and the webhook events policy), with the release's
recommended model built locally; 705 answers carry over.
2026-09-28 12:22:34 -07:00
jcoffey-dev beb6c33e63 ACME: a renewal that isn't due yet is rescheduled, not failed for good
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 16m46s
When a valid certificate already covered a domain's names (one stored
by hand before the domain was switched to automatic, for instance), the
renewal task ended with NotDue, which the task manager treats as a
permanent failure. Nothing rescheduled it, so the certificate expired
unrenewed. The renewal now returns a new AcmeRenewal task due when the
certificate falls due, the same way a successful renewal does, and logs
it as a backoff.

The ACME integration suite checks that renewing again right after
issuance hands back one AcmeRenewal for that domain, due at the
certificate's renewal point.
2026-09-28 12:15:05 -07:00
jcoffey-dev 5a73a1183a Merge pull request 'Compliance menu: Overview and Data Inventory first' (#92) from feature/compliance-pages-nav into main
ci / fork-checks (push) Successful in 50s
ci / build (push) Successful in 33m2s
2026-09-28 18:48:52 +00:00
jcoffey-dev ac204078eb Merge pull request 'New installs start with the hashed-address blocklist off, and DNSBL zones read right' (#90) from feature/d5-msbl-off into main
ci / fork-checks (push) Successful in 15s
ci / build (push) Canceled after 16m10s
2026-09-28 18:32:41 +00:00
jcoffey-dev 728586998b Catalog: what the Overview showed wrong
ci / fork-checks (pull_request) Successful in 19s
ci / build (pull_request) Successful in 44m19s
Seen in the console's first Overview. x:DmarcTroubleshoot and
x:SpamClassify are one-off actions whose results come back in the
response, not kept: object-life, not unbounded. Tasks go when done
(only a failed one's status may stay, still unconfirmed): object-life.
x:Log reads the log files, so it follows inbuxa:LogSettings.keepForDays.
x:TracerLog, x:WebHook and the OpenTelemetry tracers are configuration:
their credential fields stay classified, but they are no longer listed
in the inventory, where they counted as always sent off the server
even with none configured; the log-file, webhooks and otel-tracer
sources carry what they send.
2026-09-28 11:03:55 -07:00
jcoffey-dev cca49de92c Compliance menu: Overview and Data Inventory first
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Canceled after 9m28s
Personal-data catalog spec, §8: the Compliance section in the console
reads Overview, Data Inventory, Legal Holds, Audit Log, Locked
Accounts. The two new entries are hand-built console pages
(CustomComponent/ComplianceOverview, CustomComponent/DataInventory),
shown to people with sysComplianceGet.

A console from before these pages shows the links and answers
"Unknown component", so the console that has them should be deployed
with the server release that carries this. Schema edited as the fork's
earlier Compliance entries were, hash updated.
2026-09-28 10:54:32 -07:00
jcoffey-dev b20b09f81a New installs start with the hashed-address blocklist off, and DNSBL zones read right
ci / fork-checks (pull_request) Successful in 1m3s
ci / build (pull_request) Successful in 1h11m16s
Personal-data catalog spec, default D5 (settled 2026-09-28; built after
the v0.16.24 import's spam-rules loader landed). msbl.org's EBL is sent
a SHA-1 of every email address it's asked about. A new install's first
boot now leaves a note, and the rules update, once the bundled rules
are in, switches STWT_MSBL_EBL_EMAIL off and forgets the note, so it
happens once; the loader keeps that switch through later updates. An
existing server has no note and keeps every blocklist as it is.

Also fixes the data inventory's DNSBL endpoints: a zone is an
expression (`ip_reverse + '.zen.spamhaus.org'`, conditional branches,
`hash(email, 'sha1') + '.ebl.msbl.org'`), and the zone names are now
the quoted literals that start with a dot, from every branch, rather
than the expression's text.

Tested: unit test for the zone rule; the compliance system test (no
note, no change; the inventory lists ebl.msbl.org, not a hash; with the
note the blocklist goes off; the note works once); the system suite;
fork checks.
2026-09-28 10:21:15 -07:00
jcoffey-dev 7bbbff0648 Merge pull request 'Evaluate the personal-data catalog: the data inventory and its history' (#89) from feature/data-inventory into main
ci / fork-checks (push) Successful in 36s
ci / build (push) Successful in 43m2s
2026-09-28 17:15:10 +00:00
jcoffey-dev a8fb10458b Evaluate the personal-data catalog: the data inventory and its history
ci / fork-checks (pull_request) Successful in 57s
ci / build (pull_request) Successful in 15m6s
Personal-data catalog spec, §6 (Phase 3c).

inbuxa:DataInventory/get evaluates the catalog against the server's
live settings and says what this server holds: for each source and each
object that can hold personal data, its categories and whose data it
is, whether it is collected here at all, what bounds its retention (the
live value of the setting that does, or unbounded), whether it leaves
the host and to which endpoints, and a summary. Every host that
receives something is listed once as a candidate processor with what it
receives. Inside a tenant it answers with the tenant's slice and none
of the server's processors. Read-only, with sysComplianceGet.

inbuxa:InventorySnapshot/get is the history: a dated copy of the
evaluated inventory, recorded when it changes -- after a registry write
to an object the inventory reads, after inbuxa's log, audit or AI
settings change, and on the daily clean-up -- and kept as long as the
audit log's records. ids: null lists every snapshot, newest first; the
full inventory only when asked for.

The catalog is embedded and parsed at start (new dependency: toml,
MIT/Apache); the evaluation is a pure function of it and the live
facts, so each configuration is tested without a server. Loopback
endpoints stay on the host; any other configured endpoint leaves it.

Tested: unit tests for the evaluation (a new install's defaults, an
external blob store, a hosted AI endpoint, telemetry off, a tenant's
slice, hosts from URLs, loopback), snapshots, and the fact gathering's
store and duration rules; the compliance system test, extended (the
officer reads the inventory, a plain user is refused, a tenant's
officer sees its slice and no processors, a webhook to another host
becomes a processor and a snapshot names x:WebHook, a retention change
reads through); the system, audit, legal hold and account lock suites;
fork checks. The system suite failed once of three runs with an email
import's blob not found, in antispam.rs; the same happened once in
purge.rs on the previous branch. Nothing here touches uploads; noted
for a separate look.
2026-09-28 09:59:48 -07:00
jcoffey-dev c61497e2b2 Merge pull request 'Add the compliance permission and the Compliance Officer roles' (#88) from feature/compliance-roles into main
ci / fork-checks (push) Successful in 40s
ci / build (push) Canceled after 40m52s
2026-09-28 16:34:17 +00:00
jcoffey-dev 7285b3e38a Merge main (upstream v0.16.24) into feature/compliance-roles
ci / fork-checks (pull_request) Successful in 15s
ci / build (pull_request) Successful in 7m33s
The schema conflicted as a binary file: taken from main and the one
edit here re-applied (sysComplianceGet after sysLegalHoldExport). The
import kept the permission count at 673, so the new id stays 673.

Retested on the merged tree in its own target directory: the
compliance and system suites pass. One earlier system run failed in
purge.rs (an imported blob not found) and didn't recur.
2026-09-28 09:26:27 -07:00
jcoffey-dev 9893452ca2 Merge pull request 'Merge upstream v0.16.24' (#84) from merge/upstream-v0.16.24 into main
ci / fork-checks (push) Successful in 54s
ci / build (push) Canceled after 39m0s
2026-09-28 15:55:16 +00:00
jcoffey-dev 63adb4e2b8 Add the compliance permission and the Compliance Officer roles
ci / fork-checks (pull_request) Successful in 31s
ci / build (pull_request) Successful in 11m31s
Personal-data catalog spec, §7 (settled 2026-09-28).

sysComplianceGet (673) sees the data inventory and compliance
overview: superusers and, for their tenant's slice, tenant
administrators, by default and through the one-time grants on servers
that already have their roles stored.

A Compliance Officer role at server level holds it with reading and
exporting the audit log, placing, widening, releasing and exporting
legal holds, seeing account locks, and reading accounts, lists,
domains, tenants and roles. It changes no server setting, creates or
deletes no account, and can't shorten audit retention.

A tenant's accounts can hold only roles of their own tenant (MT-3), so
the tenant role is one "Compliance Officer" role per tenant, without
holds (LH-13): made once for every tenant a server has, and whenever a
tenant is created. While nobody holds it, it is removed with its tenant
so it doesn't block the delete, and put back if the delete is refused
for another reason. Both roles carry a user's own permissions too,
since roles given to a person replace the default user role, which a
tenant's accounts can't hold anyway.

Every server makes these once, new or existing -- the built-in roles
are only made on a server with none -- and records each under P c, so a
role an administrator deletes stays deleted.

Tested: unit tests (neither role changes a setting beyond a user's
own; holds for the server's officer only; per-place records); a new
compliance system test (one server-level role; an officer reads the
audit log, places and releases a hold, and is refused a setting, an
account and audit retention; a tenant gets its role, whose holder reads
the tenant's audit log and no holds; a tenant with an unused role is
deleted and the role goes with it); the system, audit, legal hold,
account lock and SCIM suites; fork checks. The directory suite needs
its LDAP container and wasn't run here.
2026-09-28 08:50:17 -07:00
jcoffey-dev d107c1b2bb Merge pull request 'Keep rotated log files for a set number of days' (#87) from feature/log-retention into main
ci / fork-checks (push) Successful in 14s
ci / build (push) Successful in 23m17s
2026-09-28 15:27:34 +00:00
jcoffey-dev 1d5a49409f Keep rotated log files for a set number of days
ci / fork-checks (pull_request) Successful in 2m28s
ci / build (pull_request) Successful in 3m47s
Personal-data catalog spec, default D1 (settled 2026-09-28): log files
were never deleted. inbuxa:LogSettings.keepForDays says how many days
rotated log files are kept; unset (null) keeps every file, as before,
and a new install sets 30 days.

It is a fork-owned setting, stored under T + l as audit retention is,
not a field on x:TracerLog: that object is also stored inside
x:Bootstrap with a field after it, so a new field would change
x:Bootstrap's stored format. Server-level, with the tracers'
permissions (sysTracerGet, sysTracerUpdate); changes are in the audit
log, before and after.

Log files are local, so every node deletes its own: hourly, and at once
when the setting changes on that node. Only regular files named
<prefix>.<something> in each enabled log tracer's directory, last
changed more than the limit ago, are removed; the file being written is
never that old, and nothing else in the directory is touched. Minimum
one day. The catalog classifies inbuxa:LogSettings and points the log
file's retention at it.

Tested: unit tests for the file rule (only this log's old files; the
current file, other files and directories stay) and a purge on disk;
the system suite, which reads, sets, refuses zero, restores null and
checks the audit records; fork checks.
2026-09-28 08:23:36 -07:00
jcoffey-dev 80d6c09c59 Merge main into merge/upstream-v0.16.24
ci / fork-checks (pull_request) Successful in 21s
ci / build (pull_request) Successful in 35m18s
The schema, which both sides changed, merged as JSON with no conflicts.
The personal-data catalog (#83) gains upstream's new x:DnsServerPowerDns:
nothing personal but its API key, like the other DNS providers.
2026-09-28 08:19:24 -07:00
jcoffey-dev 480d93f4d6 Merge pull request 'Spec: settle where log retention lives and when D5 is built' (#86) from spec/d1-d5-settled into main
ci / fork-checks (push) Successful in 15s
ci / build (push) Canceled after 12m46s
2026-09-28 15:14:47 +00:00
jcoffey-dev f47371b3a1 Spec: settle where log retention lives and when D5 is built
ci / fork-checks (pull_request) Successful in 15s
ci / build (pull_request) Successful in 4m50s
D1 becomes a fork-owned setting, as audit retention is, because a field
on x:TracerLog would change x:Bootstrap's stored format. D5 waits for
the v0.16.24 import's reworked spam-rules loader.
2026-09-28 08:09:32 -07:00
jcoffey-dev bdd97c5828 Merge pull request 'New-install privacy defaults, and expired bans purged daily' (#85) from feature/new-install-privacy-defaults into main
ci / fork-checks (push) Successful in 1m20s
ci / build (push) Canceled after 23m15s
2026-09-28 14:51:23 +00:00
jcoffey-dev a0ffdb8071 New-install privacy defaults, and expired bans purged daily
ci / fork-checks (pull_request) Successful in 48s
ci / build (pull_request) Successful in 6m52s
Personal-data catalog spec, defaults D2, D3, D4, D6 and D7 (settled
2026-09-28, new installs only):

- D2: automatic IP bans expire after 30 days instead of never; D3:
  spam training samples, whole messages, are kept 90 days instead of
  180; D4: Pyzor, which sends a digest of each message's text to a
  public server, is off; D6: delivery history is kept 14 days instead
  of 30. Written on the first boot of a new install only -- one with no
  roles yet, the same test the built-in roles use -- by reading each
  singleton, setting these fields and writing it back whole. A server
  with roles keeps its settings, saved or default.
- D7: a webhook created from now on starts with the include policy and
  no events, so it sends nothing until events are chosen (Rust default
  and schema default, marked). The registry stores every field, so
  existing webhooks keep their policy.
- Expired bans are also removed by the daily data clean-up. They
  already stopped blocking and were deleted when settings next loaded;
  a server that seldom reloads kept them.

D1 (log retention) and D5 (the hashed-address blocklist off) are held,
and the spec says why: x:TracerLog is stored inside x:Bootstrap with a
field after it, so adding one changes that object's stored format; and
the spam-rules loader D5 touches is being reworked by the v0.16.24
import. The spec also corrects finding 3: expired bans were deleted on
settings load; bans were permanent only because no period is set.

Tested: unit tests for the new-install values and that everything else
in each singleton stays; the system suite, whose security test now
purges an expired ban and checks its record is gone; the telemetry
test; common's unit tests; fork checks.
2026-09-28 07:43:59 -07:00
jcoffey-dev 18b28fad27 Merge pull request 'Add the personal-data catalog and the check that keeps it true' (#83) from feature/privacy-catalog into main
ci / fork-checks (push) Successful in 17s
ci / build (push) Canceled after 19m3s
2026-09-28 14:32:24 +00:00
jcoffey-dev a8dde68800 Add the personal-data catalog and the check that keeps it true
ci / fork-checks (pull_request) Successful in 52s
ci / build (pull_request) Successful in 37m38s
Phase 2 of the personal-data catalog spec.

resources/privacy/catalog.toml classifies every object in the schema
(316) and inbuxa's own JMAP objects (12): each property that can hold
personal data, with its categories, and for objects that hold any,
whose data it is, where it lives, its scope and what bounds its
retention (a named setting where there is one). Twenty sources that
are no object -- the log file, exporters, webhooks, spam lookups, the
Explain cache, relays and hooks, push, legacy-use records -- carry the
same facts plus the settings that turn them on, whether the data
leaves the host, and the code that writes it. Classifications of
objects that hold data about people are from the spec's source map;
the rest are typed from the schema alone (address, IP, secret).

tools/fork/privacy-check.py fails CI when an object or inbuxa object
has no entry, when a property the schema types as an address, IP or
secret is left to its object's default, when an entry names an
object, property, setting or code path that is gone, or when it uses
a word outside the catalog's vocabulary. --unlisted prints starting
entries. strip.py's report gains "Unclassified in the privacy
catalog": objects and fields new in an import and not classified,
informational like the Enterprise flags.

Tested: 13 unit tests (tools/fork/tests): the check passes on this
tree; fails on an unclassified object, an address hidden behind a
default, a secret in a set or object reference, stale properties,
objects, settings and code paths, an unlisted inbuxa object and a
word outside the vocabulary; --unlisted's entries; and the strip
report on a synthetic import. The check and the tests run in the
fork-checks job.
2026-09-28 06:54:34 -07:00
jcoffey-dev 09c55ba503 Merge pull request 'Stop webhooks sending every event, message content included' (#82) from fix/webhook-event-levels into main
ci / fork-checks (push) Successful in 13s
ci / build (push) Successful in 38m0s
2026-09-28 13:44:51 +00:00
jcoffey-dev eac3db34e9 Principal get test: expect legacyAllowed
ci / fork-checks (pull_request) Successful in 12s
ci / build (pull_request) Successful in 1h18m28s
The per-protocol switches (#79) added legacyAllowed to the account's
urn:inbuxa:jmap capability, but this expectation wasn't updated, so
jmap_tests stopped here and the suites after it never ran.
2026-09-28 06:42:08 -07:00
jcoffey-dev 6945714aa9 Stop webhooks sending every event, message content included
ci / fork-checks (pull_request) Successful in 45s
ci / build (pull_request) Successful in 5m42s
A webhook has a level (info by default) that nothing read: its events
were chosen by its list and policy alone. With the default policy,
exclude, and nothing listed, that meant every event type, including
smtp.raw-input (the raw SMTP bytes, DATA included) and the model's
reply to the spam classifier. The docs suggest a webhook to pass the
audit log to a SIEM; set up that way it would have received whole
messages. Found by the personal-data catalog investigation (finding 1).

Now an include list is sent as named, whatever each event's level:
naming an event is the choice. Otherwise a webhook gets only events at
or above its level, as a tracer does, and never a protocol's raw input
or output (IMAP, SMTP, POP3, ManageSieve, delivery, milter), which
carries whole messages and credentials; those go out only when named.

Tested: unit tests for the rule (level, raw I/O only when named, a
named event below the level, custom event levels, a webhook's own
errors); the telemetry system test, whose webhook names debug-level
connection events and still receives them.
2026-09-28 06:38:37 -07:00
jcoffey-dev b2453d066b Merge upstream v0.16.24
Eight conflicted files resolved, plus the lock file and the schema:

- crates/services/src/task_manager/spam_classifier.rs: upstream's rules
  update now replaces existing rules, DNSBL servers, lookups and file
  extensions, keeping only whether each is on. Taken, with one difference:
  an object an admin edited is kept as it is. Every object an update writes
  is fingerprinted (content without `enable`, SHA-256, stored under
  SUBSPACE_INBUXA "Sf"), and only one that still matches is replaced.
  Scores are never replaced, as upstream has it. The AU-1.10 summary record
  now names what was added, replaced and kept, and the bundled rules are
  marked applied only when the update fully succeeded, so a failure runs
  again on the next start. The marker becomes "3.0.2+2", which runs the
  update once on upgrade to fingerprint every rule still as bundled.
- crates/common/src/network/autoconfig/autodiscover.rs: upstream's rewrite
  (implicit TLS first, labeled SSL), with the per-protocol switches (LP-7,
  LP-14a) passed in as a filter.
- crates/store/src/backend/mysql/{search,write}.rs: upstream's chunked
  deletes (no unbounded first DELETE, stop on a short chunk, halve the
  chunk on the new chunk-too-large errors) inside the fork's query timeout.
- crates/smtp/src/lib.rs: the fork's queue spawn kept. It already fixed the
  stall upstream fixes here (a node without outboundMta stops accepting
  mail at about 1024 queued messages), and follows role changes live.
- crates/jmap/src/registry/mapping/bootstrap.rs: the log path stays
  /var/log/inbuxa/; upstream's PowerDNS mapping taken.
- crates/main/Cargo.toml: the AGPL-only license kept, version 0.16.24.
- tests/src/jmap/principal/get.rs: the fork's capabilities kept.
- resources/schema/schema.json.gz: merged as JSON; upstream relabeled the
  vendor Sieve extensions "(Stalwart)", kept as "(vnd.inbuxa)".
- Cargo.lock: upstream's, with the fork's crates added by Cargo.

Also:

- tests/src/smtp/inbound/spam_rules_kept.rs: an edited rule survives an
  update, an unedited one is updated, rules from before fingerprints are
  handled, and the audit summary says so. Upstream's own spam_rules test
  passes unchanged.
- tests/src/smtp/reporting/reschedule.rs moves to port 19058; upstream's
  new spam_rules test took 19057.
- tools/fork/renames.py renames the "(Stalwart)" labels and the default
  log path, so neither conflicts again.
- tools/fork/notice-check.py compares against the newest snapshot in the
  checked-out history instead of the upstream branch head, so moving the
  branch no longer fails other open pull requests.
- tests/src/directory/issuer.rs (since v0.16.23) stays out, and is on the
  build check's known list: it tests issuer-based directory routing, which
  the fork doesn't have (DIR-2).
- Strip report: docs/fork/strip-reports/v0.16.24.{md,json}.
2026-09-28 06:30:20 -07:00
jcoffey-dev f59b084ce5 Import upstream v0.16.24, stripped
Upstream commit: af37a234981722493b74623a983581691d2b70b6
Enterprise-only files removed or emptied: 63
Enterprise-only snippets removed: 118 in 50 files
Dangling module declarations removed: 5
Edits turning enterprise off: 25
Third-party code: 14 files, 0 not in THIRD-PARTY.md
Renamed identifiers: 62 in 18 files
Verification: clean

The same Enterprise footprint as v0.16.23. The build check fails only on
tests/src/directory/issuer.rs, unchanged since v0.16.23: it calls a helper
from upstream's Enterprise-only OIDC test, and tests issuer-based directory
routing, an Enterprise feature. main has never carried it.
2026-09-28 06:29:38 -07:00
jcoffey-dev 85ea0c80e9 Merge pull request 'Spec: personal-data catalog, compliance role, Overview and Data inventory' (#81) from spec/personal-data-catalog into main
ci / fork-checks (push) Successful in 47s
ci / build (push) Canceled after 39m47s
2026-09-28 13:05:02 +00:00
jcoffey-dev a588a8aa7d Spec: record John's answers to the personal-data catalog questions
ci / fork-checks (pull_request) Successful in 15s
ci / build (pull_request) Successful in 7m25s
The sidecar catalog; the Compliance Officer places and releases holds;
the Tenant Compliance Officer is built now; shortening audit retention
is recorded and surfaced, not gated on a second person; all seven
new-install defaults, in Phase 3; the webhook finding fixed now as a
bug; snapshots kept as long as the audit log.
2026-09-28 05:57:24 -07:00
jcoffey-dev 35cf3f405f Spec: personal-data catalog, compliance role, Overview and Data inventory
ci / fork-checks (pull_request) Successful in 50s
ci / build (pull_request) Successful in 11m33s
Phase 1 of the GDPR auditor foundation: the investigation and the
design, committed before anything is built (SPEC.md §3 rule 3).

It maps every place the server stores or sends personal data found
in the code at de275ba, each with its categories, whose data it is,
the settings that control it, what bounds its retention, where it
lives, whether it leaves the host, its scope and the code that writes
it, and the default in a new install. It proposes a sidecar catalog
(resources/privacy/catalog.toml), since the schema and registry code
are upstream's generated output with no generator here; a CI check
modeled on name-check.py; a strip-report section; a read-only
inventory method with dated snapshots; a Compliance Officer role; and
the Compliance navigation with Overview and Data inventory.

Findings worth reading on their own: webhooks ignore levels and, at
their defaults, receive every event including raw SMTP input; log
files are never deleted; automatic bans never expire; some of the
fork's records outlive the account; spam training keeps whole
messages for 180 days; traces are on in a new install; the spam
filter sends IPs, domains, hashed addresses and body digests to
third-party services by default.

Proposed default changes (new installs only) and seven open questions
are for John to decide. No default is changed.
2026-09-28 01:36:57 -07:00
jcoffey-dev de275bac60 Merge pull request 'Release 2026.9.28.3' (#80) from release-2026.9.28.3 into main
ci / fork-checks (push) Successful in 1m1s
publish / version (push) Successful in 56s
publish / publish-amd64 (push) Successful in 30m35s
publish / release (push) Successful in 6s
ci / build (push) Successful in 32m44s
publish / publish-arm64 (push) Successful in 36m4s
publish / binaries (push) Successful in 35s
publish / announce (push) Successful in 22s
2026-09-28 07:23:19 +00:00
jcoffey-dev 305406a331 Release 2026.9.28.3
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 7m25s
Per-protocol legacy switches (#79) and the hold export's exceptions
list (#75). The prepared Explain answers are relabeled for this
release; 706 carry over unchanged.
2026-09-28 00:15:33 -07:00
jcoffey-dev f5888d79b0 Merge pull request 'Give IMAP, POP3 and ManageSieve a switch each' (#79) from feature/per-protocol-switches into main
ci / fork-checks (push) Successful in 38s
ci / build (push) Successful in 35m58s
2026-09-28 06:32:41 +00:00
jcoffey-dev 8e9cedbe97 Give IMAP, POP3 and ManageSieve a switch each
ci / fork-checks (pull_request) Successful in 43s
ci / build (pull_request) Successful in 7m40s
The legacy-protocols switch was all or nothing. An operator can now stop
POP3 and keep IMAP: each of IMAP, POP3 and ManageSieve has its own
switch, server-wide on inbuxa:ProtocolPolicy and per tenant on
inbuxa:TenantProtocolPolicy (properties imap, pop3, manageSieve).

legacyProtocols stays as the kill-all: setting it sets all three, and it
reads "disabled" exactly when all three are off. A policy stored before
this has only legacyProtocols and reads as all three at that value, so
existing servers and tenants carry over unchanged. In one /set, a
protocol named beside legacyProtocols overrides it.

SMTP submission keeps no switch of its own: sign-in over it is refused
only when all three are off, as the single switch did (LP-6), so
turning one protocol off never stops a mail app sending. For a tenant,
the server's switches and the tenant's count together.

Server-wide, a change closes the listeners of whatever is now off and
puts back the saved listeners of whatever is on again, both in one
change if asked; listeners of a protocol still off stay saved. Sign-in,
autoconfig, autodiscover, PACC (now prepared once per combination) and
the suggested DNS records all follow each protocol separately. A tenant
may turn a protocol on only while the server has it on (LP-9), and the
refusal names which. The JMAP session adds legacyAllowed, the protocols
still allowed for the account; legacyProtocols there keeps its meaning
for older webmail builds. Events name the switches ("pop3 disabled"),
and audit before/after reads every switch even from an older policy.

Tested: unit tests for the switches, the old-policy reading, the
server/tenant combination, the tenant refusal and listener refusal; and
tests/e2e/legacy_protocols.py against a running server, all 100 checks,
including new ones: POP3 alone off closes only its port and refuses
only its sign-in while IMAP and sending go on; only POP3 stops being
advertised; one change closes IMAP and reopens POP3; a tenant turns
POP3 off for itself, and can't turn IMAP on while the server has it off.
2026-09-27 23:12:32 -07:00
jcoffey-dev 5ba54e8fb7 Merge pull request 'List what a hold export can't read instead of skipping it (LH-12)' (#75) from fix/hold-export-exceptions into main
ci / fork-checks (push) Successful in 54s
ci / build (push) Canceled after 23m21s
Reviewed-on: #75
2026-09-28 06:09:20 +00:00
jcoffey-dev db817dd507 Merge pull request 'Release 2026.9.28.2' (#77) from release-2026.9.28.2 into main
publish / version (push) Successful in 31s
ci / fork-checks (push) Successful in 52s
ci / build (push) Canceled after 9m20s
publish / publish-amd64 (push) Successful in 34m25s
publish / release (push) Successful in 45s
publish / publish-arm64 (push) Successful in 36m5s
publish / binaries (push) Successful in 34s
publish / announce (push) Successful in 22s
2026-09-28 05:59:59 +00:00
jcoffey-dev b7e3a765ca Release 2026.9.28.2
ci / fork-checks (pull_request) Successful in 56s
ci / build (pull_request) Successful in 5m22s
2026-09-27 22:54:18 -07:00
jcoffey-dev 7ba9ec9fa0 Merge pull request 'Send "none" instead of "pass" as the DMARC report disposition' (#76) from fix/dmarc-disposition-compat into main
ci / build (push) Canceled after 11m35s
ci / fork-checks (push) Successful in 15s
2026-09-28 05:48:22 +00:00
jcoffey-dev 4c07779c16 Merge pull request 'Recheck DNSSEC lookups that hickory wrongly calls bogus' (#72) from fix/dnssec-insecure-fallback into main
ci / fork-checks (push) Successful in 2m2s
ci / build (push) Canceled after 14m59s
2026-09-28 05:33:21 +00:00
jcoffey-dev e1e8a9aeb0 Send "none" instead of "pass" as the DMARC report disposition
ci / build (pull_request) Successful in 16m34s
ci / fork-checks (pull_request) Successful in 52s
Cloudflare's DMARC report intake rejects every aggregate report we
send with "555 5.7.1 invalid_report_schema". Bisected against the live
endpoint: the only element it objects to is <disposition>pass</disposition>,
the value RFC 9990 added for mail that passed DMARC under an enforcing
policy. The RFC 9990 namespace, <np>, <discovery_method>, <testing> and
a missing <pct> are all accepted, and a report that differs only in
using "none" there goes through.

"none" (no action taken) is valid under both RFC 9990 and RFC 7489 and
says the same thing to the reader, so reports now go out with it. The
stored report keeps "pass"; only the serialized copy changes.
2026-09-27 22:31:13 -07:00
jcoffey-dev b1bc5ed6e0 Recheck DNSSEC lookups that hickory wrongly calls bogus
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 7m34s
hickory 0.26.3 rejects two kinds of valid answers, and outbound
delivery then retries those hosts until the message expires:

- A zone delegated beneath an unsigned zone (l.google.com under
  google.com). Proving the delegation insecure needs an SOA record in
  the DS reply, and public resolvers often leave it out. Every Google
  MX host behind a signed MX record was unreachable.
- A signed CNAME to a signed name that lacks the queried type. The
  NSEC denial is checked against the original name, not the target's.

On a bogus verdict, follow a signed CNAME and repeat the lookup at its
target; otherwise look up the name's zone and its parents, nearest
first. A zone that validates as unsigned means nothing below it can be
signed, so the plain resolver answers and the result is insecure. A
zone that validates as signed first leaves the verdict standing.
2026-09-27 21:45:13 -07:00
170 changed files with 13576 additions and 1323 deletions
+10
View File
@@ -40,6 +40,16 @@ jobs:
# nothing. CI never sees the difference; a release does. # nothing. CI never sees the difference; a release does.
- if: always() - if: always()
run: python3 tools/fork/context-check.py run: python3 tools/fork/context-check.py
# The personal-data catalog must classify every object and field the
# schema has, and name nothing that is gone.
- if: always()
run: python3 tools/fork/privacy-check.py
# The admin reads each expression field's allowed values and variables
# from the schema; they're generated from the registry and must match it.
- if: always()
run: python3 tools/fork/expr-schema.py --check
- if: always()
run: python3 -m unittest discover -s tools/fork/tests
build: build:
# Either runner (host1 or host2): the build needs no docker socket. # Either runner (host1 or host2): the build needs no docker socket.
+33
View File
@@ -2,6 +2,39 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [0.16.24] - 2026-09-27
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
## Added
- DNS: PowerDNS Authoritative provider for automatic DNS record management.
## Changed
## Fixed
- Troubleshoot tool: `TLSA` records are looked up for every MX host, including hosts whose zone is not DNSSEC signed.
- Spam filter:
- OpenPhish and PhishTank entries containing uppercase characters never match, since message URLs are lowercased while HTTP lookup entries keep their original case. HTTP lookups now match keys case-insensitively.
- URL shortener links are followed using the lowercased URL, so case-sensitive short links resolve to the wrong destination or not at all.
- Incremental training never advances its position past the first run, so every retained sample added since then is trained again, and counted again in the reservoir, on each run until it expires.
- Updating the rules only adds new objects, so upstream changes to existing rules, DNSBL servers, HTTP lookups, lookup keys and file extensions never reach an existing installation.
- Updating the rules reports success when objects fail to import, or when a configuration error stops the updated settings from being activated.
- JMAP:
- A `PushSubscription` created within the verification rate limit window of another one on the same account never receives its `PushVerification`, since the blocked verification is dropped instead of being sent once the window expires.
- A push notification retried after a failed delivery can report an older state than a change queued during the failed attempt, since the older state changes are merged last and overwrite the newer ones.
- Changes made while a push request is in flight are not delivered until the next change reaches the same subscription, since a successful delivery cancels the pending retry.
- The VAPID `aud` claim is derived from a hand-written parse of the push URL, so a crafted push URL can make the server sign a token for a push service other than the one the request is sent to.
- `Email/import` rejects a `blobId` that refers to a `Blob/upload` creation id in the same request (`"#u0"`) with `Invalid blob id.`.
- `Email/set` with a full `mailboxIds` object identical to the current mailboxes, together with a keyword change, stores the message with IMAP UID 0, so IMAP clients stop seeing it.
- MTA:
- A node without the `outboundMta` role stops replying to `DATA` and to JMAP submissions once about 1024 messages have been queued on it.
- MX records are resolved through the DNSSEC-validating resolver even when DANE is disabled.
- A `DATA` stage Sieve script does not see headers added by milters or MTA hooks, and discards every milter and MTA hook change when it edits the message.
- MySQL: Range deletions and search index removals start with a single unbounded `DELETE` and switch to chunks only after a timeout.
- IMAP: `COPY` and `MOVE` fail with `NO [CONTACTADMIN]` when another session changes the same message at the same time.
- Autodiscover: Implicit TLS ports (993, 995, 465) are advertised with `<Encryption>TLS</Encryption>`, which Outlook reads as STARTTLS.
- HTTP: Idle keep-alive connections are never closed.
## [0.16.23] - 2026-09-21 ## [0.16.23] - 2026-09-21
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
Generated
+175 -177
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "common" name = "common"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"
+6
View File
@@ -290,6 +290,12 @@ impl Default for DefaultPermissions {
default.superuser.push(permission); default.superuser.push(permission);
default.tenant.push(permission); default.tenant.push(permission);
} }
// inbuxa: personal-data catalog: the data inventory, the
// server's or, inside a tenant, the tenant's slice
Permission::SysComplianceGet => {
default.superuser.push(permission);
default.tenant.push(permission);
}
// inbuxa: AL-12: tenant administrators lock and delegate // inbuxa: AL-12: tenant administrators lock and delegate
// within their tenant // within their tenant
Permission::SysAccountLockGet Permission::SysAccountLockGet
+25 -15
View File
@@ -46,10 +46,10 @@ pub struct Network {
#[derive(Clone)] #[derive(Clone)]
pub struct NetworkInfo { pub struct NetworkInfo {
pub pacc: Pacc, /// inbuxa: the document once per combination of legacy protocols off,
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve, /// indexed by `LegacyOff::index` (legacy-protocols LP-7, one switch per
/// served while legacy protocols are off (legacy-protocols LP-7). /// protocol); index 0 is the full document.
pub pacc_jmap_only: Pacc, pub pacc: Vec<Pacc>,
pub mxs: Vec<MailExchanger>, pub mxs: Vec<MailExchanger>,
pub services: VecMap<ServiceProtocol, Service>, pub services: VecMap<ServiceProtocol, Service>,
} }
@@ -333,16 +333,27 @@ impl Network {
}) })
.unwrap() .unwrap()
}; };
// inbuxa: legacy-protocols LP-7 // inbuxa: legacy-protocols LP-7, one document per combination of
let pacc_jmap_only = { // protocols off, bits as `LegacyOff::index`: IMAP, POP3, ManageSieve,
let mut pacc = pacc.clone(); // submission.
pacc.protocols.imap = None; let pacc = (0..16usize)
pacc.protocols.pop3 = None; .map(|off| {
pacc.protocols.smtp = None; let mut pacc = pacc.clone();
pacc.protocols.managesieve = None; if off & 1 != 0 {
split(&pacc) pacc.protocols.imap = None;
}; }
let pacc = split(&pacc); if off & 2 != 0 {
pacc.protocols.pop3 = None;
}
if off & 4 != 0 {
pacc.protocols.managesieve = None;
}
if off & 8 != 0 {
pacc.protocols.smtp = None;
}
split(&pacc)
})
.collect();
let mut network = Network { let mut network = Network {
node_id: bp.node_id() as u64, node_id: bp.node_id() as u64,
server_name: default_hostname.to_string(), server_name: default_hostname.to_string(),
@@ -358,7 +369,6 @@ impl Network {
mxs: system.mail_exchangers.into_iter().collect(), mxs: system.mail_exchangers.into_iter().collect(),
services: system.services, services: system.services,
pacc, pacc,
pacc_jmap_only,
}, },
}; };
+92 -1
View File
@@ -483,8 +483,16 @@ impl Tracers {
}; };
// Parse webhook events // Parse webhook events
// inbuxa: personal-data catalog, finding 1: an include list is
// sent as named; otherwise a webhook honors its level as a
// tracer does, and never sends a protocol's raw input or
// output (whole messages)
let level = Level::from(hook.level);
let named = (hook.events_policy == EventPolicy::Include)
.then(|| hook.events.iter().copied().collect::<AHashSet<_>>())
.unwrap_or_default();
apply_events(hook.events, hook.events_policy, |event_type| { apply_events(hook.events, hook.events_policy, |event_type| {
if event_type != EventType::Telemetry(TelemetryEvent::WebhookError) { if webhook_wants(event_type, level, &custom_levels, &named) {
tracer.interests.set(event_type); tracer.interests.set(event_type);
global_interests.set(event_type); global_interests.set(event_type);
} }
@@ -743,6 +751,31 @@ fn tracer_settings(tracer: &Tracer) -> u64 {
settings_hash(&tracer) settings_hash(&tracer)
} }
/// inbuxa: whether a webhook at `level` receives this event type. Its own
/// error event never, or a failing webhook would report itself to itself.
/// An event `named` in an include list always: naming it is the choice.
/// Otherwise (the exclude policy, the default) only events at or above its
/// level, as for a tracer, and never a protocol's raw input or output, which
/// carries whole messages and credentials.
fn webhook_wants(
event_type: EventType,
level: Level,
custom_levels: &AHashMap<EventType, Level>,
named: &AHashSet<EventType>,
) -> bool {
if event_type == EventType::Telemetry(TelemetryEvent::WebhookError) {
return false;
}
if named.contains(&event_type) {
return true;
}
let event_level = custom_levels
.get(&event_type)
.copied()
.unwrap_or(event_type.level());
level.is_contained(event_level) && !event_type.is_raw_io()
}
fn webhook_settings(hook: &WebHook) -> u64 { fn webhook_settings(hook: &WebHook) -> u64 {
let mut hook = hook.clone(); let mut hook = hook.clone();
in_place_reset!(hook); in_place_reset!(hook);
@@ -804,3 +837,61 @@ impl std::fmt::Debug for OtelMetrics {
.finish() .finish()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use trc::{AuthEvent, SmtpEvent};
fn wants(event: EventType, level: Level, named: &[EventType]) -> bool {
webhook_wants(
event,
level,
&AHashMap::new(),
&named.iter().copied().collect(),
)
}
#[test]
fn a_webhook_honors_its_level() {
let success = EventType::Auth(AuthEvent::Success);
assert!(wants(success, Level::Info, &[]));
assert!(!wants(success, Level::Error, &[]), "info is below error");
}
#[test]
fn raw_io_goes_out_only_when_named() {
let raw = EventType::Smtp(SmtpEvent::RawInput);
assert!(raw.is_raw_io());
// Not with the exclude policy, even at trace
assert!(!wants(raw, Level::Info, &[]));
assert!(!wants(raw, Level::Trace, &[]));
// Named in an include list, whatever the level
assert!(wants(raw, Level::Info, &[raw]));
}
#[test]
fn a_named_event_is_sent_whatever_its_level() {
let start = EventType::Smtp(SmtpEvent::ConnectionStart);
assert!(!Level::Info.is_contained(start.level()), "below info");
assert!(!wants(start, Level::Info, &[]));
assert!(wants(start, Level::Info, &[start]));
}
#[test]
fn a_custom_level_counts() {
let start = EventType::Smtp(SmtpEvent::ConnectionStart);
let custom = [(start, Level::Info)].into_iter().collect::<AHashMap<_, _>>();
assert!(webhook_wants(start, Level::Info, &custom, &AHashSet::new()));
// Raw I/O raised to info still needs naming
let raw = EventType::Smtp(SmtpEvent::RawInput);
let custom = [(raw, Level::Info)].into_iter().collect::<AHashMap<_, _>>();
assert!(!webhook_wants(raw, Level::Info, &custom, &AHashSet::new()));
}
#[test]
fn a_webhook_never_hears_its_own_errors() {
let own = EventType::Telemetry(TelemetryEvent::WebhookError);
assert!(!wants(own, Level::Trace, &[own]));
}
}
+4
View File
@@ -69,6 +69,8 @@ pub mod auth;
pub mod cache; pub mod cache;
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU) pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
pub mod hold; // inbuxa: legal holds (audit-hold-lock spec, LH) pub mod hold; // inbuxa: legal holds (audit-hold-lock spec, LH)
pub mod privacy; // inbuxa: the personal-data catalog, evaluated
pub mod reachability; // inbuxa: whether the outside world reaches each node's ports
pub mod config; pub mod config;
pub mod expr; pub mod expr;
pub mod i18n; pub mod i18n;
@@ -128,6 +130,8 @@ pub const KV_LOCK_QUEUE_MESSAGE: u8 = 21;
pub const KV_LOCK_TASK: u8 = 23; pub const KV_LOCK_TASK: u8 = 23;
pub const KV_LOCK_DAV: u8 = 25; pub const KV_LOCK_DAV: u8 = 25;
pub const KV_SIEVE_ID: u8 = 26; pub const KV_SIEVE_ID: u8 = 26;
// inbuxa: far above upstream's prefixes, so a new one of theirs never collides
pub const KV_PORT_REACHABILITY: u8 = 200;
#[derive(Clone)] #[derive(Clone)]
pub struct Server { pub struct Server {
@@ -0,0 +1,267 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The compliance roles (personal-data catalog spec, §7; settled
//! 2026-09-28): a server-level Compliance Officer, and one Compliance
//! Officer role in each tenant. A tenant's accounts can hold only roles of
//! their own tenant (MT-3), so the tenant role is made per tenant: once for
//! each tenant a server already has, and whenever a tenant is created.
//!
//! Each creation is recorded under `P` `c` in the fork's subspace, so a
//! role an administrator deletes stays deleted. A tenant's role, while
//! nobody holds it, is removed with the tenant so it doesn't block the
//! delete.
//!
//! Both read what compliance work needs and change no server setting. The
//! server-level officer also places, widens, releases and exports legal
//! holds: that is the job, and each is audited with its reason. A tenant's
//! role has no holds, which are server-level only (LH-13), and the tenant
//! ceiling keeps it within the tenant. Each role carries a user's own
//! permissions too (signing in, mail), since roles given to a person replace
//! the default user role, and a tenant's accounts can't hold the
//! server-level User role.
use registry::schema::{
enums::Permission,
prelude::ObjectType,
structs::{Role, Tenant},
};
use registry::types::map::Map;
use store::{
RegistryStore, SUBSPACE_INBUXA, Store, ValueKey,
registry::write::{RegistryWrite, RegistryWriteResult},
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
use types::id::Id;
/// The role's name, in the server's roles and in each tenant's.
pub const NAME: &str = "Compliance Officer";
/// Reading who and what records refer to, for both roles.
const READS: &[Permission] = &[
Permission::SysAccountGet,
Permission::SysAccountQuery,
Permission::SysMailingListGet,
Permission::SysMailingListQuery,
Permission::SysDomainGet,
Permission::SysDomainQuery,
Permission::SysTenantGet,
Permission::SysTenantQuery,
Permission::SysRoleGet,
Permission::SysRoleQuery,
];
/// What the server-level officer holds besides [`READS`].
const OFFICER: &[Permission] = &[
Permission::SysComplianceGet,
Permission::SysAuditGet,
Permission::SysAuditExport,
Permission::SysLegalHoldGet,
Permission::SysLegalHoldCreate,
Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldExport,
Permission::SysAccountLockGet,
];
/// What a tenant's officer holds besides [`READS`].
const TENANT_OFFICER: &[Permission] = &[
Permission::SysComplianceGet,
Permission::SysAuditGet,
Permission::SysAuditExport,
Permission::SysAccountLockGet,
];
fn role(own: &[Permission], tenant: Option<Id>) -> Role {
let mut permissions = crate::auth::permissions::DefaultPermissions::default().user;
for permission in own.iter().chain(READS) {
if !permissions.contains(permission) {
permissions.push(*permission);
}
}
Role {
description: NAME.into(),
enabled_permissions: Map::new(permissions),
member_tenant_id: tenant,
..Default::default()
}
}
/// The server-level Compliance Officer role.
pub fn officer_role() -> Role {
role(OFFICER, None)
}
/// A tenant's Compliance Officer role.
pub fn tenant_role(tenant: Id) -> Role {
role(TENANT_OFFICER, Some(tenant))
}
/// Where a creation is recorded: the server's role, or a tenant's. The value
/// is the role's id.
fn created_key(tenant: Option<Id>) -> ValueClass {
let mut key = b"Pc".to_vec();
if let Some(tenant) = tenant {
key.extend_from_slice(&tenant.id().to_be_bytes());
}
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
async fn recorded(data: &Store, tenant: Option<Id>) -> trc::Result<Option<Id>> {
Ok(data
.get_value::<u64>(ValueKey::from(created_key(tenant)))
.await
.caused_by(trc::location!())?
.map(Id::from))
}
async fn record(data: &Store, tenant: Option<Id>, role: Option<Id>) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
match role {
Some(role) => batch.set(created_key(tenant), role.id().to_be_bytes().to_vec()),
None => batch.clear(created_key(tenant)),
};
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// Creates a role, unless one was created for this place before, and records
/// it. Returns the new role's id.
async fn create_once(
registry: &RegistryStore,
data: &Store,
tenant: Option<Id>,
role: Role,
) -> trc::Result<Option<Id>> {
if recorded(data, tenant).await?.is_some() {
return Ok(None);
}
match registry.write(RegistryWrite::insert(&role.into())).await? {
RegistryWriteResult::Success(id) => {
record(data, tenant, Some(id)).await?;
Ok(Some(id))
}
err => {
trc::error!(
trc::EventType::Registry(trc::RegistryEvent::ValidationError)
.into_err()
.details(format!("Failed to create the {NAME} role: {err}"))
);
Ok(None)
}
}
}
/// Once per server: the officer role, and one in each tenant it already has.
pub async fn ensure_compliance_roles(registry: &RegistryStore, data: &Store) -> trc::Result<()> {
create_once(registry, data, None, officer_role()).await?;
for tenant in registry.list::<Tenant>().await? {
let tenant = Id::from(tenant.id.id());
create_once(registry, data, Some(tenant), tenant_role(tenant)).await?;
}
Ok(())
}
/// A new tenant gets its Compliance Officer role.
pub async fn tenant_created(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<()> {
create_once(registry, data, Some(tenant), tenant_role(tenant)).await.map(|_| ())
}
/// Before a tenant is deleted: removes its Compliance Officer role if nobody
/// holds it, so the role doesn't block the delete. Returns whether it did,
/// so a delete refused for another reason can put it back.
pub async fn tenant_deleting(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<bool> {
let Some(role) = recorded(data, Some(tenant)).await? else {
return Ok(false);
};
match registry
.write(RegistryWrite::delete(ObjectType::Role.id(role)))
.await?
{
RegistryWriteResult::Success(_) | RegistryWriteResult::NotFound { .. } => {
record(data, Some(tenant), None).await?;
Ok(true)
}
// Held by someone: the tenant's delete is refused for that anyway
_ => Ok(false),
}
}
/// A tenant's delete was refused after its role went: the role comes back.
pub async fn tenant_kept(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<()> {
tenant_created(registry, data, tenant).await
}
#[cfg(test)]
mod tests {
use super::*;
use registry::types::EnumImpl;
fn permissions(role: &Role) -> Vec<Permission> {
role.enabled_permissions.iter().copied().collect()
}
#[test]
fn neither_role_changes_a_setting() {
let user = crate::auth::permissions::DefaultPermissions::default().user;
for role in [officer_role(), tenant_role(Id::from(7u64))] {
let all = permissions(&role);
for permission in user.iter() {
assert!(all.contains(permission), "a user's own {permission:?}");
}
// Beyond what any user holds for their own account
for permission in all.into_iter().filter(|p| !user.contains(p)) {
let name = permission.as_str();
let holds = name.starts_with("sysLegalHold");
assert!(
!(name.ends_with("Update") && !holds)
&& !(name.ends_with("Create") && !holds)
&& !name.ends_with("Destroy")
&& permission != Permission::Impersonate
&& permission != Permission::FetchAnyBlob,
"{} holds {name}",
role.description
);
}
}
}
#[test]
fn the_officer_places_and_releases_holds_a_tenants_does_not() {
let officer = permissions(&officer_role());
let tenant = tenant_role(Id::from(7u64));
assert_eq!(tenant.member_tenant_id, Some(Id::from(7u64)));
let tenant = permissions(&tenant);
for hold in [
Permission::SysLegalHoldGet,
Permission::SysLegalHoldCreate,
Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldExport,
] {
assert!(officer.contains(&hold));
assert!(!tenant.contains(&hold));
}
for both in [Permission::SysComplianceGet, Permission::SysAuditGet, Permission::SysAccountGet] {
assert!(officer.contains(&both) && tenant.contains(&both));
}
assert!(!officer.contains(&Permission::SysAuditSettingsUpdate));
}
#[test]
fn records_are_per_place() {
let ValueClass::Any(server) = created_key(None) else { panic!() };
let ValueClass::Any(a) = created_key(Some(Id::from(1u64))) else { panic!() };
let ValueClass::Any(b) = created_key(Some(Id::from(2u64))) else { panic!() };
assert_eq!(server.key, b"Pc");
assert_ne!(a.key, b.key);
assert!(a.key.starts_with(b"Pc"));
}
}
+123 -4
View File
@@ -14,7 +14,7 @@ use aws_lc_rs::{
use registry::{ use registry::{
schema::{ schema::{
enums::*, enums::*,
prelude::{ObjectType, SocketAddr}, prelude::{Object, ObjectType, SocketAddr},
structs::*, structs::*,
}, },
types::{duration::Duration, error::Error, list::List, map::Map}, types::{duration::Duration, error::Error, list::List, map::Map},
@@ -388,6 +388,45 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
} }
} }
// inbuxa: personal-data catalog, defaults D2, D3, D4 and D6 (settled
// 2026-09-28): privacy-leaning values, for new installs only. A server
// with roles is not new, and keeps its settings whether saved or left at
// the default. Each singleton is read, changed and written back whole, so
// anything already in it stays.
#[cfg(not(feature = "test_mode"))]
if bp.registry.count_object(ObjectType::Role).await? == 0 {
let mut security = bp.setting_infallible::<Security>().await;
let mut classifier = bp.setting_infallible::<SpamClassifier>().await;
let mut pyzor = bp.setting_infallible::<SpamPyzor>().await;
let mut retention = bp.setting_infallible::<DataRetention>().await;
new_install_privacy_defaults(&mut security, &mut classifier, &mut pyzor, &mut retention);
for object in [
Object::from(security),
classifier.into(),
pyzor.into(),
retention.into(),
] {
bp.registry.write(RegistryWrite::insert(&object)).await?;
}
// D5: the blocklist sent hashed email addresses starts off; the
// rules load later, from a task, which acts on this note
super::spam_rules::mark_new_install(&bp.data_store).await?;
// D1: rotated log files are kept 30 days (a fork-owned setting,
// since x:TracerLog is also stored inside x:Bootstrap)
use inbuxa_features::security::log_files;
if !log_files::is_set(&bp.data_store).await? {
log_files::set(
&bp.data_store,
&log_files::LogSettings {
keep_for_days: Some(log_files::NEW_INSTALL_KEEP_DAYS),
},
)
.await?;
}
}
if bp.registry.count_object(ObjectType::Role).await? == 0 { if bp.registry.count_object(ObjectType::Role).await? == 0 {
let permissions = DefaultPermissions::default(); let permissions = DefaultPermissions::default();
let mut role_ids = Vec::with_capacity(4); let mut role_ids = Vec::with_capacity(4);
@@ -447,6 +486,8 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
// inbuxa: administrator roles stored before a permission existed get it once // inbuxa: administrator roles stored before a permission existed get it once
super::granted_permissions::grant_new_admin_permissions(bp).await?; super::granted_permissions::grant_new_admin_permissions(bp).await?;
// inbuxa: personal-data catalog: the compliance roles, once per server
super::compliance_roles::ensure_compliance_roles(&bp.registry, &bp.data_store).await?;
if bp if bp
.registry .registry
@@ -535,8 +576,8 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
// inbuxa: rules are always to hand, since a copy ships with the server // inbuxa: rules are always to hand, since a copy ships with the server
// (spam_rules). They load on first boot, and again when the bundled // (spam_rules). They load on first boot, and again when the bundled
// version differs from the one last loaded, which only adds what's // rules differ from the ones last loaded: new tags and rules, fixes to
// missing: new tags and rules, never a changed score. // rules nobody edited, never a changed score or an admin's edit.
let rules_url = super::spam_rules::rules_url( let rules_url = super::spam_rules::rules_url(
bp.registry bp.registry
.object::<SpamSettings>(Id::singleton()) .object::<SpamSettings>(Id::singleton())
@@ -547,7 +588,7 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
&& super::spam_rules::applied_version(&bp.data_store) && super::spam_rules::applied_version(&bp.data_store)
.await? .await?
.as_deref() .as_deref()
!= Some(super::spam_rules::BUNDLED_SPAM_RULES_VERSION); != Some(super::spam_rules::BUNDLED_SPAM_RULES_APPLIED);
if bp.registry.count_object(ObjectType::SpamRule).await? == 0 || bundled_is_new { if bp.registry.count_object(ObjectType::SpamRule).await? == 0 || bundled_is_new {
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance { batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
@@ -560,3 +601,81 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
Ok(()) Ok(())
} }
/// inbuxa: the new-install values of defaults D2, D3, D4 and D6 from the
/// personal-data catalog spec. Automatic IP bans expire after 30 days instead
/// of never; spam training samples are kept 90 days instead of 180; Pyzor,
/// which sends a digest of each message's text to a public server, is off;
/// delivery history is kept 14 days instead of 30.
fn new_install_privacy_defaults(
security: &mut Security,
classifier: &mut SpamClassifier,
pyzor: &mut SpamPyzor,
retention: &mut DataRetention,
) {
const DAY: u64 = 24 * 60 * 60 * 1000;
let ban_period = Some(Duration::from_millis(30 * DAY));
security.auth_ban_period = ban_period;
security.abuse_ban_period = ban_period;
security.loiter_ban_period = ban_period;
security.scan_ban_period = ban_period;
classifier.hold_samples_for = Duration::from_millis(90 * DAY);
pyzor.enable = false;
retention.hold_traces_for = Some(Duration::from_millis(14 * DAY));
}
#[cfg(test)]
mod tests {
use super::*;
const DAY: u64 = 24 * 60 * 60 * 1000;
#[test]
fn new_installs_get_the_privacy_defaults() {
let (mut security, mut classifier, mut pyzor, mut retention) = (
Security::default(),
SpamClassifier::default(),
SpamPyzor::default(),
DataRetention::default(),
);
// What an install gets without them: bans that never lift, 180-day
// samples, Pyzor on, 30-day traces.
assert_eq!(security.auth_ban_period, None);
assert!(pyzor.enable);
new_install_privacy_defaults(&mut security, &mut classifier, &mut pyzor, &mut retention);
for period in [
security.auth_ban_period,
security.abuse_ban_period,
security.loiter_ban_period,
security.scan_ban_period,
] {
assert_eq!(period.map(|p| p.into_inner().as_millis() as u64), Some(30 * DAY));
}
assert_eq!(classifier.hold_samples_for.into_inner().as_millis() as u64, 90 * DAY);
assert!(!pyzor.enable);
assert_eq!(
retention.hold_traces_for.map(|p| p.into_inner().as_millis() as u64),
Some(14 * DAY)
);
}
#[test]
fn everything_else_in_the_settings_stays() {
let mut retention = DataRetention {
archive_deleted_items_for: Some(Duration::from_millis(7 * DAY)),
..Default::default()
};
let before = retention.clone();
new_install_privacy_defaults(
&mut Security::default(),
&mut SpamClassifier::default(),
&mut SpamPyzor::default(),
&mut retention,
);
assert_eq!(retention.archive_deleted_items_for, before.archive_deleted_items_for);
assert_eq!(retention.hold_metrics_for, before.hold_metrics_for);
assert_eq!(retention.expunge_trash_after, before.expunge_trash_after);
}
}
@@ -30,7 +30,8 @@ use types::id::Id;
/// Granted to the default administrator roles: "Explain this" /// Granted to the default administrator roles: "Explain this"
/// (ai-explain spec, EX-4: superuser by default), the audit log, account /// (ai-explain spec, EX-4: superuser by default), the audit log, account
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13). /// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
/// the data inventory (personal-data catalog spec).
const ADMIN_GRANTS: &[Permission] = &[ const ADMIN_GRANTS: &[Permission] = &[
Permission::SysAiExplain, Permission::SysAiExplain,
Permission::SysAuditGet, Permission::SysAuditGet,
@@ -44,11 +45,12 @@ const ADMIN_GRANTS: &[Permission] = &[
Permission::SysLegalHoldCreate, Permission::SysLegalHoldCreate,
Permission::SysLegalHoldUpdate, Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldExport, Permission::SysLegalHoldExport,
Permission::SysComplianceGet,
]; ];
/// Granted to the default tenant administrator roles: reading and exporting /// Granted to the default tenant administrator roles: reading and exporting
/// the tenant's audit log (AU-9), and locking and delegating its accounts /// the tenant's audit log (AU-9), locking and delegating its accounts
/// (AL-12). /// (AL-12), and the tenant's slice of the data inventory.
const TENANT_GRANTS: &[Permission] = &[ const TENANT_GRANTS: &[Permission] = &[
Permission::SysAuditGet, Permission::SysAuditGet,
Permission::SysAuditExport, Permission::SysAuditExport,
@@ -56,6 +58,7 @@ const TENANT_GRANTS: &[Permission] = &[
Permission::SysAccountLockCreate, Permission::SysAccountLockCreate,
Permission::SysAccountLockUpdate, Permission::SysAccountLockUpdate,
Permission::SysAccountLockDestroy, Permission::SysAccountLockDestroy,
Permission::SysComplianceGet,
]; ];
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
+1
View File
@@ -18,6 +18,7 @@ use utils::HttpLimitResponse;
pub mod application; pub mod application;
pub mod backup; pub mod backup;
pub mod boot; pub mod boot;
pub mod compliance_roles; // inbuxa: personal-data catalog, the compliance roles
pub mod console; pub mod console;
pub mod defaults; pub mod defaults;
pub mod first_party; pub mod first_party;
+133 -8
View File
@@ -12,11 +12,16 @@
//! and license) and uses it whenever no other source is configured. The rules //! and license) and uses it whenever no other source is configured. The rules
//! URL remains an operator override (`https://` or `file://`). //! URL remains an operator override (`https://` or `file://`).
//! //!
//! Loading rules only ever adds what's missing, never changes an existing rule //! Loading rules adds what's missing and brings an existing rule up to date,
//! or score. They load on first boot, and again whenever the bundled version //! but never touches one an admin edited: every object an update writes is
//! differs from the one last applied, so an upgrade brings new tags (the AI //! fingerprinted, and one that no longer matches its fingerprint is kept as
//! classifier's `LLM_*` scores, say) to an install that already had rules. //! it is. Tags (scores) are never replaced. Switching a rule on or off isn't
//! an edit, and is kept either way. They load on first boot, and again
//! whenever the bundled rules differ from the ones last applied, so an
//! upgrade brings new tags (the AI classifier's `LLM_*` scores, say) and
//! fixed rules to an install that already had rules.
use registry::{schema::prelude::ObjectType, types::EnumImpl};
use std::io::Read; use std::io::Read;
use store::{ use store::{
SUBSPACE_INBUXA, Store, ValueKey, SUBSPACE_INBUXA, Store, ValueKey,
@@ -27,13 +32,17 @@ use trc::AddContext;
/// The version of spam-filter the embedded rules come from. /// The version of spam-filter the embedded rules come from.
pub const BUNDLED_SPAM_RULES_VERSION: &str = "3.0.2"; pub const BUNDLED_SPAM_RULES_VERSION: &str = "3.0.2";
/// What's recorded once the bundled rules are loaded: their version, then the
/// fork's own generation of the update, so a change to how an update applies
/// runs it once more. Generation 2 fingerprints (upstream v0.16.24).
pub const BUNDLED_SPAM_RULES_APPLIED: &str = "3.0.2+2";
static BUNDLED_SPAM_RULES: &[u8] = static BUNDLED_SPAM_RULES: &[u8] =
include_bytes!("../../../../resources/spam-filter/spam-filter-rules.json.gz"); include_bytes!("../../../../resources/spam-filter/spam-filter-rules.json.gz");
/// Upstream's default rules source, the value every install created before /// Upstream's default rules source, the value every install created before
/// the rules were bundled has saved. Read only to treat it as unset. /// the rules were bundled has saved. Read only to treat it as unset.
const LEGACY_DEFAULT_URL: &str = const LEGACY_DEFAULT_URL: &str = "https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz";
"https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz";
/// The URL to fetch rules from, or `None` for the bundled rules. An empty /// The URL to fetch rules from, or `None` for the bundled rules. An empty
/// setting and upstream's old default both mean the bundled rules. /// setting and upstream's old default both mean the bundled rules.
@@ -57,14 +66,49 @@ fn applied_key() -> ValueClass {
}) })
} }
/// The bundled version last loaded into the registry, if any. fn fingerprint_key(object: ObjectType, id: u64) -> ValueClass {
let mut key = b"Sf".to_vec();
key.extend_from_slice(object.as_str().as_bytes());
key.push(0);
key.extend_from_slice(&id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
/// The fingerprint of what a rules update last wrote to this object, if one
/// did.
pub async fn fingerprint(data: &Store, object: ObjectType, id: u64) -> trc::Result<Option<String>> {
data.get_value::<String>(ValueKey::from(fingerprint_key(object, id)))
.await
.caused_by(trc::location!())
}
/// Records the fingerprint of what a rules update wrote to this object.
pub async fn set_fingerprint(
data: &Store,
object: ObjectType,
id: u64,
fingerprint: &str,
) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(fingerprint_key(object, id), fingerprint.as_bytes().to_vec());
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// The bundled rules last loaded into the registry, if any
/// ([`BUNDLED_SPAM_RULES_APPLIED`]'s form).
pub async fn applied_version(data: &Store) -> trc::Result<Option<String>> { pub async fn applied_version(data: &Store) -> trc::Result<Option<String>> {
data.get_value::<String>(ValueKey::from(applied_key())) data.get_value::<String>(ValueKey::from(applied_key()))
.await .await
.caused_by(trc::location!()) .caused_by(trc::location!())
} }
/// Records that the bundled rules of this version have been loaded. /// Records that the bundled rules have been loaded.
pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()> { pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()> {
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
batch.set(applied_key(), version.as_bytes().to_vec()); batch.set(applied_key(), version.as_bytes().to_vec());
@@ -74,6 +118,78 @@ pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()>
.map(|_| ()) .map(|_| ())
} }
/// The blocklists a new install starts with switched off (personal-data
/// catalog spec, default D5, settled 2026-09-28): the one that is sent a
/// hash of every email address it's asked about.
pub const NEW_INSTALL_OFF: &[&str] = &["STWT_MSBL_EBL_EMAIL"];
fn new_install_key() -> ValueClass {
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: b"Sn".to_vec(),
})
}
/// Notes, on a new install's first boot, that [`NEW_INSTALL_OFF`] is to be
/// switched off once the rules are in: they load later, from a task.
pub async fn mark_new_install(data: &Store) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(new_install_key(), b"D5".to_vec());
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// After rules load: on a new install, switches [`NEW_INSTALL_OFF`] off and
/// forgets the note, so it happens once. Returns whether anything changed.
/// An existing server has no note, and keeps every blocklist as it is.
pub async fn apply_new_install(
registry: &store::RegistryStore,
data: &Store,
) -> trc::Result<bool> {
use registry::schema::{prelude::Object, structs::SpamDnsblServer};
use store::registry::write::RegistryWrite;
if data
.get_value::<String>(ValueKey::from(new_install_key()))
.await
.caused_by(trc::location!())?
.is_none()
{
return Ok(false);
}
let mut changed = false;
for server in registry.list::<SpamDnsblServer>().await? {
let mut updated = server.object.clone();
let SpamDnsblServer::Email(email) = &mut updated else {
continue;
};
if !NEW_INSTALL_OFF.contains(&email.name.as_str()) || !email.enable {
continue;
}
email.enable = false;
let old = Object {
inner: server.object.into(),
revision: server.revision,
};
let new = Object {
inner: updated.into(),
revision: server.revision,
};
registry
.write(RegistryWrite::update(types::id::Id::from(server.id.id()), &new, &old))
.await?;
changed = true;
}
let mut batch = BatchBuilder::new();
batch.clear(new_install_key());
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(changed)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -90,6 +206,15 @@ mod tests {
); );
} }
#[test]
fn applied_marker_names_the_bundled_version() {
assert!(
BUNDLED_SPAM_RULES_APPLIED
.strip_prefix(BUNDLED_SPAM_RULES_VERSION)
.is_some_and(|generation| generation.starts_with('+'))
);
}
#[test] #[test]
fn bundled_rules_parse_and_score_the_ai_tags() { fn bundled_rules_parse_and_score_the_ai_tags() {
let rules: serde_json::Value = serde_json::from_slice(&bundled_rules().unwrap()).unwrap(); let rules: serde_json::Value = serde_json::from_slice(&bundled_rules().unwrap()).unwrap();
+19 -5
View File
@@ -1,7 +1,10 @@
/* /*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -72,11 +75,22 @@ impl Server {
.acme_certificate_renewal_due(&domains, renew_before, now()) .acme_certificate_renewal_due(&domains, renew_before, now())
.await? .await?
{ {
return Err(AcmeError::NotDue(format!( // INBUXA: a certificate already covering these names (one stored by
"Certificate for domain {} is still valid; renewal is not due until {}", // hand before the domain was switched to automatic, say) isn't a
domain.name, // failure: schedule the renewal for when it falls due. Returning
UTCDateTime::from_timestamp(renew_at as i64) // NotDue here ended the task for good, and nothing renewed the
))); // certificate before it expired.
trc::event!(
Acme(trc::AcmeEvent::RenewBackoff),
Domain = domain.name.clone(),
Hostname = domains.as_slice(),
Details = "A valid certificate already covers these names",
NextRetry = trc::Value::Timestamp(renew_at),
);
return Ok(vec![Task::AcmeRenewal(TaskDomainManagement {
domain_id,
status: TaskStatus::at(renew_at as i64),
})]);
} }
let dns_parameters = match &domain.dns_management { let dns_parameters = match &domain.dns_management {
@@ -6,12 +6,13 @@
* Modified by Coffey Labs in 2026 for INBUXA. * Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service}; use crate::{Server, manager::application::Resource};
use quick_xml::Reader; use quick_xml::Reader;
use quick_xml::XmlVersion; use quick_xml::XmlVersion;
use quick_xml::events::Event; use quick_xml::events::Event;
use registry::schema::enums::ServiceProtocol; use registry::schema::{enums::ServiceProtocol, structs::Service};
use std::fmt::Write; use std::fmt::Write;
use utils::map::vec_map::VecMap;
impl Server { impl Server {
pub async fn handle_autodiscover_request( pub async fn handle_autodiscover_request(
@@ -26,89 +27,103 @@ impl Server {
.details("Failed to parse autodiscover request") .details("Failed to parse autodiscover request")
.ctx(trc::Key::Reason, err) .ctx(trc::Key::Reason, err)
})?; })?;
let default_host = &self.core.network.server_name;
// Build XML response
let mut config = String::with_capacity(1024);
let _ = writeln!(&mut config, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
let _ = writeln!(
&mut config,
"<Autodiscover xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006\">"
);
let _ = writeln!(
&mut config,
"\t<Response xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a\">"
);
let _ = writeln!(&mut config, "\t\t<User>");
let _ = writeln!(
&mut config,
"\t\t\t<DisplayName>{emailaddress}</DisplayName>"
);
let _ = writeln!(
&mut config,
"\t\t\t<AutoDiscoverSMTPAddress>{emailaddress}</AutoDiscoverSMTPAddress>"
);
// DeploymentId is a required field of User but we are not a MS Exchange server so use a random value
let _ = writeln!(
&mut config,
"\t\t\t<DeploymentId>644560b8-a1ce-429c-8ace-23395843f701</DeploymentId>"
);
let _ = writeln!(&mut config, "\t\t</User>");
let _ = writeln!(&mut config, "\t\t<Account>");
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
// inbuxa: legacy-protocols LP-7, LP-14a // inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = match emailaddress.rsplit_once('@') { let legacy_off = match emailaddress.rsplit_once('@') {
Some((_, domain)) => self.legacy_protocols_off_for(domain).await?, Some((_, domain)) => self.legacy_off_for(domain).await?,
None => self.legacy_protocols_off_for("").await?, None => self.legacy_off_for("").await?,
}; };
for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
continue;
}
let (protocol, ports) = match protocol {
ServiceProtocol::Imap => ("IMAP", [143, 993]),
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
ServiceProtocol::Smtp => ("SMTP", [587, 465]),
_ => continue,
};
for (is_tls, port) in ports.into_iter().enumerate() {
if is_tls == 1 || service.cleartext {
let server_name = service.hostname.as_deref().unwrap_or(default_host);
let _ = writeln!(&mut config, "\t\t\t<Protocol>");
let _ = writeln!(&mut config, "\t\t\t\t<Type>{protocol}</Type>",);
let _ = writeln!(&mut config, "\t\t\t\t<Server>{server_name}</Server>");
let _ = writeln!(&mut config, "\t\t\t\t<Port>{port}</Port>");
let _ = writeln!(&mut config, "\t\t\t\t<LoginName>{emailaddress}</LoginName>");
let _ = writeln!(&mut config, "\t\t\t\t<AuthRequired>on</AuthRequired>");
let _ = writeln!(&mut config, "\t\t\t\t<DirectoryPort>0</DirectoryPort>");
let _ = writeln!(&mut config, "\t\t\t\t<ReferralPort>0</ReferralPort>");
let _ = writeln!(
&mut config,
"\t\t\t\t<SSL>{}</SSL>",
if is_tls == 1 { "on" } else { "off" }
);
if is_tls == 1 {
let _ = writeln!(&mut config, "\t\t\t\t<Encryption>TLS</Encryption>");
}
let _ = writeln!(&mut config, "\t\t\t\t<SPA>off</SPA>");
let _ = writeln!(&mut config, "\t\t\t</Protocol>");
}
}
}
let _ = writeln!(&mut config, "\t\t</Account>");
let _ = writeln!(&mut config, "\t</Response>");
let _ = writeln!(&mut config, "</Autodiscover>");
Ok(Resource::new( Ok(Resource::new(
"application/xml; charset=utf-8", "application/xml; charset=utf-8",
config.into_bytes(), build_autodiscover_response(
&emailaddress,
&self.core.network.server_name,
&self.core.network.info.services,
|protocol| legacy_off.service(protocol),
)
.into_bytes(),
)) ))
} }
} }
fn build_autodiscover_response(
emailaddress: &str,
default_host: &str,
services: &VecMap<ServiceProtocol, Service>,
switched_off: impl Fn(&ServiceProtocol) -> bool,
) -> String {
// Build XML response
let mut config = String::with_capacity(1024);
let _ = writeln!(&mut config, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
let _ = writeln!(
&mut config,
"<Autodiscover xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006\">"
);
let _ = writeln!(
&mut config,
"\t<Response xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a\">"
);
let _ = writeln!(&mut config, "\t\t<User>");
let _ = writeln!(
&mut config,
"\t\t\t<DisplayName>{emailaddress}</DisplayName>"
);
let _ = writeln!(
&mut config,
"\t\t\t<AutoDiscoverSMTPAddress>{emailaddress}</AutoDiscoverSMTPAddress>"
);
// DeploymentId is a required field of User but we are not a MS Exchange server so use a random value
let _ = writeln!(
&mut config,
"\t\t\t<DeploymentId>644560b8-a1ce-429c-8ace-23395843f701</DeploymentId>"
);
let _ = writeln!(&mut config, "\t\t</User>");
let _ = writeln!(&mut config, "\t\t<Account>");
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
for (protocol, service) in services {
if switched_off(protocol) {
continue;
}
let (protocol, ports) = match protocol {
ServiceProtocol::Imap => ("IMAP", [(993, true), (143, false)]),
ServiceProtocol::Pop3 => ("POP3", [(995, true), (110, false)]),
ServiceProtocol::Smtp => ("SMTP", [(465, true), (587, false)]),
_ => continue,
};
// Implicit TLS is listed first so that it is preferred (RFC 8314)
for (port, is_tls) in ports {
if is_tls || service.cleartext {
let server_name = service.hostname.as_deref().unwrap_or(default_host);
let _ = writeln!(&mut config, "\t\t\t<Protocol>");
let _ = writeln!(&mut config, "\t\t\t\t<Type>{protocol}</Type>",);
let _ = writeln!(&mut config, "\t\t\t\t<Server>{server_name}</Server>");
let _ = writeln!(&mut config, "\t\t\t\t<Port>{port}</Port>");
let _ = writeln!(&mut config, "\t\t\t\t<LoginName>{emailaddress}</LoginName>");
let _ = writeln!(&mut config, "\t\t\t\t<AuthRequired>on</AuthRequired>");
let _ = writeln!(&mut config, "\t\t\t\t<DirectoryPort>0</DirectoryPort>");
let _ = writeln!(&mut config, "\t\t\t\t<ReferralPort>0</ReferralPort>");
let (ssl, encryption) = if is_tls {
("on", "SSL")
} else {
("off", "TLS")
};
let _ = writeln!(&mut config, "\t\t\t\t<SSL>{ssl}</SSL>");
let _ = writeln!(&mut config, "\t\t\t\t<Encryption>{encryption}</Encryption>");
let _ = writeln!(&mut config, "\t\t\t\t<SPA>off</SPA>");
let _ = writeln!(&mut config, "\t\t\t</Protocol>");
}
}
}
let _ = writeln!(&mut config, "\t\t</Account>");
let _ = writeln!(&mut config, "\t</Response>");
let _ = writeln!(&mut config, "</Autodiscover>");
config
}
fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> { fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
if bytes.is_empty() { if bytes.is_empty() {
return Err("Empty request body".to_string()); return Err("Empty request body".to_string());
@@ -211,4 +226,79 @@ mod tests {
"[email protected]" "[email protected]"
); );
} }
#[test]
fn autodiscover_encryption() {
use registry::schema::{enums::ServiceProtocol, structs::Service};
use utils::map::vec_map::VecMap;
fn tag<'x>(block: &'x str, name: &str) -> &'x str {
block
.split_once(&format!("<{name}>"))
.and_then(|(_, rest)| rest.split_once(&format!("</{name}>")))
.map(|(value, _)| value)
.unwrap()
}
for (cleartext, expected) in [
(
false,
vec![
("IMAP", "993", "on", "SSL"),
("POP3", "995", "on", "SSL"),
("SMTP", "465", "on", "SSL"),
],
),
(
true,
vec![
("IMAP", "993", "on", "SSL"),
("IMAP", "143", "off", "TLS"),
("POP3", "995", "on", "SSL"),
("POP3", "110", "off", "TLS"),
("SMTP", "465", "on", "SSL"),
("SMTP", "587", "off", "TLS"),
],
),
] {
let services: VecMap<ServiceProtocol, Service> = [
ServiceProtocol::Imap,
ServiceProtocol::Pop3,
ServiceProtocol::Smtp,
ServiceProtocol::Jmap,
]
.into_iter()
.map(|protocol| {
(
protocol,
Service {
hostname: None,
cleartext,
},
)
})
.collect();
let response = super::build_autodiscover_response(
"[email protected]",
"mail.example.com",
&services,
|_| false,
);
assert_eq!(
response
.split("<Protocol>")
.skip(1)
.map(|block| (
tag(block, "Type"),
tag(block, "Port"),
tag(block, "SSL"),
tag(block, "Encryption"),
))
.collect::<Vec<_>>(),
expected,
"cleartext: {cleartext}"
);
}
}
} }
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA. * Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service}; use crate::{Server, manager::application::Resource};
use registry::schema::enums::ServiceProtocol; use registry::schema::enums::ServiceProtocol;
use std::fmt::Write; use std::fmt::Write;
use utils::url_params::UrlParams; use utils::url_params::UrlParams;
@@ -31,7 +31,7 @@ impl Server {
}; };
// inbuxa: legacy-protocols LP-7, LP-14a // inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = self.legacy_protocols_off_for(domain).await?; let legacy_off = self.legacy_off_for(domain).await?;
// Build XML response // Build XML response
let mut config = String::with_capacity(1024); let mut config = String::with_capacity(1024);
@@ -45,7 +45,7 @@ impl Server {
"\t\t<displayShortName>{domain}</displayShortName>" "\t\t<displayShortName>{domain}</displayShortName>"
); );
for (protocol, service) in &self.core.network.info.services { for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) { if legacy_off.service(protocol) {
continue; continue;
} }
let (protocol, tag, ports) = match protocol { let (protocol, tag, ports) = match protocol {
+7 -14
View File
@@ -6,11 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA. * Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
Server,
config::network::Pacc,
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
};
use ahash::{AHashMap, AHashSet}; use ahash::{AHashMap, AHashSet};
use base64::{Engine, engine::general_purpose}; use base64::{Engine, engine::general_purpose};
use dns_update::{ use dns_update::{
@@ -41,7 +37,7 @@ impl Server {
let default_host = network.server_name.as_str(); let default_host = network.server_name.as_str();
let domain_name = domain.name.as_str(); let domain_name = domain.name.as_str();
// inbuxa: legacy-protocols LP-7, LP-14a // inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = self.legacy_protocols_off_for(domain_name).await?; let legacy_off = self.legacy_off_for(domain_name).await?;
let domain_name_suffix = format!(".{domain_name}"); let domain_name_suffix = format!(".{domain_name}");
for record_type in record_types { for record_type in record_types {
@@ -205,7 +201,7 @@ impl Server {
// name says "not offered" -- target "." (RFC 6186 section // name says "not offered" -- target "." (RFC 6186 section
// 3.4) -- rather than vanishing, so a client that looks // 3.4) -- rather than vanishing, so a client that looks
// is told, and an old record left in the zone is replaced. // is told, and an old record left in the zone is replaced.
if legacy_off && is_legacy_service(protocol) { if legacy_off.service(protocol) {
for (service_name, _) in services { for (service_name, _) in services {
records.push(NamedDnsRecord { records.push(NamedDnsRecord {
name: format!("_{service_name}._tcp.{domain_name}."), name: format!("_{service_name}._tcp.{domain_name}."),
@@ -307,8 +303,8 @@ impl Server {
// inbuxa: legacy-protocols LP-7. No TLS pin for a port // inbuxa: legacy-protocols LP-7. No TLS pin for a port
// the switch has closed. Submission's port stays open // the switch has closed. Submission's port stays open
// (the SMTP lock), so its record stays. // (the SMTP lock), so its record stays.
if legacy_off if matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3) && legacy_off.service(protocol)
{ {
continue; continue;
} }
@@ -418,11 +414,8 @@ impl Server {
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> { pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
// inbuxa: legacy-protocols LP-7, LP-14a // inbuxa: legacy-protocols LP-7, LP-14a
let pacc = if self.legacy_protocols_off_for(domain_name).await? { let off = self.legacy_off_for(domain_name).await?;
&self.core.network.info.pacc_jmap_only let pacc = &self.core.network.info.pacc[off.index()];
} else {
&self.core.network.info.pacc
};
self.get_directory_for_domain(domain_name) self.get_directory_for_domain(domain_name)
.await .await
.caused_by(trc::location!()) .caused_by(trc::location!())
+14
View File
@@ -961,6 +961,20 @@ impl DnsUpdater {
) )
.map_err(|err| format!("Failed to build DNS updater: {}", err))?, .map_err(|err| format!("Failed to build DNS updater: {}", err))?,
}), }),
DnsServer::PowerDns(server) => Ok(DnsUpdater {
polling_interval: server.polling_interval.into_inner(),
propagation_timeout: server.propagation_timeout.into_inner(),
propagation_delay: server.propagation_delay.map(|d| d.into_inner()),
ttl: server.ttl.into_inner(),
core,
updater: dns_update::DnsUpdater::new_pdns(
server.api_key.secret().await?,
server.endpoint,
server.server_id,
server.timeout.into_inner().into(),
)
.map_err(|err| format!("Failed to build DNS updater: {}", err))?,
}),
DnsServer::Safedns(server) => Ok(DnsUpdater { DnsServer::Safedns(server) => Ok(DnsUpdater {
polling_interval: server.polling_interval.into_inner(), polling_interval: server.polling_interval.into_inner(),
propagation_timeout: server.propagation_timeout.into_inner(), propagation_timeout: server.propagation_timeout.into_inner(),
+191 -63
View File
@@ -35,8 +35,8 @@ use directory::Credentials;
use inbuxa_features::security::{ use inbuxa_features::security::{
legacy_use::{self, LegacyUse}, legacy_use::{self, LegacyUse},
listeners, listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener}, protocol_policy::{self, ProtocolPolicy, SUBMISSION, SWITCHED, SavedListener, Switches},
tenant_protocol_policy, tenant_protocol_policy::{self, OffBy, TenantProtocolPolicy},
}; };
use registry::schema::enums::ServiceProtocol; use registry::schema::enums::ServiceProtocol;
use registry::types::{error::Error, id::ObjectId}; use registry::types::{error::Error, id::ObjectId};
@@ -97,40 +97,48 @@ impl Server {
// this, and a /set that omitted it must not lose the listeners still // this, and a /set that omitted it must not lose the listeners still
// waiting to come back. // waiting to come back.
let previous = self.protocol_policy().await?; let previous = self.protocol_policy().await?;
policy.saved_listeners = previous.saved_listeners; policy.saved_listeners = previous.saved_listeners.clone();
policy.changed_at = Some(store::write::now() * 1000); policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = changed_by; policy.changed_by = changed_by;
policy.normalize();
if policy.legacy_protocols.is_disabled() { // Each protocol on its own switch: close what is off now, and put
self.close_legacy_listeners(&mut policy, &mut change).await?; // back what was saved for a protocol that is on again. Either may
} else { // happen in one change, when one protocol goes off as another comes
self.reopen_legacy_listeners(&mut policy, &mut change) // back.
.await?; self.close_legacy_listeners(&mut policy, &mut change)
} .await?;
self.reopen_legacy_listeners(&mut policy, &mut change)
.await?;
protocol_policy::set(&self.core.storage.data, &policy).await?; protocol_policy::set(&self.core.storage.data, &policy).await?;
// LP-8. Raised here rather than by the JMAP method, so whatever turns // LP-8. Raised here rather than by the JMAP method, so whatever turns
// the switch is reported. A /set that changed nothing -- the switch // a switch is reported. A /set that changed nothing -- every switch
// already where it was asked to be, nothing to close or reopen -- is // already where it was asked to be, nothing to close or reopen -- is
// not a change. // not a change.
if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() { let mut before = previous;
let (moved, direction) = if policy.legacy_protocols.is_disabled() { before.normalize();
(&change.closed, "closed") if before.off() != policy.off() || !change.is_empty() {
} else { // The closed first, then the reopened; `Details` says which.
(&change.reopened, "reopened") let moved = change
}; .closed
.iter()
.chain(change.reopened.iter())
.map(|l| l.id.clone());
trc::event!( trc::event!(
Security(trc::SecurityEvent::LegacyProtocolsChanged), Security(trc::SecurityEvent::LegacyProtocolsChanged),
Policy = "server", Policy = "server",
Value = if policy.legacy_protocols.is_disabled() { Value = switches_value(&policy),
"disabled"
} else {
"enabled"
},
AccountId = policy.changed_by.clone(), AccountId = policy.changed_by.clone(),
Details = direction, Details = if change.closed.is_empty() {
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())), "reopened"
} else if change.reopened.is_empty() {
"closed"
} else {
"closed and reopened"
},
ListenerId = listener_names(moved),
// Only when a listener could not be put back (LP-5). // Only when a listener could not be put back (LP-5).
Reason = (!change.failed.is_empty()).then(|| listener_names( Reason = (!change.failed.is_empty()).then(|| listener_names(
change change
@@ -165,21 +173,27 @@ impl Server {
Ok(()) Ok(())
} }
/// Puts back every saved listener and starts it again (LP-5). /// Puts back every saved listener whose protocol is on again, and starts
/// it (LP-5). The rest stay saved.
async fn reopen_legacy_listeners( async fn reopen_legacy_listeners(
&self, &self,
policy: &mut ProtocolPolicy, policy: &mut ProtocolPolicy,
change: &mut PolicyChange, change: &mut PolicyChange,
) -> trc::Result<()> { ) -> trc::Result<()> {
if policy.saved_listeners.is_empty() { let (wanted, still_closed): (Vec<_>, Vec<_>) = std::mem::take(&mut policy.saved_listeners)
.into_iter()
.partition(|saved| !policy.closes(&saved.protocol, &saved.ports));
policy.saved_listeners = still_closed;
if wanted.is_empty() {
return Ok(()); return Ok(());
} }
let saved = std::mem::take(&mut policy.saved_listeners); let (restored, failed) = listeners::reopen(self.registry(), &wanted).await?;
let (restored, failed) = listeners::reopen(self.registry(), &saved).await?;
// A listener that could not be put back stays saved for another try. // A listener that could not be put back stays saved for another try.
policy.saved_listeners = failed.iter().map(|(listener, _)| listener.clone()).collect(); policy
.saved_listeners
.extend(failed.iter().map(|(listener, _)| listener.clone()));
change.failed = failed; change.failed = failed;
if !restored.is_empty() { if !restored.is_empty() {
@@ -254,6 +268,17 @@ impl Server {
} }
} }
/// The switches as an event value: `disabled` or `enabled` when all three
/// agree, otherwise which are off, such as `pop3 disabled` (LP-8).
pub fn switches_value(policy: &impl Switches) -> String {
let off = policy.off();
match off.len() {
0 => "enabled".to_string(),
n if n == SWITCHED.len() => "disabled".to_string(),
_ => format!("{} disabled", off.join(", ")),
}
}
/// Names for an event field: the listeners a change closed, reopened or /// Names for an event field: the listeners a change closed, reopened or
/// failed to reopen (LP-8). /// failed to reopen (LP-8).
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value { fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
@@ -395,15 +420,18 @@ impl Server {
credentials: &Credentials, credentials: &Credentials,
) -> trc::Result<()> { ) -> trc::Result<()> {
let domain = domain_of(credentials); let domain = domain_of(credentials);
if self.protocol_policy().await?.legacy_protocols.is_disabled() { let server = self.protocol_policy().await?;
if server.is_off(protocol.as_str()) {
return Err(protocol.refused(RefusalScope::Server, domain)); return Err(protocol.refused(RefusalScope::Server, domain));
} }
if let Some(name) = &domain if let Some(name) = &domain
&& let Some(domain) = self.domain(name).await? && let Some(domain) = self.domain(name).await?
&& let Some(tenant_id) = domain.id_tenant && let Some(tenant_id) = domain.id_tenant
&& self.tenant_legacy_protocols_off(tenant_id).await?
{ {
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone()))); let tenant = self.tenant_protocol_policy(tenant_id).await?;
if tenant_protocol_policy::off_by(&server, Some(&tenant), protocol.as_str()).is_some() {
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
}
} }
Ok(()) Ok(())
} }
@@ -422,10 +450,16 @@ impl Server {
protocol: LegacyProtocol, protocol: LegacyProtocol,
access_token: &AccessToken, access_token: &AccessToken,
) -> trc::Result<()> { ) -> trc::Result<()> {
if let Some(tenant_id) = access_token.tenant_id() if let Some(tenant_id) = access_token.tenant_id() {
&& self.tenant_legacy_protocols_off(tenant_id).await? let server = self.protocol_policy().await?;
{ let tenant = self.tenant_protocol_policy(tenant_id).await?;
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None)); match tenant_protocol_policy::off_by(&server, Some(&tenant), protocol.as_str()) {
Some(OffBy::Server) => return Err(protocol.refused(RefusalScope::Server, None)),
Some(OffBy::Tenant) => {
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
}
None => {}
}
} }
if let Err(err) = legacy_use::record( if let Err(err) = legacy_use::record(
&self.core.storage.data, &self.core.storage.data,
@@ -463,31 +497,96 @@ impl Server {
Ok(recent) Ok(recent)
} }
/// Whether legacy protocols are off for this account: the stricter of the /// Which legacy protocols are off for this account: each the stricter of
/// server's switch and its tenant's. What the JMAP session tells the /// the server's switch and its tenant's. What the JMAP session tells the
/// account's apps (legacy-protocols spec, Interfaces), so the webmail can /// account's apps (legacy-protocols spec, Interfaces), so the webmail can
/// say why a mail app won't connect (LP-19). /// say why a mail app won't connect (LP-19).
pub async fn legacy_protocols_off_for_account( pub async fn legacy_off_for_account(
&self, &self,
access_token: &AccessToken, access_token: &AccessToken,
) -> trc::Result<bool> { ) -> trc::Result<LegacyOff> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() { let server = self.protocol_policy().await?;
return Ok(true); let tenant = match access_token.tenant_id() {
} Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
match access_token.tenant_id() { None => None,
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await, };
None => Ok(false), Ok(LegacyOff::of(&server, tenant.as_ref()))
}
/// A tenant's switches, or all on when it has never set them (LP-10).
pub async fn tenant_protocol_policy(
&self,
tenant_id: u32,
) -> trc::Result<TenantProtocolPolicy> {
tenant_protocol_policy::get(&self.core.storage.data, tenant_id).await
}
}
/// Which legacy protocols are off, for one account or one domain: the server's
/// switches and the tenant's together. Submission is off only when all three
/// are.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LegacyOff {
pub imap: bool,
pub pop3: bool,
pub manage_sieve: bool,
pub submission: bool,
}
impl LegacyOff {
pub fn of(server: &ProtocolPolicy, tenant: Option<&TenantProtocolPolicy>) -> Self {
let off = |protocol| tenant_protocol_policy::off_by(server, tenant, protocol).is_some();
LegacyOff {
imap: off("imap"),
pop3: off("pop3"),
manage_sieve: off("manageSieve"),
submission: off(SUBMISSION),
} }
} }
/// Whether a tenant has turned legacy protocols off for itself (LP-10). /// Whether this configured service must not be offered (LP-7). SMTP here
pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result<bool> { /// is submission; inbound mail is never a configured service.
Ok( pub fn service(&self, protocol: &ServiceProtocol) -> bool {
tenant_protocol_policy::get(&self.core.storage.data, tenant_id) match protocol {
.await? ServiceProtocol::Imap => self.imap,
.legacy_protocols ServiceProtocol::Pop3 => self.pop3,
.is_disabled(), ServiceProtocol::Managesieve => self.manage_sieve,
) ServiceProtocol::Smtp => self.submission,
_ => false,
}
}
/// Whether anything is off.
pub fn any(&self) -> bool {
self.imap || self.pop3 || self.manage_sieve || self.submission
}
/// Whether everything is off: the kill-all's effect.
pub fn all(&self) -> bool {
self.imap && self.pop3 && self.manage_sieve && self.submission
}
/// An index for answers prepared once per combination (the PACC
/// document): one bit per protocol.
pub fn index(&self) -> usize {
(self.imap as usize)
| (self.pop3 as usize) << 1
| (self.manage_sieve as usize) << 2
| (self.submission as usize) << 3
}
/// The protocols that are still allowed, by JMAP name, for the session.
pub fn allowed(&self) -> Vec<&'static str> {
[
("imap", self.imap),
("pop3", self.pop3),
("manageSieve", self.manage_sieve),
(SUBMISSION, self.submission),
]
.into_iter()
.filter(|(_, off)| !off)
.map(|(name, _)| name)
.collect()
} }
} }
@@ -505,21 +604,20 @@ pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
} }
impl Server { impl Server {
/// Whether legacy services are off for this domain, for the answers that /// Which legacy services are off for this domain, for the answers that
/// must stop offering them: off for the whole server (LP-7), or for the /// must stop offering them: off for the whole server (LP-7), or for the
/// tenant the domain belongs to (LP-14a). Read per answer, as sign-in /// tenant the domain belongs to (LP-14a). Read per answer, as sign-in
/// reads it. A name that is no domain here answers for the server alone. /// reads it. A name that is no domain here answers for the server alone.
pub async fn legacy_protocols_off_for(&self, domain_name: &str) -> trc::Result<bool> { pub async fn legacy_off_for(&self, domain_name: &str) -> trc::Result<LegacyOff> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() { let server = self.protocol_policy().await?;
return Ok(true); let tenant = match self.domain(domain_name).await? {
}
match self.domain(domain_name).await? {
Some(domain) => match domain.id_tenant { Some(domain) => match domain.id_tenant {
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await, Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
None => Ok(false), None => None,
}, },
None => Ok(false), None => None,
} };
Ok(LegacyOff::of(&server, tenant.as_ref()))
} }
} }
@@ -619,6 +717,36 @@ mod tests {
} }
} }
#[test]
fn what_is_off_for_one_account_or_domain() {
use inbuxa_features::security::protocol_policy::LegacyProtocols;
let mut server = ProtocolPolicy::default();
server.set("pop3", LegacyProtocols::Disabled);
let mut tenant = TenantProtocolPolicy::default();
tenant.set("manageSieve", LegacyProtocols::Disabled);
let off = LegacyOff::of(&server, Some(&tenant));
assert!(off.pop3 && off.manage_sieve && !off.imap && !off.submission);
assert!(off.service(&ServiceProtocol::Pop3));
assert!(!off.service(&ServiceProtocol::Imap));
assert!(
!off.service(&ServiceProtocol::Smtp),
"sending is still offered"
);
assert!(!off.service(&ServiceProtocol::Jmap));
assert_eq!(off.allowed(), vec!["imap", "submission"]);
assert!(off.any() && !off.all());
let off = LegacyOff::of(&server, None);
assert_eq!(off.index(), 0b0010);
server.set_all(LegacyProtocols::Disabled);
let off = LegacyOff::of(&server, None);
assert!(off.all());
assert_eq!(off.index(), 0b1111);
assert!(off.allowed().is_empty());
}
#[test] #[test]
fn the_domain_comes_from_the_name_given() { fn the_domain_comes_from_the_name_given() {
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string())); assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
+31
View File
@@ -426,6 +426,37 @@ impl Server {
} }
} }
impl Server {
/// inbuxa: personal-data catalog, D2: removes bans whose period is over.
/// They already stop blocking when they expire, and go when settings are
/// next loaded; the daily clean-up makes sure a server that seldom
/// reloads doesn't keep them.
pub async fn purge_expired_blocked_ips(&self) -> trc::Result<()> {
let now = now() as i64;
let mut expired = Vec::new();
for ip in self.registry().list::<BlockedIp>().await? {
if ip.object.expires_at.as_ref().is_some_and(|at| at.timestamp() <= now) {
let address = ip.object.address.clone();
let object = Object {
inner: ip.object.into(),
revision: ip.revision,
};
self.registry()
.write(RegistryWrite::delete_object(ip.id, &object))
.await?;
expired.push(trc::Value::from(address.into_inner().0));
}
}
if !expired.is_empty() {
trc::event!(
Security(trc::SecurityEvent::IpBlockExpired),
Details = expired
);
}
Ok(())
}
}
impl BlockedIps { impl BlockedIps {
pub async fn parse(bp: &mut Bootstrap) -> Self { pub async fn parse(bp: &mut Bootstrap) -> Self {
let mut ips = Self::default(); let mut ips = Self::default();
+102 -36
View File
@@ -6,33 +6,79 @@
* Modified by Coffey Labs in 2026 for INBUXA. * Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use ahash::AHashMap;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use p256::{ use p256::{
SecretKey, SecretKey,
ecdsa::{Signature, SigningKey, signature::Signer}, ecdsa::{Signature, SigningKey, signature::Signer},
pkcs8::{DecodePrivateKey, PrivateKeyInfo, der::SecretDocument}, pkcs8::{DecodePrivateKey, PrivateKeyInfo, der::SecretDocument},
}; };
use parking_lot::Mutex;
use reqwest::{Url, header::HeaderValue};
use std::sync::Arc;
const VAPID_TOKEN_TTL: u64 = 12 * 60 * 60; const VAPID_TOKEN_TTL: u64 = 12 * 60 * 60;
const VAPID_TOKEN_REFRESH: u64 = VAPID_TOKEN_TTL / 2;
#[derive(Clone)] #[derive(Clone)]
pub struct Vapid { pub struct Vapid {
key: VapidKey, key: VapidKey,
contact: Option<String>, contact: Option<String>,
tokens: Arc<Mutex<AHashMap<String, VapidToken>>>,
}
struct VapidToken {
authorization: HeaderValue,
issued_at: u64,
} }
impl Vapid { impl Vapid {
pub fn new(key: VapidKey, contact: Option<String>) -> Self { pub fn new(key: VapidKey, contact: Option<String>) -> Self {
Self { key, contact } Self {
key,
contact,
tokens: Arc::default(),
}
} }
pub fn public_key(&self) -> &str { pub fn public_key(&self) -> &str {
self.key.public_key() self.key.public_key()
} }
pub fn authorization(&self, endpoint: &str, now: u64) -> Option<String> { pub fn authorization(&self, endpoint: &str, now: u64) -> Option<HeaderValue> {
self.key let prefix = endpoint_prefix(endpoint)?;
.authorization(endpoint, self.contact.as_deref(), now) if let Some(token) = self
.tokens
.lock()
.get(prefix)
.filter(|token| token.is_fresh(now))
{
return Some(token.authorization.clone());
}
let authorization = HeaderValue::try_from(self.key.authorization(
endpoint,
self.contact.as_deref(),
now,
)?)
.ok()?;
let mut tokens = self.tokens.lock();
tokens.retain(|_, token| token.is_fresh(now));
tokens.insert(
prefix.to_string(),
VapidToken {
authorization: authorization.clone(),
issued_at: now,
},
);
Some(authorization)
}
}
impl VapidToken {
fn is_fresh(&self, now: u64) -> bool {
now.checked_sub(self.issued_at)
.is_some_and(|age| age < VAPID_TOKEN_REFRESH)
} }
} }
@@ -105,41 +151,15 @@ impl VapidKey {
} }
} }
fn endpoint_origin(url: &str) -> Option<String> { fn endpoint_prefix(url: &str) -> Option<&str> {
let (scheme, rest) = url.split_once("://")?; let (scheme, rest) = url.split_once("://")?;
let scheme = scheme.to_ascii_lowercase();
let authority = rest.split(['/', '?', '#']).next()?; let authority = rest.split(['/', '?', '#']).next()?;
let authority = authority url.get(..scheme.len() + "://".len() + authority.len())
.rsplit_once('@') }
.map(|(_, host)| host)
.unwrap_or(authority);
if authority.is_empty() {
return None;
}
let (host, port) = if let Some(rest) = authority.strip_prefix('[') { fn endpoint_origin(url: &str) -> Option<String> {
let (addr, tail) = rest.split_once(']')?; let origin = Url::parse(url).ok()?.origin();
( origin.is_tuple().then(|| origin.ascii_serialization())
format!("[{}]", addr.to_ascii_lowercase()),
tail.strip_prefix(':').filter(|port| !port.is_empty()),
)
} else if let Some((host, port)) = authority.rsplit_once(':') {
(
host.to_ascii_lowercase(),
Some(port).filter(|p| !p.is_empty()),
)
} else {
(authority.to_ascii_lowercase(), None)
};
match port {
Some(port)
if !((scheme == "https" && port == "443") || (scheme == "http" && port == "80")) =>
{
Some(format!("{scheme}://{host}:{port}"))
}
_ => Some(format!("{scheme}://{host}")),
}
} }
pub fn normalize_contact(contact: &str) -> Option<String> { pub fn normalize_contact(contact: &str) -> Option<String> {
@@ -206,7 +226,12 @@ mod tests {
endpoint_origin("http://[2001:DB8::1]:80/p").unwrap(), endpoint_origin("http://[2001:DB8::1]:80/p").unwrap(),
"http://[2001:db8::1]" "http://[2001:db8::1]"
); );
assert_eq!(
endpoint_origin("https://attacker.example\\@fcm.googleapis.com/fcm/send/x").unwrap(),
"https://attacker.example"
);
assert!(endpoint_origin("not-a-url").is_none()); assert!(endpoint_origin("not-a-url").is_none());
assert!(endpoint_origin("mailto:[email protected]").is_none());
} }
#[test] #[test]
@@ -336,6 +361,47 @@ B4yDfR2rGOd2H6Kv3fQNHPj9Nu5Tks8QYMLzrX8ONCNoFnNUQl9S0r0QS6phVqD0
} }
} }
#[test]
fn authorization_is_reused_per_endpoint_prefix() {
let vapid = Vapid::new(test_key(), None);
let now = 1_700_000_000;
let token = vapid
.authorization("https://push.example.com/push/a", now)
.unwrap();
assert_eq!(
vapid
.authorization("https://push.example.com/push/b?x=1", now + 60)
.unwrap(),
token
);
assert_ne!(
vapid
.authorization("https://other.example.com/push/a", now)
.unwrap(),
token
);
assert_ne!(
vapid
.authorization("https://push.example.com/push/a", now - 1)
.unwrap(),
token
);
let refreshed = vapid
.authorization("https://push.example.com/push/a", now + VAPID_TOKEN_REFRESH)
.unwrap();
assert_ne!(refreshed, token);
assert_eq!(
vapid
.authorization(
"https://push.example.com/push/c",
now + VAPID_TOKEN_REFRESH + 1
)
.unwrap(),
refreshed
);
}
#[test] #[test]
fn authorization_omits_subject_when_no_contact() { fn authorization_omits_subject_when_no_contact() {
let key = test_key(); let key = test_key();
+434
View File
@@ -0,0 +1,434 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The live facts the personal-data catalog is evaluated against
//! (personal-data catalog spec, §6): which sources are switched on, what
//! bounds each one's retention, which stores and endpoints are elsewhere.
//! Read from the registry on each request, so every node answers alike.
use crate::Server;
use inbuxa_features::privacy::{
self, Days, Inventory, LiveFacts, is_loopback,
snapshot::{self, Snapshot, Trigger},
};
use registry::schema::{
prelude::Object,
structs::{
AiModel, BlobStore, DataRetention, DataStore, InMemoryStore, Jmap, MtaHook, MtaMilter,
MtaRoute, Search, SearchStore, SpamClassifier, SpamClassifierModel, SpamDnsblServer,
SpamLlm, SpamPyzor, Tracer, TracingStore, WebHook,
},
};
use registry::types::duration::Duration;
use serde_json::Value;
use types::id::Id;
/// The objects [`Server::privacy_facts`] reads: a write to one may change
/// the inventory.
pub const INVENTORY_OBJECTS: &[&str] = &[
"x:DataRetention",
"x:SpamClassifier",
"x:Jmap",
"x:TracingStore",
"x:Search",
"x:Tracer",
"x:WebHook",
"x:AiModel",
"x:SpamLlm",
"x:SpamDnsblServer",
"x:SpamPyzor",
"x:MtaMilter",
"x:MtaHook",
"x:MtaRoute",
"x:DataStore",
"x:BlobStore",
"x:SearchStore",
"x:InMemoryStore",
"inbuxa:AuditSettings",
"inbuxa:LogSettings",
"inbuxa:AiLimits",
];
/// A store or endpoint object's type and host, from its JSON: local types
/// stay on the host.
fn remote_host(value: &Value) -> Option<String> {
let kind = value.get("@type").and_then(Value::as_str).unwrap_or_default();
if matches!(kind, "" | "RocksDb" | "Sqlite" | "FileSystem" | "Default" | "Disabled") {
return None;
}
for key in ["host", "url", "endpoint", "address", "hostname"] {
if let Some(host) = value.get(key).and_then(Value::as_str).filter(|h| !h.is_empty()) {
return Some(host.to_string());
}
}
// A list of URLs, as an array or as a map keyed by URL
match value.get("urls") {
Some(Value::Array(urls)) => {
if let Some(url) = urls.first().and_then(Value::as_str) {
return Some(url.to_string());
}
}
Some(Value::Object(urls)) => {
if let Some(url) = urls.keys().next() {
return Some(url.clone());
}
}
_ => {}
}
Some(kind.to_string())
}
fn days(duration: Option<&Duration>) -> Days {
match duration {
Some(d) => Days::Days(d.into_inner().as_secs().div_ceil(86_400)),
None => Days::Unbounded,
}
}
/// The zones a DNSBL's zone expression can query: each quoted literal that
/// starts with a dot, in any branch (`ip_reverse + '.zen.spamhaus.org'`).
fn zone_hosts(value: &Value) -> Vec<String> {
let mut hosts = Vec::new();
let mut texts = Vec::new();
fn collect<'a>(value: &'a Value, texts: &mut Vec<&'a str>) {
match value {
Value::String(s) => texts.push(s),
Value::Array(items) => items.iter().for_each(|v| collect(v, texts)),
Value::Object(map) => map.values().for_each(|v| collect(v, texts)),
_ => {}
}
}
collect(value, &mut texts);
for text in texts {
for literal in text.split('\'').skip(1).step_by(2) {
if let Some(zone) = literal.strip_prefix('.')
&& zone.contains('.')
&& !hosts.iter().any(|h| h == zone)
{
hosts.push(zone.to_string());
}
}
}
hosts
}
impl Server {
async fn singleton<T: registry::types::ObjectImpl + From<Object> + Default>(&self) -> trc::Result<T> {
Ok(self.registry().object::<T>(Id::singleton()).await?.unwrap_or_default())
}
/// The facts the catalog is evaluated against, from the live settings.
pub async fn privacy_facts(&self) -> trc::Result<LiveFacts> {
let mut facts = LiveFacts::default();
let data = &self.core.storage.data;
let endpoint = |facts: &mut LiveFacts, id: &str, url: String| {
if !url.is_empty() && !is_loopback(&url) {
facts.endpoints.entry(id.to_string()).or_default().push(url);
}
};
// Retention
let retention = self.singleton::<DataRetention>().await?;
for (name, value) in [
("x:DataRetention.holdTracesFor", &retention.hold_traces_for),
("x:DataRetention.holdMetricsFor", &retention.hold_metrics_for),
("x:DataRetention.holdMtaReportsFor", &retention.hold_mta_reports_for),
("x:DataRetention.archiveDeletedItemsFor", &retention.archive_deleted_items_for),
("x:DataRetention.archiveDeletedAccountsFor", &retention.archive_deleted_accounts_for),
("x:DataRetention.expungeTrashAfter", &retention.expunge_trash_after),
("x:DataRetention.expungeSubmissionsAfter", &retention.expunge_submissions_after),
] {
facts.durations.insert(name.into(), days(value.as_ref()));
}
let classifier = self.singleton::<SpamClassifier>().await?;
facts.durations.insert(
"x:SpamClassifier.holdSamplesFor".into(),
days(Some(&classifier.hold_samples_for)),
);
let jmap = self.singleton::<Jmap>().await?;
facts
.durations
.insert("x:Jmap.uploadTtl".into(), days(Some(&jmap.upload_ttl)));
let audit = inbuxa_features::audit::log::settings(data).await?;
facts.durations.insert(
"inbuxa:AuditSettings.keepForDays".into(),
Days::Days(audit.keep_for_secs.div_ceil(86_400)),
);
let logs = inbuxa_features::security::log_files::get(data).await?;
facts.durations.insert(
"inbuxa:LogSettings.keepForDays".into(),
logs.keep_for_days.map_or(Days::Unbounded, Days::Days),
);
// What's switched on
let tracing = self.singleton::<TracingStore>().await?;
let tracing_on = !matches!(tracing, TracingStore::Disabled);
let search = self.singleton::<Search>().await?;
for id in ["x:Trace", "x:TraceEvent", "x:TraceKeyValue", "x:TraceValueIpAddr", "x:TraceValueString"] {
facts.collected.insert(id.into(), tracing_on);
}
facts
.collected
.insert("trace-index".into(), tracing_on && search.index_telemetry);
facts.collected.insert(
"full-text-index".into(),
search.index_email || search.index_calendar || search.index_contacts,
);
let archive_on = retention.archive_deleted_items_for.is_some();
for id in [
"x:ArchivedEmail",
"x:ArchivedFileNode",
"x:ArchivedCalendarEvent",
"x:ArchivedContactCard",
"x:ArchivedSieveScript",
] {
facts.collected.insert(id.into(), archive_on);
}
facts.collected.insert(
"inbuxa:DeletedAccount".into(),
retention.archive_deleted_accounts_for.is_some(),
);
let reports_on = retention.hold_mta_reports_for.is_some();
for id in [
"x:ArfExternalReport",
"x:ArfFeedbackReport",
"x:DmarcExternalReport",
"x:DmarcReport",
"x:DmarcReportRecord",
"x:TlsExternalReport",
"x:TlsReport",
"x:TlsFailureDetails",
] {
facts.collected.insert(id.into(), reports_on);
}
let classifier_on = !matches!(classifier.model, SpamClassifierModel::Disabled);
facts
.collected
.insert("x:SpamTrainingSample".into(), classifier_on);
facts
.collected
.insert("spam-trainer-state".into(), classifier_on);
// Tracers
let (mut log_on, mut console_on, mut otel_on) = (false, false, false);
for tracer in self.registry().list::<Tracer>().await? {
match tracer.object {
Tracer::Log(t) => log_on |= t.enable,
Tracer::Stdout(t) => console_on |= t.enable,
Tracer::Journal(t) => console_on |= t.enable,
Tracer::OtelHttp(t) if t.enable => {
otel_on = true;
endpoint(&mut facts, "otel-tracer", t.endpoint);
}
Tracer::OtelGrpc(t) if t.enable => {
otel_on = true;
endpoint(&mut facts, "otel-tracer", t.endpoint.unwrap_or_default());
}
_ => {}
}
}
facts.collected.insert("log-file".into(), log_on);
facts.collected.insert("x:Log".into(), log_on);
facts.collected.insert("console-and-journal".into(), console_on);
facts.collected.insert("otel-tracer".into(), otel_on);
// Webhooks
let mut hooks_on = false;
for hook in self.registry().list::<WebHook>().await? {
if hook.object.enable {
hooks_on = true;
endpoint(&mut facts, "webhooks", hook.object.url);
}
}
facts.collected.insert("webhooks".into(), hooks_on);
// AI: the classifier's model, and Explain's
let models = self.registry().list::<AiModel>().await?;
let model_url = |id: Id| {
models
.iter()
.find(|m| Id::from(m.id.id()) == id)
.map(|m| m.object.url.clone())
};
let llm_on = match self.singleton::<SpamLlm>().await? {
SpamLlm::Enable(props) => {
if let Some(url) = model_url(props.model_id) {
endpoint(&mut facts, "spam-llm", url);
}
true
}
SpamLlm::Disable => false,
};
facts.collected.insert("spam-llm".into(), llm_on);
let limits = self.ai_limits().await;
let explain = self.ai_explain_model(&limits).await;
if let Some((_, model)) = &explain {
endpoint(&mut facts, "inbuxa:Explanation", model.url.clone());
}
facts
.collected
.insert("explain-cache".into(), explain.is_some());
facts
.collected
.insert("inbuxa:Explanation".into(), explain.is_some());
// Spam lookups off the host
let mut dnsbl_on = false;
for server in self.registry().list::<SpamDnsblServer>().await? {
let value = serde_json::to_value(&server.object).unwrap_or_default();
if value.get("enable").and_then(Value::as_bool).unwrap_or(false) {
dnsbl_on = true;
for zone in value.get("zone").map(zone_hosts).unwrap_or_default() {
endpoint(&mut facts, "spam-dnsbl", zone);
}
}
}
facts.collected.insert("spam-dnsbl".into(), dnsbl_on);
let pyzor = self.singleton::<SpamPyzor>().await?;
if pyzor.enable {
endpoint(&mut facts, "spam-pyzor", format!("{}:{}", pyzor.host, pyzor.port));
}
facts.collected.insert("spam-pyzor".into(), pyzor.enable);
// Mail handed to others
let mut hooks = false;
for milter in self.registry().list::<MtaMilter>().await? {
hooks = true;
endpoint(
&mut facts,
"mta-milter-and-hooks",
format!("{}:{}", milter.object.hostname, milter.object.port),
);
}
for hook in self.registry().list::<MtaHook>().await? {
hooks = true;
endpoint(&mut facts, "mta-milter-and-hooks", hook.object.url);
}
facts.collected.insert("mta-milter-and-hooks".into(), hooks);
let mut relays = false;
for route in self.registry().list::<MtaRoute>().await? {
if let MtaRoute::Relay(relay) = route.object {
relays = true;
endpoint(&mut facts, "relay", format!("{}:{}", relay.address, relay.port));
}
}
facts.collected.insert("relay".into(), relays);
// Stores elsewhere
let stores = [
("data-store", serde_json::to_value(self.singleton::<DataStore>().await.ok()).unwrap_or_default()),
("blob-store", serde_json::to_value(self.singleton::<BlobStore>().await?).unwrap_or_default()),
("search-store", serde_json::to_value(self.singleton::<SearchStore>().await?).unwrap_or_default()),
("in-memory-store", serde_json::to_value(self.singleton::<InMemoryStore>().await?).unwrap_or_default()),
];
for (place, value) in stores {
if let Some(host) = remote_host(&value) {
facts.remote_stores.insert(place.into(), host);
}
}
if let Some(host) = remote_host(&serde_json::to_value(&tracing).unwrap_or_default()) {
for id in ["x:Trace", "x:TraceEvent", "x:TraceKeyValue", "x:TraceValueIpAddr", "x:TraceValueString"] {
endpoint(&mut facts, id, host.clone());
}
}
Ok(facts)
}
/// The server's inventory, or a tenant's slice of it.
pub async fn data_inventory(&self, tenant_only: bool) -> trc::Result<Inventory> {
let facts = self.privacy_facts().await?;
Ok(privacy::evaluate(privacy::catalog(), &facts, tenant_only))
}
/// Records a snapshot of the server's inventory if it differs from the
/// newest one, or if there is none: the history shows when what the
/// server holds changed, not a copy a day. Returns whether it recorded.
pub async fn inventory_snapshot(&self, trigger: Trigger) -> trc::Result<bool> {
let data = &self.core.storage.data;
let inventory = self.data_inventory(false).await?;
if let Some(latest) = snapshot::latest(data).await?
&& let Some(previous) = snapshot::get(data, latest).await?
&& previous.inventory == inventory
{
return Ok(false);
}
snapshot::record(
data,
&Snapshot {
taken_at: store::write::now(),
trigger,
summary: inventory.summary(),
inventory,
},
)
.await?;
Ok(true)
}
/// A snapshot after a registry write, when the object is one the
/// inventory reads. Failures are logged: a snapshot is history, not
/// worth failing the write over.
pub async fn inventory_snapshot_after(&self, object: &str) {
if !INVENTORY_OBJECTS.contains(&object) {
return;
}
if let Err(err) = self
.inventory_snapshot(Trigger::SettingChanged {
setting: object.to_string(),
})
.await
{
trc::error!(err.details("Failed to record an inventory snapshot"));
}
}
/// Removes snapshots past the audit log's retention (settled
/// 2026-09-28: snapshots are kept as long as audit records).
pub async fn purge_inventory_snapshots(&self) -> trc::Result<usize> {
let data = &self.core.storage.data;
let keep = inbuxa_features::audit::log::settings(data).await?.keep_for_secs;
snapshot::purge(data, store::write::now().saturating_sub(keep)).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn local_stores_stay_and_others_name_their_host() {
assert_eq!(remote_host(&json!({"@type": "RocksDb", "path": "/var/lib"})), None);
assert_eq!(remote_host(&json!({"@type": "Default"})), None);
assert_eq!(
remote_host(&json!({"@type": "PostgreSql", "host": "db.example.net"})),
Some("db.example.net".into())
);
assert_eq!(
remote_host(&json!({"@type": "ElasticSearch", "url": "https://es.example.net:9200"})),
Some("https://es.example.net:9200".into())
);
assert_eq!(remote_host(&json!({"@type": "S3", "bucket": "mail"})), Some("S3".into()));
}
#[test]
fn zones_come_from_every_branch() {
let zone = json!({"else": "false", "match": {"0": {"if": "location == 'tcp'",
"then": "ip_reverse + '.rep.mailspike.net'"}}});
assert_eq!(zone_hosts(&zone), vec!["rep.mailspike.net"]);
let zone = json!({"else": "hash(email, 'sha1') + '.ebl.msbl.org'", "match": {}});
assert_eq!(zone_hosts(&zone), vec!["ebl.msbl.org"], "not 'sha1'");
assert!(zone_hosts(&json!({"else": "false"})).is_empty());
}
#[test]
fn days_round_up() {
assert_eq!(days(Some(&Duration::from_millis(86_400_000))), Days::Days(1));
assert_eq!(days(Some(&Duration::from_millis(3_600_000))), Days::Days(1));
assert_eq!(days(None), Days::Unbounded);
}
}
+293
View File
@@ -0,0 +1,293 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Whether the outside world can reach each node's ports (settings-reorg,
//! Ports: the reachability check).
//!
//! A server can't answer this about itself: a connection to its own public
//! address never leaves the machine, so it passes whatever the firewall in
//! front says. In a cluster the other nodes are outside that machine. Every
//! ten minutes each node resolves every other active node's hostname, as a
//! sender would, and tries a TCP connection to each listener port on each
//! address. What it saw goes in the shared in-memory store for an hour, under
//! (target, prober), so whichever node the admin asks can report it all.
//!
//! A single server has no one outside to ask. It reports only whether each
//! port is listening, and says so.
//!
//! A connection is all that's tried: nothing is sent, so no protocol logs a
//! session and no rate limit counts it.
use crate::{KV_PORT_REACHABILITY, Server};
use registry::schema::{enums::ClusterNodeStatus, structs::NetworkListener};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{
collections::BTreeSet,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
time::{Duration, Instant},
};
use store::{dispatch::lookup::KeyValue, write::now};
/// How often each node probes the others.
pub const PROBE_INTERVAL: Duration = Duration::from_secs(600);
/// How long one node's view of another is kept: long enough to span a missed round.
const KEEP_FOR: u64 = 3600;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Probe {
pub port: u16,
pub address: String,
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Report {
/// Unix seconds.
pub checked_at: u64,
pub probes: Vec<Probe>,
/// The hostname didn't resolve, so nothing could be tried.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// The ports a sender or client could reach: every listener's port, leaving
/// out listeners bound only to loopback, which are private by design.
pub fn public_ports<'x>(listeners: impl IntoIterator<Item = &'x NetworkListener>) -> Vec<u16> {
listeners
.into_iter()
.flat_map(|l| l.bind.iter())
.map(|addr| addr.0)
.filter(|addr| !addr.ip().is_loopback())
.map(|addr| addr.port())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn key(target: &str, prober: &str) -> Vec<u8> {
format!("{target}\n{prober}").into_bytes()
}
async fn connect(address: SocketAddr) -> Result<(), String> {
match tokio::time::timeout(CONNECT_TIMEOUT, tokio::net::TcpStream::connect(address)).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(err)) => Err(err.to_string()),
Err(_) => Err("no answer within 5 seconds".into()),
}
}
/// Tries each port on each address `hostname` resolves to.
pub async fn probe_host(hostname: &str, ports: &[u16]) -> Report {
let checked_at = now();
let addresses = match tokio::net::lookup_host((hostname, 0)).await {
Ok(found) => found.map(|a| a.ip()).collect::<BTreeSet<_>>(),
Err(err) => {
return Report {
checked_at,
probes: vec![],
error: Some(format!("{hostname} doesn't resolve: {err}")),
};
}
};
let tries = addresses.iter().flat_map(|ip| {
ports.iter().map(move |port| {
let address = SocketAddr::new(*ip, *port);
async move {
let result = connect(address).await;
Probe {
port: *port,
address: ip.to_string(),
ok: result.is_ok(),
error: result.err(),
}
}
})
});
Report {
checked_at,
probes: futures::future::join_all(tries).await,
error: None,
}
}
async fn listeners(server: &Server) -> trc::Result<Vec<NetworkListener>> {
Ok(server
.registry()
.list::<NetworkListener>()
.await?
.into_iter()
.map(|l| l.object)
.collect())
}
/// Where to knock to see a port listening on this machine: the bound
/// address, or loopback of the same family for a wildcard bind.
pub fn local_targets<'x>(
listeners: impl IntoIterator<Item = &'x NetworkListener>,
) -> Vec<SocketAddr> {
listeners
.into_iter()
.flat_map(|l| l.bind.iter())
.map(|addr| addr.0)
.filter(|addr| !addr.ip().is_loopback())
.map(|addr| match addr.ip() {
IpAddr::V4(ip) if ip.is_unspecified() => {
SocketAddr::new(Ipv4Addr::LOCALHOST.into(), addr.port())
}
IpAddr::V6(ip) if ip.is_unspecified() => {
SocketAddr::new(Ipv6Addr::LOCALHOST.into(), addr.port())
}
_ => addr,
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
/// One round: this node probes every other active node and records what it saw.
pub async fn probe_peers(server: &Server) -> trc::Result<()> {
let nodes = server.registry().cluster_node_list().await?;
let me = server.registry().node_id() as u64;
let Some(prober) = nodes
.iter()
.find(|n| n.node_id == me)
.map(|n| n.hostname.clone())
else {
return Ok(());
};
let ports = public_ports(&listeners(server).await?);
for target in nodes.iter().filter(|n| {
n.node_id != me && n.status == ClusterNodeStatus::Active && n.hostname != prober
}) {
let report = probe_host(&target.hostname, &ports).await;
server
.in_memory_store()
.key_set(
KeyValue::with_prefix(
KV_PORT_REACHABILITY,
key(&target.hostname, &prober),
serde_json::to_vec(&report).unwrap_or_default(),
)
.expires(KEEP_FOR),
)
.await?;
}
Ok(())
}
/// What `GET /api/ports/check` answers.
pub async fn report(server: &Server) -> trc::Result<Value> {
let listeners = listeners(server).await?;
let ports = public_ports(&listeners);
let nodes = if server.core.storage.coordinator.is_enabled() {
server.registry().cluster_node_list().await?
} else {
vec![]
};
let active = nodes
.iter()
.filter(|n| n.status == ClusterNodeStatus::Active)
.collect::<Vec<_>>();
if active.len() < 2 {
// No one outside to ask: only whether each port is listening here.
let started = Instant::now();
let listening = futures::future::join_all(local_targets(&listeners).into_iter().map(
|address| async move {
let result = connect(address).await;
json!({ "port": address.port(), "address": address.ip().to_string(), "listening": result.is_ok() })
},
))
.await;
return Ok(json!({
"mode": "local",
"ports": ports,
"listening": listening,
"ms": started.elapsed().as_millis() as u64,
}));
}
let mut out = Vec::new();
for target in &active {
let mut seen_by = Vec::new();
for prober in active.iter().filter(|p| p.node_id != target.node_id) {
let stored = server
.in_memory_store()
.key_get::<String>(KeyValue::<()>::build_key(
KV_PORT_REACHABILITY,
key(&target.hostname, &prober.hostname),
))
.await?;
let report = stored.and_then(|raw| serde_json::from_str::<Report>(&raw).ok());
seen_by.push(json!({ "prober": prober.hostname, "report": report }));
}
out.push(json!({ "hostname": target.hostname, "seenBy": seen_by }));
}
Ok(json!({
"mode": "cluster",
"ports": ports,
"intervalSeconds": PROBE_INTERVAL.as_secs(),
"nodes": out,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn listener(binds: &[&str]) -> NetworkListener {
NetworkListener {
bind: registry::schema::prelude::Map::new(
binds.iter().map(|b| b.parse().unwrap()).collect(),
),
..Default::default()
}
}
#[test]
fn public_ports_leave_out_loopback_only_listeners() {
let listeners = [
listener(&["[::]:25"]),
listener(&["0.0.0.0:993", "[::]:993"]),
listener(&["127.0.0.1:8080"]),
listener(&["203.0.113.5:465"]),
];
assert_eq!(public_ports(listeners.iter()), vec![25, 465, 993]);
assert_eq!(
local_targets(listeners.iter())
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
vec!["127.0.0.1:993", "203.0.113.5:465", "[::1]:25", "[::1]:993"]
);
}
#[tokio::test]
async fn probe_host_reports_open_and_closed_ports() {
let open = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let open_port = open.local_addr().unwrap().port();
let closed_port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let report = probe_host("127.0.0.1", &[open_port, closed_port]).await;
assert_eq!(report.error, None);
let ok = |port| report.probes.iter().find(|p| p.port == port).unwrap().ok;
assert!(ok(open_port));
assert!(!ok(closed_port));
}
#[tokio::test]
async fn probe_host_says_when_a_name_does_not_resolve() {
let report = probe_host("does-not-exist.invalid", &[25]).await;
assert!(report.probes.is_empty());
assert!(report.error.unwrap().contains("doesn't resolve"));
}
}
+148 -9
View File
@@ -156,15 +156,7 @@ async fn post_webhook_events(
// Add HMAC-SHA256 signature // Add HMAC-SHA256 signature
let mut headers = settings.headers.clone(); let mut headers = settings.headers.clone();
if !settings.key.is_empty() { sign(&mut headers, &settings.key, &body);
let key = hmac::Key::new(hmac::HMAC_SHA256, settings.key.as_bytes());
let tag = hmac::sign(&key, body.as_bytes());
headers.insert(
"X-Signature",
STANDARD.encode(tag.as_ref()).parse().unwrap(),
);
}
// Send request // Send request
let response = settings let response = settings
@@ -188,3 +180,150 @@ async fn post_webhook_events(
)) ))
} }
} }
/// Adds the HMAC-SHA256 `X-Signature` a receiver checks, when the webhook has a key.
fn sign(headers: &mut hyper::HeaderMap, key: &str, body: &str) {
if !key.is_empty() {
let key = hmac::Key::new(hmac::HMAC_SHA256, key.as_bytes());
let tag = hmac::sign(&key, body.as_bytes());
headers.insert(
"X-Signature",
STANDARD.encode(tag.as_ref()).parse().unwrap(),
);
}
}
/// inbuxa: "Send test" for a saved webhook (settings-reorg, Webhooks). One
/// sample event, sent the way a real batch is: the same URL, headers, sign-in,
/// signature, timeout and certificate checks. The event's type,
/// `webhook.test`, is none the server raises, and an `X-Inbuxa-Test` header
/// marks it, so a receiver can tell it apart. Answers the HTTP status, or why
/// nothing came back.
pub async fn send_test(hook: &registry::schema::structs::WebHook) -> Result<u16, String> {
let mut headers = hook
.http_auth
.build_headers(hook.http_headers.clone(), "application/json".into())
.await
.map_err(|err| format!("Unable to build HTTP headers: {err}"))?;
let key = hook
.signature_key
.secret()
.await
.map_err(|err| format!("Unable to retrieve signature key: {err}"))?
.unwrap_or_default()
.into_owned();
let created = now();
let body = serde_json::json!({
"events": [{
"id": format!("test-{created}"),
"createdAt": mail_parser::DateTime::from_timestamp(created as i64).to_rfc3339(),
"type": "webhook.test",
"data": { "details": "A test from inbuxa Admin. Nothing happened on the server." },
}]
})
.to_string();
sign(&mut headers, &key, &body);
headers.insert("X-Inbuxa-Test", "true".parse().unwrap());
let response = utils::http::http_client_builder(hook.allow_invalid_certs)
.build()
.map_err(|err| format!("Unable to build an HTTP client: {err}"))?
.post(&hook.url)
.timeout(hook.timeout.into_inner())
.headers(headers)
.body(body)
.send()
.await
.map_err(|err| format!("Webhook request to {} failed: {err}", hook.url))?;
Ok(response.status().as_u16())
}
#[cfg(test)]
mod tests {
use super::*;
use registry::schema::structs::{SecretKeyOptional, SecretKeyValue, WebHook};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// One request in, the given status out; hands back what was received.
async fn receiver(status: &'static str) -> (String, tokio::task::JoinHandle<String>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/hook", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
loop {
let n = socket.read(&mut chunk).await.unwrap();
buf.extend_from_slice(&chunk[..n]);
let text = String::from_utf8_lossy(&buf);
if let Some(end) = text.find("\r\n\r\n") {
let length = text[..end]
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
if buf.len() >= end + 4 + length || n == 0 {
break;
}
}
}
socket
.write_all(
format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
.as_bytes(),
)
.await
.unwrap();
String::from_utf8_lossy(&buf).into_owned()
});
(url, task)
}
#[tokio::test]
async fn send_test_signs_and_marks_the_sample() {
let (url, task) = receiver("204 No Content").await;
let hook = WebHook {
url,
enable: false,
signature_key: SecretKeyOptional::Value(SecretKeyValue { secret: "k".into() }),
..Default::default()
};
assert_eq!(send_test(&hook).await, Ok(204));
let request = task.await.unwrap();
let (head, body) = request.split_once("\r\n\r\n").unwrap();
let head = head.to_ascii_lowercase();
assert!(head.contains("x-inbuxa-test: true"), "{head}");
let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(parsed["events"][0]["type"], "webhook.test");
let tag = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, b"k"), body.as_bytes());
assert!(
head.contains(&format!(
"x-signature: {}",
STANDARD.encode(tag.as_ref()).to_ascii_lowercase()
)),
"{head}"
);
}
#[tokio::test]
async fn send_test_reports_what_came_back() {
let (url, _task) = receiver("403 Forbidden").await;
let hook = WebHook {
url,
..Default::default()
};
assert_eq!(send_test(&hook).await, Ok(403));
let hook = WebHook {
url: "http://127.0.0.1:9/hook".into(),
..Default::default()
};
assert!(send_test(&hook).await.unwrap_err().contains("failed"));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "coordinator" name = "coordinator"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dav-proto" name = "dav-proto"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dav" name = "dav"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "directory" name = "directory"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "email" name = "email"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1
View File
@@ -15,6 +15,7 @@ utils = { path = "../utils" }
ahash = { version = "0.8.12", features = ["serde"] } ahash = { version = "0.8.12", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
toml = "1.1"
xxhash-rust = { version = "0.8.18", features = ["xxh3"] } xxhash-rust = { version = "0.8.18", features = ["xxh3"] }
base64 = "0.23" base64 = "0.23"
sha2 = "0.11" sha2 = "0.11"
+1
View File
@@ -24,6 +24,7 @@ pub mod branding;
pub mod hold; pub mod hold;
pub mod lock; pub mod lock;
pub mod masked_email; pub mod masked_email;
pub mod privacy;
pub mod security; pub mod security;
pub mod tenancy; pub mod tenancy;
pub mod undelete; pub mod undelete;
+486
View File
@@ -0,0 +1,486 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The personal-data catalog, evaluated (personal-data catalog spec, §6).
//!
//! `resources/privacy/catalog.toml` says what the server *can* hold; this
//! module turns it into what *this* server holds, given the live facts the
//! caller gathers from its settings ([`LiveFacts`]). Facts in, facts out:
//! nothing here judges, and nothing here reads the store, so every
//! configuration can be tested with made-up facts.
pub mod snapshot;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::OnceLock;
/// The catalog, as shipped with this build.
pub const CATALOG: &str = include_str!("../../../../resources/privacy/catalog.toml");
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum Retention {
Word(String),
Setting { setting: String },
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ObjectEntry {
#[serde(default)]
pub whose: Vec<String>,
#[serde(default, rename = "where")]
pub location: Vec<String>,
pub scope: Option<String>,
pub retention: Option<Retention>,
#[serde(default)]
pub properties: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SourceEntry {
#[serde(default)]
pub categories: Vec<String>,
#[serde(default)]
pub whose: Vec<String>,
#[serde(default, rename = "where")]
pub location: Vec<String>,
pub scope: Option<String>,
pub retention: Option<Retention>,
#[serde(default)]
pub enabled_by: Vec<String>,
#[serde(default)]
pub captures: Vec<String>,
#[serde(default)]
pub leaves_host: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Catalog {
#[serde(default)]
pub object: BTreeMap<String, ObjectEntry>,
#[serde(default)]
pub source: BTreeMap<String, SourceEntry>,
}
/// The shipped catalog, parsed once. It is checked in CI
/// (`tools/fork/privacy-check.py`), so a parse failure is a build bug.
pub fn catalog() -> &'static Catalog {
static CATALOG_PARSED: OnceLock<Catalog> = OnceLock::new();
CATALOG_PARSED.get_or_init(|| toml::from_str(CATALOG).expect("resources/privacy/catalog.toml parses"))
}
/// A duration setting's live value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Days {
/// Set, in whole days (rounded up).
Days(u64),
/// Unset: nothing bounds it.
Unbounded,
}
/// Everything the evaluation needs from the running server.
#[derive(Debug, Clone, Default)]
pub struct LiveFacts {
/// Duration settings by name (`x:DataRetention.holdTracesFor`,
/// `inbuxa:AuditSettings.keepForDays` ...). A setting not here is
/// reported by name, without a value.
pub durations: BTreeMap<String, Days>,
/// Whether each source or object is collected at all, by catalog id. An
/// id not here is taken as collected.
pub collected: BTreeMap<String, bool>,
/// The endpoints each source or object sends to, by catalog id: hosts or
/// URLs as configured. Loopback endpoints are left out: what goes there
/// stays on the host ([`is_loopback`]).
pub endpoints: BTreeMap<String, Vec<String>>,
/// Stores pointed at a remote backend, by location (`data-store`,
/// `blob-store`, `search-store`, `in-memory-store`), with the host.
pub remote_stores: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RetentionOut {
/// `unbounded`, `days`, `object-life`, `receiver`, or `setting` (named but
/// not evaluated).
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub days: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub setting: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Item {
pub id: String,
/// `object` or `source`.
pub kind: String,
pub categories: Vec<String>,
pub whose: Vec<String>,
#[serde(rename = "where")]
pub location: Vec<String>,
pub scope: String,
pub collected: bool,
pub retention: RetentionOut,
pub leaves_host: bool,
pub controlled_by: Vec<String>,
pub endpoints: Vec<String>,
}
/// A host that receives personal data: a candidate processor, since whether
/// it is one in law is the operator's determination.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Processor {
pub host: String,
pub receives: Vec<String>,
pub sources: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Inventory {
pub items: Vec<Item>,
pub processors: Vec<Processor>,
}
/// Counts for a snapshot's summary and the Overview.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Summary {
pub collected: u64,
pub unbounded: u64,
pub leaving_host: u64,
pub processors: u64,
}
impl Inventory {
pub fn summary(&self) -> Summary {
let collected = self.items.iter().filter(|i| i.collected);
Summary {
collected: collected.clone().count() as u64,
unbounded: collected
.clone()
.filter(|i| i.retention.kind == "unbounded")
.count() as u64,
leaving_host: collected.filter(|i| i.leaves_host).count() as u64,
processors: self.processors.len() as u64,
}
}
}
/// The host part of an endpoint as configured: a URL's host, or the string
/// itself when it is a bare host or zone.
pub fn host_of(endpoint: &str) -> String {
let rest = endpoint.split_once("://").map_or(endpoint, |(_, rest)| rest);
let rest = rest.rsplit_once('@').map_or(rest, |(_, host)| host);
let host = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let host = if host.starts_with('[') {
host.split_once(']').map_or(host, |(h, _)| h.trim_start_matches('['))
} else {
host.rsplit_once(':')
.filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit()))
.map_or(host, |(h, _)| h)
};
host.trim().trim_end_matches('.').to_ascii_lowercase()
}
/// Whether an endpoint is this host: what is sent there stays here.
pub fn is_loopback(endpoint: &str) -> bool {
let host = host_of(endpoint);
host == "localhost"
|| host.ends_with(".localhost")
|| host == "::1"
|| host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
fn retention_out(retention: Option<&Retention>, facts: &LiveFacts) -> RetentionOut {
match retention {
Some(Retention::Setting { setting }) => match facts.durations.get(setting) {
Some(Days::Days(days)) => RetentionOut {
kind: "days".into(),
days: Some(*days),
setting: Some(setting.clone()),
},
Some(Days::Unbounded) => RetentionOut {
kind: "unbounded".into(),
days: None,
setting: Some(setting.clone()),
},
None => RetentionOut {
kind: "setting".into(),
days: None,
setting: Some(setting.clone()),
},
},
Some(Retention::Word(word)) => RetentionOut {
kind: word.clone(),
days: None,
setting: None,
},
None => RetentionOut {
kind: "object-life".into(),
days: None,
setting: None,
},
}
}
/// What the catalog says `id` holds, evaluated against `facts`. Objects with
/// nothing personal are left out. `tenant_only` keeps the entries a tenant
/// can be told about: tenant-scoped, and none of the server's processors.
pub fn evaluate(catalog: &Catalog, facts: &LiveFacts, tenant_only: bool) -> Inventory {
let mut items = Vec::new();
let mut add = |id: &str,
kind: &str,
categories: Vec<String>,
whose: &[String],
location: &[String],
scope: Option<&String>,
retention: Option<&Retention>,
controlled_by: Vec<String>,
leaves: bool| {
let scope = scope.cloned().unwrap_or_else(|| "server".into());
if tenant_only && scope != "tenant" {
return;
}
let mut endpoints: Vec<String> = facts.endpoints.get(id).cloned().unwrap_or_default();
// Anything sent to an endpoint off this host leaves it
let mut leaves_host = leaves || !endpoints.is_empty();
for place in location {
if let Some(host) = facts.remote_stores.get(place) {
leaves_host = true;
endpoints.push(host.clone());
}
}
endpoints.sort();
endpoints.dedup();
items.push(Item {
id: id.to_string(),
kind: kind.to_string(),
categories,
whose: whose.to_vec(),
location: location.to_vec(),
scope,
collected: facts.collected.get(id).copied().unwrap_or(true),
retention: retention_out(retention, facts),
leaves_host,
controlled_by,
endpoints,
});
};
for (id, entry) in &catalog.source {
let controlled_by = entry
.enabled_by
.iter()
.chain(&entry.captures)
.cloned()
.collect();
add(
id,
"source",
entry.categories.clone(),
&entry.whose,
&entry.location,
entry.scope.as_ref(),
entry.retention.as_ref(),
controlled_by,
entry.leaves_host,
);
}
for (id, entry) in &catalog.object {
if entry.properties.is_empty() || entry.whose.is_empty() {
// Nothing personal, or a credential field of a configuration
// object with no place of its own in the inventory
continue;
}
let categories: BTreeSet<String> = entry.properties.values().flatten().cloned().collect();
let leaves = entry.location.iter().any(|place| place == "external");
add(
id,
"object",
categories.into_iter().collect(),
&entry.whose,
&entry.location,
entry.scope.as_ref(),
entry.retention.as_ref(),
Vec::new(),
leaves,
);
}
// Candidate processors: each host that receives something, once
let mut processors: BTreeMap<String, (BTreeSet<String>, BTreeSet<String>)> = BTreeMap::new();
if !tenant_only {
for item in items.iter().filter(|i| i.collected && i.leaves_host) {
for endpoint in &item.endpoints {
let entry = processors.entry(host_of(endpoint)).or_default();
entry.0.extend(item.categories.iter().cloned());
entry.1.insert(item.id.clone());
}
}
}
Inventory {
items,
processors: processors
.into_iter()
.filter(|(host, _)| !host.is_empty())
.map(|(host, (receives, sources))| Processor {
host,
receives: receives.into_iter().collect(),
sources: sources.into_iter().collect(),
})
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn facts() -> LiveFacts {
LiveFacts::default()
}
fn item<'a>(inventory: &'a Inventory, id: &str) -> &'a Item {
inventory
.items
.iter()
.find(|i| i.id == id)
.unwrap_or_else(|| panic!("{id} not in the inventory"))
}
#[test]
fn the_shipped_catalog_parses() {
let catalog = catalog();
assert!(catalog.source.contains_key("log-file"));
assert!(catalog.object.contains_key("x:UserAccount"));
}
#[test]
fn defaults_a_new_install_would_report() {
let mut facts = facts();
facts
.durations
.insert("x:DataRetention.holdTracesFor".into(), Days::Days(14));
facts
.durations
.insert("inbuxa:LogSettings.keepForDays".into(), Days::Days(30));
facts.endpoints.insert("spam-pyzor".into(), vec!["public.pyzor.org:24441".into()]);
facts.collected.insert("spam-pyzor".into(), false);
facts.endpoints.insert(
"spam-dnsbl".into(),
vec!["zen.spamhaus.org".into(), "bl.spamcop.net".into()],
);
let inventory = evaluate(catalog(), &facts, false);
let trace = item(&inventory, "x:Trace");
assert_eq!(trace.retention.kind, "days");
assert_eq!(trace.retention.days, Some(14));
assert!(!trace.leaves_host);
assert_eq!(item(&inventory, "log-file").retention.days, Some(30));
// Pyzor off: listed, not collected, not a processor
assert!(!item(&inventory, "spam-pyzor").collected);
let hosts: Vec<_> = inventory.processors.iter().map(|p| p.host.as_str()).collect();
assert_eq!(hosts, vec!["bl.spamcop.net", "zen.spamhaus.org"]);
// Nothing personal isn't listed
assert!(inventory.items.iter().all(|i| i.id != "x:Http"));
}
#[test]
fn an_external_store_makes_what_lives_there_leave_the_host() {
let mut facts = facts();
facts
.remote_stores
.insert("blob-store".into(), "https://s3.example.net/mail".into());
let inventory = evaluate(catalog(), &facts, false);
let archived = item(&inventory, "x:ArchivedEmail");
assert!(archived.leaves_host);
assert_eq!(archived.endpoints, vec!["https://s3.example.net/mail"]);
assert!(inventory.processors.iter().any(|p| p.host == "s3.example.net"
&& p.sources.contains(&"x:ArchivedEmail".to_string())));
// What lives only in the data store stays
assert!(!item(&inventory, "x:UserAccount").leaves_host);
}
#[test]
fn a_hosted_ai_endpoint_is_a_processor_of_content() {
let mut facts = facts();
facts.collected.insert("spam-llm".into(), true);
facts
.endpoints
.insert("spam-llm".into(), vec!["https://api.example-ai.com/v1".into()]);
let inventory = evaluate(catalog(), &facts, false);
let ai = inventory
.processors
.iter()
.find(|p| p.host == "api.example-ai.com")
.expect("the AI endpoint is listed");
assert_eq!(ai.receives, vec!["content"]);
}
#[test]
fn telemetry_off_is_reported_as_not_collected() {
let mut facts = facts();
for id in ["x:Trace", "trace-index", "log-file"] {
facts.collected.insert(id.into(), false);
}
let inventory = evaluate(catalog(), &facts, false);
for id in ["x:Trace", "trace-index", "log-file"] {
assert!(!item(&inventory, id).collected, "{id}");
}
assert_eq!(item(&inventory, "log-file").retention.kind, "setting");
}
#[test]
fn a_tenant_sees_its_slice_and_no_processors() {
let mut facts = facts();
facts.endpoints.insert("spam-dnsbl".into(), vec!["zen.spamhaus.org".into()]);
let inventory = evaluate(catalog(), &facts, true);
assert!(inventory.items.iter().all(|i| i.scope == "tenant"));
assert!(inventory.items.iter().any(|i| i.id == "x:UserAccount"));
assert!(inventory.items.iter().all(|i| i.id != "log-file"));
assert!(inventory.processors.is_empty());
}
#[test]
fn hosts_are_read_from_urls_and_bare_names() {
assert_eq!(host_of("https://user:[email protected]:8443/path?x"), "hooks.example.com");
assert_eq!(host_of("public.pyzor.org:24441"), "public.pyzor.org");
assert_eq!(host_of("zen.spamhaus.org."), "zen.spamhaus.org");
assert_eq!(host_of("http://[::1]:11434/v1"), "::1");
assert_eq!(host_of("postgres://db.internal:5432/mail"), "db.internal");
}
#[test]
fn loopback_stays_on_the_host() {
assert!(is_loopback("http://127.0.0.1:11434/v1"));
assert!(is_loopback("http://localhost:8080"));
assert!(is_loopback("http://[::1]:11434"));
assert!(!is_loopback("http://10.77.0.2:11434"), "another node leaves the host");
assert!(!is_loopback("https://api.example-ai.com"));
}
#[test]
fn a_configured_endpoint_means_it_leaves() {
let mut facts = facts();
facts.endpoints.insert("x:Trace".into(), vec!["postgres://traces.example.net".into()]);
let inventory = evaluate(catalog(), &facts, false);
assert!(item(&inventory, "x:Trace").leaves_host);
}
#[test]
fn a_summary_counts_what_is_collected() {
let mut facts = facts();
facts.collected.insert("log-file".into(), true);
let inventory = evaluate(catalog(), &facts, false);
let summary = inventory.summary();
assert!(summary.collected > 10);
assert!(summary.unbounded >= 1, "sources the catalog marks unbounded");
}
}
+155
View File
@@ -0,0 +1,155 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Dated copies of the evaluated inventory (personal-data catalog spec, §6,
//! `inbuxa:InventorySnapshot`), so the Overview can show when and why what
//! the server holds changed. Stored as JSON under `C` `i` and the time taken
//! (seconds, big-endian) in the fork's subspace; kept as long as the audit
//! log keeps its records (settled 2026-09-28).
use super::{Inventory, Summary};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Store, U64_LEN, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass, key::DeserializeBigEndian},
};
use trc::AddContext;
const PREFIX: &[u8] = b"Ci";
/// Why a snapshot was taken.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Trigger {
/// A setting the catalog names changed: the object type that changed.
SettingChanged { setting: String },
/// The daily snapshot.
Daily,
}
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct Snapshot {
/// Seconds since the epoch; also the snapshot's id.
pub taken_at: u64,
pub trigger: Trigger,
pub summary: Summary,
pub inventory: Inventory,
}
fn key(taken_at: u64) -> Vec<u8> {
let mut key = PREFIX.to_vec();
key.extend_from_slice(&taken_at.to_be_bytes());
key
}
fn class(taken_at: u64) -> ValueClass {
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: key(taken_at),
})
}
struct Json(Snapshot);
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)
})
}
}
/// Stores a snapshot. Two in the same second: the later one wins.
pub async fn record(data: &Store, snapshot: &Snapshot) -> trc::Result<()> {
let bytes = serde_json::to_vec(snapshot).map_err(|err| {
trc::StoreEvent::UnexpectedError
.caused_by(trc::location!())
.reason(err)
})?;
let mut batch = BatchBuilder::new();
batch.set(class(snapshot.taken_at), bytes);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// One snapshot, by the time it was taken.
pub async fn get(data: &Store, taken_at: u64) -> trc::Result<Option<Snapshot>> {
Ok(data
.get_value::<Json>(ValueKey::from(class(taken_at)))
.await
.caused_by(trc::location!())?
.map(|Json(snapshot)| snapshot))
}
/// The times snapshots were taken between `after` and `before` (inclusive,
/// seconds), newest first.
pub async fn list(data: &Store, after: u64, before: u64) -> trc::Result<Vec<u64>> {
let mut times = Vec::new();
data.iterate(
IterateParams::new(
ValueKey::from(class(after)),
ValueKey::from(class(before)),
)
.no_values(),
|key, _| {
times.push(key.deserialize_be_u64(key.len() - U64_LEN)?);
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
times.reverse();
Ok(times)
}
/// The newest snapshot's time, if any.
pub async fn latest(data: &Store) -> trc::Result<Option<u64>> {
Ok(list(data, 0, u64::MAX).await?.first().copied())
}
/// Removes snapshots taken before `before` (seconds). Returns how many went.
pub async fn purge(data: &Store, before: u64) -> trc::Result<usize> {
let old = list(data, 0, before.saturating_sub(1)).await?;
if old.is_empty() {
return Ok(0);
}
let mut batch = BatchBuilder::new();
for taken_at in &old {
batch.clear(class(*taken_at));
}
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(old.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_sort_by_time() {
assert!(key(1) < key(2));
assert!(key(255) < key(256));
assert_eq!(&key(7)[..2], PREFIX);
}
#[test]
fn a_trigger_reads_as_json_names_it() {
let changed = serde_json::to_value(Trigger::SettingChanged {
setting: "x:DataRetention".into(),
})
.unwrap();
assert_eq!(changed["kind"], "settingChanged");
assert_eq!(changed["setting"], "x:DataRetention");
assert_eq!(serde_json::to_value(Trigger::Daily).unwrap()["kind"], "daily");
}
}
+243
View File
@@ -0,0 +1,243 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:LogSettings`, how long rotated log files are kept (personal-data
//! catalog spec, default D1, settled 2026-09-28). Stored as JSON under `T` +
//! `l` in the fork's subspace, not on `x:TracerLog`: that object is also
//! stored inside `x:Bootstrap` with fields after it, so a new field there
//! would change `x:Bootstrap`'s stored format.
//!
//! Unset, files are kept as they always were: forever. A new install sets
//! 30 days. Each node deletes its own files, since log files are local.
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use std::{
path::{Path, PathBuf},
time::{Duration, SystemTime},
};
use store::{
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
/// The fewest days a limit may keep, so a typo can't empty the log directory
/// of what an incident needs.
pub const MIN_KEEP_DAYS: u64 = 1;
/// The days a new install keeps (D1).
pub const NEW_INSTALL_KEEP_DAYS: u64 = 30;
/// Rung when the settings change here, so this node purges at once; other
/// nodes read the settings again within the hour.
pub static CHANGED: tokio::sync::Notify = tokio::sync::Notify::const_new();
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct LogSettings {
/// Rotated log files older than this many days are deleted; `None`
/// keeps them all.
pub keep_for_days: Option<u64>,
}
/// The properties `inbuxa:LogSettings` has, as they appear over JMAP.
pub const PROPERTIES: &[&str] = &["keepForDays"];
impl LogSettings {
/// What's wrong with these values, naming the property.
pub fn check(&self) -> Result<(), (&'static str, String)> {
match self.keep_for_days {
Some(days) if days < MIN_KEEP_DAYS => Err((
"keepForDays",
format!("must be at least {MIN_KEEP_DAYS}, or null to keep every file"),
)),
_ => Ok(()),
}
}
}
fn key() -> ValueClass {
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: b"Tl".to_vec(),
})
}
struct Json(LogSettings);
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 settings in force; unset reads as keep everything.
pub async fn get(data: &Store) -> trc::Result<LogSettings> {
Ok(data
.get_value::<Json>(ValueKey::from(key()))
.await
.caused_by(trc::location!())?
.map(|Json(settings)| settings)
.unwrap_or_default())
}
/// Whether anything was ever stored: a new install writes its default only
/// when nothing is there.
pub async fn is_set(data: &Store) -> trc::Result<bool> {
Ok(data
.get_value::<Json>(ValueKey::from(key()))
.await
.caused_by(trc::location!())?
.is_some())
}
/// Stores new settings.
pub async fn set(data: &Store, settings: &LogSettings) -> trc::Result<()> {
let bytes = serde_json::to_vec(settings).map_err(|err| {
trc::StoreEvent::UnexpectedError
.caused_by(trc::location!())
.reason(err)
})?;
let mut batch = BatchBuilder::new();
batch.set(key(), bytes);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// A file in a log directory: its path, name, and when it last changed.
pub struct LogFile {
pub path: PathBuf,
pub name: String,
pub modified: SystemTime,
pub is_file: bool,
}
/// The files to delete: regular files named `<prefix>.<something>`, whose
/// last change is more than `keep` ago. The file being written changes all
/// the time, so it is never old enough; anything not named for this log is
/// never touched.
pub fn expired<'a>(
files: &'a [LogFile],
prefix: &str,
keep: Duration,
now: SystemTime,
) -> impl Iterator<Item = &'a Path> + 'a {
let lead = format!("{prefix}.");
files.iter().filter_map(move |file| {
(file.is_file
&& file.name.starts_with(&lead)
&& now
.duration_since(file.modified)
.is_ok_and(|age| age > keep))
.then_some(file.path.as_path())
})
}
/// Deletes this log's expired files in `dir`, returning how many went.
pub fn purge(dir: &Path, prefix: &str, keep: Duration) -> std::io::Result<usize> {
let mut files = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let meta = entry.metadata()?;
files.push(LogFile {
path: entry.path(),
name: entry.file_name().to_string_lossy().into_owned(),
modified: meta.modified()?,
is_file: meta.is_file(),
});
}
let mut removed = 0;
for path in expired(&files, prefix, keep, SystemTime::now()) {
std::fs::remove_file(path)?;
removed += 1;
}
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
const DAY: Duration = Duration::from_secs(86_400);
fn file(name: &str, age_days: u64, now: SystemTime) -> LogFile {
LogFile {
path: PathBuf::from(format!("/var/log/inbuxa/{name}")),
name: name.to_string(),
modified: now - DAY * age_days as u32,
is_file: true,
}
}
#[test]
fn only_this_logs_old_files_go() {
let now = SystemTime::now();
let files = [
file("inbuxa.log.2026-08-01", 58, now),
file("inbuxa.log.2026-09-27", 1, now),
file("inbuxa.log", 0, now),
file("other.log.2026-01-01", 270, now),
file("inbuxa.logs.old", 90, now),
LogFile {
is_file: false,
..file("inbuxa.log.dir", 90, now)
},
];
let gone: Vec<_> = expired(&files, "inbuxa.log", 30 * DAY, now)
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(gone, vec!["inbuxa.log.2026-08-01"]);
}
#[test]
fn unset_keeps_everything_and_zero_is_refused() {
assert_eq!(LogSettings::default().keep_for_days, None);
assert!(LogSettings::default().check().is_ok());
let zero = LogSettings {
keep_for_days: Some(0),
};
assert_eq!(zero.check().unwrap_err().0, "keepForDays");
let json: LogSettings = serde_json::from_str("{}").unwrap();
assert_eq!(json, LogSettings::default());
}
#[test]
fn purge_deletes_on_disk() {
let dir = std::env::temp_dir().join(format!("inbuxa-log-purge-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let old = dir.join("inbuxa.log.2020-01-01");
let new = dir.join("inbuxa.log.today");
let other = dir.join("keep-me.txt");
for path in [&old, &new, &other] {
std::fs::write(path, b"x").unwrap();
}
let long_ago = SystemTime::now() - 60 * DAY;
std::fs::File::options()
.write(true)
.open(&old)
.unwrap()
.set_modified(long_ago)
.unwrap();
std::fs::File::options()
.write(true)
.open(&other)
.unwrap()
.set_modified(long_ago)
.unwrap();
assert_eq!(purge(&dir, "inbuxa.log", 30 * DAY).unwrap(), 1);
assert!(!old.exists());
assert!(new.exists());
assert!(other.exists(), "a file not named for the log is never touched");
std::fs::remove_dir_all(&dir).unwrap();
}
}
+1
View File
@@ -11,6 +11,7 @@
//! `legacy-protocols.md`. //! `legacy-protocols.md`.
pub mod legacy_use; pub mod legacy_use;
pub mod log_files;
pub mod listeners; pub mod listeners;
pub mod protocol_policy; pub mod protocol_policy;
pub mod tenant_protocol_policy; pub mod tenant_protocol_policy;
+229 -11
View File
@@ -8,6 +8,17 @@
//! (legacy-protocols spec, data model and LP-1 to LP-8). Stored as JSON under //! (legacy-protocols spec, data model and LP-1 to LP-8). Stored as JSON under
//! `P` + `p` in the fork's subspace; unset fields read as the defaults. //! `P` + `p` in the fork's subspace; unset fields read as the defaults.
//! //!
//! Each mail-app protocol has its own switch (legacy-protocols spec,
//! "Revisit: one switch per protocol"): IMAP, POP3 and ManageSieve.
//! `legacyProtocols` is the kill-all: setting it sets all three, and it reads
//! `disabled` exactly when all three are off. A policy stored before the
//! per-protocol switches has only `legacyProtocols`, and reads as all three
//! at that value.
//!
//! SMTP submission has no switch of its own here: sign-in over it is refused
//! only when all three are off, as it was by the single switch (LP-6), so
//! turning off one protocol never stops a mail app sending.
//!
//! This module is the fact, not the act. It holds what the operator chose and //! This module is the fact, not the act. It holds what the operator chose and
//! which listeners were taken away to honour it. Closing sockets belongs to //! which listeners were taken away to honour it. Closing sockets belongs to
//! `common`, which owns the listener registry, and removing the listener //! `common`, which owns the listener registry, and removing the listener
@@ -59,12 +70,30 @@ pub struct SavedListener {
pub object: serde_json::Value, pub object: serde_json::Value,
} }
/// The server-wide switch. /// The protocols with a switch of their own, as the schema and JMAP spell
/// them.
pub const SWITCHED: &[&str] = &["imap", "pop3", "manageSieve"];
/// The name sign-in uses for SMTP AUTH, which follows the kill-all.
pub const SUBMISSION: &str = "submission";
/// The server-wide switches.
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)] #[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
pub struct ProtocolPolicy { pub struct ProtocolPolicy {
/// The switch itself. /// The kill-all: `disabled` exactly when all three protocols are off,
/// once [`ProtocolPolicy::normalize`] has run. In a policy stored before
/// the per-protocol switches, it is the value of all three.
pub legacy_protocols: LegacyProtocols, pub legacy_protocols: LegacyProtocols,
/// IMAP's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub imap: Option<LegacyProtocols>,
/// POP3's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub pop3: Option<LegacyProtocols>,
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub manage_sieve: Option<LegacyProtocols>,
/// With `disabled`, also close SMTP submission (LP-3). The inbound /// With `disabled`, also close SMTP submission (LP-3). The inbound
/// listener on port 25 is never closed, whatever this says. /// listener on port 25 is never closed, whatever this says.
pub close_submission: bool, pub close_submission: bool,
@@ -80,6 +109,9 @@ impl Default for ProtocolPolicy {
fn default() -> Self { fn default() -> Self {
ProtocolPolicy { ProtocolPolicy {
legacy_protocols: LegacyProtocols::Enabled, legacy_protocols: LegacyProtocols::Enabled,
imap: None,
pop3: None,
manage_sieve: None,
close_submission: true, close_submission: true,
saved_listeners: Vec::new(), saved_listeners: Vec::new(),
changed_at: None, changed_at: None,
@@ -91,6 +123,9 @@ impl Default for ProtocolPolicy {
/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP. /// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP.
pub const PROPERTIES: &[&str] = &[ pub const PROPERTIES: &[&str] = &[
"legacyProtocols", "legacyProtocols",
"imap",
"pop3",
"manageSieve",
"closeSubmission", "closeSubmission",
"savedListeners", "savedListeners",
"changedAt", "changedAt",
@@ -129,23 +164,137 @@ pub fn is_locked(protocol: &str) -> bool {
.any(|locked| locked.eq_ignore_ascii_case(protocol)) .any(|locked| locked.eq_ignore_ascii_case(protocol))
} }
impl ProtocolPolicy { /// The switch fields, by protocol name.
/// Whether a listener of this protocol and these ports is one the switch pub trait Switches {
/// closes. A listener bound to port 25 is inbound whatever its name, and /// The kill-all, which an unset per-protocol switch reads as.
/// any other SMTP listener counts as submission (LP-3). fn all(&self) -> LegacyProtocols;
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool { fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>>;
if !self.legacy_protocols.is_disabled() { fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>>;
return false; fn set_all_field(&mut self, value: LegacyProtocols);
/// One protocol's switch. `submission` follows the kill-all: it is off
/// only when all three are. Anything else has no switch and is on.
fn switch(&self, protocol: &str) -> LegacyProtocols {
if protocol == SUBMISSION {
return if self.all_off() {
LegacyProtocols::Disabled
} else {
LegacyProtocols::Enabled
};
} }
match self.slot(protocol) {
Some(value) => value.unwrap_or(self.all()),
None => LegacyProtocols::Enabled,
}
}
/// Whether this protocol is off.
fn is_off(&self, protocol: &str) -> bool {
self.switch(protocol).is_disabled()
}
/// Whether all three protocols are off.
fn all_off(&self) -> bool {
SWITCHED.iter().all(|protocol| {
self.slot(protocol)
.and_then(|value| *value)
.unwrap_or(self.all())
.is_disabled()
})
}
/// Sets one protocol's switch; false if it has none.
fn set(&mut self, protocol: &str, value: LegacyProtocols) -> bool {
match self.slot_mut(protocol) {
Some(slot) => {
*slot = Some(value);
true
}
None => false,
}
}
/// The kill-all: all three at once.
fn set_all(&mut self, value: LegacyProtocols) {
for protocol in SWITCHED {
self.set(protocol, value);
}
self.set_all_field(value);
}
/// Writes out every switch and derives the kill-all from them, so what is
/// stored and shown never depends on how it was reached.
fn normalize(&mut self) {
let values: Vec<_> = SWITCHED.iter().map(|p| self.switch(p)).collect();
for (protocol, value) in SWITCHED.iter().zip(values) {
self.set(protocol, value);
}
let all = if self.all_off() {
LegacyProtocols::Disabled
} else {
LegacyProtocols::Enabled
};
self.set_all_field(all);
}
/// The protocols that are off.
fn off(&self) -> Vec<&'static str> {
SWITCHED
.iter()
.copied()
.filter(|p| self.is_off(p))
.collect()
}
}
macro_rules! switches {
($t:ty) => {
impl Switches for $t {
fn all(&self) -> LegacyProtocols {
self.legacy_protocols
}
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>> {
match protocol {
"imap" => Some(&self.imap),
"pop3" => Some(&self.pop3),
"manageSieve" => Some(&self.manage_sieve),
_ => None,
}
}
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>> {
match protocol {
"imap" => Some(&mut self.imap),
"pop3" => Some(&mut self.pop3),
"manageSieve" => Some(&mut self.manage_sieve),
_ => None,
}
}
fn set_all_field(&mut self, value: LegacyProtocols) {
self.legacy_protocols = value;
}
}
};
}
pub(crate) use switches;
switches!(ProtocolPolicy);
impl ProtocolPolicy {
/// Whether a listener of this protocol and these ports is one the
/// switches close. A listener bound to port 25 is inbound whatever its
/// name, and any other SMTP listener counts as submission (LP-3), closed
/// only with all three off and `closeSubmission`.
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
// The lock is checked first and answers for every caller, so no // The lock is checked first and answers for every caller, so no
// request phrasing can reach past it (LP-21). // request phrasing can reach past it (LP-21).
if is_locked(protocol) { if is_locked(protocol) {
return false; return false;
} }
if LEGACY_PROTOCOLS.contains(&protocol) { if LEGACY_PROTOCOLS.contains(&protocol) {
return true; return self.is_off(protocol);
} }
protocol.eq_ignore_ascii_case("smtp") protocol.eq_ignore_ascii_case("smtp")
&& self.all_off()
&& self.close_submission && self.close_submission
&& !ports.contains(&INBOUND_SMTP_PORT) && !ports.contains(&INBOUND_SMTP_PORT)
} }
@@ -258,7 +407,10 @@ mod tests {
"an unset closeSubmission reads as the default, true" "an unset closeSubmission reads as the default, true"
); );
let json = serde_json::to_value(&policy).unwrap(); // As shown: normalized, every switch written out.
let mut shown = policy.clone();
shown.normalize();
let json = serde_json::to_value(&shown).unwrap();
for property in PROPERTIES { for property in PROPERTIES {
assert!(json.get(property).is_some(), "{property}"); assert!(json.get(property).is_some(), "{property}");
} }
@@ -410,6 +562,72 @@ mod tests {
); );
} }
/// A policy stored before the per-protocol switches reads as all three
/// at its one value.
#[test]
fn an_old_policy_reads_as_all_three() {
let old: ProtocolPolicy =
serde_json::from_str(r#"{"legacyProtocols": "disabled"}"#).unwrap();
for p in SWITCHED {
assert!(old.is_off(p), "{p}");
}
assert!(old.all_off() && old.is_off(SUBMISSION));
let old: ProtocolPolicy =
serde_json::from_str(r#"{"legacyProtocols": "enabled"}"#).unwrap();
assert!(old.off().is_empty() && !old.is_off(SUBMISSION));
}
/// One protocol off closes only its listeners, and leaves sending alone.
#[test]
fn one_protocol_off() {
let mut policy = ProtocolPolicy::default();
policy.set("pop3", LegacyProtocols::Disabled);
policy.normalize();
assert!(policy.closes("pop3", &[995]));
assert!(!policy.closes("imap", &[993]));
assert!(!policy.closes("manageSieve", &[4190]));
assert!(!policy.is_off(SUBMISSION), "sending goes on");
assert_eq!(policy.legacy_protocols, LegacyProtocols::Enabled);
assert_eq!(policy.off(), vec!["pop3"]);
let json = serde_json::to_value(&policy).unwrap();
assert_eq!(json["pop3"], "disabled");
assert_eq!(json["imap"], "enabled");
}
/// Turning the three off one at a time is the kill-all, and the kill-all
/// back on turns all three on.
#[test]
fn the_kill_all_is_all_three() {
let mut policy = ProtocolPolicy::default();
for p in SWITCHED {
policy.set(p, LegacyProtocols::Disabled);
}
policy.normalize();
assert!(policy.legacy_protocols.is_disabled());
assert!(policy.is_off(SUBMISSION));
policy.set_all(LegacyProtocols::Enabled);
policy.normalize();
assert!(policy.off().is_empty());
assert!(!policy.legacy_protocols.is_disabled());
// The kill-all then one back on: no longer all off.
policy.set_all(LegacyProtocols::Disabled);
policy.set("imap", LegacyProtocols::Enabled);
policy.normalize();
assert!(!policy.legacy_protocols.is_disabled());
assert_eq!(policy.off(), vec!["pop3", "manageSieve"]);
}
/// Protocols without a switch are never off.
#[test]
fn unswitched_protocols_are_on() {
let policy = disabled();
for p in ["smtp", "http", "lmtp", "jmap"] {
assert!(!policy.is_off(p), "{p}");
}
}
/// A saved listener with no id is refused, naming the property. /// A saved listener with no id is refused, naming the property.
#[test] #[test]
fn a_nameless_saved_listener_is_refused() { fn a_nameless_saved_listener_is_refused() {
@@ -9,12 +9,18 @@
//! the tenant id in the fork's subspace; a tenant with nothing stored has //! the tenant id in the fork's subspace; a tenant with nothing stored has
//! legacy protocols on. //! legacy protocols on.
//! //!
//! A tenant has the same three switches as the server (IMAP, POP3,
//! ManageSieve) and the same kill-all; a protocol off server-wide is off for
//! every tenant whatever the tenant's own switch says.
//!
//! A tenant's switch closes no port -- other tenants share them (LP-13). It //! A tenant's switch closes no port -- other tenants share them (LP-13). It
//! refuses sign-in on the tenant's domains, and keeps client configuration //! refuses sign-in on the tenant's domains, and keeps client configuration
//! for them from offering what's refused. That is all it is: one fact per //! for them from offering what's refused. That is all it is: one fact per
//! tenant, easy to turn back, touching no listener, role or permission. //! tenant, easy to turn back, touching no listener, role or permission.
use crate::security::protocol_policy::{LegacyProtocols, ProtocolPolicy}; use crate::security::protocol_policy::{
LegacyProtocols, ProtocolPolicy, SUBMISSION, SWITCHED, Switches, switches,
};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{ use store::{
Deserialize, SUBSPACE_INBUXA, Store, ValueKey, Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
@@ -26,24 +32,92 @@ use trc::AddContext;
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)] #[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)] #[serde(rename_all = "camelCase", default)]
pub struct TenantProtocolPolicy { pub struct TenantProtocolPolicy {
/// The switch itself. /// The kill-all, as on the server's policy.
pub legacy_protocols: LegacyProtocols, pub legacy_protocols: LegacyProtocols,
/// IMAP's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub imap: Option<LegacyProtocols>,
/// POP3's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub pop3: Option<LegacyProtocols>,
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub manage_sieve: Option<LegacyProtocols>,
/// When it last changed, in milliseconds since the epoch. /// When it last changed, in milliseconds since the epoch.
pub changed_at: Option<u64>, pub changed_at: Option<u64>,
/// The account that last changed it. /// The account that last changed it.
pub changed_by: Option<String>, pub changed_by: Option<String>,
} }
/// Why a tenant's switch can't be set this way, if it can't (LP-9). switches!(TenantProtocolPolicy);
/// Why a tenant's switches can't be set this way, if they can't (LP-9).
/// ///
/// A tenant can always turn legacy protocols off for itself. It can turn /// A tenant can always turn a protocol off for itself. It can turn one on
/// them back on only while the server has them on: server off means off for /// only while the server has it on: server off means off for everyone.
/// everyone. /// `turned_on` is what the request sets to `enabled`, by protocol name.
pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> { pub fn refusal(server: &ProtocolPolicy, turned_on: &[&str]) -> Option<String> {
(server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some( let blocked: Vec<&str> = turned_on
"Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \ .iter()
so they can't be turned back on for one organization.", .copied()
) .filter(|protocol| server.is_off(protocol))
.collect();
(!blocked.is_empty()).then(|| {
format!(
"{} off for the whole server (inbuxa:ProtocolPolicy), so {} can't be turned \
back on for one organization.",
names(&blocked),
if blocked.len() == 1 { "it" } else { "they" }
)
})
}
/// Protocol names as people read them: "IMAP and POP3 are", "POP3 is".
fn names(protocols: &[&str]) -> String {
let named: Vec<&str> = protocols
.iter()
.map(|p| match *p {
"imap" => "IMAP",
"pop3" => "POP3",
"manageSieve" => "ManageSieve",
other => other,
})
.collect();
let list = match named.as_slice() {
[one] => one.to_string(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
[] => String::new(),
};
format!("{list} {}", if named.len() == 1 { "is" } else { "are" })
}
/// Whose switch turns a protocol off, if any.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OffBy {
Server,
Tenant,
}
/// Whether this protocol is off for an account or domain, and by whose
/// switch: the server's first (LP-6), then the tenant's (LP-10). Submission
/// is off when all three protocols are, counting both switches together.
pub fn off_by(
server: &ProtocolPolicy,
tenant: Option<&TenantProtocolPolicy>,
protocol: &str,
) -> Option<OffBy> {
if server.is_off(protocol) {
return Some(OffBy::Server);
}
let tenant = tenant?;
let off = if protocol == SUBMISSION {
SWITCHED
.iter()
.all(|p| server.is_off(p) || tenant.is_off(p))
} else {
tenant.is_off(protocol)
};
off.then_some(OffBy::Tenant)
} }
fn key(tenant_id: u32) -> ValueClass { fn key(tenant_id: u32) -> ValueClass {
@@ -115,6 +189,15 @@ mod tests {
} }
} }
fn tenant_off(protocols: &[&str]) -> TenantProtocolPolicy {
let mut policy = TenantProtocolPolicy::default();
for p in protocols {
policy.set(p, LegacyProtocols::Disabled);
}
policy.normalize();
policy
}
#[test] #[test]
fn a_tenant_starts_with_legacy_protocols_on() { fn a_tenant_starts_with_legacy_protocols_on() {
assert!( assert!(
@@ -127,20 +210,59 @@ mod tests {
#[test] #[test]
fn a_tenant_can_always_turn_them_off() { fn a_tenant_can_always_turn_them_off() {
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] { for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None); assert_eq!(refusal(&server(s), &[]), None);
} }
} }
#[test] #[test]
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() { fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
// LP-9, acceptance test 9. // LP-9, acceptance test 9.
assert_eq!( assert_eq!(refusal(&server(LegacyProtocols::Enabled), SWITCHED), None);
refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled), let why = refusal(&server(LegacyProtocols::Disabled), SWITCHED).expect("refused");
None
);
let why =
refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused");
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}"); assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
assert!(
why.starts_with("IMAP, POP3 and ManageSieve are off"),
"{why}"
);
}
#[test]
fn a_tenant_can_turn_on_what_the_server_allows() {
// The server has only POP3 off: IMAP may come back, POP3 may not.
let mut s = ProtocolPolicy::default();
s.set("pop3", LegacyProtocols::Disabled);
assert_eq!(refusal(&s, &["imap"]), None);
let why = refusal(&s, &["imap", "pop3"]).expect("refused");
assert!(why.starts_with("POP3 is off"), "{why}");
}
#[test]
fn whose_switch_turns_a_protocol_off() {
let mut s = ProtocolPolicy::default();
s.set("pop3", LegacyProtocols::Disabled);
let t = tenant_off(&["imap"]);
assert_eq!(off_by(&s, Some(&t), "pop3"), Some(OffBy::Server));
assert_eq!(off_by(&s, Some(&t), "imap"), Some(OffBy::Tenant));
assert_eq!(off_by(&s, Some(&t), "manageSieve"), None);
assert_eq!(off_by(&s, None, "imap"), None);
// Sending goes on while any protocol is still allowed.
assert_eq!(off_by(&s, Some(&t), SUBMISSION), None);
// Between them, all three off: submission follows (LP-6, LP-10).
let t = tenant_off(&["imap", "manageSieve"]);
assert_eq!(off_by(&s, Some(&t), SUBMISSION), Some(OffBy::Tenant));
assert_eq!(
off_by(&server(LegacyProtocols::Disabled), None, SUBMISSION),
Some(OffBy::Server)
);
}
#[test]
fn an_old_tenant_policy_reads_as_all_three() {
let Json(old) = Json::deserialize(br#"{"legacyProtocols":"disabled"}"#).unwrap();
for p in SWITCHED {
assert!(old.is_off(p), "{p}");
}
assert!(old.is_off(SUBMISSION));
} }
#[test] #[test]
@@ -158,6 +280,7 @@ mod tests {
legacy_protocols: LegacyProtocols::Disabled, legacy_protocols: LegacyProtocols::Disabled,
changed_at: Some(1), changed_at: Some(1),
changed_by: Some("b".into()), changed_by: Some("b".into()),
..Default::default()
}; };
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap(); let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
assert_eq!(back, policy); assert_eq!(back, policy);
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "groupware" name = "groupware"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "http_proto" name = "http_proto"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "http" name = "http"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+103 -70
View File
@@ -12,7 +12,7 @@ use common::{
}, },
}; };
use hyper::body::{Bytes, Frame}; use hyper::body::{Bytes, Frame};
use mail_auth::{IpLookupStrategy, mta_sts::TlsRpt}; use mail_auth::{DnssecStatus, IpLookupStrategy, mta_sts::TlsRpt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use smtp::outbound::{ use smtp::outbound::{
client::{SmtpClient, StartTlsResult}, client::{SmtpClient, StartTlsResult},
@@ -382,81 +382,25 @@ async fn delivery_diagnose(
} }
} }
// Fetch TLSA record
tx.send(DeliveryStage::TlsaLookupStart).await?;
let now = Instant::now();
let dane_policy = match server.tlsa_lookup(format!("_25._tcp.{hostname}.")).await {
Ok(TlsaResult::Secure(tlsa)) if tlsa.has_end_entities => {
tx.send(DeliveryStage::TlsaLookupSuccess {
record: tlsa.as_ref().clone(),
elapsed: now.elapsed_ms(),
})
.await?;
Some(tlsa)
}
Ok(TlsaResult::Secure(_)) => {
tx.send(DeliveryStage::TlsaLookupError {
elapsed: now.elapsed_ms(),
reason: "TLSA record does not have end entities".to_string(),
})
.await?;
None
}
Ok(TlsaResult::Bogus) => {
tx.send(DeliveryStage::TlsaLookupError {
elapsed: now.elapsed_ms(),
reason: "Bogus TLSA record".to_string(),
})
.await?;
continue 'outer;
}
Ok(TlsaResult::Missing) => {
tx.send(DeliveryStage::TlsaNotFound {
elapsed: now.elapsed_ms(),
reason: "No TLSA DNSSEC records found".to_string(),
})
.await?;
None
}
Err(err) => {
if matches!(
&err,
mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_))
) {
tx.send(DeliveryStage::TlsaNotFound {
elapsed: now.elapsed_ms(),
reason: "No TLSA records found for MX".to_string(),
})
.await?;
None
} else {
tx.send(DeliveryStage::TlsaLookupError {
elapsed: now.elapsed_ms(),
reason: err.to_string(),
})
.await?;
continue 'outer;
}
}
};
tx.send(DeliveryStage::IpLookupStart).await?; tx.send(DeliveryStage::IpLookupStart).await?;
let now = Instant::now(); let now = Instant::now();
let remote_ips = match host.fqdn_hostname() { let validate_addresses = server.core.smtp.resolvers.dnssec_available
&& host.dnssec_status() == DnssecStatus::Secure;
let (remote_ips, addresses_dnssec_status) = match host.fqdn_hostname() {
HostOrIp::Host(hostname) => { HostOrIp::Host(hostname) => {
match server match server
.ip_lookup(&hostname, IpLookupStrategy::Ipv4thenIpv6, usize::MAX, false) .ip_lookup(
&hostname,
IpLookupStrategy::Ipv4thenIpv6,
usize::MAX,
validate_addresses,
)
.await .await
{ {
Ok((remote_ips, _)) if !remote_ips.is_empty() => remote_ips, Ok((remote_ips, dnssec_status)) if !remote_ips.is_empty() => {
(remote_ips, dnssec_status)
}
Ok(_) => { Ok(_) => {
tx.send(DeliveryStage::IpLookupError { tx.send(DeliveryStage::IpLookupError {
reason: "No IP addresses found for host".to_string(), reason: "No IP addresses found for host".to_string(),
@@ -475,7 +419,7 @@ async fn delivery_diagnose(
} }
} }
} }
HostOrIp::Ip(ip) => vec![ip], HostOrIp::Ip(ip) => (vec![ip], DnssecStatus::Indeterminate),
}; };
tx.send(DeliveryStage::IpLookupSuccess { tx.send(DeliveryStage::IpLookupSuccess {
@@ -484,6 +428,95 @@ async fn delivery_diagnose(
}) })
.await?; .await?;
// Fetch TLSA record
tx.send(DeliveryStage::TlsaLookupStart).await?;
let now = Instant::now();
let dane_policy = match host.dane_status(addresses_dnssec_status) {
(DnssecStatus::Secure, _) => {
match server.tlsa_lookup(format!("_25._tcp.{hostname}.")).await {
Ok(TlsaResult::Secure(tlsa)) if tlsa.has_end_entities => {
tx.send(DeliveryStage::TlsaLookupSuccess {
record: tlsa.as_ref().clone(),
elapsed: now.elapsed_ms(),
})
.await?;
Some(tlsa)
}
Ok(TlsaResult::Secure(_)) => {
tx.send(DeliveryStage::TlsaLookupError {
elapsed: now.elapsed_ms(),
reason: "TLSA record does not have end entities".to_string(),
})
.await?;
None
}
Ok(TlsaResult::Bogus) => {
tx.send(DeliveryStage::TlsaLookupError {
elapsed: now.elapsed_ms(),
reason: "Bogus TLSA record".to_string(),
})
.await?;
continue 'outer;
}
Ok(TlsaResult::Missing) => {
tx.send(DeliveryStage::TlsaNotFound {
elapsed: now.elapsed_ms(),
reason: "No TLSA DNSSEC records found".to_string(),
})
.await?;
None
}
Err(err) => {
if matches!(
&err,
mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_))
) {
tx.send(DeliveryStage::TlsaNotFound {
elapsed: now.elapsed_ms(),
reason: "No TLSA records found for MX".to_string(),
})
.await?;
None
} else {
tx.send(DeliveryStage::TlsaLookupError {
elapsed: now.elapsed_ms(),
reason: err.to_string(),
})
.await?;
continue 'outer;
}
}
}
}
(DnssecStatus::Bogus, dnssec_entity) => {
tx.send(DeliveryStage::TlsaLookupError {
elapsed: now.elapsed_ms(),
reason: format!("Bogus {dnssec_entity} records were found"),
})
.await?;
continue 'outer;
}
(_, dnssec_entity) => {
tx.send(DeliveryStage::TlsaNotFound {
elapsed: now.elapsed_ms(),
reason: format!(
"{dnssec_entity} records are not DNSSEC signed, DANE does not apply"
),
})
.await?;
None
}
};
for remote_ip in remote_ips { for remote_ip in remote_ips {
// Start connection // Start connection
tx.send(DeliveryStage::ConnectionStart { remote_ip }) tx.send(DeliveryStage::ConnectionStart { remote_ip })
+34
View File
@@ -120,6 +120,40 @@ impl ManagementApi for Server {
jmap::inbuxa::explanation::question(self, &access_token, &subject).await?; jmap::inbuxa::explanation::question(self, &access_token, &subject).await?;
Ok(explain_stream(self.clone(), access_token, question, in_flight)) Ok(explain_stream(self.clone(), access_token, question, in_flight))
} }
// inbuxa: try a saved directory before anything signs in through it
"directory" if is_post && path.get(1).copied() == Some("test") => {
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
jmap::inbuxa::directory_test::assert_allowed(&access_token)?;
let request = body
.as_deref()
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())
.unwrap_or_default();
let answer = jmap::inbuxa::directory_test::test(self, &request).await?;
Ok(JsonResponse::new(answer).no_cache().into_http_response())
}
// inbuxa: send one sample event to a saved webhook
"webhook" if is_post && path.get(1).copied() == Some("test") => {
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
jmap::inbuxa::webhook_test::assert_allowed(&access_token)?;
let request = body
.as_deref()
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())
.unwrap_or_default();
let answer = jmap::inbuxa::webhook_test::test(self, &request).await?;
Ok(JsonResponse::new(answer).no_cache().into_http_response())
}
// inbuxa: whether the outside world reaches each node's ports
"ports" if path.get(1).copied() == Some("check") => {
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
if access_token.tenant_id().is_some() {
return Err(trc::JmapEvent::Forbidden
.into_err()
.details("Port checks are for server-level administrators."));
}
access_token.enforce_permission(Permission::SysNetworkListenerGet)?;
let answer = common::reachability::report(self).await?;
Ok(JsonResponse::new(answer).no_cache().into_http_response())
}
"account" => { "account" => {
// Authenticate request // Authenticate request
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?; let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
+3 -1
View File
@@ -36,7 +36,7 @@ use hyper::{
server::conn::http1, server::conn::http1,
service::service_fn, service::service_fn,
}; };
use hyper_util::rt::TokioIo; use hyper_util::rt::{TokioIo, TokioTimer};
use jmap::{ use jmap::{
api::{ api::{
ToJmapHttpResponse, event_source::EventSourceHandler, request::RequestHandler, ToJmapHttpResponse, event_source::EventSourceHandler, request::RequestHandler,
@@ -690,6 +690,7 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
let is_tls = session.stream.is_tls(); let is_tls = session.stream.is_tls();
if let Err(http_err) = http1::Builder::new() if let Err(http_err) = http1::Builder::new()
.timer(TokioTimer::new())
.keep_alive(true) .keep_alive(true)
.serve_connection( .serve_connection(
TokioIo::new(session.stream), TokioIo::new(session.stream),
@@ -875,6 +876,7 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
) )
.with_upgrades() .with_upgrades()
.await .await
&& !http_err.is_timeout()
{ {
if http_err.is_parse() { if http_err.is_parse() {
let server = inner.build_server(); let server = inner.build_server();
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "imap_proto" name = "imap_proto"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "imap" name = "imap"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+249 -158
View File
@@ -9,6 +9,7 @@ use crate::{
core::{MailboxId, SelectedMailbox, Session, SessionData}, core::{MailboxId, SelectedMailbox, Session, SessionData},
spawn_op, spawn_op,
}; };
use ahash::AHashMap;
use common::{ipc::PushNotification, network::SessionStream, storage::index::ObjectIndexBuilder}; use common::{ipc::PushNotification, network::SessionStream, storage::index::ObjectIndexBuilder};
use email::{ use email::{
cache::{MessageCacheFetch, email::MessageCacheAccess}, cache::{MessageCacheFetch, email::MessageCacheAccess},
@@ -22,8 +23,13 @@ use email::{
use imap_proto::{ use imap_proto::{
Command, ResponseCode, StatusResponse, protocol::copy_move::Arguments, receiver::Request, Command, ResponseCode, StatusResponse, protocol::copy_move::Arguments, receiver::Request,
}; };
use rand::RngExt;
use registry::schema::enums::Permission; use registry::schema::enums::Permission;
use std::{sync::Arc, time::Instant}; use std::{
ops::RangeInclusive,
sync::Arc,
time::{Duration, Instant},
};
use store::{ use store::{
ValueKey, ValueKey,
roaring::RoaringBitmap, roaring::RoaringBitmap,
@@ -36,6 +42,9 @@ use types::{
type_state::{DataType, StateChange}, type_state::{DataType, StateChange},
}; };
const MAX_MOVE_RETRIES: u32 = 3;
const MOVE_RETRY_BACKOFF_MS: RangeInclusive<u64> = 1..=15;
impl<T: SessionStream> Session<T> { impl<T: SessionStream> Session<T> {
pub async fn handle_copy_move( pub async fn handle_copy_move(
&mut self, &mut self,
@@ -236,148 +245,180 @@ impl<T: SessionStream> SessionData<T> {
// Mailboxes are in the same account // Mailboxes are in the same account
let account_id = src_mailbox.id.account_id; let account_id = src_mailbox.id.account_id;
let dest_mailbox_id = UidMailbox::new_unassigned(dest_mailbox_id); let dest_mailbox_id = UidMailbox::new_unassigned(dest_mailbox_id);
let mut batch = BatchBuilder::new(); let mut written_uids = AHashMap::with_capacity(ids.len());
let mut retries = 0;
for (id, imap_id) in ids { loop {
// Obtain mailbox tags let mut batch = BatchBuilder::new();
let data_ = if let Some(result) = self copied_ids.clear();
.get_message_data(account_id, id) did_move = false;
.await
.imap_ctx(&arguments.tag, trc::location!())?
{
result
} else {
continue;
};
// Deserialize for (&id, imap_id) in &ids {
let data = data_ // Obtain mailbox tags
.to_unarchived::<MessageData>() let data_ = if let Some(result) = self
.imap_ctx(&arguments.tag, trc::location!())?; .get_message_data(account_id, id)
.await
.imap_ctx(&arguments.tag, trc::location!())?
{
result
} else {
continue;
};
// Make sure the message still belongs to this mailbox // Deserialize
if !data let data = data_
.inner .to_unarchived::<MessageData>()
.mailboxes .imap_ctx(&arguments.tag, trc::location!())?;
.iter()
.any(|mailbox| mailbox.mailbox_id == src_mailbox.id.mailbox_id)
{
continue;
}
// If the message is already in the destination mailbox, skip it. // Make sure the message still belongs to this mailbox
if let Some(mailbox) = data if !data
.inner .inner
.mailboxes .mailboxes
.iter() .iter()
.find(|mailbox| mailbox.mailbox_id == dest_mailbox_id.mailbox_id) .any(|mailbox| mailbox.mailbox_id == src_mailbox.id.mailbox_id)
{ {
copied_ids.push((imap_id.uid, mailbox.uid.to_native())); // Moved by a chunk of a previous attempt
if let Some(&uid) = written_uids.get(&id)
if is_move { && data.inner.message_uid(dest_mailbox_id.mailbox_id) == Some(uid)
let mut new_data = data.inner.to_builder(); {
new_data.remove_mailbox(src_mailbox.id.mailbox_id); copied_ids.push((imap_id.uid, uid));
batch did_move = true;
.with_account_id(account_id) }
.with_collection(Collection::Email) continue;
.with_document(id)
.custom(
ObjectIndexBuilder::new()
.with_current(data)
.with_changes(new_data.seal()),
)
.imap_ctx(&arguments.tag, trc::location!())?
.log_vanished_item(
VanishedCollection::Email,
(src_mailbox.id.mailbox_id, imap_id.uid),
)
.commit_point();
did_move = true;
} }
continue; // If the message is already in the destination mailbox, skip it.
} if let Some(mailbox) = data
.inner
.mailboxes
.iter()
.find(|mailbox| mailbox.mailbox_id == dest_mailbox_id.mailbox_id)
{
let uid = mailbox.uid.to_native();
copied_ids.push((imap_id.uid, uid));
// Prepare changes if is_move {
let mut new_data = data.inner.to_builder(); let mut new_data = data.inner.to_builder();
new_data.remove_mailbox(src_mailbox.id.mailbox_id);
batch
.with_account_id(account_id)
.with_collection(Collection::Email)
.with_document(id)
.custom(
ObjectIndexBuilder::new()
.with_current(data)
.with_changes(new_data.seal()),
)
.imap_ctx(&arguments.tag, trc::location!())?
.log_vanished_item(
VanishedCollection::Email,
(src_mailbox.id.mailbox_id, imap_id.uid),
)
.commit_point();
written_uids.insert(id, uid);
did_move = true;
}
// Add destination folder continue;
new_data.add_mailbox(dest_mailbox_id); }
if is_move {
new_data.remove_mailbox(src_mailbox.id.mailbox_id);
}
// Assign IMAP UIDs // Prepare changes
let ids = self let mut new_data = data.inner.to_builder();
.server
.assign_email_ids(
account_id,
new_data
.mailboxes
.iter()
.filter(|m| m.uid == 0)
.map(|m| m.mailbox_id),
false,
)
.await
.caused_by(trc::location!())?;
for (uid_mailbox, uid) in new_data // Add destination folder
.mailboxes new_data.add_mailbox(dest_mailbox_id);
.iter_mut() if is_move {
.filter(|m| m.uid == 0) new_data.remove_mailbox(src_mailbox.id.mailbox_id);
.zip(ids) }
{
copied_ids.push((imap_id.uid, uid));
uid_mailbox.uid = uid;
}
// Prepare write batch // Assign IMAP UIDs
batch let ids = self
.with_account_id(account_id) .server
.with_collection(Collection::Email) .assign_email_ids(
.with_document(id) account_id,
.custom( new_data
ObjectIndexBuilder::new() .mailboxes
.with_current(data) .iter()
.with_changes(new_data.seal()), .filter(|m| m.uid == 0)
) .map(|m| m.mailbox_id),
.imap_ctx(&arguments.tag, trc::location!())?; false,
if is_move { )
batch.log_vanished_item(
VanishedCollection::Email,
(src_mailbox.id.mailbox_id, imap_id.uid),
);
}
// Add message to training queue
if dest_mailbox_id.mailbox_id == JUNK_ID {
self.server
.add_account_spam_sample(&mut batch, account_id, id, true, self.session_id)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
} else if src_mailbox.id.mailbox_id == JUNK_ID
&& dest_mailbox_id.mailbox_id != TRASH_ID
{
self.server
.add_account_spam_sample(&mut batch, account_id, id, false, self.session_id)
.await .await
.caused_by(trc::location!())?;
for (uid_mailbox, uid) in new_data
.mailboxes
.iter_mut()
.filter(|m| m.uid == 0)
.zip(ids)
{
copied_ids.push((imap_id.uid, uid));
written_uids.insert(id, uid);
uid_mailbox.uid = uid;
}
// Prepare write batch
batch
.with_account_id(account_id)
.with_collection(Collection::Email)
.with_document(id)
.custom(
ObjectIndexBuilder::new()
.with_current(data)
.with_changes(new_data.seal()),
)
.imap_ctx(&arguments.tag, trc::location!())?; .imap_ctx(&arguments.tag, trc::location!())?;
if is_move {
batch.log_vanished_item(
VanishedCollection::Email,
(src_mailbox.id.mailbox_id, imap_id.uid),
);
}
// Add message to training queue
if dest_mailbox_id.mailbox_id == JUNK_ID {
self.server
.add_account_spam_sample(
&mut batch,
account_id,
id,
true,
self.session_id,
)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
} else if src_mailbox.id.mailbox_id == JUNK_ID
&& dest_mailbox_id.mailbox_id != TRASH_ID
{
self.server
.add_account_spam_sample(
&mut batch,
account_id,
id,
false,
self.session_id,
)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
}
batch.commit_point();
// Update changelog
if is_move {
did_move = true;
}
} }
batch.commit_point(); // Write changes
match self.server.commit_batch(batch).await {
// Update changelog Ok(_) => break,
if is_move { Err(err) => {
did_move = true; retry_after_conflict(err, &mut retries, &arguments.tag, trc::location!())
.await?
}
} }
} }
// Write changes
self.server
.commit_batch(batch)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
} else { } else {
// Obtain quota for target account // Obtain quota for target account
let src_account_id = src_mailbox.id.account_id; let src_account_id = src_mailbox.id.account_id;
@@ -400,7 +441,7 @@ impl<T: SessionStream> SessionData<T> {
let mut train_batch = BatchBuilder::new(); let mut train_batch = BatchBuilder::new();
let mut did_train = false; let mut did_train = false;
train_batch.with_account_id(src_account_id); train_batch.with_account_id(src_account_id);
for (id, imap_id) in ids { 'next_message: for (id, imap_id) in ids {
match self match self
.server .server
.copy_message( .copy_message(
@@ -448,22 +489,27 @@ impl<T: SessionStream> SessionData<T> {
{ {
copied_ids.push((imap_id.uid, uid)); copied_ids.push((imap_id.uid, uid));
} else { } else {
let data_ = if let Some(data_) = self let mut retries = 0;
.get_message_data(dest_account_id, existing_id)
.await loop {
.imap_ctx(&arguments.tag, trc::location!())? let data_ = if let Some(data_) = self
{ .get_message_data(dest_account_id, existing_id)
data_ .await
} else { .imap_ctx(&arguments.tag, trc::location!())?
continue; {
}; data_
let data = data_ } else {
.to_unarchived::<MessageData>() continue 'next_message;
.imap_ctx(&arguments.tag, trc::location!())?; };
let data = data_
.to_unarchived::<MessageData>()
.imap_ctx(&arguments.tag, trc::location!())?;
if let Some(uid) = data.inner.message_uid(dest_mailbox_id) {
copied_ids.push((imap_id.uid, uid));
break;
}
if let Some(uid) = data.inner.message_uid(dest_mailbox_id) {
copied_ids.push((imap_id.uid, uid));
} else {
let mut new_data = data.inner.to_builder(); let mut new_data = data.inner.to_builder();
new_data.add_mailbox(UidMailbox::new_unassigned(dest_mailbox_id)); new_data.add_mailbox(UidMailbox::new_unassigned(dest_mailbox_id));
@@ -504,15 +550,27 @@ impl<T: SessionStream> SessionData<T> {
) )
.imap_ctx(&arguments.tag, trc::location!())?; .imap_ctx(&arguments.tag, trc::location!())?;
dest_change_id = self match self
.server .server
.commit_batch(batch) .commit_batch(batch)
.await .await
.and_then(|ids| ids.last_change_id(dest_account_id)) .and_then(|ids| ids.last_change_id(dest_account_id))
.imap_ctx(&arguments.tag, trc::location!())? {
.into(); Ok(change_id) => {
dest_change_id = change_id.into();
copied_ids.push((imap_id.uid, assigned_uid)); copied_ids.push((imap_id.uid, assigned_uid));
break;
}
Err(err) => {
retry_after_conflict(
err,
&mut retries,
&arguments.tag,
trc::location!(),
)
.await?
}
}
} }
} }
} }
@@ -554,21 +612,33 @@ impl<T: SessionStream> SessionData<T> {
// Untag or delete emails // Untag or delete emails
if !destroy_ids.is_empty() { if !destroy_ids.is_empty() {
let mut batch = BatchBuilder::new(); let mut retries = 0;
self.email_untag_or_delete(
src_account_id,
src_mailbox.id.mailbox_id,
&destroy_ids,
&mut batch,
)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
self.server loop {
.commit_batch(batch) let mut batch = BatchBuilder::new();
self.email_untag_or_delete(
src_account_id,
src_mailbox.id.mailbox_id,
&destroy_ids,
&mut batch,
)
.await .await
.imap_ctx(&arguments.tag, trc::location!())?; .imap_ctx(&arguments.tag, trc::location!())?;
match self.server.commit_batch(batch).await {
Ok(_) => break,
Err(err) => {
retry_after_conflict(
err,
&mut retries,
&arguments.tag,
trc::location!(),
)
.await?
}
}
}
did_move = true; did_move = true;
} }
@@ -731,3 +801,24 @@ impl<T: SessionStream> SessionData<T> {
} }
} }
} }
async fn retry_after_conflict(
err: trc::Error,
retries: &mut u32,
tag: &str,
location: &'static str,
) -> trc::Result<()> {
if !err.is_assertion_failure() {
Err(err).imap_ctx(tag, location)
} else if *retries < MAX_MOVE_RETRIES {
*retries += 1;
let backoff = rand::rng().random_range(MOVE_RETRY_BACKOFF_MS);
tokio::time::sleep(Duration::from_millis(backoff)).await;
Ok(())
} else {
Err(trc::ImapEvent::Error
.into_err()
.details("Some messages were modified by another process.")
.id(tag.to_string()))
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jmap_proto" name = "jmap_proto"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -2
View File
@@ -12,7 +12,6 @@ use crate::{
email::{EmailProperty, EmailValue}, email::{EmailProperty, EmailValue},
}, },
request::{ request::{
MaybeInvalid,
deserialize::{DeserializeArguments, deserialize_request}, deserialize::{DeserializeArguments, deserialize_request},
reference::{MaybeIdReference, MaybeResultReference, ResultReference}, reference::{MaybeIdReference, MaybeResultReference, ResultReference},
}, },
@@ -33,7 +32,7 @@ pub struct ImportEmailRequest {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct ImportEmail { pub struct ImportEmail {
pub blob_id: MaybeInvalid<BlobId>, pub blob_id: MaybeIdReference<BlobId>,
pub mailbox_ids: MaybeResultReference<Vec<MaybeIdReference<Id>>>, pub mailbox_ids: MaybeResultReference<Vec<MaybeIdReference<Id>>>,
pub keywords: Vec<Keyword>, pub keywords: Vec<Keyword>,
pub received_at: Option<UTCDate>, pub received_at: Option<UTCDate>,
@@ -0,0 +1,165 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:DataInventory/get` under `urn:inbuxa:jmap`: the personal-data
//! catalog evaluated against this server's live settings (personal-data
//! catalog spec, §6). A singleton, id `singleton`; read-only.
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 DataInventory;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DataInventoryProperty {
Id,
EvaluatedAt,
CatalogVersion,
Summary,
Items,
Processors,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DataInventoryValue {
Id(Id),
}
impl Property for DataInventoryProperty {
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
DataInventoryProperty::parse(value)
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
DataInventoryProperty::Id => "id",
DataInventoryProperty::EvaluatedAt => "evaluatedAt",
DataInventoryProperty::CatalogVersion => "catalogVersion",
DataInventoryProperty::Summary => "summary",
DataInventoryProperty::Items => "items",
DataInventoryProperty::Processors => "processors",
}
.into()
}
}
impl DataInventoryProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => DataInventoryProperty::Id,
b"evaluatedAt" => DataInventoryProperty::EvaluatedAt,
b"catalogVersion" => DataInventoryProperty::CatalogVersion,
b"summary" => DataInventoryProperty::Summary,
b"items" => DataInventoryProperty::Items,
b"processors" => DataInventoryProperty::Processors,
)
}
}
impl FromStr for DataInventoryProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
DataInventoryProperty::parse(s).ok_or(())
}
}
impl Element for DataInventoryValue {
type Property = DataInventoryProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(DataInventoryProperty::Id) => {
Id::from_str(value).ok().map(DataInventoryValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
DataInventoryValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for DataInventory {
type Property = DataInventoryProperty;
type Element = DataInventoryValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = DataInventoryProperty::Id;
}
impl From<Id> for DataInventoryValue {
fn from(id: Id) -> Self {
DataInventoryValue::Id(id)
}
}
impl JmapObjectId for DataInventoryValue {
fn as_id(&self) -> Option<Id> {
match self {
DataInventoryValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
DataInventoryValue::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 = DataInventoryValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for DataInventoryProperty {
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,162 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:InventorySnapshot/get` under `urn:inbuxa:jmap`: dated copies of
//! the evaluated inventory (personal-data catalog spec, §6). The id is the
//! time taken; `ids: null` lists every snapshot kept, newest first.
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 InventorySnapshot;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum InventorySnapshotProperty {
Id,
TakenAt,
Trigger,
Summary,
Inventory,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum InventorySnapshotValue {
Id(Id),
}
impl Property for InventorySnapshotProperty {
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
InventorySnapshotProperty::parse(value)
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
InventorySnapshotProperty::Id => "id",
InventorySnapshotProperty::TakenAt => "takenAt",
InventorySnapshotProperty::Trigger => "trigger",
InventorySnapshotProperty::Summary => "summary",
InventorySnapshotProperty::Inventory => "inventory",
}
.into()
}
}
impl InventorySnapshotProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => InventorySnapshotProperty::Id,
b"takenAt" => InventorySnapshotProperty::TakenAt,
b"trigger" => InventorySnapshotProperty::Trigger,
b"summary" => InventorySnapshotProperty::Summary,
b"inventory" => InventorySnapshotProperty::Inventory,
)
}
}
impl FromStr for InventorySnapshotProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
InventorySnapshotProperty::parse(s).ok_or(())
}
}
impl Element for InventorySnapshotValue {
type Property = InventorySnapshotProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(InventorySnapshotProperty::Id) => {
Id::from_str(value).ok().map(InventorySnapshotValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
InventorySnapshotValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for InventorySnapshot {
type Property = InventorySnapshotProperty;
type Element = InventorySnapshotValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = InventorySnapshotProperty::Id;
}
impl From<Id> for InventorySnapshotValue {
fn from(id: Id) -> Self {
InventorySnapshotValue::Id(id)
}
}
impl JmapObjectId for InventorySnapshotValue {
fn as_id(&self) -> Option<Id> {
match self {
InventorySnapshotValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
InventorySnapshotValue::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 = InventorySnapshotValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for InventorySnapshotProperty {
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,153 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:LogSettings/get` and `/set` under `urn:inbuxa:jmap`: how long
//! rotated log files are kept (personal-data catalog spec, D1). A singleton,
//! id `singleton`.
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 LogSettings;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum LogSettingsProperty {
Id,
KeepForDays,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum LogSettingsValue {
Id(Id),
}
impl Property for LogSettingsProperty {
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
LogSettingsProperty::parse(value)
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
LogSettingsProperty::Id => "id",
LogSettingsProperty::KeepForDays => "keepForDays",
}
.into()
}
}
impl LogSettingsProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => LogSettingsProperty::Id,
b"keepForDays" => LogSettingsProperty::KeepForDays,
)
}
}
impl FromStr for LogSettingsProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
LogSettingsProperty::parse(s).ok_or(())
}
}
impl Element for LogSettingsValue {
type Property = LogSettingsProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(LogSettingsProperty::Id) => {
Id::from_str(value).ok().map(LogSettingsValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
LogSettingsValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for LogSettings {
type Property = LogSettingsProperty;
type Element = LogSettingsValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = LogSettingsProperty::Id;
}
impl From<Id> for LogSettingsValue {
fn from(id: Id) -> Self {
LogSettingsValue::Id(id)
}
}
impl JmapObjectId for LogSettingsValue {
fn as_id(&self) -> Option<Id> {
match self {
LogSettingsValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
LogSettingsValue::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 = LogSettingsValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for LogSettingsProperty {
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
}
}
@@ -23,8 +23,13 @@ pub struct ProtocolPolicy;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ProtocolPolicyProperty { pub enum ProtocolPolicyProperty {
Id, Id,
/// The switch: `enabled` or `disabled`. /// The kill-all: `enabled` or `disabled`; reads `disabled` when all
/// three protocols are off, and sets all three.
LegacyProtocols, LegacyProtocols,
/// Each protocol's own switch: `enabled` or `disabled`.
Imap,
Pop3,
ManageSieve,
/// Whether submission closes with it. Forced false while SMTP is locked. /// Whether submission closes with it. Forced false while SMTP is locked.
CloseSubmission, CloseSubmission,
/// Server-set: the listeners taken away, for LP-5. /// Server-set: the listeners taken away, for LP-5.
@@ -56,6 +61,9 @@ impl Property for ProtocolPolicyProperty {
match self { match self {
ProtocolPolicyProperty::Id => "id", ProtocolPolicyProperty::Id => "id",
ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols", ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
ProtocolPolicyProperty::Imap => "imap",
ProtocolPolicyProperty::Pop3 => "pop3",
ProtocolPolicyProperty::ManageSieve => "manageSieve",
ProtocolPolicyProperty::CloseSubmission => "closeSubmission", ProtocolPolicyProperty::CloseSubmission => "closeSubmission",
ProtocolPolicyProperty::SavedListeners => "savedListeners", ProtocolPolicyProperty::SavedListeners => "savedListeners",
ProtocolPolicyProperty::ChangedAt => "changedAt", ProtocolPolicyProperty::ChangedAt => "changedAt",
@@ -73,6 +81,9 @@ impl ProtocolPolicyProperty {
hashify::tiny_map!(value.as_bytes(), hashify::tiny_map!(value.as_bytes(),
b"id" => ProtocolPolicyProperty::Id, b"id" => ProtocolPolicyProperty::Id,
b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols, b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols,
b"imap" => ProtocolPolicyProperty::Imap,
b"pop3" => ProtocolPolicyProperty::Pop3,
b"manageSieve" => ProtocolPolicyProperty::ManageSieve,
b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission, b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission,
b"savedListeners" => ProtocolPolicyProperty::SavedListeners, b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
b"changedAt" => ProtocolPolicyProperty::ChangedAt, b"changedAt" => ProtocolPolicyProperty::ChangedAt,
@@ -24,8 +24,13 @@ pub enum TenantProtocolPolicyProperty {
Id, Id,
/// Server-set: the tenant this is the switch of. /// Server-set: the tenant this is the switch of.
TenantId, TenantId,
/// The switch: `enabled` or `disabled`. /// The kill-all: `enabled` or `disabled`; reads `disabled` when all
/// three protocols are off, and sets all three.
LegacyProtocols, LegacyProtocols,
/// Each protocol's own switch: `enabled` or `disabled`.
Imap,
Pop3,
ManageSieve,
ChangedAt, ChangedAt,
ChangedBy, ChangedBy,
/// Server-set: who signed in over a legacy protocol in the last 30 /// Server-set: who signed in over a legacy protocol in the last 30
@@ -48,6 +53,9 @@ impl Property for TenantProtocolPolicyProperty {
TenantProtocolPolicyProperty::Id => "id", TenantProtocolPolicyProperty::Id => "id",
TenantProtocolPolicyProperty::TenantId => "tenantId", TenantProtocolPolicyProperty::TenantId => "tenantId",
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols", TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
TenantProtocolPolicyProperty::Imap => "imap",
TenantProtocolPolicyProperty::Pop3 => "pop3",
TenantProtocolPolicyProperty::ManageSieve => "manageSieve",
TenantProtocolPolicyProperty::ChangedAt => "changedAt", TenantProtocolPolicyProperty::ChangedAt => "changedAt",
TenantProtocolPolicyProperty::ChangedBy => "changedBy", TenantProtocolPolicyProperty::ChangedBy => "changedBy",
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse", TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
@@ -62,6 +70,9 @@ impl TenantProtocolPolicyProperty {
b"id" => TenantProtocolPolicyProperty::Id, b"id" => TenantProtocolPolicyProperty::Id,
b"tenantId" => TenantProtocolPolicyProperty::TenantId, b"tenantId" => TenantProtocolPolicyProperty::TenantId,
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols, b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
b"imap" => TenantProtocolPolicyProperty::Imap,
b"pop3" => TenantProtocolPolicyProperty::Pop3,
b"manageSieve" => TenantProtocolPolicyProperty::ManageSieve,
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt, b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy, b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse, b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
+3
View File
@@ -23,6 +23,9 @@ pub mod email_submission;
pub mod fastmail_masked_email; // inbuxa: masked email pub mod fastmail_masked_email; // inbuxa: masked email
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
pub mod inbuxa_log_settings; // inbuxa: personal-data catalog, D1
pub mod inbuxa_data_inventory; // inbuxa: personal-data catalog
pub mod inbuxa_inventory_snapshot; // inbuxa: personal-data catalog
pub mod inbuxa_audit; // inbuxa: the audit log pub mod inbuxa_audit; // inbuxa: the audit log
pub mod inbuxa_legal_hold; // inbuxa: legal hold pub mod inbuxa_legal_hold; // inbuxa: legal hold
pub mod inbuxa_hold_export; // inbuxa: legal hold exports pub mod inbuxa_hold_export; // inbuxa: legal hold exports
+9
View File
@@ -61,6 +61,15 @@ impl Response<'_> {
GetResponseMethod::AiLimits(response) => { GetResponseMethod::AiLimits(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
GetResponseMethod::LogSettings(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::DataInventory(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::InventorySnapshot(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::AuditEvent(response) => { GetResponseMethod::AuditEvent(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
@@ -46,6 +46,9 @@ impl Response<'_> {
GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?, GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?,
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?, GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?, GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
GetRequestMethod::LogSettings(request) => request.resolve_references(self)?,
GetRequestMethod::DataInventory(request) => request.resolve_references(self)?,
GetRequestMethod::InventorySnapshot(request) => request.resolve_references(self)?,
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?, GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?, GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?, GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
@@ -98,6 +101,9 @@ impl Response<'_> {
SetRequestMethod::AiLimits(request) => { SetRequestMethod::AiLimits(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
SetRequestMethod::LogSettings(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::Explanation(request) => { SetRequestMethod::Explanation(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
@@ -388,6 +394,10 @@ impl ResolveReference for ImportEmailRequest {
fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> { fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> {
// Resolve email mailbox references // Resolve email mailbox references
for email in self.emails.values_mut() { for email in self.emails.values_mut() {
if let MaybeIdReference::Reference(ir) = &email.blob_id {
email.blob_id = MaybeIdReference::Id(response.eval_blob_id_reference(ir)?);
}
match &mut email.mailbox_ids { match &mut email.mailbox_ids {
MaybeResultReference::Reference(reference) => { MaybeResultReference::Reference(reference) => {
email.mailbox_ids = MaybeResultReference::Value( email.mailbox_ids = MaybeResultReference::Value(
@@ -169,6 +169,11 @@ pub struct InbuxaAccountCapabilities {
/// (legacy-protocols spec, Interfaces; LP-19). /// (legacy-protocols spec, Interfaces; LP-19).
#[serde(rename(serialize = "legacyProtocols"))] #[serde(rename(serialize = "legacyProtocols"))]
pub legacy_protocols: &'static str, pub legacy_protocols: &'static str,
/// The legacy protocols still allowed for the principal, each the
/// stricter of the two switches: `imap`, `pop3`, `manageSieve`,
/// `submission` (legacy-protocols spec, one switch per protocol).
#[serde(rename(serialize = "legacyAllowed"))]
pub legacy_allowed: Vec<&'static str>,
/// Whether the principal may use "Explain this" now: it holds /// Whether the principal may use "Explain this" now: it holds
/// `sysAiExplain`, is server-level, and a model resolves (ai-explain /// `sysAiExplain`, is server-level, and a model resolves (ai-explain
/// spec, EX-1 to EX-4). /// spec, EX-1 to EX-4).
+17
View File
@@ -49,6 +49,9 @@ pub enum MethodObject {
DeletedAccount, DeletedAccount,
// inbuxa: AI call limits // inbuxa: AI call limits
AiLimits, AiLimits,
LogSettings,
DataInventory,
InventorySnapshot,
// inbuxa: "Explain this" with the local model // inbuxa: "Explain this" with the local model
Explanation, Explanation,
// inbuxa: the audit log // inbuxa: the audit log
@@ -89,6 +92,9 @@ impl MethodObject {
MethodObject::MaskedEmail => Capability::FastmailMaskedEmail, MethodObject::MaskedEmail => Capability::FastmailMaskedEmail,
MethodObject::DeletedAccount => Capability::Inbuxa, MethodObject::DeletedAccount => Capability::Inbuxa,
MethodObject::AiLimits => Capability::Inbuxa, MethodObject::AiLimits => Capability::Inbuxa,
MethodObject::LogSettings => Capability::Inbuxa,
MethodObject::DataInventory => Capability::Inbuxa,
MethodObject::InventorySnapshot => Capability::Inbuxa,
MethodObject::Explanation => Capability::Inbuxa, MethodObject::Explanation => Capability::Inbuxa,
MethodObject::AuditEvent MethodObject::AuditEvent
| MethodObject::AuditSettings | MethodObject::AuditSettings
@@ -276,6 +282,10 @@ impl MethodName {
(MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set", (MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set",
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get", (MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set", (MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
(MethodFunction::Get, MethodObject::LogSettings) => "inbuxa:LogSettings/get",
(MethodFunction::Get, MethodObject::DataInventory) => "inbuxa:DataInventory/get",
(MethodFunction::Get, MethodObject::InventorySnapshot) => "inbuxa:InventorySnapshot/get",
(MethodFunction::Set, MethodObject::LogSettings) => "inbuxa:LogSettings/set",
(MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set", (MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set",
(MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get", (MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get",
(MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query", (MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query",
@@ -424,6 +434,10 @@ impl MethodName {
"inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set), "inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set),
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get), "inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set), "inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
"inbuxa:LogSettings/get" => (MethodObject::LogSettings, MethodFunction::Get),
"inbuxa:DataInventory/get" => (MethodObject::DataInventory, MethodFunction::Get),
"inbuxa:InventorySnapshot/get" => (MethodObject::InventorySnapshot, MethodFunction::Get),
"inbuxa:LogSettings/set" => (MethodObject::LogSettings, MethodFunction::Set),
"inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set), "inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set),
"inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get), "inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get),
"inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query), "inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query),
@@ -494,6 +508,9 @@ impl Display for MethodObject {
MethodObject::MaskedEmail => "MaskedEmail", MethodObject::MaskedEmail => "MaskedEmail",
MethodObject::DeletedAccount => "inbuxa:DeletedAccount", MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
MethodObject::AiLimits => "inbuxa:AiLimits", MethodObject::AiLimits => "inbuxa:AiLimits",
MethodObject::LogSettings => "inbuxa:LogSettings",
MethodObject::DataInventory => "inbuxa:DataInventory",
MethodObject::InventorySnapshot => "inbuxa:InventorySnapshot",
MethodObject::Explanation => "inbuxa:Explanation", MethodObject::Explanation => "inbuxa:Explanation",
MethodObject::AuditEvent => "inbuxa:AuditEvent", MethodObject::AuditEvent => "inbuxa:AuditEvent",
MethodObject::AuditSettings => "inbuxa:AuditSettings", MethodObject::AuditSettings => "inbuxa:AuditSettings",
+4
View File
@@ -116,6 +116,9 @@ pub enum GetRequestMethod {
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>), MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>), DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>), AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
LogSettings(Box<GetRequest<crate::object::inbuxa_log_settings::LogSettings>>),
DataInventory(Box<GetRequest<crate::object::inbuxa_data_inventory::DataInventory>>),
InventorySnapshot(Box<GetRequest<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>>),
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>), AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>), AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>), AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
@@ -148,6 +151,7 @@ pub enum SetRequestMethod<'x> {
MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>), MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>), DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>), AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
LogSettings(Box<SetRequest<'x, crate::object::inbuxa_log_settings::LogSettings>>),
Explanation(Box<SetRequest<'x, crate::object::inbuxa_explanation::Explanation>>), Explanation(Box<SetRequest<'x, crate::object::inbuxa_explanation::Explanation>>),
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>), AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>), AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
+28
View File
@@ -169,6 +169,27 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self)); return Err(de::Error::invalid_length(1, &self));
} }
}, },
(MethodFunction::Get, MethodObject::LogSettings) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LogSettings(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::DataInventory) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DataInventory(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::InventorySnapshot) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::InventorySnapshot(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::ProtocolPolicy) => match seq.next_element() { (MethodFunction::Get, MethodObject::ProtocolPolicy) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ProtocolPolicy(value)), Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ProtocolPolicy(value)),
Err(err) => RequestMethod::invalid(err), Err(err) => RequestMethod::invalid(err),
@@ -350,6 +371,13 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self)); return Err(de::Error::invalid_length(1, &self));
} }
}, },
(MethodFunction::Set, MethodObject::LogSettings) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::LogSettings(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::Explanation) => match seq.next_element() { (MethodFunction::Set, MethodObject::Explanation) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Explanation(value)), Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Explanation(value)),
Err(err) => RequestMethod::invalid(err), Err(err) => RequestMethod::invalid(err),
@@ -105,6 +105,12 @@ impl<V: Default> Default for MaybeResultReference<V> {
} }
} }
impl<V: FromStr> Default for MaybeIdReference<V> {
fn default() -> Self {
MaybeIdReference::Invalid(String::new())
}
}
impl<T: Default> MaybeResultReference<T> { impl<T: Default> MaybeResultReference<T> {
pub fn unwrap(self) -> T { pub fn unwrap(self) -> T {
match self { match self {
+28
View File
@@ -103,6 +103,9 @@ pub enum GetResponseMethod {
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>), MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>), DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>), AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
LogSettings(GetResponse<crate::object::inbuxa_log_settings::LogSettings>),
DataInventory(GetResponse<crate::object::inbuxa_data_inventory::DataInventory>),
InventorySnapshot(GetResponse<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>),
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>), AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>), AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>), AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
@@ -136,6 +139,7 @@ pub enum SetResponseMethod {
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>), MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>), DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>), AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
LogSettings(Box<SetResponse<crate::object::inbuxa_log_settings::LogSettings>>),
AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>), AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>),
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>), AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>), AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
@@ -349,12 +353,36 @@ impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for Respon
} }
} }
impl<'x> From<GetResponse<crate::object::inbuxa_log_settings::LogSettings>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_log_settings::LogSettings>) -> Self {
ResponseMethod::Get(GetResponseMethod::LogSettings(value))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_data_inventory::DataInventory>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_data_inventory::DataInventory>) -> Self {
ResponseMethod::Get(GetResponseMethod::DataInventory(value))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>) -> Self {
ResponseMethod::Get(GetResponseMethod::InventorySnapshot(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> { impl<'x> From<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self { fn from(value: SetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
ResponseMethod::Set(SetResponseMethod::AiLimits(Box::new(value))) ResponseMethod::Set(SetResponseMethod::AiLimits(Box::new(value)))
} }
} }
impl<'x> From<SetResponse<crate::object::inbuxa_log_settings::LogSettings>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_log_settings::LogSettings>) -> Self {
ResponseMethod::Set(SetResponseMethod::LogSettings(Box::new(value)))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_explanation::Explanation>> for ResponseMethod<'x> { impl<'x> From<SetResponse<crate::object::inbuxa_explanation::Explanation>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_explanation::Explanation>) -> Self { fn from(value: SetResponse<crate::object::inbuxa_explanation::Explanation>) -> Self {
ResponseMethod::Set(SetResponseMethod::Explanation(Box::new(value))) ResponseMethod::Set(SetResponseMethod::Explanation(Box::new(value)))
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jmap" name = "jmap"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+17
View File
@@ -90,6 +90,12 @@ impl JmapAuthorization for AccessToken {
GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet, GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet,
// inbuxa: AI call limits, with the classifier's permissions // inbuxa: AI call limits, with the classifier's permissions
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet, GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
// inbuxa: log file retention, with the tracers' permissions
GetRequestMethod::LogSettings(_) => Permission::SysTracerGet,
// inbuxa: personal-data catalog, the inventory and its history
GetRequestMethod::DataInventory(_) | GetRequestMethod::InventorySnapshot(_) => {
Permission::SysComplianceGet
}
// inbuxa: the audit log (AU-9) // inbuxa: the audit log (AU-9)
GetRequestMethod::AuditEvent(_) | GetRequestMethod::AuditSettings(_) => { GetRequestMethod::AuditEvent(_) | GetRequestMethod::AuditSettings(_) => {
Permission::SysAuditGet Permission::SysAuditGet
@@ -201,6 +207,14 @@ impl JmapAuthorization for AccessToken {
Permission::SysSpamLlmUpdate, Permission::SysSpamLlmUpdate,
Permission::SysSpamLlmUpdate, Permission::SysSpamLlmUpdate,
), ),
// inbuxa: log file retention, with the tracers' permissions
SetRequestMethod::LogSettings(s) => validate_set(
s,
self,
Permission::SysTracerUpdate,
Permission::SysTracerUpdate,
Permission::SysTracerUpdate,
),
// inbuxa: the audit log (AU-7, AU-9, AU-11) // inbuxa: the audit log (AU-7, AU-9, AU-11)
SetRequestMethod::AuditSettings(s) => validate_set( SetRequestMethod::AuditSettings(s) => validate_set(
s, s,
@@ -382,6 +396,9 @@ impl JmapAuthorization for AccessToken {
| MethodObject::MaskedEmail | MethodObject::MaskedEmail
| MethodObject::DeletedAccount | MethodObject::DeletedAccount
| MethodObject::AiLimits | MethodObject::AiLimits
| MethodObject::LogSettings
| MethodObject::DataInventory
| MethodObject::InventorySnapshot
| MethodObject::Explanation | MethodObject::Explanation
| MethodObject::AuditEvent | MethodObject::AuditEvent
| MethodObject::AuditSettings | MethodObject::AuditSettings
+41
View File
@@ -261,6 +261,9 @@ impl RequestHandler for Server {
SetResponseMethod::AiLimits(set_response) => { SetResponseMethod::AiLimits(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
SetResponseMethod::LogSettings(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AuditSettings(set_response) => { SetResponseMethod::AuditSettings(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
@@ -445,6 +448,27 @@ impl RequestHandler for Server {
.await? .await?
.into() .into()
} }
// inbuxa: inbuxa:LogSettings/get
GetRequestMethod::LogSettings(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::log_settings::get(self, access_token, *req)
.await?
.into()
}
// inbuxa: inbuxa:DataInventory/get
GetRequestMethod::DataInventory(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::data_inventory::inventory_get(self, access_token, *req)
.await?
.into()
}
// inbuxa: inbuxa:InventorySnapshot/get
GetRequestMethod::InventorySnapshot(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::data_inventory::snapshot_get(self, access_token, *req)
.await?
.into()
}
// inbuxa: account lock with delegation (AL-1) // inbuxa: account lock with delegation (AL-1)
GetRequestMethod::AccountLock(mut req) => { GetRequestMethod::AccountLock(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
@@ -769,6 +793,23 @@ impl RequestHandler for Server {
.await? .await?
.into() .into()
} }
// inbuxa: inbuxa:LogSettings/set
SetRequestMethod::LogSettings(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::log_settings::set(self, access_token, req)),
)
.await?
.into()
}
// inbuxa: the audit log (AU-7, AU-11, AU-6) // inbuxa: the audit log (AU-7, AU-11, AU-6)
SetRequestMethod::AuditSettings(mut req) => { SetRequestMethod::AuditSettings(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
+7 -2
View File
@@ -66,12 +66,16 @@ impl SessionHandler for Server {
Capability::Inbuxa, Capability::Inbuxa,
Capabilities::Empty(EmptyCapabilities::default()), Capabilities::Empty(EmptyCapabilities::default()),
); );
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter // inbuxa: legacy-protocols, Interfaces: whichever switch is stricter,
let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? { // per protocol. `legacyProtocols` stays for older webmail builds:
// `disabled` only when every protocol is off.
let legacy_off = self.legacy_off_for_account(access_token).await?;
let legacy_protocols = if legacy_off.all() {
"disabled" "disabled"
} else { } else {
"enabled" "enabled"
}; };
let legacy_allowed = legacy_off.allowed();
// inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered // inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered
let ai_explain = access_token.has_permission(Permission::SysAiExplain) let ai_explain = access_token.has_permission(Permission::SysAiExplain)
&& access_token.tenant_id().is_none() && access_token.tenant_id().is_none()
@@ -81,6 +85,7 @@ impl SessionHandler for Server {
Capabilities::Inbuxa(InbuxaAccountCapabilities { Capabilities::Inbuxa(InbuxaAccountCapabilities {
logo, logo,
legacy_protocols, legacy_protocols,
legacy_allowed,
ai_explain, ai_explain,
}), }),
); );
+3
View File
@@ -418,6 +418,9 @@ impl IntermediateChangesResponse {
| MethodObject::MaskedEmail | MethodObject::MaskedEmail
| MethodObject::DeletedAccount | MethodObject::DeletedAccount
| MethodObject::AiLimits | MethodObject::AiLimits
| MethodObject::LogSettings
| MethodObject::DataInventory
| MethodObject::InventorySnapshot
| MethodObject::Explanation | MethodObject::Explanation
| MethodObject::AuditEvent | MethodObject::AuditEvent
| MethodObject::AuditSettings | MethodObject::AuditSettings
+2 -2
View File
@@ -18,7 +18,7 @@ use jmap_proto::{
error::set::{SetError, SetErrorType}, error::set::{SetError, SetErrorType},
method::import::{ImportEmailRequest, ImportEmailResponse}, method::import::{ImportEmailRequest, ImportEmailResponse},
object::email::EmailProperty, object::email::EmailProperty,
request::MaybeInvalid, request::reference::MaybeIdReference,
types::state::State, types::state::State,
}; };
use mail_parser::{HeaderName, MessageParser}; use mail_parser::{HeaderName, MessageParser};
@@ -128,7 +128,7 @@ impl EmailImport for Server {
} }
} }
let MaybeInvalid::Value(blob_id) = email.blob_id else { let MaybeIdReference::Id(blob_id) = email.blob_id else {
response.not_created.append( response.not_created.append(
id, id,
SetError::invalid_properties() SetError::invalid_properties()
+5 -2
View File
@@ -854,8 +854,11 @@ impl EmailSet for Server {
new_data.set_mailboxes( new_data.set_mailboxes(
ids.into_expanded_boolean_set() ids.into_expanded_boolean_set()
.filter_map(|id| { .filter_map(|id| {
UidMailbox::new_unassigned( let mailbox_id =
id.try_into_property()?.try_into_id()?.document_id(), id.try_into_property()?.try_into_id()?.document_id();
UidMailbox::new(
mailbox_id,
data.inner.message_uid(mailbox_id).unwrap_or(0),
) )
.into() .into()
}) })
+2
View File
@@ -206,6 +206,8 @@ pub async fn set(
Some(error) => response.not_updated.append(id, error), Some(error) => response.not_updated.append(id, error),
None => { None => {
limits::set(data, &limits).await?; limits::set(data, &limits).await?;
// inbuxa: personal-data catalog: the inventory's history
server.inventory_snapshot_after("inbuxa:AiLimits").await;
response.updated.append(id, None); response.updated.append(id, None);
} }
} }
+24 -6
View File
@@ -376,21 +376,36 @@ async fn full_name(server: &Server, object: &str, value: &Value, name: Option<St
} }
async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> { async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> {
use inbuxa_features::{ai::limits, audit::log, security}; use inbuxa_features::{
ai::limits,
audit::log,
security::{self, protocol_policy::Switches},
};
let data = server.store(); let data = server.store();
match object { match object {
"inbuxa:AuditSettings" => log::settings(data) "inbuxa:AuditSettings" => log::settings(data)
.await .await
.ok() .ok()
.map(|settings| serde_json::json!({"keepForDays": settings.keep_for_secs / 86_400})), .map(|settings| serde_json::json!({"keepForDays": settings.keep_for_secs / 86_400})),
"inbuxa:LogSettings" => security::log_files::get(data)
.await
.ok()
.and_then(|settings| serde_json::to_value(settings).ok()),
"inbuxa:AiLimits" => limits::get(data) "inbuxa:AiLimits" => limits::get(data)
.await .await
.ok() .ok()
.and_then(|limits| serde_json::to_value(limits).ok()), .and_then(|limits| serde_json::to_value(limits).ok()),
"inbuxa:ProtocolPolicy" => security::protocol_policy::get(data) // Normalized, so every switch reads before and after, even from a
.await // policy stored before the per-protocol switches
.ok() "inbuxa:ProtocolPolicy" => {
.and_then(|policy| serde_json::to_value(policy).ok()), security::protocol_policy::get(data)
.await
.ok()
.and_then(|mut policy| {
policy.normalize();
serde_json::to_value(policy).ok()
})
}
// LH-1: a hold as the API shows it, so a change reads before/after // LH-1: a hold as the API shows it, so a change reads before/after
"inbuxa:LegalHold" => match id { "inbuxa:LegalHold" => match id {
MaybeInvalid::Value(id) => { MaybeInvalid::Value(id) => {
@@ -424,7 +439,10 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
security::tenant_protocol_policy::get(data, id.document_id()) security::tenant_protocol_policy::get(data, id.document_id())
.await .await
.ok() .ok()
.and_then(|policy| serde_json::to_value(policy).ok()) .and_then(|mut policy| {
policy.normalize();
serde_json::to_value(policy).ok()
})
} }
MaybeInvalid::Invalid(_) => None, MaybeInvalid::Invalid(_) => None,
}, },
+2
View File
@@ -392,6 +392,8 @@ pub async fn settings_set(
Some(error) => response.not_updated.append(id, error), Some(error) => response.not_updated.append(id, error),
None => { None => {
log::set_settings(server.store(), &settings).await?; log::set_settings(server.store(), &settings).await?;
// Audit retention is also the inventory's (personal-data catalog)
server.inventory_snapshot_after("inbuxa:AuditSettings").await;
response.updated.append(id, None); response.updated.append(id, None);
} }
} }
+179
View File
@@ -0,0 +1,179 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:DataInventory/get` and `inbuxa:InventorySnapshot/get`: the
//! personal-data catalog evaluated against this server, and its history
//! (personal-data catalog spec, §6). Read-only, with `sysComplianceGet`.
//!
//! Inside a tenant both answer with the tenant's slice: tenant-scoped
//! sources only, and none of the server's processors, which describe the
//! whole server. The same holds for snapshots, which are taken of the whole
//! server and cut to the slice when read.
use common::{Server, auth::AccessToken};
use inbuxa_features::privacy::{Inventory, snapshot};
use jmap_proto::{
method::get::{GetRequest, GetResponse},
object::{
inbuxa_data_inventory::{DataInventory, DataInventoryProperty as P, DataInventoryValue},
inbuxa_inventory_snapshot::{
InventorySnapshot, InventorySnapshotProperty as S, InventorySnapshotValue,
},
},
};
use jmap_tools::{Element, Key, Map, Property, Value};
use std::borrow::Cow;
use types::{brand_version, id::Id};
fn json_to_value<Pr: Property, E: Element<Property = Pr>>(
json: serde_json::Value,
) -> Value<'static, Pr, 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 to_json<T: serde::Serialize>(value: &T) -> serde_json::Value {
serde_json::to_value(value).unwrap_or_default()
}
fn utc(seconds: u64) -> String {
jmap_proto::types::date::UTCDate::from_timestamp(seconds as i64).to_string()
}
/// A snapshot's inventory, cut to a tenant's slice when asked from one.
fn slice(mut inventory: Inventory, tenant_only: bool) -> Inventory {
if tenant_only {
inventory.items.retain(|item| item.scope == "tenant");
inventory.processors.clear();
}
inventory
}
/// `inbuxa:DataInventory/get`.
pub async fn inventory_get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<DataInventory>,
) -> trc::Result<GetResponse<DataInventory>> {
let properties = request.unwrap_properties(&[
P::Id,
P::EvaluatedAt,
P::CatalogVersion,
P::Summary,
P::Items,
P::Processors,
]);
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 wanted = match ids {
None => true,
Some(ids) => {
let mut wanted = false;
for id in ids {
if id.is_singleton() {
wanted = true;
} else {
response.push_not_found(id);
}
}
wanted
}
};
if wanted {
let inventory = server
.data_inventory(access_token.tenant_id().is_some())
.await?;
let mut out = Map::with_capacity(properties.len());
for property in &properties {
let value = match property {
P::Id => Value::Element(DataInventoryValue::Id(Id::singleton())),
P::EvaluatedAt => Value::Str(utc(store::write::now()).into()),
P::CatalogVersion => Value::Str(brand_version!().into()),
P::Summary => json_to_value(to_json(&inventory.summary())),
P::Items => json_to_value(to_json(&inventory.items)),
P::Processors => json_to_value(to_json(&inventory.processors)),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
response.list.push(Value::Object(out));
}
Ok(response)
}
/// `inbuxa:InventorySnapshot/get`: by id (the time taken, as an id), or
/// `ids: null` for every snapshot kept, newest first. `inventory` is the
/// whole evaluated inventory; leave it out of `properties` for the list.
pub async fn snapshot_get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<InventorySnapshot>,
) -> trc::Result<GetResponse<InventorySnapshot>> {
let tenant_only = access_token.tenant_id().is_some();
let data = &server.core.storage.data;
let properties =
request.unwrap_properties(&[S::Id, S::TakenAt, S::Trigger, S::Summary, S::Inventory]);
let times: Vec<u64> = match request.ids.take() {
None => snapshot::list(data, 0, u64::MAX).await?,
Some(_) => {
let (ids, _) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
ids.unwrap_or_default().into_iter().map(|id| id.id()).collect()
}
};
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found: vec![],
};
for taken_at in times {
let Some(found) = snapshot::get(data, taken_at).await? else {
response.push_not_found(Id::from(taken_at));
continue;
};
let inventory = slice(found.inventory, tenant_only);
let summary = if tenant_only { inventory.summary() } else { found.summary };
let mut out = Map::with_capacity(properties.len());
for property in &properties {
let value = match property {
S::Id => Value::Element(InventorySnapshotValue::Id(Id::from(taken_at))),
S::TakenAt => Value::Str(utc(found.taken_at).into()),
S::Trigger => json_to_value(to_json(&found.trigger)),
S::Summary => json_to_value(to_json(&summary)),
S::Inventory => json_to_value(to_json(&inventory)),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
response.list.push(Value::Object(out));
}
Ok(response)
}
+156
View File
@@ -0,0 +1,156 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `POST /api/directory/test`: try a saved directory before anything signs in
//! through it (settings-reorg, guided setup "Connect a sign-in directory").
//!
//! The body names a directory and an address, and optionally a password:
//!
//! ```json
//! {"directoryId": "b", "address": "[email protected]", "password": "…"}
//! ```
//!
//! The answer says whether the directory opened, what a recipient lookup of
//! the address finds, and, when a password is given, whether it signs in.
//! It calls the directory itself, below the sign-in path, so a test never
//! creates or updates an account (DIR-14), never counts toward the sign-in
//! ban, and doesn't mind which domains use the directory (DIR-6). Nothing is
//! cached (DIR-32). A password hash a directory hands back is never returned.
//!
//! For server-level administrators who may change directories.
use common::{Server, auth::AccessToken};
use directory::{Credentials, Directory, Recipient};
use registry::schema::enums::Permission;
use serde_json::{Value, json};
use std::str::FromStr;
use types::id::Id;
fn message(err: &trc::Error) -> String {
err.value_as_str(trc::Key::Reason)
.or_else(|| err.value_as_str(trc::Key::Details))
.map(str::to_string)
.unwrap_or_else(|| err.to_string())
}
fn kind(directory: &Directory) -> &'static str {
match directory {
Directory::Ldap(_) => "ldap",
Directory::Sql(_) => "sql",
Directory::OpenId(_) => "oidc",
Directory::Unavailable(d) => match d.directory_type() {
registry::schema::enums::DirectoryType::Ldap => "ldap",
registry::schema::enums::DirectoryType::Sql => "sql",
registry::schema::enums::DirectoryType::Oidc => "oidc",
},
}
}
pub fn assert_allowed(access_token: &AccessToken) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
return Err(trc::JmapEvent::Forbidden
.into_err()
.details("Directory tests are for server-level administrators."));
}
access_token.enforce_permission(Permission::SysDirectoryUpdate)
}
fn bad(details: &'static str) -> trc::Error {
trc::ResourceEvent::BadParameters.into_err().details(details)
}
pub async fn test(server: &Server, body: &Value) -> trc::Result<Value> {
let directory_id = body
.get("directoryId")
.and_then(Value::as_str)
.and_then(|id| Id::from_str(id).ok())
.ok_or_else(|| bad("Expected {\"directoryId\": …, \"address\": …}"))?;
let address = body
.get("address")
.and_then(Value::as_str)
.map(|a| a.trim().to_lowercase())
.filter(|a| !a.is_empty())
.ok_or_else(|| bad("Expected an address to look up"))?;
let password = body
.get("password")
.and_then(Value::as_str)
.filter(|p| !p.is_empty());
let Some(directory) = server
.core
.storage
.directories
.get(&(directory_id.id() as u32))
.cloned()
else {
return Ok(json!({
"opened": false,
"error": "The server hasn't loaded this directory. Save it, and try again in a few seconds.",
}));
};
let mut out = json!({ "kind": kind(&directory) });
if let Directory::Unavailable(d) = directory.as_ref() {
out["opened"] = json!(false);
out["error"] = json!(message(&d.error()));
return Ok(out);
}
out["opened"] = json!(true);
if let Some(discovery) = directory.oidc_discovery_document() {
out["oidc"] = json!({
"issuer": discovery.document.issuer,
"jwksUri": discovery.document.jwks_uri,
});
}
// What mail for this address would find.
if directory.can_lookup_recipients() {
out["lookup"] = match directory.recipient(&address).await {
Ok(Recipient::Account(a)) => json!({
"found": "account",
"email": a.email,
"aliases": a.email_aliases,
"groups": a.groups.unwrap_or_default(),
"description": a.description,
}),
Ok(Recipient::Group(g)) => json!({
"found": "group",
"email": g.email,
"aliases": g.email_aliases,
"description": g.description,
}),
Ok(Recipient::Invalid) => json!({ "found": "none" }),
Err(err) => json!({ "error": message(&err) }),
};
}
// Whether this person could sign in. OIDC takes tokens, not passwords
// (DIR-29), so there's nothing to try there.
if let Some(password) = password
&& !matches!(directory.as_ref(), Directory::OpenId(_))
{
let credentials = Credentials::Basic {
username: address.clone(),
secret: password.to_string(),
mfa_token: None,
};
out["signIn"] = match directory.authenticate(&credentials).await {
Ok(a) => json!({
"ok": true,
"email": a.email,
"groups": a.groups.unwrap_or_default(),
"description": a.description,
}),
Err(err) if matches!(err.as_ref(), trc::EventType::Auth(trc::AuthEvent::Failed)) => {
json!({ "ok": false, "wrongPassword": true })
}
Err(err) => json!({ "ok": false, "error": message(&err) }),
};
}
Ok(out)
}
+11
View File
@@ -798,6 +798,10 @@ mod tests {
assert!(delivery_facts(&mut Facts::default(), &message, "[email protected]").is_err()); assert!(delivery_facts(&mut Facts::default(), &message, "[email protected]").is_err());
} }
fn is_timestamp(value: &str) -> bool {
chrono::DateTime::parse_from_rfc3339(value).is_ok()
}
/// The settings questions a release prepares answers for (EX-26): every /// The settings questions a release prepares answers for (EX-26): every
/// non-secret property of every settings object, at the object's own /// non-secret property of every settings object, at the object's own
/// default, built exactly as a live question is. /// default, built exactly as a live question is.
@@ -842,6 +846,12 @@ mod tests {
if info.secret { if info.secret {
continue; continue;
} }
// A date's default is the moment the object is built, so its
// question changes every run and no live question ever
// matches it: nothing worth preparing.
if matches!(map[&property].as_str(), Some(v) if is_timestamp(v)) {
continue;
}
let mut facts = Facts::default(); let mut facts = Facts::default();
push_setting(&mut facts, &object, &property, &info, &map[&property]); push_setting(&mut facts, &object, &property, &info, &map[&property]);
out.push((object.clone(), property, facts)); out.push((object.clone(), property, facts));
@@ -856,6 +866,7 @@ mod tests {
assert!(questions.len() > 500, "found {}", questions.len()); assert!(questions.len() > 500, "found {}", questions.len());
assert!(questions.iter().any(|(o, p, _)| o == "x:Domain" && p == "dnsManagement")); assert!(questions.iter().any(|(o, p, _)| o == "x:Domain" && p == "dnsManagement"));
assert!(!questions.iter().any(|(o, p, _)| o == "x:AiModel" && p == "httpAuth")); assert!(!questions.iter().any(|(o, p, _)| o == "x:AiModel" && p == "httpAuth"));
assert!(!questions.iter().any(|(o, p, _)| o == "x:Account" && p == "createdAt"));
} }
/// Writes `resources/explain/settings.json.gz` (EX-26). Run before a /// Writes `resources/explain/settings.json.gz` (EX-26). Run before a
+163
View File
@@ -0,0 +1,163 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:LogSettings/get` and `/set`: how long rotated log files are kept
//! (personal-data catalog spec, D1). Server-level: log files belong to the
//! server, not to a tenant. `null` restores the default, which keeps every
//! file.
use common::{Server, auth::AccessToken};
use inbuxa_features::security::log_files::{self, LogSettings as Settings};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_log_settings::{LogSettings, LogSettingsProperty as P, LogSettingsValue},
request::IntoValid,
};
use jmap_tools::{Key, Map, Value};
use types::id::Id;
type LValue = Value<'static, P, LogSettingsValue>;
const ALL: &[P] = &[P::Id, P::KeepForDays];
fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("Log file settings are server-level."))
} else {
Ok(())
}
}
fn to_value(settings: &Settings, properties: &[P]) -> LValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(LogSettingsValue::Id(Id::singleton())),
P::KeepForDays => settings
.keep_for_days
.map_or(Value::Null, |days| Value::Number(days.into())),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:LogSettings/get`.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<LogSettings>,
) -> trc::Result<GetResponse<LogSettings>> {
assert_server_level(access_token)?;
let properties = request.unwrap_properties(ALL);
let (ids, not_found) = request.unwrap_ids(1)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let settings = log_files::get(&server.core.storage.data).await?;
match ids {
None => response.list.push(to_value(&settings, &properties)),
Some(ids) => {
for id in ids {
if id.is_singleton() {
response.list.push(to_value(&settings, &properties));
} else {
response.push_not_found(id);
}
}
}
}
Ok(response)
}
fn apply(
settings: &mut Settings,
property: &P,
value: &Value<'_, P, LogSettingsValue>,
) -> Result<(), String> {
match property {
P::KeepForDays => match value {
Value::Null => settings.keep_for_days = None,
value => {
settings.keep_for_days = Some(
value
.as_u64()
.ok_or_else(|| "must be a whole number of days, or null".to_string())?,
)
}
},
P::Id => return Err("is immutable".to_string()),
}
Ok(())
}
/// `inbuxa:LogSettings/set`: updates the singleton.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, LogSettings>,
) -> trc::Result<SetResponse<LogSettings>> {
assert_server_level(access_token)?;
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
for (client_id, _) in request.unwrap_create() {
response.not_created.append(client_id, SetError::singleton());
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(id, SetError::singleton());
}
let data = &server.core.storage.data;
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 = log_files::get(data).await?;
let mut error = None;
for (key, value) in value.into_expanded_object() {
let Key::Property(property) = &key else {
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
break;
};
if let Err(why) = apply(&mut settings, property, &value) {
error = Some(
SetError::invalid_properties()
.with_property(property.clone())
.with_description(why),
);
break;
}
}
if error.is_none()
&& let Err((property, why)) = settings.check()
{
error = Some(
SetError::invalid_properties()
.with_property(property.parse::<P>().unwrap_or(P::Id))
.with_description(format!("{property} {why}.")),
);
}
match error {
Some(error) => response.not_updated.append(id, error),
None => {
log_files::set(data, &settings).await?;
// This node purges now; the others within the hour
log_files::CHANGED.notify_one();
server.inventory_snapshot_after("inbuxa:LogSettings").await;
response.updated.append(id, None);
}
}
}
Ok(response)
}
+4
View File
@@ -15,6 +15,10 @@ pub mod hold_export_api;
pub mod audit; pub mod audit;
pub mod audit_log; pub mod audit_log;
pub mod ai_limits; pub mod ai_limits;
pub mod log_settings;
pub mod data_inventory;
pub mod directory_test;
pub mod webhook_test;
pub mod explanation; pub mod explanation;
pub mod protocol_policy; pub mod protocol_policy;
pub mod tenant_protocol_policy; pub mod tenant_protocol_policy;
+105 -23
View File
@@ -26,7 +26,9 @@ use common::{
}; };
use inbuxa_features::security::{ use inbuxa_features::security::{
listeners, listeners,
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener}, protocol_policy::{
LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener, Switches,
},
}; };
use jmap_proto::{ use jmap_proto::{
error::set::SetError, error::set::SetError,
@@ -48,6 +50,9 @@ type PValue = Value<'static, P, ProtocolPolicyValue>;
const ALL: &[P] = &[ const ALL: &[P] = &[
P::Id, P::Id,
P::LegacyProtocols, P::LegacyProtocols,
P::Imap,
P::Pop3,
P::ManageSieve,
P::CloseSubmission, P::CloseSubmission,
P::SavedListeners, P::SavedListeners,
P::ChangedAt, P::ChangedAt,
@@ -91,22 +96,49 @@ fn listener_value(listener: &SavedListener) -> PValue {
Value::Object(out) Value::Object(out)
} }
/// A switch as JMAP spells it.
pub(crate) fn switch_str(value: LegacyProtocols) -> &'static str {
match value {
LegacyProtocols::Enabled => "enabled",
LegacyProtocols::Disabled => "disabled",
}
}
/// A switch from JMAP.
pub(crate) fn parse_switch(value: Option<&str>) -> Result<LegacyProtocols, String> {
match value {
Some("enabled") => Ok(LegacyProtocols::Enabled),
Some("disabled") => Ok(LegacyProtocols::Disabled),
_ => Err(r#"must be "enabled" or "disabled""#.to_string()),
}
}
/// The JMAP name of a per-protocol switch property.
pub(crate) fn switch_name(property: &P) -> Option<&'static str> {
match property {
P::Imap => Some("imap"),
P::Pop3 => Some("pop3"),
P::ManageSieve => Some("manageSieve"),
_ => None,
}
}
fn to_value( fn to_value(
policy: &Policy, policy: &Policy,
would_close: &[SavedListener], would_close: &[SavedListener],
recent: &[RecentUse], recent: &[RecentUse],
properties: &[P], properties: &[P],
) -> PValue { ) -> PValue {
let mut policy = policy.clone();
policy.normalize();
let policy = &policy;
let mut out = Map::with_capacity(properties.len()); let mut out = Map::with_capacity(properties.len());
for property in properties { for property in properties {
let value = match property { let value = match property {
P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())), P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())),
P::LegacyProtocols => Value::Str( P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
match policy.legacy_protocols { P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
LegacyProtocols::Enabled => "enabled", switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
LegacyProtocols::Disabled => "disabled",
}
.into(),
), ),
P::CloseSubmission => Value::Bool(policy.close_submission), P::CloseSubmission => Value::Bool(policy.close_submission),
P::SavedListeners => Value::Array( P::SavedListeners => Value::Array(
@@ -176,10 +208,11 @@ where
) )
} }
/// The listeners turning the switch on would close, whatever it is now. /// The listeners turning every protocol off would close, whatever the switches
/// are now; each names its protocol, so the console shows one protocol's.
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> { async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
let mut hypothetical = policy.clone(); let mut hypothetical = policy.clone();
hypothetical.legacy_protocols = LegacyProtocols::Disabled; hypothetical.set_all(LegacyProtocols::Disabled);
hypothetical.apply_locks(); hypothetical.apply_locks();
listeners::would_close(server.registry(), &hypothetical).await listeners::would_close(server.registry(), &hypothetical).await
} }
@@ -238,12 +271,12 @@ fn apply(
value: &Value<'_, P, ProtocolPolicyValue>, value: &Value<'_, P, ProtocolPolicyValue>,
) -> Result<(), String> { ) -> Result<(), String> {
match property { match property {
P::LegacyProtocols => { // The kill-all sets all three; a protocol named in the same /set is
policy.legacy_protocols = match value.as_str().as_deref() { // applied after it (see `set`), so it wins.
Some("enabled") => LegacyProtocols::Enabled, P::LegacyProtocols => policy.set_all(parse_switch(value.as_str().as_deref())?),
Some("disabled") => LegacyProtocols::Disabled, P::Imap | P::Pop3 | P::ManageSieve => {
_ => return Err(r#"must be "enabled" or "disabled""#.to_string()), let value = parse_switch(value.as_str().as_deref())?;
} policy.set(switch_name(property).unwrap_or_default(), value);
} }
P::CloseSubmission => { P::CloseSubmission => {
policy.close_submission = value policy.close_submission = value
@@ -262,7 +295,13 @@ fn apply(
/// Puts a property back to its default (a `null` in `/set`). /// Puts a property back to its default (a `null` in `/set`).
fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> { fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> {
match property { match property {
P::LegacyProtocols => policy.legacy_protocols = defaults.legacy_protocols, P::LegacyProtocols => policy.set_all(defaults.legacy_protocols),
P::Imap | P::Pop3 | P::ManageSieve => {
policy.set(
switch_name(property).unwrap_or_default(),
LegacyProtocols::Enabled,
);
}
P::CloseSubmission => policy.close_submission = defaults.close_submission, P::CloseSubmission => policy.close_submission = defaults.close_submission,
P::Id => return Err("is immutable".to_string()), P::Id => return Err("is immutable".to_string()),
other if other.is_server_set() => return Err("is set by the server".to_string()), other if other.is_server_set() => return Err("is set by the server".to_string()),
@@ -312,10 +351,14 @@ pub async fn set(
} }
let mut policy = server.protocol_policy().await?; let mut policy = server.protocol_policy().await?;
policy.normalize();
let defaults = Policy::default(); let defaults = Policy::default();
let mut error = None; let mut error = None;
for (key, value) in value.into_expanded_object() { // The kill-all first, so a protocol named beside it overrides it.
let mut entries: Vec<_> = value.into_expanded_object().collect();
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
for (key, value) in entries {
let Key::Property(property) = &key else { let Key::Property(property) = &key else {
error = Some(SetError::invalid_properties().with_property(key.into_owned())); error = Some(SetError::invalid_properties().with_property(key.into_owned()));
break; break;
@@ -393,21 +436,30 @@ fn listener_refusal(policy: &Policy, listener: &NetworkListener) -> Option<(Prop
let protocol = listeners::protocol_name(listener.protocol); let protocol = listeners::protocol_name(listener.protocol);
// A submission listener closes because of its port, not its protocol // A submission listener closes because of its port, not its protocol
// (LP-3), so the port is what would have to change. // (LP-3), so the port is what would have to change.
let property = if protocol == "smtp" { let (property, what) = if protocol == "smtp" {
Property::Bind (Property::Bind, "Legacy mail protocols are".to_string())
} else { } else {
Property::Protocol (Property::Protocol, format!("{} is", display_name(protocol)))
}; };
Some(( Some((
property, property,
format!( format!(
"Legacy mail protocols are off (inbuxa:ProtocolPolicy), and this {protocol} \ "{what} off (inbuxa:ProtocolPolicy), and this {protocol} listener would reopen \
listener would reopen a port the switch keeps closed. Turn legacy protocols \ a port the switch keeps closed. Turn it back on first."
back on first."
), ),
)) ))
} }
/// A protocol's name as people read it.
fn display_name(protocol: &str) -> &str {
match protocol {
"imap" => "IMAP",
"pop3" => "POP3",
"manageSieve" => "ManageSieve",
other => other,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -461,6 +513,36 @@ mod tests {
} }
} }
#[test]
fn one_protocol_off_refuses_only_its_listeners() {
let mut policy = Policy::default();
policy.set("pop3", LegacyProtocols::Disabled);
policy.normalize();
let (property, why) = listener_refusal(
&policy,
&listener(NetworkListenerProtocol::Pop3, "[::]:995"),
)
.expect("refused");
assert_eq!(property, Property::Protocol);
assert!(why.starts_with("POP3 is off"), "{why}");
assert!(
listener_refusal(
&policy,
&listener(NetworkListenerProtocol::Imap, "[::]:993")
)
.is_none()
);
}
#[test]
fn a_switch_reads_and_parses_as_jmap_spells_it() {
assert_eq!(switch_str(LegacyProtocols::Disabled), "disabled");
assert_eq!(parse_switch(Some("enabled")), Ok(LegacyProtocols::Enabled));
assert!(parse_switch(Some("off")).is_err());
assert_eq!(switch_name(&P::ManageSieve), Some("manageSieve"));
assert_eq!(switch_name(&P::CloseSubmission), None);
}
#[test] #[test]
fn off_still_allows_what_the_switch_never_closes() { fn off_still_allows_what_the_switch_never_closes() {
// Locked (LP-21) and inbound (LP-3): the switch doesn't close them, // Locked (LP-21) and inbound (LP-3): the switch doesn't close them,
@@ -12,16 +12,22 @@
//! with no ids answers with it, and any other id is `notFound`. At server //! with no ids answers with it, and any other id is `notFound`. At server
//! level, `/get` with no ids answers with every tenant's. //! level, `/get` with no ids answers with every tenant's.
//! //!
//! Turning it off never needs the server's leave; turning it back on is //! Each of IMAP, POP3 and ManageSieve has its own switch, and
//! refused with `forbidden` while the server has legacy protocols off (LP-9). //! `legacyProtocols` is the kill-all, as on the server's policy. Turning one
//! off never needs the server's leave; turning one back on is refused with
//! `forbidden` while the server has that protocol off (LP-9).
//! A tenant's switch closes no port (LP-13) -- sign-in and client //! A tenant's switch closes no port (LP-13) -- sign-in and client
//! configuration read it (LP-10, LP-14a). //! configuration read it (LP-10, LP-14a).
use crate::inbuxa::protocol_policy::recent_value; use crate::inbuxa::protocol_policy::{parse_switch, recent_value, switch_str};
use common::{Server, auth::AccessToken, network::legacy::RecentUse}; use common::{
Server,
auth::AccessToken,
network::legacy::{RecentUse, switches_value},
};
use inbuxa_features::{ use inbuxa_features::{
security::{ security::{
protocol_policy::LegacyProtocols, protocol_policy::{LegacyProtocols, SWITCHED, Switches},
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal}, tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
}, },
tenancy::quota::all_tenants, tenancy::quota::all_tenants,
@@ -46,6 +52,9 @@ const ALL: &[P] = &[
P::Id, P::Id,
P::TenantId, P::TenantId,
P::LegacyProtocols, P::LegacyProtocols,
P::Imap,
P::Pop3,
P::ManageSieve,
P::ChangedAt, P::ChangedAt,
P::ChangedBy, P::ChangedBy,
P::RecentLegacyUse, P::RecentLegacyUse,
@@ -60,19 +69,29 @@ async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<V
} }
} }
/// The JMAP name of a per-protocol switch property.
fn switch_name(property: &P) -> Option<&'static str> {
match property {
P::Imap => Some("imap"),
P::Pop3 => Some("pop3"),
P::ManageSieve => Some("manageSieve"),
_ => None,
}
}
fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue { fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
let mut policy = policy.clone();
policy.normalize();
let policy = &policy;
let mut out = Map::with_capacity(properties.len()); let mut out = Map::with_capacity(properties.len());
for property in properties { for property in properties {
let value = match property { let value = match property {
P::Id | P::TenantId => { P::Id | P::TenantId => {
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id))) Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
} }
P::LegacyProtocols => Value::Str( P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
match policy.legacy_protocols { P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
LegacyProtocols::Enabled => "enabled", switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
LegacyProtocols::Disabled => "disabled",
}
.into(),
), ),
P::ChangedAt => policy P::ChangedAt => policy
.changed_at .changed_at
@@ -165,29 +184,41 @@ pub async fn set(
let data = &server.core.storage.data; let data = &server.core.storage.data;
let previous = tenant_protocol_policy::get(data, tenant_id).await?; let previous = tenant_protocol_policy::get(data, tenant_id).await?;
let mut policy = previous.clone(); let mut policy = previous.clone();
policy.normalize();
let mut error = None; let mut error = None;
for (key, value) in value.into_expanded_object() { // What this request sets to `enabled`, for LP-9.
let mut turned_on: Vec<&'static str> = Vec::new();
// The kill-all first, so a protocol named beside it overrides it.
let mut entries: Vec<_> = value.into_expanded_object().collect();
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
for (key, value) in entries {
// `null` puts a switch back to its default, on.
let parsed = match value {
Value::Null => Ok(LegacyProtocols::Enabled),
value => parse_switch(value.as_str().as_deref()),
};
let result = match &key { let result = match &key {
Key::Property(P::LegacyProtocols) => match value { Key::Property(P::LegacyProtocols) => parsed.map(|value| {
Value::Null => { policy.set_all(value);
policy.legacy_protocols = LegacyProtocols::Enabled; if !value.is_disabled() {
Ok(()) turned_on.extend(SWITCHED.iter().copied());
} else {
turned_on.clear();
} }
value => match value.as_str().as_deref() { }),
Some("enabled") => { Key::Property(property @ (P::Imap | P::Pop3 | P::ManageSieve)) => {
policy.legacy_protocols = LegacyProtocols::Enabled; parsed.map(|value| {
Ok(()) let name = switch_name(property).unwrap_or_default();
policy.set(name, value);
turned_on.retain(|p| *p != name);
if !value.is_disabled() {
turned_on.push(name);
} }
Some("disabled") => { })
policy.legacy_protocols = LegacyProtocols::Disabled; }
Ok(()) Key::Property(P::Id) => Err("is immutable".to_string()),
} Key::Property(_) => Err("is set by the server".to_string()),
_ => Err(r#"must be "enabled" or "disabled""#), _ => Err("is not a property of inbuxa:TenantProtocolPolicy".to_string()),
},
},
Key::Property(P::Id) => Err("is immutable"),
Key::Property(_) => Err("is set by the server"),
_ => Err("is not a property of inbuxa:TenantProtocolPolicy"),
}; };
if let Err(why) = result { if let Err(why) = result {
error = Some( error = Some(
@@ -203,15 +234,18 @@ pub async fn set(
continue; continue;
} }
// LP-9: server off means off for everyone. // LP-9: server off means off for everyone, protocol by protocol.
if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) { if let Some(why) = refusal(&server.protocol_policy().await?, &turned_on) {
response response
.not_updated .not_updated
.append(id, SetError::forbidden().with_description(why)); .append(id, SetError::forbidden().with_description(why));
continue; continue;
} }
if policy.legacy_protocols != previous.legacy_protocols { policy.normalize();
let mut before = previous;
before.normalize();
if policy.off() != before.off() {
policy.changed_at = Some(store::write::now() * 1000); policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = Some(Id::from(access_token.account_id()).to_string()); policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
tenant_protocol_policy::set(data, tenant_id, &policy).await?; tenant_protocol_policy::set(data, tenant_id, &policy).await?;
@@ -221,11 +255,7 @@ pub async fn set(
Security(trc::SecurityEvent::LegacyProtocolsChanged), Security(trc::SecurityEvent::LegacyProtocolsChanged),
Policy = "tenant", Policy = "tenant",
Id = tenant_id, Id = tenant_id,
Value = if policy.legacy_protocols.is_disabled() { Value = switches_value(&policy),
"disabled"
} else {
"enabled"
},
AccountId = policy.changed_by.clone(), AccountId = policy.changed_by.clone(),
); );
} }
+61
View File
@@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `POST /api/webhook/test`: send one sample event to a saved webhook
//! (settings-reorg, Webhooks "Send test").
//!
//! ```json
//! {"webhookId": "b"}
//! ```
//!
//! The answer is `{"sent": true, "status": 200, "ms": 84}` when the receiver
//! answered 2xx, `{"sent": false, "status": 403, …}` when it answered
//! otherwise, and `{"sent": false, "error": "…"}` when nothing came back. The
//! webhook is used as saved, even when it's off, so it can be tried before
//! it's switched on. The request goes where the saved webhook already sends,
//! so this gives nobody a reach they didn't have.
//!
//! For server-level administrators who may change webhooks.
use common::{Server, auth::AccessToken};
use registry::schema::{enums::Permission, structs::WebHook};
use serde_json::{Value, json};
use std::{str::FromStr, time::Instant};
use types::id::Id;
pub fn assert_allowed(access_token: &AccessToken) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
return Err(trc::JmapEvent::Forbidden
.into_err()
.details("Webhook tests are for server-level administrators."));
}
access_token.enforce_permission(Permission::SysWebHookUpdate)
}
pub async fn test(server: &Server, body: &Value) -> trc::Result<Value> {
let webhook_id = body
.get("webhookId")
.and_then(Value::as_str)
.and_then(|id| Id::from_str(id).ok())
.ok_or_else(|| {
trc::ResourceEvent::BadParameters
.into_err()
.details("Expected {\"webhookId\": …}")
})?;
let Some(hook) = server.registry().object::<WebHook>(webhook_id).await? else {
return Ok(json!({ "sent": false, "error": "There's no such webhook. Save it first." }));
};
let started = Instant::now();
Ok(match common::telemetry::webhooks::send_test(&hook).await {
Ok(status) => json!({
"sent": (200..300).contains(&status),
"status": status,
"ms": started.elapsed().as_millis() as u64,
}),
Err(error) => json!({ "sent": false, "error": error }),
})
}
@@ -641,6 +641,7 @@ fn map_dns_server(dns_server: &DnsServerBootstrap) -> Option<registry::schema::s
DnsServerBootstrap::Ns1(inner) => DnsServer::Ns1(inner.clone()).into(), DnsServerBootstrap::Ns1(inner) => DnsServer::Ns1(inner.clone()).into(),
DnsServerBootstrap::OracleCloud(inner) => DnsServer::OracleCloud(inner.clone()).into(), DnsServerBootstrap::OracleCloud(inner) => DnsServer::OracleCloud(inner.clone()).into(),
DnsServerBootstrap::Plesk(inner) => DnsServer::Plesk(inner.clone()).into(), DnsServerBootstrap::Plesk(inner) => DnsServer::Plesk(inner.clone()).into(),
DnsServerBootstrap::PowerDns(inner) => DnsServer::PowerDns(inner.clone()).into(),
DnsServerBootstrap::Safedns(inner) => DnsServer::Safedns(inner.clone()).into(), DnsServerBootstrap::Safedns(inner) => DnsServer::Safedns(inner.clone()).into(),
DnsServerBootstrap::Scaleway(inner) => DnsServer::Scaleway(inner.clone()).into(), DnsServerBootstrap::Scaleway(inner) => DnsServer::Scaleway(inner.clone()).into(),
DnsServerBootstrap::TencentCloud(inner) => DnsServer::TencentCloud(inner.clone()).into(), DnsServerBootstrap::TencentCloud(inner) => DnsServer::TencentCloud(inner.clone()).into(),
+42 -1
View File
@@ -747,6 +747,16 @@ impl RegistrySet for Server {
if let ObjectInner::MaskedEmail(mask) = &new_object.inner { if let ObjectInner::MaskedEmail(mask) = &new_object.inner {
crate::inbuxa::masked_email::created(self, id, mask).await?; crate::inbuxa::masked_email::created(self, id, mask).await?;
} }
// inbuxa: personal-data catalog: a new tenant gets its
// Compliance Officer role
if matches!(new_object.inner, ObjectInner::Tenant(_)) {
common::manager::compliance_roles::tenant_created(
self.registry(),
&self.core.storage.data,
id,
)
.await?;
}
response.object.insert(Property::Id, RegistryValue::Id(id)); response.object.insert(Property::Id, RegistryValue::Id(id));
set.response set.response
.created .created
@@ -800,6 +810,15 @@ impl RegistrySet for Server {
&& object.inner.account_id() != Some(Id::from(set.account_id)))) && object.inner.account_id() != Some(Id::from(set.account_id))))
}) })
{ {
// inbuxa: personal-data catalog: a tenant's compliance
// role, while nobody holds it, goes first
let role_released = matches!(object.inner, ObjectInner::Tenant(_))
&& common::manager::compliance_roles::tenant_deleting(
self.registry(),
&self.core.storage.data,
id,
)
.await?;
match self match self
.registry() .registry()
.write(RegistryWrite::Delete { .write(RegistryWrite::Delete {
@@ -863,6 +882,15 @@ impl RegistrySet for Server {
set.response.destroyed.push(id); set.response.destroyed.push(id);
} }
err => { err => {
// inbuxa: refused for another reason: the role comes back
if role_released {
common::manager::compliance_roles::tenant_kept(
self.registry(),
&self.core.storage.data,
id,
)
.await?;
}
set.response.not_destroyed.append(id, map_write_error(err)); set.response.not_destroyed.append(id, map_write_error(err));
} }
} }
@@ -874,7 +902,20 @@ impl RegistrySet for Server {
// Finalize cache invalidation // Finalize cache invalidation
self.invalidate_caches(cache_invalidator).await?; self.invalidate_caches(cache_invalidator).await?;
Ok(set.into_response()) // inbuxa: personal-data catalog: what the server holds may
// have changed, so the inventory's history is brought up to date
let response = set.into_response();
if !response.created.is_empty()
|| !response.updated.is_empty()
|| !response.destroyed.is_empty()
{
self.inventory_snapshot_after(&format!(
"x:{}",
registry::types::EnumImpl::as_str(&object_type)
))
.await;
}
Ok(response)
} }
ObjectType::ArfExternalReport ObjectType::ArfExternalReport
| ObjectType::DmarcExternalReport | ObjectType::DmarcExternalReport
+1 -1
View File
@@ -7,7 +7,7 @@ keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
categories = ["email"] categories = ["email"]
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; inbuxa takes the AGPL only. # Upstream offers AGPL-3.0-only OR LicenseRef-SEL; inbuxa takes the AGPL only.
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[[bin]] [[bin]]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "managesieve" name = "managesieve"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "migration" name = "migration"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "nlp" name = "nlp"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "pop3" name = "pop3"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "registry" name = "registry"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+4
View File
@@ -620,6 +620,7 @@ pub enum DnsServerBootstrapType {
Vultr = 68, Vultr = 68,
WebSupport = 69, WebSupport = 69,
YandexCloud = 70, YandexCloud = 70,
PowerDns = 71,
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
@@ -696,6 +697,7 @@ pub enum DnsServerType {
Vultr = 67, Vultr = 67,
WebSupport = 68, WebSupport = 68,
YandexCloud = 69, YandexCloud = 69,
PowerDns = 70,
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
@@ -1744,6 +1746,8 @@ pub enum Permission {
SysLegalHoldCreate = 670, SysLegalHoldCreate = 670,
SysLegalHoldUpdate = 671, SysLegalHoldUpdate = 671,
SysLegalHoldExport = 672, SysLegalHoldExport = 672,
// inbuxa: personal-data catalog, the data inventory and compliance overview
SysComplianceGet = 673,
SysAccountGet = 219, SysAccountGet = 219,
SysAccountCreate = 220, SysAccountCreate = 220,
SysAccountUpdate = 221, SysAccountUpdate = 221,
+12 -3
View File
@@ -3023,6 +3023,7 @@ impl EnumImpl for DnsServerBootstrapType {
b"Vultr" => DnsServerBootstrapType::Vultr, b"Vultr" => DnsServerBootstrapType::Vultr,
b"WebSupport" => DnsServerBootstrapType::WebSupport, b"WebSupport" => DnsServerBootstrapType::WebSupport,
b"YandexCloud" => DnsServerBootstrapType::YandexCloud, b"YandexCloud" => DnsServerBootstrapType::YandexCloud,
b"PowerDns" => DnsServerBootstrapType::PowerDns,
} }
.copied() .copied()
} }
@@ -3100,6 +3101,7 @@ impl EnumImpl for DnsServerBootstrapType {
DnsServerBootstrapType::Vultr => "Vultr", DnsServerBootstrapType::Vultr => "Vultr",
DnsServerBootstrapType::WebSupport => "WebSupport", DnsServerBootstrapType::WebSupport => "WebSupport",
DnsServerBootstrapType::YandexCloud => "YandexCloud", DnsServerBootstrapType::YandexCloud => "YandexCloud",
DnsServerBootstrapType::PowerDns => "PowerDns",
} }
} }
@@ -3180,11 +3182,12 @@ impl EnumImpl for DnsServerBootstrapType {
68 => Some(DnsServerBootstrapType::Vultr), 68 => Some(DnsServerBootstrapType::Vultr),
69 => Some(DnsServerBootstrapType::WebSupport), 69 => Some(DnsServerBootstrapType::WebSupport),
70 => Some(DnsServerBootstrapType::YandexCloud), 70 => Some(DnsServerBootstrapType::YandexCloud),
71 => Some(DnsServerBootstrapType::PowerDns),
_ => None, _ => None,
} }
} }
const COUNT: usize = 71; const COUNT: usize = 72;
} }
impl serde::Serialize for DnsServerBootstrapType { impl serde::Serialize for DnsServerBootstrapType {
@@ -3281,6 +3284,7 @@ impl EnumImpl for DnsServerType {
b"Vultr" => DnsServerType::Vultr, b"Vultr" => DnsServerType::Vultr,
b"WebSupport" => DnsServerType::WebSupport, b"WebSupport" => DnsServerType::WebSupport,
b"YandexCloud" => DnsServerType::YandexCloud, b"YandexCloud" => DnsServerType::YandexCloud,
b"PowerDns" => DnsServerType::PowerDns,
} }
.copied() .copied()
} }
@@ -3357,6 +3361,7 @@ impl EnumImpl for DnsServerType {
DnsServerType::Vultr => "Vultr", DnsServerType::Vultr => "Vultr",
DnsServerType::WebSupport => "WebSupport", DnsServerType::WebSupport => "WebSupport",
DnsServerType::YandexCloud => "YandexCloud", DnsServerType::YandexCloud => "YandexCloud",
DnsServerType::PowerDns => "PowerDns",
} }
} }
@@ -3436,11 +3441,12 @@ impl EnumImpl for DnsServerType {
67 => Some(DnsServerType::Vultr), 67 => Some(DnsServerType::Vultr),
68 => Some(DnsServerType::WebSupport), 68 => Some(DnsServerType::WebSupport),
69 => Some(DnsServerType::YandexCloud), 69 => Some(DnsServerType::YandexCloud),
70 => Some(DnsServerType::PowerDns),
_ => None, _ => None,
} }
} }
const COUNT: usize = 70; const COUNT: usize = 71;
} }
impl serde::Serialize for DnsServerType { impl serde::Serialize for DnsServerType {
@@ -7084,6 +7090,7 @@ impl EnumImpl for Permission {
b"sysLegalHoldCreate" => Permission::SysLegalHoldCreate, b"sysLegalHoldCreate" => Permission::SysLegalHoldCreate,
b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate, b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate,
b"sysLegalHoldExport" => Permission::SysLegalHoldExport, b"sysLegalHoldExport" => Permission::SysLegalHoldExport,
b"sysComplianceGet" => Permission::SysComplianceGet,
b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountGet" => Permission::SysAccountGet,
b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountCreate" => Permission::SysAccountCreate,
b"sysAccountUpdate" => Permission::SysAccountUpdate, b"sysAccountUpdate" => Permission::SysAccountUpdate,
@@ -7773,6 +7780,7 @@ impl EnumImpl for Permission {
Permission::SysLegalHoldCreate => "sysLegalHoldCreate", Permission::SysLegalHoldCreate => "sysLegalHoldCreate",
Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate", Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate",
Permission::SysLegalHoldExport => "sysLegalHoldExport", Permission::SysLegalHoldExport => "sysLegalHoldExport",
Permission::SysComplianceGet => "sysComplianceGet",
Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountGet => "sysAccountGet",
Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountCreate => "sysAccountCreate",
Permission::SysAccountUpdate => "sysAccountUpdate", Permission::SysAccountUpdate => "sysAccountUpdate",
@@ -8455,6 +8463,7 @@ impl EnumImpl for Permission {
670 => Some(Permission::SysLegalHoldCreate), 670 => Some(Permission::SysLegalHoldCreate),
671 => Some(Permission::SysLegalHoldUpdate), 671 => Some(Permission::SysLegalHoldUpdate),
672 => Some(Permission::SysLegalHoldExport), 672 => Some(Permission::SysLegalHoldExport),
673 => Some(Permission::SysComplianceGet),
219 => Some(Permission::SysAccountGet), 219 => Some(Permission::SysAccountGet),
220 => Some(Permission::SysAccountCreate), 220 => Some(Permission::SysAccountCreate),
221 => Some(Permission::SysAccountUpdate), 221 => Some(Permission::SysAccountUpdate),
@@ -8899,7 +8908,7 @@ impl EnumImpl for Permission {
} }
} }
const COUNT: usize = 673; const COUNT: usize = 674;
} }
impl serde::Serialize for Permission { impl serde::Serialize for Permission {
+1
View File
@@ -1042,6 +1042,7 @@ pub enum Property {
SentinelUsername = 914, SentinelUsername = 914,
Separator = 97, Separator = 97,
ServerHostname = 121, ServerHostname = 121,
ServerId = 934,
Servers = 308, Servers = 308,
ServiceAccountJson = 316, ServiceAccountJson = 316,
ServiceName = 913, ServiceName = 913,
@@ -1195,6 +1195,7 @@ impl EnumImpl for Property {
b"sentinelUsername" => Property::SentinelUsername, b"sentinelUsername" => Property::SentinelUsername,
b"separator" => Property::Separator, b"separator" => Property::Separator,
b"serverHostname" => Property::ServerHostname, b"serverHostname" => Property::ServerHostname,
b"serverId" => Property::ServerId,
b"servers" => Property::Servers, b"servers" => Property::Servers,
b"serviceAccountJson" => Property::ServiceAccountJson, b"serviceAccountJson" => Property::ServiceAccountJson,
b"serviceName" => Property::ServiceName, b"serviceName" => Property::ServiceName,
@@ -2134,6 +2135,7 @@ impl EnumImpl for Property {
Property::SentinelUsername => "sentinelUsername", Property::SentinelUsername => "sentinelUsername",
Property::Separator => "separator", Property::Separator => "separator",
Property::ServerHostname => "serverHostname", Property::ServerHostname => "serverHostname",
Property::ServerId => "serverId",
Property::Servers => "servers", Property::Servers => "servers",
Property::ServiceAccountJson => "serviceAccountJson", Property::ServiceAccountJson => "serviceAccountJson",
Property::ServiceName => "serviceName", Property::ServiceName => "serviceName",
@@ -3077,6 +3079,7 @@ impl EnumImpl for Property {
914 => Some(Property::SentinelUsername), 914 => Some(Property::SentinelUsername),
97 => Some(Property::Separator), 97 => Some(Property::Separator),
121 => Some(Property::ServerHostname), 121 => Some(Property::ServerHostname),
934 => Some(Property::ServerId),
308 => Some(Property::Servers), 308 => Some(Property::Servers),
316 => Some(Property::ServiceAccountJson), 316 => Some(Property::ServiceAccountJson),
913 => Some(Property::ServiceName), 913 => Some(Property::ServiceName),
@@ -3227,7 +3230,7 @@ impl EnumImpl for Property {
} }
} }
const COUNT: usize = 934; const COUNT: usize = 935;
} }
impl serde::Serialize for Property { impl serde::Serialize for Property {
@@ -4493,6 +4496,7 @@ impl ObjectInner {
ObjectInner::DnsServer(DnsServer::Vultr(obj)) => obj.member_tenant_id, ObjectInner::DnsServer(DnsServer::Vultr(obj)) => obj.member_tenant_id,
ObjectInner::DnsServer(DnsServer::WebSupport(obj)) => obj.member_tenant_id, ObjectInner::DnsServer(DnsServer::WebSupport(obj)) => obj.member_tenant_id,
ObjectInner::DnsServer(DnsServer::YandexCloud(obj)) => obj.member_tenant_id, ObjectInner::DnsServer(DnsServer::YandexCloud(obj)) => obj.member_tenant_id,
ObjectInner::DnsServer(DnsServer::PowerDns(obj)) => obj.member_tenant_id,
ObjectInner::Domain(obj) => obj.member_tenant_id, ObjectInner::Domain(obj) => obj.member_tenant_id,
ObjectInner::MailingList(obj) => obj.member_tenant_id, ObjectInner::MailingList(obj) => obj.member_tenant_id,
ObjectInner::OAuthClient(obj) => obj.member_tenant_id, ObjectInner::OAuthClient(obj) => obj.member_tenant_id,
@@ -4595,6 +4599,7 @@ impl ObjectInner {
ObjectInner::DnsServer(DnsServer::Vultr(obj)) => obj.member_tenant_id = Some(id), ObjectInner::DnsServer(DnsServer::Vultr(obj)) => obj.member_tenant_id = Some(id),
ObjectInner::DnsServer(DnsServer::WebSupport(obj)) => obj.member_tenant_id = Some(id), ObjectInner::DnsServer(DnsServer::WebSupport(obj)) => obj.member_tenant_id = Some(id),
ObjectInner::DnsServer(DnsServer::YandexCloud(obj)) => obj.member_tenant_id = Some(id), ObjectInner::DnsServer(DnsServer::YandexCloud(obj)) => obj.member_tenant_id = Some(id),
ObjectInner::DnsServer(DnsServer::PowerDns(obj)) => obj.member_tenant_id = Some(id),
ObjectInner::Domain(obj) => obj.member_tenant_id = Some(id), ObjectInner::Domain(obj) => obj.member_tenant_id = Some(id),
ObjectInner::MailingList(obj) => obj.member_tenant_id = Some(id), ObjectInner::MailingList(obj) => obj.member_tenant_id = Some(id),
ObjectInner::OAuthClient(obj) => obj.member_tenant_id = Some(id), ObjectInner::OAuthClient(obj) => obj.member_tenant_id = Some(id),
+27
View File
@@ -1457,6 +1457,7 @@ pub enum DnsServer {
Vultr(DnsServerCloud), Vultr(DnsServerCloud),
WebSupport(DnsServerWebSupport), WebSupport(DnsServerWebSupport),
YandexCloud(DnsServerYandexCloud), YandexCloud(DnsServerYandexCloud),
PowerDns(DnsServerPowerDns),
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -1672,6 +1673,7 @@ pub enum DnsServerBootstrap {
Vultr(DnsServerCloud), Vultr(DnsServerCloud),
WebSupport(DnsServerWebSupport), WebSupport(DnsServerWebSupport),
YandexCloud(DnsServerYandexCloud), YandexCloud(DnsServerYandexCloud),
PowerDns(DnsServerPowerDns),
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -2435,6 +2437,31 @@ pub struct DnsServerPorkbun {
pub propagation_delay: Option<Duration>, pub propagation_delay: Option<Duration>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct DnsServerPowerDns {
#[serde(rename = "apiKey")]
pub api_key: SecretKey,
#[serde(rename = "endpoint")]
pub endpoint: Option<String>,
#[serde(rename = "serverId")]
pub server_id: Option<String>,
#[serde(rename = "description")]
pub description: String,
#[serde(rename = "memberTenantId")]
pub member_tenant_id: Option<Id>,
#[serde(rename = "timeout")]
pub timeout: Duration,
#[serde(rename = "ttl")]
pub ttl: Duration,
#[serde(rename = "pollingInterval")]
pub polling_interval: Duration,
#[serde(rename = "propagationTimeout")]
pub propagation_timeout: Duration,
#[serde(rename = "propagationDelay")]
pub propagation_delay: Option<Duration>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct DnsServerRoute53 { pub struct DnsServerRoute53 {
+182 -1
View File
@@ -10559,6 +10559,7 @@ impl ObjectImpl for DnsServer {
DnsServer::Vultr(inner) => inner.validate(errors), DnsServer::Vultr(inner) => inner.validate(errors),
DnsServer::WebSupport(inner) => inner.validate(errors), DnsServer::WebSupport(inner) => inner.validate(errors),
DnsServer::YandexCloud(inner) => inner.validate(errors), DnsServer::YandexCloud(inner) => inner.validate(errors),
DnsServer::PowerDns(inner) => inner.validate(errors),
} }
} }
@@ -10772,6 +10773,9 @@ impl ObjectImpl for DnsServer {
DnsServer::YandexCloud(object) => { DnsServer::YandexCloud(object) => {
object.index(i); object.index(i);
} }
DnsServer::PowerDns(object) => {
object.index(i);
}
} }
} }
} }
@@ -11064,6 +11068,10 @@ impl Pickle for DnsServer {
69u16.pickle(out); 69u16.pickle(out);
inner.pickle(out); inner.pickle(out);
} }
DnsServer::PowerDns(inner) => {
70u16.pickle(out);
inner.pickle(out);
}
} }
} }
@@ -11139,6 +11147,7 @@ impl Pickle for DnsServer {
67 => Pickle::unpickle(stream).map(DnsServer::Vultr), 67 => Pickle::unpickle(stream).map(DnsServer::Vultr),
68 => Pickle::unpickle(stream).map(DnsServer::WebSupport), 68 => Pickle::unpickle(stream).map(DnsServer::WebSupport),
69 => Pickle::unpickle(stream).map(DnsServer::YandexCloud), 69 => Pickle::unpickle(stream).map(DnsServer::YandexCloud),
70 => Pickle::unpickle(stream).map(DnsServer::PowerDns),
_ => None, _ => None,
} }
} }
@@ -11635,6 +11644,13 @@ impl IntoValue for DnsServer {
.insert_unchecked(Property::Type, JmapValue::Str("YandexCloud".into())); .insert_unchecked(Property::Type, JmapValue::Str("YandexCloud".into()));
obj obj
} }
DnsServer::PowerDns(obj) => {
let mut obj = obj.into_value();
obj.as_object_mut()
.unwrap()
.insert_unchecked(Property::Type, JmapValue::Str("PowerDns".into()));
obj
}
} }
} }
} }
@@ -11719,6 +11735,7 @@ impl RegistryJsonPatch for DnsServer {
DnsServerType::Vultr => *self = DnsServer::Vultr(Default::default()), DnsServerType::Vultr => *self = DnsServer::Vultr(Default::default()),
DnsServerType::WebSupport => *self = DnsServer::WebSupport(Default::default()), DnsServerType::WebSupport => *self = DnsServer::WebSupport(Default::default()),
DnsServerType::YandexCloud => *self = DnsServer::YandexCloud(Default::default()), DnsServerType::YandexCloud => *self = DnsServer::YandexCloud(Default::default()),
DnsServerType::PowerDns => *self = DnsServer::PowerDns(Default::default()),
} }
} }
match self { match self {
@@ -11792,6 +11809,7 @@ impl RegistryJsonPatch for DnsServer {
DnsServer::Vultr(inner) => inner.patch(pointer, value), DnsServer::Vultr(inner) => inner.patch(pointer, value),
DnsServer::WebSupport(inner) => inner.patch(pointer, value), DnsServer::WebSupport(inner) => inner.patch(pointer, value),
DnsServer::YandexCloud(inner) => inner.patch(pointer, value), DnsServer::YandexCloud(inner) => inner.patch(pointer, value),
DnsServer::PowerDns(inner) => inner.patch(pointer, value),
} }
} }
} }
@@ -11869,6 +11887,7 @@ impl DnsServer {
DnsServer::Vultr(_) => DnsServerType::Vultr, DnsServer::Vultr(_) => DnsServerType::Vultr,
DnsServer::WebSupport(_) => DnsServerType::WebSupport, DnsServer::WebSupport(_) => DnsServerType::WebSupport,
DnsServer::YandexCloud(_) => DnsServerType::YandexCloud, DnsServer::YandexCloud(_) => DnsServerType::YandexCloud,
DnsServer::PowerDns(_) => DnsServerType::PowerDns,
} }
} }
} }
@@ -12704,6 +12723,7 @@ impl DnsServerBootstrap {
DnsServerBootstrap::Vultr(inner) => inner.validate(errors), DnsServerBootstrap::Vultr(inner) => inner.validate(errors),
DnsServerBootstrap::WebSupport(inner) => inner.validate(errors), DnsServerBootstrap::WebSupport(inner) => inner.validate(errors),
DnsServerBootstrap::YandexCloud(inner) => inner.validate(errors), DnsServerBootstrap::YandexCloud(inner) => inner.validate(errors),
DnsServerBootstrap::PowerDns(inner) => inner.validate(errors),
} }
} }
} }
@@ -12999,6 +13019,10 @@ impl Pickle for DnsServerBootstrap {
70u16.pickle(out); 70u16.pickle(out);
inner.pickle(out); inner.pickle(out);
} }
DnsServerBootstrap::PowerDns(inner) => {
71u16.pickle(out);
inner.pickle(out);
}
} }
} }
@@ -13075,6 +13099,7 @@ impl Pickle for DnsServerBootstrap {
68 => Pickle::unpickle(stream).map(DnsServerBootstrap::Vultr), 68 => Pickle::unpickle(stream).map(DnsServerBootstrap::Vultr),
69 => Pickle::unpickle(stream).map(DnsServerBootstrap::WebSupport), 69 => Pickle::unpickle(stream).map(DnsServerBootstrap::WebSupport),
70 => Pickle::unpickle(stream).map(DnsServerBootstrap::YandexCloud), 70 => Pickle::unpickle(stream).map(DnsServerBootstrap::YandexCloud),
71 => Pickle::unpickle(stream).map(DnsServerBootstrap::PowerDns),
_ => None, _ => None,
} }
} }
@@ -13576,6 +13601,13 @@ impl IntoValue for DnsServerBootstrap {
.insert_unchecked(Property::Type, JmapValue::Str("YandexCloud".into())); .insert_unchecked(Property::Type, JmapValue::Str("YandexCloud".into()));
obj obj
} }
DnsServerBootstrap::PowerDns(obj) => {
let mut obj = obj.into_value();
obj.as_object_mut()
.unwrap()
.insert_unchecked(Property::Type, JmapValue::Str("PowerDns".into()));
obj
}
} }
} }
} }
@@ -13793,6 +13825,9 @@ impl RegistryJsonPatch for DnsServerBootstrap {
DnsServerBootstrapType::YandexCloud => { DnsServerBootstrapType::YandexCloud => {
*self = DnsServerBootstrap::YandexCloud(Default::default()) *self = DnsServerBootstrap::YandexCloud(Default::default())
} }
DnsServerBootstrapType::PowerDns => {
*self = DnsServerBootstrap::PowerDns(Default::default())
}
} }
} }
match self { match self {
@@ -13867,6 +13902,7 @@ impl RegistryJsonPatch for DnsServerBootstrap {
DnsServerBootstrap::Vultr(inner) => inner.patch(pointer, value), DnsServerBootstrap::Vultr(inner) => inner.patch(pointer, value),
DnsServerBootstrap::WebSupport(inner) => inner.patch(pointer, value), DnsServerBootstrap::WebSupport(inner) => inner.patch(pointer, value),
DnsServerBootstrap::YandexCloud(inner) => inner.patch(pointer, value), DnsServerBootstrap::YandexCloud(inner) => inner.patch(pointer, value),
DnsServerBootstrap::PowerDns(inner) => inner.patch(pointer, value),
} }
} }
} }
@@ -13945,6 +13981,7 @@ impl DnsServerBootstrap {
DnsServerBootstrap::Vultr(_) => DnsServerBootstrapType::Vultr, DnsServerBootstrap::Vultr(_) => DnsServerBootstrapType::Vultr,
DnsServerBootstrap::WebSupport(_) => DnsServerBootstrapType::WebSupport, DnsServerBootstrap::WebSupport(_) => DnsServerBootstrapType::WebSupport,
DnsServerBootstrap::YandexCloud(_) => DnsServerBootstrapType::YandexCloud, DnsServerBootstrap::YandexCloud(_) => DnsServerBootstrapType::YandexCloud,
DnsServerBootstrap::PowerDns(_) => DnsServerBootstrapType::PowerDns,
} }
} }
} }
@@ -18228,6 +18265,148 @@ impl RegistryJsonPropertyPatch for DnsServerPorkbun {
} }
} }
impl DnsServerPowerDns {
fn validate(&self, errors: &mut Vec<ValidationError>) -> bool {
let neb = errors.len();
let value = &self.api_key;
value.validate(errors);
if let Some(value) = &self.endpoint {
if value.is_empty() {
errors.push(ValidationError::required(Property::Endpoint));
}
}
if let Some(value) = &self.server_id {
if value.is_empty() {
errors.push(ValidationError::required(Property::ServerId));
}
}
let value = &self.description;
if value.is_empty() {
errors.push(ValidationError::required(Property::Description));
}
if let Some(value) = &self.member_tenant_id {
if !value.is_valid() {
errors.push(ValidationError::required(Property::MemberTenantId));
}
}
errors.len() == neb
}
fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) {
i.foreign_key(ObjectType::Tenant, self.member_tenant_id, None);
if let Some(value) = &self.member_tenant_id {
i.search(Property::MemberTenantId, value);
}
}
}
impl Pickle for DnsServerPowerDns {
fn pickle(&self, out: &mut Vec<u8>) {
self.api_key.pickle(out);
self.endpoint.pickle(out);
self.server_id.pickle(out);
self.description.pickle(out);
self.member_tenant_id.pickle(out);
self.timeout.pickle(out);
self.ttl.pickle(out);
self.polling_interval.pickle(out);
self.propagation_timeout.pickle(out);
self.propagation_delay.pickle(out);
}
fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option<Self> {
let mut this = Self::default();
this.api_key = Pickle::unpickle(stream)?;
this.endpoint = Pickle::unpickle(stream)?;
this.server_id = Pickle::unpickle(stream)?;
this.description = Pickle::unpickle(stream)?;
this.member_tenant_id = Pickle::unpickle(stream)?;
this.timeout = Pickle::unpickle(stream)?;
this.ttl = Pickle::unpickle(stream)?;
this.polling_interval = Pickle::unpickle(stream)?;
this.propagation_timeout = Pickle::unpickle(stream)?;
this.propagation_delay = Pickle::unpickle(stream)?;
Some(this)
}
}
impl Default for DnsServerPowerDns {
fn default() -> Self {
Self {
api_key: Default::default(),
endpoint: Default::default(),
server_id: Default::default(),
description: Default::default(),
member_tenant_id: Default::default(),
timeout: Duration::from_millis(30000),
ttl: Duration::from_millis(300000),
polling_interval: Duration::from_millis(15000),
propagation_timeout: Duration::from_millis(60000),
propagation_delay: Default::default(),
}
}
}
impl IntoValue for DnsServerPowerDns {
fn into_value(self) -> JmapValue<'static> {
let mut map = jmap_tools::Map::with_capacity(12);
map.insert_unchecked(Property::ApiKey, self.api_key.into_value());
map.insert_unchecked(Property::Endpoint, self.endpoint.into_value());
map.insert_unchecked(Property::ServerId, self.server_id.into_value());
map.insert_unchecked(Property::Description, self.description.into_value());
map.insert_unchecked(Property::MemberTenantId, self.member_tenant_id.into_value());
map.insert_unchecked(Property::Timeout, self.timeout.into_value());
map.insert_unchecked(Property::Ttl, self.ttl.into_value());
map.insert_unchecked(
Property::PollingInterval,
self.polling_interval.into_value(),
);
map.insert_unchecked(
Property::PropagationTimeout,
self.propagation_timeout.into_value(),
);
map.insert_unchecked(
Property::PropagationDelay,
self.propagation_delay.into_value(),
);
JmapValue::Object(map)
}
}
impl RegistryJsonPropertyPatch for DnsServerPowerDns {
fn patch_property<'x>(
&mut self,
mut pointer: JsonPointerPatch<'_>,
value: JmapValue<'x>,
) -> PatchResult<'x> {
match pointer.next_property() {
Some(Property::ApiKey) => self.api_key.patch(pointer, value),
Some(Property::Endpoint) => self
.endpoint
.patch(pointer.with_validators(&[StringValidator::Trim]), value),
Some(Property::ServerId) => self
.server_id
.patch(pointer.with_validators(&[StringValidator::Trim]), value),
Some(Property::Description) => self
.description
.patch(pointer.with_validators(&[StringValidator::Trim]), value),
Some(Property::MemberTenantId) => self
.member_tenant_id
.patch(pointer.assert_can_set_tenant()?, value),
Some(Property::Timeout) => self.timeout.patch(pointer, value),
Some(Property::Ttl) => self.ttl.patch(pointer, value),
Some(Property::PollingInterval) => self.polling_interval.patch(pointer, value),
Some(Property::PropagationTimeout) => self.propagation_timeout.patch(pointer, value),
Some(Property::PropagationDelay) => self.propagation_delay.patch(pointer, value),
Some(Property::Type) => Ok(MaybeUnpatched::Unpatched {
property: Property::Type,
value,
}),
_ => Err(PatchError::new(pointer, "Invalid property")),
}
}
}
impl DnsServerRoute53 { impl DnsServerRoute53 {
fn validate(&self, errors: &mut Vec<ValidationError>) -> bool { fn validate(&self, errors: &mut Vec<ValidationError>) -> bool {
let neb = errors.len(); let neb = errors.len();
@@ -47353,7 +47532,9 @@ impl Default for WebHook {
level: TracingLevel::Info, level: TracingLevel::Info,
lossy: false, lossy: false,
events: Default::default(), events: Default::default(),
events_policy: EventPolicy::Exclude, // inbuxa: personal-data catalog, D7: a new webhook sends nothing
// until its events are chosen
events_policy: EventPolicy::Include,
} }
} }
} }
+1
View File
@@ -14,6 +14,7 @@ pub mod dkim;
pub mod http; pub mod http;
pub mod report; pub mod report;
pub mod secret; pub mod secret;
pub mod spam;
pub mod task; pub mod task;
impl Roles { impl Roles {
+59
View File
@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::schema::prelude::{SpamDnsblServer, SpamRule};
impl SpamRule {
pub fn enable(&self) -> bool {
match self {
SpamRule::Any(rule) => rule.enable,
SpamRule::Url(rule) => rule.enable,
SpamRule::Domain(rule) => rule.enable,
SpamRule::Email(rule) => rule.enable,
SpamRule::Ip(rule) => rule.enable,
SpamRule::Header(rule) => rule.enable,
SpamRule::Body(rule) => rule.enable,
}
}
pub fn set_enable(&mut self, enable: bool) {
match self {
SpamRule::Any(rule) => rule.enable = enable,
SpamRule::Url(rule) => rule.enable = enable,
SpamRule::Domain(rule) => rule.enable = enable,
SpamRule::Email(rule) => rule.enable = enable,
SpamRule::Ip(rule) => rule.enable = enable,
SpamRule::Header(rule) => rule.enable = enable,
SpamRule::Body(rule) => rule.enable = enable,
}
}
}
impl SpamDnsblServer {
pub fn enable(&self) -> bool {
match self {
SpamDnsblServer::Any(server) => server.enable,
SpamDnsblServer::Url(server) => server.enable,
SpamDnsblServer::Domain(server) => server.enable,
SpamDnsblServer::Email(server) => server.enable,
SpamDnsblServer::Ip(server) => server.enable,
SpamDnsblServer::Header(server) => server.enable,
SpamDnsblServer::Body(server) => server.enable,
}
}
pub fn set_enable(&mut self, enable: bool) {
match self {
SpamDnsblServer::Any(server) => server.enable = enable,
SpamDnsblServer::Url(server) => server.enable = enable,
SpamDnsblServer::Domain(server) => server.enable = enable,
SpamDnsblServer::Email(server) => server.enable = enable,
SpamDnsblServer::Ip(server) => server.enable = enable,
SpamDnsblServer::Header(server) => server.enable = enable,
SpamDnsblServer::Body(server) => server.enable = enable,
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "scim-proto" name = "scim-proto"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "scim" name = "scim"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "services" name = "services"
version = "0.16.23" version = "0.16.24"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Deletes rotated log files past `inbuxa:LogSettings.keepForDays`
//! (personal-data catalog spec, D1). Log files are local, so every node
//! cleans its own: hourly, and at once when the settings change here.
use common::{BuildServer, Inner, Server};
use inbuxa_features::security::log_files;
use registry::schema::structs::Tracer;
use std::{path::PathBuf, sync::Arc, time::Duration};
const EVERY: Duration = Duration::from_secs(3600);
pub fn spawn_log_retention(inner: Arc<Inner>) {
tokio::spawn(async move {
loop {
let server = inner.build_server();
if let Err(err) = purge(&server).await {
trc::error!(err.details("Failed to delete old log files"));
}
tokio::select! {
_ = tokio::time::sleep(EVERY) => {}
_ = log_files::CHANGED.notified() => {}
}
}
});
}
async fn purge(server: &Server) -> trc::Result<()> {
let Some(days) = log_files::get(&server.core.storage.data)
.await?
.keep_for_days
else {
return Ok(());
};
let keep = Duration::from_secs(days.max(log_files::MIN_KEEP_DAYS) * 86_400);
for tracer in server.registry().list::<Tracer>().await? {
let Tracer::Log(log) = tracer.object else {
continue;
};
if !log.enable || log.path.is_empty() {
continue;
}
let (dir, prefix) = (PathBuf::from(&log.path), log.prefix.clone());
let result = tokio::task::spawn_blocking(move || log_files::purge(&dir, &prefix, keep))
.await
.map_err(|err| trc::EventType::Server(trc::ServerEvent::ThreadError).reason(err))?;
if let Err(err) = result {
trc::event!(
Telemetry(trc::TelemetryEvent::LogError),
Details = "Failed to delete old log files",
Path = log.path.clone(),
Reason = err.to_string(),
);
}
}
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More