email-connector · git:20260602.b03d416 · 2026-06-02 · sha256 7ebaf94be189c32a

email-connector git:20260602.b03d416F

Immutable. This exact content never changes and is served at /api/v1/blob/7ebaf94be189c32a.

---
name: email-connector
description: "Use when wiring an app to send transactional or bulk email through an HTTP provider (Resend, Twilio SendGrid, Postmark) from server code: welcome/reset/receipt sends, a provider-agnostic sendEmail() seam, React Email or dynamic templates, idempotent retries, 100-cap batch with partial-failure handling, transactional-vs-broadcast stream split, and the bounce/complaint webhook feeding a suppression list. Triggers: 'send a password-reset email with Resend', 'emails double-send when the queue retries', 'switch us off SendGrid onto Postmark without rewriting every call site', 'send a digest to 4000 users and handle the ones that fail', 'el webhook de bounces no actualiza la lista de supresión', 'montar el envío de correos con Resend'. NOT writing the subject line or the open/click growth system (that is newsletter)."
tags: [email, transactional-email, resend, sendgrid, postmark, webhooks, idempotency]
recommends: [email-deliverability, newsletter, webhooks, secure-coding, api-connector-builder, automation-flows]
origin: risco
---

# email-connector — put transactional & bulk email on the wire

You wire the *send*. A welcome mail, a password reset, a receipt, a 4,000-row
digest — your job is the server code that hands it to a provider, makes it safe
to retry, and keeps the suppression list honest. You do **not** own the inbox
(SPF/DKIM/DMARC/reputation is `../email-deliverability/SKILL.md`) and you do
**not** own the words (subject lines and growth are `../newsletter/SKILL.md`,
launch copy is `../marketing/SKILL.md`). The deliverable is idempotent,
secret-safe, stream-correct server code — not subject lines, DNS records, or
audience strategy.

Stack as of June 2026: `resend` 6.12.4, `@sendgrid/mail` 8.1.6, Postmark via its
HTTP API, React Email 5.0 (React 19.2 / Next.js 16, Tailwind 4), Node 20+ / TS.

## The four non-negotiables

Every email integration you produce must satisfy these. `scripts/verify.sh`
greps for the first three; the fourth is the design constraint behind them.

1. **API key from env, never a literal.** `process.env.RESEND_API_KEY`, not
   `re_xxx` / `SG.xxx` / a server token in source. *Why:* a committed key is a
   send-as-you credential — instant abuse and reputation burn.
2. **Idempotency key on every transactional send.** *Why:* queues retry,
   serverless functions re-fire, users double-click. Without a stable key one
   password reset becomes three.
3. **Send on the correct stream.** Transactional and broadcast traffic go on
   distinct From + subdomain + stream. *Why:* a marketing reputation hit must
   never push password resets to spam.
4. **Verify the inbound webhook signature against the raw body.** *Why:* the
   bounce/complaint hook mutates your suppression list; an unverified hook lets
   anyone suppress (or un-suppress) your users.

## Step 1 — pick a provider

| Provider | Best default fit | Native idempotency | Template model | Batch cap | Pick when |
|----------|------------------|--------------------|----------------|-----------|-----------|
| **Resend** | Greenfield, React/Next shops | Yes — `{ idempotencyKey }`, 24h, ≤256 chars | React Email JSX via `react:` | 100/call | You want JSX templates and the least ceremony |
| **SendGrid (Twilio)** | High volume, marketing+txn mix | No — dedupe yourself | `d-` dynamic templates + `dynamicTemplateData` | per-send `personalizations` | You need 10k req/s scale or already on Twilio |
| **Postmark** | Pure transactional, deliverability-first | No — self-dedupe via your key + webhooks | Postmark server templates | per-stream | Receipts/resets must never queue behind marketing |

Idempotency support changes your strategy, not just your config — see Step 4.
Full per-provider matrix (auth headers, exact signatures, webhook event names,
rate limits) is in `references/providers.md`.

## Step 2 — the `sendEmail()` seam

One provider-agnostic function. The rest of the app calls `sendEmail(...)` and
never imports a provider SDK. *Why:* swapping SendGrid→Postmark is then one file,
not a grep across every call site.

```ts
// lib/email/index.ts — the only place a provider SDK is imported
export type SendArgs = {
  to: string | string[];
  subject: string;
  react?: React.ReactElement; // template component
  html?: string;
  text?: string;
  idempotencyKey: string;     // required for transactional sends
  stream?: 'transactional' | 'broadcast';
};
export async function sendEmail(args: SendArgs): Promise<{ id: string }> { /* provider impl */ }
```

```ts
// lib/email/resend.ts
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendEmail(a: SendArgs) {
  const { data, error } = await resend.emails.send(
    { from: 'YourApp <noreply@notify.yourdomain.com>', to: a.to, subject: a.subject, react: a.react, html: a.html, text: a.text },
    { idempotencyKey: a.idempotencyKey }, // 2nd arg, retained 24h, ≤256 chars
  );
  if (error) throw new Error(error.message);
  return { id: data!.id };
}
```

```ts
// lib/email/sendgrid.ts
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);

export async function sendEmail(a: SendArgs) {
  const [res] = await sgMail.send({
    from: 'noreply@notify.yourdomain.com',
    to: a.to, subject: a.subject, html: a.html, text: a.text,
    // SendGrid has no idempotency key — guard with your own dedupe (Step 4)
  });
  return { id: res.headers['x-message-id'] };
}
```

```ts
// lib/email/postmark.ts — raw HTTP, X-Postmark-Server-Token header
export async function sendEmail(a: SendArgs) {
  // Postmark has no idempotency key: self-dedupe BEFORE calling (Step 4)
  const r = await fetch('https://api.postmarkapp.com/email', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
      'X-Postmark-Server-Token': process.env.POSTMARK_SERVER_TOKEN!,
    },
    body: JSON.stringify({
      From: 'noreply@notify.yourdomain.com',
      To: Array.isArray(a.to) ? a.to.join(',') : a.to,
      Subject: a.subject, HtmlBody: a.html, TextBody: a.text,
      MessageStream: a.stream === 'broadcast' ? 'broadcast' : 'outbound',
    }),
  });
  if (!r.ok) throw new Error(`Postmark ${r.status}`);
  return { id: (await r.json()).MessageID };
}
```

Bad → Good:

```ts
// Bad — provider SDK called directly in a route handler, key inline
import { Resend } from 'resend';
await new Resend('re_live_123abc').emails.send({ to, subject, html });
```
```ts
// Good — call the seam; key is in env, swap is one file
import { sendEmail } from '@/lib/email';
await sendEmail({ to, subject, react: <Welcome name={n} />, idempotencyKey });
```

## Step 3 — templates

Templates are typed components, not string concat. *Why:* JSX escapes
interpolated values; hand-built HTML invites injection and broken markup.

React Email 5.0 renamed `renderAsync` → `render`. The Resend SDK lazily imports
`@react-email/render` when you pass `react:`, so you usually pass the component
directly and skip manual rendering.

```tsx
// emails/welcome.tsx
import { Html, Button, Text } from '@react-email/components';
export function Welcome({ name, url }: { name: string; url: string }) {
  return (
    <Html>
      <Text>Welcome, {name}.</Text>
      <Button href={url}>Confirm your email</Button>
    </Html>
  );
}
```

```ts
// SendGrid: dynamic template referenced by a d- id, data passed separately
await sgMail.send({
  to, from: 'noreply@notify.yourdomain.com',
  templateId: 'd-abc123...',                  // dynamic template id starts with d-
  dynamicTemplateData: { name, confirm_url },  // values, not pre-rendered HTML
});
```

```ts
// Bad — string concat, unescaped user input straight into HTML
const html = '<h1>Hi ' + req.body.name + '</h1>'; // XSS + broken layout risk
```

## Step 4 — idempotency & retries

Derive a deterministic key from the *event*, not the clock. Same event → same
key → provider (or your table) collapses the duplicate.

```ts
const idempotencyKey = `pwreset:${userId}:${tokenVersion}`; // stable across retries
```

- **Resend:** native. Pass `{ idempotencyKey }` as the 2nd arg; retained 24h,
  ≤256 chars. For a batch, the key represents the whole batch (e.g.
  `team-quota/123456789`), not each row.
- **Postmark / SendGrid:** no idempotency feature. You must self-dedupe: write
  the key to a `sent_emails` table inside the same transaction as the send,
  unique-constrain it, and skip if it already exists.

```ts
// Self-dedupe seam for providers without native keys
const inserted = await db.sentEmails.insertIfAbsent({ key: idempotencyKey });
if (!inserted) return; // already sent — do not re-fire
await sendEmail({ to, subject, html, idempotencyKey });
```

```ts
// Bad — no key; queue retry sends the reset 3×
await sendEmail({ to, subject, react: <Reset url={url} /> } as any);
```

## Step 5 — batch / bulk

