feat(email): styled approve/cancel buttons + rec summary block (#287)#298
Conversation
…#287) Approval-request emails were single-part plain-text with raw token URLs that read as untrusted to recipients and lacked the rec context an approver actually needs to decide ("approve what, in which account, costing how much?"). #287 calls for an HTML half with styled CTAs + a richer rec summary, shipped as multipart/alternative so plain-text clients still work. Changes: * `internal/email/templates.go`: - Extended `purchaseApprovalRequestTemplate` (plain-text) with per-rec Term / Payment / UpfrontCost / AccountLabel lines, a "Requested by" block, and a cancellation-window note (custom override or generic fallback). Approve/Cancel URLs are now on their own labeled lines (`Approve: <url>` / `Cancel: <url>`) matching the issue's plain-text fallback contract. - New `purchaseApprovalRequestHTMLTemplate` constant — full HTML5 document with inline-styled CTAs (`background:#16a34a` / red outline cancel), a summary table, the same authorized-approver block, and the cancellation-window note. Inline `style=""` only — CSS classes are not honoured by Outlook / mobile Gmail. - `(s *Sender) SendPurchaseApprovalRequest` now renders both halves and ships via `SendToEmailWithCCMultipart`. HTML render failure is non-fatal — falls back to single-part text so a template bug doesn't drop approval emails. * `internal/email/template_renderers.go`: - New `RenderPurchaseApprovalRequestEmailHTML` helper. * `internal/email/sender.go`: - New `SendToEmailWithCCMultipart` SES path. `htmlBody == ""` degrades to `SendToEmailWithCC` so callers without an HTML body don't need a parallel code path. - New `buildSESSendEmailInputMultipart` constructs `types.Body{Text, Html}`; SES handles the multipart/alternative MIME assembly server-side when both fields are populated. - `NotificationData` extended with `RequestedByName`, `RequestedByEmail`, `RequestedAt`, `CancellationWindowNote`. - `RecommendationSummary` extended with `Term`, `Payment`, `UpfrontCost`, `AccountLabel`. All new fields are zero-default- safe — existing email types that use these structs won't render them (their templates don't reference them). * `internal/email/smtp_sender.go`: - New `SendToEmailWithCCMultipart` SMTP path with the same htmlBody-empty fallback contract. - New `buildSMTPMessageMultipart` assembles a real multipart/alternative RFC-5322 message with both text/plain and text/html parts. - `(s *SMTPSender) SendPurchaseApprovalRequest` uses the multipart path with the same fallback as the SES variant. * `internal/email/template_renderers_test.go`: - 3 new tests pinning the plain-text new context fields, the HTML template's inline-styled buttons + summary + requested-by line, and the no-AuthorizedApprovers HTML branch. * `internal/email/sender_test.go`: - 2 new tests: `SendPurchaseApprovalRequest` ships multipart with both text + HTML bodies populated, the right href, and the configured (not hardcoded) From; `SendToEmailWithCCMultipart` falls back to single-part text when htmlBody is empty. * `internal/email/smtp_sender_test.go`: - 1 new test pinning the SMTP multipart message shape: `Content-Type: multipart/alternative` header + both `text/plain` and `text/html` part headers + body markers. Verification: - `go test ./...` clean across full root suite. - `go test ./internal/email/...` 280 + new tests, all green. - Pre-commit hooks (gofmt, govet, gosec, etc.) clean. - From: address sourced from `os.Getenv("FROM_EMAIL")` via `factory.go` — no hardcoded sender literal introduced. Out of scope (filing follow-ups): - RI exchange approval email (`SendRIExchangePendingApproval`) currently routes through SNS broadcast, not targeted SES. Upgrading it to multipart HTML requires switching delivery semantics from broadcast to per-recipient targeted send — a larger change. Will file as a follow-up. - List-Unsubscribe header — no per-recipient mute endpoint exists in this repo today. Will file a follow-up so the endpoint + header land together.
📝 WalkthroughWalkthroughAdds multipart/alternative email support (text + HTML) across SES and SMTP senders, extends email template data with requester and recommendation fields, introduces HTML template rendering and styled approve/cancel buttons, and updates approval-email flow to render and send both plain-text and HTML bodies. ChangesPurchase Approval Multipart Email
Sequence DiagramsequenceDiagram
participant Client
participant Notifier as SendPurchaseApprovalRequest
participant Renderer as TemplateRenderer
participant Sender as EmailSender (SES/SMTP)
participant Outbound as Email Service
Client->>Notifier: SendPurchaseApprovalRequest(ctx, NotificationData)
Notifier->>Renderer: RenderPurchaseApprovalRequestEmail(data)
Renderer-->>Notifier: textBody
Notifier->>Renderer: RenderPurchaseApprovalRequestEmailHTML(data)
Renderer-->>Notifier: htmlBody (or error)
Notifier->>Sender: SendToEmailWithCCMultipart(to, cc, subject, textBody, htmlBody)
alt htmlBody present
Sender->>Sender: build multipart/alternative (text + html)
else htmlBody empty or render error
Sender->>Sender: fallback to text-only send
end
Sender->>Outbound: deliver via SES or SMTP
Outbound-->>Client: email delivered (HTML or text fallback)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related issues
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
Bumping for review — this PR has been waiting since 10:58Z (~4.5 hours) with only the auto-summary, no actual review pass. CI is all green (4/4); no further pushes since the initial commit. @coderabbitai review |
|
Tip For best results, initiate chat on the files or code changes.
[review] |
…across Sender + SMTPSender Removes ~10 LOC of duplicated rendering + HTML-fallback composition from SendPurchaseApprovalRequest on both transports. The shared package-level helper sendPurchaseApprovalRequestVia takes a SenderInterface so it can delegate to the transport-specific SendToEmailWithCCMultipart without knowing which transport is in use. Added SendToEmailWithCCMultipart to SenderInterface (both Sender and SMTPSender already implemented it; the five mock/stub/noop test doubles that assert email.SenderInterface compliance each received a no-op stub). Intentionally NOT unified: buildSESSendEmailInputMultipart (AWS SDK structured types.Body) vs buildSMTPMessageMultipart (hand-rolled RFC-5322 multipart/alternative). Different APIs, different output shapes — merging them would be premature abstraction and obscure the transport-specific work each builder does.
Dedup refactor: approval-request rendering hoisted into shared helperAddresses the maintainer note about duplicated rendering composition across What changed
What was intentionally NOT unified
Verification: @coderabbitai review |
|
Tip For best results, initiate chat on the files or code changes.
[full_review] |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
🧹 Nitpick comments (1)
internal/email/templates.go (1)
414-420: 💤 Low valueConsider logging HTML render failures for debugging.
When
RenderPurchaseApprovalRequestEmailHTMLfails, the error is silently swallowed. While the graceful degradation to text-only is correct behavior, logging the HTML render error would help diagnose template bugs in production without breaking email delivery.♻️ Suggested improvement
// HTML render failure is non-fatal: degrade to single-part text. // SendToEmailWithCCMultipart already handles htmlBody=="" by delegating // to the single-part path on each transport. htmlBody, htmlErr := RenderPurchaseApprovalRequestEmailHTML(data) if htmlErr != nil { + // Log but don't fail - degradation to text-only is acceptable + fmt.Printf("warning: HTML template render failed, falling back to text-only: %v\n", htmlErr) htmlBody = "" }Or if you have a logging package available in this file:
logging.Warnf("HTML template render failed, falling back to text-only: %v", htmlErr)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/email/templates.go` around lines 414 - 420, When RenderPurchaseApprovalRequestEmailHTML(data) returns an error (htmlErr) we should still degrade to text-only but also record the failure for diagnostics; update the block around RenderPurchaseApprovalRequestEmailHTML and htmlErr to log the error (e.g., logging.Warnf or the file's existing logger) with a clear message like "HTML template render failed, falling back to text-only" including htmlErr, so SendToEmailWithCCMultipart continues to receive htmlBody=="" while the render failure is captured for debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@internal/email/templates.go`:
- Around line 414-420: When RenderPurchaseApprovalRequestEmailHTML(data) returns
an error (htmlErr) we should still degrade to text-only but also record the
failure for diagnostics; update the block around
RenderPurchaseApprovalRequestEmailHTML and htmlErr to log the error (e.g.,
logging.Warnf or the file's existing logger) with a clear message like "HTML
template render failed, falling back to text-only" including htmlErr, so
SendToEmailWithCCMultipart continues to receive htmlBody=="" while the render
failure is captured for debugging.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 59ce2d6c-a719-4ff7-b7a1-d54a7d356f04
📒 Files selected for processing (8)
internal/api/coverage_gaps_test.gointernal/email/interfaces.gointernal/email/smtp_sender.gointernal/email/templates.gointernal/purchase/mocks_test.gointernal/scheduler/scheduler_test.gointernal/server/app_test.gointernal/server/handler_ri_exchange_test.go
✅ Files skipped from review due to trivial changes (2)
- internal/server/handler_ri_exchange_test.go
- internal/api/coverage_gaps_test.go
|
Status note (autonomous CR loop): CodeRabbit appears stuck on this PR — it has posted the in-progress walkthrough at 10:58Z (commit CI is green (4/4 checks passing), I'm stopping the autonomous CR-bump loop on this PR — further pings just produce more "review triggered" auto-replies. This PR is now waiting on either (a) CR's backend recovering and posting a real review, or (b) human review. Will resume the CR-loop only if/when CR posts an actionable finding. |
|
Tip For best results, initiate chat on the files or code changes.
[full_review] |
…298) Addresses CodeRabbit's nitpick on PR #298 review 4229577658: `internal/email/templates.go:414-420` swallows the HTML render error when degrading to text-only. While the graceful fallback is correct behavior (text-only is the safer alternative to dropping the approval email entirely), the silent swallow hides template-syntax bugs from production diagnostics. Imports the project's standard `pkg/logging` package and emits a `Warnf` at the fallback site so an HTML render regression surfaces in logs without breaking email delivery. Comment notes the deliberate non-return decision so a future reader doesn't escalate the warning into an early `return err`. Verification: - `go build ./...` clean - `go test ./...` clean (4271 passed / 0 failed) - `go test ./internal/email/...` clean (286 passed)
|
@coderabbitai — addressed the lone nitpick from review
Verification:
@coderabbitai review |
|
Tip For best results, initiate chat on the files or code changes.
[review] |
…loses #332) (#333) * fix(email): short-circuit to no-op sender when EMAIL_ENABLED=false (closes #332) `internal/email/factory.go::NewSenderFromEnvironment` now returns a no-op `SenderInterface` at the top of the function when `EMAIL_ENABLED=false`, before dispatching by `SECRET_PROVIDER`. Background: the factory and the secret resolver share `SECRET_PROVIDER` for two different purposes (cloud email backend selection vs. secret-store backend selection). The local-dev `docker-compose.yml` already declares `EMAIL_ENABLED: "false"` and explains "Email disabled for local development (no SNS topic)", but the factory never reads the flag — it just dispatches on `SECRET_PROVIDER`. So: - `SECRET_PROVIDER=aws` + no AWS creds boots an SES sender that nothing exercises (passes by luck, not design). - `SECRET_PROVIDER=env` (the documented local-dev resolver per `internal/secrets/resolver.go:50`) + `EMAIL_ENABLED=false` hard-fails on startup: `failed to initialize email sender: unsupported email provider: env`. The factory now respects the documented `EMAIL_ENABLED` contract. The no-op sender implements all 15 `SenderInterface` methods, logs each invocation at debug level so local-dev traces still show where an email would have gone, and is guarded by a compile-time `var _ SenderInterface = (*NopSender)(nil)` assertion. Verification: - `go build ./...` clean - `go test ./...` clean (0 failures) * fix(email): log HTML approval-request render fallback for production diagnosis `sendPurchaseApprovalRequestVia` in `internal/email/templates.go` silently swallowed `RenderPurchaseApprovalRequestEmailHTML` errors when degrading to text-only delivery. The graceful fallback is correct (text-only is the safer alternative to dropping the approval email entirely), but the silent swallow hides template-syntax bugs from production diagnostics. This commit imports the project's standard `pkg/logging` package and emits a `Warnf` at the fallback site so an HTML render regression surfaces in logs without breaking email delivery. The fallback itself is unchanged — `htmlBody = ""` still routes through the multipart sender's text-only path. A comment notes the deliberate non-return decision so a future reader doesn't escalate the warning into an early `return err`. Originally a CodeRabbit nitpick on PR #298 that raced with the merge (commit eb09eab on the closed feat/issue-287 branch). Folded into this PR per follow-up tracking — both touch internal/email/ and the change is six lines. Verification: - `go build ./...` clean - `go test ./internal/email/...` clean * fix(email): parse EMAIL_ENABLED via strconv.ParseBool + drop PII from no-op logs (CR pass on PR #333) Addresses both actionable items from CodeRabbit's review on PR #333: 1. **EMAIL_ENABLED parsing** (`internal/email/factory.go`): the literal `== "false"` check only matched the lower-case spelling. `"False"`, `"FALSE"`, `"0"`, etc. would all be treated as enabled, even though the user clearly intended the no-op path. Replaced with `strconv.ParseBool` so the full set of false-y spellings is honoured (false/False/FALSE/0/f/F per stdlib semantics). Unset / empty values keep the default (enabled) so existing deployments that don't set the var are unaffected. Unparseable values emit a warning and fall through to the enabled path so the misconfiguration is visible in logs without bringing the app down. 2. **PII redaction** (`internal/email/nop_sender.go`): the previous logs included raw recipient email addresses, subjects, and (in one place) admin email addresses. Even in dev, log files leak into shared environments (terminal scrollback, screen-shares, support tickets), and email addresses are sufficient identifying information to require treating them as PII. Replaced all recipient/subject/email-data formatting with just method-name + recipient counts (e.g. `to=1 cc=3`) — enough trace context to know which email path was invoked without leaking who it would have gone to. Documented the policy in the type-level doc comment so a future maintainer doesn't add the addresses back. Verification: - `go build ./...` clean - `go test ./...` clean (0 failures) - `go test ./internal/email/...` clean * test(email): cover EMAIL_ENABLED dispatch + NopSender contract Tests added alongside the runtime changes from this PR. `internal/email/factory_test.go` gains `TestNewSenderFromEnvironment_EmailEnabled` — a table-driven test with fifteen sub-cases covering every branch of the new dispatch: - Six false-y EMAIL_ENABLED spellings (false, False, FALSE, 0, f, F) all short-circuit to NopSender, with SECRET_PROVIDER set to a deliberately-unsupported "env" backend so the assertion proves the short-circuit fires BEFORE provider dispatch. - Six true-y spellings (true, True, TRUE, 1, t, T) all fall through to the AWS sender via SECRET_PROVIDER=aws. - Unset / empty values fall through to the default-enabled path. - An unparseable value ("maybe") emits a warning (visible in test output) and falls through to enabled — protecting an accidental misconfiguration from taking the app down. `internal/email/nop_sender_test.go` (new file) covers the NopSender contract: - TestNopSender_AllMethodsReturnNil — exercises every one of the 15 SenderInterface methods and asserts they all return nil. A future bug that returns an error would propagate into the calling application path, breaking the EMAIL_ENABLED=false promise that no work happens. - TestNopSender_NilSafe — empty / nil inputs (empty CC list, empty bodies) must not panic. - TestNopSender_NilContext — a nil context.Context must not panic either; the no-op shouldn't be the surface that leaks a panic. - TestNopSender_SatisfiesInterface — runtime echo of the compile-time `var _ SenderInterface = (*NopSender)(nil)` guard, so a refactor that silently removes the guard is still caught. Verification: - `go test ./internal/email/...` clean (19 new sub-cases) - `go test ./...` clean (0 failures) * fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit <noreply@coderabbit.ai> * Apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Closes #287.
Why
The existing approval-request emails are single-part plain-text with raw token URLs. Recipients see a wall of opaque text that reads as untrusted — corp mail filters often quarantine these messages, and approvers don't have the rec context they need to decide ("approve what, in which account, costing how much?") without round-tripping to the dashboard.
What changed
Templates
purchaseApprovalRequestTemplate(plain-text, extended): now carries per-rec Term · Payment · UpfrontCost · AccountLabel lines, a "Requested by" header, and a cancellation-window note (custom override or generic fallback). Approve/Cancel URLs are now on labeled lines (Approve: <url>/Cancel: <url>) matching the issue's plain-text fallback contract.purchaseApprovalRequestHTMLTemplate(new): full HTML5 document with inline-styled CTAs (green "Approve" + red-outline "Cancel"), a summary table, the same authorized-approver block, and the cancellation-window note. Inlinestyle=""only — CSS classes aren't honoured by Outlook / mobile Gmail.Sender plumbing
SendToEmailWithCCMultiparton bothSender(SES) andSMTPSender.htmlBody == ""degrades to single-part text so callers without an HTML body don't need a parallel code path.buildSESSendEmailInputMultipartpopulatestypes.Body{Text, Html}; SES handles themultipart/alternativeassembly.buildSMTPMessageMultipartassembles a real RFC-5322multipart/alternativemessage with bothtext/plainandtext/htmlparts.(s *Sender) SendPurchaseApprovalRequestand(s *SMTPSender) SendPurchaseApprovalRequestnow render both halves and ship via the multipart path. HTML render failure is non-fatal — falls back to single-part text so a template bug doesn't drop approval emails.Data shape
NotificationDataextended withRequestedByName,RequestedByEmail,RequestedAt,CancellationWindowNote. All zero-default-safe — existing call sites that don't populate them get the legacy rendering (template{{if}}guards).RecommendationSummaryextended withTerm,Payment,UpfrontCost,AccountLabel. Same zero-default-safe shape.Test plan
SendPurchaseApprovalRequestcaptures the SESSendEmailInputand asserts bothBody.Text.DataandBody.Html.Dataare populated, with the right href in the HTML, andFromsourced from the configured value (not a hardcoded literal). The fallback path test asserts empty htmlBody → single-part text (Body.Htmlnil).Content-Type: multipart/alternativeheader + bothtext/plainandtext/htmlpart headers + body markers.go test ./...clean across full root suite.go test ./internal/email/...280 existing + new tests, all green.From:address sourced fromos.Getenv("FROM_EMAIL")viafactory.go— verified by grep: no hardcoded sender literal introduced.Out of scope (filing follow-ups)
SendRIExchangePendingApproval) currently routes through SNS broadcast, not targeted SES. Upgrading it to multipart HTML requires switching delivery semantics from broadcast to per-recipient targeted send — a larger change. Will file as a follow-up.List-Unsubscribeheader — no per-recipient mute endpoint exists in this repo today. Will file a follow-up so the endpoint + header land together.Notes for reviewers
html/template(preexisting choice — seetemplate_renderers.go:6). This means&in URLs becomes&in the plain-text body, which is a latent cosmetic issue not introduced here. My tests use substring-contains assertions on the URL path/parameters, not on the exact&vs&form, so they don't pin either choice.html/templateengine — context-aware escaping handles<a href>interpolation correctly (URLs insidehref=""are URL-context-escaped, not HTML-escaped).destructiveflag in the inline button styles isn't a CSS class; the styling is fully inline so the green/red distinction survives email clients that strip<style>blocks.Summary by CodeRabbit
New Features
Content Enhancements
Tests