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_DOMAIN: 'mock.daily.co',
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',
...overrides,
}

View File

@ -58,7 +58,8 @@ function createMockEnv(overrides: Partial<Environment> = {}): Environment {
DAILY_API_KEY: 'mock-daily-key',
DAILY_DOMAIN: 'mock.daily.co',
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',
...overrides,
}

View File

@ -1099,13 +1099,13 @@ export async function handleRequestAdminAccess(
const body = await request.json().catch(() => ({})) as { reason?: string };
// 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 emailResponse = await fetch('https://api.resend.com/emails', {
const emailResponse = await fetch(`${env.EMAIL_RELAY_URL}/send`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
'Authorization': `Bearer ${env.EMAIL_RELAY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({

View File

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

View File

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

View File

@ -108,7 +108,7 @@ crons = ["0 0 * * *"] # Run at midnight UTC every day
# - CLOUDFLARE_API_TOKEN
# - FAL_API_KEY # For fal.ai image/video generation 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
#
# To set secrets: