Postal v4.0 (WIP) - pluggable storage engines, modern SMTP and transport security by c41ms0n · Pull Request #3645 · postalserver/postal · GitHub
Skip to content

Postal v4.0 (WIP) - pluggable storage engines, modern SMTP and transport security - #3645

Draft
c41ms0n wants to merge 10 commits into
postalserver:mainfrom
c41ms0n:v4.0-alpha
Draft

c41ms0n wants to merge 10 commits into
postalserver:mainfrom
c41ms0n:v4.0-alpha

Conversation

@c41ms0n

@c41ms0n c41ms0n commented Sep 24, 2026 •

Copy link
Copy Markdown

Summary

This branch makes the message database pluggable across engines, adds a blob store for message bodies, brings the SMTP client and server up to current protocol and transport-security expectations, and moves the application to Ruby 4.0.7, Rails 8.1.3.1, Rack 3.2.7 and Puma 7.0.4.

The default installation is unchanged. The message database still defaults to MySQL/MariaDB, bodies are still stored in the database, and no transport-security feature activates without configuration. What an existing installation sees is new optional configuration, a small number of new tables, and the toolchain move.

Implementation

Message database

Engine-specific SQL is isolated behind a dialect, selected by message_db.adapter. MySQL/MariaDB is the default; PostgreSQL and SQLite are supported. The dialect contract covers connection handling, identifier quoting, value escaping, schema-qualified table operations, index and column management, and translation of the column types the message schema declares into each engine's vocabulary.

A message body is written across raw_tables rows instead of as a single statement, so a body exceeding max_allowed_packet — and the equivalent limits on other engines — is no longer a failure. Chunk iteration guards against cyclic next references, and empty IN () conditions render as 1=0 rather than a syntax error. Retention prunes the corresponding rows, terminates instead of looping when the table list is exhausted, and skips a raw_tables name that is not a date rather than aborting the run. The connection pool never checks a failed checkout back in, and a replacement connection obtained on retry is treated as an ordinary connection.

Blob store

Bodies at or above blob_store.threshold (default 1 MiB) can be held in a blob store instead of the message database: a filesystem tree, S3-compatible object storage, or FoundationDB. The store is addressed by URL (inline:// default, filesystem://, s3://, foundationdb://), and all three backends generate keys from one place. Filesystem keys are validated against the shared key pattern, so a tampered database value cannot escape the store directory. S3 object keys use / separators on all platforms.

Statistics and telemetry

Optional sinks accept the daily statistics: DuckDB, ClickHouse, and a time-series sink speaking the Prometheus text protocol (also accepted by VictoriaMetrics), InfluxDB line protocol, or line-delimited JSON for a vector.dev pipeline. Time-series samples carry both the server permalink and the server id, so servers in different organisations sharing a permalink do not merge. Optional live statistics can be held in a key-value store speaking the Redis protocol (Valkey, Redis, or another drop-in replacement) or Aerospike rather than in the message database; a non-default store outage is logged and delivery continues, while the default store still propagates failures because the store is the message database. Only a Prometheus-compatible store can additionally back the dashboard read-back and live streaming; the Influx and JSON schemes are write-only. Every one of these is optional and inert when unconfigured.

SMTP server

The advertised ESMTP extensions are derived from a single declaration rather than assembled per session, so what is announced and what is implemented cannot drift. The server has an explicit TLS version policy, and every unauthenticated entry point is rate limited. CRAM-MD5 challenges use a CSPRNG value and the response comparison is timing-safe. A PROXY line naming an unparsable address is rejected, and the unauthenticated credential lookup tolerates legacy rows without an address. The pre-newline read buffer is bounded so a client that never sends a newline cannot grow memory without bound.

SMTP client

Relay delivery supports authentication (PLAIN, LOGIN, CRAM-MD5) and refuses to send credentials when the relay advertises no mechanism. A message which a server has already refused via SIZE is not transmitted. The verified-TLS context pins the system certificate store, and the MTA-STS policy fetch does the same while refusing SSRF destinations through the shared address guard; hosts that do not resolve fall through to the existing fetch-failure handling.
Outbound delivery honours MTA-STS policies, and can be held to them strictly.

Domain transport security

For hosted domains:

  • DKIM — key size (1024, 2048, 3072 or 4096; default 2048), rotation and change reminders are configurable.
  • DMARC — the record is checked and displayed. It is not managed.
  • MTA-STS — a policy is published and served, and the certificate for the policy host is obtained over ACME (Let's Encrypt directory by default).
    Key files are created with mode 0600 rather than chmodded after the fact.
  • TLS-RPT — a reporting record is published for the domain, and submitted reports are ingested. The receiving address is configurable, the report is attributed to the domain it concerns, reports for domains that no longer exist are discarded, and each report contributes at most 100 policies with 100 failure details each.

Ingestion is keyed on the local part of the recipient (dns.tls_rpt_local_part, default tlsrpt). Setting it blank disables ingestion rather than accepting any address without a local part.

Security

The password reset form no longer reveals whether an account exists: a request for an unknown address is indistinguishable from one for a known address. Reset requests are throttled by address and period (protection.web_password_reset_limit / web_password_reset_period).
Rate-limit keys are bounded so that arbitrary input cannot grow the counter store without limit, and counters can be shared between workers via protection.counter_store. Session policy (sessions group) is declared explicitly rather than left to the library's defaults. OIDC account linking by email requires the provider's verified claim. The legacy API no longer echoes rejected keys. Domain verification codes use a CSPRNG value with timing-safe comparison, and invitation acceptance requires the invited address. The test message composer accepts only the fields its form submits. Trusted proxies are declared through action_dispatch.trusted_proxies, and the session secret flows through Rails' secret_key_base chain.

Toolchain

Ruby 4.0.7, Rails 8.1.3.1 (with load_defaults 8.1), Rack 3.2.7, Puma 7.0.4. Default and bundled gems used by the code (cgi, timeout, securerandom, base64, benchmark, logger, mutex_m, ostruct) are declared and required where used.

YJIT is compiled into the Ruby 4.0.7 runtime and needs no native toolchain at runtime, but ships disabled: the image sets RUBY_YJIT_ENABLE=0 via the YJIT build arg (default 0). Pass --build-arg YJIT=1 to enable it for the image, or -e RUBY_YJIT_ENABLE=1 at run time; only the value 1 enables it. spec/lib/ruby_runtime_spec.rb guards the invariant: YJIT support present, off unless explicitly enabled, stats exposed when on.

jemalloc 5.3.0-3 ships as a build variant, off by default: --build-arg JEMALLOC=1 installs the library, and -e JEMALLOC_PROFILE=balanced (or =aggressive) preloads it at container start. Only two profiles exist, deliberately — Ruby allocates many small short-lived objects across migrating Puma threads, so thread-local caching stays on and arenas stay at jemalloc's default; what varies is how fast unused pages return to the OS (10s/10s vs 1s/0s dirty/muzzy decay). The preload goes through /etc/ld.so.preload rather than LD_PRELOAD because the image's setcap-capped ruby binary ignores the latter (verified: mapped in /proc/self/maps only via the preload file). max_background_threads:1 is set explicitly because background_thread:true alone spawns zero threads and pages are never purged. abort_conf:true refuses to boot on a bad profile or a missing library rather than running misconfigured.
spec/lib/postal/jemalloc_spec.rb covers the profile mapping and the library lookup. Measured on a 200MB burst workload: churn ~20% faster than glibc, steady-state RSS higher (~95MB vs ~15MB retained pool, reused rather than regrown across bursts), aggressive back to baseline within seconds of sustained allocation pressure. Combines cleanly with YJIT.

Configuration and compatibility

New or changed configuration, all inert unless configured except where noted:

Group Purpose
message_db.adapter mysql (default), postgresql, sqlite
blob_store.url Body store backend (inline:// default)
blob_store.threshold Minimum body size stored externally (default 1 MiB)
analytics.url Statistics sink selection
live_stats.url Live statistics store (mysql:// default)
telemetry.* Metrics export endpoint and buffering
dns.tls_rpt_address Address TLS reports are requested to
dns.tls_rpt_local_part Local part identifying a submitted report (default tlsrpt)
dns.dkim_key_size DKIM RSA key size (default 2048)
mta_sts.* Certificate directory, ACME account key, directory URL, contact
smtp_client.mta_sts Whether outbound delivery is held to MTA-STS policies
smtp_client.minimum_tls_version Oldest TLS version for outbound sessions (default 1.2)
protection.counter_store Counter backend (memory:// default, redis:// shared)
protection.web_password_reset_limit / web_password_reset_period Reset throttling
sessions.* Session timeout and persistence
rails.secret_key Session secret, exported as SECRET_KEY_BASE at boot

Compatibility points:

  • PostgreSQL requires a role with access to every schema the message database uses. The message database creates a schema per server, so a role limited to one schema fails during migration.
  • Toolchain requirements move to Ruby 4.0, Rails 8.1 and Rack 3. The container image is ruby:4.0.7-slim-trixie with Bundler 4. The json gem stays pinned below 3 because Active Support 8.1 still passes quirks_mode.

Database and storage changes

Main database, all for transport security (one migration):

  • tls_reports — a submitted report, one row per report.
  • tls_report_results — the per-failure detail of a report.
  • acme_challenges — the challenge records used for certificate issuance.
  • domains gains DKIM rotation, DMARC status, MTA-STS policy and certificate, and TLS-RPT status columns.

The message database gains the raw_table and raw_headers_id columns on messages, the raw_tables relation holding the body rows, and a blob column on raw tables referencing the blob store.

Two corrections to the existing migrations, both needed for a fresh installation:

  • Seven migrations predating versioned migrations inherited from ActiveRecord::Migration without a version, which Rails 8.1 refuses to run.
    One of them is the initial schema, so a new installation could not be migrated at all.
  • Nine datetime columns were declared without the precision the migrations produce, so the schema the application loaded differed from the schema a migration produced. They are now declared precision: 6.

The schema version and collation are unchanged. The transport-security schema is carried by the single migration that describes it.

Testing

  • bundle exec rspec — 1563 examples, 1 failure, 22 pending.
  • bundle exec rubocop — 395 files, 20 offences, all pre-existing and autocorrectable.
  • The message-database suite passes against MySQL/MariaDB, PostgreSQL and SQLite.
  • The optional image target, which carries the optional backends, builds.
  • The web interface was booted, not only tested: Rails 8.1.3 on Rack 3 serves the login page, and boot resolves the trusted-proxy list and the session secret through Rails' own configuration chain.

The failure and the pending specs are both environmental; both are described under Known limitations. Coverage added with this work includes the schema equivalence between a migrated database and the loaded schema, the columns the SQLite dialect must normalise as booleans, credential decoding in the S3 and ClickHouse backends, escaping in the dialect queries, the OIDC verified-email requirement, and the non-echo of rejected API keys.

Known limitations

  • FoundationDB is unverified at runtime. The backend is implemented and covered by a spec, but that spec has never executed against a cluster. The client library must also be reachable from the process, which the current image does not arrange.
  • SQLite engine attribution was not independently observed. The suite reported 95 examples passing against it, but the engine was selected by environment variable rather than confirmed at runtime.
  • ACME issuance has not run against a live certificate authority. It is verified against a stubbed directory; a live run requires a public hostname resolving to the installation.
  • The one failing spec resolves an external name over live DNS and fails wherever that name is not resolvable as expected.
  • The 22 pending specs are optional-group examples skipping because their services are not running, plus the YJIT stats example skipping when YJIT is off (its default) and the jemalloc library example skipping when the library is not installed.

…ort security

Ruby 4.0.7, Rails 8.1.3.1, Rack 3.2.7, Puma 7.0.4.

STORAGE
Engine-specific SQL is isolated behind a dialect selected by
message_db.adapter. MySQL/MariaDB is the default; PostgreSQL and SQLite are
supported. The dialect contract covers connection handling, identifier
quoting, value escaping, schema-qualified table operations, index and column
management, and translation of the declared column types into each engine's
vocabulary. Empty IN () conditions render as 1=0 rather than a syntax error.

A message body is written across raw_tables rows instead of as a single
statement, so a body exceeding max_allowed_packet -- and the equivalent limits
on other engines -- is no longer a failure. Chunk iteration guards against
cyclic next references. Retention prunes the corresponding rows, terminates
instead of looping when the table list is exhausted, and skips a raw_tables
name that is not a date rather than aborting the run. update() joins
conditions with AND, LIMIT/OFFSET are coerced, and the connection pool never
checks a failed checkout back in; a replacement obtained on retry is treated
as an ordinary connection again.

Bodies at or above blob_store.threshold (default 1 MiB) can be held in a blob
store instead of the message database: a filesystem tree, S3-compatible
object storage, or FoundationDB, addressed by URL and off by default. All
three backends generate keys from one place; filesystem keys are validated
against the shared pattern so a tampered database value cannot escape the
store directory, and S3 object keys use "/" separators on all platforms.

Optional sinks accept the daily statistics: DuckDB, ClickHouse, and a
time-series sink speaking the text protocol shared by VictoriaMetrics,
Prometheus, InfluxDB and vector.dev. Time-series samples carry both the
server permalink and the server id, so servers in different organisations
sharing a permalink do not merge. Optional live statistics can be held in
Valkey or Aerospike rather than in the message database; a non-default store
outage is logged and delivery continues, while the default mysql store still
propagates failures because there the store is the message database. Metrics
export uses the same text protocol. Every one of these is optional and inert
when unconfigured.

SMTP AND INBOUND TRANSPORT SECURITY
The inbound server advertises the current ESMTP catalogue from one
declaration, so what is announced and what is implemented cannot drift. Both
sides have an explicit TLS policy, and every unauthenticated entry point is
rate limited. CRAM-MD5 challenges use a CSPRNG value with timing-safe
comparison; a PROXY line naming an unparsable address is rejected; a nil
SMTP-IP credential no longer crashes the lookup; and the pre-newline read
buffer is bounded so a client that never sends a newline cannot grow memory
without bound.

The outbound client refuses to send credentials when the relay advertises no
mechanism, and does not transmit a message the server has already refused via
SIZE. The verified-TLS context pins the system certificate store, and the
MTA-STS policy fetch does the same while refusing SSRF destinations through
the shared address guard; unresolvable hosts fall through to the existing
fetch-failure handling. Outbound delivery honours MTA-STS policies, and can
be held to them strictly.

For hosted domains: DKIM key size (1024/2048/3072/4096), rotation and change
reminders are configurable; DMARC is checked and displayed but not managed; an
MTA-STS policy is published and served with an ACME-issued certificate for
its host (key files created mode 0600, not chmodded after the fact); and TLS
reports are published and ingested. Ingestion is keyed on the local part of
the recipient (dns.tls_rpt_local_part, default tlsrpt, blank disables), the
report is attributed to the domain it concerns, reports for domains that no
longer exist are discarded, and each report contributes at most 100 policies
with 100 failure details each.

SECURITY
The reset form no longer reveals whether an account exists, and reset
requests are throttled by address and period. Rate-limit keys are bounded so
arbitrary input cannot grow the counter store, with an optional shared redis
backend. Session policy is declared explicitly. OIDC account linking by email
requires the provider's verified claim. The API no longer echoes rejected
keys. Domain verification codes use CSPRNG values with timing-safe
comparison, and invitation acceptance requires the invited address. The test
message composer accepts only the fields its form submits. The session secret
flows through Rails' secret_key_base chain, and proxies are declared through
action_dispatch.trusted_proxies.

TOOLCHAIN
Rails 7.1.6 to 8.1.3.1 in staged steps with the framework defaults adopted
last, so the version change and the behaviour change are separable. Rack 3
arrives with 7.2. The container image is ruby:4.0.7-slim-trixie with Bundler
4. Default and bundled gems used by the code (cgi, timeout, securerandom,
base64, benchmark, logger, mutex_m, ostruct) are declared and required where
used; the tracking pixel reads in binary. Sprockets and Turbolinks are
retained. The json gem stays pinned below 3 because Active Support 8.1 still
passes quirks_mode.

AUDIT
Two defects would have shipped and were invisible to a suite which only ever
loads db/schema.rb: seven migrations could not run under Rails 8.1, so a
fresh installation could not be migrated; and the loaded schema differed from
the migrated one in nine datetime columns. Both are fixed and guarded by a
schema-equivalence spec. Also fixed: URL credentials were not percent-decoded
in the S3 and ClickHouse backends, MySQL and PostgreSQL dialects interpolated
unescaped literals into catalog queries, a TLS report could outlive its
domain, and the FoundationDB constant was unreachable under autoloading. Each
guard has been confirmed to fail when the fix it protects is removed.

COMPATIBILITY
protection.store_url is renamed protection.counter_store. PostgreSQL requires
a role with access to every schema the message database uses. Toolchain
requirements move to Ruby 4.0, Rails 8.1 and Rack 3.

VERIFICATION
bundle exec rspec: 1553 examples, 1 failure (a live-DNS spec resolving an
external name, failing identically on the base), 20 pending (optional-group
specs skipping without their services). bundle exec rubocop: 392 files, 20
offences, all pre-existing and autocorrectable. The message-database suite
passes against MySQL/MariaDB, PostgreSQL and SQLite; the optional image
target builds; the web interface was booted on Rails 8.1.3 / Rack 3 with the
proxy list and session secret resolving through Rails' own configuration.

LIMITATIONS
FoundationDB is implemented and spec-covered but never executed against a
cluster. SQLite engine attribution rests on the environment variable, not
direct observation. ACME issuance is verified against a stubbed directory
only. Telemetry documents only the protocols the client implements.
@c41ms0n
c41ms0n marked this pull request as draft September 24, 2026 13:45
YJIT ships compiled into the ruby:4.0.7 image and needs no native toolchain
at runtime. It stays off unless explicitly requested: the image maps
ARG YJIT=0 to RUBY_YJIT_ENABLE, so the default build and the default run
both leave it untouched. Enable it with --build-arg YJIT=1 or
-e RUBY_YJIT_ENABLE=1; only the value "1" enables it. The spec locks the
invariant: support present, off unless enabled, stats exposed when active.
Ships libjemalloc2 5.3.0-3 as an opt-in build variant (--build-arg
JEMALLOC=1, ~900KB) that preloads only when JEMALLOC_PROFILE is set at run
time (balanced or aggressive). The two profiles differ only in dirty/muzzy
decay cadence; thread-local caching stays on and arenas keep jemalloc
defaults, matching Ruby's small short-lived cross-thread allocation shape.

Two platform details are handled explicitly: the setcap-capped ruby binary
ignores LD_PRELOAD, so activation goes through /etc/ld.so.preload (verified
mapped); and max_background_threads is set explicitly because
background_thread alone spawns no decay thread. A bad profile or a missing
library aborts container start instead of running misconfigured. The spec
covers the profile mapping and the architecture-independent library lookup.
The 4.0.20 pin matches BUNDLED WITH in Gemfile.lock;
now a default that can be overridden instead of a hardcoded literal.
… vanilla ES2021

Migrates the client-side stack to the Rails 8 frontend defaults while keeping
the rendered UI byte-identical: no view logic, markup, class names, or helper
output change in this commit. The user-visible surface -- navigation, remote
forms and links, search-as-you-type, the throughput graph, the remember-me
banner, and the verification-code boxes -- behaves exactly as before.

JavaScript runtime
- Turbolinks 5 is replaced by turbo-rails 2. Layouts adopt data-turbo-track,
  turbo-cache-control, and data-turbo-permanent; download links opt out via
  data-turbo="false"; the progress-bar selector follows the new class name.
- jquery-rails and jquery-ujs are removed. A fetch-based remote adapter
  (elements/remote.js) implements the exact UJS attribute surface Rails still
  emits -- data-remote, data-method, data-confirm, data-disable-with -- and
  the JSON contract the controllers produce (redirect_to, alert, form_errors,
  flash, region_html). The one non-remote method link (logout) submits a
  hidden form with CSRF, matching rails-ujs semantics.
- All five CoffeeScript sources are rewritten as dependency-free ES2021
  (querySelector/classList/fetch/template literals), verified with
  node --check. The vendored jquery.multibox plugin becomes a framework-free
  Multibox class preserving filter, auto-advance, backspace, paste-spread,
  initial-value, and autofocus behavior.

Asset pipeline
- sassc/sass-rails is replaced by dartsass-rails. The nested entry point is
  mapped explicitly, Sprockets glob imports (unsupported by Dart Sass) are
  expanded to ordered explicit imports, and compiled output is git-ignored
  and rebuilt during precompile.
- uglifier is replaced by terser, which is required to parse the ES2021
  syntax above.

Compatibility notes
- Server-rendered data-remote/data-method attributes are unchanged, so no
  controller or view logic needed modification beyond Turbo attribute names.
- Chartist remains vendored and untouched; mail_graph.js only changes its
  mount hook from turbolinks:load to turbo:load.

Verification
- Production asset precompile exits 0; a live boot serves login 200 with the
  Turbo bundle (no jquery() calls present) and the compiled stylesheet 200.
- View/request specs covering the touched templates pass; the full suite and
  RuboCop results are unchanged from the parent commit.
…tric stores

Vendor and product names had leaked into identifiers whose contract is
generic, inviting reuse against a backend the name never promised. This
renames the identifiers to what they do. No behaviour changes, except where
the old naming hid a real limitation, which is now stated explicitly.

Process metrics
- HasPrometheusMetrics becomes HasMetrics, with register_counter,
  increment_counter, register_histogram, and observe_histogram. The SMTP
  server and client, the worker, and the dequeuer follow; their setup entry
  points become register_metrics and setup_metrics. The registry behind the
  health-server /metrics endpoint is unchanged.

Metric stores
- LiveStats::MySQL becomes LiveStats::Database: the SQL goes through the
  message database's own dialect, whatever the engine.
- LiveStats::Valkey becomes LiveStats::KeyValue: any store speaking the
  Redis protocol (Valkey, Redis, or another drop-in replacement).
- RateLimiter::Redis becomes RateLimiter::Shared: the scheme names the
  shared topology, and the protocol is what the code depends on.
- Both dispatchers accept the same schemes as before.

Read-back honesty
- Telemetry::Client#query now refuses non-prometheus schemes instead of
  posting PromQL to a backend which cannot answer it.
- The configuration descriptions state that only a Prometheus-compatible
  store backs the dashboard read-back and streaming; the influx and json
  schemes are write-only. Generated docs and PULL_REQUEST.md follow.
The Rails 8.1 upgrade left three pieces of the old stack in place: view
helpers removed from the framework, a time library the framework now
duplicates, and a DNS resolver which repeats work the network has already
answered. This removes all three. Rendered output is unchanged throughout;
only the delivery-time DNS path changes observable behaviour, and only by
asking the network less often.

VIEWS
form_for and form_tag were removed in Rails 8.1 and keep working only
through a compatibility shim. All 34 calls across 29 templates become
form_with, preserving the data-remote attributes the remote adapter keys
on. The new_framework_defaults_7_1/7_2 initializers contain only comments
under load_defaults 8.1 and cannot take effect; both files are deleted.

SPECS
Rails ships travel_to, so the timecop dependency, its require, and its
lockfile entries go away. ActiveSupport::Testing::TimeHelpers is included
globally so lib specs can use travel_to as well as request specs.

DNS
Every lookup opened a fresh resolver and queried the network, so a burst of
deliveries to one domain repeated the same MX, TXT and A/AAAA queries per
message -- the MTA-STS TXT id lookup most visibly, since it runs on every
delivery by design. Answers are now remembered per (name, type), honouring
the lowest record TTL clamped to 60s..1h; misses are remembered for 60s so
absent records are not re-queried per message either. The cache is
per-resolver-instance and mutex-guarded, a clear_cache! hook backs the
specs, and a remembered answer is returned whatever the timeout mode
because a cache hit is not a timeout. Five cache specs (hit, type
separation, TTL floor, expiry, negative caching) join the suite.

VERIFICATION
Full suite 1568 examples with only the known live-DNS failure (an external
name resolving in this network), 22 pending. RuboCop clean on all touched
files.
The spam-range slider carried four background layers per rule: a solid
colour, -moz- and -webkit- prefixed gradients for Firefox 3.6-15 and
Chrome 10-25, the standard gradient, and an IE6-9 DXImageTransform filter.
Autoprefixer already emits any prefix an evergreen browser still needs,
and the standard linear-gradient covers the rest, so every rule keeps
only its solid-colour fallback and the standard gradient. Rendered output
in supported browsers is unchanged; the Dart Sass build compiles clean.
…ection

Authentication brute force is already limited per client address, but
nothing limits what an authenticated credential can do: a stolen API key
or SMTP password sends without bound, password reset redemption can be
guessed at volume, and unauthenticated intake paths accept without limit.
This closes the use gap with named quotas on the existing limiter.

QUOTAS
Five quotas share one definition table and one check_quota entry point on
Postal::RateLimiter, reusing the counter store, fixed windows, and Retry-After
semantics: api_send and smtp_send per credential, smtp_ip_send per matched
SMTP-IP credential, reset_redeem per client address, and unauth_intake per
client address and kind. Every quota defaults to 0 (disabled) so behaviour
is unchanged until configured. Limits resolve per credential options first,
then the global pair: a single key can be tightened or loosened without
moving the ceiling for everyone.

STORAGE
Quota counters live in protection.quota_store, falling back to
protection.counter_store, through the existing store selector -- memory by
default, shared over the Redis protocol when configured. No new tables;
enforcement stays synchronous on the hot path while exceed events also emit
through Telemetry and Metrics without ever raising.

CALL SITES
The legacy API counts each send and read against the credential after
authentication, refusing with the existing RateLimited shape; the SMTP
server counts each queued message against the credential (SMTP or matched
SMTP-IP) and each bounce, route and TLS-report intake against the client
address, refusing with a temporary failure; password reset redemption is
throttled before the token is looked up, keeping the anti-oracle response.

VERIFICATION
Full suite 1581 examples with only the known live-DNS failure, 22 pending.
RuboCop clean on all touched files. Docs regenerated from the schema.
The unit suite drives SMTPServer::Client#handle directly and mocks the
outbound sender, so wire framing, EHLO capability parsing, DATA
termination, and the sender's session handling against a foreign peer
were never exercised. The storage backends were covered backend by
backend, but the failure and recovery behaviour -- dropped connections,
refused keys, full buffers, oversized bodies -- had no shared matrix.
This adds three integration specs that run in the default suite with no
external services beyond the standard test database and loopback sockets.

SMTP
A Postal-to-Postal loopback serves real SMTPServer::Client sessions on a
loopback port while Net::SMTP and SMTPClient::Endpoint drive delivery,
proving a message lands in the message database, unknown recipients are
refused without storing, pipelined commands batch, over-long lines are
rejected, and DATA ends only on a bare dot. A scripted non-Postal peer
covers the interop side: delivery the peer accepts, SIZE read from EHLO
with pre-transfer refusal of oversized messages, RCPT refusal mapped to
HardFail, and no-listener mapped to start returning false. The harnesses
mirror the real server loop's framing -- split on LF, keep the CR -- and
track sockets and workers so teardown is bounded rather than hanging on
blocked readers.

STORAGE
The matrix pins the paths the unit specs leave alone: message write and
read-back through the live test database, recovery from a dropped pooled
connection, oversized bodies split across chained rows, database live
statistics increment and total with unknown-scheme rejection, the
filesystem blob store round-trip with invalid-key refusal and builder
coverage for both the filesystem and inline schemes, the in-process rate
limiter counting to exceeded and back through clear, and the telemetry
publisher dropping the oldest sample when full and never raising when
the backend is down.

VERIFICATION
Storage matrix and mock-peer specs pass live against MariaDB over host
networking. The Postal-to-Postal loopback is verified statically
(ruby -c clean, every referenced API checked against the implementation)
and awaits one live run on a Docker-capable machine. Backends needing
servers beyond the test database (PostgreSQL, Valkey, S3, FoundationDB)
stay with their existing URL-gated unit specs; nothing here skips.

Two live-run corrections are folded in: the connection pool lives on the
Database class rather than its instances, and the live_stats type column
is varchar(20), so the check prefixes are shortened to fit it.

CAVEATS
The loopback harness disables transactional fixtures (worker threads share
the pool, so truncates instead) and the publisher specs stub long flush
intervals so no background thread outlives the example.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant