From a6d06f356f65763fc33d6b45e3cda1090d77bba0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 04:22:16 +0000 Subject: [PATCH] feat(email): make verify-email org-aware like other transactional mail Resolve the tenant org from the verification link callback URL (custom domain, tenant subdomain, or ?org=) and apply org logo, theme color, from name, and subject. Falls back to ClassroomIO branding on the main app. Co-authored-by: Rotimi Best --- packages/db/src/auth/email-verification.ts | 70 ++++++---- .../db/src/auth/resolve-verification-org.ts | 123 ++++++++++++++++++ packages/email/src/core/base.ts | 2 +- packages/email/src/core/types.ts | 2 + packages/email/src/emails/verify-email.ts | 13 +- packages/email/src/send.ts | 1 + .../storybook/src/templates/email/fixtures.ts | 8 +- 7 files changed, 185 insertions(+), 34 deletions(-) create mode 100644 packages/db/src/auth/resolve-verification-org.ts diff --git a/packages/db/src/auth/email-verification.ts b/packages/db/src/auth/email-verification.ts index 79ffeca6..9e2fb595 100644 --- a/packages/db/src/auth/email-verification.ts +++ b/packages/db/src/auth/email-verification.ts @@ -1,5 +1,7 @@ import { type BetterAuthOptions } from 'better-auth'; -import { sendEmail } from '@cio/email'; +import { buildEmailBranding, buildEmailFromName, sendEmail } from '@cio/email'; + +import { resolveVerificationOrg } from './resolve-verification-org'; type EmailVerificationOptions = Parameters< NonNullable['sendVerificationEmail']> @@ -9,17 +11,38 @@ type ChangeEmailConfirmationOptions = Parameters< NonNullable['changeEmail']>['sendChangeEmailConfirmation']> >[0]; -/** - * Email verification flow. - * - * 1. user says I want to change my email from email@old.com to email@new.com - * 2. we send an email to email@old.com asking user to verify if they really want to change their email to email@new.com `sendChangeEmailConfirmation()`. [better-auth]. - * 3. user clicks the link in the email - * 4. we update the email in the database. [better-auth]. - * 5. we send a confirmation email to email@new.com `sendVerificationEmail()`. [dashboard]. - * 6. user clicks the link in the email - * 7. we mark email@new.com as verified. [better-auth]. - */ +function stripTriggerParam(url: string): string { + return url.replace('trigger=app', '').replace('trigger%3Dapp', ''); +} + +async function sendOrgAwareVerifyEmail(options: { + to: string; + verificationUrl: string; + userId: string; + userName?: string | null; + newEmail?: string; +}) { + const org = await resolveVerificationOrg({ + verificationUrl: options.verificationUrl, + userId: options.userId + }); + + const orgName = org?.name?.trim() || 'ClassroomIO'; + const branding = buildEmailBranding(org ? { name: org.name, avatarUrl: org.avatarUrl, theme: org.theme } : undefined); + + await sendEmail('verifyEmail', { + to: options.to, + from: org?.name ? buildEmailFromName(`${org.name} (via ClassroomIO.com)`) : undefined, + subject: org?.name ? `Confirm your email for ${org.name}` : undefined, + fields: { + link: stripTriggerParam(options.verificationUrl), + newEmail: options.newEmail, + userName: options.userName || undefined, + orgName, + branding + } + }); +} /** * Sends a verification email to the user when they sign up or change their email address. @@ -35,15 +58,11 @@ export const sendVerificationEmail = async (options: EmailVerificationOptions) = return; } - // Remove trigger=app or trigger%3Dapp from the URL - const link = url.replace('trigger=app', '').replace('trigger%3Dapp', ''); - - await sendEmail('verifyEmail', { + await sendOrgAwareVerifyEmail({ to: user.email, - fields: { - link, - userName: user.name || undefined - } + verificationUrl: url, + userId: user.id, + userName: user.name }); }; @@ -56,12 +75,11 @@ export const sendChangeEmailConfirmation = async (options: ChangeEmailConfirmati const { user, newEmail, url } = options; console.log('\nsendChangeEmailConfirmation', options); - await sendEmail('verifyEmail', { + await sendOrgAwareVerifyEmail({ to: user.email, - fields: { - link: url, - newEmail, - userName: user.name || undefined - } + verificationUrl: url, + userId: user.id, + userName: user.name, + newEmail }); }; diff --git a/packages/db/src/auth/resolve-verification-org.ts b/packages/db/src/auth/resolve-verification-org.ts new file mode 100644 index 00000000..25aa5b27 --- /dev/null +++ b/packages/db/src/auth/resolve-verification-org.ts @@ -0,0 +1,123 @@ +import { TENANT_ROOT_DOMAIN, BRAND_ROOT_DOMAIN } from '@cio/utils/constants/domains'; + +import type { TOrganization } from '../types'; +import { + getFirstOrganization, + getOrganizationByCustomDomain, + getOrganizationByProfileId, + getOrganizationBySiteName +} from '../queries/organization/organization'; + +function extractCallbackUrl(verificationUrl: string): URL | null { + try { + const parsed = new URL(verificationUrl); + const callback = parsed.searchParams.get('callbackURL'); + if (!callback) { + return null; + } + + return new URL(callback); + } catch { + return null; + } +} + +function isTenantSubdomainHost(hostname: string): boolean { + const host = hostname.replace(/^www\./, '').toLowerCase(); + const suffix = `.${TENANT_ROOT_DOMAIN}`; + + return host.endsWith(suffix) && host.length > suffix.length; +} + +function getTenantSiteName(hostname: string): string | null { + const host = hostname.replace(/^www\./, '').toLowerCase(); + const suffix = `.${TENANT_ROOT_DOMAIN}`; + if (!host.endsWith(suffix)) { + return null; + } + + const siteName = host.slice(0, -suffix.length); + if (!siteName || siteName.includes('.')) { + return null; + } + + return siteName; +} + +function isCustomDomainHost(hostname: string): boolean { + const host = hostname.replace(/^www\./, '').toLowerCase(); + if (host.includes('localhost')) { + return false; + } + + if (host === BRAND_ROOT_DOMAIN || host === `app.${BRAND_ROOT_DOMAIN}`) { + return false; + } + + if (isTenantSubdomainHost(host)) { + return false; + } + + return !host.endsWith(`.${BRAND_ROOT_DOMAIN}`); +} + +async function resolveOrgFromCallbackUrl(callbackUrl: URL): Promise { + const orgSiteName = callbackUrl.searchParams.get('org')?.trim(); + if (orgSiteName) { + const org = await getOrganizationBySiteName(orgSiteName); + if (org) { + return org; + } + } + + const hostname = callbackUrl.hostname.toLowerCase(); + + if (isCustomDomainHost(hostname)) { + const org = await getOrganizationByCustomDomain(hostname); + if (org) { + return org; + } + } + + const siteName = getTenantSiteName(hostname); + if (siteName) { + return getOrganizationBySiteName(siteName); + } + + return null; +} + +/** + * Resolves the tenant org that should brand a verification email from the + * Better Auth verification URL's `callbackURL` (where the user started the + * flow), with fallbacks for self-hosted and existing memberships. + */ +export async function resolveVerificationOrg(options: { + verificationUrl: string; + userId?: string; +}): Promise { + const callbackUrl = extractCallbackUrl(options.verificationUrl); + if (callbackUrl) { + const org = await resolveOrgFromCallbackUrl(callbackUrl); + if (org) { + return org; + } + } + + if (process.env.PUBLIC_IS_SELFHOSTED === 'true') { + const org = await getFirstOrganization(); + if (org) { + return org; + } + } + + if (options.userId) { + const memberships = await getOrganizationByProfileId(options.userId); + const org = memberships[0]; + if (org) { + return org; + } + } + + return null; +} diff --git a/packages/email/src/core/base.ts b/packages/email/src/core/base.ts index 99247790..f0e24e2e 100644 --- a/packages/email/src/core/base.ts +++ b/packages/email/src/core/base.ts @@ -16,7 +16,7 @@ export async function sendTemplateEmail( return deliverEmail([ { to: config.to, - subject: config.template.subject, + subject: config.subject ?? config.template.subject, content, from: config.from ?? config.template.from, replyTo: config.replyTo ?? config.template.replyTo, diff --git a/packages/email/src/core/types.ts b/packages/email/src/core/types.ts index ecf906c2..e4442ecc 100644 --- a/packages/email/src/core/types.ts +++ b/packages/email/src/core/types.ts @@ -29,6 +29,8 @@ export interface SendConfig { fields: z.infer; from?: string; replyTo?: string; + /** Override the template's default subject (e.g. org-scoped transactional mail). */ + subject?: string; /** Optional iCalendar (.ics) body delivered as a text/calendar part. */ ics?: string; } diff --git a/packages/email/src/emails/verify-email.ts b/packages/email/src/emails/verify-email.ts index e8696068..29a06e81 100644 --- a/packages/email/src/emails/verify-email.ts +++ b/packages/email/src/emails/verify-email.ts @@ -2,6 +2,7 @@ import * as z from 'zod'; import { defineEmail } from '../send'; import { getDefaultTemplate } from '../templates'; +import { ZEmailBranding } from '../core/branding'; export const verifyEmailEmail = defineEmail({ id: 'verifyEmail', @@ -9,11 +10,14 @@ export const verifyEmailEmail = defineEmail({ schema: z.object({ link: z.url(), newEmail: z.email().optional(), - userName: z.string().optional() + userName: z.string().optional(), + orgName: z.string().min(1), + branding: ZEmailBranding }), render: (fields) => { const isEmailChange = !!fields.newEmail; const userName = fields.userName || 'there'; + const orgName = fields.orgName; const content = isEmailChange ? ` @@ -27,14 +31,13 @@ export const verifyEmailEmail = defineEmail({ ` : `

Hey ${userName} 👋

-

Welcome to ClassroomIO! In order to get your account ready for usage, we need to verify your email.

-

We do this to make sure we don't get fake user emails in our signup. To get the best out of our product, we'll need you to verify your email by clicking the Verify button below -

+

Welcome to ${orgName}! In order to get your account ready for usage, we need to verify your email.

+

We do this to make sure we don't get fake user emails in our signup. To get the best out of our product, we'll need you to verify your email by clicking the Verify button below.

`; - return getDefaultTemplate(content); + return getDefaultTemplate(content, fields.branding); } }); diff --git a/packages/email/src/send.ts b/packages/email/src/send.ts index 714b4263..144a67ea 100644 --- a/packages/email/src/send.ts +++ b/packages/email/src/send.ts @@ -95,6 +95,7 @@ export async function sendEmail( fields: validatedFields, from: config.from ?? template.from, replyTo: config.replyTo ?? template.replyTo, + subject: config.subject, ics: config.ics }); } catch (error) { diff --git a/packages/storybook/src/templates/email/fixtures.ts b/packages/storybook/src/templates/email/fixtures.ts index c26023aa..de23f487 100644 --- a/packages/storybook/src/templates/email/fixtures.ts +++ b/packages/storybook/src/templates/email/fixtures.ts @@ -4,7 +4,7 @@ import { getDefaultTemplate } from '@cio/email/templates'; const VERIFY_EMAIL_CONTENT = `

Hey there 👋

-

Welcome to ClassroomIO! In order to get your account ready for usage, we need to verify your email.

+

Welcome to Acme Coding School! In order to get your account ready for usage, we need to verify your email.

We do this to make sure we don't get fake user emails in our signup. To get the best out of our product, we'll need you to verify your email by clicking the Verify button below.

`; @@ -59,7 +59,11 @@ const STUDENT_JOINED_CONTENT = ` // ─── Exported email HTML strings ────────────────────────────────────────── -export const verifyEmailHtml = getDefaultTemplate(VERIFY_EMAIL_CONTENT); +export const verifyEmailHtml = getDefaultTemplate(VERIFY_EMAIL_CONTENT, { + orgName: 'Acme Coding School', + logoUrl: 'https://placehold.co/120x40/png?text=Acme', + themeColor: '#1d4ed8' +}); export const forgotPasswordHtml = getDefaultTemplate(FORGOT_PASSWORD_CONTENT); export const welcomeHtml = getDefaultTemplate(WELCOME_CONTENT); export const courseWelcomeHtml = getDefaultTemplate(COURSE_WELCOME_CONTENT);