diff --git a/.claude/skills/wave-run/SKILL.md b/.claude/skills/wave-run/SKILL.md index 6be5e6ec..078f82c7 100644 --- a/.claude/skills/wave-run/SKILL.md +++ b/.claude/skills/wave-run/SKILL.md @@ -62,10 +62,10 @@ subagents, whose context is thrown away. branch>` (the only git command it may run). Merge each finished worktree by committing there and cherry-picking onto the wave branch; resolve shared files (a registry, an allowlist) by script, and conflicts in code by hand. - A worktree has no `cmd/web/node_modules` and no built `cmd/web/dist` (both untracked), and the E2E tests - serve `cmd/web/dist`: a task that doesn't change assets links the main checkout's - (`ln -s /home/user/pcom/cmd/web/dist cmd/web/dist`); one that does links `node_modules` the same way and - builds once. Parallel worktrees share golangci-lint's lock: "parallel golangci-lint is running" means + A worktree has no `cmd/web/node_modules` and no built `cmd/web/dist` (both untracked). The E2E tests + serve stub assets and need neither; only the browser tests (`make test-ui`) need a build. A task that + runs them links `node_modules` from the main checkout and lets `make test-ui` build. Don't link the main + checkout's `dist`: it exists only after a `make test-ui` there, and a dangling link breaks E2E. Parallel worktrees share golangci-lint's lock: "parallel golangci-lint is running" means retry, not a lint failure. Delete finished worktrees (`git worktree remove`) once merged; their copies of the tree show up in every repository-wide grep. With a worktree of its own, an agent may run `make test-ui` freely. After merging parallel tasks, run the diff --git a/.env.example b/.env.example index 74bcc261..e47cb757 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,7 @@ HSTS=false STATIC_CACHE=false MEDIA_PERMA_CACHE=false SHOW_ERRORS=true +DEV_ROUTES=true REPORT_PANICS=false LOG_LEVEL=debug diff --git a/AGENTS.md b/AGENTS.md index 46d5162a..ac77deca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ the one for the area you touch: | How pcom behaves on purpose: accounts, login, visibility, feeds, comments | `docs/product.md` | | Layers: handlers, services, repositories, service errors, the arch test; configuration and tooling | `docs/architecture.md` | | Markdown rendering, view types, custom renderers | `pkg/markdown/AGENTS.md` | +| Adding or changing a mail: inputs, samples, templates, goldens | package doc of `pkg/mail/mail.go`, item 6 in `docs/testing.md` | | RSS feed fetching and image budgets | `pkg/feedops/AGENTS.md` | | Writing tests: libraries, test DB, factories, mocks, E2E and browser tests, ground rules | `docs/testing.md` | | User guide, website, screenshots | `website/`, `docs/guide/` | @@ -44,7 +45,8 @@ longer holds rather than annotating it. Wave names, dates and incident stories b **No magic numbers.** A limit or tunable value (a length cap, a page size, a timeout) is a setting in `pkg/config` with its default there, handed to the service as an option; the service exports the same default for callers built without configuration, and tests use that constant, not the literal. Pattern: -`Limits.ProfileAboutMaxLength` → `accounts.WithProfileAboutMaxLength`. +`Limits.ProfileAboutMaxLength` → `accounts.WithProfileAboutMaxLength`. A new setting also appears in +`web serve --help`, whose golden the same diff rewrites (`UPDATE_GOLDEN=1 go test ./cmd/web -run TestHelp`). ## Reading code economically @@ -91,4 +93,5 @@ for callers built without configuration, and tests use that constant, not the li the report prints, or re-run the single test with `-v`. - Golden files: after `UPDATE_GOLDEN=1`, check `git diff --stat`, not the contents. - Pipe unavoidable noisy commands (`docker compose`, `yarn`, `gh run view`) through `tail -n 40` or `grep`. + Never pipe a quiet target: the pipe takes `tail`'s exit status, so a failure no longer stops a `&&` chain. - Don't paste code or logs into reports to a coordinator. diff --git a/cmd/web/client/html/dev--mail-index.html b/cmd/web/client/html/dev--mail-index.html new file mode 100644 index 00000000..970354ab --- /dev/null +++ b/cmd/web/client/html/dev--mail-index.html @@ -0,0 +1,24 @@ + + + + + + Mail previews + + + +
+

Mail previews

+ {{ range .Mails }} +

{{ .Name }}

+ + {{ else }} +

No mails are registered.

+ {{ end }} +
+ + diff --git a/cmd/web/client/html/dev--mail-sample.html b/cmd/web/client/html/dev--mail-sample.html new file mode 100644 index 00000000..bf503a3d --- /dev/null +++ b/cmd/web/client/html/dev--mail-sample.html @@ -0,0 +1,26 @@ + + + + + + {{ .Sample }} - mail preview + + + +
+

All mails

+

{{ .Sample }}

+

Mail: {{ .Mail }}

+ {{ if .Err }} +

The sample failed to render: {{ .Err }}

+ {{ else }} +

Subject

+

{{ .Subject }}

+

Text

+
{{ .Text }}
+

HTML

+ + {{ end }} +
+ + diff --git a/cmd/web/serve.go b/cmd/web/serve.go index de821251..8c621886 100644 --- a/cmd/web/serve.go +++ b/cmd/web/serve.go @@ -44,7 +44,7 @@ func (c *serveCmd) Execute([]string) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - dbSender := dbsender.NewSender(repo.New(db), mailjet.NewSenderFromConfig(&cfg.Mail.Mailjet)) + dbSender := dbsender.NewSender(repo.New(db), mailjet.NewSenderFromConfig(&cfg.Mail.Mailjet), dbsender.WithRetryIntervals(cfg.Mail.RetryIntervals)) go dbSender.RunPoller(ctx, cfg.Mail.PollInterval) @@ -105,6 +105,7 @@ func appConfig(cfg config.Serve, staticAsset app.StaticAssetFunc) app.Config { SecureCookies: cfg.Web.SecureCookies.On(), HSTS: cfg.Web.HSTS.On(), StaticCache: cfg.Web.StaticCache.On(), + DevRoutes: cfg.Web.DevRoutes.On(), ShowErrors: cfg.Web.ShowErrors.On(), ReportPanics: cfg.Web.ReportPanics.On(), ProfileAboutMaxLength: cfg.Limits.ProfileAboutMaxLength, diff --git a/cmd/web/testdata/serve_help.golden b/cmd/web/testdata/serve_help.golden index 98a71d0b..58d1888d 100644 --- a/cmd/web/testdata/serve_help.golden +++ b/cmd/web/testdata/serve_help.golden @@ -55,6 +55,9 @@ Help Options: [$LOG_LEVEL] --enable-pprof= Serve net/http/pprof on :8081 (default: false) [$ENABLE_PPROF] + --dev-routes= Serve the /dev pages (mail + previews); local development only + (default: false) [$DEV_ROUTES] Mail: --sender-address= From address of every mail @@ -65,6 +68,11 @@ Help Options: --email-poll-interval= How often the mail queue is sent (default: 10s) [$EMAIL_POLL_INTERVAL] + --email-retry-intervals= Waits between attempts to send a + mail; a mail fails after one + attempt more than there are waits + (default: 10s, 60s, 30m) + [$EMAIL_RETRY_INTERVALS] Mailjet: --mj-api-key-public= Mailjet API key public diff --git a/docker-compose.yml b/docker-compose.yml index 9a091be7..8f7f1fea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,6 +131,7 @@ services: STATIC_CACHE: "false" MEDIA_PERMA_CACHE: "false" SHOW_ERRORS: "true" + DEV_ROUTES: "true" REPORT_PANICS: "false" LOG_LEVEL: debug PCOM_WATCH_POLL: ${PCOM_WATCH_POLL:-} diff --git a/docs/architecture.md b/docs/architecture.md index 84df2b71..8f81bcf3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,6 +27,17 @@ Pure packages stay where they are (`pkg/markdown`, `pkg/links`, `sender.Sender`. The queue drops a repeated (type, unique id), so a feature that notifies again about the same object needs its own key, and its test sends two events in a row. +- **Mail content** is declared in `pkg/mail`, one file per mail: a typed input, + samples built from in-memory models, and templates in `pkg/mail/templates` + (subject and text through `text/template`, HTML through `html/template`). + Escaping is `html/template`'s job; a `template.HTML` field is the one way + trusted HTML (a rendered markdown body) enters. `mail.All()` is the only + list of mails: the golden test and the `/dev/mail` preview read it. The + package doc of `pkg/mail/mail.go` is the how-to for adding a mail. +- **Delivery.** `dbsender` sends each due mail in its own transaction, so one + failure doesn't resend the others; the waits between attempts are + `--email-retry-intervals`, and a mail fails after one attempt more than + there are waits. - **Errors** (`pkg/service`, which re-exports gogo's `apperr`; code uses the `service` names): `ErrNotFound` (also for "exists, but you may not see it"), `ErrForbidden`, `ErrNeedsLogin`, `ErrConflict`, and diff --git a/docs/archive/history/r4.md b/docs/archive/history/r4.md new file mode 100644 index 00000000..46581866 --- /dev/null +++ b/docs/archive/history/r4.md @@ -0,0 +1,79 @@ +# R4 — Mailers (done 2026-10-07, branch `claude/zealous-shannon-ieo14d`) + +**Built.** All 14 mails are declared in `pkg/mail`, one file per mail: a typed input with a `Header()` +method, samples built from in-memory models, and three embedded templates (subject and text through +`text/template`, HTML through `html/template`), parsed at init. `declare(...)` registers each mail, so adding +one touches no shared list, and `mail.All()` returns them sorted by name. `TestMails_Goldens` renders every +sample into `testdata/.golden`, and each constructor has a `_MatchesSample` test against its +sample. `Outgoing` was folded into `mail.Envelope`. The five admin notifications moved from `pkg/admin` +into `pkg/mail` as `Admin…`; `pkg/admin` keeps only `ClonedCustomRecovery`, whose report the router passes +to `AdminPageFailure` so `pkg/mail` doesn't import gin. The `/dev/mail` preview lists every sample and +renders its subject, text and sandboxed HTML. It is mounted only with `--dev-routes`/`DEV_ROUTES`, which +is off by default and on in `docker-compose.yml` and `.env.example`. `dbsender` now sends each due mail in +its own transaction (`ListDueEmailIDs`, then `LockDueEmail ... FOR UPDATE SKIP LOCKED` per mail), and +its retry schedule is `--email-retry-intervals` (default `10s,60s,30m`, the same four attempts as before). + +**Decisions.** +- **Whitespace may change; content may not.** Every pre-existing golden differed only in whitespace when + it moved to templates (`git diff -w` empty), including the admin goldens, compared by hand with the old + text/html pairs. Two later changes were deliberate: samples use `SampleSite` with root + `https://pcom.test`, so links became absolute, and the post-prompt-answer sample's post id became + `post-2`, so it can't be confused with the prompt's post id. +- **The preview is behind a flag, not an admin route.** The owner chose a dev-only flag that production + never sets over an admin page or a CLI that writes files. +- **No job queue in R4.** Mail already entered `outgoing_emails` in the same transaction as its change, so + a queue would mostly help the feed poller. It became its own wave, R8, which took over open question Q9. + R4 fixed only the sender's real defect: one transaction for the whole batch, so a failed update or + commit resent every mail already sent. +- **Constructors kept their signatures**, so the services didn't change. The exceptions were the + `Envelope` rename and the admin mails' move. The latter dropped an unused `site` parameter from two + admin constructors. + +**What went wrong.** +- S0 added two settings and broke `cmd/web`'s `serve --help` golden; M0 reported it. AGENTS.md now says a + new setting rewrites that golden in the same diff. +- The worktree recipe linked the main checkout's `cmd/web/dist`, which didn't exist, and the dangling link + broke E2E in every worktree. Each agent worked around it. E2E serves stub assets and needs no build; the + `wave-run` skill now says so. +- The coordinator piped `make lint-q` through `tail` in an `&&` chain, so a lint failure (the shared lock) + didn't stop the commit and cherry-pick. A later `make check-q` passed. AGENTS.md now forbids piping a + quiet target. +- Three parallel tasks each wrote the same `sampleEnvelope` test helper; the clash at merge moved it into + `golden_test.go`. +- The assertion audit found the comment mails' samples built through the constructors' own mapper, which + made their `_MatchesSample` test circular. It also found an empty `SampleSite` that let a constructor + ignore its site, the same id for a post and a prompt's post, a send-error test that exercised no mail + code (it became a rollback test in the posts service), and redundant admin asserts. All were fixed, each + proven by a mutation; the rule is now item 6 in `docs/testing.md`. Two pre-existing weak tests in + `pkg/admin/panics_test.go` became #218. +- `UPDATE_GOLDEN=1` doesn't create a missing golden, so deleting the goldens to find orphans told nothing. + `docs/testing.md` says so. +- In this sandbox, three browser tests fail as documented (older Chromium, no outbound network); CI's + `browser` job decides them. + +**Left out.** The job queue (R8). No `pkg/mail/AGENTS.md`: the package doc of `mail.go` holds the +conventions, and the AGENTS.md table points there. The branch is the session's designated branch rather +than `refactor/r4-mailers`, because the session could push nowhere else. + +**Docs and skills changed.** +- `docs/architecture.md`: mail content (declaration, escaping, registry) and delivery (one transaction per + mail, the retry setting). +- `docs/testing.md`: item 6 rewritten as the rule for mail goldens and constructor tests. +- `docs/running.md`: the `/dev/mail` preview and `DEV_ROUTES`. +- `docs/gogo-extraction.md`: the mail definitions now live in `pkg/mail/mail.go`; the queue entry points + at R8. +- `AGENTS.md`: a table row for mails, the serve-help golden on new settings, never piping a quiet target. +- `wave-run` skill: worktrees need no `dist` for E2E; don't link the main checkout's. +- Plan: R8 added (and Q9 moved into it), R4's line dropped from the index, R7 no longer mentions R4. + +**Cost.** +- **Coordinator:** 1 session, 112 turns, peak 236k context, 135k of tool results; flags: `cat`×6. +- **Subagents:** 9 (M0 strong; M1 cheap; M2–M6 and the audit fixes mid; the audit strong), 204 turns and + 548k of tool results between them. None needed a retry or escalation. The heaviest took 85k of results + (M4). +- **Across the wave:** `cat`×42, raw builds×1. +- **Skills:** `wave-run`, `wave-close` and `test-failure` once each in the coordinator. No subagent used a + skill, including `frontend-htmx`, which M5's prompt named. +- **Compared with R5** (228 turns, peak 363k): the coordinator used half the turns and a third less peak + context. The contract task went to a subagent instead of the coordinator, and verification relied on + mutation checks and one audit rather than reading tests. diff --git a/docs/gogo-extraction.md b/docs/gogo-extraction.md index 28a52188..61668155 100644 --- a/docs/gogo-extraction.md +++ b/docs/gogo-extraction.md @@ -18,7 +18,7 @@ Roughly ordered by payoff: | Image media server | `pkg/media/server` (+ `storage/s3`) | libvips resizing by named class, caching server, concurrency limiter, S3 storage behind `MediaStorage`. Self-contained apart from `media.ErrNotFound`. **Highest value.** | | Goldmark extensions | `pkg/markdown/mdext/*` | linkrenderer (target/rel by view), lazyload, headershift, videoembed (YouTube), blocktags (gallery). They depend on `types.HTMLView` and `types.Replacer`, which would need to become generic options. | | CSP nonce middleware | `pkg/util/ginhelpers/csp` | Parametrize the source lists (today they're built from env at init). | -| Mail definitions and preview | R4 (planned) | Typed definitions with sample factories, an `html/template` layout, golden helpers and a preview handler. Design it so it imports nothing from pcom, and extract it right after R4. | +| Mail definitions and preview | `pkg/mail/mail.go`, `pkg/web/app/routes_dev.go` | Typed definitions with samples, embedded `text/template`/`html/template` parts, a registry, and the `/dev/mail` preview. `mail.go` depends on pcom only through `links.Site` in `SampleSite`. | | Webpack/asset manifest loader | `LoadStaticManifest` in `pkg/web/app/funcmap.go` | `static_asset` funcmap with CDN prefix. | | Template helpers | `toMap` in `pkg/web/app/funcmap.go`, `renderHumanTime` (`pkg/util/date`) | A small `gogo/tmplfuncs`. | | Postgres session store | `pkg/pgsession` | Wraps `antonlindstrom/pgstore` (stale) for gin-contrib/sessions, plus user-in-context. Replace pgstore when extracting. | @@ -29,6 +29,6 @@ Roughly ordered by payoff: | Timezone list | `pkg/util/tz.go` | IANA zones for settings dropdowns. | | JSON action helper | `pkg/web/app` | `jsonAction[T]`: bind, call, `{explanation}` on error. Pairs with gogo's `API` renderer. | | RSS fetcher | `pkg/feedops/reader` | Size and time-limited fetch with MIME validation. Maybe; it's pcom-specific today. | -| DB-backed mail queue | `pkg/mail/sender/dbsender` | **Don't extract as is.** R4 considers replacing it with a general job queue with a transactional outbox. Extract that instead, if it happens. | +| DB-backed mail queue | `pkg/mail/sender/dbsender` | **Don't extract as is.** R8 plans to replace it with a general job queue; extract that instead, if it happens. | | Boolean switch setting | `pkg/config.Switch` | A go-flags boolean that can default to true (`--x`, `--x=false`, `X=false`; empty is an error). Belongs next to `settings.Secret` as `settings.Switch`. | | tommy test container | `pkg/testutil/tommy` | One tommy container per test binary (Mailjet API, S3 bucket, read-back API URLs). Belongs next to `testcontainers/postgres` as `testcontainers/tommy`. | diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 230dffd5..e745dc7e 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -10,7 +10,7 @@ support read directly. Rules for reading and verifying code cheaply are in `AGEN Where the modernization is going: -- declared, golden-tested mailers (R4); +- one job queue for mail and feeds (R8); - structured logging through one [zap](https://github.com/uber-go/zap) logger, passed explicitly (R7); - no unmaintained or duplicate dependencies (R6); - shared plumbing in [gogo](https://github.com/can3p/gogo) (`docs/gogo-extraction.md`). diff --git a/docs/open-questions.md b/docs/open-questions.md index 96c3c3ff..83d7db50 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -8,10 +8,6 @@ When the owner answers, the answer goes where it applies (`docs/product.md` for `docs/architecture.md` for how it is built, the wave file for what a wave builds) and the question is deleted here. The reasoning goes into the history of the wave that acts on it. -- **Q9. The RSS poller downloads feeds and images inside the DB transaction** - that holds the feed row lock (`refreshFeeds` in `pkg/service/feeds/poller.go`). - With `GlobalImageDownloadTimeout` per item, a transaction can stay open for minutes. Restructure when - moving email and feed work to a general job queue? - **Q10. Audit and idempotency for mutations (narrowed 2026-09-24).** The layering is decided (`docs/architecture.md`): services take an actor, check authorization, and HTTP, API and CLI are transports over them. Still open: should every diff --git a/docs/plan/r4.md b/docs/plan/r4.md deleted file mode 100644 index 1f08e7cf..00000000 --- a/docs/plan/r4.md +++ /dev/null @@ -1,22 +0,0 @@ -## R4 — Mailers - -Status: planned · Depends on: — · Branch: `refactor/r4-mailers` - -Every email becomes a declared definition: - -- a typed input; -- a sample factory that touches no database, including the awkward variants - (no subject, long subject, markup in user content, a linked URL); -- a template rendered through `html/template`; -- a golden file for the text and the HTML; -- a development preview page that lists every email rendered from its - samples. - -Call sites stop building mail bodies with `fmt.Sprintf`. The mail goldens in -`pkg/mail` (`testdata/*.golden`) define "unchanged output", and escaping user -content becomes `html/template`'s job instead of each mail's. - -Consider replacing `dbsender`'s ad-hoc poller with a -general job queue, where the email is enqueued in the same transaction as the -state change. tommy is where a human checks what was actually delivered. E2E and browser tests assert on the mail tommy received; the only e2e code that knows about the queue is `App.WaitMailSent`/`QueuedMails` in `e2e/harness.go`, so a job queue replaces just those. - diff --git a/docs/plan/r7.md b/docs/plan/r7.md index 6cde2c05..8e1e3979 100644 --- a/docs/plan/r7.md +++ b/docs/plan/r7.md @@ -10,7 +10,7 @@ to `log/slog`, and 17 `fmt.Print*` calls, spread over `cmd/web`, `pkg/markdown` and `cmd/scripts` (now the `cmd/web` subcommands). Most of the code that logs is in services, and the log level is already a -setting. R7 can run alongside R4 only if the tasks own disjoint files. +setting. F7's translation worker logs too: whichever of F7 and R7 merges second converts it. diff --git a/docs/plan/r8.md b/docs/plan/r8.md new file mode 100644 index 00000000..ea3c425e --- /dev/null +++ b/docs/plan/r8.md @@ -0,0 +1,36 @@ +## R8 — Job queue + +Status: planned · Depends on: — · Branch: `refactor/r8-jobs` + +One background worker for everything pcom does outside a request, replacing the two hand-written pollers: +`dbsender.RunPoller` (mail) and the feed poller in `pkg/service/feeds/poller.go`. A job is enqueued in the +same transaction as the state change that causes it, as mail already is through `outgoing_emails`. + +What it buys: + +- **The feed poller leaves its long transaction.** `refreshFeeds` downloads feeds and images inside the + transaction that holds the feed row lock, and with `GlobalImageDownloadTimeout` per item that can last + minutes. As one job per feed, the download runs outside any transaction and only the save is + transactional. +- **Fan-out leaves the request.** Saving a post renders one mail per connection inside the request's + transaction (`pkg/service/posts/save.go`); a "post published" job builds them in the worker instead. +- **Retries, backoff and dead jobs are configured and visible in one place**, instead of per-poller code + (mail has its retry schedule as a setting already; the feed poller has none). +- **Later background work** (exports, media processing) gets a worker without a third poller. + +What it costs: a dependency (River fits: pcom already uses pgx v5), a migration, the move of +`outgoing_emails`, and `App.WaitMailSent` / `QueuedMails` in `e2e/harness.go`, the only e2e code that knows +the queue. E2E and browser tests keep asserting on the mail tommy received. + +Tasks are planned when the open questions are answered. + +### Open questions + +- **Library.** River, or a home-grown jobs table generalizing `outgoing_emails`? +- **Render at enqueue or at send?** Rendering in the worker shows the post as it is when the mail goes out + (an edit in between is included) and lets a template fix reach queued mail; rendering at enqueue keeps + today's snapshot. +- **Mail history.** Keep a sent-mail record (today's `outgoing_emails` rows with their payload), or rely on + the queue's finished jobs and tommy / Mailjet for what was delivered? +- **Feed transaction shape (was Q9).** One job per feed with the download outside the transaction, or keep + the row lock for the whole refresh and only bound its time? diff --git a/docs/running.md b/docs/running.md index 45e45885..6efc07d4 100644 --- a/docs/running.md +++ b/docs/running.md @@ -61,6 +61,9 @@ once first. The server listens on `$PORT` (8080), and `SITE_ROOT` has to match. tommy stands in for both outside services: * Mail goes to tommy's fake Mailjet API (`MJ_API_BASE`); read it at http://localhost:8811/ui/. +* Every mail's samples render at http://localhost:8080/dev/mail; `make dev` prints this address with the + others, or a warning when the app answers 404 there. The `/dev` pages exist only with `DEV_ROUTES=true`, + which the local config sets and production never does. * Uploads go to tommy's S3 bucket `pcom-media` on port 9555, with path-style addressing (`USER_MEDIA_PATH_STYLE=true`); list the objects at http://localhost:8811/api/v1/s3/buckets/pcom-media/objects. diff --git a/docs/testing.md b/docs/testing.md index 23d94b2a..ef60c067 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -34,11 +34,18 @@ happen is tested in the browser, never over plain HTTP. ordering. 5. **Every test gets its own database** (`testdb.New(t)`), and tests may run in parallel (`t.Parallel()` is encouraged for DB tests). -6. **Mail content is golden-tested** under `testdata/*.golden`, with the - convention `UPDATE_GOLDEN=1 go test ./pkg/mail/...` to rewrite. - These goldens are the safety net for R4. Golden fixtures use fixed values, - never factory emails or usernames, which come from a counter that parallel - tests share and would make the golden depend on test order. +6. **Mail content is golden-tested**: `TestMails_Goldens` renders every sample + of every mail into `pkg/mail/testdata/.golden` + (`UPDATE_GOLDEN=1 go test ./pkg/mail/...` rewrites; the helper doesn't create + a missing file, so a new sample's golden starts as an empty file). Samples use + fixed values, never factory emails or usernames, which come from a counter that + parallel tests share and would make the golden depend on test order. The + goldens test the samples, not the constructors: each constructor has a + `_MatchesSample` test that calls it with models and compares with its sample's + rendering. Write the sample inputs as literals, not through the constructor's + own model-to-input mapper (a wrong mapping would then be on both sides), give + ids that a mapper could swap different values, and use `SampleSite`, which has + a root, so a constructor that ignores its site fails. 7. **Keep tests compact.** Cases that differ only in inputs and expectations are rows of one table-driven test with `t.Run`, not one function each. A table test opens one database at the top, and each row makes its own diff --git a/e2e/guards_test.go b/e2e/guards_test.go index 6384500f..9c9ec9ab 100644 --- a/e2e/guards_test.go +++ b/e2e/guards_test.go @@ -367,6 +367,7 @@ func TestGuards_RouteTableMatchesSource(t *testing.T) { "../pkg/web/app/routes_media.go": {"router": ""}, "../pkg/web/app/routes_public.go": {"r": ""}, "../pkg/web/app/routes_rss.go": {"r": ""}, + "../pkg/web/app/routes_dev.go": {"dev": "/dev"}, "../pkg/web/app/routes_auth.go": {"r": "", "actions": "/controls/action", "nonControlsForms": "/form"}, "../pkg/web/app/routes_connections.go": {"controls": "/controls", "controlsForms": "/controls/form"}, "../pkg/web/app/routes_posts.go": {"r": "", "controlsForms": "/controls/form"}, diff --git a/pkg/admin/notifications.go b/pkg/admin/notifications.go deleted file mode 100644 index f83e90ad..00000000 --- a/pkg/admin/notifications.go +++ /dev/null @@ -1,156 +0,0 @@ -package admin - -import ( - "fmt" - "html" - "net/mail" - - "github.com/can3p/gogo/sender" - "github.com/can3p/pcom/pkg/links" - pcommail "github.com/can3p/pcom/pkg/mail" - "github.com/can3p/pcom/pkg/model" - "github.com/google/uuid" - "github.com/samber/lo" -) - -// NewUser tells the admin about a new account. -func NewUser(site links.Site, from, adminAddress string, user *model.User) *pcommail.Envelope { - blogURL := site.Abs("user", user.Username) - - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: adminAddress, - }, - }, - Subject: "New User on pcom", - Text: fmt.Sprintf(` - Hi! - - New user alert: - - * ID: %s - * Blog: %s - * Email: %s`, user.ID, blogURL, user.Email), - Html: fmt.Sprintf(` -

