Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
452ca6e
feat(skills): move SMTP sends to PostHog in every SDK language
Silthus Nov 4, 2026
84f852e
Merge branch 'feat/migrate-email-sendgrid' into feat/migrate-email-smtp
Silthus Nov 4, 2026
3bb052d
fix(migrate-email): apply the SendGrid round-1 rules to SMTP
Silthus Nov 4, 2026
b0c3b2b
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
3dff5f2
docs(agents): list wizard migrate smtp
Silthus Nov 4, 2026
ab78e81
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
7c24912
fix(migrate-email): close round-2 sibling findings on the SMTP layer
Silthus Nov 4, 2026
0de496c
docs(migrate-email): say why each kept smtp field stays
Silthus Nov 4, 2026
a500057
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
0de96fd
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
c459472
fix(migrate-email): keep network errors raising in smtp rewrites
Silthus Nov 4, 2026
41971e5
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
cc3e097
fix(migrate-email): close qa-swarm round-1 findings on the SMTP layer
Silthus Nov 4, 2026
0fd57a7
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
0e4df3d
docs(migrate-email): point other providers' SMTP sends to wizard migr…
Silthus Nov 4, 2026
5942a24
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
4b2a53f
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
5a98a27
fix(migrate-email): decide SMTP send shapes real code showed
Silthus Nov 4, 2026
d651c6e
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
b69c545
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
8da3a7e
fix(migrate-email): route Loops SMTP sends to the Loops migration, na…
Silthus Nov 4, 2026
71f3857
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
eb16708
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
35dc3e5
fix(migrate-email): take email-templates' attachments as send does, a…
Silthus Nov 4, 2026
24fecd1
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
80de430
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
ac93840
fix(migrate-email): default email-templates' message to {} before rea…
Silthus Nov 4, 2026
a2d59ed
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
dc01335
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
5909e01
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
9526651
fix(migrate-email): an SMTP site keeps the open and click tracking it…
Silthus Nov 4, 2026
d0a5c16
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
d58415c
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
f49c27b
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
b984821
fix(migrate-email): an SMTP site that reads more of Nodemailer's info…
Silthus Nov 4, 2026
b1815fd
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
93238c4
fix(migrate-email): a Python SMTPException handler keeps taking a los…
Silthus Nov 4, 2026
7d7ab2a
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
2ebd114
fix(migrate-email): a Python SMTPException site moves only when a ref…
Silthus Nov 4, 2026
1ea906f
fix(migrate-email): a Python SMTPException site moves only when a ref…
Silthus Nov 4, 2026
6701a55
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
79e5f74
Merge branch 'feat/migrate-email-loops' into feat/migrate-email-smtp
Silthus Nov 4, 2026
9c41113
Merge feat/migrate-email-loops into feat/migrate-email-smtp
Silthus Nov 5, 2026
893c7ea
Merge #472 at fbd1e83 (a rewrite file per SDK) into feat/migrate-emai…
Silthus Nov 5, 2026
a810b96
docs(migrate-email): point the smtp mapping at the rewrite file
Silthus Nov 5, 2026
d87cc2c
Merge #472 at 78d56ce and the layers below into feat/migrate-email-smtp
Silthus Nov 5, 2026
0d981d6
Merge #472 at bb711c5 into feat/migrate-email-smtp
Silthus Nov 5, 2026
1663181
Merge #472 at 5398fd9 into feat/migrate-email-smtp
Silthus Nov 5, 2026
3e93eac
Merge #472 at 6924d48 into feat/migrate-email-smtp
Silthus Nov 5, 2026
dae9b70
Merge #459 at c24df79 (stored templates move into the code) into feat…
Silthus Nov 5, 2026
a421e60
Merge loops at 10917bf2 (SendGrid substitutions in the code) into fea…
Silthus Nov 5, 2026
4dd168e
Merge loops at a07c14a6 (templates into the code) into feat/migrate-e…
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 @@ -99,6 +99,7 @@ longer exist — only some keep an alias.
| `wizard migrate postmark` | `migrate-email-postmark` |
| `wizard migrate mailgun` | `migrate-email-mailgun` |
| `wizard migrate loops` | `migrate-email-loops` |
| `wizard migrate smtp` | `migrate-email-smtp` |

**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 @@ -28,3 +28,6 @@ variants:
- id: loops
display_name: Loops
tags: [loops]
- id: smtp
display_name: SMTP
tags: [smtp]
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ A Mailgun send has no idempotency key, apart from an `Idempotency-Key` header th
| `/messages.mime`, `client.mimemessage.create`, `send_message` with `message:`, `$mg->messages()->sendMime`, `mailgun.NewMIMEMessage` | Always: a MIME message is built outside the send |
| `Mailgun::BatchMessage`, `$mg->messages()->getBatchMessage`, `AddRecipientAndVariables` on a message with more than one recipient, or a send with `recipient-variables` and more than one address in `to` | Always: each sends several emails in one call |
| A send whose `to` is a Mailgun mailing list address | Always: Mailgun sends it to every member |
| SMTP through `smtp.mailgun.org` or `smtp.eu.mailgun.org` | Always. PostHog has no SMTP relay |
| SMTP through `smtp.mailgun.org` or `smtp.eu.mailgun.org` | Always here. `migrate-email-smtp` moves sends through SMTP: run `wizard migrate smtp` for them, or `wizard skill migrate-email-smtp` on a wizard whose `migrate` takes no provider |
| A framework mailer or transport (Laravel, Symfony, Rails, Django, Nodemailer, Swoosh, Bamboo) | Always. PostHog has no mailer driver |
| A send in a language without a PostHog SDK that sends email, such as Java or Kotlin | Always. Step 7 lists the languages PostHog's SDKs cover |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ A Postmark send has no idempotency key, so the moved site sends none either. Pos
| `sendEmail`, `outbound.send`, `emails.send` (`postmarker`), `deliver`, `deliver_message`, PHP `sendEmail` and `sendEmailModel`, `SendMessageAsync`, Go `SendEmail`, `POST /email` | A field above keeps it |
| `sendEmailWithTemplate`, `outbound.send_with_template`, `emails.send_with_template`, `deliver_with_template`, `deliver_message_with_template`, PHP `sendEmailWithTemplate`, `SendEmailWithTemplateAsync`, `POST /email/withTemplate` | See "Templates" |
| `sendEmailBatch`, `sendEmailBatchWithTemplates`, `sendBulkEmail`, `send_batch`, `send_batch_with_template`, `send_bulk`, `deliver_in_batches`, `deliver_in_batches_with_templates`, `deliver_messages`, PHP `sendEmailBatch` and `sendEmailBatchWithTemplate`, `SendMessagesAsync`, Go `SendEmailBatch`, `POST /email/batch`, `/email/batchWithTemplates`, `/email/bulk` | Always: each sends several emails in one call. The one exception is a batch call whose argument is a list of exactly one message, written in the code, such as PHP `sendEmailBatch([$message])`: that is one send. Classify it by that message's fields, and a template message by "Templates" |
| SMTP through `smtp.postmarkapp.com` or `smtp-broadcasts.postmarkapp.com` | Always. PostHog has no SMTP relay |
| SMTP through `smtp.postmarkapp.com` or `smtp-broadcasts.postmarkapp.com` | Always here. `migrate-email-smtp` moves sends through SMTP: run `wizard migrate smtp` for them, or `wizard skill migrate-email-smtp` on a wizard whose `migrate` takes no provider |
| A framework mailer (`postmark-rails`, a Django backend, Laravel or Symfony `postmark`, Swoosh, Bamboo, `nodemailer-postmark-transport`) | Always. PostHog has no mailer driver |
| A send in a language without a PostHog SDK that sends email, such as Java or Kotlin | Always. Step 7 lists the languages PostHog's SDKs cover |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The field names below are Node.js's. Every Resend SDK sends the same fields unde
|---|---|
| A send: `resend.emails.send` or `resend.emails.create` (Node.js), `resend.Emails.send` or `send_async` (Python), `Resend::Emails.send` (Ruby), `$resend->emails->send` or `create` (PHP), `client.Emails.Send`, `SendWithContext`, or `SendWithOptions` (Go), `EmailSendAsync` (.NET), `resend.emails.send` (Rust), `Resend.Emails.send` (Elixir), or `POST /emails` | A field above keeps it |
| A batch: `batch.send`, `Batch.send`, `Batch.Send`, `EmailBatchAsync`, `send_batch`, or `POST /emails/batch` | Always |
| SMTP through `smtp.resend.com` | Always. PostHog has no SMTP relay |
| SMTP through `smtp.resend.com` | Always here. `migrate-email-smtp` moves sends through SMTP: run `wizard migrate smtp` for them, or `wizard skill migrate-email-smtp` on a wizard whose `migrate` takes no provider |
| A framework mailer that sends through Resend: Laravel's or Rails' `resend` mailer, the `Resend.Swoosh.Adapter` Swoosh adapter in Elixir, or `Resend.FluentEmail` in .NET | Always. PostHog has no mailer driver |
| A send in a language without a PostHog SDK that sends email, such as Java or Kotlin | Always. Step 7 lists the languages PostHog's SDKs cover |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ A SendGrid send has no idempotency key, so the moved site sends none either. Pos
|---|---|
| `sgMail.send(msg)`, `sgMail.send(msg, callback)`, `SendGridAPIClient.send`, `sg.client.mail._('send').post`, `$sendgrid->send`, `client.Send`, `SendEmailAsync`, `POST /v3/mail/send` | A field above keeps it |
| `sgMail.send(msg, true)`, `sgMail.sendMultiple`, `sgMail.send([...])`, `Mail(..., is_multiple=True)`, `CreateSingleEmailToMultipleRecipients`, `CreateMultipleEmailsToMultipleRecipients` | Always: each sends several emails in one call |
| SMTP through `smtp.sendgrid.net` | Always. PostHog has no SMTP relay |
| SMTP through `smtp.sendgrid.net` | Always here. `migrate-email-smtp` moves sends through SMTP: run `wizard migrate smtp` for them, or `wizard skill migrate-email-smtp` on a wizard whose `migrate` takes no provider |
| A framework mailer (Django, Rails, Laravel, Swoosh, Bamboo) | Always. PostHog has no mailer driver |
| A send in a language without a PostHog SDK that sends email, such as Java or Kotlin | Always. Step 7 lists the languages PostHog's SDKs cover |

