From 43213f080a96cd3148949b622ae95b0584a92ed3 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 17:28:59 +0800 Subject: [PATCH] feat(identity): add resend otp delivery adapter --- .../identity/email/fake-email-otp-sender.ts | 12 ++ .../identity/email/resend-email-otp-sender.ts | 73 +++++++++++ frontend/tests/identity-email-sender.test.ts | 113 ++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 frontend/src/modules/identity/email/fake-email-otp-sender.ts create mode 100644 frontend/src/modules/identity/email/resend-email-otp-sender.ts create mode 100644 frontend/tests/identity-email-sender.test.ts diff --git a/frontend/src/modules/identity/email/fake-email-otp-sender.ts b/frontend/src/modules/identity/email/fake-email-otp-sender.ts new file mode 100644 index 00000000..1923f001 --- /dev/null +++ b/frontend/src/modules/identity/email/fake-email-otp-sender.ts @@ -0,0 +1,12 @@ +import type { + EmailOtpMessage, + EmailOtpSender, +} from "../contracts.ts"; + +export class FakeEmailOtpSender implements EmailOtpSender { + readonly messages: EmailOtpMessage[] = []; + + async send(message: EmailOtpMessage): Promise { + this.messages.push({ ...message }); + } +} diff --git a/frontend/src/modules/identity/email/resend-email-otp-sender.ts b/frontend/src/modules/identity/email/resend-email-otp-sender.ts new file mode 100644 index 00000000..772b4b66 --- /dev/null +++ b/frontend/src/modules/identity/email/resend-email-otp-sender.ts @@ -0,0 +1,73 @@ +import type { + EmailOtpMessage, + EmailOtpSender, + EmailOtpType, +} from "../contracts.ts"; + +const resendEndpoint = "https://api.resend.com/emails"; +const safeDeliveryError = "OTP email delivery failed"; + +const subjectByType: Record = { + "sign-in": "Your Jyotisha sign-in code", + "email-verification": "Verify your Jyotisha email", + "forget-password": "Reset your Jyotisha password", +}; + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + const entities: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return entities[character]; + }); +} + +export interface ResendEmailOtpSenderOptions { + apiKey: string; + from: string; + fetchImpl?: typeof fetch; +} + +export class ResendEmailOtpSender implements EmailOtpSender { + private readonly apiKey: string; + private readonly from: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: ResendEmailOtpSenderOptions) { + this.apiKey = options.apiKey; + this.from = options.from; + this.fetchImpl = options.fetchImpl ?? fetch; + } + + async send(message: EmailOtpMessage): Promise { + const escapedOtp = escapeHtml(message.otp); + const subject = subjectByType[message.type]; + + try { + const response = await this.fetchImpl(resendEndpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + "Idempotency-Key": message.idempotencyKey, + "User-Agent": "jyotisha-identity/1.0", + }, + body: JSON.stringify({ + from: this.from, + to: [message.email], + subject, + text: `${subject}: ${message.otp}. This code expires in five minutes.`, + html: `

${escapeHtml(subject)}

${escapedOtp}

This code expires in five minutes.

`, + }), + }); + + if (!response.ok) throw new Error(safeDeliveryError); + } catch { + throw new Error(safeDeliveryError); + } + } +} diff --git a/frontend/tests/identity-email-sender.test.ts b/frontend/tests/identity-email-sender.test.ts new file mode 100644 index 00000000..0a44f0e7 --- /dev/null +++ b/frontend/tests/identity-email-sender.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts"; +import { ResendEmailOtpSender } from "../src/modules/identity/email/resend-email-otp-sender.ts"; +import type { EmailOtpMessage } from "../src/modules/identity/contracts.ts"; + +const message: EmailOtpMessage = { + email: "person@example.com", + otp: "123456", + type: "sign-in", + idempotencyKey: "otp-request-018f4e6d", +}; + +test("fake OTP sender records messages without network access", async () => { + const sender = new FakeEmailOtpSender(); + + await sender.send(message); + + assert.deepEqual(sender.messages, [message]); + assert.notEqual(sender.messages[0], message); +}); + +test("Resend OTP sender emits an idempotent authenticated request", async () => { + const requests: Array<{ input: string | URL | Request; init?: RequestInit }> = []; + const fetchImpl: typeof fetch = async (input, init) => { + requests.push({ input, init }); + return Response.json({ id: "email_123" }, { status: 200 }); + }; + const sender = new ResendEmailOtpSender({ + apiKey: "re_test_secret_value", + from: "Jyotisha ", + fetchImpl, + }); + + await sender.send(message); + + assert.equal(requests.length, 1); + assert.equal(requests[0].input, "https://api.resend.com/emails"); + assert.equal(requests[0].init?.method, "POST"); + const headers = new Headers(requests[0].init?.headers); + assert.equal(headers.get("authorization"), "Bearer re_test_secret_value"); + assert.equal(headers.get("content-type"), "application/json"); + assert.equal(headers.get("idempotency-key"), message.idempotencyKey); + assert.equal(headers.get("user-agent"), "jyotisha-identity/1.0"); + + const body = JSON.parse(String(requests[0].init?.body)) as Record< + string, + unknown + >; + assert.equal(body.from, "Jyotisha "); + assert.deepEqual(body.to, [message.email]); + assert.equal(body.subject, "Your Jyotisha sign-in code"); + assert.match(String(body.text), /123456/); + assert.match(String(body.html), /123456/); +}); + +test("Resend OTP sender escapes template values", async () => { + let body = ""; + const sender = new ResendEmailOtpSender({ + apiKey: "re_test_secret_value", + from: "Jyotisha ", + fetchImpl: async (_input, init) => { + body = String(init?.body); + return Response.json({ id: "email_123" }); + }, + }); + + await sender.send({ ...message, otp: "" }); + + const parsed = JSON.parse(body) as { html: string }; + assert.doesNotMatch(parsed.html, /