Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
e2c9cd2
feat(skills): add SparkPost to the migrate-email skill
Silthus Nov 4, 2026
d8bb889
Merge branch 'feat/migrate-email-sendgrid' into feat/migrate-email-sp…
Silthus Nov 4, 2026
32ebb55
fix(migrate-email): apply the SendGrid review rules to SparkPost
Silthus Nov 4, 2026
3042693
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
7deb777
docs(agents): list wizard migrate sparkpost
Silthus Nov 4, 2026
8b0ffe5
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
94ab766
fix(migrate-email): apply the round-2 SendGrid review rules to SparkPost
Silthus Nov 4, 2026
e2118a2
docs(migrate-email): say why each kept sparkpost field stays
Silthus Nov 4, 2026
f17a74d
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
74f2c27
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
dcbb90f
fix(migrate-email): keep a sparkpost site whose key length is unbounded
Silthus Nov 4, 2026
5be3eab
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
62379b5
fix(migrate-email): close round-1 review findings on the SparkPost layer
Silthus Nov 4, 2026
e0ecc01
fix(migrate-email): a SparkPost idempotency key always fits PostHog's
Silthus Nov 4, 2026
785323d
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
c6e9bd3
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
900a703
fix(migrate-email): keep network_error on the Node error a sparkpost …
Silthus Nov 4, 2026
77ff18a
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
f17cff4
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
10d5d3b
migrate-email-sparkpost: sandbox sender and Node.js 1.x send shape
Silthus Nov 4, 2026
9bc16ac
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
e702f00
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
ed8b4eb
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
a91b0b5
fix(migrate-email): SparkPost account tracking, 429 without quota_lim…
Silthus Nov 4, 2026
b013ca8
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
8ac3d2b
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
3dbc969
fix(migrate-email): mirror only importable SparkPost suppression writ…
Silthus Nov 4, 2026
7ba96b6
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
40f977a
fix(migrate-email): SparkPost template sites read transmission tracki…
Silthus Nov 4, 2026
168f74f
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
47e25c2
fix(migrate-email): a SparkPost suppression removal follows Step 5's …
Silthus Nov 4, 2026
e5dd48f
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
af30066
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
0efdb62
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
9d56fa5
fix(migrate-email): a SparkPost site that tracked opens stays while s…
Silthus Nov 4, 2026
6e02892
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
e4296c6
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
95cb0f9
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
6a323d2
fix(migrate-email): a SparkPost site that tracked opens stays where t…
Silthus Nov 4, 2026
21a065f
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
db0d2c2
fix(migrate-email): every SparkPost site that tracked opens checks op…
Silthus Nov 4, 2026
c882d2b
fix(migrate-email): a SparkPost subaccount without its own minimizati…
Silthus Nov 4, 2026
4bb2b96
fix(migrate-email): the SparkPost account minimization sentence names…
Silthus Nov 4, 2026
9d8e1fe
fix(migrate-email): SparkPost still substitutes from, subject and rep…
Silthus Nov 4, 2026
d07029c
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
958fbe5
Merge branch 'feat/migrate-email-mandrill' into feat/migrate-email-sp…
Silthus Nov 4, 2026
4cdeda6
Merge feat/migrate-email-mandrill into feat/migrate-email-sparkpost
Silthus Nov 5, 2026
2a66932
Merge #472 at fbd1e83 (a rewrite file per SDK) into feat/migrate-emai…
Silthus Nov 5, 2026
e31f24b
docs(migrate-email): point the sparkpost mapping at the rewrite file
Silthus Nov 5, 2026
940dc0d
Merge #472 at 78d56ce and the layers below into feat/migrate-email-sp…
Silthus Nov 5, 2026
8289926
Merge #472 at bb711c5 into feat/migrate-email-sparkpost
Silthus Nov 5, 2026
8e47732
Merge #472 at 5398fd9 into feat/migrate-email-sparkpost
Silthus Nov 5, 2026
ee9e706
Merge #472 at 6924d48 into feat/migrate-email-sparkpost
Silthus Nov 5, 2026
85ec11b
Merge #459 at 1fd77cd (stored templates move into the code) into feat…
Silthus Nov 5, 2026
5ee7e3b
Merge mandrill at a9e76839 (SendGrid substitutions in the code) into …
Silthus Nov 5, 2026
a2c134e
feat(migrate-email): bring SparkPost templates into the code
Silthus Nov 5, 2026
b1f0935
Merge mandrill at 67588ec4 (templates into the code) into feat/migrat…
Silthus Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ longer exist — only some keep an alias.
| `wizard migrate brevo` | `migrate-email-brevo` |
| `wizard migrate mailjet` | `migrate-email-mailjet` |
| `wizard migrate mandrill` | `migrate-email-mandrill` |
| `wizard migrate sparkpost` | `migrate-email-sparkpost` |

