Migrate email pipeline off Lovable + branded auth emails

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 23:07:26 -04:00
parent 5bf2a5887e
commit b1486a0b2a
10 changed files with 889 additions and 211 deletions
+102 -23
View File
@@ -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<typeof createClient>
): Promise<SmtpSender> {
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