SendGrid Outbound Email Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace AWS SES outbound email with SendGrid (Web API v3) for noreply@mail.getorcha.com, remove outbound SES code/infra, and fix the hard-coded sender in the settings handler.

Architecture: A single side-effecting multimethod com.getorcha.email/send! dispatches on a :provider key (:sendgrid via the existing traced HTTP wrapper, :log for local/test). Provider, API key, and sender live in the existing :com.getorcha/notifications config map, gated by aero #profile. No new Integrant component. DNS and CDK changes are driver-only (live AWS).

Tech Stack: Clojure, aero config, hato (via com.getorcha.http.client), Integrant, AWS CDK (Python), Route53.

Spec: docs/superpowers/specs/2026-05-17-sendgrid-outbound-email-design.html


Task ownership & sequencing

Recommended order: Tasks 1–6 → Task 7 → Task 8 → gate → Tasks 9–12.


File Structure


Task 1: com.getorcha.email namespace [Subagent-delegable]

Files:

(ns com.getorcha.email-test
  (:require
   [clojure.test :refer [deftest is testing]]
   [com.getorcha.email :as email]
   [com.getorcha.http.client :as http]))


(deftest log-provider-does-not-send
  (testing ":log provider returns without calling HTTP"
    (let [called? (atom false)]
      (with-redefs [http/request (fn [_] (reset! called? true) {:status 202})]
        (email/send! {:provider :log :sender "noreply@mail.getorcha.com"}
                     {:to "user@example.com" :subject "Hi" :body "Body"}))
      (is (false? @called?)))))


(deftest sendgrid-builds-v3-request
  (testing ":sendgrid posts the v3 payload with Bearer auth"
    (let [captured (atom nil)]
      (with-redefs [http/request (fn [opts] (reset! captured opts) {:status 202 :body ""})]
        (email/send! {:provider :sendgrid
                      :sender   "noreply@mail.getorcha.com"
                      :api-key  "SG.testkey"}
                     {:to "user@example.com" :subject "Subj" :body "Body"}))
      (let [{:keys [url method headers content-type form-params]} @captured]
        (is (= "https://api.sendgrid.com/v3/mail/send" url))
        (is (= :post method))
        (is (= "Bearer SG.testkey" (get headers "Authorization")))
        (is (= :json content-type))
        (is (= {:email "noreply@mail.getorcha.com"} (:from form-params)))
        (is (= [{:to [{:email "user@example.com"}]}] (:personalizations form-params)))
        (is (= [{:type "text/plain" :value "Body"}] (:content form-params)))
        (is (= "Subj" (:subject form-params)))))))


(deftest sendgrid-accepts-vector-recipients
  (testing ":sendgrid normalizes a vector of recipients"
    (let [captured (atom nil)]
      (with-redefs [http/request (fn [opts] (reset! captured opts) {:status 202})]
        (email/send! {:provider :sendgrid :sender "noreply@mail.getorcha.com" :api-key "k"}
                     {:to ["a@example.com" "b@example.com"] :subject "S" :body "B"}))
      (is (= [{:to [{:email "a@example.com"} {:email "b@example.com"}]}]
             (:personalizations (:form-params @captured)))))))


(deftest sendgrid-throws-on-non-2xx
  (testing ":sendgrid throws ex-info with status and body on failure"
    (with-redefs [http/request (fn [_] {:status 401 :body "unauthorized"})]
      (is (thrown-with-msg?
           clojure.lang.ExceptionInfo #"SendGrid send failed"
           (email/send! {:provider :sendgrid :sender "x@mail.getorcha.com" :api-key "k"}
                        {:to "u@example.com" :subject "S" :body "B"}))))))


(deftest unknown-provider-throws
  (testing "unconfigured provider fails loudly"
    (is (thrown? clojure.lang.ExceptionInfo
                 (email/send! {:provider :carrier-pigeon}
                              {:to "u@example.com" :subject "S" :body "B"})))))

Run: clj -X:test:silent :nses '[com.getorcha.email-test]' 2>&1 | grep -E "(FAIL|ERROR|Execution error|Ran .* tests)" Expected: failure — namespace com.getorcha.email does not exist.

