Commit Graph
100 Commits
Author SHA1 Message Date
jcoffey-dev 309835be1d ACME: post a challenge once, then poll the authorization
The renewal loop re-posted the challenge every time it polled and found the
authorization still pending. RFC 8555 section 7.5.1 has the client post a
challenge once to say it is ready and then poll; a server that has already
moved the challenge to "processing" refuses a second post, and pebble
answers 400 malformed, which failed the whole renewal.

Verified against pebble: the 400s are gone and the client polls. The suite
still can't finish on this machine, because pebble never reaches the test
server to validate the challenge; that path is the environment, and the
runbook now says so.
2026-09-19 16:28:15 -07:00
jcoffey-dev ce12659138 Specs: what a plain regression leaves failing, and why none of it is ours 2026-09-19 16:15:09 -07:00
jcoffey-dev e492b7875a Specs: how to run the suites a plain regression doesn't reach
`cargo test -p tests` runs none of the fork's own feature suites except
what's inside `system_tests`: SCIM, per-domain directories, the sharded
stores and the four replica suites are all ignored, each needing a
container, a STORE the harness only builds on request, or both. The
runbook lists what each one needs, and the container-reuse trap.
2026-09-19 16:14:44 -07:00
jcoffey-dev 02ed679890 Tests: drop an unused import, so the tests crate builds without warnings 2026-09-19 16:14:44 -07:00
jcoffey-dev 5cc28784df SCIM: turning a domain's SCIM authority off takes effect at once (SCIM-60)
`allowScimProvisioning` is cached with the domain as DOMAIN_FLAG_SCIM, but
it wasn't among the fields whose change drops the cached entry, so flipping
the flag changed nothing until something else evicted the domain. SCIM-60
says the change takes effect without a restart.

Found by the two acceptance checks that were written but never called from
the driver, so neither had ever run: `authority` (SCIM-58 to SCIM-60,
through `synchronize_account` itself) and `rate_limits` (SCIM-14). Both are
wired in now, and `scim_tests` passes with them.
2026-09-19 16:14:38 -07:00
jcoffey-dev 2f1e4bd2c7 Tests: give the fail2ban and task-manager checks room under load
Two long-standing flakes in system_tests, both timing:

- security: the ban period was set to one second, but the test makes a
  hundred more requests before it checks that a valid password from the
  banned address is refused, so under load the ban had already expired.
  Five seconds, and the expiry check sleeps six.
- task: a task scheduled one second out was queried for straight away,
  and under load the query landed after the task manager had run and
  removed it. Three seconds.

Seven consecutive system_tests runs, neither recurred.
2026-09-19 15:38:01 -07:00
jcoffey-dev cee4fd6bc6 Specs: how to run the eight compat tests on a copy of INBUXA's data, and the statuses of tests 10, 11, 17 to 19
docs/spec/compat-tests.md lists each compat test, what it needs, what it
checks and what a failure means, and says plainly that they run against a
copy only, since the monitoring one purges the history it reads. The
scale-out and per-domain statuses record the acceptance tests that now
run.
2026-09-19 15:11:04 -07:00
jcoffey-dev c0377df942 Per-domain directories: a refused token counts toward the sign-in ban, and tests 10 and 18 (DIR-12, DIR-30)
A token the OIDC directory rejects is an authentication failure, so it
counts toward the ban; a network, provider or configuration fault stays
an error and doesn't. Before, a rejected token was an error too, so bad
tokens never led to a ban.

The Keycloak container now imports a second realm, so test 10 checks
/api/discover and the PACC record answer with each domain's own provider.
Test 18 checks that eight sign-ins during an outage don't ban the client,
while bad tokens do.
2026-09-19 15:09:52 -07:00
jcoffey-dev 0755fad51e Scale-out storage: IMAP CONDSTORE raises the mark, and the two-node check (ST-7, test 11)
A FETCH with CHANGEDSINCE presents its mod-sequence to the read scope, so
a replica must have that change before it answers, as a JMAP sinceState
already did. replica_cluster_tests covers test 11: with the replica's
replay paused, a write on one node is read back through a second store
with its own marks, sharing through Redis.

Composite stores nest store futures deeply enough to pass rustc's default
query depth once both postgres and redis are compiled in, so the server
crates raise their recursion limit.
2026-09-19 14:59:46 -07:00
jcoffey-dev e913a22b66 Scale-out storage: read replicas on MySQL, tested (ST-10, ST-15, tests 17 to 19)
Two source-and-replica pairs run in containers: one replicating with
GTIDs, one by binary log position. mysql_replica_tests covers test 17
(tests 9, 10 and 12 with GTIDs) and mysql_replica_position_tests covers
test 18 (lag from Seconds_Behind_Source, and a replica whose account
lacks REPLICATION CLIENT getting no reads) and test 19 (a parallel
replica without replica_preserve_commit_order left out at startup).

