Replace Resend with self-hosted email relay for all email sending

- cryptidAuth.ts: sendEmail() now calls email-relay.jeffemmett.com
  instead of api.resend.com
- boardPermissions.ts: admin request emails use email relay
- types.ts: RESEND_API_KEY → EMAIL_RELAY_URL + EMAIL_RELAY_API_KEY
- wrangler.toml: updated secrets documentation
- Tests updated with new mock env vars

Email relay is a lightweight Flask service on Netcup that accepts
HTTP POST and sends via Mailcow SMTP. Needed because CF Workers
can't do TCP/SMTP directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 16:13:05 -07:00
parent dea24bde81
commit 1d95d1f398
6 changed files with 18 additions and 13 deletions

View File

@ -75,7 +75,8 @@ function createMockEnv(overrides: Partial<Environment> = {}): Environment {
DAILY_API_KEY: 'mock-daily-key', DAILY_API_KEY: 'mock-daily-key',
DAILY_DOMAIN: 'mock.daily.co', DAILY_DOMAIN: 'mock.daily.co',
CRYPTID_DB: createMockD1() as unknown as D1Database, CRYPTID_DB: createMockD1() as unknown as D1Database,
RESEND_API_KEY: 'mock-resend-key', EMAIL_RELAY_URL: 'https://email-relay.jeffemmett.com',
EMAIL_RELAY_API_KEY: 'mock-relay-key',
APP_URL: 'https://test.example.com', APP_URL: 'https://test.example.com',
...overrides, ...overrides,
} }

View File

@ -58,7 +58,8 @@ function createMockEnv(overrides: Partial<Environment> = {}): Environment {
DAILY_API_KEY: 'mock-daily-key', DAILY_API_KEY: 'mock-daily-key',
DAILY_DOMAIN: 'mock.daily.co', DAILY_DOMAIN: 'mock.daily.co',
CRYPTID_DB: createMockD1() as unknown as D1Database, CRYPTID_DB: createMockD1() as unknown as D1Database,
RESEND_API_KEY: 'mock-resend-key', EMAIL_RELAY_URL: 'https://email-relay.jeffemmett.com',
EMAIL_RELAY_API_KEY: 'mock-relay-key',
APP_URL: 'https://test.example.com', APP_URL: 'https://test.example.com',
...overrides, ...overrides,
} }

View File

@ -1099,13 +1099,13 @@ export async function handleRequestAdminAccess(
const body = await request.json().catch(() => ({})) as { reason?: string }; const body = await request.json().catch(() => ({})) as { reason?: string };
// Send email to global admin (jeffemmett@gmail.com) // Send email to global admin (jeffemmett@gmail.com)
if (env.RESEND_API_KEY) { if (env.EMAIL_RELAY_URL && env.EMAIL_RELAY_API_KEY) {
const emailFrom = env.CRYPTID_EMAIL_FROM || 'Canvas <noreply@jeffemmett.com>'; const emailFrom = env.CRYPTID_EMAIL_FROM || 'Canvas <noreply@jeffemmett.com>';
const emailResponse = await fetch('https://api.resend.com/emails', { const emailResponse = await fetch(`${env.EMAIL_RELAY_URL}/send`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Authorization': `Bearer ${env.EMAIL_RELAY_API_KEY}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({

View File

@ -12,7 +12,7 @@ function generateUUID(): string {
return crypto.randomUUID(); return crypto.randomUUID();
} }
// Send email via Resend // Send email via self-hosted email relay (Mailcow SMTP)
async function sendEmail( async function sendEmail(
env: Environment, env: Environment,
to: string, to: string,
@ -20,15 +20,17 @@ async function sendEmail(
htmlContent: string htmlContent: string
): Promise<boolean> { ): Promise<boolean> {
try { try {
if (!env.RESEND_API_KEY) { const relayUrl = env.EMAIL_RELAY_URL;
console.error('RESEND_API_KEY not configured'); const relayKey = env.EMAIL_RELAY_API_KEY;
if (!relayUrl || !relayKey) {
console.error('EMAIL_RELAY_URL or EMAIL_RELAY_API_KEY not configured');
return false; return false;
} }
const response = await fetch('https://api.resend.com/emails', { const response = await fetch(`${relayUrl}/send`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Authorization': `Bearer ${relayKey}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -41,7 +43,7 @@ async function sendEmail(
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error('Resend error:', errorText); console.error('Email relay error:', errorText);
return false; return false;
} }

View File

@ -10,7 +10,8 @@ export interface Environment {
DAILY_DOMAIN: string; DAILY_DOMAIN: string;
// CryptID auth bindings // CryptID auth bindings
CRYPTID_DB?: D1Database; CRYPTID_DB?: D1Database;
RESEND_API_KEY?: string; EMAIL_RELAY_URL?: string;
EMAIL_RELAY_API_KEY?: string;
CRYPTID_EMAIL_FROM?: string; CRYPTID_EMAIL_FROM?: string;
APP_URL?: string; APP_URL?: string;
// Admin secret for protected endpoints // Admin secret for protected endpoints

View File

@ -108,7 +108,7 @@ crons = ["0 0 * * *"] # Run at midnight UTC every day
# - CLOUDFLARE_API_TOKEN # - CLOUDFLARE_API_TOKEN
# - FAL_API_KEY # For fal.ai image/video generation proxy # - FAL_API_KEY # For fal.ai image/video generation proxy
# - RUNPOD_API_KEY # For RunPod AI endpoints proxy # - RUNPOD_API_KEY # For RunPod AI endpoints proxy
# - RESEND_API_KEY # For email sending # - EMAIL_RELAY_API_KEY # For email sending via self-hosted relay
# - ADMIN_SECRET # For admin-only endpoints # - ADMIN_SECRET # For admin-only endpoints
# #
# To set secrets: # To set secrets: