mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user