The lag reader now takes Seconds_Behind_Source whatever numeric type the
server returns, accepts the older column name, and says in the log why it
gave up measuring.
2026-09-19 14:53:31 -07:00
jcoffey-dev 216bb5b711 Scale-out storage: implementation status of read replicas (ST-5 to ST-15)
Where the routing lives and what a read scope covers, which acceptance
tests the container suite runs, what isn't exercised (two nodes, the
primary stopped, and the MySQL paths, which are built but unrun), what
was settled from the code, and the known limits.
2026-09-19 14:21:54 -07:00
jcoffey-dev 1518c69033 Scale-out storage: PostgreSQL and MySQL read replicas (ST-5 to ST-15)
A data store with readReplicas becomes a replicated store. Writes,
operator-written SQL and everything outside a read scope go to the
primary. JMAP reads before a request's first write, IMAP LIST, STATUS,
SEARCH, SORT and FETCH, POP3 RETR and TOP, DAV GET, PROPFIND and REPORT,
and blob downloads run in a read scope. Only account data (properties,
indexes, change logs, counters, ACLs, blobs, the search index) is read
from a replica; the registry, in-memory values, the task queue and the
rest stay on the primary.

In a scope, the first read picks a replica round-robin among those up
and under the lag limit, and only if it has every change this node has
written or heard of for the scope's accounts: marks come from write
results, the cluster's state-change broadcasts, a sinceState the client
presents, and, with more than one node, Redis. A write inside the scope
sends the rest of it to the primary. A miss on a replica is looked up on
the primary, and a replica error retries the read there and marks the
replica down.

Each node samples lag every second (WAL positions on PostgreSQL; GTID
sets or Seconds_Behind_Source on MySQL), stops reading from a replica
over 5 s and starts again under 2.5 s, and probes a down replica every
10 s. At startup a replica is left out if it's the primary, isn't
read-only, applies out of commit order, or doesn't show a marker written
to the primary within six tries.

replica_tests (postgres, STORE=PostgreSqlReplicated) runs a primary and a
streaming hot standby in containers: tests 9, 10, 12, 13, 14 and 15 pass.
2026-09-19 14:05:59 -07:00
jcoffey-dev a635b490ec Per-domain directories: implementation status and the compat check (DIR-1 to DIR-32, test 20)
per_domain_directory_compat, ignored, checks a copy of INBUXA's data has
no directory, no server default and no domain with its own directory, as
observed. The status names where the rules live, which suite covers each
acceptance test and how far, what was settled from the code, and the
known limits. SCIM's status notes its test 5 now passes.
2026-09-19 10:51:41 -07:00
jcoffey-dev 0237d6fa92 Per-domain directories: the OIDC directory tests, rebuilt from the spec; SCIM's OIDC test runs (DIR-7, DIR-10, DIR-14, DIR-15, DIR-18, DIR-26, DIR-29, SCIM test 5)
directory_tests now runs a new oidc module in place of the removed one,
with Keycloak as example.org's own directory: first sign-in creates the
account with its name and group, an existing account is reused, a token
named as another user is refused, password sign-in is refused, forged
JWTs (HS256, unknown kid, another issuer, expired) are refused, an OIDC
address is a recipient only once an administrator creates it, and sync
can't pass a tenant's account limit. scim_oidc_tests, deferred until
this feature, passes.
2026-09-19 10:45:37 -07:00
jcoffey-dev 3158277b04 Per-domain directories: changes apply on the next request, and one broken directory doesn't block reloads (DIR-17, DIR-21)
A write to x:Directory or Authentication reloads the directories at once,
here and across the cluster. A directory that fails to open is logged as
a warning against its id and becomes unavailable; before, it was a build
error, and any build error stopped every later reload from applying.

per_domain_directory_tests covers acceptance tests 1, 3, 4, 6, 7, 9, 11,
15 and 19 over SQL directories on SQLite files: each domain against its
own directory and the default, no fallback to internal passwords, a
directory answering for another directory's domain, aliases and groups
dropped, recipients through the directory and a 4xx while it's down, app
passwords while it's down, password changes refused and then allowed
after a move to the internal directory, linked directories, tenant
foreign keys, and changes taking effect without a reload.
2026-09-19 10:42:33 -07:00
jcoffey-dev f72e3bb85c Per-domain directories: each domain signs in against its own directory (DIR-1 to DIR-15)
The two lookups every caller uses now honor Domain.directoryId, then the
server default, then the internal directory, so sign-in, bearer routing,
recipient lookup, discovery, the PACC record and the refusal of password
changes on external accounts all follow the domain. A directoryId, or a
server default, naming a directory that doesn't exist is unavailable,
never the internal directory.

A directory speaks only for the domains it serves: an account it returns
on another directory's domain is refused, for sign-in and recipients
alike, and aliases and group claims on such domains are dropped with a
warning. A bearer token must belong to the user the client names, or the
name must be one of its aliases with alias sign-in allowed. Accounts and
groups that sync creates pass the tenant checks, limits included.
2026-09-19 10:32:57 -07:00
jcoffey-dev b080fa0736 Scale-out storage: implementation status of the sharded stores (ST-1 to ST-4, ST-16 to ST-30)
Where the code lives, which suite covers each acceptance test, what isn't
exercised, what was settled from the code, and that read-replica routing
(ST-5 to ST-15) waits for per-domain directories.
2026-09-19 10:21:30 -07:00
jcoffey-dev fdfa2bc259 SCIM: box each bulk operation's dispatch, so the crate builds with the redis feature (SCIM-51)
With redis on, bulk()'s future nested past the compiler's query depth
limit. Boxing the per-operation call keeps it shallow; behavior is
unchanged.
2026-09-19 10:15:39 -07:00
jcoffey-dev e5e326de6b Scale-out storage: sharded blob, in-memory and lookup stores; configured read replicas reported (ST-1 to ST-4, ST-16 to ST-30)
A Sharded blob store places each blob on xxh3(key) mod N, over the whole
key; reads fall back to the other members, so blobs placed under an
earlier member list stay readable, and deletes find them wherever they
are. The member list is recorded in the data store (secrets left out):
added or reordered members are a warning, a missing one refuses to open.
Blobs are compressed and marked before they reach a member. A Sharded
in-memory or lookup store sends each key to its home Redis member, and
prefix deletes and purges to all; a node whose member list differs from
the recorded one logs an error and runs on. Members are checked for
duplicates and must all open.

Until read-replica routing is built, each configured replica is reported
at startup instead of being silently ignored, and nothing connects to it.
The new scaleout_blob_tests covers tests 2 to 7, and the existing blob
suite passes against three FileSystem members (BLOB_STORE=Sharded).
2026-09-19 10:11:30 -07:00
jcoffey-dev e7efa91cbc Scale-out storage decision: sharded stores first, read-replica routing after per-domain directories, with configured replicas reported at startup meanwhile (ST-2) 2026-09-19 10:01:34 -07:00
jcoffey-dev 00b2eb6f4b SCIM: implementation status (SCIM-1 to SCIM-61)
Where each part lives, which suite covers each acceptance test, test 5
deferred to per-domain directories and test 31 unrun until a copy of
INBUXA's data, what was settled from the code, and the known limits.
2026-09-19 09:59:53 -07:00
jcoffey-dev 3df72b355a SCIM tests: the authenticated rate limit, and the compat check on INBUXA's data (SCIM-14, test 31)
A principal's key without unlimitedRequests gets 429 with Retry-After
over the limit, while its unlimited key still works. scim_compat, ignored,
checks a copy of INBUXA's data reads back as observed: no domain open to
SCIM and no externalId.
2026-09-19 09:53:36 -07:00
jcoffey-dev a18e209015 SCIM: publish meta in /Schemas, and run the conformance container on the host network (SCIM-6, SCIM-30)
scim2-client builds its models from /Schemas, so meta is described there
as the mapping tables give it, without lastModified. The tester container
now shares the host's network, so a host firewall that drops the Docker
bridge doesn't block the test server. With SCIM_CONFORMANCE=1, the
scim2-client lifecycle (12 steps), the eight replayed Okta, Keycloak and
Entra payloads, and scim2-tester (errors only for its generated
non-address userName) all pass.
2026-09-19 09:52:11 -07:00
jcoffey-dev 3ffaee0695 SCIM: a suspended account's open sessions end, and no credential it holds still works (SCIM-52)
The push router records which account each subscription belongs to, and
a new Revoke event drops every subscription the account holds, so its
IMAP IDLE, JMAP event streams and WebSockets on this node end. Other
users' subscriptions to its shared mailboxes stay. Test 28 now checks an
open IDLE is ended, and that the account's password over HTTP and its
own API key, both already cached, are refused on the next request.
2026-09-19 09:42:03 -07:00
jcoffey-dev 940b42f3b4 SCIM: just-in-time directory sync is read-only on domains open to SCIM (SCIM-58 to SCIM-60)
On such a domain, sign-in sync creates no account (the person gets an
ordinary authentication failure), changes nothing on an existing one,
and creates no group from a groups claim. Without the flag sync works as
before, and turning it off hands accounts back to sync with no restart.
Checked through synchronize_account itself; acceptance test 5 does the
same over OIDC once per-domain directories exist.
2026-09-19 09:38:00 -07:00
jcoffey-dev 0ca26070d7 SCIM: users, groups, queries, PATCH, Bulk and cursors at /scim/v2, over x:Account (SCIM-1 to SCIM-57)
Every SCIM operation becomes the x:Account get, query or set JMAP makes,
as the service principal, so permissions, tenant scope and limits,
address uniqueness and account destruction are enforced in one place.
Discovery is anonymous; everything else takes an API key as a bearer
token and nothing else. Domains open to SCIM carry a flag in the domain
cache. Filters take eq and and, answered from the account indexes, with
unindexed attributes checked on at most 200 candidates. Cursors are
stateless, HMAC-sealed under the server key. PATCH applies to the
resource in memory and saves it as a PUT, so it is all or nothing.
Groups get an address from their display name on the principal's
domain; membership is written on each user.

Every write emits one of five new scim.* events (ids 637 to 641), also
added to the packaged schema. The helpers the surviving SCIM suites
import are rebuilt from the spec; scim_tests runs the new acceptance
suite and the surviving tenant isolation suite, and both pass.
2026-09-19 09:35:23 -07:00
jcoffey-dev 776d18d06e SCIM: the protocol crate: URNs, the error document, and the filter and PATCH path grammars (SCIM-42, SCIM-45)
The filter parser reads all of RFC 7644's grammar, so a server supporting
only eq and and can name the construct it refuses. PATCH paths take a
schema URN prefix, sub-attributes and value filters.
2026-09-19 09:06:50 -07:00
jcoffey-dev 2d3f8251c5 SCIM decision: a groups value equal to the current membership in a POST or PUT body is accepted and ignored, since Okta sends groups: [] on create (SCIM-28) 2026-09-19 09:04:25 -07:00
jcoffey-dev 4b389b6b4e SCIM decisions: build before per-domain directories, with the OIDC authority test deferred; build to the spec without the profile drafts or vendor docs (SCIM-61, open questions 12 and 13) 2026-09-19 08:56:53 -07:00
jcoffey-dev f107443b29 Monitoring: implementation status, and the compat test for INBUXA's data (test 26)
monitoring_compat, ignored, checks the observed settings against a copy
of INBUXA's data, reads the old history without an error, and purges it.
The status names where each part lives, which suite covers each
acceptance test, the tests not exercised, and the known limits.
2026-09-19 08:54:21 -07:00
jcoffey-dev d9b36a3806 Undelete tests: restore the default upload settings on entry (UD-1)
Inside system_tests the quota suite leaves uploads with a 1-second
lifetime, so test 6's Sieve script upload could expire before its set
(BlobNotFound), intermittently. The suite now resets upload quota, count
and lifetime to their defaults first.
2026-09-19 08:54:21 -07:00
jcoffey-dev 2edb552b6b Monitoring: the fork's acceptance suite (MON-1 to MON-31)
system::monitoring::monitoring_tests covers the defaults, metric
sampling, the Prometheus gauges, trace history over SMTP and LMTP
(probe sessions skipped, no raw I/O), trace destroy, indexTelemetry off,
live metrics and live tracing with their tokens and stream limit,
alerts by event and by email, and tenant admins refused. Also removes a
leftover enterprise-only attribute on the Prometheus counters loop,
which would have dropped counters from an enterprise-feature build.
2026-09-19 08:43:45 -07:00
jcoffey-dev 49e3733428 Monitoring: alerts over metrics, by event and by email (MON-25 to MON-30)
Every minute the node that calculates metrics evaluates each enabled
x:Alert, read from the registry, with metric('name') or the underscore
name. An alert fires when its condition turns true: it emits
telemetry.alert-event with the rendered message, and queues one email per
recipient through the outbound queue, placeholders filled and
Auto-Submitted set. An alert naming an unknown metric is refused on save.
The shared alerts suite runs; every telemetry suite is un-gated.
2026-09-19 08:39:17 -07:00
jcoffey-dev 7b6959155b Monitoring: live tracing and live metrics streams, and their tokens (MON-20 to MON-24)
GET /api/token/tracing and /api/token/metrics issue a 60-second token for
holders of liveTracing or liveMetrics outside a tenant. /api/live/tracing
streams event: trace frames of x:TraceEvents, never raw I/O, filtered by
text or by key, with a ping while idle; /api/live/metrics streams event:
metrics frames of current totals every interval. At most eight streams run
per node, each for 30 minutes. Upstream's documented paths are aliases.
2026-09-19 08:34:51 -07:00
jcoffey-dev 143a10fcf0 Monitoring decision: a live token may be reused within its 60 seconds, since INBUXA Admin reconnects with the same token (MON-23) 2026-09-19 08:34:03 -07:00
jcoffey-dev fc2d4f237f Monitoring: trace history, trace search, and x:Trace over the stored traces (MON-10 to MON-17, MON-32)
A lossy collector subscriber keeps each inbound SMTP session that reached
MAIL FROM and each delivery attempt, info level and above and never raw
I/O, at most 1,000 events with strings cut at 4 KiB, and writes it when
the span closes as an x:Trace under the telemetry key class, scheduling
its indexing. The index task builds a document of event types, queue ids
and keywords when indexTelemetry is on. x:Trace/get derives timestamp,
from, to and size; /query filters by opening event, text, queueId and
time; /set destroys only. The data purge honours holdTracesFor. The shared
tracing and webhook suites run.
2026-09-19 08:31:04 -07:00
jcoffey-dev 9f7035588f Monitoring: metric history, one edition of metrics, and x:Metric over the stored samples (MON-4 to MON-7, MON-9, MON-17, MON-39)
Every node writes a sample per metric on metricsCollectionInterval:
counters as the increase since its last sample, gauges always, histograms
as totals when changed. Samples are x:Metric in the registry's encoding
under the telemetry key class, ids time-ordered. x:Metric/get and /query
read them with metric and timestamp filters and full paging, hide what's
past holdMetricsFor, and the data purge deletes it. The is_enterprise
split is gone, so every gauge and histogram is collected and exported, and
queue.count is set from the queue itself. The shared metrics suite runs.
2026-09-19 08:21:45 -07:00
jcoffey-dev 715f219528 Monitoring: correct the storage decision; history uses the AGPL telemetry key classes with the registry's encoding, and INBUXA's old history can't be read 2026-09-19 08:12:11 -07:00
jcoffey-dev 3ea242f7fb Monitoring decisions before implementation: live streams and stored histograms match INBUXA Admin, and history is stored as x:Trace and x:Metric registry items (MON-4, MON-20, MON-22, MON-23) 2026-09-19 08:10:27 -07:00
jcoffey-dev 88090b3ea6 AI spam classification: calibration results for commercially usable models; Qwen3 4B Instruct 2507 recommended, 4 vCPU minimum 2026-09-19 07:42:12 -07:00
jcoffey-dev 2e6c234152 AI spam classification: default to 2 KiB of text and a +2.0 cap on the model's tag, as calibrated for low-end CPU-only instances
The calibration harness (tests/src/system/ai_calibration.rs, ignored) sends
what the classifier sends to a real local model and scores its answers with
the classifier's parser.
2026-09-19 06:21:08 -07:00
jcoffey-dev 554cc4fcd2 AI spam classification decisions from calibration: maxContentBytes 2048 and spamMaxAdded 2.0 by default, for low-end CPU-only instances 2026-09-19 06:19:38 -07:00
jcoffey-dev 9490fc4677 AI spam classification: the model's opinion as one bounded spam signal, and the llm_prompt Sieve function (AI-1 to AI-28)
The classifier sends only the subject and text, between unforgeable markers
after the operator's prompt, to an OpenAI-compatible endpoint the operator
configured; nothing is preset. Its answer maps to an LLM_ tag whose score is
clamped (+5.0, -1.0 by default) and can never discard or reject on its own;
X-Spam-LLM is sanitized, encoded and folded, and a planted one is removed.
Failures, timeouts past the ceiling, a full slot or a paused model leave
mail flowing untagged. llm_prompt answers trusted scripts, and accounts
holding interactAi within an hourly limit. Redirects aren't followed and no
content or secret is logged. The limits live in inbuxa:AiLimits.
Acceptance tests 1 and 3 to 21; test 2 as the re-enabled shared llm case,
whose setup no longer waits on a rules file from a developer's own path;
test 22 written as the ignored ai_compat.
2026-09-19 00:41:00 -07:00
jcoffey-dev cba48cf03b AI spam classification decision before implementation: the limits singleton is inbuxa:AiLimits under urn:inbuxa:jmap (open question 10) 2026-09-18 23:50:33 -07:00
jcoffey-dev e844878ba7 Tests: stabilize the OIDC suite with five times upstream's token lifetimes
Expiry counts whole seconds, so a 1s access token, code or device code could
lapse before a debug build's next request: 4 of 8 baseline runs failed at
six different points. Lifetimes are now 5s (tokens, codes), 15s (refresh)
and 10s (renewal), with the expiry checks' sleeps scaled to match. OIDC then
passed in 12 of 12 runs.
2026-09-18 22:49:27 -07:00
jcoffey-dev 725fbe7ec7 Tests: drop an unused undelete test helper 2026-09-18 22:27:19 -07:00
jcoffey-dev 0bc6b03dcd Branding and templates: per-domain, tenant and server logos, /logo, operator calendar email templates and RSVP page (BT-1 to BT-26)
Logos resolve domain, then tenant, then server-wide, then the built-in, with
subdomains finding their domain. GET /logo serves a data-URL image, redirects
to a URL logo without fetching it, sandboxes SVG, and answers 404 when no
custom logo applies. Emails embed the first PNG, JPEG or GIF logo. Logo and
template writes are checked; stored templates are read at send time, always
escaped, and fall back to the built-in with a build warning when they don't
parse. The RSVP page is served byte for byte with a CSP and no-referrer. The
sign-in and RSVP pages load the logo through an image element. MT-22's
session logo follows the chain to the server-wide logo.
Acceptance tests 1 to 17; test 18 written as the ignored branding_compat.
2026-09-18 22:27:19 -07:00
jcoffey-dev ecbdfd533b Undelete: deleted accounts are kept for their period, hold their addresses, and are restored or destroyed through inbuxa:DeletedAccount (UD-15 to UD-17a)
With archiveDeletedAccountsFor set, a destroyed account's record is kept in
the fork subspace with its id, its DestroyAccount task is due at the end of
the period, and its shares are suspended both ways. Its addresses can't be
taken by new accounts, aliases, lists or masks. inbuxa:DeletedAccount/get
lists kept accounts to server and tenant administrators; /set restores one
with a new password (same id, task cancelled, shares reinstated) or destroys
it now. The destroy task also clears undelete's own records.
Acceptance test 14; test 16 written as the ignored undelete_compat.
2026-09-18 21:35:48 -07:00
jcoffey-dev 0a2c29e8fd Undelete decision before implementation: a kept account's shares are suspended both ways and reinstated on restore (UD-17a) 2026-09-18 21:19:09 -07:00
jcoffey-dev 5b963b06c6 Undelete: deleted files, calendar events, contacts and Sieve scripts are kept and restored where they were (UD-1, UD-8 to UD-11)
Files, events and contacts are noted when deleted for good and archived by
the unindex task when retention is on; Sieve scripts are archived at
deletion. A restore goes back to its folder, calendar or address book if it
still exists, takes a free " (restored)" name, comes back inactive for
scripts, and is refused over quota with the item left archived.
Acceptance tests 6, 8 and 12.
2026-09-18 21:07:27 -07:00
jcoffey-dev 4a631bd0b5 Undelete: deleted email is kept, restored where it was, and managed over x:ArchivedItem (UD-1 to UD-14 for email)
Every way of deleting mail for good (JMAP, IMAP expunge, POP3, Trash
emptying, mailbox removal) notes the message's mailboxes and keywords while
archiving is on, fixing its deadline then; when its data is finally removed
it becomes an x:ArchivedItem record, written as upstream writes them, with
its copy held until the deadline. Retention is read at deletion time, so a
change applies at once. Restore puts a message back in the mailboxes it was
in (Trash only if that was all), with its keywords, and removes the record;
over quota it stays archived. x:ArchivedItem/get returns status and
accountId; query filters on type, archivedAt and text; set requests a
restore once or destroys; /changes is a fork addition. Expired items go in
the data purge. The shared account-access rule moves to jmap::inbuxa::access.
system_tests now calls undelete::test, and the archiving gate is gone.
2026-09-18 20:05:56 -07:00
jcoffey-dev a1ce14b76b Undelete decisions before implementation: restore data, kept accounts, the restore API (UD-4, UD-15a, UD-17) 2026-09-18 19:49:34 -07:00
jcoffey-dev ec4d668bc4 Multi-tenancy: impersonate has no effect inside a tenant (MT-1, MT-15)
The ceiling always disables impersonate for principals in a tenant, so no
tenant setting or grant lets them reach accounts beyond it.
2026-09-18 18:51:42 -07:00
jcoffey-dev ac2232c98d Multi-tenancy decision before implementation: impersonate has no effect inside a tenant (MT-1, MT-15) 2026-09-18 18:49:10 -07:00
jcoffey-dev 4a9aa9c548 Masked email: rewrite to the owner at RCPT TO, create responses carry the address, admins query all masks (ME-4, ME-9, ME-13, ME-19)
Found by running system_tests, which masked email no longer stops:
- rcpt_resolve rewrites a live mask to its owner's address, so
  Delivered-To names the account; delivery recognizes the mask from the
  original recipient when it belongs to that account.
