Skip to content
Open
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
2 changes: 2 additions & 0 deletions cloudflare_workers/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand Down
11 changes: 10 additions & 1 deletion supabase/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
54 changes: 54 additions & 0 deletions supabase/functions/_backend/triggers/send_email.ts
Original file line number Diff line number Diff line change
@@ -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<MiddlewareKeyVariables>()

app.post('/', middlewareAPISecret, async (c) => {
const deliveries = authEmailDeliveriesFromGoTrueEvent(await parseBody<GoTrueSendEmailEvent>(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) {
Comment thread
riderx marked this conversation as resolved.
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)
})
196 changes: 196 additions & 0 deletions supabase/functions/_backend/utils/auth_email.ts
Original file line number Diff line number Diff line change
@@ -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',
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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,
Comment thread
riderx marked this conversation as resolved.
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,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
})
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),
}
}
2 changes: 2 additions & 0 deletions supabase/functions/triggers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading