mcpField
P
portaly-email

portaly-email

github.com/portaly-ai其他252 次安装2 天前更新
代码仓库

关于 portaly-email

帮助创作者将 Portaly Email 的事务邮件 API 接入应用,涵盖账户与域名设置、服务端发送、重试和投递跟踪;Beta 阶段仅限受邀账户使用。

主要功能

  1. 01API 密钥与域名设置
  2. 02服务端发送事务邮件
  3. 03支持幂等键的重试
  4. 04定时与批量发送邮件
  5. 05定期跟踪投递状态

适用场景

  1. 01发送订单收据和通知
  2. 02发送验证与密码重置邮件
  3. 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 是工作副本。如有冲突,以文档为准。

快速开始

  1. 密钥 — 用户在 Portaly 管理后台创建邮件 API 密钥,并将其放入 .env 中的 PORTALY_EMAIL_API_KEY。(工作流步骤 1。)
  2. 先用沙盒测试 — 验证域名之前,发送邮件时使用 <name>@sandbox.portaly.tw。邮件仅会送达账号所有者已验证的登录邮箱,每天最多 50 封。<name> 长度为 3–32 个字符,只能包含字母、数字、.、_ 或 -,不得包含 portaly,也不得使用 noreply、no-reply、support、info 或 admin 等保留名称(400 INVALID_SENDER)——例如 orders 或 hello。这足以验证代码能否正常工作。
  3. 域名 — 用户绑定发件子域名并添加 DNS 记录,然后确认密钥包含该域名。(工作流步骤 2。)
  4. 使用服务器端代码发送邮件,并设置 Idempotency-Key。(工作流步骤 3。)
  5. 处理响应并跟踪投递情况 — 重试可重试的请求,配额不足时发出提醒,并记录退信和投诉地址。(工作流步骤 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:三个 DKIM CNAME 记录,以及一个用于 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 的其他功能,例如同一应用的支付功能。

评论

支持 Markdown