diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index 2c4b988945..e340de1c5e 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -87,6 +87,7 @@ import { app as on_version_delete } from '../../supabase/functions/_backend/trig import { app as on_version_update } from '../../supabase/functions/_backend/triggers/on_version_update.ts' import { app as pluginNotifications } from '../../supabase/functions/_backend/triggers/plugin_notifications.ts' import { app as queue_consumer } from '../../supabase/functions/_backend/triggers/queue_consumer.ts' +import { app as send_email } from '../../supabase/functions/_backend/triggers/send_email.ts' import { app as stripe_event } from '../../supabase/functions/_backend/triggers/stripe_event.ts' import { app as webhook_delivery } from '../../supabase/functions/_backend/triggers/webhook_delivery.ts' import { app as webhook_dispatcher } from '../../supabase/functions/_backend/triggers/webhook_dispatcher.ts' @@ -213,6 +214,7 @@ appTriggers.route('/cron_stat_org', cron_stat_org) appTriggers.route('/cron_sync_sub', cron_sync_sub) appTriggers.route('/cron_rollout_auto_pause', cron_rollout_auto_pause) appTriggers.route('/queue_consumer', queue_consumer) +appTriggers.route('/send_email', send_email) appTriggers.route('/webhook_delivery', webhook_delivery) appTriggers.route('/webhook_dispatcher', webhook_dispatcher) diff --git a/supabase/config.toml b/supabase/config.toml index 837747bfce..f6c1efe90f 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -203,7 +203,9 @@ subject = "[Capgo]: Multi-factor authentication method removed from your Capgo.a content_path = "./templates/mfa_factor_unenrolled_notification.html" # Custom email templates kept in ./templates for consistency, but not managed by Supabase. -# These are sent through Bento: +# Auth templates above are GoTrue fallbacks. Live auth mail uses Bento events from +# the Send Email hook (see supabase/templates/bento/auth_*.html). +# Other Bento templates: # - invite_new_user_to_org.html # subject = "[Capgo]: Join {{ event.details.org_name }} org & create account" # - invite_existing_user_to_org.html @@ -230,6 +232,13 @@ max_frequency = "5s" # Force log out if the user has been inactive longer than the specified duration. # inactivity_timeout = "8h" +# Auth emails are enqueued by this hook and sent as Bento transactional events. +# Production: enable the same Send Email hook in the dashboard, pointing at +# pg-functions://postgres/public/hook_send_email. Do not use SMTP. +[auth.hook.send_email] +enabled = true +uri = "pg-functions://postgres/public/hook_send_email" + # This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. # [auth.hook.custom_access_token] # enabled = false diff --git a/supabase/functions/_backend/triggers/send_email.ts b/supabase/functions/_backend/triggers/send_email.ts new file mode 100644 index 0000000000..3512491169 --- /dev/null +++ b/supabase/functions/_backend/triggers/send_email.ts @@ -0,0 +1,54 @@ +import type { GoTrueSendEmailEvent } from '../utils/auth_email.ts' +import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import { Hono } from 'hono/tiny' +import { + authEmailDeliveriesFromGoTrueEvent, + buildAuthEmailBentoDetails, + getAuthEmailBentoEvent, +} from '../utils/auth_email.ts' +import { isBentoConfigured, trackBentoEvent } from '../utils/bento.ts' +import { BRES, middlewareAPISecret, parseBody, quickError, simpleError } from '../utils/hono.ts' +import { cloudlog } from '../utils/logging.ts' +import { getEnv } from '../utils/utils.ts' + +export const app = new Hono() + +app.post('/', middlewareAPISecret, async (c) => { + const deliveries = authEmailDeliveriesFromGoTrueEvent(await parseBody(c)) + .filter(item => item.payload.email_action_type) + if (deliveries.length === 0) + throw simpleError('invalid_payload', 'Invalid send_email payload') + + if (!isBentoConfigured(c)) { + quickError(500, 'bento_not_configured', 'Bento is not configured for auth email delivery', { + email_action_type: deliveries[0]?.payload.email_action_type, + }) + } + + const supabaseUrl = getEnv(c, 'SUPABASE_URL') + const webappUrl = getEnv(c, 'WEBAPP_URL') + + for (const delivery of deliveries) { + const details = buildAuthEmailBentoDetails(delivery.payload, supabaseUrl, webappUrl) + const eventName = getAuthEmailBentoEvent(delivery.payload.email_action_type) + + cloudlog({ + requestId: c.get('requestId'), + message: 'send_email queue message', + email_action_type: delivery.payload.email_action_type, + event: eventName, + has_confirmation_url: Boolean(details.confirmation_url), + has_token: Boolean(details.token), + }) + + const result = await trackBentoEvent(c, delivery.email, { ...details }, eventName) + if (result === false) { + quickError(500, 'bento_auth_email_delivery_failed', 'Bento auth email delivery failed', { + email_action_type: delivery.payload.email_action_type, + event: eventName, + }) + } + } + + return c.json(BRES) +}) diff --git a/supabase/functions/_backend/utils/auth_email.ts b/supabase/functions/_backend/utils/auth_email.ts new file mode 100644 index 0000000000..1a81292fe4 --- /dev/null +++ b/supabase/functions/_backend/utils/auth_email.ts @@ -0,0 +1,196 @@ +import { trimTrailingSlashes } from './utils.ts' + +export const AUTH_EMAIL_EVENT_PREFIX = 'auth_' + +export const AUTH_EMAIL_EVENTS = { + email_change: 'auth_email_change', + email_change_current: 'auth_email_change', + email_change_new: 'auth_email_change', + email_changed_notification: 'auth_email_changed_notification', + invite: 'auth_invite', + magiclink: 'auth_magic_link', + mfa_factor_enrolled_notification: 'auth_mfa_factor_enrolled_notification', + mfa_factor_unenrolled_notification: 'auth_mfa_factor_unenrolled_notification', + password_changed_notification: 'auth_password_changed_notification', + reauthentication: 'auth_reauthentication', + recovery: 'auth_recovery', + signup: 'auth_confirmation', +} as const + +export interface GoTrueSendEmailEvent { + email_data?: { + email_action_type?: string + factor_type?: string + new_email?: string + old_email?: string + redirect_to?: string + site_url?: string + token?: string + token_hash?: string + token_hash_new?: string + token_new?: string + } + user?: { + email?: string + new_email?: string + } +} + +export interface AuthEmailPayload { + email: string + email_action_type: string + factor_type?: string + new_email?: string + old_email?: string + redirect_to?: string + site_url?: string + token?: string + token_hash?: string +} + +export interface AuthEmailDelivery { + email: string + payload: AuthEmailPayload +} + +export interface AuthEmailBentoDetails { + confirmation_link: string + confirmation_url: string + email: string + factor_type: string + new_email: string + old_email: string + site_url: string + token: string +} + +function textField(value: string | undefined): string { + return typeof value === 'string' ? value.trim() : '' +} + +function verifyType(emailActionType: string): string { + if (emailActionType === 'email_change_current' || emailActionType === 'email_change_new') + return 'email_change' + return emailActionType +} + +function delivery( + email: string, + event: GoTrueSendEmailEvent, + token: string, + tokenHash: string, +): AuthEmailDelivery { + const emailData = event.email_data ?? {} + const user = event.user ?? {} + return { + email, + payload: { + email: textField(user.email) || email, + email_action_type: verifyType(textField(emailData.email_action_type)), + factor_type: textField(emailData.factor_type), + new_email: textField(user.new_email) || textField(emailData.new_email), + old_email: textField(emailData.old_email), + redirect_to: textField(emailData.redirect_to), + site_url: textField(emailData.site_url), + token, + token_hash: tokenHash, + }, + } +} + +export function authEmailDeliveriesFromGoTrueEvent(event: GoTrueSendEmailEvent): AuthEmailDelivery[] { + const emailData = event.email_data ?? {} + const user = event.user ?? {} + const actionType = textField(emailData.email_action_type) + const currentEmail = textField(user.email) + const newEmail = textField(user.new_email) || textField(emailData.new_email) + const oldEmail = textField(emailData.old_email) + const token = textField(emailData.token) + const tokenNew = textField(emailData.token_new) + const tokenHash = textField(emailData.token_hash) + const tokenHashNew = textField(emailData.token_hash_new) + + if (actionType === 'email_change' && tokenHash && tokenHashNew) { + return [ + delivery(currentEmail, event, token, tokenHashNew), + delivery(newEmail || currentEmail, event, tokenNew, tokenHash), + ].filter(item => item.email) + } + + if (actionType === 'email_change_current') + return [delivery(currentEmail, event, token, tokenHashNew || tokenHash)].filter(item => item.email) + + if (actionType === 'email_change_new' || actionType === 'email_change') { + return [delivery( + newEmail || currentEmail, + event, + tokenNew || token, + tokenHash, + )].filter(item => item.email) + } + + if (actionType === 'email_changed_notification') + return [delivery(oldEmail || currentEmail, event, token, tokenHash)].filter(item => item.email) + + return [delivery(currentEmail, event, token, tokenHash)].filter(item => item.email) +} + +export function getAuthEmailBentoEvent(emailActionType: string): string { + const actionType = textField(emailActionType) + if (!actionType) + return `${AUTH_EMAIL_EVENT_PREFIX}unknown` + + return AUTH_EMAIL_EVENTS[actionType as keyof typeof AUTH_EMAIL_EVENTS] + ?? AUTH_EMAIL_EVENTS[verifyType(actionType) as keyof typeof AUTH_EMAIL_EVENTS] + ?? `${AUTH_EMAIL_EVENT_PREFIX}${actionType}` +} + +export function buildAuthConfirmationUrl( + supabaseUrl: string, + tokenHash: string, + emailActionType: string, + redirectTo: string, +): string { + const baseUrl = trimTrailingSlashes(textField(supabaseUrl)) + const hash = textField(tokenHash) + const actionType = verifyType(textField(emailActionType)) + if (!baseUrl || !hash || !actionType) + return '' + + const params = new URLSearchParams({ + token: hash, + type: actionType, + }) + const redirect = textField(redirectTo) + if (redirect) + params.set('redirect_to', redirect) + + return `${baseUrl}/auth/v1/verify?${params.toString()}` +} + +export function buildAuthEmailBentoDetails( + payload: AuthEmailPayload, + supabaseUrl: string, + webappUrl: string, +): AuthEmailBentoDetails { + const siteUrl = trimTrailingSlashes(textField(webappUrl)) || trimTrailingSlashes(textField(payload.site_url)) + const confirmationUrl = buildAuthConfirmationUrl( + supabaseUrl, + payload.token_hash ?? '', + payload.email_action_type, + payload.redirect_to ?? '', + ) + + return { + confirmation_link: siteUrl && confirmationUrl + ? `${siteUrl}/confirm-signup?confirmation_url=${encodeURIComponent(confirmationUrl)}` + : '', + confirmation_url: confirmationUrl, + email: textField(payload.email), + factor_type: textField(payload.factor_type), + new_email: textField(payload.new_email), + old_email: textField(payload.old_email), + site_url: siteUrl, + token: textField(payload.token), + } +} diff --git a/supabase/functions/triggers/index.ts b/supabase/functions/triggers/index.ts index 83b7d48728..d195f9a4ae 100644 --- a/supabase/functions/triggers/index.ts +++ b/supabase/functions/triggers/index.ts @@ -28,6 +28,7 @@ import { app as on_version_delete } from '../_backend/triggers/on_version_delete import { app as on_version_update } from '../_backend/triggers/on_version_update.ts' import { app as pluginNotifications } from '../_backend/triggers/plugin_notifications.ts' import { app as queue_consumer } from '../_backend/triggers/queue_consumer.ts' +import { app as send_email } from '../_backend/triggers/send_email.ts' import { app as stripe_event } from '../_backend/triggers/stripe_event.ts' import { app as webhook_delivery } from '../_backend/triggers/webhook_delivery.ts' import { app as webhook_dispatcher } from '../_backend/triggers/webhook_dispatcher.ts' @@ -85,6 +86,7 @@ appGlobal.route('/on_organization_delete', on_organization_delete) appGlobal.route('/on_deploy_history_create', on_deploy_history_create) appGlobal.route('/plugin_notifications', pluginNotifications) appGlobal.route('/queue_consumer', queue_consumer) +appGlobal.route('/send_email', send_email) appGlobal.route('/webhook_delivery', webhook_delivery) appGlobal.route('/webhook_dispatcher', webhook_dispatcher) diff --git a/supabase/migrations/20260820101459_auth_send_email_hook_queue.sql b/supabase/migrations/20260820101459_auth_send_email_hook_queue.sql new file mode 100644 index 0000000000..48546f91dd --- /dev/null +++ b/supabase/migrations/20260820101459_auth_send_email_hook_queue.sql @@ -0,0 +1,136 @@ +-- Route GoTrue Send Email hooks through pgmq so auth mail is durable. +-- The queue consumer then emits Bento transactional events. SMTP is not used. +-- +-- Execution model: +-- - Where: GoTrue Send Email hook (once per auth email). Postgres function +-- hook_send_email does O(1) pgmq.send and returns. +-- No Bento/HTTP in the hook. +-- - Frequency: once per signup / recovery / magic link / invite / +-- email change / reauthentication / security notification. +-- Auth retries the hook only if this function returns an error object. +-- - Roles: supabase_auth_admin executes the hook. The function is SECURITY +-- DEFINER (postgres) so pgmq.send does not need grants on pgmq to auth admin. +-- anon / authenticated / PUBLIC cannot execute it. +-- - Cardinality: O(1) insert into pgmq.q_send_email. No table scans. +-- - Drain: high_frequency_queues (every 10s) counts the queue. If it has jobs, +-- it calls queue_consumer. The consumer reads send_email, runs the job, and +-- deletes the message when it succeeds. Failures retry up to MAX_QUEUE_READS = 5. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pgmq.list_queues() + WHERE queue_name = 'send_email' + ) THEN + PERFORM pgmq.create('send_email'); + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION public.hook_send_email(event jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_action_type text; + v_email text; +BEGIN + v_email := btrim(COALESCE(event -> 'user' ->> 'email', '')); + v_action_type := btrim( + COALESCE(event -> 'email_data' ->> 'email_action_type', '') + ); + + IF v_email = '' THEN + RETURN jsonb_build_object( + 'error', jsonb_build_object( + 'http_code', 400, + 'message', 'Send email hook missing user email' + ) + ); + END IF; + + IF v_action_type = '' THEN + RETURN jsonb_build_object( + 'error', jsonb_build_object( + 'http_code', 400, + 'message', 'Send email hook missing email_action_type' + ) + ); + END IF; + + -- Same envelope as other function queues: event goes onto pgmq. Cron later + -- calls the consumer, which reads the job and marks it done. + PERFORM pgmq.send( + 'send_email', + jsonb_build_object( + 'function_name', 'send_email', + 'function_type', 'cloudflare', + 'payload', event + ) + ); + + RETURN '{}'::jsonb; +EXCEPTION + WHEN OTHERS THEN + RETURN jsonb_build_object( + 'error', jsonb_build_object( + 'http_code', 500, + 'message', 'Failed to enqueue auth email' + ) + ); +END; +$$; + +COMMENT ON FUNCTION public.hook_send_email(jsonb) IS +'GoTrue Send Email hook. Enqueues auth mail onto pgmq send_email.'; + +ALTER FUNCTION public.hook_send_email(jsonb) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.hook_send_email(jsonb) FROM public; +REVOKE ALL ON FUNCTION public.hook_send_email(jsonb) FROM anon, authenticated; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_auth_admin') THEN + GRANT USAGE ON SCHEMA public TO supabase_auth_admin; + GRANT EXECUTE ON FUNCTION public.hook_send_email(jsonb) TO supabase_auth_admin; + END IF; +END +$$; + +DO $$ +DECLARE + high_frequency_task_type public.cron_task_type; + high_frequency_target jsonb; +BEGIN + SELECT cron.task_type, cron.target::jsonb + INTO high_frequency_task_type, high_frequency_target + FROM public.cron_tasks AS cron + WHERE cron.name = 'high_frequency_queues' + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Required cron task high_frequency_queues is missing'; + END IF; + + IF high_frequency_task_type + IS DISTINCT FROM 'function_queue'::public.cron_task_type THEN + RAISE EXCEPTION 'Cron task high_frequency_queues must use task type function_queue'; + END IF; + + IF pg_catalog.jsonb_typeof(high_frequency_target) + IS DISTINCT FROM 'array' THEN + RAISE EXCEPTION 'Cron task high_frequency_queues target must be a JSON array'; + END IF; + + IF NOT (high_frequency_target ? 'send_email') THEN + UPDATE public.cron_tasks + SET + target = (high_frequency_target || '["send_email"]'::jsonb)::text, + updated_at = pg_catalog.now() + WHERE name = 'high_frequency_queues'; + END IF; +END; +$$; diff --git a/supabase/templates/bento/auth_email_change.html b/supabase/templates/bento/auth_email_change.html new file mode 100644 index 0000000000..c19f4359c8 --- /dev/null +++ b/supabase/templates/bento/auth_email_change.html @@ -0,0 +1,33 @@ + + + +

Hi,

+ +

+ You have requested to change the email address associated with your Capgo.app account. This request is to update your + email from {{ event.details.email }} to {{ event.details.new_email }}. +

+ +

To confirm this change, please click on the following link:

+ +

+ + Confirm email change + +

+ +

- Martin, Founder of Capgo.app

diff --git a/supabase/templates/bento/auth_email_changed.html b/supabase/templates/bento/auth_email_changed.html new file mode 100644 index 0000000000..bf57735d2a --- /dev/null +++ b/supabase/templates/bento/auth_email_changed.html @@ -0,0 +1,15 @@ + + + +

Hi,

+ +

+ This is a confirmation that the email address for your Capgo.app account has been changed from + {{ event.details.old_email }} to {{ event.details.email }}. +

+ +

If you made this change, no further action is required.

+ +

If you did not make this change, please secure your account immediately and contact Capgo support.

+ +

- Martin, Founder of Capgo.app

diff --git a/supabase/templates/bento/auth_invite.html b/supabase/templates/bento/auth_invite.html new file mode 100644 index 0000000000..11cc7bfdfd --- /dev/null +++ b/supabase/templates/bento/auth_invite.html @@ -0,0 +1,32 @@ + + + +

Hi,

+ +

You have been invited to create a user on Capgo.app.

+ +

To accept the invitation and complete your account setup, please follow the link below:

+ +

+ + Accept the invitation + +

+ +

If you did not expect this invitation, you can safely ignore this email.

+ +

- Martin, Founder of Capgo.app

diff --git a/supabase/templates/bento/auth_magiclink.html b/supabase/templates/bento/auth_magiclink.html new file mode 100644 index 0000000000..10200cf6bc --- /dev/null +++ b/supabase/templates/bento/auth_magiclink.html @@ -0,0 +1,44 @@ + + + +

Hi,

+ +

You requested access to your Capgo.app account. Use the option that matches what you are doing. Both expire in 1 hour.

+ +

+ Option 1: One-time code (only for confirming your email in the 2FA system)
+ Enter this code on the 2FA email confirmation screen:
+ {{ event.details.token }} +

+ +

+ Option 2: Magic link (for normal login to Capgo.app)
+ Click this link to sign in: +

+ +

+ + Sign in to Capgo.app + +

+ +

+ If you did not request this, ignore this email. If you suspect someone is trying to access your account, change your + password and contact Capgo support. +

+ +

- Martin, Founder of Capgo.app

diff --git a/supabase/templates/bento/auth_mfa_factor_enrolled.html b/supabase/templates/bento/auth_mfa_factor_enrolled.html new file mode 100644 index 0000000000..629d6487b2 --- /dev/null +++ b/supabase/templates/bento/auth_mfa_factor_enrolled.html @@ -0,0 +1,15 @@ + + + +

Hi,

+ +

+ A new multi-factor authentication method{% if event.details.factor_type %} ({{ event.details.factor_type }}){% endif %} + has been added to your Capgo.app account associated with {{ event.details.email }}. +

+ +

If you made this change, no further action is required.

+ +

If you did not make this change, please secure your account immediately and contact Capgo support.

+ +

- Martin, Founder of Capgo.app

diff --git a/supabase/templates/bento/auth_mfa_factor_unenrolled.html b/supabase/templates/bento/auth_mfa_factor_unenrolled.html new file mode 100644 index 0000000000..99e9f6e339 --- /dev/null +++ b/supabase/templates/bento/auth_mfa_factor_unenrolled.html @@ -0,0 +1,15 @@ + + + +

Hi,

+ +

+ A multi-factor authentication method{% if event.details.factor_type %} ({{ event.details.factor_type }}){% endif %} has + been removed from your Capgo.app account associated with {{ event.details.email }}. +

+ +

If you made this change, no further action is required.

+ +

If you did not make this change, please secure your account immediately and contact Capgo support.

+ +

- Martin, Founder of Capgo.app

diff --git a/supabase/templates/bento/auth_password_changed.html b/supabase/templates/bento/auth_password_changed.html new file mode 100644 index 0000000000..fb3f5cc82b --- /dev/null +++ b/supabase/templates/bento/auth_password_changed.html @@ -0,0 +1,15 @@ + + + +

Hi,

+ +

+ This is a confirmation that the password for your Capgo.app account associated with {{ event.details.email }} has been + changed. +

+ +

If you made this change, no further action is required.

+ +

If you did not make this change, please reset your password immediately and contact Capgo support.

+ +

- Martin, Founder of Capgo.app

diff --git a/supabase/templates/bento/auth_reauthentication.html b/supabase/templates/bento/auth_reauthentication.html new file mode 100644 index 0000000000..1f1b868249 --- /dev/null +++ b/supabase/templates/bento/auth_reauthentication.html @@ -0,0 +1,10 @@ + + + +

Hi,

+ +

Please confirm your re-authentication with Capgo.app by using the token below:

+ +

{{ event.details.token }}

+ +

- Martin, Founder of Capgo.app

diff --git a/supabase/templates/bento/auth_recovery.html b/supabase/templates/bento/auth_recovery.html new file mode 100644 index 0000000000..14fca0bd47 --- /dev/null +++ b/supabase/templates/bento/auth_recovery.html @@ -0,0 +1,35 @@ + + + +

Hi,

+ +

+ You have asked to reset your password for the Capgo.app account associated with this email address + ({{ event.details.email }}). +

+ +

To reset the password, please click on the following link:

+ +

+ + Reset your password + +

+ +

This link will expire in 1 hour for security reasons.

+ +

- Martin, Founder of Capgo.app

diff --git a/supabase/templates/bento/auth_signup.html b/supabase/templates/bento/auth_signup.html new file mode 100644 index 0000000000..cce40da419 --- /dev/null +++ b/supabase/templates/bento/auth_signup.html @@ -0,0 +1,30 @@ + + + +

Hi,

+ +

Thank you for registering with Capgo.app.

+ +

To complete your account setup, please confirm your email address by following the link below:

+ +

+ + Confirm your email + +

+ +

- Martin, Founder of Capgo.app

diff --git a/supabase/tests/66_test_on_user_org_access_queue.sql b/supabase/tests/66_test_on_user_org_access_queue.sql index b894e383a4..d977891adf 100644 --- a/supabase/tests/66_test_on_user_org_access_queue.sql +++ b/supabase/tests/66_test_on_user_org_access_queue.sql @@ -125,9 +125,11 @@ SELECT is( "webhook_delivery", "credit_usage_posthog", "on_user_org_access", - "canceled_org_retention_alerts" + "canceled_org_retention_alerts", + "send_email" ]'::jsonb, - 'high-frequency queues retain order and append on_user_org_access then canceled_org_retention_alerts' + 'high-frequency queues retain order and append on_user_org_access,' + ' canceled_org_retention_alerts, then send_email' ); SELECT is( diff --git a/supabase/tests/72_test_hook_send_email.sql b/supabase/tests/72_test_hook_send_email.sql new file mode 100644 index 0000000000..695c0c28f4 --- /dev/null +++ b/supabase/tests/72_test_hook_send_email.sql @@ -0,0 +1,164 @@ +BEGIN; + +SELECT plan(14); + +CREATE OR REPLACE FUNCTION pg_temp.send_email_payload() +RETURNS jsonb +LANGUAGE plpgsql +SET search_path = '' +AS $$ +DECLARE + v_payload jsonb; +BEGIN + IF pg_catalog.to_regclass('pgmq.q_send_email') IS NULL THEN + RETURN NULL; + END IF; + + EXECUTE + 'SELECT message -> ''payload'' + FROM pgmq.q_send_email + WHERE message -> ''payload'' -> ''email_data'' ->> ''token_hash'' = $1 + ORDER BY msg_id + LIMIT 1' + INTO v_payload + USING 'hook-send-email-token-hash'; + + RETURN v_payload; +END; +$$; + +SELECT has_function( + 'public', + 'hook_send_email', + ARRAY['jsonb'], + 'send email hook exists' +); + +SELECT is( + has_function_privilege( + 'anon', 'public.hook_send_email(jsonb)', 'execute' + ), + false, + 'anon cannot execute send email hook' +); + +SELECT is( + has_function_privilege( + 'authenticated', 'public.hook_send_email(jsonb)', 'execute' + ), + false, + 'authenticated cannot execute send email hook' +); + +SELECT ok( + NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_auth_admin' + ) + OR has_function_privilege( + 'supabase_auth_admin', + 'public.hook_send_email(jsonb)', + 'execute' + ), + 'supabase_auth_admin can execute send email hook when the role exists' +); + +SELECT ok( + pg_catalog.to_regclass('pgmq.q_send_email') IS NOT null, + 'send_email queue exists' +); + +SELECT ok( + ( + SELECT cron.target::jsonb ? 'send_email' + FROM public.cron_tasks AS cron + WHERE cron.name = 'high_frequency_queues' + ), + 'high_frequency_queues drains send_email' +); + +SELECT is( + public.hook_send_email( + '{"user":{},"email_data":{"email_action_type":"signup"}}'::jsonb + ) -> 'error' ->> 'message', + 'Send email hook missing user email', + 'hook rejects missing email' +); + +SELECT is( + public.hook_send_email( + '{"user":{"email":"hook-send-email@capgo.test"},"email_data":{}}'::jsonb + ) -> 'error' ->> 'message', + 'Send email hook missing email_action_type', + 'hook rejects missing email_action_type' +); + +SELECT is( + public.hook_send_email( + '{ + "user": { + "id": "72000000-0000-4000-8000-000000000072", + "email": "hook-send-email@capgo.test", + "new_email": "hook-send-email-new@capgo.test" + }, + "email_data": { + "email_action_type": "email_change", + "factor_type": "totp", + "old_email": "hook-send-email-old@capgo.test", + "redirect_to": "https://console.capgo.app", + "site_url": "https://console.capgo.app", + "token": "305805", + "token_hash": "hook-send-email-token-hash" + } + }'::jsonb + ), + '{}'::jsonb, + 'hook enqueues a valid auth email' +); + +SELECT is( + pg_temp.send_email_payload() -> 'user' ->> 'email', + 'hook-send-email@capgo.test', + 'queued payload keeps the raw GoTrue user email' +); + +SELECT is( + pg_temp.send_email_payload() -> 'user' ->> 'new_email', + 'hook-send-email-new@capgo.test', + 'queued payload keeps the raw GoTrue user.new_email' +); + +SELECT is( + pg_temp.send_email_payload() -> 'email_data' ->> 'token', + '305805', + 'queued payload keeps the raw GoTrue OTP token' +); + +SELECT is( + pg_temp.send_email_payload() -> 'email_data' ->> 'email_action_type', + 'email_change', + 'queued payload keeps the raw GoTrue email_action_type' +); + +SELECT is( + ( + SELECT message ->> 'function_name' + FROM pgmq.q_send_email + WHERE + message -> 'payload' -> 'email_data' ->> 'token_hash' + = 'hook-send-email-token-hash' + ORDER BY msg_id + LIMIT 1 + ), + 'send_email', + 'queued message targets send_email' +); + +DELETE FROM pgmq.q_send_email +WHERE + message -> 'payload' -> 'email_data' ->> 'token_hash' + = 'hook-send-email-token-hash'; + +SELECT * FROM finish(); -- noqa: AM04 +ROLLBACK; diff --git a/tests/auth-email.unit.test.ts b/tests/auth-email.unit.test.ts new file mode 100644 index 0000000000..8e7128226a --- /dev/null +++ b/tests/auth-email.unit.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { + authEmailDeliveriesFromGoTrueEvent, + buildAuthConfirmationUrl, + buildAuthEmailBentoDetails, + getAuthEmailBentoEvent, +} from '../supabase/functions/_backend/utils/auth_email.ts' + +describe('auth email Bento mapping', () => { + it.concurrent('maps GoTrue action types to Bento auth_* events', () => { + expect(getAuthEmailBentoEvent('signup')).toBe('auth_confirmation') + expect(getAuthEmailBentoEvent('recovery')).toBe('auth_recovery') + expect(getAuthEmailBentoEvent('magiclink')).toBe('auth_magic_link') + expect(getAuthEmailBentoEvent('invite')).toBe('auth_invite') + expect(getAuthEmailBentoEvent('email_change')).toBe('auth_email_change') + expect(getAuthEmailBentoEvent('email_change_new')).toBe('auth_email_change') + expect(getAuthEmailBentoEvent('password_changed_notification')).toBe('auth_password_changed_notification') + expect(getAuthEmailBentoEvent('mfa_factor_enrolled_notification')).toBe('auth_mfa_factor_enrolled_notification') + }) + + it.concurrent('keeps unknown action types instead of dropping the email', () => { + expect(getAuthEmailBentoEvent('custom_action')).toBe('auth_custom_action') + }) + + it.concurrent('builds GoTrue ConfirmationURL from token_hash', () => { + expect(buildAuthConfirmationUrl( + 'https://api.capgo.app/', + 'token-hash', + 'signup', + 'https://console.capgo.app/', + )).toBe('https://api.capgo.app/auth/v1/verify?token=token-hash&type=signup&redirect_to=https%3A%2F%2Fconsole.capgo.app%2F') + }) + + it.concurrent('normalizes email_change_current verify type to email_change', () => { + expect(buildAuthConfirmationUrl( + 'https://xyz.supabase.co', + 'hash-current', + 'email_change_current', + 'https://console.capgo.app', + )).toContain('type=email_change') + }) + + it.concurrent('sends GoTrue template fields plus an encoded confirmation link', () => { + const details = buildAuthEmailBentoDetails({ + email: ' user@capgo.app ', + email_action_type: 'email_change', + factor_type: 'totp', + new_email: 'new@capgo.app', + old_email: 'old@capgo.app', + redirect_to: 'https://console.capgo.app', + site_url: 'https://ignored.example', + token: '305805', + token_hash: 'hash-1', + }, 'https://xyz.supabase.co', 'https://console.capgo.app/') + + expect(details.confirmation_url).toBe('https://xyz.supabase.co/auth/v1/verify?token=hash-1&type=email_change&redirect_to=https%3A%2F%2Fconsole.capgo.app') + expect(details.confirmation_link).toBe(`https://console.capgo.app/confirm-signup?confirmation_url=${encodeURIComponent(details.confirmation_url)}`) + expect(details).toMatchObject({ + email: 'user@capgo.app', + factor_type: 'totp', + new_email: 'new@capgo.app', + old_email: 'old@capgo.app', + site_url: 'https://console.capgo.app', + token: '305805', + }) + expect(Object.keys(details).sort()).toEqual([ + 'confirmation_link', + 'confirmation_url', + 'email', + 'factor_type', + 'new_email', + 'old_email', + 'site_url', + 'token', + ]) + }) + + it.concurrent('sends insecure email change to the new address', () => { + const [delivery] = authEmailDeliveriesFromGoTrueEvent({ + user: { + email: 'old@capgo.app', + new_email: 'new@capgo.app', + }, + email_data: { + email_action_type: 'email_change', + token: '305805', + token_hash: 'hash-3', + }, + }) + expect(delivery?.email).toBe('new@capgo.app') + expect(delivery?.payload.email).toBe('old@capgo.app') + }) + + it.concurrent('splits secure email change into current and new deliveries', () => { + expect(authEmailDeliveriesFromGoTrueEvent({ + user: { + email: 'old@capgo.app', + new_email: 'new@capgo.app', + }, + email_data: { + email_action_type: 'email_change', + token: '111111', + token_hash: 'hash-new', + token_new: '222222', + token_hash_new: 'hash-current', + }, + })).toEqual([ + { + email: 'old@capgo.app', + payload: expect.objectContaining({ + email: 'old@capgo.app', + token: '111111', + token_hash: 'hash-current', + }), + }, + { + email: 'new@capgo.app', + payload: expect.objectContaining({ + email: 'old@capgo.app', + token: '222222', + token_hash: 'hash-new', + }), + }, + ]) + }) + + it.concurrent('uses GoTrue site_url when WEBAPP_URL is empty', () => { + const details = buildAuthEmailBentoDetails({ + email: 'user@capgo.app', + email_action_type: 'signup', + site_url: 'https://console.capgo.app/', + token: '123456', + token_hash: 'hash-2', + }, 'https://xyz.supabase.co', '') + + expect(details.site_url).toBe('https://console.capgo.app') + expect(details.confirmation_url).toContain('/auth/v1/verify?token=hash-2&type=signup') + expect(details.confirmation_link).toContain('confirmation_url=') + }) +}) diff --git a/tests/send-email-trigger.unit.test.ts b/tests/send-email-trigger.unit.test.ts new file mode 100644 index 0000000000..edd8763019 --- /dev/null +++ b/tests/send-email-trigger.unit.test.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + isBentoConfiguredMock, + trackBentoEventMock, +} = vi.hoisted(() => ({ + isBentoConfiguredMock: vi.fn(() => true), + trackBentoEventMock: vi.fn(async () => true as boolean | undefined), +})) + +vi.mock('../supabase/functions/_backend/utils/bento.ts', () => ({ + isBentoConfigured: isBentoConfiguredMock, + trackBentoEvent: trackBentoEventMock, +})) + +vi.mock('../supabase/functions/_backend/utils/hono.ts', async () => { + const actual = await vi.importActual('../supabase/functions/_backend/utils/hono.ts') + return { + ...actual, + middlewareAPISecret: async (_c: unknown, next: () => Promise) => await next(), + } +}) + +vi.mock('../supabase/functions/_backend/utils/utils.ts', async () => { + const actual = await vi.importActual('../supabase/functions/_backend/utils/utils.ts') + return { + ...actual, + getEnv: (_c: unknown, key: string) => { + if (key === 'SUPABASE_URL') + return 'https://xyz.supabase.co' + if (key === 'WEBAPP_URL') + return 'https://console.capgo.app' + return '' + }, + } +}) + +const { app } = await import('../supabase/functions/_backend/triggers/send_email.ts') + +const signupEvent = { + user: { + email: 'user@capgo.app', + }, + email_data: { + email_action_type: 'signup', + factor_type: '', + redirect_to: 'https://console.capgo.app', + site_url: 'https://console.capgo.app', + token: '305805', + token_hash: 'token-hash', + }, +} + +const signupConfirmationUrl = 'https://xyz.supabase.co/auth/v1/verify?token=token-hash&type=signup&redirect_to=https%3A%2F%2Fconsole.capgo.app' + +function postSendEmail(body: unknown) { + return app.request('http://local/', { + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }) +} + +describe('send_email queue handler', () => { + beforeEach(() => { + vi.clearAllMocks() + isBentoConfiguredMock.mockReturnValue(true) + trackBentoEventMock.mockResolvedValue(true) + }) + + it('tracks a Bento transactional event with GoTrue template fields', async () => { + const response = await postSendEmail(signupEvent) + + expect(response.status).toBe(200) + expect(trackBentoEventMock).toHaveBeenCalledWith( + expect.anything(), + 'user@capgo.app', + { + confirmation_link: `https://console.capgo.app/confirm-signup?confirmation_url=${encodeURIComponent(signupConfirmationUrl)}`, + confirmation_url: signupConfirmationUrl, + email: 'user@capgo.app', + factor_type: '', + new_email: '', + old_email: '', + site_url: 'https://console.capgo.app', + token: '305805', + }, + 'auth_confirmation', + ) + }) + + it('tracks magic link with OTP token and auth_magic_link', async () => { + const response = await postSendEmail({ + user: { email: 'user@capgo.app' }, + email_data: { + email_action_type: 'magiclink', + token: '847291', + token_hash: 'magic-hash', + redirect_to: 'https://console.capgo.app', + }, + }) + + expect(response.status).toBe(200) + expect(trackBentoEventMock).toHaveBeenCalledWith( + expect.anything(), + 'user@capgo.app', + expect.objectContaining({ + token: '847291', + }), + 'auth_magic_link', + ) + }) + + it('sends two Bento events for secure email change', async () => { + const response = await postSendEmail({ + user: { + email: 'old@capgo.app', + new_email: 'new@capgo.app', + }, + email_data: { + email_action_type: 'email_change', + old_email: 'old@capgo.app', + token: '111111', + token_hash: 'hash-new', + token_new: '222222', + token_hash_new: 'hash-current', + redirect_to: 'https://console.capgo.app', + }, + }) + + expect(response.status).toBe(200) + expect(trackBentoEventMock).toHaveBeenCalledTimes(2) + expect(trackBentoEventMock).toHaveBeenNthCalledWith( + 1, + expect.anything(), + 'old@capgo.app', + expect.objectContaining({ token: '111111' }), + 'auth_email_change', + ) + expect(trackBentoEventMock).toHaveBeenNthCalledWith( + 2, + expect.anything(), + 'new@capgo.app', + expect.objectContaining({ token: '222222' }), + 'auth_email_change', + ) + }) + + it('sends insecure email change to the new address', async () => { + const response = await postSendEmail({ + user: { + email: 'old@capgo.app', + new_email: 'new@capgo.app', + }, + email_data: { + email_action_type: 'email_change', + old_email: 'old@capgo.app', + token: '305805', + token_hash: 'hash-new', + redirect_to: 'https://console.capgo.app', + }, + }) + + expect(response.status).toBe(200) + expect(trackBentoEventMock).toHaveBeenCalledOnce() + expect(trackBentoEventMock).toHaveBeenCalledWith( + expect.anything(), + 'new@capgo.app', + expect.objectContaining({ + email: 'old@capgo.app', + new_email: 'new@capgo.app', + old_email: 'old@capgo.app', + }), + 'auth_email_change', + ) + }) + + it('sends email_changed_notification to the old address', async () => { + const response = await postSendEmail({ + user: { email: 'new@capgo.app' }, + email_data: { + email_action_type: 'email_changed_notification', + old_email: 'old@capgo.app', + }, + }) + + expect(response.status).toBe(200) + expect(trackBentoEventMock).toHaveBeenCalledWith( + expect.anything(), + 'old@capgo.app', + expect.objectContaining({ + email: 'new@capgo.app', + old_email: 'old@capgo.app', + }), + 'auth_email_changed_notification', + ) + }) + + it('fails so the queue retries when Bento is not configured', async () => { + isBentoConfiguredMock.mockReturnValue(false) + + const response = await postSendEmail(signupEvent) + + expect(response.status).toBe(500) + expect(trackBentoEventMock).not.toHaveBeenCalled() + }) + + it('fails for queue retry when configured Bento delivery fails', async () => { + trackBentoEventMock.mockResolvedValue(false) + + const response = await postSendEmail(signupEvent) + + expect(response.status).toBe(500) + }) +})