Expand Down
62 changes: 62 additions & 0 deletions context/skills/migrate-email/references/smtp/account.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# SMTP account

What the migration needs from the account behind the SMTP host. SMTP is only how the app hands mail over: the provider that runs the host, found from the table in `sdk-reference.md`, holds the DNS records and any suppression list. The agent never changes that account: it reads what the person exports or tells it.

Name that provider in the plan, such as `SMTP host: smtp.mailgun.org (Mailgun)`. When the host comes from a variable with no value in the repository, ask the person which provider runs it. The steps say "the provider" for it.

**Region.** The agent sends no request to the provider: the person reads its dashboard. When the host is regional, as `sdk-reference.md` says, the domain records and the suppression list belong to the account in that region. Tell the person to open the provider's dashboard in that region. For Amazon SES, open the console in the region the host names, such as `eu-west-1` for `email-smtp.eu-west-1.amazonaws.com`: SES keeps the suppression list per region. An app that sends through hosts in several regions has a list in each one. Read every region the app's hosts use.

## DNS records

A domain that sends through an SMTP provider carries that provider's records. Keep all of them until the cutover is done, and longer when the domain also gets or sends normal mail. The provider's dashboard shows the exact names, under its domain or sender settings.

| Record | Usual name | What it does |
|---|---|---|
| `TXT` (SPF) | The domain, or the provider's return-path label | Names the servers that may send for the domain, with an `include:` of the provider |
| `TXT` or `CNAME` (DKIM) | `<selector>._domainkey` | Signs the provider's mail. The selector is the provider's own |
| `CNAME` or `MX` | A return-path or bounce label, such as `bounce`, `pm-bounces`, or `em<digits>` | The provider's MAIL FROM, where its bounces go |
| `CNAME` | A tracking label | Link and open tracking, only when it is set up |
| `MX` | The domain | Where the domain's own mail arrives, when it has mailboxes |
| `TXT` (DMARC) | `_dmarc` | Shared by every provider on the domain |

- PostHog's sender uses its own label for its MAIL FROM, `feedback` by default. Never give PostHog a label the provider already uses. Check the domain's records for that label first.
- PostHog's DKIM records have their own selector names, so they sit next to the provider's `_domainkey` records.
- Keep the one `_dmarc` record. Never add a second one.
- A domain whose SPF record names the provider's `include:` keeps it. Merge PostHog's include into that record: a domain has one SPF record.
- **Gmail, Google Workspace, Microsoft 365, and other mailbox providers.** The domain's MX, SPF, and DKIM records also carry the person's normal mail. They stay after the cutover. Never suggest removing one.
- **A server the person runs.** Its records are what the person set up, such as an `A` record for the host, an SPF entry for its address, and its own DKIM selector. Keep them, and ask the person when you can't tell which labels it uses.

## Read-only key

A read key is usually not needed. The SMTP password sends mail and reads nothing. The templates are in the app's code, and the suppression list, when the provider keeps one, comes from its dashboard export. Don't ask for a key to the provider's API: this skill has no reference for it, and the steps never need one. Note in the plan `full-access provider key: none created`, so Step 5 and the report have no key to delete.

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

## Template content

SMTP stores no templates, so there is no template content to read from the provider. The content of each site is in the app's code, or in its template files, such as Handlebars views or `email-templates` folders. Read those in the repository.

A template the provider renders because a header names it stays with its site, so its content needs no check.

## Suppression list

Whether there is a list depends on the provider behind the host.

**Providers that keep one.** Sending services keep a list of addresses that hard-bounced or complained, and skip them on every send: SendGrid, Mailgun, Postmark, Resend, Brevo, Mailjet, SparkPost, Mandrill, and Amazon SES. Ask the person to export it from the provider's dashboard as a CSV file, and to tell you where they saved each file. Read the header row to find the address column and the column that says why each address is on the list. Don't read it from the provider's API: this skill has no key for it.

**Providers that keep none.** Gmail, Google Workspace, Microsoft 365, Zoho Mail, and other mailbox providers, and a server the person runs, keep no list to export. Record the list as empty: `- [x] suppressions — empty list: <provider> keeps no suppression list`. When the app keeps its own list of bounced addresses, such as a column it sets from bounce mail, that list stays in the app and its sends keep checking it.

**Map each entry.**

| Entry on the provider's list | PostHog source | Reason |
|---|---|---|
| Hard bounce, permanent failure, invalid or unknown address | bounce | `Imported from <Provider>: bounce` |
| Spam complaint or spam report | complaint | `Imported from <Provider>: spam complaint` |
| An address the person added by hand | manual | `Imported from <Provider>: manual` |

`<Provider>` is the provider behind the host, such as `Imported from Mailgun: bounce`. When the export doesn't say why an address is on the list, ask the person which kind the file holds.

**Leave out the other entries.**

- **Soft bounces, deferrals, and blocks** are temporary refusals by a receiving server. The provider retries those addresses, and so does PostHog.
- **Unsubscribes** are marketing preferences, and PostHog's opt-outs apply to marketing messages only. Some providers also stopped transactional mail to them. Once the sends move, these addresses get transactional email again. Count them, and tell the person the count in the report.
Loading
Loading