From 5cd4415e8c7dcd0909fde6d60f387e02602795b5 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:13:25 +0200 Subject: [PATCH] refactor(email): route Resend transport through @bitbaum/mail-kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the raw-fetch Resend implementation in src/lib/email/resend.ts with @bitbaum/mail-kit calls (sendMail / mailHealth / isMailConfigured), keeping the public helper signatures (sendViaResend, testResendConnection, isResendEnabled) and the throw-on-failure contract that sendEmail() uses to fall back to SMTP. Provider precedence stays intact: listmonk explicit > resend > smtp. nodemailer stays — the SMTP fallback still uses it. No resend SDK dep existed, so no dep removed; @bitbaum/mail-kit added (with a minimumReleaseAgeExclude entry — the package is <24h old). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn --- package.json | 3 +- pnpm-lock.yaml | 9 ++++++ pnpm-workspace.yaml | 1 + src/lib/email/resend.ts | 69 +++++++++++++++-------------------------- 4 files changed, 37 insertions(+), 45 deletions(-) diff --git a/package.json b/package.json index 3ad1a5a7f..c8e04e025 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,8 @@ "dependencies": { "@auth/pg-adapter": "^1.11.3", "@aws-sdk/client-s3": "^3.1123.0", + "@bitbaum/ai-kit": "^0.6.2", + "@bitbaum/mail-kit": "^0.1.0", "@fleet/ai-forms": "github:bitbaum/ai-forms#v0.1.2", "@sentry/nextjs": "^10.73.0", "@tiptap/core": "^3.30.6", @@ -110,7 +112,6 @@ "@tiptap/pm": "^3.30.6", "@tiptap/react": "^3.30.6", "@tiptap/starter-kit": "^3.30.6", - "@bitbaum/ai-kit": "^0.6.2", "bcryptjs": "^2.4.3", "busboy": "^1.6.0", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdab48b56..b5940ac02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@bitbaum/ai-kit': specifier: ^0.6.2 version: 0.6.2(react@19.2.8) + '@bitbaum/mail-kit': + specifier: ^0.1.0 + version: 0.1.0 '@fleet/ai-forms': specifier: github:bitbaum/ai-forms#v0.1.2 version: ai-forms@https://codeload.github.com/bitbaum/ai-forms/tar.gz/52e3239ae4b0a2fcdc579edc2b9d8f8e7bff44b9(react@19.2.8) @@ -476,6 +479,10 @@ packages: react: optional: true + '@bitbaum/mail-kit@0.1.0': + resolution: {integrity: sha512-RbFBDANTUEb2/O2duSXIp+lb8y9ycrdbSTPnvoQ8iZOZsfxQZLseKzgCZumN31q7jRbVNHjQRao0envGWsFJ5Q==} + engines: {node: '>=20'} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -6189,6 +6196,8 @@ snapshots: optionalDependencies: react: 19.2.8 + '@bitbaum/mail-kit@0.1.0': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b67097687..c10e57ca5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,7 @@ allowBuilds: unrs-resolver: true minimumReleaseAgeExclude: - '@bitbaum/ai-kit' + - '@bitbaum/mail-kit' overrides: '@swc/helpers': 0.5.23 '@opentelemetry/core': ^2.8.0 diff --git a/src/lib/email/resend.ts b/src/lib/email/resend.ts index 4bdd766f2..c4e41befd 100644 --- a/src/lib/email/resend.ts +++ b/src/lib/email/resend.ts @@ -1,71 +1,52 @@ /** - * Resend API client + * Resend transport — thin adapter over @bitbaum/mail-kit. * - * 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. + * The fleet's one email layer (shared RESEND_API_KEY across bitbaum apps). + * mail-kit owns the HTTP call, timeout, and the "configured-looking but dead" + * guards (placeholder key, sandbox sender); this file only adapts its + * SendResult to evig's SendEmailResult and preserves the throw-on-failure + * contract that sendEmail() uses to fall back to SMTP. * * 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. + * until evig gets its own verified domain (RESEND_CONFIG.FROM reads + * RESEND_FROM with that convention as the default). */ +import { sendMail, mailHealth, isMailConfigured } from '@bitbaum/mail-kit'; 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); + return isMailConfigured(); } 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, - }), + const result = await sendMail({ + to, + from: RESEND_CONFIG.FROM, + subject: content.subject, + html: content.html, + text: content.text, }); - if (!res.ok) { - const detail = await res.text().catch(() => ''); + if (!result.sent) { // 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)}`); + throw new Error(`Resend send failed: ${result.error}`); } - const body = (await res.json()) as { id?: string }; - logger.info('Email sent via Resend', { messageId: body.id, to }); - return { success: true, messageId: body.id }; + logger.info('Email sent via Resend', { messageId: result.id, to }); + return { success: true, messageId: result.id }; } /** - * Connection test for the diagnostics endpoint: an authenticated read against - * /domains proves key validity without sending anything. + * Connection test for the diagnostics endpoint: mail-kit's health probe does + * 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', - }; - } + const health = await mailHealth(); + return health.ok ? { success: true } : { success: false, error: health.error }; }