/* |-------------------------------------------------------------------------- | Agência PSI — Edge Function: process-email-queue |-------------------------------------------------------------------------- | Processa a notification_queue para channel = 'email'. | | Fluxo por item: | 1. Busca pendentes (channel='email', status='pendente', scheduled_at <= now) | 2. Marca como 'processando' (lock otimista) | 3. Busca canal SMTP em notification_channels | 4. Resolve template: COALESCE(tenant, global) | 5. Renderiza variáveis e condicionais {{#if}} | 6. Envia via SMTP (Deno raw TCP com STARTTLS) | 7. Atualiza queue e insere em notification_logs | 8. Em erro: retry com backoff ou marca 'falhou' |-------------------------------------------------------------------------- */ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { SmtpClient } from 'https://deno.land/x/smtp@v0.7.0/mod.ts' const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS', } // ── Template renderer ────────────────────────────────────────── function resolveVariable(key: string, vars: Record): unknown { if (!key.includes('.')) return vars[key] return key.split('.').reduce((obj: any, part) => obj?.[part], vars) } function renderTemplate(template: string, variables: Record): string { if (!template) return '' let result = template // Blocos condicionais {{#if var}}...{{/if}} result = result.replace( /\{\{#if\s+([\w.]+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (_, key, content) => resolveVariable(key, variables) ? content : '' ) // Substituições simples {{variavel}} result = result.replace(/\{\{([\w.]+)\}\}/g, (_, key) => { const value = resolveVariable(key, variables) return value !== undefined && value !== null ? String(value) : '' }) return result } function stripHtml(html: string): string { return html .replace(//gi, '\n') .replace(/<\/p>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/\n{3,}/g, '\n\n') .trim() } // ── SMTP sender ──────────────────────────────────────────────── interface SmtpCredentials { host: string port: number username: string password: string from_email: string from_name?: string tls?: boolean } async function sendEmail( creds: SmtpCredentials, to: string, subject: string, bodyHtml: string, bodyText: string ): Promise<{ messageId?: string }> { const client = new SmtpClient() const connectConfig = { hostname: creds.host, port: creds.port, username: creds.username, password: creds.password, } // Porta 465 = TLS direto, outras = STARTTLS if (creds.port === 465 || creds.tls === true) { await client.connectTLS(connectConfig) } else { await client.connect(connectConfig) } const fromHeader = creds.from_name ? `${creds.from_name} <${creds.from_email}>` : creds.from_email await client.send({ from: fromHeader, to, subject, content: bodyText, html: bodyHtml, }) await client.close() return { messageId: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } } // ── Main handler ─────────────────────────────────────────────── Deno.serve(async (req: Request) => { if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }) } const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ) const now = new Date().toISOString() // 1. Busca itens pendentes de email const { data: items, error: fetchErr } = await supabase .from('notification_queue') .select('*') .eq('channel', 'email') .eq('status', 'pendente') .lte('scheduled_at', now) .lt('attempts', 5) // respeita max_attempts padrão .order('scheduled_at', { ascending: true }) .limit(20) if (fetchErr) { return new Response( JSON.stringify({ error: fetchErr.message }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } if (!items || items.length === 0) { return new Response( JSON.stringify({ message: 'Nenhum email na fila', processed: 0 }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } const results: Array<{ id: string; status: string; error?: string }> = [] for (const item of items) { // 2. Lock otimista — marca como processando const { error: lockErr } = await supabase .from('notification_queue') .update({ status: 'processando', attempts: item.attempts + 1 }) .eq('id', item.id) .eq('status', 'pendente') // garante que outro worker não pegou if (lockErr) { results.push({ id: item.id, status: 'skip', error: 'lock failed' }) continue } try { // 3. Busca canal SMTP const { data: channel, error: chErr } = await supabase .from('notification_channels') .select('credentials, sender_address, provider') .eq('owner_id', item.owner_id) .eq('channel', 'email') .eq('is_active', true) .is('deleted_at', null) .single() if (chErr || !channel) { throw new Error('Canal SMTP não encontrado ou inativo para este owner') } // 4. Resolve template: tenant → global fallback const { data: tenantTpl } = await supabase .from('email_templates_tenant') .select('subject, body_html, body_text, enabled') .eq('tenant_id', item.tenant_id) .eq('owner_id', item.owner_id) .eq('template_key', item.template_key) .maybeSingle() // Se tenant desabilitou o template → ignorar if (tenantTpl && tenantTpl.enabled === false) { await supabase .from('notification_queue') .update({ status: 'ignorado' }) .eq('id', item.id) await supabase.from('notification_logs').insert({ tenant_id: item.tenant_id, owner_id: item.owner_id, queue_id: item.id, agenda_evento_id: item.agenda_evento_id, patient_id: item.patient_id, channel: 'email', template_key: item.template_key, schedule_key: item.schedule_key, recipient_address: item.recipient_address, status: 'failed', failure_reason: 'Template desabilitado pelo tenant', created_at: now, }) results.push({ id: item.id, status: 'ignorado' }) continue } // Busca global const { data: globalTpl } = await supabase .from('email_templates_global') .select('subject, body_html, body_text') .eq('key', item.template_key) .eq('is_active', true) .single() if (!globalTpl) { throw new Error(`Template global não encontrado: ${item.template_key}`) } // COALESCE: tenant sobrescreve global quando não-null const resolvedSubject = tenantTpl?.subject ?? globalTpl.subject const resolvedBodyHtml = tenantTpl?.body_html ?? globalTpl.body_html const resolvedBodyText = tenantTpl?.body_text ?? globalTpl.body_text // 5. Renderiza variáveis const vars = item.resolved_vars || {} const finalSubject = renderTemplate(resolvedSubject, vars) const finalBodyHtml = renderTemplate(resolvedBodyHtml, vars) const finalBodyText = resolvedBodyText ? renderTemplate(resolvedBodyText, vars) : stripHtml(finalBodyHtml) // 6. Envia via SMTP const creds = channel.credentials as SmtpCredentials if (!creds.from_email && channel.sender_address) { creds.from_email = channel.sender_address } const sendResult = await sendEmail( creds, item.recipient_address, finalSubject, finalBodyHtml, finalBodyText ) // 7. Sucesso — atualiza queue await supabase .from('notification_queue') .update({ status: 'enviado', sent_at: new Date().toISOString(), provider_message_id: sendResult.messageId || null, }) .eq('id', item.id) // Insere log de sucesso await supabase.from('notification_logs').insert({ tenant_id: item.tenant_id, owner_id: item.owner_id, queue_id: item.id, agenda_evento_id: item.agenda_evento_id, patient_id: item.patient_id, channel: 'email', template_key: item.template_key, schedule_key: item.schedule_key, recipient_address: item.recipient_address, resolved_message: finalBodyHtml, resolved_vars: vars, status: 'sent', provider: channel.provider || 'smtp', provider_message_id: sendResult.messageId || null, sent_at: new Date().toISOString(), }) results.push({ id: item.id, status: 'enviado' }) } catch (err) { // 8. Erro — retry com backoff ou falha definitiva const attempts = item.attempts + 1 const maxAttempts = item.max_attempts || 5 const isExhausted = attempts >= maxAttempts const retryDelay = attempts * 2 * 60 * 1000 // backoff: attempts * 2 min const nextRetryAt = isExhausted ? null : new Date(Date.now() + retryDelay).toISOString() await supabase .from('notification_queue') .update({ status: isExhausted ? 'falhou' : 'pendente', last_error: err.message, next_retry_at: nextRetryAt, }) .eq('id', item.id) // Log de falha await supabase.from('notification_logs').insert({ tenant_id: item.tenant_id, owner_id: item.owner_id, queue_id: item.id, agenda_evento_id: item.agenda_evento_id, patient_id: item.patient_id, channel: 'email', template_key: item.template_key, schedule_key: item.schedule_key, recipient_address: item.recipient_address, status: 'failed', failure_reason: err.message, failed_at: new Date().toISOString(), }) results.push({ id: item.id, status: isExhausted ? 'falhou' : 'retry', error: err.message }) } } const sent = results.filter(r => r.status === 'enviado').length const failed = results.filter(r => r.status === 'falhou').length const retried = results.filter(r => r.status === 'retry').length const ignored = results.filter(r => r.status === 'ignorado').length return new Response( JSON.stringify({ processed: results.length, sent, failed, retried, ignored, details: results, }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) })