Hi!

- -

New user alert:

- - `, html.EscapeString(user.ID), html.EscapeString(blogURL), html.EscapeString(blogURL), html.EscapeString(user.Email)), - } - - return &pcommail.Envelope{UniqueID: user.ID, Type: "admin_new_user", Mail: mail} -} - -// NewWaitingListMember tells the admin about a new waiting list entry. -func NewWaitingListMember(site links.Site, from, adminAddress string, waitingList *model.UserSignupRequest) *pcommail.Envelope { - r := lo.FromPtr(waitingList.Reason) - - if r == "" { - r = "Not specified" - } - - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: adminAddress, - }, - }, - Subject: "New waiting list member on pcom", - Text: fmt.Sprintf(` - Hi! - - New waiting list member alert: - - * Email address: %s - * Reason: %s - `, waitingList.Email, r), - Html: fmt.Sprintf(` -

Hi!

- -

New waiting list member alert:

- - `, - html.EscapeString(waitingList.Email), html.EscapeString(r)), - } - - return &pcommail.Envelope{UniqueID: waitingList.ID, Type: "new_waiting_list_member", Mail: mail} -} - -// SignupConfirmed tells the admin that an account confirmed its email. -func SignupConfirmed(site links.Site, from, adminAddress string, user *model.User) *pcommail.Envelope { - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: adminAddress, - }, - }, - Subject: "New User confirmed email on pcom", - Text: fmt.Sprintf(` - Hi! - - New conrirmed user alert: - - * ID: %s - * Email: %s`, user.ID, user.Email), - Html: fmt.Sprintf(` -

Hi!

- -

New conrirmed user alert:

- - `, html.EscapeString(user.ID), html.EscapeString(user.Email)), - } - - return &pcommail.Envelope{UniqueID: user.ID, Type: "signup_confirmed", Mail: mail} -} - -// ThrowAwayEmailSignupAttempt tells the admin that somebody tried to sign up -// with a throwaway email domain. -func ThrowAwayEmailSignupAttempt(from, adminAddress string, email string) *pcommail.Envelope { - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: adminAddress, - }, - }, - Subject: "An attempt to use a throwaway email domain on pcom", - Text: fmt.Sprintf(` - Hi! - - A user has just tried to use a throwaway email: %s`, email), - Html: fmt.Sprintf(` -

Hi!

- -

A user has just tried to use a throwaway email: %s

- `, html.EscapeString(email)), - } - - return &pcommail.Envelope{UniqueID: uuid.NewString(), Type: "throw_away_email_signup", Mail: mail} -} diff --git a/pkg/admin/notifications_test.go b/pkg/admin/notifications_test.go deleted file mode 100644 index ec3655da..00000000 --- a/pkg/admin/notifications_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package admin_test - -import ( - "context" - "regexp" - "testing" - - "github.com/can3p/gogo/sender" - "github.com/can3p/pcom/pkg/admin" - "github.com/can3p/pcom/pkg/links" - pcommail "github.com/can3p/pcom/pkg/mail" - "github.com/can3p/pcom/pkg/model" - "github.com/can3p/pcom/pkg/testutil/fakesender" - "github.com/can3p/pcom/pkg/testutil/golden" - "github.com/stretchr/testify/require" -) - -const ( - testFrom = "noreply@pcom.test" - testAdmin = "admin@pcom.test" -) - -// send queues the way a service would: through the sender, with no database. -func send(ctx context.Context, s *fakesender.Sender, m *pcommail.Envelope) error { - return s.Send(ctx, nil, m.UniqueID, m.Type, m.Mail) -} - -// normalizeUUID replaces UUIDs with a placeholder for golden test consistency -func normalizeUUID(content string) string { - // Match UUID pattern: 8-4-4-4-12 hex digits - uuidPattern := regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) - return uuidPattern.ReplaceAllString(content, "[UUID]") -} - -func TestNotifyNewUser(t *testing.T) { - t.Parallel() - - ctx := context.Background() - sender := fakesender.New() - - user := &model.User{ - ID: "0190a3b4-0000-7000-8000-000000000001", - Email: "alice@example.test", - Username: "alice", - } - - require.NoError(t, send(ctx, sender, admin.NewUser(links.Site{}, testFrom, testAdmin, user))) - - sent := sender.Sent() - require.Len(t, sent, 1) - - mail := sent[0].Mail - requireAddresses(t, mail) - require.Equal(t, "admin_new_user", sent[0].EmailType) - require.Equal(t, "New User on pcom", mail.Subject) - - // Golden test for text content - golden.Assert(t, "notify_new_user_text", []byte(normalizeUUID(mail.Text))) - golden.Assert(t, "notify_new_user_html", []byte(normalizeUUID(mail.Html))) -} - -func TestNotifyNewWaitingListMember(t *testing.T) { - t.Parallel() - - ctx := context.Background() - sender := fakesender.New() - - signup := &model.UserSignupRequest{ - ID: "0190a3b4-0000-7000-8000-000000000002", - Email: "signup@example.test", - } - - require.NoError(t, send(ctx, sender, admin.NewWaitingListMember(links.Site{}, testFrom, testAdmin, signup))) - - sent := sender.Sent() - require.Len(t, sent, 1) - - mail := sent[0].Mail - requireAddresses(t, mail) - require.Equal(t, "new_waiting_list_member", sent[0].EmailType) - require.Equal(t, "New waiting list member on pcom", mail.Subject) - - // Golden test for text content - golden.Assert(t, "notify_new_waiting_list_member_text", []byte(normalizeUUID(mail.Text))) - golden.Assert(t, "notify_new_waiting_list_member_html", []byte(normalizeUUID(mail.Html))) -} - -func TestNotifySignupConfirmed(t *testing.T) { - t.Parallel() - - ctx := context.Background() - sender := fakesender.New() - - user := &model.User{ - ID: "0190a3b4-0000-7000-8000-000000000001", - Email: "alice@example.test", - Username: "alice", - } - - require.NoError(t, send(ctx, sender, admin.SignupConfirmed(links.Site{}, testFrom, testAdmin, user))) - - sent := sender.Sent() - require.Len(t, sent, 1) - - mail := sent[0].Mail - requireAddresses(t, mail) - require.Equal(t, "signup_confirmed", sent[0].EmailType) - require.Equal(t, "New User confirmed email on pcom", mail.Subject) - - // Golden test for text content - golden.Assert(t, "notify_signup_confirmed_text", []byte(normalizeUUID(mail.Text))) - golden.Assert(t, "notify_signup_confirmed_html", []byte(normalizeUUID(mail.Html))) -} - -func TestNotifyThrowAwayEmailSignupAttempt(t *testing.T) { - t.Parallel() - - ctx := context.Background() - sender := fakesender.New() - - email := "test@throwaway.example.com" - require.NoError(t, send(ctx, sender, admin.ThrowAwayEmailSignupAttempt(testFrom, testAdmin, email))) - - sent := sender.Sent() - require.Len(t, sent, 1) - - mail := sent[0].Mail - requireAddresses(t, mail) - require.Equal(t, "throw_away_email_signup", sent[0].EmailType) - require.Equal(t, "An attempt to use a throwaway email domain on pcom", mail.Subject) - - // Golden test for text content - golden.Assert(t, "notify_throwaway_email_text", []byte(normalizeUUID(mail.Text))) - golden.Assert(t, "notify_throwaway_email_html", []byte(normalizeUUID(mail.Html))) -} - -// notifiers lists every admin notification with all user-controlled fields -// set to hostile markup (a tag plus quotes). -func notifiers() map[string]func(s *fakesender.Sender) error { - const hostile = `">x` - - ctx := context.Background() - user := &model.User{ID: "user-1", Email: hostile, Username: hostile} - signup := &model.UserSignupRequest{ID: "signup-1", Email: hostile} - signup.Reason = new(hostile) - - return map[string]func(s *fakesender.Sender) error{ - "NewUser": func(s *fakesender.Sender) error { - return send(ctx, s, admin.NewUser(links.Site{}, testFrom, testAdmin, user)) - }, - "NewWaitingListMember": func(s *fakesender.Sender) error { - return send(ctx, s, admin.NewWaitingListMember(links.Site{}, testFrom, testAdmin, signup)) - }, - "SignupConfirmed": func(s *fakesender.Sender) error { - return send(ctx, s, admin.SignupConfirmed(links.Site{}, testFrom, testAdmin, user)) - }, - "ThrowAwayEmail": func(s *fakesender.Sender) error { - return send(ctx, s, admin.ThrowAwayEmailSignupAttempt(testFrom, testAdmin, hostile)) - }, - } -} - -func TestNotifications_EscapeUserContentInHTML(t *testing.T) { - t.Parallel() - - for name, notify := range notifiers() { - t.Run(name, func(t *testing.T) { - t.Parallel() - - s := fakesender.New() - require.NoError(t, notify(s)) - - sent := s.Sent() - require.Len(t, sent, 1) - - requireAddresses(t, sent[0].Mail) - html := sent[0].Mail.Html - require.NotContains(t, html, "x") - require.NotContains(t, html, `"><`) - require.Contains(t, html, "<b>x</b>") - require.Contains(t, html, """) - }) - } -} - -// Every notification goes from the sender address to the admin address. -func requireAddresses(t *testing.T, m *sender.Mail) { - t.Helper() - - require.Equal(t, testFrom, m.From.Address) - require.Len(t, m.To, 1) - require.Equal(t, testAdmin, m.To[0].Address) -} diff --git a/pkg/admin/panics.go b/pkg/admin/panics.go index 84f904a0..dc9fdd9a 100644 --- a/pkg/admin/panics.go +++ b/pkg/admin/panics.go @@ -4,20 +4,14 @@ import ( "bytes" "errors" "fmt" - "html" "net" "net/http/httputil" - "net/mail" "os" "runtime" "strings" "time" - "github.com/can3p/gogo/sender" - pcommail "github.com/can3p/pcom/pkg/mail" - "github.com/can3p/pcom/pkg/model" "github.com/gin-gonic/gin" - "github.com/google/uuid" ) var ( @@ -27,51 +21,8 @@ var ( slash = []byte("/") ) -// PageFailure tells the admin that a page panicked. -func PageFailure(from, adminAddress string, c *gin.Context, err any, user *model.User) *pcommail.Envelope { - decodedStack := strings.Split(ClonedCustomRecovery(c, err), "\r\n") - - userInfo := "Anonymous" - - if user != nil { - userInfo = fmt.Sprintf("email (%s), id (%s)", user.Email, user.ID) - } - - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: adminAddress, - }, - }, - Subject: "Panic on the page", - Text: fmt.Sprintf(` - Hi! - - Panic on the page - - * User: %s - * Request data: - - %s - `, userInfo, strings.Join(decodedStack, "\r\n")), - Html: fmt.Sprintf(` -

Hi!

- -

Panic on the page:

