From bf4075233dc27bf85bd894ae2404837f7a161615 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 7 Jul 2026 20:12:56 +0200 Subject: [PATCH] Add Resend mailer as primary with SMTP fallback for reliable email delivery --- package.json | 1 + pnpm-lock.yaml | 40 +++++++++++++++++++++++++++++++++ src/env.ts | 2 ++ src/lib/services/email.ts | 47 ++++++++++++++++++++++++++------------- 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index aa509bee..dfd8d339 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "otplib": "^12.0.1", "react": "^19.2.0", "react-dom": "^19.2.0", + "resend": "^6.17.1", "sanitize-html": "^2.17.5", "zod": "^3.24.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c906643f..7a94824b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.7(react@19.2.7) + resend: + specifier: ^6.17.1 + version: 6.17.1 sanitize-html: specifier: ^2.17.5 version: 2.17.5 @@ -1381,6 +1384,9 @@ packages: '@schummar/icu-type-parser@1.21.5': resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1946,6 +1952,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} @@ -2293,6 +2302,9 @@ packages: po-parser@2.1.1: resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==} + postal-mime@2.7.4: + resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -2369,6 +2381,15 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resend@6.17.1: + resolution: {integrity: sha512-QhHHIRPPM9Tq2uilWmNF8C8kd6nLkUmiFgDQCt1v4eR4o+6p86qrK+Vn12jnAs0jR8Gf6ABdxI18/90LGQ7GVA==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2452,6 +2473,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -3415,6 +3439,8 @@ snapshots: '@schummar/icu-type-parser@1.21.5': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/core-darwin-arm64@1.15.43': @@ -3915,6 +3941,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.2: {} foreground-child@3.3.1: @@ -4225,6 +4253,8 @@ snapshots: po-parser@2.1.1: {} + postal-mime@2.7.4: {} + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -4311,6 +4341,11 @@ snapshots: require-from-string@2.0.2: {} + resend@6.17.1: + dependencies: + postal-mime: 2.7.4 + standardwebhooks: 1.0.0 + resolve-pkg-maps@1.0.0: {} retry@0.12.0: {} @@ -4429,6 +4464,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + std-env@3.10.0: {} string_decoder@1.1.1: diff --git a/src/env.ts b/src/env.ts index 92167e2a..a08be36b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -11,6 +11,8 @@ const schema = z.object({ DATABASE_CONNECT_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), HOTEL_NAME: z.string().default("Atom"), APP_URL: z.string().url().default("http://localhost:3000"), + // Resend API key (preferred — simpler HTTP API, always works). + RESEND_API_KEY: z.string().optional(), // SMTP (password reset / notifications). Email features no-op if unset. SMTP_HOST: z.string().optional(), SMTP_PORT: z.coerce.number().int().positive().optional(), diff --git a/src/lib/services/email.ts b/src/lib/services/email.ts index 037489bd..08dc72aa 100644 --- a/src/lib/services/email.ts +++ b/src/lib/services/email.ts @@ -1,7 +1,9 @@ import nodemailer, { type Transporter } from "nodemailer"; +import { Resend } from "resend"; import { env } from "@/env"; let transporter: Transporter | null = null; +let resend: Resend | null = null; function getTransport(): Transporter | null { if (!env.SMTP_HOST) return null; @@ -16,23 +18,38 @@ function getTransport(): Transporter | null { return transporter; } -/** Send an HTML email. No-ops (returns false) when SMTP isn't configured. */ +function getResend(): Resend | null { + if (!env.RESEND_API_KEY) return null; + if (!resend) resend = new Resend(env.RESEND_API_KEY); + return resend; +} + +/** Send an HTML email. Tries Resend first, then SMTP. Returns false if both are unconfigured. */ export async function sendMail(to: string, subject: string, html: string): Promise { + const from = env.SMTP_FROM ?? `no-reply@${env.HOTEL_NAME}`; + + const r = getResend(); + if (r) { + try { + await r.emails.send({ from, to, subject, html }); + return true; + } catch (e) { + console.error("[email] Resend failed:", (e as Error).message); + } + } + const t = getTransport(); - if (!t) { - console.warn("[email] SMTP not configured — skipping mail to", to); - return false; + if (t) { + try { + await t.sendMail({ from, to, subject, html }); + return true; + } catch (e) { + console.error("[email] SMTP failed:", (e as Error).message); + } } - try { - await t.sendMail({ - from: env.SMTP_FROM ?? `no-reply@${env.HOTEL_NAME}`, - to, - subject, - html, - }); - return true; - } catch (e) { - console.error("[email] send failed:", (e as Error).message); - return false; + + if (!r && !t) { + console.warn("[email] No mailer configured — set RESEND_API_KEY or SMTP_HOST"); } + return false; }