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
+
+ 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) => (
+
+
+
+ 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
+
+ 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
+
+ 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(/