fix(dashboard): enroll verify flow and org-aware verification email (#718)

This commit is contained in:
Rotimi Best
2026-07-10 06:59:52 +01:00
committed by GitHub
19 changed files with 281 additions and 39 deletions
@@ -54,7 +54,7 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
if (isURLCustomDomain(url)) { if (isURLCustomDomain(url)) {
console.log('it is custom domain'); console.log('it is custom domain');
const apiKeyHeaders = getApiKeyHeaders(); const apiKeyHeaders = getApiKeyHeaders();
const orgs = await getOrgsByCustomDomain(url.host, true, apiKeyHeaders); const orgs = await getOrgsByCustomDomain(url.hostname, true, apiKeyHeaders);
if (!orgs || orgs.length === 0) { if (!orgs || orgs.length === 0) {
return response; return response;
@@ -892,7 +892,9 @@
"enroll_page": { "enroll_page": {
"requires_payment_or_invite": "Du skal betale for dette kursus eller blive inviteret for at få adgang.", "requires_payment_or_invite": "Du skal betale for dette kursus eller blive inviteret for at få adgang.",
"back_to_course": "Tilbage til kurset", "back_to_course": "Tilbage til kurset",
"join_course": "Deltag i kursus" "join_course": "Deltag i kursus",
"enrolled_success": "Du er tilmeldt dette kursus.",
"enrolled_verify_email": "Du er tilmeldt! Bekræft din e-mail for at få adgang til dette kursus."
}, },
"pricing_section": { "pricing_section": {
"free": "Gratis", "free": "Gratis",
@@ -892,7 +892,9 @@
"enroll_page": { "enroll_page": {
"requires_payment_or_invite": "Sie müssen für diesen Kurs bezahlen oder eingeladen werden, um Zugang zu erhalten.", "requires_payment_or_invite": "Sie müssen für diesen Kurs bezahlen oder eingeladen werden, um Zugang zu erhalten.",
"back_to_course": "Zurück zum Kurs", "back_to_course": "Zurück zum Kurs",
"join_course": "Nehmen Sie am Kurs teil" "join_course": "Nehmen Sie am Kurs teil",
"enrolled_success": "Sie sind für diesen Kurs eingeschrieben.",
"enrolled_verify_email": "Sie sind eingeschrieben! Bestätigen Sie Ihre E-Mail, um auf diesen Kurs zuzugreifen."
}, },
"pricing_section": { "pricing_section": {
"free": "Kostenlos", "free": "Kostenlos",
@@ -3371,7 +3371,9 @@
"enroll_page": { "enroll_page": {
"requires_payment_or_invite": "You need to pay for this course or be invited to get access.", "requires_payment_or_invite": "You need to pay for this course or be invited to get access.",
"back_to_course": "Back to course", "back_to_course": "Back to course",
"join_course": "Join Course" "join_course": "Join Course",
"enrolled_success": "You're enrolled in this course.",
"enrolled_verify_email": "You're enrolled! Verify your email to access this course."
}, },
"course_content": "Course content", "course_content": "Course content",
"modules": "modules", "modules": "modules",
@@ -892,7 +892,9 @@
"enroll_page": { "enroll_page": {
"requires_payment_or_invite": "Debes pagar por este curso o ser invitado a obtener acceso.", "requires_payment_or_invite": "Debes pagar por este curso o ser invitado a obtener acceso.",
"back_to_course": "volver al curso", "back_to_course": "volver al curso",
"join_course": "Unirse al curso" "join_course": "Unirse al curso",
"enrolled_success": "Estás inscrito en este curso.",
"enrolled_verify_email": "¡Estás inscrito! Verifica tu correo electrónico para acceder a este curso."
}, },
"pricing_section": { "pricing_section": {
"free": "Gratis", "free": "Gratis",
@@ -892,7 +892,9 @@
"enroll_page": { "enroll_page": {
"requires_payment_or_invite": "Vous devez payer pour ce cours ou être invité pour y accéder.", "requires_payment_or_invite": "Vous devez payer pour ce cours ou être invité pour y accéder.",
"back_to_course": "Retour au cours", "back_to_course": "Retour au cours",
"join_course": "Rejoindre le cours" "join_course": "Rejoindre le cours",
"enrolled_success": "Vous êtes inscrit à ce cours.",
"enrolled_verify_email": "Vous êtes inscrit ! Vérifiez votre e-mail pour accéder à ce cours."
}, },
"pricing_section": { "pricing_section": {
"free": "Gratuit", "free": "Gratuit",
@@ -892,7 +892,9 @@
"enroll_page": { "enroll_page": {
"requires_payment_or_invite": "आपको इस पाठ्यक्रम के लिए भुगतान करना होगा या प्रवेश पाने के लिए आमंत्रित किया जाना होगा।", "requires_payment_or_invite": "आपको इस पाठ्यक्रम के लिए भुगतान करना होगा या प्रवेश पाने के लिए आमंत्रित किया जाना होगा।",
"back_to_course": "पाठ्यक्रम पर वापस जाएँ", "back_to_course": "पाठ्यक्रम पर वापस जाएँ",
"join_course": "पाठ्यक्रम में शामिल हों" "join_course": "पाठ्यक्रम में शामिल हों",
"enrolled_success": "आप इस पाठ्यक्रम में नामांकित हैं।",
"enrolled_verify_email": "आप नामांकित हैं! इस पाठ्यक्रम तक पहुँचने के लिए अपना ईमेल सत्यापित करें।"
}, },
"pricing_section": { "pricing_section": {
"free": "निःशुल्क", "free": "निःशुल्क",
@@ -892,7 +892,9 @@
"enroll_page": { "enroll_page": {
"requires_payment_or_invite": "Aby uzyskać dostęp, musisz zapłacić za ten kurs lub otrzymać zaproszenie.", "requires_payment_or_invite": "Aby uzyskać dostęp, musisz zapłacić za ten kurs lub otrzymać zaproszenie.",
"back_to_course": "Powrót na kurs", "back_to_course": "Powrót na kurs",
"join_course": "Dołącz do kursu" "join_course": "Dołącz do kursu",
"enrolled_success": "Zapisano Cię na ten kurs.",
"enrolled_verify_email": "Jesteś zapisany! Zweryfikuj swój e-mail, aby uzyskać dostęp do tego kursu."
}, },
"pricing_section": { "pricing_section": {
"free": "Bezpłatny", "free": "Bezpłatny",
@@ -892,7 +892,9 @@
"enroll_page": { "enroll_page": {
"requires_payment_or_invite": "Você precisa pagar por este curso ou ser convidado para obter acesso.", "requires_payment_or_invite": "Você precisa pagar por este curso ou ser convidado para obter acesso.",
"back_to_course": "De volta ao curso", "back_to_course": "De volta ao curso",
"join_course": "Participe do curso" "join_course": "Participe do curso",
"enrolled_success": "Você está inscrito neste curso.",
"enrolled_verify_email": "Você está inscrito! Verifique seu e-mail para acessar este curso."
}, },
"pricing_section": { "pricing_section": {
"free": "Grátis", "free": "Grátis",
@@ -892,7 +892,9 @@
"enroll_page": { "enroll_page": {
"requires_payment_or_invite": "Вам нужно заплатить за этот курс или получить приглашение для получения доступа.", "requires_payment_or_invite": "Вам нужно заплатить за этот курс или получить приглашение для получения доступа.",
"back_to_course": "Вернуться к курсу", "back_to_course": "Вернуться к курсу",
"join_course": "Присоединиться к курсу" "join_course": "Присоединиться к курсу",
"enrolled_success": "Вы записаны на этот курс.",
"enrolled_verify_email": "Вы записаны! Подтвердите email, чтобы получить доступ к курсу."
}, },
"pricing_section": { "pricing_section": {
"free": "Бесплатно", "free": "Бесплатно",
@@ -892,7 +892,9 @@
"enroll_page": { "enroll_page": {
"requires_payment_or_invite": "Bạn cần phải trả tiền cho khóa học này hoặc được mời để có quyền truy cập.", "requires_payment_or_invite": "Bạn cần phải trả tiền cho khóa học này hoặc được mời để có quyền truy cập.",
"back_to_course": "Quay lại khóa học", "back_to_course": "Quay lại khóa học",
"join_course": "Tham gia khóa học" "join_course": "Tham gia khóa học",
"enrolled_success": "Bạn đã ghi danh khóa học này.",
"enrolled_verify_email": "Bạn đã ghi danh! Xác minh email để truy cập khóa học này."
}, },
"pricing_section": { "pricing_section": {
"free": "miễn phí", "free": "miễn phí",
@@ -12,17 +12,44 @@
import { capturePosthogEvent } from '$lib/utils/services/posthog'; import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { authClient } from '$lib/utils/services/auth/client'; import { authClient } from '$lib/utils/services/auth/client';
import { appInitApi } from '$features/app/init.svelte';
let { data } = $props(); let { data } = $props();
let loading = $state(false); let loading = $state(false);
let enrollmentInFlight = $state(false); let enrollmentInFlight = $state(false);
let enrollmentAttemptKey = $state(''); let enrollmentAttemptKey = $state('');
let enrolledPendingVerification = $state(false);
const session = authClient.useSession(); const session = authClient.useSession();
const sessionReady = $derived(!$session.isPending && !$session.isRefetching); const sessionReady = $derived(!$session.isPending && !$session.isRefetching);
const sessionUser = $derived($session.data?.user ?? null); const sessionUser = $derived($session.data?.user ?? null);
const isLoggedIn = $derived(sessionReady && Boolean(sessionUser)); const isLoggedIn = $derived(sessionReady && Boolean(sessionUser));
const isEmailVerified = $derived(Boolean($profile.isEmailVerified || sessionUser?.emailVerified));
function resolvePostEnrollRedirect(): string {
return '/lms';
}
async function sendVerificationEmail() {
const email = $profile.email || sessionUser?.email;
if (!email) {
snackbar.error('verify_email_modal.snackbar.error');
return;
}
try {
const callbackURL = new URL(page.url.href);
callbackURL.searchParams.set('trigger', 'app');
await authClient.sendVerificationEmail({
email,
callbackURL: callbackURL.toString()
});
} catch {
snackbar.error('verify_email_modal.snackbar.error');
}
}
const inviteStatus = $derived(data.invite?.status ?? 'INVALID'); const inviteStatus = $derived(data.invite?.status ?? 'INVALID');
const hasActiveInvite = $derived(Boolean(data.invite) && inviteStatus === 'ACTIVE'); const hasActiveInvite = $derived(Boolean(data.invite) && inviteStatus === 'ACTIVE');
@@ -74,6 +101,7 @@
const result = await courseApi.enroll(data.course.id, body); const result = await courseApi.enroll(data.course.id, body);
if (!result?.data) { if (!result?.data) {
snackbar.error('snackbar.invite.failed_join');
return; return;
} }
@@ -87,8 +115,14 @@
already_joined: result.data.alreadyJoined already_joined: result.data.alreadyJoined
}); });
if (!isEmailVerified) {
enrolledPendingVerification = true;
void sendVerificationEmail();
return;
}
navigatingAway = true; navigatingAway = true;
window.location.href = result.data.redirectTo || '/lms'; window.location.href = resolvePostEnrollRedirect();
} finally { } finally {
enrollmentInFlight = false; enrollmentInFlight = false;
if (!navigatingAway) { if (!navigatingAway) {
@@ -133,6 +167,10 @@
return; return;
} }
if (!appInitApi.isInitializedAndReady) {
return;
}
const attemptKey = `${sessionUser?.id ?? ''}:${data.course?.id ?? ''}`; const attemptKey = `${sessionUser?.id ?? ''}:${data.course?.id ?? ''}`;
if (enrollmentAttemptKey === attemptKey) { if (enrollmentAttemptKey === attemptKey) {
return; return;
@@ -167,14 +205,26 @@
<AuthUI <AuthUI
isLogin={false} isLogin={false}
{handleSubmit} {handleSubmit}
isLoading={loading || !sessionReady || (isLoggedIn && canJoinCourse)} isLoading={loading || !sessionReady || (isLoggedIn && canJoinCourse && enrollmentInFlight)}
showOnlyContent={true} showOnlyContent={true}
showLogo={true} showLogo={true}
> >
<div class="mt-0 w-full"> <div class="mt-0 w-full">
<h3 class="mt-0 mb-4 text-center text-lg font-medium dark:text-white">{data.course?.title}</h3> <h3 class="mt-0 mb-4 text-center text-lg font-medium dark:text-white">{data.course?.title}</h3>
<p class="text-center text-sm font-light dark:text-white">{data.course?.description}</p> <p class="text-center text-sm font-light dark:text-white">{data.course?.description}</p>
{#if data.requiresPaymentOrInvite} {#if enrolledPendingVerification}
<p class="ui:text-primary mt-3 text-center text-sm font-medium">
{$t('course.navItem.landing_page.enroll_page.enrolled_success')}
</p>
<p class="ui:text-muted-foreground mt-2 text-center text-sm">
{$t('course.navItem.landing_page.enroll_page.enrolled_verify_email')}
</p>
<div class="mt-4 flex justify-center">
<Button type="button" variant="outline" onclick={sendVerificationEmail}>
{$t('verify_email_modal.resend')}
</Button>
</div>
{:else if data.requiresPaymentOrInvite}
<p class="ui:text-muted-foreground mt-3 text-center text-sm"> <p class="ui:text-muted-foreground mt-3 text-center text-sm">
{$t('course.navItem.landing_page.enroll_page.requires_payment_or_invite')} {$t('course.navItem.landing_page.enroll_page.requires_payment_or_invite')}
</p> </p>
@@ -189,9 +239,9 @@
{/if} {/if}
</div> </div>
{#if !isLoggedIn} {#if !enrolledPendingVerification && (!isLoggedIn || (isLoggedIn && canJoinCourse && !enrollmentInFlight))}
<div class="my-4 flex w-full items-center justify-center"> <div class="my-4 flex w-full items-center justify-center">
<Button type="submit" disabled={!canJoinCourse || loading || !sessionReady} {loading}> <Button type="submit" disabled={!canJoinCourse || loading || !sessionReady || enrollmentInFlight} {loading}>
{$t('course.navItem.landing_page.enroll_page.join_course')} {$t('course.navItem.landing_page.enroll_page.join_course')}
</Button> </Button>
</div> </div>
+51 -15
View File
@@ -1,5 +1,7 @@
import { type BetterAuthOptions } from 'better-auth'; 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< type EmailVerificationOptions = Parameters<
NonNullable<NonNullable<BetterAuthOptions['emailVerification']>['sendVerificationEmail']> NonNullable<NonNullable<BetterAuthOptions['emailVerification']>['sendVerificationEmail']>
@@ -21,6 +23,45 @@ type ChangeEmailConfirmationOptions = Parameters<
* 7. we mark [email protected] as verified. [better-auth]. * 7. we mark [email protected] as verified. [better-auth].
*/ */
function stripTriggerParam(url: string): string {
return url.replace('trigger=app', '').replace('trigger%3Dapp', '');
}
/**
* Brands verification mail with the org from `resolveVerificationOrg` (callback URL
* host/?org=, self-hosted first org, then the user's first membership). When no
* org applies — e.g. dashboard signup before onboarding creates an org — copy
* and masthead fall back to ClassroomIO defaults.
*/
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. * Sends a verification email to the user when they sign up or change their email address.
* @param options - The options for sending the verification email. * @param options - The options for sending the verification email.
@@ -35,15 +76,11 @@ export const sendVerificationEmail = async (options: EmailVerificationOptions) =
return; return;
} }
// Remove trigger=app or trigger%3Dapp from the URL await sendOrgAwareVerifyEmail({
const link = url.replace('trigger=app', '').replace('trigger%3Dapp', '');
await sendEmail('verifyEmail', {
to: user.email, to: user.email,
fields: { verificationUrl: url,
link, userId: user.id,
userName: user.name || undefined userName: user.name
}
}); });
}; };
@@ -56,12 +93,11 @@ export const sendChangeEmailConfirmation = async (options: ChangeEmailConfirmati
const { user, newEmail, url } = options; const { user, newEmail, url } = options;
console.log('\nsendChangeEmailConfirmation', options); console.log('\nsendChangeEmailConfirmation', options);
await sendEmail('verifyEmail', { await sendOrgAwareVerifyEmail({
to: user.email, to: user.email,
fields: { verificationUrl: url,
link: url, userId: user.id,
newEmail, userName: user.name,
userName: user.name || undefined newEmail
}
}); });
}; };
@@ -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<TOrganization | null> {
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<TOrganization | null> {
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;
}
+1 -1
View File
@@ -16,7 +16,7 @@ export async function sendTemplateEmail<TSchema extends z.ZodType>(
return deliverEmail([ return deliverEmail([
{ {
to: config.to, to: config.to,
subject: config.template.subject, subject: config.subject ?? config.template.subject,
content, content,
from: config.from ?? config.template.from, from: config.from ?? config.template.from,
replyTo: config.replyTo ?? config.template.replyTo, replyTo: config.replyTo ?? config.template.replyTo,
+2
View File
@@ -29,6 +29,8 @@ export interface SendConfig<TSchema extends z.ZodType = z.ZodType> {
fields: z.infer<TSchema>; fields: z.infer<TSchema>;
from?: string; from?: string;
replyTo?: 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. */ /** Optional iCalendar (.ics) body delivered as a text/calendar part. */
ics?: string; ics?: string;
} }
+11 -5
View File
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send'; import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates'; import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const verifyEmailEmail = defineEmail({ export const verifyEmailEmail = defineEmail({
id: 'verifyEmail', id: 'verifyEmail',
@@ -9,11 +10,17 @@ export const verifyEmailEmail = defineEmail({
schema: z.object({ schema: z.object({
link: z.url(), link: z.url(),
newEmail: z.email().optional(), newEmail: z.email().optional(),
userName: z.string().optional() userName: z.string().optional(),
orgName: z.string().min(1),
branding: ZEmailBranding
}), }),
render: (fields) => { render: (fields) => {
const isEmailChange = !!fields.newEmail; const isEmailChange = !!fields.newEmail;
const userName = fields.userName || 'there'; const userName = fields.userName || 'there';
const hasOrgBranding = Boolean(fields.branding?.orgName);
const welcomeLine = hasOrgBranding
? `Welcome to ${fields.orgName}! In order to get your account ready for usage, we need to verify your email.`
: 'Welcome! In order to get your account ready for usage, we need to verify your email.';
const content = isEmailChange const content = isEmailChange
? ` ? `
@@ -27,14 +34,13 @@ export const verifyEmailEmail = defineEmail({
` `
: ` : `
<p><strong>Hey ${userName} 👋</strong></p> <p><strong>Hey ${userName} 👋</strong></p>
<p>Welcome to ClassroomIO! In order to get your account ready for usage, we need to verify your email. </p> <p>${welcomeLine}</p>
<p>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 <strong>Verify</strong> button below <p>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 <strong>Verify</strong> button below.</p>
</p>
<div> <div>
<a class="button" href="${fields.link}">Verify</a> <a class="button" href="${fields.link}">Verify</a>
</div> </div>
`; `;
return getDefaultTemplate(content); return getDefaultTemplate(content, fields.branding);
} }
}); });
+1
View File
@@ -95,6 +95,7 @@ export async function sendEmail<TEmailId extends EmailId>(
fields: validatedFields, fields: validatedFields,
from: config.from ?? template.from, from: config.from ?? template.from,
replyTo: config.replyTo ?? template.replyTo, replyTo: config.replyTo ?? template.replyTo,
subject: config.subject,
ics: config.ics ics: config.ics
}); });
} catch (error) { } catch (error) {
@@ -4,7 +4,7 @@ import { getDefaultTemplate } from '@cio/email/templates';
const VERIFY_EMAIL_CONTENT = ` const VERIFY_EMAIL_CONTENT = `
<p><strong>Hey there 👋</strong></p> <p><strong>Hey there 👋</strong></p>
<p>Welcome to ClassroomIO! In order to get your account ready for usage, we need to verify your email.</p> <p>Welcome to Acme Coding School! In order to get your account ready for usage, we need to verify your email.</p>
<p>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 <strong>Verify</strong> button below.</p> <p>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 <strong>Verify</strong> button below.</p>
<div><a class="button" href="#">Verify</a></div> <div><a class="button" href="#">Verify</a></div>
`; `;
@@ -59,7 +59,11 @@ const STUDENT_JOINED_CONTENT = `
// ─── Exported email HTML strings ────────────────────────────────────────── // ─── 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 forgotPasswordHtml = getDefaultTemplate(FORGOT_PASSWORD_CONTENT);
export const welcomeHtml = getDefaultTemplate(WELCOME_CONTENT); export const welcomeHtml = getDefaultTemplate(WELCOME_CONTENT);
export const courseWelcomeHtml = getDefaultTemplate(COURSE_WELCOME_CONTENT); export const courseWelcomeHtml = getDefaultTemplate(COURSE_WELCOME_CONTENT);