ship-email
Scaffold Resend email infrastructure with templates, segmentation, campaigns, and unsubscribe handling. Use when adding transactional or campaign email to an app.
- Category
- infrastructure
- Package
- ship-email/SKILL.md
- License
- MIT
- Author
- @tushaarmehtaa
- Tags
- emailresendtransactionalcampaignsdeliverability
Install
Swipe for more runtimes.
Codex
Skills directory: ~/.codex/skills
Install globally
npx skills add tushaarmehtaa/tushar-skills --skill ship-email -g -a codex -yInvoke
$ship-email or /skillsYou can also describe the task naturally; runtimes may select the skill from its description.
Required access
Claude Code
Skills directory: ~/.claude/skills
Install globally
npx skills add tushaarmehtaa/tushar-skills --skill ship-email -g -a claude-code -yInvoke
/ship-emailYou can also describe the task naturally; runtimes may select the skill from its description.
Required access
Cursor
Skills directory: ~/.cursor/skills
Install globally
npx skills add tushaarmehtaa/tushar-skills --skill ship-email -g -a cursor -yInvoke
/ship-emailYou can also describe the task naturally; runtimes may select the skill from its description.
Required access
local coding agent required
This skill requires project files, terminal commands, and network access. Uploading it to a chat app does not provide equivalent execution.
ChatGPT Skills
This workflow needs a local coding environment or capabilities that a chat-only Skills upload does not provide.
Why local agent required →Instructions
Source: SKILL.mdScaffold complete email infrastructure — Resend setup, transactional templates, user segmentation, and an admin send UI. Reads the project first, plugs into existing auth and database.
Three things that silently break Resend integrations
- Domain not verified before going live. Resend sends from
@resend.devby default. Emails land in spam or get blocked entirely. Verify your domain in the Resend dashboard and confirm thefromaddress matches the verified domain before any emails go to real users. - Throwing on email failure. If
sendEmailthrows 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. - Sending from a
noreplyaddress with noreply-to. Users hit reply when they have questions. If there's no monitored inbox on the other end, you lose them. SetreplyToto a real inbox even iffromisnoreply@.
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
npm install resend
Add to .env.example:
RESEND_API_KEY=re_xxxxx
Create the email utility — every email in the codebase goes through this:
// 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)
// 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)
// 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:
// 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:
-- 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:
- Check admin auth — never skip this
- Query users in segment
- Loop and send — catch per-email errors, don't abort the batch on one failure
- Return
{ sentCount, failedEmails, errors }
// 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.
// 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
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
Flow 3: Unsubscribe
[ ] Unsubscribe link in every campaign email
[ ] Clicking it marks user as unsubscribed in DB
[ ] Unsubscribed users excluded from future segments
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)
Important Notes
- 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.
See references/guide.md for email copy by segment, subject line formulas, and rate limiting patterns.