Skip to content

Commit e0efe0c

Browse files
author
Cryden Agent
committed
Merge branch 'feat/custom-email-templates' into main
2 parents d0431ac + 4058460 commit e0efe0c

14 files changed

Lines changed: 2875 additions & 31 deletions

File tree

cmd/smoketest/custom-email-templates/main.go

Lines changed: 460 additions & 0 deletions
Large diffs are not rendered by default.

cmd/smoketest/webhooks/main.go

Lines changed: 783 additions & 0 deletions
Large diffs are not rendered by default.

config.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,28 @@ type Config struct {
106106
// separates the label from the secret. Ignored unless APIKeys is
107107
// set.
108108
APIKeyPrefix string
109+
// Webhooks is optional — set it to have the engine hand every
110+
// subscribed event to the host app as it is recorded. Ships no
111+
// implementation (see notify.WebhookSender's own doc comment): the
112+
// engine makes no outbound HTTP call, so the endpoint, the signing
113+
// and the retries are the host's. Left nil, nothing about the engine
114+
// changes and no event is dispatched anywhere.
115+
//
116+
// Delivery is synchronous, on the request path, and a send error is
117+
// logged rather than returned — a webhook never fails the login it
118+
// was reporting.
119+
Webhooks notify.WebhookSender
120+
// WebhookEvents selects which events reach Webhooks. Left empty it is
121+
// DefaultWebhookEvents(), which is the actionable, low-volume subset
122+
// and deliberately excludes login_success, login_failed and
123+
// token_rotated; set it to take exact control. Ignored unless
124+
// Webhooks is set, and setting it without Webhooks is an error rather
125+
// than a subscription to nowhere.
126+
//
127+
// An event type the engine never records is never delivered and is
128+
// not an error — there is no canonical list to validate against, and
129+
// inventing one would be a second place to keep the constants.
130+
WebhookEvents []store.AuditEventType
109131
// PasswordPolicy is checked on every SignUp/ChangePassword. Unlike
110132
// TOTP/WebAuthn, this has no "unconfigured means off" state —
111133
// leaving it as the entire zero value (security.PasswordPolicy{})
@@ -266,6 +288,14 @@ func (c *Config) validate() error {
266288
if c.APIKeyPrefix != "" && strings.ContainsAny(c.APIKeyPrefix, " \t\r\n_") {
267289
return ErrInvalidAPIKeyPrefix
268290
}
291+
if len(c.WebhookEvents) > 0 && c.Webhooks == nil {
292+
return ErrMissingWebhookSender
293+
}
294+
for _, t := range c.WebhookEvents {
295+
if t == "" {
296+
return ErrInvalidWebhookEvent
297+
}
298+
}
269299
return nil
270300
}
271301

@@ -318,6 +348,9 @@ func (c *Config) applyDefaults() {
318348
if c.APIKeyPrefix == "" {
319349
c.APIKeyPrefix = "ck"
320350
}
351+
if c.Webhooks != nil && len(c.WebhookEvents) == 0 {
352+
c.WebhookEvents = DefaultWebhookEvents()
353+
}
321354
if c.Logger == nil {
322355
c.Logger = logger.NewConsoleJSONLogger()
323356
}

custom_email_templates_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package cryden
2+
3+
import (
4+
"context"
5+
"net/url"
6+
"reflect"
7+
"strings"
8+
"testing"
9+
10+
"github.com/crydensync/cryden/v2/notify"
11+
"github.com/crydensync/cryden/v2/store/memory"
12+
)
13+
14+
// These tests pin a verdict rather than a feature: email templates are
15+
// already entirely the host app's, because the engine composes nothing
16+
// and offers nowhere to configure a template. That stays true only if
17+
// nobody later adds a subject, a body, a from-address or a template
18+
// field to Config — which is what the reflection below is for. It fails
19+
// on the commit that adds one, in `go test ./...`, instead of quietly
20+
// making the manual guide wrong.
21+
22+
// The two send methods are the engine's entire outward email surface.
23+
// Both take a recipient and a raw token; neither has anywhere to put a
24+
// subject, a body, a URL or a locale.
25+
func TestEmailInterfaces_TakeOnlyARecipientAndAToken(t *testing.T) {
26+
for _, tc := range []struct {
27+
name string
28+
typ reflect.Type
29+
}{
30+
{"notify.EmailSender", reflect.TypeOf((*notify.EmailSender)(nil)).Elem()},
31+
{"notify.MagicLinkSender", reflect.TypeOf((*notify.MagicLinkSender)(nil)).Elem()},
32+
} {
33+
if got := tc.typ.NumMethod(); got != 1 {
34+
t.Errorf("%s has %d methods, want 1", tc.name, got)
35+
continue
36+
}
37+
const want = "func(context.Context, string, string) error"
38+
if got := tc.typ.Method(0).Type.String(); got != want {
39+
t.Errorf("%s.%s is %s, want %s", tc.name, tc.typ.Method(0).Name, got, want)
40+
}
41+
}
42+
}
43+
44+
// Config's only email-shaped fields are the two senders, and both are
45+
// interfaces the host implements. A new field named anything like
46+
// EmailSubject, EmailTemplate, FromAddress or SMTPHost fails this.
47+
func TestConfig_HasNoEmailTemplateKnobs(t *testing.T) {
48+
var found []string
49+
cfgType := reflect.TypeOf(Config{})
50+
for i := 0; i < cfgType.NumField(); i++ {
51+
f := cfgType.Field(i)
52+
lower := strings.ToLower(f.Name)
53+
for _, needle := range []string{"email", "mail", "sender", "template", "subject", "body", "smtp", "html", "from"} {
54+
if strings.Contains(lower, needle) {
55+
found = append(found, f.Name+" "+f.Type.Kind().String())
56+
break
57+
}
58+
}
59+
}
60+
want := []string{"EmailSender interface", "MagicLinkSender interface"}
61+
if !reflect.DeepEqual(found, want) {
62+
t.Errorf("Config's email-shaped fields are %v, want exactly %v", found, want)
63+
}
64+
}
65+
66+
// composingSender is a host app's mailer: it receives two strings and
67+
// writes the whole email itself, including the URL and the domain.
68+
type composingSender struct {
69+
subject, body, to string
70+
calls int
71+
}
72+
73+
func (s *composingSender) SendVerification(_ context.Context, to, rawToken string) error {
74+
s.calls++
75+
s.to = to
76+
s.subject = "Confirm your new address"
77+
s.body = `<a href="https://host.example/confirm?token=` + url.QueryEscape(rawToken) + `">Confirm</a>`
78+
return nil
79+
}
80+
81+
var _ notify.EmailSender = (*composingSender)(nil)
82+
83+
// The round trip that makes the verdict real: a link the host composed,
84+
// on the host's own domain, in the host's own markup, is accepted by
85+
// the engine — so owning the template costs the host nothing.
86+
func TestRequestEmailChange_AcceptsALinkTheHostComposed(t *testing.T) {
87+
sender := &composingSender{}
88+
cfg := validConfig()
89+
cfg.Verifications = memory.NewVerificationStore()
90+
cfg.EmailSender = sender
91+
e, err := New(cfg)
92+
if err != nil {
93+
t.Fatalf("unexpected error: %v", err)
94+
}
95+
ctx := context.Background()
96+
97+
user, err := SignUp(ctx, e, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4")
98+
if err != nil {
99+
t.Fatalf("SignUp: %v", err)
100+
}
101+
if err := RequestEmailChange(ctx, e, user.ID, "ray@acme.example"); err != nil {
102+
t.Fatalf("RequestEmailChange: %v", err)
103+
}
104+
if sender.calls != 1 || sender.to != "ray@acme.example" {
105+
t.Fatalf("sender got %d call(s) for %q, want 1 for the new address", sender.calls, sender.to)
106+
}
107+
108+
// Pull the token back out of the host's own HTML, exactly as the
109+
// user's browser would.
110+
href := sender.body[strings.Index(sender.body, `"`)+1:]
111+
link, err := url.Parse(href[:strings.Index(href, `"`)])
112+
if err != nil {
113+
t.Fatalf("the host's own link did not parse: %v", err)
114+
}
115+
raw := link.Query().Get("token")
116+
if raw == "" {
117+
t.Fatal("no token in the host's link")
118+
}
119+
120+
if err := ConfirmEmailChange(ctx, e, raw); err != nil {
121+
t.Fatalf("ConfirmEmailChange with the host-composed link: %v", err)
122+
}
123+
moved, err := GetUser(ctx, e, "ray@acme.example")
124+
if err != nil {
125+
t.Fatalf("the account did not move: %v", err)
126+
}
127+
if moved.ID != user.ID {
128+
t.Errorf("moved account is %q, want %q", moved.ID, user.ID)
129+
}
130+
}

docs/development/CURRENT-STATE.md

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,9 @@ re-derive it**:
214214
`CountTargetsForIP`, added by item 9 above against the same table.
215215

216216

217-
## Tier 3 — Infrastructure & Extensibility: IN PROGRESS (5 of 7)
217+
## Tier 3 — Infrastructure & Extensibility: DONE (7 of 7)
218218

219-
Two items left. See `NEXT.md`.
219+
Tier 4 is next. See `NEXT.md`.
220220

221221
### Item 12 — Argon2id hasher: DONE, branch `feat/argon2id-hasher`
222222

@@ -525,6 +525,104 @@ successes plus five unknown keys recording nothing). Manual guide:
525525
`docs/testing/api-keys.md`. `gofmt -l`, `go build ./...`, `go vet ./...`
526526
and `go test ./...` all clean here.
527527

528+
### Item 17 — webhooks: DONE, branch `feat/webhooks`
529+
530+
The engine tells the host app what happened instead of waiting to be
531+
asked. `Config.Webhooks` takes a `notify.WebhookSender`
532+
`SendWebhook(ctx, notify.WebhookEvent) error`, one method, **zero
533+
shipped implementations**, the same shape as `EmailSender` and
534+
`IPGeolocator`. `Config.WebhookEvents` selects which events reach it and
535+
defaults to `cryden.DefaultWebhookEvents()`.
536+
537+
Wired as a **decorator over `store.AuditStore`** (`webhookRecorder` in
538+
`webhooks.go`), not as a parameter on the 33 `audit.Record` call sites
539+
and not as a second event bus. `New` wraps `Config.Audit` when
540+
`Webhooks` is set; `Record` writes the row and then dispatches, so every
541+
existing call site notifies without a line changing and nothing in
542+
`auth/` knows the type exists. Reads pass straight through to the
543+
wrapped store.
544+
545+
`DefaultWebhookEvents()` is sixteen events, the ones bounded by human
546+
action, and deliberately excludes `login_success`, `token_rotated` and
547+
`login_failed` — a thousand logged-in users at the default 15-minute
548+
`AccessTokenTTL` is 4,000 `token_rotated` deliveries an hour, and
549+
`login_failed` volume is chosen by whoever is attacking you. It returns
550+
a fresh slice, so `append(cryden.DefaultWebhookEvents(), ...)` is the
551+
documented way to add one back. There is deliberately **no "all"**
552+
switch: it would silently start delivering event types added after the
553+
host wrote its sender.
554+
555+
Delivery is synchronous, on the request path, immediately after the
556+
audit write — so the doc comment on the interface says to enqueue rather
557+
than make the HTTP call there. A send error is logged at Error level and
558+
never fails the operation; a failed audit write still delivers; a
559+
**panic is not recovered** and takes the request, following
560+
`logger/multi.go`'s own stated rule that recovery exists only where a
561+
second sink can preserve the record. `Metadata` is copied before
562+
delivery so a sender cannot rewrite audit history, and `WebhookEvent.ID`
563+
is a delivery/idempotency key, explicitly not the audit row's ID — no
564+
backend reports that back.
565+
566+
Two new sentinels: `ErrMissingWebhookSender` (events set, no sender) and
567+
`ErrInvalidWebhookEvent` (an empty type). A non-empty but misspelled
568+
event type builds and is never delivered; there is no canonical list to
569+
validate against and inventing one would duplicate the constants.
570+
571+
No store change, **no migration**, no new dependency, no external
572+
service. Tests: 17 in `webhooks_test.go`. Smoke test:
573+
`cmd/smoketest/webhooks` (75 checks over ten sections, including five
574+
logins and five refreshes delivering nothing, a sender that errors, and
575+
a sender that panics). Manual guide: `docs/testing/webhooks.md`.
576+
`gofmt -l`, `go build ./...`, `go vet ./...` and `go test ./...` all
577+
clean here.
578+
579+
### Item 18 — custom email templates: DONE (no engine change), branch `feat/custom-email-templates`
580+
581+
**Nothing was built, and that is the finding.** The queue entry said to
582+
check `EmailSender`/`MagicLinkSender` first because there was "a real
583+
chance this needs no engine change at all." There is nothing to build.
584+
Checked and confirmed:
585+
586+
- Two interfaces, two methods, **two call sites in the whole tree**:
587+
`SendVerification` at `auth/email.go:70` (email change) and
588+
`SendMagicLink` at `auth/magiclink.go:88` (passwordless login). Each
589+
interface has exactly one purpose, so the "which email am I sending?"
590+
ambiguity `notify/magic_link_sender.go`'s doc comment worried about
591+
does not exist in practice.
592+
- Both methods pass `(ctx, to, rawToken)`. The engine composes no
593+
subject, no body, no HTML, no plain-text part, no from-address and no
594+
URL — it does not know the host's domain or routing, as both doc
595+
comments already say.
596+
- `Config` has exactly two email-shaped fields and both are those
597+
interfaces. There is no template, subject or from-address knob to
598+
override.
599+
- No third or fourth template is missing either: there is no signup
600+
verification flow (`store.PurposeEmailVerify` has no producer outside
601+
a store smoke test) and no password-reset flow at all
602+
(`ChangePassword` requires the current password), so nothing else in
603+
the engine wants to send mail.
604+
605+
Built instead of a feature: `docs/testing/custom-email-templates.md`
606+
answering the question behind the item (how a host controls what those
607+
emails say, in full), `cmd/smoketest/custom-email-templates` (54 checks
608+
over ten sections — a real host mailer with `html/template` bodies, two
609+
languages and two providers, whose own composed URL round-trips back
610+
into `ConfirmEmailChange` and `CompleteMagicLink`), and
611+
`custom_email_templates_test.go`, three tests that pin the verdict by
612+
reflection so a `Config.EmailSubject` added later fails `go test ./...`
613+
rather than quietly making the guide wrong.
614+
615+
One real gap recorded rather than filled: both TTLs
616+
(`changeEmailTokenTTL` 1 hour, `magicLinkTTL` 15 minutes) are unexported
617+
and not passed to the sender, so a template that says "expires in 1
618+
hour" hardcodes a number that could drift. Exporting two constants would
619+
fix it; adding a parameter to either send method would break every
620+
existing host implementation at compile time, which is why
621+
`MagicLinkSender` was a new interface rather than a second method on
622+
`EmailSender`. Queue it as its own item if the project owner wants it —
623+
it is not done here, on the item's own "don't build something
624+
speculative to have built something" instruction.
625+
528626
## Tier 4 — AI-assisted admin features: NOT STARTED
529627

530628
Four items, all read-only/surface-only by explicit, non-negotiable
@@ -605,6 +703,23 @@ project brief.
605703
**two migrations** that have to run before the feature works:
606704
`store/postgres/migrations/0007_api_keys.up.sql` and
607705
`store/sqlite/migrations/0002_api_keys.up.sql`. Unmerged and unpushed.
706+
- `feat/webhooks` — item 17, complete, 7 commits, branched from
707+
`feat/api-keys` at `f11e40e`, the tip of the chain, so this branch
708+
carries items 8 through 17. Touches engine files only (`config.go`,
709+
`errors.go`, `engine.go`, the new `webhooks.go`) plus the new
710+
`notify/webhook_sender.go`, so the same by-hand adjacency as items
711+
10-12, 14, 15 and 16 applies if it is lifted onto `main` alone. It
712+
adds **no dependency**, **no migration** and no store change at all —
713+
it delivers events the audit table already recorded. Unmerged and
714+
unpushed.
715+
- `feat/custom-email-templates` — item 18, complete, 4 commits,
716+
branched from `feat/webhooks` at `6f84095`, the tip of the chain, so
717+
this branch carries items 8 through 18. **Contains no engine change at
718+
all** — the `feat/` prefix is the naming convention, not a claim. Adds
719+
one root test file, one smoke test and one guide, touching no existing
720+
Go file, so unlike every branch before it this one lifts onto `main`
721+
with nothing to reconcile. No dependency, no migration. Unmerged and
722+
unpushed.
608723
- `fix/committed-smoketest-binary` — not a queue item. A pre-existing
609724
bug found while working on item 14: a 9.8 MB compiled `argon2id-hasher`
610725
binary was committed to the repo by item 12's session (`57a5dbd`) and

docs/development/NEXT.md

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,7 @@ patterns and note the assumption in `PROGRESS.md` — don't block on it.
1212

1313
---
1414

15-
## Tier 3 — Infrastructure & Extensibility
16-
17-
### 1. Webhooks (item 17)
18-
19-
Notify the host app on key events. Same question as everything else
20-
that reaches outward: interface-only, zero shipped implementations
21-
(`notify.WebhookSender` or similar), matching `EmailSender` — the
22-
engine surfaces the event, the host app's implementation does the
23-
actual HTTP call, retries, signing, etc. Decide which existing audit
24-
events should also trigger a webhook call (probably a configurable
25-
subset, not all of them) and wire it in wherever `audit.Record` is
26-
already called for those events — don't build a second parallel event
27-
bus.
28-
29-
### 2. Custom email templates (item 18)
30-
31-
Check `notify.EmailSender`/`notify.MagicLinkSender` as they exist
32-
today first — there's a real chance this needs **no engine change at
33-
all**, since the host app's own implementation already owns the
34-
actual email body/template (the engine only ever hands over a raw
35-
token, per `EmailSender`'s own doc comment). If that's true, say so
36-
plainly in `PROGRESS.md` and mark the item done-as-a-non-issue rather
37-
than building something speculative to have built something.
15+
Tier 3 is complete — all seven items done. See `CURRENT-STATE.md`.
3816

3917
---
4018

@@ -44,19 +22,19 @@ than building something speculative to have built something.
4422
automatic action — no auto-lock, no auto-config-change, nothing. Every
4523
one of these produces information for a human to act on.
4624

47-
### 3. Weekly digest (item 19)
25+
### 1. Weekly digest (item 19)
4826
Reads `AuditStore`, summarizes in plain English, returns text. Nothing
4927
else.
5028

51-
### 4. Support-ticket assistant (item 20)
29+
### 2. Support-ticket assistant (item 20)
5230
Read-only diagnosis ("why can't user X log in") — queries
5331
`AuditStore`/`UserStore`/session state, produces an explanation, never
5432
touches anything.
5533

56-
### 5. Config tuning advisor (item 21)
34+
### 3. Config tuning advisor (item 21)
5735
Produces a report of suggested config changes. Never applies them.
5836

59-
### 6. Ask-AI widget (item 22)
37+
### 4. Ask-AI widget (item 22)
6038
The most complex of the four. Needs its own full design pass before
6139
any code — at minimum: an LLM provider interface (zero shipped
6240
implementations, host brings their own key/provider, same pattern as

0 commit comments

Comments
 (0)