(ns com.getorcha.email
  "Outbound email. Provider-dispatched side-effecting send.

   `send!` takes the notifications config map (carrying `:provider`,
   `:sender`, and for SendGrid `:api-key`) and a message map
   `{:to :subject :body}`. `:to` is a string or a vector of strings.

   Providers:
     :sendgrid - SendGrid Web API v3 over the traced HTTP client.
     :log      - logs instead of sending (local/test).

   Provider selection is config, not lifecycle — there is no component."
  (:require
   [clojure.tools.logging :as log]
   [com.getorcha.http.client :as http]))


(defmulti send!
  "Sends a plain-text email. Dispatches on the config's `:provider`.
   Throws `ex-info` on delivery failure or unknown provider."
  (fn [{:keys [provider] :as _email-config} _message] provider))


(defmethod send! :log
  [{:keys [sender] :as _email-config} {:keys [to subject body] :as _message}]
  (log/info "[LOCAL DEV] Would send email"
            {:from sender :to to :subject subject :body body})
  nil)


(defmethod send! :sendgrid
  [{:keys [api-key sender] :as _email-config} {:keys [to subject body] :as _message}]
  (let [recipients (if (string? to) [to] to)
        payload    {:personalizations [{:to (mapv (fn [addr] {:email addr}) recipients)}]
                    :from             {:email sender}
                    :subject          subject
                    :content          [{:type "text/plain" :value body}]}
        {:keys [status body] :as _response}
        (http/request {:url          "https://api.sendgrid.com/v3/mail/send"
                       :method       :post
                       :headers      {"Authorization" (str "Bearer " api-key)}
                       :content-type :json
                       :form-params  payload})]
    (when-not (<= 200 status 299)
      (throw (ex-info "SendGrid send failed" {:status status :body body})))
    nil))


(defmethod send! :default
  [{:keys [provider] :as _email-config} _message]
  (throw (ex-info "Unknown email provider" {:provider provider})))

Run: clj -X:test:silent :nses '[com.getorcha.email-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran .* tests)" Expected: Ran 5 tests with 0 failures, 0 errors.

Run: clj-kondo --lint src/com/getorcha/email.clj test/com/getorcha/email_test.clj Expected: linting took ... with no errors/warnings/info. Fix any reported, however small.

git add orcha/src/com/getorcha/email.clj orcha/test/com/getorcha/email_test.clj
git commit -m "feat(email): add provider-dispatched send! (sendgrid, log)"

Task 2: Config — provider & API key [Subagent-delegable]

Files:

Replace lines 81–86 (the :com.getorcha/notifications map) with:

 :com.getorcha/notifications
 {:sender   "noreply@mail.getorcha.com"
  :provider #profile {#{:local-dev :demo :test} :log
                      :default                  :sendgrid}
  :api-key  #profile {#{:local-dev :demo :test} nil
                      :default                  #orcha/param "/v1-orcha/sendgrid-api-key"}
  :admin    {:email "admin-prod@getorcha.com"
             :slack #profile {:local   nil
                              :test    nil
                              :default "https://hooks.slack.com/services/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"}}}

Rationale: #profile with a set key and nested #orcha/param is an existing pattern (config.edn:50-53). Test fixtures run profile :test (test/com/getorcha/test/fixtures.clj:78), so :test must resolve :provider to :log and :api-key to nil — the unselected #profile branch's #orcha/param is never evaluated by aero, so tests need no SSM.

Run: clj -X:test:silent :nses '[com.getorcha.notifications-test]' 2>&1 | grep -E "(FAIL|ERROR|Execution error|Ran .* tests)" Expected: it still runs (failures from Task 3 not yet applied are acceptable here ONLY if pre-existing; if this is run before Task 3, expect existing tests to pass since config shape is additive). If config fails to parse, aero will throw at load — fix the EDN.

git add orcha/resources/com/getorcha/config.edn
git commit -m "feat(email): add provider/api-key to notifications config"

Task 3: Rewire user email channel [Subagent-delegable]

Files:

In test/com/getorcha/notifications_test.clj, the test test-notify-dispatches-to-email-channel wraps the body in (with-redefs [aws/build-ses-client (constantly nil)] ...). Remove that with-redefs form, keeping its body. The :test profile now selects :provider :log, so the email channel logs and reports :sent without SES. Also remove the now-unused [com.getorcha.aws :as aws] from the test ns :require only if no other test in the file uses aws/... (grep first: grep -n "aws/" test/com/getorcha/notifications_test.clj).

Run: clj -X:test:silent :nses '[com.getorcha.notifications-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran .* tests)" Expected: FAIL/ERROR — -channel-notify! :email still calls aws/send-email! / get-in aws [:clients :ses], which no longer matches the :log path.

Replace notifications.clj:37-63 with:

(defmethod -channel-notify! :email
  [{:notification-channel/keys [id]
    :notification-channel-email/keys [email-address]
    :as _channel}
   {:keys [title body] :as _message}
   {:keys [notifications] :as _context}]
  (try
    (email/send! notifications {:to email-address :subject title :body body})
    (log/info "Sent email notification" {:channel-id id :to email-address})
    {:status :sent}
    (catch Exception e
      (log/error e "Failed to send email notification"
                 {:channel-id id :to email-address})
      {:status :failed :error (.getMessage e)})))

Add [com.getorcha.email :as email] to the notifications.clj ns :require (alphabetical order). Verify [clojure.tools.logging :as log] is already required (it is — used elsewhere in the file).

Run: clj -X:test:silent :nses '[com.getorcha.notifications-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran .* tests)" Expected: Ran N tests, 0 failures, 0 errors.

Run: clj-kondo --lint src/com/getorcha/notifications.clj test/com/getorcha/notifications_test.clj Expected: clean.

git add orcha/src/com/getorcha/notifications.clj orcha/test/com/getorcha/notifications_test.clj
git commit -m "feat(email): route user email channel through email/send!"

Task 4: Rewire admin email [Subagent-delegable]

Files:

In notify-admins!, change the context destructure from {:keys [aws db-pool notifications] :as _context} to {:keys [db-pool notifications] :as _context} (drop aws — verify no other aws use remains in notify-admins! with grep -n "aws" src/com/getorcha/notifications.clj scoped to the function body lines 202–260).

Replace the email block (lines 223–231, the (when (and email (not= organization-slug "orcha")) ...) form) with:

    (when (and email (not= organization-slug "orcha"))
      (try
        (email/send! notifications {:to email :subject admin-title :body admin-body})
        (catch Exception e
          (log/warn e "Failed to send admin email notification" {:to email}))))

Leave the Slack block (line 232 onward) unchanged.

Run: clj -X:test:silent :nses '[com.getorcha.notifications-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran .* tests)" Expected: 0 failures, 0 errors.

Run: clj-kondo --lint src/com/getorcha/notifications.clj Expected: clean (no unused-binding warning for aws).

git add orcha/src/com/getorcha/notifications.clj
git commit -m "feat(email): route admin email through email/send!"

Task 5: Rewire verification email + drop hard-coded sender [Subagent-delegable]

Files:

Remove line 220 entirely:

(def ^:private sender-email "noreply@mail.getorcha.com")

Replace notifications.clj (settings) lines 352–366 with:

(defn ^:private send-verification-email!
  "Sends the channel verification email via the configured provider."
  [notifications base-url email-address token]
  (let [verify-url (str base-url "/settings/notifications/email/verify?token=" token)
        subject    "Verify your notification email"
        body       (str "Click the link below to verify your email address for Orcha notifications:\n\n"
                        verify-url "\n\n"
                        "This link expires in 24 hours.\n\n"
                        "If you didn't request this, you can ignore this email.")]
    (email/send! notifications {:to email-address :subject subject :body body})))

Add [com.getorcha.email :as email] to this ns's :require (alphabetical). Remove [com.getorcha.aws :as aws] from the :require only if no other aws/ usage remains in the file (grep -n "aws/" src/com/getorcha/app/http/settings/notifications.clj; the (get-in aws [:clients :ses]) callers are removed in steps 3–4 — if aws/ count reaches 0, drop the require; otherwise keep it).

In add-email (starts ~line 373), change the request destructure {:keys [aws db-pool form-params providers ::reitit/router] :as request} to {:keys [notifications db-pool form-params providers ::reitit/router] :as request} (replace aws with notifications; aws is only used at the old line 432).

Change the call at line 432 from:

(send-verification-email! (get-in aws [:clients :ses]) base-url email-address token)

to:

(send-verification-email! notifications base-url email-address token)

In resend-email (starts ~line 497), change destructure {:keys [aws db-pool identity path-params providers ::reitit/router] :as request} to {:keys [notifications db-pool identity path-params providers ::reitit/router] :as request}.

Change lines 539–540 from:

(send-verification-email! (get-in aws [:clients :ses]) base-url
                          (:notification-channel-email/email-address channel) token)

to:

(send-verification-email! notifications base-url
                          (:notification-channel-email/email-address channel) token)

The HTTP app component injects :notifications #ref [:com.getorcha/notifications] (config.edn:152) alongside :providers (:153). add-email/resend-email already destructure providers from the request, so notifications arrives by the same mechanism. Verify by reading the component at config.edn:147-160 — both keys are siblings. No wiring change needed.

Run: clj -X:test:silent :nses '[com.getorcha.notifications-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran .* tests)" Run: clj-kondo --lint src/com/getorcha/app/http/settings/notifications.clj Expected: tests 0 failures/errors; lint clean (no unused aws binding).

git add orcha/src/com/getorcha/app/http/settings/notifications.clj
git commit -m "feat(email): config-driven sender for verification email"

Task 6: Delete outbound SES [Subagent-delegable]

Files:

Run: grep -rn "send-email!\|build-ses-client\|:clients :ses\|\\[:clients :ses\\]\|SesV2\|sesv2" src test --include='*.clj' Expected after edits: zero matches outside deletions. Use this list to confirm nothing else depends on the SES send path (inbound uses S3/SQS, not the SESv2 client — do NOT touch the :ses-emails S3 bucket or any receiving code).

In aws.clj, delete lines 26–28:

           (software.amazon.awssdk.services.sesv2 SesV2Client)
           (software.amazon.awssdk.services.sesv2.model
            Body Content Destination EmailContent SendEmailRequest)

Delete the entire ;; SES section: aws.clj:417-464 (the ;; SES banner comment, build-ses-client, and send-email!), through to the blank lines before ;; Integrant Component.

In the AWS state init (around lines 498–512), delete the ses-fut binding (line 504: ses-fut (future (build-ses-client config))] → remove, and move the ] to close the let binding vector after kms-fut) and delete the :ses @ses-fut entry from the :clients map (line 512). Resulting :clients map ends at :kms @kms-fut.

Run: clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|Ran .* tests)" Expected: 0 failures, 0 errors. Run: clj-kondo --lint src test dev Expected: clean. Fix every issue, including info-level.

git add orcha/src/com/getorcha/aws.clj
git commit -m "refactor(email): remove outbound SES client and send-email!"

Task 7: DNS pre-flight inspection [Driver-only]

Files: none (read-only AWS).

Run (management account profile):

aws route53 list-resource-record-sets --hosted-zone-id Z02414383CQNYTPGX2EIK \
  --profile orcha --output json > /tmp/getorcha-zone.json

Inspect /tmp/getorcha-zone.json for: an existing _dmarc.mail.getorcha.com TXT, existing s1._domainkey.mail.getorcha.com / s2._domainkey.mail.getorcha.com, and any record named em8281.mail.getorcha.com, url5843.mail.getorcha.com, or 107596766.mail.getorcha.com. SES Easy-DKIM uses token selectors (not s1/s2), so no DKIM collision is expected.

If a _dmarc.mail.getorcha.com TXT already exists, STOP and report to the user for reconciliation (do not overwrite — only one DMARC record per name is valid). If no conflicts, record "clear" and proceed to Task 8. Existing mail.getorcha.com SPF/inbound records are left untouched.


Task 8: Apply DNS [Driver-only]

Files:

Create infra/scripts/sendgrid-dns.sh:

#!/usr/bin/env bash
# Idempotent UPSERT of SendGrid (mail.getorcha.com) auth records into the
# management-account root getorcha.com zone. Records sourced from SendGrid
# domain authentication configured for mail.getorcha.com (2026-05-17).
set -euo pipefail
ZONE_ID=Z02414383CQNYTPGX2EIK
PROFILE=orcha

aws route53 change-resource-record-sets --hosted-zone-id "$ZONE_ID" --profile "$PROFILE" \
  --change-batch '{
    "Comment": "SendGrid domain auth for mail.getorcha.com",
    "Changes": [
      {"Action":"UPSERT","ResourceRecordSet":{"Name":"url5843.mail.getorcha.com","Type":"CNAME","TTL":300,"ResourceRecords":[{"Value":"sendgrid.net"}]}},
      {"Action":"UPSERT","ResourceRecordSet":{"Name":"107596766.mail.getorcha.com","Type":"CNAME","TTL":300,"ResourceRecords":[{"Value":"sendgrid.net"}]}},
      {"Action":"UPSERT","ResourceRecordSet":{"Name":"em8281.mail.getorcha.com","Type":"CNAME","TTL":300,"ResourceRecords":[{"Value":"u107596766.wl017.sendgrid.net"}]}},
      {"Action":"UPSERT","ResourceRecordSet":{"Name":"s1._domainkey.mail.getorcha.com","Type":"CNAME","TTL":300,"ResourceRecords":[{"Value":"s1.domainkey.u107596766.wl017.sendgrid.net"}]}},
      {"Action":"UPSERT","ResourceRecordSet":{"Name":"s2._domainkey.mail.getorcha.com","Type":"CNAME","TTL":300,"ResourceRecords":[{"Value":"s2.domainkey.u107596766.wl017.sendgrid.net"}]}},
      {"Action":"UPSERT","ResourceRecordSet":{"Name":"_dmarc.mail.getorcha.com","Type":"TXT","TTL":300,"ResourceRecords":[{"Value":"\"v=DMARC1; p=quarantine; rua=mailto:max@getorcha.com; adkim=s; aspf=s; pct=100\""}]}}
    ]
  }'

chmod +x infra/scripts/sendgrid-dns.sh.

The driver presents the change-batch and the Task 7 pre-flight result to the user and gets explicit confirmation BEFORE running the mutating command. This is a stateful Route53 change in the management account — not CDK, no drift (the root zone is in no stack), but state-changing.

Run: infra/scripts/sendgrid-dns.sh Expected: a ChangeInfo JSON with "Status": "PENDING".

git add orcha/infra/scripts/sendgrid-dns.sh
git commit -m "infra: SendGrid mail.getorcha.com DNS change-batch script"

After propagation, the user confirms the SendGrid dashboard shows the mail.getorcha.com domain authenticated/verified. Do NOT proceed to Tasks 9–12 until this passes.


Task 9: CDK code changes [Driver-only]

This task only edits CDK source; nothing is deployed until Task 10.

Files:

In foundation_stack.py, add this tuple to the params list (after the google-drive-* entries, before the closing ] ~:825):

            ("sendgrid-api-key", "SendgridApiKey", "SendGrid API key for outbound email"),

The existing loop creates it as a plain ssm.StringParameter placeholder (PLACEHOLDER_UPDATE_ME), identical to every sibling secret. No new IAM read grant is needed: compute_stack.py:314-321 already grants ssm:GetParameter on parameter/v1-orcha/*.

In infra/scripts/update-secrets.sh, add to the PARAMETERS array (after the google-drive-api-key line ~:51) so --from-file/--verify/--list know it and it gets a description:

    "sendgrid-api-key:SendGrid API key for outbound email"

In foundation_stack.py, delete the SES Sending Domain block: the comment banner (:600-606), sending_domain = "mail.getorcha.com" (:608), the entire cr.AwsCustomResource(self, "VerifySendingDomain", ...) form (:612-635), and the CfnOutput(self, "SesSendingDomain", ...) (:637-642).

In compute_stack.py, delete lines 267–275 (the # SES Policy (send emails for notifications) comment and the service_role.add_to_policy(iam.PolicyStatement(actions=["ses:SendEmail","ses:SendRawEmail"], ...)) form).

Run: cd infra && cdk diff (prod context). Expected: (a) a new SSM parameter /v1-orcha/sendgrid-api-key (value PLACEHOLDER_UPDATE_ME), (b) removal of the VerifySendingDomain custom resource, (c) removal of the SES IAM statement from the instance role. Confirm the VerifySendingDomain removal is the only stateful deletion and that it targets the mail.getorcha.com sending identity (inbound uses the separate mail.{env}.getorcha.com identity — unaffected). Present the diff to the user before Task 10.

git add orcha/infra/stacks/foundation_stack.py orcha/infra/stacks/compute_stack.py orcha/infra/scripts/update-secrets.sh
git commit -m "infra: SSM placeholder for SendGrid key; remove outbound SES identity + IAM"

Task 10: Deploy CDK [Driver-only]

Prerequisite: the Task 8 hard gate (SendGrid domain verified) has passed.

Files: none.

Present the Task 9 Step 5 diff to the user and get explicit confirmation. The deploy deletes the mail.getorcha.com SES sending identity (expected; inbound uses the separate mail.{env}.getorcha.com identity and is unaffected). Then run the project's standard prod CDK deploy (per infra/runbooks/deploy.md).

Confirm: the stack update completes; /v1-orcha/sendgrid-api-key exists (value still the placeholder); the SES IAM statement is gone from the instance role; inbound document-acquisition queues/receipt rules are untouched.


Task 11: Populate the SendGrid key via infra/secrets [Driver-only]

The API key is created by the user in SendGrid (Settings → API Keys, scope Mail Send). SendGrid's onboarding gates this behind domain authentication, so the key only exists after DNS — which is fine: it is not needed until now, after the Task 8 gate and the Task 10 deploy.

Files:

The user appends one line to infra/secrets (real production secret; do not echo it into logs/transcripts):

/v1-orcha/sendgrid-api-key=<the SendGrid API key>

From infra/, run the established workflow:

./scripts/update-secrets.sh --from-file secrets

This writes every line as a SecureString with --overwrite to the prod account (AWS_PROFILE=orcha-prod, eu-central-1), overwriting the CDK placeholder. aws/get-parameter already passes WithDecryption=true, so the app reads it transparently.

Run: ./scripts/update-secrets.sh --verify Expected: /v1-orcha/sendgrid-api-key listed under "Found parameters".

Accepted pattern, not new drift: every sibling secret follows this exact "CDK placeholder → update-secrets.sh overwrites as SecureString" flow; a later cdk deploy resets the value to the placeholder and update-secrets.sh is re-run, per infra/runbooks/update-secrets.md and deploy.md. This is the team's existing operational model, not a regression introduced here.


Task 12: Cutover & verification [Driver-only]

Files: none.

Deploy the merged code (Tasks 1–6) through the normal app pipeline so prod runs with :provider :sendgrid.

Trigger a real outbound path (e.g., add an email notification channel in the prod app → verification email) to a Gmail inbox you control.

In Gmail, open the message → "Show original". Confirm:

If DKIM is not PASS with d=mail.getorcha.com, STOP — every prod email will be quarantined (p=quarantine; pct=100). Re-check the s1/s2 CNAMEs and SendGrid verification before leaving the system on SendGrid.

Send a non-verification notification (admin or user channel) and confirm delivery + that the channel records :sent.


Self-Review

Spec coverage:

Placeholder scan: No TBD/TODO; every code/command step shows concrete content. Line numbers are marked ~ where the file shifts under prior edits — intentional, not a placeholder.

Type consistency: send! signature (send! email-config message) is identical across Task 1 (def), Tasks 3–5 (callers). email-config is always the :com.getorcha/notifications map (carries :provider/:sender/:api-key). Message map keys {:to :subject :body} consistent everywhere. :provider values :sendgrid/:log match config (Task 2) and methods (Task 1).