--- name: transactional-email description: Use when adding, changing, restyling, reviewing, validating, or previewing a Gram/Speakeasy transactional email, in Go or in LMX/MJML — a `template_.go`, a `TemplateKey` constant, a `RegisteredTemplates` entry, a `Variables()` map, or `AddToAudience()`; calling `emailSvc.Send`/`SendIdempotent`; wiring `email.NewService` or `loops.New`; testing sends with a capture client; editing `.lmx`/`.mjml` layout, `loops/manifest.json`, email copy, subject/preview text, or brand chrome; taking email screenshots or desktop/mobile QA; or any file under `server/internal/email/` or `server/internal/thirdparty/loops/`. Also use for "email renders blank fields", "camelCase variable", `ErrUnregisteredTemplate`, `ErrUnknownTemplateKey`, "no Loops ID for template", or `TestManifestMatchesApplicationTemplateContract` failures. metadata: relevant_files: - "server/internal/email/**/*.go" - "server/internal/email/loops/**" - "server/internal/thirdparty/loops/**/*.go" --- # Transactional email Transactional email goes through `server/internal/email`, a typed facade over the Loops client in `server/internal/thirdparty/loops`. Feature code depends on a consumer-owned interface implemented by `*email.Service`, and on concrete `email.Template` values; it never calls Loops directly and never holds a Loops transactional ID. Each template is one snake_case logical key shared by the Go struct, `server/internal/email/loops/manifest.json`, and `loops/.lmx`; the Go `Variables()` keys and the manifest `variables` list must match exactly. Release CI creates the Loops emails from the manifest; the deployment injects the key-to-ID JSON map, parsed by `email.ParseTemplateIDs`. No real provider ID belongs in source; synthetic IDs in tests are fine. ## Authorization and confidentiality - Repository edits do not authorize manual Loops reads, mutations, previews, test-sends, or publishes. Resolve each external action separately. - Never use customer or private production names, IDs, domains, addresses, URLs, or figures in source, tests, temporary previews, screenshots, logs, or test sends. Use `Example Organization`, `person@example.com`, and ``. - Keep content operational. No marketing copy or unsubscribe UI. - Recipient-visible copy names the product **Speakeasy**, never "Gram": subjects, preview text, body copy, CTA labels, image alt text, and footer reasons. Internal identifiers keep their existing names (logical keys such as `access_paused`, file names); only managed names carry the `gram.transactional.v2.` prefix. ## Adding or changing a template First check the `TemplateKey` constants in `server/internal/email/templates.go` for an existing template covering the event (for example `trial_ending_soon`, `access_paused`). Also check the send sites (`git grep -n 'email\.[A-Z][A-Za-z]*{' server/internal`): one template may serve several events, for example `access_paused` is also sent on trial demotion (`billingnotifications.AccessPausedTrialDemotion`). Extend an existing template (copy change or a condition variable) instead of adding a near-duplicate. What a change touches: | Change | Update together | | --------------------------------- | --------------------------------------------------------------------------------------------------------------- | | New template | Every surface in the list below, in one change | | Add, rename, or remove a variable | The Go struct, `Variables()`, the manifest `variables` (and `unused_variables`), the LMX, and the template test | | Subject or preview text | The manifest entry | | Body copy or layout | The LMX, then a fresh [preview](#preview-screenshots) | A new template needs all of these in one change: 1. `templates.go`: add a `TemplateKey` constant, and append a fully initialized zero value to `RegisteredTemplates` (the manifest contract test and `TemplateIDs.ValidateRegistered` iterate this list). 2. `template_.go`: a struct whose exported fields are the variables, implementing `Key()`, `Variables()`, and `AddToAudience()`. 3. `template__test.go`: assert `Key()`, the complete `Variables()` map, and `AddToAudience()` (see `template_access_paused_test.go`). 4. `server/internal/email/loops/.lmx`: copy `transactional_base.lmx` from the same directory and specialize it (see LMX rules below). 5. `loops/manifest.json`: add one object under `templates`; preserve `version`, `defaults`, and every existing template. ```json "example_notice": { "managed_name": "gram.transactional.v2.example_notice", "subject": "Action required for {data.resource_name}", "preview_text": "Review the requested change.", "source": "example_notice.lmx", "variables": ["resource_name", "action_url"] } ``` New managed names are `gram.transactional.v2.`. The sender identity lives only in the manifest `defaults` (`from_name: "Speakeasy"`, `from_email: "platform"`, `reply_to_email: "platform@speakeasy.com"`); never override it per template. A declared variable that the subject, preview, and LMX intentionally never reference (including `if=` conditions) must also be listed in the entry's `unused_variables`, or manifest validation fails with `declares unused variable`. `TestManifestMatchesApplicationTemplateContract` (`loops/manifest_contract_test.go`) requires one manifest entry per `RegisteredTemplates` entry, matching variable lists, exactly one canonical lockup and gradient `` in the LMX (matched as the full tag string, so copy both lines from `transactional_base.lmx` verbatim), and no "Gram" in subject, preview text, or LMX. ### `Variables()` keys Return **snake_case** keys. Loops substitutes them directly into `{data.}`; camelCase keys silently render as blank fields in the delivered email. Return every declared key, even when the value is empty, including condition-only variables. ```go func (t MyTemplate) Variables() map[string]string { return map[string]string{ "approval_url": t.ApprovalURL, // not "approvalUrl" "requester_email": t.RequesterEmail, // not "requesterEmail" } } ``` Never send a blank field that produces broken copy. Apply a fallback with `conv.Default` where the caller builds the struct, not in `Variables()` or the LMX (from `background/activities/weekly_usage_summary.go`): ```go tmpl := email.WeeklyUsageSummary{ OrganizationName: conv.Default(target.OrganizationName, "your organization"), // ... } ``` ### `AddToAudience()` Controls whether Loops upserts the recipient as a contact when the email is sent. Default to `false` (operational, billing, and admin alerts, one-off or incidental recipients). Return `true` only when the event deliberately enrolls a known user in the Speakeasy audience, matching `TeamInvite` or onboarding semantics. ## Sending Feature packages depend on a narrow interface declared at the consumer (see `billingnotifications.Sender` in `server/internal/billingnotifications/service.go`, `organizations.EmailSender` in `server/internal/organizations/impl.go`); `*email.Service` satisfies both. Send with `Send(ctx, recipient, tmpl)`. Use `SendIdempotent(ctx, recipient, idempotencyKey, tmpl)` when retries (Temporal activities, sweeps) could send twice. The key must be stable for the same event and recipient across retries, and distinct for an intentional resend. Loops dedupes it for 24 hours only and caps it at 100 characters, so hash long inputs; the billing path does this with `billingnotifications.RecipientIdempotencyKey(recipient, parts...)` (a 64-character sha256 hex digest). Keep one key helper: if a non-billing caller needs one, generalize it into `email` in that change rather than adding a second helper beside it; moving the billing callers onto it may be a follow-up. `email.NewService(logger, sender loops.Client, ids email.TemplateIDs, enabled bool)`: an empty recipient returns `email.ErrEmptyRecipient` even when disabled. With a nonempty recipient, a disabled service returns nil before template resolution or any provider call; an enabled one returns `email.ErrUnregisteredTemplate` for a key with no ID in `ids`. Never pass a nil `*email.Service`: production wiring (`newEmailService` in `server/cmd/gram/deps.go`) already builds a disabled service when Loops is unconfigured, and tests use the no-op service in [Go tests](#go-tests). Some existing callers still guard against nil; do not add new nil guards. ## Go tests No-op service (the code under test sends, but the test does not assert on it): ```go loopsClient := loops.New(t.Context(), testenv.NewLogger(t), nil, "") // empty key returns a noop client; nil guardian policy is safe emailSvc := email.NewService(testenv.NewLogger(t), loopsClient, email.NewTemplateIDs(nil), false) ``` Asserting on sends: reuse the test double already in the current package if it can record the payload, including an existing `testify/mock` client (record through `.Run`); do not add a second double beside it. If the package has none, add a small local capture of `loops.Client`, a one-method interface we own, rather than a new `testify/mock`. Do not copy or move doubles from other packages for a narrow change. Build the service with `enabled` true and an ID for each template under test, or nothing reaches the client: ```go type captureLoopsClient struct { mu sync.Mutex sent []loops.SendTransactionalInput } func (c *captureLoopsClient) SendTransactional(_ context.Context, input loops.SendTransactionalInput) error { c.mu.Lock() defer c.mu.Unlock() c.sent = append(c.sent, input) return nil } func (c *captureLoopsClient) Sent() []loops.SendTransactionalInput { c.mu.Lock() defer c.mu.Unlock() return append([]loops.SendTransactionalInput(nil), c.sent...) } captured := &captureLoopsClient{mu: sync.Mutex{}, sent: nil} emailSvc := email.NewService(testenv.NewLogger(t), captured, email.NewTemplateIDs(map[string]string{ "access_request": "access-request-test-id", }), true) ``` `require.NoError` on the send, then assert the whole payload with one `require.Equal` so a missing or renamed variable fails: ```go require.Equal(t, []loops.SendTransactionalInput{{ TransactionalID: "access-request-test-id", Email: "person@example.com", DataVariables: map[string]string{"requester_name": "Example User" /* every key */}, AddToAudience: false, IdempotencyKey: "", }}, captured.Sent()) ``` Existing captures to learn from: `server/internal/access/setup_test.go` (`recordingEmailSender`) and `server/internal/background/activities/setup_test.go` (`captureLoopsClient`, with failure injection). When the feature package sends through its own consumer interface, its tests can capture at that interface and type-assert the template instead (`captureSender` in `server/internal/billingnotifications/service_test.go`). ## LMX rules `loops/transactional_base.mjml` is the approved visual specification; `loops/transactional_base.lmx` is its production translation and the only shell to copy (Loops templates do not inherit a parent). Preserve the Speakeasy lockup header, light canvas, uppercase gray eyebrow, RGB gradient line under the headline, square black CTA, and closing footer (hairline divider, then the 12px gray footer reason on the white body). Transactional emails end there: no gray band, no black "Connect. Secure. Control. Observe." banner (`cmshjs1x705gp0jy23gmy6s06.png`), no dark masthead, neon accent, rounded corners, CSS gradients, or substitute palette. Never load webfonts (Gmail and Outlook strip `@font-face`); live text is Helvetica/Arial and the brand fonts appear only inside baked images. - Use `{data.variable_name}` everywhere (case-sensitive), never `{DATA_VARIABLE:...}`. Reference exactly the variables in the Go contract, not the starter's generic chrome variables. - Keep labels, headline fragments, body copy, CTA labels, and footer reasons static unless they genuinely vary at send time. - State the event directly; delete vague lead-ins. One verb-led CTA in sentence case ("Review access request"), no arrows or uppercase. - Delete the detail block or CTA when not needed; never render empty chrome. Use conditional sections for variants, e.g. `
` (see `openrouter_chat_credits_threshold.lmx`); do not invent fallback syntax. - LMX cannot embed raw HTML. Send scalar variables and compose the layout in LMX. - `` must be a Loops-hosted upload, never a repo-local or public URL. Copy the canonical assets unchanged: - Lockup (first block): `https://images.vialoops.com/clydgspni01t0bsa10jmd46rt/cmt7eueee05e20izu0frdx1jq.png`, `width="160"`, `align="left"`. Hosted render of `assets/speakeasy-lockup-black.png`; never pair the isotype with live wordmark text. - Gradient line (directly under the headline, once per email): `https://images.vialoops.com/clydgspni01t0bsa10jmd46rt/cmshilgvx01u30j6t4t0211tl.png`, `width="536"` in LMX (no width in MJML; it fills the 536px column). - Deprecated, never copy: the flat eight-block rail `cmsrzwke702cu0j3bz2gats4u.png` and the bare isotype `cmsrzv81y00z60i1dmtf9twha.png`. If a shell still has these, an Inter body font, or live wordmark text, update your copy to this spec and flag the shell for migration. - `