workers/email-sender.ts
Rama main · réplica de lectura del repositorio original.
// Copyright (c) 2026 Doforu
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE-APACHE file or at:
// https://opensource.org/licenses/Apache-2.0
// Modifications Copyright (c) 2026 Doforu, distributed under the AGPL-3.0-only (see LICENSE).
/**
* Email sending via Resend API.
*
* Uses the Resend REST API (https://api.resend.com/emails) to send emails.
* Works on Workers Free plan — no need for Workers Paid's send_email binding.
*
* API key resolution: per-mailbox R2 settings → per-domain DB record → error.
*
* See: https://resend.com/docs/api-reference/emails/send-email
*/
export interface SendEmailParams {
to: string | string[];
from: string | { email: string; name: string };
subject: string;
html?: string;
text?: string;
cc?: string | string[];
bcc?: string | string[];
replyTo?: string | { email: string; name: string };
attachments?: {
content: string; // base64 encoded
filename: string;
// Optional to match the zod-parsed attachment shape used by callers
// (type/disposition are `.optional()` in ReplyBodySchema / ForwardBodySchema).
type?: string;
disposition?: "attachment" | "inline";
contentId?: string;
}[];
headers?: Record<string, string>;
}
import { fetchWithTimeout } from "./lib/fetch-with-timeout";
// [i18n apiSetup] The "no API key configured" error is returned verbatim in the
// API response (callers surface `error.message`), so it must be localized. The
// send helpers have no Hono context, so the request locale is an optional
// trailing parameter defaulting to English (backward compatible).
import { DEFAULT_LOCALE } from "../shared/i18n/config";
import { getBackendT } from "../shared/i18n/translate";
import type { Locale } from "../shared/i18n/types";
const RESEND_API_URL = "https://api.resend.com/emails";
/**
* Format a `from` / `replyTo` field value into the "Name <email>" string
* that the Resend API expects.
*/
function formatAddress(field: string | { email: string; name?: string }): string {
if (typeof field === "string") return field;
return field.name ? `${field.name} <${field.email}>` : field.email;
}
/**
* Send an email using the Resend API.
*
* @param apiKey - Resend API key
* @param params - Email parameters (to, from, subject, body, etc.)
* @returns The send result with messageId
* @throws On API errors (HTTP non-2xx)
*/
export async function sendEmail(
apiKey: string,
params: SendEmailParams,
): Promise<{ messageId: string }> {
const body: Record<string, unknown> = {
from: formatAddress(params.from),
to: Array.isArray(params.to) ? params.to : [params.to],
subject: params.subject,
};
if (params.html) body.html = params.html;
if (params.text) body.text = params.text;
if (params.cc) body.cc = Array.isArray(params.cc) ? params.cc : [params.cc];
if (params.bcc) body.bcc = Array.isArray(params.bcc) ? params.bcc : [params.bcc];
if (params.replyTo) body.reply_to = formatAddress(params.replyTo);
if (params.headers && Object.keys(params.headers).length > 0) {
body.headers = params.headers;
}
if (params.attachments && params.attachments.length > 0) {
body.attachments = params.attachments.map((att) => ({
filename: att.filename,
content: att.content,
content_type: att.type,
disposition: att.disposition,
...(att.contentId ? { content_id: att.contentId } : {}),
}));
}
const response = await fetchWithTimeout(RESEND_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const err = (await response.json().catch(() => null)) as {
message?: string;
} | null;
throw new Error(err?.message || `Resend API error: ${response.status}`);
}
let data: { id?: string } = {};
try {
data = (await response.json()) as { id?: string };
} catch {
console.warn("Failed to parse Resend response JSON, using fallback");
}
return { messageId: data?.id || "" };
}
/**
* Read the Resend API key from mailbox settings (stored in R2).
* Each mailbox must have its own API key configured via Settings.
*
* @param bucket - R2 bucket (c.env.BUCKET)
* @param mailboxId - Mailbox email address (used as the R2 key)
* @param params - Email parameters
* @param db - Optional D1 database (unused, kept for API compatibility)
* @returns The send result with messageId
*/
export async function sendEmailFromMailbox(
bucket: R2Bucket,
mailboxId: string,
params: SendEmailParams,
_fallbackKey?: string,
db?: D1Database,
locale: Locale = DEFAULT_LOCALE,
): Promise<{ messageId: string }> {
// 1. Read API key from mailbox settings in R2 (per-mailbox, highest priority)
const obj = await bucket.get(`mailboxes/${mailboxId}.json`);
if (obj) {
let settings: { resendApiKey?: string } = {};
try {
settings = (await obj.json()) as { resendApiKey?: string };
} catch {
console.warn(`Failed to parse mailbox config for ${mailboxId}, falling back to D1`);
}
if (settings.resendApiKey) {
return sendEmail(settings.resendApiKey, params);
}
}
// 2. Fallback: per-domain resend_api_key from domains table
if (db) {
try {
const atIdx = mailboxId.indexOf("@");
if (atIdx !== -1) {
const domainName = mailboxId.substring(atIdx + 1).toLowerCase();
const result = await db
.prepare("SELECT resend_api_key FROM domains WHERE name = ?")
.bind(domainName)
.first<{ resend_api_key?: string | null }>();
if (result?.resend_api_key) {
return sendEmail(result.resend_api_key, params);
}
}
} catch {
// Ignore domain lookup errors
}
}
throw new Error(
getBackendT(locale, "apiSetup")("resendApiKeyNotConfigured") as string,
);
}