- x:MaskedEmail/set create responses carry the server-set email.
- x:MaskedEmail/query returns every mask to a server-level impersonate
  holder, and filters on accountId.
- The refusal for an unlinked emailDomain uses upstream's wording.
- The shared delivery test checks the fork's address format (ME-13).
- The masked email test's tenant domain uses manual DKIM, so its cleanup
  leaves nothing behind.
2026-09-18 18:29:19 -07:00
jcoffey-dev f58aea000f Cargo.lock: inbuxa-features for the tests crate (masked email) 2026-09-18 16:56:29 -07:00
jcoffey-dev 31b8ca8ed5 Masked email built: implementation status, deferral and known limits (ME-1 to ME-19) 2026-09-18 16:40:14 -07:00
jcoffey-dev d319f013ec Masked email acceptance test 12 against a copy of INBUXA's data (compat) 2026-09-18 16:37:53 -07:00
jcoffey-dev f07c00fffb Masked email acceptance tests 1 to 11, with ME-9, ME-10, ME-11 and /changes (ME-1 to ME-19)
tests/src/system/masked_email.rs runs from system_tests and alone as
masked_email_tests. Fastmail's MaskedEmail/set updates from the stored
object, so the registry's revision check holds.
2026-09-18 16:37:12 -07:00
jcoffey-dev d04aafd3d7 Masked email: Fastmail's Masked Email API, MaskedEmail/get and /set (ME-1, ME-7a, ME-16)
Advertised as https://www.fastmail.com/dev/maskedemail in the session and on
every account that may hold masks. Masks created through it start pending
unless the create sets a state; pending can't be set again once left; state
and the other mutable fields map onto the same records the x: API uses.
2026-09-18 16:29:47 -07:00
jcoffey-dev 7080028437 Masked email: x:MaskedEmail/changes and a state in /get (fork additions)
The fork's per-account change log answers /changes, collapsing a mask
created and destroyed in the window. /changes on a registry type needs that
type's get permission.
2026-09-18 16:25:54 -07:00
jcoffey-dev 53ccc8f4de Masked email: owners send as their live masks, over JMAP identities and SMTP submission (ME-11) 2026-09-18 16:23:49 -07:00
jcoffey-dev 60d1d8b84a Masked email: delivery through masks (ME-4, ME-5, ME-6, ME-7, ME-9, ME-10)
A live mask accepts mail at RCPT TO and delivers to its owner, with an
X-Masked-Email header naming it. A disabled mask files straight to Trash,
past the owner's Sieve script. Deleted and expired masks are refused like
unknown addresses, without being cached as unknown. Mail moves lastMessageAt
and turns a pending mask enabled. Sub-addresses on a mask work.
2026-09-18 16:22:50 -07:00
jcoffey-dev 3b052a57da Masked email: upstream's x:MaskedEmail API (ME-2, ME-3, ME-6a, ME-7a, ME-12 to ME-19)
x:MaskedEmail is no longer refused as unbuilt. Creates generate the address
on an allowed domain and check the prefix, maxMaskedAddresses and the create
rate; updates keep server-set fields; enabled reads and writes map to the
shared state; query filters on enabled, forDomain and text; a tenant
administrator reaches its tenant's accounts' masks.
2026-09-18 16:20:39 -07:00
jcoffey-dev aaca8fe537 Masked email: the fork's subspace and the masked_email module (ME-1, ME-2, ME-3, ME-6a, ME-7, ME-8, ME-13, ME-14, ME-15)
Subspace _ holds the fork's own data, with its own SQL table and RocksDB
column family, and is part of backup. The masked_email module keeps each
mask's state, last mail and pending deadline beside upstream's record, an
index from address to mask with tombstones, and a per-account change log;
and generates addresses in the fork's format.
2026-09-18 16:17:47 -07:00
jcoffey-dev 7ef2cdc273 Masked email: the fork's subspace is _, not X, which SQL backends fold into upstream's x 2026-09-18 16:10:50 -07:00
jcoffey-dev edfb357cb1 Masked email decisions before implementation: storage, start state, rate config, who administers (ME-7a, ME-15, ME-19) 2026-09-18 16:09:57 -07:00
jcoffey-dev e109cf86ae The session test expects urn:inbuxa:jmap (MT-22, contract C-1) 2026-09-18 16:00:25 -07:00
jcoffey-dev 2e43f5fc0d Multi-tenancy built: implementation status, deferrals and known limits (MT-1 to MT-23) 2026-09-18 15:38:29 -07:00
jcoffey-dev dd584dc9ce Multi-tenancy acceptance tests 1 to 14, and test 15 against a copy of INBUXA's data (MT-1 to MT-23)
tests/src/system/tenant.rs is new, and system_tests calls it again without
the pending-rebuild gate. tenant_tests runs it alone; tenant_compat is test 15,
ignored until a copy of INBUXA's data is provided.
2026-09-18 15:34:21 -07:00
jcoffey-dev 11d780b3d3 Copyright of the fork's own files is Coffey Labs's 2026-09-18 15:30:27 -07:00
jcoffey-dev 05ee6ac2be Multi-tenancy: a new domain's generated DKIM keys count against maxDkimKeys (MT-17) 2026-09-18 15:28:59 -07:00
jcoffey-dev 2cbecac415 Multi-tenancy decision before implementation: generated DKIM keys count against maxDkimKeys (MT-17) 2026-09-18 15:28:24 -07:00
jcoffey-dev bcf4a49325 Multi-tenancy: sharing grants stay within the owner's tenant (MT-1, MT-3)
JMAP shareWith refuses a grantee outside the owner's tenant, or missing, with
invalidForeignKey naming the account. WebDAV ACL answers AllowedPrincipal and
IMAP SETACL answers as for an unknown account.
2026-09-18 15:20:04 -07:00
jcoffey-dev cea8b1593b Multi-tenancy: the applicable logo in the JMAP session (MT-22, MT-23)
The session's own account carries urn:inbuxa:jmap with logo: its domain's
logo, else its tenant's, as stored. The session lists urn:inbuxa:jmap as a
server capability too.
2026-09-18 15:20:04 -07:00
jcoffey-dev 406aa05dc0 Multi-tenancy: recalculateQuota and resetTenantQuotas (MT-21) 2026-09-18 15:20:04 -07:00
jcoffey-dev 3896f720a1 Multi-tenancy: queue visibility (MT-5)
A tenant sees queued mail with a recipient on its domains, and mail its own
authenticated senders sent from them, in get, query, update and destroy.
2026-09-18 15:20:04 -07:00
jcoffey-dev 5a22e79992 Multi-tenancy: registry reach, links, membership and count limits over JMAP (MT-2, MT-3, MT-6, MT-7, MT-8, MT-11, MT-12, MT-17, MT-18)
Inside a tenant, server-level object types are forbidden and x:Tenant reads
return only the caller's own tenant, which it can't change. Registry writes
refuse links across tenant boundaries in both directions, give a new
principal its domain's tenant, move a domain's principals and DKIM keys with
it into a tenant, refuse moves out of a tenant while its people remain, and
refuse creates past a tenant's count limits with overQuota and
limit.tenant-quota.
2026-09-18 15:20:04 -07:00
jcoffey-dev ad0db8b2b0 Multi-tenancy: the inbuxa-features crate, the permission ceiling and tenant disk quota (MT-12, MT-13, MT-14, MT-15, MT-16, MT-19, MT-20)
New crate crates/features (inbuxa-features), AGPL-3.0-only, holding the
tenancy rules. Hooks in common: a tenant's roles and permission lists cap
its people's permissions; a change to a tenant, or to a role a tenant holds,
drops its members' cached permissions; delivery and every other write check
the tenant's maxDiskQuota; usedDiskQuota reads the tenant usage counter.
The default Tenant Administrator role gains sysTenantGet and sysTenantQuery.
2026-09-18 15:19:58 -07:00
jcoffey-dev b6b345a129 Multi-tenancy decision before implementation: sharing grants across tenants (MT-3) 2026-09-18 15:17:03 -07:00
jcoffey-dev d0d4ac4317 Open decisions: the Enterprise License text stays until the headers are cleaned up 2026-09-18 14:52:45 -07:00
jcoffey-dev a1254cd3c4 Multi-tenancy decision before implementation: what moves with a domain (MT-8, MT-17) 2026-09-18 14:50:57 -07:00
jcoffey-dev edc6a8ccbd Multi-tenancy decisions before implementation: MT-3, MT-7, MT-12, MT-19a, MT-22
Test 3 expects invalidForeignKey, as MT-3 says. A tenant admin reads its own
tenant through sysTenantGet and sysTenantQuery, added to the default Tenant
Administrator role for new installs only. The server adds nothing for MT-7's
dashboard list. The MT-19a submission warning is deferred. MT-22's logo is
the urn:inbuxa:jmap account capability's logo field (contract C-1).
2026-09-18 14:48:22 -07:00
jcoffey-dev cf59d5183a Branding: INBUXA's last two logo values removed 2026-09-18 14:45:38 -07:00
jcoffey-dev 8c1879e853 Cross-origin requests only from the front ends' origins (contract C-14) 2026-09-18 13:44:55 -07:00
jcoffey-dev a45e0ef8b1 Feature specs 4-9: observations from INBUXA's live server 2026-09-18 13:42:00 -07:00
jcoffey-dev cd7c1a6d4c Feature specs 4-9: branding and templates, AI spam classification, monitoring, SCIM, scale-out storage, per-domain directories 2026-09-18 13:18:39 -07:00
jcoffey-dev 31e64f5a4d Rebrand the packaged schema: defaults, labels and descriptions; the Enterprise page becomes Branding; log prefix inbuxa.log 2026-09-18 13:15:17 -07:00
jcoffey-dev 05d220ae4f No web interface on the mail host: first boot installs and downloads none (SPEC §5.3) 2026-09-18 13:10:32 -07:00
jcoffey-dev c2be7e2956 Multi-tenancy implementation hand-off brief 2026-09-18 13:06:47 -07:00
jcoffey-dev d6fc4600cd OAuth: registration required by default; first-party clients registered on every start (contract C-5, C-6) 2026-09-18 13:04:20 -07:00
jcoffey-dev f82f15d863 Contract: note the browser sign-in check after the OAuth fix 2026-09-18 12:53:59 -07:00
jcoffey-dev ad3322183a Contract: record the OAuth registration fix applied on production 2026-09-18 12:40:33 -07:00
jcoffey-dev 2e2eb76301 Contract: C-7 matches C-5 (third-party OAuth apps are admin-registered unless open registration is chosen) 2026-09-18 12:31:18 -07:00
jcoffey-dev 559bb3d1a6 Contract: INBUXA production OAuth findings; C-5 also turns anonymous client registration off 2026-09-18 12:31:07 -07:00
jcoffey-dev 0ed540f43a Contract spec: inbuxa-server, ihasmail-inbuxa and INBUXA Admin
Discovery and a contract version in the session; front ends configured once
(x:FrontEnds); OAuth with required registration, first-party clients,
server-hosted sign-in and consent for everything else; per-grant revocation;
cross-origin limited to the front ends; an admin lane by scope; push
unchanged. Records what upstream does today, including that it accepts any
client and redirect URI by default, and the phishing that allows.
2026-09-18 12:19:47 -07:00
jcoffey-dev b2de803680 Spec: public ihasmail stays Stalwart-facing; INBUXA work goes to an INBUXA fork of ihasmail 2026-09-18 12:09:12 -07:00
jcoffey-dev faedf7a1da Versioning: INBUXA's own dated version, with the Stalwart base shown
inbuxa --version, the banner, startup events, OpenTelemetry and the JMAP
implementation string read "2026.9.18 (Stalwart 0.16.22)". The base comes
from Cargo, which keeps following upstream so version bumps merge cleanly.
Received headers and IMAP ID carry the INBUXA version.
2026-09-18 12:08:41 -07:00
jcoffey-dev bde11d54c3 Spec: two web front ends, ihasmail and INBUXA Admin (a fork of Stalwart WebUI)
Administration is no longer moving wholesale into ihasmail. INBUXA Admin, a
fork of the schema-driven webui (no Enterprise-only code, ordinary fork),
covers the whole server, setup and recovery from its own deployment.
ihasmail keeps its account and tenant administration. Contract additions:
the inbuxa-admin OAuth client, cross-origin access limited to the two
front ends (the server currently allows every origin), and relative OAuth
endpoints on servers with no public URL.
2026-09-18 12:01:36 -07:00
jcoffey-dev c9c761fab5 Quick fixes from the first boot: recovery admin, upsell, warnings
- The recovery administrator (INBUXA_RECOVERY_ADMIN, or STALWART_RECOVERY_ADMIN)
  is honored only in bootstrap and recovery mode. On a configured server it's
  ignored with a startup warning. Before, it was a standing full-admin login
  for as long as the variable stayed set.
- The Enterprise upsell error is replaced by "This feature isn't available in
  INBUXA yet" for the features still to be rebuilt.
- Workspace warnings: 25 to 0. cargo fix removed the unused imports. The
  seven places where Enterprise code used to plug in keep their parameters,
  each with an inbuxa: comment naming the rebuild that uses it again. The
  antispam test's mock-server imports are back behind pending-rebuild.
2026-09-18 11:36:44 -07:00