- - `, html.EscapeString(userInfo), html.EscapeString(strings.Join(decodedStack, "\r\n"))), - } - - return &pcommail.Envelope{UniqueID: uuid.NewString(), Type: "panic_notification", Mail: mail} -} - +// ClonedCustomRecovery formats a recovered panic for the admin: the request +// without its secrets, the panic value and the stack. func ClonedCustomRecovery(c *gin.Context, err any) string { // Check for a broken connection, as it is not really a // condition that warrants a panic stack trace. diff --git a/pkg/admin/testdata/notify_new_user_html.golden b/pkg/admin/testdata/notify_new_user_html.golden deleted file mode 100644 index f81ce103..00000000 --- a/pkg/admin/testdata/notify_new_user_html.golden +++ /dev/null @@ -1,10 +0,0 @@ - -

Hi!

- -

New user alert:

- - \ No newline at end of file diff --git a/pkg/admin/testdata/notify_new_user_text.golden b/pkg/admin/testdata/notify_new_user_text.golden deleted file mode 100644 index 8511ca2d..00000000 --- a/pkg/admin/testdata/notify_new_user_text.golden +++ /dev/null @@ -1,8 +0,0 @@ - - Hi! - - New user alert: - - * ID: [UUID] - * Blog: /users/alice - * Email: alice@example.test \ No newline at end of file diff --git a/pkg/admin/testdata/notify_new_waiting_list_member_html.golden b/pkg/admin/testdata/notify_new_waiting_list_member_html.golden deleted file mode 100644 index 58f2b227..00000000 --- a/pkg/admin/testdata/notify_new_waiting_list_member_html.golden +++ /dev/null @@ -1,9 +0,0 @@ - -

Hi!

- -

New waiting list member alert:

- - \ No newline at end of file diff --git a/pkg/admin/testdata/notify_new_waiting_list_member_text.golden b/pkg/admin/testdata/notify_new_waiting_list_member_text.golden deleted file mode 100644 index af6ef4d0..00000000 --- a/pkg/admin/testdata/notify_new_waiting_list_member_text.golden +++ /dev/null @@ -1,8 +0,0 @@ - - Hi! - - New waiting list member alert: - - * Email address: signup@example.test - * Reason: Not specified - \ No newline at end of file diff --git a/pkg/admin/testdata/notify_signup_confirmed_html.golden b/pkg/admin/testdata/notify_signup_confirmed_html.golden deleted file mode 100644 index e6861420..00000000 --- a/pkg/admin/testdata/notify_signup_confirmed_html.golden +++ /dev/null @@ -1,9 +0,0 @@ - -

Hi!

- -

New conrirmed user alert:

- - \ No newline at end of file diff --git a/pkg/admin/testdata/notify_signup_confirmed_text.golden b/pkg/admin/testdata/notify_signup_confirmed_text.golden deleted file mode 100644 index 0ffb8c0a..00000000 --- a/pkg/admin/testdata/notify_signup_confirmed_text.golden +++ /dev/null @@ -1,7 +0,0 @@ - - Hi! - - New conrirmed user alert: - - * ID: [UUID] - * Email: alice@example.test \ No newline at end of file diff --git a/pkg/admin/testdata/notify_throwaway_email_html.golden b/pkg/admin/testdata/notify_throwaway_email_html.golden deleted file mode 100644 index 483c3e05..00000000 --- a/pkg/admin/testdata/notify_throwaway_email_html.golden +++ /dev/null @@ -1,5 +0,0 @@ - -

Hi!

- -

A user has just tried to use a throwaway email: test@throwaway.example.com

- \ No newline at end of file diff --git a/pkg/admin/testdata/notify_throwaway_email_text.golden b/pkg/admin/testdata/notify_throwaway_email_text.golden deleted file mode 100644 index 629e1fb2..00000000 --- a/pkg/admin/testdata/notify_throwaway_email_text.golden +++ /dev/null @@ -1,4 +0,0 @@ - - Hi! - - A user has just tried to use a throwaway email: test@throwaway.example.com \ No newline at end of file diff --git a/pkg/config/config.go b/pkg/config/config.go index a771ca0c..f43244db 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -49,6 +49,7 @@ type Web struct { ReportPanics Switch `long:"report-panics" env:"REPORT_PANICS" description:"Mail the admin address when a page panics" optional:"true" optional-value:"true" default:"true"` LogLevel string `long:"log-level" env:"LOG_LEVEL" description:"Log level" choice:"debug" choice:"info" choice:"warn" choice:"error" default:"info"` EnablePprof Switch `long:"enable-pprof" env:"ENABLE_PPROF" description:"Serve net/http/pprof on :8081" optional:"true" optional-value:"true" default:"false"` + DevRoutes Switch `long:"dev-routes" env:"DEV_ROUTES" description:"Serve the /dev pages (mail previews); local development only" optional:"true" optional-value:"true" default:"false"` } // SlogLevel is LogLevel as a slog level. @@ -87,9 +88,10 @@ type Login struct { // Mail is outgoing mail: the addresses, the queue, and the Mailjet API that // delivers it (tommy in development and tests). type Mail struct { - SenderAddress string `long:"sender-address" env:"SENDER_ADDRESS" description:"From address of every mail" required:"true"` - AdminAddress string `long:"admin-address" env:"ADMIN_ADDRESS" description:"Where admin notifications (signups, page failures) go"` - PollInterval time.Duration `long:"email-poll-interval" env:"EMAIL_POLL_INTERVAL" description:"How often the mail queue is sent" default:"10s"` + SenderAddress string `long:"sender-address" env:"SENDER_ADDRESS" description:"From address of every mail" required:"true"` + AdminAddress string `long:"admin-address" env:"ADMIN_ADDRESS" description:"Where admin notifications (signups, page failures) go"` + PollInterval time.Duration `long:"email-poll-interval" env:"EMAIL_POLL_INTERVAL" description:"How often the mail queue is sent" default:"10s"` + RetryIntervals []time.Duration `long:"email-retry-intervals" env:"EMAIL_RETRY_INTERVALS" env-delim:"," description:"Waits between attempts to send a mail; a mail fails after one attempt more than there are waits" default:"10s" default:"60s" default:"30m"` Mailjet mjconfig.Config `group:"Mailjet" namespace:"mj" env-namespace:"MJ"` } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 787b59de..f923b0c1 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -127,11 +127,13 @@ func TestServe_EveryVariable(t *testing.T) { {"REPORT_PANICS", "false", func(s *config.Serve) any { return s.Web.ReportPanics.On() }, true, false}, {"LOG_LEVEL", "debug", func(s *config.Serve) any { return s.Web.SlogLevel() }, slog.LevelInfo, slog.LevelDebug}, {"ENABLE_PPROF", "true", func(s *config.Serve) any { return s.Web.EnablePprof.On() }, false, true}, + {"DEV_ROUTES", "true", func(s *config.Serve) any { return s.Web.DevRoutes.On() }, false, true}, {"PAGE_SIZE", "10", func(s *config.Serve) any { return s.Limits.PageSize }, 30, 10}, {"RSS_LIMIT", "20", func(s *config.Serve) any { return s.Limits.RSSLimit }, 50, 20}, {"SENDER_ADDRESS", "other@pcom.test", func(s *config.Serve) any { return s.Mail.SenderAddress }, nil, "other@pcom.test"}, {"ADMIN_ADDRESS", "admin@pcom.test", func(s *config.Serve) any { return s.Mail.AdminAddress }, "", "admin@pcom.test"}, {"EMAIL_POLL_INTERVAL", "250ms", func(s *config.Serve) any { return s.Mail.PollInterval }, 10 * time.Second, 250 * time.Millisecond}, + {"EMAIL_RETRY_INTERVALS", "1s,2s", func(s *config.Serve) any { return s.Mail.RetryIntervals }, []time.Duration{10 * time.Second, 60 * time.Second, 30 * time.Minute}, []time.Duration{time.Second, 2 * time.Second}}, {"MJ_APIKEY_PUBLIC", "other-public", func(s *config.Serve) any { return s.Mail.Mailjet.ApiKeyPublic }, nil, "other-public"}, {"MJ_APIKEY_PRIVATE", "other-private", func(s *config.Serve) any { return s.Mail.Mailjet.ApiKeyPrivate.Reveal() }, nil, "other-private"}, {"MJ_API_BASE", "http://tommy:8822", func(s *config.Serve) any { return s.Mail.Mailjet.BaseURL }, "", "http://tommy:8822"}, diff --git a/pkg/mail/admin_new_user.go b/pkg/mail/admin_new_user.go new file mode 100644 index 00000000..6976c094 --- /dev/null +++ b/pkg/mail/admin_new_user.go @@ -0,0 +1,42 @@ +package mail + +import ( + "github.com/can3p/pcom/pkg/links" + "github.com/can3p/pcom/pkg/model" +) + +// sampleAdmin is the admin address the samples of the admin mails go to. +const sampleAdmin = "admin@pcom.test" + +// AdminNewUserInput is what the new account alert to the admin shows. +type AdminNewUserInput struct { + From string + AdminAddress string + ID string + BlogURL string + Email string +} + +// Header addresses the mail to the admin, unique per account. +func (in AdminNewUserInput) Header() Header { + return Header{UniqueID: in.ID, From: FromPcom(in.From), To: To(in.AdminAddress)} +} + +var adminNewUserMail = declare("admin_new_user", "admin_new_user", adminNewUserSamples) + +func adminNewUserSamples() []Sample[AdminNewUserInput] { + return []Sample[AdminNewUserInput]{ + {Name: "admin_new_user", Input: AdminNewUserInput{ + From: SampleFrom, AdminAddress: sampleAdmin, + ID: "0190a3b4-0000-7000-8000-000000000001", BlogURL: SampleSite.Abs("user", "alice"), Email: "alice@example.test", + }}, + } +} + +// AdminNewUser tells the admin about a new account. +func AdminNewUser(site links.Site, from, adminAddress string, user *model.User) *Envelope { + return adminNewUserMail.MustRender(AdminNewUserInput{ + From: from, AdminAddress: adminAddress, + ID: user.ID, BlogURL: site.Abs("user", user.Username), Email: user.Email, + }) +} diff --git a/pkg/mail/admin_new_waiting_list_member.go b/pkg/mail/admin_new_waiting_list_member.go new file mode 100644 index 00000000..cb829f08 --- /dev/null +++ b/pkg/mail/admin_new_waiting_list_member.go @@ -0,0 +1,51 @@ +package mail + +import ( + "github.com/can3p/pcom/pkg/model" + "github.com/samber/lo" +) + +const noReasonGiven = "Not specified" + +// AdminNewWaitingListMemberInput is what the waiting list alert to the admin +// shows. +type AdminNewWaitingListMemberInput struct { + From string + AdminAddress string + ID string + Email string + Reason string +} + +// Header addresses the mail to the admin, unique per waiting list entry. +func (in AdminNewWaitingListMemberInput) Header() Header { + return Header{UniqueID: in.ID, From: FromPcom(in.From), To: To(in.AdminAddress)} +} + +var adminNewWaitingListMemberMail = declare("new_waiting_list_member", "admin_new_waiting_list_member", adminNewWaitingListMemberSamples) + +func adminNewWaitingListMemberSamples() []Sample[AdminNewWaitingListMemberInput] { + return []Sample[AdminNewWaitingListMemberInput]{ + {Name: "admin_new_waiting_list_member", Input: AdminNewWaitingListMemberInput{ + From: SampleFrom, AdminAddress: sampleAdmin, + ID: "0190a3b4-0000-7000-8000-000000000002", Email: "signup@example.test", Reason: noReasonGiven, + }}, + {Name: "admin_new_waiting_list_member_reason", Input: AdminNewWaitingListMemberInput{ + From: SampleFrom, AdminAddress: sampleAdmin, + ID: "0190a3b4-0000-7000-8000-000000000002", Email: "signup@example.test", Reason: "I read your blog", + }}, + } +} + +// AdminNewWaitingListMember tells the admin about a new waiting list entry. +func AdminNewWaitingListMember(from, adminAddress string, waitingList *model.UserSignupRequest) *Envelope { + reason := lo.FromPtr(waitingList.Reason) + if reason == "" { + reason = noReasonGiven + } + + return adminNewWaitingListMemberMail.MustRender(AdminNewWaitingListMemberInput{ + From: from, AdminAddress: adminAddress, + ID: waitingList.ID, Email: waitingList.Email, Reason: reason, + }) +} diff --git a/pkg/mail/admin_page_failure.go b/pkg/mail/admin_page_failure.go new file mode 100644 index 00000000..309f1daa --- /dev/null +++ b/pkg/mail/admin_page_failure.go @@ -0,0 +1,63 @@ +package mail + +import ( + "fmt" + + "github.com/can3p/pcom/pkg/model" + "github.com/google/uuid" +) + +const anonymousUser = "Anonymous" + +// SampleAdminPageFailureReport is the report the page failure samples carry. +const SampleAdminPageFailureReport = "[Recovery] 2026/01/02 - 03:04:05 panic recovered:\r\n" + + "GET /posts/1 HTTP/1.1\r\nHost: pcom.test\r\nCookie: \r\n\r\n" + + "something broke\r\n" + + "/app/pkg/web/handler.go:42 (0x1234)\r\n\tHandle: panic(err)\r\n" + +// AdminPageFailureInput is what the panic alert to the admin shows: who hit +// the page and the report of the request, the panic and its stack. +type AdminPageFailureInput struct { + From string + AdminAddress string + UniqueID string + User string + Report string +} + +// Header addresses the mail to the admin; every failure has its own id. +func (in AdminPageFailureInput) Header() Header { + return Header{UniqueID: in.UniqueID, From: FromPcom(in.From), To: To(in.AdminAddress)} +} + +var adminPageFailureMail = declare("panic_notification", "admin_page_failure", adminPageFailureSamples) + +func adminPageFailureSamples() []Sample[AdminPageFailureInput] { + const report = SampleAdminPageFailureReport + + return []Sample[AdminPageFailureInput]{ + {Name: "admin_page_failure", Input: AdminPageFailureInput{ + From: SampleFrom, AdminAddress: sampleAdmin, + UniqueID: "0190a3b4-0000-7000-8000-000000000004", + User: "email (alice@example.test), id (0190a3b4-0000-7000-8000-000000000001)", Report: report, + }}, + {Name: "admin_page_failure_anonymous", Input: AdminPageFailureInput{ + From: SampleFrom, AdminAddress: sampleAdmin, + UniqueID: "0190a3b4-0000-7000-8000-000000000004", User: anonymousUser, Report: report, + }}, + } +} + +// AdminPageFailure tells the admin that a page panicked. report is the +// request, the panic value and the stack as the caller formatted them; user +// is nil for a visitor who is not logged in. +func AdminPageFailure(from, adminAddress, report string, user *model.User) *Envelope { + who := anonymousUser + if user != nil { + who = fmt.Sprintf("email (%s), id (%s)", user.Email, user.ID) + } + + return adminPageFailureMail.MustRender(AdminPageFailureInput{ + From: from, AdminAddress: adminAddress, UniqueID: uuid.NewString(), User: who, Report: report, + }) +} diff --git a/pkg/mail/admin_signup_confirmed.go b/pkg/mail/admin_signup_confirmed.go new file mode 100644 index 00000000..161d3dc9 --- /dev/null +++ b/pkg/mail/admin_signup_confirmed.go @@ -0,0 +1,35 @@ +package mail + +import "github.com/can3p/pcom/pkg/model" + +// AdminSignupConfirmedInput is what the confirmed account alert to the admin +// shows. +type AdminSignupConfirmedInput struct { + From string + AdminAddress string + ID string + Email string +} + +// Header addresses the mail to the admin, unique per account. +func (in AdminSignupConfirmedInput) Header() Header { + return Header{UniqueID: in.ID, From: FromPcom(in.From), To: To(in.AdminAddress)} +} + +var adminSignupConfirmedMail = declare("signup_confirmed", "admin_signup_confirmed", adminSignupConfirmedSamples) + +func adminSignupConfirmedSamples() []Sample[AdminSignupConfirmedInput] { + return []Sample[AdminSignupConfirmedInput]{ + {Name: "admin_signup_confirmed", Input: AdminSignupConfirmedInput{ + From: SampleFrom, AdminAddress: sampleAdmin, + ID: "0190a3b4-0000-7000-8000-000000000001", Email: "alice@example.test", + }}, + } +} + +// AdminSignupConfirmed tells the admin that an account confirmed its email. +func AdminSignupConfirmed(from, adminAddress string, user *model.User) *Envelope { + return adminSignupConfirmedMail.MustRender(AdminSignupConfirmedInput{ + From: from, AdminAddress: adminAddress, ID: user.ID, Email: user.Email, + }) +} diff --git a/pkg/mail/admin_test.go b/pkg/mail/admin_test.go new file mode 100644 index 00000000..7f66515e --- /dev/null +++ b/pkg/mail/admin_test.go @@ -0,0 +1,132 @@ +package mail_test + +import ( + "context" + "testing" + + "github.com/can3p/pcom/pkg/links" + "github.com/can3p/pcom/pkg/mail" + "github.com/can3p/pcom/pkg/model" + "github.com/can3p/pcom/pkg/testutil/fakesender" + "github.com/stretchr/testify/require" +) + +const testAdmin = "admin@pcom.test" + +// requireSample checks that got is the rendering of the sample called name. +// Mails whose id is generated per send take the sample's id. +func requireSample(t *testing.T, name string, got *mail.Envelope, generatedID bool) { + t.Helper() + + want := sampleEnvelope(t, name) + if generatedID { + require.NotEmpty(t, got.UniqueID) + got.UniqueID = want.UniqueID + } + + require.Equal(t, want, got) +} + +func TestAdminNewUser(t *testing.T) { + t.Parallel() + + user := &model.User{ID: "0190a3b4-0000-7000-8000-000000000001", Email: "alice@example.test", Username: "alice"} + + got := mail.AdminNewUser(mail.SampleSite, testFrom, testAdmin, user) + requireSample(t, "admin_new_user", got, false) +} + +func TestAdminNewWaitingListMember(t *testing.T) { + t.Parallel() + + signup := &model.UserSignupRequest{ID: "0190a3b4-0000-7000-8000-000000000002", Email: "signup@example.test"} + + got := mail.AdminNewWaitingListMember(testFrom, testAdmin, signup) + requireSample(t, "admin_new_waiting_list_member", got, false) + + signup.Reason = new("I read your blog") + requireSample(t, "admin_new_waiting_list_member_reason", mail.AdminNewWaitingListMember(testFrom, testAdmin, signup), false) +} + +func TestAdminSignupConfirmed(t *testing.T) { + t.Parallel() + + user := &model.User{ID: "0190a3b4-0000-7000-8000-000000000001", Email: "alice@example.test", Username: "alice"} + + got := mail.AdminSignupConfirmed(testFrom, testAdmin, user) + requireSample(t, "admin_signup_confirmed", got, false) +} + +func TestAdminThrowAwayEmailSignupAttempt(t *testing.T) { + t.Parallel() + + got := mail.AdminThrowAwayEmailSignupAttempt(testFrom, testAdmin, "test@throwaway.example.com") + requireSample(t, "admin_throwaway_email", got, true) + + other := mail.AdminThrowAwayEmailSignupAttempt(testFrom, testAdmin, "test@throwaway.example.com") + require.NotEqual(t, got.UniqueID, other.UniqueID) +} + +func TestAdminPageFailure(t *testing.T) { + t.Parallel() + + user := &model.User{ID: "0190a3b4-0000-7000-8000-000000000001", Email: "alice@example.test"} + + // the sample report is what the router would pass in + in := mail.SampleAdminPageFailureReport + + got := mail.AdminPageFailure(testFrom, testAdmin, in, user) + requireSample(t, "admin_page_failure", got, true) + + requireSample(t, "admin_page_failure_anonymous", mail.AdminPageFailure(testFrom, testAdmin, in, nil), true) +} + +// adminMailers lists every admin notification with all user-controlled +// fields set to hostile markup. +func adminMailers() map[string]func() *mail.Envelope { + user := &model.User{ID: "user-1", Email: hostile, Username: hostile} + signup := &model.UserSignupRequest{ID: "signup-1", Email: hostile, Reason: new(hostile)} + + return map[string]func() *mail.Envelope{ + "AdminNewUser": func() *mail.Envelope { + return mail.AdminNewUser(links.Site{}, testFrom, testAdmin, user) + }, + "AdminNewWaitingListMember": func() *mail.Envelope { + return mail.AdminNewWaitingListMember(testFrom, testAdmin, signup) + }, + "AdminSignupConfirmed": func() *mail.Envelope { + return mail.AdminSignupConfirmed(testFrom, testAdmin, user) + }, + "AdminThrowAwayEmail": func() *mail.Envelope { + return mail.AdminThrowAwayEmailSignupAttempt(testFrom, testAdmin, hostile) + }, + "AdminPageFailure": func() *mail.Envelope { + return mail.AdminPageFailure(testFrom, testAdmin, hostile, user) + }, + } +} + +func TestAdminMails_EscapeUserContentInHTML(t *testing.T) { + t.Parallel() + + for name, build := range adminMailers() { + t.Run(name, func(t *testing.T) { + t.Parallel() + + s := fakesender.New() + require.NoError(t, send(context.Background(), s, build())) + + sent := s.Sent() + require.Len(t, sent, 1) + require.Equal(t, testFrom, sent[0].Mail.From.Address) + require.Len(t, sent[0].Mail.To, 1) + require.Equal(t, testAdmin, sent[0].Mail.To[0].Address) + + html := sent[0].Mail.Html + require.NotContains(t, html, rawTag) + require.NotContains(t, html, `"><`) + require.Contains(t, html, escapedTag) + require.Contains(t, html, escapedQuo) + }) + } +} diff --git a/pkg/mail/admin_throwaway_email.go b/pkg/mail/admin_throwaway_email.go new file mode 100644 index 00000000..38483c27 --- /dev/null +++ b/pkg/mail/admin_throwaway_email.go @@ -0,0 +1,36 @@ +package mail + +import "github.com/google/uuid" + +// AdminThrowAwayEmailInput is what the throwaway email alert to the admin +// shows. +type AdminThrowAwayEmailInput struct { + From string + AdminAddress string + UniqueID string + Email string +} + +// Header addresses the mail to the admin; every attempt has its own id. +func (in AdminThrowAwayEmailInput) Header() Header { + return Header{UniqueID: in.UniqueID, From: FromPcom(in.From), To: To(in.AdminAddress)} +} + +var adminThrowAwayEmailMail = declare("throw_away_email_signup", "admin_throwaway_email", adminThrowAwayEmailSamples) + +func adminThrowAwayEmailSamples() []Sample[AdminThrowAwayEmailInput] { + return []Sample[AdminThrowAwayEmailInput]{ + {Name: "admin_throwaway_email", Input: AdminThrowAwayEmailInput{ + From: SampleFrom, AdminAddress: sampleAdmin, + UniqueID: "0190a3b4-0000-7000-8000-000000000003", Email: "test@throwaway.example.com", + }}, + } +} + +// AdminThrowAwayEmailSignupAttempt tells the admin that somebody tried to +// sign up with a throwaway email domain. +func AdminThrowAwayEmailSignupAttempt(from, adminAddress string, email string) *Envelope { + return adminThrowAwayEmailMail.MustRender(AdminThrowAwayEmailInput{ + From: from, AdminAddress: adminAddress, UniqueID: uuid.NewString(), Email: email, + }) +} diff --git a/pkg/mail/comments_test.go b/pkg/mail/comments_test.go new file mode 100644 index 00000000..e30c2e65 --- /dev/null +++ b/pkg/mail/comments_test.go @@ -0,0 +1,142 @@ +package mail_test + +import ( + "context" + "testing" + "time" + + "github.com/can3p/pcom/pkg/links" + "github.com/can3p/pcom/pkg/mail" + "github.com/can3p/pcom/pkg/model" + "github.com/can3p/pcom/pkg/testutil/fakesender" + "github.com/stretchr/testify/require" +) + +type formatComment func(commenter, recipient *model.User, post *model.Post, comment *model.PostComment, edited bool) (*mail.Envelope, error) + +func formatAuthor(commenter, recipient *model.User, post *model.Post, comment *model.PostComment, edited bool) (*mail.Envelope, error) { + mediaReplacer := func(in string) (bool, string) { return false, in } + + return mail.PostCommentAuthor(mail.SampleSite, testFrom, mediaReplacer, commenter, recipient, post, comment, edited) +} + +func formatParticipants(commenter, recipient *model.User, post *model.Post, comment *model.PostComment, edited bool) (*mail.Envelope, error) { + mediaReplacer := func(in string) (bool, string) { return false, in } + + return mail.PostCommentParticipants(mail.SampleSite, testFrom, mediaReplacer, commenter, recipient, post, comment, edited) +} + +// The comment mails are built from models; each case builds the models of +// the sample it names and expects the sample's rendering, so a wrong mapping +// from models to the input fails. +func TestPostCommentMails_MatchSamples(t *testing.T) { + t.Parallel() + + commenter := &model.User{ID: "user-1", Email: "commenter@example.test", Username: "alice"} + newPost := func() *model.Post { + return &model.Post{ID: "post-1", Subject: new("Original Post"), Body: "Post body", UserID: "user-3"} + } + withURL := newPost() + withURL.URLID = new("url-1") + setPostURL(withURL, &model.NormalizedURL{ID: "url-1", URL: "https://example.com/article"}) + + for _, tc := range []struct { + name string + recipient string + post *model.Post + body string + edited bool + format formatComment + }{ + {"post_comment_author", "author@example.test", newPost(), "Nice post!", false, formatAuthor}, + {"post_comment_author_edited", "recipient@example.test", newPost(), "Nice post, edited!", true, formatAuthor}, + {"post_comment_author_with_url", "author@example.test", withURL, "Nice post!", false, formatAuthor}, + {"post_comment_participants", "participant@example.test", newPost(), "Great comment!", false, formatParticipants}, + {"post_comment_participants_edited", "recipient@example.test", newPost(), "Nice post, edited!", true, formatParticipants}, + {"post_comment_participants_with_url", "participant@example.test", withURL, "Great comment!", false, formatParticipants}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + comment := &model.PostComment{ID: "comment-1", PostID: tc.post.ID, UserID: commenter.ID, Body: tc.body} + if tc.edited { + comment.EditedAt = new(time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)) + } + + recipient := &model.User{ID: "user-2", Email: tc.recipient, Username: "bob"} + got, err := tc.format(commenter, recipient, tc.post, comment, tc.edited) + require.NoError(t, err) + require.Equal(t, sampleEnvelope(t, tc.name), got) + + if tc.edited { + require.Contains(t, got.UniqueID, "2026-09-30T12:00:00Z") + } + }) + } +} + +func TestPostCommentAuthor_NotToMyself(t *testing.T) { + t.Parallel() + + user := &model.User{ + ID: "user-1", + Email: "user@example.test", + Username: "alice", + } + post := &model.Post{ + ID: "post-1", + Subject: new("Original Post"), + Body: "Post body", + UserID: user.ID, + } + comment := &model.PostComment{ + ID: "comment-1", + PostID: post.ID, + UserID: user.ID, + Body: "Nice post!", + } + + sender := fakesender.New() + ctx := context.Background() + mediaReplacer := func(in string) (bool, string) { return false, in } + + // Passing the same user as both commenter and author should skip sending + err := deliverE(ctx, sender)(mail.PostCommentAuthor(links.Site{}, testFrom, mediaReplacer, user, user, post, comment, false)) + require.NoError(t, err) + + sent := sender.Sent() + require.Empty(t, sent, "should not send email to self") +} + +func TestPostCommentParticipants_NotToMyself(t *testing.T) { + t.Parallel() + + user := &model.User{ + ID: "user-1", + Email: "user@example.test", + Username: "alice", + } + post := &model.Post{ + ID: "post-1", + Subject: new("Original Post"), + Body: "Post body", + UserID: "user-3", + } + comment := &model.PostComment{ + ID: "comment-1", + PostID: post.ID, + UserID: user.ID, + Body: "Great comment!", + } + + sender := fakesender.New() + ctx := context.Background() + mediaReplacer := func(in string) (bool, string) { return false, in } + + // Passing the same user as both commenter and participant should skip sending + err := deliverE(ctx, sender)(mail.PostCommentParticipants(links.Site{}, testFrom, mediaReplacer, user, user, post, comment, false)) + require.NoError(t, err) + + sent := sender.Sent() + require.Empty(t, sent, "should not send email to self") +} diff --git a/pkg/mail/confirm_signup.go b/pkg/mail/confirm_signup.go index 6ffae766..8c3af055 100644 --- a/pkg/mail/confirm_signup.go +++ b/pkg/mail/confirm_signup.go @@ -1,51 +1,40 @@ package mail -import ( - "fmt" - "html" - "net/mail" - "time" - - "github.com/can3p/gogo/sender" -) - -// Envelope is a mail with the id and type the outgoing queue files it under. -// The functions of this package build messages; the services send them. -type Envelope struct { - UniqueID string - Type string - Mail *sender.Mail +import "time" + +// ConfirmSignupInput is what the signup confirmation mail shows: the code +// that confirms a new account's address and how long it works. +type ConfirmSignupInput struct { + From string + AttemptID string + To string + Code string + Lifetime time.Duration } -// ConfirmSignup is the mail that carries the code which confirms a new -// account's email address and logs it in, for the login attempt attemptID. -func ConfirmSignup(from string, attemptID, to, code string, lifetime time.Duration) *Envelope { - minutes := int(lifetime.Minutes()) - - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: to, - }, - }, - Subject: "Welcome to pcom", - Text: fmt.Sprintf(` - Hi! - - Thank you for your interest in pcom! Your confirmation code is %s +// Header addresses the mail to the new account's address, unique per login +// attempt. +func (in ConfirmSignupInput) Header() Header { + return Header{UniqueID: in.AttemptID, From: FromPcom(in.From), To: To(in.To)} +} - Type it on the signup page to confirm your email address and log in. It works once, for the next %d minutes.`, code, minutes), - Html: fmt.Sprintf(` -

Hi!

+// Minutes is the lifetime of the code in whole minutes. +func (in ConfirmSignupInput) Minutes() int { + return int(in.Lifetime.Minutes()) +} -

Thank you for your interest in pcom! Your confirmation code is %s

+var confirmSignupMail = declare("confirm_signup", "confirm_signup", confirmSignupSamples) -

Type it on the signup page to confirm your email address and log in. It works once, for the next %d minutes.

`, html.EscapeString(code), minutes), +func confirmSignupSamples() []Sample[ConfirmSignupInput] { + return []Sample[ConfirmSignupInput]{ + {Name: "confirm_signup", Input: ConfirmSignupInput{ + From: SampleFrom, AttemptID: "attempt-1", To: "user@example.test", Code: "123456", Lifetime: 15 * time.Minute, + }}, } +} - return &Envelope{UniqueID: attemptID, Type: "confirm_signup", Mail: mail} +// ConfirmSignup is the mail that carries the code which confirms a new +// account's email address and logs it in, for the login attempt attemptID. +func ConfirmSignup(from string, attemptID, to, code string, lifetime time.Duration) *Envelope { + return confirmSignupMail.MustRender(ConfirmSignupInput{From: from, AttemptID: attemptID, To: to, Code: code, Lifetime: lifetime}) } diff --git a/pkg/mail/confirm_waiting_list.go b/pkg/mail/confirm_waiting_list.go index f4071750..dad4738c 100644 --- a/pkg/mail/confirm_waiting_list.go +++ b/pkg/mail/confirm_waiting_list.go @@ -1,45 +1,43 @@ package mail import ( - "fmt" - "html" - "net/mail" - - "github.com/can3p/gogo/sender" "github.com/can3p/pcom/pkg/links" "github.com/can3p/pcom/pkg/model" ) -// ConfirmWaitingList is the mail with the link that confirms a waiting list -// entry's email address. -func ConfirmWaitingList(site links.Site, from string, waitingList *model.UserSignupRequest) *Envelope { - link := site.Abs("confirm_waiting_list", waitingList.ID) - to := waitingList.Email - - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: to, - }, - }, - Subject: "Waiting list on pcom", - Text: fmt.Sprintf(` - Hi! +// ConfirmWaitingListInput is what the waiting list confirmation mail shows: +// the link that confirms the entry's email address. +type ConfirmWaitingListInput struct { + Site links.Site + From string + RequestID string + To string +} - Thank you for your interest pcom! Please follow the link to confirm your email address +// Header addresses the mail to the entry's address, unique per entry. +func (in ConfirmWaitingListInput) Header() Header { + return Header{UniqueID: in.RequestID, From: FromPcom(in.From), To: To(in.To)} +} - %s`, link), - Html: fmt.Sprintf(` -

Hi!

+// Link is the absolute link that confirms the entry. +func (in ConfirmWaitingListInput) Link() string { + return in.Site.Abs("confirm_waiting_list", in.RequestID) +} -

Thank you for your interest pcom! Please follow the link to confirm your email address

+var confirmWaitingListMail = declare("waiting_list_confirm", "confirm_waiting_list", confirmWaitingListSamples) - %s`, html.EscapeString(link), html.EscapeString(link)), +func confirmWaitingListSamples() []Sample[ConfirmWaitingListInput] { + return []Sample[ConfirmWaitingListInput]{ + {Name: "send_actual_confirm_waiting_list", Input: ConfirmWaitingListInput{ + Site: SampleSite, From: SampleFrom, RequestID: "request-1", To: "newuser@example.test", + }}, } +} - return &Envelope{UniqueID: waitingList.ID, Type: "waiting_list_confirm", Mail: mail} +// ConfirmWaitingList is the mail with the link that confirms a waiting list +// entry's email address. +func ConfirmWaitingList(site links.Site, from string, waitingList *model.UserSignupRequest) *Envelope { + return confirmWaitingListMail.MustRender(ConfirmWaitingListInput{ + Site: site, From: from, RequestID: waitingList.ID, To: waitingList.Email, + }) } diff --git a/pkg/mail/escape_test.go b/pkg/mail/escape_test.go index 5e7ace43..412b5fe3 100644 --- a/pkg/mail/escape_test.go +++ b/pkg/mail/escape_test.go @@ -2,7 +2,6 @@ package mail_test import ( "context" - "errors" "testing" "github.com/can3p/pcom/pkg/links" @@ -76,20 +75,3 @@ func TestMailers_EscapeUserContentInHTML(t *testing.T) { }) } } - -func TestMailers_ReturnSendErrors(t *testing.T) { - t.Parallel() - - sendErr := errors.New("database is gone") - - for name, send := range mailers() { - t.Run(name, func(t *testing.T) { - t.Parallel() - - s := fakesender.New() - s.FailWith(sendErr) - - require.ErrorIs(t, send(s), sendErr) - }) - } -} diff --git a/pkg/mail/golden_test.go b/pkg/mail/golden_test.go new file mode 100644 index 00000000..e47e8dfb --- /dev/null +++ b/pkg/mail/golden_test.go @@ -0,0 +1,87 @@ +package mail_test + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/can3p/pcom/pkg/mail" + "github.com/can3p/pcom/pkg/testutil/fakesender" + "github.com/can3p/pcom/pkg/testutil/golden" + "github.com/stretchr/testify/require" +) + +// mailsToGolden serializes fakesender recordings to a human-readable format for golden testing +func mailsToGolden(sent []fakesender.Recorded) []byte { + var buf bytes.Buffer + for i, rec := range sent { + if i > 0 { + buf.WriteString("\n---\n\n") + } + fmt.Fprintf(&buf, "EmailType: %s\n", rec.EmailType) + fmt.Fprintf(&buf, "UniqueID: %s\n", rec.UniqueID) + fmt.Fprintf(&buf, "From: %s <%s>\n", rec.Mail.From.Name, rec.Mail.From.Address) + if len(rec.Mail.To) > 0 { + fmt.Fprintf(&buf, "To: %s\n", rec.Mail.To[0].Address) + } + fmt.Fprintf(&buf, "Subject: %s\n", rec.Mail.Subject) + fmt.Fprintf(&buf, "\n--- TEXT ---\n%s\n", rec.Mail.Text) + fmt.Fprintf(&buf, "\n--- HTML ---\n%s\n", rec.Mail.Html) + } + return buf.Bytes() +} + +func TestMails_Goldens(t *testing.T) { + t.Parallel() + + for _, m := range mail.All() { + for _, sample := range m.Samples { + t.Run(sample.Name, func(t *testing.T) { + t.Parallel() + + require.NoError(t, sample.Err) + + s := fakesender.New() + require.NoError(t, send(context.Background(), s, sample.Envelope)) + golden.Assert(t, sample.Name, mailsToGolden(s.Sent())) + }) + } + } +} + +func TestMails_Registry(t *testing.T) { + t.Parallel() + + all := mail.All() + require.NotEmpty(t, all) + + seen := map[string]bool{} + for _, m := range all { + require.NotEmpty(t, m.Samples, "mail %s has no samples", m.Name) + + for _, sample := range m.Samples { + require.False(t, seen[sample.Name], "sample %s is declared twice", sample.Name) + seen[sample.Name] = true + } + } +} + +// sampleEnvelope is the rendering of the sample called name. +func sampleEnvelope(t *testing.T, name string) *mail.Envelope { + t.Helper() + + for _, m := range mail.All() { + for _, s := range m.Samples { + if s.Name == name { + require.NoError(t, s.Err) + + return s.Envelope + } + } + } + + require.Failf(t, "no such sample", "sample %s", name) + + return nil +} diff --git a/pkg/mail/invite.go b/pkg/mail/invite.go index 5b4cd564..87b4d8fd 100644 --- a/pkg/mail/invite.go +++ b/pkg/mail/invite.go @@ -1,44 +1,41 @@ package mail import ( - "fmt" - "html" - "net/mail" - - "github.com/can3p/gogo/sender" "github.com/can3p/pcom/pkg/links" "github.com/can3p/pcom/pkg/model" ) -// Invitation is the mail that carries an invitation link to the address it -// was sent to. -func Invitation(site links.Site, from string, invite *model.UserInvitation, to string) *Envelope { - link := site.Abs("invite", invite.ID) - - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: to, - }, - }, - Subject: "Welcome to pcom", - Text: fmt.Sprintf(` - Hi! +// InviteInput is what the invitation mail shows: the link that opens the +// invitation, sent to the address it was sent to. +type InviteInput struct { + Site links.Site + From string + InviteID string + To string +} - Welcome to pcom! Please follow the link to choose a username. You will log in with a code we mail you, so there is no password to set. +// Header addresses the mail to the invited address, unique per invitation. +func (in InviteInput) Header() Header { + return Header{UniqueID: in.InviteID, From: FromPcom(in.From), To: To(in.To)} +} - %s`, link), - Html: fmt.Sprintf(` -

Hi!

+// Link is the absolute link that opens the invitation. +func (in InviteInput) Link() string { + return in.Site.Abs("invite", in.InviteID) +} -

Welcome to pcom! Please follow the link to choose a username. You will log in with a code we mail you, so there is no password to set.

+var inviteMail = declare("user_invitation", "invite", inviteSamples) - %s`, html.EscapeString(link), html.EscapeString(link)), +func inviteSamples() []Sample[InviteInput] { + return []Sample[InviteInput]{ + {Name: "send_actual_invitation", Input: InviteInput{ + Site: SampleSite, From: SampleFrom, InviteID: "invite-1", To: "newuser@example.test", + }}, } +} - return &Envelope{UniqueID: invite.ID, Type: "user_invitation", Mail: mail} +// Invitation is the mail that carries an invitation link to the address it +// was sent to. +func Invitation(site links.Site, from string, invite *model.UserInvitation, to string) *Envelope { + return inviteMail.MustRender(InviteInput{Site: site, From: from, InviteID: invite.ID, To: to}) } diff --git a/pkg/mail/login_code.go b/pkg/mail/login_code.go index fcc0e6e1..84246098 100644 --- a/pkg/mail/login_code.go +++ b/pkg/mail/login_code.go @@ -1,47 +1,40 @@ package mail -import ( - "fmt" - "html" - "net/mail" - "time" - - "github.com/can3p/gogo/sender" -) - -// LoginCode is the mail that carries the code for one login attempt to the -// address it was started with. -func LoginCode(from string, attemptID, to, code string, lifetime time.Duration) *Envelope { - minutes := int(lifetime.Minutes()) - - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: to, - }, - }, - Subject: "Your pcom login code", - Text: fmt.Sprintf(` - Hi! - - Your pcom login code is %s - - It works once, for the next %d minutes. +import "time" + +// LoginCodeInput is what the login code mail shows: the code of one login +// attempt and how long it works. +type LoginCodeInput struct { + From string + AttemptID string + To string + Code string + Lifetime time.Duration +} - If you didn't try to log in to pcom, ignore this mail: nobody can log in without the code.`, code, minutes), - Html: fmt.Sprintf(` -

Hi!

+// Header addresses the mail to the address the attempt was started with, +// unique per attempt. +func (in LoginCodeInput) Header() Header { + return Header{UniqueID: in.AttemptID, From: FromPcom(in.From), To: To(in.To)} +} -

Your pcom login code is %s

+// Minutes is the lifetime of the code in whole minutes. +func (in LoginCodeInput) Minutes() int { + return int(in.Lifetime.Minutes()) +} -

It works once, for the next %d minutes.

+var loginCodeMail = declare("login_code", "login_code", loginCodeSamples) -

If you didn't try to log in to pcom, ignore this mail: nobody can log in without the code.

`, html.EscapeString(code), minutes), +func loginCodeSamples() []Sample[LoginCodeInput] { + return []Sample[LoginCodeInput]{ + {Name: "login_code", Input: LoginCodeInput{ + From: SampleFrom, AttemptID: "attempt-1", To: "user@example.test", Code: "123456", Lifetime: 15 * time.Minute, + }}, } +} - return &Envelope{UniqueID: attemptID, Type: "login_code", Mail: mail} +// LoginCode is the mail that carries the code for one login attempt to the +// address it was started with. +func LoginCode(from string, attemptID, to, code string, lifetime time.Duration) *Envelope { + return loginCodeMail.MustRender(LoginCodeInput{From: from, AttemptID: attemptID, To: to, Code: code, Lifetime: lifetime}) } diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go new file mode 100644 index 00000000..54cbde85 --- /dev/null +++ b/pkg/mail/mail.go @@ -0,0 +1,256 @@ +// Package mail formats the mail pcom sends. It builds messages and never +// sends them: a service queues the Envelope a constructor returns with +// tx.SendMail, in the transaction of the change it reports. +// +// # Declaring a mail +// +// Every mail lives in its own file, pkg/mail/.go, where is its +// template name (snake_case, unique across the package). The file holds: +// +// - the input, a struct Input with everything the templates show and +// a Header method returning the unique id, the from address and the +// recipients; values the templates need derived (minutes from a duration, +// an absolute link) are methods on the input, so the templates stay +// logic-free; +// - the declaration, a package-level +// var Mail = declare("", "", Samples), +// where is the outgoing queue's email_type. declare +// registers the mail itself, so adding a mail touches no shared list; +// - the samples, func Samples() []Sample[Input], returning named +// inputs built from in-memory model structs with fixed values (SampleFrom, +// SampleSite, literal ids, emails and usernames), never a database. A +// sample is named after the golden it reproduces, +// testdata/.golden, and sample names are unique across the +// package; +// - the public constructor, which keeps its signature: it returns nil +// before rendering when there is nobody to notify, builds the input and +// returns Mail.Render(in), or Mail.MustRender(in) when its +// signature has no error. +// +// # Templates +// +// The templates are embedded from pkg/mail/templates/: .subject.txt and +// .txt are text/template, .html is html/template; the input is +// the dot. They are parsed once, when declare runs at package init, and a +// missing or broken template panics there rather than at send time. Render +// trims the trailing whitespace of each part, so a template file may end with +// a newline, and the leading whitespace of the subject, which is one line. +// The leading whitespace of the text and HTML parts is kept: a mail whose +// golden starts with a blank line has a template that starts with one. +// +// html/template escapes every value, so user content (usernames, subjects, +// comment text, links) goes into the input as a plain string. A field of type +// template.HTML is the one way trusted HTML enters a template: a markdown +// body rendered with markdown.ToEnrichedTemplate is such a field, and nothing +// else is wrapped in template.HTML. +// +// # Registry +// +// All lists every declared mail with its samples rendered, sorted by template +// name. It is the only list of mails: the golden test (TestMails_Goldens) +// renders every sample into its golden file and the preview page shows them. +package mail + +import ( + "bytes" + "embed" + htmltemplate "html/template" + "io" + "maps" + "net/mail" + "slices" + "strings" + texttemplate "text/template" + "unicode" + + "github.com/can3p/gogo/sender" + "github.com/can3p/pcom/pkg/links" + "github.com/pkg/errors" +) + +// Envelope is a mail with the id and type the outgoing queue files it under. +// The functions of this package build messages; the services send them. +type Envelope struct { + UniqueID string + Type string + Mail *sender.Mail +} + +// Header is the addressing of one mail: the unique id the queue drops repeats +// by, and who it is from and to. +type Header struct { + UniqueID string + From mail.Address + To []mail.Address +} + +// Input is what a declared mail renders: the template data, which also knows +// its own addressing. +type Input interface { + Header() Header +} + +// Sample is a named input a mail is previewed and golden-tested with. +type Sample[In Input] struct { + Name string + Input In +} + +// SampleFrom is the from address samples use. +const SampleFrom = "noreply@pcom.test" + +// SampleSite is the site samples format their links for. +var SampleSite = links.Site{Root: "https://pcom.test"} + +// FromPcom is the from address of every pcom mail, at address from. +func FromPcom(from string) mail.Address { + return mail.Address{Address: from, Name: "Your pcom"} +} + +// To is the recipient list of a mail to one address. +func To(address string) []mail.Address { + return []mail.Address{{Address: address}} +} + +//go:embed templates +var templatesFS embed.FS + +// Definition is one declared mail: its email type, its parsed templates and +// its samples. +type Definition[In Input] struct { + emailType string + name string + subject *texttemplate.Template + text *texttemplate.Template + html *htmltemplate.Template + samples func() []Sample[In] +} + +// Type is the queue's email_type of the mail. +func (d *Definition[In]) Type() string { return d.emailType } + +// Name is the template name of the mail. +func (d *Definition[In]) Name() string { return d.name } + +// Render executes the templates on in and addresses the result. +func (d *Definition[In]) Render(in In) (*Envelope, error) { + subject, err := execute(d.subject, in) + if err != nil { + return nil, errors.Wrapf(err, "mail %s: subject", d.name) + } + + text, err := execute(d.text, in) + if err != nil { + return nil, errors.Wrapf(err, "mail %s: text", d.name) + } + + html, err := execute(d.html, in) + if err != nil { + return nil, errors.Wrapf(err, "mail %s: html", d.name) + } + + subject = strings.TrimSpace(subject) + h := in.Header() + + return &Envelope{ + UniqueID: h.UniqueID, + Type: d.emailType, + Mail: &sender.Mail{ + From: h.From, + To: h.To, + Subject: subject, + Text: text, + Html: html, + }, + }, nil +} + +// MustRender is Render for constructors that cannot return an error. A +// template that fails on a well-formed input is a programming error, which +// the golden test catches, so it panics. +func (d *Definition[In]) MustRender(in In) *Envelope { + e, err := d.Render(in) + if err != nil { + panic(err) + } + + return e +} + +func execute(t interface { + Execute(w io.Writer, data any) error +}, data any) (string, error) { + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return "", err + } + + return strings.TrimRightFunc(buf.String(), unicode.IsSpace), nil +} + +// RenderedSample is one sample of a registered mail, rendered. +type RenderedSample struct { + Name string + Envelope *Envelope + Err error +} + +// Registered is one declared mail as All lists it. +type Registered struct { + Type string + Name string + Samples []RenderedSample +} + +type registration interface { + register() Registered +} + +func (d *Definition[In]) register() Registered { + r := Registered{Type: d.emailType, Name: d.name} + + for _, s := range d.samples() { + e, err := d.Render(s.Input) + r.Samples = append(r.Samples, RenderedSample{Name: s.Name, Envelope: e, Err: err}) + } + + return r +} + +// registry holds every declared mail by name. declare fills it during +// package init, before anything reads it. +var registry = map[string]registration{} + +// declare parses the templates of the mail name and registers it. It panics +// when a template is missing or does not parse, or when name is declared +// twice, so a broken mail stops the binary at init. +func declare[In Input](emailType, name string, samples func() []Sample[In]) *Definition[In] { + d := &Definition[In]{ + emailType: emailType, + name: name, + subject: texttemplate.Must(texttemplate.New(name+".subject.txt").ParseFS(templatesFS, "templates/"+name+".subject.txt")), + text: texttemplate.Must(texttemplate.New(name+".txt").ParseFS(templatesFS, "templates/"+name+".txt")), + html: htmltemplate.Must(htmltemplate.New(name+".html").ParseFS(templatesFS, "templates/"+name+".html")), + samples: samples, + } + + if _, ok := registry[name]; ok { + panic("mail " + name + " is declared twice") + } + + registry[name] = d + + return d +} + +// All lists every declared mail with its samples rendered, sorted by name. +func All() []Registered { + names := slices.Sorted(maps.Keys(registry)) + + out := make([]Registered, 0, len(names)) + for _, name := range names { + out = append(out, registry[name].register()) + } + + return out +} diff --git a/pkg/mail/mail_test.go b/pkg/mail/mail_test.go index 8f7cb4b1..9f484361 100644 --- a/pkg/mail/mail_test.go +++ b/pkg/mail/mail_test.go @@ -1,18 +1,11 @@ package mail_test import ( - "bytes" "context" - "fmt" - "testing" - "time" - "github.com/can3p/pcom/pkg/links" "github.com/can3p/pcom/pkg/mail" "github.com/can3p/pcom/pkg/model" "github.com/can3p/pcom/pkg/testutil/fakesender" - "github.com/can3p/pcom/pkg/testutil/golden" - "github.com/stretchr/testify/require" ) const testFrom = "noreply@pcom.test" @@ -24,8 +17,8 @@ func setPostURL(post *model.Post, url *model.NormalizedURL) { // deliver queues out on s, the way the posts service does, so the tests read // what a recipient would get. A nil out sends nothing. -func deliver(ctx context.Context, s *fakesender.Sender) func(*mail.Outgoing) error { - return func(out *mail.Outgoing) error { +func deliver(ctx context.Context, s *fakesender.Sender) func(*mail.Envelope) error { + return func(out *mail.Envelope) error { if out == nil { return nil } @@ -35,8 +28,8 @@ func deliver(ctx context.Context, s *fakesender.Sender) func(*mail.Outgoing) err } // deliverE is deliver for the formatters that can fail. -func deliverE(ctx context.Context, s *fakesender.Sender) func(*mail.Outgoing, error) error { - return func(out *mail.Outgoing, err error) error { +func deliverE(ctx context.Context, s *fakesender.Sender) func(*mail.Envelope, error) error { + return func(out *mail.Envelope, err error) error { if err != nil { return err } @@ -45,614 +38,7 @@ func deliverE(ctx context.Context, s *fakesender.Sender) func(*mail.Outgoing, er } } -// mailsToGolden serializes fakesender recordings to a human-readable format for golden testing -func mailsToGolden(sent []fakesender.Recorded) []byte { - var buf bytes.Buffer - for i, rec := range sent { - if i > 0 { - buf.WriteString("\n---\n\n") - } - fmt.Fprintf(&buf, "EmailType: %s\n", rec.EmailType) - fmt.Fprintf(&buf, "UniqueID: %s\n", rec.UniqueID) - fmt.Fprintf(&buf, "From: %s <%s>\n", rec.Mail.From.Name, rec.Mail.From.Address) - if len(rec.Mail.To) > 0 { - fmt.Fprintf(&buf, "To: %s\n", rec.Mail.To[0].Address) - } - fmt.Fprintf(&buf, "Subject: %s\n", rec.Mail.Subject) - fmt.Fprintf(&buf, "\n--- TEXT ---\n%s\n", rec.Mail.Text) - fmt.Fprintf(&buf, "\n--- HTML ---\n%s\n", rec.Mail.Html) - } - return buf.Bytes() -} - // send queues an envelope the way a service does, through the sender with no database. func send(ctx context.Context, s *fakesender.Sender, e *mail.Envelope) error { return s.Send(ctx, nil, e.UniqueID, e.Type, e.Mail) } - -func TestConfirmSignup(t *testing.T) { - t.Parallel() - - sender := fakesender.New() - - require.NoError(t, send(context.Background(), sender, mail.ConfirmSignup(testFrom, "attempt-1", "user@example.test", "123456", 15*time.Minute))) - - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "confirm_signup", mailsToGolden(sent)) -} - -func TestNewPost(t *testing.T) { - t.Parallel() - - user := &model.User{ - ID: "user-1", - Email: "user@example.test", - Username: "alice", - } - connection := &model.User{ - ID: "user-2", - Email: "connection@example.test", - Username: "bob", - } - post := &model.Post{ - ID: "post-1", - Subject: new("Test Post"), - Body: "This is a test post body", - UserID: user.ID, - } - - sender := fakesender.New() - ctx := context.Background() - mediaReplacer := func(in string) (bool, string) { return false, in } - - err := deliver(ctx, sender)(mail.NewPost(links.Site{}, testFrom, mediaReplacer, user, connection, post)) - require.NoError(t, err) - - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "new_post", mailsToGolden(sent)) -} - -func TestNewPost_NotToMyself(t *testing.T) { - t.Parallel() - - user := &model.User{ - ID: "user-1", - Email: "user@example.test", - Username: "alice", - } - post := &model.Post{ - ID: "post-1", - Subject: new("Test Post"), - Body: "This is a test post body", - UserID: user.ID, - } - - sender := fakesender.New() - ctx := context.Background() - mediaReplacer := func(in string) (bool, string) { return false, in } - - // Passing the same user as both author and connection should skip sending - err := deliver(ctx, sender)(mail.NewPost(links.Site{}, testFrom, mediaReplacer, user, user, post)) - require.NoError(t, err) - - sent := sender.Sent() - require.Empty(t, sent, "should not send email to self") -} - -func TestPostCommentAuthor(t *testing.T) { - t.Parallel() - - commenter := &model.User{ - ID: "user-1", - Email: "commenter@example.test", - Username: "alice", - } - author := &model.User{ - ID: "user-2", - Email: "author@example.test", - Username: "bob", - } - post := &model.Post{ - ID: "post-1", - Subject: new("Original Post"), - Body: "Post body", - UserID: author.ID, - } - comment := &model.PostComment{ - ID: "comment-1", - PostID: post.ID, - UserID: commenter.ID, - Body: "Nice post!", - } - - sender := fakesender.New() - ctx := context.Background() - mediaReplacer := func(in string) (bool, string) { return false, in } - - err := deliverE(ctx, sender)(mail.PostCommentAuthor(links.Site{}, testFrom, mediaReplacer, commenter, author, post, comment, false)) - require.NoError(t, err) - - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "post_comment_author", mailsToGolden(sent)) -} - -func TestPostCommentAuthor_NotToMyself(t *testing.T) { - t.Parallel() - - user := &model.User{ - ID: "user-1", - Email: "user@example.test", - Username: "alice", - } - post := &model.Post{ - ID: "post-1", - Subject: new("Original Post"), - Body: "Post body", - UserID: user.ID, - } - comment := &model.PostComment{ - ID: "comment-1", - PostID: post.ID, - UserID: user.ID, - Body: "Nice post!", - } - - sender := fakesender.New() - ctx := context.Background() - mediaReplacer := func(in string) (bool, string) { return false, in } - - // Passing the same user as both commenter and author should skip sending - err := deliverE(ctx, sender)(mail.PostCommentAuthor(links.Site{}, testFrom, mediaReplacer, user, user, post, comment, false)) - require.NoError(t, err) - - sent := sender.Sent() - require.Empty(t, sent, "should not send email to self") -} - -func TestPostCommentParticipants(t *testing.T) { - t.Parallel() - - commenter := &model.User{ - ID: "user-1", - Email: "commenter@example.test", - Username: "alice", - } - participant := &model.User{ - ID: "user-2", - Email: "participant@example.test", - Username: "bob", - } - post := &model.Post{ - ID: "post-1", - Subject: new("Original Post"), - Body: "Post body", - UserID: "user-3", - } - comment := &model.PostComment{ - ID: "comment-1", - PostID: post.ID, - UserID: commenter.ID, - Body: "Great comment!", - } - - sender := fakesender.New() - ctx := context.Background() - mediaReplacer := func(in string) (bool, string) { return false, in } - - err := deliverE(ctx, sender)(mail.PostCommentParticipants(links.Site{}, testFrom, mediaReplacer, commenter, participant, post, comment, false)) - require.NoError(t, err) - - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "post_comment_participants", mailsToGolden(sent)) -} - -func TestPostCommentParticipants_NotToMyself(t *testing.T) { - t.Parallel() - - user := &model.User{ - ID: "user-1", - Email: "user@example.test", - Username: "alice", - } - post := &model.Post{ - ID: "post-1", - Subject: new("Original Post"), - Body: "Post body", - UserID: "user-3", - } - comment := &model.PostComment{ - ID: "comment-1", - PostID: post.ID, - UserID: user.ID, - Body: "Great comment!", - } - - sender := fakesender.New() - ctx := context.Background() - mediaReplacer := func(in string) (bool, string) { return false, in } - - // Passing the same user as both commenter and participant should skip sending - err := deliverE(ctx, sender)(mail.PostCommentParticipants(links.Site{}, testFrom, mediaReplacer, user, user, post, comment, false)) - require.NoError(t, err) - - sent := sender.Sent() - require.Empty(t, sent, "should not send email to self") -} - -func TestPostPrompt(t *testing.T) { - t.Parallel() - - asker := &model.User{ - ID: "user-1", - Email: "asker@example.test", - Username: "alice", - } - recipient := &model.User{ - ID: "user-2", - Email: "recipient@example.test", - Username: "bob", - } - prompt := &model.PostPrompt{ - ID: "prompt-1", - AskerID: asker.ID, - RecipientID: recipient.ID, - Message: "What is your favorite hobby?", - } - - sender := fakesender.New() - ctx := context.Background() - - err := deliver(ctx, sender)(mail.PostPrompt(links.Site{}, testFrom, asker, recipient, prompt)) - require.NoError(t, err) - - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "post_prompt", mailsToGolden(sent)) -} - -func TestPostPrompt_NotToMyself(t *testing.T) { - t.Parallel() - - user := &model.User{ - ID: "user-1", - Email: "user@example.test", - Username: "alice", - } - prompt := &model.PostPrompt{ - ID: "prompt-1", - AskerID: user.ID, - RecipientID: user.ID, - Message: "What is your favorite hobby?", - } - - sender := fakesender.New() - ctx := context.Background() - - // Passing the same user as both asker and recipient should skip sending - err := deliver(ctx, sender)(mail.PostPrompt(links.Site{}, testFrom, user, user, prompt)) - require.NoError(t, err) - - sent := sender.Sent() - require.Empty(t, sent, "should not send email to self") -} - -func TestPostPromptAnswer(t *testing.T) { - t.Parallel() - - asker := &model.User{ - ID: "user-1", - Email: "asker@example.test", - Username: "alice", - } - responder := &model.User{ - ID: "user-2", - Email: "responder@example.test", - Username: "bob", - } - post := &model.Post{ - ID: "post-1", - Subject: new("My Answer"), - Body: "Here is my answer to the prompt", - UserID: responder.ID, - } - prompt := &model.PostPrompt{ - ID: "prompt-1", - AskerID: asker.ID, - RecipientID: responder.ID, - Message: "What is your favorite hobby?", - PostID: new(post.ID), - } - - sender := fakesender.New() - ctx := context.Background() - - err := deliver(ctx, sender)(mail.PostPromptAnswer(links.Site{}, testFrom, asker, responder, post, prompt)) - require.NoError(t, err) - - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "post_prompt_answer", mailsToGolden(sent)) -} - -func TestNewPost_NoSubject(t *testing.T) { - t.Parallel() - - user := &model.User{ - ID: "user-1", - Email: "user@example.test", - Username: "alice", - } - connection := &model.User{ - ID: "user-2", - Email: "connection@example.test", - Username: "bob", - } - post := &model.Post{ - ID: "post-1", - Subject: nil, // No subject - Body: "This is a test post body", - UserID: user.ID, - } - - sender := fakesender.New() - ctx := context.Background() - mediaReplacer := func(in string) (bool, string) { return false, in } - - err := deliver(ctx, sender)(mail.NewPost(links.Site{}, testFrom, mediaReplacer, user, connection, post)) - require.NoError(t, err) - - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "new_post_no_subject", mailsToGolden(sent)) -} - -func TestNewPost_WithLinkedURL(t *testing.T) { - t.Parallel() - - user := &model.User{ - ID: "user-1", - Email: "user@example.test", - Username: "alice", - } - connection := &model.User{ - ID: "user-2", - Email: "connection@example.test", - Username: "bob", - } - post := &model.Post{ - ID: "post-1", - Subject: new("Check out this article"), - Body: "This is a test post body", - UserID: user.ID, - URLID: new("url-1"), - } - // Post has a linked URL relation loaded - setPostURL(post, &model.NormalizedURL{ - ID: "url-1", - URL: "https://example.com/article", - }) - - sender := fakesender.New() - ctx := context.Background() - mediaReplacer := func(in string) (bool, string) { return false, in } - - err := deliver(ctx, sender)(mail.NewPost(links.Site{}, testFrom, mediaReplacer, user, connection, post)) - require.NoError(t, err) - - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "new_post_with_url", mailsToGolden(sent)) -} - -func TestNewPost_SubjectWithSpecialChars(t *testing.T) { - t.Parallel() - - user := &model.User{ - ID: "user-1", - Email: "user@example.test", - Username: "alice", - } - connection := &model.User{ - ID: "user-2", - Email: "connection@example.test", - Username: "bob", - } - post := &model.Post{ - ID: "post-1", - Subject: new(`Test bold and "quotes"`), - Body: "This is a test post body", - UserID: user.ID, - } - - sender := fakesender.New() - ctx := context.Background() - mediaReplacer := func(in string) (bool, string) { return false, in } - - err := deliver(ctx, sender)(mail.NewPost(links.Site{}, testFrom, mediaReplacer, user, connection, post)) - require.NoError(t, err) - - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "new_post_special_chars", mailsToGolden(sent)) -} - -func TestNewPost_EmptyBody(t *testing.T) { - t.Parallel() - - user := &model.User{ - ID: "user-1", - Email: "user@example.test", - Username: "alice", - } - connection := &model.User{ - ID: "user-2", - Email: "connection@example.test", - Username: "bob", - } - post := &model.Post{ - ID: "post-1", - Subject: new("Test Post"), - Body: "", // Empty body - UserID: user.ID, - } - - sender := fakesender.New() - ctx := context.Background() - mediaReplacer := func(in string) (bool, string) { return false, in } - - err := deliver(ctx, sender)(mail.NewPost(links.Site{}, testFrom, mediaReplacer, user, connection, post)) - require.NoError(t, err) - - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "new_post_empty_body", mailsToGolden(sent)) -} - -func TestPostCommentAuthor_WithURL(t *testing.T) { - t.Parallel() - - commenter := &model.User{ - ID: "user-1", - Email: "commenter@example.test", - Username: "alice", - } - author := &model.User{ - ID: "user-2", - Email: "author@example.test", - Username: "bob", - } - post := &model.Post{ - ID: "post-1", - Subject: new("Original Post"), - Body: "Post body", - UserID: author.ID, - URLID: new("url-1"), - } - setPostURL(post, &model.NormalizedURL{ - ID: "url-1", - URL: "https://example.com/article", - }) - comment := &model.PostComment{ - ID: "comment-1", - PostID: post.ID, - UserID: commenter.ID, - Body: "Nice post!", - } - - sender := fakesender.New() - ctx := context.Background() - mediaReplacer := func(in string) (bool, string) { return false, in } - - err := deliverE(ctx, sender)(mail.PostCommentAuthor(links.Site{}, testFrom, mediaReplacer, commenter, author, post, comment, false)) - require.NoError(t, err) - - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "post_comment_author_with_url", mailsToGolden(sent)) -} - -func TestPostCommentParticipants_WithURL(t *testing.T) { - t.Parallel() - - commenter := &model.User{ - ID: "user-1", - Email: "commenter@example.test", - Username: "alice", - } - participant := &model.User{ - ID: "user-2", - Email: "participant@example.test", - Username: "bob", - } - post := &model.Post{ - ID: "post-1", - Subject: new("Original Post"), - Body: "Post body", - UserID: "user-3", - URLID: new("url-1"), - } - setPostURL(post, &model.NormalizedURL{ - ID: "url-1", - URL: "https://example.com/article", - }) - comment := &model.PostComment{ - ID: "comment-1", - PostID: post.ID, - UserID: commenter.ID, - Body: "Great comment!", - } - - sender := fakesender.New() - ctx := context.Background() - mediaReplacer := func(in string) (bool, string) { return false, in } - - err := deliverE(ctx, sender)(mail.PostCommentParticipants(links.Site{}, testFrom, mediaReplacer, commenter, participant, post, comment, false)) - require.NoError(t, err) - - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "post_comment_participants_with_url", mailsToGolden(sent)) -} - -func TestPostCommentEdited(t *testing.T) { - t.Parallel() - - commenter := &model.User{ID: "user-1", Email: "commenter@example.test", Username: "alice"} - recipient := &model.User{ID: "user-2", Email: "recipient@example.test", Username: "bob"} - post := &model.Post{ID: "post-1", Subject: new("Original Post"), Body: "Post body", UserID: recipient.ID} - comment := &model.PostComment{ - ID: "comment-1", - PostID: post.ID, - UserID: commenter.ID, - Body: "Nice post, edited!", - EditedAt: new(time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)), - } - mediaReplacer := func(in string) (bool, string) { return false, in } - - for name, format := range map[string]func() (*mail.Outgoing, error){ - "post_comment_author_edited": func() (*mail.Outgoing, error) { - return mail.PostCommentAuthor(links.Site{}, testFrom, mediaReplacer, commenter, recipient, post, comment, true) - }, - "post_comment_participants_edited": func() (*mail.Outgoing, error) { - return mail.PostCommentParticipants(links.Site{}, testFrom, mediaReplacer, commenter, recipient, post, comment, true) - }, - } { - t.Run(name, func(t *testing.T) { - t.Parallel() - - sender := fakesender.New() - require.NoError(t, deliverE(context.Background(), sender)(format())) - - sent := sender.Sent() - require.Len(t, sent, 1) - require.Contains(t, sent[0].UniqueID, "2026-09-30T12:00:00Z") - - golden.Assert(t, name, mailsToGolden(sent)) - }) - } -} - -func TestLoginCode(t *testing.T) { - t.Parallel() - - sender := fakesender.New() - require.NoError(t, send(context.Background(), sender, mail.LoginCode(testFrom, "attempt-1", "user@example.test", "123456", 15*time.Minute))) - - golden.Assert(t, "login_code", mailsToGolden(sender.Sent())) -} diff --git a/pkg/mail/new_post.go b/pkg/mail/new_post.go index 8791de44..96bd89de 100644 --- a/pkg/mail/new_post.go +++ b/pkg/mail/new_post.go @@ -1,11 +1,8 @@ package mail import ( - "fmt" - "html" - "net/mail" + htmltemplate "html/template" - "github.com/can3p/gogo/sender" "github.com/can3p/pcom/pkg/links" "github.com/can3p/pcom/pkg/markdown" "github.com/can3p/pcom/pkg/model" @@ -13,28 +10,35 @@ import ( "github.com/can3p/pcom/pkg/types" ) -// Outgoing is a mail ready to be queued: the unique id and the type that -// sender.Send takes along with the message. -type Outgoing struct { - UniqueID string - Type string - Mail *sender.Mail +// NewPostInput is what the new post mail shows: the author, the post and +// the connection it is sent to. +type NewPostInput struct { + From string + PostID string + ConnectionID string + To string + Username string + Subject string + // LinkedURL is the url the post links to, empty when it has none. + LinkedURL string + Link string + // Body is the post body rendered by markdown.ToEnrichedTemplate. + Body htmltemplate.HTML } -// NewPost formats the notification about a new post for one connection of its -// author. It returns nil when there is nobody to notify. -func NewPost(site links.Site, from string, mediaReplacer types.Replacer[string], user *model.User, connection *model.User, post *model.Post) *Outgoing { - // we're not sending email notifications to ourselves - if user.ID == connection.ID { - return nil - } +// Header addresses the mail to the connection, unique per post and +// connection. +func (in NewPostInput) Header() Header { + return Header{UniqueID: in.PostID + in.ConnectionID, From: FromPcom(in.From), To: To(in.To)} +} + +var newPostMail = declare("post_notification", "new_post", newPostSamples) - link := site.Abs("post", post.ID) - // there reason to omit body in the text version is that we should redo the logic with cut, gallery etc - // and I have no desire to spend time on that - htmlbody := markdown.ToEnrichedTemplate(post.Body, types.ViewEmail, mediaReplacer, func(in string, add2 ...string) string { +// newPostBody renders the markdown body of a post for the mail. +func newPostBody(site links.Site, postID, body string, mediaReplacer types.Replacer[string]) htmltemplate.HTML { + return markdown.ToEnrichedTemplate(body, types.ViewEmail, mediaReplacer, func(in string, add2 ...string) string { if in == "single_post_special" { - args := []string{post.ID} + args := []string{postID} args = append(args, add2...) return site.Abs("post", args...) @@ -42,49 +46,57 @@ func NewPost(site links.Site, from string, mediaReplacer types.Replacer[string], return site.Abs(in, add2...) }) +} - subject := postops.PostSubject(post.Subject) +func newPostSamples() []Sample[NewPostInput] { + keep := func(in string) (bool, string) { return false, in } - // Get linked URL if available - var urlText string - var htmlUrlSection string + sample := func(subject, linkedURL, body string) NewPostInput { + return NewPostInput{ + From: SampleFrom, PostID: "post-1", ConnectionID: "user-2", To: "connection@example.test", + Username: "alice", Subject: subject, LinkedURL: linkedURL, + Link: SampleSite.Abs("post", "post-1"), + Body: newPostBody(SampleSite, "post-1", body, keep), + } + } - if post.URL != nil { - urlText = fmt.Sprintf("\nLinked URL: %s", post.URL.URL) - htmlUrlSection = fmt.Sprintf(`

Linked URL: %s

`, html.EscapeString(post.URL.URL), html.EscapeString(post.URL.URL)) + const body = "This is a test post body" + + return []Sample[NewPostInput]{ + {Name: "new_post", Input: sample("Test Post", "", body)}, + {Name: "new_post_no_subject", Input: sample(postops.PostSubject(nil), "", body)}, + {Name: "new_post_with_url", Input: sample("Check out this article", "https://example.com/article", body)}, + {Name: "new_post_special_chars", Input: sample(`Test bold and "quotes"`, "", body)}, + {Name: "new_post_empty_body", Input: sample("Test Post", "", "")}, + {Name: "new_post_long_subject", Input: sample("A very long subject that goes on and on and on to see how the mail copes with a title that no one would reasonably write but someone surely will", "", body)}, + {Name: "new_post_markup_in_body", Input: sample("Test Post", "", `Hello and a link`)}, + } +} + +// NewPost formats the notification about a new post for one connection of its +// author. It returns nil when there is nobody to notify. +func NewPost(site links.Site, from string, mediaReplacer types.Replacer[string], user *model.User, connection *model.User, post *model.Post) *Envelope { + // we're not sending email notifications to ourselves + if user.ID == connection.ID { + return nil } - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: connection.Email, - }, - }, - Subject: fmt.Sprintf("New post \"%s\" from %s", subject, user.Username), - Text: fmt.Sprintf(`Hi! - -@%s has published a new post "%s"%s - -Head to the post to leave a comment! %s`, user.Username, subject, urlText, link), - Html: fmt.Sprintf(` -

Hi!

- -

@%s has published a new post "%s"

- -
%s
%s - -

Head to the post to leave a comment! %s

`, - html.EscapeString(user.Username), - html.EscapeString(subject), - htmlbody, - htmlUrlSection, - html.EscapeString(link), - html.EscapeString(link)), + in := NewPostInput{ + From: from, + PostID: post.ID, + ConnectionID: connection.ID, + To: connection.Email, + Username: user.Username, + Subject: postops.PostSubject(post.Subject), + Link: site.Abs("post", post.ID), + // there reason to omit body in the text version is that we should redo the logic with cut, gallery etc + // and I have no desire to spend time on that + Body: newPostBody(site, post.ID, post.Body, mediaReplacer), + } + + if post.URL != nil { + in.LinkedURL = post.URL.URL } - return &Outgoing{UniqueID: post.ID + connection.ID, Type: "post_notification", Mail: mail} + return newPostMail.MustRender(in) } diff --git a/pkg/mail/post_comment_author.go b/pkg/mail/post_comment_author.go index 20e61398..9473151f 100644 --- a/pkg/mail/post_comment_author.go +++ b/pkg/mail/post_comment_author.go @@ -1,84 +1,168 @@ package mail import ( - "fmt" - "github.com/samber/lo" - "html" - "net/mail" + "html/template" "strings" "time" - "github.com/can3p/gogo/sender" "github.com/can3p/pcom/pkg/links" "github.com/can3p/pcom/pkg/markdown" "github.com/can3p/pcom/pkg/model" "github.com/can3p/pcom/pkg/postops" "github.com/can3p/pcom/pkg/types" "github.com/pkg/errors" + "github.com/samber/lo" ) -// PostCommentAuthor formats the notification for the author of a post about -// a new comment, or an edited one when edited is set. It returns nil when -// there is nobody to notify. -func PostCommentAuthor(site links.Site, from string, mediaReplacer types.Replacer[string], user *model.User, author *model.User, post *model.Post, comment *model.PostComment, edited bool) (*Outgoing, error) { - // we're not sending email notifications to ourselves - if user.ID == author.ID { - return nil, nil +// postComment is what the two comment mails show: one comment, the post it +// belongs to and who it is for. The mails differ in their wording only. +type postComment struct { + From string + To string + UniqueUserID string + CommentID string + Commenter string + Subject string + // URL is the url the post links to, empty when it links to none. + URL string + // Link is the absolute link to the comment. + Link string + // Body is the comment as markdown with its image urls replaced. + Body string + // BodyHTML is the comment rendered for mail. + BodyHTML template.HTML + Edited bool + EditedAt time.Time +} + +// uniqueID is the comment and the recipient; every edit is a new mail, the +// queue drops a repeated unique id. +func (in postComment) uniqueID() string { + id := in.CommentID + in.UniqueUserID + if in.Edited { + id += in.EditedAt.UTC().Format(time.RFC3339Nano) } - link := site.Abs("comment", post.ID, comment.ID) + return id +} + +func (in postComment) header() Header { + return Header{UniqueID: in.uniqueID(), From: FromPcom(in.From), To: To(in.To)} +} + +// Quote is the comment as a text quote. +func (in postComment) Quote() string { + return "> " + strings.Join(strings.Split(in.Body, "\n"), "\n> ") +} + +// newPostComment maps the models of a comment mail to its input. +func newPostComment(site links.Site, from string, mediaReplacer types.Replacer[string], commenter, recipient *model.User, post *model.Post, comment *model.PostComment, edited bool) (postComment, error) { body, err := markdown.ReplaceImageUrls(comment.Body, mediaReplacer) if err != nil { // ReplaceImageUrls only fails if goldmark cannot render, which no input triggers, so no test covers this. - return nil, errors.Wrap(err, "failed to render the comment") + return postComment{}, errors.Wrap(err, "failed to render the comment") } - htmlBody := markdown.ToEnrichedTemplate(comment.Body, types.ViewEmail, mediaReplacer, site.Abs) - subject := postops.PostSubject(post.Subject) - - // Get linked URL if available - var urlText string - var htmlUrlSection string + in := postComment{ + From: from, + To: recipient.Email, + UniqueUserID: recipient.ID, + CommentID: comment.ID, + Commenter: commenter.Username, + Subject: postops.PostSubject(post.Subject), + Link: site.Abs("comment", post.ID, comment.ID), + Body: body, + BodyHTML: markdown.ToEnrichedTemplate(comment.Body, types.ViewEmail, mediaReplacer, site.Abs), + Edited: edited, + EditedAt: lo.FromPtr(comment.EditedAt), + } if post.URL != nil { - urlText = fmt.Sprintf("\nLinked URL: %s", post.URL.URL) - htmlUrlSection = fmt.Sprintf(`

Linked URL: %s

`, html.EscapeString(post.URL.URL), html.EscapeString(post.URL.URL)) + in.URL = post.URL.URL } - verb, subjectLine := "has left a comment in", "New comment in your post" - uniqueID := comment.ID + user.ID - if edited { - verb, subjectLine = "has edited a comment in", "Edited comment in your post" - // every edit is a new mail, the queue drops a repeated unique id - uniqueID += lo.FromPtr(comment.EditedAt).UTC().Format(time.RFC3339Nano) - } + return in, nil +} - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: author.Email, - }, - }, - Subject: fmt.Sprintf("%s \"%s\"", subjectLine, subject), - Text: fmt.Sprintf(`Hi! +type postCommentSample struct { + name string + in postComment +} -@%s %s your post "%s".%s +// postCommentSamples are the samples both comment mails share, written as +// literal inputs so that no mapper from models stands on both sides of the +// tests. name is the sample name of the plain one, the others add a suffix; +// to is the recipient's address and body the comment of the plain and the +// linked-url ones. +func postCommentSamples(name, to, body string) []postCommentSample { + const ( + commentLink = "https://pcom.test/posts/post-1#commentpost-1comment-1" + subject = "Original Post" + ) -%s + return []postCommentSample{ + {name, postComment{ + From: SampleFrom, To: to, UniqueUserID: "user-2", CommentID: "comment-1", Commenter: "alice", + Subject: subject, URL: "", Link: commentLink, Body: body, BodyHTML: template.HTML("

" + body + "

\n"), + Edited: false, EditedAt: time.Time{}, + }}, + {name + "_edited", postComment{ + From: SampleFrom, To: "recipient@example.test", UniqueUserID: "user-2", CommentID: "comment-1", Commenter: "alice", + Subject: subject, URL: "", Link: commentLink, Body: "Nice post, edited!", BodyHTML: "

Nice post, edited!

\n", + Edited: true, EditedAt: time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC), + }}, + {name + "_with_url", postComment{ + From: SampleFrom, To: to, UniqueUserID: "user-2", CommentID: "comment-1", Commenter: "alice", + Subject: subject, URL: "https://example.com/article", Link: commentLink, Body: body, BodyHTML: template.HTML("

" + body + "

