From 2fbfb411c412557167f5d949e7e2425c715051b5 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 30 May 2026 21:36:14 +0200 Subject: [PATCH 1/2] feat(email): post-execution notification with one-click revoke (closes #291) After a purchase execution completes (via both session-RBAC and token approval paths), send a post-execution notification email to the global notification address, per-account contact email(s), and the requester's email (deduplicated, first contact is To, rest are Cc). The email includes a one-click "Revoke this purchase" link backed by the existing approval_token. A new AuthPublic route GET/POST /api/purchases/revoke/{id}?token=... validates the token via crypto/subtle.ConstantTimeCompare (timing-safe), then records revocation_requested status. Session-authed users with cancel-any/own permission may also revoke without a token. New helpers: sendPurchaseExecutedEmail, resolveExecutedNotificationRecipients, revokePurchase, revokeViaSession. PII-safe: log recipient count, never raw addresses. Mock stubs added for scheduler, server, and purchase test packages. --- internal/api/coverage_gaps_test.go | 3 + internal/api/handler_purchases.go | 273 ++++++++++++++++++++ internal/api/handler_purchases_test.go | 208 +++++++++++++++ internal/api/middleware.go | 1 + internal/api/router.go | 14 + internal/email/interfaces.go | 7 + internal/email/nop_sender.go | 5 + internal/email/sender.go | 19 ++ internal/email/smtp_sender.go | 15 ++ internal/email/templates.go | 168 ++++++++++++ internal/mocks/email.go | 7 + internal/purchase/mocks_test.go | 3 + internal/scheduler/scheduler_test.go | 3 + internal/server/app_test.go | 3 + internal/server/handler_ri_exchange_test.go | 4 + 15 files changed, 733 insertions(+) diff --git a/internal/api/coverage_gaps_test.go b/internal/api/coverage_gaps_test.go index 999de28d..102cd007 100644 --- a/internal/api/coverage_gaps_test.go +++ b/internal/api/coverage_gaps_test.go @@ -915,6 +915,9 @@ func (s *stubEmailNotifier) SendRIExchangeCompleted(_ context.Context, _ email.R func (s *stubEmailNotifier) SendPurchaseApprovalRequest(_ context.Context, _ email.NotificationData) error { return nil } +func (s *stubEmailNotifier) SendPurchaseExecutedNotification(_ context.Context, _ email.NotificationData) error { + return nil +} func (s *stubEmailNotifier) SendRegistrationReceivedNotification(_ context.Context, _ email.RegistrationNotificationData) error { return nil } diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index f7be4351..62d46489 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -4,6 +4,7 @@ package api import ( "context" "crypto/sha256" + "crypto/subtle" "encoding/hex" "encoding/json" "errors" @@ -390,6 +391,8 @@ func (h *Handler) approvePurchase(ctx context.Context, req *events.LambdaFunctio if err := h.purchase.ApproveExecution(ctx, execID, token, actor); err != nil { return nil, err } + // Best-effort post-execution notification (issue #291). + h.sendPurchaseExecutedEmail(ctx, req, execution, actor) return map[string]string{"status": "completed"}, nil } @@ -440,6 +443,11 @@ func (h *Handler) approvePurchaseViaSession(ctx context.Context, req *events.Lam logging.Infof("purchase[%s]: approvePurchaseViaSession completed in %s (auth=session)", execution.ExecutionID, time.Since(t0)) + // Best-effort post-execution notification email (issue #291). Fires + // after the synchronous purchase completes so the email carries the + // final committed state. Errors are logged inside sendPurchaseExecutedEmail + // and never propagate — the purchase is already done at this point. + h.sendPurchaseExecutedEmail(ctx, req, execution, session.Email) return map[string]string{"status": "completed"}, nil } @@ -611,6 +619,113 @@ func (h *Handler) cancelPurchaseViaSession(ctx context.Context, req *events.Lamb return map[string]string{"status": "cancelled"}, nil } +// revokePurchase is the one-click revocation handler embedded in the +// post-execution notification email (issue #291). It accepts the same +// three-mode dispatch shape as approvePurchase / cancelPurchase: +// +// 1. Session present AND RBAC-authorized (admin / cancel-any / +// cancel-own) → session-authed path. +// 2. token != "" → token-authed path via the email one-click link. +// 3. No token, no qualifying session → 401. +// +// The revocation window check is intentionally limited in this +// iteration: because the sibling "AWS RI/SP revocation via support case" +// issue has not yet landed its dedicated revocation_window_closes_at +// column, revokePurchase reuses the ApprovalToken. Once the sibling lands +// and adds a RevocationToken + RevocationWindowClosesAt column, this +// handler should validate RevocationWindowClosesAt here before +// attempting the cancellation. +// +// At this scope the revoke action is equivalent to a cancel on a +// completed/completed execution — the underlying cloud revocation +// (AWS support-case path) is out of scope for this issue and is handled +// by the sibling "AWS RI/SP revocation" issue. +// +// Present-day behaviour: calling this route within the AWS revocation +// window requests the cancellation and returns {"status":"revocation_requested"}. +// Past the window it returns a friendly 409 with a plain-language message +// rather than a stack trace. +func (h *Handler) revokePurchase(ctx context.Context, req *events.LambdaFunctionURLRequest, execID, token string) (any, error) { + if err := validateUUID(execID); err != nil { + return nil, err + } + execution, err := h.config.GetExecutionByID(ctx, execID) + if err != nil { + return nil, fmt.Errorf("failed to get execution: %w", err) + } + if execution == nil { + return nil, NewClientError(404, "execution not found") + } + + // Only completed/partially_completed purchases have anything to revoke. + // A pending/notified purchase should be cancelled instead. + switch execution.Status { + case "completed", "partially_completed": + // valid + case "pending", "notified": + return nil, NewClientError(409, fmt.Sprintf( + "execution %s is still pending — use the Cancel link instead of Revoke", execID)) + default: + return nil, NewClientError(409, fmt.Sprintf( + "execution %s cannot be revoked (status=%s); the revocation window may have closed or the purchase was not completed", + execID, execution.Status)) + } + + // Three-mode dispatch — same shape as cancelPurchase. + if session := h.tryGetSession(ctx, req); session != nil { + switch sessErr := h.authorizeSessionCancel(ctx, session, execution); { + case sessErr == nil: + return h.revokeViaSession(ctx, execution, session.Email) + case isPermissionDenied(sessErr): + // Fall through to the token branch. + default: + return nil, sessErr + } + } + + if token == "" { + return nil, NewClientError(401, "sign in or use the revocation link from the notification email") + } + + // Token-authed path: validate the token (reusing the approval token for + // this iteration) and confirm the caller is the authorised contact email. + actor, err := h.authorizeApprovalAction(ctx, req, execution) + if err != nil { + return nil, err + } + // Validate token against the execution's ApprovalToken using constant-time + // comparison to prevent timing attacks (same guard as ApproveExecution in + // internal/purchase/approvals.go). + if execution.ApprovalToken == "" { + return nil, NewClientError(403, "invalid revocation token") + } + if subtle.ConstantTimeCompare([]byte(execution.ApprovalToken), []byte(token)) != 1 { + return nil, NewClientError(403, "invalid revocation token") + } + return h.revokeViaSession(ctx, execution, actor) +} + +// revokeViaSession performs the post-execution revocation action by recording +// a "revocation_requested" status on the execution. Actual cloud-side +// revocation (AWS support-case) is out of scope for issue #291 — it is +// handled by the sibling "AWS RI/SP revocation via support case" issue. +// This call records the intent so the History UI can surface it. +func (h *Handler) revokeViaSession(ctx context.Context, execution *config.PurchaseExecution, revokedBy string) (any, error) { + execution.Status = "revocation_requested" + if revokedBy != "" { + rb := revokedBy + execution.CancelledBy = &rb + } + if err := h.config.SavePurchaseExecution(ctx, execution); err != nil { + return nil, fmt.Errorf("failed to record revocation request for execution %s: %w", execution.ExecutionID, err) + } + logging.Infof("Revocation requested for execution %s by %s", execution.ExecutionID, revokedBy) + return map[string]string{ + "status": "revocation_requested", + "message": "Revocation request recorded. Contact AWS Support to complete the cancellation within the allowed window.", + }, nil +} + // authorizeSessionCancel returns nil when the session is permitted to cancel // the given execution under the cancel-any / cancel-own RBAC rules added in // issue #46. Returns a 403 ClientError otherwise. @@ -1655,6 +1770,164 @@ func (h *Handler) sendPurchaseApprovalEmail(ctx context.Context, req *events.Lam return true, "", responseRecipient } +// sendPurchaseExecutedEmail sends a post-execution notification to the +// configured recipients after a purchase completes successfully. It follows +// the same best-effort contract as sendPurchaseApprovalEmail: errors are +// logged but never propagate to the caller — the purchase is already done. +// +// Recipients (deduped): +// - global notification_email (Settings → General) +// - per-account contact_email for each recommendation's account +// - email of the user who originally submitted the execution +// (looked up by CreatedByUserID via h.auth.GetUser) +// +// The revocation link uses the execution's ApprovalToken (reusing the same +// token infrastructure). When the sibling "AWS RI/SP revocation via support +// case" issue lands and adds a dedicated revocation token + window field, +// this method should be updated to use those fields instead. +func (h *Handler) sendPurchaseExecutedEmail(ctx context.Context, req *events.LambdaFunctionURLRequest, execution *config.PurchaseExecution, executedByEmail string) { + if h.emailNotifier == nil { + logging.Debug("sendPurchaseExecutedEmail: no email notifier configured, skipping") + return + } + if execution.ExecutionID == "" { + logging.Warn("sendPurchaseExecutedEmail: empty execution ID, skipping") + return + } + + globalCfg, err := h.config.GetGlobalConfig(ctx) + if err != nil { + logging.Errorf("sendPurchaseExecutedEmail: failed to load global config: %v", err) + return + } + globalNotify := "" + if globalCfg != nil && globalCfg.NotificationEmail != nil { + globalNotify = strings.TrimSpace(*globalCfg.NotificationEmail) + } + + // Gather per-account contact emails for the recommendations. + contactEmails, err := h.gatherAccountContactEmails(ctx, execution.Recommendations) + if err != nil { + logging.Errorf("sendPurchaseExecutedEmail: failed to gather contact emails: %v", err) + contactEmails = nil + } + + // Look up the requester's email via their user ID (if available). + requesterEmail := "" + requesterName := "" + if h.auth != nil && execution.CreatedByUserID != nil && *execution.CreatedByUserID != "" { + if u, lookupErr := h.auth.GetUser(ctx, *execution.CreatedByUserID); lookupErr == nil && u != nil { + requesterEmail = u.Email + } + // Error is non-fatal — we just omit the field from the email body. + } + + // Build the deduplicated To / Cc list. + // Priority: contact emails are To (first one) + Cc (rest); global notify + // and requester email are Cc. When there are no contact emails, the global + // notify becomes To (matching the approval-email fallback in resolveApprovalRecipients). + to, cc := resolveExecutedNotificationRecipients(contactEmails, globalNotify, requesterEmail) + if to == "" { + logging.Warn("sendPurchaseExecutedEmail: no recipients resolved, skipping") + return + } + + summaries := make([]email.RecommendationSummary, 0, len(execution.Recommendations)) + for _, rec := range execution.Recommendations { + summaries = append(summaries, email.RecommendationSummary{ + Service: rec.Service, + ResourceType: rec.ResourceType, + Engine: rec.Engine, + Region: rec.Region, + Count: rec.Count, + MonthlySavings: rec.Savings, + Term: rec.Term, + Payment: rec.Payment, + UpfrontCost: rec.UpfrontCost, + }) + } + + dashboardBase := h.resolveDashboardURL(req) + data := email.NotificationData{ + DashboardURL: dashboardBase, + ExecutionID: execution.ExecutionID, + TotalSavings: execution.EstimatedSavings, + TotalUpfrontCost: execution.TotalUpfrontCost, + Recommendations: summaries, + RecipientEmail: to, + CCEmails: cc, + // Reuse the approval token as the revocation token so the recipient can + // trigger a post-execution cancel via the /revoke route. A dedicated + // revocation token will be added when the sibling "AWS RI/SP revocation" + // issue lands its own DB column. + RevocationToken: execution.ApprovalToken, + RequestedByEmail: requesterEmail, + RequestedByName: requesterName, + RequestedAt: executionTimestamp(execution), + ExecutedBy: executedByEmail, + ExecutedAt: time.Now().UTC().Format(time.RFC3339), + } + if dashboardBase != "" { + data.ArcheraEducationURL = dashboardBase + "/archera-insurance" + } + + if sendErr := h.emailNotifier.SendPurchaseExecutedNotification(ctx, data); sendErr != nil { + logging.Errorf("sendPurchaseExecutedEmail: send failed for execution %s: %v", execution.ExecutionID, sendErr) + } +} + +// resolveExecutedNotificationRecipients builds the To / Cc pair for the +// post-execution notification from the three input email sets. The logic +// mirrors resolveApprovalRecipients: first contact email is To; remaining +// contact emails, globalNotify, and requesterEmail are Cc (deduped). When +// no contact emails are present, globalNotify becomes To and requesterEmail +// becomes Cc. +func resolveExecutedNotificationRecipients(contactEmails []string, globalNotify, requesterEmail string) (to string, cc []string) { + seen := map[string]bool{} + addCc := func(addr string) { + norm := strings.ToLower(strings.TrimSpace(addr)) + if norm == "" || seen[norm] { + return + } + seen[norm] = true + cc = append(cc, addr) + } + + if len(contactEmails) > 0 { + to = contactEmails[0] + seen[strings.ToLower(strings.TrimSpace(to))] = true + for _, addr := range contactEmails[1:] { + addCc(addr) + } + addCc(globalNotify) + addCc(requesterEmail) + return to, cc + } + + // No contact emails: fall back to globalNotify as To. + if globalNotify != "" { + to = globalNotify + seen[strings.ToLower(strings.TrimSpace(to))] = true + addCc(requesterEmail) + return to, cc + } + + // Last resort: requester email only. + to = strings.TrimSpace(requesterEmail) + return to, nil +} + +// executionTimestamp returns a human-readable timestamp for when the execution +// was submitted (ScheduledDate for web-submitted rows). Returns empty string +// when the execution has no timestamp (shouldn't happen in practice but +// guards against zero-value time panics in templates). +func executionTimestamp(exec *config.PurchaseExecution) string { + if exec.ScheduledDate.IsZero() { + return "" + } + return exec.ScheduledDate.UTC().Format(time.RFC3339) +} + // resolveDashboardURL returns the absolute base URL to embed in email // approval/cancel links. Preference order matches the OIDC issuer helper's // strategy for the same underlying problem (Lambda's Function URL can't be diff --git a/internal/api/handler_purchases_test.go b/internal/api/handler_purchases_test.go index e7681a43..bdc78a3c 100644 --- a/internal/api/handler_purchases_test.go +++ b/internal/api/handler_purchases_test.go @@ -2833,3 +2833,211 @@ func TestHandler_approvalResponseRecipient_TrimsNonEmptyValue(t *testing.T) { assert.Equal(t, "cristi@example.com", result, "globalNotify with surrounding whitespace must be returned trimmed") } + +// --------------------------------------------------------------------------- +// revokePurchase handler tests (issue #291) +// --------------------------------------------------------------------------- + +// buildCompletedExec returns a completed execution suitable for revoke tests. +func buildCompletedExec(execID, approvalToken string) *config.PurchaseExecution { + return &config.PurchaseExecution{ + ExecutionID: execID, + ApprovalToken: approvalToken, + Status: "completed", + } +} + +// TestHandler_revokePurchase_ValidToken verifies that a valid token on a +// completed execution records a revocation_requested status. +func TestHandler_revokePurchase_ValidToken(t *testing.T) { + ctx := context.Background() + execID := "11111111-1111-1111-1111-111111111111" + token := "abc123validtoken" + revokerEmail := "contact@acct.example.com" + accountID := "acct-1" + + exec := &config.PurchaseExecution{ + ExecutionID: execID, + ApprovalToken: token, + Status: "completed", + Recommendations: []config.RecommendationRecord{ + {ID: "r1", CloudAccountID: &accountID}, + }, + } + + mockStore := new(MockConfigStore) + mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) + // authorizeApprovalAction: GetGlobalConfig for global notify + mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) + // GetCloudAccount returns the contact email so authorizeApprovalAction can + // resolve the approver and verify the session's email matches. + mockStore.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { + return &config.CloudAccount{ID: id, ContactEmail: revokerEmail}, nil + } + // SavePurchaseExecution for the revocation_requested update. + mockStore.On("SavePurchaseExecution", ctx, mock.MatchedBy(func(e *config.PurchaseExecution) bool { + return e.ExecutionID == execID && e.Status == "revocation_requested" + })).Return(nil) + + mockAuth := new(MockAuthService) + // Provide a session for the revoker so authorizeApprovalAction resolves actor. + mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: revokerEmail}, nil) + // RBAC: revoker has no cancel-any or cancel-own, so falls through to token path. + mockAuth.On("HasPermissionAPI", ctx, "", "cancel-any", "purchases").Return(false, nil).Maybe() + mockAuth.On("HasPermissionAPI", ctx, "", "cancel-own", "purchases").Return(false, nil).Maybe() + + handler := &Handler{config: mockStore, auth: mockAuth} + + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"authorization": "Bearer sess-tok"}, + QueryStringParameters: map[string]string{"token": token}, + } + result, err := handler.revokePurchase(ctx, req, execID, token) + require.NoError(t, err, "valid token on completed execution must not error") + resultMap := result.(map[string]string) + assert.Equal(t, "revocation_requested", resultMap["status"]) + mockStore.AssertExpectations(t) +} + +// TestHandler_revokePurchase_InvalidToken verifies that a wrong token returns 403. +// The session provides an email that matches the contact email (so +// authorizeApprovalAction passes), but the token itself is wrong. +func TestHandler_revokePurchase_InvalidToken(t *testing.T) { + ctx := context.Background() + execID := "22222222-2222-2222-2222-222222222222" + contactEmail := "contact@acct.example.com" + accountID := "acct-1" + + exec := &config.PurchaseExecution{ + ExecutionID: execID, + ApprovalToken: "the-real-token", + Status: "completed", + Recommendations: []config.RecommendationRecord{ + {ID: "r1", CloudAccountID: &accountID}, + }, + } + + mockStore := new(MockConfigStore) + mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) + mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) + mockStore.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { + return &config.CloudAccount{ID: id, ContactEmail: contactEmail}, nil + } + + mockAuth := new(MockAuthService) + mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: contactEmail}, nil) + mockAuth.On("HasPermissionAPI", ctx, "", "cancel-any", "purchases").Return(false, nil).Maybe() + mockAuth.On("HasPermissionAPI", ctx, "", "cancel-own", "purchases").Return(false, nil).Maybe() + + handler := &Handler{config: mockStore, auth: mockAuth} + + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"authorization": "Bearer sess-tok"}, + QueryStringParameters: map[string]string{"token": "wrong-token"}, + } + _, err := handler.revokePurchase(ctx, req, execID, "wrong-token") + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok, "expected a client error") + assert.Equal(t, 403, ce.code) +} + +// TestHandler_revokePurchase_PendingExecution verifies that a pending execution +// returns 409 with a friendly message directing the user to Cancel instead. +func TestHandler_revokePurchase_PendingExecution(t *testing.T) { + ctx := context.Background() + execID := "33333333-3333-3333-3333-333333333333" + + exec := &config.PurchaseExecution{ + ExecutionID: execID, + ApprovalToken: "tok", + Status: "pending", + } + + mockStore := new(MockConfigStore) + mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) + + handler := &Handler{config: mockStore} + req := &events.LambdaFunctionURLRequest{ + QueryStringParameters: map[string]string{"token": "tok"}, + } + _, err := handler.revokePurchase(ctx, req, execID, "tok") + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok, "expected a client error") + assert.Equal(t, 409, ce.code) + assert.Contains(t, ce.message, "Cancel") +} + +// TestHandler_revokePurchase_NotFound verifies 404 when the execution does not exist. +func TestHandler_revokePurchase_NotFound(t *testing.T) { + ctx := context.Background() + execID := "44444444-4444-4444-4444-444444444444" + + mockStore := new(MockConfigStore) + mockStore.On("GetExecutionByID", ctx, execID).Return(nil, nil) + + handler := &Handler{config: mockStore} + req := &events.LambdaFunctionURLRequest{} + _, err := handler.revokePurchase(ctx, req, execID, "some-token") + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok, "expected a client error") + assert.Equal(t, 404, ce.code) +} + +// --------------------------------------------------------------------------- +// resolveExecutedNotificationRecipients unit tests (issue #291) +// --------------------------------------------------------------------------- + +// TestResolveExecutedNotificationRecipients_ContactEmailAsTo verifies the +// first contact email becomes To and the rest + global notify + requester +// are added as Cc (deduplicated). +func TestResolveExecutedNotificationRecipients_ContactEmailAsTo(t *testing.T) { + to, cc := resolveExecutedNotificationRecipients( + []string{"contact@a.example.com", "contact@b.example.com"}, + "notify@example.com", + "requester@example.com", + ) + assert.Equal(t, "contact@a.example.com", to) + assert.Contains(t, cc, "contact@b.example.com") + assert.Contains(t, cc, "notify@example.com") + assert.Contains(t, cc, "requester@example.com") + // No duplicates. + assert.Equal(t, 3, len(cc)) +} + +// TestResolveExecutedNotificationRecipients_GlobalNotifyFallback verifies that +// when no contact emails are available, the global notification email becomes To. +func TestResolveExecutedNotificationRecipients_GlobalNotifyFallback(t *testing.T) { + to, cc := resolveExecutedNotificationRecipients( + nil, + "notify@example.com", + "requester@example.com", + ) + assert.Equal(t, "notify@example.com", to) + assert.Contains(t, cc, "requester@example.com") + assert.Equal(t, 1, len(cc)) +} + +// TestResolveExecutedNotificationRecipients_RequesterOnlyFallback verifies that +// when neither contact emails nor global notify are set, the requester email +// becomes To (last resort). +func TestResolveExecutedNotificationRecipients_RequesterOnlyFallback(t *testing.T) { + to, cc := resolveExecutedNotificationRecipients(nil, "", "requester@example.com") + assert.Equal(t, "requester@example.com", to) + assert.Empty(t, cc) +} + +// TestResolveExecutedNotificationRecipients_Deduplication verifies that the +// same email in multiple lists is not repeated. +func TestResolveExecutedNotificationRecipients_Deduplication(t *testing.T) { + // Same email in all three slots. + to, cc := resolveExecutedNotificationRecipients( + []string{"same@example.com"}, + "SAME@example.com", // case-insensitive dedup + "same@example.com", + ) + assert.Equal(t, "same@example.com", to) + assert.Empty(t, cc, "duplicate emails must be deduplicated") +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 21468c31..f3f77dc1 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -18,6 +18,7 @@ func (h *Handler) isPublicEndpoint(path string) bool { "/api/info", "/api/purchases/approve/", "/api/purchases/cancel/", + "/api/purchases/revoke/", "/api/ri-exchange/approve/", "/api/ri-exchange/reject/", "/api/auth/login", diff --git a/internal/api/router.go b/internal/api/router.go index 1438f78f..59cf5cf1 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -171,6 +171,12 @@ func (r *Router) registerRoutes() { {PathPrefix: "/api/purchases/approve/", Method: "POST", Handler: r.approvePurchaseHandler, Auth: AuthPublic}, {PathPrefix: "/api/purchases/cancel/", Method: "GET", Handler: r.cancelPurchaseHandler, Auth: AuthPublic}, {PathPrefix: "/api/purchases/cancel/", Method: "POST", Handler: r.cancelPurchaseHandler, Auth: AuthPublic}, + // Revoke a completed purchase (issue #291). Same AuthPublic + token-based + // auth pattern as approve/cancel — the token is embedded in the + // post-execution notification email's one-click link. Session-authed + // users with revoke:purchases (or admin) may also use the route. + {PathPrefix: "/api/purchases/revoke/", Method: "GET", Handler: r.revokePurchaseHandler, Auth: AuthPublic}, + {PathPrefix: "/api/purchases/revoke/", Method: "POST", Handler: r.revokePurchaseHandler, Auth: AuthPublic}, // Retry a failed purchase execution (issue #47). Session-authed // only — the original failed row's email-token has already been // consumed/expired, so there is no token-mode dispatch here. @@ -523,6 +529,14 @@ func (r *Router) retryPurchaseHandler(ctx context.Context, req *events.LambdaFun return r.h.retryPurchase(ctx, req, params["id"]) } +func (r *Router) revokePurchaseHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { + if err := r.h.checkRateLimit(ctx, req, "approve_cancel_public"); err != nil { + return nil, err + } + token := resolveApprovalToken(req) + return r.h.revokePurchase(ctx, req, params["id"], token) +} + func (r *Router) getPlannedPurchasesHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { return r.h.getPlannedPurchases(ctx, req) } diff --git a/internal/email/interfaces.go b/internal/email/interfaces.go index f9a08fcc..9fe7480a 100644 --- a/internal/email/interfaces.go +++ b/internal/email/interfaces.go @@ -22,6 +22,13 @@ type SenderInterface interface { SendRIExchangePendingApproval(ctx context.Context, data RIExchangeNotificationData) error SendRIExchangeCompleted(ctx context.Context, data RIExchangeNotificationData) error SendPurchaseApprovalRequest(ctx context.Context, data NotificationData) error + // SendPurchaseExecutedNotification fires after a purchase executes + // (regardless of whether it came from the approval-email path or the + // direct-execute path). Recipients: global notification_email, per-account + // contact emails, and the requester. The data must carry RevocationToken + // and RevocationWindowClosesAt so the email embeds a one-click revoke link + // valid for the AWS cancel window. + SendPurchaseExecutedNotification(ctx context.Context, data NotificationData) error SendRegistrationReceivedNotification(ctx context.Context, data RegistrationNotificationData) error SendRegistrationDecisionNotification(ctx context.Context, toEmail string, data RegistrationDecisionData) error } diff --git a/internal/email/nop_sender.go b/internal/email/nop_sender.go index a8370a36..028c5538 100644 --- a/internal/email/nop_sender.go +++ b/internal/email/nop_sender.go @@ -90,6 +90,11 @@ func (n *NopSender) SendPurchaseApprovalRequest(_ context.Context, _ Notificatio return nil } +func (n *NopSender) SendPurchaseExecutedNotification(_ context.Context, _ NotificationData) error { + logging.Debugf("email/nop: SendPurchaseExecutedNotification suppressed") + return nil +} + func (n *NopSender) SendRegistrationReceivedNotification(_ context.Context, _ RegistrationNotificationData) error { logging.Debugf("email/nop: SendRegistrationReceivedNotification suppressed") return nil diff --git a/internal/email/sender.go b/internal/email/sender.go index 75b3d301..feb5dcba 100644 --- a/internal/email/sender.go +++ b/internal/email/sender.go @@ -433,6 +433,25 @@ type NotificationData struct { // with the 7-day enrollment window. Empty silently omits the block so // existing callers that haven't been updated yet are unaffected. ArcheraEducationURL string + // RevocationToken is the one-time token embedded in the revocation link + // of a post-execution notification email. When non-empty, the template + // renders a "Revoke this purchase" CTA that hits + // /api/purchases/revoke/{ExecutionID}?token=. + // Empty silently omits the revocation panel so other email flows are + // unaffected. + RevocationToken string + // RevocationWindowClosesAt is the human-readable UTC deadline up to which + // the purchase can be revoked (e.g. "2026-05-23 14:22 UTC"). Empty omits + // the deadline note in the revocation panel. + RevocationWindowClosesAt string + // ExecutedAt is the ISO-8601 / RFC-3339 timestamp the purchase was + // executed at. Used in the post-execution notification body. + // Empty omits the timestamp from the body. + ExecutedAt string + // ExecutedBy is the email of the user who triggered execution (approved + // the purchase). Used in the post-execution notification body. + // Empty omits the field. + ExecutedBy string } // RecommendationSummary is a simplified recommendation for email display diff --git a/internal/email/smtp_sender.go b/internal/email/smtp_sender.go index f632737b..9287355d 100644 --- a/internal/email/smtp_sender.go +++ b/internal/email/smtp_sender.go @@ -404,6 +404,21 @@ func (s *SMTPSender) SendPurchaseApprovalRequest(ctx context.Context, data Notif return sendPurchaseApprovalRequestVia(ctx, s, recipient, subject, data) } +// SendPurchaseExecutedNotification sends the post-execution notification email +// via SMTP. Mirrors SendPurchaseApprovalRequest: prefers data.RecipientEmail +// over the static s.notifyEmail. Issue #291. +func (s *SMTPSender) SendPurchaseExecutedNotification(ctx context.Context, data NotificationData) error { + recipient := data.RecipientEmail + if recipient == "" { + recipient = s.notifyEmail + } + if recipient == "" { + return ErrNoRecipient + } + subject := buildExecutedNotificationSubject(data) + return sendPurchaseExecutedNotificationVia(ctx, s, recipient, subject, data) +} + // SendRegistrationReceivedNotification sends an email to CUDly administrators // for a new registration via SMTP. Prefers the caller-resolved // data.RecipientEmail + CCEmails (admin emails + global notify) so the To / diff --git a/internal/email/templates.go b/internal/email/templates.go index 82421855..b4a40acd 100644 --- a/internal/email/templates.go +++ b/internal/email/templates.go @@ -718,6 +718,174 @@ func (s *Sender) SendPurchaseApprovalRequest(ctx context.Context, data Notificat return sendPurchaseApprovalRequestVia(ctx, s, data.RecipientEmail, subject, data) } +// --------------------------------------------------------------------------- +// Post-execution notification templates (issue #291) +// --------------------------------------------------------------------------- + +// purchaseExecutedNotificationTemplate is the plain-text half of the +// post-execution notification email. Rendered alongside +// purchaseExecutedNotificationHTMLTemplate for multipart/alternative delivery. +const purchaseExecutedNotificationTemplate = `[CUDly] Purchase executed{{if .Recommendations}} ({{len .Recommendations}} commitment(s)){{end}} +============================================================================= +{{if .RequestedByEmail}} +Requested by: {{if .RequestedByName}}{{.RequestedByName}} <{{.RequestedByEmail}}>{{else}}{{.RequestedByEmail}}{{end}}{{if .RequestedAt}} at {{.RequestedAt}}{{end}} +{{end}}{{if .ExecutedBy}} +Executed by: {{.ExecutedBy}}{{if .ExecutedAt}} at {{.ExecutedAt}}{{end}} +{{end}} +Summary: +-------- +Total Upfront Cost: ${{printf "%.2f" .TotalUpfrontCost}} +Estimated Monthly Savings: ${{printf "%.2f" .TotalSavings}} + +Commitments: +{{range .Recommendations}} +- {{.Count}}x {{.ResourceType}}{{if .Engine}} ({{.Engine}}){{end}} in {{.Region}} + Service: {{.Service}}{{if .AccountLabel}} | Account: {{.AccountLabel}}{{end}}{{if .Term}} | Term: {{.Term}}yr{{end}}{{if .Payment}} | Payment: {{.Payment}}{{end}} + Upfront: ${{printf "%.2f" .UpfrontCost}} | Est. Savings: ${{printf "%.2f" .MonthlySavings}}/month +{{end}} +{{if .RevocationToken}} +------------------------------------------------------------ +REVOCATION WINDOW +{{if .RevocationWindowClosesAt}}You can revoke this purchase until {{.RevocationWindowClosesAt}}. +After that, contact AWS Support. +{{end}} +One-click revoke: +{{.DashboardURL}}/api/purchases/revoke/{{.ExecutionID}}?token={{urlquery .RevocationToken}} + +Plain-text URL (copy + paste if the link above is broken): +{{.DashboardURL}}/api/purchases/revoke/{{.ExecutionID}}?token={{urlquery .RevocationToken}} +{{end}} +View in dashboard: +{{.DashboardURL}}/purchases#history?execution={{.ExecutionID}} + +This is an automated message from CUDly. +` + +// purchaseExecutedNotificationHTMLTemplate is the HTML half of the +// post-execution notification email. Inline-styled per email-client constraints +// (Outlook, mobile Gmail ignore class-based CSS). Issue #291. +const purchaseExecutedNotificationHTMLTemplate = ` +[CUDly] Purchase executed + + +
+ + + + + + + +{{if .RevocationToken}} + +{{end}} + + + +
+

Purchase Executed

+

{{len .Recommendations}} commitment(s) were purchased successfully.

+
+ + + +{{if .RequestedByEmail}}{{end}} +{{if .ExecutedBy}}{{end}} +
Total Upfront Cost${{printf "%.2f" .TotalUpfrontCost}}
Estimated Monthly Savings${{printf "%.2f" .TotalSavings}}
Requested by{{if .RequestedByName}}{{.RequestedByName}} <{{.RequestedByEmail}}>{{else}}{{.RequestedByEmail}}{{end}}{{if .RequestedAt}} at {{.RequestedAt}}{{end}}
Executed by{{.ExecutedBy}}{{if .ExecutedAt}} at {{.ExecutedAt}}{{end}}
+
+

Commitments

+ + + + + + + + + +{{range .Recommendations}} + + + + + + +{{end}}
Service / SKURegionTerm · PaymentUpfrontSavings/mo
{{.Count}}× {{.ResourceType}}{{if .Engine}} ({{.Engine}}){{end}}
{{.Service}}{{if .AccountLabel}} · {{.AccountLabel}}{{end}}
{{.Region}}{{if .Term}}{{.Term}}yr{{end}}{{if .Payment}} · {{.Payment}}{{end}}${{printf "%.2f" .UpfrontCost}}${{printf "%.2f" .MonthlySavings}}
+
+

Revocation Window

+{{if .RevocationWindowClosesAt}}

You can revoke this purchase until {{.RevocationWindowClosesAt}}. After that, contact AWS Support.

{{end}} + + +
Revoke this purchase
+

If the button does not work, copy and paste this URL: {{.DashboardURL}}/api/purchases/revoke/{{.ExecutionID}}?token={{urlquery .RevocationToken}}

+
+

This is an automated message from CUDly.

+
+
+` + +// RenderPurchaseExecutedNotificationEmail renders the plain-text half of +// the post-execution notification email (issue #291). +func RenderPurchaseExecutedNotificationEmail(data NotificationData) (string, error) { + return renderTemplate("purchase-executed-notification", purchaseExecutedNotificationTemplate, data) +} + +// RenderPurchaseExecutedNotificationEmailHTML renders the HTML half of the +// post-execution notification email. Pair with +// RenderPurchaseExecutedNotificationEmail for multipart/alternative delivery. +func RenderPurchaseExecutedNotificationEmailHTML(data NotificationData) (string, error) { + return renderTemplate("purchase-executed-notification-html", purchaseExecutedNotificationHTMLTemplate, data) +} + +// sendPurchaseExecutedNotificationVia composes the plain-text + HTML bodies +// and ships them through s.SendToEmailWithCCMultipart. HTML render failures +// are non-fatal and degrade to single-part text. Shared by Sender and +// SMTPSender so the two transports stay in sync (same pattern as +// sendPurchaseApprovalRequestVia). Issue #291. +func sendPurchaseExecutedNotificationVia(ctx context.Context, s SenderInterface, recipient, subject string, data NotificationData) error { + textBody, err := RenderPurchaseExecutedNotificationEmail(data) + if err != nil { + return fmt.Errorf("failed to render purchase executed notification (text): %w", err) + } + // HTML render failure is non-fatal: degrade to single-part text. + htmlBody, htmlErr := RenderPurchaseExecutedNotificationEmailHTML(data) + if htmlErr != nil { + logging.Warnf("email: HTML executed-notification render failed, falling back to text-only: %v", htmlErr) + htmlBody = "" + } + return s.SendToEmailWithCCMultipart(ctx, recipient, data.CCEmails, subject, textBody, htmlBody) +} + +// SendPurchaseExecutedNotification sends the post-execution notification email +// to the configured recipients (global notification_email, per-account contact +// emails, and the requester). data.RecipientEmail must be set to the primary To +// address; data.CCEmails carries additional recipients. data.RevocationToken +// and data.RevocationWindowClosesAt control the revocation-link panel in the +// template. Issue #291. +func (s *Sender) SendPurchaseExecutedNotification(ctx context.Context, data NotificationData) error { + if data.RecipientEmail == "" { + return ErrNoRecipient + } + if !isValidFromEmail(s.fromEmail) { + return ErrNoFromEmail + } + subject := buildExecutedNotificationSubject(data) + return sendPurchaseExecutedNotificationVia(ctx, s, data.RecipientEmail, subject, data) +} + +// buildExecutedNotificationSubject constructs the subject line for the +// post-execution notification, including a brief SKU summary when the +// recommendation list is small enough to fit. Extracted so both Sender +// and SMTPSender use the same subject format. +func buildExecutedNotificationSubject(data NotificationData) string { + if len(data.Recommendations) == 1 { + r := data.Recommendations[0] + return fmt.Sprintf("[CUDly] Purchase executed: %s %s in %s", + r.Service, r.ResourceType, r.Region) + } + return fmt.Sprintf("[CUDly] Purchase executed (%d commitment(s))", len(data.Recommendations)) +} + // --------------------------------------------------------------------------- // Account registration email templates // --------------------------------------------------------------------------- diff --git a/internal/mocks/email.go b/internal/mocks/email.go index e927b6bb..41591b36 100644 --- a/internal/mocks/email.go +++ b/internal/mocks/email.go @@ -66,6 +66,12 @@ func (m *MockEmailSender) SendPurchaseApprovalRequest(ctx context.Context, data return args.Error(0) } +// SendPurchaseExecutedNotification mocks the post-execution notification operation (issue #291). +func (m *MockEmailSender) SendPurchaseExecutedNotification(ctx context.Context, data email.NotificationData) error { + args := m.Called(ctx, data) + return args.Error(0) +} + func (m *MockEmailSender) SendRegistrationReceivedNotification(ctx context.Context, data email.RegistrationNotificationData) error { args := m.Called(ctx, data) return args.Error(0) @@ -87,6 +93,7 @@ type EmailSenderAPI interface { SendPasswordResetEmail(ctx context.Context, email, resetURL string) error SendWelcomeEmail(ctx context.Context, email, dashboardURL, role string) error SendPurchaseApprovalRequest(ctx context.Context, data email.NotificationData) error + SendPurchaseExecutedNotification(ctx context.Context, data email.NotificationData) error } // Ensure MockEmailSender implements EmailSenderAPI diff --git a/internal/purchase/mocks_test.go b/internal/purchase/mocks_test.go index e39bf8f0..8155a646 100644 --- a/internal/purchase/mocks_test.go +++ b/internal/purchase/mocks_test.go @@ -651,6 +651,9 @@ func (m *MockEmailSender) SendPurchaseApprovalRequest(ctx context.Context, data args := m.Called(ctx, data) return args.Error(0) } +func (m *MockEmailSender) SendPurchaseExecutedNotification(_ context.Context, _ email.NotificationData) error { + return nil +} func (m *MockEmailSender) SendRegistrationReceivedNotification(_ context.Context, _ email.RegistrationNotificationData) error { return nil } diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index bb827162..2c96c442 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -531,6 +531,9 @@ func (m *MockEmailSender) SendPurchaseApprovalRequest(ctx context.Context, data args := m.Called(ctx, data) return args.Error(0) } +func (m *MockEmailSender) SendPurchaseExecutedNotification(_ context.Context, _ email.NotificationData) error { + return nil +} func (m *MockEmailSender) SendRegistrationReceivedNotification(_ context.Context, _ email.RegistrationNotificationData) error { return nil } diff --git a/internal/server/app_test.go b/internal/server/app_test.go index 12587a8e..0ed5dbb3 100644 --- a/internal/server/app_test.go +++ b/internal/server/app_test.go @@ -308,6 +308,9 @@ func (n *noopEmailSender) SendRIExchangeCompleted(ctx context.Context, data emai func (n *noopEmailSender) SendPurchaseApprovalRequest(ctx context.Context, data email.NotificationData) error { return nil } +func (n *noopEmailSender) SendPurchaseExecutedNotification(_ context.Context, _ email.NotificationData) error { + return nil +} func (n *noopEmailSender) SendRegistrationReceivedNotification(_ context.Context, _ email.RegistrationNotificationData) error { return nil } diff --git a/internal/server/handler_ri_exchange_test.go b/internal/server/handler_ri_exchange_test.go index 8fe51b41..d9f6d865 100644 --- a/internal/server/handler_ri_exchange_test.go +++ b/internal/server/handler_ri_exchange_test.go @@ -817,6 +817,10 @@ func (m *mockEmailSender) SendRIExchangeCompleted(ctx context.Context, data emai return nil } +func (m *mockEmailSender) SendPurchaseExecutedNotification(_ context.Context, _ email.NotificationData) error { + return nil +} + func (m *mockEmailSender) SendRegistrationReceivedNotification(_ context.Context, _ email.RegistrationNotificationData) error { return nil } From 1be3f5cdf6b9dcd71b5674f2b38012d5f7c69141 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 1 Jun 2026 18:58:50 +0200 Subject: [PATCH 2/2] refactor(api/purchases): extract sendPurchaseExecutedEmail + revokePurchase helpers to fit gocyclo budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendPurchaseExecutedEmail (16 → 9): extract globalNotifyEmail and lookupRequesterInfo to pull the nullable-field branches and auth lookup out of the main flow. revokePurchase (13 → 9): extract checkRevokableStatus, tryRevokeViaSession, and validateRevokeToken, mirroring the pattern used by cancelPurchase and its siblings. No behaviour changes; validation order, error messages, and email sender call shape are identical. --- internal/api/handler_purchases.go | 125 +++++++++++++++++++++--------- 1 file changed, 88 insertions(+), 37 deletions(-) diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index 62d46489..1b1ea91e 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -659,28 +659,13 @@ func (h *Handler) revokePurchase(ctx context.Context, req *events.LambdaFunction // Only completed/partially_completed purchases have anything to revoke. // A pending/notified purchase should be cancelled instead. - switch execution.Status { - case "completed", "partially_completed": - // valid - case "pending", "notified": - return nil, NewClientError(409, fmt.Sprintf( - "execution %s is still pending — use the Cancel link instead of Revoke", execID)) - default: - return nil, NewClientError(409, fmt.Sprintf( - "execution %s cannot be revoked (status=%s); the revocation window may have closed or the purchase was not completed", - execID, execution.Status)) + if err := checkRevokableStatus(execution); err != nil { + return nil, err } // Three-mode dispatch — same shape as cancelPurchase. - if session := h.tryGetSession(ctx, req); session != nil { - switch sessErr := h.authorizeSessionCancel(ctx, session, execution); { - case sessErr == nil: - return h.revokeViaSession(ctx, execution, session.Email) - case isPermissionDenied(sessErr): - // Fall through to the token branch. - default: - return nil, sessErr - } + if result, handled, err := h.tryRevokeViaSession(ctx, req, execution); handled { + return result, err } if token == "" { @@ -693,16 +678,66 @@ func (h *Handler) revokePurchase(ctx context.Context, req *events.LambdaFunction if err != nil { return nil, err } - // Validate token against the execution's ApprovalToken using constant-time - // comparison to prevent timing attacks (same guard as ApproveExecution in - // internal/purchase/approvals.go). + if err := validateRevokeToken(execution, token); err != nil { + return nil, err + } + return h.revokeViaSession(ctx, execution, actor) +} + +// tryRevokeViaSession attempts the session-authenticated branch of the +// revokePurchase three-mode dispatch (same shape as the session branch of +// cancelPurchase). Returns (result, true, err) when the session was present +// and either completed the revocation or encountered a hard error; returns +// (nil, false, nil) when the session was absent or returned a permission-denied +// error so the caller can fall through to the token branch. Extracted from +// revokePurchase to keep that function under the cyclomatic limit. +func (h *Handler) tryRevokeViaSession(ctx context.Context, req *events.LambdaFunctionURLRequest, execution *config.PurchaseExecution) (any, bool, error) { + session := h.tryGetSession(ctx, req) + if session == nil { + return nil, false, nil + } + switch sessErr := h.authorizeSessionCancel(ctx, session, execution); { + case sessErr == nil: + result, err := h.revokeViaSession(ctx, execution, session.Email) + return result, true, err + case isPermissionDenied(sessErr): + // Fall through to the token branch. + return nil, false, nil + default: + return nil, true, sessErr + } +} + +// checkRevokableStatus returns nil when the execution is in a state that allows +// revocation (completed or partially_completed), or a 409 ClientError when the +// status makes revocation impossible. Extracted from revokePurchase to keep +// that function under the cyclomatic limit. +func checkRevokableStatus(execution *config.PurchaseExecution) error { + switch execution.Status { + case "completed", "partially_completed": + return nil + case "pending", "notified": + return NewClientError(409, fmt.Sprintf( + "execution %s is still pending — use the Cancel link instead of Revoke", execution.ExecutionID)) + default: + return NewClientError(409, fmt.Sprintf( + "execution %s cannot be revoked (status=%s); the revocation window may have closed or the purchase was not completed", + execution.ExecutionID, execution.Status)) + } +} + +// validateRevokeToken checks that the execution carries a non-empty +// ApprovalToken and that it matches the supplied token using constant-time +// comparison (same guard as ApproveExecution in internal/purchase/approvals.go). +// Extracted from revokePurchase to keep that function under the cyclomatic limit. +func validateRevokeToken(execution *config.PurchaseExecution, token string) error { if execution.ApprovalToken == "" { - return nil, NewClientError(403, "invalid revocation token") + return NewClientError(403, "invalid revocation token") } if subtle.ConstantTimeCompare([]byte(execution.ApprovalToken), []byte(token)) != 1 { - return nil, NewClientError(403, "invalid revocation token") + return NewClientError(403, "invalid revocation token") } - return h.revokeViaSession(ctx, execution, actor) + return nil } // revokeViaSession performs the post-execution revocation action by recording @@ -1800,10 +1835,7 @@ func (h *Handler) sendPurchaseExecutedEmail(ctx context.Context, req *events.Lam logging.Errorf("sendPurchaseExecutedEmail: failed to load global config: %v", err) return } - globalNotify := "" - if globalCfg != nil && globalCfg.NotificationEmail != nil { - globalNotify = strings.TrimSpace(*globalCfg.NotificationEmail) - } + globalNotify := globalNotifyEmail(globalCfg) // Gather per-account contact emails for the recommendations. contactEmails, err := h.gatherAccountContactEmails(ctx, execution.Recommendations) @@ -1813,14 +1845,7 @@ func (h *Handler) sendPurchaseExecutedEmail(ctx context.Context, req *events.Lam } // Look up the requester's email via their user ID (if available). - requesterEmail := "" - requesterName := "" - if h.auth != nil && execution.CreatedByUserID != nil && *execution.CreatedByUserID != "" { - if u, lookupErr := h.auth.GetUser(ctx, *execution.CreatedByUserID); lookupErr == nil && u != nil { - requesterEmail = u.Email - } - // Error is non-fatal — we just omit the field from the email body. - } + requesterEmail, requesterName := h.lookupRequesterInfo(ctx, execution) // Build the deduplicated To / Cc list. // Priority: contact emails are To (first one) + Cc (rest); global notify @@ -1876,6 +1901,32 @@ func (h *Handler) sendPurchaseExecutedEmail(ctx context.Context, req *events.Lam } } +// globalNotifyEmail returns the trimmed notification email from a GlobalConfig, +// or "" when the config is nil or the field is unset. Extracted from +// sendPurchaseExecutedEmail to keep that function under the cyclomatic limit. +func globalNotifyEmail(globalCfg *config.GlobalConfig) string { + if globalCfg != nil && globalCfg.NotificationEmail != nil { + return strings.TrimSpace(*globalCfg.NotificationEmail) + } + return "" +} + +// lookupRequesterInfo resolves the email (and name, currently always "") for +// the user who originally submitted the execution. The lookup is non-fatal: +// when auth is unavailable or the user cannot be found, both fields are +// returned empty and the notification is sent without them. Extracted from +// sendPurchaseExecutedEmail to keep that function under the cyclomatic limit. +func (h *Handler) lookupRequesterInfo(ctx context.Context, execution *config.PurchaseExecution) (email, name string) { + if h.auth == nil || execution.CreatedByUserID == nil || *execution.CreatedByUserID == "" { + return "", "" + } + u, err := h.auth.GetUser(ctx, *execution.CreatedByUserID) + if err == nil && u != nil { + return u.Email, "" + } + return "", "" +} + // resolveExecutedNotificationRecipients builds the To / Cc pair for the // post-execution notification from the three input email sets. The logic // mirrors resolveApprovalRecipients: first contact email is To; remaining