From b1486a0b2a473df519e850f83e65e12081e745d3 Mon Sep 17 00:00:00 2001 From: renee-png Date: Tue, 2 Jun 2026 23:07:26 -0400 Subject: [PATCH] Migrate email pipeline off Lovable + branded auth emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Lovable-bound email transport and auth webhook so the platform sends all automated email through its own infrastructure. - process-email-queue: drop sendLovableEmail/LOVABLE_API_KEY; send via the Hostinger Email API (primary) with automatic SMTP fallback. Shared transports added in _shared/hostinger-mail.ts and _shared/smtp-send.ts. - auth-email-hook: verify Supabase's native Send Email hook signature (Standard Webhooks via SEND_EMAIL_HOOK_SECRET) instead of Lovable's libs; build the GoTrue verify URL; keep enqueue → process-email-queue. - Recreate the 6 auth email templates under _shared/email-templates/ that previously only existed in the deployed function. Co-Authored-By: Claude Opus 4.8 --- .../_shared/email-templates/email-change.tsx | 87 ++++++ .../_shared/email-templates/invite.tsx | 90 ++++++ .../_shared/email-templates/magic-link.tsx | 70 +++++ .../email-templates/reauthentication.tsx | 60 ++++ .../_shared/email-templates/recovery.tsx | 71 +++++ .../_shared/email-templates/signup.tsx | 86 ++++++ supabase/functions/_shared/hostinger-mail.ts | 71 +++++ supabase/functions/_shared/smtp-send.ts | 183 +++++++++++++ supabase/functions/auth-email-hook/index.ts | 257 +++++------------- .../functions/process-email-queue/index.ts | 125 +++++++-- 10 files changed, 889 insertions(+), 211 deletions(-) create mode 100644 supabase/functions/_shared/email-templates/email-change.tsx create mode 100644 supabase/functions/_shared/email-templates/invite.tsx create mode 100644 supabase/functions/_shared/email-templates/magic-link.tsx create mode 100644 supabase/functions/_shared/email-templates/reauthentication.tsx create mode 100644 supabase/functions/_shared/email-templates/recovery.tsx create mode 100644 supabase/functions/_shared/email-templates/signup.tsx create mode 100644 supabase/functions/_shared/hostinger-mail.ts create mode 100644 supabase/functions/_shared/smtp-send.ts diff --git a/supabase/functions/_shared/email-templates/email-change.tsx b/supabase/functions/_shared/email-templates/email-change.tsx new file mode 100644 index 0000000..fa1127d --- /dev/null +++ b/supabase/functions/_shared/email-templates/email-change.tsx @@ -0,0 +1,87 @@ +/// + +import * as React from 'npm:react@18.3.1' + +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Link, + Preview, + Text, +} from 'npm:@react-email/components@0.0.22' + +interface EmailChangeEmailProps { + siteName: string + email: string + newEmail: string + confirmationUrl: string +} + +export const EmailChangeEmail = ({ + siteName, + email, + newEmail, + confirmationUrl, +}: EmailChangeEmailProps) => ( + + + Confirm your email change for {siteName} + + + Confirm your email change + + You requested to change your email address for {siteName} from{' '} + + {email} + {' '} + to{' '} + + {newEmail} + + . + + + Click the button below to confirm this change: + + + + If you didn't request this change, please secure your account + immediately. + + + + +) + +export default EmailChangeEmail + +const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' } +const container = { padding: '20px 25px' } +const h1 = { + fontSize: '22px', + fontWeight: 'bold' as const, + color: '#000000', + margin: '0 0 20px', +} +const text = { + fontSize: '14px', + color: '#55575d', + lineHeight: '1.5', + margin: '0 0 25px', +} +const link = { color: 'inherit', textDecoration: 'underline' } +const button = { + backgroundColor: '#000000', + color: '#ffffff', + fontSize: '14px', + borderRadius: '8px', + padding: '12px 20px', + textDecoration: 'none', +} +const footer = { fontSize: '12px', color: '#999999', margin: '30px 0 0' } diff --git a/supabase/functions/_shared/email-templates/invite.tsx b/supabase/functions/_shared/email-templates/invite.tsx new file mode 100644 index 0000000..6beca1b --- /dev/null +++ b/supabase/functions/_shared/email-templates/invite.tsx @@ -0,0 +1,90 @@ +/// + +import * as React from 'npm:react@18.3.1' + +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Link, + Preview, + Text, +} from 'npm:@react-email/components@0.0.22' + +interface InviteEmailProps { + siteName: string + siteUrl: string + confirmationUrl: string +} + +export const InviteEmail = ({ + siteName, + siteUrl, + confirmationUrl, +}: InviteEmailProps) => ( + + + You've been invited to join {siteName} + + + You've been invited + + You've been invited to join{' '} + + {siteName} + + . Click the button below to accept the invitation and create your + account. + + + + If the button does not open, copy and paste this secure link into your browser: + + + + {confirmationUrl} + + + + If you weren't expecting this invitation, you can safely ignore this + email. + + + + +) + +export default InviteEmail + +const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' } +const container = { padding: '20px 25px' } +const h1 = { + fontSize: '22px', + fontWeight: 'bold' as const, + color: '#000000', + margin: '0 0 20px', +} +const text = { + fontSize: '14px', + color: '#55575d', + lineHeight: '1.5', + margin: '0 0 25px', +} +const link = { color: 'inherit', textDecoration: 'underline' } +const button = { + backgroundColor: '#000000', + color: '#ffffff', + fontSize: '14px', + borderRadius: '8px', + padding: '12px 20px', + textDecoration: 'none', +} +const fallbackText = { fontSize: '13px', color: '#55575d', lineHeight: '1.5', margin: '24px 0 8px' } +const fallbackLinkWrapper = { margin: '0 0 24px', wordBreak: 'break-all' as const } +const fallbackLink = { color: '#000000', fontSize: '13px', lineHeight: '1.5', textDecoration: 'underline' } +const footer = { fontSize: '12px', color: '#999999', margin: '30px 0 0' } diff --git a/supabase/functions/_shared/email-templates/magic-link.tsx b/supabase/functions/_shared/email-templates/magic-link.tsx new file mode 100644 index 0000000..bc9ea17 --- /dev/null +++ b/supabase/functions/_shared/email-templates/magic-link.tsx @@ -0,0 +1,70 @@ +/// + +import * as React from 'npm:react@18.3.1' + +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Preview, + Text, +} from 'npm:@react-email/components@0.0.22' + +interface MagicLinkEmailProps { + siteName: string + confirmationUrl: string +} + +export const MagicLinkEmail = ({ + siteName, + confirmationUrl, +}: MagicLinkEmailProps) => ( + + + Your login link for {siteName} + + + Your login link + + Click the button below to log in to {siteName}. This link will expire + shortly. + + + + If you didn't request this link, you can safely ignore this email. + + + + +) + +export default MagicLinkEmail + +const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' } +const container = { padding: '20px 25px' } +const h1 = { + fontSize: '22px', + fontWeight: 'bold' as const, + color: '#000000', + margin: '0 0 20px', +} +const text = { + fontSize: '14px', + color: '#55575d', + lineHeight: '1.5', + margin: '0 0 25px', +} +const button = { + backgroundColor: '#000000', + color: '#ffffff', + fontSize: '14px', + borderRadius: '8px', + padding: '12px 20px', + textDecoration: 'none', +} +const footer = { fontSize: '12px', color: '#999999', margin: '30px 0 0' } diff --git a/supabase/functions/_shared/email-templates/reauthentication.tsx b/supabase/functions/_shared/email-templates/reauthentication.tsx new file mode 100644 index 0000000..4763832 --- /dev/null +++ b/supabase/functions/_shared/email-templates/reauthentication.tsx @@ -0,0 +1,60 @@ +/// + +import * as React from 'npm:react@18.3.1' + +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Text, +} from 'npm:@react-email/components@0.0.22' + +interface ReauthenticationEmailProps { + token: string +} + +export const ReauthenticationEmail = ({ token }: ReauthenticationEmailProps) => ( + + + Your verification code + + + Confirm reauthentication + Use the code below to confirm your identity: + {token} + + This code will expire shortly. If you didn't request this, you can + safely ignore this email. + + + + +) + +export default ReauthenticationEmail + +const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' } +const container = { padding: '20px 25px' } +const h1 = { + fontSize: '22px', + fontWeight: 'bold' as const, + color: '#000000', + margin: '0 0 20px', +} +const text = { + fontSize: '14px', + color: '#55575d', + lineHeight: '1.5', + margin: '0 0 25px', +} +const codeStyle = { + fontFamily: 'Courier, monospace', + fontSize: '22px', + fontWeight: 'bold' as const, + color: '#000000', + margin: '0 0 30px', +} +const footer = { fontSize: '12px', color: '#999999', margin: '30px 0 0' } diff --git a/supabase/functions/_shared/email-templates/recovery.tsx b/supabase/functions/_shared/email-templates/recovery.tsx new file mode 100644 index 0000000..8726d19 --- /dev/null +++ b/supabase/functions/_shared/email-templates/recovery.tsx @@ -0,0 +1,71 @@ +/// + +import * as React from 'npm:react@18.3.1' + +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Preview, + Text, +} from 'npm:@react-email/components@0.0.22' + +interface RecoveryEmailProps { + siteName: string + confirmationUrl: string +} + +export const RecoveryEmail = ({ + siteName, + confirmationUrl, +}: RecoveryEmailProps) => ( + + + Reset your password for {siteName} + + + Reset your password + + We received a request to reset your password for {siteName}. Click + the button below to choose a new password. + + + + If you didn't request a password reset, you can safely ignore this + email. Your password will not be changed. + + + + +) + +export default RecoveryEmail + +const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' } +const container = { padding: '20px 25px' } +const h1 = { + fontSize: '22px', + fontWeight: 'bold' as const, + color: '#000000', + margin: '0 0 20px', +} +const text = { + fontSize: '14px', + color: '#55575d', + lineHeight: '1.5', + margin: '0 0 25px', +} +const button = { + backgroundColor: '#000000', + color: '#ffffff', + fontSize: '14px', + borderRadius: '8px', + padding: '12px 20px', + textDecoration: 'none', +} +const footer = { fontSize: '12px', color: '#999999', margin: '30px 0 0' } diff --git a/supabase/functions/_shared/email-templates/signup.tsx b/supabase/functions/_shared/email-templates/signup.tsx new file mode 100644 index 0000000..9eafea1 --- /dev/null +++ b/supabase/functions/_shared/email-templates/signup.tsx @@ -0,0 +1,86 @@ +/// + +import * as React from 'npm:react@18.3.1' + +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Link, + Preview, + Text, +} from 'npm:@react-email/components@0.0.22' + +interface SignupEmailProps { + siteName: string + siteUrl: string + recipient: string + confirmationUrl: string +} + +export const SignupEmail = ({ + siteName, + siteUrl, + recipient, + confirmationUrl, +}: SignupEmailProps) => ( + + + Confirm your email for {siteName} + + + Confirm your email + + Thanks for signing up for{' '} + + {siteName} + + ! + + + Please confirm your email address ( + + {recipient} + + ) by clicking the button below: + + + + If you didn't create an account, you can safely ignore this email. + + + + +) + +export default SignupEmail + +const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' } +const container = { padding: '20px 25px' } +const h1 = { + fontSize: '22px', + fontWeight: 'bold' as const, + color: '#000000', + margin: '0 0 20px', +} +const text = { + fontSize: '14px', + color: '#55575d', + lineHeight: '1.5', + margin: '0 0 25px', +} +const link = { color: 'inherit', textDecoration: 'underline' } +const button = { + backgroundColor: '#000000', + color: '#ffffff', + fontSize: '14px', + borderRadius: '8px', + padding: '12px 20px', + textDecoration: 'none', +} +const footer = { fontSize: '12px', color: '#999999', margin: '30px 0 0' } diff --git a/supabase/functions/_shared/hostinger-mail.ts b/supabase/functions/_shared/hostinger-mail.ts new file mode 100644 index 0000000..61d909e --- /dev/null +++ b/supabase/functions/_shared/hostinger-mail.ts @@ -0,0 +1,71 @@ +// Hostinger Email API transport (https://api.mail.hostinger.com). +// +// Send endpoint: POST /api/v1/mailboxes/{mailboxResourceId}/send +// Auth: Authorization: Bearer (token + mailbox are scoped to one order) +// The From identity is implicit — it is the mailbox the token is authorized for, +// so the body carries no `from` field. +// +// `sendViaHostingerMail` throws on any non-2xx so the caller can fall back to +// SMTP and/or let the queue retry. The thrown Error carries `.status` so HTTP +// 429 can be mapped to the dispatcher's existing rate-limit backoff. + +export type HostingerMailConfig = { + token: string; + mailboxResourceId: string; +}; + +export type HostingerMessage = { + to: string; + subject: string; + html: string; + text?: string; +}; + +const BASE_URL = 'https://api.mail.hostinger.com'; + +export class HostingerMailError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = 'HostingerMailError'; + this.status = status; + } +} + +export async function sendViaHostingerMail( + cfg: HostingerMailConfig, + msg: HostingerMessage, +): Promise { + if (!cfg.token || !cfg.mailboxResourceId) { + throw new Error('Hostinger mail config incomplete (token and mailboxResourceId required)'); + } + + const url = `${BASE_URL}/api/v1/mailboxes/${encodeURIComponent(cfg.mailboxResourceId)}/send`; + const body = { + to: [msg.to], + subject: msg.subject, + html: msg.html, + text: msg.text && msg.text.trim() ? msg.text : undefined, + }; + + const resp = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${cfg.token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + let detail = ''; + try { detail = (await resp.text()).slice(0, 500); } catch { /* ignore */ } + throw new HostingerMailError( + `Hostinger mail API ${resp.status} ${resp.statusText}: ${detail}`, + resp.status, + ); + } + // Drain the body so the connection can be reused. + try { await resp.text(); } catch { /* ignore */ } +} diff --git a/supabase/functions/_shared/smtp-send.ts b/supabase/functions/_shared/smtp-send.ts new file mode 100644 index 0000000..4d04476 --- /dev/null +++ b/supabase/functions/_shared/smtp-send.ts @@ -0,0 +1,183 @@ +// Self-contained SMTP transport for server-to-server (queued) email. +// +// Extracted from the proven conversation in `send-smtp-email` so the automated +// email dispatcher (`process-email-queue`) can send through the same SMTP path +// the app already uses — no third-party email API required. +// +// `sendSmtpMessage` throws on any failure so the caller's existing retry / DLQ +// logic can record and reschedule the message. + +export type SmtpSender = { + host: string; + port: number; + username: string; + password: string; + use_tls?: boolean; + use_ssl?: boolean; + fromEmail: string; + fromName?: string; +}; + +export type SmtpMessage = { + to: string; + subject: string; + html: string; + text?: string; + /** When set, adds List-Unsubscribe + one-click headers (bulk deliverability). */ + unsubscribeUrl?: string; +}; + +const SMTP_CONNECT_TIMEOUT_MS = 20000; +const SMTP_COMMAND_TIMEOUT_MS = 30000; + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timeoutId: number | undefined; + return new Promise((resolve, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + promise.then(resolve).catch(reject).finally(() => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + }); + }); +} + +function sanitizeHeaderValue(value: string): string { + return String(value ?? "").replace(/[\r\n]+/g, " ").trim(); +} + +function formatAddressHeader(email: string, displayName?: string): string { + const safeEmail = sanitizeHeaderValue(email); + const safeName = sanitizeHeaderValue(displayName || ""); + if (!safeName) return safeEmail; + const escaped = safeName.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const needsQuotes = /[",;<>@\[\]\(\):]/.test(escaped); + return needsQuotes ? `"${escaped}" <${safeEmail}>` : `${escaped} <${safeEmail}>`; +} + +function toBase64Utf8(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); +} + +function chunkBase64(value: string, lineLength = 76): string { + const lines: string[] = []; + for (let i = 0; i < value.length; i += lineLength) lines.push(value.slice(i, i + lineLength)); + return lines.join("\r\n"); +} + +function dotStuff(content: string): string { + return content.replace(/\r?\n/g, "\r\n").replace(/(^|\r\n)\./g, "$1.."); +} + +function htmlToPlainText(html: string): string { + return html + .replace(/<\s*br\s*\/?>/gi, "\n") + .replace(/<\/(p|div|h1|h2|h3|h4|h5|h6|li|tr|section)\s*>/gi, "\n") + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, "") + .replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">") + .replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +export async function sendSmtpMessage(sender: SmtpSender, msg: SmtpMessage): Promise { + const { host, password, username } = sender; + const port = Number(sender.port || 587); + const use_ssl = sender.use_ssl ?? port === 465; + const use_tls = sender.use_tls ?? port === 587; + + if (!host || !username || !password || !sender.fromEmail) { + throw new Error("Incomplete SMTP sender configuration (host, username, password, fromEmail required)"); + } + + const to = sanitizeHeaderValue(msg.to); + if (!to) throw new Error("Missing recipient"); + + const fromHeader = formatAddressHeader(sender.fromEmail, sender.fromName); + const subject = sanitizeHeaderValue(msg.subject); + const html = String(msg.html ?? "").trim() || ``; + const text = msg.text && msg.text.trim() ? msg.text : (htmlToPlainText(html) || subject); + const domain = sender.fromEmail.includes("@") ? sender.fromEmail.split("@")[1] : host; + const messageId = `<${crypto.randomUUID()}@${domain}>`; + const boundary = `----=_Part_${crypto.randomUUID().replace(/-/g, "")}`; + + const headers: string[] = [ + `From: ${fromHeader}`, + `To: ${to}`, + `Reply-To: ${sanitizeHeaderValue(sender.fromEmail)}`, + `Subject: ${subject}`, + `Date: ${new Date().toUTCString()}`, + `Message-ID: ${messageId}`, + `MIME-Version: 1.0`, + ]; + if (msg.unsubscribeUrl) { + headers.push(`List-Unsubscribe: <${sanitizeHeaderValue(msg.unsubscribeUrl)}>`); + headers.push(`List-Unsubscribe-Post: List-Unsubscribe=One-Click`); + } + headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`); + + let mime = headers.join("\r\n") + "\r\n\r\n"; + mime += `--${boundary}\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n`; + mime += chunkBase64(toBase64Utf8(text)) + "\r\n\r\n"; + mime += `--${boundary}\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n`; + mime += chunkBase64(toBase64Utf8(html)) + "\r\n\r\n"; + mime += `--${boundary}--\r\n`; + + const conn: Deno.Conn = use_ssl + ? await withTimeout(Deno.connectTls({ hostname: host, port }), SMTP_CONNECT_TIMEOUT_MS, `Timed out connecting (SSL) to ${host}:${port}`) + : await withTimeout(Deno.connect({ hostname: host, port }), SMTP_CONNECT_TIMEOUT_MS, `Timed out connecting to ${host}:${port}`); + + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + const read = async (c: Deno.Conn): Promise => { + const buf = new Uint8Array(4096); + const n = await withTimeout(c.read(buf), SMTP_COMMAND_TIMEOUT_MS, `Timed out awaiting SMTP response from ${host}`); + if (n === null) throw new Error("SMTP server closed the connection unexpectedly"); + return decoder.decode(buf.subarray(0, n)); + }; + const cmd = async (c: Deno.Conn, command: string): Promise => { + await withTimeout(c.write(encoder.encode(command + "\r\n")), SMTP_COMMAND_TIMEOUT_MS, `Timed out sending SMTP command to ${host}`); + return await read(c); + }; + + try { + await read(conn); + let active: Deno.Conn = conn; + const ehlo = await cmd(active, "EHLO localhost"); + + if (!use_ssl && use_tls && ehlo.includes("STARTTLS")) { + const st = await cmd(active, "STARTTLS"); + if (!st.startsWith("220")) throw new Error(`STARTTLS not available: ${st.trim()}`); + active = await withTimeout(Deno.startTls(active as Deno.TcpConn, { hostname: host }), SMTP_COMMAND_TIMEOUT_MS, `Timed out on STARTTLS upgrade for ${host}`); + await cmd(active, "EHLO localhost"); + } + + await cmd(active, "AUTH LOGIN"); + await cmd(active, btoa(username)); + const authResp = await cmd(active, btoa(password)); + if (!authResp.startsWith("235")) throw new Error(`SMTP authentication failed for ${username}: ${authResp.trim()}`); + + const mailFrom = await cmd(active, `MAIL FROM:<${sanitizeHeaderValue(sender.fromEmail)}>`); + if (!mailFrom.startsWith("250")) throw new Error(`MAIL FROM rejected: ${mailFrom.trim()}`); + + const rcpt = await cmd(active, `RCPT TO:<${to}>`); + if (!rcpt.startsWith("250") && !rcpt.startsWith("251")) throw new Error(`RCPT TO rejected for ${to}: ${rcpt.trim()}`); + + const dataResp = await cmd(active, "DATA"); + if (!dataResp.startsWith("354")) throw new Error(`DATA rejected: ${dataResp.trim()}`); + + const payload = dotStuff(mime) + "\r\n.\r\n"; + const CHUNK = 4096; + for (let off = 0; off < payload.length; off += CHUNK) { + await withTimeout(active.write(encoder.encode(payload.slice(off, off + CHUNK))), SMTP_COMMAND_TIMEOUT_MS, `Timed out sending message body to ${host}`); + } + const finalResp = await read(active); + try { await cmd(active, "QUIT"); } catch (_) { /* ignore */ } + if (!finalResp.startsWith("250")) throw new Error(`Message rejected by ${host}: ${finalResp.trim()}`); + return finalResp.trim(); + } finally { + try { conn.close(); } catch (_) { /* already closed */ } + } +} diff --git a/supabase/functions/auth-email-hook/index.ts b/supabase/functions/auth-email-hook/index.ts index 968b6c9..39a27c5 100644 --- a/supabase/functions/auth-email-hook/index.ts +++ b/supabase/functions/auth-email-hook/index.ts @@ -1,7 +1,6 @@ import * as React from 'npm:react@18.3.1' import { renderAsync } from 'npm:@react-email/components@0.0.22' -import { parseEmailWebhookPayload } from 'npm:@lovable.dev/email-js' -import { WebhookError, verifyWebhookRequest } from 'npm:@lovable.dev/webhooks-js' +import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0' import { createClient } from 'npm:@supabase/supabase-js@2' import { SignupEmail } from '../_shared/email-templates/signup.tsx' import { InviteEmail } from '../_shared/email-templates/invite.tsx' @@ -10,10 +9,20 @@ import { RecoveryEmail } from '../_shared/email-templates/recovery.tsx' import { EmailChangeEmail } from '../_shared/email-templates/email-change.tsx' import { ReauthenticationEmail } from '../_shared/email-templates/reauthentication.tsx' +// This is the Supabase Auth "Send Email" hook. Supabase Auth POSTs a +// Standard Webhooks-signed payload here whenever it needs to send an auth email +// (signup confirmation, password reset, invite, magic link, email change, +// reauthentication). We verify the signature, render the matching branded +// template, and enqueue it for the dispatcher (process-email-queue) to send. +// +// Migrated off Lovable: verification now uses the native Supabase hook secret +// (SEND_EMAIL_HOOK_SECRET, format "v1,whsec_") via the standardwebhooks +// library — no @lovable.dev/* dependencies. + const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': - 'authorization, x-client-info, apikey, content-type, x-lovable-signature, x-lovable-timestamp, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version', + 'authorization, x-client-info, apikey, content-type, webhook-id, webhook-timestamp, webhook-signature', } const EMAIL_SUBJECTS: Record = { @@ -25,7 +34,6 @@ const EMAIL_SUBJECTS: Record = { reauthentication: 'Your verification code', } -// Template mapping const EMAIL_TEMPLATES: Record> = { signup: SignupEmail, invite: InviteEmail, @@ -35,209 +43,93 @@ const EMAIL_TEMPLATES: Record> = { reauthentication: ReauthenticationEmail, } -// Configuration -const SITE_NAME = "Avria Community Management, LLC" -const SENDER_DOMAIN = "notify.avriamail.com" -const ROOT_DOMAIN = "avria.cloud" -const FROM_DOMAIN = "notify.avriamail.com" // Domain shown in From address (may be root or sender subdomain) -const FROM_ADDRESS = `"${SITE_NAME.replace(/"/g, '\\"')}" ` +const SITE_NAME = 'Avria Community Management, LLC' +const ROOT_DOMAIN = 'avria.cloud' +const FROM_ADDRESS = `"${SITE_NAME.replace(/"/g, '\\"')}" ` -// Sample data for preview mode ONLY (not used in actual email sending). -// URLs are baked in at scaffold time from the project's real data. -// The sample email uses a fixed placeholder (RFC 6761 .test TLD) so the Go backend -// can always find-and-replace it with the actual recipient when sending test emails, -// even if the project's domain has changed since the template was scaffolded. -const SAMPLE_PROJECT_URL = "https://avria.cloud" -const SAMPLE_EMAIL = "user@example.test" -const SAMPLE_DATA: Record = { - signup: { - siteName: SITE_NAME, - siteUrl: SAMPLE_PROJECT_URL, - recipient: SAMPLE_EMAIL, - confirmationUrl: SAMPLE_PROJECT_URL, - }, - magiclink: { - siteName: SITE_NAME, - confirmationUrl: SAMPLE_PROJECT_URL, - }, - recovery: { - siteName: SITE_NAME, - confirmationUrl: SAMPLE_PROJECT_URL, - }, - invite: { - siteName: SITE_NAME, - siteUrl: SAMPLE_PROJECT_URL, - confirmationUrl: SAMPLE_PROJECT_URL, - }, - email_change: { - siteName: SITE_NAME, - email: SAMPLE_EMAIL, - newEmail: SAMPLE_EMAIL, - confirmationUrl: SAMPLE_PROJECT_URL, - }, - reauthentication: { - token: '123456', - }, +// Normalize GoTrue email_action_type to our template keys. +function normalizeActionType(actionType: string): string { + if (actionType.startsWith('email_change')) return 'email_change' + return actionType } -// Preview endpoint handler - returns rendered HTML without sending email -async function handlePreview(req: Request): Promise { - const previewCorsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, content-type', - } - - if (req.method === 'OPTIONS') { - return new Response(null, { headers: previewCorsHeaders }) - } - - const apiKey = Deno.env.get('LOVABLE_API_KEY') - const authHeader = req.headers.get('Authorization') - - if (!apiKey || authHeader !== `Bearer ${apiKey}`) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { ...previewCorsHeaders, 'Content-Type': 'application/json' }, - }) - } - - let type: string - try { - const body = await req.json() - type = body.type - } catch (error) { - return new Response(JSON.stringify({ error: 'Invalid JSON in request body' }), { - status: 400, - headers: { ...previewCorsHeaders, 'Content-Type': 'application/json' }, - }) - } - - const EmailTemplate = EMAIL_TEMPLATES[type] - - if (!EmailTemplate) { - return new Response(JSON.stringify({ error: `Unknown email type: ${type}` }), { - status: 400, - headers: { ...previewCorsHeaders, 'Content-Type': 'application/json' }, - }) - } - - const sampleData = SAMPLE_DATA[type] || {} - const html = await renderAsync(React.createElement(EmailTemplate, sampleData)) - - return new Response(html, { - status: 200, - headers: { ...previewCorsHeaders, 'Content-Type': 'text/html; charset=utf-8' }, +// Build the GoTrue verification URL from the hashed token. Hitting this URL +// consumes the token and then redirects the user to redirect_to. +function buildConfirmationUrl(supabaseUrl: string, emailData: any): string { + const params = new URLSearchParams({ + token: emailData.token_hash ?? '', + type: emailData.email_action_type ?? '', }) + if (emailData.redirect_to) params.set('redirect_to', emailData.redirect_to) + return `${supabaseUrl.replace(/\/$/, '')}/auth/v1/verify?${params.toString()}` } -// Webhook handler - verifies signature and sends email async function handleWebhook(req: Request): Promise { - const apiKey = Deno.env.get('LOVABLE_API_KEY') - - if (!apiKey) { - console.error('LOVABLE_API_KEY not configured') + const hookSecret = Deno.env.get('SEND_EMAIL_HOOK_SECRET') + if (!hookSecret) { + console.error('SEND_EMAIL_HOOK_SECRET not configured') return new Response( JSON.stringify({ error: 'Server configuration error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } - // Verify signature + timestamp, then parse payload. - let payload: any - let run_id = '' + const payloadRaw = await req.text() + const headers = Object.fromEntries(req.headers) + + // Verify the Standard Webhooks signature. The library expects the base64 + // secret without the "v1,whsec_" prefix Supabase displays. + let verified: any try { - const verified = await verifyWebhookRequest({ - req, - secret: apiKey, - parser: parseEmailWebhookPayload, - }) - payload = verified.payload - run_id = payload.run_id + const wh = new Webhook(hookSecret.replace(/^v1,whsec_/, '')) + verified = wh.verify(payloadRaw, headers) } catch (error) { - if (error instanceof WebhookError) { - switch (error.code) { - case 'invalid_signature': - case 'missing_timestamp': - case 'invalid_timestamp': - case 'stale_timestamp': - console.error('Invalid webhook signature', { error: error.message }) - return new Response(JSON.stringify({ error: 'Invalid signature' }), { - status: 401, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }) - case 'invalid_payload': - case 'invalid_json': - console.error('Invalid webhook payload', { error: error.message }) - return new Response( - JSON.stringify({ error: 'Invalid webhook payload' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ) - } - } - - console.error('Webhook verification failed', { error }) + console.error('Auth hook signature verification failed', { + error: error instanceof Error ? error.message : String(error), + }) return new Response( - JSON.stringify({ error: 'Invalid webhook payload' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + JSON.stringify({ error: 'Invalid signature' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } - if (!run_id) { - console.error('Webhook payload missing run_id') - return new Response( - JSON.stringify({ error: 'Invalid webhook payload' }), - { - status: 400, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - } - ) - } + const user = verified.user + const emailData = verified.email_data + const recipient: string | undefined = user?.email + const rawActionType: string = emailData?.email_action_type ?? '' + const emailType = normalizeActionType(rawActionType) - if (payload.version !== '1') { - console.error('Unsupported payload version', { version: payload.version, run_id }) - return new Response( - JSON.stringify({ error: `Unsupported payload version: ${payload.version}` }), - { - status: 400, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - } - ) - } - - // The email action type is in payload.data.action_type (e.g., "signup", "recovery") - // payload.type is the hook event type ("auth") - const emailType = payload.data.action_type - console.log('Received auth event', { emailType, email: payload.data.email, run_id }) + console.log('Received auth send-email hook', { emailType, rawActionType, recipient }) const EmailTemplate = EMAIL_TEMPLATES[emailType] - if (!EmailTemplate) { - console.error('Unknown email type', { emailType, run_id }) + if (!EmailTemplate || !recipient) { + console.error('Unknown email type or missing recipient', { emailType, hasRecipient: !!recipient }) return new Response( - JSON.stringify({ error: `Unknown email type: ${emailType}` }), + JSON.stringify({ error: `Unsupported email action type: ${rawActionType}` }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } - // Build template props from payload.data (HookData structure) + const supabaseUrl = Deno.env.get('SUPABASE_URL')! + const confirmationUrl = buildConfirmationUrl(supabaseUrl, emailData) + const templateProps = { siteName: SITE_NAME, siteUrl: `https://${ROOT_DOMAIN}`, - recipient: payload.data.email, - confirmationUrl: payload.data.url, - token: payload.data.token, - email: payload.data.email, - newEmail: payload.data.new_email, + recipient, + confirmationUrl, + token: emailData?.token, + email: recipient, + newEmail: user?.new_email ?? recipient, } - // Render React Email to HTML and plain text const html = await renderAsync(React.createElement(EmailTemplate, templateProps)) const text = await renderAsync(React.createElement(EmailTemplate, templateProps), { plainText: true, }) - // Enqueue email for async processing by the dispatcher (process-email-queue). const supabase = createClient( - Deno.env.get('SUPABASE_URL')!, + supabaseUrl, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ) @@ -247,18 +139,16 @@ async function handleWebhook(req: Request): Promise { await supabase.from('email_send_log').insert({ message_id: messageId, template_name: emailType, - recipient_email: payload.data.email, + recipient_email: recipient, status: 'pending', }) const { error: enqueueError } = await supabase.rpc('enqueue_email', { queue_name: 'auth_emails', payload: { - run_id, message_id: messageId, - to: payload.data.email, + to: recipient, from: FROM_ADDRESS, - sender_domain: SENDER_DOMAIN, subject: EMAIL_SUBJECTS[emailType] || 'Notification', html, text, @@ -269,11 +159,11 @@ async function handleWebhook(req: Request): Promise { }) if (enqueueError) { - console.error('Failed to enqueue auth email', { error: enqueueError, run_id, emailType }) + console.error('Failed to enqueue auth email', { error: enqueueError, emailType }) await supabase.from('email_send_log').insert({ message_id: messageId, template_name: emailType, - recipient_email: payload.data.email, + recipient_email: recipient, status: 'failed', error_message: 'Failed to enqueue email', }) @@ -283,32 +173,23 @@ async function handleWebhook(req: Request): Promise { }) } - console.log('Auth email enqueued', { emailType, email: payload.data.email, run_id }) + console.log('Auth email enqueued', { emailType, recipient }) + // Supabase expects a 200 with an empty/again-acknowledged body on success. return new Response( - JSON.stringify({ success: true, queued: true }), + JSON.stringify({}), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } Deno.serve(async (req) => { - const url = new URL(req.url) - - // Handle CORS preflight for main endpoint if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }) } - - // Route to preview handler for /preview path - if (url.pathname.endsWith('/preview')) { - return handlePreview(req) - } - - // Main webhook handler try { return await handleWebhook(req) } catch (error) { - console.error('Webhook handler error:', error) + console.error('Auth hook handler error:', error) const message = error instanceof Error ? error.message : 'Unknown error' return new Response(JSON.stringify({ error: message }), { status: 500, diff --git a/supabase/functions/process-email-queue/index.ts b/supabase/functions/process-email-queue/index.ts index ffa4672..80defa6 100644 --- a/supabase/functions/process-email-queue/index.ts +++ b/supabase/functions/process-email-queue/index.ts @@ -1,5 +1,47 @@ -import { sendLovableEmail } from 'npm:@lovable.dev/email-js' import { createClient } from 'npm:@supabase/supabase-js@2' +import { sendSmtpMessage, type SmtpSender } from '../_shared/smtp-send.ts' +import { sendViaHostingerMail, type HostingerMailConfig } from '../_shared/hostinger-mail.ts' + +// Which sender identity automated email is sent from. Must be an active row in +// public.email_senders that the SMTP account is authorized to send as. Override +// per-environment with the AUTOMATED_EMAIL_FROM secret. +// NOTE: no-reply@avriamail.com (Office365) is the sender with currently-valid +// SMTP credentials — it is the address that has actually been delivering mail. +// The Hostinger mail@avriacam.com mailbox rejects its stored password (SMTP 535) +// and must have its password re-entered in Email Settings before it can be used. +const DEFAULT_AUTOMATED_FROM = 'no-reply@avriamail.com' + +// Load (and shape) the SMTP sender used for all queued automated email. +async function loadAutomatedSender( + supabase: ReturnType +): Promise { + const fromEmail = Deno.env.get('AUTOMATED_EMAIL_FROM') || DEFAULT_AUTOMATED_FROM + const { data, error } = await supabase + .from('email_senders') + .select('sender_name, email_address, smtp_host, smtp_port, smtp_username, smtp_password, use_tls, use_ssl') + .eq('is_active', true) + .eq('email_address', fromEmail) + .order('verified', { ascending: false }) + .order('is_default', { ascending: false }) + .order('updated_at', { ascending: false }) + .limit(1) + .maybeSingle() + + if (error) throw new Error(`Failed to load SMTP sender '${fromEmail}': ${error.message}`) + if (!data) throw new Error(`No active email_senders row for '${fromEmail}'`) + + const port = Number(data.smtp_port ?? 587) + return { + host: data.smtp_host as string, + port, + username: data.smtp_username as string, + password: data.smtp_password as string, + use_ssl: port === 465 ? true : Boolean(data.use_ssl), + use_tls: port === 465 ? false : (data.use_tls ?? port === 587), + fromEmail: data.email_address as string, + fromName: (data.sender_name as string) || undefined, + } +} const MAX_RETRIES = 5 const DEFAULT_BATCH_SIZE = 10 @@ -79,11 +121,10 @@ async function moveToDlq( } Deno.serve(async (req) => { - const apiKey = Deno.env.get('LOVABLE_API_KEY') const supabaseUrl = Deno.env.get('SUPABASE_URL') const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') - if (!apiKey || !supabaseUrl || !supabaseServiceKey) { + if (!supabaseUrl || !supabaseServiceKey) { console.error('Missing required environment variables') return new Response( JSON.stringify({ error: 'Server configuration error' }), @@ -113,6 +154,31 @@ Deno.serve(async (req) => { const supabase = createClient(supabaseUrl, supabaseServiceKey) + // Resolve the SMTP sender once per run (shared across the whole batch). + let smtpSender: SmtpSender + try { + smtpSender = await loadAutomatedSender(supabase) + } catch (senderErr) { + console.error('Cannot load automated email sender', { error: senderErr }) + return new Response( + JSON.stringify({ error: 'Email sender not configured', detail: String(senderErr) }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ) + } + + // Base URL for the one-click List-Unsubscribe link. + const unsubscribeBase = `${supabaseUrl.replace(/\/$/, '')}/functions/v1/handle-email-unsubscribe` + + // Preferred transport: Hostinger Email API (when configured via secrets). + // Falls back to SMTP automatically on any API error, so there is no outage + // while the secrets are being set or if the API has a transient failure. + const hostingerToken = Deno.env.get('HOSTINGER_MAIL_API_TOKEN') + const hostingerResourceId = Deno.env.get('HOSTINGER_MAIL_RESOURCE_ID') + const hostinger: HostingerMailConfig | null = + hostingerToken && hostingerResourceId + ? { token: hostingerToken, mailboxResourceId: hostingerResourceId } + : null + // 1. Check rate-limit cooldown and read queue config const { data: state } = await supabase .from('email_send_state') @@ -249,26 +315,38 @@ Deno.serve(async (req) => { } try { - await sendLovableEmail( - { - run_id: payload.run_id, - to: payload.to, - from: payload.from, - sender_domain: payload.sender_domain, - subject: payload.subject, - html: payload.html, - text: payload.text, - purpose: payload.purpose, - label: payload.label, - idempotency_key: payload.idempotency_key, - unsubscribe_token: payload.unsubscribe_token, - message_id: payload.message_id, - }, - // sendUrl is optional — when LOVABLE_SEND_URL is not set, the library - // falls back to the default Lovable API endpoint (https://api.lovable.dev). - // Set LOVABLE_SEND_URL as a Supabase secret to override (e.g. for local dev). - { apiKey, sendUrl: Deno.env.get('LOVABLE_SEND_URL') } - ) + // Preferred: Hostinger Email API. Fall back to the project's own SMTP + // sender (Lovable-free) on any API error so delivery keeps flowing. + let sentVia = 'smtp' + const sendSmtp = () => sendSmtpMessage(smtpSender, { + to: payload.to as string, + subject: payload.subject as string, + html: payload.html as string, + text: payload.text as string | undefined, + unsubscribeUrl: payload.unsubscribe_token + ? `${unsubscribeBase}?token=${payload.unsubscribe_token}` + : undefined, + }) + + if (hostinger) { + try { + await sendViaHostingerMail(hostinger, { + to: payload.to as string, + subject: payload.subject as string, + html: payload.html as string, + text: payload.text as string | undefined, + }) + sentVia = 'hostinger-api' + } catch (apiErr) { + console.warn('Hostinger API send failed — falling back to SMTP', { + msg_id: msg.msg_id, + error: apiErr instanceof Error ? apiErr.message : String(apiErr), + }) + await sendSmtp() + } + } else { + await sendSmtp() + } // Log success await supabase.from('email_send_log').insert({ @@ -276,6 +354,7 @@ Deno.serve(async (req) => { template_name: payload.label || queue, recipient_email: payload.to, status: 'sent', + error_message: `via:${sentVia}`, }) // Delete from queue