Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4d4cc95
ci: run the backend and frontend suites on every push
JuansesDev Sep 9, 2026
a783c24
fix(broadcast): keep job ownership across restarts so nothing is sent…
JuansesDev Sep 9, 2026
fa07509
docs: reconcile the roadmap with what actually shipped
JuansesDev Sep 9, 2026
da4c80f
fix(email): stop header injection through the subject
JuansesDev Sep 10, 2026
b96bd31
fix(smtp): verify certificates on outgoing mail
JuansesDev Sep 11, 2026
cd47f68
fix(workspace): guard every demotion, and drop the role the database …
JuansesDev Sep 11, 2026
43fa9ae
fix(segments): compare date custom fields as dates
JuansesDev Sep 11, 2026
733c7f9
fix(email): record an unknown outcome as pending, not sent
JuansesDev Sep 12, 2026
da1b883
fix(webhooks): stop returning the signing secret on every read
JuansesDev Sep 12, 2026
292ac35
fix(compose): refuse to start without a database password
JuansesDev Sep 12, 2026
d943c7e
docs: write the roadmap status note in english
JuansesDev Sep 13, 2026
24e8dfa
docs: use english names in the sample data
JuansesDev Sep 13, 2026
9a0baa6
ci: check that everything tracked is in english
JuansesDev Sep 16, 2026
e668248
fix(ci): exempt the language check from itself
JuansesDev Sep 16, 2026
96c780e
fix(workspace): create an account and its workspace in one transaction
JuansesDev Sep 17, 2026
9e7ad72
docs(api): correct the authentication reference
JuansesDev Sep 17, 2026
ecd1c73
docs: correct the subscribers and suppressions references
JuansesDev Sep 18, 2026
6f2e9d4
docs(api): correct the sending reference
JuansesDev Sep 19, 2026
29a85b3
docs: correct the SDK page and the self-hosting steps
JuansesDev Sep 19, 2026
89dfe62
docs(api): correct the three Pro reference pages
JuansesDev Sep 20, 2026
5914612
docs(api): correct the bounces and analytics references
JuansesDev Sep 20, 2026
2b20633
docs(guide): correct the webhooks and onboarding pages
JuansesDev Sep 22, 2026
672ec69
docs(guide): correct the account, audit log and environment pages
JuansesDev Sep 24, 2026
39c536b
docs(api): fix examples that could not work as written
JuansesDev Sep 24, 2026
cbd8fc5
docs: correct the remaining guide and reference sentences
JuansesDev Sep 25, 2026
35f79b8
chore: drop the ignore rule for a directory that should not exist
JuansesDev Sep 26, 2026
e635202
release: 0.8.3
JuansesDev Sep 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
#
# Usage:
# cp .env.production.example .env
# # replace every "change-me-..." below
# # generate the two required values below
# docker compose -f docker-compose.prod.yml up -d --build
#
# The two required values ship empty on purpose and the compose refuses to start while
# either is missing, so a skipped step is a clear error rather than a stack running on a
# password published in this repository.

# === REQUIRED ===

# JWT signing secret (min 32 chars) — generate with: openssl rand -hex 32
JWT_SECRET=change-me-openssl-rand-hex-32
JWT_SECRET=

# Postgres password — generate with: openssl rand -base64 32
POSTGRES_PASSWORD=change-me-openssl-rand-base64-32
POSTGRES_PASSWORD=

# === OPTIONAL ===

Expand Down
112 changes: 112 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: CI

# Nothing ran the test suite before this workflow existed, so a regression could
# reach main with every check green simply because there were no checks. This runs
# the backend build, vet and tests, the frontend type-check, build and tests, the compose
# guards, and the language check.
#
# CGO is disabled to match the released image: the Dockerfile builds with
# CGO_ENABLED=0. The suite needs no database — the integration tests that use one skip
# themselves unless DATABASE_URL is set.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
backend:
runs-on: ubuntu-latest
env:
CGO_ENABLED: "0"
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum

- name: Build
working-directory: backend
run: go build ./...

- name: Vet
working-directory: backend
run: go vet ./...

- name: Test
working-directory: backend
run: go test ./...

frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install
working-directory: frontend
run: npm ci

# `npm run build` runs the type-check and the production build together.
- name: Type-check and build
working-directory: frontend
run: npm run build

- name: Test
working-directory: frontend
run: npm test

