Compare commits

..
Author SHA1 Message Date
jcoffey-dev b62713bb8d Merge pull request 'Release 2026.10.6.1' (#159) from release/2026.10.6.1-pr into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
publish / version (push) Skipped
publish / publish-amd64 (push) Skipped
publish / publish-arm64 (push) Skipped
publish / release (push) Skipped
publish / binaries (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 42m8s
announce / announce (release) Successful in 9s
publish / github (push) Failing after 1h19m31s
publish / announce (push) Skipped
github/ci (tag) GitHub Actions
2026-10-06 14:24:52 +00:00
jcoffey-dev ef56eb33ed Release 2026.10.6.1
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m26s
2026-10-06 07:05:59 -07:00
jcoffey-dev b64f6690f6 Merge pull request 'Merge upstream v0.16.25' (#155) from merge/upstream-v0.16.25 into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 42m47s
2026-10-06 07:30:12 +00:00
jcoffey-dev bf7c84bc15 Merge pull request 'Send security and conduct reports to Coffey Labs LLC addresses' (#158) from chore/company-contacts into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 7s
2026-10-06 07:30:09 +00:00
jcoffey-dev b07e69b5ed Send security and conduct reports to Coffey Labs LLC addresses
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m47s
Security reports now go to [email protected] and code-of-conduct reports to [email protected], replacing a personal address. Written in the same obfuscated form as before.
2026-10-06 00:22:10 -07:00
jcoffey-dev ce8284df6c Merge pull request 'Name Coffey Labs LLC as the copyright holder' (#157) from chore/copyright-coffey-labs-llc into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 43m21s
2026-10-06 06:42:02 +00:00
jcoffey-dev 966241070e Name Coffey Labs LLC as the copyright holder
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m29s
Coffey Labs is now Coffey Labs LLC, an Arizona limited liability company. Copyright lines and SPDX-FileCopyrightText headers naming Coffey Labs or John Coffey now name Coffey Labs LLC. Upstream copyright notices are unchanged.
2026-10-05 23:33:00 -07:00
jcoffey-dev b25258330c Give the spam classifier test its own upload limits
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m48s
The quota test, which runs earlier in the same system suite, leaves
uploads expiring after one second, at most three at a time, and the
spam classifier test inherited that. It imports twenty samples, each an
upload followed by an import; when the step between them takes longer
than a second, the import fails with BlobNotFound.

After the v0.16.25 merge this failed in two runs out of three, where
main passed three out of three. Putting back the old midnight rounding
of a training sample's deadline (upstream now uses now() + hold, which
is now() in this suite since the purge test holds samples for zero
seconds) made it pass, so that change is what tips the timing; exactly
how it slows the import loop wasn't traced.

The test now resets the upload TTL, count and quota to their defaults
before it starts. Production isn't affected: uploads are kept for an
hour by default and samples for ninety days.
2026-10-05 22:16:30 -07:00
jcoffey-dev db4135e481 Merge upstream v0.16.25
Brings in the stripped v0.16.25 snapshot (72f8ddd), a bug-fix release:
DKIM rotation (keys retired before their successor is published, keys
made under manual DNS never rotating, manual DNS activating an
unpublished key), IMAP answering failed logins with an untagged NO,
DNSBL scoring only the first return code and caching "not listed" for
24 hours, Pyzor digesting empty input, queue quotas with an empty match
never enforced, RocksDB's info log growing without limit, MaskedEmail
creation with several domains, and Autodiscover answering other schemas
with the Outlook settings.

Conflicts:
- crates/main/Cargo.toml: inbuxa's name and AGPL-only license, version
  0.16.25.
- SECURITY.md and .github/PULL_REQUEST_TEMPLATE.md: inbuxa's own.
- Cargo.lock: upstream's, re-resolved against inbuxa's manifests.
- crates/common/src/network/autoconfig/autodiscover.rs: upstream now
  parses the request into a struct, so the legacy-protocol switch
  (LP-7) reads the address from request.email; ActiveSync and other
  schemas get upstream's error 601 untouched.
- resources/schema: unchanged upstream apart from two labels the rename
  pass now covers, which main already had.

AGENTS.md, new upstream, is left out: it is about contributing to
upstream, which doesn't apply to this repository.
2026-10-05 21:20:26 -07:00
jcoffey-dev 72f8ddd5e7 Import upstream v0.16.25, stripped
Upstream commit: 3f657330c0f49a015a3a372fb59669b5cccbca6d
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: 67 in 19 files
Verification: clean

The same Enterprise footprint as v0.16.24. The build check is clean
apart from the expected errors in the rebuilt-feature tests. A
bug-fix release: DKIM rotation, IMAP failed-login answers, DNSBL
multi-code scoring and negative TTLs, Pyzor on short messages, queue
quotas with an empty match, RocksDB info-log rotation, and
Autodiscover schema handling.
2026-10-05 21:15:02 -07:00
jcoffey-dev 88e756bc3a Merge pull request 'Release 2026.10.6' (#154) from release/2026.10.6-pr into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
publish / version (push) Skipped
publish / publish-amd64 (push) Skipped
publish / publish-arm64 (push) Skipped
publish / release (push) Skipped
publish / binaries (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 39m48s
announce / announce (release) Successful in 10s
github/ci (tag) GitHub Actions
publish / github (push) Successful in 1h6m10s
publish / announce (push) Failing after 8s
2026-10-06 03:27:45 +00:00
jcoffey-dev f77d171063 Release 2026.10.6
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 8m6s
2026-10-05 20:19:28 -07:00
jcoffey-dev 79db6c537b Merge pull request 'Domains menu: Deliverability' (#153) from feat/deliverability-menu into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 40m48s
2026-10-06 01:28:09 +00:00
jcoffey-dev 1a23243cc1 Merge pull request 'Release 2026.10.5.1' (#152) from release/2026.10.5.1-pr into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
publish / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 47m10s
announce / announce (release) Successful in 23s
github/ci (tag) GitHub Actions
publish / github (push) Successful in 1h2m30s
publish / publish-amd64 (push) Skipped
publish / publish-arm64 (push) Skipped
publish / release (push) Skipped
publish / binaries (push) Skipped
publish / announce (push) Failing after 10s
2026-10-05 23:37:40 +00:00
jcoffey-dev ca4bf75c1b Domains menu: Deliverability, after DKIM Signatures
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m50s
The console's Domains › Deliverability page (deliverability spec, DL-17),
for the reports #150 added.
2026-10-05 16:33:17 -07:00
jcoffey-dev 8a596c44ac Merge pull request 'SPEC §2.4: allow factual comparisons, never with Stalwart' (#151) from docs/spec-comparisons into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 4m50s
2026-10-05 23:32:44 +00:00
jcoffey-dev c50d5eb109 Merge pull request 'Deliverability check: each node asks what the internet sees of it' (#150) from feat/deliverability into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 3m13s
2026-10-05 23:29:27 +00:00
jcoffey-dev 098abb102a Release 2026.10.5.1
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m46s
2026-10-05 16:29:17 -07:00
jcoffey-dev c1702a00bb SPEC §2.4: allow factual comparisons, never with Stalwart
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m5s
The last bullet forbade comparison of any kind. Public material may now
compare inbuxa with the hosted suites organizations choose between and
with other self-hosted mail stacks, when the comparison is factual,
dated, names no price and says when the other choice is better.
Stalwart is still never compared: no editions, no pricing, no
commentary on Stalwart Labs or other forks.

The lineage is now told in the past tense ("started as a fork of
Stalwart"), once, with the clean-room provenance on one documentation
page that everything else links to.
2026-10-05 16:26:01 -07:00
jcoffey-dev a24ed3b60a Deliverability check: each node asks what the internet sees of it
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m6s
Deliverability spec (inbuxa-drafts specs/deliverability.md), the server
side. Every node that sends mail checks itself once a day, at its own
minute in the first hour (UTC), and when an administrator asks:

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

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

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

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

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

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

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

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

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

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

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

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

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

For a member through a group only (is_group_member_only):

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

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

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

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

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

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

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

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

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

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

The audit suite has a group member send once as the group (one
record, with the address, account and details) and once as themselves
(none) (specs/multi-account.md, MA-D0a, G2).
2026-10-05 14:07:25 -07:00
jcoffey-dev d7bebd454d Merge pull request 'Release 2026.10.5' (#143) from release/2026.10.5-pr into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
publish / version (push) Skipped
publish / publish-amd64 (push) Skipped
publish / publish-arm64 (push) Skipped
publish / release (push) Skipped
publish / binaries (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 41m48s
publish / github (push) Failing after 1h27m32s
publish / announce (push) Skipped
announce / announce (release) Successful in 21s
github/ci (tag) GitHub Actions
2026-10-05 05:25:14 +00:00
jcoffey-dev 282ad5fc13 Release 2026.10.5
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 5m45s
2026-10-04 22:18:55 -07:00
jcoffey-dev 133d41df36 Merge pull request 'Check TLSA lookups for false bogus verdicts too' (#142) from fix/tlsa-false-bogus into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 6m40s
2026-10-05 05:18:42 +00:00
jcoffey-dev c4a6e4d117 Check TLSA lookups for false bogus verdicts too
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
ci / github (pull_request) Successful in 6m46s
github/ci (branch) GitHub Actions
Mail to chuckmckinnon.com sat in the queue for days with "Error fetching
TLSA record: DNSSEC validation failed". Its MX, mail.usefulinsight.com,
is on Cloudflare, and behind Hetzner's resolvers
_25._tcp.mail.usefulinsight.com answers TLSA with a signed CNAME to the
zone apex, which has no TLSA record. That is the second hickory 0.26.3
bug #72 works around: it checks the denial against the name first asked
for, not the CNAME's target, and calls a valid answer bogus.

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

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

sample() now takes whether the node calculates them and leaves them out
otherwise. A unit test covers both cases.
2026-09-30 18:51:22 -07:00
jcoffey-dev 6c1eeea038 Merge pull request 'ci: retry release file uploads over HTTP/1.1' (#138) from ci/release-upload-retry into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 49m48s
2026-09-30 22:24:27 +00:00
jcoffey-dev ea9a6f0c58 ci: retry release file uploads over HTTP/1.1
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m45s
The v2026.9.30.1 binaries job lost a 50 MB upload to Gitea's release
API on each of its two runs (curl 92, HTTP/2 PROTOCOL_ERROR; the origin
logged 400 with no body), arm64 the first time and amd64 the second.
The uploads cross Cloudflare. A failed run also left the release short
of the file it had just deleted.

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

Each publish job now adds a 16 GB swap file before the build; buildx's
container has no memory limit of its own, so the linker can use it.
2026-09-30 13:37:27 -07:00
jcoffey-dev 81deaa69c4 Merge pull request 'Release 2026.9.30' (#134) from release/2026.9.30-pr into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 28m9s
2026-09-30 20:17:09 +00:00
jcoffey-dev d9754c46a6 Release 2026.9.30
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
publish / version (push) Skipped
publish / publish-amd64 (push) Skipped
publish / publish-arm64 (push) Skipped
publish / release (push) Skipped
publish / binaries (push) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 11m5s
github/ci (tag) GitHub Actions
publish / github (push) Failing after 49m30s
publish / announce (push) Skipped
2026-09-30 12:04:16 -07:00
jcoffey-dev 4481279f1c Merge pull request 'x:Metric: say which node wrote each sample' (#133) from fix/metric-node-id into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 45m8s
Reviewed-on: #133
2026-09-30 18:56:20 +00:00
jcoffey-dev 20abf69d31 x:Metric: say which node wrote each sample
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m5s
Each node stores histograms as running totals since it started. A sample
didn't say which node wrote it (the node was only in the id's low bits),
so a reader couldn't diff totals per node, and the console diffed across
nodes: on the three-node production cluster the delivery attempt time
read 14.7 s over the last hour against 0.7 s from the nodes' own figures.

x:Metric/get now returns nodeId alongside timestamp, both from the id.
The telemetry suite checks every sample carries it.
2026-09-30 11:43:13 -07:00
jcoffey-dev 69ef48239a Merge pull request 'ci: copy each release image to GHCR as a replica' (#132) from ci/ghcr-replica into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 44m28s
2026-09-30 16:34:53 +00:00
jcoffey-dev 00f00d6d75 ci: copy each release image to GHCR as a replica
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m27s
The Gitea registry stays authoritative; GHCR becomes a copy of it, the way
the GitHub repository is a copy of the Gitea one. After the tag build has
pushed the release image to the registry, a new ghcr job copies it to
ghcr.io under the same version tag and :latest with `imagetools create` --
a copy, not a rebuild, so the digest on GHCR is the digest on the registry.

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

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

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

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

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

Removed: cleanup.yml and publish.yml (GHCR), release.yml (a second weekly
schedule), and dependabot.yml, whose pull request branches every mirror
sync would delete.
2026-09-29 23:06:52 -07:00
jcoffey-dev 29d3a5f779 Merge pull request 'Release 2026.9.29.2' (#125) from release/2026.9.29.2-pr into main
ci / fork-checks (push) Successful in 48s
publish / version (push) Successful in 58s
ci / build (push) Successful in 29m10s
publish / publish-amd64 (push) Successful in 32m14s
publish / release (push) Successful in 9s
publish / publish-arm64 (push) Successful in 40m42s
publish / binaries (push) Successful in 51s
publish / announce (push) Successful in 22s
2026-09-29 17:27:07 +00:00
jcoffey-dev f1f112fc38 Release 2026.9.29.2
ci / fork-checks (pull_request) Successful in 52s
ci / build (pull_request) Successful in 16m40s
2026-09-29 10:10:08 -07:00
jcoffey-dev 96be849976 Merge pull request 'Document why the client registration override is setup-only' (#124) from fix/client-override-recovery-only into main
ci / fork-checks (push) Successful in 34s
ci / build (push) Canceled after 22m54s
2026-09-29 17:04:07 +00:00
jcoffey-dev a5c8927dbc Merge pull request 'Take a token, never a password, outside DAV' (#122) from feature/http-basic-dav-only into main
ci / fork-checks (push) Canceled after 7s
ci / build (push) Canceled after 7s
2026-09-29 17:04:01 +00:00
jcoffey-dev ad09eeeefb Contract and end-to-end check for the client registration override
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Successful in 4m38s
Documents under C-5 why oAuthClientOverride counts only in bootstrap
and recovery mode, and adds tests/e2e/client_override.py: the
recovery administrator keeps the override in both modes; after setup,
an administrator gets no code for an unregistered client or a
redirect URI its client didn't register, and a device code approved
for an unregistered client can't be exchanged. The script fails
against a build without the change (3 of 8) and passes with it.
2026-09-29 09:46:31 -07:00
jcoffey-dev 7e06a3b1f6 Merge pull request 'Release 2026.9.29.1' (#123) from release/2026.9.29.1-pr into main
ci / fork-checks (push) Successful in 48s
publish / version (push) Successful in 11s
ci / build (push) Successful in 30m5s
publish / publish-amd64 (push) Successful in 32m52s
publish / release (push) Successful in 10s
publish / publish-arm64 (push) Successful in 43m6s
publish / binaries (push) Successful in 36s
publish / announce (push) Successful in 10s
2026-09-29 15:40:42 +00:00
jcoffey-dev e147206e82 Release 2026.9.29.1
ci / fork-checks (pull_request) Successful in 50s
ci / build (pull_request) Successful in 13m48s
2026-09-29 08:25:13 -07:00
jcoffey-dev ca6484c356 Honor the client registration override only in setup and recovery
The recovery administrator signs in before any OAuth client is
registered, so it needs to skip the registration check. Outside
bootstrap and recovery mode, every account now signs in through a
registered client and one of its redirect URIs.
2026-09-29 08:25:13 -07:00
jcoffey-dev faf3d1e056 Take a token, never a password, outside DAV
ci / fork-checks (pull_request) Successful in 17s
ci / build (pull_request) Successful in 7m41s
Anyone could host a copy of a front end on a server of their own,
collect a person's password there, and replay it as HTTP Basic against
JMAP or the API. Cross-origin rules don't stop that, since a server
isn't a browser, and neither does client registration, since Basic
never goes through OAuth (contract C-23).

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

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

Tested: unit tests for the paths, and tests/e2e/http_basic_auth.py
against the debug build, 26 checks, including both front ends' sign-in
path and a refused unregistered redirect.
2026-09-29 07:02:05 -07:00
390 changed files with 7915 additions and 1447 deletions

No files matched your search

+44 -1
View File
@@ -7,7 +7,12 @@
# instance resolves short `uses:` against itself, never GitHub, so nothing
# unreviewed can be pulled in.
#
# Not ported, as on GitLab: publish.yml and release.yml still need doing.
# BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo),
# fork-checks and build skip here and the `github` job below waits for the
# same work done by .github/workflows/ci.yml on the GitHub mirror, passing or
# failing with it -- so this run still carries the answer pull requests and
# merges look at. Unset, everything builds here as before. If GitHub is
# unavailable, unset BUILD_ON and nothing else has to change.
name: ci
on:
@@ -25,6 +30,7 @@ jobs:
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
# check diffs against the upstream snapshot branch, hence the full fetch.
fork-checks:
if: ${{ vars.BUILD_ON != 'github' }}
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
@@ -52,6 +58,7 @@ jobs:
run: python3 -m unittest discover -s tools/fork/tests
build:
if: ${{ vars.BUILD_ON != 'github' }}
# Either runner (host1 or host2): the build needs no docker socket.
runs-on: light
container:
@@ -101,3 +108,39 @@ jobs:
used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1)
echo "target dir: ${used:-0} GB"
if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi
# BUILD_ON=github: the GitHub mirror builds this commit and posts the result
# back as the commit status "github/ci (branch)". This waits for that status
# and takes its answer. The mirror pushes on every commit, so a missing
# status means GitHub has not got the push or is not running: after the
# timeout this fails, which is the cue to unset BUILD_ON.
github:
if: ${{ vars.BUILD_ON == 'github' }}
# Its own runner label with plenty of slots: this job only polls, but holds a slot
# for as long as the GitHub build takes, and must not starve the build runners.
runs-on: wait
timeout-minutes: 150
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
steps:
- env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
CONTEXT: github/ci (branch)
run: |
python3 - <<'EOF'
import json, os, time, urllib.request
url = (f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}"
f"/commits/{os.environ['SHA']}/statuses?limit=50")
req = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['TOKEN']}"})
ctx, last = os.environ["CONTEXT"], None
print(f"waiting for '{ctx}' on {os.environ['SHA']}", flush=True)
while True:
mine = [s for s in json.load(urllib.request.urlopen(req)) if s["context"] == ctx]
state = max(mine, key=lambda s: s["id"]) if mine else None
if state and state["status"] != last:
last = state["status"]; print(f"{ctx}: {last} {state.get('target_url', '')}", flush=True)
if last == "success": raise SystemExit(0)
if last in ("failure", "error"): raise SystemExit(1)
time.sleep(20)
EOF
+54 -1
View File
@@ -42,6 +42,14 @@
#
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
# own token is refused by the container registry.
#
# BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo), every
# job here but the announcement skips, and the tag is published by
# .github/workflows/ci.yml on the GitHub mirror instead -- same guards, same
# tags, the same Release and binaries, created here through the API. The
# `github` job waits for that run's commit status, "github/ci (tag)", and the
# announcement follows it as it follows the binaries here. Unset, everything
# runs here as before.
name: publish
on:
@@ -50,6 +58,7 @@ on:
jobs:
version:
if: ${{ vars.BUILD_ON != 'github' }}
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
@@ -88,6 +97,7 @@ jobs:
echo "version $V"
publish-amd64:
if: ${{ vars.BUILD_ON != 'github' }}
needs: [version]
runs-on: docker
container:
@@ -128,6 +138,7 @@ jobs:
run: docker logout "$REGISTRY" || true
publish-arm64:
if: ${{ vars.BUILD_ON != 'github' }}
needs: [version, publish-amd64]
runs-on: docker
container:
@@ -162,11 +173,46 @@ jobs:
- if: always()
run: docker logout "$REGISTRY" || true
# BUILD_ON=github: waits for the GitHub mirror's run for this tag, which
# posts its result back as the commit status "github/ci (tag)", and takes
# its answer. Fails after the timeout if no answer comes.
github:
if: ${{ vars.BUILD_ON == 'github' }}
# Its own runner label with plenty of slots: this job only polls, but holds a slot
# for as long as the GitHub build takes, and must not starve the build runners.
runs-on: wait
timeout-minutes: 240
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
steps:
- env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ github.sha }}
CONTEXT: github/ci (tag)
run: |
python3 - <<'EOF'
import json, os, time, urllib.request
url = (f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}"
f"/commits/{os.environ['SHA']}/statuses?limit=50")
req = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['TOKEN']}"})
ctx, last = os.environ["CONTEXT"], None
print(f"waiting for '{ctx}' on {os.environ['SHA']}", flush=True)
while True:
mine = [s for s in json.load(urllib.request.urlopen(req)) if s["context"] == ctx]
state = max(mine, key=lambda s: s["id"]) if mine else None
if state and state["status"] != last:
last = state["status"]; print(f"{ctx}: {last} {state.get('target_url', '')}", flush=True)
if last == "success": raise SystemExit(0)
if last in ("failure", "error"): raise SystemExit(1)
time.sleep(20)
EOF
# The weekly release creates its Release (and so the tag) first; a tag
# pushed by hand has none. Either way the tag ends up with exactly one
# Release, created once the amd64 image exists so its pull instructions
# work; arm64 and the binaries follow.
release:
if: ${{ vars.BUILD_ON != 'github' }}
needs: [version, publish-amd64]
runs-on: light
container:
@@ -216,6 +262,7 @@ jobs:
# `docker create` does not start anything, so pulling an arm64 image on an
# amd64 runner and copying a file out of it needs no emulation.
binaries:
if: ${{ vars.BUILD_ON != 'github' }}
needs: [version, publish-arm64, release]
runs-on: docker
container:
@@ -289,8 +336,14 @@ jobs:
# The release above is made with the job's own token, and Gitea starts no
# workflow for events the Actions bot causes -- announce.yml's
# 'on: release' never fires for it -- so announce it from here.
#
# With BUILD_ON=github the release and binaries come from the GitHub run,
# so the announcement waits for the `github` job instead. The Release that
# run creates for a hand-pushed tag is made with a user token, so
# announce.yml fires for it too; discourse-release keeps one topic per tag.
announce:
needs: [release, binaries]
needs: [release, binaries, github]
if: ${{ always() && ((needs.release.result == 'success' && needs.binaries.result == 'success') || needs.github.result == 'success') }}
runs-on: light
steps:
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
-5
View File
@@ -5,11 +5,6 @@
version: 2
updates:
- package-ecosystem: "cargo" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
# Enable version updates for GitHub Actions
- package-ecosystem: "github-actions"
# Workflow files stored in the default location of `.github/workflows`
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Close issues from non-allowed authors
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
// Users allowed to open issues directly. All other authors will have
@@ -18,7 +18,7 @@ jobs:
sparse-checkout-cone-mode: false
- name: Close PRs from non-allowed authors
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Post support portal redirect
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const discussion = context.payload.discussion;
+1 -1
View File
@@ -73,6 +73,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/[email protected]7.4
uses: github/codeql-action/[email protected]8.2
with:
sarif_file: results.sarif
+1 -1
View File
@@ -36,6 +36,6 @@ jobs:
severity: 'CRITICAL,HIGH'
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/[email protected]7.4
uses: github/codeql-action/[email protected]8.2
with:
sarif_file: 'trivy-results.sarif'
-42
View File
@@ -1,42 +0,0 @@
version: 2
updates:
# Cargo. One entry: the workspace has a single lockfile at the root, and
# ~30 manifests that upstream bumps on every release -- pointing entries at
# individual crates would find manifests with no lockfile beside them.
#
# Minor and patch arrive as one pull request a week. Majors are left out of
# the group on purpose: they are migrations rather than bumps, and each one
# deserves its own pull request and its own CI run.
- package-ecosystem: cargo
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
open-pull-requests-limit: 5
groups:
minor-and-patch:
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
groups:
actions:
patterns:
- "*"
# The Dockerfiles pin their base images, so this is what keeps a published
# image off a stale base between releases.
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
+469 -38
View File
@@ -1,51 +1,482 @@
# What CI can check without a mail server's worth of infrastructure.
# CI and publishing on GitHub, for the repository Gitea mirrors here.
#
# The build, and that every test target compiles. It deliberately does not
# *run* the test suites: the unit tests only build with the integration crate
# in the graph, because that is what switches on the `test_mode` features they
# rely on (docs/spec/SPEC.md 2.2b), and the integration suites need a `STORE`,
# fixed ports, and in most cases a container apiece (docs/spec/
# container-tests.md). Running them here would mean either a green tick that
# skipped everything, or a red one that means "the runner has no Redis".
# Gitea (git.coffeylabs.org) is where this project lives: pull requests,
# issues, releases and the container registry are all there, and it pushes
# every branch and tag to this GitHub copy as it changes. GitHub's hosted
# runners are faster than the self-hosted ones -- and have native arm64 -- so
# the building happens here, and the answer goes back to Gitea as a commit
# status that Gitea's own ci.yml / publish.yml wait on.
#
# So this catches what it can honestly catch -- code that does not compile,
# including test code -- and the suites are run by hand, one at a time, as
# that page describes. If that changes, it changes because someone made the
# suites runnable unattended, not because CI started ignoring failures.
name: CI
# One switch decides which side builds: the Actions variable BUILD_ON, set on
# both forges. BUILD_ON=github runs every job below and turns Gitea's heavy
# jobs into a wait for this one; anything else leaves Gitea building exactly
# as before and every job here skips. If GitHub is ever unavailable, unset it
# on Gitea and nothing else has to change.
#
# Needs, as organization settings rather than anything in this file:
# variables BUILD_ON=github, REGISTRY (the Gitea container registry),
# GITEA_URL (the Gitea base URL)
# secret GITEA_TOKEN -- jcoffey-dev, write:repository + write:package:
# commit statuses, the release and its assets, the registry push
#
# There is no pull_request trigger: pull requests happen on Gitea, and their
# branch arrives here as an ordinary push. Branch pushes get what Gitea's
# ci.yml checks; v* tags get what its publish.yml does. Schedules (the weekly
# release, the upstream watch) and the release announcement stay on Gitea.
#
# Every `uses:` is pinned to a full commit SHA with the release in the
# trailing comment. A tag is a mutable pointer; do not "simplify" a pin back
# to one. Only GitHub's own actions and the three docker/* ones are used.
name: ci
on:
push:
branches: [main]
pull_request:
# Lets CI be run by hand against any ref, including one that predates a CI
# change, without pushing an empty commit to move it.
branches: ['**']
tags: ['**']
workflow_dispatch:
# A second push to a branch cancels the run still going for the first: the
# older run's answer is about code nobody is looking at any more.
# A newer push to a branch cancels the run for the older one, whose answer is
# about code nobody is looking at any more. A tag run is never cancelled: it
# publishes.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.ref_type == 'branch' }}
permissions:
contents: read
env:
GITEA_URL: ${{ vars.GITEA_URL }}
# The Gitea status this run answers for. Gitea waits on the one matching
# its own event: "(branch)" from ci.yml, "(tag)" from publish.yml.
STATUS_CONTEXT: github/ci (${{ github.ref_type }})
jobs:
build:
# Tells Gitea a run has started, so a pull request shows it as pending
# rather than missing while the build is still going.
start:
if: ${{ vars.BUILD_ON == 'github' }}
runs-on: ubuntu-latest
steps:
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
jq -n --arg c "$STATUS_CONTEXT" \
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}' |
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
-H 'Content-Type: application/json' --data @- \
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
# ----------------------------------------------------------- branches ------
# What an upstream merge can bring in or leave behind without a conflict:
# the upstream name in a new string literal, and a changed upstream file
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
# check diffs against the upstream snapshot in the history, hence the full
# fetch.
fork-checks:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }}
runs-on: ubuntu-latest
steps:
# Every `uses:` here is pinned to a full commit SHA, with the release it
# belongs to in the trailing comment. A tag is a mutable pointer, so
# trusting `@v7` is trusting every future version of that action,
# including one pushed by whoever compromises the account. Dependabot
# updates both halves together -- do not "simplify" a pin back to a tag.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: System dependencies
# foundationdb and the search backends are off by default, but the
# default feature set still links against the system's C libraries.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang
- name: Build the server
run: cargo build -p inbuxa --locked
- name: Compile every test target
# `--no-run` is the point: it builds the unit tests and the integration
# crate together, which is the combination that resolves the test
# features, and stops short of running anything that wants a store.
run: cargo test --workspace --locked --no-run
with:
fetch-depth: 0
- run: python3 tools/fork/name-check.py
- if: always()
run: python3 tools/fork/notice-check.py
# Cargo can patch a dependency to a directory in this repository, and
# the image builds from a context .dockerignore prunes to almost
# nothing. CI never sees the difference; a release does.
- if: always()
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
# The build, and that every test target compiles. The suites are not run:
# they need a store, fixed ports and containers (docs/spec/
# container-tests.md), and are run by hand.
build:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }}
runs-on: ubuntu-latest
env:
CARGO_INCREMENTAL: "0"
# Debug info is most of a dev target dir, and nothing here runs a
# debugger. Without it the dev and test builds fit the runner's disk and
# the cache below stays small enough to be worth restoring.
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
steps:
# The hosted image carries toolchains this build never touches; a dev,
# test and release build of RocksDB and the workspace needs the room.
- run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
df -h /
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Current stable, as Gitea's rust:1 image is.
- id: rust
run: |
rustup toolchain install stable --profile minimal
rustup default stable
echo "version=$(rustc -V | cut -d' ' -f2)" >> "$GITHUB_OUTPUT"
- run: sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends clang >/dev/null
# Cargo's download cache and the dev/test target dir, keyed on the
# lockfile and the compiler. Saved from main only, so the one cache
# every branch restores is main's, and branches cannot evict it.
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target/debug
key: cargo-${{ steps.rust.outputs.version }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-${{ steps.rust.outputs.version }}-
- run: cargo build -p inbuxa --locked
# --no-run: compiles every test target without running them, which
# catches a test that no longer builds without needing a store.
- run: cargo test --workspace --locked --no-run
- if: github.ref == 'refs/heads/main'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
target/debug
key: cargo-${{ steps.rust.outputs.version }}-${{ hashFiles('Cargo.lock') }}
# The release profile, on main only. It is the profile the image is
# built with, and it fails in ways the dev profile does not: v2026.9.24
# was tagged on a commit whose CI was green and whose release build
# could not compile the scim crate at all.
- if: github.ref == 'refs/heads/main'
run: cargo build -p inbuxa --locked --release
# --------------------------------------------------------------- tags ------
# Two guards before anything is pushed, the same as Gitea's publish.yml:
# * the tag must be v<brand_version!>. The version is a string in
# crates/types/src/branding.rs, not Cargo.toml, and the image is tagged
# with it, so a tag beside an unbumped macro would publish an image that
# reports a different version from its tag.
# * the tag must be on main or on a release/* branch, so an image never
# describes code that was never reviewed onto one of them. A release/*
# branch carries a hotfix cut from an earlier release tag.
version:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }}
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
steps:
# Full history, and every branch as origin/*: the ancestry check cannot
# be answered from a shallow clone.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- id: v
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Scoped to the macro body: branding.rs holds other string literals,
# and tagging an image from one of those would be worse than failing.
V="$(awk '/macro_rules! brand_version /,/^}/' crates/types/src/branding.rs \
| grep -om1 '"[0-9][^"]*"' | tr -d '"')"
[ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; }
if [ "$TAG" != "v$V" ]; then
echo "Tag $TAG names a commit whose brand_version! says $V." >&2
echo "Refusing to publish an image that would report the wrong version." >&2
exit 1
fi
commit="$(git rev-parse "${TAG}^{commit}")"
on=""
for ref in origin/main $(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/*'); do
if git merge-base --is-ancestor "$commit" "$ref"; then on="$ref"; break; fi
done
[ -n "$on" ] || { echo "$TAG is not on main or a release/* branch" >&2; exit 1; }
echo "$TAG is on $on"
echo "version=$V" >> "$GITHUB_OUTPUT"
# Each architecture on its own native runner, side by side. The Dockerfile
# cross-compiles from the build platform, and on the self-hosted runners one
# machine built both one after the other; here two machines build at once,
# each natively (the builder stage picks the matching target, and the
# aarch64 toolchain it installs exists on arm64 too), and the small final
# stage needs no QEMU. amd64 also moves :<version> as soon as it is done, so
# a production deploy can start from it; :latest waits for the index below,
# so it never names an image without arm64.
publish:
needs: [version]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
- run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
# The release link (fat LTO, one codegen unit) outgrows the runner's
# 16 GB: v2026.9.30's arm64 link was killed for memory. Swap gives it
# room; buildx's container has no memory limit of its own, so it
# reaches the host's swap.
- run: |
sudo fallocate -l 16G /swap.release
sudo chmod 600 /swap.release
sudo mkswap /swap.release >/dev/null
sudo swapon /swap.release
free -g
df -h /
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ vars.REGISTRY }}
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
# Attestations off: they add manifests of their own, and the index
# should hold the two images and nothing else. No build cache: GitHub
# scopes a tag run's cache to that tag, so the next release could never
# read it, and each one would park several GB in the repository's 10 GB
# cache and evict main's cargo cache.
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/${{ matrix.arch }}
provenance: false
sbom: false
push: true
tags: |
${{ env.IMAGE }}:${{ env.VERSION }}-${{ matrix.arch }}
${{ matrix.arch == 'amd64' && format('{0}:{1}', env.IMAGE, env.VERSION) || '' }}
# Joins the two per-architecture tags into :<version> and :latest. Built
# from the per-architecture tags rather than :<version>, which by now is
# the amd64 image and would be read as such.
index:
needs: [version, publish]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
- run: echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ vars.REGISTRY }}
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
- run: |
docker buildx imagetools create \
--tag "$IMAGE:$VERSION" \
--tag "$IMAGE:latest" \
"$IMAGE:$VERSION-amd64" "$IMAGE:$VERSION-arm64"
docker buildx imagetools inspect "$IMAGE:$VERSION"
# Gitea keeps a container package on its owner; linking it shows it on
# the repository's Packages tab. Idempotent.
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
owner="${GITHUB_REPOSITORY%%/*}"; name="${GITHUB_REPOSITORY#*/}"
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
"$GITEA_URL/api/v1/packages/${owner,,}/container/$name/-/link/$name" \
|| echo "package already linked (or link refused); not fatal"
# The weekly release creates its Release (and so the tag) on Gitea first; a
# tag pushed by hand has none. Either way the tag ends up with exactly one
# Release there, created once the image exists so its pull instructions
# work.
release:
needs: [version, index]
runs-on: ubuntu-latest
steps:
- env:
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.version.outputs.version }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY: ${{ vars.REGISTRY }}
run: |
set -euo pipefail
api="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY"
code="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$api/releases/tags/$TAG")"
if [ "$code" = 200 ]; then echo "$TAG already has a release"; exit 0; fi
[ "$code" = 404 ] || { echo "looking up the release for $TAG answered $code" >&2; exit 1; }
image="$REGISTRY/${GITHUB_REPOSITORY,,}:$VERSION"
body="Container image: \`$image\` (linux/amd64, linux/arm64); also \`:latest\`.
Binaries for a host install are attached: \`inbuxa-linux-amd64.tar.gz\` and \`inbuxa-linux-arm64.tar.gz\`, with \`SHA256SUMS\`. Each is the binary out of this release's image for that architecture, so it is the same build. The image grants it \`cap_net_bind_service\`; a host install has to grant that itself (\`setcap\`, or \`AmbientCapabilities\` in the unit) to bind port 25."
jq -n --arg tag "$TAG" --arg name "INBUXA $VERSION" --arg body "$body" \
'{tag_name:$tag, name:$name, body:$body}' |
curl -fsS -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \
--data @- "$api/releases" | jq -r '"created release " + .tag_name'
# The binaries for a host install, taken out of the image that was just
# pushed rather than compiled again: the binary in the tarball is the file
# the image runs. `docker create` starts nothing, so copying a file out of
# the arm64 image on an amd64 runner needs no emulation.
binaries:
needs: [version, index, release]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.version.outputs.version }}
TAG: ${{ github.ref_name }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
steps:
- run: echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ vars.REGISTRY }}
username: jcoffey-dev
password: ${{ secrets.GITEA_TOKEN }}
- name: take the binaries out of the image
run: |
set -euo pipefail
mkdir -p out && cd out
for arch in amd64 arm64; do
docker pull -q --platform "linux/$arch" "$IMAGE:$VERSION"
id="$(docker create --platform "linux/$arch" "$IMAGE:$VERSION")"
docker cp "$id:/usr/local/bin/inbuxa" inbuxa
docker rm -f "$id" >/dev/null
chmod 0755 inbuxa
tar -czf "inbuxa-linux-$arch.tar.gz" inbuxa
rm inbuxa
done
sha256sum inbuxa-linux-*.tar.gz > SHA256SUMS
cat SHA256SUMS
# A re-run of a tag replaces its assets rather than leaving two files
# with the same name and different contents.
#
# The uploads cross Cloudflare, which dropped 50 MB HTTP/2 uploads
# part-way for v2026.9.30.1 (curl 92, PROTOCOL_ERROR; origin logged
# 400), once on each of two runs. Uploads go over HTTP/1.1 and retry.
- name: attach them to the release
run: |
set -euo pipefail
api="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY"
auth="Authorization: token $GITEA_TOKEN"
retry=(--retry 5 --retry-all-errors --retry-delay 15)
rel="$(curl -fsS "${retry[@]}" -H "$auth" "$api/releases/tags/$TAG" | jq -r .id)"
assets="$(curl -fsS "${retry[@]}" -H "$auth" "$api/releases/$rel/assets")"
for f in out/inbuxa-linux-amd64.tar.gz out/inbuxa-linux-arm64.tar.gz out/SHA256SUMS; do
name="$(basename "$f")"
old="$(jq -r --arg n "$name" '.[] | select(.name == $n) | .id' <<<"$assets")"
for id in $old; do curl -fsS "${retry[@]}" -o /dev/null -X DELETE -H "$auth" "$api/releases/$rel/assets/$id"; done
curl -fsS --http1.1 "${retry[@]}" -o /dev/null -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$rel/assets?name=$name"
echo "attached $name"
done
# ------------------------------------------------------ ghcr replica ------
# Copies the release image from the Gitea registry, which stays the
# authoritative one, to ghcr.io under the same version tag and :latest. It is
# a copy, not a second build: the digest on GHCR is the digest on the
# registry, so `docker pull ghcr.io/...` gets exactly the same image. Left
# out of the report to Gitea, like the release copy, so a GHCR problem
# cannot fail a release.
ghcr:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
needs: [version, index]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
src="${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}"
dst="ghcr.io/${GITHUB_REPOSITORY,,}"
tag="$TAG"
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
docker buildx imagetools create -t "$dst:$tag" -t "$dst:latest" "$src:$tag"
want="$(docker buildx imagetools inspect "$src:$tag" --format '{{json .Manifest.Digest}}')"
got="$(docker buildx imagetools inspect "$dst:$tag" --format '{{json .Manifest.Digest}}')"
echo "registry $src:$tag = $want"
echo "ghcr $dst:$tag = $got"
[ "$want" = "$got" ] || echo "::warning::GHCR digest differs from the registry's"
docker logout ghcr.io
# ---------------------------------------------------- github release ------
# Copies this tag's Gitea release -- notes and files -- to a GitHub release,
# so the replica's Releases page, and anyone watching it, keeps up. Gitea's
# release is the real one; this is left out of the report to Gitea, so a
# failure here cannot fail a release. PR and issue numbers in the notes are
# rewritten to Gitea links: on GitHub a bare #16 is some other PR.
github-release:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
needs: [binaries]
runs-on: ubuntu-latest
permissions:
contents: write
env:
GITEA_URL: ${{ vars.GITEA_URL }}
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
steps:
- run: |
set -euo pipefail
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "GitHub already has a release for $TAG"; exit 0
fi
# The Gitea release exists by now if this run made it; if the weekly
# release job made it, it came before the tag. Allow a few minutes.
code=0
for _ in $(seq 1 15); do
code="$(curl -sS -o rel.json -w '%{http_code}' "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")"
[ "$code" = 200 ] && break
sleep 20
done
if [ "$code" != 200 ]; then echo "No Gitea release for $TAG; nothing to copy"; exit 0; fi
if [ "$(jq -r .draft rel.json)" = true ]; then echo "The Gitea release is a draft; not copying"; exit 0; fi
export BASE="$(jq -r '.html_url | sub("/releases/tag/.*$"; "")' rel.json)"
jq -r '.body // ""' rel.json | perl -pe 's{(?<![\w/&\[])#(\d+)\b}{[#$1]($ENV{BASE}/pulls/$1)}g' > notes.md
printf '\n\n_Mirrored from [the Gitea release](%s); report issues on [Gitea](%s/issues)._\n' \
"$(jq -r .html_url rel.json)" "$BASE" >> notes.md
files=()
mkdir -p files
while IFS=$'\t' read -r name url; do
curl -fsSL -o "files/$name" "$url"; files+=("files/$name")
done < <(jq -r '.assets[]? | [.name, .browser_download_url] | @tsv' rel.json)
title="$(jq -r '.name // ""' rel.json)"; [ -n "$title" ] || title="$TAG"
if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; else kind=--latest; fi
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$title" \
--notes-file notes.md "$kind" "${files[@]}"
echo "created the GitHub release for $TAG with ${#files[@]} file(s)"
# ------------------------------------------------------------- report ------
# One commit status on Gitea for the whole run: what Gitea's ci.yml and
# publish.yml wait on. Skipped jobs (the tag jobs on a branch, and the other
# way round) count as passing; a failed or cancelled one does not.
report:
if: ${{ always() && vars.BUILD_ON == 'github' }}
needs: [start, fork-checks, build, version, publish, index, release, binaries]
runs-on: ubuntu-latest
steps:
- env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
STATE: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }}
run: |
# A cancelled run was superseded by a newer run for the same commit (the
# mirror can push one commit twice); that run reports. Posting "failure"
# here would fail the Gitea check while the real build is still going.
if [ "$STATE" = cancelled ]; then echo "cancelled: leaving the result to the newer run"; exit 0; fi
jq -n --arg s "$STATE" --arg c "$STATUS_CONTEXT" \
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}' |
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
-H 'Content-Type: application/json' --data @- \
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
echo "$STATUS_CONTEXT: $STATE"
-69
View File
@@ -1,69 +0,0 @@
# Prune old image versions from GHCR.
#
# Releases are kept forever -- they carry no assets and their generated notes
# are this project's only changelog, so deleting one destroys history that
# cannot be reconstructed for nothing saved. Images are the opposite: a
# multi-arch build a week, and the by-digest push in publish.yml leaves two
# untagged per-architecture manifests behind each time on top of the tagged
# index. Those accumulate and nobody wants fifty of them.
#
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
# `delete-only-untagged-versions` -- will happily delete the per-architecture
# manifests that a multi-arch tag points *at*, because they are untagged by
# design. Nothing appears to break: the tag still exists, and pulls simply
# start failing for one architecture. This action understands manifest lists
# and will not orphan a retained index, and `validate` re-checks every
# multi-arch manifest against the registry afterwards.
#
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
# exactly what would be deleted without rebuilding and re-pushing an image to
# find out.
name: Prune images
on:
workflow_call:
inputs:
dry_run:
type: boolean
default: false
workflow_dispatch:
inputs:
dry_run:
description: "List what would be deleted, delete nothing"
type: boolean
default: true
jobs:
prune:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
# The only third-party action here that is not published by GitHub or
# Docker, and the one with the most to lose: it is handed
# `packages: write` and its whole job is deletion, so a ref repointed at
# something else -- by a compromise or a mistake upstream -- is a bad
# day. It was pinned to a commit long before the rest of them were.
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
with:
owner: inbuxa
package: inbuxa-server
token: ${{ secrets.GITHUB_TOKEN }}
# Ten weekly releases is roughly a quarter of history, which is more
# than enough to roll back to and far less than the year's worth that
# would otherwise pile up. Older *releases* stay either way; this
# only removes the images.
keep-n-tagged: 10
# Belt and braces on top of the action's own manifest awareness:
# `latest` is never a candidate for deletion under any counting.
exclude-tags: latest
delete-untagged: true
# Sweeps the wreckage of a half-failed run: an index whose platform
# images did not all land, and referrers whose parent is gone.
delete-partial-images: true
delete-orphaned-images: true
# Checks every remaining multi-architecture manifest still resolves
# in the registry. This is the step that would catch the footgun
# above rather than leaving a reader to discover it on `docker pull`.
validate: true
dry-run: ${{ inputs.dry_run }}
-198
View File
@@ -1,198 +0,0 @@
# Publish the container image to GHCR.
#
# The README and the docs site have told people to run
# `ghcr.io/inbuxa/inbuxa-server:latest` for a long time, and nothing ever
# pushed it: `docker pull` answered `denied`, because the package did not
# exist. This is the workflow that makes those instructions true. It is also
# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid
# both install by pulling an image and neither builds from source.
#
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
# a public repository, and an anonymous `docker pull` will still answer
# `denied`. Nothing in a workflow can change that -- the visibility is set once
# by hand under the package's settings, and until it is, this looks like it
# worked while the docs stay just as wrong as before. Check with a logged-out
# pull, not with one from a machine that has credentials.
#
# Two architectures, each built on its own native runner rather than under
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
# instruction translation, which takes tens of minutes and occasionally runs
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
# the same work at native speed. The cost is the by-digest dance below: each
# runner pushes an untagged image, and a final job joins the two digests into
# one multi-arch tag.
name: Publish image
on:
release:
types: [published]
# Callable, so release.yml can build the release it just cut. This is not a
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
# `release` event -- GitHub refuses to let a token trigger another workflow,
# to stop a workflow looping on its own output. A scheduled job that cut a
# release and expected this file to notice would silently never publish. The
# alternatives are a personal access token kept as a secret, or calling the
# workflow directly. This is the one that needs no credential.
workflow_call:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
type: string
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
# orphans can be neither rerun nor canceled, and this workflow otherwise
# only fires on a release -- which is not something to cut twice because a
# runner died. `ref` also allows publishing an image for a tag that predates
# this workflow, which is how the first one gets built.
workflow_dispatch:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
default: main
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
env:
# Hardcoded rather than derived from github.repository, which would have to
# be lowercased to be a legal registry path. This is the string the docs name.
IMAGE: ghcr.io/inbuxa/inbuxa-server
jobs:
# The version is read once and handed to both builds, so the two
# architectures cannot disagree about what they are. It is read from the
# macro the binary itself compiles in, which the weekly release commits
# before this runs -- so the image is tagged with the version it reports.
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
- id: v
run: |
set -euo pipefail
# Scoped to the macro body: branding.rs holds other string literals,
# and tagging an image from one of those would be worse than failing.
V="$(awk '/macro_rules! brand_version/,/^}/' crates/types/src/branding.rs \
| grep -om1 '"[0-9][^"]*"' | tr -d '"')"
[ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; }
# A date version carries nothing a Docker tag objects to, so there is
# no second, sanitized form of it here.
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "version $V"
build:
needs: version
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
# Attestations are off deliberately: they add manifests of their own
# to the index, and `imagetools create` below expects the two entries
# it pushed rather than four.
provenance: false
sbom: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Save the digest
run: |
mkdir -p /tmp/digests
# The prefix is stripped here and put back in the merge job, so the
# filename is the bare hash. Leaving it on produces
# `image@sha256:sha256:...` when the reference is rebuilt.
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
# One artifact per platform; the merge job globs them back together.
name: digest-${{ strategy.job-index }}
path: /tmp/digests/*
retention-days: 1
if-no-files-found: error
# Joins the per-architecture digests into a single tagged manifest, so
# `docker pull ghcr.io/inbuxa/inbuxa-server:<tag>` resolves on both.
publish:
needs: [version, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create the manifest
run: |
# Arrays rather than a string: the tags and the digest references
# have to reach docker as separate arguments, and building them by
# word-splitting an unquoted variable is the version of this that
# breaks the day a value contains a space.
tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}")
# :latest follows real releases only. A prerelease that moved it
# would hand every `:latest` deployment an unfinished build, and a
# dispatch run has to ask for it on purpose.
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
tags+=(-t "${IMAGE}:latest")
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
tags+=(-t "${IMAGE}:latest")
fi
refs=()
for f in /tmp/digests/*; do
refs+=("${IMAGE}@sha256:$(basename "$f")")
done
echo "tags: ${tags[*]}"
echo "refs: ${refs[*]}"
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
- name: Show what landed
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}"
# Runs only after a successful publish, because that is the only moment the
# package grows. See cleanup.yml for why this is not the obvious one-liner.
prune:
needs: publish
permissions:
packages: write
uses: ./.github/workflows/cleanup.yml
-246
View File
@@ -1,246 +0,0 @@
# Cut a release once a week, but only if there is something in it.
#
# It does nothing on a quiet week. A release with no commits in it is worse
# than no release: it moves `:latest` to an identical build, spends a version
# number, and mails everybody watching the repository about nothing.
#
# INBUXA's version is a string in crates/types/src/branding.rs, deliberately
# not in Cargo.toml so that upstream's version bumps merge without conflicts.
# So this writes it: the bump is committed to main, and the tag names that
# commit. The tree a tag points at therefore reports the version the tag
# claims, which a tag placed beside an unbumped macro cannot promise.
name: Weekly release
on:
schedule:
# Mondays, 10:07 UTC, and last of the three: INBUXA Admin and the webmail
# release ahead of the server they talk to. Staggered rather than
# simultaneous so three releases do not compete for runners, and so a bad
# Monday names one repository instead of three. GitHub runs scheduled jobs
# best-effort and can delay a run considerably, so the exact minute is not
# a promise; the odd minute keeps it off the crowded top of the hour.
#
# Note also that GitHub disables scheduled workflows in a repository with
# no activity for 60 days, which is worth checking for before assuming
# this file is broken.
- cron: "7 10 * * 1"
workflow_dispatch:
inputs:
dry_run:
description: "Work out what would be released, then stop"
type: boolean
default: false
# One at a time. Two overlapping runs would race to write the same version and
# create the same tag, and the loser fails noisily for a reason that has
# nothing to do with the code.
concurrency:
group: weekly-release
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_release: ${{ steps.decide.outputs.should_release }}
version: ${{ steps.decide.outputs.version }}
tag: ${{ steps.decide.outputs.tag }}
previous: ${{ steps.decide.outputs.previous }}
count: ${{ steps.decide.outputs.count }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- id: decide
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# The newest published release, or empty on a repository that has
# never had one -- in which case everything counts as new. Drafts are
# excluded: an unpublished draft is not a release anybody has, so
# counting from it would hide commits that have never shipped.
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
# A tag named by a release is normally present after a full checkout,
# but a release can outlive its tag. Falling back to the whole
# history is the safe direction to be wrong in: it over-counts, which
# cuts a release that was due anyway, where under-counting would skip
# one that was.
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
count="$(git rev-list --count "${previous}..HEAD")"
else
count="$(git rev-list --count HEAD)"
fi
# INBUXA's version is the date: YYYY.M.D, unpadded, as branding.rs
# documents. A second release on one day takes a `.N` suffix,
# counting from 2, which is why this asks the tags rather than
# assuming today is free.
today="$(date -u +%Y.%-m.%-d)"
version="$today"
n=2
while git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; do
version="${today}.${n}"
n=$((n + 1))
done
should_release=true
reason=""
if [ "$count" -eq 0 ]; then
should_release=false
reason="no commits since ${previous}"
fi
{
echo "should_release=$should_release"
echo "version=$version"
echo "tag=v${version}"
echo "previous=$previous"
echo "count=$count"
} >> "$GITHUB_OUTPUT"
# Written to the run summary so a skipped week reads as a decision
# rather than as a workflow that quietly did nothing.
{
echo "### Weekly release"
echo
if [ "$should_release" = "true" ]; then
echo "Releasing **v${version}** — ${count} commit(s) since ${previous:-the beginning}."
else
echo "Nothing to release: ${reason}."
fi
} >> "$GITHUB_STEP_SUMMARY"
cut:
needs: check
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
sha: ${{ steps.land.outputs.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- id: bump
env:
VERSION: ${{ needs.check.outputs.version }}
BRANCH: release/v${{ needs.check.outputs.version }}
run: |
set -euo pipefail
# Scoped to the macro body rather than replacing the first quoted
# string in the file, and asserted to have matched exactly once.
# branding.rs holds other string literals, and a bump that silently
# edited one of those -- or none -- would ship a build whose version
# disagrees with its tag.
python3 - <<'PY'
import os, re
path = "crates/types/src/branding.rs"
src = open(path, encoding="utf-8").read()
pattern = re.compile(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")')
out, n = pattern.subn(lambda m: m.group(1) + os.environ["VERSION"] + m.group(2), src, count=1)
assert n == 1, f"brand_version! not found in {path}"
open(path, "w", encoding="utf-8").write(out)
PY
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add crates/types/src/branding.rs
git commit -m "Version ${VERSION}"
git push origin "HEAD:refs/heads/${BRANCH}"
# main is protected: it takes a pull request with a green build, and
# GITHUB_TOKEN is not among the bypass actors. So the bump lands the way
# every other change does. The alternative was to hand the release a
# credential that outranks the rule, which is a worse thing to own than
# a slower Monday.
- id: land
env:
VERSION: ${{ needs.check.outputs.version }}
BRANCH: release/v${{ needs.check.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
url="$(gh pr create --base main --head "${BRANCH}" \
--title "Version ${VERSION}" \
--body "Weekly release. Bumps \`brand_version!\` to ${VERSION} so the tag names a tree that reports the version the tag claims.")"
# The number, not the branch: the branch is deleted on merge, and a
# deleted branch no longer resolves to its pull request.
pr="${url##*/}"
echo "Opened #${pr}"
# The build is what the rule actually requires, and it is also the
# thing worth waiting for: a release cut from a tree that does not
# compile is the failure this whole arrangement exists to prevent.
# A full build of this tree is long, so the deadline is generous.
deadline=$(( SECONDS + 3600 ))
while :; do
state="$(gh pr view "${pr}" --json statusCheckRollup \
--jq '[.statusCheckRollup[]? | .conclusion // "PENDING"] | join(",")')"
case "${state}" in
*FAILURE*|*CANCELLED*|*TIMED_OUT*)
echo "::error::CI failed on ${BRANCH} (${state}); no release cut. PR #${pr} is left open."
exit 1 ;;
*SUCCESS*) break ;;
esac
if [ "${SECONDS}" -ge "${deadline}" ]; then
echo "::error::timed out waiting for CI on ${BRANCH}. PR #${pr} is left open."
exit 1
fi
sleep 30
done
gh pr merge "${pr}" --rebase --delete-branch
# A rebase merge rewrites the commit, so the sha to tag is the one
# GitHub recorded for the merge, not the tip that was pushed. It can
# take a moment to appear.
sha=""
for _ in $(seq 1 30); do
sha="$(gh pr view "${pr}" --json mergeCommit --jq '.mergeCommit.oid // ""')"
[ -n "${sha}" ] && break
sleep 5
done
if [ -z "${sha}" ]; then
echo "::error::#${pr} merged but GitHub reported no merge commit; nothing safe to tag."
exit 1
fi
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
args=(--target "${{ steps.land.outputs.sha }}"
--title "INBUXA ${{ needs.check.outputs.version }}"
--generate-notes)
# Bound the notes to what is actually new. Without a start tag the
# generator reaches back to whatever it decides is previous, which on
# a repository carrying upstream's tag shapes is not always the last
# release.
if [ -n "${{ needs.check.outputs.previous }}" ]; then
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
fi
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
# Called rather than left to the `release` trigger on purpose: see the note
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
# event, so without this the tag would exist and no image would follow it.
publish:
needs: [check, cut]
permissions:
contents: read
packages: write
uses: ./.github/workflows/publish.yml
with:
ref: ${{ needs.cut.outputs.sha }}
tag_latest: true
+27
View File
@@ -2,6 +2,33 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [0.16.25] - 2026-10-05
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
## Changed
## Fixed
- JMAP: Creating a `MaskedEmail` with `emailDomain` fails with `forbidden` for every domain when the account has addresses on more than one domain.
- Autodiscover: Requests for a response schema other than Outlook's, such as ActiveSync (`mobilesync`), are answered with the Outlook settings instead of error 601.
- IMAP:
- `LOGIN` and `AUTHENTICATE` with a wrong, expired or unknown app password or API key are answered with an untagged `NO`, so clients keep waiting for the command to complete until the connection times out.
- The failed login that exceeds the maximum number of authentication failures is answered with an untagged `NO` before the connection is closed.
- DKIM:
- A rotation moves the active key to retiring even when its successor fails to publish or propagate, so outgoing mail is sent unsigned until a retry publishes the new key. The DNS write failure is also not logged and the task reports success.
- Keys created while DNS management was manual, or before DKIM was added to the published records, are never rotated after DNS management becomes automatic. Domains already affected start rotating once a `DkimManagement` task is created for them.
- After switching DNS management from automatic to manual, a due rotation activates a new key that was never published in DNS, so signatures fail verification, and retiring the old key is retried forever.
- Spam filter:
- Messages with no text line long enough for a Pyzor digest are checked with the digest of empty input and tagged `PYZOR`.
- DNSBL answers with several return codes, such as a Spamhaus ZEN listing in both SBL and PBL, are scored for only the first code returned.
- DNSBL lookups that return "not listed" are cached for 24 hours regardless of the zone's negative TTL.
- Removing a duplicate training sample of a message reclassified on the same day clears the blob link of the sample that is kept.
- MTA: Queue quotas with an empty `match` expression are never enforced, including the global queue quota created on first start.
- RocksDB: The info log (`LOG`, `LOG.old.*`) grows without limit because log rotation and retention are left at RocksDB defaults.
- WebUI: A blob store read error at startup, such as an S3 authentication failure, stops the web interface from being downloaded.
## [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.
+1 -1
View File
@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**johnellisATlinuxDOTcom**.
**communityATcoffeylabsDOTorg**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
+1 -1
View File
@@ -54,7 +54,7 @@ Coffey Labs" line in place. New files carry:
```
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
Generated
+81 -69
View File
@@ -277,9 +277,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.48"
version = "0.4.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
dependencies = [
"compression-codecs",
"compression-core",
@@ -1292,7 +1292,7 @@ dependencies = [
[[package]]
name = "common"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"aes-gcm-siv",
"ahash",
@@ -1392,9 +1392,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.43"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
dependencies = [
"compression-core",
"flate2",
@@ -1477,7 +1477,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "coordinator"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"async-nats",
"futures",
@@ -1889,7 +1889,7 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dav"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"calcard",
"chrono",
@@ -1912,7 +1912,7 @@ dependencies = [
[[package]]
name = "dav-proto"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"calcard",
"chrono",
@@ -2125,7 +2125,7 @@ dependencies = [
[[package]]
name = "directory"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"argon2 0.6.0",
@@ -2366,7 +2366,7 @@ dependencies = [
[[package]]
name = "email"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"aes 0.9.3",
"aes-gcm 0.11.1",
@@ -2406,6 +2406,16 @@ dependencies = [
"log",
]
[[package]]
name = "encodify"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "798c447647dd23f673748f2b868ef309a01dd86aaae999182559d36f06ac82f0"
dependencies = [
"memchr",
"simdutf8",
]
[[package]]
name = "encoding_rs"
version = "0.8.42"
@@ -2474,7 +2484,7 @@ dependencies = [
[[package]]
name = "event_macro"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"quote",
"syn 3.0.6",
@@ -3002,7 +3012,7 @@ dependencies = [
[[package]]
name = "groupware"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"calcard",
@@ -3289,7 +3299,7 @@ dependencies = [
[[package]]
name = "http"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"async-stream",
"base64 0.23.1",
@@ -3385,7 +3395,7 @@ dependencies = [
[[package]]
name = "http_proto"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"common",
"compact_str",
@@ -3872,7 +3882,7 @@ checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
[[package]]
name = "imap"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"common",
@@ -3897,7 +3907,7 @@ dependencies = [
[[package]]
name = "imap_proto"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"base64 0.23.1",
@@ -3912,7 +3922,7 @@ dependencies = [
[[package]]
name = "inbuxa"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"common",
"coordinator",
@@ -3920,7 +3930,7 @@ dependencies = [
"directory",
"email",
"groupware",
"http 0.16.24",
"http 0.16.25",
"http_proto",
"imap",
"jmap",
@@ -4209,7 +4219,7 @@ dependencies = [
[[package]]
name = "jmap"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"async-stream",
"base64 0.23.1",
@@ -4258,14 +4268,14 @@ dependencies = [
[[package]]
name = "jmap-client"
version = "0.4.2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4deab22e057d24e32122f0fc6e2d667a124fdd6a0d8ef3ed4f8a89923c11084f"
checksum = "f5b5bc66252cc8e779ef1238f40ab93971d54ad00b5d7afb5c1d447a9647ed62"
dependencies = [
"ahash",
"async-stream",
"base64 0.22.1",
"chrono",
"encodify",
"futures-util",
"maybe-async",
"parking_lot",
@@ -4292,7 +4302,7 @@ dependencies = [
[[package]]
name = "jmap_proto"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"calcard",
@@ -4502,9 +4512,9 @@ dependencies = [
[[package]]
name = "lazy_static"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
dependencies = [
"spin 0.9.9",
]
@@ -4798,7 +4808,7 @@ dependencies = [
[[package]]
name = "managesieve"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"common",
"compact_str",
@@ -4933,7 +4943,7 @@ checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492"
[[package]]
name = "migration"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"common",
"email",
@@ -5183,7 +5193,7 @@ dependencies = [
[[package]]
name = "nlp"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"hashify",
@@ -5503,8 +5513,8 @@ dependencies = [
[[package]]
name = "opentelemetry"
version = "0.32.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
version = "0.33.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
dependencies = [
"futures-core",
"futures-sink",
@@ -5516,8 +5526,8 @@ dependencies = [
[[package]]
name = "opentelemetry-http"
version = "0.32.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
version = "0.33.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
dependencies = [
"async-trait",
"bytes",
@@ -5528,8 +5538,8 @@ dependencies = [
[[package]]
name = "opentelemetry-otlp"
version = "0.32.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
version = "0.33.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
dependencies = [
"http 1.5.0",
"httpdate",
@@ -5547,8 +5557,8 @@ dependencies = [
[[package]]
name = "opentelemetry-proto"
version = "0.32.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
version = "0.33.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
dependencies = [
"opentelemetry",
"opentelemetry_sdk",
@@ -5559,13 +5569,13 @@ dependencies = [
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.32.1"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
version = "0.33.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
[[package]]
name = "opentelemetry_sdk"
version = "0.32.1"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
version = "0.33.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
dependencies = [
"futures-channel",
"futures-executor",
@@ -6015,7 +6025,7 @@ dependencies = [
[[package]]
name = "pop3"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"common",
"directory",
@@ -6385,9 +6395,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.18"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"aws-lc-rs",
"bytes",
@@ -6410,9 +6420,9 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.15"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
@@ -6835,7 +6845,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "registry"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"hashify",
@@ -7392,7 +7402,7 @@ dependencies = [
[[package]]
name = "scim"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"base64 0.23.1",
@@ -7418,7 +7428,7 @@ dependencies = [
[[package]]
name = "scim-proto"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"hashify",
"serde",
@@ -7672,9 +7682,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.23.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4"
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
dependencies = [
"base64 0.23.1",
"bs58",
@@ -7693,9 +7703,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.23.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc"
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
dependencies = [
"darling 0.24.1",
"proc-macro2",
@@ -7753,7 +7763,7 @@ dependencies = [
[[package]]
name = "services"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"aes-gcm 0.11.1",
"aho-corasick",
@@ -7762,11 +7772,13 @@ dependencies = [
"common",
"dns-update",
"email",
"futures",
"groupware",
"hkdf 0.13.0",
"inbuxa-features",
"jmap-tools",
"jmap_proto",
"mail-auth",
"mail-builder 1.0.0",
"mail-parser",
"memory-stats",
@@ -8066,7 +8078,7 @@ checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "smtp"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"base64 0.23.1",
@@ -8105,9 +8117,9 @@ dependencies = [
[[package]]
name = "smtp-proto"
version = "0.2.4"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707104487221ff447b5b796b5049e5c09cf52ff9fc1c8abba4695b89ac4b0f37"
checksum = "142a5a642c6bd7ffd7e1b525ad6a6e9921ccef992dba4663974f8519209a00c1"
dependencies = [
"memchr",
"rkyv",
@@ -8157,7 +8169,7 @@ dependencies = [
[[package]]
name = "spam-filter"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"common",
"compact_str",
@@ -8277,7 +8289,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "store"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"arc-swap",
@@ -8537,7 +8549,7 @@ dependencies = [
[[package]]
name = "tests"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"aws-lc-rs",
@@ -8559,7 +8571,7 @@ dependencies = [
"form_urlencoded",
"futures",
"groupware",
"http 0.16.24",
"http 0.16.25",
"http_proto",
"hyper",
"hyper-util",
@@ -8816,9 +8828,9 @@ dependencies = [
[[package]]
name = "tokio-rustls"
version = "0.26.5"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
@@ -9152,7 +9164,7 @@ dependencies = [
[[package]]
name = "trc"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"base64 0.23.1",
@@ -9261,7 +9273,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "types"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"blake3",
"compact_str",
@@ -9430,7 +9442,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utils"
version = "0.16.24"
version = "0.16.25"
dependencies = [
"ahash",
"arcstr",
@@ -10115,9 +10127,9 @@ dependencies = [
[[package]]
name = "xxhash-rust"
version = "0.8.18"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yasna"
@@ -10142,9 +10154,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
+1 -1
View File
@@ -4,7 +4,7 @@
# *****************
# Base image for planner & builder
# *****************
FROM --platform=$BUILDPLATFORM rust:slim-trixie AS base
FROM --platform=$BUILDPLATFORM rust:1.98.1-slim-trixie AS base
ENV DEBIAN_FRONTEND="noninteractive" \
BINSTALL_DISABLE_TELEMETRY=true \
+4
View File
@@ -8,6 +8,10 @@
---
> [!NOTE]
> Development happens on [git.coffeylabs.org/inbuxa/inbuxa-server](https://git.coffeylabs.org/inbuxa/inbuxa-server); the copy on GitHub is a read-only mirror.
> Report issues at **[git.coffeylabs.org/inbuxa/inbuxa-server/issues](https://git.coffeylabs.org/inbuxa/inbuxa-server/issues)**, and join discussions at **[community.coffeylabs.org](https://community.coffeylabs.org)**.
**inbuxa** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
+2 -2
View File
@@ -17,7 +17,7 @@ visible to everyone, including whoever would use it, before there is a fix.
Report it privately by email to:
**johnellisATlinuxDOTcom**
**securityATcoffeylabsDOTorg**
Include as much as you can of:
@@ -36,7 +36,7 @@ to Stalwart Labs with credit to you, and you'll be told that has happened.
This repository is the mail server. The web front ends have their own:
- [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin)
- [ihasmail-inbuxa](https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa)
- [inbuxa-webmail](https://git.coffeylabs.org/inbuxa/inbuxa-webmail)
Upstream's own security documents are kept in `.github-upstream/` for
reference. They describe Stalwart Labs' process, not this project's.
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "common"
version = "0.16.24"
version = "0.16.25"
edition = "2024"
build = "build.rs"
+58 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -445,6 +445,63 @@ impl Server {
}
}
/// MA-D0a: a message sent from an address that isn't the sender's own:
/// a group's or a shared mailbox's. The message itself only says
/// `From:` that address, so the audit log is where the person who sent
/// it is named. A locked account's delegate's send is AL-9's record, not
/// this one.
pub async fn audit_send_as(
&self,
token: &AccessToken,
submission_account_id: u32,
submission_id: u32,
address: &str,
) {
let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else {
return;
};
if as_account_id == token.account_id()
|| token
.delegation(as_account_id)
.is_some_and(|delegation| delegation.kind.is_lock())
{
return;
}
let actor = self.audit_actor(token).await;
let tenant_id = self
.account(as_account_id)
.await
.ok()
.and_then(|account| account.id_tenant);
let details = if submission_account_id == as_account_id {
format!("Sent as {address}")
} else {
format!(
"Sent as {address}, from {}",
self.audit_account_name(submission_account_id).await
)
};
self.audit_note(Record {
at: ms(),
actor,
via: token.origin().cloned(),
remote_ip: None,
action: Action::Create,
target: Target {
kind: "EmailSubmission".into(),
id: Some(Id::from(submission_id).to_string()),
name: Some(address.to_string()),
account_id: Some(as_account_id),
tenant_id,
},
changes: vec![],
details: Some(details),
reason: None,
outcome: Outcome::success(),
})
.await;
}
/// AU-7: removes entries past the retention period.
pub async fn audit_purge(&self) -> trc::Result<usize> {
let settings = log::settings(self.store()).await?;
+84 -4
View File
@@ -36,6 +36,32 @@ use utils::map::bitmap::{Bitmap, BitmapItem};
use xxhash_rust::xxh3;
impl Server {
/// inbuxa: MA-C: whether people in `owner`'s tenant may share their mail
/// (the server's switch, narrowed by the tenant's).
pub async fn mail_sharing_allowed(&self, owner: u32) -> trc::Result<bool> {
let tenant_id = self.account(owner).await.ok().and_then(|account| account.id_tenant);
Ok(
inbuxa_features::security::sharing_policy::effective_for(self.store(), tenant_id)
.await
.caused_by(trc::location!())?
.mail_sharing,
)
}
/// inbuxa: MA-C: whether `owner`'s mail shares give access now. A locked
/// account's or shared mailbox's grants are an administrator's and always
/// do; anyone else's only while their tenant allows mail sharing.
pub async fn mail_shares_honored(&self, owner: u32) -> trc::Result<bool> {
if inbuxa_features::lock::get(self.store(), owner)
.await
.caused_by(trc::location!())?
.is_some()
{
return Ok(true);
}
self.mail_sharing_allowed(owner).await
}
async fn build_access_token(
&self,
account: Account,
@@ -46,19 +72,22 @@ impl Server {
// inbuxa: AL-2, AL-5: whether this account is locked, and which
// locked accounts are handed to it. The token is their cache: every
// change to a lock invalidates the tokens it touches.
let locked = inbuxa_features::lock::get(self.store(), account_id)
let lock_kind = inbuxa_features::lock::get(self.store(), account_id)
.await
.caused_by(trc::location!())?
.is_some();
.map(|lock| lock.kind);
let locked = lock_kind.is_some();
let shared_mailbox = lock_kind == Some(inbuxa_features::lock::Kind::SharedMailbox);
let now_secs = now();
let delegations: Box<[super::Delegation]> =
inbuxa_features::lock::delegated_to(self.store(), account_id)
.await
.caused_by(trc::location!())?
.into_iter()
.filter(|(_, delegate)| delegate.is_current(now_secs))
.map(|(locked_id, delegate)| super::Delegation {
.filter(|(_, delegate, _)| delegate.is_current(now_secs))
.map(|(locked_id, delegate, kind)| super::Delegation {
account_id: locked_id,
kind,
access: delegate.access,
send_as: delegate.send_as,
until: delegate.until,
@@ -97,6 +126,9 @@ impl Server {
.map(|m| m.id() as u32)
.collect::<TinyVec<[u32; 3]>>();
let mut access_to: Vec<AccessTo> = Vec::new();
// inbuxa: MA-C: whether an owner's mail shares are honored,
// looked up once per owner
let mut mail_shares_honored: Vec<(u32, bool)> = Vec::new();
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
for acl_item in self
.store()
@@ -117,6 +149,27 @@ impl Server {
.caused_by(trc::location!()));
}
// inbuxa: MA-C: a mail share from an account whose
// tenant (or server) has mail sharing off gives
// nothing while it is off. It stays stored, so it
// comes back when sharing does. A lock's and a
// shared mailbox's grants are an administrator's,
// and always count.
if collection == Collection::Mailbox {
let owner = acl_item.to_account_id;
let honored = match mail_shares_honored.iter().find(|(id, _)| *id == owner) {
Some((_, honored)) => *honored,
None => {
let honored = self.mail_shares_honored(owner).await?;
mail_shares_honored.push((owner, honored));
honored
}
};
if !honored {
continue;
}
}
let mut collections: Bitmap<Collection> = Bitmap::new();
if acl.contains(Acl::Read) {
collections.insert(collection);
@@ -247,6 +300,7 @@ impl Server {
.map(ConcurrencyLimiter::new),
obj_size: 0,
locked,
shared_mailbox,
delegations: delegations.clone(),
revision,
revision_account,
@@ -300,6 +354,7 @@ impl Server {
.map(ConcurrencyLimiter::new),
obj_size: 0,
locked,
shared_mailbox,
delegations: delegations.clone(),
revision,
revision_account,
@@ -553,6 +608,16 @@ impl AccessToken {
|| self.inner.access_to.iter().any(|a| a.account_id == account_id)
}
/// inbuxa: MA-D0: in the account only because it is a group this token
/// belongs to. Such a member has the group's mailbox but may not share it
/// on: who is in a group is an administrator's decision, and a share
/// would let anyone in.
pub fn is_group_member_only(&self, account_id: u32) -> bool {
self.inner.account_id != account_id
&& self.inner.member_of.contains(&account_id)
&& !self.has_permission(Permission::Impersonate)
}
pub fn is_account_id(&self, account_id: u32) -> bool {
self.inner.account_id == account_id
}
@@ -648,6 +713,7 @@ impl AccessToken {
credential_version: old_inner.credential_version,
obj_size: old_inner.obj_size,
locked: old_inner.locked,
shared_mailbox: old_inner.shared_mailbox,
delegations: old_inner.delegations.clone(),
};
@@ -838,6 +904,18 @@ impl AccessToken {
self.inner.locked
}
/// inbuxa: MA-S: the account is a shared mailbox (a lock of that kind).
pub fn is_shared_mailbox(&self) -> bool {
self.inner.shared_mailbox
}
/// inbuxa: MA-S: this account's delegation into `account_id` is to a
/// shared mailbox, not a locked account.
pub fn delegated_shared_mailbox(&self, account_id: u32) -> bool {
self.delegation(account_id)
.is_some_and(|d| d.kind == inbuxa_features::lock::Kind::SharedMailbox)
}
/// inbuxa: AL-5: this account's delegation into a locked account, if it
/// has one that hasn't ended.
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
@@ -918,6 +996,7 @@ impl AccessToken {
credential_version: Default::default(),
obj_size: Default::default(),
locked: false,
shared_mailbox: false,
delegations: Default::default(),
}),
}
@@ -978,6 +1057,7 @@ impl AccessTokenInner {
credential_version: Default::default(),
obj_size: Default::default(),
locked: false,
shared_mailbox: false,
delegations: Default::default(),
}
}
+4
View File
@@ -152,6 +152,8 @@ pub struct AccessTokenInner {
pub(crate) obj_size: u64,
// inbuxa: AL-2: the account is locked; it may not authenticate
pub(crate) locked: bool,
// inbuxa: MA-S: the lock is a shared mailbox
pub(crate) shared_mailbox: bool,
// inbuxa: AL-5: locked accounts handed to this one
pub(crate) delegations: Box<[Delegation]>,
}
@@ -165,6 +167,8 @@ pub struct Delegation {
pub send_as: bool,
/// Seconds since the epoch.
pub until: Option<u64>,
/// MA-S: a locked account, or a shared mailbox.
pub kind: inbuxa_features::lock::Kind,
}
#[derive(Debug, Default, Hash, Clone)]
+13
View File
@@ -111,6 +111,9 @@ impl Server {
Permission::SysLegalHoldCreate,
Permission::SysLegalHoldUpdate,
Permission::SysLegalHoldExport,
// inbuxa: DL-20: the lists and the check are the server's
Permission::SysDeliverabilityUpdate,
Permission::SysDeliverabilityCheck,
] {
permissions.disabled.set(permission as usize);
}
@@ -304,6 +307,16 @@ impl Default for DefaultPermissions {
default.superuser.push(permission);
default.tenant.push(permission);
}
// inbuxa: deliverability spec, DL-20: a tenant administrator
// reads its own domains' findings; the lists and the check
// itself are the server's
Permission::SysDeliverabilityGet => {
default.superuser.push(permission);
default.tenant.push(permission);
}
Permission::SysDeliverabilityUpdate | Permission::SysDeliverabilityCheck => {
default.superuser.push(permission);
}
// inbuxa: DLP and mail flow rules, and held mail, are the
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
// settled answer 3)
+34
View File
@@ -72,6 +72,10 @@ pub struct Http {
pub cors_origins: Vec<hyper::header::HeaderValue>,
pub use_forwarded: bool,
pub redirect_root: Option<String>,
/// inbuxa: HTTP Basic accepted on every endpoint, not only DAV (contract
/// C-23). True in bootstrap and recovery mode, or with
/// `INBUXA_HTTP_BASIC_AUTH=all`.
pub basic_auth_everywhere: bool,
}
#[derive(Clone)]
@@ -453,6 +457,35 @@ impl Http {
.collect()
};
// inbuxa: outside DAV, HTTP sign-in is a token unless the operator
// says otherwise (contract C-23). The integration suites sign in with
// passwords over JMAP and the API, so test builds accept Basic
// everywhere.
#[cfg(feature = "test_mode")]
let basic_auth_everywhere = true;
#[cfg(not(feature = "test_mode"))]
let basic_auth_everywhere = bp.registry.is_recovery_mode()
|| bp.registry.is_bootstrap_mode()
|| match types::branding::env_var("HTTP_BASIC_AUTH") {
Ok(value) if value.trim().eq_ignore_ascii_case("all") => true,
Ok(value)
if value.trim().is_empty() || value.trim().eq_ignore_ascii_case("dav") =>
{
false
}
Ok(value) => {
bp.build_warning(
ObjectType::Http.singleton(),
format!(
"INBUXA_HTTP_BASIC_AUTH is {value:?}; expected \"dav\" or \"all\". Basic authentication stays on DAV only."
),
);
false
}
Err(_) => false,
};
if use_permissive_cors {
http_headers.push((
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
@@ -512,6 +545,7 @@ impl Http {
cors_origins,
use_forwarded: http.use_x_forwarded,
redirect_root: http.redirect_root,
basic_auth_everywhere,
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+2
View File
@@ -88,6 +88,8 @@ pub enum BroadcastEvent {
QueueRefresh,
// inbuxa: AL-3: end an account's open sessions on every node
EndSessions(u32),
// inbuxa: deliverability spec, DL-15: every node checks itself now
DeliverabilityCheck,
}
#[derive(Debug, Clone, Copy)]
+1 -1
View File
@@ -225,7 +225,7 @@ pub struct Caches {
pub dns_ipv6: CacheWithTtl<Box<str>, RecordSet<Ipv6Addr>>,
pub dns_tlsa: CacheWithTtl<Box<str>, Arc<Tlsa>>,
pub dns_mta_sts: CacheWithTtl<Box<str>, Arc<Policy>>,
pub dns_rbl: CacheWithTtl<Box<str>, Option<Arc<IpResolver>>>,
pub dns_rbl: CacheWithTtl<Box<str>, Option<Arc<[IpResolver]>>>,
pub negative_cache_ttl: Duration,
}
+14 -2
View File
@@ -227,10 +227,22 @@ impl WebApplicationManager {
let cached = if force_refresh {
None
} else {
server
match server
.blob_store()
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
.await?
.await
{
Ok(cached) => cached,
Err(err) => {
trc::event!(
Resource(trc::ResourceEvent::Error),
Reason = err,
Url = self.url.clone(),
Details = "Failed to read cached application bundle, downloading it again"
);
None
}
}
};
let is_cached = cached.is_some();
let bundle = match cached {
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+3 -3
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -14,7 +14,7 @@
//! application names another;
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
//! is set;
//! - ihasmail-inbuxa, as the confidential client `ihasmail-inbuxa`, when
//! - inbuxa-webmail, as the confidential client `ihasmail-inbuxa`, when
//! `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` are set.
//!
//! inbuxa: the environment variables stand in for `x:FrontEnds` (C-4) until
@@ -22,7 +22,7 @@
//! it instead.
//!
//! A missing client is created. An existing one gains any redirect URI it
//! lacks and, for ihasmail-inbuxa, the configured secret; nothing an operator
//! lacks and, for inbuxa-webmail, the configured secret; nothing an operator
//! added is removed.
use directory::core::secret::{hash_secret, verify_secret_hash};
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -31,8 +31,9 @@ use types::id::Id;
/// Granted to the default administrator roles: "Explain this"
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
/// the data inventory (personal-data catalog spec), and accepting security
/// to-do items (security to-do list spec).
/// the data inventory (personal-data catalog spec), accepting security
/// to-do items (security to-do list spec), and the deliverability check
/// (deliverability spec).
const ADMIN_GRANTS: &[Permission] = &[
Permission::SysAiExplain,
Permission::SysAuditGet,
@@ -56,6 +57,9 @@ const ADMIN_GRANTS: &[Permission] = &[
Permission::SysJournalGet,
Permission::SysJournalUpdate,
Permission::SysSecurityAccept,
Permission::SysDeliverabilityGet,
Permission::SysDeliverabilityUpdate,
Permission::SysDeliverabilityCheck,
];
/// Granted to the server-level Compliance Officer role once it exists:
@@ -73,7 +77,8 @@ const OFFICER_GRANTS: &[Permission] = &[
/// Granted to the default tenant administrator roles: reading and exporting
/// the tenant's audit log (AU-9), locking and delegating its accounts
/// (AL-12), and the tenant's slice of the data inventory.
/// (AL-12), the tenant's slice of the data inventory, and its own domains'
/// deliverability findings (DL-20).
const TENANT_GRANTS: &[Permission] = &[
Permission::SysAuditGet,
Permission::SysAuditExport,
@@ -82,6 +87,7 @@ const TENANT_GRANTS: &[Permission] = &[
Permission::SysAccountLockUpdate,
Permission::SysAccountLockDestroy,
Permission::SysComplianceGet,
Permission::SysDeliverabilityGet,
];
#[derive(Clone, Copy, PartialEq, Eq)]
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,6 +1,6 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
@@ -11,7 +11,7 @@ use quick_xml::Reader;
use quick_xml::XmlVersion;
use quick_xml::events::Event;
use registry::schema::{enums::ServiceProtocol, structs::Service};
use std::fmt::Write;
use std::{borrow::Cow, fmt::Write};
use utils::map::vec_map::VecMap;
impl Server {
@@ -20,32 +20,78 @@ impl Server {
body: Option<Vec<u8>>,
) -> trc::Result<Resource<Vec<u8>>> {
// Obtain parameters
let emailaddress = parse_autodiscover_request(body.as_deref().unwrap_or_default())
.map_err(|err| {
let request =
parse_autodiscover_request(body.as_deref().unwrap_or_default()).map_err(|err| {
trc::ResourceEvent::BadParameters
.into_err()
.details("Failed to parse autodiscover request")
.ctx(trc::Key::Reason, err)
})?;
// inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = match emailaddress.rsplit_once('@') {
let legacy_off = match request.email.rsplit_once('@') {
Some((_, domain)) => self.legacy_off_for(domain).await?,
None => self.legacy_off_for("").await?,
};
Ok(Resource::new(
"application/xml; charset=utf-8",
build_autodiscover_response(
&emailaddress,
let response = match request.response_schema {
ResponseSchema::Outlook => build_autodiscover_response(
&request.email,
&self.core.network.server_name,
&self.core.network.info.services,
|protocol| legacy_off.service(protocol),
)
.into_bytes(),
))
ResponseSchema::Unsupported => PROVIDER_NOT_AVAILABLE_RESPONSE.as_bytes().to_vec(),
};
Ok(Resource::new("application/xml; charset=utf-8", response))
}
}
const OUTLOOK_RESPONSE_SCHEMA: &str =
"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a";
const PROVIDER_NOT_AVAILABLE_RESPONSE: &str = concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<Autodiscover xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006\">\n",
"\t<Response>\n",
"\t\t<Error>\n",
"\t\t\t<ErrorCode>601</ErrorCode>\n",
"\t\t\t<Message>Provider is not available</Message>\n",
"\t\t\t<DebugData />\n",
"\t\t</Error>\n",
"\t</Response>\n",
"</Autodiscover>\n",
);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResponseSchema {
Outlook,
Unsupported,
}
impl ResponseSchema {
fn parse(value: &str) -> Self {
if value.trim().eq_ignore_ascii_case(OUTLOOK_RESPONSE_SCHEMA) {
ResponseSchema::Outlook
} else {
ResponseSchema::Unsupported
}
}
}
#[derive(Debug, PartialEq, Eq)]
struct AutodiscoverRequest {
email: String,
response_schema: ResponseSchema,
}
#[derive(Clone, Copy)]
enum RequestField {
EmailAddress,
ResponseSchema,
}
fn build_autodiscover_response(
emailaddress: &str,
default_host: &str,
@@ -124,7 +170,7 @@ fn build_autodiscover_response(
config
}
fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
fn parse_autodiscover_request(bytes: &[u8]) -> Result<AutodiscoverRequest, String> {
if bytes.is_empty() {
return Err("Empty request body".to_string());
}
@@ -132,8 +178,9 @@ fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
let mut reader = Reader::from_reader(bytes);
reader.config_mut().trim_text(true);
let mut buf = Vec::with_capacity(128);
let mut value_buf = Vec::with_capacity(128);
'outer: for tag_name in ["Autodiscover", "Request", "EMailAddress"] {
'outer: for tag_name in ["Autodiscover", "Request"] {
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
@@ -143,30 +190,6 @@ fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
.eq_ignore_ascii_case(found_tag_name.as_ref())
{
continue 'outer;
} else if tag_name == "EMailAddress" {
// Skip unsupported tags under Request, such as AcceptableResponseSchema
let mut tag_count = 0;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::End(_)) => {
if tag_count == 0 {
break;
} else {
tag_count -= 1;
}
}
Ok(Event::Start(_)) => {
tag_count += 1;
}
Ok(Event::Eof) => {
return Err(format!(
"Expected value, found unexpected EOF at position {}.",
reader.buffer_position()
));
}
_ => (),
}
}
} else {
return Err(format!(
"Expected tag {}, found unexpected tag {} at position {}.",
@@ -195,36 +218,170 @@ fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
}
}
if let Ok(Event::Text(text)) = reader.read_event_into(&mut buf)
&& let Ok(text) = text.xml_content(XmlVersion::Implicit1_0)
&& text.contains('@')
{
return Ok(text.trim().to_lowercase());
let mut email = None;
let mut response_schema = ResponseSchema::Outlook;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let local_name = e.local_name();
let field = hashify::tiny_map_ignore_case!(local_name.as_ref(),
b"EMailAddress" => RequestField::EmailAddress,
b"AcceptableResponseSchema" => RequestField::ResponseSchema,
);
let value = match reader.read_event_into(&mut value_buf) {
Ok(Event::End(_)) => None,
Ok(event) => {
let value = match event {
Event::Text(text) => text
.xml_content(XmlVersion::Implicit1_0)
.ok()
.map(Cow::into_owned),
_ => None,
};
reader
.read_to_end_into(e.name(), &mut value_buf)
.map_err(|err| {
format!("Error at position {}: {:?}", reader.buffer_position(), err)
})?;
value
}
Err(err) => {
return Err(format!(
"Error at position {}: {:?}",
reader.buffer_position(),
err
));
}
};
match (field, value) {
(Some(RequestField::EmailAddress), Some(value)) => {
email = Some(value);
}
(Some(RequestField::ResponseSchema), Some(value)) => {
response_schema = ResponseSchema::parse(&value);
}
_ => (),
}
}
Ok(Event::End(_) | Event::Eof) => break,
Ok(_) => (),
Err(e) => {
return Err(format!(
"Error at position {}: {:?}",
reader.buffer_position(),
e
));
}
}
}
Err(format!(
"Expected email address, found unexpected value at position {}.",
reader.buffer_position()
))
match email {
Some(email) if email.contains('@') => Ok(AutodiscoverRequest {
email: email.trim().to_lowercase(),
response_schema,
}),
_ => Err(format!(
"Expected email address, found unexpected value at position {}.",
reader.buffer_position()
)),
}
}
#[cfg(test)]
mod tests {
use super::{AutodiscoverRequest, ResponseSchema, parse_autodiscover_request};
#[test]
fn parse_autodiscover() {
let r = r#"<?xml version="1.0" encoding="utf-8"?>
const OUTLOOK: &str =
"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a";
const MOBILESYNC: &str =
"http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006";
for (request, expected) in [
(
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
<Request>
<EMailAddress>email@example.com</EMailAddress>
<AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
<EMailAddress>Email@Example.com</EMailAddress>
<AcceptableResponseSchema>{OUTLOOK}</AcceptableResponseSchema>
</Request>
</Autodiscover>"#;
</Autodiscover>"#
),
ResponseSchema::Outlook,
),
(
format!(
r#"<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
<Request>
<AcceptableResponseSchema>{OUTLOOK}</AcceptableResponseSchema>
<EMailAddress>email@example.com</EMailAddress>
</Request>
</Autodiscover>"#
),
ResponseSchema::Outlook,
),
(
r#"<Autodiscover>
<Request>
<EMailAddress>email@example.com</EMailAddress>
</Request>
</Autodiscover>"#
.to_string(),
ResponseSchema::Outlook,
),
(
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/mobilesync/requestschema/2006">
<Request>
<EMailAddress>email@example.com</EMailAddress>
<AcceptableResponseSchema>{MOBILESYNC}</AcceptableResponseSchema>
</Request>
</Autodiscover>"#
),
ResponseSchema::Unsupported,
),
(
format!(
r#"<Autodiscover>
<Request>
<LegacyDN>/o=Example/ou=Users/cn=email</LegacyDN>
<Unknown><Nested>value</Nested><Empty/></Unknown>
<AcceptableResponseSchema>{MOBILESYNC}</AcceptableResponseSchema>
<EMailAddress>email@example.com</EMailAddress>
</Request>
</Autodiscover>"#
),
ResponseSchema::Unsupported,
),
] {
assert_eq!(
parse_autodiscover_request(request.as_bytes()).expect("valid request"),
AutodiscoverRequest {
email: "[email protected]".to_string(),
response_schema: expected,
},
"{request}"
);
}
assert_eq!(
super::parse_autodiscover_request(r.as_bytes()).unwrap(),
"[email protected]"
);
for request in [
"",
"<Autodiscover><Request></Request></Autodiscover>",
"<Autodiscover><Request><EMailAddress>no-domain</EMailAddress></Request></Autodiscover>",
"<Autodiscover><Request><EMailAddress>[email protected]</Request></Autodiscover>",
"<Request><EMailAddress>[email protected]</EMailAddress></Request>",
] {
assert!(
parse_autodiscover_request(request.as_bytes()).is_err(),
"{request}"
);
}
}
#[test]
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+62 -4
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -104,14 +104,31 @@ impl StoredMetric {
pub fn timestamp(&self) -> u64 {
SnowflakeIdGenerator::to_timestamp(self.id)
}
/// The node that wrote the sample. Histogram totals are per node, so a
/// reader diffs them per node.
pub fn node_id(&self) -> u64 {
SnowflakeIdGenerator::to_node_id(self.id)
}
}
/// What the node wrote last, so counters and histograms are written as
/// changes (MON-4). Per process: a restart counts from the start.
static LAST: Mutex<Option<AHashMap<MetricType, (u64, u64)>>> = Mutex::new(None);
/// One tick's samples (MON-4 to MON-6).
pub fn sample() -> Vec<Metric> {
/// Gauges that count the whole cluster's data, not this node's. Only the node
/// that computes them (the metrics-calculation role) has a true reading; on
/// the others the queue gauge only moves with local queue events and drifts
/// below zero, and the account and domain counts stay at 0.
const CLUSTER_GAUGES: [MetricType; 3] = [
MetricType::QueueCount,
MetricType::UserCount,
MetricType::DomainCount,
];
/// One tick's samples (MON-4 to MON-6). `calculates` is whether this node
/// computes the cluster-wide gauges; a node that doesn't leaves them out.
pub fn sample(calculates: bool) -> Vec<Metric> {
let mut last_guard = LAST.lock().unwrap();
let last = last_guard.get_or_insert_with(AHashMap::new);
let mut samples = Vec::new();
@@ -134,6 +151,9 @@ pub fn sample() -> Vec<Metric> {
// Gauges: the reading, always (MON-5)
for gauge in Collector::collect_gauges() {
if !calculates && CLUSTER_GAUGES.contains(&gauge.id()) {
continue;
}
samples.push(Metric::Gauge(MetricCount {
count: gauge.get(),
metric: gauge.id(),
@@ -175,7 +195,7 @@ impl Server {
if store.is_none() {
return;
}
let samples = sample();
let samples = sample(self.core.network.roles.metrics_calculate);
let count = samples.len();
let started = std::time::Instant::now();
match store.write_metrics(samples, now()).await {
@@ -265,3 +285,41 @@ impl Server {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gauges(samples: &[Metric]) -> Vec<MetricType> {
samples
.iter()
.filter_map(|m| match m {
Metric::Gauge(g) => Some(g.metric),
_ => None,
})
.collect()
}
#[test]
fn only_the_calculating_node_stores_cluster_gauges() {
let all = gauges(&sample(true));
let local = gauges(&sample(false));
for metric in CLUSTER_GAUGES {
assert!(
all.contains(&metric),
"{metric:?} missing on the calculating node"
);
assert!(
!local.contains(&metric),
"{metric:?} stored by a node that doesn't compute it"
);
}
// Per-node gauges are stored either way
for metric in [MetricType::ServerMemory, MetricType::HttpActiveConnections] {
assert!(
all.contains(&metric) && local.contains(&metric),
"{metric:?}"
);
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "coordinator"
version = "0.16.24"
version = "0.16.25"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dav-proto"
version = "0.16.24"
version = "0.16.25"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dav"
version = "0.16.24"
version = "0.16.25"
edition = "2024"
[dependencies]
+11 -1
View File
@@ -133,6 +133,10 @@ impl DavAclHandler for Server {
{
return Err(DavError::Code(StatusCode::FORBIDDEN));
}
// inbuxa: MA-D0: a group's members don't share what it owns on.
if access_token.is_group_member_only(account_id) {
return Err(DavError::Code(StatusCode::FORBIDDEN));
}
// Validate ACEs
let grants = self
@@ -565,7 +569,13 @@ impl Privileges for AccessToken {
grants: &ArchivedVec<ArchivedAclGrant>,
is_calendar: bool,
) -> Vec<Privilege> {
if self.is_member(account_id) {
if self.is_group_member_only(account_id) {
// inbuxa: MA-D0: everything but sharing it on.
Privilege::all(is_calendar)
.into_iter()
.filter(|privilege| !matches!(privilege, Privilege::All | Privilege::WriteAcl))
.collect()
} else if self.is_member(account_id) {
Privilege::all(is_calendar)
} else {
current_user_privilege_set(grants.effective_acl(self))
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "directory"
version = "0.16.24"
version = "0.16.25"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "email"
version = "0.16.24"
version = "0.16.25"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+2 -6
View File
@@ -20,7 +20,7 @@ use groupware::{
scheduling::{ItipError, ItipMessages},
};
use mail_parser::{
DateTime, Header, HeaderName, HeaderValue, Message, MessageParser, MimeHeaders, PartType,
Header, HeaderName, HeaderValue, Message, MessageParser, MimeHeaders, PartType,
parsers::fields::thread::thread_name,
};
use registry::{
@@ -924,11 +924,7 @@ impl EmailIngest for Server {
span_id: u64,
) {
if let Some(config) = &self.core.spam.classifier {
let mut dt = DateTime::from_timestamp(now() as i64);
dt.hour = 0;
dt.minute = 0;
dt.second = 0;
let until = dt.to_timestamp() as u64 + config.hold_samples_for;
let until = now() + config.hold_samples_for;
let sample = SpamTrainingSample {
account_id: Some(Id::from(account_id)),
+10 -2
View File
@@ -290,7 +290,11 @@ impl SieveScriptIngest for Server {
// inbuxa: AL-4: a locked account answers no sender, so a
// rejection is kept instead; sieve has already cleared
// the implicit keep, so it is filed here
Event::Reject { .. } if access_token.is_locked() => {
// A shared mailbox (MA-S) is a role address and answers
// as one: its Sieve script runs as written
Event::Reject { .. }
if access_token.is_locked() && !access_token.is_shared_mailbox() =>
{
if let Some(message) = messages.get_mut(0)
&& !message.file_into.contains(&INBOX_ID)
{
@@ -403,7 +407,11 @@ impl SieveScriptIngest for Server {
// inbuxa: AL-4: a locked account sends nothing on its
// own: no redirect, vacation reply or notification. An
// unsent redirect leaves the message to be kept.
Event::SendMessage { .. } if access_token.is_locked() => {
// A shared mailbox's acknowledgements and redirects go
// out (MA-S).
Event::SendMessage { .. }
if access_token.is_locked() && !access_token.is_shared_mailbox() =>
{
trc::event!(
Sieve(SieveEvent::ActionReject),
Details = "Account is locked: nothing is sent",
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+282
View File
@@ -0,0 +1,282 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The blocklists a node asks about itself (deliverability spec, DL-6), and
//! how to read each one's answer.
//!
//! A list answers with an address in 127.0.0.0/8. Each list says which of
//! those mean "listed" and which mean "I won't answer you": Spamhaus, for
//! one, answers `127.255.255.254` to a query that came through a public
//! resolver. A refusal is never read as a listing (DL-4).
use std::net::{IpAddr, Ipv4Addr};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
/// Looked up by the reversed address: `2.0.0.127.zen.spamhaus.org`.
Ip,
/// Looked up by name: `example.org.dbl.spamhaus.org`.
Domain,
}
#[derive(Debug, Clone, Copy)]
pub struct BlockList {
/// What the page and the settings call it.
pub name: &'static str,
pub zone: &'static str,
pub scope: Scope,
/// Where an administrator looks the address up and asks for removal.
pub lookup: &'static str,
/// Something the page says beside the list.
pub note: Option<&'static str>,
read: fn(Ipv4Addr) -> Answer,
}
/// What a list's answer means.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer {
Listed(&'static str),
/// The list won't answer this resolver, or not now.
Refused(&'static str),
/// A code the list doesn't define: neither listed nor clean.
Unknown,
}
impl BlockList {
pub fn read(&self, answer: Ipv4Addr) -> Answer {
(self.read)(answer)
}
/// The name to look up for `subject`, or None when the subject doesn't
/// suit the list (a domain on an IP list, or an IPv6 address: none of
/// these lists publish IPv6 zones worth asking).
pub fn query(&self, subject: &Subject<'_>) -> Option<String> {
match (self.scope, subject) {
(Scope::Ip, Subject::Ip(IpAddr::V4(ip))) => {
let [a, b, c, d] = ip.octets();
Some(format!("{d}.{c}.{b}.{a}.{}.", self.zone))
}
(Scope::Domain, Subject::Domain(domain)) => {
Some(format!("{}.{}.", domain.trim_end_matches('.'), self.zone))
}
_ => None,
}
}
}
pub enum Subject<'x> {
Ip(IpAddr),
Domain(&'x str),
}
/// Spamhaus' error codes, the same on every Spamhaus zone.
fn spamhaus_refusal(ip: Ipv4Addr) -> Option<Answer> {
match ip.octets() {
[127, 255, 255, 252] => Some(Answer::Refused("The query was malformed")),
[127, 255, 255, 254] => Some(Answer::Refused(
"Spamhaus doesn't answer public resolvers; use the server's own",
)),
[127, 255, 255, 255] => Some(Answer::Refused("Too many queries from this resolver")),
_ => None,
}
}
fn zen(ip: Ipv4Addr) -> Answer {
if let Some(refused) = spamhaus_refusal(ip) {
return refused;
}
match ip.octets() {
[127, 0, 0, 2] => Answer::Listed("SBL: a known spam source"),
[127, 0, 0, 3] => Answer::Listed("CSS: sent spam recently"),
[127, 0, 0, 4..=7] => Answer::Listed("XBL: a compromised or infected host"),
[127, 0, 0, 9] => Answer::Listed("DROP: a hijacked or criminal network"),
[127, 0, 0, 10 | 11] => {
Answer::Listed("PBL: an address that isn't meant to send mail directly")
}
_ => Answer::Unknown,
}
}
fn dbl(ip: Ipv4Addr) -> Answer {
if let Some(refused) = spamhaus_refusal(ip) {
return refused;
}
match ip.octets() {
[127, 0, 1, 2] => Answer::Listed("A spam domain"),
[127, 0, 1, 4] => Answer::Listed("A phishing domain"),
[127, 0, 1, 5] => Answer::Listed("A malware domain"),
[127, 0, 1, 6] => Answer::Listed("A botnet controller"),
[127, 0, 1, 102..=106] => Answer::Listed("A legitimate domain being abused"),
[127, 0, 1, 255] => Answer::Refused("The query was malformed"),
_ => Answer::Unknown,
}
}
/// Most lists answer 127.0.0.2 for "listed" and define nothing else.
fn just_two(ip: Ipv4Addr) -> Answer {
match ip.octets() {
[127, 0, 0, 2] => Answer::Listed("Listed"),
_ => Answer::Unknown,
}
}
fn surbl(ip: Ipv4Addr) -> Answer {
match ip.octets() {
[127, 0, 0, 1] => Answer::Refused("SURBL doesn't answer this resolver"),
[127, 0, 0, bits] if bits & (8 | 16 | 64 | 128) != 0 => {
Answer::Listed("Seen in phishing, malware, abuse or cracked sites")
}
_ => Answer::Unknown,
}
}
fn uribl(ip: Ipv4Addr) -> Answer {
match ip.octets() {
[127, 0, 0, 1] => Answer::Refused("URIBL doesn't answer public resolvers"),
[127, 0, 0, bits] if bits & (2 | 8) != 0 => Answer::Listed("Seen in spam"),
[127, 0, 0, bits] if bits & 4 != 0 => {
Answer::Listed("Grey: seen in bulk mail some people don't want")
}
_ => Answer::Unknown,
}
}
pub const LISTS: &[BlockList] = &[
BlockList {
name: "Spamhaus ZEN",
zone: "zen.spamhaus.org",
scope: Scope::Ip,
lookup: "https://check.spamhaus.org/",
note: None,
read: zen,
},
BlockList {
name: "SpamCop",
zone: "bl.spamcop.net",
scope: Scope::Ip,
lookup: "https://www.spamcop.net/bl.shtml",
note: None,
read: just_two,
},
BlockList {
name: "Barracuda",
zone: "b.barracudacentral.org",
scope: Scope::Ip,
lookup: "https://www.barracudacentral.org/lookups",
note: Some(
"Barracuda answers only resolvers whose address is registered with it (free, at barracudacentral.org/rbl). Until then its lookups can't be checked.",
),
read: just_two,
},
BlockList {
name: "UCEPROTECT level 1",
zone: "dnsbl-1.uceprotect.net",
scope: Scope::Ip,
lookup: "https://www.uceprotect.net/en/rblcheck.php",
note: None,
read: just_two,
},
BlockList {
name: "Mailspike",
zone: "bl.mailspike.net",
scope: Scope::Ip,
lookup: "https://mailspike.org/iplookup.html",
note: None,
read: just_two,
},
BlockList {
name: "PSBL",
zone: "psbl.surriel.com",
scope: Scope::Ip,
lookup: "https://psbl.org/",
note: None,
read: just_two,
},
BlockList {
name: "Spamhaus DBL",
zone: "dbl.spamhaus.org",
scope: Scope::Domain,
lookup: "https://check.spamhaus.org/",
note: None,
read: dbl,
},
BlockList {
name: "SURBL",
zone: "multi.surbl.org",
scope: Scope::Domain,
lookup: "https://surbl.org/surbl-analysis",
note: None,
read: surbl,
},
BlockList {
name: "URIBL",
zone: "multi.uribl.com",
scope: Scope::Domain,
lookup: "https://admin.uribl.com/",
note: None,
read: uribl,
},
];
pub fn by_name(name: &str) -> Option<&'static BlockList> {
LISTS.iter().find(|list| list.name == name)
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> Ipv4Addr {
s.parse().unwrap()
}
#[test]
fn a_refusal_is_not_a_listing() {
let zen = by_name("Spamhaus ZEN").unwrap();
assert!(matches!(
zen.read(ip("127.255.255.254")),
Answer::Refused(_)
));
assert!(matches!(zen.read(ip("127.0.0.2")), Answer::Listed(_)));
assert!(matches!(zen.read(ip("127.0.0.10")), Answer::Listed(_)));
assert_eq!(zen.read(ip("127.0.0.200")), Answer::Unknown);
let uribl = by_name("URIBL").unwrap();
assert!(matches!(uribl.read(ip("127.0.0.1")), Answer::Refused(_)));
assert!(matches!(uribl.read(ip("127.0.0.2")), Answer::Listed(_)));
}
#[test]
fn queries_are_built_per_scope() {
let zen = by_name("Spamhaus ZEN").unwrap();
let dbl = by_name("Spamhaus DBL").unwrap();
let v4 = Subject::Ip("192.0.2.10".parse().unwrap());
let v6 = Subject::Ip("2001:db8::1".parse().unwrap());
let domain = Subject::Domain("example.org");
assert_eq!(
zen.query(&v4).as_deref(),
Some("10.2.0.192.zen.spamhaus.org.")
);
assert_eq!(zen.query(&v6), None);
assert_eq!(zen.query(&domain), None);
assert_eq!(
dbl.query(&domain).as_deref(),
Some("example.org.dbl.spamhaus.org.")
);
assert_eq!(dbl.query(&v4), None);
}
#[test]
fn names_are_unique() {
for (i, a) in LISTS.iter().enumerate() {
assert!(
LISTS[i + 1..].iter().all(|b| b.name != a.name),
"{}",
a.name
);
}
}
}
+410
View File
@@ -0,0 +1,410 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The deliverability check (deliverability spec): what other mail servers
//! see when this one sends. Not a rebuild of anything upstream ships.
//!
//! Every node that sends mail checks itself, because only it knows which
//! address it leaves from, and keeps one report. The report holds facts: an
//! address's reverse DNS, what each blocklist answered, what SPF said for
//! each address, whether a DKIM key in DNS matches the one signing. The
//! console grades them, so its wording can change without a server release.
//!
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
//! with `D`, then one byte for the kind:
//!
//! - `r` + node id (u64): that node's last report, as JSON.
//! - `s`: the settings, as JSON.
//!
//! Numbers are big-endian.
pub mod lists;
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
const FEATURE: u8 = b'D';
const KIND_REPORT: u8 = b'r';
const KIND_SETTINGS: u8 = b's';
/// DL-15: **Check now** runs a node again only this long after its last run.
pub const MIN_INTERVAL_SECS: u64 = 600;
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Report {
/// The node's cluster id, as metric samples carry it.
pub node_id: u64,
pub hostname: String,
/// Seconds since the epoch.
pub checked_at: u64,
pub addresses: Vec<Address>,
pub domains: Vec<DomainReport>,
pub certificates: Vec<Certificate>,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Address {
pub ip: String,
/// DL-2: how the node came by the address.
pub source: AddressSource,
/// The connection strategy that sends from it.
pub strategy: String,
/// The name the node greets with from this address.
pub ehlo: String,
/// The PTR names, empty when there's none.
pub ptr: Vec<String>,
/// Some PTR name resolves back to the address.
pub forward_confirmed: bool,
/// The forward-confirmed name is the EHLO name.
pub ehlo_matches: bool,
/// Set when the reverse lookup itself failed, rather than found nothing.
pub ptr_error: Option<String>,
pub listings: Vec<Listing>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum AddressSource {
/// Set in the connection strategy's source addresses.
#[default]
Configured,
/// What the EHLO name resolves to.
Ehlo,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Listing {
/// The list's name, as in [`lists::LISTS`].
pub list: String,
pub state: ListingState,
/// The address the list answered, when it answered one.
pub code: Option<String>,
/// What the list says the answer means.
pub meaning: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum ListingState {
#[default]
Clean,
Listed,
/// The list wouldn't answer, or the lookup failed: neither listed nor clean.
Refused,
Error,
/// Switched off in the settings, so not asked.
Off,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DomainReport {
pub domain: String,
/// DL-20: a tenant administrator sees only their tenant's domains.
pub tenant_id: Option<u32>,
/// DL-7: what SPF says for each of the node's addresses.
pub spf: Vec<SpfResult>,
/// DL-8: each DKIM key the domain signs with.
pub dkim: Vec<DkimKey>,
/// DL-9: the DMARC record, if there's one.
pub dmarc: Option<Dmarc>,
/// DL-10.
pub mta_sts: MtaSts,
/// DL-11: there's a `_smtp._tls` record.
pub tls_rpt: bool,
/// DL-12.
pub listings: Vec<Listing>,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct SpfResult {
pub ip: String,
/// `pass`, `fail`, `softFail`, `neutral`, `none`, `tempError` or `permError`.
pub result: String,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DkimKey {
pub selector: String,
pub state: DkimState,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum DkimState {
#[default]
Matches,
/// Nothing published at `<selector>._domainkey.<domain>`.
Missing,
/// Published, but a different key.
Different,
/// The lookup failed.
Error,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Dmarc {
/// `none`, `quarantine` or `reject`.
pub policy: String,
/// DKIM alignment: `relaxed` or `strict`.
pub adkim: String,
/// SPF alignment: `relaxed` or `strict`.
pub aspf: String,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct MtaSts {
/// The `_mta-sts` record's id; None when there's no record.
pub record_id: Option<String>,
/// The policy was fetched and parsed. False with a record means the
/// fetch or the parse failed, and `error` says why.
pub fetched: bool,
pub error: Option<String>,
/// `enforce`, `testing` or `none`.
pub mode: Option<String>,
pub max_age: Option<u64>,
/// The domain's MX names no `mx:` line matches.
pub mx_not_covered: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Certificate {
/// The EHLO name, or an MX name that points at this node.
pub name: String,
/// The node holds a certificate for the name.
pub covered: bool,
}
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Settings {
/// DL-6: lists not to ask, by name.
pub disabled_lists: Vec<String>,
}
impl Settings {
pub fn is_off(&self, list: &str) -> bool {
self.disabled_lists.iter().any(|name| name == list)
}
/// Only the built-in lists' names, once each.
pub fn validate(&self) -> Result<(), String> {
for (i, name) in self.disabled_lists.iter().enumerate() {
if lists::by_name(name).is_none() {
return Err(format!("There's no list called {name:?}."));
}
if self.disabled_lists[..i].contains(name) {
return Err(format!("{name:?} is named twice."));
}
}
Ok(())
}
}
impl Report {
/// DL-20: what a tenant administrator may see: their tenant's domains
/// and nothing about the node's addresses or certificates.
pub fn for_tenant(&self, tenant_id: u32) -> Report {
Report {
node_id: self.node_id,
hostname: self.hostname.clone(),
checked_at: self.checked_at,
addresses: Vec::new(),
domains: self
.domains
.iter()
.filter(|d| d.tenant_id == Some(tenant_id))
.cloned()
.collect(),
certificates: Vec::new(),
}
}
}
// --- Storage --------------------------------------------------------------
struct Json<T>(T);
impl<T: SerdeSerialize> Serialize for Json<T> {
fn serialize(&self) -> trc::Result<Vec<u8>> {
serde_json::to_vec(&self.0).map_err(|err| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to serialize deliverability data")
.reason(err)
})
}
}
impl<T: for<'de> SerdeDeserialize<'de> + Send + Sync> Deserialize for Json<T> {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.into_err()
.details("Invalid deliverability data")
.reason(err)
})
}
}
fn class(kind: u8, node_id: Option<u64>) -> ValueClass {
let mut key = Vec::with_capacity(10);
key.push(FEATURE);
key.push(kind);
if let Some(node_id) = node_id {
key.extend_from_slice(&node_id.to_be_bytes());
}
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
pub async fn report(data: &Store, node_id: u64) -> trc::Result<Option<Report>> {
Ok(data
.get_value::<Json<Report>>(ValueKey::from(class(KIND_REPORT, Some(node_id))))
.await
.caused_by(trc::location!())?
.map(|Json(report)| report))
}
/// Every node's report, by node id.
pub async fn reports(data: &Store) -> trc::Result<Vec<Report>> {
let mut out = Vec::new();
data.iterate(
IterateParams::new(
ValueKey::from(class(KIND_REPORT, Some(0))),
ValueKey::from(class(KIND_REPORT, Some(u64::MAX))),
),
|_, value| {
if let Ok(Json(report)) = Json::<Report>::deserialize(value) {
out.push(report);
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
out.sort_by_key(|r| r.node_id);
Ok(out)
}
/// Replaces the node's report.
pub async fn put_report(data: &Store, report: &Report) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(
class(KIND_REPORT, Some(report.node_id)),
Json(report).serialize()?,
);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
pub async fn settings(data: &Store) -> trc::Result<Settings> {
Ok(data
.get_value::<Json<Settings>>(ValueKey::from(class(KIND_SETTINGS, None)))
.await
.caused_by(trc::location!())?
.map(|Json(settings)| settings)
.unwrap_or_default())
}
pub async fn put_settings(data: &Store, settings: &Settings) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(class(KIND_SETTINGS, None), Json(settings).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_name_only_built_in_lists_once() {
let ok = Settings {
disabled_lists: vec!["Barracuda".into(), "URIBL".into()],
};
assert!(ok.validate().is_ok());
assert!(ok.is_off("Barracuda"));
assert!(!ok.is_off("SpamCop"));
let unknown = Settings {
disabled_lists: vec!["My list".into()],
};
assert!(unknown.validate().is_err());
let twice = Settings {
disabled_lists: vec!["URIBL".into(), "URIBL".into()],
};
assert!(twice.validate().is_err());
}
#[test]
fn a_tenant_sees_only_its_domains() {
let report = Report {
node_id: 2,
hostname: "mx2.example.org".into(),
checked_at: 1,
addresses: vec![Address {
ip: "192.0.2.10".into(),
..Default::default()
}],
domains: vec![
DomainReport {
domain: "a.example".into(),
tenant_id: Some(7),
..Default::default()
},
DomainReport {
domain: "b.example".into(),
tenant_id: Some(8),
..Default::default()
},
DomainReport {
domain: "server.example".into(),
tenant_id: None,
..Default::default()
},
],
certificates: vec![Certificate {
name: "mx2.example.org".into(),
covered: true,
}],
};
let seen = report.for_tenant(7);
assert!(seen.addresses.is_empty());
assert!(seen.certificates.is_empty());
assert_eq!(
seen.domains
.iter()
.map(|d| d.domain.as_str())
.collect::<Vec<_>>(),
["a.example"]
);
}
#[test]
fn a_report_reads_back_with_missing_fields() {
let report: Report = serde_json::from_str(r#"{"nodeId": 3}"#).unwrap();
assert_eq!(report.node_id, 3);
assert!(report.domains.is_empty());
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+2 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -21,6 +21,7 @@
pub mod ai;
pub mod audit;
pub mod branding;
pub mod deliverability; // inbuxa: the deliverability check (not a rebuild)
pub mod hold;
pub mod journal;
pub mod lock;
+73 -4
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -66,6 +66,53 @@ const KIND_DELEGATE: u8 = b'd';
/// Most delegates one lock may have (AL-5).
pub const MAX_DELEGATES: usize = 10;
/// Most people one shared mailbox may have (MA-S): a help desk is bigger
/// than the handful a departed colleague's mail is handed to.
pub const MAX_SHARED_MAILBOX_DELEGATES: usize = 100;
/// What a lock is for (multi-account spec, MA-S).
///
/// Both kinds keep receiving mail, can't be signed in to, and are opened by
/// delegates through real grants. A shared mailbox is a role address such
/// as support@: it needs no reason, holds more people, runs its own Sieve
/// replies (an automatic acknowledgement), records only what is sent as it,
/// and may only send as its own addresses.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum Kind {
#[default]
Lock,
SharedMailbox,
}
impl Kind {
pub fn as_str(&self) -> &'static str {
match self {
Kind::Lock => "lock",
Kind::SharedMailbox => "sharedMailbox",
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
"lock" => Some(Kind::Lock),
"sharedMailbox" => Some(Kind::SharedMailbox),
_ => None,
}
}
pub fn is_lock(&self) -> bool {
matches!(self, Kind::Lock)
}
pub fn max_delegates(&self) -> usize {
match self {
Kind::Lock => MAX_DELEGATES,
Kind::SharedMailbox => MAX_SHARED_MAILBOX_DELEGATES,
}
}
}
/// What a delegate may do in the locked account (AL-6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
@@ -189,6 +236,9 @@ pub struct Replaced {
#[serde(rename_all = "camelCase")]
pub struct Lock {
pub account_id: u32,
/// Absent on locks written before shared mailboxes existed: a lock.
#[serde(default, skip_serializing_if = "Kind::is_lock")]
pub kind: Kind,
pub reason: String,
/// Seconds since the epoch.
pub locked_at: u64,
@@ -401,8 +451,9 @@ pub async fn all(data: &Store) -> trc::Result<Vec<Lock>> {
Ok(locks)
}
/// The accounts delegated to `delegate`, with its delegation in each.
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate)>> {
/// The accounts delegated to `delegate`, with its delegation in each and
/// the kind of lock it is in.
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate, Kind)>> {
let mut locked = Vec::new();
data.iterate(
IterateParams::new(
@@ -425,7 +476,7 @@ pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32,
if let Some(lock) = get(data, account_id).await?
&& let Some(delegation) = lock.delegate(delegate)
{
delegations.push((account_id, delegation.clone()));
delegations.push((account_id, delegation.clone(), lock.kind));
}
}
Ok(delegations)
@@ -472,6 +523,22 @@ pub async fn remove(data: &Store, lock: &Lock) -> trc::Result<()> {
mod tests {
use super::*;
#[test]
fn kind_reads_back_and_defaults_to_lock() {
// MA-S: a lock stored before shared mailboxes existed has no kind
let stored = r#"{"accountId":1,"reason":"r","lockedAt":0,"lockedBy":"admin","delegates":[]}"#;
let lock: Lock = serde_json::from_str(stored).unwrap();
assert_eq!(lock.kind, Kind::Lock);
assert!(!serde_json::to_string(&lock).unwrap().contains("kind"), "a lock is written as before");
let shared = Lock { kind: Kind::SharedMailbox, ..lock };
let written = serde_json::to_string(&shared).unwrap();
assert!(written.contains(r#""kind":"sharedMailbox""#), "{written}");
assert_eq!(serde_json::from_str::<Lock>(&written).unwrap().kind, Kind::SharedMailbox);
assert_eq!(Kind::parse("sharedMailbox"), Some(Kind::SharedMailbox));
assert_eq!(Kind::SharedMailbox.max_delegates(), MAX_SHARED_MAILBOX_DELEGATES);
}
#[test]
fn keys_read_back() {
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
@@ -509,6 +576,7 @@ mod tests {
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
Lock {
account_id: 1,
kind: Kind::Lock,
reason: "r".into(),
locked_at: 0,
locked_by: "admin".into(),
@@ -623,6 +691,7 @@ mod tests {
fn expired_delegations_grant_nothing() {
let lock = Lock {
account_id: 1,
kind: Kind::Lock,
reason: "Left the company".into(),
locked_at: 100,
locked_by: "admin".into(),
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
Loaded 100 of 390 files, more files were not shown because too many files have changed in this diff. Show more