`resend.batch.send([...])` is capped at **100 emails per call** and forbids
attachments/scheduling. Chunk larger runs, then inspect *both* arrays for
partial failure — a 200 response can still contain per-row errors.

Checklist for a bulk run:

- [ ] Filter the recipient list against the suppression list (Step 7) first.
- [ ] Chunk into ≤100; one `idempotencyKey` per chunk.
- [ ] Use `batchValidation: 'permissive'` so one bad address does not nuke the chunk.
- [ ] Iterate results: collect succeeded ids and failed rows separately.
- [ ] Re-queue only the failed rows; never replay the whole chunk.

```ts
function chunk<T>(xs: T[], n = 100) { const o: T[][] = []; for (let i = 0; i < xs.length; i += n) o.push(xs.slice(i, i + n)); return o; }

for (const [i, group] of chunk(recipients).entries()) {
  const { data } = await resend.batch.send(
    group.map((r) => ({ from, to: r.email, subject, react: <Digest items={r.items} /> })),
    { idempotencyKey: `digest-2026-06/${i}`, batchValidation: 'permissive' },
  );
  data?.data?.forEach((d) => markSent(d.id));      // succeeded rows
  // inspect per-row errors and re-queue only those — do not replay the chunk
}
```

## Step 6 — transactional vs broadcast split

Reputation isolation. Give each stream a distinct From, subdomain, and
stream/IP so they cannot poison each other:

| Stream | From | Subdomain | Provider stream |
|--------|------|-----------|-----------------|
| Transactional | `noreply@notify.yourdomain.com` | `notify.` | Resend default / Postmark `outbound` |
| Broadcast | `news@promo.yourdomain.com` | `promo.` | dedicated marketing stream / `broadcast` |

*Why:* a marketing send that trips a blocklist must never take password resets
down with it. The DNS/auth setup for those subdomains is
`../email-deliverability/SKILL.md`'s job; you just send on the right one.

## Step 7 — delivery/bounce/complaint webhook → suppression

The provider POSTs bounce and complaint events. Verify the signature on the
**raw** body (parse after verifying), then write the address to a suppression
list and check that list before every future send.

```ts
// app/api/email/webhook/route.ts (Next.js 16) — verify BEFORE parsing
export async function POST(req: Request) {
  const raw = await req.text();                       // raw body, not req.json()
  if (!verifyProviderSignature(raw, req.headers)) return new Response('bad sig', { status: 401 });
  const event = JSON.parse(raw);
  if (event.type === 'email.bounced' || event.type === 'email.complained') {
    await db.suppressions.upsert({ email: event.data.to, reason: event.type });
  }
  return new Response('ok');
}
```

```ts
// Before any send: skip suppressed addresses
const recipients = candidates.filter(async (e) => !(await db.suppressions.has(e)));
```

Generic webhook hardening (replay windows, queueing, retries beyond email) is
`../webhooks/SKILL.md`. The address-validity question (is this mailbox real
before I ever send) is `../lead-gen/SKILL.md` / `../email-deliverability/SKILL.md`.

## Anti-patterns

| Anti-pattern | Why it bites | Do instead |
|--------------|--------------|------------|
| API key hard-coded (`re_…`, `SG.…`, server token) | Committed credential = send-as-you abuse | Read from `process.env`; rotate via `../secure-coding/SKILL.md` |
| No idempotency key on transactional sends | Queue/serverless retry double-sends | Deterministic `event:userId:version` key |
| One stream for everything | Marketing hit poisons reset/receipt deliverability | Split From + subdomain + stream (Step 6) |
| String-concatenated HTML with user input | XSS + broken layout | React Email component or `d-` dynamic template |
| Ignoring per-row `data.errors` in a batch | Silent partial loss; "looked like 200" | Inspect both arrays; re-queue only failures |
| Trusting the webhook without signature check | Anyone can poison your suppression list | Verify signature on raw body, then parse |
| Sending to a bounced/complained address | Reputation damage, ISP penalties | Filter against suppression list before send |
| Calling the provider SDK at scattered call sites | Provider swap = grep across the app | One `sendEmail()` seam (Step 2) |

## Reference

`references/providers.md` — full side-by-side matrix (auth header, SDK +
version, single/batch signatures, idempotency model, stream/subdomain model,
dynamic-template syntax, webhook event names, rate limits, when to pick each)
plus a suppression-webhook handler skeleton per provider.

Generic typed HTTP-client wrappers for *any* REST API →
`../api-connector-builder/SKILL.md`. The orchestration that decides *when* to
fire a multi-step sequence → `../automation-flows/SKILL.md`.