Remix 魔法登录链接(2026):用 HTTP 邮件 API 发信
在 Remix 里做无密码魔法登录:crypto token 哈希落库、路由 action + loader、仅在 POST redeem 写 cookie session、fetch 调 POST /emails 并带 Idempotency-Key——Rovela 只负责投递。
为什么用 Remix + 纯 HTTP 邮件 API
Remix(React Router 框架)做无密码登录本来就很顺:一个 POST 到路由 action 的表单、一个负责发 token 并 upsert 行的服务端模块,以及一个 loader 读取 ?token=、另用 action 核销并写入 session cookie 的验证路由。你不需要 Rovela 的 Remix 插件,也不需要产品侧专门的魔法链接接口,更不必只为发信再套一层 Cloudflare Worker。
你需要的是和 密码重置链接 同一套 token 配方:应用用 crypto.randomBytes 生成一次性 token,只存 SHA-256,明文只出现在邮件 URL 里,再用 fetch POST /emails 投递。Rovela 负责排队与 DKIM 签名;它不会签发 session,也不会校验 token。
Rovela 不提供魔法链接功能。 密码学、数据库行(或邻近存储)、过期、单次核销、session cookie 都归你的 Remix 应用。Rovela 只负责投递。
本文聚焦 Remix / TypeScript(路由 action + loader、createCookieSessionStorage、Node crypto、服务端 fetch 调 Rovela)。它不是 Next.js 魔法登录(App Router Route Handlers)的复读,也不是 Cloudflare Workers 发事务邮件(没有 Remix loader 的边缘 fetch)。Nest 见 NestJS 魔法登录;FastAPI 见 FastAPI 魔法登录;Rails 见 Rails 魔法登录。与框架无关的配方见 魔法登录邮件链接;验证码 vs 链接选型见 邮箱验证:验证码还是链接。
你负责什么,Rovela 发什么
你的 Remix 应用负责:
crypto.randomBytes(32).toString("base64url")作为明文 token- 数据库只存 token 的 SHA-256 十六进制(永不存明文)
- 过期时间(文档示例对重置类链接约 30 分钟)
- 你自己源站上的绝对 URL:
https://app.example.com/auth/magic?token=… - 原子核销(删除并返回)+ 用 Remix session storage 写 cookie
- 不论邮箱是否注册,action 对外响应保持一致
- 对 request / redeem action 做限流
Rovela 负责:
POST https://api.rovela.dev/emails,Authorization: Bearer+ 具备sending_access的密钥(ROVELA_API_KEY放服务端环境变量——不要提交进仓库,也不要放进会打进浏览器的 Vite 公开环境变量)- 排队、签名、重试、退信/投诉处理
发送接口返回 200 只表示已接受并入队,不是「已进收件箱」。
环境变量与精简 Rovela 客户端(fetch)
密钥放在托管 Remix 的 Node/Bun 进程环境里——不要放进会下发到浏览器的 import.meta.env:
// app/lib/env.server.ts
export function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`missing_env:${name}`);
return v;
}
// ROVELA_API_KEY=re_... // sending_access
// MAGIC_FROM=Acme <security@mail.acme.com>
// MAGIC_LINK_BASE_URL=https://app.example.com
// MAGIC_TTL_MINUTES=30
// SESSION_SECRET=long-random-string
// app/lib/rovela.server.ts
import { requireEnv } from "./env.server";
export async function sendEmail(opts: {
to: string;
subject: string;
html: string;
text: string;
idempotencyKey: string;
}) {
const apiKey = requireEnv("ROVELA_API_KEY"); // sending_access
const from = process.env.MAGIC_FROM ?? "Acme <security@mail.acme.com>";
const res = await fetch("https://api.rovela.dev/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": opts.idempotencyKey.slice(0, 256),
},
body: JSON.stringify({
from,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
}),
});
if (!res.ok) {
// 不要打印 API 密钥。
console.error("rovela_send_failed", res.status);
throw new Response("send_failed", { status: 502 });
}
return (await res.json()) as { object: string; id: string };
}
鉴权见 Authentication,报文见 API: emails,首次发送见 Quick Start。
Session cookie(Remix)
// app/sessions.server.ts
import { createCookieSessionStorage } from "@remix-run/node"; // 或 react-router/node
import { requireEnv } from "./lib/env.server";
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__acme_session",
httpOnly: true,
path: "/",
sameSite: "lax",
secrets: [requireEnv("SESSION_SECRET")],
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 14,
},
});
export async function getSession(request: Request) {
return sessionStorage.getSession(request.headers.get("Cookie"));
}
export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
return sessionStorage.commitSession(session);
}
Token 助手:只存哈希,短 TTL
// app/auth/magic-token.server.ts
import crypto from "node:crypto";
export function createMagicToken(ttlMinutes = 30) {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000);
return { token, tokenHash, expiresAt };
}
export function hashToken(token: string) {
return crypto.createHash("sha256").update(token).digest("hex");
}
按用户 upsert,使只有最新未用链接生效。高熵 URL-safe token 用纯 SHA-256 即可(六位 OTP 不同,需要 HMAC + pepper——见 验证码示例)。
Service:创建、落库、发送
// app/auth/magic.server.ts
import { createMagicToken, hashToken } from "./magic-token.server";
import { sendEmail } from "../lib/rovela.server";
import { requireEnv } from "../lib/env.server";
// import { db } from "../db.server"; // 你的 Prisma / Drizzle / SQL
const SAFE_MSG =
"If that address has an account, a sign-in link is on its way.";
export async function requestMagicLink(rawEmail: string) {
const email = rawEmail.trim().toLowerCase();
const user = await findUserByEmail(email); // 你的存储
if (user) {
const ttl = Number(process.env.MAGIC_TTL_MINUTES ?? "30");
const { token, tokenHash, expiresAt } = createMagicToken(ttl);
await upsertMagicLogin(user.id, tokenHash, expiresAt);
const base = requireEnv("MAGIC_LINK_BASE_URL").replace(/\/$/, "");
const link = `${base}/auth/magic?token=${token}`;
await sendEmail({
to: email,
subject: "Your Acme sign-in link",
html:
`<p>Use this link to sign in to Acme.</p>` +
`<p><a href="${link}">Sign in</a></p>` +
`<p>The link works once and expires in 30 minutes. ` +
`If you did not request it, ignore this email.</p>`,
text:
`Use this link to sign in to Acme.\n\n` +
`Sign in: ${link}\n\n` +
`The link works once and expires in 30 minutes.`,
idempotencyKey: `magic-${tokenHash.slice(0, 32)}`,
});
}
// 不论账号是否存在,返回相同文案。
return { message: SAFE_MSG };
}
export async function redeemMagicToken(token: string) {
if (!token || token.length < 16) return null;
const tokenHash = hashToken(token);
// 优先 DELETE … WHERE token_hash = $1 AND expires_at > NOW() RETURNING user_id
// 避免两次并行点击都成功。
return await consumeMagicLogin(tokenHash); // 你的原子删除并返回
}
// 占位——换成你的数据库:
declare function findUserByEmail(email: string): Promise<{ id: string } | null>;
declare function upsertMagicLogin(
userId: string,
tokenHash: string,
expiresAt: Date,
): Promise<void>;
declare function consumeMagicLogin(tokenHash: string): Promise<string | null>;
务必同时发 html 与 text,并在纯文本里带完整 URL。鉴权邮件用已验证子域(如 security@mail.…)。Idempotency-Key 由 token 哈希派生,超时重试不会再入队一封;真正「重发」应换新 token、新 key。
路由 action:请求链接
// app/routes/auth.magic.request.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { requestMagicLink } from "../auth/magic.server";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
throw new Response("method_not_allowed", { status: 405 });
}
// 同时按邮箱 + IP 限流(Redis / Upstash / 你的限流器)。
const form = await request.formData();
const email = String(form.get("email") ?? "");
if (!email.includes("@")) {
return json({ error: "invalid_email" }, { status: 400 });
}
const payload = await requestMagicLink(email);
return json(payload);
}
export default function MagicRequestPage() {
const data = useActionData<typeof action>();
const nav = useNavigation();
const busy = nav.state !== "idle";
return (
<Form method="post">
<label>
Work email
<input name="email" type="email" required autoComplete="email" />
</label>
<button type="submit" disabled={busy}>
Email me a sign-in link
</button>
{data && "message" in data ? <p>{data.message}</p> : null}
{data && "error" in data ? <p>{String(data.error)}</p> : null}
</Form>
);
}
Remix 在服务端跑这个 action,Rovela 密钥不会离开该进程。优先用可渐进增强的 <Form method="post">,无客户端 JS 也能用;若需要 JSON,仍可对同一 action 发 fetch。
验证路由:loader 展示 token,action 核销(POST)
优先用 POST redeem,不要在 GET 上直接建 session:邮件扫描器和预取器常会打 GET。
// app/routes/auth.magic.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useLoaderData, useActionData } from "@remix-run/react";
import { redeemMagicToken } from "../auth/magic.server";
import { commitSession, getSession } from "../sessions.server";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const token = url.searchParams.get("token") ?? "";
// 不要在这里建 session——扫描器会预取 GET。
return json({ token });
}
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const token = String(form.get("token") ?? "");
const userId = await redeemMagicToken(token);
if (!userId) {
return json({ error: "expired_or_used" }, { status: 400 });
}
const session = await getSession(request);
session.set("userId", userId);
return redirect("/app", {
headers: { "Set-Cookie": await commitSession(session) },
});
}
export default function MagicVerifyPage() {
const { token } = useLoaderData<typeof loader>();
const data = useActionData<typeof action>();
return (
<div>
<h1>Continue sign-in</h1>
<p>Click once to finish signing in. This step stops email scanners from burning the link.</p>
<Form method="post">
<input type="hidden" name="token" value={token} />
<button type="submit" disabled={!token}>
Continue
</button>
</Form>
{data && "error" in data ? <p>This link expired or was already used.</p> : null}
</div>
);
}
若必须提供不带按钮的 GET 落地页,仍把 GET 当展示,等用户明确操作后再签发 session(如上表单)。
若是邮箱归属确认(不是登录),同一 service 形状即可,换表与路径——见 邮箱验证链接。密码重置是文档里的孪生场景:安全的密码重置邮件 与 密码重置示例。
登录 URL 请关闭点击追踪
打开/点击追踪是按域名配置的。开启点击追踪时,Rovela 会把链接改写到追踪域名。登录在跳转后仍可用,但 token 多了一跳。
对魔法链接(以及密码重置 / 邀请 token),建议单独的发送域或子域,并关闭点击追踪,让 token 直达你的 Remix 验证路由。这与 密码重置示例 的建议一致。若鉴权邮件仍进垃圾箱,按 OTP / 魔法链接进垃圾箱 排查。
限流 / 单次 / 过期清单
- 发放限流 — 例如同一邮箱每分钟一条新链接;request action 同时按 IP 封顶。
- 短 TTL — 登录约 15–30 分钟;二次验证可以更短。
- 落库只存哈希 — 仅 SHA-256 十六进制;明文只在邮件 URL。
- Upsert — 新请求作废该用户上一条未用哈希。
- 原子核销 —
DELETE … RETURNING(或加锁删除),双击竞争安全失败。 - 防枚举 request — 不论地址是否存在,状态码与正文一致。
- Idempotency-Key — 每次逻辑发送稳定(
magic-+ 哈希前缀);新 token → 新 key。 - 防预取 redeem — loader 可读取
?token=;只有 POST action 才建 session。
curl 等价(代理 / 冒烟)
与 Remix 服务端发出的报文相同——便于本地冒烟,也方便直接调 HTTP 的 AI agent(AI agent 发邮件):
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer re_your_sending_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: magic-smoke-2026-11-05" \
-d '{
"from": "Acme <security@mail.acme.com>",
"to": "alice@example.com",
"subject": "Your Acme sign-in link",
"html": "<p><a href=\"https://app.acme.com/auth/magic?token=TEST\">Sign in</a></p><p>Expires in 30 minutes.</p>",
"text": "Sign in: https://app.acme.com/auth/magic?token=TEST\n\nExpires in 30 minutes."
}'
仅在非生产环境把 TEST 换成真实 token。生产密钥需具备 sending_access;from 必须落在已验证域名上(Quick Start)。
Remix vs Next.js vs Workers(避免混淆)
| 本文(Remix) | Next.js 魔法登录 | Workers 事务邮件 | |
|---|---|---|---|
| 服务端入口 | 路由 action / loader | App Router Route Handlers | Worker fetch handler |
| Session | Remix cookie session storage | Next cookies / 你的鉴权库 | 通常在源站或 KV——不是 Remix session |
| UI 表单 | 渐进增强 <Form method="post"> | Server Actions / 客户端 fetch | 可选的 Worker HTTP API |
| 重点 | Remix 无密码登录 | Next 无密码登录 | 通用边缘发送(OTP/收据等) |
三者都调同一个 POST /emails。不要把 Remix 魔法登录路由和 Cloudflare Email Workers 混为一谈。
同一 Remix 应用里:魔法链接 vs OTP
| 魔法链接 | OTP(六位数字) | |
|---|---|---|
| 文档形状 | 同 密码重置 | 验证码 |
| Remix 位置 | /auth/magic + /auth/magic/request | 独立 OTP 路由 / action |
| 体验 | 一点即登(+ Continue) | 输入数字 |
| 存储 | 高熵 token 的 SHA-256 | email:code 的 HMAC + pepper |
| 常见 TTL | ~30 分钟 | ~10 分钟 |
| 适合 | 桌面/手机浏览器登录 | 共享屏幕、二次验证 |
两者都调同一个 POST /emails。表要分开——不要把 OTP 行当魔法链接 token 用。产品选型:验证码 vs 链接。
上线清单
- 域名已验证;鉴权子域 SPF/DKIM/DMARC 正常。
- 该子域(或独立安全域)关闭点击追踪。
- 仅具备
sending_access的ROVELA_API_KEY放在服务端密钥——不要进 Vite 公开环境变量。 MAGIC_FROM用已验证子域;html + text 都有;纯文本含完整 URL。- 每次逻辑发送带
Idempotency-Key;redeem 为删除并返回。 - request 防枚举;request(与 redeem)限流。
- 把发送
200当作已入队;若需投递真相,用 webhook 看退信/投诉(Webhooks)。
本文不是什么
- 不是完整生产级鉴权框架(OAuth2、Remix Auth 深潜、MFA)。
- 不是 Next.js、NestJS、FastAPI、Rails 或 Workers 的重复。
- 不是声称 Rovela 会存储、校验或过期你的 token。
- 不是国内 ESP 对比,也不捏造产品功能。
下一步
- 按 密码重置示例 实现 token + Remix action。
- 用 POST /emails · Quick Start · Authentication 发送。
- 投递加固:OTP / 魔法链接进垃圾箱。
- UX 选型:验证码 vs 链接。
- 姊妹栈:Next.js · NestJS · FastAPI · Rails。
- 额度:Pricing。
一句话: 把魔法登录放进 Remix 的路由 action:用 crypto.randomBytes 生成 token 并哈希,只在 POST redeem 时写入 cookie session,再对 Rovela fetch 一次 POST /emails——安全域关闭点击追踪,也永远不要把邮件 API 当成身份提供方。