mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,162 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user