From 0c0e1c975870f3f288930555b9eb57fbee6b739a Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:50:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(email):=20Resend=20as=20primary=20provider?= =?UTF-8?q?=20=E2=80=94=20prod=20SMTP=20cred=20is=20dead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live check 2026-09-05 from the box: the Brevo relay answers "Login denied" to the deployed EMAIL_USER/PASS, so every verification code, password reset and contact mail silently failed at send time. The fleet standard is the shared Resend key (5 apps); evig now prefers Resend when RESEND_API_KEY is set, keeps Listmonk as explicit opt-in and SMTP as fallback. Sender follows the surf/vitareba convention (evig@fleetcrown.orangecat.ch) since only fleetcrown.orangecat.ch is verified in the Resend account. Diagnostics endpoint reports the new provider. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn --- src/app/api/admin/email/diagnostics/route.ts | 19 +++++- src/config/email.ts | 37 ++++++++-- src/lib/email/index.ts | 29 ++++++++ src/lib/email/resend.ts | 71 ++++++++++++++++++++ 4 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 src/lib/email/resend.ts diff --git a/src/app/api/admin/email/diagnostics/route.ts b/src/app/api/admin/email/diagnostics/route.ts index a2a02fb48..d29aabccb 100644 --- a/src/app/api/admin/email/diagnostics/route.ts +++ b/src/app/api/admin/email/diagnostics/route.ts @@ -3,8 +3,13 @@ import { z } from 'zod'; import { withAdmin, type ValidSession } from '@/lib/api/middleware'; import { apiBadRequest, apiError, apiSuccess } from '@/lib/api/helpers'; import { logger } from '@/lib/logger'; -import { getEmailProvider, EMAIL_CONFIG, LISTMONK_CONFIG } from '@/config/email'; -import { testEmailConfig, testListmonkConnection, sendCustomEmail } from '@/lib/email'; +import { getEmailProvider, EMAIL_CONFIG, LISTMONK_CONFIG, RESEND_CONFIG } from '@/config/email'; +import { + testEmailConfig, + testListmonkConnection, + testResendConnection, + sendCustomEmail, +} from '@/lib/email'; import { ORG } from '@/config/org'; /** @@ -18,11 +23,19 @@ export const GET = withAdmin('settings', async () => { try { const provider = getEmailProvider(); const connectionTest = - provider === 'listmonk' ? await testListmonkConnection() : await testEmailConfig(); + provider === 'resend' + ? await testResendConnection() + : provider === 'listmonk' + ? await testListmonkConnection() + : await testEmailConfig(); return apiSuccess({ provider, connectionTest, + resend: { + keySet: Boolean(RESEND_CONFIG.API_KEY), + from: RESEND_CONFIG.FROM, + }, smtp: { host: EMAIL_CONFIG.HOST, port: EMAIL_CONFIG.PORT, diff --git a/src/config/email.ts b/src/config/email.ts index df0ee4a27..a10cb9106 100644 --- a/src/config/email.ts +++ b/src/config/email.ts @@ -2,30 +2,45 @@ * Email configuration * * Single Source of Truth for email service configuration - * Supports both direct SMTP (nodemailer) and Listmonk - * - * Listmonk is the recommended FOSS solution for production. + * Supports Resend (fleet standard), Listmonk, and direct SMTP (nodemailer). */ import { ORG } from './org'; /** * Email provider type - * - 'listmonk': Use Listmonk for transactional and newsletter emails (recommended) - * - 'smtp': Use direct SMTP via nodemailer (fallback) + * - 'resend': Fleet-standard transactional email via the Resend API (preferred) + * - 'listmonk': Listmonk for transactional and newsletter emails + * - 'smtp': Direct SMTP via nodemailer (legacy fallback) */ -export type EmailProvider = 'listmonk' | 'smtp'; +export type EmailProvider = 'resend' | 'listmonk' | 'smtp'; /** - * Get the configured email provider + * Get the configured email provider. + * Listmonk stays an explicit opt-in; otherwise Resend wins whenever its key + * is present, because the prod SMTP credential (Brevo) is dead — verified + * 2026-09-05: the relay answers "Login denied", so SMTP sends deliver nothing. */ export function getEmailProvider(): EmailProvider { if (process.env.LISTMONK_ENABLED === 'true') { return 'listmonk'; } + if (process.env.RESEND_API_KEY) { + return 'resend'; + } return 'smtp'; } +/** + * Resend configuration (fleet standard). + * Only fleetcrown.orangecat.ch is verified in the shared Resend account, so + * the default sender follows the surf-your-life/vitareba convention. + */ +export const RESEND_CONFIG = { + API_KEY: process.env.RESEND_API_KEY || '', + FROM: process.env.RESEND_FROM || `${ORG.name} `, +} as const; + /** * SMTP configuration (for direct nodemailer or as Listmonk's SMTP backend) */ @@ -57,6 +72,11 @@ export const LISTMONK_CONFIG = { export function validateEmailConfig(): void { const provider = getEmailProvider(); + if (provider === 'resend') { + // Presence of the key is the only requirement; FROM has a safe default. + return; + } + if (provider === 'listmonk') { if (!LISTMONK_CONFIG.ENABLED) { throw new Error('LISTMONK_ENABLED must be true to use Listmonk'); @@ -78,6 +98,9 @@ export function validateEmailConfig(): void { * Check if any email provider is configured */ export function isEmailConfigured(): boolean { + if (RESEND_CONFIG.API_KEY) { + return true; + } if (LISTMONK_CONFIG.ENABLED) { return true; } diff --git a/src/lib/email/index.ts b/src/lib/email/index.ts index f613fb6af..6a926eaa4 100644 --- a/src/lib/email/index.ts +++ b/src/lib/email/index.ts @@ -29,6 +29,7 @@ import { isListmonkEnabled, subscribeToList, } from './listmonk'; +import { sendViaResend } from './resend'; import type { EmailContent, SendEmailResult, TestEmailResult } from './types'; // Re-export types @@ -37,6 +38,9 @@ export type { EmailContent, SendEmailResult, TestEmailResult } from './types'; // Re-export transporter utilities (SMTP) export { getTransporter, testEmailConfig } from './transporter'; +// Re-export Resend utilities +export { sendViaResend, testResendConnection, isResendEnabled } from './resend'; + // Re-export Listmonk utilities export { sendViaListmonk, @@ -160,6 +164,19 @@ export async function sendEmail( } } + // Resend (fleet standard) — falls back to SMTP on failure like Listmonk + if (provider === 'resend') { + try { + return await sendViaResend(to, emailContent); + } catch (resendError) { + logger.warn('Resend failed, falling back to SMTP', { + error: resendError instanceof Error ? resendError.message : 'unknown', + to, + template, + }); + } + } + // SMTP (primary if provider=smtp, fallback if Listmonk failed) const transporter = await getTransporter(); const mailOptions = { @@ -206,6 +223,18 @@ export async function sendCustomEmail(to: string, content: EmailContent): Promis } } + // Resend (fleet standard) — falls back to SMTP on failure like Listmonk + if (provider === 'resend') { + try { + return await sendViaResend(to, content); + } catch (resendError) { + logger.warn('Resend failed for custom email, falling back to SMTP', { + error: resendError instanceof Error ? resendError.message : 'unknown', + to, + }); + } + } + // SMTP (primary if provider=smtp, fallback if Listmonk failed) const transporter = await getTransporter(); const mailOptions = { diff --git a/src/lib/email/resend.ts b/src/lib/email/resend.ts new file mode 100644 index 000000000..4bdd766f2 --- /dev/null +++ b/src/lib/email/resend.ts @@ -0,0 +1,71 @@ +/** + * Resend API client + * + * The fleet's standard transactional-email provider (see RESEND_API_KEY shared + * across bitbaum apps). Deliberately a plain fetch to Resend's HTTP API rather + * than the `resend` SDK: we need exactly one endpoint (POST /emails) and no + * attachments, so a dependency buys nothing here. + * + * Sender: only `fleetcrown.orangecat.ch` is verified in the Resend account, so + * like surf-your-life and vitareba we send as @fleetcrown.orangecat.ch + * until evig gets its own verified domain. + */ + +import { RESEND_CONFIG } from '@/config/email'; +import { logger } from '@/lib/logger'; +import type { EmailContent, SendEmailResult, TestEmailResult } from './types'; + +const RESEND_API_URL = 'https://api.resend.com'; + +export function isResendEnabled(): boolean { + return Boolean(RESEND_CONFIG.API_KEY); +} + +export async function sendViaResend(to: string, content: EmailContent): Promise { + const res = await fetch(`${RESEND_API_URL}/emails`, { + method: 'POST', + headers: { + Authorization: `Bearer ${RESEND_CONFIG.API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: RESEND_CONFIG.FROM, + to: [to], + subject: content.subject, + html: content.html, + text: content.text, + }), + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ''); + // Throw instead of returning failure: sendEmail() treats a thrown resend + // error as "fall back to SMTP", mirroring the existing Listmonk pattern. + throw new Error(`Resend send failed (${res.status}): ${detail.slice(0, 200)}`); + } + + const body = (await res.json()) as { id?: string }; + logger.info('Email sent via Resend', { messageId: body.id, to }); + return { success: true, messageId: body.id }; +} + +/** + * Connection test for the diagnostics endpoint: an authenticated read against + * /domains proves key validity without sending anything. + */ +export async function testResendConnection(): Promise { + try { + const res = await fetch(`${RESEND_API_URL}/domains`, { + headers: { Authorization: `Bearer ${RESEND_CONFIG.API_KEY}` }, + }); + if (!res.ok) { + return { success: false, error: `Resend API returned ${res.status}` }; + } + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +}