-
Notifications
You must be signed in to change notification settings - Fork 0
#256 Set Up Neon Auth Email Webhooks With Resend #261
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9f012dd
d89d1b0
21d5bd6
1adb4e0
811585e
9a095ce
bb45e6a
05b4c5b
82ce40f
6f2863f
e06d51c
72ea3c4
5825cd6
02865d8
1fa1776
8cff5df
82914a0
2d92e6b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { z } from 'zod'; | ||
|
|
||
| import { sendEmail } from '@/lib/email/resend'; | ||
| import { magicLinkEmail, otpEmail } from '@/lib/email/templates'; | ||
| import { verifyWebhookSignature } from '@/lib/email/verify-webhook'; | ||
|
|
||
| // Neon Auth webhook route — intercepts send.otp and send.magic_link events and | ||
| // delivers branded transactional emails through Resend instead of Neon's default | ||
| // sender (noreply@stackframe.co). | ||
| // | ||
| // This is the one permitted API route exception to the no-API-routes rule: it is | ||
| // auth infrastructure that must be a publicly reachable HTTP endpoint. It is NOT | ||
| // a Server Action, so the §4 { error }/throw action model does not apply — the | ||
| // contract here is plain HTTP status codes: 200 handled, 400 bad request, 500 | ||
| // unexpected failure (so Neon can retry). | ||
| // | ||
| // Static segment "webhook" takes precedence over the [...path] catch-all beside it, | ||
| // so existing auth routes (login, callback, etc.) are unaffected. | ||
|
|
||
| // Neon uses event_type (not type) as the discriminator, and nests the recipient | ||
| // under user.email and event-specific fields under event_data. | ||
| const userSchema = z.object({ email: z.string().email().optional() }); | ||
|
|
||
| const otpEventSchema = z.object({ | ||
| event_type: z.literal('send.otp'), | ||
| user: userSchema, | ||
| event_data: z.object({ | ||
| otp_code: z.string(), | ||
| expires_at: z.string().optional(), | ||
| }), | ||
| }); | ||
|
|
||
| const magicLinkEventSchema = z.object({ | ||
| event_type: z.literal('send.magic_link'), | ||
| user: userSchema, | ||
| event_data: z.object({ | ||
| link_url: z.string().url(), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. R11-L1 🟡 Low — |
||
| expires_at: z.string().optional(), | ||
| }), | ||
| }); | ||
|
|
||
| const webhookEventSchema = z.discriminatedUnion('event_type', [ | ||
| otpEventSchema, | ||
| magicLinkEventSchema, | ||
| ]); | ||
|
|
||
| const badRequest = () => new Response(null, { status: 400 }); | ||
|
|
||
| function minutesUntil(isoString: string | undefined): number | undefined { | ||
| if (!isoString) return undefined; | ||
| const mins = Math.round( | ||
| (new Date(isoString).getTime() - Date.now()) / 60_000, | ||
| ); | ||
| return mins > 0 ? mins : undefined; | ||
| } | ||
|
|
||
| export async function POST(req: Request): Promise<Response> { | ||
| // Read the raw body once — needed both for signature verification and JSON parsing. | ||
| const rawBody = await req.text(); | ||
|
|
||
| // Verify the Neon Auth signature before doing anything else. | ||
| const signatureHeader = req.headers.get('X-Neon-Signature'); | ||
| const kidHeader = req.headers.get('X-Neon-Signature-Kid'); | ||
| const timestampHeader = req.headers.get('X-Neon-Timestamp'); | ||
|
|
||
| const { valid } = await verifyWebhookSignature( | ||
| rawBody, | ||
| signatureHeader, | ||
| kidHeader, | ||
| timestampHeader, | ||
| ); | ||
| if (!valid) return badRequest(); | ||
|
|
||
| // Parse and validate the payload. | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(rawBody); | ||
| } catch { | ||
| return badRequest(); | ||
| } | ||
|
|
||
| const result = webhookEventSchema.safeParse(parsed); | ||
| if (!result.success) return badRequest(); | ||
|
|
||
| const event = result.data; | ||
| const email = event.user.email; | ||
| if (!email) return badRequest(); | ||
|
|
||
| // Dispatch by event type and send the branded email. | ||
| try { | ||
| switch (event.event_type) { | ||
| case 'send.otp': { | ||
| const template = otpEmail({ | ||
| code: event.event_data.otp_code, | ||
| expiresInMinutes: minutesUntil(event.event_data.expires_at), | ||
| }); | ||
| await sendEmail({ to: email, ...template }); | ||
| break; | ||
| } | ||
| case 'send.magic_link': { | ||
| const template = magicLinkEmail({ | ||
| url: event.event_data.link_url, | ||
| expiresInMinutes: minutesUntil(event.event_data.expires_at), | ||
| }); | ||
| await sendEmail({ to: email, ...template }); | ||
| break; | ||
| } | ||
| default: { | ||
| // TypeScript exhaustiveness guard — unreachable if zod schema is complete. | ||
| const _exhaustive: never = event; | ||
| void _exhaustive; | ||
| return badRequest(); | ||
| } | ||
| } | ||
| } catch (err) { | ||
| // Log server-side without leaking internals; return 500 so Neon retries. | ||
| // Note: a transient Resend failure + retry may cause a duplicate send for the | ||
| // same OTP/magic-link — acceptable for auth emails (they carry short-lived codes). | ||
| console.error('[webhook] Failed to send email:', err); | ||
| return new Response(null, { status: 500 }); | ||
| } | ||
|
|
||
| return new Response(null, { status: 200 }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import 'server-only'; | ||
|
|
||
| import { Resend } from 'resend'; | ||
|
|
||
| // Lazily constructed singleton — avoids construction at import time when the | ||
| // env var is absent (e.g. during build-time type-checking or non-email paths). | ||
| let _resend: Resend | null = null; | ||
|
|
||
| function getResend(): Resend { | ||
| if (!process.env.RESEND_API_KEY) | ||
| throw new Error('RESEND_API_KEY is not configured'); | ||
| if (!_resend) _resend = new Resend(process.env.RESEND_API_KEY); | ||
| return _resend; | ||
| } | ||
|
|
||
| function getSenderAddress(): string { | ||
| if (!process.env.RESEND_FROM_EMAIL) | ||
| throw new Error('RESEND_FROM_EMAIL is not configured'); | ||
| return `Aplio <${process.env.RESEND_FROM_EMAIL}>`; | ||
| } | ||
|
|
||
| export interface SendEmailParams { | ||
| to: string; | ||
| subject: string; | ||
| html: string; | ||
| text: string; | ||
| } | ||
|
|
||
| // Thin wrapper — throws on Resend errors so the webhook handler can catch and | ||
| // return 500 (Neon retries) rather than silently dropping the email. | ||
| export async function sendEmail({ | ||
| to, | ||
| subject, | ||
| html, | ||
| text, | ||
| }: SendEmailParams): Promise<void> { | ||
| const resend = getResend(); | ||
| const from = getSenderAddress(); | ||
|
|
||
| const { error } = await resend.emails.send({ from, to, subject, html, text }); | ||
| if (error) throw new Error(`Resend send failed: ${error.message}`); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| import 'server-only'; | ||
|
|
||
| // Email templates for Neon Auth webhook events. | ||
|
b-at-neu marked this conversation as resolved.
|
||
| // | ||
| // Tailwind classes are ignored by email clients, so inline styles are used here. | ||
| // Logo is served from the hosted /logo-512.svg asset; VERCEL_URL is set automatically | ||
| // in Vercel deployments and falls back to localhost:3000 for local dev. | ||
| // Red accent #D41B2C matches the Aplio logo; zinc tokens match the app design system. | ||
|
|
||
| const LOGO_URL = `https://${process.env.VERCEL_URL ?? 'localhost:3000'}/logo-512.svg`; | ||
|
|
||
| // Prevent HTML injection in user-supplied values interpolated into email markup. | ||
| function escapeHtml(s: string): string { | ||
| return s | ||
| .replace(/&/g, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, '''); | ||
| } | ||
|
|
||
| interface LayoutOptions { | ||
| title: string; | ||
| content: string; | ||
| } | ||
|
|
||
| function emailLayout({ title, content }: LayoutOptions): string { | ||
| return `<!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>${title}</title> | ||
| </head> | ||
| <body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"> | ||
| <table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 16px;"> | ||
| <tr> | ||
| <td align="center"> | ||
| <table width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;"> | ||
| <!-- Card --> | ||
| <tr> | ||
| <td> | ||
| <table width="100%" cellpadding="0" cellspacing="0"> | ||
| <!-- Branded red header --> | ||
| <tr> | ||
| <td bgcolor="#D41B2C" style="background-color:#D41B2C;padding:20px 28px;border-radius:10px 10px 0 0;"> | ||
| <table cellpadding="0" cellspacing="0"> | ||
| <tr> | ||
| <td style="vertical-align:middle;padding-right:10px;"> | ||
| <img src="${LOGO_URL}" width="34" height="34" alt="" style="display:block;border-radius:6px;" /> | ||
| </td> | ||
| <td style="vertical-align:middle;"> | ||
| <span style="font-size:17px;font-weight:700;color:#ffffff;letter-spacing:-0.01em;">Aplio</span> | ||
| </td> | ||
| </tr> | ||
| </table> | ||
| </td> | ||
| </tr> | ||
| <!-- White card body --> | ||
| <tr> | ||
| <td bgcolor="#ffffff" style="background-color:#ffffff;padding:32px;border:1px solid #e4e4e7;border-top:none;border-radius:0 0 10px 10px;"> | ||
| ${content} | ||
| </td> | ||
| </tr> | ||
| </table> | ||
| </td> | ||
| </tr> | ||
| <!-- Footer --> | ||
| <tr> | ||
| <td style="padding-top:24px;font-size:12px;color:#71717a;text-align:center;"> | ||
| You're receiving this because a sign-in was requested for your Aplio account.<br /> | ||
| Didn't request this? You can safely ignore it. | ||
| </td> | ||
| </tr> | ||
| </table> | ||
| </td> | ||
| </tr> | ||
| </table> | ||
| </body> | ||
| </html>`; | ||
| } | ||
|
|
||
| export interface EmailTemplate { | ||
| subject: string; | ||
| html: string; | ||
| text: string; | ||
| } | ||
|
|
||
| export interface OtpEmailOptions { | ||
| code: string; | ||
| expiresInMinutes?: number; | ||
| } | ||
|
|
||
| export function otpEmail({ | ||
| code, | ||
| expiresInMinutes, | ||
| }: OtpEmailOptions): EmailTemplate { | ||
| const safeCode = escapeHtml(code); | ||
| const expiryLine = expiresInMinutes | ||
| ? `This code expires in ${expiresInMinutes} minute${expiresInMinutes === 1 ? '' : 's'}.` | ||
| : ''; | ||
|
|
||
| const content = ` | ||
| <h1 style="margin:0 0 8px;font-size:20px;font-weight:700;color:#09090b;">Your access code</h1> | ||
| <p style="margin:0 0 24px;font-size:14px;color:#71717a;">Enter this code to sign in to your Aplio account. It is single-use and will expire shortly.</p> | ||
| <table width="100%" cellpadding="0" cellspacing="0" style="border:2px solid #D41B2C;border-radius:6px;margin-bottom:24px;"> | ||
| <tr> | ||
| <td style="padding:20px;text-align:center;"> | ||
| <span style="font-size:36px;font-weight:700;letter-spacing:0.2em;color:#09090b;font-family:'Courier New',monospace;">${safeCode}</span> | ||
| </td> | ||
| </tr> | ||
| </table> | ||
| ${expiryLine ? `<p style="margin:0;font-size:13px;color:#71717a;">${expiryLine}</p>` : ''} | ||
| `; | ||
|
|
||
| const textLines = [ | ||
| 'Your Aplio access code', | ||
| '', | ||
| `Code: ${code}`, | ||
| ...(expiryLine ? [expiryLine] : []), | ||
| '', | ||
| 'If you did not request this code, you can ignore this email.', | ||
| ]; | ||
|
|
||
| return { | ||
| subject: 'Your Aplio access code', | ||
| html: emailLayout({ title: 'Your Aplio access code', content }), | ||
| text: textLines.join('\n'), | ||
| }; | ||
| } | ||
|
|
||
| export interface MagicLinkEmailOptions { | ||
| url: string; | ||
| expiresInMinutes?: number; | ||
| } | ||
|
|
||
| export function magicLinkEmail({ | ||
| url, | ||
| expiresInMinutes, | ||
| }: MagicLinkEmailOptions): EmailTemplate { | ||
| const safeUrl = escapeHtml(url); | ||
| const expiryLine = expiresInMinutes | ||
| ? `This link expires in ${expiresInMinutes} minute${expiresInMinutes === 1 ? '' : 's'}.` | ||
| : ''; | ||
|
|
||
| const content = ` | ||
| <h1 style="margin:0 0 8px;font-size:20px;font-weight:700;color:#09090b;">Your sign-in link</h1> | ||
| <p style="margin:0 0 24px;font-size:14px;color:#71717a;">Use the button below to sign in to your Aplio account — no password needed.</p> | ||
| <div style="text-align:center;margin-bottom:24px;"> | ||
| <a href="${safeUrl}" style="display:inline-block;background-color:#D41B2C;color:#ffffff;text-decoration:none;padding:12px 32px;border-radius:6px;font-size:14px;font-weight:600;letter-spacing:0.01em;">Sign in to Aplio</a> | ||
| </div> | ||
| ${expiryLine ? `<p style="margin:0 0 16px;font-size:13px;color:#71717a;">${expiryLine}</p>` : ''} | ||
| <p style="margin:0;font-size:12px;color:#71717a;">Or copy this link into your browser:<br /><span style="color:#09090b;word-break:break-all;">${safeUrl}</span></p> | ||
| `; | ||
|
|
||
| const textLines = [ | ||
| 'Sign in to Aplio', | ||
| '', | ||
| `Open this link to sign in: ${url}`, | ||
| ...(expiryLine ? [expiryLine] : []), | ||
| '', | ||
| 'If you did not request this link, you can ignore this email.', | ||
| ]; | ||
|
|
||
| return { | ||
| subject: 'Sign in to Aplio', | ||
| html: emailLayout({ title: 'Sign in to Aplio', content }), | ||
| text: textLines.join('\n'), | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
R12-N1 ⚪ Nit — The plan and PR description both list
lib/types.tsas a change addingNeonAuthWebhookOtpEvent,NeonAuthWebhookMagicLinkEvent, andNeonAuthWebhookEventdiscriminated union types, butlib/types.tsis not in the diff and its file size is identical todev. The route works correctly with its local zod schemas — no functional gap — but the PR description's Changes section inaccurately describes what was shipped. Either add the types or update the description.