compose:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# A compose file that fills in a default for POSTGRES_PASSWORD starts a database
# with a password anyone can read, which is how docker-compose.prod.yml used to
# resolve its DSN to senddock:senddock. Each production compose has to refuse to
# interpolate without it and to interoperate once it is provided.
- name: The production composes require POSTGRES_PASSWORD
run: |
set -euo pipefail
export JWT_SECRET="$(head -c 48 /dev/urandom | base64)"
unset POSTGRES_PASSWORD

for file in docker-compose.prod.yml docker-compose.image.yml; do
if docker compose -f "$file" config >/dev/null 2>&1; then
echo "::error file=$file::$file resolved without POSTGRES_PASSWORD, so it carries a default someone can guess"
exit 1
fi
POSTGRES_PASSWORD=probe docker compose -f "$file" config >/dev/null
done

# The same trap one step earlier: an example env whose required values are
# placeholders boots a stack on a password published in the repository.
- name: The example env ships no secret
run: |
set -euo pipefail
for key in JWT_SECRET POSTGRES_PASSWORD; do
line="$(grep -E "^${key}=" .env.production.example || true)"
if [ "$line" != "${key}=" ]; then
echo "::error file=.env.production.example::$key must ship empty, found: ${line:-nothing}"
exit 1
fi
done

hygiene:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# The repository ships in English. A file mixing two languages is unreadable in
# review and cannot be translated later without guessing which half is current, so
# the rule is checked rather than trusted. It also runs locally:
# scripts/check-language.sh
- name: Everything tracked is in English
run: scripts/check-language.sh
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ Thumbs.db

# Logs
*.log
.claude/

# Internal / sensitive — never commit or publish
.internal/
Expand Down
41 changes: 38 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

Releases are also published on [GitHub](https://github.com/arkhe-systems/senddock/releases) and the `ghcr.io/arkhe-systems/senddock` image carries the matching tag.

## [0.8.3] — 2026-09-27

### Removed

- **The `member` workspace role.** The API accepted it and this changelog advertised it, but the database constraint never allowed it, so assigning it failed with a 500 after leaving an account behind. Its capability set was also identical to `admin`. The roles are `owner`, `admin`, `developer` and `viewer`, which is what the database has permitted all along.

### Fixed

- **A failed registration no longer leaves a half-created account behind.** Registering wrote the account first and its workspace afterwards, as separate statements, so a failure on the second write left an account that nobody could see or sign into — while holding an email that could never be registered again. The same shape sat in the paths that create a workspace (a workspace with no owner is invisible to everyone and cannot be deleted, and it blocks deleting its creator) and in the flow that adds a member to a workspace. Each of them now writes its rows in one transaction: either the account and its workspace and its membership all land, or none of them do.

- **A log row no longer claims the send succeeded before it did.** The row is written before the SMTP call so the tracking pixel and the click links have an id to point at, and it was recorded as `sent` at that moment. A crash or a hung attempt therefore left a row that claimed a delivery which never happened, and the stats endpoint counted it. Rows now start as `pending` and settle to `sent` once the relay accepts the message.

- **Date rules no longer break a segment.** `greater than` and `less than` compiled a custom field to a numeric cast, but date fields are stored as `YYYY-MM-DD`, so a rule that saved without complaint failed when the segment actually ran: the preview errored and any broadcast targeting it never sent. The comparison now casts to match the value — dates compare as dates, numbers as numbers — and a value that cannot be ordered is refused while the segment is being saved rather than when it is used.

- **Demoting the last workspace owner no longer leaves the workspace ownerless.** The guard that refuses to remove the last owner ran only when the target role was `member` — a value the database rejects — so demoting an owner to `admin`, `developer` or `viewer` went through and left the workspace with no owner, unrecoverable from the UI. The guard now runs for every target role except `owner`.

- **A restart no longer re-sends a broadcast another instance is delivering.** Broadcast jobs left in `sending` were reset to `retry` on every startup regardless of who owned them, so starting or rolling one instance re-queued jobs another instance was still sending, and those recipients received the same email twice. A claimed job now carries the worker's id and a lease, and startup recovery only reclaims jobs whose lease has expired — work in flight is left alone.

- **The documentation was corrected against the product.** The API reference and the guides described fields, status codes, response shapes, defaults and permissions that the code does not have: an invented `403` body, a refresh token lifetime of seven days, a retry on every `5xx`, a ULID where the id is a UUID, `deliverability_pct` where the field is `acceptance_pct`, an `ip_address` in the click detail that is never returned, a `status` claimed absent from a webhook payload that carries it, and an import that offers to map CSV columns which are matched automatically. Each correction was checked against the handler, the query or the schema it describes.

- **Two editor fixes that missed the 0.8.2 image.** The visual canvas mirroring the template's `<style>`, and the editor destroying a template when switching between the code and visual tabs, were merged after 0.8.2 was built from its tag. They are in this image.

### Security

- **The source-build compose no longer starts on a published Postgres password.** `docker-compose.prod.yml` fell back to `senddock` when `POSTGRES_PASSWORD` was unset — in the app's connection string, in the database service and in the migration step — so a deployment that skipped the variable came up matching a password printed in this repository. It now refuses to start and names the variable, like the image compose already did. `.env.production.example` ships the two required values empty for the same reason: a placeholder that is copied and forgotten is a secret everyone can read, and the stack now stops instead of accepting it.

- **A webhook's signing secret can no longer be read back.** Listing, fetching or pausing a webhook returned its secret, and reading webhooks needs no capability at all, so every member of the workspace — a read-only viewer included — could take the signing key and sign deliveries of its own. The dashboard meanwhile told the user the secret was shown once, and the API docs said the field came back empty. It is now returned by the creation response only. A secret that is lost is replaced by creating a new webhook, which is the recovery path both documents already described.

- **Headers could be injected through the subject.** Subjects are built from subscriber-supplied text — their name, email and custom fields are substituted into them — and that text was written into the message headers as-is. A value containing a line break could therefore add headers of the attacker's choosing, a `Bcc` for example, to the message SendDock sent. Carriage returns and line feeds are now stripped from every value that reaches a header: the subject, the sender name, the recipient and the unsubscribe URL.
- **Certificates are verified on outgoing SMTP.** Every connection accepted any certificate, so an attacker able to sit between the instance and the relay could read SMTP credentials and the mail itself while the connection still looked encrypted. Verification is now on for both implicit TLS and STARTTLS. A relay with a self-signed or expired certificate can be kept working per project under **Project → SMTP → Allow insecure TLS**, and the failure message names that setting so the fix is one click away.

> **Upgrade note — outgoing mail and certificates.** This is the only change in this release that can stop an install from sending. Certificates are verified now; a relay serving a self-signed or expired one fails the handshake until **Allow insecure TLS** is enabled for that project under **Project → SMTP**. Every other fix here is invisible until something goes wrong.



## [0.8.2] — 2026-09-01

Audience & white-label: multiple newsletters per project with per-newsletter unsubscribe, and branded unsubscribe pages.
Expand Down Expand Up @@ -41,7 +76,7 @@ Production hardening. Making SendDock honest to run at scale, keep, and observe.

### Added

- **Safe multi-replica operation.** The IMAP bounce poller is now **leader-elected** with a Postgres session advisory lock, so only one replica polls each mailbox at a time. The scheduled-campaign worker, broadcast queue and webhook delivery were already coordinated (atomic claim / `FOR UPDATE SKIP LOCKED`) — the poller was the last unguarded loop. Running more than one instance no longer double-processes bounces. The container entrypoint also **retries migrations** instead of dying when two replicas race `goose up` against the same database.
- **Safe multi-replica operation.** The IMAP bounce poller is now **leader-elected** with a Postgres session advisory lock, so only one replica polls each mailbox at a time. The scheduled-campaign worker, broadcast queue and webhook delivery were already coordinated (atomic claim / `FOR UPDATE SKIP LOCKED`) — the poller was the last unguarded loop. _The claim was coordinated, but startup recovery was not: see [0.8.3](#083--2026-09-27)._ Running more than one instance no longer double-processes bounces. The container entrypoint also **retries migrations** instead of dying when two replicas race `goose up` against the same database.
- **Observability for the send pipeline.** Logs are now **structured JSON** (`slog`), and a Prometheus **`GET /metrics`** endpoint exposes send attempts/sends/failures, SMTP errors by 4xx/5xx class, broadcast queue depth, webhook delivery outcomes, bounce/complaint ingest and poller tick duration. `/metrics` is unauthenticated by convention — scope it at the network layer. See [Monitoring](https://docs.senddock.dev/self-hosting/monitoring).

### Docs
Expand Down Expand Up @@ -221,7 +256,7 @@ Hotfix on top of [0.6.4](#064--2026-05-08). No new features, no DB migrations

### Added

- **Per-recipient broadcast queue with retries (#41).** Broadcasts and scheduled campaigns no longer run in a single goroutine that loses everything on restart. Each recipient is enqueued as a row in a new `broadcast_jobs` table; five worker goroutines drain it concurrently using `SELECT … FOR UPDATE SKIP LOCKED`. Transient SMTP errors (4xx, network, DNS) reschedule the job with exponential backoff (30s, 2m, 8m, 30m, 1h), capping at five attempts before the recipient is marked `failed`. SMTP 5xx bounces are not retried — they are tagged `bounced` and the recipient is added to the suppression list. A backend restart mid-broadcast resets jobs that were in `sending` to `retry` so sending resumes from where it left off, with no recipient sent to twice and none silently dropped.
- **Per-recipient broadcast queue with retries (#41).** Broadcasts and scheduled campaigns no longer run in a single goroutine that loses everything on restart. Each recipient is enqueued as a row in a new `broadcast_jobs` table; five worker goroutines drain it concurrently using `SELECT … FOR UPDATE SKIP LOCKED`. Transient SMTP errors (4xx, network, DNS) reschedule the job with exponential backoff (30s, 2m, 8m, 30m, 1h), capping at five attempts before the recipient is marked `failed`. SMTP 5xx bounces are not retried — they are tagged `bounced` and the recipient is added to the suppression list. A backend restart mid-broadcast resets jobs that were in `sending` to `retry` so sending resumes from where it left off, with no recipient sent to twice and none silently dropped. _Ownership of a claimed job was not part of that reset until [0.8.3](#083--2026-09-27): a restart also re-queued jobs another instance was still delivering, and those recipients received the same email twice._
- **Live progress for newsletters and broadcasts.** The Newsletters list shows `sent_count` / `failed_count` updating every five seconds while a campaign is in `sending`, instead of jumping from `0/0` to the final number at the end. Same goes for the Broadcasts tab progress bars.
- **"Broadcasts in flight" panel on Pro Analytics (#41).** When at least one broadcast is actively sending, a card appears at the top of the Analytics dashboard with a live progress bar per broadcast, elapsed time, and per-status counters. Polls every five seconds and disappears as soon as the queue drains.
- **Email Logs filters and CSV export (#44).** Status filter is now a row of clickable chips (Sent / Failed / Bounced / Suppressed) with status-tinted highlight. Added a Template dropdown to filter logs to a single template's sends. New `GET /api/v1/projects/{id}/logs/export.csv` endpoint and **Export CSV** button (top right of Logs) downloads every row matching the active filters — no `limit`/`offset`. Columns: id, recipient, subject, status, error, sent_at, opened_at, clicked_at, template_id, subscriber_id.
Expand Down Expand Up @@ -278,7 +313,7 @@ The "team launch" release. Workspaces with members and roles, a new Team plan ab
### Added

- **Workspaces.** A workspace is a container for projects with its own member list. Authorization for project endpoints now scopes by workspace membership; existing single-user installs are migrated transparently (every user got a `My Workspace` with their projects under it). Workspace CRUD, listing your own workspaces and listing members stay free in Core.
- **Roles & capabilities.** Four roles — `owner`, `admin`, `developer`, `viewer` — with a fixed capability matrix enforced at the handler level. Developers can `POST /send` (transactional) but not broadcast or edit templates; viewers are read-only; admins do everything except member management; owners do everything. The legacy `member` role is kept for backward compatibility (same access as `admin`).
- **Roles & capabilities.** Four roles — `owner`, `admin`, `developer`, `viewer` — with a fixed capability matrix enforced at the handler level. Developers can `POST /send` (transactional) but not broadcast or edit templates; viewers are read-only; admins do everything except member management; owners do everything. The legacy `member` role is kept for backward compatibility (same access as `admin`). _It was removed in [0.8.3](#083--2026-09-27): the database never allowed it, so assigning it failed with a 500 after leaving an account behind._
- **Team plan tier.** Multi-member workspaces, role management, and the new admin "Create user" endpoint (`POST /workspaces/{id}/users`) belong to a new **Team** plan above Pro. Without a Team-entitled `SENDDOCK_LICENSE_KEY` the endpoints return `402 Payment Required` and the Members page renders a paywall — the single-user flow stays free.
- **In-app Subscribe links.** Pro and Team paywalls now drive directly to the Lemon Squeezy checkout for the right tier instead of bouncing the user back to the landing page. Pro's CTA pre-applies the `LAUNCH3FREE` launch coupon while it is active.
- **Pro vs Team gating in the validator.** The Lemon Squeezy validator now classifies licenses by `variant_id` and returns the right tier in `Status.Tier`. `AllowsFeature(feature)` consults a feature → tier matrix so a Pro key cannot unlock Team features.
Expand Down
Loading
Loading