← 返回博客

团队邀请邮件链接(2026):用 Rovela 拉人进协作

在应用侧生成一次性邀请令牌并存储哈希,再用 Rovela POST /emails 发出接受链接——模式与密码重置相同;安全邮件建议关闭点击追踪,正文需含完整 URL。

更新于 2026年11月4日
团队邀请邮件链接(2026):用 Rovela 拉人进协作

为什么多用户应用需要邀请链接

如果你在做 AI 产品、协作空间,或任何需要多人共用一个项目的 SaaS,迟早会碰到团队邀请:管理员输入邮箱,应用发出一次性接受链接,受邀人落到你自己的域名,核销后写入成员关系(以及角色)。

Rovela 没有单独的「团队邀请邮件」接口或产品功能。文档里提到的「接受邀请」,指的是 Rovela 控制台组织成员——把同事拉进你的 Rovela 组织,而不是替你的终端用户发产品内邀请。产品邀请由你保管 token 与接受路由;Rovela 只通过 POST /emails 负责投递。

最接近的官方示例是 密码重置链接:32 字节随机数、存哈希、短过期、一次性核销,同时发 html 与带完整 URL 的 text。魔法登录邮件链接 是同一套发送形态、不同落地页。团队邀请只是多存了 team id、role 和可选的 inviter——仍然一次 HTTP 发送。

你负责什么,Rovela 负责什么

应用侧必须自己做:

  • 邀请记录:邮箱、团队/工作区 id、角色、邀请人、状态(pending / accepted / revoked)
  • 密码学安全的 token;只存哈希;过期时间;在你自己的源站一次性核销
  • 绝对接受 URL(如 https://app.example.com/invites/accept?token=…)
  • 接受时的登录/注册,以及成员关系写入
  • 重发 / 撤销 / 限流;只在服务端调用发信 API

Rovela 负责:

  • 接受带 Bearer re_… 的 POST https://api.rovela.dev/emails(生产发信用 sending_access 即可)
  • 入队、已验证域名的 DKIM、重试、退信/投诉处理与消息日志
  • 可选模板、Webhook、抑制列表——与其他事务邮件同一套基础设施

不要把 API 密钥放进浏览器或移动客户端。套餐与额度见 定价页;本文不杜撰任何价格数字。

Token 与存储(哈希、团队/角色、过期)

沿用 密码重置示例 的熵要求:

  1. 规范化受邀邮箱(trim + 小写)。
  2. 校验邀请人权限(必须有权向该团队加人)。
  3. 生成 32 字节随机数,编码为 base64url。
  4. 只存 token 的 SHA-256 十六进制哈希,绝不存明文。32 字节无法从哈希暴力还原,因此普通 SHA-256 足够(六位 OTP 不同:短数字需要带 pepper 的 HMAC——见 验证码)。
  5. 持久化 token_hash、team_id、role、email、invited_by、expires_at(邀请常见 7 天,按产品自定)、status=pending。
  6. 拼出 https://app.acme.com/invites/accept?token= + 明文 token。token 只出现在邮件里。

同一邮箱+团队的新邀请应作废或覆盖上一条 pending,避免两条有效链接对角色说法不一致。

与密码重置或魔法登录不同,邀请流程通常会暴露「已为该地址创建邀请」(管理员刚输入过)。但仍避免在受邀人点击后的错误页里泄露「该邮箱是否已有 Acme 账号」。

用 POST /emails 发出

用已验证的子域发件地址隔离事务/认证信誉(例如 invites@mail.acme.com 或 security@mail.acme.com)。同时发 html 和 text,并在纯文本里写上完整 URL——部分企业过滤器会剥掉 HTML 链接。

为每次逻辑发送设置一次 Idempotency-Key(≤256 字符,24 小时窗口,按组织生效)。可用 token 哈希的稳定前缀派生,避免超时重试发出第二封。管理员点「重发邀请」是新的逻辑发送:换新 token、新 key。

以下草图改编自密码重置文档(仅占位):

import crypto from "node:crypto"

export async function createTeamInvite({ teamId, email, role, invitedBy }) {
  const normalized = email.trim().toLowerCase()
  const token = crypto.randomBytes(32).toString("base64url")
  const tokenHash = crypto.createHash("sha256").update(token).digest("hex")

  await db.teamInvites.insert({
    teamId,
    email: normalized,
    role,
    invitedBy,
    tokenHash,
    status: "pending",
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
  })

  const link = "https://app.acme.com/invites/accept?token=" + token

  const res = await fetch("https://api.rovela.dev/emails", {
    method: "POST",
    headers: {
      Authorization: "Bearer " + process.env.ROVELA_API_KEY,
      "Content-Type": "application/json",
      "Idempotency-Key": "invite-" + tokenHash.slice(0, 32),
    },
    body: JSON.stringify({
      from: "Acme <invites@mail.acme.com>",
      to: normalized,
      subject: "邀请你加入 Acme 团队",
      html:
        "<p>你被邀请在 Acme 上协作。</p>" +
        '<p><a href="' + link + '">接受邀请</a></p>' +
        "<p>链接仅可使用一次,7 天后失效。" +
        "若并非你本人,可忽略本邮件。</p>",
      text:
        "你被邀请在 Acme 上协作。\n\n" +
        "接受邀请: " + link + "\n\n" +
        "链接仅可使用一次,7 天后失效。",
    }),
  })

  // 200 = 已接受并入队,不等于已送达收件箱
  return res.json()
}

返回 200 且 body 为 {"object":"email","id":"…"} 表示 Rovela 已入队。需要退信/投诉信号时接 Webhook。字段说明见 发信 API。也可以用 模板 的 {{invite_url}}——非 URL 的用户字段要自行转义;模板变量不会自动做 HTML 转义。

一次性接受(会话 + 成员关系)

在一步数据库操作里花掉 token,避免并行点击都成功:

export async function acceptTeamInvite(token, currentUserId) {
  const tokenHash = crypto.createHash("sha256").update(token).digest("hex")

  const row = await db.query(
    `UPDATE team_invites
     SET status = 'accepted', accepted_at = NOW()
     WHERE token_hash = $1
       AND status = 'pending'
       AND expires_at > NOW()
     RETURNING team_id, role, email`,
    [tokenHash]
  )

  if (row.rowCount === 0) return null

  const invite = row.rows[0]
  // 把成员关系绑到已登录用户;如需可强制邮箱一致
  await db.memberships.upsert({
    teamId: invite.team_id,
    userId: currentUserId,
    role: invite.role,
  })

  return invite
}

接受页常见产品规则:

  • 未登录先走注册 / 魔法登录 / 密码登录,再核销。
  • 可选:要求 currentUser.email 与 invite.email 一致。
  • 成功后跳进团队;尽快去掉地址栏里的原始 token(核销后无 query 重定向)。

这是你的成员模型,与把某人拉进 Rovela 控制台组织无关。

关闭追踪 + 纯文本兜底

打开/点击追踪是按域名配置的。开启点击追踪时,Rovela 会把链接改写到追踪主机。邀请 URL 经跳转后仍可用,但 token 多了一跳。

安全类邮件——邀请、密码重置、魔法登录——建议用单独发信域名或子域并关闭点击追踪,让 token 直达你的应用。该域名上不会产生打开/点击类互动 Webhook,这是预期行为。

务必保留带完整 URL 的纯文本。只有 HTML 时,剥链接的过滤器会让用户无路可点。

重发、撤销与抑制

  • 重发: 作废上一条 pending(或标记 superseded),重新生成 32 字节 token、新的 Idempotency-Key 再发。按团队+邮箱限流。
  • 撤销: 设 status=revoked(或删除哈希)。核销必须检查状态,使转发的旧链接失败关闭。
  • 接受前改角色: 撤销后重邀,或更新 pending 行并重发,保证邮件文案与将授予的角色一致。
  • 收件人被抑制: 单次 POST /emails 时,验证码文档 写明消息以 recipient is suppressed 开头的 400。按不可投递处理——让管理员换地址,不要对受邀人说「请查收件箱」。
  • 未验证的 from 域名: 400 并指出域名——先完成 域名验证。
  • 429: 退避;同一逻辑发送用同一 Idempotency-Key 重试。有意重发 = 新 token + 新 key。

邀请邮件避免 cc/bcc(泄露 token)。优先每次只发一个收件人。

相关模式

流程谁保管密钥文档 / 文章
团队邀请链接你的应用(token + 团队/角色)本文;经 POST /emails 发送
魔法登录你的应用(token → 会话)魔法登录邮件链接
密码重置你的应用(token → 新密码)密码重置链接
邮箱 OTP你的应用(验证码哈希)验证码
Rovela 组织邀请Rovela 控制台会话Authentication —— 不是终端用户邀请 API

自己维护邀请表,一次 HTTP 发出邮件,在安全域名上关闭追踪——就能拉人进协作,而不把邮件 API 当成身份或目录产品。