mcpbeat Sign in

Email Connector Agent Skill

Use when wiring server code to send transactional or bulk email via Resend, SendGrid, or Postmark: a provider-agnostic sendEmail() seam, idempotent retries, 100-cap batches with partial failures, transactional-vs-broadcast streams, bounce webhooks feeding a suppression list. NOT SPF/DKIM/DMARC inbox reputation (that is `email-deliverability`).

6k tokens
context cost
the whole folder, loaded on every use
5
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill email-connector

What comes with it

12 727 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/providers.md
scripts/verify.sh

The instruction itself

9 sections, as written by the author

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). Generic typed clients for *any* REST API

are ../api-connector-builder/SKILL.md; deciding *when* a multi-step sequence

fires is ../automation-flows/SKILL.md.

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.

scripts/verify.sh is read-only and greps a target for the four invariants this

skill exists to hold: env-sourced key, idempotency, a single sendEmail() seam,

and a webhook signature checked on the raw body.

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 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 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. That one file reads the key from

process.env — never a re_… / SG.… / server-token literal, because a

committed key is a send-as-you credential and burns your reputation with it.

// 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 */ }
// 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 <[email protected]>', 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 };
}
// 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: '[email protected]',
    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'] };
}
// 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: '[email protected]',
      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:

// 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 });
// 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 renderAsyncrender. The Resend SDK lazily imports

@react-email/render when you pass react:, so you usually pass the component

directly and skip manual rendering.

// 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>
  );
}
// SendGrid: dynamic template referenced by a d- id, data passed separately
await sgMail.send({
  to, from: '[email protected]',
  templateId: 'd-abc123...',                  // dynamic template id starts with d-
  dynamicTemplateData: { name, confirm_url },  // values, not pre-rendered HTML
});
// 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

Every transactional send carries a key, because queues retry, serverless

functions re-fire, and users double-click — without a stable key one password

reset becomes three. Derive it from the *event*, not the clock. Same event → same

key → provider (or your table) collapses the duplicate.

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.

// 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 });
// 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.
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 | [email protected] | notify. | Resend default / Postmark outbound |

| Broadcast | [email protected] | 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. The verification is absolute

because this hook mutates the suppression list: unverified, anyone can suppress

— or un-suppress — your users.

// 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');
}
// 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) |

How to use it

Copy the folder

Take ericrisco/email-connector from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.