Skip to content

Release 0.8.3 - #128

Merged
JuansesDev merged 27 commits into
mainfrom
fix/production-hardening
Sep 28, 2026
Merged

JuansesDev merged 27 commits into
mainfrom
fix/production-hardening

Conversation

@JuansesDev

Copy link
Copy Markdown
Contributor

A patch release for what production is missing: three security fixes, the
functional defects behind them, and the documentation corrections that came out
of checking the guides against the code.

27 commits, 80 files.

Security

  • Headers could be injected through the subject. Subscriber-supplied text (name,
    email, custom fields) reaches the subject, and a line break in it added headers of
    the attacker's choosing to the message we sent. Carriage returns and line feeds are
    now stripped from every value that reaches a header.
  • Certificates are verified on outgoing SMTP, for implicit TLS and STARTTLS. A relay
    serving a self-signed or expired certificate keeps working per project under
    Project -> SMTP -> Allow insecure TLS, and the failure names that setting.
    This is the one change in the release that can stop an install from sending.
  • A webhook's signing secret can no longer be read back. It came out on every list,
    fetch and pause, and reading webhooks needs no capability, so a read-only viewer
    could sign deliveries of its own. It is returned by the creation response only.
  • The source-build compose no longer starts on a published Postgres password: it
    refuses to come up and names the missing variable.

Fixed

  • A restart no longer re-sends a broadcast another instance is delivering. Startup
    recovery reset every job in sending regardless of who owned it, so starting or
    rolling one instance re-queued work in flight and those recipients received the
    same email twice. A claimed job now carries the worker's id and a lease, and
    recovery only reclaims expired leases.
  • A log row no longer claims the send succeeded before it did, which the stats
    endpoint counted. Rows start as pending and settle to sent once the relay
    accepts.
  • Date rules no longer break a segment. greater than and less than compiled a
    date field to a numeric cast, so a rule that saved without complaint failed when
    the segment ran. A value that cannot be ordered is refused while saving.
  • Demoting the last workspace owner no longer leaves the workspace ownerless: the
    guard ran only for a role the database rejects.
  • A failed registration no longer leaves a half-created account behind, holding an
    email that could never be registered again. Registration, workspace creation and
    adding a member each write their rows in one transaction.
  • The member role is gone. The API accepted it and the previous changelog
    advertised it, but the database never allowed it, so assigning it failed with a
    500 after leaving an account behind.
  • Two editor fixes that missed the 0.8.2 image are in this one.

Docs

The API reference and the guides were checked against the handlers, the queries and
the schema. Invented status codes, fields, lifetimes and defaults the code does not
have were corrected, and the Mailpit and installation recipes now match what the
product does. Three older changelog entries that stated behaviour this release
proves false now point at 0.8.3 instead of reading as current.

CI

A backend, frontend, compose and hygiene workflow, so the suite runs on every pull
request. It was not running anywhere before this.

Verification

Every fix was exercised against a running instance: a two-replica setup sharing one
database with a relay that hangs and a catcher counting what actually arrives, a TLS
relay, an SMTP catcher reading raw headers, real HTTP requests against a real
Postgres, with and without the cloud build tag, plus the frontend suite and the
docs build.

Release note

The tag goes on the senddock-pro side, and the image is built from Core main —
so this has to land before that tag.

Nothing executed the test suite, so a regression could reach main with every
check green simply because there were no checks. This runs the backend build,
vet and tests — with CGO disabled to match the released image — plus the
frontend type-check, production build and vitest suite.

(cherry picked from commit 61c758c48c7d35b0339916d671c3193522e0895f)
… twice

Every startup reset jobs left in 'sending' regardless of who owned them, so
starting or rolling one instance re-queued jobs another instance was still
delivering, and those recipients received the same broadcast twice.

Jobs now carry the worker that claimed them and a lease. Startup recovery only
reclaims rows whose lease has expired, which leaves work in flight alone while
still recovering genuinely abandoned jobs — including rows written before the
lease existed.

Covered by unit tests for the lease window and by integration tests that run
against a real database when DATABASE_URL is set: one asserts that a live lease
survives recovery, one that expired and pre-lease rows are reclaimed, and one
that a claim stamps ownership and a deadline.

(cherry picked from commit 5fef4e68692e60223ff40008e10ad8b5d6a44b52)
The roadmap advertised work that was already implemented, which misleads both
contributors looking for something to pick up and users checking whether a
feature exists.

Fifteen items are now marked done, the analytics entries no longer claim to be
Pro (they moved to Core in v0.8.0), and the API key prefix, the webhook backoff
steps, the webhook event list and the licensing note are corrected.

A status note states that this file describes product direction, and that
active work and open bugs live in issues.

(cherry picked from commit a5a60bc1b760f3ea764e77ba2a81813bb91cdb40)
Subjects are built from subscriber-supplied text — their name, email and custom
fields are substituted into them — and that text went into the message headers
untouched. A value carrying a line break could close the Subject line and start
a header of its own, so a crafted value could add a Bcc to the message SendDock
sends. The unsubscribe URL had the same weakness.

Every value that reaches a header is now stripped of carriage returns and line
feeds, at the single point where headers are built, which also covers the sender
name and the recipient. Message assembly moved into buildMIME so the property is
testable without going near the network.

The documentation already claimed newlines were stripped. That is now true, and
the sentence states which values it covers.

(cherry picked from commit b8899b8cd9529c04bdeaa4923f753cd1960ef105)
Outgoing SMTP accepted any certificate, so an attacker positioned between the
instance and the relay could read the SMTP password and the mail itself while
the connection still looked encrypted. Verification is now on for both implicit
TLS and STARTTLS.

It is deliberately not unconditional: relays serving a self-signed or expired
certificate are common in self-hosted setups, so a project can opt out under
Project -> SMTP -> Allow insecure TLS. The opt-out is per project, which keeps
the exception scoped to the relay that needs it instead of weakening every
project on the instance, and the form says what it gives up.

A failed handshake now explains itself and names that setting, so the fix is a
click instead of a support thread.

(cherry picked from commit 5ed600f1a3740e7ab98c856ffe4e10701defb439)
…rejects

Two problems in the same code path.

The guard that refuses to remove the last workspace owner ran only when the
target role was `member` — a value the database has never allowed. Demoting an
owner to admin, developer or viewer therefore went through and left the
workspace with no owner, which no screen can undo. The guard now runs for every
target role except owner.

`member` itself is gone. The constraint has permitted only owner, admin,
developer and viewer since the table was created, so no row could ever carry it:
assigning it validated in the API, failed at the constraint and surfaced as a
500, after the account had already been created. Its capabilities were identical
to admin's and the dashboard never offered it. The API now rejects it as an
invalid role, and a constraint violation maps to a 400 rather than a 500.

(cherry picked from commit cfec47cea00af425c192498aa7897e37e133be3c)
Greater/less-than rules compiled a custom field to a numeric cast, but date
fields are stored as YYYY-MM-DD, so the cast failed the moment the segment ran:
the preview returned an error and a broadcast targeting it never sent, while the
segment itself saved without complaint.

The comparison now casts by the rule's value — dates as dates, numbers as
numbers — and a value that cannot be ordered is refused when the segment is
saved instead of when it is used.

Both paths are exercised against a real database, which is where the original
cast failed rather than in the query builder.

(cherry picked from commit 8656a529b4ebe8403462ca2396d9a88e4e789cf3)
The log row is written before the SMTP call so the tracking pixel and the click
links have an id to point at, and it was written as `sent` at that moment. Every
attempt therefore claimed a delivery before the relay had answered: an attempt
that crashed or hung stayed counted as delivered forever, and the stats endpoint
reported successes that never left the instance.

Rows now start as `pending` and settle to `sent` once the relay accepts the
message. The failed branch already overwrote the row, so the only rows that were
never corrected are the ones nobody could correct.

The status filter gained a Pending chip, and the docs and changelog describe the
state. Existing rows are left alone: an old `sent` row is indistinguishable from
a real one, and rewriting history to guess would be worse than the original bug.

(cherry picked from commit 3d6b7af5002eb358f3860d49fa84e7fc4d7eff18)
Any member of a workspace could read the secret of every webhook in the project.
Reads require no capability, so a read-only viewer could take the signing key and
sign deliveries of its own, and so could anyone holding a session. Listing a
project's webhooks returned the key of each one, and so did fetching a single
webhook and pausing it.

That is not what the product promised: the dashboard says the secret is shown
once, the API docs say the field comes back empty, and both describe the recovery
path as creating a new webhook. Only the code disagreed, so the code changed.

The secret now leaves the instance on the response that creates the webhook and
nowhere else. FromWebhook redacts it for every read path, and FromCreatedWebhook
is the single place allowed to put it back, so a new endpoint cannot reintroduce
the leak by reusing the common mapper.

Covered twice: the mapper's wire output, and the real handler over HTTP against a
migrated database, including the viewer role. With the redaction reverted, the
integration test prints the leaked response body.

(cherry picked from commit b6e44bdeac736756daed69415863dd7742eb00f4)
docker-compose.prod.yml, the source-build path, fell back to `senddock` wherever
POSTGRES_PASSWORD was used: in the app's connection string, in the database
service and in the migration step. A deployment that never set the variable
therefore came up with a password printed in this repository, while the docs
already told operators the compose fails fast without it. It now fails fast,
naming the variable, exactly as docker-compose.image.yml already did.

The same trap sat one step earlier in .env.production.example, where the two
required values were non-empty placeholders: `cp` plus a forgotten edit booted a
stack on a published secret and the `:?` guard could not see it. Both required
values now ship empty, so the compose stops and says which one is missing.

Leaving the development compose alone: it is the `make dev` file, it is not a
production path, and its Postgres is on a host port for local work.

CI gains the guard this bug never had. One step asserts each production compose
refuses to interpolate without the variable and does interpolate once it is set;
another asserts the example env ships no values for the two required keys.
Reintroducing either bug fails the build.

(cherry picked from commit 9084f5bc809e5137dad23e4888250ffe66f82225)
ROADMAP.md is a public file and the status note added when the roadmap was
reconciled was written in Spanish, so the repository mixed two languages in one
document. A reader who does not know Spanish cannot tell which half of the file is
current, and neither can review.

The content is unchanged; it now reads in the language the rest of the file, and
the rest of the public surface, is written in.

(cherry picked from commit bf4ae8621eddf4fca8c92ef6a22b29174a06ec16)
Two examples named a subscriber María and Sebastián while the rest of the
documentation and the import fixture use names like Ada Lovelace, John Doe and
Jane Smith. A reader cannot tell whether the accent is load-bearing — a hint that
non-ASCII survives the round trip — or just how the example was written, and the
answer changes what they expect from their own data.

The webhook payload now matches the name the subscribers API already uses for the
same example address.

(cherry picked from commit 6104da39acf6745262a54a85dc303fd8c6a1b864)
The roadmap carried a status note written in Spanish for a week. Nothing caught
it, because a mixed-language file reads fine to someone who knows both languages
and a reviewer skimming a diff has no reason to look for one.

scripts/check-language.sh fails on either signal: an accented word, which a stray
identifier almost never produces, or a run of Spanish words that English does not
use, which covers prose written without accents. Words English also uses (sin,
solo, hay), language codes and colliding identifiers are left out on purpose, and
a single hit is not enough to fail — a check that cries wolf gets switched off.
It runs locally and from CI, and reports the file and the words rather than a
count.

An accented proper noun is not the exception to add: sample data uses English
names like the rest of the repository.

(cherry picked from commit 72595dbc00896c09b96a8036b87948a9f123a878)
The script has to name the Spanish words it looks for, so once it became a tracked
file the check flagged its own word list and failed. It only passed when I ran it
before committing, which is exactly the window where the mistake is invisible.

The exemption is by exact path, and the run reports how many files it skipped so
the exception stays visible. A new file with Spanish still fails, and so does
Spanish added to any other script.

(cherry picked from commit 878f6f6c22826431c370314bc52fb82900c8cc71)
Registering wrote the account first and then its workspace and its membership, as
separate statements. A failure on either of the later writes left an account that
nobody could see or sign into, holding an email that could never be registered
again — and the caller was told registration had failed, so nothing pointed at the
row still sitting in the table.

Two more flows had the same shape. Creating a workspace wrote the workspace and
then its owner membership, so a failure left a workspace no member can list or
delete, and one that keeps its creator from being deleted at all. Adding a member
to an existing workspace wrote the account and then the membership, the case the
audit named.

Each of them now runs inside one transaction: the account and its workspace and
its membership all land, or none of them do. A first use for the WithTx that sqlc
generated and nothing called.

Covered by integration tests that make the second write fail on purpose — a
trigger, since no unique key can be made to collide without taking the decision
away from the code under test. With the transaction removed, all three report the
row that survived. The email constraint race now also reads as a taken email
rather than an internal error, since the lookup that guards creation is a courtesy
and the constraint is what actually holds.
The page described a product that does not exist in several places, and two of
them would cost a developer real time: it named a refresh token lifetime of seven
days instead of two hours, and told you that changing your password leaves other
sessions alive when it revokes every refresh token. The 2FA exchange used field
names the API has never sent, so a client copied from the page could not complete
a login, and the disable-2FA body was missing a required field.

The error table invented a 403 body and claimed API keys carry full project
access. Keys can exercise three capabilities, and the refusal for anything else
is a different message than the one for a role. The first-boot endpoints were
described with the wrong status codes and the wrong body — 201 with a message, and
403 rather than 409 once an account exists — and the CSV import that is offered
nowhere in the backend is gone.

Every claim was checked against the handler and service code before it was
written, not against the audit that reported it.
Four facts the pages got wrong. The bulk action answers 200 with a message rather
than 204, suppression timestamps are refreshed only when an address is added
again and never by a send, the 401 on the suppression routes is a missing session
cookie because those routes do not accept an API key at all, and access to the
subscriber endpoints follows workspace membership rather than project ownership,
with two documented exceptions.

The email-validation wording was also stricter than the code: a domain with an A
record and no MX passes, and only a domain that resolves to nothing is rejected.
Both the API page and the guide carried the overstated version, so both changed.
Five of these would have cost a developer a debugging session. The broadcast
response was documented with counters the endpoint never returns — it answers the
enqueue result, so `failed` is always 0 and the real progress lives in the
broadcast history. The log row was said to omit `opened_at` and `clicked_at`,
which it carries. The click detail listed an `ip_address` the query does not
select. The batch response claims `suppressed` is omitted at zero, when it is
always present. And the tracking pixel was documented without its `.gif`, which
is the form SendDock actually emits.

The suppression body was also narrower than the code: it is returned for a send
addressed with `to`, whether by template or raw HTML, so both are described now.
Broadcast status loses a `pending` value that no row can hold.

Checked against the handlers, the response structs and the SQL, and the pixel
path against a message captured on the wire during earlier work.
Four things that would have sent someone down the wrong path. The SDK page
promised a retry on every 5xx when it retries five statuses and treats the rest
as final, and it claimed the newsletter event arrived in v0.8.2 — the server
version — when it is SDK v0.2.0, so a reader would have looked for it in the wrong
release notes.

In the self-hosting guides, two commands named the wrong compose service: the
source build calls it `app` and the image build calls it `senddock`, and the pages
had them swapped, so the command a reader copies to debug a failed boot fails
instead. Another page told you to restart the backend after setting the public
URL, which the settings provider applies without a restart, and the rate limiter
exemption listed only `/health` when `/metrics` is registered beside it.
The reports page documented response shapes the engine never returns — a flat
`{dimension, rows}` and a `{row_dim, col_dim, cols}` pivot — when it answers
`{dataset, measure, dimensions, rows}` and `{..., pivot{columns, rows}}`, and it
listed a fraction of the measures that exist. It also described a `viz` field
that is accepted and never read, so a reader would set it and wonder why the
output did not change.

The deliverability page named invented metric keys (`hard`, `soft`, `bounce_pct`,
`spam_pct`) for a response that sends `hard_bounces`, `soft_bounces`,
`bounce_rate_pct` and `complaint_rate_pct`, listed four mailbox providers when
seven are recognised, and documented a check item as `record` when the code emits
`name`. Its error table was missing the `400` a project without a from address
gets.

The audit-log page called the entry id a ULID when the column is a UUID, listed
five target types when eleven are written, and promised a `404` for a project
that does not exist where the handler answers `403` — a project id that cannot be
parsed is the only `404`.

All three pages also claimed the caller must own the project. These endpoints
authorize workspace membership and check no role, which is what they say now.
The bounce pages documented two response fields that no handler sends and a
timestamp that does not exist, and they showed the bounce webhook answering with
a full URL when it returns a relative path you are meant to join to your instance.
One of the two 401 bodies was missing and the 400 for a malformed project id was
not documented at all, so whoever hit it had nothing to look up.

The analytics page called the window metrics `deliverability_pct` when the field
is `acceptance_pct`, said the series fields can be `null` when they are always
arrays, claimed the client cannot choose the bucket granularity when it can, and
described neither `range_days` nor the fact that `from` and `to` are optional —
they default to the last thirty days, and the page had been calling them required.
The webhook payload example told the reader `status` is omitted on
`subscriber.unsubscribed`, which it is not: that event carries it, and the two
events that genuinely do not are the unsubscribe performed from a link and the
newsletter-specific one. A reader filtering on the field would have written the
wrong branch.

"What is SendDock" promised per-project API keys for everything the dashboard
does, when five routes accept them — send, batch, broadcast, stats and import —
and the other eighty-eight are cookie-only. That claim is what makes someone
paste a key into a call that can never authenticate.

The onboarding guide described a column-mapping step in the CSV importer that
does not exist; the importer matches extra columns to custom fields by header.
The account page described the 2FA exchange with field names the API does not
send, said changing your password leaves other sessions alive when it revokes
every refresh token, and named a field the license status does not return. It
also presented a client-side confirmation as something the API checks.

The audit log page claimed entries are only recorded on a Pro plan, when the
license gates reading and never writing, listed a fraction of the recorded
actions, and promised login-attempt history that does not exist — the auth layer
keeps a short-lived lockout counter and nothing else. It also promised a
workspace trail visible in project logs and a filter by actor, neither of which
the shipped build has.

The environment page left `/metrics` out of the rate-limiter exemption,
documented `SENDDOCK_LICENSE_ENDPOINT` as if it did something on official images
where the build tag that reads it is absent, and said `DISPOSABLE_DOMAINS_FILE`
replaces the built-in list when it extends it.
Five of the seven language examples authenticated the subscriber listing with an
API key, and that endpoint is cookie-only: the snippet returned 401 for anyone who
copied it, in every language. They now send the session cookie, which in C# also
means leaving the JSON helper behind, since it cannot attach one.

Elsewhere the page crossed up raw-body APIs: `request.body()` is not how Express
exposes the bytes, FastAPI's version is async, and ASP.NET Core has no
`InputStream` — that was removed with the classic framework. It also omitted
`/stats` from the endpoints that accept an API key and pointed at `GET /broadcasts`
for tallies that an API key cannot read, and one error row sat outside the table
where Markdown renders it as plain text.
The last of the documentation pass, covering the guide pages and the four
reference sentences that sat in the API pages.

The tracking pixel was documented without its `.gif` in two more places, including
the diagram on the same page that had just been corrected — the pixel SendDock
writes carries the suffix and the handler strips it. Page templates were said to
run through a CSS inliner on render, which only happens on an email send;
suppression and campaign pages named their capabilities with a colon where the
code defines dots; the workspace reference described a password rule that omits
two of the three requirements, and dropped the Team-plan gate that removing
another member carries while keeping it for the owner guard.

The index and the projects reference claimed API keys work for everything and
that projects are returned to their owner, when five endpoints accept a key and
the query joins workspace membership.
A tool-specific entry in .gitignore makes a working directory look like part
of the project, and that one had been quietly collecting a local database
password and a JWT secret in plaintext. The directory is gone, so the rule
has nothing left to hide.
The release notes for the patch, written from what the branch actually contains:
the three security fixes, the defects that broke sending or account creation, and
the documentation pass, with the one behaviour change called out at the top of the
security section instead of buried in a bullet.

The transaction fix had lost its entry when the branch was cut and its changelog
conflicted, so the notes would have shipped a fix nobody could read about. The two
editor fixes merged after the 0.8.2 image are named here too, because 0.8.2's own
section describes them and that image does not contain them.

version.go moves to 0.8.3 so the tag has something to match.
@JuansesDev
JuansesDev merged commit 1956021 into main Sep 28, 2026
4 checks passed
@JuansesDev
JuansesDev deleted the fix/production-hardening branch September 28, 2026 05:08
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