Repository navigation
Release 0.8.3 - #128
Merged
Merged
Release 0.8.3#128
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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.
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.
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.
refuses to come up and names the missing variable.
Fixed
recovery reset every job in
sendingregardless of who owned it, so starting orrolling 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.
endpoint counted. Rows start as
pendingand settle tosentonce the relayaccepts.
greater thanandless thancompiled adate 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.
guard ran only for a role the database rejects.
email that could never be registered again. Registration, workspace creation and
adding a member each write their rows in one transaction.
memberrole is gone. The API accepted it and the previous changelogadvertised it, but the database never allowed it, so assigning it failed with a
500 after leaving an account behind.
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
cloudbuild tag, plus the frontend suite and thedocs build.
Release note
The tag goes on the
senddock-proside, and the image is built from Coremain—so this has to land before that tag.