ship-email

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese
Scaffold complete email infrastructure — Resend setup, transactional templates, user segmentation, and an admin send UI. Reads the project first, plugs into existing auth and database.
搭建完整的邮件基础设施——包括Resend配置、事务性邮件模板、用户分群以及管理端发送UI。首先读取项目信息,接入现有鉴权体系和数据库。

Phase 1: Detect the Project

第一阶段:项目识别

Before writing anything, read the codebase:
编写任何代码前,先读取代码库信息:

1.1 Stack Detection

1.1 技术栈识别

  • 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
    )
  • 框架:Next.js / Express / FastAPI / 其他?
  • 数据库:Supabase / Prisma / Drizzle / Mongoose / 原生SQL?
  • 鉴权体系:用什么字段识别用户?(
    clerk_user_id
    email
    id
  • 用户表结构:存在哪些字段?(
    email
    name
    credits
    plan
    created_at
    last_active_at

1.2 Ask the User

1.2 询问用户确认

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.
搭建前确认以下信息:
我将为你的基于[framework]开发、使用[database]的应用接入邮件功能。

请确认几个问题:

1. 你需要哪些邮件功能?(事务性邮件、营销活动,或两者都要)
2. 发件人信息:发件人名称和发件邮箱?(例如:"Tushar from Bangers Only <hi@bangersonly.xyz>")
3. 域名是否已在Resend完成验证?(是 / 需要配置)

默认配置:欢迎邮件 + 用户召回营销活动,使用Resend服务。

Phase 2: Provider Setup

第二阶段:服务商配置

Install and Configure Resend

安装并配置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.
bash
npm install resend
添加到
.env.example
文件:
RESEND_API_KEY=re_xxxxx
创建邮件工具函数——代码库中所有邮件发送都通过该函数处理:
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 };
  }
}
永远不要在邮件工具函数中抛出异常。 邮件发送失败不应该导致主请求流程崩溃,返回
{ success, error }
让调用方自行处理。

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.
告知用户上线前需要在Resend控制台完成以下配置:
[ ] 向DNS添加MX记录
[ ] 添加SPF TXT记录:v=spf1 include:resend.com ~all
[ ] 添加DKIM TXT记录(Resend控制台会提供对应值)
[ ] 确认Resend控制台中域名状态显示为"已验证"
[ ] 将回复地址设置为有人值守的邮箱(不要用noreply地址)
如果未完成域名验证,邮件会进入垃圾箱甚至无法发送。

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>
  `;
}
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)

用户召回邮件(触发条件:用户7天以上未活跃)

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>
  `;
}
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)
  • 保持纯文本观感,不要使用过重的HTML设计
  • 每封邮件仅保留一个行动号召按钮
  • 发件人使用真实人名,不要用"团队"之类的统称
  • 所有营销类邮件模板必须包含退订链接
  • 仅使用行内样式,不要引入外部样式表(垃圾邮件过滤器会拦截外部样式)

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! }),
  });
}
找到注册/鉴权同步接口,在用户创建完成后调用
sendEmail
typescript
// 创建新用户后执行:
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;
读取用户表结构,基于可用字段生成分群规则:
如果表包含...生成对应分群
credits
/
usage_count
核心用户(前20%)、活跃用户(中间60%)、低活用户(后20%)
created_at
新用户(注册<7天)、成熟用户(注册7-30天)、资深用户(注册30天以上)
plan
/
subscription_tier
免费用户、付费用户、流失用户
last_active_at
活跃用户(<7天未活跃)、沉睡用户(7-30天未活跃)、流失用户(30天以上未活跃)
为每个存在的分群生成对应的SQL/ORM查询语句,以下是基于积分的应用示例:
sql
-- 沉睡用户:使用过部分积分但长期未活跃
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

第五阶段:管理端营销活动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.
如果用户需要营销活动功能,搭建管理端路由:
POST /api/admin/send-campaign
请求体: { segment: string, templateId: string }
处理逻辑:
  1. 校验管理员权限——绝对不能跳过该步骤
  2. 查询对应分群的用户列表
  3. 循环发送邮件——捕获单封邮件发送错误,不要因为单条失败终止整批发送
  4. 返回
    { 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 });
}
速率限制注意事项:Resend免费版额度为100封/天,付费版最低20美元/月可支持50000封/天。如果分群用户数超过日限额,需要添加批次延迟或提醒用户。

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
.
每封营销邮件都需要可用的退订功能,这不是可选项——既是法律要求,也是邮件送达率的保障。
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}`;
}
如果用户表不存在
email_unsubscribed
字段,添加该字段,默认值为false。退订路由会将该字段设为true,所有分群查询都必须过滤
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)
流程1:事务性邮件
[ ] 用户注册 → 欢迎邮件送达收件箱(不在垃圾箱)
[ ] 发件人显示已验证的域名,而非@resend.dev
[ ] 回复地址是真实可用的邮箱

流程2:营销活动
[ ] 管理端发送活动接口需要鉴权
[ ] 分群查询返回正确的用户列表
[ ] 单封邮件发送失败不会终止整批发送
[ ] 响应返回发送成功数和失败邮箱列表

流程3:退订功能
[ ] 所有营销邮件都包含退订链接
[ ] 点击退订链接会将用户标记为已退订
[ ] 已退订用户不会出现在后续的分群列表中

流程4:边界场景
[ ] sendEmail永远不会抛出异常,失败时返回{ success: false }
[ ] 邮件发送不会阻塞主注册流程
[ ] RESEND_API_KEY保存在.env.example中(不硬编码在代码里)

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.
  • 永远不要硬编码邮箱地址,发件人/回复地址使用环境变量存储
  • 欢迎邮件发送时机很重要,在后台异步发送,不要让注册流程等待邮件发送完成
  • 切换到正式API密钥前使用Resend的测试模式验证功能
  • 每个事件仅触发一封邮件,不要同一个触发条件发送多封邮件,会引起用户反感
参考 references/guide.md 查看不同分群的邮件文案、主题行公式和限流方案。