Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ type Config struct {
// and fail token validation (deterministic disable for rollback).
FamilyBindingsEnabled bool

// Email-feedback webhook secrets. Each provider authenticates its
// callbacks differently — these env vars give the handler the shared
// secret (Brevo, SendGrid) or topic ARN (SES via SNS) it needs to
// reject unsigned traffic. All three may be empty in local dev; the
// handlers then 401 every request, which is the correct fail-closed
// behavior for an unauthenticated public endpoint.
BrevoWebhookSecret string // BREVO_WEBHOOK_SECRET — shared secret for HMAC-SHA256 verification
SESSNSTopicARN string // SES_SNS_SUBSCRIPTION_ARN — expected SNS TopicArn on inbound notifications
SendGridWebhookKey string // SENDGRID_WEBHOOK_PUBLIC_KEY — ECDSA public key (reserved; SendGrid is stubbed today)

// AdminPathPrefix is the unguessable URL segment under which the
// founder-only customer-management endpoints register. When set,
// admin routes mount at /api/v1/<prefix>/customers/... instead of
Expand Down Expand Up @@ -228,6 +238,14 @@ func Load() *Config {
cfg.ObjectStoreBackend = "shared-key"
}
}
// Email-feedback webhook auth secrets. Empty values → handler rejects
// every inbound webhook (fail-closed). Operators MUST set these in
// production; absence is logged via the BrevoWebhookSecret_set etc.
// flags emitted by logStartupConfig.
cfg.BrevoWebhookSecret = os.Getenv("BREVO_WEBHOOK_SECRET")
cfg.SESSNSTopicARN = os.Getenv("SES_SNS_SUBSCRIPTION_ARN")
cfg.SendGridWebhookKey = os.Getenv("SENDGRID_WEBHOOK_PUBLIC_KEY")

cfg.DeployDomain = getenv("DEPLOY_DOMAIN", "instant.dev")
cfg.ComputeProvider = getenv("COMPUTE_PROVIDER", "noop")
cfg.KubeNamespaceApps = getenv("KUBE_NAMESPACE_APPS", "instant-apps")
Expand Down
45 changes: 45 additions & 0 deletions internal/db/migrations/025_email_events.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-- Migration: 022_email_events — provider-side delivery feedback (bounces,
-- unsubscribes, spam complaints, soft bounces) normalized into a single
-- table so the worker's email forwarder can suppress sends to addresses
-- that have already told us "stop".
--
-- WHY: every email we send to a known-bouncing address erodes sender
-- reputation; every nudge to someone who unsubscribed is a CAN-SPAM /
-- GDPR risk. Today instanode has zero surface for provider feedback —
-- this table is the ingestion point.
--
-- Sources: Brevo + SES (SNS) webhooks today, SendGrid stub for parity.
-- Schema is provider-shaped enough to add columns later (e.g. bounce
-- subtype) without breaking existing readers.
--
-- Idempotency: providers retry on slow responses, so the same delivery
-- event can arrive twice. We dedupe on the four-tuple
-- (provider, event_type, email, raw->>'message_id') via a partial UNIQUE
-- index so retries are silent no-ops. The "message_id" key is what every
-- supported provider stamps on the raw payload — see the parser in
-- handlers/email_webhooks.go for the per-provider extraction.
CREATE TABLE IF NOT EXISTS email_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider TEXT NOT NULL, -- 'brevo' | 'ses' | 'sendgrid'
event_type TEXT NOT NULL, -- 'bounce' | 'unsubscribe' | 'spam_complaint' | 'soft_bounce'
email TEXT NOT NULL,
reason TEXT, -- provider-specific text, optional
raw JSONB NOT NULL, -- full provider payload, for audit
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Suppression-query index: the worker forwarder reads
-- WHERE email = $1 AND event_type IN (...) AND created_at > now() - interval '365 days'
-- before every send. The composite (email, event_type, created_at DESC)
-- means the worker's lookup is a single index range scan even when the
-- table grows to millions of rows.
CREATE INDEX IF NOT EXISTS idx_email_events_email_type
ON email_events(email, event_type, created_at DESC);

-- Idempotency / dedupe index. message_id is the provider-stamped delivery
-- id (Brevo: "message-id"; SES: "mail.messageId"; SendGrid: "sg_message_id").
-- Partial index — only when message_id is present in the payload, so the
-- table still accepts events from any future provider that omits it.
CREATE UNIQUE INDEX IF NOT EXISTS uq_email_events_dedupe
ON email_events(provider, event_type, email, (raw->>'message_id'))
WHERE raw->>'message_id' IS NOT NULL;
Loading