Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/app/api/admin/email/diagnostics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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,
Expand Down
37 changes: 30 additions & 7 deletions src/config/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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} <evig@fleetcrown.orangecat.ch>`,
} as const;

/**
* SMTP configuration (for direct nodemailer or as Listmonk's SMTP backend)
*/
Expand Down Expand Up @@ -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');
Expand All @@ -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;
}
Expand Down
29 changes: 29 additions & 0 deletions src/lib/email/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
isListmonkEnabled,
subscribeToList,
} from './listmonk';
import { sendViaResend } from './resend';
import type { EmailContent, SendEmailResult, TestEmailResult } from './types';

// Re-export types
Expand All @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
71 changes: 71 additions & 0 deletions src/lib/email/resend.ts
Original file line number Diff line number Diff line change
@@ -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 <app>@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<SendEmailResult> {
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<TestEmailResult> {
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',
};
}
}
Loading