Add Resend mailer as primary with SMTP fallback for reliable email delivery

This commit is contained in:
openhands committed 2026-07-07 20:12:56 +02:00
1 parent f386ae2b25
commit bf4075233d
4 files changed
+75 -15

No files matched your search

+1
View File
@@ -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"
},
+40
View File
@@ -50,6 +50,9 @@ importers:
react-dom:
specifier: ^19.2.0
version: 19.2.7([email protected])
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/[email protected]':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@stablelib/[email protected]':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -1946,6 +1952,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
[email protected]:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
[email protected]:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
@@ -2293,6 +2302,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
[email protected]:
resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==}
[email protected]:
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'}
[email protected]:
resolution: {integrity: sha512-QhHHIRPPM9Tq2uilWmNF8C8kd6nLkUmiFgDQCt1v4eR4o+6p86qrK+Vn12jnAs0jR8Gf6ABdxI18/90LGQ7GVA==}
engines: {node: '>=20'}
peerDependencies:
'@react-email/render': '*'
peerDependenciesMeta:
'@react-email/render':
optional: true
[email protected]:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -2452,6 +2473,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
[email protected]:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
[email protected]:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -3415,6 +3439,8 @@ snapshots:
'@schummar/[email protected]': {}
'@stablelib/[email protected]': {}
'@standard-schema/[email protected]': {}
'@swc/[email protected]':
@@ -3915,6 +3941,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -4225,6 +4253,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
cssesc: 3.0.0
@@ -4311,6 +4341,11 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
postal-mime: 2.7.4
standardwebhooks: 1.0.0
[email protected]: {}
[email protected]: {}
@@ -4429,6 +4464,11 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
[email protected]: {}
[email protected]:
+2
View File
@@ -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(),
+32 -15
View File
@@ -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<boolean> {
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;
}