关于 portaly-email
帮助创作者将 Portaly Email 的事务邮件 API 接入应用,涵盖账户与域名设置、服务端发送、重试和投递跟踪;Beta 阶段仅限受邀账户使用。
主要功能
- 01API 密钥与域名设置
- 02服务端发送事务邮件
- 03支持幂等键的重试
- 04定时与批量发送邮件
- 05定期跟踪投递状态
适用场景
- 01发送订单收据和通知
- 02发送验证与密码重置邮件
- 03查看退信或投诉收件人
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ portaly-ai/ portaly-skills --skill portaly-email安装后在 Claude Code 中输入/portaly-email确认已加载。
附带文件references/api-contract.md
详细介绍
Portaly 邮件(Beta 版)
使用此技能,帮助非工程师的普通用户(通常是创作者)将其应用自己的邮件——收据、验证码、密码重置邮件、通知——接入 Portaly 的邮件 API。 回答时应以可执行操作为主:先给出下一步,然后提供可直接复制、且符合项目自身技术栈的代码。
这仅用于事务性邮件:由某个人的某项操作触发的一封邮件。
面向受众发送的新闻简报和公告应通过 Portaly 管理后台发送
(https://portaly.cc/admin/email),而不是通过此 API 发送。
Beta 版——仅限受邀用户。 Portaly Email 目前处于 Beta 阶段,只有收到 Portaly 邀请的账号才能使用。 其他账号每次调用都会收到
403 FORBIDDEN,Agent 无法解决,只能告知用户联系 Portaly。 创建邮件 API 密钥还需要 Premium 套餐,该套餐包含每月配额。如果用户尚未提及收到邀请,请在进行任何设置之前说明这一点。
API 主机
const PORTALY_API_HOST = process.env.PORTALY_API_HOST || 'https://portaly.ai'
除非用户明确要连接非生产环境中的 Portaly,否则请勿设置 PORTALY_API_HOST。完整且权威的 API 契约见 https://portaly.ai/docs(Email 部分)和
https://portaly.ai/openapi.json;此处的 references/api-contract.md 是工作副本。如有冲突,以文档为准。
快速开始
- 密钥 — 用户在 Portaly 管理后台创建邮件 API 密钥,并将其放入
.env中的PORTALY_EMAIL_API_KEY。(工作流步骤 1。) - 先用沙盒测试 — 验证域名之前,发送邮件时使用
<name>@sandbox.portaly.tw。邮件仅会送达账号所有者已验证的登录邮箱,每天最多 50 封。<name>长度为 3–32 个字符,只能包含字母、数字、.、_或-,不得包含portaly,也不得使用noreply、no-reply、support、info或admin等保留名称(400 INVALID_SENDER)——例如orders或hello。这足以验证代码能否正常工作。 - 域名 — 用户绑定发件子域名并添加 DNS 记录,然后确认密钥包含该域名。(工作流步骤 2。)
- 使用服务器端代码发送邮件,并设置
Idempotency-Key。(工作流步骤 3。) - 处理响应并跟踪投递情况 — 重试可重试的请求,配额不足时发出提醒,并记录退信和投诉地址。(工作流步骤 4–5。)
工作流
1. 获取 API 密钥(用户操作)
-
用户在
https://portaly.cc/admin/email/api-keys(經營工具 › 信件管理 › API 管理)创建密钥。 密钥以pem_开头,且只显示一次。 -
权限:仅发送邮件的应用只需
sending(发送、列表查询、查询详情、取消、配额查询)权限。 只有在此集成还需要通过 API 绑定域名时,才选择full。 -
密钥只能从创建时选定的域名发送邮件。尚未验证任何域名时,管理后台的默认选项只有
sandbox.portaly.tw,因此现在创建的密钥之后无法从真实域名发送邮件(403 DOMAIN_NOT_ALLOWED)。请选择「全部网域」(包括之后绑定的域名;full密钥始终包含此选项),或者让用户在完成步骤 2 后再创建另一个密钥。仅限sandbox.portaly.tw的密钥实际上就是测试密钥——没有单独的测试模式。 -
这不是 Portaly Payment 密钥。
pcs_live_/pcs_test_密钥不能发送邮件,pem_密钥也不能调用支付端点。项目若同时使用两者,应分别保留。 -
绝不要让用户把密钥粘贴到聊天中。 告诉他们自行将密钥添加到
.env中:PORTALY_EMAIL_API_KEY=pem_xxx使用
process.env.PORTALY_EMAIL_API_KEY读取密钥。首先确认.env已加入.gitignore。如果用户把密钥粘贴到了聊天中,告诉他们在管理后台撤销该密钥并创建新的密钥。 -
仅限服务器端使用。 绝不要将密钥放入浏览器代码、
NEXT_PUBLIC_/VITE_变量、移动应用或任何交付给用户的内容中:任何持有密钥的人都能以创作者的域名发送邮件。
2. 选择并验证发件域名
- 询问邮件应从哪个地址发出(
orders@…、no-reply@…)。建议使用一个不接收邮件的子域名,例如mail.example.com,这样退信问题就不会影响他们的主收件箱。 - 使用
full密钥时,GET /api/email/domains会显示已绑定并验证的域名;否则请用户检查https://portaly.cc/admin/email/domains(信箱設定)。 - 绑定域名(由管理员操作,或使用
full密钥调用POST /api/email/domains并传入{ "host": "mail.example.com" })会返回dnsRecords:三个 DKIMCNAME记录,以及一个用于 MAIL FROM 子域名的MX记录和一个TXT记录。用户需在 DNS 服务商处添加所有记录,然后在管理页面点击「重新檢查」。DNS 生效可能需要几分钟到几小时;identityStatus: "verified"和mailFromStatus: "success"表示已就绪。不要更改主域名的现有记录(其MX、SPF、DKIM)。在 Cloudflare 上,将新记录设为仅 DNS(灰色云朵),不要启用代理。 - 发件子域名不接收邮件,因此请将
replyTo设为可接收邮件的地址(例如[email protected]);否则客户的回复将无处送达。 - 在域名验证完成前,请使用沙盒进行构建和测试:
sender.email使用sandbox.portaly.tw上的地址,收件人使用账户所有者已验证的登录邮箱。如果所有者登录时没有已验证的邮箱(例如通过手机号或 LINE 登录),沙盒将无法投递邮件(403 RECIPIENT_NOT_ALLOWED)。之后切换到真实域名只需更改配置,无需修改代码。 - 域名验证完成后,请检查密钥是否涵盖该域名(步骤 1);否则发送请求会返回
403 DOMAIN_NOT_ALLOWED。 - 切勿尝试从
mail.portaly.tw或任何其他 Portaly 域名发送邮件,否则会返回403 DOMAIN_NOT_ALLOWED。
3. 发送
仅限服务端代码调用 POST {PORTALY_API_HOST}/api/email/emails。
const PORTALY_API_HOST = process.env.PORTALY_API_HOST || 'https://portaly.ai'
export class PortalyEmailError extends Error {
readonly status: number
readonly code: string | undefined
readonly retryAfterSeconds: number | null
constructor(status: number, code: string | undefined, message: string | undefined, retryAfterSeconds: number | null) {
super(message ?? `Portaly email API answered ${status}`)
this.name = 'PortalyEmailError'
this.status = status
this.code = code
this.retryAfterSeconds = retryAfterSeconds
}
}
export async function sendEmail(input: {
to: string
subject: string
html: string
idempotencyKey: string // derive from your own event, e.g. `order-${orderId}-receipt`; letters, digits, `_`, `-`
}) {
const res = await fetch(`${PORTALY_API_HOST}/api/email/emails`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PORTALY_EMAIL_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': input.idempotencyKey,
},
body: JSON.stringify({
sender: { email: process.env.EMAIL_FROM, name: 'Example Shop' },
replyTo: process.env.EMAIL_REPLY_TO, // the sending subdomain does not receive mail
recipients: [{ email: input.to }],
subject: input.subject,
html: input.html,
tags: [{ name: 'ref', value: input.idempotencyKey }], // to find this email again (step 4, 409)
}),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
const retryAfter = res.headers.get('retry-after')
throw new PortalyEmailError(res.status, body.error?.code, body.error?.message, retryAfter ? Number(retryAfter) : null)
}
return body.data.id as string // emsg_…, store it next to your own record
}
- 始终发送一个根据业务事件生成的
Idempotency-Key,不要为每次尝试生成随机值。使用相同密钥重试不会重复发送;因此超时后重试是安全的。 - **每封要发送的邮件使用一个密钥,而不是每个用户使用一个。**密钥永不过期,Portaly 也不会比较请求正文:重复使用密钥时,会返回第一封邮件的 id,且不会发送新邮件。
order-${orderId}-receipt是合适的;password-reset-${userId}会在第一次之后悄悄丢弃所有密码重置邮件——请使用重置请求自己的 id。如果内容发生变化,请使用新密钥。 - 密钥的作用范围是 Portaly 账户和发件域名。如果暂存环境和生产环境从同一账户及域名发送邮件,请在密钥前加上环境前缀(
prod-order-…)——否则,不同环境中重复的 id 会导致生产环境悄悄重复使用暂存环境的邮件。 - 为每封邮件添加其密钥作为标签(
{ name: 'ref', value: <key> },如上所示):列表结果不包含Idempotency-Key,因此可通过标签重新查找特定邮件。标签值只能包含字母、数字、_和-,请据此生成密钥。 - 将
EMAIL_FROM和EMAIL_REPLY_TO保存在配置中,这样从沙盒切换到已验证域名只需更改配置。 - 将返回的
id与对应的订单或用户一同保存,以便之后查询投递情况。 - 收件人:每封邮件最多 50 位,格式为
{ email, type },其中type可为to/cc/bcc(默认值为to)。这是一封群发邮件——抄送收件人可以看到彼此的信息。如需分别向多位用户发送独立邮件,请使用POST /api/email/batches(最多 100 封邮件,在后台发送)。 scheduledAt(带时区的 ISO 8601 格式,安排在 1 分钟后至不到 30 天后发送——即 30 天减 10 分钟)可安排发送,并返回202;POST /api/email/emails/{id}/cancel可在邮件发出前取消。- 附件:使用
content(base64,文件最大约 3 MB——请求上限为 4.5 MB)或url(Portaly 会下载的公开 https URL)。每封邮件的附件总大小上限为 10 MB。 - 字段级规则和批量请求格式见
references/api-contract.md。
4. 处理响应
每个错误的格式都是 { "error": { "code", "message" } }。根据 code 分支处理,切勿根据 message 判断。
| 响应 | 代码应执行的操作 |
|---|---|
200 / 202 | 存储 data.id。 |
429 RATE_LIMITED | 每个密钥的速率限制。等待 Retry-After 秒,然后使用相同的 Idempotency-Key 重试。 |
500、任何 503、网络错误或超时 | 使用相同的 Idempotency-Key 重试,并采用指数退避策略且设置上限(例如,最多尝试 5 次);之后将其加入队列,留待后续任务处理并发出警报;不会重复发送。Retry-After 的单位是秒。 |
502 SEND_OUTCOME_UNKNOWN | 邮件可能已经发出。使用相同的 Idempotency-Key 重试;在 Portaly 确认结果之前,该重试会返回 409。 |
409 IDEMPOTENCY_KEY_IN_USE | 使用相同的密钥重试,最长约 10 分钟——务必设置上限,切勿无限循环。如果之后仍返回 409,则邮件很可能从未发出(该密钥会一直返回 409):使用 GET /api/email/emails?status=unknown&tag=ref:<key> 查找,然后通知人工处理,或使用新密钥重新发送,并接受重复发送的小概率风险。 |
429 SEND_QUOTA_EXCEEDED | 不是暂时性错误。不要循环重试。 通知人工处理(记录错误级日志并发送通知);创建者可以购买更多配额,或等待每月重置。 |
429 SANDBOX_DAILY_LIMIT_REACHED | 沙盒每天可发送 50 封邮件;在 Asia/Taipei 午夜重置。 |
422 RECIPIENT_SUPPRESSED | 所有 to 地址都在 Portaly 的抑制名单中(地址曾硬退信,或有多个 Portaly 发件人收到来自该地址的垃圾邮件投诉)。不要重试。在数据库中将这些地址标记为无效,并请已登录的用户提供新地址;对于未登录流程(密码重置),显示常见的通用消息,以免泄露账户是否存在。 |
422 MESSAGE_REJECTED | 邮件服务拒绝了这封邮件。使用相同的密钥会持续返回此响应——修复邮件后,使用新密钥发送。 |
403 DOMAIN_NOT_ALLOWED | 该域名未绑定到此账户、属于 Portaly 域名,或密钥未涵盖该域名(步骤 1)。向创建者显示此错误,不要重试。 |
其他 403 DOMAIN_* | 配置问题——参见步骤 2。向创建者显示此错误,不要重试。 |
403 HARD_BOUNCE_LIMIT_REACHED | 该域名已达到当日硬退信上限,当天剩余时间内会被封锁,通常之后还会被暂停。停止使用该域名发送,通知人工处理,并清理 bounced 地址(步骤 5)。当天不要重试。 |
403 RECIPIENT_NOT_ALLOWED | 沙盒模式下向账户所有者以外的对象发送邮件。 |
其他 4xx | 修正请求;INVALID_BODY 消息以出错字段开头(recipients.0.email: …)。 |
- 为避免触及配额上限,在大量发送前或按计划调用
GET /api/email/quota(data.remaining)检查剩余配额,并在配额不足时提醒创建者。配额按收件人计算,并与他们从管理后台发送的广播邮件共享;沙盒邮件不占用配额。
5. 跟踪投递情况
Webhooks 目前尚不可用。请运行定期任务(例如每 10–15 分钟一次),通过 GET /api/email/emails?recipientStatus=bounced,complained&startDate=… 列出近期的问题,并使用 startAfter=<pagination.nextCursor> 翻页。startDate 按邮件创建时间筛选,而投诉可能在几天后才到达,因此每次运行时建议回溯约 7 天。如果应用使用 scheduledAt 或批次发送,也要列出 recipientStatus=failed,quota_exceeded,domain_limited,suppressed:这些状态会在邮件到期时检查,而被拦截的邮件只会显示在这里。
GET /api/email/emails/{id} 会显示一封邮件的收件人信息,适用于少量邮件;但读取操作与列表请求和配额查询共用每个密钥每分钟 120 次的限制,因此应用每分钟发送超过几十封邮件后,逐封轮询就行不通了。
bounced— 地址不存在。在数据库中将其标记为无效,并停止向该地址发送邮件;请用户更新其邮箱。complained— 收件人将其标记为垃圾邮件。Portaly 不会阻止你之后通过 API 向其发送邮件——请记录此状态,并自行停止向其发送任何非必要邮件。suppressed— Portaly 因此地址位于 Portaly 的抑制列表中而跳过了它(之前发生过硬退信,或向多个 Portaly 发件人投诉过垃圾邮件)。被抑制的收件人不占用配额。soft_bounced— 重试后仍出现临时失败(邮箱已满、服务器宕机)。除非同一地址反复出现此情况,否则无需采取措施。delivered表示接收服务器已接受邮件;邮件是否进入垃圾邮件文件夹不可见。- 响应中的地址会经过掩码处理(
bu***@example.com);收件人顺序与发送时一致,因此请按位置将状态对应到你的记录。
硬退信和投诉会计入创建者的域名。持续发生退信的域名会被封禁一天(403 HARD_BOUNCE_LIMIT_REACHED),之后会被暂停——必须处理 bounced 状态。
6. 安全测试
-
代码路径:使用沙盒,向账户所有者自己的邮箱发送。
-
退信和投诉处理:从已验证的自定义域名发送到邮箱模拟器——这些邮件绝不会进入真实收件箱,也不会计入域名的退信率或投诉率(但会占用配额):
地址 结果 [email protected]delivered[email protected]bounced[email protected]delivered,然后变为complained然后轮询
GET /api/email/emails/{id},确认用户代码按照第 5 步所述标记该地址。 -
切勿使用真实邮件服务商上的虚构地址进行测试(
[email protected]、[email protected]):真实硬退信会计入统计,几次硬退信就可能导致创建者的域名被暂停。
安全防护
- 不得通过此 API 发送营销邮件或群发邮件——新闻邮件、促销信息、陌生开发邮件和导入的名单应使用 Portaly 管理后台的广播工具处理,该工具会管理同意状态和退订。通过此 API 发送会引发投诉,导致创作者的域名被停用,届时收据和广播邮件都无法发送。
- 只给预期会收到邮件的人发送——即用户自己的客户,且由他们的操作触发。
- 对会发送邮件的公开表单进行限流(密码重置、注册、联系表单),按地址和 IP 分别限流。任何人都可以触发这些表单,洪水式请求会耗尽配额、引发投诉并导致域名被停用。
- 绝不要在客户端暴露密钥,绝不要记录密钥,也绝不要提交密钥。
- 绝不要循环重试
SEND_QUOTA_EXCEEDED或其他4xx错误——409最多重试到设定上限(第 4 步);遵循Retry-After;重试时始终使用相同的Idempotency-Key。 - 绝不要使用虚假的收件人地址进行测试——请使用沙箱或模拟器。
- 开发期间,向真实用户发送真实邮件前必须确认(发给账户所有者或模拟器的邮件除外):说明收件人和邮件内容,并等待用户确认。
- 不要臆造字段、状态或限额。如果此处的内容与
https://portaly.ai/docs不一致,以文档为准。 - 每个密钥的每分钟速率限制:
POST /emails可发送给 60 位收件人,10 个批次,120 次读取,20 次取消或域名绑定。请分散批量任务,避免突发发送。
交付内容
- 为用户准备一份简短的设置检查清单(密钥、域名、DNS、
.env) - 使用项目技术栈编写服务端
sendEmail辅助函数,包含幂等处理和上述错误处理 - 编写一个投递跟踪任务,记录
bounced/complained地址 - 提供使用沙箱和邮箱模拟器的测试步骤
资源
references/api-contract.md— 所有端点、字段、状态和错误代码。https://portaly.ai/docs(Email 部分)和https://portaly.ai/openapi.json— 权威接口契约。../portaly-overview/SKILL.md— Portaly 的其他功能,例如同一应用的支付功能。