**Commands vs. skills:** those audit subcommands **are** skills, promoted to
commands via `cli: role: command`. A `role: skill` skill is reachable only via
Expand Down
3 changes: 3 additions & 0 deletions context/skills/migrate-email/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ variants:
- id: mandrill
display_name: Mailchimp Transactional
tags: [mandrill]
- id: sparkpost
display_name: SparkPost
tags: [sparkpost]
81 changes: 81 additions & 0 deletions context/skills/migrate-email/references/sparkpost/account.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# SparkPost account

What the migration needs from the person's SparkPost account. The agent never changes the account: it reads what the person exports or allows.

An account lives in one region: `app.sparkpost.com` with `api.sparkpost.com`, or `app.eu.sparkpost.com` with `api.eu.sparkpost.com`.

**EU accounts.** An app on an EU account names `api.eu.sparkpost.com`, or sets the client's host to it by a setting, as `sdk-reference.md` says under "EU accounts". For such an app, use `api.eu.sparkpost.com` in every request of this file instead of `api.sparkpost.com`.

## DNS records

A domain set up at SparkPost carries these records. Keep all of them until the cutover is done. The SparkPost app shows the exact names and values under Domains, then the domain.

| Record | Name | What it does |
|---|---|---|
| `TXT` (DKIM) | `<selector>._domainkey`, with a selector SparkPost makes, such as `scph0820` | Signs SparkPost's mail |
| `CNAME` | The bounce domain: a subdomain such as `bounce`, or the sending domain itself, pointing to `sparkpostmail.com` (`eu.sparkpostmail.com` in the EU) | Return path for SparkPost's bounces, and its SPF alignment |
| `CNAME` | A subdomain pointing to `spgo.io`, `v2.spgo.io`, `eu.spgo.io`, or `eu.v2.spgo.io` | A custom tracking domain, only when it is set up |
| `TXT` (DMARC) | `_dmarc` | Optional, shared by every provider on the domain |

- An Enterprise account points its bounce CNAME at `<tenant>.mail.e.sparkpost.com` and its tracking CNAME at `<tenant>.et.e.sparkpost.com`, and can verify a domain with `MX` records.
- PostHog's sender uses its own label for its MAIL FROM, `feedback` by default. Never give PostHog a label SparkPost uses for its bounce or tracking domain.
- PostHog's DKIM records have their own names, so they sit next to SparkPost's `_domainkey` record.
- When the sending domain itself is SparkPost's bounce domain, it holds a `CNAME`, and DNS allows no other record on that name. A PostHog record for that exact name, such as an SPF `TXT` record, cannot be added. Tell the person: they can move SparkPost's bounce domain to a subdomain such as `bounce` first, in SparkPost and in DNS, or send from PostHog on another domain. Record the domain as not verified until then.
- Keep the one `_dmarc` record. Never add a second one.
- A domain whose `v=spf1` record names SparkPost keeps it. Merge PostHog's include into that record.

## Read-only key

The app's own SparkPost key usually has only the permissions to send, and it can't read templates or suppressions. When the person chooses the API over the dashboard, ask them to create an API key in SparkPost (Configuration, then API Keys) with only the permissions to read the account, subaccounts, templates, and the suppression list, store it in `SPARKPOST_READ_API_KEY` in the git-ignored env file, and note in the plan that the run created it. Its last use is the suppression import in Step 5. At the end of Step 5, on every way out of it, ask the person to delete the key in SparkPost and remove its variable from the env file, and note in the plan whether they did. The report reminds them when they didn't. Never print the key, and never write it into another file.

The steps call this key the full-access provider key.

## Tracking settings

A site that sets no open or click tracking, in its recipients' `options`, its own `options`, or its template's, sends with the account's settings, as `mapping.md` says under "Tracking". A subaccount can also turn off open tracking for every send of its own. Every site that tracked opens, including one that sets its own tracking, also needs whether the account or subaccount it sends from minimizes open events: that setting wins over the send's own value.

**Ask the person.** In SparkPost's account settings: whether engagement tracking is on by default for API sends, whether click tracking is turned off, whether open events are minimized, and whether sends are transactional by default. For each subaccount the moved sites send from: whether open tracking is turned off for it, and whether its open events are minimized.

**Read from the API.** `GET https://api.sparkpost.com/api/v1/account` with `Authorization: $SPARKPOST_READ_API_KEY`. The answer holds `results.options` with `rest_tracking_default`, `click_tracking`, `enable_minimized_event_passthrough`, and `transactional_default`. A missing `rest_tracking_default` means `true`, a missing `click_tracking` turns nothing off, and a missing `enable_minimized_event_passthrough` or `transactional_default` means `false`. The account's `enable_minimized_event_passthrough` applies to the primary account's sends, and to a subaccount's sends when the subaccount sets none of its own. A subaccount has no setting of its own for the others. For a subaccount, `GET https://api.sparkpost.com/api/v1/subaccounts/{subaccount_id}` holds `results.options.open_tracking_disabled`, `false` when missing, and `results.options.enable_minimized_event_passthrough`. A subaccount without its own `enable_minimized_event_passthrough` takes the account's, and its own value wins when it sets one.

## Template content

A site whose template moves needs the content of the version it sends: Step 3 checks it, and Step 7 brings it into the code, or the person makes a template in PostHog from it when they ask for one. The API returns the content as SparkPost stores it, in its template language, and so does the dashboard. A site sends the published version, or the draft when it sets `use_draft_template: true`.

**Copy from the dashboard.** In SparkPost: Content, then Templates, then the template, then the version the site sends. Ask the person for the template's id, its subject, its from address, its reply-to address when it has one, its HTML, and its plain text when it has one. Also ask whether the version has an AMP part or custom headers, and how its open tracking, click tracking, and transactional settings are set: `mapping.md` keeps a template for each of them. For each snippet the content renders with `render_snippet`, ask for its id, its HTML, and its text.

**Read from the API.** `GET https://api.sparkpost.com/api/v1/templates/{template_id}?draft=false`, or `?draft=true` for a site that sends the draft, with `Authorization: $SPARKPOST_READ_API_KEY`, the key alone with no `Bearer`. The answer holds `results.id`, `results.published`, `results.options` with `open_tracking`, `click_tracking`, and `transactional`, and `results.content`, with `subject`, `from`, `reply_to`, `html`, `text`, `amp_html`, and `headers`, or `email_rfc822` alone. A snippet reads from `GET https://api.sparkpost.com/api/labs/snippets/{snippet_id}`, whose `results.content` holds `html`, `text`, and `amp_html`. SparkPost's docs don't name the key permission that reads snippets: when the read is refused, ask the person for copies.

Read the person's copies where they saved them. An API answer goes only into the temporary file Step 3 writes, which Step 7 deletes once it has brought the email into the code. Never write the content into the plan or the report.

## Suppression list

SparkPost keeps one suppression list per account, and one per subaccount. Each entry has a `recipient`, a `type` (`transactional`, `non_transactional`, or `open_tracking`), a `source`, and a `description`, and some carry a `list_id` for one mailing list. SparkPost skips an address with a `transactional` entry on transactional sends, and one with a `non_transactional` entry on the others. It also keeps a global suppression list that no API can read.

**Export from the dashboard.** In SparkPost: Recipients, then Suppressions. SparkPost's docs describe no file export there, so ask the person whether they already have an export file, such as from their own tooling, and where they saved it. Read the header row to find the address, type, and source columns. Without a file, read the list from the API.

**Read from the API.** With `Authorization: $SPARKPOST_READ_API_KEY`, on `api.eu.sparkpost.com` for an EU account (see the top of this file):

```
GET https://api.sparkpost.com/api/v1/suppression-list?cursor=initial&per_page=10000&types=transactional,non_transactional,open_tracking
```

The request takes every source and every type, so the agent can import some entries and count the others. Without `types`, SparkPost leaves out `open_tracking` entries.

The answer holds `results`, a list of `{ recipient, type, source, description, list_id, created, updated }`, `total_count`, and `links`. Follow the `href` of the link whose `rel` is `next` until no such link comes back or `results` is empty. Wait 1 to 5 seconds and retry on `429`. An account with subaccounts: read the list of each account the moved sites send from, as `mapping.md` says under "Subaccounts", on its own, with the header `X-MSYS-SUBACCOUNT: 0` for the primary account and the subaccount's ID for a subaccount. A search without the header covers every list at once, and SparkPost says such a search can be up to 20 minutes out of date. An account without subaccounts needs no header. When the moved sites send from more than one account, count the import as complete only when the list of every one is in.

**Map each entry.**

| SparkPost `source` | Entries to take | PostHog source | Reason |
|---|---|---|---|
| `Bounce Rule` | Every type | bounce | `Imported from SparkPost: bounce` |
| `Spam Complaint` | Every type | complaint | `Imported from SparkPost: spam complaint` |
| `Manually Added` | `transactional` only | manual | `Imported from SparkPost: added manually` |
| `Compliance` | `transactional` only | manual | `Imported from SparkPost: compliance` |

An address can come back once per type. Repeating it in the import is safe.

**Leave out the other entries.**

- **`List Unsubscribe` and `Unsubscribe Link`** are unsubscribes from non-transactional mail, and so are `Manually Added` and `Compliance` entries of type `non_transactional`, and entries with a `list_id`. They are marketing preferences, and PostHog's opt-outs apply to marketing messages only. SparkPost skipped these addresses on any send that was not marked `transactional: true`, and sends that move send as transactional. Count them, and tell the person the count in the report.
- **`Tracking Consent Revoked`** entries, of type `open_tracking`, block no send: they only turn off open tracking for the address. Count them for the report. Step 3 asks about them first, as `mapping.md` says under "Tracking".
Loading
Loading