mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
163 lines
5.0 KiB
TypeScript
163 lines
5.0 KiB
TypeScript
import { createClient } from 'npm:@supabase/supabase-js@2'
|
|
import { WebhookError, verifyWebhookRequest } from 'npm:@lovable.dev/webhooks-js'
|
|
|
|
// Suppression event payload sent by the Go API when Mailgun reports
|
|
// a bounce, complaint, or unsubscribe.
|
|
interface SuppressionPayload {
|
|
email: string
|
|
reason: 'bounce' | 'complaint' | 'unsubscribe'
|
|
message_id?: string
|
|
metadata?: Record<string, unknown>
|
|
is_retry: boolean
|
|
retry_count: number
|
|
}
|
|
|
|
function parseSuppressionPayload(body: string): SuppressionPayload {
|
|
const parsed = JSON.parse(body)
|
|
if (!parsed.data) {
|
|
throw new Error('Missing data field in payload')
|
|
}
|
|
const data = parsed.data as SuppressionPayload
|
|
if (!data.email || !data.reason) {
|
|
throw new Error('Missing required fields: email, reason')
|
|
}
|
|
return data
|
|
}
|
|
|
|
function jsonResponse(data: Record<string, unknown>, status = 200): Response {
|
|
return new Response(JSON.stringify(data), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method !== 'POST') {
|
|
return jsonResponse({ error: 'Method not allowed' }, 405)
|
|
}
|
|
|
|
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) {
|
|
console.error('Missing required environment variables')
|
|
return jsonResponse({ error: 'Server configuration error' }, 500)
|
|
}
|
|
|
|
// Verify HMAC signature using the Lovable API Key (same as auth-email-hook)
|
|
let payload: SuppressionPayload
|
|
try {
|
|
const verified = await verifyWebhookRequest({
|
|
req,
|
|
secret: apiKey,
|
|
parser: parseSuppressionPayload,
|
|
})
|
|
payload = verified.payload
|
|
} catch (error) {
|
|
if (error instanceof WebhookError) {
|
|
switch (error.code) {
|
|
case 'invalid_signature':
|
|
console.error('Invalid webhook signature')
|
|
return jsonResponse({ error: 'Invalid signature' }, 401)
|
|
case 'stale_timestamp':
|
|
console.error('Stale webhook timestamp')
|
|
return jsonResponse({ error: 'Stale timestamp' }, 401)
|
|
case 'invalid_payload':
|
|
case 'invalid_json':
|
|
console.error('Invalid payload', { code: error.code })
|
|
return jsonResponse({ error: 'Invalid payload' }, 400)
|
|
default:
|
|
console.error('Webhook verification failed', {
|
|
code: error.code,
|
|
message: error.message,
|
|
})
|
|
return jsonResponse({ error: 'Verification failed' }, 401)
|
|
}
|
|
}
|
|
console.error('Unexpected error during verification', { error })
|
|
return jsonResponse({ error: 'Internal error' }, 500)
|
|
}
|
|
|
|
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
|
const normalizedEmail = payload.email.toLowerCase()
|
|
|
|
// 1. Upsert to suppressed_emails (idempotent — safe for retries)
|
|
const { error: suppressError } = await supabase
|
|
.from('suppressed_emails')
|
|
.upsert(
|
|
{
|
|
email: normalizedEmail,
|
|
reason: payload.reason,
|
|
metadata: payload.metadata ?? null,
|
|
},
|
|
{ onConflict: 'email' },
|
|
)
|
|
|
|
if (suppressError) {
|
|
console.error('Failed to upsert suppressed email', {
|
|
error: suppressError,
|
|
email_redacted: normalizedEmail[0] + '***@' + normalizedEmail.split('@')[1],
|
|
})
|
|
return jsonResponse({ error: 'Failed to write suppression' }, 500)
|
|
}
|
|
|
|
// 2. Append a new log entry for the suppression event (never update existing rows)
|
|
const sendLogStatus = mapReasonToStatus(payload.reason)
|
|
const sendLogMessage = mapReasonToMessage(payload.reason)
|
|
|
|
const { error: insertError } = await supabase
|
|
.from('email_send_log')
|
|
.insert({
|
|
message_id: payload.message_id ?? null,
|
|
template_name: 'system',
|
|
recipient_email: normalizedEmail,
|
|
status: sendLogStatus,
|
|
error_message: sendLogMessage,
|
|
metadata: payload.metadata ?? null,
|
|
})
|
|
|
|
if (insertError) {
|
|
// Non-fatal — log and continue. The suppression was already recorded.
|
|
console.warn('Failed to insert email_send_log', {
|
|
error: insertError,
|
|
})
|
|
}
|
|
|
|
console.log('Suppression processed', {
|
|
email_redacted: normalizedEmail[0] + '***@' + normalizedEmail.split('@')[1],
|
|
reason: payload.reason,
|
|
is_retry: payload.is_retry,
|
|
retry_count: payload.retry_count,
|
|
has_message_id: !!payload.message_id,
|
|
})
|
|
|
|
return jsonResponse({ success: true })
|
|
})
|
|
|
|
function mapReasonToStatus(
|
|
reason: string,
|
|
): 'bounced' | 'complained' | 'suppressed' {
|
|
switch (reason) {
|
|
case 'bounce':
|
|
return 'bounced'
|
|
case 'complaint':
|
|
return 'complained'
|
|
default:
|
|
return 'suppressed'
|
|
}
|
|
}
|
|
|
|
function mapReasonToMessage(reason: string): string {
|
|
switch (reason) {
|
|
case 'bounce':
|
|
return 'Permanent bounce — email address is invalid or rejected'
|
|
case 'complaint':
|
|
return 'Spam complaint — recipient marked email as spam'
|
|
case 'unsubscribe':
|
|
return 'Recipient unsubscribed'
|
|
default:
|
|
return 'Email suppressed'
|
|
}
|
|
}
|