email-with-resend · diff
git:20260807.975fe85 to git:20260807.fac9f85
16 added, 295 removed. Audit F to A.
---
name: email-with-resend
- description: Implement transactional and product email with Resend, including templates, preferences, audiences, campaigns, and delivery safety. Use when an app needs email.
+ description: Implement or audit Resend email with templates, queues, preferences, audiences, campaigns, webhooks, and delivery safety. Use when an app needs consent-aware email or Resend repair.
license: MIT
---
# Email with Resend
- Implement product email around Resend: transactional events, lifecycle messages, preferences, audiences, campaigns, delivery observability, and safe admin controls. This is not a cold-outreach workflow.
-
- ## Three things that silently break Resend integrations
-
- 1. **Domain not verified before going live.** Resend sends from `@resend.dev` by default. Emails land in spam or get blocked entirely. Verify your domain in the Resend dashboard and confirm the `from` address matches the verified domain before any emails go to real users.
- 2. **Throwing on email failure.** If `sendEmail` throws and you don't catch it, a welcome email failure crashes the signup flow. Email is fire-and-forget — always catch, always return `{ success, error }`, never throw.
- 3. **Sending from a `noreply` address with no `reply-to`.** Users hit reply when they have questions. If there's no monitored inbox on the other end, you lose them. Set `replyTo` to a real inbox even if `from` is `noreply@`.
-
- ## Phase 1: Detect the Project
-
- Before writing anything, read the codebase:
-
- ### 1.1 Stack Detection
- - **Framework**: Next.js / Express / FastAPI / other?
- - **Database**: Supabase / Prisma / Drizzle / Mongoose / raw SQL?
- - **Auth**: What fields identify a user? (`clerk_user_id`, `email`, `id`)
- - **User table schema**: What fields exist? (`email`, `name`, `credits`, `plan`, `created_at`, `last_active_at`)
-
- ### 1.2 Ask the User
-
- Before scaffolding, confirm:
-
- ```
- I'll wire email for your [framework] app with [database].
-
- Quick decisions:
-
- 1. What emails do you need? (transactional, campaigns, or both)
- 2. Sender: From name and from email? (e.g., "Tushar from Bangers Only <hi@bangersonly.xyz>")
- 3. Domain verified in Resend? (yes / need to set it up)
-
- Defaults: welcome email + re-engagement campaign, Resend.
- ```
-
- ## Phase 2: Provider Setup
-
- ### Install and Configure Resend
-
- ```bash
- npm install resend
- ```
-
- Add to `.env.example`:
- ```
- RESEND_API_KEY=re_xxxxx
- ```
-
- Create the email utility — every email in the codebase goes through this:
-
- ```typescript
- // lib/email.ts
- import { Resend } from 'resend';
-
- const resend = new Resend(process.env.RESEND_API_KEY);
-
- interface SendEmailParams {
- to: string | string[];
- subject: string;
- html: string;
- from?: string;
- replyTo?: string;
- }
-
- export async function sendEmail({
- to,
- subject,
- html,
- from = 'Your Name <you@yourdomain.com>',
- replyTo,
- }: SendEmailParams) {
- try {
- const result = await resend.emails.send({ from, to, subject, html, reply_to: replyTo });
- return { success: true, id: result.data?.id };
- } catch (error) {
- console.error('Email send failed:', error);
- return { success: false, error };
- }
- }
- ```
-
- **Never throw from the email utility.** Email failures should not crash the main request flow. Return `{ success, error }` and let the caller decide.
-
- ### Domain Verification Checklist
-
- Tell the user to complete this in the Resend dashboard before going to production:
-
- ```
- [ ] Add MX record to DNS
- [ ] Add SPF TXT record: v=spf1 include:resend.com ~all
- [ ] Add DKIM TXT records (Resend provides these in dashboard)
- [ ] Verify domain status shows "Verified" in Resend dashboard
- [ ] Set reply-to to a monitored inbox (not noreply)
- ```
-
- Without domain verification, emails land in spam or don't send at all.
-
- ## Phase 3: Email Templates
-
- Create templates for the events that exist in the codebase. At minimum, scaffold these:
-
- ### Welcome Email (triggers on user signup)
-
- ```typescript
- // lib/emails/welcome.ts
- export function welcomeEmail({ name, ctaUrl }: { name: string; ctaUrl: string }): string {
- return `
- <div style="font-family: sans-serif; max-width: 560px; margin: 0 auto; padding: 40px 20px; color: #111;">
- <p>Hey ${name},</p>
- <p>You're in. Here's what to do first:</p>
- <p>
- <a href="${ctaUrl}"
- style="display: inline-block; background: #000; color: #fff; padding: 12px 24px;
- text-decoration: none; border-radius: 6px; font-size: 14px;">
- Get started
- </a>
- </p>
- <p>Any questions — just reply to this email.</p>
- <p style="color: #666; font-size: 13px; margin-top: 40px;">— [Your Name]</p>
- <p style="color: #999; font-size: 11px;">
- <a href="{{{unsubscribe_url}}}" style="color: #999;">Unsubscribe</a>
- </p>
- </div>
- `;
- }
- ```
-
- ### Re-engagement Email (triggers for users inactive 7+ days)
-
- ```typescript
- // lib/emails/reengagement.ts
- export function reengagementEmail({ name, daysSinceActive }: { name: string; daysSinceActive: number }): string {
- return `
- <div style="font-family: sans-serif; max-width: 560px; margin: 0 auto; padding: 40px 20px; color: #111;">
- <p>Hey ${name},</p>
- <p>Haven't seen you in ${daysSinceActive} days. [Product] has [one improvement since they last used it].</p>
- <p>
- <a href="[APP_URL]"
- style="display: inline-block; background: #000; color: #fff; padding: 12px 24px;
- text-decoration: none; border-radius: 6px; font-size: 14px;">
- Pick up where you left off
- </a>
- </p>
- <p style="color: #666; font-size: 13px; margin-top: 40px;">— [Your Name]</p>
- <p style="color: #999; font-size: 11px;">
- <a href="{{{unsubscribe_url}}}" style="color: #999;">Unsubscribe</a>
- </p>
- </div>
- `;
- }
- ```
-
- ### Template Rules
- - Plain text feel — no heavy HTML design
- - One CTA per email
- - Signed by a human name, not "The Team"
- - Every template must include an unsubscribe link for campaigns
- - Inline styles only — no external stylesheets (spam filters strip them)
-
- ### Wire Welcome Email Into Signup Flow
-
- Find the signup/auth sync endpoint and call `sendEmail` after user creation:
-
- ```typescript
- // After creating the new user:
- if (isNewUser) {
- await sendEmail({
- to: user.email,
- subject: 'Welcome',
- html: welcomeEmail({ name: user.name || 'there', ctaUrl: process.env.APP_URL! }),
- });
- }
- ```
-
- ## Phase 4: User Segmentation
-
- Read the user table schema and generate segments based on available fields:
-
- | If table has... | Generate these segments |
- |---|---|
- | `credits` / `usage_count` | Power (top 20%), Active (middle 60%), Inactive (bottom 20%) |
- | `created_at` | New (<7 days), Established (7-30 days), Veteran (30+ days) |
- | `plan` / `subscription_tier` | Free, Paid, Churned |
- | `last_active_at` | Active (<7 days), Dormant (7-30 days), Churned (30+ days) |
-
- Generate the actual SQL/ORM query for each segment that exists. Example for credits-based app:
-
- ```sql
- -- Dormant users: used some credits but went quiet
- SELECT email, name
- FROM users
- WHERE (initial_credits - credits) > 0
- AND last_active_at < NOW() - INTERVAL '7 days'
- AND email_unsubscribed = false;
- ```
-
- ## Phase 5: Admin Campaign UI
-
- If campaigns are requested, scaffold an admin route:
-
- ```
- POST /api/admin/send-campaign
- Body: { segment: string, templateId: string }
- ```
-
- Handler logic:
- 1. Check admin auth — never skip this
- 2. Query users in segment
- 3. Loop and send — catch per-email errors, don't abort the batch on one failure
- 4. Return `{ sentCount, failedEmails, errors }`
-
- ```typescript
- // api/admin/send-campaign/route.ts
- export async function POST(req: Request) {
- const adminSecret = req.headers.get('x-admin-secret');
- if (adminSecret !== process.env.ADMIN_SECRET) {
- return Response.json({ error: 'Unauthorized' }, { status: 401 });
- }
-
- const { segment, templateId } = await req.json();
- const users = await getUsersInSegment(segment);
-
- let sentCount = 0;
- const failedEmails: string[] = [];
-
- for (const user of users) {
- const result = await sendEmail({
- to: user.email,
- subject: getSubjectForTemplate(templateId),
- html: renderTemplate(templateId, user),
- });
-
- if (result.success) {
- sentCount++;
- } else {
- failedEmails.push(user.email);
- }
- }
-
- return Response.json({ sentCount, failedEmails });
- }
- ```
-
- **Rate limit awareness**: Resend free tier = 100 emails/day. Paid starts at $20/mo for 50,000/day. If segment is larger than the daily limit, add batch delays or warn the user.
-
- ## Phase 6: Unsubscribe Mechanism
-
- Every campaign email needs a working unsubscribe. This isn't optional — it's both legal and a deliverability requirement.
-
- ```typescript
- // lib/unsubscribe.ts
- import { createHmac } from 'crypto';
-
- export function generateUnsubToken(email: string): string {
- return createHmac('sha256', process.env.UNSUB_SECRET!)
- .update(email)
- .digest('hex')
- .slice(0, 16);
- }
-
- export function unsubUrl(email: string): string {
- const token = generateUnsubToken(email);
- return `${process.env.APP_URL}/unsubscribe?email=${encodeURIComponent(email)}&token=${token}`;
- }
- ```
-
- Add `email_unsubscribed boolean DEFAULT false` to the users table if it doesn't exist. The unsubscribe route sets it to true and all segment queries must filter on `email_unsubscribed = false`.
-
- ## Phase 7: Verify
-
- ```
- Flow 1: Transactional
- [ ] User signs up → welcome email arrives in inbox (not spam)
- [ ] Email shows from verified domain, not @resend.dev
- [ ] Reply-to is a real inbox
+ Implement email as a reliable, permission-aware subsystem. This is not a cold-outreach workflow.
- Flow 2: Campaigns
- [ ] Admin send-campaign endpoint requires auth
- [ ] Segment query returns correct users
- [ ] Failed individual emails don't abort the batch
- [ ] Response shows sentCount and failedEmails
+ ## Workflow
- Flow 3: Unsubscribe
- [ ] Unsubscribe link in every campaign email
- [ ] Clicking it marks user as unsubscribed in DB
- [ ] Unsubscribed users excluded from future segments
+ 1. Inspect framework/runtime, installed Resend SDK version, queues/jobs, database and user fields, auth/admin roles, existing templates, email events, consent/preferences, suppression data, webhooks, and verified-domain configuration.
+ 2. Classify each requested message as transactional, security, lifecycle, or marketing. Do not scaffold re-engagement or campaigns by default. Ask about sender identity, jurisdiction/consent, and preference policy only when not established by the product.
+ 3. Read [the Resend guide](references/guide.md) for current send/batch/webhook patterns, suppression handling, unsubscribe design, and copy guidance relevant to the selected class.
+ 4. Centralize provider calls. Detect the installed SDK API, handle both returned `error` values and thrown transport/runtime errors, use idempotency keys for event-triggered sends, and return a typed result to the caller.
+ 5. Escape untrusted template values or use React/provider templates. Generate both HTML and useful text. Keep sender/reply-to configuration server-side and use a verified sending subdomain where appropriate.
+ 6. Do not block or silently abandon the primary transaction. Use a durable outbox/queue, or a runtime-supported post-response primitive with observability when loss is acceptable. Unawaited promises are not reliable in serverless runtimes.
+ 7. For campaigns, use contacts/audiences or a durable campaign job rather than a long request loop. Enforce consent, preferences, suppression, per-recipient idempotency, rate limits, cancellation, and an audit record. Require role-based admin authorization and confirmation of segment/count before send.
+ 8. Verify Resend webhook signatures over the raw body, deduplicate events, and process bounces/complaints/delivery failures into a suppression model. Never trust an unsigned event.
+ 9. Provide one-click unsubscribe and preference handling where required. Do not expose raw email addresses in URLs when an opaque signed identifier can be used. Keep transactional/security opt-outs separate from marketing preferences.
- Flow 4: Edge Cases
- [ ] sendEmail never throws — returns { success: false } on failure
- [ ] Email sending doesn't block the main signup flow
- [ ] RESEND_API_KEY in .env.example (not in code)
- ```
+ ## Verification
- ## Important Notes
+ Test provider success plus returned API error, thrown network error, duplicate event, queue retry, invalid recipient, escaped user content, text rendering, verified sender/reply-to, signed/invalid/replayed webhook, bounce/complaint suppression, unsubscribe/preferences, unauthorized campaign access, segment preview, and rate-limit behavior. Run repository lint/type/test/build commands and send only to designated test recipients until production setup is confirmed.
- - **Never hardcode email addresses.** Use env vars for from/reply-to.
- - **Welcome email timing matters.** Send it in the background — don't make signup wait for email delivery.
- - **Test with Resend's test mode** before switching to live API key.
- - **One email per event.** Don't fire multiple emails on the same trigger — users notice.
+ ## Output
- See [references/guide.md](references/guide.md) for email copy by segment, subject line formulas, and rate limiting patterns.
+ Report message classes and triggers, files/templates/jobs changed, sender and environment-variable names, consent/preferences/suppression behavior, webhook and idempotency design, verification evidence/message IDs, and remaining DNS/dashboard/production checks.