Mail previews
+ {{ range .Mails }} +{{ .Name }}
+-
+ {{ range .Samples }}
+
- {{ .Name }} + {{ end }} +
No mails are registered.
+ {{ end }} +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 @@ + + +
+ + +No mails are registered.
+ {{ end }} +Mail: {{ .Mail }}
+ {{ if .Err }} +The sample failed to render: {{ .Err }}
+ {{ else }} +{{ .Subject }}
+{{ .Text }}
+ Hi!
- -New user alert:
- -Hi!
- -New waiting list member alert:
- -Hi!
- -New conrirmed user alert:
- -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:
- -%s
Hi!
- -New user alert:
- -Hi!
- -New waiting list member alert:
- -Hi!
- -New conrirmed user alert:
- -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: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/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\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: "quoted & "done"
\n
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:
+ +Hi!
+ +New waiting list member alert:
+ +Hi!
+ +Panic on the page:
+ +{{.Report}}Hi!
+ +New conrirmed user alert:
+ +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 pcomHi!
+ +New user alert:
+ +Hi!
+ +New waiting list member alert:
+ +Hi!
+ +New waiting list member alert:
+ +Hi!
+ +Panic on the page:
+ +[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) +
Hi!
+ +Panic on the page:
+ +[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) +
Hi!
+ +New conrirmed user alert:
+ +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 pcomTo: 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(), "