\n"), + Edited: false, EditedAt: time.Time{}, + }}, + {name + "_markup", postComment{ + From: SampleFrom, To: to, UniqueUserID: "user-2", CommentID: "comment-1", Commenter: "alice", + Subject: subject, URL: "", Link: commentLink, + Body: "Nice **post**, see [the docs](https://example.com/a?b=1&c=2).\n\n- one\n- \n\n> quoted & \"done\"", + BodyHTML: "

Nice post, see the docs.

\n
    \n
  • one
  • \n
  • \n\n
  • \n
\n
\n

quoted & "done"

\n
\n", + Edited: false, EditedAt: time.Time{}, + }}, + {name + "_markup_long_subject", postComment{ + From: SampleFrom, To: to, UniqueUserID: "user-2", CommentID: "comment-1", Commenter: "alice", + Subject: "A very long & \"quoted\" subject that goes on and on to see how a mail client wraps a subject line of this length, past any sensible limit", + URL: "", Link: commentLink, Body: "Nice **post**!", BodyHTML: "

Nice post!

\n", + Edited: false, EditedAt: time.Time{}, + }}, + } +} -Checkout the comment in the post: %s`, user.Username, verb, subject, urlText, "> "+strings.Join(strings.Split(body, "\n"), "\n> "), link), - Html: fmt.Sprintf(` -

Hi!

+// PostCommentAuthorInput is what the mail to the author of a post about a +// comment shows. +type PostCommentAuthorInput struct{ postComment } -

@%s %s your post "%s".

%s +// Header addresses the mail to the author of the post. +func (in PostCommentAuthorInput) Header() Header { return in.header() } -
%s
+var postCommentAuthorMail = declare("comment_notification", "post_comment_author", postCommentAuthorSamples) -

Checkout the comment in the post.

`, html.EscapeString(user.Username), verb, html.EscapeString(subject), htmlUrlSection, htmlBody, html.EscapeString(link)), +func postCommentAuthorSamples() []Sample[PostCommentAuthorInput] { + return lo.Map(postCommentSamples("post_comment_author", "author@example.test", "Nice post!"), func(s postCommentSample, _ int) Sample[PostCommentAuthorInput] { + s.in.UniqueUserID = "user-1" // the commenter, as in PostCommentAuthor + return Sample[PostCommentAuthorInput]{Name: s.name, Input: PostCommentAuthorInput{s.in}} + }) +} + +// PostCommentAuthor formats the notification for the author of a post about +// a new comment, or an edited one when edited is set. It returns nil when +// there is nobody to notify. +func PostCommentAuthor(site links.Site, from string, mediaReplacer types.Replacer[string], user *model.User, author *model.User, post *model.Post, comment *model.PostComment, edited bool) (*Envelope, error) { + // we're not sending email notifications to ourselves + if user.ID == author.ID { + return nil, nil } - return &Outgoing{UniqueID: uniqueID, Type: "comment_notification", Mail: mail}, nil + in, err := newPostComment(site, from, mediaReplacer, user, author, post, comment, edited) + if err != nil { + return nil, err + } + + // the unique id of this mail has always been made of the commenter, not of the recipient + in.UniqueUserID = user.ID + + return postCommentAuthorMail.Render(PostCommentAuthorInput{in}) } diff --git a/pkg/mail/post_comment_participants.go b/pkg/mail/post_comment_participants.go index 4b8f4a76..13a48490 100644 --- a/pkg/mail/post_comment_participants.go +++ b/pkg/mail/post_comment_participants.go @@ -1,84 +1,40 @@ package mail import ( - "fmt" - "github.com/samber/lo" - "html" - "net/mail" - "strings" - "time" - - "github.com/can3p/gogo/sender" "github.com/can3p/pcom/pkg/links" - "github.com/can3p/pcom/pkg/markdown" "github.com/can3p/pcom/pkg/model" - "github.com/can3p/pcom/pkg/postops" "github.com/can3p/pcom/pkg/types" - "github.com/pkg/errors" + "github.com/samber/lo" ) +// PostCommentParticipantsInput is what the mail to a user who commented on a +// post earlier shows about a new comment. +type PostCommentParticipantsInput struct{ postComment } + +// Header addresses the mail to the participant. +func (in PostCommentParticipantsInput) Header() Header { return in.header() } + +var postCommentParticipantsMail = declare("comment_notification", "post_comment_participants", postCommentParticipantsSamples) + +func postCommentParticipantsSamples() []Sample[PostCommentParticipantsInput] { + return lo.Map(postCommentSamples("post_comment_participants", "participant@example.test", "Great comment!"), func(s postCommentSample, _ int) Sample[PostCommentParticipantsInput] { + return Sample[PostCommentParticipantsInput]{Name: s.name, Input: PostCommentParticipantsInput{s.in}} + }) +} + // PostCommentParticipants formats the notification about a new comment for a // user who commented on the post earlier. It returns nil when there is nobody // to notify. With edited set it is about an edited comment instead. -func PostCommentParticipants(site links.Site, from string, mediaReplacer types.Replacer[string], commentAuthor *model.User, participant *model.User, post *model.Post, comment *model.PostComment, edited bool) (*Outgoing, error) { +func PostCommentParticipants(site links.Site, from string, mediaReplacer types.Replacer[string], commentAuthor *model.User, participant *model.User, post *model.Post, comment *model.PostComment, edited bool) (*Envelope, error) { // we're not sending email notifications to ourselves if commentAuthor.ID == participant.ID { return nil, nil } - link := site.Abs("comment", post.ID, comment.ID) - body, err := markdown.ReplaceImageUrls(comment.Body, mediaReplacer) + in, err := newPostComment(site, from, mediaReplacer, commentAuthor, participant, post, comment, edited) if err != nil { - // ReplaceImageUrls only fails if goldmark cannot render, which no input triggers, so no test covers this. - return nil, errors.Wrap(err, "failed to render the comment") - } - htmlBody := markdown.ToEnrichedTemplate(comment.Body, types.ViewEmail, mediaReplacer, site.Abs) - - subject := postops.PostSubject(post.Subject) - - // Get linked URL if available - var urlText string - var htmlUrlSection string - if post.URL != nil { - urlText = fmt.Sprintf("\nLinked URL: %s", post.URL.URL) - htmlUrlSection = fmt.Sprintf(`

Linked URL: %s

`, html.EscapeString(post.URL.URL), html.EscapeString(post.URL.URL)) - } - - verb, subjectLine := "has left a comment in", "New comment in the post" - uniqueID := comment.ID + participant.ID - if edited { - verb, subjectLine = "has edited a comment in", "Edited comment in the post" - // every edit is a new mail, the queue drops a repeated unique id - uniqueID += lo.FromPtr(comment.EditedAt).UTC().Format(time.RFC3339Nano) - } - - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: participant.Email, - }, - }, - Subject: fmt.Sprintf("%s \"%s\"", subjectLine, subject), - Text: fmt.Sprintf(`Hi! - -@%s %s the post "%s" where you've also left a comment.%s - -%s - -Checkout the comment in the post: %s`, commentAuthor.Username, verb, subject, urlText, "> "+strings.Join(strings.Split(body, "\n"), "\n> "), link), - Html: fmt.Sprintf(` -

Hi!

- -

@%s %s the post "%s" where you've also left a comment.

%s - -
%s
- -

Checkout the comment in the post.

`, html.EscapeString(commentAuthor.Username), verb, html.EscapeString(subject), htmlUrlSection, htmlBody, html.EscapeString(link)), + return nil, err } - return &Outgoing{UniqueID: uniqueID, Type: "comment_notification", Mail: mail}, nil + return postCommentParticipantsMail.Render(PostCommentParticipantsInput{in}) } diff --git a/pkg/mail/post_prompt.go b/pkg/mail/post_prompt.go index 55a7318e..17f1f677 100644 --- a/pkg/mail/post_prompt.go +++ b/pkg/mail/post_prompt.go @@ -1,48 +1,57 @@ package mail import ( - "fmt" - "html" - "net/mail" - - "github.com/can3p/gogo/sender" "github.com/can3p/pcom/pkg/links" "github.com/can3p/pcom/pkg/model" ) -// PostPrompt formats the notification about a prompt for its recipient. It -// returns nil when there is nobody to notify. -func PostPrompt(site links.Site, from string, asker *model.User, recipient *model.User, postPrompt *model.PostPrompt) *Outgoing { - // we're not sending email notifications to ourselves - if asker.ID == recipient.ID { - return nil - } - - link := site.Abs("write", "prompt", postPrompt.ID) +// PostPromptInput is what the post prompt mail shows: who asks, what they +// ask and where to answer. +type PostPromptInput struct { + From string + PromptID string + To string + Asker string + Message string + Link string +} - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: recipient.Email, - }, - }, - Subject: fmt.Sprintf("New prompt from %s", asker.Username), - Text: fmt.Sprintf(`Hi! +// Header addresses the mail to the recipient of the prompt, unique per +// prompt. +func (in PostPromptInput) Header() Header { + return Header{UniqueID: in.PromptID, From: FromPcom(in.From), To: To(in.To)} +} -@%s has asked you to write a post on "%s" +var postPromptMail = declare("post_prompt", "post_prompt", postPromptSamples) -Head to new post page to give an update! %s`, asker.Username, postPrompt.Message, link), - Html: fmt.Sprintf(` -

Hi!

+func postPromptSamples() []Sample[PostPromptInput] { + sample := func(message string) PostPromptInput { + return PostPromptInput{ + From: SampleFrom, PromptID: "prompt-1", To: "recipient@example.test", Asker: "alice", + Message: message, Link: SampleSite.Abs("write", "prompt", "prompt-1"), + } + } -

@%s has asked you to write a post on "%s"

+ return []Sample[PostPromptInput]{ + {Name: "post_prompt", Input: sample("What is your favorite hobby?")}, + {Name: "post_prompt_markup", Input: sample(`What is bold & "quoted" ?`)}, + } +} -

Head to new post page to give an update! %s

`, html.EscapeString(asker.Username), html.EscapeString(postPrompt.Message), html.EscapeString(link), html.EscapeString(link)), +// PostPrompt formats the notification about a prompt for its recipient. It +// returns nil when there is nobody to notify. +func PostPrompt(site links.Site, from string, asker *model.User, recipient *model.User, postPrompt *model.PostPrompt) *Envelope { + // we're not sending email notifications to ourselves + if asker.ID == recipient.ID { + return nil } - return &Outgoing{UniqueID: postPrompt.ID, Type: "post_prompt", Mail: mail} + return postPromptMail.MustRender(PostPromptInput{ + From: from, + PromptID: postPrompt.ID, + To: recipient.Email, + Asker: asker.Username, + Message: postPrompt.Message, + Link: site.Abs("write", "prompt", postPrompt.ID), + }) } diff --git a/pkg/mail/post_prompt_answer.go b/pkg/mail/post_prompt_answer.go index 854f03b6..f1683cc2 100644 --- a/pkg/mail/post_prompt_answer.go +++ b/pkg/mail/post_prompt_answer.go @@ -1,47 +1,51 @@ package mail import ( - "fmt" - "github.com/samber/lo" - "html" - "net/mail" - - "github.com/can3p/gogo/sender" "github.com/can3p/pcom/pkg/links" "github.com/can3p/pcom/pkg/model" "github.com/can3p/pcom/pkg/postops" + "github.com/samber/lo" ) -// PostPromptAnswer formats the notification for the asker of a prompt that -// the recipient answered with a post. -func PostPromptAnswer(site links.Site, from string, asker, recipient *model.User, post *model.Post, postPrompt *model.PostPrompt) *Outgoing { - link := site.Abs("post", lo.FromPtr(postPrompt.PostID)) - - subject := postops.PostSubject(post.Subject) - - mail := &sender.Mail{ - From: mail.Address{ - Address: from, - Name: "Your pcom", - }, - To: []mail.Address{ - { - Address: asker.Email, - }, - }, - Subject: fmt.Sprintf("Response to your prompt from %s", recipient.Username), - Text: fmt.Sprintf(`Hi! - -@%s has responded on your prompt "%s" with the post "%s" +// PostPromptAnswerInput is what the post prompt answer mail shows: the +// prompt, who answered it and the post they answered with. +type PostPromptAnswerInput struct { + From string + PostID string + To string + Responder string + Message string + Subject string + Link string +} -Check out their post! %s`, recipient.Username, postPrompt.Message, subject, link), - Html: fmt.Sprintf(` -

Hi!

+// Header addresses the mail to the asker of the prompt, unique per +// answering post. +func (in PostPromptAnswerInput) Header() Header { + return Header{UniqueID: in.PostID, From: FromPcom(in.From), To: To(in.To)} +} -

@%s has responded on your prompt "%s" with the post "%s"

+var postPromptAnswerMail = declare("post_prompt_answer", "post_prompt_answer", postPromptAnswerSamples) -

Head to new post page to give an update! %s

`, html.EscapeString(recipient.Username), html.EscapeString(postPrompt.Message), html.EscapeString(subject), html.EscapeString(link), html.EscapeString(link)), +func postPromptAnswerSamples() []Sample[PostPromptAnswerInput] { + return []Sample[PostPromptAnswerInput]{ + {Name: "post_prompt_answer", Input: PostPromptAnswerInput{ + From: SampleFrom, PostID: "post-2", To: "asker@example.test", Responder: "bob", + Message: "What is your favorite hobby?", Subject: "My Answer", Link: SampleSite.Abs("post", "post-1"), + }}, } +} - return &Outgoing{UniqueID: post.ID, Type: "post_prompt_answer", Mail: mail} +// PostPromptAnswer formats the notification for the asker of a prompt that +// the recipient answered with a post. +func PostPromptAnswer(site links.Site, from string, asker, recipient *model.User, post *model.Post, postPrompt *model.PostPrompt) *Envelope { + return postPromptAnswerMail.MustRender(PostPromptAnswerInput{ + From: from, + PostID: post.ID, + To: asker.Email, + Responder: recipient.Username, + Message: postPrompt.Message, + Subject: postops.PostSubject(post.Subject), + Link: site.Abs("post", lo.FromPtr(postPrompt.PostID)), + }) } diff --git a/pkg/mail/posts_test.go b/pkg/mail/posts_test.go new file mode 100644 index 00000000..e7e5c3dd --- /dev/null +++ b/pkg/mail/posts_test.go @@ -0,0 +1,142 @@ +package mail_test + +import ( + "context" + "testing" + + "github.com/can3p/pcom/pkg/links" + "github.com/can3p/pcom/pkg/mail" + "github.com/can3p/pcom/pkg/model" + "github.com/can3p/pcom/pkg/testutil/fakesender" + "github.com/stretchr/testify/require" +) + +func keepMedia(in string) (bool, string) { return false, in } + +func postUsers() (user, connection *model.User) { + return &model.User{ID: "user-1", Email: "user@example.test", Username: "alice"}, + &model.User{ID: "user-2", Email: "connection@example.test", Username: "bob"} +} + +func TestNewPost_MatchesSamples(t *testing.T) { + t.Parallel() + + user, connection := postUsers() + newPost := func(subject *string, body string) *model.Post { + return &model.Post{ID: "post-1", Subject: subject, Body: body, UserID: user.ID} + } + withURL := newPost(new("Check out this article"), "This is a test post body") + withURL.URLID = new("url-1") + setPostURL(withURL, &model.NormalizedURL{ID: "url-1", URL: "https://example.com/article"}) + + tests := []struct { + sample string + post *model.Post + }{ + {"new_post", newPost(new("Test Post"), "This is a test post body")}, + {"new_post_no_subject", newPost(nil, "This is a test post body")}, + {"new_post_with_url", withURL}, + {"new_post_special_chars", newPost(new(`Test bold and "quotes"`), "This is a test post body")}, + {"new_post_empty_body", newPost(new("Test Post"), "")}, + } + + for _, tt := range tests { + t.Run(tt.sample, func(t *testing.T) { + t.Parallel() + + got := mail.NewPost(mail.SampleSite, testFrom, keepMedia, user, connection, tt.post) + require.Equal(t, sampleEnvelope(t, tt.sample), got) + }) + } +} + +func TestNewPost_NotToMyself(t *testing.T) { + t.Parallel() + + user := &model.User{ + ID: "user-1", + Email: "user@example.test", + Username: "alice", + } + post := &model.Post{ + ID: "post-1", + Subject: new("Test Post"), + Body: "This is a test post body", + UserID: user.ID, + } + + sender := fakesender.New() + ctx := context.Background() + + // Passing the same user as both author and connection should skip sending + err := deliver(ctx, sender)(mail.NewPost(links.Site{}, testFrom, keepMedia, user, user, post)) + require.NoError(t, err) + + sent := sender.Sent() + require.Empty(t, sent, "should not send email to self") +} + +func TestPostPrompt_MatchesSample(t *testing.T) { + t.Parallel() + + asker := &model.User{ID: "user-1", Email: "asker@example.test", Username: "alice"} + recipient := &model.User{ID: "user-2", Email: "recipient@example.test", Username: "bob"} + prompt := &model.PostPrompt{ + ID: "prompt-1", + AskerID: asker.ID, + RecipientID: recipient.ID, + Message: "What is your favorite hobby?", + } + + got := mail.PostPrompt(mail.SampleSite, testFrom, asker, recipient, prompt) + require.Equal(t, sampleEnvelope(t, "post_prompt"), got) +} + +func TestPostPrompt_NotToMyself(t *testing.T) { + t.Parallel() + + user := &model.User{ + ID: "user-1", + Email: "user@example.test", + Username: "alice", + } + prompt := &model.PostPrompt{ + ID: "prompt-1", + AskerID: user.ID, + RecipientID: user.ID, + Message: "What is your favorite hobby?", + } + + sender := fakesender.New() + ctx := context.Background() + + // Passing the same user as both asker and recipient should skip sending + err := deliver(ctx, sender)(mail.PostPrompt(links.Site{}, testFrom, user, user, prompt)) + require.NoError(t, err) + + sent := sender.Sent() + require.Empty(t, sent, "should not send email to self") +} + +func TestPostPromptAnswer_MatchesSample(t *testing.T) { + t.Parallel() + + asker := &model.User{ID: "user-1", Email: "asker@example.test", Username: "alice"} + responder := &model.User{ID: "user-2", Email: "responder@example.test", Username: "bob"} + post := &model.Post{ + ID: "post-2", + Subject: new("My Answer"), + Body: "Here is my answer to the prompt", + UserID: responder.ID, + } + prompt := &model.PostPrompt{ + ID: "prompt-1", + AskerID: asker.ID, + RecipientID: responder.ID, + Message: "What is your favorite hobby?", + PostID: new("post-1"), + } + + got := mail.PostPromptAnswer(mail.SampleSite, testFrom, asker, responder, post, prompt) + require.Equal(t, sampleEnvelope(t, "post_prompt_answer"), got) +} diff --git a/pkg/mail/send_internal_test.go b/pkg/mail/send_internal_test.go index 62de314f..4a87be0e 100644 --- a/pkg/mail/send_internal_test.go +++ b/pkg/mail/send_internal_test.go @@ -1,85 +1,28 @@ package mail import ( - "bytes" - "context" - "fmt" "testing" - "github.com/can3p/pcom/pkg/links" "github.com/can3p/pcom/pkg/model" - "github.com/can3p/pcom/pkg/testutil/fakesender" - "github.com/can3p/pcom/pkg/testutil/golden" "github.com/stretchr/testify/require" ) -const testFrom = "noreply@pcom.test" - -// mailsToGolden serializes fakesender recordings to a human-readable format for golden testing -func mailsToGoldenInternal(sent []fakesender.Recorded) []byte { - var buf bytes.Buffer - for i, rec := range sent { - if i > 0 { - buf.WriteString("\n---\n\n") - } - fmt.Fprintf(&buf, "EmailType: %s\n", rec.EmailType) - fmt.Fprintf(&buf, "UniqueID: %s\n", rec.UniqueID) - fmt.Fprintf(&buf, "From: %s <%s>\n", rec.Mail.From.Name, rec.Mail.From.Address) - if len(rec.Mail.To) > 0 { - fmt.Fprintf(&buf, "To: %s\n", rec.Mail.To[0].Address) - } - fmt.Fprintf(&buf, "Subject: %s\n", rec.Mail.Subject) - fmt.Fprintf(&buf, "\n--- TEXT ---\n%s\n", rec.Mail.Text) - fmt.Fprintf(&buf, "\n--- HTML ---\n%s\n", rec.Mail.Html) - } - return buf.Bytes() -} - -func TestSendActualConfirmWaitingList(t *testing.T) { +func TestConfirmWaitingList_MatchesSample(t *testing.T) { t.Parallel() - waitingList := &model.UserSignupRequest{ - ID: "request-1", - Email: "newuser@example.test", - } - - sender := fakesender.New() - ctx := context.Background() - - e := ConfirmWaitingList(links.Site{}, testFrom, waitingList) - err := sender.Send(ctx, nil, e.UniqueID, e.Type, e.Mail) - require.NoError(t, err) + waitingList := &model.UserSignupRequest{ID: "request-1", Email: "newuser@example.test"} + want := confirmWaitingListMail.MustRender(confirmWaitingListSamples()[0].Input) - sent := sender.Sent() - require.Len(t, sent, 1) - - golden.Assert(t, "send_actual_confirm_waiting_list", mailsToGoldenInternal(sent)) + require.Equal(t, want, ConfirmWaitingList(SampleSite, SampleFrom, waitingList)) } -func TestSendActualInvitation(t *testing.T) { +func TestInvitation_MatchesSample(t *testing.T) { t.Parallel() - user := &model.User{ - ID: "user-1", - Email: "sender@example.test", - Username: "alice", - } - invite := &model.UserInvitation{ - ID: "invite-1", - UserID: user.ID, - } - - sender := fakesender.New() - ctx := context.Background() - - e := Invitation(links.Site{}, testFrom, invite, "newuser@example.test") - err := sender.Send(ctx, nil, e.UniqueID, e.Type, e.Mail) - require.NoError(t, err) - - sent := sender.Sent() - require.Len(t, sent, 1) + invite := &model.UserInvitation{ID: "invite-1"} + want := inviteMail.MustRender(inviteSamples()[0].Input) - golden.Assert(t, "send_actual_invitation", mailsToGoldenInternal(sent)) + require.Equal(t, want, Invitation(SampleSite, SampleFrom, invite, "newuser@example.test")) } func TestValidateFormat_InvalidFormat(t *testing.T) { diff --git a/pkg/mail/sender/dbsender/dbsender.go b/pkg/mail/sender/dbsender/dbsender.go index 316edcbe..eb393d21 100644 --- a/pkg/mail/sender/dbsender/dbsender.go +++ b/pkg/mail/sender/dbsender/dbsender.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "slices" "time" "github.com/can3p/gogo/sender" @@ -13,23 +14,43 @@ import ( "github.com/google/uuid" ) -const attemptsNumber = 3 - -var retryIntervals = []time.Duration{10 * time.Second, 60 * time.Second, 30 * time.Minute} +// DefaultRetryIntervals is the wait before each retry of a failed mail, equal +// to the default of config.Mail.RetryIntervals. A mail fails for good after +// one attempt more than there are intervals. +var DefaultRetryIntervals = []time.Duration{10 * time.Second, 60 * time.Second, 30 * time.Minute} type dbSender struct { - realSender sender.Sender - store *repo.Store + realSender sender.Sender + store *repo.Store + retryIntervals []time.Duration +} + +// Option configures the sender. +type Option func(*dbSender) + +// WithRetryIntervals sets the wait before each retry of a failed mail; a mail +// fails for good after len(intervals)+1 attempts. +func WithRetryIntervals(intervals []time.Duration) Option { + return func(m *dbSender) { + m.retryIntervals = slices.Clone(intervals) + } } // compile-time check that dbSender is the mail queue. var _ repo.MailQueue = (*dbSender)(nil) -func NewSender(db *repo.Store, realSender sender.Sender) *dbSender { - return &dbSender{ - realSender: realSender, - store: db, +func NewSender(db *repo.Store, realSender sender.Sender, opts ...Option) *dbSender { + m := &dbSender{ + realSender: realSender, + store: db, + retryIntervals: slices.Clone(DefaultRetryIntervals), } + + for _, opt := range opts { + opt(m) + } + + return m } // RunPoller sends the queued mail every interval until ctx is done. @@ -57,25 +78,35 @@ func (m *dbSender) sendEmails(ctx context.Context) (err error) { } }() - return m.store.Tx(ctx, func(tx *repo.Store) error { - pending, err := tx.GetPendingEmails(ctx) + ids, err := m.store.ListDueEmailIDs(ctx) + if err != nil { + return err + } + + for _, id := range ids { + if err := m.sendOne(ctx, id); err != nil { + slog.Warn("failed to send email", "email_id", id, "err", err) + } + } + + return nil +} +// sendOne sends one mail in its own transaction: it locks the row, sends the +// mail and records the outcome. A row another worker holds, or that is no +// longer new, is skipped. A failure here leaves every other mail alone. +func (m *dbSender) sendOne(ctx context.Context, id string) error { + return m.store.Tx(ctx, func(tx *repo.Store) error { + outgoing, err := tx.LockDueEmail(ctx, id) if err != nil { return err } - if len(pending) == 0 { + if outgoing == nil { return nil } - for _, outgoing := range pending { - if err := m.trySendEmail(ctx, tx, outgoing); err != nil { - slog.Warn("failed to send email", "email_id", outgoing.ID, "err", err) - continue - } - } - - return nil + return m.trySendEmail(ctx, tx, outgoing) }) } @@ -93,8 +124,8 @@ func (m *dbSender) trySendEmail(ctx context.Context, tx *repo.Store, outgoing *m outgoing.Status = model.OutgoingEmailStatusSent outgoing.SentAt = new(time.Now()) } else { - if outgoing.AttemptsNumber < attemptsNumber { - outgoing.TryAt = time.Now().Add(retryIntervals[outgoing.AttemptsNumber]) + if outgoing.AttemptsNumber < len(m.retryIntervals) { + outgoing.TryAt = time.Now().Add(m.retryIntervals[outgoing.AttemptsNumber]) outgoing.AttemptsNumber = outgoing.AttemptsNumber + 1 } else { outgoing.Status = model.OutgoingEmailStatusFailed diff --git a/pkg/mail/sender/dbsender/dbsender_test.go b/pkg/mail/sender/dbsender/dbsender_test.go index ffbb84d7..c3c768f4 100644 --- a/pkg/mail/sender/dbsender/dbsender_test.go +++ b/pkg/mail/sender/dbsender/dbsender_test.go @@ -99,21 +99,21 @@ func TestTrySendEmail(t *testing.T) { real.FailWith(errors.New("smtp down")) - // Attempts 1 through attemptsNumber fail and reschedule with the + // Attempts 1 through len(DefaultRetryIntervals) fail and reschedule with the // matching interval, keeping the email in the New status. - for i := range attemptsNumber { + for i := range len(DefaultRetryIntervals) { trySend(t, ctx, m, store, outgoing) require.Equal(t, i+1, outgoing.AttemptsNumber, "attempt %d", i+1) require.Equal(t, model.OutgoingEmailStatusNew, outgoing.Status, "attempt %d", i+1) - require.WithinDuration(t, time.Now().Add(retryIntervals[i]), outgoing.TryAt, 5*time.Second, "attempt %d", i+1) + require.WithinDuration(t, time.Now().Add(DefaultRetryIntervals[i]), outgoing.TryAt, 5*time.Second, "attempt %d", i+1) } - // One more failure past attemptsNumber gives up for good. + // One more failure past the last interval gives up for good. trySend(t, ctx, m, store, outgoing) require.Equal(t, model.OutgoingEmailStatusFailed, outgoing.Status) - require.Equal(t, attemptsNumber, outgoing.AttemptsNumber) + require.Equal(t, len(DefaultRetryIntervals), outgoing.AttemptsNumber) require.Empty(t, real.Sent(), "the real sender never succeeded, so nothing should be recorded as sent") }) @@ -189,7 +189,7 @@ func TestSendEmails(t *testing.T) { // Exhausted its attempts: must not be retried either. failedEmail := newOutgoingEmail(t, ctx, store) setup.FailWith(errors.New("smtp down")) - for range attemptsNumber + 1 { + for range len(DefaultRetryIntervals) + 1 { trySend(t, ctx, setupSender, store, failedEmail) } require.Equal(t, model.OutgoingEmailStatusFailed, failedEmail.Status) @@ -371,3 +371,73 @@ func TestRunPoller_LongIntervalSendsNothingEarly(t *testing.T) { require.Never(t, func() bool { return len(real.Sent()) > 0 }, 500*time.Millisecond, 50*time.Millisecond) } + +func TestSendEmails_OneFailingUpdateLeavesOthersSent(t *testing.T) { + t.Parallel() + + db := testdb.New(t).DB + store := repo.New(db) + ctx := context.Background() + + real := fakesender.New() + m := NewSender(store, real.Delivery()) + + first := newOutgoingEmail(t, ctx, store) + broken := newOutgoingEmail(t, ctx, store) + last := newOutgoingEmail(t, ctx, store) + + // The update that records the outcome of one mail fails; the others are + // processed in their own transactions and stay sent. + _, err := db.ExecContext(ctx, ` + CREATE FUNCTION fail_outgoing_update() RETURNS trigger AS $$ + BEGIN + IF NEW.id = '`+broken.ID+`' THEN + RAISE EXCEPTION 'update refused'; + END IF; + RETURN NEW; + END + $$ LANGUAGE plpgsql; + CREATE TRIGGER fail_outgoing_update BEFORE UPDATE ON outgoing_emails + FOR EACH ROW EXECUTE FUNCTION fail_outgoing_update();`) + require.NoError(t, err) + + require.NoError(t, m.sendEmails(ctx)) + + status := map[string]model.OutgoingEmailStatus{} + for _, r := range listOutgoing(t, ctx, store) { + status[r.ID] = r.Status + } + + require.Equal(t, model.OutgoingEmailStatusSent, status[first.ID]) + require.Equal(t, model.OutgoingEmailStatusSent, status[last.ID]) + require.Equal(t, model.OutgoingEmailStatusNew, status[broken.ID], "the mail whose update failed stays queued") +} + +func TestWithRetryIntervals(t *testing.T) { + t.Parallel() + + db := testdb.New(t).DB + store := repo.New(db) + ctx := context.Background() + + intervals := []time.Duration{time.Hour, 2 * time.Hour} + real := fakesender.New() + real.FailWith(errors.New("smtp down")) + m := NewSender(store, real.Delivery(), WithRetryIntervals(intervals)) + + outgoing := newOutgoingEmail(t, ctx, store) + + for i, interval := range intervals { + trySend(t, ctx, m, store, outgoing) + + require.Equal(t, i+1, outgoing.AttemptsNumber) + require.Equal(t, model.OutgoingEmailStatusNew, outgoing.Status) + require.WithinDuration(t, time.Now().Add(interval), outgoing.TryAt, 5*time.Second) + } + + // The failure after the last interval gives up. + trySend(t, ctx, m, store, outgoing) + + require.Equal(t, model.OutgoingEmailStatusFailed, outgoing.Status) + require.Equal(t, len(intervals), outgoing.AttemptsNumber) +} diff --git a/pkg/mail/templates/admin_new_user.html b/pkg/mail/templates/admin_new_user.html new file mode 100644 index 00000000..5549150d --- /dev/null +++ b/pkg/mail/templates/admin_new_user.html @@ -0,0 +1,9 @@ +

Hi!

+ +

New user alert:

+ + diff --git a/pkg/mail/templates/admin_new_user.subject.txt b/pkg/mail/templates/admin_new_user.subject.txt new file mode 100644 index 00000000..ad6a8614 --- /dev/null +++ b/pkg/mail/templates/admin_new_user.subject.txt @@ -0,0 +1 @@ +New User on pcom diff --git a/pkg/mail/templates/admin_new_user.txt b/pkg/mail/templates/admin_new_user.txt new file mode 100644 index 00000000..175c52ec --- /dev/null +++ b/pkg/mail/templates/admin_new_user.txt @@ -0,0 +1,7 @@ +Hi! + +New user alert: + +* ID: {{.ID}} +* Blog: {{.BlogURL}} +* Email: {{.Email}} diff --git a/pkg/mail/templates/admin_new_waiting_list_member.html b/pkg/mail/templates/admin_new_waiting_list_member.html new file mode 100644 index 00000000..5a2e5438 --- /dev/null +++ b/pkg/mail/templates/admin_new_waiting_list_member.html @@ -0,0 +1,8 @@ +

Hi!

+ +

New waiting list member alert:

+ +
    +
  • Email address: {{.Email}}
  • +
  • Reason: {{.Reason}}
  • +
diff --git a/pkg/mail/templates/admin_new_waiting_list_member.subject.txt b/pkg/mail/templates/admin_new_waiting_list_member.subject.txt new file mode 100644 index 00000000..840e2d70 --- /dev/null +++ b/pkg/mail/templates/admin_new_waiting_list_member.subject.txt @@ -0,0 +1 @@ +New waiting list member on pcom diff --git a/pkg/mail/templates/admin_new_waiting_list_member.txt b/pkg/mail/templates/admin_new_waiting_list_member.txt new file mode 100644 index 00000000..ab656b38 --- /dev/null +++ b/pkg/mail/templates/admin_new_waiting_list_member.txt @@ -0,0 +1,6 @@ +Hi! + +New waiting list member alert: + +* Email address: {{.Email}} +* Reason: {{.Reason}} diff --git a/pkg/mail/templates/admin_page_failure.html b/pkg/mail/templates/admin_page_failure.html new file mode 100644 index 00000000..dfa9c0ad --- /dev/null +++ b/pkg/mail/templates/admin_page_failure.html @@ -0,0 +1,8 @@ +

Hi!

+ +

Panic on the page:

+ +
    +
  • user: {{.User}}
  • +
  • Request data:
    {{.Report}}
  • +
diff --git a/pkg/mail/templates/admin_page_failure.subject.txt b/pkg/mail/templates/admin_page_failure.subject.txt new file mode 100644 index 00000000..93f5ef62 --- /dev/null +++ b/pkg/mail/templates/admin_page_failure.subject.txt @@ -0,0 +1 @@ +Panic on the page diff --git a/pkg/mail/templates/admin_page_failure.txt b/pkg/mail/templates/admin_page_failure.txt new file mode 100644 index 00000000..83ab3850 --- /dev/null +++ b/pkg/mail/templates/admin_page_failure.txt @@ -0,0 +1,8 @@ +Hi! + +Panic on the page + +* User: {{.User}} +* Request data: + +{{.Report}} diff --git a/pkg/mail/templates/admin_signup_confirmed.html b/pkg/mail/templates/admin_signup_confirmed.html new file mode 100644 index 00000000..e1d83e4f --- /dev/null +++ b/pkg/mail/templates/admin_signup_confirmed.html @@ -0,0 +1,8 @@ +

Hi!

+ +

New conrirmed user alert:

+ +
    +
  • ID: {{.ID}}
  • +
  • Email: {{.Email}}
  • +
diff --git a/pkg/mail/templates/admin_signup_confirmed.subject.txt b/pkg/mail/templates/admin_signup_confirmed.subject.txt new file mode 100644 index 00000000..60201f21 --- /dev/null +++ b/pkg/mail/templates/admin_signup_confirmed.subject.txt @@ -0,0 +1 @@ +New User confirmed email on pcom diff --git a/pkg/mail/templates/admin_signup_confirmed.txt b/pkg/mail/templates/admin_signup_confirmed.txt new file mode 100644 index 00000000..1f623f0e --- /dev/null +++ b/pkg/mail/templates/admin_signup_confirmed.txt @@ -0,0 +1,6 @@ +Hi! + +New conrirmed user alert: + +* ID: {{.ID}} +* Email: {{.Email}} diff --git a/pkg/mail/templates/admin_throwaway_email.html b/pkg/mail/templates/admin_throwaway_email.html new file mode 100644 index 00000000..0c3b952c --- /dev/null +++ b/pkg/mail/templates/admin_throwaway_email.html @@ -0,0 +1,3 @@ +

Hi!

+ +

A user has just tried to use a throwaway email: {{.Email}}

diff --git a/pkg/mail/templates/admin_throwaway_email.subject.txt b/pkg/mail/templates/admin_throwaway_email.subject.txt new file mode 100644 index 00000000..60f7cdd6 --- /dev/null +++ b/pkg/mail/templates/admin_throwaway_email.subject.txt @@ -0,0 +1 @@ +An attempt to use a throwaway email domain on pcom diff --git a/pkg/mail/templates/admin_throwaway_email.txt b/pkg/mail/templates/admin_throwaway_email.txt new file mode 100644 index 00000000..0007288e --- /dev/null +++ b/pkg/mail/templates/admin_throwaway_email.txt @@ -0,0 +1,3 @@ +Hi! + +A user has just tried to use a throwaway email: {{.Email}} diff --git a/pkg/mail/templates/confirm_signup.html b/pkg/mail/templates/confirm_signup.html new file mode 100644 index 00000000..d8fa9a86 --- /dev/null +++ b/pkg/mail/templates/confirm_signup.html @@ -0,0 +1,6 @@ + +

Hi!

+ +

Thank you for your interest in pcom! Your confirmation code is {{.Code}}

+ +

Type it on the signup page to confirm your email address and log in. It works once, for the next {{.Minutes}} minutes.

diff --git a/pkg/mail/templates/confirm_signup.subject.txt b/pkg/mail/templates/confirm_signup.subject.txt new file mode 100644 index 00000000..dbba9a46 --- /dev/null +++ b/pkg/mail/templates/confirm_signup.subject.txt @@ -0,0 +1 @@ +Welcome to pcom diff --git a/pkg/mail/templates/confirm_signup.txt b/pkg/mail/templates/confirm_signup.txt new file mode 100644 index 00000000..0a4bf084 --- /dev/null +++ b/pkg/mail/templates/confirm_signup.txt @@ -0,0 +1,6 @@ + +Hi! + +Thank you for your interest in pcom! Your confirmation code is {{.Code}} + +Type it on the signup page to confirm your email address and log in. It works once, for the next {{.Minutes}} minutes. diff --git a/pkg/mail/templates/confirm_waiting_list.html b/pkg/mail/templates/confirm_waiting_list.html new file mode 100644 index 00000000..7bcf5c34 --- /dev/null +++ b/pkg/mail/templates/confirm_waiting_list.html @@ -0,0 +1,6 @@ + +

Hi!

+ +

Thank you for your interest pcom! Please follow the link to confirm your email address

+ +{{.Link}} diff --git a/pkg/mail/templates/confirm_waiting_list.subject.txt b/pkg/mail/templates/confirm_waiting_list.subject.txt new file mode 100644 index 00000000..afe491c1 --- /dev/null +++ b/pkg/mail/templates/confirm_waiting_list.subject.txt @@ -0,0 +1 @@ +Waiting list on pcom diff --git a/pkg/mail/templates/confirm_waiting_list.txt b/pkg/mail/templates/confirm_waiting_list.txt new file mode 100644 index 00000000..b935d544 --- /dev/null +++ b/pkg/mail/templates/confirm_waiting_list.txt @@ -0,0 +1,6 @@ + +Hi! + +Thank you for your interest pcom! Please follow the link to confirm your email address + +{{.Link}} diff --git a/pkg/mail/templates/invite.html b/pkg/mail/templates/invite.html new file mode 100644 index 00000000..b4f6439a --- /dev/null +++ b/pkg/mail/templates/invite.html @@ -0,0 +1,6 @@ + +

Hi!

+ +

Welcome to pcom! Please follow the link to choose a username. You will log in with a code we mail you, so there is no password to set.

+ +{{.Link}} diff --git a/pkg/mail/templates/invite.subject.txt b/pkg/mail/templates/invite.subject.txt new file mode 100644 index 00000000..dbba9a46 --- /dev/null +++ b/pkg/mail/templates/invite.subject.txt @@ -0,0 +1 @@ +Welcome to pcom diff --git a/pkg/mail/templates/invite.txt b/pkg/mail/templates/invite.txt new file mode 100644 index 00000000..3783d155 --- /dev/null +++ b/pkg/mail/templates/invite.txt @@ -0,0 +1,6 @@ + +Hi! + +Welcome to pcom! Please follow the link to choose a username. You will log in with a code we mail you, so there is no password to set. + +{{.Link}} diff --git a/pkg/mail/templates/login_code.html b/pkg/mail/templates/login_code.html new file mode 100644 index 00000000..b9a90668 --- /dev/null +++ b/pkg/mail/templates/login_code.html @@ -0,0 +1,8 @@ + +

Hi!

+ +

Your pcom login code is {{.Code}}

+ +

It works once, for the next {{.Minutes}} minutes.

+ +

If you didn't try to log in to pcom, ignore this mail: nobody can log in without the code.

diff --git a/pkg/mail/templates/login_code.subject.txt b/pkg/mail/templates/login_code.subject.txt new file mode 100644 index 00000000..a63b20f9 --- /dev/null +++ b/pkg/mail/templates/login_code.subject.txt @@ -0,0 +1 @@ +Your pcom login code diff --git a/pkg/mail/templates/login_code.txt b/pkg/mail/templates/login_code.txt new file mode 100644 index 00000000..c686e817 --- /dev/null +++ b/pkg/mail/templates/login_code.txt @@ -0,0 +1,8 @@ + +Hi! + +Your pcom login code is {{.Code}} + +It works once, for the next {{.Minutes}} minutes. + +If you didn't try to log in to pcom, ignore this mail: nobody can log in without the code. diff --git a/pkg/mail/templates/new_post.html b/pkg/mail/templates/new_post.html new file mode 100644 index 00000000..bc2352b4 --- /dev/null +++ b/pkg/mail/templates/new_post.html @@ -0,0 +1,8 @@ + +

Hi!

+ +

@{{.Username}} has published a new post "{{.Subject}}"

+ +
{{.Body}}
{{if .LinkedURL}}

Linked URL: {{.LinkedURL}}

{{end}} + +

Head to the post to leave a comment! {{.Link}}

diff --git a/pkg/mail/templates/new_post.subject.txt b/pkg/mail/templates/new_post.subject.txt new file mode 100644 index 00000000..5a5ec69c --- /dev/null +++ b/pkg/mail/templates/new_post.subject.txt @@ -0,0 +1 @@ +New post "{{.Subject}}" from {{.Username}} diff --git a/pkg/mail/templates/new_post.txt b/pkg/mail/templates/new_post.txt new file mode 100644 index 00000000..87a5e466 --- /dev/null +++ b/pkg/mail/templates/new_post.txt @@ -0,0 +1,6 @@ +Hi! + +@{{.Username}} has published a new post "{{.Subject}}"{{if .LinkedURL}} +Linked URL: {{.LinkedURL}}{{end}} + +Head to the post to leave a comment! {{.Link}} diff --git a/pkg/mail/templates/post_comment_author.html b/pkg/mail/templates/post_comment_author.html new file mode 100644 index 00000000..eedbaca6 --- /dev/null +++ b/pkg/mail/templates/post_comment_author.html @@ -0,0 +1,8 @@ + +

Hi!

+ +

@{{.Commenter}} {{if .Edited}}has edited a comment in{{else}}has left a comment in{{end}} your post "{{.Subject}}".

{{with .URL}}

Linked URL: {{.}}

{{end}} + +
{{.BodyHTML}}
+ +

Checkout the comment in the post.

diff --git a/pkg/mail/templates/post_comment_author.subject.txt b/pkg/mail/templates/post_comment_author.subject.txt new file mode 100644 index 00000000..0e0f1417 --- /dev/null +++ b/pkg/mail/templates/post_comment_author.subject.txt @@ -0,0 +1 @@ +{{if .Edited}}Edited comment in your post{{else}}New comment in your post{{end}} "{{.Subject}}" diff --git a/pkg/mail/templates/post_comment_author.txt b/pkg/mail/templates/post_comment_author.txt new file mode 100644 index 00000000..6785ee6f --- /dev/null +++ b/pkg/mail/templates/post_comment_author.txt @@ -0,0 +1,8 @@ +Hi! + +@{{.Commenter}} {{if .Edited}}has edited a comment in{{else}}has left a comment in{{end}} your post "{{.Subject}}".{{with .URL}} +Linked URL: {{.}}{{end}} + +{{.Quote}} + +Checkout the comment in the post: {{.Link}} diff --git a/pkg/mail/templates/post_comment_participants.html b/pkg/mail/templates/post_comment_participants.html new file mode 100644 index 00000000..f6e3e1f3 --- /dev/null +++ b/pkg/mail/templates/post_comment_participants.html @@ -0,0 +1,8 @@ + +

Hi!

+ +

@{{.Commenter}} {{if .Edited}}has edited a comment in{{else}}has left a comment in{{end}} the post "{{.Subject}}" where you've also left a comment.

{{with .URL}}

Linked URL: {{.}}

{{end}} + +
{{.BodyHTML}}
+ +

Checkout the comment in the post.

diff --git a/pkg/mail/templates/post_comment_participants.subject.txt b/pkg/mail/templates/post_comment_participants.subject.txt new file mode 100644 index 00000000..f4f59723 --- /dev/null +++ b/pkg/mail/templates/post_comment_participants.subject.txt @@ -0,0 +1 @@ +{{if .Edited}}Edited comment in the post{{else}}New comment in the post{{end}} "{{.Subject}}" diff --git a/pkg/mail/templates/post_comment_participants.txt b/pkg/mail/templates/post_comment_participants.txt new file mode 100644 index 00000000..289c3bf2 --- /dev/null +++ b/pkg/mail/templates/post_comment_participants.txt @@ -0,0 +1,8 @@ +Hi! + +@{{.Commenter}} {{if .Edited}}has edited a comment in{{else}}has left a comment in{{end}} the post "{{.Subject}}" where you've also left a comment.{{with .URL}} +Linked URL: {{.}}{{end}} + +{{.Quote}} + +Checkout the comment in the post: {{.Link}} diff --git a/pkg/mail/templates/post_prompt.html b/pkg/mail/templates/post_prompt.html new file mode 100644 index 00000000..c10892f1 --- /dev/null +++ b/pkg/mail/templates/post_prompt.html @@ -0,0 +1,6 @@ + +

Hi!

+ +

@{{.Asker}} has asked you to write a post on "{{.Message}}"

+ +

Head to new post page to give an update! {{.Link}}

diff --git a/pkg/mail/templates/post_prompt.subject.txt b/pkg/mail/templates/post_prompt.subject.txt new file mode 100644 index 00000000..a93a49c8 --- /dev/null +++ b/pkg/mail/templates/post_prompt.subject.txt @@ -0,0 +1 @@ +New prompt from {{.Asker}} diff --git a/pkg/mail/templates/post_prompt.txt b/pkg/mail/templates/post_prompt.txt new file mode 100644 index 00000000..5b7b36e6 --- /dev/null +++ b/pkg/mail/templates/post_prompt.txt @@ -0,0 +1,5 @@ +Hi! + +@{{.Asker}} has asked you to write a post on "{{.Message}}" + +Head to new post page to give an update! {{.Link}} diff --git a/pkg/mail/templates/post_prompt_answer.html b/pkg/mail/templates/post_prompt_answer.html new file mode 100644 index 00000000..ab697629 --- /dev/null +++ b/pkg/mail/templates/post_prompt_answer.html @@ -0,0 +1,6 @@ + +

Hi!

+ +

@{{.Responder}} has responded on your prompt "{{.Message}}" with the post "{{.Subject}}"

+ +

Head to new post page to give an update! {{.Link}}

diff --git a/pkg/mail/templates/post_prompt_answer.subject.txt b/pkg/mail/templates/post_prompt_answer.subject.txt new file mode 100644 index 00000000..c6e4cb73 --- /dev/null +++ b/pkg/mail/templates/post_prompt_answer.subject.txt @@ -0,0 +1 @@ +Response to your prompt from {{.Responder}} diff --git a/pkg/mail/templates/post_prompt_answer.txt b/pkg/mail/templates/post_prompt_answer.txt new file mode 100644 index 00000000..3f54e001 --- /dev/null +++ b/pkg/mail/templates/post_prompt_answer.txt @@ -0,0 +1,5 @@ +Hi! + +@{{.Responder}} has responded on your prompt "{{.Message}}" with the post "{{.Subject}}" + +Check out their post! {{.Link}} diff --git a/pkg/mail/testdata/admin_new_user.golden b/pkg/mail/testdata/admin_new_user.golden new file mode 100644 index 00000000..680324ca --- /dev/null +++ b/pkg/mail/testdata/admin_new_user.golden @@ -0,0 +1,25 @@ +EmailType: admin_new_user +UniqueID: 0190a3b4-0000-7000-8000-000000000001 +From: Your pcom +To: admin@pcom.test +Subject: New User on pcom + +--- TEXT --- +Hi! + +New user alert: + +* ID: 0190a3b4-0000-7000-8000-000000000001 +* Blog: https://pcom.test/users/alice +* Email: alice@example.test + +--- HTML --- +

Hi!

+ +

New user alert:

+ + diff --git a/pkg/mail/testdata/admin_new_waiting_list_member.golden b/pkg/mail/testdata/admin_new_waiting_list_member.golden new file mode 100644 index 00000000..b9c5d887 --- /dev/null +++ b/pkg/mail/testdata/admin_new_waiting_list_member.golden @@ -0,0 +1,23 @@ +EmailType: new_waiting_list_member +UniqueID: 0190a3b4-0000-7000-8000-000000000002 +From: Your pcom +To: admin@pcom.test +Subject: New waiting list member on pcom + +--- TEXT --- +Hi! + +New waiting list member alert: + +* Email address: signup@example.test +* Reason: Not specified + +--- HTML --- +

Hi!

+ +

New waiting list member alert:

+ +
    +
  • Email address: signup@example.test
  • +
  • Reason: Not specified
  • +
diff --git a/pkg/mail/testdata/admin_new_waiting_list_member_reason.golden b/pkg/mail/testdata/admin_new_waiting_list_member_reason.golden new file mode 100644 index 00000000..f659bd92 --- /dev/null +++ b/pkg/mail/testdata/admin_new_waiting_list_member_reason.golden @@ -0,0 +1,23 @@ +EmailType: new_waiting_list_member +UniqueID: 0190a3b4-0000-7000-8000-000000000002 +From: Your pcom +To: admin@pcom.test +Subject: New waiting list member on pcom + +--- TEXT --- +Hi! + +New waiting list member alert: + +* Email address: signup@example.test +* Reason: I read your blog + +--- HTML --- +

Hi!

+ +

New waiting list member alert:

+ +
    +
  • Email address: signup@example.test
  • +
  • Reason: I read your blog
  • +
diff --git a/pkg/mail/testdata/admin_page_failure.golden b/pkg/mail/testdata/admin_page_failure.golden new file mode 100644 index 00000000..0e03b3fe --- /dev/null +++ b/pkg/mail/testdata/admin_page_failure.golden @@ -0,0 +1,40 @@ +EmailType: panic_notification +UniqueID: 0190a3b4-0000-7000-8000-000000000004 +From: Your pcom +To: admin@pcom.test +Subject: Panic on the page + +--- TEXT --- +Hi! + +Panic on the page + +* User: email (alice@example.test), id (0190a3b4-0000-7000-8000-000000000001) +* Request data: + +[Recovery] 2026/01/02 - 03:04:05 panic recovered: +GET /posts/1 HTTP/1.1 +Host: pcom.test +Cookie: + +something broke +/app/pkg/web/handler.go:42 (0x1234) + Handle: panic(err) + +--- HTML --- +

Hi!

+ +

Panic on the page:

+ +
    +
  • user: email (alice@example.test), id (0190a3b4-0000-7000-8000-000000000001)
  • +
  • Request data:
    [Recovery] 2026/01/02 - 03:04:05 panic recovered:
    +GET /posts/1 HTTP/1.1
    +Host: pcom.test
    +Cookie: <hidden>
    +
    +something broke
    +/app/pkg/web/handler.go:42 (0x1234)
    +	Handle: panic(err)
    +
  • +
diff --git a/pkg/mail/testdata/admin_page_failure_anonymous.golden b/pkg/mail/testdata/admin_page_failure_anonymous.golden new file mode 100644 index 00000000..f1f64ef0 --- /dev/null +++ b/pkg/mail/testdata/admin_page_failure_anonymous.golden @@ -0,0 +1,40 @@ +EmailType: panic_notification +UniqueID: 0190a3b4-0000-7000-8000-000000000004 +From: Your pcom +To: admin@pcom.test +Subject: Panic on the page + +--- TEXT --- +Hi! + +Panic on the page + +* User: Anonymous +* Request data: + +[Recovery] 2026/01/02 - 03:04:05 panic recovered: +GET /posts/1 HTTP/1.1 +Host: pcom.test +Cookie: + +something broke +/app/pkg/web/handler.go:42 (0x1234) + Handle: panic(err) + +--- HTML --- +

Hi!

+ +

Panic on the page:

+ +
    +
  • user: Anonymous
  • +
  • Request data:
    [Recovery] 2026/01/02 - 03:04:05 panic recovered:
    +GET /posts/1 HTTP/1.1
    +Host: pcom.test
    +Cookie: <hidden>
    +
    +something broke
    +/app/pkg/web/handler.go:42 (0x1234)
    +	Handle: panic(err)
    +
  • +
diff --git a/pkg/mail/testdata/admin_signup_confirmed.golden b/pkg/mail/testdata/admin_signup_confirmed.golden new file mode 100644 index 00000000..f71a137d --- /dev/null +++ b/pkg/mail/testdata/admin_signup_confirmed.golden @@ -0,0 +1,23 @@ +EmailType: signup_confirmed +UniqueID: 0190a3b4-0000-7000-8000-000000000001 +From: Your pcom +To: admin@pcom.test +Subject: New User confirmed email on pcom + +--- TEXT --- +Hi! + +New conrirmed user alert: + +* ID: 0190a3b4-0000-7000-8000-000000000001 +* Email: alice@example.test + +--- HTML --- +

Hi!

+ +

New conrirmed user alert:

+ +
    +
  • ID: 0190a3b4-0000-7000-8000-000000000001
  • +
  • Email: alice@example.test
  • +
diff --git a/pkg/mail/testdata/admin_throwaway_email.golden b/pkg/mail/testdata/admin_throwaway_email.golden new file mode 100644 index 00000000..02e89bb7 --- /dev/null +++ b/pkg/mail/testdata/admin_throwaway_email.golden @@ -0,0 +1,15 @@ +EmailType: throw_away_email_signup +UniqueID: 0190a3b4-0000-7000-8000-000000000003 +From: Your pcom +To: admin@pcom.test +Subject: An attempt to use a throwaway email domain on pcom + +--- TEXT --- +Hi! + +A user has just tried to use a throwaway email: test@throwaway.example.com + +--- HTML --- +

Hi!

+ +

A user has just tried to use a throwaway email: test@throwaway.example.com

diff --git a/pkg/mail/testdata/confirm_signup.golden b/pkg/mail/testdata/confirm_signup.golden index a228f462..d65170e1 100644 --- a/pkg/mail/testdata/confirm_signup.golden +++ b/pkg/mail/testdata/confirm_signup.golden @@ -6,16 +6,16 @@ Subject: Welcome to pcom --- TEXT --- - Hi! +Hi! - Thank you for your interest in pcom! Your confirmation code is 123456 +Thank you for your interest in pcom! Your confirmation code is 123456 - Type it on the signup page to confirm your email address and log in. It works once, for the next 15 minutes. +Type it on the signup page to confirm your email address and log in. It works once, for the next 15 minutes. --- HTML --- -

Hi!

+

Hi!

-

Thank you for your interest in pcom! Your confirmation code is 123456

+

Thank you for your interest in pcom! Your confirmation code is 123456

-

Type it on the signup page to confirm your email address and log in. It works once, for the next 15 minutes.

+

Type it on the signup page to confirm your email address and log in. It works once, for the next 15 minutes.

diff --git a/pkg/mail/testdata/login_code.golden b/pkg/mail/testdata/login_code.golden index cd3c49cb..f8181fff 100644 --- a/pkg/mail/testdata/login_code.golden +++ b/pkg/mail/testdata/login_code.golden @@ -6,20 +6,20 @@ Subject: Your pcom login code --- TEXT --- - Hi! +Hi! - Your pcom login code is 123456 +Your pcom login code is 123456 - It works once, for the next 15 minutes. +It works once, for the next 15 minutes. - If you didn't try to log in to pcom, ignore this mail: nobody can log in without the code. +If you didn't try to log in to pcom, ignore this mail: nobody can log in without the code. --- HTML --- -

Hi!

+

Hi!

-

Your pcom login code is 123456

+

Your pcom login code is 123456

-

It works once, for the next 15 minutes.

+

It works once, for the next 15 minutes.

-

If you didn't try to log in to pcom, ignore this mail: nobody can log in without the code.

+

If you didn't try to log in to pcom, ignore this mail: nobody can log in without the code.

diff --git a/pkg/mail/testdata/new_post.golden b/pkg/mail/testdata/new_post.golden index 2cb5f15b..affbc04a 100644 --- a/pkg/mail/testdata/new_post.golden +++ b/pkg/mail/testdata/new_post.golden @@ -9,15 +9,15 @@ Hi! @alice has published a new post "Test Post" -Head to the post to leave a comment! /posts/post-1 +Head to the post to leave a comment! https://pcom.test/posts/post-1 --- HTML --- -

Hi!

+

Hi!

-

@alice has published a new post "Test Post"

+

@alice has published a new post "Test Post"

-

This is a test post body

+

This is a test post body

-

Head to the post to leave a comment! /posts/post-1

+

Head to the post to leave a comment! https://pcom.test/posts/post-1

diff --git a/pkg/mail/testdata/new_post_empty_body.golden b/pkg/mail/testdata/new_post_empty_body.golden index 9fe2a5a8..5ee4e56a 100644 --- a/pkg/mail/testdata/new_post_empty_body.golden +++ b/pkg/mail/testdata/new_post_empty_body.golden @@ -9,14 +9,14 @@ Hi! @alice has published a new post "Test Post" -Head to the post to leave a comment! /posts/post-1 +Head to the post to leave a comment! https://pcom.test/posts/post-1 --- HTML --- -

Hi!

+

Hi!

-

@alice has published a new post "Test Post"

+

@alice has published a new post "Test Post"

-
+
-

Head to the post to leave a comment! /posts/post-1

+

Head to the post to leave a comment! https://pcom.test/posts/post-1

diff --git a/pkg/mail/testdata/new_post_long_subject.golden b/pkg/mail/testdata/new_post_long_subject.golden new file mode 100644 index 00000000..70a4259f --- /dev/null +++ b/pkg/mail/testdata/new_post_long_subject.golden @@ -0,0 +1,23 @@ +EmailType: post_notification +UniqueID: post-1user-2 +From: Your pcom +To: connection@example.test +Subject: New post "A very long subject that goes on and on and on to see how the mail copes with a title that no one would reasonably write but someone surely will" from alice + +--- TEXT --- +Hi! + +@alice has published a new post "A very long subject that goes on and on and on to see how the mail copes with a title that no one would reasonably write but someone surely will" + +Head to the post to leave a comment! https://pcom.test/posts/post-1 + +--- HTML --- + +

Hi!

+ +

@alice has published a new post "A very long subject that goes on and on and on to see how the mail copes with a title that no one would reasonably write but someone surely will"

+ +

This is a test post body

+
+ +

Head to the post to leave a comment! https://pcom.test/posts/post-1

diff --git a/pkg/mail/testdata/new_post_markup_in_body.golden b/pkg/mail/testdata/new_post_markup_in_body.golden new file mode 100644 index 00000000..819e484a --- /dev/null +++ b/pkg/mail/testdata/new_post_markup_in_body.golden @@ -0,0 +1,23 @@ +EmailType: post_notification +UniqueID: post-1user-2 +From: Your pcom +To: connection@example.test +Subject: New post "Test Post" from alice + +--- TEXT --- +Hi! + +@alice has published a new post "Test Post" + +Head to the post to leave a comment! https://pcom.test/posts/post-1 + +--- HTML --- + +

Hi!

+ +

@alice has published a new post "Test Post"

+ +

Hello alert(1) and a link

+
+ +

Head to the post to leave a comment! https://pcom.test/posts/post-1

diff --git a/pkg/mail/testdata/new_post_no_subject.golden b/pkg/mail/testdata/new_post_no_subject.golden index 8e025ce8..06b5c569 100644 --- a/pkg/mail/testdata/new_post_no_subject.golden +++ b/pkg/mail/testdata/new_post_no_subject.golden @@ -9,15 +9,15 @@ Hi! @alice has published a new post "No Subject" -Head to the post to leave a comment! /posts/post-1 +Head to the post to leave a comment! https://pcom.test/posts/post-1 --- HTML --- -

Hi!

+

Hi!

-

@alice has published a new post "No Subject"

+

@alice has published a new post "No Subject"

-

This is a test post body

+

This is a test post body

-

Head to the post to leave a comment! /posts/post-1

+

Head to the post to leave a comment! https://pcom.test/posts/post-1

diff --git a/pkg/mail/testdata/new_post_special_chars.golden b/pkg/mail/testdata/new_post_special_chars.golden index e3ffce66..372577e2 100644 --- a/pkg/mail/testdata/new_post_special_chars.golden +++ b/pkg/mail/testdata/new_post_special_chars.golden @@ -9,15 +9,15 @@ Hi! @alice has published a new post "Test bold and "quotes"" -Head to the post to leave a comment! /posts/post-1 +Head to the post to leave a comment! https://pcom.test/posts/post-1 --- HTML --- -

Hi!

+

Hi!

-

@alice has published a new post "Test <b>bold</b> and "quotes""

+

@alice has published a new post "Test <b>bold</b> and "quotes""

-

This is a test post body

+

This is a test post body

-

Head to the post to leave a comment! /posts/post-1

+

Head to the post to leave a comment! https://pcom.test/posts/post-1

diff --git a/pkg/mail/testdata/new_post_with_url.golden b/pkg/mail/testdata/new_post_with_url.golden index 8d573736..3a694afe 100644 --- a/pkg/mail/testdata/new_post_with_url.golden +++ b/pkg/mail/testdata/new_post_with_url.golden @@ -10,15 +10,15 @@ Hi! @alice has published a new post "Check out this article" Linked URL: https://example.com/article -Head to the post to leave a comment! /posts/post-1 +Head to the post to leave a comment! https://pcom.test/posts/post-1 --- HTML --- -

Hi!

+

Hi!

-

@alice has published a new post "Check out this article"

+

@alice has published a new post "Check out this article"

-

This is a test post body

+

This is a test post body

Linked URL: https://example.com/article

-

Head to the post to leave a comment! /posts/post-1

+

Head to the post to leave a comment! https://pcom.test/posts/post-1

diff --git a/pkg/mail/testdata/post_comment_author.golden b/pkg/mail/testdata/post_comment_author.golden index d3667add..ae6ab0ba 100644 --- a/pkg/mail/testdata/post_comment_author.golden +++ b/pkg/mail/testdata/post_comment_author.golden @@ -11,7 +11,7 @@ Hi! > Nice post! -Checkout the comment in the post: /posts/post-1#commentpost-1comment-1 +Checkout the comment in the post: https://pcom.test/posts/post-1#commentpost-1comment-1 --- HTML --- @@ -22,4 +22,4 @@ Checkout the comment in the post: /posts/post-1#commentpost-1comment-1

Nice post!

-

Checkout the comment in the post.

+

Checkout the comment in the post.

diff --git a/pkg/mail/testdata/post_comment_author_edited.golden b/pkg/mail/testdata/post_comment_author_edited.golden index cc6575ef..dce7c880 100644 --- a/pkg/mail/testdata/post_comment_author_edited.golden +++ b/pkg/mail/testdata/post_comment_author_edited.golden @@ -11,7 +11,7 @@ Hi! > Nice post, edited! -Checkout the comment in the post: /posts/post-1#commentpost-1comment-1 +Checkout the comment in the post: https://pcom.test/posts/post-1#commentpost-1comment-1 --- HTML --- @@ -22,4 +22,4 @@ Checkout the comment in the post: /posts/post-1#commentpost-1comment-1

Nice post, edited!

-

Checkout the comment in the post.

+

Checkout the comment in the post.

diff --git a/pkg/mail/testdata/post_comment_author_markup.golden b/pkg/mail/testdata/post_comment_author_markup.golden new file mode 100644 index 00000000..285a522a --- /dev/null +++ b/pkg/mail/testdata/post_comment_author_markup.golden @@ -0,0 +1,39 @@ +EmailType: comment_notification +UniqueID: comment-1user-1 +From: Your pcom +To: author@example.test +Subject: New comment in your post "Original Post" + +--- TEXT --- +Hi! + +@alice has left a comment in your post "Original Post". + +> Nice **post**, see [the docs](https://example.com/a?b=1&c=2). +> +> - one +> - +> +> > quoted & "done" + +Checkout the comment in the post: https://pcom.test/posts/post-1#commentpost-1comment-1 + +--- HTML --- + +

Hi!

+ +

@alice has left a comment in your post "Original Post".

+ +

Nice post, see the docs.

+
    +
  • one
  • +
  • + +
  • +
+
+

quoted & "done"

+
+
+ +

Checkout the comment in the post.

diff --git a/pkg/mail/testdata/post_comment_author_markup_long_subject.golden b/pkg/mail/testdata/post_comment_author_markup_long_subject.golden new file mode 100644 index 00000000..d8aa9852 --- /dev/null +++ b/pkg/mail/testdata/post_comment_author_markup_long_subject.golden @@ -0,0 +1,25 @@ +EmailType: comment_notification +UniqueID: comment-1user-1 +From: Your pcom +To: author@example.test +Subject: New comment in your post "A very long & "quoted" subject that goes on and on to see how a mail client wraps a subject line of this length, past any sensible limit" + +--- TEXT --- +Hi! + +@alice has left a comment in your post "A very long & "quoted" subject that goes on and on to see how a mail client wraps a subject line of this length, past any sensible limit". + +> Nice **post**! + +Checkout the comment in the post: https://pcom.test/posts/post-1#commentpost-1comment-1 + +--- HTML --- + +

Hi!

+ +

@alice has left a comment in your post "A <b>very</b> long & "quoted" subject that goes on and on to see how a mail client wraps a subject line of this length, past any sensible limit".

+ +

Nice post!

+
+ +

Checkout the comment in the post.

diff --git a/pkg/mail/testdata/post_comment_author_with_url.golden b/pkg/mail/testdata/post_comment_author_with_url.golden index 0d6bdb97..e2bf411a 100644 --- a/pkg/mail/testdata/post_comment_author_with_url.golden +++ b/pkg/mail/testdata/post_comment_author_with_url.golden @@ -12,7 +12,7 @@ Linked URL: https://example.com/article > Nice post! -Checkout the comment in the post: /posts/post-1#commentpost-1comment-1 +Checkout the comment in the post: https://pcom.test/posts/post-1#commentpost-1comment-1 --- HTML --- @@ -23,4 +23,4 @@ Checkout the comment in the post: /posts/post-1#commentpost-1comment-1

Nice post!

-

Checkout the comment in the post.

+

Checkout the comment in the post.

diff --git a/pkg/mail/testdata/post_comment_participants.golden b/pkg/mail/testdata/post_comment_participants.golden index 19501998..67365b3d 100644 --- a/pkg/mail/testdata/post_comment_participants.golden +++ b/pkg/mail/testdata/post_comment_participants.golden @@ -11,7 +11,7 @@ Hi! > Great comment! -Checkout the comment in the post: /posts/post-1#commentpost-1comment-1 +Checkout the comment in the post: https://pcom.test/posts/post-1#commentpost-1comment-1 --- HTML --- @@ -22,4 +22,4 @@ Checkout the comment in the post: /posts/post-1#commentpost-1comment-1

Great comment!

-

Checkout the comment in the post.

+

Checkout the comment in the post.

diff --git a/pkg/mail/testdata/post_comment_participants_edited.golden b/pkg/mail/testdata/post_comment_participants_edited.golden index 6c02925e..fc2d9084 100644 --- a/pkg/mail/testdata/post_comment_participants_edited.golden +++ b/pkg/mail/testdata/post_comment_participants_edited.golden @@ -11,7 +11,7 @@ Hi! > Nice post, edited! -Checkout the comment in the post: /posts/post-1#commentpost-1comment-1 +Checkout the comment in the post: https://pcom.test/posts/post-1#commentpost-1comment-1 --- HTML --- @@ -22,4 +22,4 @@ Checkout the comment in the post: /posts/post-1#commentpost-1comment-1

Nice post, edited!

-

Checkout the comment in the post.

+

Checkout the comment in the post.

diff --git a/pkg/mail/testdata/post_comment_participants_markup.golden b/pkg/mail/testdata/post_comment_participants_markup.golden new file mode 100644 index 00000000..7055544e --- /dev/null +++ b/pkg/mail/testdata/post_comment_participants_markup.golden @@ -0,0 +1,39 @@ +EmailType: comment_notification +UniqueID: comment-1user-2 +From: Your pcom +To: participant@example.test +Subject: New comment in the post "Original Post" + +--- TEXT --- +Hi! + +@alice has left a comment in the post "Original Post" where you've also left a comment. + +> Nice **post**, see [the docs](https://example.com/a?b=1&c=2). +> +> - one +> - +> +> > quoted & "done" + +Checkout the comment in the post: https://pcom.test/posts/post-1#commentpost-1comment-1 + +--- HTML --- + +

Hi!

+ +

@alice has left a comment in the post "Original Post" where you've also left a comment.

+ +

Nice post, see the docs.

+
    +
  • one
  • +
  • + +
  • +
+
+

quoted & "done"

+
+
+ +

Checkout the comment in the post.

diff --git a/pkg/mail/testdata/post_comment_participants_markup_long_subject.golden b/pkg/mail/testdata/post_comment_participants_markup_long_subject.golden new file mode 100644 index 00000000..908142c3 --- /dev/null +++ b/pkg/mail/testdata/post_comment_participants_markup_long_subject.golden @@ -0,0 +1,25 @@ +EmailType: comment_notification +UniqueID: comment-1user-2 +From: Your pcom +To: participant@example.test +Subject: New comment in the post "A very long & "quoted" subject that goes on and on to see how a mail client wraps a subject line of this length, past any sensible limit" + +--- TEXT --- +Hi! + +@alice has left a comment in the post "A very long & "quoted" subject that goes on and on to see how a mail client wraps a subject line of this length, past any sensible limit" where you've also left a comment. + +> Nice **post**! + +Checkout the comment in the post: https://pcom.test/posts/post-1#commentpost-1comment-1 + +--- HTML --- + +

Hi!

+ +

@alice has left a comment in the post "A <b>very</b> long & "quoted" subject that goes on and on to see how a mail client wraps a subject line of this length, past any sensible limit" where you've also left a comment.

+ +

Nice post!

+
+ +

Checkout the comment in the post.

diff --git a/pkg/mail/testdata/post_comment_participants_with_url.golden b/pkg/mail/testdata/post_comment_participants_with_url.golden index cd6c9603..d373daca 100644 --- a/pkg/mail/testdata/post_comment_participants_with_url.golden +++ b/pkg/mail/testdata/post_comment_participants_with_url.golden @@ -12,7 +12,7 @@ Linked URL: https://example.com/article > Great comment! -Checkout the comment in the post: /posts/post-1#commentpost-1comment-1 +Checkout the comment in the post: https://pcom.test/posts/post-1#commentpost-1comment-1 --- HTML --- @@ -23,4 +23,4 @@ Checkout the comment in the post: /posts/post-1#commentpost-1comment-1

Great comment!

-

Checkout the comment in the post.

+

Checkout the comment in the post.

diff --git a/pkg/mail/testdata/post_prompt.golden b/pkg/mail/testdata/post_prompt.golden index 99628b85..d58607b0 100644 --- a/pkg/mail/testdata/post_prompt.golden +++ b/pkg/mail/testdata/post_prompt.golden @@ -9,12 +9,12 @@ Hi! @alice has asked you to write a post on "What is your favorite hobby?" -Head to new post page to give an update! /write?prompt=prompt-1 +Head to new post page to give an update! https://pcom.test/write?prompt=prompt-1 --- HTML --- -

Hi!

+

Hi!

-

@alice has asked you to write a post on "What is your favorite hobby?"

+

@alice has asked you to write a post on "What is your favorite hobby?"

-

Head to new post page to give an update! /write?prompt=prompt-1

+

Head to new post page to give an update! https://pcom.test/write?prompt=prompt-1

diff --git a/pkg/mail/testdata/post_prompt_answer.golden b/pkg/mail/testdata/post_prompt_answer.golden index 5f4b1d7b..fb53f54d 100644 --- a/pkg/mail/testdata/post_prompt_answer.golden +++ b/pkg/mail/testdata/post_prompt_answer.golden @@ -1,5 +1,5 @@ EmailType: post_prompt_answer -UniqueID: post-1 +UniqueID: post-2 From: Your pcom To: asker@example.test Subject: Response to your prompt from bob @@ -9,12 +9,12 @@ Hi! @bob has responded on your prompt "What is your favorite hobby?" with the post "My Answer" -Check out their post! /posts/post-1 +Check out their post! https://pcom.test/posts/post-1 --- HTML --- -

Hi!

+

Hi!

-

@bob has responded on your prompt "What is your favorite hobby?" with the post "My Answer"

+

@bob has responded on your prompt "What is your favorite hobby?" with the post "My Answer"

-

Head to new post page to give an update! /posts/post-1

+

Head to new post page to give an update! https://pcom.test/posts/post-1

diff --git a/pkg/mail/testdata/post_prompt_markup.golden b/pkg/mail/testdata/post_prompt_markup.golden new file mode 100644 index 00000000..c94ba66b --- /dev/null +++ b/pkg/mail/testdata/post_prompt_markup.golden @@ -0,0 +1,20 @@ +EmailType: post_prompt +UniqueID: prompt-1 +From: Your pcom +To: recipient@example.test +Subject: New prompt from alice + +--- TEXT --- +Hi! + +@alice has asked you to write a post on "What is bold & "quoted" ?" + +Head to new post page to give an update! https://pcom.test/write?prompt=prompt-1 + +--- HTML --- + +

Hi!

+ +

@alice has asked you to write a post on "What is <b>bold</b> & "quoted" <script>alert(1)</script>?"

+ +

Head to new post page to give an update! https://pcom.test/write?prompt=prompt-1

diff --git a/pkg/mail/testdata/send_actual_confirm_waiting_list.golden b/pkg/mail/testdata/send_actual_confirm_waiting_list.golden index 2105cc8f..f5feac4c 100644 --- a/pkg/mail/testdata/send_actual_confirm_waiting_list.golden +++ b/pkg/mail/testdata/send_actual_confirm_waiting_list.golden @@ -6,16 +6,16 @@ Subject: Waiting list on pcom --- TEXT --- - Hi! +Hi! - Thank you for your interest pcom! Please follow the link to confirm your email address +Thank you for your interest pcom! Please follow the link to confirm your email address - /confirm_waiting_list/request-1 +https://pcom.test/confirm_waiting_list/request-1 --- HTML --- -

Hi!

+

Hi!

-

Thank you for your interest pcom! Please follow the link to confirm your email address

+

Thank you for your interest pcom! Please follow the link to confirm your email address

- /confirm_waiting_list/request-1 +https://pcom.test/confirm_waiting_list/request-1 diff --git a/pkg/mail/testdata/send_actual_invitation.golden b/pkg/mail/testdata/send_actual_invitation.golden index 1b9c137d..4ab73fe6 100644 --- a/pkg/mail/testdata/send_actual_invitation.golden +++ b/pkg/mail/testdata/send_actual_invitation.golden @@ -6,16 +6,16 @@ Subject: Welcome to pcom --- TEXT --- - Hi! +Hi! - Welcome to pcom! Please follow the link to choose a username. You will log in with a code we mail you, so there is no password to set. +Welcome to pcom! Please follow the link to choose a username. You will log in with a code we mail you, so there is no password to set. - /invite/invite-1 +https://pcom.test/invite/invite-1 --- HTML --- -

Hi!

+

Hi!

-

Welcome to pcom! Please follow the link to choose a username. You will log in with a code we mail you, so there is no password to set.

+

Welcome to pcom! Please follow the link to choose a username. You will log in with a code we mail you, so there is no password to set.

- /invite/invite-1 +https://pcom.test/invite/invite-1 diff --git a/pkg/repo/mail_queue.go b/pkg/repo/mail_queue.go index e024d834..7bf6f129 100644 --- a/pkg/repo/mail_queue.go +++ b/pkg/repo/mail_queue.go @@ -2,6 +2,8 @@ package repo import ( "context" + "database/sql" + "errors" "time" "github.com/can3p/gogo/sender" @@ -21,20 +23,44 @@ func (s *Store) SendMail(ctx context.Context, snd MailQueue, uniqueID, emailType return snd.Send(ctx, s.exec, uniqueID, emailType, mail) } -// GetPendingEmails returns emails ready to be sent, locked for update. -func (s *Store) GetPendingEmails(ctx context.Context) ([]*model.OutgoingEmail, error) { - var emails []*model.OutgoingEmail +// ListDueEmailIDs returns the ids of the emails ready to be sent, oldest first. +// It takes no lock: LockDueEmail claims each one. +func (s *Store) ListDueEmailIDs(ctx context.Context) ([]string, error) { + var ids []string - err := s.query().NewSelect().Model(&emails). + err := s.query().NewSelect().Model((*model.OutgoingEmail)(nil)). + Column("id"). + Where("status = ?", model.OutgoingEmailStatusNew). + Where("try_at < ?", time.Now()). + Order("id"). + Scan(ctx, &ids) + if err != nil { + return nil, err + } + + return ids, nil +} + +// LockDueEmail locks one email for update inside the store's transaction. It +// returns nil when another worker holds the row or it is no longer new and due. +func (s *Store) LockDueEmail(ctx context.Context, id string) (*model.OutgoingEmail, error) { + email := new(model.OutgoingEmail) + + err := s.query().NewSelect().Model(email). + Where("id = ?", id). Where("status = ?", model.OutgoingEmailStatusNew). Where("try_at < ?", time.Now()). For("UPDATE SKIP LOCKED"). Scan(ctx) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { return nil, err } - return emails, nil + return email, nil } // UpdateOutgoingEmail updates an outgoing email record. diff --git a/pkg/service/accounts/codes.go b/pkg/service/accounts/codes.go index 4fc68e0e..bf36c041 100644 --- a/pkg/service/accounts/codes.go +++ b/pkg/service/accounts/codes.go @@ -13,7 +13,6 @@ import ( "math/big" "time" - "github.com/can3p/pcom/pkg/admin" "github.com/can3p/pcom/pkg/mail" "github.com/can3p/pcom/pkg/model" "github.com/can3p/pcom/pkg/pgsession" @@ -310,7 +309,7 @@ func (s *Service) FinishLogin(ctx context.Context, attemptID, code string) (*mod } if confirmed { - s.notifyAdmin(ctx, "signup confirmed", admin.SignupConfirmed(s.ident.Site, s.ident.From, s.ident.AdminAddress, user)) + s.notifyAdmin(ctx, "signup confirmed", mail.AdminSignupConfirmed(s.ident.From, s.ident.AdminAddress, user)) } return user, returnURL, nil diff --git a/pkg/service/accounts/invites.go b/pkg/service/accounts/invites.go index 7dda11f6..2ba4c4a6 100644 --- a/pkg/service/accounts/invites.go +++ b/pkg/service/accounts/invites.go @@ -5,7 +5,6 @@ import ( "fmt" "time" - "github.com/can3p/pcom/pkg/admin" "github.com/can3p/pcom/pkg/mail" "github.com/can3p/pcom/pkg/model" "github.com/can3p/pcom/pkg/pgsession" @@ -119,7 +118,7 @@ func (s *Service) AcceptInvite(ctx context.Context, invite *model.UserInvitation return err } - if err := s.send(ctx, tx, admin.NewUser(s.ident.Site, s.ident.From, s.ident.AdminAddress, u)); err != nil { + if err := s.send(ctx, tx, mail.AdminNewUser(s.ident.Site, s.ident.From, s.ident.AdminAddress, u)); err != nil { return err } diff --git a/pkg/service/accounts/signup.go b/pkg/service/accounts/signup.go index 9bb4fd82..8df1f95a 100644 --- a/pkg/service/accounts/signup.go +++ b/pkg/service/accounts/signup.go @@ -7,7 +7,6 @@ import ( "time" disposable "github.com/can3p/anti-disposable-email" - "github.com/can3p/pcom/pkg/admin" "github.com/can3p/pcom/pkg/forms/validation" "github.com/can3p/pcom/pkg/mail" "github.com/can3p/pcom/pkg/model" @@ -41,7 +40,7 @@ func (s *Service) CheckSignupEmail(ctx context.Context, address string) error { if parsedEmail.Disposable { go func() { - if err := s.send(ctx, s.store, admin.ThrowAwayEmailSignupAttempt(s.ident.From, s.ident.AdminAddress, address)); err != nil { + if err := s.send(ctx, s.store, mail.AdminThrowAwayEmailSignupAttempt(s.ident.From, s.ident.AdminAddress, address)); err != nil { log.Printf("failed to queue the throwaway email notification: %v", err) } }() @@ -138,7 +137,7 @@ func (s *Service) Register(ctx context.Context, email, username, attribution str return err } - if err := s.send(ctx, tx, admin.NewUser(s.ident.Site, s.ident.From, s.ident.AdminAddress, u)); err != nil { + if err := s.send(ctx, tx, mail.AdminNewUser(s.ident.Site, s.ident.From, s.ident.AdminAddress, u)); err != nil { return err } @@ -181,7 +180,7 @@ func (s *Service) JoinWaitingList(ctx context.Context, email, reason, attributio return fatal(err) } - return s.send(ctx, tx, admin.NewWaitingListMember(s.ident.Site, s.ident.From, s.ident.AdminAddress, request)) + return s.send(ctx, tx, mail.AdminNewWaitingListMember(s.ident.From, s.ident.AdminAddress, request)) }) } diff --git a/pkg/service/posts/comments_edit_db_test.go b/pkg/service/posts/comments_edit_db_test.go index c1d25a0c..7c3dbb82 100644 --- a/pkg/service/posts/comments_edit_db_test.go +++ b/pkg/service/posts/comments_edit_db_test.go @@ -156,3 +156,28 @@ func TestEditComment_LostConnection(t *testing.T) { require.Nil(t, got.EditedAt) require.Empty(t, out.Sent()) } + +// A mail that cannot be queued fails the change it reports, and the +// transaction rolls the change back. +func TestSaveAndComment_SenderFailureRollsBack(t *testing.T) { + t.Parallel() + + db := testdb.New(t).DB + ctx := context.Background() + w := newWorld(t, ctx, db) + out := fakesender.New() + s := posts.New(repo.New(db), out, nil) + sendErr := errors.New("queue is gone") + out.FailWith(sendErr) + + _, err := s.Save(ctx, w.author, posts.SaveInput{ + Subject: "published", Body: "a body for the post", Visibility: model.PostVisibilityPublic, Action: posts.ActionPublish, + }) + require.ErrorIs(t, err, sendErr) + require.Empty(t, testutil.Must(factory.ListPosts(ctx, db, w.author.ID))(t)) + + post := testutil.Must(factory.Post(ctx, db, w.author.ID, factory.Published()))(t) + require.ErrorIs(t, s.AddComment(ctx, w.direct, posts.CommentInput{PostID: post.ID, Body: "a fine comment"}), sendErr) + require.Empty(t, testutil.Must(factory.ListComments(ctx, db, post.ID))(t)) + require.Zero(t, commentsNumber(t, ctx, db, post.ID)) +} diff --git a/pkg/service/posts/posts.go b/pkg/service/posts/posts.go index 67f0f08d..7bc51eb1 100644 --- a/pkg/service/posts/posts.go +++ b/pkg/service/posts/posts.go @@ -105,7 +105,7 @@ func (s *Service) PostURL(postID string) string { // queue puts a formatted mail in the outbox of the transaction tx. A nil mail // is a notification nobody needs, and is skipped. -func (s *Service) queue(ctx context.Context, tx *repo.Store, out *mail.Outgoing) error { +func (s *Service) queue(ctx context.Context, tx *repo.Store, out *mail.Envelope) error { if out == nil { return nil } @@ -118,7 +118,7 @@ func (s *Service) queue(ctx context.Context, tx *repo.Store, out *mail.Outgoing) } // queueE is queue for the formatters that can fail. -func (s *Service) queueE(ctx context.Context, tx *repo.Store, out *mail.Outgoing, err error) error { +func (s *Service) queueE(ctx context.Context, tx *repo.Store, out *mail.Envelope, err error) error { if err != nil { return err } diff --git a/pkg/web/app/deps.go b/pkg/web/app/deps.go index ef8f5db5..b647ae13 100644 --- a/pkg/web/app/deps.go +++ b/pkg/web/app/deps.go @@ -51,6 +51,8 @@ type Config struct { HSTS bool // StaticCache serves /static with a long immutable Cache-Control. StaticCache bool + // DevRoutes mounts the /dev pages (mail previews); local development only. + DevRoutes bool // ShowErrors shows error details in pages. ShowErrors bool // ReportPanics mails AdminAddress when a page panics. diff --git a/pkg/web/app/router.go b/pkg/web/app/router.go index c9cebb00..08825ca8 100644 --- a/pkg/web/app/router.go +++ b/pkg/web/app/router.go @@ -9,6 +9,7 @@ import ( "github.com/can3p/gogo/util/ginhelpers/csrf" "github.com/can3p/pcom/pkg/admin" "github.com/can3p/pcom/pkg/auth" + "github.com/can3p/pcom/pkg/mail" "github.com/can3p/pcom/pkg/pgsession" "github.com/can3p/pcom/pkg/service/registry" "github.com/can3p/pcom/pkg/util/ginhelpers/csp" @@ -50,7 +51,7 @@ func New(d *Deps) *gin.Engine { // session, and its MustGet would panic inside the recovery user := auth.GetAPIUserData(c).DBUser - if nerr := d.Services.Accounts.SendAdminMail(c, admin.PageFailure(d.Config.SenderAddress, d.Config.AdminAddress, c, err, user)); nerr != nil { + if nerr := d.Services.Accounts.SendAdminMail(c, mail.AdminPageFailure(d.Config.SenderAddress, d.Config.AdminAddress, admin.ClonedCustomRecovery(c, err), user)); nerr != nil { log.Printf("failed to queue the page failure notification: %v", nerr) } })) diff --git a/pkg/web/app/routes.go b/pkg/web/app/routes.go index 2b050225..ceff7061 100644 --- a/pkg/web/app/routes.go +++ b/pkg/web/app/routes.go @@ -13,6 +13,7 @@ var articlesRE = regexp.MustCompile("^[a-z0-9]+(_[a-z0-9]+)*$") // mountRoutes registers every route on the groups New creates; each area's routes are in its own file. func mountRoutes(d *Deps, router *gin.Engine, apiGroup, r, controls, actions, nonControlsForms, controlsForms *gin.RouterGroup) { mountMediaRoutes(d, router) + mountDevRoutes(d, router) setupApi(apiGroup, d) mountPublicRoutes(d, r) mountAuthRoutes(d, r, actions, nonControlsForms) diff --git a/pkg/web/app/routes_dev.go b/pkg/web/app/routes_dev.go new file mode 100644 index 00000000..b1f08ca4 --- /dev/null +++ b/pkg/web/app/routes_dev.go @@ -0,0 +1,48 @@ +package app + +import ( + "net/http" + + "github.com/can3p/pcom/pkg/mail" + "github.com/gin-gonic/gin" +) + +// mountDevRoutes registers the developer pages under /dev when +// Config.DevRoutes is on; with it off nothing under /dev exists. The pages +// render the mails' samples only: no database and no session. +func mountDevRoutes(d *Deps, router *gin.Engine) { + if !d.Config.DevRoutes { + return + } + + dev := router.Group("/dev") + + dev.GET("/mail", func(c *gin.Context) { + c.HTML(http.StatusOK, "dev--mail-index.html", map[string]any{"Mails": mail.All()}) + }) + + dev.GET("/mail/:sample", func(c *gin.Context) { + name := c.Param("sample") + + for _, m := range mail.All() { + for _, s := range m.Samples { + if s.Name != name { + continue + } + + page := map[string]any{"Sample": s.Name, "Mail": m.Name, "Err": s.Err} + if s.Envelope != nil && s.Envelope.Mail != nil { + page["Subject"] = s.Envelope.Mail.Subject + page["Text"] = s.Envelope.Mail.Text + page["HTML"] = s.Envelope.Mail.Html + } + + c.HTML(http.StatusOK, "dev--mail-sample.html", page) + + return + } + } + + c.AbortWithStatus(http.StatusNotFound) + }) +} diff --git a/pkg/web/app/routes_dev_test.go b/pkg/web/app/routes_dev_test.go new file mode 100644 index 00000000..100152f2 --- /dev/null +++ b/pkg/web/app/routes_dev_test.go @@ -0,0 +1,82 @@ +package app_test + +import ( + "html/template" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/can3p/pcom/pkg/mail" + "github.com/can3p/pcom/pkg/testutil/fakesender" + "github.com/can3p/pcom/pkg/testutil/fakestorage" + "github.com/can3p/pcom/pkg/testutil/testdb" + "github.com/can3p/pcom/pkg/web/app" + "github.com/stretchr/testify/require" +) + +// escaped is s as html/template writes it into a page. +func escaped(t *testing.T, s string) string { + t.Helper() + + var b strings.Builder + require.NoError(t, template.Must(template.New("").Parse("{{.}}")).Execute(&b, s)) + + return b.String() +} + +func newDevApp(t *testing.T, dev bool) http.Handler { + t.Helper() + + return app.New(&app.Deps{ + DB: testdb.New(t).DB, + Sender: fakesender.New(), + MediaStorage: fakestorage.New(), + Config: app.Config{ + HTMLDir: "../../../cmd/web/client/html", + SessionSalt: "test-salt", + StaticAsset: func(n string) string { return "/static/" + n }, + DevRoutes: dev, + }, + }) +} + +func getPage(h http.Handler, target string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) + + return rec +} + +func TestDevRoutes_MailPreview(t *testing.T) { + t.Parallel() + + h := newDevApp(t, true) + + index := getPage(h, "/dev/mail") + require.Equal(t, http.StatusOK, index.Code) + + for _, m := range mail.All() { + require.Contains(t, index.Body.String(), m.Name) + + for _, s := range m.Samples { + require.Contains(t, index.Body.String(), `href="/dev/mail/`+s.Name+`"`) + + rec := getPage(h, "/dev/mail/"+s.Name) + require.Equal(t, http.StatusOK, rec.Code, s.Name) + require.Contains(t, rec.Body.String(), "