43 Commits
Author SHA1 Message Date
jcoffey-dev 5853831bad Merge pull request 'Search: find addresses by local part, domain or name on PostgreSQL and MySQL' (#37) from fix/pg-address-search into main
ci / build (push) Failing after 3s
ci / fork-checks (push) Successful in 15s
2026-09-24 19:11:31 +00:00
jcoffey-dev 639a415a4f Search: find addresses by local part, domain or name on PostgreSQL and MySQL
ci / fork-checks (pull_request) Successful in 43s
ci / build (pull_request) Successful in 4m20s
A 3-node PostgreSQL rehearsal found IMAP SEARCH FROM "noreply" matched
0-2 messages where RocksDB matched 23 of 930. The message indexer hands
each address and display name of From/To/Cc/Bcc to the search store as
keyword text (Language::None). The built-in index splits keyword text
into lowercase runs of alphanumerics, so an address is found by its full
form, its local part, its domain or a display-name word. The SQL
backends didn't:

- PostgreSQL's text parser keeps "[email protected]" as one email
  token (host names and URLs likewise), so neither "noreply" nor
  "amazon.com" ever matched it. Keyword text is now split the same way
  as the built-in index (SpaceTokenizer) before to_tsvector on insert
  and before plainto_tsquery/phraseto_tsquery on search, still under
  the 'simple' configuration, so the GIN index keeps serving the query.
  The sort columns keep the raw text.
- MySQL's FULLTEXT parser already splits on punctuation, but InnoDB
  never indexes its stopwords ("com", "de", "www", ...) or words under
  innodb_ft_min_token_size (3), and a required +word it hasn't indexed
  matches no row. So "amazon.com", "[email protected]" or "jane doe" found
  nothing. Those words are now matched with a word-boundary REGEXP on
  the rows the indexed words select. In language text (bodies,
  subjects) they are dropped when other words remain, and only checked
  when nothing else is left, so "the invoice" no longer finds nothing
  either.

Existing PostgreSQL search indexes hold the old single-token vectors and
need a reindex (the reindexAccounts task) before address searches find
old messages. MySQL needs none: only the query changed.

store::search_tests gains test_address_search: five messages, 28
FROM/TO/CC/BCC searches by full address, local part, domain, domain
labels, display name and hyphenated local part, plus a TEXT-style OR,
with the same expected ids on every backend. It passes on RocksDB,
SQLite, PostgreSQL and MySQL; on main it fails on PostgreSQL (From
"noreply") and MySQL (From "[email protected]").
2026-09-24 11:25:25 -07:00
jcoffey-dev 9311c1a38b Merge pull request 'Coordinator: join the cluster when NATS comes up, report the connection' (#36) from fix/coordinator-retry-and-health into main
ci / build (push) Failing after 3h0m50s
ci / fork-checks (push) Successful in 40s
2026-09-24 15:51:06 +00:00
jcoffey-dev 24be4a1b85 Merge pull request 'Publish amd64 first, then arm64, on a builder that keeps its cache' (#34) from ci/faster-publish into main
ci / fork-checks (push) Successful in 53s
ci / build (push) Canceled after 5m39s
Reviewed-on: #34
2026-09-24 15:45:26 +00:00
jcoffey-dev 95f0445d83 Coordinator: join the cluster when NATS comes up, report the connection
ci / fork-checks (pull_request) Successful in 46s
ci / build (pull_request) Successful in 11m8s
A node that started while NATS was down never got a coordinator. The
connect failed at boot, bootstrap recorded a build error and the node ran
with Coordinator::None until restarted. It had no broadcast subscriber
or publisher, so cross-node push and cache invalidation to it stayed
broken, and its healthcheck said nothing about it. Losing NATS after
startup was silent too.

- The NATS client now connects in the background
  (retry_on_initial_connect): startup never waits on NATS or fails over
  it, the node gets its coordinator, subscriber and publisher at once,
  and the client keeps trying (async-nats's backoff, at most 4 s apart)
  until NATS answers. Subscriptions made meanwhile start delivering when
  it does. A configured maxReconnects still ends the attempts.
- Three new events report the connection: cluster.coordinator-connected
  (info), cluster.coordinator-disconnected (warn: lost, closed, gave up,
  or not connected within the connection timeout at startup) and
  cluster.coordinator-error (warn: a failed attempt, reported once per
  outage rather than every retry, and server errors, slow consumers and
  lame duck mode). They are in the packaged schema, ids 644 to 646.
- GET /healthz/cluster reports the coordinator: 200
  {"coordinator":"connected"}, 503 {"coordinator":"disconnected"}, or
  200 with "none" (no coordinator) or "unknown" (a backend that doesn't
  track its connection). /healthz/live and /healthz/ready are unchanged
  on purpose: a node without its coordinator still serves mail, and
  failing those would have orchestrators restart, or pull out of
  service, every node at once whenever NATS is down.

Only NATS connects lazily; the other coordinator backends still fail at
boot as before.

cluster::coordinator::coordinator_reconnect_tests starts a node against a
NATS port with nothing behind it, checks it boots with a coordinator and
reports it disconnected, subscribes, then starts NATS on that port: the
node connects on its own and the subscription receives a message from a
second client. Stopping and restarting NATS shows disconnected, then
connected, and the same subscription keeps working.
2026-09-24 08:38:59 -07:00
jcoffey-dev c974a0918e Merge pull request 'Task manager: release task locks on stop, recheck claims held elsewhere' (#35) from fix/task-lock-recovery into main
ci / build (push) Canceled after 6m47s
ci / fork-checks (push) Successful in 49s
2026-09-24 15:38:39 +00:00
jcoffey-dev 7c80a12d75 Task manager: release task locks on stop, recheck claims held elsewhere
ci / build (pull_request) Successful in 17m2s
ci / fork-checks (pull_request) Successful in 18s
A cluster rehearsal (PostgreSQL + NATS) left index tasks pending well
past the one-hour task lock after the node that claimed them was stopped
or killed. The exact cause there isn't confirmed; this closes every path
found in the task manager that stretches a takeover past the lock, or
keeps a task claimed without running it:

- A graceful stop never released the locks it held, so every task the
  node had claimed stayed blocked for an hour. The server now tracks the
  locks it holds (common::ipc::TaskLocks) and, once the shutdown signal
  arrives, stops claiming and releases them before exiting.
- A node that failed to claim a task (another node held it) set its own
  local hold for a full lock lifetime from that scan. If the holder
  claimed it just after the scan began, or ran on a clock ahead, that
  hold ran out a moment before the lock did and was set for another
  hour: two hours in all. Such claims are now tried again every five
  minutes (a twelfth of the lock lifetime), and the task manager wakes
  up for them: before, a node without a coordinator could sleep up to
  five minutes past the recheck, or until something else woke it.
- A worker that panicked took its task type down on that node for good,
  while the scan kept claiming that type's tasks and failing to hand them
  over, re-taking each lock as it expired and so starving every other
  node of them. Each batch now runs on a task of its own; a panic is
  logged, the batch's locks are released and the worker carries on. A
  failed hand-over releases the lock too.
- A claimed task the worker couldn't read, or found gone, kept its lock
  for the hour. It is released.
- An IndexDocument task for a file (not indexed) returned no result,
  which shifted every later result in the batch onto the wrong task in
  update_tasks. It returns Ignored. Nothing queues such a task today.

The lock lifetime stays one hour; it now lives per server so the tests
can shorten it.

store::task_locks::task_lock_tests plays a second node by writing its
locks straight into the in-memory store: tasks it claimed and abandoned
run here once its locks expire, including locks that outlive this node's
view of them, and a graceful stop hands this node's locks back at once
and claims nothing more. It passes on RocksDB, SQLite and PostgreSQL.
With the old recheck it fails.
2026-09-24 08:19:05 -07:00
jcoffey-dev 22d8ad8572 Publish amd64 first, then arm64, on a builder that keeps its cache
ci / fork-checks (pull_request) Successful in 49s
ci / build (pull_request) Successful in 4m30s
Two release builds side by side on one machine each take twice as long,
and production only needs amd64. publish-amd64 now pushes :<version> as
soon as the amd64 build is done; publish-arm64 builds arm64 afterwards,
then replaces :<version> with the two-platform index and moves :latest.

Both jobs use one named BuildKit builder whose container outlives the
job, so the dependency layer (cargo chef cook) is reused until the
dependencies change. The release is created after amd64; the binaries
are attached once arm64 is in.
2026-09-24 08:04:53 -07:00
jcoffey-dev 7109e67f07 Merge pull request 'Trace search: index event type and queue id as integers' (#33) from fix/pg-index-trace-types into main
ci / fork-checks (push) Successful in 30s
ci / build (push) Successful in 37m5s
2026-09-24 14:57:18 +00:00
jcoffey-dev 52b5a5f909 Mark tests/src/store/query.rs as modified by the fork
ci / fork-checks (pull_request) Successful in 1m4s
ci / build (pull_request) Successful in 4m20s
The trace document test changed an upstream file, so it carries the
AGPL section 5(a) notice (tools/fork/notice-check.py).
2026-09-24 07:39:04 -07:00
jcoffey-dev 9232662913 Trace search: index event type and queue id as integers
ci / fork-checks (pull_request) Failing after 47s
ci / build (pull_request) Successful in 4m55s
The trace index task wrote the event type (its name) and the queue id as
text, but the tracing search index types both as integers on every
backend: BIGINT on PostgreSQL and MySQL, long on Elasticsearch. On
PostgreSQL every batch holding a trace document failed with "cannot
convert between the Rust type String and the Postgres type int8", and
since a batch writes trace and email documents together, email indexing
stalled behind it.

The document is now built by trace_search_document(), which writes:

- the event type as the opening event's numeric id, the event
  x:Trace/query's event filter already matches on;
- the queue id as an integer, the first one the trace names;
- every queue id into the keywords as well, since the column holds one
  value and an SMTP session can queue several messages.

index_keyword() replaced the field on every call, so before this only the
last event type and queue id survived anyway.

x:Trace/query's queueId filter parses the id (a string, or now a number)
and matches the column or the keywords, so a session is found by any of
its queue ids on every backend. The monitoring spec says what is indexed.

Traces indexed before this on the built-in index keep their text values;
the reindexTelemetry maintenance task rebuilds them.

Tests: the search store suite builds trace documents with the index
task's code, indexes them and finds them by queue id, event type and
keyword (Sqlite, PostgreSQL, MySQL); the monitoring suite finds a real
trace by queueId through x:Trace/query.
2026-09-24 07:29:08 -07:00
jcoffey-dev 57d1c5b074 Merge pull request 'Broadcast subscriber: fix the inverted subscribe retry backoff' (#32) from fix/subscriber-backoff into main
ci / fork-checks (push) Successful in 43s
ci / build (push) Canceled after 30m24s
2026-09-24 14:26:52 +00:00
jcoffey-dev ca3abf40f0 Broadcast subscriber: fix the inverted subscribe retry backoff
ci / fork-checks (pull_request) Successful in 56s
ci / build (pull_request) Successful in 5m14s
The broadcast subscriber waited 1 << retry_count.max(6) seconds between
failed subscribe attempts. max(6) turns the cap into a floor: the first
retry waited 64 s instead of 1 s, and each later one doubled without a
bound (and would overflow the shift after enough failures).

The delay now comes from subscribe_retry_delay(), 1 s, 2 s, 4 s ... capped
at 64 s, and the retry counter saturates. A unit test pins the schedule
and the top of the range.
2026-09-24 07:01:56 -07:00
jcoffey-dev 499e4d7810 Merge pull request 'Export/import: keep archived items, spam samples and the spam model' (#31) from fix/export-all-subspaces into main
ci / fork-checks (push) Successful in 3m24s
ci / build (push) Successful in 43m50s
2026-09-23 08:50:16 +00:00
jcoffey-dev 212cd77cd3 Export/import: keep archived items, spam samples and the spam model
ci / fork-checks (pull_request) Successful in 32s
ci / build (pull_request) Successful in 7m57s
--export skipped three things, so a move from one database to another
(RocksDB to PostgreSQL, say) lost them without a word:

- archived items (subspace j), the records behind undelete;
- spam training samples (subspace w);
- the trained spam classifier and its trainer state, blobs stored under
  fixed names that no blob link points at, so the walk over links never
  reached them.

j and w now travel with the registry family, where their indexes and id
counters already were, so EXPORT_TYPES=registry keeps them consistent.
The two named blobs travel with the blob family. The file format is
unchanged and import reads any subspace it is given, so an export made
by an older binary still imports.

The full-text index (subspace z) stays out, on purpose. It belongs to one
search backend: PostgreSQL and MySQL index into their own tables and have
no z table at all, and external engines keep the index themselves. So
--import now returns the subspaces it wrote, and boot queues the
reindexAccounts and reindexTelemetry store maintenance tasks, the same
ones an administrator can queue by hand, to rebuild the index for
whichever search store the server runs with once it starts.

The round trip also turned up a loss in import itself: the SQL stores
add a negative amount with an UPDATE, which does nothing to a row that
isn't there yet, so every negative counter or quota vanished on import
into PostgreSQL, MySQL or SQLite. Import now creates the row first.

The in-memory subspaces (m, y) stay out: rate limits, locks, greylisting,
ACME challenge tokens and OAuth codes, all short-lived. Issued
certificates are registry objects and travel.

The store test now writes archived items, spam samples, directory
entries, the fork's own subspace and the named blobs, checks they come
back in place, then imports the same export into a fresh store of the
other local backend (RocksDB to SQLite, or SQLite to RocksDB), compares
it key for key and counter for counter, and checks the queued reindex.
It fails on the old export code ("Subspace j was not exported").
--help now says what an export holds.
2026-09-23 01:41:28 -07:00
jcoffey-dev 7735780807 Merge pull request 'Image build: put the vendored crate where cargo chef cooks; release 2026.9.24.3' (#30) from fix/image-vendor-before-cook into main
ci / fork-checks (push) Successful in 41s
publish / version (push) Successful in 39s
ci / build (push) Successful in 36m33s
publish / publish (push) Successful in 1h2m24s
publish / release (push) Successful in 2s
publish / binaries (push) Successful in 1m8s
Reviewed-on: #30
2026-09-23 06:10:46 +00:00
jcoffey-dev e223f7d327 Release 2026.9.24.3
ci / fork-checks (pull_request) Successful in 45s
ci / build (pull_request) Successful in 4m26s
2026.9.24.3 is 2026.9.24.2 plus the image build fix; 2026.9.24.2's tag never
published an image. Everything in 2026.9.24.2's notes applies.
2026-09-22 23:04:59 -07:00
jcoffey-dev 30df055e39 Image build: put the vendored crate where cargo chef cooks
#27 let the build context see vendor/, but the Dockerfile cooks the
dependencies before it copies the tree, from a recipe that carries only the
workspace's manifests. [patch.crates-io] points sieve-rs at vendor/, so the
cook failed the same way: failed to read /build/vendor/sieve-rs/Cargo.toml.
That's why 2026.9.24.2's publish failed. The builder stage now copies
vendor/ before cooking; a local build got past it into compiling the
dependencies.

context-check.py now also checks that each patched path is copied into the
cooking stage before the cook, and fails on the Dockerfile as it was.
2026-09-22 23:04:50 -07:00
jcoffey-dev 5393c4405a Merge pull request 'Release 2026.9.24.2' (#29) from release/2026.9.24.2 into main
ci / fork-checks (push) Successful in 50s
publish / version (push) Successful in 24s
publish / publish (push) Failing after 2m38s
publish / release (push) Skipped
publish / binaries (push) Skipped
ci / build (push) Successful in 22m35s
Reviewed-on: #29
2026-09-23 05:47:10 +00:00
jcoffey-dev cc532b914c Release 2026.9.24.2
ci / fork-checks (pull_request) Successful in 1m49s
ci / build (pull_request) Successful in 4m8s
Replaces 2026.9.24, whose tag predates the image build fix (#27) and never
published. Carries everything 2026.9.24 did -- upstream 0.16.23 and its
fixes, the scim release-profile fix -- and since then:

- identifiers renamed from the upstream name, with no aliases: the JMAP
  registry capability is urn:inbuxa:jmap:registry, WebDAV tokens
  urn:inbuxa:dav*, Sieve extensions vnd.inbuxa.*, the web interface client
  inbuxa-webui; INBUXA_* settings only. Deploy with admin and webmail
  releases that use the new names.
- the brand in lowercase where people see it.
- the spam filter rules bundled with the server; on first start they add
  the AI classifier's LLM_* scores.
- a Local AI page link in Settings › Spam Filter, for the admin release
  that draws it.
- two start-up migrations: the spam model moves to its renamed keys, and
  the web interface's old OAuth client is retired.
2026-09-22 22:40:17 -07:00
jcoffey-dev c09eff2214 Merge pull request 'Bundle the spam filter rules with the server, and link the Local AI page' (#28) from fork/bundled-spam-rules into main
ci / fork-checks (push) Successful in 20s
ci / build (push) Canceled after 7m16s
Reviewed-on: #28
2026-09-23 05:39:50 +00:00
jcoffey-dev eba4c7a32e Settings › Spam Filter gains Local AI, the AI spam filtering setup page
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 4m23s
Adds a link to CustomComponent/LocalAi in the packaged schema's Settings ›
Spam Filter, above LLM Classifier, and updates the schema hash so admins
fetch the new layout rather than a cached one.

INBUXA Admin draws the page (feature/local-ai-setup); this makes it
reachable. An admin from before that page would show "Unknown component"
here, so this lands after the admin release that carries it.
2026-09-22 22:08:50 -07:00
jcoffey-dev 17426f6d60 Bundle the spam filter rules with the server
The server fetched upstream's latest published rules from GitHub at run
time: a version nobody here tested, code-like expressions from an account
we don't control, and the upstream name as a default in the admin form.

The published rules of spam-filter v3.0.2 are now embedded
(resources/spam-filter/, MIT, in THIRD-PARTY.md) and used whenever no other
source is configured. An empty setting and upstream's old default both mean
the bundled rules, so existing installs switch without a settings change;
the URL stays an operator override (https:// or file://). The schema default
is dropped and its description says what empty means, and the strip's
rename pass does the same to each import.

Rules load on first boot as before, and again whenever the bundled version
differs from the last one loaded, which only adds missing rules and tags.
That brings the AI classifier's LLM_* scores to installs that predate them:
production has none today.

upstream-watch now also opens an issue when spam-filter publishes a newer
release; resources/spam-filter/README.md says how to take it.

The antispam test now runs on the bundled rules, the path production
takes; SPAM_RULES_URL tests another set. Unit tests cover the URL handling
and that the bundled rules parse and score the AI tags as the AI spec says.
2026-09-22 22:01:30 -07:00
jcoffey-dev d7c9416713 Merge pull request 'Let the image build see the dependency Cargo patches' (#27) from fix/vendor-in-build-context into main
ci / fork-checks (push) Successful in 14s
ci / build (push) Successful in 31m11s
2026-09-23 04:48:35 +00:00
jcoffey-dev 238079da66 Let the image build see the dependency Cargo patches
ci / fork-checks (pull_request) Successful in 49s
ci / build (pull_request) Successful in 4m22s
The rename pass vendored a patched sieve-rs and pointed Cargo.toml's
[patch.crates-io] at vendor/sieve-rs. .dockerignore ignores everything and
re-includes a short list that did not have vendor on it, so the image build
had no such directory and stopped at

    failed to load source for dependency `sieve-rs`
    failed to read /build/vendor/sieve-rs/Cargo.toml

CI could not have caught that: it builds from a checkout, where the
directory is simply there, and only the image build has a context to prune.
The first that was known about it was a tag that had already been pushed.

So: vendor is re-included, and tools/fork/context-check.py now asserts the
thing that was quietly assumed -- every path a [patch] section names exists
and survives .dockerignore. It runs beside the other fork checks and takes
no toolchain.

Also, the comments in .dockerignore started with // , which Docker does not
read as a comment: they were patterns that happened to match nothing. They
are # now.
2026-09-22 21:43:37 -07:00
jcoffey-dev 0d8caaa514 Merge pull request 'Compile the scim crate in release, and check that profile in CI' (#26) from fix/scim-recursion-limit into main
ci / fork-checks (push) Successful in 1m11s
publish / version (push) Successful in 58s
publish / publish (push) Failing after 34s
publish / release (push) Skipped
publish / binaries (push) Skipped
ci / build (push) Canceled after 25m24s
2026-09-23 04:23:07 +00:00
jcoffey-dev ce6882fe93 Merge pull request 'queue_retry test: measure retries from when each attempt started' (#25) from fix/queue-retry-test into main
ci / fork-checks (push) Canceled after 1m30s
ci / build (push) Canceled after 1m30s
Reviewed-on: #25
2026-09-23 04:21:37 +00:00
jcoffey-dev 3df042e7d4 Compile the scim crate in release, and check that profile in CI
ci / name-check (pull_request) Successful in 3m31s
ci / build (pull_request) Successful in 7m28s
v2026.9.24 was tagged on a commit CI had passed, and its release build could
not compile crates/scim at all:

  error: queries overflow the depth limit!
    = note: query depth increased by 130 when computing layout of
      {async fn body of context::<impl ...>::writable_domain()}

The crate is ours, and the failure is profile-dependent: the release profile
computes those async fn layouts in one go and goes past rustc's default query
depth, while the dev profile never gets that far. CI builds dev, so CI was
green on a commit that could not be released. The tag produced no image and
no release, which is the one merciful part.

Two changes:

- #![recursion_limit = "256"] on the crate, which is what rustc itself
  suggests, with a note saying why it only shows up in release. Proved by
  building -p scim in release locally: it now finishes.

- CI builds the release profile too, on pushes to main. Pull requests stay
  on dev, where the wait is worth less. A few minutes per merge is cheaper
  than learning this from a tag, which throws away a multi-architecture
  build and leaves a version half-cut.
2026-09-22 21:13:50 -07:00
jcoffey-dev 64385007c1 Merge pull request 'Fix the antispam test: pin the rules it scores against, stop live Pyzor' (#24) from fix/antispam-test into main
ci / fork-checks (push) Successful in 2m4s
ci / build (push) Successful in 6m26s
Reviewed-on: #24
2026-09-23 04:12:40 +00:00
jcoffey-dev 4d794c6a65 Merge pull request 'Fork/brand lowercase' (#23) from fork/brand-lowercase into main
ci / fork-checks (push) Successful in 15s
ci / build (push) Canceled after 31s
Reviewed-on: #23
2026-09-23 04:12:06 +00:00
jcoffey-dev a404ca89f0 Merge pull request 'Fork/rename upstream identifiers' (#22) from fork/rename-upstream-identifiers into main
ci / fork-checks (push) Successful in 17s
ci / build (push) Canceled after 27s
Reviewed-on: #22
2026-09-23 04:11:34 +00:00
jcoffey-dev 79f54add2f Merge pull request 'Fork tooling: a build check and a rename pass in the strip, a notice check in CI' (#21) from fork/strip-build-check-and-notices into main
ci / fork-checks (push) Successful in 48s
ci / build (push) Canceled after 1m2s
Reviewed-on: #21
2026-09-23 04:10:32 +00:00
jcoffey-dev 86bf2432a2 queue_retry test: measure retries from when each attempt started
ci / name-check (pull_request) Successful in 2m30s
ci / build (pull_request) Successful in 7m33s
The server sets a deferred recipient's next retry from its clock when the
attempt defers, in whole seconds. The test subtracted its own clock taken
when the loop next saw the message, after saving and reporting, so
whenever that lag crossed a second boundary the 2 s retry measured 1 s
and the test failed. Under load, after the other SMTP tests, that was
most runs.

It now measures from when the test started the attempt, which the
server's deferral can only follow, by under a second: each retry is its
interval or one more. Each position is still checked against its own
interval, so a wrong schedule still fails.
2026-09-22 20:55:42 -07:00
jcoffey-dev 5be578ba3c antispam test: run it serially, like the tests sharing its port
ci / name-check (pull_request) Successful in 17s
ci / build (pull_request) Successful in 7m11s
It listens on HTTP 19048, as the dkim2 DSN and report tests do. Those are
marked serial, this wasn't, so when the SMTP tests ran together (as
upstream's CI runs them) it could start beside them and requests reached
whichever server had the port: missing JMAP creates here, and 'You must
authenticate first' in dkim2_dsn_is_signed.
2026-09-22 20:55:42 -07:00
jcoffey-dev f32992ca36 Fix the antispam test: pin the rules it scores against, stop live Pyzor
ci / name-check (pull_request) Successful in 17s
ci / build (pull_request) Successful in 7m12s
It failed everywhere but upstream's machines, for two reasons:

- The spam rules, which carry every score, came from a path on an
  upstream developer's own disk. Without SPAM_RULES_URL none loaded, every
  score was 0.00 and the combined case came out ham instead of spam at
  13.70. The published rules of spam-filter v3.0.2 are now pinned beside
  the test cases (Apache-2.0 or MIT, taken as MIT; in THIRD-PARTY.md).
  SPAM_RULES_URL still overrides.
- The first combined case expects a Pyzor hit, and its digest (that of an
  empty body) wasn't among the three the test mode answers, so it went to
  a public Pyzor server: it failed offline and would drift with that
  server's counts. Test mode now answers every digest from a fixed table,
  with the empty body's added, and never reaches the network.

The test passes online and offline, alone and with the rest of the SMTP
tests. queue_retry, unrelated, still fails when it runs after the others
in one process, though it passes alone every time.
2026-09-22 20:22:29 -07:00
jcoffey-dev a993f9ab01 Write the brand in lowercase where people see it
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Successful in 5m4s
The name is inbuxa, lowercase, like the wordmark; INBUXA reads as an
acronym. The admin and webmail already changed. Here that's everything
the server shows people: the brand macro behind the protocol greetings,
the HTTP and SCIM realms, the startup banner and the calendar and contact
PRODID; the first-party OAuth client descriptions; the legacy-protocol
refusals; the default calendar and address book names and the SMTP
greeting default, in the code and the schema served to the admin
(checksum regenerated); startup and shutdown events; the User-Agent;
the sign-in and RSVP pages; the service units; the OpenAPI realm; the
crate descriptions and the README, where it's set in bold.

Identifiers that are uppercase for their own reasons stay: INBUXA_*
settings, SUBSPACE_INBUXA. So do code comments and the AGPL 5(a) notice
lines.

Tests follow: the IMAP ID name, the default collection names, the PRODID
in the iTIP fixtures and the CalDAV free-busy expectations, and the e2e
legacy-protocol refusals. The webdav, imap and jmap suites pass, so do
the unit tests of every crate touched, and 73 of 75 SMTP tests; of the
other two, antispam fails on main too, and queue_retry is a timing flake
that passes on its own.
2026-09-22 20:08:10 -07:00
jcoffey-dev 99096cdc9b Merge pull request 'Release 2026.9.24' (#20) from release/2026.9.24 into main
ci / name-check (push) Successful in 1m6s
publish / version (push) Successful in 1m3s
ci / build (push) Successful in 4m38s
publish / publish (push) Failing after 24m24s
publish / release (push) Skipped
publish / binaries (push) Skipped
2026-09-23 02:53:03 +00:00
jcoffey-dev 96ac70ad28 Release 2026.9.24
ci / name-check (pull_request) Successful in 15s
ci / build (pull_request) Successful in 7m10s
Carries upstream 0.16.23 -- the DSN, POP3, Sieve, DMARC-report, ACME and
DNSSEC-resolver fixes in its own change log -- with the files it changed
marked under AGPL section 5(a), and one upstream test dropped that the fork's
routing makes meaningless.

It is also the first release whose tag attaches binaries: a host install can
now fetch inbuxa-linux-amd64.tar.gz or inbuxa-linux-arm64.tar.gz instead of
pulling the image and copying the file out of it.
2026-09-22 19:45:19 -07:00
jcoffey-dev 835b278e66 Merge pull request 'Attach binaries to a release, for installs that are not containers' (#19) from release/binaries into main
ci / name-check (push) Successful in 44s
ci / build (push) Successful in 4m34s
2026-09-23 02:36:53 +00:00
jcoffey-dev cc6f1eb298 Rename the identifiers that carried the upstream name
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 7m53s
Everything clients, users and operators meet now carries the fork's name,
with no aliases (SPEC.md §2.4, changed here from "protocol identifiers
stay"):

- JMAP: upstream's registry capability is urn:inbuxa:jmap:registry, beside
  the fork's own urn:inbuxa:jmap.
- WebDAV lock and sync tokens are urn:inbuxa:dav*; clients resync once.
- Sieve: vnd.inbuxa.while and vnd.inbuxa.expressions. sieve-rs spells these
  into its compiler, so it's vendored (vendor/sieve-rs, 0.7.3) and patched in;
  a unit test fails if Cargo.lock ever moves past the vendored copy. The
  trusted runtime now names itself too, rather than answering sieve-rs's
  default.
- The web interface's OAuth client is inbuxa-webui. On every start the old
  stalwart-webui client is removed and any application naming it is moved
  over.
- The spam filter's blobs are INBUXA_SPAM_*; every start moves any left
  under the old keys, so a trained model survives.
- SQL stores and log files default to inbuxa, in the code and in the
  schema served to the admin (checksum regenerated).
- Settings are INBUXA_* only. A STALWART_* variable that's set where its
  INBUXA_* one isn't stops the server at startup, naming it.
- The version-upgrade messages link docs.inbuxa.org's migration page, and
  the OpenAPI description, smtp crate metadata and web-push test fixtures
  lose the name.

Kept on purpose, allowlisted with reasons: the OAuth key-derivation
contexts (renaming them would end every session and invalidate every
sealed client id) and the hashed application prefix.

Also fixes a latent start-up failure: ensure_client updated an existing
first-party client with a revision of 0, which the registry's assertion
never matches, so adding a redirect URI or changing the webmail secret
failed start-up. And the principal session test now expects
legacyProtocols (C-1, added 2026-09-21), which it had missed.

Tested: the server builds without warnings; common's 106 unit tests,
including the vendoring check; a new integration test for the two
start-up migrations; and the webdav, jmap, imap and SMTP Sieve suites.
2026-09-22 19:33:02 -07:00
jcoffey-dev 674ae5d037 Attach binaries to a release, for installs that are not containers
ci / name-check (pull_request) Successful in 43s
ci / build (pull_request) Successful in 5m25s
A release published an image and nothing else, so there was nothing for a
host install to download -- the only way to get the binary was to pull the
image and copy it out, which makes "install without Docker" depend on
Docker.

Each release now carries inbuxa-linux-amd64.tar.gz, inbuxa-linux-arm64.tar.gz
and SHA256SUMS, named as stalwart-migrator's are.

They are taken out of the image this pipeline just pushed rather than
compiled again. A second Rust build per architecture is the slowest thing
here, and it would leave two artifacts that are meant to be the same build
and only probably are. Extracting makes that identity a fact: the binary in
the tarball is the file the image runs. `docker create` starts nothing, so
copying a file out of an arm64 image on an amd64 runner needs no emulation.

One thing the extraction cannot carry: the image grants the binary
cap_net_bind_service, and a tar archive does not keep that xattr. The
release body says so, and says what to do instead -- setcap, or
AmbientCapabilities in the unit -- because a server that cannot bind 25 and
does not say why is a bad first hour.

Checked by hand against v2026.9.23 before this landed: both architectures
extract to the right ELF, and the amd64 binary runs on a bare Debian 13 with
every library resolved and reports its own version.
2026-09-22 19:31:05 -07:00
jcoffey-dev 4799d191a0 Fork tooling: a build check and a rename pass in the strip, a notice check in CI
ci / fork-checks (pull_request) Successful in 18s
ci / build (pull_request) Successful in 7m11s
strip.py compiles the stripped tree, so a dual-licensed file that only
serves an Enterprise feature fails the import instead of the merge, as
v0.16.23's tests/src/directory/issuer.rs does. Upstream's tests of the
features the fork rebuilt are expected not to compile there and are listed
in build-check-known.txt; an error anywhere else fails the run. Checked
against both imports: v0.16.22 passes with its 16 expected errors, v0.16.23
fails on issuer.rs alone. Imports the strip leaves unused are reported.

It also renames the upstream name where clients, users or operators meet
it as an identifier, from tools/fork/renames.py: wire-protocol names, the
web interface's client id, store keys, configuration defaults and the
served schema. main is renamed with the same module, so a re-import
arrives purged and those lines don't conflict.

notice-check.py fails CI when an upstream file the fork changed, measured
against the upstream branch, lacks its AGPL 5(a) notice; --fix adds it.
It runs beside the name check in a renamed fork-checks job.

Also commits v0.16.23's strip report under docs/fork/strip-reports/, which
the import in #18 left out.
2026-09-22 19:02:34 -07:00
jcoffey-dev c5bf67f1bf Merge pull request 'Merge/upstream v0.16.23' (#18) from merge/upstream-v0.16.23 into main
ci / name-check (push) Successful in 19s
ci / build (push) Successful in 37m41s
Reviewed-on: #18
2026-09-23 00:48:23 +00:00
212 changed files with 24210 additions and 612 deletions
+8 -2
View File
@@ -1,10 +1,16 @@
// Ignore everything # Ignore everything
* *
// Allow what is needed # Allow what is needed
!crates !crates
!tests !tests
!resources !resources
# The patched dependency Cargo.toml's [patch.crates-io] points at. Without
# it the build context has no vendor/, and `cargo chef cook` fails on
# "failed to load source for dependency sieve-rs" -- which CI cannot see,
# because CI builds from a checkout and only the image build has a context.
!vendor
!Cargo.lock !Cargo.lock
!Cargo.toml !Cargo.toml
+24 -3
View File
@@ -20,15 +20,26 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# The upstream name in a new string literal, typically brought in by an # What an upstream merge can bring in or leave behind without a conflict:
# upstream merge. Seconds, and needs no toolchain. tools/fork/name-check.py. # the upstream name in a new string literal, and a changed upstream file
name-check: # 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:
runs-on: light runs-on: light
container: container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
steps: steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
- run: python3 tools/fork/name-check.py - 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
build: build:
# Either runner (host1 or host2): the build needs no docker socket. # Either runner (host1 or host2): the build needs no docker socket.
@@ -61,6 +72,16 @@ jobs:
# --no-run: the workflow compiled every test target without running them, # --no-run: the workflow compiled every test target without running them,
# which catches a test that no longer builds without paying for the suite. # which catches a test that no longer builds without paying for the suite.
- run: cargo test --workspace --locked --no-run - run: cargo test --workspace --locked --no-run
# 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. A few minutes per merge is
# cheaper than finding that out from a tag, which throws away a
# multi-architecture build and leaves a version half-cut.
#
# Pull requests stay on the dev profile, where the wait is worth less.
- if: github.event_name == 'push'
run: cargo build -p inbuxa --locked --release
# Keep the cache from growing without bound: past 60 GB the target dir # Keep the cache from growing without bound: past 60 GB the target dir
# is dropped and the next build starts cold. The download cache stays. # is dropped and the next build starts cold. The download cache stays.
# Two builds (dev + test profiles) already fill ~22 GB, so the limit # Two builds (dev + test profiles) already fill ~22 GB, so the limit
+155 -14
View File
@@ -3,11 +3,28 @@
# whether a person pushed it or weekly-release.yml created it through the # whether a person pushed it or weekly-release.yml created it through the
# releases API. # releases API.
# #
# The image is multi-arch (linux/amd64, linux/arm64) as before, but built in # The image is multi-arch (linux/amd64, linux/arm64), built by two jobs on
# one buildx run on host1 instead of one native runner per architecture: the # the image-build runner rather than one buildx run for both. The Dockerfile's
# Dockerfile's builder stage runs on the build platform and cross-compiles # builder stage runs on the build platform and cross-compiles with an aarch64
# with an aarch64 linker, so only the small final stage (apt, setcap) goes # linker, so only the small final stage (apt, setcap) goes through QEMU for
# through QEMU for arm64. No digest-joining job is needed. # arm64 -- but two release builds (LTO, one codegen unit) side by side on one
# machine each take twice as long. Production runs amd64, so amd64 goes first
# and on its own:
# * publish-amd64 pushes :<version>-amd64 and :<version>, a plain amd64
# image, as soon as its build is done. A deploy can start from it.
# * publish-arm64 then builds arm64, pushes :<version>-arm64, and replaces
# :<version> with the two-platform index. :latest moves only here, so it
# never names an image without arm64.
#
# Both jobs use one BuildKit builder, `gitea-builder`, whose container
# (buildx_buildkit_gitea-builder0) and state volume stay on the runner's host
# between jobs: a job container's `buildx create` finds the existing container
# and reuses it and its cache. The dependency build (`cargo chef cook`) is
# keyed on the recipe, which only a dependency change alters, so a release
# normally compiles just the workspace. Removing that container or its volume
# costs the next release a cold build, nothing more. The planner and dependency
# layers for the build platform are shared, so arm64 also reuses what amd64
# just did where it can.
# #
# Two guards before anything is pushed: # Two guards before anything is pushed:
# * the tag must be v<brand_version!>. The version is a string in # * the tag must be v<brand_version!>. The version is a string in
@@ -62,7 +79,7 @@ jobs:
echo "version=$V" >> "$GITHUB_OUTPUT" echo "version=$V" >> "$GITHUB_OUTPUT"
echo "version $V" echo "version $V"
publish: publish-amd64:
needs: [version] needs: [version]
runs-on: docker runs-on: docker
container: container:
@@ -81,16 +98,15 @@ jobs:
test -n "$REGISTRY" && test -n "$VERSION" test -n "$REGISTRY" && test -n "$VERSION"
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; } test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY" echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
# Attestations off, as before: they add manifests of their own to the # Attestations off, as before: they add manifests of their own, and the
# index, and the index should hold the two images and nothing else. # index should hold the two images and nothing else.
- run: | - run: |
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64 \
--provenance=false --sbom=false \ --provenance=false --sbom=false \
--tag "$IMAGE:$VERSION-amd64" \
--tag "$IMAGE:$VERSION" \ --tag "$IMAGE:$VERSION" \
--tag "$IMAGE:latest" \
--push . --push .
docker buildx imagetools inspect "$IMAGE:$VERSION" docker buildx imagetools inspect "$IMAGE:$VERSION"
# Gitea keeps a container package on its owner; linking it shows it on # Gitea keeps a container package on its owner; linking it shows it on
@@ -103,11 +119,47 @@ jobs:
- if: always() - if: always()
run: docker logout "$REGISTRY" || true run: docker logout "$REGISTRY" || true
publish-arm64:
needs: [version, publish-amd64]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
DOCKER_BUILDKIT: "1"
REGISTRY: ${{ vars.REGISTRY }}
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
VERSION: ${{ needs.version.outputs.version }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: |
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
# The index is built from the two per-architecture tags rather than from
# :<version>, which by now is the amd64 image and would be read as such.
- run: |
docker buildx build \
--platform linux/arm64 \
--provenance=false --sbom=false \
--tag "$IMAGE:$VERSION-arm64" \
--push .
docker buildx imagetools create \
--tag "$IMAGE:$VERSION" \
--tag "$IMAGE:latest" \
"$IMAGE:$VERSION-amd64" "$IMAGE:$VERSION-arm64"
docker buildx imagetools inspect "$IMAGE:$VERSION"
- if: always()
run: docker logout "$REGISTRY" || true
# The weekly release creates its Release (and so the tag) first; a tag # The weekly release creates its Release (and so the tag) first; a tag
# pushed by hand has none. Either way the tag ends up with exactly one # pushed by hand has none. Either way the tag ends up with exactly one
# Release, created after the image exists so its pull instructions work. # Release, created once the amd64 image exists so its pull instructions
# work; arm64 and the binaries follow.
release: release:
needs: [version, publish] needs: [version, publish-amd64]
runs-on: light runs-on: light
container: container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
@@ -131,8 +183,97 @@ jobs:
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code != 404: raise if e.code != 404: raise
image = f"{os.environ['REGISTRY']}/{os.environ['REPO']}:{version}" image = f"{os.environ['REGISTRY']}/{os.environ['REPO']}:{version}"
body = f"Container image: `{image}` (linux/amd64, linux/arm64); also `:latest`." body = (f"Container image: `{image}` (linux/amd64, linux/arm64); also `:latest`. "
"amd64 is published first; arm64 is added to the same tag when its build "
"finishes, and `:latest` moves then.\n\n"
"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.")
data = json.dumps({"tag_name": tag, "name": f"INBUXA {version}", "body": body}).encode() data = json.dumps({"tag_name": tag, "name": f"INBUXA {version}", "body": body}).encode()
r = json.load(urllib.request.urlopen(urllib.request.Request(f"{api}/releases", data=data, headers=h))) r = json.load(urllib.request.urlopen(urllib.request.Request(f"{api}/releases", data=data, headers=h)))
print(f"created release {r['tag_name']}") print(f"created release {r['tag_name']}")
PY PY
# The binaries for a host install, taken out of the image that was just
# pushed rather than compiled again.
#
# Building them separately would mean a second Rust build per architecture
# -- the slowest thing this pipeline does -- and would leave two artifacts
# that are supposed to be the same build but only probably are. Extracting
# them makes that identity a fact: the binary in the tarball is the file
# the image runs.
#
# `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:
needs: [version, publish-arm64, release]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
REGISTRY: ${{ vars.REGISTRY }}
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
VERSION: ${{ needs.version.outputs.version }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: take the binaries out of the image
run: |
set -euo pipefail
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
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
- name: attach them to the release
run: |
set -euo pipefail
apk add --no-cache -q python3
python3 - <<'PY'
import json, os, urllib.request, urllib.error, uuid, pathlib
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
tok = {"Authorization": f"token {os.environ['TOKEN']}"}
tag = os.environ["TAG"]
def get(path):
return json.load(urllib.request.urlopen(urllib.request.Request(api + path, headers=tok)))
rel = get(f"/releases/tags/{tag}")
assets = {a["name"]: a["id"] for a in get(f"/releases/{rel['id']}/assets")}
for path in ["/out/inbuxa-linux-amd64.tar.gz", "/out/inbuxa-linux-arm64.tar.gz", "/out/SHA256SUMS"]:
name = os.path.basename(path)
# A re-run of a tag replaces its assets rather than leaving two
# files with the same name and different contents.
if name in assets:
urllib.request.urlopen(urllib.request.Request(
f"{api}/releases/{rel['id']}/assets/{assets[name]}", headers=tok, method="DELETE"))
boundary = uuid.uuid4().hex
body = b"".join([
f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n".encode(),
b"Content-Type: application/octet-stream\r\n\r\n",
pathlib.Path(path).read_bytes(),
f"\r\n--{boundary}--\r\n".encode(),
])
req = urllib.request.Request(
f"{api}/releases/{rel['id']}/assets?name={name}", data=body, method="POST",
headers={**tok, "Content-Type": f"multipart/form-data; boundary={boundary}"})
urllib.request.urlopen(req)
print("attached", name)
PY
- if: always()
run: docker logout "$REGISTRY" || true
+35 -1
View File
@@ -12,6 +12,9 @@
# An issue is opened once per release: an existing one with the same title, # An issue is opened once per release: an existing one with the same title,
# open or closed, stops a second. # open or closed, stops a second.
# #
# It also watches spam-filter, whose rules the server bundles
# (resources/spam-filter/), and opens an issue for a newer release.
#
# Daily 06:17 UTC; run it by hand with workflow_dispatch. # Daily 06:17 UTC; run it by hand with workflow_dispatch.
name: upstream-watch name: upstream-watch
@@ -64,7 +67,7 @@ jobs:
and key(r["tag_name"]) > key(base)), and key(r["tag_name"]) > key(base)),
key=lambda r: key(r["tag_name"])) key=lambda r: key(r["tag_name"]))
if not newer: if not newer:
print(f"Up to date: {base} is the newest upstream release."); sys.exit(0) print(f"Up to date: {base} is the newest upstream release.")
# Titles and bodies stay free of the upstream project's name, as the # Titles and bodies stay free of the upstream project's name, as the
# rest of the fork's user-visible text does. # rest of the fork's user-visible text does.
@@ -85,4 +88,35 @@ jobs:
"add any new third-party notices to `THIRD-PARTY.md`, then merge `upstream` into `main`.") "add any new third-party notices to `THIRD-PARTY.md`, then merge `upstream` into `main`.")
issue = call("POST", f"{api}/issues", {"title": title, "body": body}) issue = call("POST", f"{api}/issues", {"title": title, "body": body})
print(f"{tag}: opened #{issue['number']}.") print(f"{tag}: opened #{issue['number']}.")
# The spam filter rules bundled with the server (resources/spam-filter/):
# an issue when spam-filter publishes a newer release than the one
# BUNDLED_SPAM_RULES_VERSION names on main.
src = call("GET", f"{api}/contents/crates/common/src/manager/spam_rules.rs?ref=main")
import base64
text = base64.b64decode(src["content"]).decode()
m = re.search(r'BUNDLED_SPAM_RULES_VERSION: &str = "(\d+\.\d+\.\d+)"', text)
if not m:
print("Can't read BUNDLED_SPAM_RULES_VERSION from spam_rules.rs", file=sys.stderr); sys.exit(1)
bundled = "v" + m.group(1)
rels = call("GET", "https://api.github.com/repos/stalwartlabs/spam-filter/releases?per_page=30", token=None)
newer = sorted((r for r in rels
if not r["draft"] and not r["prerelease"] and SEMVER.match(r["tag_name"])
and key(r["tag_name"]) > key(bundled)),
key=lambda r: key(r["tag_name"]))
if not newer:
print(f"Up to date: the bundled spam rules are {bundled}, the newest release."); sys.exit(0)
latest = newer[-1]
tag = latest["tag_name"]
title = f"Update the bundled spam rules to {tag}"
existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues&q=bundled+spam+rules&limit=50")}
if title in existing:
print(f"spam rules {tag}: issue already exists."); sys.exit(0)
body = (f"spam-filter published {tag} on {latest['published_at'][:10]}. "
f"The server bundles {bundled}.\n\n"
"Update it as resources/spam-filter/README.md describes: take the rules file "
f"from the {tag} release (by tag, not `latest`), set BUNDLED_SPAM_RULES_VERSION, "
"and run the antispam test.")
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
print(f"spam rules {tag}: opened #{issue['number']}.")
PY PY
Generated
+1 -2
View File
@@ -7958,8 +7958,6 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]] [[package]]
name = "sieve-rs" name = "sieve-rs"
version = "0.7.3" version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd00a548fde57bd0c8e7c13ae65fc5fe30bd923ff8655c81e010f3f02a90997b"
dependencies = [ dependencies = [
"ahash", "ahash",
"arc-swap", "arc-swap",
@@ -8590,6 +8588,7 @@ dependencies = [
"mail-builder 1.0.0", "mail-builder 1.0.0",
"mail-parser", "mail-parser",
"managesieve", "managesieve",
"migration",
"nlp", "nlp",
"pop3", "pop3",
"quick-xml 0.41.0", "quick-xml 0.41.0",
+9
View File
@@ -1,5 +1,7 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
# Vendored crates are patched in below, not built as members.
exclude = ["vendor"]
members = [ members = [
"crates/main", "crates/main",
"crates/types", "crates/types",
@@ -78,3 +80,10 @@ incremental = false
debug-assertions = false debug-assertions = false
overflow-checks = false overflow-checks = false
rpath = false rpath = false
# inbuxa: sieve-rs spells upstream's name into its Sieve extension names
# (vnd.stalwart.*), which scripts `require` and ManageSieve advertises.
# vendor/sieve-rs is the published 0.7.3 with those renamed; see its
# VENDORED.md. Re-vendor when the version in Cargo.lock moves.
[patch.crates-io]
sieve-rs = { path = "vendor/sieve-rs" }
+4
View File
@@ -19,6 +19,10 @@ RUN export DEBIAN_FRONTEND=noninteractive && \
g++-x86-64-linux-gnu binutils-x86-64-linux-gnu g++-x86-64-linux-gnu binutils-x86-64-linux-gnu
RUN rustup target add "$(cat /target.txt)" RUN rustup target add "$(cat /target.txt)"
COPY --from=planner /recipe.json /recipe.json COPY --from=planner /recipe.json /recipe.json
# inbuxa: [patch.crates-io] points sieve-rs at vendor/, and the recipe only
# carries the workspace's own manifests, so cooking the dependencies needs the
# vendored crate itself (the context allows it since #27; this puts it here).
COPY vendor/ vendor/
RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json
COPY . . COPY . .
RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
+8 -7
View File
@@ -8,13 +8,13 @@
--- ---
**INBUXA** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP, **inbuxa** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart). end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
Project site: [inbuxa.org](https://inbuxa.org). Documentation: [docs.inbuxa.org](https://docs.inbuxa.org). Project site: [inbuxa.org](https://inbuxa.org). Documentation: [docs.inbuxa.org](https://docs.inbuxa.org).
Stalwart ships some features only in a paid Enterprise Edition: multi-tenancy, Stalwart ships some features only in a paid Enterprise Edition: multi-tenancy,
masked email, undelete and others. INBUXA ships everything to everybody under masked email, undelete and others. **inbuxa** ships everything to everybody under
the AGPL-3.0, rebuilding those features independently and without using any the AGPL-3.0, rebuilding those features independently and without using any
of Stalwart's Enterprise code. of Stalwart's Enterprise code.
@@ -46,25 +46,26 @@ docker build -t inbuxa . # or the container image
``` ```
Settings are read from `INBUXA_*` environment variables. An existing Stalwart Settings are read from `INBUXA_*` environment variables. An existing Stalwart
install's `STALWART_*` variables still work, with a warning to rename them. install's `STALWART_*` variables aren't read: the server stops at startup and
names each one to rename.
New installs keep their data in `/var/lib/inbuxa` and logs in New installs keep their data in `/var/lib/inbuxa` and logs in
`/var/log/inbuxa`. Existing installs keep the paths their configuration `/var/log/inbuxa`. Existing installs keep the paths their configuration
already names, so none of their data moves. already names, so none of their data moves.
## License and credits ## License and credits
INBUXA is free software under the [GNU Affero General Public License, **inbuxa** is free software under the [GNU Affero General Public License,
version 3](./LICENSES/AGPL-3.0-only.txt). version 3](./LICENSES/AGPL-3.0-only.txt).
It is a fork of Stalwart, copyright © Stalwart Labs LLC, **modified by It is a fork of Stalwart, copyright © Stalwart Labs LLC, **modified by
Coffey Labs in 2026**. Upstream's copyright notices are kept on every file Coffey Labs in 2026**. Upstream's copyright notices are kept on every file
they cover, and every upstream file this fork changed says so in its header, they cover, and every upstream file this fork changed says so in its header,
under the notice it came with. Stalwart's files are dual-licensed under the notice it came with. Stalwart's files are dual-licensed
AGPL-3.0-only or Stalwart's Enterprise License, and INBUXA takes them under AGPL-3.0-only or Stalwart's Enterprise License, and **inbuxa** takes them under
the AGPL-3.0 only. A few of those files also carry code from other projects the AGPL-3.0 only. A few of those files also carry code from other projects
under MIT or BSD licenses, which stays under those licenses; under MIT or BSD licenses, which stays under those licenses;
[THIRD-PARTY.md](./THIRD-PARTY.md) lists it with its notices. "Stalwart" is [THIRD-PARTY.md](./THIRD-PARTY.md) lists it with its notices. "Stalwart" is
Stalwart Labs' name. INBUXA isn't affiliated with or endorsed by Stalwart Stalwart Labs' name. **inbuxa** isn't affiliated with or endorsed by Stalwart
Labs. Labs.
The INBUXA mark reuses ihasmail's cat-and-envelope artwork. The **inbuxa** mark reuses ihasmail's cat-and-envelope artwork.
+1
View File
@@ -24,6 +24,7 @@ carry their own license files.
| `crates/common/src/network/acme/directory.rs`, `crates/common/src/network/acme/jose.rs`, `crates/common/src/network/acme/order.rs` | [rustls-acme](https://github.com/FlorianUekermann/rustls-acme) (MIT or Apache-2.0) | Copyright (c) Florian Uekermann | | `crates/common/src/network/acme/directory.rs`, `crates/common/src/network/acme/jose.rs`, `crates/common/src/network/acme/order.rs` | [rustls-acme](https://github.com/FlorianUekermann/rustls-acme) (MIT or Apache-2.0) | Copyright (c) Florian Uekermann |
| `crates/types/src/id.rs` | [crockford](https://github.com/archer884/crockford) (MIT or Apache-2.0) | Copyright (c) 2017 J/A <archer884@gmail.com> | | `crates/types/src/id.rs` | [crockford](https://github.com/archer884/crockford) (MIT or Apache-2.0) | Copyright (c) 2017 J/A <archer884@gmail.com> |
| `crates/nlp/src/tokenizers/types.rs` | test cases from [linkify](https://github.com/robinst/linkify) (MIT or Apache-2.0) | Copyright (c) 2017 Robin Stocker | | `crates/nlp/src/tokenizers/types.rs` | test cases from [linkify](https://github.com/robinst/linkify) (MIT or Apache-2.0) | Copyright (c) 2017 Robin Stocker |
| `resources/spam-filter/spam-filter-rules.json.gz` | the published rules of [spam-filter](https://github.com/stalwartlabs/spam-filter) v3.0.2, unmodified, built into the server as its default spam rules (MIT or Apache-2.0) | Copyright (C) 2024, Stalwart Labs LLC |
Each notice above applies with this permission notice: Each notice above applies with this permission notice:
+7 -7
View File
@@ -1,8 +1,8 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Stalwart Management API title: inbuxa Management API
description: | description: |
REST Management API for Stalwart server. These endpoints are helpers REST Management API for the inbuxa server. These endpoints are helpers
that complement the JMAP API — most of the server's configuration and data that complement the JMAP API — most of the server's configuration and data
is managed via JMAP (see `POST /jmap/`). The endpoints documented here cover is managed via JMAP (see `POST /jmap/`). The endpoints documented here cover
interactive login, account introspection, configuration schema retrieval and interactive login, account introspection, configuration schema retrieval and
@@ -12,11 +12,11 @@ info:
name: AGPL-3.0-only OR LicenseRef-SEL name: AGPL-3.0-only OR LicenseRef-SEL
servers: servers:
- url: https://{host} - url: https://{host}
description: Stalwart server description: inbuxa server
variables: variables:
host: host:
default: mail.example.com default: mail.example.com
description: The hostname of Stalwart server description: The hostname of the inbuxa server
security: security:
- bearerAuth: [] - bearerAuth: []
- basicAuth: [] - basicAuth: []
@@ -154,7 +154,7 @@ paths:
operationId: getSchema operationId: getSchema
summary: Return the configuration schema at a specific hash summary: Return the configuration schema at a specific hash
description: | description: |
Returns the JSON Schema describing the full Stalwart configuration tree. Returns the JSON Schema describing the full inbuxa configuration tree.
The response is always gzip-encoded (`Content-Encoding: gzip`) and served The response is always gzip-encoded (`Content-Encoding: gzip`) and served
with an immutable cache policy — the schema for a given hash never with an immutable cache policy — the schema for a given hash never
changes. If the hash does not match the server's current schema, the changes. If the hash does not match the server's current schema, the
@@ -183,7 +183,7 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
description: JSON Schema document describing Stalwart config description: JSON Schema document describing inbuxa config
additionalProperties: true additionalProperties: true
'302': '302':
description: Redirect to the current schema URL when the hash is stale description: Redirect to the current schema URL when the hash is stale
@@ -395,7 +395,7 @@ components:
WWW-Authenticate: WWW-Authenticate:
schema: schema:
type: string type: string
example: Bearer realm="Stalwart Server" example: Bearer realm="inbuxa Server"
content: content:
application/problem+json: application/problem+json:
schema: schema:
+23 -1
View File
@@ -143,7 +143,9 @@ impl Scripting {
.with_cpu_limit(trusted.max_cpu_cycles as usize) .with_cpu_limit(trusted.max_cpu_cycles as usize)
.with_max_nested_includes(trusted.max_nested_includes as usize) .with_max_nested_includes(trusted.max_nested_includes as usize)
.with_max_received_headers(trusted.max_received_headers as usize) .with_max_received_headers(trusted.max_received_headers as usize)
.with_default_duplicate_expiry(trusted.duplicate_expiry.into_inner().as_secs()); .with_default_duplicate_expiry(trusted.duplicate_expiry.into_inner().as_secs())
// inbuxa: without it, `environment "name"` answers sieve-rs's default
.with_env_variable("name", types::brand_server!());
trusted_runtime.set_local_hostname(local_hostname.clone()); trusted_runtime.set_local_hostname(local_hostname.clone());
untrusted_runtime.set_local_hostname(local_hostname); untrusted_runtime.set_local_hostname(local_hostname);
@@ -279,3 +281,23 @@ impl Clone for Scripting {
} }
} }
} }
#[cfg(test)]
mod tests {
use sieve::compiler::grammar::Capability;
// inbuxa: sieve-rs is vendored (vendor/sieve-rs) to carry the fork's
// name in its Sieve extensions. If Cargo.lock moves sieve-rs past the
// vendored version, Cargo drops the patch with only a warning and
// upstream's spelling comes back; this fails instead.
#[test]
fn sieve_extensions_carry_the_fork_name() {
for (capability, name) in [
(Capability::While, "vnd.inbuxa.while"),
(Capability::Expressions, "vnd.inbuxa.expressions"),
] {
assert_eq!(capability.to_string(), name);
assert_eq!(Capability::parse(name), capability);
}
}
}
@@ -243,7 +243,8 @@ impl SpamFilterConfig {
spam_threshold: spam.score_spam.into_inner() as f32, spam_threshold: spam.score_spam.into_inner() as f32,
}, },
grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()), grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()),
spam_rules_url: spam.spam_filter_rules_url, // inbuxa: unset, empty or upstream's old default means the bundled rules
spam_rules_url: crate::manager::spam_rules::rules_url(spam.spam_filter_rules_url),
url_client: utils::http::http_client_builder(true) url_client: utils::http::http_client_builder(true)
.pool_max_idle_per_host(0) .pool_max_idle_per_host(0)
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
+54
View File
@@ -335,3 +335,57 @@ impl EmailPush {
} }
} }
} }
/// inbuxa: the task locks this node holds, so a graceful stop can hand them
/// back instead of leaving the tasks blocked until the locks expire.
pub struct TaskLocks {
held: parking_lot::Mutex<ahash::AHashSet<u64>>,
stopping: AtomicBool,
expiry: std::sync::atomic::AtomicU64,
}
impl TaskLocks {
/// How long a task lock lasts, in seconds, unless it is released first.
pub const DEFAULT_EXPIRY: u64 = 60 * 60;
pub fn is_stopping(&self) -> bool {
self.stopping.load(Ordering::Acquire)
}
/// Stops new claims and returns the ids of every lock still held.
pub fn stop(&self) -> Vec<u64> {
self.stopping.store(true, Ordering::Release);
self.held.lock().drain().collect()
}
pub fn insert(&self, id: u64) {
self.held.lock().insert(id);
}
pub fn remove(&self, id: u64) {
self.held.lock().remove(&id);
}
pub fn held(&self) -> usize {
self.held.lock().len()
}
pub fn expiry(&self) -> u64 {
self.expiry.load(Ordering::Relaxed)
}
/// Changes the lock lifetime; the tests shorten it.
pub fn set_expiry(&self, seconds: u64) {
self.expiry.store(seconds.max(1), Ordering::Relaxed);
}
}
impl Default for TaskLocks {
fn default() -> Self {
Self {
held: Default::default(),
stopping: AtomicBool::new(false),
expiry: std::sync::atomic::AtomicU64::new(Self::DEFAULT_EXPIRY),
}
}
}
+2
View File
@@ -279,6 +279,8 @@ pub struct HttpAuthCache {
pub struct Ipc { pub struct Ipc {
pub push_tx: mpsc::Sender<PushEvent>, pub push_tx: mpsc::Sender<PushEvent>,
pub task_tx: Arc<Notify>, pub task_tx: Arc<Notify>,
// inbuxa: task locks held by this node, released on a graceful stop
pub task_locks: Arc<crate::ipc::TaskLocks>,
pub queue_tx: mpsc::Sender<QueueEvent>, pub queue_tx: mpsc::Sender<QueueEvent>,
pub report_tx: mpsc::Sender<ReportingEvent>, pub report_tx: mpsc::Sender<ReportingEvent>,
pub broadcast_tx: Option<mpsc::Sender<BroadcastEvent>>, pub broadcast_tx: Option<mpsc::Sender<BroadcastEvent>>,
+3 -3
View File
@@ -514,12 +514,12 @@ mod tests {
#[test] #[test]
fn index_is_rewritten_with_the_prefix_and_client_id() { fn index_is_rewritten_with_the_prefix_and_client_id() {
let meta = oauth_client_id_meta("stalwart-webui"); let meta = oauth_client_id_meta("inbuxa-webui");
let html = String::from_utf8(rewrite_index(INDEX, "admin", Some(&meta))).unwrap(); let html = String::from_utf8(rewrite_index(INDEX, "admin", Some(&meta))).unwrap();
assert!(html.contains("<base href=\"/admin/\" />"), "{html}"); assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
assert!( assert!(
html.contains("<meta name=\"oauth-client-id\" content=\"stalwart-webui\" />"), html.contains("<meta name=\"oauth-client-id\" content=\"inbuxa-webui\" />"),
"{html}" "{html}"
); );
assert!(html.contains("<title>Portal</title>"), "{html}"); assert!(html.contains("<title>Portal</title>"), "{html}");
@@ -541,7 +541,7 @@ mod tests {
#[test] #[test]
fn index_without_a_placeholder_is_left_alone() { fn index_without_a_placeholder_is_left_alone() {
let bundle = "<head>\n <base href=\"/\" />\n</head>"; let bundle = "<head>\n <base href=\"/\" />\n</head>";
let meta = oauth_client_id_meta("stalwart-webui"); let meta = oauth_client_id_meta("inbuxa-webui");
let html = String::from_utf8(rewrite_index(bundle, "admin", Some(&meta))).unwrap(); let html = String::from_utf8(rewrite_index(bundle, "admin", Some(&meta))).unwrap();
assert_eq!(html, "<head>\n <base href=\"/admin/\" />\n</head>"); assert_eq!(html, "<head>\n <base href=\"/admin/\" />\n</head>");
+25 -6
View File
@@ -23,6 +23,13 @@ use utils::{UnwrapFailure, codec::leb128::Leb128_};
pub(super) const MAGIC_MARKER: u8 = 123; pub(super) const MAGIC_MARKER: u8 = 123;
// inbuxa: blobs kept under a fixed name instead of a content hash. Nothing
// links to them, so the export names them outright.
const NAMED_BLOBS: &[&[u8]] = &[
crate::manager::SPAM_CLASSIFIER_KEY,
crate::manager::SPAM_TRAINER_KEY,
];
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
pub(super) enum Family { pub(super) enum Family {
Data = 0, Data = 0,
@@ -143,15 +150,21 @@ impl Core {
.await .await
.failed("Failed to iterate over data store"); .failed("Failed to iterate over data store");
for hash in blobs { // inbuxa: the trained spam classifier and its trainer state are
// blobs stored under fixed names with no blob link, so the walk
// over links above never reaches them.
let named = NAMED_BLOBS.iter().map(|key| key.to_vec());
for key in blobs
.into_iter()
.map(|hash| hash.as_slice().to_vec())
.chain(named)
{
if let Some(blob) = blob_store if let Some(blob) = blob_store
.get_blob(hash.as_slice(), 0..usize::MAX) .get_blob(&key, 0..usize::MAX)
.await .await
.failed("Failed to get blob") .failed("Failed to get blob")
{ {
writer writer.send((key, blob)).failed("Failed to send key");
.send((hash.as_slice().to_vec(), blob))
.failed("Failed to send key");
} }
} }
}), }),
@@ -323,7 +336,13 @@ impl Family {
SUBSPACE_REGISTRY_IDX, SUBSPACE_REGISTRY_IDX,
SUBSPACE_REGISTRY_PK, SUBSPACE_REGISTRY_PK,
SUBSPACE_DIRECTORY, SUBSPACE_DIRECTORY,
store::SUBSPACE_INBUXA, // inbuxa: masked email // inbuxa: registry objects the upstream list left out, so an
// export dropped them: archived items (undelete) and spam
// training samples. Their indexes and id counters already
// travel in this family and in `data`, so they ride along.
SUBSPACE_DELETED_ITEMS,
SUBSPACE_SPAM_SAMPLES,
store::SUBSPACE_INBUXA, // inbuxa: the fork's own data (masked email, undelete, policies)
], ],
Family::Changelog => &[SUBSPACE_LOGS], Family::Changelog => &[SUBSPACE_LOGS],
Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT], Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT],
+12 -4
View File
@@ -54,6 +54,13 @@ Options:
-o, --console Open the store console -o, --console Open the store console
-h, --help Print help -h, --help Print help
-V, --version Print version -V, --version Print version
An export holds everything in the data and blob stores except short-lived
in-memory state (rate limits, locks, greylisting) and the full-text search
index, which belongs to one search backend. An import into an empty store
queues the index to be rebuilt when the server next starts. EXPORT_TYPES
limits an export to some of: data, registry, blob, changelog, queue, report,
telemetry, tasks.
"# "#
); );
@@ -256,10 +263,10 @@ impl BootManager {
telemetry.enable(); telemetry.enable();
// Parse settings and restore // Parse settings and restore
Box::pin(Core::parse(&mut bootstrap, storage)) let core = Box::pin(Core::parse(&mut bootstrap, storage)).await;
.await let imported = core.restore(path).await;
.restore(path) // inbuxa: the search index isn't exported; rebuild it
.await; core.queue_reindex(&imported).await;
std::process::exit(0); std::process::exit(0);
} }
StoreOp::Console => { StoreOp::Console => {
@@ -290,6 +297,7 @@ pub fn build_ipc(has_pubsub: bool) -> (Ipc, IpcReceivers) {
report_tx, report_tx,
broadcast_tx: has_pubsub.then_some(broadcast_tx), broadcast_tx: has_pubsub.then_some(broadcast_tx),
task_tx: Arc::new(Notify::new()), task_tx: Arc::new(Notify::new()),
task_locks: Arc::new(crate::ipc::TaskLocks::default()),
train_task_controller: Arc::new(TrainTaskController::default()), train_task_controller: Arc::new(TrainTaskController::default()),
}, },
IpcReceivers { IpcReceivers {
+14 -5
View File
@@ -530,13 +530,22 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
use store::write::BatchBuilder; use store::write::BatchBuilder;
use types::id::Id; use types::id::Id;
if bp.registry.count_object(ObjectType::SpamRule).await? == 0 // inbuxa: rules are always to hand, since a copy ships with the server
&& bp // (spam_rules). They load on first boot, and again when the bundled
.registry // version differs from the one last loaded, which only adds what's
// missing: new tags and rules, never a changed score.
let rules_url = super::spam_rules::rules_url(
bp.registry
.object::<SpamSettings>(Id::singleton()) .object::<SpamSettings>(Id::singleton())
.await? .await?
.is_none_or(|spam| spam.spam_filter_rules_url.is_some()) .and_then(|spam| spam.spam_filter_rules_url),
{ );
let bundled_is_new = rules_url.is_none()
&& super::spam_rules::applied_version(&bp.data_store)
.await?
.as_deref()
!= Some(super::spam_rules::BUNDLED_SPAM_RULES_VERSION);
if bp.registry.count_object(ObjectType::SpamRule).await? == 0 || bundled_is_new {
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance { batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules, maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules,
+68 -10
View File
@@ -10,7 +10,7 @@
//! that ship with it are registered for it, on every start: //! that ship with it are registered for it, on every start:
//! //!
//! - the web interface the server serves itself (`Application`, `/admin` and //! - the web interface the server serves itself (`Application`, `/admin` and
//! `/account`), as its OAuth client id, `stalwart-webui` unless the //! `/account`), as its OAuth client id, `inbuxa-webui` unless the
//! application names another; //! application names another;
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL` //! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
//! is set; //! is set;
@@ -29,7 +29,7 @@ use directory::core::secret::{hash_secret, verify_secret_hash};
use registry::{ use registry::{
schema::{ schema::{
enums::{PasswordHashAlgorithm, ServiceProtocol}, enums::{PasswordHashAlgorithm, ServiceProtocol},
prelude::{ObjectType, Property, UTCDateTime}, prelude::{Object, ObjectInner, ObjectType, Property, UTCDateTime},
structs::{Application, OAuthClient, SystemSettings}, structs::{Application, OAuthClient, SystemSettings},
}, },
types::map::Map, types::map::Map,
@@ -40,9 +40,12 @@ use store::registry::{
}; };
/// The client id the upstream web interface uses when its application names none. /// The client id the upstream web interface uses when its application names none.
pub const WEB_INTERFACE_CLIENT_ID: &str = "stalwart-webui"; pub const WEB_INTERFACE_CLIENT_ID: &str = "inbuxa-webui";
pub const ADMIN_CLIENT_ID: &str = "inbuxa-admin"; pub const ADMIN_CLIENT_ID: &str = "inbuxa-admin";
pub const WEBMAIL_CLIENT_ID: &str = "ihasmail-inbuxa"; pub const WEBMAIL_CLIENT_ID: &str = "ihasmail-inbuxa";
/// The web interface's client id before the fork renamed it (SPEC §2.4).
/// Only ever read to retire it.
const LEGACY_WEB_INTERFACE_CLIENT_ID: &str = "stalwart-webui";
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirstPartyClient { pub struct FirstPartyClient {
@@ -102,7 +105,7 @@ pub fn first_party_clients(
if let Some(url) = admin_url.map(|url| url.trim().trim_end_matches('/')).filter(|url| !url.is_empty()) { if let Some(url) = admin_url.map(|url| url.trim().trim_end_matches('/')).filter(|url| !url.is_empty()) {
clients.push(FirstPartyClient { clients.push(FirstPartyClient {
client_id: ADMIN_CLIENT_ID.to_string(), client_id: ADMIN_CLIENT_ID.to_string(),
description: "INBUXA Admin".to_string(), description: "inbuxa Admin".to_string(),
redirect_uris: vec![format!("{url}/oauth/callback")], redirect_uris: vec![format!("{url}/oauth/callback")],
secret: None, secret: None,
}); });
@@ -187,6 +190,7 @@ fn env(name: &str) -> Option<String> {
} }
pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> { pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> {
retire_legacy_web_interface_client(bp).await?;
let system = bp.setting_infallible::<SystemSettings>().await; let system = bp.setting_infallible::<SystemSettings>().await;
let base_url = base_url(bp, &system); let base_url = base_url(bp, &system);
let applications = bp let applications = bp
@@ -213,6 +217,56 @@ pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Resul
Ok(()) Ok(())
} }
/// An install from before the rename, upstream's or this fork's, has the web
/// interface registered as `stalwart-webui`, and may
/// have an application naming it. The application is moved to the current id
/// and the old client removed, so the old id stops working rather than
/// living on as an alias; anyone signed in to the web interface signs in
/// again. Runs on every start and does nothing once both are gone.
async fn retire_legacy_web_interface_client(bp: &mut Bootstrap) -> trc::Result<()> {
for app in bp.list_infallible::<Application>().await {
if app.object.oauth_client_id.as_deref() != Some(LEGACY_WEB_INTERFACE_CLIENT_ID) {
continue;
}
let mut updated = app.object.clone();
updated.oauth_client_id = Some(WEB_INTERFACE_CLIENT_ID.to_string());
// The old object carries its revision: the write asserts on it.
let current = Object::with_revision(ObjectInner::from(app.object), app.revision);
let result = bp
.registry
.write(RegistryWrite::update(app.id.id(), &updated.into(), &current))
.await?;
if !matches!(result, RegistryWriteResult::Success(_)) {
return Err(trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to move an application to the renamed web interface client.")
.reason(result.to_string())
.caused_by(trc::location!()));
}
}
if let Some(object_id) = bp
.registry
.primary_key(
ObjectType::OAuthClient.into(),
Property::ClientId,
LEGACY_WEB_INTERFACE_CLIENT_ID.as_bytes().to_vec(),
)
.await?
{
let result = bp.registry.write(RegistryWrite::delete(object_id)).await?;
if !matches!(result, RegistryWriteResult::Success(_)) {
return Err(trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to remove the web interface's pre-rename OAuth client.")
.reason(result.to_string())
.caused_by(trc::location!()));
}
}
Ok(())
}
async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Result<()> { async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Result<()> {
let existing = match bp let existing = match bp
.registry .registry
@@ -223,15 +277,18 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res
) )
.await? .await?
{ {
// inbuxa: read as an Object, keeping the revision the update below
// asserts on (a bare OAuthClient converts back with revision 0, which
// never matches, so any update failed start-up).
Some(object_id) => bp Some(object_id) => bp
.registry .registry
.object::<OAuthClient>(object_id.id()) .get(object_id)
.await? .await?
.map(|object| (object_id.id(), object)), .map(|object| (object_id.id(), object.revision, OAuthClient::from(object))),
None => None, None => None,
}; };
let result = if let Some((id, current)) = existing { let result = if let Some((id, revision, current)) = existing {
let mut updated = current.clone(); let mut updated = current.clone();
for uri in &client.redirect_uris { for uri in &client.redirect_uris {
if !updated.redirect_uris.contains(uri) { if !updated.redirect_uris.contains(uri) {
@@ -255,8 +312,9 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res
if updated == current { if updated == current {
return Ok(()); return Ok(());
} }
let current = Object::with_revision(ObjectInner::from(current), revision);
bp.registry bp.registry
.write(RegistryWrite::update(id, &updated.into(), &current.into())) .write(RegistryWrite::update(id, &updated.into(), &current))
.await? .await?
} else { } else {
let secret = match &client.secret { let secret = match &client.secret {
@@ -298,7 +356,7 @@ mod tests {
fn web_interface() -> Application { fn web_interface() -> Application {
Application { Application {
description: "INBUXA Web Interface".to_string(), description: "inbuxa Web Interface".to_string(),
enabled: true, enabled: true,
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]), url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
..Default::default() ..Default::default()
@@ -312,7 +370,7 @@ mod tests {
clients, clients,
vec![FirstPartyClient { vec![FirstPartyClient {
client_id: WEB_INTERFACE_CLIENT_ID.to_string(), client_id: WEB_INTERFACE_CLIENT_ID.to_string(),
description: "INBUXA Web Interface (served by this server)".to_string(), description: "inbuxa Web Interface (served by this server)".to_string(),
redirect_uris: vec![ redirect_uris: vec![
"https://mail.example.org/admin/oauth/callback".to_string(), "https://mail.example.org/admin/oauth/callback".to_string(),
"https://mail.example.org/account/oauth/callback".to_string(), "https://mail.example.org/account/oauth/callback".to_string(),
+3 -2
View File
@@ -22,9 +22,10 @@ pub mod console;
pub mod defaults; pub mod defaults;
pub mod first_party; pub mod first_party;
pub mod restore; pub mod restore;
pub mod spam_rules; // inbuxa: rules bundled with the server
pub const SPAM_TRAINER_KEY: &[u8] = "STALWART_SPAM_TRAIN_DATA.lz4".as_bytes(); pub const SPAM_TRAINER_KEY: &[u8] = "INBUXA_SPAM_TRAIN_DATA.lz4".as_bytes();
pub const SPAM_CLASSIFIER_KEY: &[u8] = "STALWART_SPAM_CLASSIFIER_MODEL.lz4".as_bytes(); pub const SPAM_CLASSIFIER_KEY: &[u8] = "INBUXA_SPAM_CLASSIFIER_MODEL.lz4".as_bytes();
pub async fn fetch_resource( pub async fn fetch_resource(
url: &str, url: &str,
+79 -10
View File
@@ -9,15 +9,22 @@
use super::backup::MAGIC_MARKER; use super::backup::MAGIC_MARKER;
use crate::{Core, DATABASE_SCHEMA_VERSION}; use crate::{Core, DATABASE_SCHEMA_VERSION};
use lz4_flex::frame::FrameDecoder; use lz4_flex::frame::FrameDecoder;
use registry::schema::enums::CompressionAlgo; use registry::{
schema::{
enums::{CompressionAlgo, TaskStoreMaintenanceType},
structs::{Task, TaskStatus, TaskStoreMaintenance},
},
types::EnumImpl,
};
use std::{ use std::{
fs::File, fs::File,
io::{BufReader, ErrorKind, Read}, io::{BufReader, ErrorKind, Read},
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use store::{ use store::{
BlobStore, IterateParams, SUBSPACE_BLOBS, SUBSPACE_COUNTER, SUBSPACE_INDEXES, SUBSPACE_QUOTA, BlobStore, IterateParams, SUBSPACE_BLOBS, SUBSPACE_COUNTER, SUBSPACE_INDEXES,
SUBSPACE_REGISTRY_PK, Store, U32_LEN, SUBSPACE_PROPERTY, SUBSPACE_QUOTA, SUBSPACE_REGISTRY_PK, SUBSPACE_TELEMETRY_SPAN, Store,
U32_LEN,
write::{ write::{
AnyClass, AnyKey, BatchBuilder, ValueClass, AnyClass, AnyKey, BatchBuilder, ValueClass,
key::{DeserializeBigEndian, is_node_id_key}, key::{DeserializeBigEndian, is_node_id_key},
@@ -27,7 +34,9 @@ use types::{collection::Collection, field::Field};
use utils::{UnwrapFailure, failed}; use utils::{UnwrapFailure, failed};
impl Core { impl Core {
pub async fn restore(&self, src: PathBuf) { /// Imports an export into an empty store and returns the subspaces it
/// wrote. inbuxa: the caller hands them to [`Core::queue_reindex`].
pub async fn restore(&self, src: PathBuf) -> Vec<u8> {
// Backup the core // Backup the core
let paths = if src.is_dir() { let paths = if src.is_dir() {
let mut paths = Vec::new(); let mut paths = Vec::new();
@@ -64,6 +73,13 @@ impl Core {
std::process::exit(1); std::process::exit(1);
} }
let mut imported = paths
.iter()
.map(|path| KeyValueReader::new(path).subspace)
.collect::<Vec<_>>();
imported.sort_unstable();
imported.dedup();
let mut tasks = Vec::new(); let mut tasks = Vec::new();
for path in paths { for path in paths {
let storage = self.storage.clone(); let storage = self.storage.clone();
@@ -76,6 +92,54 @@ impl Core {
for task in tasks { for task in tasks {
task.await.failed("Failed to wait for task"); task.await.failed("Failed to wait for task");
} }
imported
}
/// inbuxa: an export never carries the full-text index. It is built by
/// and for one search backend (the SQL stores index into their own
/// tables, the key-value stores into a subspace, external engines keep it
/// themselves), so it would be wrong or unreadable after a move to
/// another one. Instead, an import queues the same reindex tasks an
/// administrator can queue by hand (`reindexAccounts` and
/// `reindexTelemetry` store maintenance), and the server rebuilds the
/// index for whatever search store it is configured with once it starts.
pub async fn queue_reindex(&self, imported: &[u8]) -> Vec<TaskStoreMaintenanceType> {
let mut queued = Vec::new();
if imported.contains(&SUBSPACE_PROPERTY) {
queued.push(TaskStoreMaintenanceType::ReindexAccounts);
}
if imported.contains(&SUBSPACE_TELEMETRY_SPAN) {
queued.push(TaskStoreMaintenanceType::ReindexTelemetry);
}
if queued.is_empty() {
return queued;
}
let mut batch = BatchBuilder::new();
for maintenance_type in &queued {
batch.schedule_task(Task::StoreMaintenance(TaskStoreMaintenance {
maintenance_type: *maintenance_type,
status: TaskStatus::now(),
shard_index: None,
}));
}
self.storage
.data
.write(batch.build_all())
.await
.failed("Failed to queue the reindex tasks");
println!(
"Queued {} to rebuild the search index; it runs when the server starts.",
queued
.iter()
.map(|t| t.as_str())
.collect::<Vec<_>>()
.join(" and ")
);
queued
} }
} }
@@ -125,17 +189,22 @@ async fn restore_file(store: Store, blob_store: BlobStore, path: &Path) {
} }
SUBSPACE_COUNTER | SUBSPACE_QUOTA => { SUBSPACE_COUNTER | SUBSPACE_QUOTA => {
while let Some((key, value)) = reader.next() { while let Some((key, value)) = reader.next() {
batch.add( let class = ValueClass::Any(AnyClass {
ValueClass::Any(AnyClass {
subspace: reader.subspace, subspace: reader.subspace,
key, key,
}), });
u64::from_le_bytes( let value = u64::from_le_bytes(
value value
.try_into() .try_into()
.expect("Failed to deserialize counter/quota"), .expect("Failed to deserialize counter/quota"),
) as i64, ) as i64;
); // inbuxa: the SQL stores add a negative amount with an UPDATE,
// which does nothing to a row that isn't there yet, so a
// negative counter vanished on import. Create the row first.
if value < 0 {
batch.add(class.clone(), 0);
}
batch.add(class, value);
if batch.is_large_batch() { if batch.is_large_batch() {
store store
.write(batch.build_all()) .write(batch.build_all())
+103
View File
@@ -0,0 +1,103 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: the spam filter rules that ship with the server.
//!
//! Upstream fetches its latest published rules from GitHub at run time, so
//! scoring changes with a release nobody here tested and depends on reaching
//! it. The fork embeds a pinned copy (resources/spam-filter/, with its version
//! and license) and uses it whenever no other source is configured. The rules
//! URL remains an operator override (`https://` or `file://`).
//!
//! Loading rules only ever adds what's missing, never changes an existing rule
//! or score. They load on first boot, and again whenever the bundled version
//! differs from the one last applied, so an upgrade brings new tags (the AI
//! classifier's `LLM_*` scores, say) to an install that already had rules.
use std::io::Read;
use store::{
SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
/// The version of spam-filter the embedded rules come from.
pub const BUNDLED_SPAM_RULES_VERSION: &str = "3.0.2";
static BUNDLED_SPAM_RULES: &[u8] =
include_bytes!("../../../../resources/spam-filter/spam-filter-rules.json.gz");
/// Upstream's default rules source, the value every install created before
/// the rules were bundled has saved. Read only to treat it as unset.
const LEGACY_DEFAULT_URL: &str =
"https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz";
/// The URL to fetch rules from, or `None` for the bundled rules. An empty
/// setting and upstream's old default both mean the bundled rules.
pub fn rules_url(configured: Option<String>) -> Option<String> {
configured.filter(|url| !url.trim().is_empty() && url != LEGACY_DEFAULT_URL)
}
/// The bundled rules, uncompressed: the same JSON the rules URL serves.
pub fn bundled_rules() -> Result<Vec<u8>, String> {
let mut json = Vec::new();
mail_auth::flate2::read::GzDecoder::new(BUNDLED_SPAM_RULES)
.read_to_end(&mut json)
.map_err(|err| format!("Failed to decompress the bundled spam rules: {err}"))?;
Ok(json)
}
fn applied_key() -> ValueClass {
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: b"Sr".to_vec(),
})
}
/// The bundled version last loaded into the registry, if any.
pub async fn applied_version(data: &Store) -> trc::Result<Option<String>> {
data.get_value::<String>(ValueKey::from(applied_key()))
.await
.caused_by(trc::location!())
}
/// Records that the bundled rules of this version have been loaded.
pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(applied_key(), version.as_bytes().to_vec());
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upstream_default_and_empty_mean_bundled() {
assert_eq!(rules_url(None), None);
assert_eq!(rules_url(Some(String::new())), None);
assert_eq!(rules_url(Some(" ".into())), None);
assert_eq!(rules_url(Some(LEGACY_DEFAULT_URL.into())), None);
assert_eq!(
rules_url(Some("file:///srv/rules.json.gz".into())).as_deref(),
Some("file:///srv/rules.json.gz")
);
}
#[test]
fn bundled_rules_parse_and_score_the_ai_tags() {
let rules: serde_json::Value = serde_json::from_slice(&bundled_rules().unwrap()).unwrap();
let tags = rules["SpamTag"].as_array().unwrap();
for (tag, score) in [("LLM_UNSOLICITED_HIGH", 3.0), ("LLM_LEGITIMATE_HIGH", -3.0)] {
let found = tags.iter().find(|t| t["tag"] == tag).unwrap();
assert_eq!(found["score"].as_f64(), Some(score), "{tag}");
}
assert!(!rules["SpamRule"].as_array().unwrap().is_empty());
}
}
+15 -15
View File
@@ -299,28 +299,28 @@ impl LegacyProtocol {
pub fn refusal(&self, scope: RefusalScope) -> &'static str { pub fn refusal(&self, scope: RefusalScope) -> &'static str {
match (scope, self) { match (scope, self) {
(RefusalScope::Server, LegacyProtocol::Imap) => { (RefusalScope::Server, LegacyProtocol::Imap) => {
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in." "This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
} }
(RefusalScope::Server, LegacyProtocol::Pop3) => { (RefusalScope::Server, LegacyProtocol::Pop3) => {
"[AUTH] This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in." "[AUTH] This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
} }
(RefusalScope::Server, LegacyProtocol::ManageSieve) => { (RefusalScope::Server, LegacyProtocol::ManageSieve) => {
"This server allows only INBUXA webmail and JMAP apps." "This server allows only inbuxa webmail and JMAP apps."
} }
(RefusalScope::Server, LegacyProtocol::Submission) => { (RefusalScope::Server, LegacyProtocol::Submission) => {
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" "535 5.7.0 This server allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
} }
(RefusalScope::Tenant(_), LegacyProtocol::Imap) => { (RefusalScope::Tenant(_), LegacyProtocol::Imap) => {
"Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in." "Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
} }
(RefusalScope::Tenant(_), LegacyProtocol::Pop3) => { (RefusalScope::Tenant(_), LegacyProtocol::Pop3) => {
"[AUTH] Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in." "[AUTH] Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
} }
(RefusalScope::Tenant(_), LegacyProtocol::ManageSieve) => { (RefusalScope::Tenant(_), LegacyProtocol::ManageSieve) => {
"Your organization allows only INBUXA webmail and JMAP apps." "Your organization allows only inbuxa webmail and JMAP apps."
} }
(RefusalScope::Tenant(_), LegacyProtocol::Submission) => { (RefusalScope::Tenant(_), LegacyProtocol::Submission) => {
"535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" "535 5.7.0 Your organization allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
} }
} }
} }
@@ -541,7 +541,7 @@ mod tests {
let server = RefusalScope::Server; let server = RefusalScope::Server;
assert_eq!( assert_eq!(
LegacyProtocol::Imap.refusal(server), LegacyProtocol::Imap.refusal(server),
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in." "This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
); );
assert!( assert!(
LegacyProtocol::Pop3 LegacyProtocol::Pop3
@@ -550,11 +550,11 @@ mod tests {
); );
assert_eq!( assert_eq!(
LegacyProtocol::ManageSieve.refusal(server), LegacyProtocol::ManageSieve.refusal(server),
"This server allows only INBUXA webmail and JMAP apps." "This server allows only inbuxa webmail and JMAP apps."
); );
assert_eq!( assert_eq!(
LegacyProtocol::Submission.refusal(server), LegacyProtocol::Submission.refusal(server),
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" "535 5.7.0 This server allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
); );
} }
@@ -564,19 +564,19 @@ mod tests {
let tenant = RefusalScope::Tenant(7); let tenant = RefusalScope::Tenant(7);
assert_eq!( assert_eq!(
LegacyProtocol::Imap.refusal(tenant), LegacyProtocol::Imap.refusal(tenant),
"Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in." "Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
); );
assert_eq!( assert_eq!(
LegacyProtocol::Pop3.refusal(tenant), LegacyProtocol::Pop3.refusal(tenant),
"[AUTH] Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in." "[AUTH] Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
); );
assert_eq!( assert_eq!(
LegacyProtocol::ManageSieve.refusal(tenant), LegacyProtocol::ManageSieve.refusal(tenant),
"Your organization allows only INBUXA webmail and JMAP apps." "Your organization allows only inbuxa webmail and JMAP apps."
); );
assert_eq!( assert_eq!(
LegacyProtocol::Submission.refusal(tenant), LegacyProtocol::Submission.refusal(tenant),
"535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" "535 5.7.0 Your organization allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
); );
let err = LegacyProtocol::Imap.refused(tenant, Some("example.org".into())); let err = LegacyProtocol::Imap.refused(tenant, Some("example.org".into()));
assert_eq!(err.value_as_str(trc::Key::Policy), Some("tenant")); assert_eq!(err.value_as_str(trc::Key::Policy), Some("tenant"));
+10 -8
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -313,16 +315,16 @@ B4yDfR2rGOd2H6Kv3fQNHPj9Nu5Tks8QYMLzrX8ONCNoFnNUQl9S0r0QS6phVqD0
#[test] #[test]
fn contact_is_normalized_to_a_uri() { fn contact_is_normalized_to_a_uri() {
for (input, expected) in [ for (input, expected) in [
("hello@stalw.art", Some("mailto:hello@stalw.art")), ("hello@example.org", Some("mailto:hello@example.org")),
(" hello@stalw.art ", Some("mailto:hello@stalw.art")), (" hello@example.org ", Some("mailto:hello@example.org")),
("mailto:hello@stalw.art", Some("mailto:hello@stalw.art")), ("mailto:hello@example.org", Some("mailto:hello@example.org")),
("MAILTO:hello@stalw.art", Some("MAILTO:hello@stalw.art")), ("MAILTO:hello@example.org", Some("MAILTO:hello@example.org")),
( (
"https://stalw.art/contact", "https://example.org/contact",
Some("https://stalw.art/contact"), Some("https://example.org/contact"),
), ),
("stalw.art", None), ("example.org", None),
("http://stalw.art", None), ("http://example.org", None),
("tel:+123456789", None), ("tel:+123456789", None),
("", None), ("", None),
] { ] {
+1 -1
View File
@@ -8,7 +8,7 @@ store = { path = "../store" }
registry = { path = "../registry" } registry = { path = "../registry" }
trc = { path = "../trc" } trc = { path = "../trc" }
futures = { version = "0.3", optional = true } futures = { version = "0.3", optional = true }
tokio = { version = "1.53", features = ["sync", "fs", "io-util"] } tokio = { version = "1.53", features = ["sync", "fs", "io-util", "rt", "time"] }
async-nats = { version = "0.50", default-features = false, features = ["server_2_10", "server_2_11", "aws-lc-rs"], optional = true } async-nats = { version = "0.50", default-features = false, features = ["server_2_10", "server_2_11", "aws-lc-rs"], optional = true }
zenoh = { version = "1.10.0", default-features = false, features = ["auth_pubkey", "transport_multilink", "transport_compression", "transport_quic", "transport_tcp", "transport_tls", "transport_udp"], optional = true } zenoh = { version = "1.10.0", default-features = false, features = ["auth_pubkey", "transport_multilink", "transport_compression", "transport_quic", "transport_tcp", "transport_tls", "transport_udp"], optional = true }
rdkafka = { version = "0.39", features = ["cmake-build"], optional = true } rdkafka = { version = "0.39", features = ["cmake-build"], optional = true }
+118 -2
View File
@@ -2,13 +2,22 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use std::sync::Arc; use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use crate::Coordinator; use crate::Coordinator;
use async_nats::Client; use async_nats::Client;
use registry::schema::structs::NatsCoordinator; use registry::schema::structs::NatsCoordinator;
use trc::ClusterEvent;
pub mod pubsub; pub mod pubsub;
@@ -47,9 +56,116 @@ impl NatsPubSub {
opts = opts.token(credentials); opts = opts.token(credentials);
} }
// inbuxa: connect in the background and keep trying, so a node that
// starts while NATS is down still joins the cluster once NATS is
// back, instead of running without a coordinator until restarted;
// and report the connection going and coming back
let reporter = Arc::new(Reporter::default());
opts = opts.retry_on_initial_connect().event_callback({
let reporter = reporter.clone();
move |event| {
let reporter = reporter.clone();
async move { reporter.report(event) }
}
});
let connection_timeout = config.timeout_connection.into_inner();
async_nats::connect_with_options(config.addresses.into_inner(), opts) async_nats::connect_with_options(config.addresses.into_inner(), opts)
.await .await
.map(|client| Coordinator::Nats(Arc::new(NatsPubSub { client }))) .map(|client| {
reporter.watch_first_connection(client.clone(), connection_timeout);
Coordinator::Nats(Arc::new(NatsPubSub { client }))
})
.map_err(|err| format!("Failed to connect to Nats: {}", err)) .map_err(|err| format!("Failed to connect to Nats: {}", err))
} }
/// inbuxa: whether the client is connected to a NATS server right now.
pub fn is_connected(&self) -> bool {
matches!(
self.client.connection_state(),
async_nats::connection::State::Connected
)
}
}
/// inbuxa: reports the client's connection events as the server's own.
#[derive(Default)]
struct Reporter {
connected_once: AtomicBool,
// A failed attempt raises an error each time the client retries, every
// few seconds while NATS is down: report the first after each change
error_reported: AtomicBool,
}
impl Reporter {
fn report(&self, event: async_nats::Event) {
match event {
async_nats::Event::Connected => {
self.connected_once.store(true, Ordering::Relaxed);
self.error_reported.store(false, Ordering::Relaxed);
trc::event!(Cluster(ClusterEvent::CoordinatorConnected), Type = "nats");
}
async_nats::Event::Disconnected => {
self.error_reported.store(false, Ordering::Relaxed);
trc::event!(
Cluster(ClusterEvent::CoordinatorDisconnected),
Type = "nats",
Details = "Connection lost; reconnecting in the background",
);
}
async_nats::Event::Closed => {
trc::event!(
Cluster(ClusterEvent::CoordinatorDisconnected),
Type = "nats",
Details = "Connection closed; no further attempts will be made",
);
}
async_nats::Event::ClientError(async_nats::ClientError::MaxReconnects) => {
trc::event!(
Cluster(ClusterEvent::CoordinatorDisconnected),
Type = "nats",
Details = "Gave up reconnecting (maxReconnects reached)",
);
}
async_nats::Event::ClientError(err) => {
if !self.error_reported.swap(true, Ordering::Relaxed) {
trc::event!(
Cluster(ClusterEvent::CoordinatorError),
Type = "nats",
Details = "Connection attempt failed; retrying",
Reason = err.to_string(),
);
}
}
event => {
trc::event!(
Cluster(ClusterEvent::CoordinatorError),
Type = "nats",
Details = event.to_string(),
);
}
}
}
/// The first connection is made in the background, so say so when it
/// hasn't been made within the connection timeout. The client keeps
/// trying, and reports the connection when it comes.
fn watch_first_connection(self: &Arc<Self>, client: Client, timeout: Duration) {
let reporter = self.clone();
tokio::spawn(async move {
tokio::time::sleep(timeout).await;
if !reporter.connected_once.load(Ordering::Relaxed)
&& !matches!(
client.connection_state(),
async_nats::connection::State::Connected
)
{
trc::event!(
Cluster(ClusterEvent::CoordinatorDisconnected),
Type = "nats",
Details = "Not connected at startup; retrying in the background",
);
}
});
}
} }
+13
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{Coordinator, Msg, PubSubStream}; use crate::{Coordinator, Msg, PubSubStream};
@@ -43,6 +45,17 @@ impl Coordinator {
pub fn is_none(&self) -> bool { pub fn is_none(&self) -> bool {
matches!(self, Coordinator::None) matches!(self, Coordinator::None)
} }
/// inbuxa: whether the coordinator is connected right now, for the
/// backends that track it (NATS); `None` for the others and when no
/// coordinator is configured.
pub fn is_connected(&self) -> Option<bool> {
match self {
#[cfg(feature = "nats")]
Coordinator::Nats(store) => Some(store.is_connected()),
_ => None,
}
}
} }
impl PubSubStream { impl PubSubStream {
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::ETag; use super::ETag;
@@ -490,7 +492,7 @@ impl LockRequestHandler for Server {
for cond in &if_.list { for cond in &if_.list {
match cond { match cond {
Condition::StateToken { token, .. } => { Condition::StateToken { token, .. } => {
if token.starts_with("urn:stalwart:davsync:") { if token.starts_with("urn:inbuxa:davsync:") {
needs_sync_token = true; needs_sync_token = true;
} else { } else {
needs_lock_token = true; needs_lock_token = true;
+7 -5
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{DavError, DavResourceName}; use crate::{DavError, DavResourceName};
@@ -181,12 +183,12 @@ impl OwnedUri<'_> {
impl Urn { impl Urn {
pub fn try_extract_sync_id(token: &str) -> Option<&str> { pub fn try_extract_sync_id(token: &str) -> Option<&str> {
token token
.strip_prefix("urn:stalwart:davsync:") .strip_prefix("urn:inbuxa:davsync:")
.map(|x| x.split_once(':').map(|(x, _)| x).unwrap_or(x)) .map(|x| x.split_once(':').map(|(x, _)| x).unwrap_or(x))
} }
pub fn parse(input: &str) -> Option<Self> { pub fn parse(input: &str) -> Option<Self> {
let inbox = input.strip_prefix("urn:stalwart:")?; let inbox = input.strip_prefix("urn:inbuxa:")?;
let (kind, id) = inbox.split_once(':')?; let (kind, id) = inbox.split_once(':')?;
match kind { match kind {
"davlock" => u64::from_str_radix(id, 16).ok().map(Urn::Lock), "davlock" => u64::from_str_radix(id, 16).ok().map(Urn::Lock),
@@ -223,12 +225,12 @@ impl Urn {
impl Display for Urn { impl Display for Urn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Urn::Lock(id) => write!(f, "urn:stalwart:davlock:{id:x}",), Urn::Lock(id) => write!(f, "urn:inbuxa:davlock:{id:x}",),
Urn::Sync { id, seq } => { Urn::Sync { id, seq } => {
if *seq == 0 { if *seq == 0 {
write!(f, "urn:stalwart:davsync:{id:x}") write!(f, "urn:inbuxa:davsync:{id:x}")
} else { } else {
write!(f, "urn:stalwart:davsync:{id:x}:{seq:x}") write!(f, "urn:inbuxa:davsync:{id:x}:{seq:x}")
} }
} }
} }
+1 -1
View File
@@ -40,7 +40,7 @@ impl OpenIdDirectory {
pub async fn new(config: OidcConfig) -> Result<Self, OidcError> { pub async fn new(config: OidcConfig) -> Result<Self, OidcError> {
let http = utils::http::http_client_builder(false) let http = utils::http::http_client_builder(false)
.user_agent("INBUXA/1.0") // types::brand!(); this crate does not depend on types .user_agent("inbuxa/1.0") // types::brand!(); this crate does not depend on types
.timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(30))
.build() .build()
.map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?; .map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?;
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "inbuxa-features" name = "inbuxa-features"
description = "INBUXA's rebuilt features: behavior Stalwart ships only in its Enterprise Edition, rebuilt clean-room" description = "inbuxa's rebuilt features: behavior Stalwart ships only in its Enterprise Edition, rebuilt clean-room"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
version = "0.16.22" version = "0.16.22"
edition = "2024" edition = "2024"
+21
View File
@@ -562,6 +562,27 @@ impl ParseHttp for Server {
}) })
.into_http_response()); .into_http_response());
} }
// inbuxa: the cluster coordinator's connection, for
// monitoring. It stays out of live and ready on purpose:
// a node without its coordinator still serves mail, and
// failing those would have an orchestrator restart, or
// take out of service, every node at once when the
// coordinator goes down
"cluster" => {
let coordinator = &self.core.storage.coordinator;
let (status, state) = match coordinator.is_connected() {
Some(true) => (StatusCode::OK, "connected"),
Some(false) => (StatusCode::SERVICE_UNAVAILABLE, "disconnected"),
None if coordinator.is_none() => (StatusCode::OK, "none"),
None => (StatusCode::OK, "unknown"),
};
return Ok(http_proto::JsonResponse::with_status(
status,
serde_json::json!({ "coordinator": state }),
)
.no_cache()
.into_http_response());
}
_ => (), _ => (),
} }
} }
+3 -3
View File
@@ -91,7 +91,7 @@ pub enum Capability {
FileNode = 1 << 15, FileNode = 1 << 15,
#[serde(rename(serialize = "urn:ietf:params:jmap:mail:share"))] #[serde(rename(serialize = "urn:ietf:params:jmap:mail:share"))]
MailShare = 1 << 16, MailShare = 1 << 16,
#[serde(rename(serialize = "urn:stalwart:jmap"))] #[serde(rename(serialize = "urn:inbuxa:jmap:registry"))]
Stalwart = 1 << 17, Stalwart = 1 << 17,
#[serde(rename(serialize = "urn:ietf:params:jmap:webpush-vapid"))] #[serde(rename(serialize = "urn:ietf:params:jmap:webpush-vapid"))]
WebPushVapid = 1 << 18, WebPushVapid = 1 << 18,
@@ -353,7 +353,7 @@ impl Capability {
Capability::PrincipalsAvailability => "urn:ietf:params:jmap:principals:availability", Capability::PrincipalsAvailability => "urn:ietf:params:jmap:principals:availability",
Capability::FileNode => "urn:ietf:params:jmap:filenode", Capability::FileNode => "urn:ietf:params:jmap:filenode",
Capability::MailShare => "urn:ietf:params:jmap:mail:share", Capability::MailShare => "urn:ietf:params:jmap:mail:share",
Capability::Stalwart => "urn:stalwart:jmap", Capability::Stalwart => "urn:inbuxa:jmap:registry",
Capability::WebPushVapid => "urn:ietf:params:jmap:webpush-vapid", Capability::WebPushVapid => "urn:ietf:params:jmap:webpush-vapid",
Capability::EmailPush => "urn:ietf:params:jmap:emailpush", Capability::EmailPush => "urn:ietf:params:jmap:emailpush",
Capability::Inbuxa => "urn:inbuxa:jmap", Capability::Inbuxa => "urn:inbuxa:jmap",
@@ -501,7 +501,7 @@ impl Capability {
"urn:ietf:params:jmap:contacts:parse" => Capability::ContactsParse, "urn:ietf:params:jmap:contacts:parse" => Capability::ContactsParse,
"urn:ietf:params:jmap:calendars:parse" => Capability::CalendarsParse, "urn:ietf:params:jmap:calendars:parse" => Capability::CalendarsParse,
"urn:ietf:params:jmap:mail:share" => Capability::MailShare, "urn:ietf:params:jmap:mail:share" => Capability::MailShare,
"urn:stalwart:jmap" => Capability::Stalwart, "urn:inbuxa:jmap:registry" => Capability::Stalwart,
"urn:ietf:params:jmap:webpush-vapid" => Capability::WebPushVapid, "urn:ietf:params:jmap:webpush-vapid" => Capability::WebPushVapid,
"urn:ietf:params:jmap:emailpush" => Capability::EmailPush, "urn:ietf:params:jmap:emailpush" => Capability::EmailPush,
"urn:inbuxa:jmap" => Capability::Inbuxa, "urn:inbuxa:jmap" => Capability::Inbuxa,
+17 -2
View File
@@ -427,9 +427,24 @@ pub(crate) async fn trace_query(
} }
None => false, None => false,
}, },
Property::QueueId => match value.as_str() { // The queue id column is an integer on every search backend, and
// holds a trace's first queue id; the keywords carry all of them
Property::QueueId => match value
.as_str()
.and_then(|v| v.trim().parse::<u64>().ok())
.or_else(|| value.as_u64())
{
Some(queue_id) => { Some(queue_id) => {
search.push(SearchFilter::eq(TracingSearchField::QueueId, queue_id.to_string())); search.extend([
SearchFilter::Or,
SearchFilter::eq(TracingSearchField::QueueId, queue_id),
SearchFilter::has_text(
TracingSearchField::Keywords,
queue_id.to_string(),
nlp::language::Language::None,
),
SearchFilter::End,
]);
true true
} }
None => false, None => false,
@@ -208,7 +208,7 @@ pub(crate) async fn bootstrap_set(
.with_description(concat!( .with_description(concat!(
"The selected data store contains information from an older version. ", "The selected data store contains information from an older version. ",
"Please follow the upgrade instructions at ", "Please follow the upgrade instructions at ",
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md" "https://docs.inbuxa.org/install/migrating/"
)), )),
); );
break; break;
@@ -679,7 +679,7 @@ fn build_default_bootstrap(server: &Server) -> Bootstrap {
directory: DirectoryBootstrap::Internal, directory: DirectoryBootstrap::Internal,
tracer: Tracer::Log(TracerLog { tracer: Tracer::Log(TracerLog {
path: "/var/log/inbuxa/".to_string(), path: "/var/log/inbuxa/".to_string(),
prefix: "stalwart".to_string(), prefix: "inbuxa".to_string(),
ansi: true, ansi: true,
enable: true, enable: true,
..Default::default() ..Default::default()
+2 -2
View File
@@ -1,11 +1,11 @@
[package] [package]
name = "inbuxa" name = "inbuxa"
description = "INBUXA Mail and Collaboration Server, a fork of Stalwart" description = "inbuxa mail and collaboration server, a fork of Stalwart"
authors = [ "Stalwart Labs LLC <[email protected]>"] authors = [ "Stalwart Labs LLC <[email protected]>"]
homepage = "https://inbuxa.org" homepage = "https://inbuxa.org"
keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"] keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
categories = ["email"] categories = ["email"]
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; INBUXA takes the AGPL only. # Upstream offers AGPL-3.0-only OR LicenseRef-SEL; inbuxa takes the AGPL only.
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
version = "0.16.23" version = "0.16.23"
edition = "2024" edition = "2024"
+4
View File
@@ -109,6 +109,10 @@ async fn main() -> std::io::Result<()> {
// Wait for shutdown signal // Wait for shutdown signal
wait_for_shutdown().await; wait_for_shutdown().await;
// inbuxa: hand back the task locks this node holds, so other nodes can
// run those tasks now rather than when the locks expire
services::task_manager::lock::release_task_locks(&inner.build_server()).await;
// Shutdown collector // Shutdown collector
Collector::shutdown(); Collector::shutdown();
+53 -3
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
#![warn(clippy::large_futures)] #![warn(clippy::large_futures)]
@@ -19,6 +21,10 @@ pub mod destroy;
pub mod v016; pub mod v016;
pub async fn try_migrate(server: &Server) -> trc::Result<()> { pub async fn try_migrate(server: &Server) -> trc::Result<()> {
// inbuxa: before the version check, which returns early on a current
// store, and before migrate_v0_16, which reads the renamed key.
rename_spam_blobs(server).await?;
match server match server
.store() .store()
.get_value::<u32>(AnyKey { .get_value::<u32>(AnyKey {
@@ -36,14 +42,14 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> {
Some(0..=4) => { Some(0..=4) => {
abort(concat!( abort(concat!(
"You must first upgrade to version 0.15, please read ", "You must first upgrade to version 0.15, please read ",
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md" "https://docs.inbuxa.org/install/migrating/"
)); ));
} }
Some(5) => { Some(5) => {
if !server.registry().is_recovery_mode() { if !server.registry().is_recovery_mode() {
abort(concat!( abort(concat!(
"Upgrading to version 0.16 is a multi-step process, please read ", "Upgrading to version 0.16 is a multi-step process, please read ",
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md" "https://docs.inbuxa.org/install/migrating/"
)); ));
} }
} }
@@ -61,7 +67,7 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> {
} else { } else {
abort(concat!( abort(concat!(
"You must first upgrade to version 0.15, please read ", "You must first upgrade to version 0.15, please read ",
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md" "https://docs.inbuxa.org/install/migrating/"
)); ));
} }
} }
@@ -134,3 +140,47 @@ async fn is_new_install(server: &Server) -> trc::Result<bool> {
Ok(true) Ok(true)
} }
/// inbuxa: the spam filter's trainer and model blobs, under the names they
/// had before the fork renamed them (SPEC §2.4), paired with the current ones.
const RENAMED_SPAM_BLOBS: [(&[u8], &[u8]); 2] = [
(b"STALWART_SPAM_TRAIN_DATA.lz4", common::manager::SPAM_TRAINER_KEY),
(
b"STALWART_SPAM_CLASSIFIER_MODEL.lz4",
common::manager::SPAM_CLASSIFIER_KEY,
),
];
/// Moves each spam blob from its pre-rename key to the current one, so a
/// trained model survives the rename. A blob already under the current key
/// wins and the old one is just removed; with neither, nothing happens.
async fn rename_spam_blobs(server: &Server) -> trc::Result<()> {
let blobs = server.blob_store();
for (old, new) in RENAMED_SPAM_BLOBS {
let Some(data) = blobs
.get_blob(old, 0..usize::MAX)
.await
.caused_by(trc::location!())?
else {
continue;
};
if blobs
.get_blob(new, 0..usize::MAX)
.await
.caused_by(trc::location!())?
.is_none()
{
blobs
.put_blob(new, &data, server.core.email.compression)
.await
.caused_by(trc::location!())?;
}
blobs.delete_blob(old).await.caused_by(trc::location!())?;
trc::event!(
Server(trc::ServerEvent::Startup),
Details = "Moved a spam filter blob to its renamed key",
Key = new,
);
}
Ok(())
}
+6 -4
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
// This file is auto-generated. Do not edit directly. // This file is auto-generated. Do not edit directly.
@@ -10372,8 +10374,8 @@ impl EnumImpl for SieveCapability {
b"spamtest" => SieveCapability::Spamtest, b"spamtest" => SieveCapability::Spamtest,
b"spamtestplus" => SieveCapability::Spamtestplus, b"spamtestplus" => SieveCapability::Spamtestplus,
b"virustest" => SieveCapability::Virustest, b"virustest" => SieveCapability::Virustest,
b"vnd.stalwart.while" => SieveCapability::VndStalwartWhile, b"vnd.inbuxa.while" => SieveCapability::VndStalwartWhile,
b"vnd.stalwart.expressions" => SieveCapability::VndStalwartExpressions, b"vnd.inbuxa.expressions" => SieveCapability::VndStalwartExpressions,
} }
} }
@@ -10426,8 +10428,8 @@ impl EnumImpl for SieveCapability {
SieveCapability::Spamtest => "spamtest", SieveCapability::Spamtest => "spamtest",
SieveCapability::Spamtestplus => "spamtestplus", SieveCapability::Spamtestplus => "spamtestplus",
SieveCapability::Virustest => "virustest", SieveCapability::Virustest => "virustest",
SieveCapability::VndStalwartWhile => "vnd.stalwart.while", SieveCapability::VndStalwartWhile => "vnd.inbuxa.while",
SieveCapability::VndStalwartExpressions => "vnd.stalwart.expressions", SieveCapability::VndStalwartExpressions => "vnd.inbuxa.expressions",
} }
} }
+18 -18
View File
@@ -732,7 +732,7 @@ impl Pickle for AddressBook {
impl Default for AddressBook { impl Default for AddressBook {
fn default() -> Self { fn default() -> Self {
Self { Self {
default_display_name: Some("INBUXA Address Book".to_string()), default_display_name: Some("inbuxa Address Book".to_string()),
default_href_name: Some("default".to_string()), default_href_name: Some("default".to_string()),
max_v_card_size: 524288u64, max_v_card_size: 524288u64,
max_address_books: Some(250u64), max_address_books: Some(250u64),
@@ -4597,7 +4597,7 @@ impl Pickle for Calendar {
impl Default for Calendar { impl Default for Calendar {
fn default() -> Self { fn default() -> Self {
Self { Self {
default_display_name: Some("INBUXA Calendar".to_string()), default_display_name: Some("inbuxa Calendar".to_string()),
default_href_name: Some("default".to_string()), default_href_name: Some("default".to_string()),
max_attendees: 20u64, max_attendees: 20u64,
max_recurrence_expansions: 3000u64, max_recurrence_expansions: 3000u64,
@@ -4734,7 +4734,7 @@ impl Default for CalendarAlarm {
allow_external_rcpts: false, allow_external_rcpts: false,
enable: true, enable: true,
from_email: Default::default(), from_email: Default::default(),
from_name: "INBUXA Calendar".to_string(), from_name: "inbuxa Calendar".to_string(),
min_trigger_interval: Duration::from_millis(3600000), min_trigger_interval: Duration::from_millis(3600000),
template: Default::default(), template: Default::default(),
} }
@@ -28310,7 +28310,7 @@ impl MtaStageConnect {
ExpressionContext { ExpressionContext {
expr: &self.smtp_greeting, expr: &self.smtp_greeting,
default: Some(Expression { default: Some(Expression {
else_: "system('hostname') + ' INBUXA ESMTP at your service'".to_string(), else_: "system('hostname') + ' inbuxa ESMTP at your service'".to_string(),
..Default::default() ..Default::default()
}), }),
property: Property::SmtpGreeting, property: Property::SmtpGreeting,
@@ -28374,7 +28374,7 @@ impl Default for MtaStageConnect {
fn default() -> Self { fn default() -> Self {
Self { Self {
smtp_greeting: Expression { smtp_greeting: Expression {
else_: "system('hostname') + ' INBUXA ESMTP at your service'".to_string(), else_: "system('hostname') + ' inbuxa ESMTP at your service'".to_string(),
..Default::default() ..Default::default()
}, },
hostname: Expression { hostname: Expression {
@@ -29622,8 +29622,8 @@ impl Default for MySqlSettings {
Self { Self {
host: Default::default(), host: Default::default(),
port: 3306u64, port: 3306u64,
database: "stalwart".to_string(), database: "inbuxa".to_string(),
auth_username: Some("stalwart".to_string()), auth_username: Some("inbuxa".to_string()),
auth_secret: Default::default(), auth_secret: Default::default(),
} }
} }
@@ -29780,8 +29780,8 @@ impl Default for MySqlStore {
read_replicas: Default::default(), read_replicas: Default::default(),
host: Default::default(), host: Default::default(),
port: 3306u64, port: 3306u64,
database: "stalwart".to_string(), database: "inbuxa".to_string(),
auth_username: Some("stalwart".to_string()), auth_username: Some("inbuxa".to_string()),
auth_secret: Default::default(), auth_secret: Default::default(),
} }
} }
@@ -29942,7 +29942,7 @@ impl Default for NatsCoordinator {
no_echo: true, no_echo: true,
use_tls: false, use_tls: false,
auth_secret: Default::default(), auth_secret: Default::default(),
auth_username: Some("stalwart".to_string()), auth_username: Some("inbuxa".to_string()),
credentials: Default::default(), credentials: Default::default(),
} }
} }
@@ -31125,8 +31125,8 @@ impl Default for PostgreSqlSettings {
Self { Self {
host: Default::default(), host: Default::default(),
port: 5432u64, port: 5432u64,
database: "stalwart".to_string(), database: "inbuxa".to_string(),
auth_username: Some("stalwart".to_string()), auth_username: Some("inbuxa".to_string()),
auth_secret: Default::default(), auth_secret: Default::default(),
options: Default::default(), options: Default::default(),
} }
@@ -31270,8 +31270,8 @@ impl Default for PostgreSqlStore {
read_replicas: Default::default(), read_replicas: Default::default(),
host: Default::default(), host: Default::default(),
port: 5432u64, port: 5432u64,
database: "stalwart".to_string(), database: "inbuxa".to_string(),
auth_username: Some("stalwart".to_string()), auth_username: Some("inbuxa".to_string()),
auth_secret: Default::default(), auth_secret: Default::default(),
options: Default::default(), options: Default::default(),
} }
@@ -32576,7 +32576,7 @@ impl Default for RedisClusterStore {
Self { Self {
urls: Map::new(vec!["redis://127.0.0.1".to_string()]), urls: Map::new(vec!["redis://127.0.0.1".to_string()]),
timeout: Duration::from_millis(10000), timeout: Duration::from_millis(10000),
auth_username: Some("stalwart".to_string()), auth_username: Some("inbuxa".to_string()),
auth_secret: Default::default(), auth_secret: Default::default(),
max_retry_wait: Default::default(), max_retry_wait: Default::default(),
min_retry_wait: Default::default(), min_retry_wait: Default::default(),
@@ -32743,7 +32743,7 @@ impl Default for RedisSentinelStore {
urls: Map::new(vec!["redis://127.0.0.1:26379".to_string()]), urls: Map::new(vec!["redis://127.0.0.1:26379".to_string()]),
service_name: "mymaster".to_string(), service_name: "mymaster".to_string(),
timeout: Duration::from_millis(10000), timeout: Duration::from_millis(10000),
auth_username: Some("stalwart".to_string()), auth_username: Some("inbuxa".to_string()),
auth_secret: Default::default(), auth_secret: Default::default(),
sentinel_username: Default::default(), sentinel_username: Default::default(),
sentinel_secret: Default::default(), sentinel_secret: Default::default(),
@@ -40215,7 +40215,7 @@ impl Default for SpamSettings {
score_reject: Float::new(0.0f64), score_reject: Float::new(0.0f64),
score_spam: Float::new(5.0f64), score_spam: Float::new(5.0f64),
trust_replies: true, trust_replies: true,
spam_filter_rules_url: Some("https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz".to_string()), spam_filter_rules_url: None,
} }
} }
} }
@@ -46308,7 +46308,7 @@ impl Default for TracerLog {
fn default() -> Self { fn default() -> Self {
Self { Self {
path: Default::default(), path: Default::default(),
prefix: "stalwart".to_string(), prefix: "inbuxa".to_string(),
rotate: LogRotateFrequency::Daily, rotate: LogRotateFrequency::Daily,
ansi: true, ansi: true,
multiline: false, multiline: false,
+9 -1
View File
@@ -4,6 +4,14 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
// The release profile computes the layout of this crate's async fn bodies in
// one go, and the deepest of them -- writable_domain, which awaits through
// the directory, the store and the JMAP registry -- takes rustc past its
// default query depth. The dev profile does not get that far, so the failure
// only appears in a release build: CI was green and the tag that started a
// release was not.
#![recursion_limit = "256"]
//! SCIM 2.0 provisioning (`docs/spec/features/scim.md`). inbuxa-server is the //! SCIM 2.0 provisioning (`docs/spec/features/scim.md`). inbuxa-server is the
//! service provider: an identity provider pushes users and groups to //! service provider: an identity provider pushes users and groups to
//! `/scim/v2`, and each request becomes the same `x:Account` reads and //! `/scim/v2`, and each request becomes the same `x:Account` reads and
@@ -205,7 +213,7 @@ impl ScimResponse {
if error.status == 401 { if error.status == 401 {
response.headers.push(( response.headers.push((
"WWW-Authenticate", "WWW-Authenticate",
"Bearer realm=\"INBUXA SCIM\"".to_string(), "Bearer realm=\"inbuxa SCIM\"".to_string(),
)); ));
} }
response response
+24 -3
View File
@@ -26,7 +26,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
}; };
tokio::spawn(async move { tokio::spawn(async move {
let mut retry_count = 0; let mut retry_count: u32 = 0;
trc::event!(Cluster(ClusterEvent::SubscriberStart)); trc::event!(Cluster(ClusterEvent::SubscriberStart));
@@ -53,7 +53,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
); );
match tokio::time::timeout( match tokio::time::timeout(
Duration::from_secs(1 << retry_count.max(6)), subscribe_retry_delay(retry_count),
shutdown_rx.changed(), shutdown_rx.changed(),
) )
.await .await
@@ -62,7 +62,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
break; break;
} }
Err(_) => { Err(_) => {
retry_count += 1; retry_count = retry_count.saturating_add(1);
continue; continue;
} }
} }
@@ -234,6 +234,11 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
}); });
} }
/// Delay before the next subscribe attempt: 1 s, 2 s, 4 s ... capped at 64 s.
fn subscribe_retry_delay(retry_count: u32) -> Duration {
Duration::from_secs(1u64 << retry_count.min(6))
}
fn log_event(event: &BroadcastEvent) -> trc::Value { fn log_event(event: &BroadcastEvent) -> trc::Value {
match event { match event {
BroadcastEvent::PushNotification(notification) => match notification { BroadcastEvent::PushNotification(notification) => match notification {
@@ -296,3 +301,19 @@ fn log_event(event: &BroadcastEvent) -> trc::Value {
BroadcastEvent::QueueRefresh => "QueueRefresh".into(), BroadcastEvent::QueueRefresh => "QueueRefresh".into(),
} }
} }
#[cfg(test)]
mod tests {
use super::subscribe_retry_delay;
use std::time::Duration;
#[test]
fn subscribe_retry_backoff_grows_then_caps() {
let schedule: Vec<u64> = (0..10)
.map(|n| subscribe_retry_delay(n).as_secs())
.collect();
assert_eq!(schedule, vec![1, 2, 4, 8, 16, 32, 64, 64, 64, 64]);
// No shift overflow at the top of the range.
assert_eq!(subscribe_retry_delay(u32::MAX), Duration::from_secs(64));
}
}
+67 -22
View File
@@ -91,7 +91,15 @@ impl SearchIndexTask for Server {
build_contact_document(self, account_id, document_id).await build_contact_document(self, account_id, document_id).await
} }
IndexDocumentType::File => { IndexDocumentType::File => {
// File indexing not implemented yet // File indexing not implemented yet. inbuxa: still
// one result per task: update_tasks pairs them by
// position, and a missing one shifts every result
// after it onto the wrong task
results.push(IndexTaskResult {
task_type: TaskType::Insert,
index: task.document_type,
result: TaskResult::Ignored,
});
continue; continue;
} }
}; };
@@ -567,20 +575,14 @@ async fn build_contact_document(
} }
// inbuxa: MON-16: a trace's search document, when trace search is on: // inbuxa: MON-16: a trace's search document, when trace search is on
// its event types, queue ids, and addresses, their domains, hosts, IPs,
// message ids and account names as keywords
async fn build_tracing_span_document( async fn build_tracing_span_document(
server: &Server, server: &Server,
span_id: u64, span_id: u64,
) -> trc::Result<Option<IndexDocument>> { ) -> trc::Result<Option<IndexDocument>> {
use common::telemetry::tracers::store::MaybeTrace; use common::telemetry::tracers::store::MaybeTrace;
use registry::schema::{enums::SearchTracingField, structs::Search}; use registry::schema::structs::Search;
use store::{ use store::write::{TelemetryClass, ValueClass};
search::TracingSearchField,
write::{TelemetryClass, ValueClass},
};
use trc::Key;
let settings = server let settings = server
.registry() .registry()
@@ -590,7 +592,6 @@ async fn build_tracing_span_document(
if !settings.index_telemetry { if !settings.index_telemetry {
return Ok(None); return Ok(None);
} }
let wants = |field: SearchTracingField| settings.index_tracing_fields.iter().any(|f| *f == field);
let Some(MaybeTrace(Some(trace))) = server let Some(MaybeTrace(Some(trace))) = server
.tracing_store() .tracing_store()
.get_value::<MaybeTrace>(ValueKey::from(ValueClass::Telemetry(TelemetryClass::Span( .get_value::<MaybeTrace>(ValueKey::from(ValueClass::Telemetry(TelemetryClass::Span(
@@ -601,23 +602,67 @@ async fn build_tracing_span_document(
return Ok(None); return Ok(None);
}; };
Ok(Some(trace_search_document(
span_id,
&trace,
&settings
.index_tracing_fields
.iter()
.copied()
.collect::<Vec<_>>(),
)))
}
/// inbuxa: MON-16: the search document for a stored trace.
///
/// The event type and queue id columns are integers on every search backend
/// (BIGINT on PostgreSQL and MySQL, long on Elasticsearch), and each holds a
/// single value per trace: the event type is the trace's opening event, the
/// one `x:Trace/query` filters on, and the queue id is the first queue id the
/// trace mentions. Every queue id also goes into the keywords, so a session
/// that queued several messages is found by any of them.
pub fn trace_search_document(
span_id: u64,
trace: &registry::schema::structs::Trace,
fields: &[registry::schema::enums::SearchTracingField],
) -> IndexDocument {
use registry::schema::{enums::SearchTracingField, structs::TraceValue};
use store::search::TracingSearchField;
use trc::Key;
let wants = |field: SearchTracingField| fields.contains(&field);
let mut document = IndexDocument::new(SearchIndex::Tracing).with_id(span_id); let mut document = IndexDocument::new(SearchIndex::Tracing).with_id(span_id);
let mut seen = store::ahash::AHashSet::new(); if wants(SearchTracingField::EventType)
for event in trace.events.iter() { && let Some(first) = trace.events.iter().next()
if wants(SearchTracingField::EventType) && seen.insert(event.event.as_str().to_string()) { {
document.index_keyword(TracingSearchField::EventType, event.event.as_str()); document.index_unsigned(TracingSearchField::EventType, first.event.to_id() as u64);
} }
let mut seen = store::ahash::AHashSet::new();
let mut queue_id_indexed = false;
for event in trace.events.iter() {
for kv in event.key_values.iter() { for kv in event.key_values.iter() {
let text = match &kv.value { let text = match &kv.value {
registry::schema::structs::TraceValue::String(v) => v.value.clone(), TraceValue::String(v) => v.value.clone(),
registry::schema::structs::TraceValue::UnsignedInt(v) => v.value.to_string(), TraceValue::UnsignedInt(v) => v.value.to_string(),
registry::schema::structs::TraceValue::IpAddr(v) => v.value.to_string(), TraceValue::IpAddr(v) => v.value.to_string(),
_ => continue, _ => continue,
}; };
match kv.key { match kv.key {
Key::QueueId if wants(SearchTracingField::QueueId) => { Key::QueueId => {
if seen.insert(format!("q:{text}")) { let Ok(queue_id) = text.parse::<u64>() else {
document.index_keyword(TracingSearchField::QueueId, &text); continue;
};
if wants(SearchTracingField::QueueId) && !queue_id_indexed {
document.index_unsigned(TracingSearchField::QueueId, queue_id);
queue_id_indexed = true;
}
if wants(SearchTracingField::Keywords) && seen.insert(format!("k:{text}")) {
document.index_text(
TracingSearchField::Keywords,
&text,
nlp::language::Language::None,
);
} }
} }
Key::From Key::From
@@ -648,7 +693,7 @@ async fn build_tracing_span_document(
} }
} }
} }
Ok(Some(document)) document
} }
// inbuxa: UD-1, UD-4: archives a deleted file, event or contact noted at // inbuxa: UD-1, UD-4: archives a deleted file, event or contact noted at
+34 -2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::task_manager::*; use crate::task_manager::*;
@@ -13,13 +15,21 @@ pub trait TaskLockManager: Sync + Send {
impl TaskLockManager for Server { impl TaskLockManager for Server {
async fn try_lock_task(&self, id: u64) -> bool { async fn try_lock_task(&self, id: u64) -> bool {
// inbuxa: a node that is stopping claims nothing new
let locks = &self.inner.ipc.task_locks;
if locks.is_stopping() {
return false;
}
match self match self
.in_memory_store() .in_memory_store()
.try_lock(KV_LOCK_TASK, &id.to_be_bytes(), DEFAULT_LOCK_EXPIRY) .try_lock(KV_LOCK_TASK, &id.to_be_bytes(), locks.expiry())
.await .await
{ {
Ok(result) => { Ok(result) => {
if !result { if result {
locks.insert(id);
} else {
trc::event!( trc::event!(
TaskManager(TaskManagerEvent::TaskLocked), TaskManager(TaskManagerEvent::TaskLocked),
Id = id, Id = id,
@@ -48,5 +58,27 @@ impl TaskLockManager for Server {
.caused_by(trc::location!()) .caused_by(trc::location!())
); );
} }
self.inner.ipc.task_locks.remove(id);
} }
} }
/// inbuxa: on a graceful stop, stops claiming tasks and releases every task
/// lock this node holds, so the rest of the cluster can pick the tasks up at
/// once instead of after the lock expires. Returns how many were released.
pub async fn release_task_locks(server: &Server) -> usize {
let ids = server.inner.ipc.task_locks.stop();
for id in &ids {
if let Err(err) = server
.in_memory_store()
.remove_lock(KV_LOCK_TASK, &id.to_be_bytes())
.await
{
trc::error!(
err.details("Failed to release task lock on shutdown")
.ctx(trc::Key::Id, *id)
.caused_by(trc::location!())
);
}
}
ids.len()
}
+184 -127
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::task_manager::acme::AcmeTask; use crate::task_manager::acme::AcmeTask;
@@ -18,7 +20,7 @@ use crate::task_manager::report::{self, SubmitReportTask};
use crate::task_manager::restore_item::RestoreItemTask; use crate::task_manager::restore_item::RestoreItemTask;
use crate::task_manager::spam_classifier::SpamFilterMaintenanceTask; use crate::task_manager::spam_classifier::SpamFilterMaintenanceTask;
use crate::task_manager::{ use crate::task_manager::{
DEFAULT_LOCK_EXPIRY, Locked, QUEUE_REFRESH_INTERVAL, TaskDetails, TaskFailureType, TaskInfo, CLAIM_RECHECK_INTERVAL, Locked, QUEUE_REFRESH_INTERVAL, TaskDetails, TaskFailureType, TaskInfo,
TaskJob, TaskManagerIpc, TaskResult, TaskJob, TaskManagerIpc, TaskResult,
}; };
use common::BuildServer; use common::BuildServer;
@@ -124,72 +126,47 @@ pub fn spawn_task_manager(inner: Arc<Inner>) {
let server = inner.build_server(); let server = inner.build_server();
let batch_size = server.core.email.index_batch_size; let batch_size = server.core.email.index_batch_size;
let mut batch = Vec::with_capacity(batch_size); let mut batch = Vec::with_capacity(batch_size);
match server if let Some(task) = fetch_task(&server, job).await {
.store() batch.push(task);
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id: job.id },
)))
.await
{
Ok(Some(task)) => {
batch.push(TaskDetails { task, info: job });
}
Ok(None) => {
trc::event!(
TaskManager(TaskManagerEvent::TaskIgnored),
Id = job.id,
Reason = "Task not found in store, likely already processed.",
);
}
Err(err) => {
trc::error!(
err.id(job.id)
.details("Failed to retrieve task details.")
.caused_by(trc::location!())
);
}
} }
while batch.len() < batch_size { while batch.len() < batch_size {
match rx.try_recv() { match rx.try_recv() {
Ok(job) => { Ok(job) => {
match server if let Some(task) = fetch_task(&server, job).await {
.store() batch.push(task);
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
TaskQueueClass::Task { id: job.id },
)))
.await
{
Ok(Some(task)) => {
batch.push(TaskDetails { task, info: job });
}
Ok(None) => {
trc::event!(
TaskManager(TaskManagerEvent::TaskIgnored),
Id = job.id,
Reason = "Task not found in store, likely already processed.",
);
}
Err(err) => {
trc::error!(
err.id(job.id)
.details("Failed to retrieve task details.")
.caused_by(trc::location!())
);
}
} }
} }
Err(_) => break, Err(_) => break,
} }
} }
// Dispatch // Dispatch. inbuxa: on a task of its own, so a panic
// releases the batch's locks and leaves this worker
// running; a dead worker would keep claiming tasks it
// can never run
let mut refresh_queue = false; let mut refresh_queue = false;
let results = server.index(&batch).await.into_iter().map(|r| { let ids = batch.iter().map(|task| task.info.id).collect::<Vec<_>>();
let run = {
let server = server.clone();
tokio::spawn(async move {
let results = server.index(&batch).await;
(batch, results)
})
};
match run.await {
Ok((mut batch, results)) => {
let results = results.into_iter().map(|r| {
refresh_queue |= r.result.is_retry(); refresh_queue |= r.result.is_retry();
r.result r.result
}); });
update_tasks(&server, &mut batch, results).await; update_tasks(&server, &mut batch, results).await;
}
Err(err) => {
worker_failed(&server, &ids, err).await;
refresh_queue = true;
}
}
if refresh_queue || rx.is_empty() { if refresh_queue || rx.is_empty() {
server.notify_task_queue(); server.notify_task_queue();
@@ -203,83 +180,31 @@ pub fn spawn_task_manager(inner: Arc<Inner>) {
let server = inner.build_server(); let server = inner.build_server();
let mut refresh_queue = false; let mut refresh_queue = false;
match server if let Some(TaskDetails { task, info }) = fetch_task(&server, job).await {
.store() // inbuxa: on a task of its own, as above
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue( let run = {
TaskQueueClass::Task { id: job.id }, let server = server.clone();
))) let server_instance = server_instance.clone();
.await tokio::spawn(async move {
{ let result = run_task(&server, &task, server_instance).await;
Ok(Some(task)) => { (task, result)
let result = match &task { })
Task::CalendarAlarmEmail(task) => {
server.send_email_alarm(task, server_instance.clone()).await
}
Task::CalendarAlarmNotification(task) => {
server.send_display_alarm(task).await
}
Task::CalendarItipMessage(task) => {
server.send_imip(task, server_instance.clone()).await
}
Task::MergeThreads(task) => server.merge_threads(task).await,
Task::DmarcReport(task) => {
server
.submit_report(report::ReportId::Dmarc(task.report_id.id()))
.await
}
Task::TlsReport(task) => {
server
.submit_report(report::ReportId::Tls(task.report_id.id()))
.await
}
Task::RestoreArchivedItem(task) => server.restore_item(task).await,
Task::DestroyAccount(task) => server.destroy_account(task).await,
Task::AccountMaintenance(task) => {
server.account_maintenance(task).await
}
Task::TenantMaintenance(task) => {
server.tenant_maintenance(task).await
}
Task::StoreMaintenance(task) => {
server.store_maintenance(task).await
}
Task::SpamFilterMaintenance(task) => {
Box::pin(server.spam_filter_maintenance(task)).await
}
Task::AcmeRenewal(task) => server.acme_management(task).await,
Task::DkimManagement(task_dkim_rotation) => {
server.dkim_management(task_dkim_rotation).await
}
Task::DnsManagement(task_dns_management) => {
server.dns_management(task_dns_management).await
}
Task::IndexDocument(_)
| Task::UnindexDocument(_)
| Task::IndexTrace(_) => unreachable!(),
}; };
match run.await {
Ok((task, result)) => {
refresh_queue = result.is_retry(); refresh_queue = result.is_retry();
update_tasks( update_tasks(
&server, &server,
&mut [TaskDetails { task, info: job }], &mut [TaskDetails { task, info }],
vec![result], vec![result],
) )
.await; .await;
} }
Ok(None) => {
trc::event!(
TaskManager(TaskManagerEvent::TaskIgnored),
Id = job.id,
Reason = "Task not found in store, likely already processed.",
);
}
Err(err) => { Err(err) => {
trc::error!( worker_failed(&server, &[info.id], err).await;
err.id(job.id) refresh_queue = true;
.details("Failed to retrieve task details.") }
.caused_by(trc::location!())
);
} }
} }
@@ -318,6 +243,13 @@ pub(crate) trait TaskQueueManager: Sync + Send {
impl TaskQueueManager for Server { impl TaskQueueManager for Server {
async fn process_tasks(&self, ipc: &mut TaskManagerIpc) -> Duration { async fn process_tasks(&self, ipc: &mut TaskManagerIpc) -> Duration {
// inbuxa: a node that is stopping has released its locks and claims
// nothing new
let task_locks = &self.inner.ipc.task_locks;
if task_locks.is_stopping() {
return Duration::from_secs(QUEUE_REFRESH_INTERVAL);
}
let lock_expiry = task_locks.expiry();
let now_timestamp = now(); let now_timestamp = now();
let from_key = ValueKey::<ValueClass> { let from_key = ValueKey::<ValueClass> {
account_id: 0, account_id: 0,
@@ -393,9 +325,7 @@ impl TaskQueueManager for Server {
let locked = entry.get_mut(); let locked = entry.get_mut();
if locked.expires <= now || locked.due < task_due { if locked.expires <= now || locked.due < task_due {
locked.expires = Instant::now() locked.expires = Instant::now()
+ std::time::Duration::from_secs( + std::time::Duration::from_secs(lock_expiry + 1);
DEFAULT_LOCK_EXPIRY + 1,
);
locked.due = task_due; locked.due = task_due;
tasks.push(( tasks.push((
TaskJob { TaskJob {
@@ -411,9 +341,7 @@ impl TaskQueueManager for Server {
Entry::Vacant(entry) => { Entry::Vacant(entry) => {
entry.insert(Locked { entry.insert(Locked {
expires: Instant::now() expires: Instant::now()
+ std::time::Duration::from_secs( + std::time::Duration::from_secs(lock_expiry + 1),
DEFAULT_LOCK_EXPIRY + 1,
),
due: task_due, due: task_due,
revision: ipc.revision, revision: ipc.revision,
}); });
@@ -464,12 +392,26 @@ impl TaskQueueManager for Server {
let tx = &ipc.txs[task_type_idx as usize]; let tx = &ipc.txs[task_type_idx as usize];
if tx.capacity() > 0 { if tx.capacity() > 0 {
if self.try_lock_task(task_job.id).await && tx.send(task_job).await.is_err() { let id = task_job.id;
if !self.try_lock_task(id).await {
// inbuxa: another node holds the task. Look again after a
// short while rather than a full lock lifetime from now:
// the holder may have claimed it after this scan began,
// or run on a clock ahead of this one, and waiting the
// whole lifetime again would leave the task stuck for
// another hour past its lock if that holder died
if let Some(locked) = ipc.locked.get_mut(&id) {
locked.expires =
Instant::now() + Duration::from_secs(claim_recheck_interval(lock_expiry));
}
} else if tx.send(task_job).await.is_err() {
trc::event!( trc::event!(
Server(trc::ServerEvent::ThreadError), Server(trc::ServerEvent::ThreadError),
Details = "Error sending task.", Details = "Error sending task.",
CausedBy = trc::location!() CausedBy = trc::location!()
); );
// inbuxa: nothing will run it here, so don't hold it
self.remove_index_lock(id).await;
} }
} else { } else {
// If the channel is full, release the lock so it can be picked up in the next iteration // If the channel is full, release the lock so it can be picked up in the next iteration
@@ -481,9 +423,117 @@ impl TaskQueueManager for Server {
let now = Instant::now(); let now = Instant::now();
ipc.locked ipc.locked
.retain(|_, locked| locked.expires > now && locked.revision == ipc.revision); .retain(|_, locked| locked.expires > now && locked.revision == ipc.revision);
Duration::from_secs(next_event.map_or(QUEUE_REFRESH_INTERVAL, |timestamp| { let sleep_for = Duration::from_secs(next_event.map_or(QUEUE_REFRESH_INTERVAL, |timestamp| {
timestamp.saturating_sub(store::write::now()) timestamp.saturating_sub(store::write::now())
})) }));
// inbuxa: wake up when a claim held elsewhere is due to be tried
// again, rather than only on the next task or refresh
ipc.locked
.values()
.map(|locked| locked.expires.saturating_duration_since(now))
.min()
.map_or(sleep_for, |recheck| sleep_for.min(recheck.max(Duration::from_secs(1))))
}
}
async fn run_task(
server: &Server,
task: &Task,
server_instance: Arc<ServerInstance>,
) -> TaskResult {
match task {
Task::CalendarAlarmEmail(task) => {
server.send_email_alarm(task, server_instance.clone()).await
}
Task::CalendarAlarmNotification(task) => {
server.send_display_alarm(task).await
}
Task::CalendarItipMessage(task) => {
server.send_imip(task, server_instance.clone()).await
}
Task::MergeThreads(task) => server.merge_threads(task).await,
Task::DmarcReport(task) => {
server
.submit_report(report::ReportId::Dmarc(task.report_id.id()))
.await
}
Task::TlsReport(task) => {
server
.submit_report(report::ReportId::Tls(task.report_id.id()))
.await
}
Task::RestoreArchivedItem(task) => server.restore_item(task).await,
Task::DestroyAccount(task) => server.destroy_account(task).await,
Task::AccountMaintenance(task) => {
server.account_maintenance(task).await
}
Task::TenantMaintenance(task) => {
server.tenant_maintenance(task).await
}
Task::StoreMaintenance(task) => {
server.store_maintenance(task).await
}
Task::SpamFilterMaintenance(task) => {
Box::pin(server.spam_filter_maintenance(task)).await
}
Task::AcmeRenewal(task) => server.acme_management(task).await,
Task::DkimManagement(task_dkim_rotation) => {
server.dkim_management(task_dkim_rotation).await
}
Task::DnsManagement(task_dns_management) => {
server.dns_management(task_dns_management).await
}
Task::IndexDocument(_)
| Task::UnindexDocument(_)
| Task::IndexTrace(_) => unreachable!(),
}
}
/// Reads a claimed task. When it is gone or can't be read, the claim is
/// released: inbuxa: holding it would block the task, everywhere, until
/// the lock expired.
async fn fetch_task(server: &Server, job: TaskJob) -> Option<TaskDetails> {
match server
.store()
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task {
id: job.id,
})))
.await
{
Ok(Some(task)) => Some(TaskDetails { task, info: job }),
Ok(None) => {
trc::event!(
TaskManager(TaskManagerEvent::TaskIgnored),
Id = job.id,
Reason = "Task not found in store, likely already processed.",
);
server.remove_index_lock(job.id).await;
None
}
Err(err) => {
trc::error!(
err.id(job.id)
.details("Failed to retrieve task details.")
.caused_by(trc::location!())
);
server.remove_index_lock(job.id).await;
None
}
}
}
/// inbuxa: a task panicked: its locks are released so it runs again, here or
/// on another node, and the worker carries on.
async fn worker_failed(server: &Server, ids: &[u64], err: tokio::task::JoinError) {
trc::event!(
Server(trc::ServerEvent::ThreadError),
Details = "Task worker failed",
Reason = err.to_string(),
CausedBy = trc::location!()
);
for id in ids {
server.remove_index_lock(*id).await;
} }
} }
@@ -614,6 +664,13 @@ async fn update_tasks(
} }
} }
/// inbuxa: how long to wait before trying again to claim a task another node
/// holds: a twelfth of the lock lifetime, so five minutes for the one-hour
/// lock, never more than that and never under a second.
pub(crate) fn claim_recheck_interval(lock_expiry: u64) -> u64 {
(lock_expiry / 12).clamp(1, CLAIM_RECHECK_INTERVAL)
}
pub fn perpetual_retry_time(typ: TaskType, attempt: u64) -> Option<u64> { pub fn perpetual_retry_time(typ: TaskType, attempt: u64) -> Option<u64> {
matches!( matches!(
typ, typ,
+3 -1
View File
@@ -35,7 +35,9 @@ pub mod scheduler;
pub mod spam_classifier; pub mod spam_classifier;
const QUEUE_REFRESH_INTERVAL: u64 = 60 * 5; // 5 minutes const QUEUE_REFRESH_INTERVAL: u64 = 60 * 5; // 5 minutes
const DEFAULT_LOCK_EXPIRY: u64 = 60 * 60; // 1 hour // inbuxa: the lock lifetime (one hour) lives in common::ipc::TaskLocks, per
// server, so a graceful stop can release the locks and the tests can shorten it
const CLAIM_RECHECK_INTERVAL: u64 = 60 * 5; // 5 minutes
pub(crate) struct TaskManagerIpc { pub(crate) struct TaskManagerIpc {
txs: [mpsc::Sender<TaskJob>; TaskType::COUNT], txs: [mpsc::Sender<TaskJob>; TaskType::COUNT],
@@ -2,13 +2,15 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::task_manager::{TaskFailureType, TaskResult}; use crate::task_manager::{TaskFailureType, TaskResult};
use common::{ use common::{
Server, Server,
ipc::{BroadcastEvent, RegistryChange}, ipc::{BroadcastEvent, RegistryChange},
manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, fetch_resource}, manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, fetch_resource, spam_rules},
}; };
use registry::{ use registry::{
schema::{ schema::{
@@ -106,6 +108,7 @@ struct RuleUpdateResult {
async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> { async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
let started = Instant::now(); let started = Instant::now();
let bundled = server.core.spam.spam_rules_url.is_none();
let rules = match fetch_spam_rules(server).await { let rules = match fetch_spam_rules(server).await {
Ok(rules) => rules, Ok(rules) => rules,
Err(err) => { Err(err) => {
@@ -289,24 +292,31 @@ async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
Elapsed = started.elapsed(), Elapsed = started.elapsed(),
); );
// inbuxa: so the next start knows these bundled rules are in
if bundled {
spam_rules::set_applied_version(server.store(), spam_rules::BUNDLED_SPAM_RULES_VERSION)
.await?;
}
Ok(TaskResult::Success(vec![])) Ok(TaskResult::Success(vec![]))
} }
async fn fetch_spam_rules(server: &Server) -> Result<Rules, RuleUpdateError> { async fn fetch_spam_rules(server: &Server) -> Result<Rules, RuleUpdateError> {
let Some(rules_url) = server.core.spam.spam_rules_url.as_ref() else { // inbuxa: no URL means the rules bundled with the server
return Err(RuleUpdateError { let bytes = match server.core.spam.spam_rules_url.as_ref() {
typ: TaskFailureType::Permanent, Some(rules_url) => fetch_resource(rules_url, None, Duration::from_secs(60), 1024 * 500)
reason: "Spam rules resource URL not configured".to_string(),
});
};
let rules_json: AHashMap<String, Vec<serde_json::Value>> =
fetch_resource(rules_url, None, Duration::from_secs(60), 1024 * 500)
.await .await
.map_err(|reason| RuleUpdateError { .map_err(|reason| RuleUpdateError {
typ: TaskFailureType::Temporary, typ: TaskFailureType::Temporary,
reason, reason,
}) }),
.and_then(|bytes| { None => spam_rules::bundled_rules().map_err(|reason| RuleUpdateError {
typ: TaskFailureType::Permanent,
reason,
}),
};
let rules_json: AHashMap<String, Vec<serde_json::Value>> =
bytes.and_then(|bytes| {
serde_json::from_slice(&bytes).map_err(|err| RuleUpdateError { serde_json::from_slice(&bytes).map_err(|err| RuleUpdateError {
typ: TaskFailureType::Permanent, typ: TaskFailureType::Permanent,
reason: format!("Failed to parse spam rules JSON: {err}"), reason: format!("Failed to parse spam rules JSON: {err}"),
+2 -3
View File
@@ -1,9 +1,8 @@
[package] [package]
name = "smtp" name = "smtp"
description = "Stalwart SMTP Server" description = "inbuxa SMTP server"
authors = [ "Stalwart Labs LLC <[email protected]>"] authors = [ "Stalwart Labs LLC <[email protected]>"]
repository = "https://github.com/stalwartlabs/smtp-server" homepage = "https://inbuxa.org"
homepage = "https://stalw.art/smtp"
keywords = ["smtp", "email", "mail", "server"] keywords = ["smtp", "email", "mail", "server"]
categories = ["email"] categories = ["email"]
license = "AGPL-3.0-only OR LicenseRef-SEL" license = "AGPL-3.0-only OR LicenseRef-SEL"
+34 -27
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use common::config::mailstore::spamfilter::PyzorConfig; use common::config::mailstore::spamfilter::PyzorConfig;
@@ -43,35 +45,14 @@ pub(crate) async fn pyzor_check(
// Hash message // Hash message
let request = message.pyzor_check_message(); let request = message.pyzor_check_message();
// Send message to address. inbuxa: in tests, a fixed table answers
// instead of a public server (test_response).
#[cfg(not(feature = "test_mode"))]
let response = pyzor_send_message(config.address, config.timeout, &request).await;
#[cfg(feature = "test_mode")] #[cfg(feature = "test_mode")]
{ let response = std::io::Result::Ok(test_response(&request));
if request.contains("b5b476f0b5ba6e1c038361d3ded5818dd39c90a2") {
return Ok(PyzorResponse {
code: 200,
count: 1000,
wl_count: 0,
}
.into());
} else if request.contains("d67d4b8bfc3860449e3418bb6017e2612f3e2a99") {
return Ok(PyzorResponse {
code: 200,
count: 60,
wl_count: 10,
}
.into());
} else if request.contains("81763547012b75e57a20d18ce0b93014208cdfdb") {
return Ok(PyzorResponse {
code: 200,
count: 50,
wl_count: 20,
}
.into());
}
}
// Send message to address response
pyzor_send_message(config.address, config.timeout, &request)
.await
.map(Into::into) .map(Into::into)
.map_err(|err| { .map_err(|err| {
trc::SpamEvent::PyzorError trc::SpamEvent::PyzorError
@@ -82,6 +63,32 @@ pub(crate) async fn pyzor_check(
}) })
} }
/// inbuxa: the answers tests get, by digest, instead of a public server's,
/// whose counts change and which a test may not be able to reach. Upstream
/// answered the first three here and sent every other digest to the network.
#[cfg(feature = "test_mode")]
fn test_response(request: &str) -> PyzorResponse {
let (count, wl_count) = if request.contains("b5b476f0b5ba6e1c038361d3ded5818dd39c90a2")
// The digest of an empty body, as an HTML-only message with no text
// to hash produces; public servers report it widely.
|| request.contains("da39a3ee5e6b4b0d3255bfef95601890afd80709")
{
(1000, 0)
} else if request.contains("d67d4b8bfc3860449e3418bb6017e2612f3e2a99") {
(60, 10)
} else if request.contains("81763547012b75e57a20d18ce0b93014208cdfdb") {
(50, 20)
} else {
(0, 0)
};
PyzorResponse {
code: 200,
count,
wl_count,
}
}
#[cfg_attr(feature = "test_mode", allow(dead_code))]
async fn pyzor_send_message( async fn pyzor_send_message(
addr: SocketAddr, addr: SocketAddr,
timeout: Duration, timeout: Duration,
+71 -8
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -19,7 +21,7 @@ use crate::{
write::SearchIndex, write::SearchIndex,
}; };
use mysql_async::{IsolationLevel, TxOpts, Value, prelude::Queryable}; use mysql_async::{IsolationLevel, TxOpts, Value, prelude::Queryable};
use nlp::tokenizers::word::WordTokenizer; use nlp::{language::Language, tokenizers::word::WordTokenizer};
use std::fmt::Write; use std::fmt::Write;
impl MysqlStore { impl MysqlStore {
@@ -146,6 +148,20 @@ impl MysqlStore {
} }
} }
// inbuxa: InnoDB's default full-text stopword list
// (INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD) and innodb_ft_min_token_size
// default; words outside these are not in a FULLTEXT index.
const FT_STOPWORDS: &[&str] = &[
"a", "about", "an", "are", "as", "at", "be", "by", "com", "de", "en", "for", "from", "how",
"i", "in", "is", "it", "la", "of", "on", "or", "that", "the", "this", "to", "was", "what",
"when", "where", "who", "will", "with", "und", "www",
];
const FT_MIN_TOKEN_SIZE: usize = 3;
fn is_ft_indexed(word: &str) -> bool {
word.chars().count() >= FT_MIN_TOKEN_SIZE && !FT_STOPWORDS.contains(&word)
}
fn build_filter(query: &mut String, filters: &[SearchFilter]) -> Vec<Value> { fn build_filter(query: &mut String, filters: &[SearchFilter]) -> Vec<Value> {
if filters.is_empty() { if filters.is_empty() {
return Vec::new(); return Vec::new();
@@ -171,30 +187,77 @@ fn build_filter(query: &mut String, filters: &[SearchFilter]) -> Vec<Value> {
if field.is_text() && matches!(op, SearchOperator::Equal | SearchOperator::Contains) if field.is_text() && matches!(op, SearchOperator::Equal | SearchOperator::Contains)
{ {
let (value, mode) = match (value, op) { let (value, mode, unindexed) = match (value, op) {
(SearchValue::Text { value, .. }, SearchOperator::Equal) => { (SearchValue::Text { value, .. }, SearchOperator::Equal) => (
(Value::Bytes(format!("{value:?}").into_bytes()), "BOOLEAN") Value::Bytes(format!("{value:?}").into_bytes()),
} "BOOLEAN",
(SearchValue::Text { value, .. }, ..) => { Vec::new(),
),
(SearchValue::Text { value, language }, ..) => {
let mut text_query = String::with_capacity(value.len() + 1); let mut text_query = String::with_capacity(value.len() + 1);
let mut unindexed = Vec::new();
for item in WordTokenizer::new(value, MAX_TOKEN_LENGTH) { for item in WordTokenizer::new(value, MAX_TOKEN_LENGTH) {
// inbuxa: InnoDB never indexes stopwords ("com",
// "de", "www", ...) or words under
// innodb_ft_min_token_size, and a required
// (+word) term it has not indexed matches no row,
// so "example.com" or "[email protected]" found
// nothing. Such words are matched with a
// word-boundary REGEXP instead.
if is_ft_indexed(&item.word) {
if !text_query.is_empty() { if !text_query.is_empty() {
text_query.push(' '); text_query.push(' ');
} }
text_query.push('+'); text_query.push('+');
text_query.push_str(&item.word); text_query.push_str(&item.word);
} else {
unindexed.push(item.word);
}
} }
(Value::Bytes(text_query.into_bytes()), "BOOLEAN") // For language text (bodies, subjects) the unindexed
// words are noise words and only checked when nothing
// else is left to match; keyword text (addresses,
// contact fields) checks every word, as the other
// backends do.
if !text_query.is_empty() && !matches!(language, Language::None) {
unindexed.clear();
}
(Value::Bytes(text_query.into_bytes()), "BOOLEAN", unindexed)
} }
_ => { _ => {
debug_assert!(false, "Invalid search value for text field"); debug_assert!(false, "Invalid search value for text field");
continue; continue;
} }
}; };
let _ = write!(query, "MATCH({}) AGAINST(? IN {mode} MODE)", field.column()); if unindexed.is_empty() {
let _ =
write!(query, "MATCH({}) AGAINST(? IN {mode} MODE)", field.column());
values.push(value); values.push(value);
} else {
query.push('(');
let is_empty = matches!(&value, Value::Bytes(v) if v.is_empty());
if !is_empty {
let _ = write!(
query,
"MATCH({}) AGAINST(? IN {mode} MODE) AND ",
field.column()
);
values.push(value);
}
for (i, word) in unindexed.iter().enumerate() {
if i > 0 {
query.push_str(" AND ");
}
let _ = write!(query, "{} REGEXP ?", field.column());
values.push(Value::Bytes(
format!("(^|[^[:alnum:]]){word}([^[:alnum:]]|$)").into_bytes(),
));
}
query.push(')');
}
} else if let SearchValue::KeyValues(kv) = value { } else if let SearchValue::KeyValues(kv) = value {
let (key, value) = kv.iter().next().unwrap(); let (key, value) = kv.iter().next().unwrap();
+77 -8
View File
@@ -2,20 +2,25 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
backend::postgres::{ backend::{
MAX_TOKEN_LENGTH,
postgres::{
DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, PostgresStore, PsqlSearchField, into_error, DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, PostgresStore, PsqlSearchField, into_error,
into_pool_error, is_timeout_error, into_pool_error, is_timeout_error,
}, },
},
search::{ search::{
IndexDocument, SearchComparator, SearchDocumentId, SearchFilter, SearchOperator, IndexDocument, SearchComparator, SearchDocumentId, SearchFilter, SearchOperator,
SearchQuery, SearchValue, SearchQuery, SearchValue,
}, },
write::SearchIndex, write::SearchIndex,
}; };
use nlp::language::Language; use nlp::{language::Language, tokenizers::space::SpaceTokenizer};
use std::fmt::Write; use std::fmt::Write;
use tokio_postgres::{ use tokio_postgres::{
IsolationLevel, IsolationLevel,
@@ -43,6 +48,19 @@ impl PostgresStore {
let primary_keys = index.primary_keys(); let primary_keys = index.primary_keys();
let all_fields = index.all_fields(); let all_fields = index.all_fields();
let fields = document.fields; let fields = document.fields;
// inbuxa: keyword text (addresses, contact fields, ...) is split into
// words before it reaches the text parser, see keyword_terms().
let keywords = primary_keys
.iter()
.chain(all_fields)
.map(|field| match fields.get(field) {
Some(SearchValue::Text {
value,
language: Language::None,
}) if field.is_text() => Some(keyword_terms(value)),
_ => None,
})
.collect::<Vec<_>>();
let mut values = Vec::with_capacity(fields.len() + 2); let mut values = Vec::with_capacity(fields.len() + 2);
let mut query = format!("INSERT INTO {} (", index.psql_table()); let mut query = format!("INSERT INTO {} (", index.psql_table());
@@ -74,7 +92,20 @@ impl PostgresStore {
(0, PG_UNSTEMMED_LANG) (0, PG_UNSTEMMED_LANG)
}; };
if field.is_text() { if let Some(keywords) = &keywords[i] {
let _ = write!(&mut query, "to_tsvector('{language}',{value_ref})");
values.push(keywords as &(dyn ToSql + Sync));
if field.sort_column().is_some() {
let value_ref = format!("${}", values.len() + 1);
if text_len > 255 {
let _ = write!(&mut query, ",left({value_ref},255)");
} else {
let _ = write!(&mut query, ",{value_ref}");
}
values.push(value as &(dyn ToSql + Sync));
}
continue;
} else if field.is_text() {
let _ = write!(&mut query, "to_tsvector('{language}',{value_ref})"); let _ = write!(&mut query, "to_tsvector('{language}',{value_ref})");
} else if text_len > 512 { } else if text_len > 512 {
query.push_str("left("); query.push_str("left(");
@@ -134,6 +165,7 @@ impl PostgresStore {
) -> trc::Result<Vec<R>> { ) -> trc::Result<Vec<R>> {
let mut query = format!("SELECT {} FROM {}", R::field().column(), index.psql_table()); let mut query = format!("SELECT {} FROM {}", R::field().column(), index.psql_table());
let params = self.build_filter(&mut query, filters); let params = self.build_filter(&mut query, filters);
let params = params.iter().map(SqlParam::as_sql).collect::<Vec<_>>();
if !sort.is_empty() { if !sort.is_empty() {
build_sort(&mut query, sort); build_sort(&mut query, sort);
} }
@@ -155,6 +187,7 @@ impl PostgresStore {
let table = filter.index.psql_table(); let table = filter.index.psql_table();
let mut where_clause = String::new(); let mut where_clause = String::new();
let params = self.build_filter(&mut where_clause, &filter.filters); let params = self.build_filter(&mut where_clause, &filter.filters);
let params = params.iter().map(SqlParam::as_sql).collect::<Vec<_>>();
let conn = self.conn_pool.get().await.map_err(into_pool_error)?; let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
let s = conn let s = conn
.prepare_cached(&format!("DELETE FROM {table}{where_clause}")) .prepare_cached(&format!("DELETE FROM {table}{where_clause}"))
@@ -196,7 +229,7 @@ impl PostgresStore {
&self, &self,
query: &mut String, query: &mut String,
filters: &'x [SearchFilter], filters: &'x [SearchFilter],
) -> Vec<&'x (dyn ToSql + Sync)> { ) -> Vec<SqlParam<'x>> {
if filters.is_empty() { if filters.is_empty() {
return Vec::new(); return Vec::new();
} }
@@ -237,6 +270,10 @@ impl PostgresStore {
if matches!(language, Language::None) { if matches!(language, Language::None) {
let _ = write!(query, "@@ {method}('{config}', ${value_pos})"); let _ = write!(query, "@@ {method}('{config}', ${value_pos})");
if let SearchValue::Text { value, .. } = value {
values.push(SqlParam::Owned(keyword_terms(value)));
continue;
}
} else { } else {
let _ = write!(query, "@@ ({method}('{config}', ${value_pos})"); let _ = write!(query, "@@ ({method}('{config}', ${value_pos})");
for fallback in [PG_FALLBACK_LANG, PG_UNSTEMMED_LANG] { for fallback in [PG_FALLBACK_LANG, PG_UNSTEMMED_LANG] {
@@ -247,18 +284,18 @@ impl PostgresStore {
} }
query.push(')'); query.push(')');
} }
values.push(value as &(dyn ToSql + Sync)); values.push(SqlParam::Ref(value));
} else if let SearchValue::KeyValues(kv) = value { } else if let SearchValue::KeyValues(kv) = value {
query.push_str(field.column()); query.push_str(field.column());
query.push(' '); query.push(' ');
let (key, value) = kv.iter().next().unwrap(); let (key, value) = kv.iter().next().unwrap();
values.push(key as &(dyn ToSql + Sync)); values.push(SqlParam::Ref(key));
if !value.is_empty() { if !value.is_empty() {
let _ = write!(query, "->> ${value_pos} "); let _ = write!(query, "->> ${value_pos} ");
op.write_pqsql(query, values.len() + 1); op.write_pqsql(query, values.len() + 1);
values.push(value as &(dyn ToSql + Sync)); values.push(SqlParam::Ref(value));
} else { } else {
let _ = write!(query, " ? ${value_pos}"); let _ = write!(query, " ? ${value_pos}");
} }
@@ -267,7 +304,7 @@ impl PostgresStore {
query.push(' '); query.push(' ');
op.write_pqsql(query, value_pos); op.write_pqsql(query, value_pos);
values.push(value as &(dyn ToSql + Sync)); values.push(SqlParam::Ref(value));
} }
} }
SearchFilter::And | SearchFilter::Or => { SearchFilter::And | SearchFilter::Or => {
@@ -321,6 +358,38 @@ impl PostgresStore {
} }
} }
// inbuxa: PostgreSQL's text parser keeps "[email protected]" (and host names,
// URLs, file paths, ...) as a single token, so a search for "user" or
// "example.com" never matched an address. Keyword text is split into words the
// same way the built-in index splits it (SpaceTokenizer: lowercase runs of
// alphanumerics) on both the indexing and the query side, so a full address,
// its local part, its domain and the display-name words all match, as they do
// on the other backends.
pub(crate) fn keyword_terms(value: &str) -> String {
let mut terms = String::with_capacity(value.len());
for token in SpaceTokenizer::new(value, MAX_TOKEN_LENGTH) {
if !terms.is_empty() {
terms.push(' ');
}
terms.push_str(&token);
}
terms
}
pub(super) enum SqlParam<'x> {
Ref(&'x (dyn ToSql + Sync)),
Owned(String),
}
impl SqlParam<'_> {
fn as_sql(&self) -> &(dyn ToSql + Sync) {
match self {
SqlParam::Ref(value) => *value,
SqlParam::Owned(value) => value,
}
}
}
fn build_sort(query: &mut String, sort: &[SearchComparator]) { fn build_sort(query: &mut String, sort: &[SearchComparator]) {
query.push_str(" ORDER BY "); query.push_str(" ORDER BY ");
for (i, comparator) in sort.iter().enumerate() { for (i, comparator) in sort.iter().enumerate() {
+2 -2
View File
@@ -27,7 +27,7 @@ impl RegistryStore {
// variable, so it's ignored there, and loudly. // variable, so it's ignored there, and loudly.
if !inner.env_recovery_mode && inner.env_recovery_admin.take().is_some() { if !inner.env_recovery_mode && inner.env_recovery_admin.take().is_some() {
eprintln!(); eprintln!();
eprintln!("⚠️ INBUXA_RECOVERY_ADMIN (or STALWART_RECOVERY_ADMIN) is set, but the"); eprintln!("⚠️ INBUXA_RECOVERY_ADMIN is set, but the");
eprintln!(" server is configured and not in recovery mode, so it is ignored."); eprintln!(" server is configured and not in recovery mode, so it is ignored.");
eprintln!(" Remove it from the environment. To use it for recovery, also set"); eprintln!(" Remove it from the environment. To use it for recovery, also set");
eprintln!(" INBUXA_RECOVERY_MODE=1."); eprintln!(" INBUXA_RECOVERY_MODE=1.");
@@ -47,7 +47,7 @@ impl RegistryStore {
.collect::<String>(); .collect::<String>();
eprintln!(); eprintln!();
eprintln!("════════════════════════════════════════════════════════════"); eprintln!("════════════════════════════════════════════════════════════");
eprintln!("🔑 INBUXA bootstrap mode - temporary administrator account"); eprintln!("🔑 inbuxa bootstrap mode - temporary administrator account");
eprintln!(); eprintln!();
eprintln!(" username: admin"); eprintln!(" username: admin");
eprintln!(" password: {password}"); eprintln!(" password: {password}");
+7 -2
View File
@@ -10,8 +10,9 @@
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is // inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is
// auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is // auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is
// security.legacy-protocols-changed (LP-8) // security.legacy-protocols-changed (LP-8); 644 to 646 are the cluster
pub const TOTAL_EVENT_COUNT: usize = 644; // coordinator's connection events
pub const TOTAL_EVENT_COUNT: usize = 647;
pub const TOTAL_METRIC_COUNT: usize = 369; pub const TOTAL_METRIC_COUNT: usize = 369;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -150,6 +151,10 @@ pub enum ClusterEvent {
MessageSkipped = 47, MessageSkipped = 47,
MessageInvalid = 49, MessageInvalid = 49,
NodeIdRenewed = 275, NodeIdRenewed = 275,
// inbuxa: the coordinator's connection
CoordinatorConnected = 644,
CoordinatorDisconnected = 645,
CoordinatorError = 646,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+35 -3
View File
@@ -81,6 +81,10 @@ impl EventType {
b"cluster.message-skipped" => EventType::Cluster(ClusterEvent::MessageSkipped), b"cluster.message-skipped" => EventType::Cluster(ClusterEvent::MessageSkipped),
b"cluster.message-invalid" => EventType::Cluster(ClusterEvent::MessageInvalid), b"cluster.message-invalid" => EventType::Cluster(ClusterEvent::MessageInvalid),
b"cluster.node-id-renewed" => EventType::Cluster(ClusterEvent::NodeIdRenewed), b"cluster.node-id-renewed" => EventType::Cluster(ClusterEvent::NodeIdRenewed),
// inbuxa: coordinator connection
b"cluster.coordinator-connected" => EventType::Cluster(ClusterEvent::CoordinatorConnected),
b"cluster.coordinator-disconnected" => EventType::Cluster(ClusterEvent::CoordinatorDisconnected),
b"cluster.coordinator-error" => EventType::Cluster(ClusterEvent::CoordinatorError),
b"dane.authentication-success" => EventType::Dane(DaneEvent::AuthenticationSuccess), b"dane.authentication-success" => EventType::Dane(DaneEvent::AuthenticationSuccess),
b"dane.authentication-failure" => EventType::Dane(DaneEvent::AuthenticationFailure), b"dane.authentication-failure" => EventType::Dane(DaneEvent::AuthenticationFailure),
b"dane.no-certificates-found" => EventType::Dane(DaneEvent::NoCertificatesFound), b"dane.no-certificates-found" => EventType::Dane(DaneEvent::NoCertificatesFound),
@@ -742,6 +746,14 @@ impl EventType {
EventType::Cluster(ClusterEvent::MessageSkipped) => "cluster.message-skipped", EventType::Cluster(ClusterEvent::MessageSkipped) => "cluster.message-skipped",
EventType::Cluster(ClusterEvent::MessageInvalid) => "cluster.message-invalid", EventType::Cluster(ClusterEvent::MessageInvalid) => "cluster.message-invalid",
EventType::Cluster(ClusterEvent::NodeIdRenewed) => "cluster.node-id-renewed", EventType::Cluster(ClusterEvent::NodeIdRenewed) => "cluster.node-id-renewed",
// inbuxa: coordinator connection
EventType::Cluster(ClusterEvent::CoordinatorConnected) => {
"cluster.coordinator-connected"
}
EventType::Cluster(ClusterEvent::CoordinatorDisconnected) => {
"cluster.coordinator-disconnected"
}
EventType::Cluster(ClusterEvent::CoordinatorError) => "cluster.coordinator-error",
EventType::Dane(DaneEvent::AuthenticationSuccess) => "dane.authentication-success", EventType::Dane(DaneEvent::AuthenticationSuccess) => "dane.authentication-success",
EventType::Dane(DaneEvent::AuthenticationFailure) => "dane.authentication-failure", EventType::Dane(DaneEvent::AuthenticationFailure) => "dane.authentication-failure",
EventType::Dane(DaneEvent::NoCertificatesFound) => "dane.no-certificates-found", EventType::Dane(DaneEvent::NoCertificatesFound) => "dane.no-certificates-found",
@@ -1524,6 +1536,10 @@ impl EventType {
EventType::Cluster(ClusterEvent::MessageSkipped) => 47, EventType::Cluster(ClusterEvent::MessageSkipped) => 47,
EventType::Cluster(ClusterEvent::MessageInvalid) => 49, EventType::Cluster(ClusterEvent::MessageInvalid) => 49,
EventType::Cluster(ClusterEvent::NodeIdRenewed) => 275, EventType::Cluster(ClusterEvent::NodeIdRenewed) => 275,
// inbuxa: coordinator connection
EventType::Cluster(ClusterEvent::CoordinatorConnected) => 644,
EventType::Cluster(ClusterEvent::CoordinatorDisconnected) => 645,
EventType::Cluster(ClusterEvent::CoordinatorError) => 646,
EventType::Dane(DaneEvent::AuthenticationSuccess) => 67, EventType::Dane(DaneEvent::AuthenticationSuccess) => 67,
EventType::Dane(DaneEvent::AuthenticationFailure) => 66, EventType::Dane(DaneEvent::AuthenticationFailure) => 66,
EventType::Dane(DaneEvent::NoCertificatesFound) => 69, EventType::Dane(DaneEvent::NoCertificatesFound) => 69,
@@ -2176,6 +2192,10 @@ impl EventType {
47 => Some(EventType::Cluster(ClusterEvent::MessageSkipped)), 47 => Some(EventType::Cluster(ClusterEvent::MessageSkipped)),
49 => Some(EventType::Cluster(ClusterEvent::MessageInvalid)), 49 => Some(EventType::Cluster(ClusterEvent::MessageInvalid)),
275 => Some(EventType::Cluster(ClusterEvent::NodeIdRenewed)), 275 => Some(EventType::Cluster(ClusterEvent::NodeIdRenewed)),
// inbuxa: coordinator connection
644 => Some(EventType::Cluster(ClusterEvent::CoordinatorConnected)),
645 => Some(EventType::Cluster(ClusterEvent::CoordinatorDisconnected)),
646 => Some(EventType::Cluster(ClusterEvent::CoordinatorError)),
67 => Some(EventType::Dane(DaneEvent::AuthenticationSuccess)), 67 => Some(EventType::Dane(DaneEvent::AuthenticationSuccess)),
66 => Some(EventType::Dane(DaneEvent::AuthenticationFailure)), 66 => Some(EventType::Dane(DaneEvent::AuthenticationFailure)),
69 => Some(EventType::Dane(DaneEvent::NoCertificatesFound)), 69 => Some(EventType::Dane(DaneEvent::NoCertificatesFound)),
@@ -3114,6 +3134,10 @@ impl EventType {
EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn, EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn,
EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn, EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn,
EventType::Cluster(ClusterEvent::SubscriberDisconnected) => Level::Warn, EventType::Cluster(ClusterEvent::SubscriberDisconnected) => Level::Warn,
// inbuxa: coordinator connection
EventType::Cluster(ClusterEvent::CoordinatorConnected) => Level::Info,
EventType::Cluster(ClusterEvent::CoordinatorDisconnected) => Level::Warn,
EventType::Cluster(ClusterEvent::CoordinatorError) => Level::Warn,
EventType::Delivery(DeliveryEvent::MissingOutboundHostname) => Level::Warn, EventType::Delivery(DeliveryEvent::MissingOutboundHostname) => Level::Warn,
EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded) => Level::Warn, EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded) => Level::Warn,
EventType::Delivery(DeliveryEvent::RateLimitExceeded) => Level::Warn, EventType::Delivery(DeliveryEvent::RateLimitExceeded) => Level::Warn,
@@ -3244,6 +3268,10 @@ impl EventType {
EventType::Cluster(ClusterEvent::MessageSkipped) => "PubSub message skipped", EventType::Cluster(ClusterEvent::MessageSkipped) => "PubSub message skipped",
EventType::Cluster(ClusterEvent::MessageInvalid) => "Invalid PubSub message", EventType::Cluster(ClusterEvent::MessageInvalid) => "Invalid PubSub message",
EventType::Cluster(ClusterEvent::NodeIdRenewed) => "Node ID renewed", EventType::Cluster(ClusterEvent::NodeIdRenewed) => "Node ID renewed",
// inbuxa: coordinator connection
EventType::Cluster(ClusterEvent::CoordinatorConnected) => "Coordinator connected",
EventType::Cluster(ClusterEvent::CoordinatorDisconnected) => "Coordinator unavailable",
EventType::Cluster(ClusterEvent::CoordinatorError) => "Coordinator error",
EventType::Dane(DaneEvent::AuthenticationSuccess) => "DANE authentication successful", EventType::Dane(DaneEvent::AuthenticationSuccess) => "DANE authentication successful",
EventType::Dane(DaneEvent::AuthenticationFailure) => "DANE authentication failed", EventType::Dane(DaneEvent::AuthenticationFailure) => "DANE authentication failed",
EventType::Dane(DaneEvent::NoCertificatesFound) => "No certificates found for DANE", EventType::Dane(DaneEvent::NoCertificatesFound) => "No certificates found for DANE",
@@ -3729,8 +3757,8 @@ impl EventType {
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
"Legacy mail protocols switch changed" "Legacy mail protocols switch changed"
} }
EventType::Server(ServerEvent::Startup) => "Starting INBUXA Server", EventType::Server(ServerEvent::Startup) => "Starting inbuxa Server",
EventType::Server(ServerEvent::Shutdown) => "Shutting down INBUXA Server", EventType::Server(ServerEvent::Shutdown) => "Shutting down inbuxa Server",
EventType::Server(ServerEvent::StartupError) => "Server startup error", EventType::Server(ServerEvent::StartupError) => "Server startup error",
EventType::Server(ServerEvent::ThreadError) => "Server thread error", EventType::Server(ServerEvent::ThreadError) => "Server thread error",
EventType::Server(ServerEvent::Licensing) => "Server licensing event", EventType::Server(ServerEvent::Licensing) => "Server licensing event",
@@ -3983,7 +4011,7 @@ impl EventType {
EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error", EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error",
// inbuxa: legacy-protocols LP-6 // inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused) => { EventType::Auth(AuthEvent::LegacyProtocolRefused) => {
"This server allows only INBUXA webmail and JMAP apps" "This server allows only inbuxa webmail and JMAP apps"
} }
EventType::Auth(AuthEvent::Error) => "Authentication error", EventType::Auth(AuthEvent::Error) => "Authentication error",
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
@@ -4322,6 +4350,10 @@ impl EventType {
EventType::Cluster(ClusterEvent::MessageSkipped), EventType::Cluster(ClusterEvent::MessageSkipped),
EventType::Cluster(ClusterEvent::MessageInvalid), EventType::Cluster(ClusterEvent::MessageInvalid),
EventType::Cluster(ClusterEvent::NodeIdRenewed), EventType::Cluster(ClusterEvent::NodeIdRenewed),
// inbuxa: coordinator connection
EventType::Cluster(ClusterEvent::CoordinatorConnected),
EventType::Cluster(ClusterEvent::CoordinatorDisconnected),
EventType::Cluster(ClusterEvent::CoordinatorError),
EventType::Dane(DaneEvent::AuthenticationSuccess), EventType::Dane(DaneEvent::AuthenticationSuccess),
EventType::Dane(DaneEvent::AuthenticationFailure), EventType::Dane(DaneEvent::AuthenticationFailure),
EventType::Dane(DaneEvent::NoCertificatesFound), EventType::Dane(DaneEvent::NoCertificatesFound),
+38 -15
View File
@@ -18,7 +18,7 @@
#[macro_export] #[macro_export]
macro_rules! brand { macro_rules! brand {
() => { () => {
"INBUXA" "inbuxa"
}; };
} }
@@ -47,22 +47,32 @@ macro_rules! brand_url {
} }
/// Reads one of the server's environment variables by its unprefixed name, /// Reads one of the server's environment variables by its unprefixed name,
/// such as `RECOVERY_ADMIN`. /// such as `RECOVERY_ADMIN`, from `INBUXA_<name>`.
/// ///
/// `INBUXA_<name>` wins. `STALWART_<name>` is still read when the new name /// The upstream prefix isn't read (SPEC §2.4). An install moved over from
/// isn't set, so an existing Stalwart install moves over without editing its /// upstream that still sets it stops here with the variable to rename, rather
/// environment, and a warning says which variable to rename. /// than starting on defaults the operator didn't choose.
pub fn env_var(name: &str) -> Result<String, std::env::VarError> { pub fn env_var(name: &str) -> Result<String, std::env::VarError> {
match std::env::var(format!("INBUXA_{name}")) { let found = std::env::var(format!("INBUXA_{name}"));
Err(std::env::VarError::NotPresent) => { if matches!(found, Err(std::env::VarError::NotPresent))
let legacy = std::env::var(format!("STALWART_{name}")); && let Some(legacy) = legacy_setting(name, |var| std::env::var_os(var).is_some())
if legacy.is_ok() { {
eprintln!("Warning: STALWART_{name} is deprecated; set INBUXA_{name} instead."); eprintln!(
} "Error: {legacy} is set, but inbuxa reads INBUXA_{name}. Rename it and start again \
legacy (https://docs.inbuxa.org/install/migrating/)."
} );
found => found, std::process::exit(1);
} }
found
}
/// The environment prefix upstream reads. Only ever used to refuse it.
const LEGACY_ENV_PREFIX: &str = "STALWART";
/// The upstream-prefixed variable for `name`, if it's set.
fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
let legacy = format!("{LEGACY_ENV_PREFIX}_{name}");
is_set(&legacy).then_some(legacy)
} }
/// INBUXA's own version, dated like the rest of its family: `YYYY.M.D`, with /// INBUXA's own version, dated like the rest of its family: `YYYY.M.D`, with
@@ -71,7 +81,7 @@ pub fn env_var(name: &str) -> Result<String, std::env::VarError> {
#[macro_export] #[macro_export]
macro_rules! brand_version { macro_rules! brand_version {
() => { () => {
"2026.9.23" "2026.9.24.3"
}; };
} }
@@ -90,3 +100,16 @@ macro_rules! brand_version_full {
concat!($crate::brand_version!(), " (upstream ", env!("CARGO_PKG_VERSION"), ")") concat!($crate::brand_version!(), " (upstream ", env!("CARGO_PKG_VERSION"), ")")
}; };
} }
#[cfg(test)]
mod tests {
use super::{LEGACY_ENV_PREFIX, legacy_setting};
#[test]
fn an_upstream_setting_is_named_for_renaming() {
let old = format!("{LEGACY_ENV_PREFIX}_RECOVERY_ADMIN");
let set = |var: &str| var == old;
assert_eq!(legacy_setting("RECOVERY_ADMIN", set), Some(old.clone()));
assert_eq!(legacy_setting("HOSTNAME", set), None);
}
}
+1 -1
View File
@@ -147,7 +147,7 @@ pub fn build_http_client(
allow_invalid_certs: bool, allow_invalid_certs: bool,
) -> Result<Client, String> { ) -> Result<Client, String> {
let mut headers = build_http_headers(raw_headers, username, password, token, content_type)?; let mut headers = build_http_headers(raw_headers, username, password, token, content_type)?;
headers.insert(USER_AGENT, "INBUXA/1.0.0".parse().unwrap()); // types::brand!(); utils does not depend on types headers.insert(USER_AGENT, "inbuxa/1.0.0".parse().unwrap()); // types::brand!(); utils does not depend on types
match http_client_builder(allow_invalid_certs) match http_client_builder(allow_invalid_certs)
.connect_timeout(timeout) .connect_timeout(timeout)
+500
View File
@@ -0,0 +1,500 @@
{
"ref": "v0.16.23",
"commit": "9d1c75ab68435e4417337f768291e5f947686203",
"removed_files": [
"crates/common/src/enterprise/alerts.rs",
"crates/common/src/enterprise/config.rs",
"crates/common/src/enterprise/license.rs",
"crates/common/src/enterprise/llm.rs",
"crates/common/src/enterprise/masked.rs",
"crates/common/src/enterprise/mod.rs",
"crates/common/src/telemetry/metrics/store.rs",
"crates/common/src/telemetry/metrics/test_data.rs",
"crates/common/src/telemetry/tracers/store.rs",
"crates/http/src/api/telemetry.rs",
"crates/jmap/src/registry/mapping/archived_item.rs",
"crates/jmap/src/registry/mapping/masked_email.rs",
"crates/jmap/src/registry/mapping/telemetry.rs",
"crates/scim-proto/src/attributes.rs",
"crates/scim-proto/src/etag.rs",
"crates/scim-proto/src/filter.rs",
"crates/scim-proto/src/json.rs",
"crates/scim-proto/src/lib.rs",
"crates/scim-proto/src/message/bulk.rs",
"crates/scim-proto/src/message/error.rs",
"crates/scim-proto/src/message/list.rs",
"crates/scim-proto/src/message/mod.rs",
"crates/scim-proto/src/message/patch.rs",
"crates/scim-proto/src/message/search.rs",
"crates/scim-proto/src/path.rs",
"crates/scim-proto/src/schema/group.rs",
"crates/scim-proto/src/schema/mod.rs",
"crates/scim-proto/src/schema/spc.rs",
"crates/scim-proto/src/schema/user.rs",
"crates/scim/src/auth.rs",
"crates/scim/src/bulk.rs",
"crates/scim/src/context.rs",
"crates/scim/src/discovery.rs",
"crates/scim/src/error.rs",
"crates/scim/src/groups/get.rs",
"crates/scim/src/groups/mod.rs",
"crates/scim/src/groups/patch.rs",
"crates/scim/src/groups/set.rs",
"crates/scim/src/lib.rs",
"crates/scim/src/query/cursor.rs",
"crates/scim/src/query/mod.rs",
"crates/scim/src/request.rs",
"crates/scim/src/users/get.rs",
"crates/scim/src/users/mod.rs",
"crates/scim/src/users/patch.rs",
"crates/scim/src/users/set.rs",
"crates/spam-filter/src/analysis/llm.rs",
"crates/store/src/backend/composite/mod.rs",
"crates/store/src/backend/composite/read_replica.rs",
"crates/store/src/backend/composite/sharded_blob.rs",
"crates/store/src/backend/composite/sharded_lookup.rs",
"crates/trc/src/serializers/binary.rs",
"tests/src/directory/oidc.rs",
"tests/src/scim/auth.rs",
"tests/src/scim/bulk.rs",
"tests/src/scim/discovery.rs",
"tests/src/scim/groups.rs",
"tests/src/scim/limits.rs",
"tests/src/scim/mod.rs",
"tests/src/scim/query.rs",
"tests/src/scim/users.rs",
"tests/src/system/archiving.rs",
"tests/src/system/tenant.rs"
],
"removed_snippets": {
"crates/common/src/auth/authentication.rs": 3,
"crates/common/src/auth/mod.rs": 2,
"crates/common/src/auth/permissions.rs": 1,
"crates/common/src/cache/directory.rs": 6,
"crates/common/src/cache/invalidate.rs": 1,
"crates/common/src/cache/principals.rs": 2,
"crates/common/src/cache/reload.rs": 1,
"crates/common/src/config/mod.rs": 2,
"crates/common/src/config/telemetry.rs": 4,
"crates/common/src/lib.rs": 2,
"crates/common/src/manager/boot.rs": 1,
"crates/common/src/network/mta.rs": 1,
"crates/common/src/scripts/plugins/llm_prompt.rs": 1,
"crates/common/src/storage/quota.rs": 2,
"crates/common/src/telemetry/metrics/mod.rs": 1,
"crates/common/src/telemetry/metrics/prometheus.rs": 1,
"crates/common/src/telemetry/mod.rs": 1,
"crates/common/src/telemetry/tracers/mod.rs": 1,
"crates/http/src/api/mod.rs": 4,
"crates/http/src/auth/permissions.rs": 1,
"crates/http/src/request.rs": 4,
"crates/jmap/src/registry/get.rs": 1,
"crates/jmap/src/registry/mapping/mod.rs": 1,
"crates/jmap/src/registry/mapping/principal.rs": 3,
"crates/jmap/src/registry/mapping/queued_message.rs": 1,
"crates/jmap/src/registry/mapping/task.rs": 1,
"crates/jmap/src/registry/mod.rs": 1,
"crates/jmap/src/registry/query.rs": 1,
"crates/jmap/src/registry/set.rs": 2,
"crates/main/src/main.rs": 1,
"crates/services/src/task_manager/alarm.rs": 1,
"crates/services/src/task_manager/imip.rs": 1,
"crates/services/src/task_manager/index.rs": 2,
"crates/services/src/task_manager/maintenance.rs": 3,
"crates/services/src/task_manager/scheduler.rs": 8,
"crates/spam-filter/src/analysis/mod.rs": 1,
"crates/spam-filter/src/analysis/score.rs": 2,
"crates/store/src/backend/mod.rs": 1,
"crates/store/src/backend/mysql/main.rs": 1,
"crates/store/src/backend/postgres/main.rs": 1,
"crates/store/src/build/blob.rs": 2,
"crates/store/src/build/lookup.rs": 1,
"crates/store/src/build/memory.rs": 2,
"crates/store/src/dispatch/blob.rs": 6,
"crates/store/src/dispatch/lookup.rs": 10,
"crates/store/src/dispatch/mod.rs": 1,
"crates/store/src/dispatch/search.rs": 6,
"crates/store/src/dispatch/store.rs": 8,
"crates/store/src/lib.rs": 6,
"crates/trc/src/serializers/mod.rs": 1
},
"cargo_edits": [
{
"file": "crates/main/Cargo.toml",
"line": 50,
"before": "default = [\"rocks\", \"enterprise\"]",
"after": "default = [\"rocks\"]"
},
{
"file": "tests/Cargo.toml",
"line": 22,
"before": "store = { path = \"../crates/store\", features = [\"test_mode\", \"enterprise\"] }",
"after": "store = { path = \"../crates/store\", features = [\"test_mode\"] }"
},
{
"file": "tests/Cargo.toml",
"line": 24,
"before": "directory = { path = \"../crates/directory\", features = [\"test_mode\", \"enterprise\"] }",
"after": "directory = { path = \"../crates/directory\", features = [\"test_mode\"] }"
},
{
"file": "tests/Cargo.toml",
"line": 25,
"before": "coordinator = { path = \"../crates/coordinator\", features = [\"test_mode\", \"enterprise\"] }",
"after": "coordinator = { path = \"../crates/coordinator\", features = [\"test_mode\"] }"
},
{
"file": "tests/Cargo.toml",
"line": 26,
"before": "jmap = { path = \"../crates/jmap\", features = [\"test_mode\", \"enterprise\"] }",
"after": "jmap = { path = \"../crates/jmap\", features = [\"test_mode\"] }"
},
{
"file": "tests/Cargo.toml",
"line": 35,
"before": "http = { path = \"../crates/http\", features = [\"test_mode\", \"enterprise\"] }",
"after": "http = { path = \"../crates/http\", features = [\"test_mode\"] }"
},
{
"file": "tests/Cargo.toml",
"line": 37,
"before": "scim = { path = \"../crates/scim\", features = [\"test_mode\", \"enterprise\"] }",
"after": "scim = { path = \"../crates/scim\", features = [\"test_mode\"] }"
},
{
"file": "tests/Cargo.toml",
"line": 39,
"before": "services = { path = \"../crates/services\", features = [\"test_mode\", \"enterprise\"] }",
"after": "services = { path = \"../crates/services\", features = [\"test_mode\"] }"
},
{
"file": "tests/Cargo.toml",
"line": 41,
"before": "smtp = { path = \"../crates/smtp\", features = [\"test_mode\", \"enterprise\"] }",
"after": "smtp = { path = \"../crates/smtp\", features = [\"test_mode\"] }"
},
{
"file": "tests/Cargo.toml",
"line": 42,
"before": "common = { path = \"../crates/common\", features = [\"test_mode\", \"enterprise\"] }",
"after": "common = { path = \"../crates/common\", features = [\"test_mode\"] }"
},
{
"file": "tests/Cargo.toml",
"line": 44,
"before": "email = { path = \"../crates/email\", features = [\"test_mode\", \"enterprise\"] }",
"after": "email = { path = \"../crates/email\", features = [\"test_mode\"] }"
},
{
"file": "tests/Cargo.toml",
"line": 45,
"before": "spam-filter = { path = \"../crates/spam-filter\", features = [\"test_mode\", \"enterprise\"] }",
"after": "spam-filter = { path = \"../crates/spam-filter\", features = [\"test_mode\"] }"
},
{
"file": "tests/Cargo.toml",
"line": 46,
"before": "trc = { path = \"../crates/trc\", features = [\"enterprise\"] }",
"after": "trc = { path = \"../crates/trc\", features = [] }"
},
{
"file": "tests/Cargo.toml",
"line": 47,
"before": "managesieve = { path = \"../crates/managesieve\", features = [\"test_mode\", \"enterprise\"] }",
"after": "managesieve = { path = \"../crates/managesieve\", features = [\"test_mode\"] }"
},
{
"file": ".github/workflows/ci.yml",
"line": 341,
"before": "cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\"",
"after": "cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\""
},
{
"file": ".github/workflows/ci.yml",
"line": 379,
"before": "# cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features \"foundationdb s3 redis nats enterprise\"",
"after": "# cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features \"foundationdb s3 redis nats\""
},
{
"file": ".github/workflows/ci.yml",
"line": 386,
"before": "cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\"",
"after": "cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\""
},
{
"file": ".github/workflows/ci.yml",
"line": 442,
"before": "cargo build --release -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\"",
"after": "cargo build --release -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\""
},
{
"file": "Dockerfile",
"line": 22,
"before": "RUN RUSTFLAGS=\"$(cat /flags.txt)\" cargo chef cook --target \"$(cat /target.txt)\" --release --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\" --recipe-path /recipe.json",
"after": "RUN RUSTFLAGS=\"$(cat /flags.txt)\" cargo chef cook --target \"$(cat /target.txt)\" --release --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\" --recipe-path /recipe.json"
},
{
"file": "Dockerfile",
"line": 24,
"before": "RUN RUSTFLAGS=\"$(cat /flags.txt)\" cargo build --target \"$(cat /target.txt)\" --release -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\"",
"after": "RUN RUSTFLAGS=\"$(cat /flags.txt)\" cargo build --target \"$(cat /target.txt)\" --release -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\""
},
{
"file": "Dockerfile.build",
"line": 111,
"before": "RUSTFLAGS=\"-L /usr/lib\" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"foundationdb s3 redis nats enterprise\"; \\",
"after": "RUSTFLAGS=\"-L /usr/lib\" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"foundationdb s3 redis nats\"; \\"
},
{
"file": "Dockerfile.build",
"line": 119,
"before": "cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\"",
"after": "cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\""
},
{
"file": "Dockerfile.build",
"line": 132,
"before": "RUSTFLAGS=\"-L /usr/lib\" cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"foundationdb s3 redis nats enterprise\" && \\",
"after": "RUSTFLAGS=\"-L /usr/lib\" cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"foundationdb s3 redis nats\" && \\"
},
{
"file": "Dockerfile.build",
"line": 142,
"before": "cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\" && \\",
"after": "cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\" && \\"
},
{
"file": "Dockerfile.fdb",
"line": 56,
"before": "RUN cargo build -p stalwart --no-default-features --features \"foundationdb s3 redis azure nats enterprise\" --release",
"after": "RUN cargo build -p stalwart --no-default-features --features \"foundationdb s3 redis azure nats\" --release"
}
],
"dangling_mods": [
{
"file": "crates/common/src/telemetry/metrics/mod.rs",
"line": 12,
"module": "test_data",
"lines": [
"#[cfg(any(feature = \"dev_mode\", feature = \"test_mode\"))]",
"pub mod test_data;"
]
},
{
"file": "tests/src/directory/mod.rs",
"line": 11,
"module": "oidc",
"lines": [
"pub mod oidc;"
]
},
{
"file": "tests/src/lib.rs",
"line": 27,
"module": "scim",
"lines": [
"#[cfg(test)]",
"pub mod scim;"
]
},
{
"file": "tests/src/system/mod.rs",
"line": 8,
"module": "archiving",
"lines": [
"pub mod archiving;"
]
},
{
"file": "tests/src/system/mod.rs",
"line": 19,
"module": "tenant",
"lines": [
"pub mod tenant;"
]
}
],
"problems": [],
"feature_gates": {
"crates/common/src/cache/reload.rs": 1,
"crates/common/src/manager/boot.rs": 1,
"crates/common/src/storage/mod.rs": 1,
"crates/common/src/storage/quota.rs": 1,
"crates/common/src/telemetry/metrics/prometheus.rs": 1,
"crates/http/src/api/mod.rs": 1,
"crates/http/src/auth/permissions.rs": 1,
"crates/jmap/src/registry/get.rs": 1,
"crates/jmap/src/registry/mapping/principal.rs": 2,
"crates/jmap/src/registry/mapping/queued_message.rs": 1,
"crates/jmap/src/registry/mapping/task.rs": 1,
"crates/jmap/src/registry/set.rs": 1,
"crates/services/src/task_manager/alarm.rs": 1,
"crates/services/src/task_manager/imip.rs": 1,
"crates/services/src/task_manager/index.rs": 1,
"crates/services/src/task_manager/maintenance.rs": 1,
"crates/services/src/task_manager/scheduler.rs": 1,
"crates/store/src/lib.rs": 1
},
"edition_checks": {},
"schema": {
"objects": [
"x:AiModel",
"x:Alert",
"x:ArchivedItem",
"x:MaskedEmail",
"x:MetricsStore",
"x:SpamLlm",
"x:Tenant",
"x:Trace",
"x:TracingStore"
],
"fields": [
"x:AcmeProvider.memberTenantId",
"x:ArfExternalReport.memberTenantId",
"x:Authentication.defaultTenantRoleIds",
"x:CalendarAlarm.template",
"x:CalendarScheduling.emailTemplate",
"x:CalendarScheduling.httpRsvpTemplate",
"x:DataRetention.archiveDeletedAccountsFor",
"x:DataRetention.archiveDeletedItemsFor",
"x:DataRetention.holdMetricsFor",
"x:DataRetention.holdTracesFor",
"x:DataRetention.metricsCollectionInterval",
"x:Dkim1Signature.memberTenantId",
"x:Dkim2Signature.memberTenantId",
"x:DmarcExternalReport.memberTenantId",
"x:Domain.allowScimProvisioning",
"x:Domain.directoryId",
"x:Domain.logo",
"x:Domain.memberTenantId",
"x:Email.maxMaskedAddresses",
"x:Enterprise.logoUrl",
"x:GroupAccount.externalId",
"x:LdapDirectory.memberTenantId",
"x:MySqlStore.readReplicas",
"x:OidcDirectory.memberTenantId",
"x:PostgreSqlStore.readReplicas",
"x:Search.indexTelemetry",
"x:Search.indexTracingFields",
"x:SqlDirectory.memberTenantId",
"x:TlsExternalReport.memberTenantId",
"x:UserAccount.externalId"
]
},
"third_party": {
"crates/common/src/network/acme/directory.rs": [
{
"line": 7,
"text": "Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0."
}
],
"crates/common/src/network/acme/jose.rs": [
{
"line": 7,
"text": "Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0."
}
],
"crates/common/src/network/acme/order.rs": [
{
"line": 7,
"text": "Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0."
}
],
"crates/common/src/scripts/functions/text.rs": [
{
"line": 239,
"text": "MIT licensed."
},
{
"line": 241,
"text": "Copyright (c) 2016 Titus Wormer <[email protected]>"
}
],
"crates/common/src/telemetry/tracers/journald.rs": [
{
"line": 70,
"text": "SPDX-FileCopyrightText: 2018 Benjamin Saunders <[email protected]>"
},
{
"line": 71,
"text": "SPDX-License-Identifier: MIT"
}
],
"crates/imap-proto/src/utf7.rs": [
{
"line": 7,
"text": "Ported from https://github.com/jstedfast/MailKit/blob/master/MailKit/Net/Imap/ImapEncoding.cs"
}
],
"crates/jmap/src/registry/mapping/log.rs": [
{
"line": 341,
"text": "SPDX-FileCopyrightText: 2017 Michael Coyne <[email protected]>"
},
{
"line": 343,
"text": "SPDX-License-Identifier: MIT"
},
{
"line": 346,
"text": "Adapted from https://github.com/mjc-gh/rev_lines/blob/main/src/lib.rs"
}
],
"crates/jmap-proto/src/types/date.rs": [
{
"line": 121,
"text": "Ported from http://howardhinnant.github.io/date_algorithms.html#civil_from_days"
},
{
"line": 158,
"text": "Ported from https://github.com/protocolbuffers/upb/blob/22182e6e/upb/json_decode.c#L982-L992"
}
],
"crates/nlp/src/tokenizers/japanese.rs": [
{
"line": 73,
"text": "Ported from https://github.com/woxtu/rust-tinysegmenter, MIT license"
}
],
"crates/nlp/src/tokenizers/types.rs": [
{
"line": 738,
"text": "Credits: test suite from linkify crate"
}
],
"crates/registry/src/types/datetime.rs": [
{
"line": 150,
"text": "Ported from http://howardhinnant.github.io/date_algorithms.html#civil_from_days"
},
{
"line": 188,
"text": "Ported from https://github.com/protocolbuffers/upb/blob/22182e6e/upb/json_decode.c#L982-L992"
}
],
"crates/store/src/backend/postgres/tls.rs": [
{
"line": 7,
"text": "Credits: https://github.com/jbg/tokio-postgres-rustls"
}
],
"crates/types/src/id.rs": [
{
"line": 69,
"text": "From https://github.com/archer884/crockford by J/A <[email protected]>"
},
{
"line": 70,
"text": "License: MIT/Apache 2.0"
}
],
"crates/utils/src/glob.rs": [
{
"line": 107,
"text": "Credits: Algorithm ported from https://research.swtch.com/glob"
}
]
},
"third_party_unlisted": [],
"ossify_log": "$ ossify.py crates\nProcessing Rust files in: /tmp/claude-1000/-run-media-john-PROJECTS/b60a056e-7e44-433c-bbb6-7e1b1fece570/scratchpad/strip-v0.16.23/tree/crates\n\nFound 957 Rust files\n\n\nSummary:\n- 50 files were completely removed\n- 2 crate roots were emptied\n- 118 proprietary snippets were removed from 50 files\n\n$ ossify.py tests\nProcessing Rust files in: /tmp/claude-1000/-run-media-john-PROJECTS/b60a056e-7e44-433c-bbb6-7e1b1fece570/scratchpad/strip-v0.16.23/tree/tests\n\nFound 211 Rust files\n\n\nSummary:\n- 11 files were completely removed\n- 0 crate roots were emptied\n- 0 proprietary snippets were removed from 0 files\n"
}
+211
View File
@@ -0,0 +1,211 @@
# Strip report: upstream v0.16.23 (9d1c75ab6843)
- Enterprise-only files removed or emptied: **63**
- Enterprise-only snippets removed: **118** in 50 files
- Cargo edits turning `enterprise` off: **25**
- Dangling module declarations removed: **5**
- Verification: **clean**
- Left for the rebuilt features to replace: 19 `enterprise` feature gates in 18 files; 0 `is_enterprise_edition()` checks in 0 files
- Third-party code: 14 files, **0** not in THIRD-PARTY.md
- Upstream schema flags 9 objects and 30 fields as Enterprise
## Removed files
- `crates/common/src/enterprise/alerts.rs`
- `crates/common/src/enterprise/config.rs`
- `crates/common/src/enterprise/license.rs`
- `crates/common/src/enterprise/llm.rs`
- `crates/common/src/enterprise/masked.rs`
- `crates/common/src/enterprise/mod.rs`
- `crates/common/src/telemetry/metrics/store.rs`
- `crates/common/src/telemetry/metrics/test_data.rs`
- `crates/common/src/telemetry/tracers/store.rs`
- `crates/http/src/api/telemetry.rs`
- `crates/jmap/src/registry/mapping/archived_item.rs`
- `crates/jmap/src/registry/mapping/masked_email.rs`
- `crates/jmap/src/registry/mapping/telemetry.rs`
- `crates/scim-proto/src/attributes.rs`
- `crates/scim-proto/src/etag.rs`
- `crates/scim-proto/src/filter.rs`
- `crates/scim-proto/src/json.rs`
- `crates/scim-proto/src/lib.rs`
- `crates/scim-proto/src/message/bulk.rs`
- `crates/scim-proto/src/message/error.rs`
- `crates/scim-proto/src/message/list.rs`
- `crates/scim-proto/src/message/mod.rs`
- `crates/scim-proto/src/message/patch.rs`
- `crates/scim-proto/src/message/search.rs`
- `crates/scim-proto/src/path.rs`
- `crates/scim-proto/src/schema/group.rs`
- `crates/scim-proto/src/schema/mod.rs`
- `crates/scim-proto/src/schema/spc.rs`
- `crates/scim-proto/src/schema/user.rs`
- `crates/scim/src/auth.rs`
- `crates/scim/src/bulk.rs`
- `crates/scim/src/context.rs`
- `crates/scim/src/discovery.rs`
- `crates/scim/src/error.rs`
- `crates/scim/src/groups/get.rs`
- `crates/scim/src/groups/mod.rs`
- `crates/scim/src/groups/patch.rs`
- `crates/scim/src/groups/set.rs`
- `crates/scim/src/lib.rs`
- `crates/scim/src/query/cursor.rs`
- `crates/scim/src/query/mod.rs`
- `crates/scim/src/request.rs`
- `crates/scim/src/users/get.rs`
- `crates/scim/src/users/mod.rs`
- `crates/scim/src/users/patch.rs`
- `crates/scim/src/users/set.rs`
- `crates/spam-filter/src/analysis/llm.rs`
- `crates/store/src/backend/composite/mod.rs`
- `crates/store/src/backend/composite/read_replica.rs`
- `crates/store/src/backend/composite/sharded_blob.rs`
- `crates/store/src/backend/composite/sharded_lookup.rs`
- `crates/trc/src/serializers/binary.rs`
- `tests/src/directory/oidc.rs`
- `tests/src/scim/auth.rs`
- `tests/src/scim/bulk.rs`
- `tests/src/scim/discovery.rs`
- `tests/src/scim/groups.rs`
- `tests/src/scim/limits.rs`
- `tests/src/scim/mod.rs`
- `tests/src/scim/query.rs`
- `tests/src/scim/users.rs`
- `tests/src/system/archiving.rs`
- `tests/src/system/tenant.rs`
## Removed snippets
- `crates/common/src/auth/authentication.rs`: 3
- `crates/common/src/auth/mod.rs`: 2
- `crates/common/src/auth/permissions.rs`: 1
- `crates/common/src/cache/directory.rs`: 6
- `crates/common/src/cache/invalidate.rs`: 1
- `crates/common/src/cache/principals.rs`: 2
- `crates/common/src/cache/reload.rs`: 1
- `crates/common/src/config/mod.rs`: 2
- `crates/common/src/config/telemetry.rs`: 4
- `crates/common/src/lib.rs`: 2
- `crates/common/src/manager/boot.rs`: 1
- `crates/common/src/network/mta.rs`: 1
- `crates/common/src/scripts/plugins/llm_prompt.rs`: 1
- `crates/common/src/storage/quota.rs`: 2
- `crates/common/src/telemetry/metrics/mod.rs`: 1
- `crates/common/src/telemetry/metrics/prometheus.rs`: 1
- `crates/common/src/telemetry/mod.rs`: 1
- `crates/common/src/telemetry/tracers/mod.rs`: 1
- `crates/http/src/api/mod.rs`: 4
- `crates/http/src/auth/permissions.rs`: 1
- `crates/http/src/request.rs`: 4
- `crates/jmap/src/registry/get.rs`: 1
- `crates/jmap/src/registry/mapping/mod.rs`: 1
- `crates/jmap/src/registry/mapping/principal.rs`: 3
- `crates/jmap/src/registry/mapping/queued_message.rs`: 1
- `crates/jmap/src/registry/mapping/task.rs`: 1
- `crates/jmap/src/registry/mod.rs`: 1
- `crates/jmap/src/registry/query.rs`: 1
- `crates/jmap/src/registry/set.rs`: 2
- `crates/main/src/main.rs`: 1
- `crates/services/src/task_manager/alarm.rs`: 1
- `crates/services/src/task_manager/imip.rs`: 1
- `crates/services/src/task_manager/index.rs`: 2
- `crates/services/src/task_manager/maintenance.rs`: 3
- `crates/services/src/task_manager/scheduler.rs`: 8
- `crates/spam-filter/src/analysis/mod.rs`: 1
- `crates/spam-filter/src/analysis/score.rs`: 2
- `crates/store/src/backend/mod.rs`: 1
- `crates/store/src/backend/mysql/main.rs`: 1
- `crates/store/src/backend/postgres/main.rs`: 1
- `crates/store/src/build/blob.rs`: 2
- `crates/store/src/build/lookup.rs`: 1
- `crates/store/src/build/memory.rs`: 2
- `crates/store/src/dispatch/blob.rs`: 6
- `crates/store/src/dispatch/lookup.rs`: 10
- `crates/store/src/dispatch/mod.rs`: 1
- `crates/store/src/dispatch/search.rs`: 6
- `crates/store/src/dispatch/store.rs`: 8
- `crates/store/src/lib.rs`: 6
- `crates/trc/src/serializers/mod.rs`: 1
## Dangling module declarations removed
- `crates/common/src/telemetry/metrics/mod.rs:12`: `mod test_data` (#[cfg(any(feature = "dev_mode", feature = "test_mode"))] / pub mod test_data;)
- `tests/src/directory/mod.rs:11`: `mod oidc` (pub mod oidc;)
- `tests/src/lib.rs:27`: `mod scim` (#[cfg(test)] / pub mod scim;)
- `tests/src/system/mod.rs:8`: `mod archiving` (pub mod archiving;)
- `tests/src/system/mod.rs:19`: `mod tenant` (pub mod tenant;)
## Cargo edits
- `crates/main/Cargo.toml:50`: `default = ["rocks", "enterprise"]` → `default = ["rocks"]`
- `tests/Cargo.toml:22`: `store = { path = "../crates/store", features = ["test_mode", "enterprise"] }` → `store = { path = "../crates/store", features = ["test_mode"] }`
- `tests/Cargo.toml:24`: `directory = { path = "../crates/directory", features = ["test_mode", "enterprise"] }` → `directory = { path = "../crates/directory", features = ["test_mode"] }`
- `tests/Cargo.toml:25`: `coordinator = { path = "../crates/coordinator", features = ["test_mode", "enterprise"] }` → `coordinator = { path = "../crates/coordinator", features = ["test_mode"] }`
- `tests/Cargo.toml:26`: `jmap = { path = "../crates/jmap", features = ["test_mode", "enterprise"] }` → `jmap = { path = "../crates/jmap", features = ["test_mode"] }`
- `tests/Cargo.toml:35`: `http = { path = "../crates/http", features = ["test_mode", "enterprise"] }` → `http = { path = "../crates/http", features = ["test_mode"] }`
- `tests/Cargo.toml:37`: `scim = { path = "../crates/scim", features = ["test_mode", "enterprise"] }` → `scim = { path = "../crates/scim", features = ["test_mode"] }`
- `tests/Cargo.toml:39`: `services = { path = "../crates/services", features = ["test_mode", "enterprise"] }` → `services = { path = "../crates/services", features = ["test_mode"] }`
- `tests/Cargo.toml:41`: `smtp = { path = "../crates/smtp", features = ["test_mode", "enterprise"] }` → `smtp = { path = "../crates/smtp", features = ["test_mode"] }`
- `tests/Cargo.toml:42`: `common = { path = "../crates/common", features = ["test_mode", "enterprise"] }` → `common = { path = "../crates/common", features = ["test_mode"] }`
- `tests/Cargo.toml:44`: `email = { path = "../crates/email", features = ["test_mode", "enterprise"] }` → `email = { path = "../crates/email", features = ["test_mode"] }`
- `tests/Cargo.toml:45`: `spam-filter = { path = "../crates/spam-filter", features = ["test_mode", "enterprise"] }` → `spam-filter = { path = "../crates/spam-filter", features = ["test_mode"] }`
- `tests/Cargo.toml:46`: `trc = { path = "../crates/trc", features = ["enterprise"] }` → `trc = { path = "../crates/trc", features = [] }`
- `tests/Cargo.toml:47`: `managesieve = { path = "../crates/managesieve", features = ["test_mode", "enterprise"] }` → `managesieve = { path = "../crates/managesieve", features = ["test_mode"] }`
- `.github/workflows/ci.yml:341`: `cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise"` → `cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"`
- `.github/workflows/ci.yml:379`: `# cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "foundationdb s3 redis nats enterprise"` → `# cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "foundationdb s3 redis nats"`
- `.github/workflows/ci.yml:386`: `cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise"` → `cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"`
- `.github/workflows/ci.yml:442`: `cargo build --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise"` → `cargo build --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"`
- `Dockerfile:22`: `RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" --recipe-path /recipe.json` → `RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json`
- `Dockerfile:24`: `RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise"` → `RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"`
- `Dockerfile.build:111`: `RUSTFLAGS="-L /usr/lib" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats enterprise"; \` → `RUSTFLAGS="-L /usr/lib" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats"; \`
- `Dockerfile.build:119`: `cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise"` → `cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"`
- `Dockerfile.build:132`: `RUSTFLAGS="-L /usr/lib" cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats enterprise" && \` → `RUSTFLAGS="-L /usr/lib" cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats" && \`
- `Dockerfile.build:142`: `cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" && \` → `cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" && \`
- `Dockerfile.fdb:56`: `RUN cargo build -p stalwart --no-default-features --features "foundationdb s3 redis azure nats enterprise" --release` → `RUN cargo build -p stalwart --no-default-features --features "foundationdb s3 redis azure nats" --release`
## Flagged Enterprise in upstream's schema
**Objects:** `x:AiModel`, `x:Alert`, `x:ArchivedItem`, `x:MaskedEmail`, `x:MetricsStore`, `x:SpamLlm`, `x:Tenant`, `x:Trace`, `x:TracingStore`
**Fields:** `x:AcmeProvider.memberTenantId`, `x:ArfExternalReport.memberTenantId`, `x:Authentication.defaultTenantRoleIds`, `x:CalendarAlarm.template`, `x:CalendarScheduling.emailTemplate`, `x:CalendarScheduling.httpRsvpTemplate`, `x:DataRetention.archiveDeletedAccountsFor`, `x:DataRetention.archiveDeletedItemsFor`, `x:DataRetention.holdMetricsFor`, `x:DataRetention.holdTracesFor`, `x:DataRetention.metricsCollectionInterval`, `x:Dkim1Signature.memberTenantId`, `x:Dkim2Signature.memberTenantId`, `x:DmarcExternalReport.memberTenantId`, `x:Domain.allowScimProvisioning`, `x:Domain.directoryId`, `x:Domain.logo`, `x:Domain.memberTenantId`, `x:Email.maxMaskedAddresses`, `x:Enterprise.logoUrl`, `x:GroupAccount.externalId`, `x:LdapDirectory.memberTenantId`, `x:MySqlStore.readReplicas`, `x:OidcDirectory.memberTenantId`, `x:PostgreSqlStore.readReplicas`, `x:Search.indexTelemetry`, `x:Search.indexTracingFields`, `x:SqlDirectory.memberTenantId`, `x:TlsExternalReport.memberTenantId`, `x:UserAccount.externalId`
## Third-party code
Comments in the stripped tree that name another copyright holder, another license, or a source the code came from. Files marked **new** aren't in THIRD-PARTY.md yet.
- `crates/common/src/network/acme/directory.rs`
- 7: Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
- `crates/common/src/network/acme/jose.rs`
- 7: Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
- `crates/common/src/network/acme/order.rs`
- 7: Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
- `crates/common/src/scripts/functions/text.rs`
- 239: MIT licensed.
- 241: Copyright (c) 2016 Titus Wormer <tituswormer@gmail.com>
- `crates/common/src/telemetry/tracers/journald.rs`
- 70: SPDX-FileCopyrightText: 2018 Benjamin Saunders <ben.e.saunders@gmail.com>
- 71: SPDX-License-Identifier: MIT
- `crates/imap-proto/src/utf7.rs`
- 7: Ported from https://github.com/jstedfast/MailKit/blob/master/MailKit/Net/Imap/ImapEncoding.cs
- `crates/jmap/src/registry/mapping/log.rs`
- 341: SPDX-FileCopyrightText: 2017 Michael Coyne <mjc@hey.com>
- 343: SPDX-License-Identifier: MIT
- 346: Adapted from https://github.com/mjc-gh/rev_lines/blob/main/src/lib.rs
- `crates/jmap-proto/src/types/date.rs`
- 121: Ported from http://howardhinnant.github.io/date_algorithms.html#civil_from_days
- 158: Ported from https://github.com/protocolbuffers/upb/blob/22182e6e/upb/json_decode.c#L982-L992
- `crates/nlp/src/tokenizers/japanese.rs`
- 73: Ported from https://github.com/woxtu/rust-tinysegmenter, MIT license
- `crates/nlp/src/tokenizers/types.rs`
- 738: Credits: test suite from linkify crate
- `crates/registry/src/types/datetime.rs`
- 150: Ported from http://howardhinnant.github.io/date_algorithms.html#civil_from_days
- 188: Ported from https://github.com/protocolbuffers/upb/blob/22182e6e/upb/json_decode.c#L982-L992
- `crates/store/src/backend/postgres/tls.rs`
- 7: Credits: https://github.com/jbg/tokio-postgres-rustls
- `crates/types/src/id.rs`
- 69: From https://github.com/archer884/crockford by J/A <archer884@gmail.com>
- 70: License: MIT/Apache 2.0
- `crates/utils/src/glob.rs`
- 107: Credits: Algorithm ported from https://research.swtch.com/glob
+53 -20
View File
@@ -91,7 +91,22 @@ The wrapper is `tools/fork/strip.py`. Beyond `ossify.py` it:
was ported or adapted from elsewhere. Any file `THIRD-PARTY.md` doesn't was ported or adapted from elsewhere. Any file `THIRD-PARTY.md` doesn't
cover yet is flagged as new. It's reported, not a failure: the notice goes cover yet is flagged as new. It's reported, not a failure: the notice goes
into `THIRD-PARTY.md` in the merge that brings the release in, since the into `THIRD-PARTY.md` in the merge that brings the release in, since the
fork redistributes that code and its license requires the notice. fork redistributes that code and its license requires the notice;
- renames the upstream name where it's an identifier clients, users or
operators meet (wire-protocol names, the web interface's client id, store
keys; §2.4), with the same substitutions `main`
carries, so those lines arrive purged and never conflict (added
2026-09-22);
- compiles the stripped tree. A dual-licensed file that only serves an
Enterprise feature survives the strip but can't build without it:
v0.16.23's `tests/src/directory/issuer.rs` was the first. The build check
fails the run on it, and the merge into `main` drops or reworks it (added
2026-09-22).
Two checks in CI cover what a merge can bring in without a conflict:
`tools/fork/name-check.py` (the upstream name in a new string literal) and
`tools/fork/notice-check.py` (a changed upstream file without its AGPL 5(a)
notice).
### 2.2a Snapshots, not a git fork ### 2.2a Snapshots, not a git fork
@@ -197,14 +212,29 @@ depends on `store` and can't be called from it (`features/scale-out-storage.md`)
- Factual statements are allowed and required: "a fork of Stalwart", - Factual statements are allowed and required: "a fork of Stalwart",
"compatible with Stalwart 0.16 data". Upstream copyright notices stay on "compatible with Stalwart 0.16 data". Upstream copyright notices stay on
every file they cover. every file they cover.
- Protocol identifiers stay as upstream has them, for example the JMAP - **Identifiers people meet carry the fork's name** (changed 2026-09-22;
capability `urn:stalwart:jmap` and the `x:` object names. They're this bullet used to keep upstream's). Upstream's JMAP capability for the
interoperability, not branding, and renaming them breaks every existing registry (`x:`) objects is `urn:inbuxa:jmap:registry`, beside the fork's
client. Anything the fork adds uses its own namespace (open: which one). own `urn:inbuxa:jmap` (contract C-1); WebDAV lock and sync tokens are
- **Version and build metadata are exempt from the first bullet** (added `urn:inbuxa:dav*`, the Sieve extensions are `vnd.inbuxa.while` and `vnd.inbuxa.expressions`, the
2026-09-19). `inbuxa --version`, the startup banner and events, web interface's OAuth client is `inbuxa-webui`, the spam filter's blobs are
OpenTelemetry's `service.version`, the JMAP `implementation` string, release `INBUXA_SPAM_*`, and the SQL stores and log files default to `inbuxa`.
notes and the strip report all name the Stalwart base, as §2.6 requires. There are no aliases: the old names stop working, so front ends move with
the server, Sieve scripts that `require` the old extensions are edited,
DAV clients resync once, and anyone signed in to the web interface signs in
again. Pre-rename data is carried over where it would otherwise be lost
(the spam model, the web interface's client). The `x:` object names are
unchanged, having no name in them. `tools/fork/renames.py` holds the list,
and the strip applies it to every import (§2.2).
- **Identifiers nobody sees keep upstream's spelling:** the OAuth
key-derivation contexts, whose renaming would invalidate every issued token
and client id, and the application blob prefix, which is hashed before use.
`tools/fork/name-allowlist.txt` lists them with their reasons.
- **Version and build metadata give the base without the name** (added
2026-09-19, narrowed 2026-09-22). `inbuxa --version`, the startup banner and
events, OpenTelemetry's `service.version` and the JMAP `implementation`
string say `2026.9.23 (upstream 0.16.23)`, as §2.6 requires; release notes
and the strip report may name the Stalwart base.
That is a factual statement about what was compiled, not a name the product That is a factual statement about what was compiled, not a name the product
calls itself, and the two bullets don't conflict: the first governs calls itself, and the two bullets don't conflict: the first governs
identity, this one governs provenance. A reader who takes "no Stalwart in identity, this one governs provenance. A reader who takes "no Stalwart in
@@ -228,15 +258,18 @@ Done 2026-09-18:
- The package and binary are `inbuxa` (`cargo build -p inbuxa`). The binary's - The package and binary are `inbuxa` (`cargo build -p inbuxa`). The binary's
help, banner and every protocol greeting say INBUXA (the branding module, help, banner and every protocol greeting say INBUXA (the branding module,
`types::brand!()`). `types::brand!()`).
- Settings come from `INBUXA_*` environment variables. Each still falls back - Settings come from `INBUXA_*` environment variables
to its `STALWART_*` name, with a startup warning to rename it (`types::branding::env_var`): `HOSTNAME`, `RECOVERY_MODE`, `RECOVERY_ADMIN`,
(`types::branding::env_var`). That covers all nine the server reads: `RECOVERY_MODE_PORT`, `RECOVERY_MODE_LOG_LEVEL`, `ROLE`, `PUSH_SHARD`,
`HOSTNAME`, `RECOVERY_MODE`, `RECOVERY_ADMIN`, `RECOVERY_MODE_PORT`, `PUBLIC_URL`, `HTTPS_PORT`, and the front-end variables of contract C-6.
`RECOVERY_MODE_LOG_LEVEL`, `ROLE`, `PUSH_SHARD`, `PUBLIC_URL`, `HTTPS_PORT`. Until 2026-09-22 each fell back to its `STALWART_*` name with a warning.
- **Not renamed, on purpose:** `STALWART_APP_` and the two `STALWART_SPAM_...` Now a `STALWART_*` name that's set where its `INBUXA_*` one isn't stops the
names. They look like environment variables, but they're keys inside the server at startup, naming the variable to rename, so an install moved over
data store, so renaming them would orphan existing installed apps and from upstream never runs on settings it silently dropped.
spam-classifier models. - The spam filter's blobs moved from `STALWART_SPAM_*` to `INBUXA_SPAM_*` on
2026-09-22; every start moves any left under the old keys
(`migration::try_migrate`), so no trained model is orphaned.
`STALWART_APP_` stays: it's hashed into a blob key and never seen.
- New installs default to `/var/lib/inbuxa` for data and `/var/log/inbuxa` for - New installs default to `/var/lib/inbuxa` for data and `/var/log/inbuxa` for
logs. Existing installs keep the paths their configuration names, so no data logs. Existing installs keep the paths their configuration names, so no data
moves. moves.
@@ -406,8 +439,8 @@ Versioned, and advertised in the JMAP session so either side can check it.
address or network, so an admin credential is useless from anywhere else. address or network, so an admin credential is useless from anywhere else.
- **Push.** Unchanged: JMAP push with VAPID, as ihasmail uses today. - **Push.** Unchanged: JMAP push with VAPID, as ihasmail uses today.
- **INBUXA Admin's client.** INBUXA Admin signs in by OAuth (authorization - **INBUXA Admin's client.** INBUXA Admin signs in by OAuth (authorization
code with PKCE) as upstream's `webui` does, as client `stalwart-webui` for code with PKCE) as upstream's `webui` does, as client `inbuxa-webui`
now. The fork registers a first-party `inbuxa-admin` client with the (upstream's `stalwart-webui` until 2026-09-22) when the server serves it. The fork registers a first-party `inbuxa-admin` client with the
admin's own redirect URIs, and the admin switches to it (its admin's own redirect URIs, and the admin switches to it (its
`<meta name="oauth-client-id">`). `<meta name="oauth-client-id">`).
- **Cross-origin access.** Verified 2026-09-18 against a separate - **Cross-origin access.** Verified 2026-09-18 against a separate
+4 -3
View File
@@ -106,8 +106,9 @@ Each has an ID, and tests name the IDs they check.
ihasmail-inbuxa server, authorization code with PKCE S256, redirect URI ihasmail-inbuxa server, authorization code with PKCE S256, redirect URI
`{webmailUrl}/api/auth/callback`. `{webmailUrl}/api/auth/callback`.
INBUXA Admin's `<meta name="oauth-client-id">` is set to `inbuxa-admin`. INBUXA Admin's `<meta name="oauth-client-id">` is set to `inbuxa-admin`.
Until then it keeps upstream's `stalwart-webui`, which only works while Served by the server itself it uses the web interface's client,
registration isn't required. `inbuxa-webui` (upstream's `stalwart-webui` until 2026-09-22, retired on
start since).
**Built (interim), 2026-09-18.** C-5's defaults are in the server, and **Built (interim), 2026-09-18.** C-5's defaults are in the server, and
`crates/common/src/manager/first_party.rs` registers the clients on every `crates/common/src/manager/first_party.rs` registers the clients on every
@@ -116,7 +117,7 @@ Each has an ID, and tests name the IDs they check.
`INBUXA_WEBMAIL_CLIENT_SECRET` (the webmail client is registered only when `INBUXA_WEBMAIL_CLIENT_SECRET` (the webmail client is registered only when
both of its variables are set). A web interface the server serves itself both of its variables are set). A web interface the server serves itself
(none on a new install since SPEC.md §5.3; possible on one upgraded from (none on a new install since SPEC.md §5.3; possible on one upgraded from
Stalwart) is registered too, as its application's OAuth client id or `stalwart-webui`, at the Stalwart) is registered too, as its application's OAuth client id or `inbuxa-webui`, at the
server's public URL. A missing client is created. An existing one gains any server's public URL. A missing client is created. An existing one gains any
redirect URI it lacks, and the webmail client gets the configured secret. redirect URI it lacks, and the webmail client gets the configured secret.
Nothing an operator added is removed. Bootstrap and recovery mode skip this: Nothing an operator added is removed. Bootstrap and recovery mode skip this:
+5 -2
View File
@@ -102,7 +102,10 @@ Permissions: `sysSpamLlmGet`, `sysSpamLlmUpdate`.
- **Tags and scores.** The classifier's tags are ordinary spam tags, scored - **Tags and scores.** The classifier's tags are ordinary spam tags, scored
by `x:SpamTag` entries like every other tag: `Score` (a number), `Discard` by `x:SpamTag` entries like every other tag: `Score` (a number), `Discard`
or `Reject`. The documented defaults are `LLM_UNSOLICITED_HIGH` 3.0 and or `Reject`. The documented defaults are `LLM_UNSOLICITED_HIGH` 3.0 and
`LLM_LEGITIMATE_HIGH` −3.0. A tag with no entry scores 0. `LLM_LEGITIMATE_HIGH` −3.0. A tag with no entry scores 0. The server ships
those entries in its bundled spam rules (`resources/spam-filter/`), loaded
on first boot and again when the bundled version changes, so an install
that predates them gains them on upgrade (added 2026-09-23).
- **`interactAi`** permission ("Interact with AI models"): lets an account's - **`interactAi`** permission ("Interact with AI models"): lets an account's
own Sieve scripts call `llm_prompt`. This repository's default roles give it own Sieve scripts call `llm_prompt`. This repository's default roles give it
to users, tenant administrators and superusers to users, tenant administrators and superusers
@@ -291,7 +294,7 @@ adds at most 2.
### The Sieve function `llm_prompt` ### The Sieve function `llm_prompt`
- **AI-20.** `llm_prompt(model, prompt, temperature)`, available with - **AI-20.** `llm_prompt(model, prompt, temperature)`, available with
`require "vnd.stalwart.expressions"`. `model` names an `x:AiModel` by its `require "vnd.inbuxa.expressions"`. `model` names an `x:AiModel` by its
`name`, or failing that by its id. `prompt` is sent as is. `temperature` is `name`, or failing that by its id. `prompt` is sent as is. `temperature` is
clamped to 0.0–1.0. A value that isn't a number uses the model's own clamped to 0.0–1.0. A value that isn't a number uses the model's own
`temperature`. **Decision** on name first, see open question 4. `temperature`. **Decision** on name first, see open question 4.
+1 -1
View File
@@ -210,7 +210,7 @@ accepted, which is the fact `enabled` reports.
### Upstream's, unchanged ### Upstream's, unchanged
`x:MaskedEmail/get`, `/query`, `/set` under `urn:stalwart:jmap`, standard RFC `x:MaskedEmail/get`, `/query`, `/set` under `urn:inbuxa:jmap:registry`, standard RFC
8620 shapes, the record above. Upstream's `/query` accepts only an 8620 shapes, the record above. Upstream's `/query` accepts only an
`accountId` filter, and it has no `/changes` (observed 6). `accountId` filter, and it has no `/changes` (observed 6).
+9 -4
View File
@@ -212,10 +212,15 @@ unchanged.
- **MON-16.** With `indexTelemetry` on, storing a trace schedules an - **MON-16.** With `indexTelemetry` on, storing a trace schedules an
`IndexTrace` task. The task builds one document for `SearchIndex::Tracing` `IndexTrace` task. The task builds one document for `SearchIndex::Tracing`
with the fields named in `indexTracingFields`: with the fields named in `indexTracingFields`:
- `eventType`: every event type in the trace; - `eventType`: the trace's opening event, as its numeric id;
- `queueId`: every `queueId` value; - `queueId`: the first `queueId` value, as an integer;
- `keywords`: every address in `from` and `to`, each address's domain, every - `keywords`: every address in `from` and `to`, each address's domain, every
`domain`, `hostname`, `remoteIp`, `messageId` and `accountName` value. `domain`, `hostname`, `remoteIp`, `messageId` and `accountName` value,
and every `queueId` value.
The event type and queue id are single integer columns on every search
backend (BIGINT on PostgreSQL and MySQL), so the `queueId` filter matches
the column or any queue id in the keywords, and a session that queued
several messages is found by each of them.
So searching `example.org` finds every trace to or from that domain, as the So searching `example.org` finds every trace to or from that domain, as the
upstream suite expects. With `indexTelemetry` off nothing is indexed, and upstream suite expects. With `indexTelemetry` off nothing is indexed, and
the `text` and `queueId` filters are refused (see "Interfaces"). the `text` and `queueId` filters are refused (see "Interfaces").
@@ -374,7 +379,7 @@ unchanged.
## Interfaces ## Interfaces
- **JMAP, existing names, unchanged.** Over `urn:stalwart:jmap`: - **JMAP, existing names, unchanged.** Over `urn:inbuxa:jmap:registry`:
- `x:Alert/get`, `/query`, `/set`, and the `x:TracingStore`, - `x:Alert/get`, `/query`, `/set`, and the `x:TracingStore`,
`x:MetricsStore`, `x:DataRetention`, `x:Search` singletons. `x:MetricsStore`, `x:DataRetention`, `x:Search` singletons.
- `x:Trace/get` and `/query`. Filters: `text`, `timestamp` (comparison names - `x:Trace/get` and `/query`. Filters: `text`, `timestamp` (comparison names
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
rLRbZKj15KvcPMVmnisfCDsXXZEKks6BXpMkMpS0mlI XFI3xuKC_rH1KZyaVBF0uTIiRDXRqyYboijquiGz2eg
+32
View File
@@ -0,0 +1,32 @@
# Bundled spam filter rules
`spam-filter-rules.json.gz` is the published rules file of
[spam-filter](https://github.com/stalwartlabs/spam-filter) **v3.0.2**,
unmodified. The server embeds it (`crates/common/src/manager/spam_rules.rs`)
and loads it whenever no other rules source is configured, so a release
scores mail with the rules it was tested with, offline and with nothing to
fetch. The rules URL setting stays an operator override.
The rules are dual-licensed MIT or Apache-2.0, Copyright (C) 2024, Stalwart
Labs LLC; the fork takes them under MIT, with the notice in `THIRD-PARTY.md`.
They include the scores for the AI classifier's tags (`LLM_*`, 3.0 for the
high-confidence spam categories, −3.0 for legitimate), which match
`docs/spec/features/ai-spam-classification.md`.
## Updating
The `upstream-watch` workflow opens an issue when spam-filter publishes a
newer release. To take it:
1. Download `spam-filter-rules.json.gz` from that release, pinned by tag
(`releases/download/vX.Y.Z/…`, not `latest`), over this file.
2. Set `BUNDLED_SPAM_RULES_VERSION` in `spam_rules.rs` and the version in
this README and in `THIRD-PARTY.md`.
3. Run the antispam test (`STORE=RocksDb RUST_MIN_STACK=16777216 cargo test
-p tests --lib -- smtp::inbound::antispam::antispam --exact`) and fix
expectations the new rules change, knowingly.
On the next start each server loads the new version once. Loading only adds
rules and tags that are missing; it never changes an existing one, so an
operator's own adjustments survive.
Binary file not shown.
+1 -1
View File
@@ -6,7 +6,7 @@
<key>Label</key> <key>Label</key>
<string>inbuxa.mail</string> <string>inbuxa.mail</string>
<key>ServiceDescription</key> <key>ServiceDescription</key>
<string>INBUXA</string> <string>inbuxa</string>
<key>ProgramArguments</key> <key>ProgramArguments</key>
<array> <array>
<string>__PATH__/bin/inbuxa</string> <string>__PATH__/bin/inbuxa</string>
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=INBUXA Server Description=inbuxa Server
Conflicts=postfix.service sendmail.service exim4.service Conflicts=postfix.service sendmail.service exim4.service
ConditionPathExists=__PATH__/etc/config.json ConditionPathExists=__PATH__/etc/config.json
After=network-online.target After=network-online.target
+1
View File
@@ -49,6 +49,7 @@ email = { path = "../crates/email", features = ["test_mode"] }
spam-filter = { path = "../crates/spam-filter", features = ["test_mode"] } spam-filter = { path = "../crates/spam-filter", features = ["test_mode"] }
trc = { path = "../crates/trc", features = [] } trc = { path = "../crates/trc", features = [] }
managesieve = { path = "../crates/managesieve", features = ["test_mode"] } managesieve = { path = "../crates/managesieve", features = ["test_mode"] }
migration = { path = "../crates/migration" }
smtp-proto = { version = "0.2" } smtp-proto = { version = "0.2" }
mail-auth = { version = "0.13", features = ["test"] } mail-auth = { version = "0.13", features = ["test"] }
mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] } mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] }
+5 -5
View File
@@ -49,7 +49,7 @@ HTTP = "http://127.0.0.1:18080"
# made to speak. # made to speak.
PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025} PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025}
TLS_PORTS = {18993, 18995, 18465} TLS_PORTS = {18993, 18995, 18465}
SMTP_REFUSAL = ("535 5.7.0 This server allows only INBUXA webmail and JMAP apps. " SMTP_REFUSAL = ("535 5.7.0 This server allows only inbuxa webmail and JMAP apps. "
"This mail app can't send.") "This mail app can't send.")
INBUXA = "urn:inbuxa:jmap" INBUXA = "urn:inbuxa:jmap"
failures = [] failures = []
@@ -105,7 +105,7 @@ def stop():
docker("rm", "-f", NAME, check_rc=False) docker("rm", "-f", NAME, check_rc=False)
def jmap(user, password, calls, using=("urn:ietf:params:jmap:core", "urn:stalwart:jmap", INBUXA)): def jmap(user, password, calls, using=("urn:ietf:params:jmap:core", "urn:inbuxa:jmap:registry", INBUXA)):
body = json.dumps({"using": list(using), "methodCalls": calls}).encode() body = json.dumps({"using": list(using), "methodCalls": calls}).encode()
req = urllib.request.Request(f"{HTTP}/jmap/", data=body, method="POST") req = urllib.request.Request(f"{HTTP}/jmap/", data=body, method="POST")
req.add_header("Content-Type", "application/json") req.add_header("Content-Type", "application/json")
@@ -333,7 +333,7 @@ def tenant_checks(admin, admin_pw, account):
"and still enabled for an account outside the tenant (test 13)") "and still enabled for an account outside the tenant (test 13)")
# Refused on the tenant's domain, every way in the same words (tests 6-8). # Refused on the tenant's domain, every way in the same words (tests 6-8).
imap_no = ("NO [ALERT] Your organization allows only INBUXA webmail and JMAP apps. " imap_no = ("NO [ALERT] Your organization allows only inbuxa webmail and JMAP apps. "
"This mail app can't sign in.") "This mail app can't sign in.")
check(imap_login(PORTS["imap"], tu, user_pw) == imap_no, check(imap_login(PORTS["imap"], tu, user_pw) == imap_no,
"the tenant's user is refused over IMAP with the right password (test 6)") "the tenant's user is refused over IMAP with the right password (test 6)")
@@ -341,10 +341,10 @@ def tenant_checks(admin, admin_pw, account):
check(imap_login(PORTS["imap"], "[email protected]", "x") == imap_no, check(imap_login(PORTS["imap"], "[email protected]", "x") == imap_no,
"and a made-up address on the domain gets the same (test 7)") "and a made-up address on the domain gets the same (test 7)")
check(pop3_login(PORTS["pop3"], tu, user_pw) == check(pop3_login(PORTS["pop3"], tu, user_pw) ==
"-ERR [AUTH] Your organization allows only INBUXA webmail and JMAP apps. " "-ERR [AUTH] Your organization allows only inbuxa webmail and JMAP apps. "
"This mail app can't sign in.", "POP3 refuses in its own form (test 8)") "This mail app can't sign in.", "POP3 refuses in its own form (test 8)")
check(smtp_auths(PORTS["submissions"], tu, [user_pw])[0] == check(smtp_auths(PORTS["submissions"], tu, [user_pw])[0] ==
"535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. " "535 5.7.0 Your organization allows only inbuxa webmail and JMAP apps. "
"This mail app can't send.", "submission refuses in its own form (test 8)") "This mail app can't send.", "submission refuses in its own form (test 8)")
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
"an account on another domain signs in over IMAP normally (test 6)") "an account on another domain signs in over IMAP normally (test 6)")
+3 -3
View File
@@ -61,7 +61,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count:
summary.summary: Text("Meet me maybe") summary.summary: Text("Meet me maybe")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
X-MICROSOFT-CDO-OWNERAPPTID:299828133 X-MICROSOFT-CDO-OWNERAPPTID:299828133
@@ -176,7 +176,7 @@ summary.summary: Text("Meet me maybe")
~summary.description: Text("This is the event description") ~summary.description: Text("This is the event description")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
X-MICROSOFT-CDO-OWNERAPPTID:299828133 X-MICROSOFT-CDO-OWNERAPPTID:299828133
@@ -224,7 +224,7 @@ END:VCALENDAR
# Make sure the original alarms are preserved # Make sure the original alarms are preserved
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
X-MICROSOFT-CDO-OWNERAPPTID:299828133 X-MICROSOFT-CDO-OWNERAPPTID:299828133
+2 -2
View File
@@ -64,7 +64,7 @@ summary.location: Text("Conference Room A")
summary.summary: Text("Review Accounts") summary.summary: Text("Review Accounts")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
LOCATION:Conference Room A LOCATION:Conference Room A
@@ -114,7 +114,7 @@ summary.location: Text("Conference Room A")
summary.summary: Text("Review Accounts") summary.summary: Text("Review Accounts")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
LOCATION:Conference Room A LOCATION:Conference Room A
+3 -3
View File
@@ -146,7 +146,7 @@ summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
summary.summary: Text("Conference") summary.summary: Text("Conference")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CONFIRMED STATUS:CONFIRMED
@@ -402,7 +402,7 @@ summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
summary.summary: Text("Conference") summary.summary: Text("Conference")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CONFIRMED STATUS:CONFIRMED
@@ -450,7 +450,7 @@ summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
summary.summary: Text("Conference") summary.summary: Text("Conference")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CONFIRMED STATUS:CONFIRMED
@@ -37,7 +37,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(Partia
summary.summary: Text("IETF Calendaring Working Group Meeting") summary.summary: Text("IETF Calendaring Working Group Meeting")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
CLASS:PUBLIC CLASS:PUBLIC
@@ -117,7 +117,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(Partia
summary.summary: Text("IETF Calendaring Working Group Meeting") summary.summary: Text("IETF Calendaring Working Group Meeting")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
CLASS:PUBLIC CLASS:PUBLIC
@@ -207,7 +207,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(Partia
summary.summary: Text("IETF Calendaring Working Group Meeting") summary.summary: Text("IETF Calendaring Working Group Meeting")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:CANCEL METHOD:CANCEL
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CANCELLED STATUS:CANCELLED
@@ -226,7 +226,7 @@ END:VCALENDAR
# Make sure B has the cancelled event # Make sure B has the cancelled event
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
CLASS:PUBLIC CLASS:PUBLIC
@@ -358,7 +358,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(Partia
summary.summary: Text("IETF Calendaring Working Group Meeting") summary.summary: Text("IETF Calendaring Working Group Meeting")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
CLASS:PUBLIC CLASS:PUBLIC
@@ -386,7 +386,7 @@ END:VCALENDAR
# Make sure B has the complete event including all updates # Make sure B has the complete event including all updates
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
CLASS:PUBLIC CLASS:PUBLIC
@@ -538,7 +538,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(Partia
summary.summary: Text("IETF Calendaring Working Group Meeting") summary.summary: Text("IETF Calendaring Working Group Meeting")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:CANCEL METHOD:CANCEL
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
DESCRIPTION:IETF-C&S Conference Call DESCRIPTION:IETF-C&S Conference Call
@@ -564,7 +564,7 @@ END:VCALENDAR
# Make sure all instances in B were deleted # Make sure all instances in B were deleted
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
CLASS:PUBLIC CLASS:PUBLIC
@@ -677,7 +677,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count:
summary.summary: Text("Review Accounts") summary.summary: Text("Review Accounts")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
LOCATION:The White Room LOCATION:The White Room
@@ -742,7 +742,7 @@ summary.summary: Text("Review Accounts")
~summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: None, weekday: Tuesday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: Some(Sunday), rscale: None, skip: None }) ~summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: None, weekday: Tuesday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: Some(Sunday), rscale: None, skip: None })
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
LOCATION:The White Room LOCATION:The White Room
@@ -770,7 +770,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count:
summary.summary: Text("Review Accounts") summary.summary: Text("Review Accounts")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:CANCEL METHOD:CANCEL
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
LOCATION:The Red Room LOCATION:The Red Room
@@ -791,7 +791,7 @@ END:VCALENDAR
# Make sure B has the updated event # Make sure B has the updated event
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
LOCATION:The White Room LOCATION:The White Room
@@ -812,7 +812,7 @@ END:VCALENDAR
# Make sure C has the updated event # Make sure C has the updated event
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
LOCATION:The Red Room LOCATION:The Red Room
@@ -853,7 +853,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count:
summary.summary: Text("Review Accounts") summary.summary: Text("Review Accounts")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Review Accounts SUMMARY:Review Accounts
@@ -919,7 +919,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count:
summary.summary: Text("Weekly Sync") summary.summary: Text("Weekly Sync")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Weekly Sync SUMMARY:Weekly Sync
@@ -967,7 +967,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count:
summary.summary: Text("Weekly Sync") summary.summary: Text("Weekly Sync")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Weekly Sync SUMMARY:Weekly Sync
+20 -20
View File
@@ -32,7 +32,7 @@ summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
summary.summary: Text("Conference") summary.summary: Text("Conference")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CONFIRMED STATUS:CONFIRMED
@@ -61,7 +61,7 @@ END:VCALENDAR
# Make sure B receives the request # Make sure B receives the request
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CONFIRMED STATUS:CONFIRMED
@@ -88,7 +88,7 @@ END:VCALENDAR
> put [email protected] [email protected] > put [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
BEGIN:VEVENT BEGIN:VEVENT
DTSTAMP:19970701T200000Z DTSTAMP:19970701T200000Z
SEQUENCE:1 SEQUENCE:1
@@ -119,7 +119,7 @@ summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
summary.summary: Text("Conference") summary.summary: Text("Conference")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Conference SUMMARY:Conference
@@ -198,7 +198,7 @@ summary.summary: Text("Phone Conference")
~summary.summary: Text("Conference") ~summary.summary: Text("Conference")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CONFIRMED STATUS:CONFIRMED
@@ -228,7 +228,7 @@ END:VCALENDAR
# Make sure C receives the request # Make sure C receives the request
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CONFIRMED STATUS:CONFIRMED
@@ -256,7 +256,7 @@ END:VCALENDAR
> put [email protected] [email protected] > put [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
BEGIN:VEVENT BEGIN:VEVENT
DTSTAMP:19970701T180000Z DTSTAMP:19970701T180000Z
UID:[email protected] UID:[email protected]
@@ -287,7 +287,7 @@ summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference") summary.summary: Text("Phone Conference")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Phone Conference SUMMARY:Phone Conference
@@ -313,7 +313,7 @@ summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference") summary.summary: Text("Phone Conference")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CONFIRMED STATUS:CONFIRMED
@@ -343,7 +343,7 @@ END:VCALENDAR
# Make sure E receives the request # Make sure E receives the request
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CONFIRMED STATUS:CONFIRMED
@@ -398,7 +398,7 @@ END:VCALENDAR
> put [email protected] [email protected] > put [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
BEGIN:VEVENT BEGIN:VEVENT
DTSTAMP:19970701T180000Z DTSTAMP:19970701T180000Z
SEQUENCE:2 SEQUENCE:2
@@ -430,7 +430,7 @@ summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference") summary.summary: Text("Phone Conference")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Phone Conference SUMMARY:Phone Conference
@@ -479,7 +479,7 @@ END:VCALENDAR
# Make sure C receives the reply # Make sure C receives the reply
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CONFIRMED STATUS:CONFIRMED
@@ -508,7 +508,7 @@ END:VCALENDAR
> put [email protected] [email protected] > put [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
BEGIN:VEVENT BEGIN:VEVENT
DTSTAMP:19970701T180000Z DTSTAMP:19970701T180000Z
SEQUENCE:2 SEQUENCE:2
@@ -540,7 +540,7 @@ summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference") summary.summary: Text("Phone Conference")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Phone Conference SUMMARY:Phone Conference
@@ -564,7 +564,7 @@ END:VCALENDAR
# Make sure C receives the reply # Make sure C receives the reply
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CONFIRMED STATUS:CONFIRMED
@@ -650,7 +650,7 @@ summary.summary: Text("Phone Conference")
~summary.attendee: Participants([ItipParticipant { email: "[email protected]", name: None, is_organizer: true }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }, ItipParticipant { email: "[email protected]", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }]) ~summary.attendee: Participants([ItipParticipant { email: "[email protected]", name: None, is_organizer: true }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }, ItipParticipant { email: "[email protected]", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }])
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CONFIRMED STATUS:CONFIRMED
@@ -681,7 +681,7 @@ summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference") summary.summary: Text("Phone Conference")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:CANCEL METHOD:CANCEL
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CANCELLED STATUS:CANCELLED
@@ -702,7 +702,7 @@ END:VCALENDAR
# Make sure B receives the cancelation # Make sure B receives the cancelation
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CANCELLED STATUS:CANCELLED
@@ -727,7 +727,7 @@ summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference") summary.summary: Text("Phone Conference")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:CANCEL METHOD:CANCEL
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CANCELLED STATUS:CANCELLED
+9 -9
View File
@@ -32,7 +32,7 @@ summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
summary.summary: Text("Create the requirements document") summary.summary: Text("Create the requirements document")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VTODO BEGIN:VTODO
PRIORITY:1 PRIORITY:1
@@ -57,7 +57,7 @@ END:VCALENDAR
# Make sure B received the todo # Make sure B received the todo
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VTODO BEGIN:VTODO
PRIORITY:1 PRIORITY:1
@@ -103,7 +103,7 @@ summary.attendee: Participants([ItipParticipant { email: "[email protected]", name:
summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 }) summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VTODO BEGIN:VTODO
STATUS:IN-PROCESS STATUS:IN-PROCESS
@@ -172,7 +172,7 @@ summary.attendee: Participants([ItipParticipant { email: "[email protected]", name:
summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 }) summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VTODO BEGIN:VTODO
PERCENT-COMPLETE:75 PERCENT-COMPLETE:75
@@ -241,7 +241,7 @@ summary.attendee: Participants([ItipParticipant { email: "[email protected]", name:
summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 }) summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VTODO BEGIN:VTODO
DUE:19970722T170000Z DUE:19970722T170000Z
@@ -317,7 +317,7 @@ summary.summary: Text("Send Status Reports to Area Managers")
~summary.summary: Text("Create the requirements document") ~summary.summary: Text("Create the requirements document")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VTODO BEGIN:VTODO
PRIORITY:1 PRIORITY:1
@@ -347,7 +347,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: None, count
summary.summary: Text("Create the requirements document") summary.summary: Text("Create the requirements document")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:CANCEL METHOD:CANCEL
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VTODO BEGIN:VTODO
STATUS:CANCELLED STATUS:CANCELLED
@@ -367,7 +367,7 @@ END:VCALENDAR
# Make sure "C" received the cancel request # Make sure "C" received the cancel request
> get [email protected] [email protected] > get [email protected] [email protected]
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VTODO BEGIN:VTODO
PRIORITY:1 PRIORITY:1
@@ -451,7 +451,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: None, count
summary.summary: Text("Send Status Reports to Area Managers") summary.summary: Text("Send Status Reports to Area Managers")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VTODO BEGIN:VTODO
PERCENT-COMPLETE:75 PERCENT-COMPLETE:75
+4 -4
View File
@@ -47,7 +47,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Daily, until: None, count:
summary.summary: Text("Review Internet-Draft") summary.summary: Text("Review Internet-Draft")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Review Internet-Draft SUMMARY:Review Internet-Draft
@@ -89,7 +89,7 @@ END:VCALENDAR
# Make sure the participant receives the event # Make sure the participant receives the event
> get [email protected] 9263504FD3AD > get [email protected] 9263504FD3AD
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Review Internet-Draft SUMMARY:Review Internet-Draft
@@ -185,7 +185,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Daily, until: None, count:
summary.summary: Text("Review Internet-Draft") summary.summary: Text("Review Internet-Draft")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Review Internet-Draft SUMMARY:Review Internet-Draft
@@ -343,7 +343,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Daily, until: None, count:
summary.summary: Text("Review Internet-Draft") summary.summary: Text("Review Internet-Draft")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Review Internet-Draft SUMMARY:Review Internet-Draft
+3 -3
View File
@@ -30,7 +30,7 @@ summary.dtstart: Time(ItipTime { start: 1243958400, tz_id: 32768 })
summary.summary: Text("Lunch") summary.summary: Text("Lunch")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Lunch SUMMARY:Lunch
@@ -83,7 +83,7 @@ END:VCALENDAR
# Make sure the message was received by the attendees # Make sure the message was received by the attendees
> get [email protected] 9263504FD3AD > get [email protected] 9263504FD3AD
BEGIN:VCALENDAR BEGIN:VCALENDAR
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Lunch SUMMARY:Lunch
@@ -170,7 +170,7 @@ summary.dtstart: Time(ItipTime { start: 1243958400, tz_id: 32768 })
summary.summary: Text("Lunch") summary.summary: Text("Lunch")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Lunch SUMMARY:Lunch
+4 -4
View File
@@ -26,7 +26,7 @@ summary.dtstart: Time(ItipTime { start: 1794322800, tz_id: 433 })
summary.summary: Text("Berlin meeting") summary.summary: Text("Berlin meeting")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Berlin meeting SUMMARY:Berlin meeting
@@ -92,7 +92,7 @@ summary.dtstart: Time(ItipTime { start: 1794322800, tz_id: 433 })
summary.summary: Text("Berlin meeting") summary.summary: Text("Berlin meeting")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REPLY METHOD:REPLY
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Berlin meeting SUMMARY:Berlin meeting
@@ -144,7 +144,7 @@ summary.dtstart: Time(ItipTime { start: 1794322800, tz_id: 433 })
summary.summary: Text("Berlin meeting") summary.summary: Text("Berlin meeting")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:CANCEL METHOD:CANCEL
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
STATUS:CANCELLED STATUS:CANCELLED
@@ -228,7 +228,7 @@ summary.dtstart: Time(ItipTime { start: 1794322800, tz_id: 433 })
summary.summary: Text("Berlin meeting") summary.summary: Text("Berlin meeting")
BEGIN:VCALENDAR BEGIN:VCALENDAR
METHOD:REQUEST METHOD:REQUEST
PRODID:-//INBUXA//INBUXA Server//EN PRODID:-//inbuxa//inbuxa Server//EN
VERSION:2.0 VERSION:2.0
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Berlin meeting SUMMARY:Berlin meeting
@@ -1,4 +1,4 @@
require ["fileinto", "mailbox", "mailboxid", "special-use", "ihave", "imap4flags", "vnd.stalwart.expressions"]; require ["fileinto", "mailbox", "mailboxid", "special-use", "ihave", "imap4flags", "vnd.inbuxa.expressions"];
# SpecialUse extension tests # SpecialUse extension tests
if not specialuse_exists ["inbox", "trash"] { if not specialuse_exists ["inbox", "trash"] {
@@ -8,7 +8,7 @@ import urllib.error
import urllib.request import urllib.request
CORE = "urn:ietf:params:jmap:core" CORE = "urn:ietf:params:jmap:core"
STALWART = "urn:stalwart:jmap" STALWART = "urn:inbuxa:jmap:registry"
USING = [CORE, STALWART] USING = [CORE, STALWART]
DEFAULT_BASE_URL = "https://127.0.0.1" DEFAULT_BASE_URL = "https://127.0.0.1"
+1 -1
View File
@@ -1,4 +1,4 @@
require ["variables", "include", "vnd.stalwart.expressions", "reject"]; require ["variables", "include", "vnd.inbuxa.expressions", "reject"];
global "score"; global "score";
+1 -1
View File
@@ -1,4 +1,4 @@
require ["variables", "include", "vnd.stalwart.expressions", "reject"]; require ["variables", "include", "vnd.inbuxa.expressions", "reject"];
global "score"; global "score";
set "awl_factor" "0.5"; set "awl_factor" "0.5";
+1 -1
View File
@@ -1,4 +1,4 @@
require ["variables", "envelope", "reject", "vnd.stalwart.expressions"]; require ["variables", "envelope", "reject", "vnd.inbuxa.expressions"];
if envelope :localpart :is "from" "spammer" { if envelope :localpart :is "from" "spammer" {
reject "450 4.1.1 Invalid address"; reject "450 4.1.1 Invalid address";
+1 -1
View File
@@ -1,4 +1,4 @@
require ["variables", "envelope", "reject", "vnd.stalwart.expressions"]; require ["variables", "envelope", "reject", "vnd.inbuxa.expressions"];
if envelope :domain :is "to" "foobar.org" { if envelope :domain :is "to" "foobar.org" {
eval "query('sql', 'CREATE TABLE IF NOT EXISTS greylist (addr TEXT PRIMARY KEY)', [])"; eval "query('sql', 'CREATE TABLE IF NOT EXISTS greylist (addr TEXT PRIMARY KEY)', [])";
+145
View File
@@ -0,0 +1,145 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! A node that starts while its NATS coordinator is down joins the cluster
//! once NATS comes up, without a restart, and reports the coordinator's
//! connection on `/healthz/cluster` as it goes and comes back.
use crate::utils::server::TestServerBuilder;
use coordinator::Coordinator;
use registry::{
schema::{
enums::NetworkListenerProtocol,
structs::{Coordinator as CoordinatorSetting, NatsCoordinator},
},
types::map::Map,
};
use serde_json::{Value, json};
use std::time::{Duration, Instant};
use testcontainers::{
GenericImage, ImageExt, core::IntoContainerPort, core::WaitFor, runners::AsyncRunner,
};
const HTTP_PORT: u16 = 11_310;
const TOPIC: &str = "inbuxa-coordinator-test";
#[tokio::test(flavor = "multi_thread")]
pub async fn coordinator_reconnect_tests() {
println!("Running coordinator reconnect tests...");
// A port with no NATS server behind it, yet
let nats_port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
let config = NatsCoordinator {
addresses: Map::new(vec![format!("127.0.0.1:{nats_port}")]),
use_tls: false,
timeout_connection: 1_000u64.into(),
..Default::default()
};
// 1. The node starts, without a build error, while NATS is down, and
// says so
let test = TestServerBuilder::new("coordinator_reconnect_tests")
.await
.with_object(CoordinatorSetting::Nats(config.clone()))
.await
.with_listener(NetworkListenerProtocol::Http, "http", HTTP_PORT, true)
.await
.build()
.await;
let coordinator = test.server.core.storage.coordinator.clone();
assert!(
coordinator.is_enabled(),
"a coordinator, though not connected"
);
assert_eq!(coordinator.is_connected(), Some(false));
assert_eq!(
cluster_health().await,
(503, json!({"coordinator": "disconnected"}))
);
// A subscription made now, as the broadcast subscriber makes it at
// startup, has to work once NATS is up
let mut stream = coordinator.subscribe(TOPIC).await.unwrap();
// 2. NATS comes up: the node connects on its own
let nats = GenericImage::new("nats", "latest")
.with_wait_for(WaitFor::message_on_stderr("Server is ready"))
.with_mapped_port(nats_port, 4222.tcp())
.start()
.await
.expect("Failed to start NATS container");
wait_for_health(200, "connected").await;
let other_node = coordinator::backend::nats::NatsPubSub::open(config.clone())
.await
.unwrap();
wait_until_connected(&other_node).await;
round_trip(&other_node, &mut stream, b"after startup").await;
// 3. NATS goes away: the node reports it; and it comes back: the node
// reconnects and the same subscription carries on
nats.stop().await.unwrap();
wait_for_health(503, "disconnected").await;
nats.start().await.unwrap();
wait_for_health(200, "connected").await;
wait_until_connected(&other_node).await;
round_trip(&other_node, &mut stream, b"after reconnect").await;
drop(nats);
if test.is_reset() {
test.temp_dir.delete();
}
}
async fn cluster_health() -> (u16, Value) {
let response = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(5))
.build()
.unwrap()
.get(format!("https://127.0.0.1:{HTTP_PORT}/healthz/cluster"))
.send()
.await
.unwrap();
let status = response.status().as_u16();
(status, response.json().await.unwrap())
}
async fn wait_for_health(status: u16, state: &str) {
let started = Instant::now();
loop {
let health = cluster_health().await;
if health == (status, json!({"coordinator": state})) {
return;
}
assert!(
started.elapsed() < Duration::from_secs(30),
"expected {status} {state}, still {health:?}"
);
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
async fn wait_until_connected(coordinator: &Coordinator) {
let started = Instant::now();
while coordinator.is_connected() != Some(true) {
assert!(started.elapsed() < Duration::from_secs(30), "not connected");
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// Another node publishes; this one's subscription receives it.
async fn round_trip(from: &Coordinator, stream: &mut coordinator::PubSubStream, payload: &[u8]) {
from.publish(TOPIC, payload.to_vec()).await.unwrap();
let message = tokio::time::timeout(Duration::from_secs(10), stream.next())
.await
.expect("no message within 10 seconds")
.expect("subscription ended");
assert_eq!(message.payload(), payload);
}
+4
View File
@@ -2,7 +2,11 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
pub mod broadcast; pub mod broadcast;
#[cfg(feature = "nats")]
pub mod coordinator; // inbuxa: coordinator reconnects
pub mod stress; pub mod stress;
+1 -1
View File
@@ -47,7 +47,7 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) {
imap.send("ID").await; imap.send("ID").await;
imap.assert_read(Type::Tagged, ResponseType::Ok) imap.assert_read(Type::Tagged, ResponseType::Ok)
.await .await
.assert_contains("* ID (\"name\" \"INBUXA\" \"version\" "); .assert_contains("* ID (\"name\" \"inbuxa\" \"version\" ");
// Login should be disabled // Login should be disabled
imap.send("LOGIN [email protected] secret").await; imap.send("LOGIN [email protected] secret").await;
+2 -2
View File
@@ -45,7 +45,7 @@ pub async fn test(test: &TestServer) {
list[0], list[0],
json!({ json!({
"id": default_calendar_id, "id": default_calendar_id,
"name": "INBUXA Calendar ([email protected])", "name": "inbuxa Calendar ([email protected])",
"description": null, "description": null,
"sortOrder": 0, "sortOrder": 0,
"isSubscribed": true, "isSubscribed": true,
@@ -310,7 +310,7 @@ pub async fn test(test: &TestServer) {
})); }));
response.list()[1].assert_is_equal(json!({ response.list()[1].assert_is_equal(json!({
"id": default_calendar_id, "id": default_calendar_id,
"name": "INBUXA Calendar ([email protected])", "name": "inbuxa Calendar ([email protected])",
"description": (), "description": (),
"sortOrder": 0, "sortOrder": 0,
"isSubscribed": true, "isSubscribed": true,
+2 -2
View File
@@ -38,7 +38,7 @@ pub async fn test(test: &TestServer) {
assert_eq!( assert_eq!(
list[0], list[0],
json!({ json!({
"name": "INBUXA Address Book ([email protected])", "name": "inbuxa Address Book ([email protected])",
"description": (), "description": (),
"sortOrder": 0, "sortOrder": 0,
"isSubscribed": true, "isSubscribed": true,
@@ -148,7 +148,7 @@ pub async fn test(test: &TestServer) {
"id": addressbook_id, "id": addressbook_id,
}), }),
json!({ json!({
"name": "INBUXA Address Book ([email protected])", "name": "inbuxa Address Book ([email protected])",
"description": (), "description": (),
"sortOrder": 0, "sortOrder": 0,
"isSubscribed": true, "isSubscribed": true,
+5 -4
View File
@@ -250,9 +250,10 @@ pub async fn test(test: &TestServer) {
"webWriteUrlTemplate": null "webWriteUrlTemplate": null
}, },
"urn:ietf:params:jmap:mail:share": {}, "urn:ietf:params:jmap:mail:share": {},
"urn:stalwart:jmap": {}, "urn:inbuxa:jmap:registry": {},
// inbuxa: MT-22, the logo that applies to the account // inbuxa: MT-22, the logo that applies to the account, and
"urn:inbuxa:jmap": { "logo": null }, // LP-19, whether the legacy protocols are open to it
"urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled" },
"https://www.fastmail.com/dev/maskedemail": {} "https://www.fastmail.com/dev/maskedemail": {}
} }
} }
@@ -274,7 +275,7 @@ pub async fn test(test: &TestServer) {
"urn:ietf:params:jmap:principals:availability": john_id, "urn:ietf:params:jmap:principals:availability": john_id,
"urn:ietf:params:jmap:filenode": john_id, "urn:ietf:params:jmap:filenode": john_id,
"urn:ietf:params:jmap:mail:share": john_id, "urn:ietf:params:jmap:mail:share": john_id,
"urn:stalwart:jmap": john_id, "urn:inbuxa:jmap:registry": john_id,
"https://www.fastmail.com/dev/maskedemail": john_id "https://www.fastmail.com/dev/maskedemail": john_id
}, },
"username": "[email protected]", "username": "[email protected]",
+2
View File
@@ -30,6 +30,8 @@ pub mod imap;
#[cfg(test)] #[cfg(test)]
pub mod jmap; pub mod jmap;
#[cfg(test)] #[cfg(test)]
pub mod renamed_identifiers; // inbuxa: SPEC.md §2.4
#[cfg(test)]
pub mod scim; pub mod scim;
#[cfg(test)] #[cfg(test)]
pub mod smtp; pub mod smtp;

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