Skip to content
Skillv1.0.0

email-connector

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, tran

by ericrisco(0) 0 installs
Free
Sign in to install

Free account. Installing gives you the manifest plus copy-paste snippets.

See reviews

About

Imported from ericrisco/rsc-harness (skills/email-connector/SKILL.md). Install upstream with npx skills add ericrisco/rsc-harness --skill email-connector. Copyright stays with 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 <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 };
}
// 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'] };
}
// 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:

// 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: 'noreply@notify.yourdomain.com',
  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 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. 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)

Use it

Copy one of these into your project. Installing also returns the manifest and these snippets.

yaml
targets:
  - https://api.opensmartroute.ai/api/v1/registry/ericrisco-rsc-harness-email-connector/manifest   # or paste the manifest below

Manifest

An Open Capability Manifest: the router reads it to know what this does, what it costs and when to pick it.

ericrisco-rsc-harness-email-connector.ocm.jsonjson
{
  "ocm": "1",
  "id": "ericrisco-rsc-harness-email-connector",
  "kind": "skill",
  "name": "email-connector",
  "description": "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`).",
  "publisher": "ericrisco",
  "version": "1.0.0",
  "capabilities": {
    "domains": [
      "coding"
    ],
    "tags": [
      "skill-md",
      "email",
      "transactional-email",
      "resend",
      "sendgrid",
      "postmark",
      "webhooks",
      "idempotency",
      "github"
    ],
    "languages": [
      "en"
    ]
  },
  "quality_prior": 0.6,
  "examples": [
    "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`)."
  ],
  "primary": false,
  "metadata": {
    "source": {
      "provider": "github",
      "repository": "https://github.com/ericrisco/rsc-harness",
      "path": "skills/email-connector/SKILL.md",
      "ref": "8cc4716ea549275ade1590ad270da01bdf837ab5",
      "url": "https://github.com/ericrisco/rsc-harness/blob/8cc4716ea549275ade1590ad270da01bdf837ab5/skills/email-connector/SKILL.md",
      "key": "ericrisco/rsc-harness/skills/email-connector/SKILL.md"
    }
  },
  "instructions": "# email-connector — put transactional & bulk email on the wire\n\nYou wire the *send*. A welcome mail, a password reset, a receipt, a 4,000-row\ndigest — your job is the server code that hands it to a provider, makes it safe\nto retry, and keeps the suppression list honest. You do **not** own the inbox\n(SPF/DKIM/DMARC/reputation is `../email-deliverability/SKILL.md`) and you do\n**not** own the words (subject lines and growth are `../newsletter/SKILL.md`,\nlaunch copy is `../marketing/SKILL.md`). Generic typed clients for *any* REST API\nare `../api-connector-builder/SKILL.md`; deciding *when* a mult",
  "cost": {
    "context_tokens": 2978
  }
}

Fetch it by URL: GET /api/v1/registry/ericrisco-rsc-harness-email-connector/manifest?version=1.0.0

Reviews

Star ratings from people who tried it. One review per account; edit yours any time.

No reviews yet. Install it, try it, and be the first to rate it.