feat: live class sessions, CE workflow, and docs (#683)
Adds live-class session scheduling on lessons (join link, date/time, timezone) with automatic 24h and 1h reminder emails plus a calendar invite (.ics), a time-change notification with an updated invite, a teacher-authored welcome email and session invite on enrollment, and a "Notify students" quiz nudge. LMS gets an upcoming live-sessions card and Join buttons, plus a reusable live-session card component (3 states) in @cio/ui with a Storybook story. The sidebar content tree now reflects sequential progression locking like the content list, and the course-completion confetti regression is fixed. Org-based emails no longer reference ClassroomIO by name. Docs add live-class, welcome-email, and course-progression guides, expand the certificates guide (design, signatures, corrected labels), move plan-required callouts to the top, flatten how-to-guides to the docs root with redirects, and add screenshots. Includes an add-docs-image skill.
This commit is contained in:
@@ -19,7 +19,8 @@ export async function sendTemplateEmail<TSchema extends z.ZodType>(
|
||||
subject: config.template.subject,
|
||||
content,
|
||||
from: config.from ?? config.template.from,
|
||||
replyTo: config.replyTo ?? config.template.replyTo
|
||||
replyTo: config.replyTo ?? config.template.replyTo,
|
||||
ics: config.ics
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
/**
|
||||
* Branding passed into org-scoped emails so the masthead, logo, and primary
|
||||
* button reflect the sending organization rather than ClassroomIO defaults.
|
||||
*
|
||||
* `themeColor` is always a resolved hex (e.g. `#1d4ed8`) — named org themes are
|
||||
* resolved to a hex by `buildEmailBranding` so the template never has to know
|
||||
* about ClassroomIO's theme tokens.
|
||||
*/
|
||||
export const ZEmailBranding = z
|
||||
.object({
|
||||
orgName: z.string().optional(),
|
||||
logoUrl: z.string().url().optional(),
|
||||
themeColor: z
|
||||
.string()
|
||||
.regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/)
|
||||
.optional()
|
||||
})
|
||||
.optional();
|
||||
|
||||
export type EmailBranding = z.infer<typeof ZEmailBranding>;
|
||||
|
||||
const HEX_REGEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
|
||||
/**
|
||||
* sRGB hex for each named org theme's primary button color. Mirrors the
|
||||
* `--primary` token each theme sets in the dashboard (see `app.css`), pinned to
|
||||
* plain hex because email clients can't resolve CSS vars or oklch reliably.
|
||||
*/
|
||||
const NAMED_THEME_HEX: Record<string, string> = {
|
||||
default: '#1d4ed8',
|
||||
blue: '#1d4ed8',
|
||||
green: '#65a30d',
|
||||
amber: '#d97706',
|
||||
rose: '#e11d48',
|
||||
purple: '#9333ea',
|
||||
orange: '#ea580c',
|
||||
teal: '#0d9488',
|
||||
mono: '#57534e',
|
||||
red: '#dc2626',
|
||||
yellow: '#facc15',
|
||||
violet: '#7c3aed'
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves an org `theme` value to an email-safe hex. Accepts either a named
|
||||
* theme token (`blue`, `rose`, …) or a raw hex; returns undefined when the
|
||||
* value is empty or unrecognized so callers fall back to the default color.
|
||||
*/
|
||||
export function resolveThemeColor(theme?: string | null): string | undefined {
|
||||
if (!theme) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = theme.trim();
|
||||
if (trimmed.startsWith('#')) {
|
||||
return HEX_REGEX.test(trimmed) ? trimmed.toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
return NAMED_THEME_HEX[trimmed.toLowerCase()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the email branding payload from an organization's raw fields. Pass the
|
||||
* result as the `branding` field of any org-scoped transactional email.
|
||||
*/
|
||||
export function buildEmailBranding(org?: {
|
||||
name?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
theme?: string | null;
|
||||
}): EmailBranding {
|
||||
if (!org) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const orgName = org.name?.trim() || undefined;
|
||||
const logoUrl = org.avatarUrl?.trim() || undefined;
|
||||
const themeColor = resolveThemeColor(org.theme);
|
||||
|
||||
if (!orgName && !logoUrl && !themeColor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { orgName, logoUrl, themeColor };
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export interface SendConfig<TSchema extends z.ZodType = z.ZodType> {
|
||||
fields: z.infer<TSchema>;
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
/** Optional iCalendar (.ics) body delivered as a text/calendar part. */
|
||||
ics?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,10 @@ export * from './teacher-course-welcome';
|
||||
export * from './student-org-invite';
|
||||
export * from './student-program-welcome';
|
||||
export * from './program-goal-reminder';
|
||||
export * from './quiz-assigned';
|
||||
export * from './session';
|
||||
export * from './submission-received';
|
||||
export * from './submission-graded';
|
||||
|
||||
import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed';
|
||||
|
||||
@@ -36,6 +40,10 @@ import type { studentProvePaymentEmail } from './student-prove-payment';
|
||||
import type { studentOrgInviteEmail } from './student-org-invite';
|
||||
import type { studentProgramWelcomeEmail } from './student-program-welcome';
|
||||
import type { programGoalReminderEmail } from './program-goal-reminder';
|
||||
import type { quizAssignedEmail } from './quiz-assigned';
|
||||
import type { sessionReminderEmail, sessionUpdatedEmail } from './session';
|
||||
import type { submissionReceivedEmail } from './submission-received';
|
||||
import type { submissionGradedEmail } from './submission-graded';
|
||||
import type { teacherCourseWelcomeEmail } from './teacher-course-welcome';
|
||||
import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request';
|
||||
import type { teacherStudentJoinedEmail } from './teacher-student-joined';
|
||||
@@ -66,4 +74,9 @@ export type EmailSchemas = {
|
||||
studentOrgInvite: typeof studentOrgInviteEmail.template.schema;
|
||||
studentProgramWelcome: typeof studentProgramWelcomeEmail.template.schema;
|
||||
programGoalReminder: typeof programGoalReminderEmail.template.schema;
|
||||
quizAssigned: typeof quizAssignedEmail.template.schema;
|
||||
sessionReminder: typeof sessionReminderEmail.template.schema;
|
||||
sessionUpdated: typeof sessionUpdatedEmail.template.schema;
|
||||
submissionReceived: typeof submissionReceivedEmail.template.schema;
|
||||
submissionGraded: typeof submissionGradedEmail.template.schema;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const inviteTeacherEmail = defineEmail({
|
||||
id: 'inviteTeacher',
|
||||
@@ -12,7 +13,8 @@ export const inviteTeacherEmail = defineEmail({
|
||||
orgSiteName: z.string().min(1),
|
||||
roleName: z.string().min(1),
|
||||
expiresAt: z.string().min(1),
|
||||
inviteLink: z.url()
|
||||
inviteLink: z.url(),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
@@ -24,6 +26,6 @@ export const inviteTeacherEmail = defineEmail({
|
||||
</div>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const newsfeedPostEmail = defineEmail({
|
||||
id: 'newsfeedPost',
|
||||
@@ -11,7 +12,8 @@ export const newsfeedPostEmail = defineEmail({
|
||||
teacherName: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
postLink: z.url(),
|
||||
orgName: z.string().min(1)
|
||||
orgName: z.string().min(1),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
@@ -22,7 +24,7 @@ export const newsfeedPostEmail = defineEmail({
|
||||
</div>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,7 +35,8 @@ export const newsfeedCommentEmail = defineEmail({
|
||||
courseTitle: z.string().min(1),
|
||||
comment: z.string().min(1),
|
||||
postLink: z.url(),
|
||||
orgName: z.string().min(1)
|
||||
orgName: z.string().min(1),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
@@ -44,6 +47,6 @@ export const newsfeedCommentEmail = defineEmail({
|
||||
</div>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const programGoalReminderEmail = defineEmail({
|
||||
id: 'programGoalReminder',
|
||||
@@ -13,7 +14,8 @@ export const programGoalReminderEmail = defineEmail({
|
||||
daysUntilDue: z.number().int(),
|
||||
completedCount: z.number().int(),
|
||||
requiredCount: z.number().int(),
|
||||
loginUrl: z.string().min(1)
|
||||
loginUrl: z.string().min(1),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const dueLine =
|
||||
@@ -30,11 +32,11 @@ export const programGoalReminderEmail = defineEmail({
|
||||
<p>This is a reminder that the goal <strong>${fields.goalTitle}</strong> in your program <strong>${fields.programName}</strong> at ${fields.orgName} needs your attention.</p>
|
||||
${dueLine}
|
||||
<p>Your progress so far: <strong>${progress}</strong>.</p>
|
||||
<p><a href="${fields.loginUrl}">Open ClassroomIO</a> to keep going.</p>
|
||||
<p><a href="${fields.loginUrl}">Open the LMS</a> to keep going.</p>
|
||||
<p>Cheers,</p>
|
||||
<p>${fields.orgName}</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const quizAssignedEmail = defineEmail({
|
||||
id: 'quizAssigned',
|
||||
subject: 'You have a quiz to complete',
|
||||
schema: z.object({
|
||||
orgName: z.string().min(1),
|
||||
courseName: z.string().min(1),
|
||||
exerciseTitle: z.string().min(1),
|
||||
quizUrl: z.string().min(1),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
<p>Hi there,</p>
|
||||
<p>A quiz — <strong>${fields.exerciseTitle}</strong> — has been assigned to you in <strong>${fields.courseName}</strong> at <strong>${fields.orgName}</strong>.</p>
|
||||
<div>
|
||||
<a class="button" href="${fields.quizUrl}">Take the quiz</a>
|
||||
</div>
|
||||
<p>Cheers,</p>
|
||||
<p>${fields.orgName}</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const sessionReminderEmail = defineEmail({
|
||||
id: 'sessionReminder',
|
||||
subject: 'Reminder: your live session is coming up',
|
||||
schema: z.object({
|
||||
orgName: z.string().min(1),
|
||||
courseName: z.string().min(1),
|
||||
sessionTitle: z.string().min(1),
|
||||
sessionTimeLabel: z.string().min(1),
|
||||
whenLabel: z.string().min(1),
|
||||
joinUrl: z.string().min(1),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
<p>Hi there,</p>
|
||||
<p>Your live session <strong>${fields.sessionTitle}</strong> in <strong>${fields.courseName}</strong> starts <strong>${fields.whenLabel}</strong>.</p>
|
||||
<p><strong>When:</strong> ${fields.sessionTimeLabel}</p>
|
||||
<div>
|
||||
<a class="button" href="${fields.joinUrl}">Join the session</a>
|
||||
</div>
|
||||
<p>See you there,</p>
|
||||
<p>${fields.orgName}</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
export const sessionUpdatedEmail = defineEmail({
|
||||
id: 'sessionUpdated',
|
||||
subject: 'Updated: your live session details changed',
|
||||
schema: z.object({
|
||||
orgName: z.string().min(1),
|
||||
courseName: z.string().min(1),
|
||||
sessionTitle: z.string().min(1),
|
||||
sessionTimeLabel: z.string().min(1),
|
||||
joinUrl: z.string().min(1),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
<p>Hi there,</p>
|
||||
<p>The live session <strong>${fields.sessionTitle}</strong> in <strong>${fields.courseName}</strong> has been updated.</p>
|
||||
<p><strong>New time:</strong> ${fields.sessionTimeLabel}</p>
|
||||
<p>The attached calendar invite will update the event already on your calendar.</p>
|
||||
<div>
|
||||
<a class="button" href="${fields.joinUrl}">Join the session</a>
|
||||
</div>
|
||||
<p>Cheers,</p>
|
||||
<p>${fields.orgName}</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const studentCourseCompletionEmail = defineEmail({
|
||||
id: 'studentCourseCompletion',
|
||||
@@ -11,7 +12,8 @@ export const studentCourseCompletionEmail = defineEmail({
|
||||
courseName: z.string().min(1),
|
||||
studentName: z.string().min(1),
|
||||
certificateUrl: z.string().url(),
|
||||
customMessage: z.string().nullable().optional()
|
||||
customMessage: z.string().nullable().optional(),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const customBlock =
|
||||
@@ -28,6 +30,6 @@ export const studentCourseCompletionEmail = defineEmail({
|
||||
<p>Cheers,<br/>${fields.orgName}</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const studentCourseInviteEmail = defineEmail({
|
||||
id: 'studentCourseInvite',
|
||||
@@ -10,7 +11,8 @@ export const studentCourseInviteEmail = defineEmail({
|
||||
orgName: z.string().min(1),
|
||||
courseName: z.string().min(1),
|
||||
inviteLink: z.string().url(),
|
||||
expiresAt: z.string().min(1)
|
||||
expiresAt: z.string().min(1),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
@@ -22,6 +24,6 @@ export const studentCourseInviteEmail = defineEmail({
|
||||
</div>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const studentCourseWelcomeEmail = defineEmail({
|
||||
id: 'studentCourseWelcome',
|
||||
@@ -9,18 +10,28 @@ export const studentCourseWelcomeEmail = defineEmail({
|
||||
schema: z.object({
|
||||
orgName: z.string().min(1),
|
||||
courseName: z.string().min(1),
|
||||
loginUrl: z.string().min(1)
|
||||
loginUrl: z.string().min(1),
|
||||
customMessage: z.string().optional(),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
const hasCustomMessage = !!fields.customMessage && fields.customMessage.trim().length > 0;
|
||||
|
||||
const intro = hasCustomMessage
|
||||
? fields.customMessage
|
||||
: `
|
||||
<p>Hi there,</p>
|
||||
<p>You now have access to <strong>${fields.courseName}</strong> in <strong>${fields.orgName}</strong>.</p>
|
||||
<p><a href="${fields.loginUrl}">Sign in to ClassroomIO</a> to open the course and get started.</p>
|
||||
<p>If you run into any issues, reach out to your instructor(s).</p>
|
||||
<p>Cheers,</p>
|
||||
<p>${fields.orgName}</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
const content = `
|
||||
${intro}
|
||||
<p><a href="${fields.loginUrl}">Sign in to the LMS</a> to open the course and start learning.</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const studentOrgInviteEmail = defineEmail({
|
||||
id: 'studentOrgInvite',
|
||||
@@ -11,7 +12,8 @@ export const studentOrgInviteEmail = defineEmail({
|
||||
orgName: z.string().min(1),
|
||||
inviteLink: z.url(),
|
||||
expiresAt: z.string().min(1),
|
||||
courseNames: z.string().optional()
|
||||
courseNames: z.string().optional(),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const courseLine = fields.courseNames
|
||||
@@ -28,6 +30,6 @@ export const studentOrgInviteEmail = defineEmail({
|
||||
</div>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const studentProgramWelcomeEmail = defineEmail({
|
||||
id: 'studentProgramWelcome',
|
||||
@@ -9,18 +10,19 @@ export const studentProgramWelcomeEmail = defineEmail({
|
||||
schema: z.object({
|
||||
orgName: z.string().min(1),
|
||||
programName: z.string().min(1),
|
||||
loginUrl: z.string().min(1)
|
||||
loginUrl: z.string().min(1),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
<p>Hi there,</p>
|
||||
<p>You now have access to <strong>${fields.programName}</strong> in <strong>${fields.orgName}</strong>.</p>
|
||||
<p><a href="${fields.loginUrl}">Sign in to ClassroomIO</a> to open the program and get started.</p>
|
||||
<p><a href="${fields.loginUrl}">Sign in to the LMS</a> to open the program and start learning.</p>
|
||||
<p>If you run into any issues, reach out to your instructor(s).</p>
|
||||
<p>Cheers,</p>
|
||||
<p>${fields.orgName}</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const studentProvePaymentEmail = defineEmail({
|
||||
id: 'studentProvePayment',
|
||||
@@ -10,7 +11,8 @@ export const studentProvePaymentEmail = defineEmail({
|
||||
courseName: z.string().min(1),
|
||||
teacherEmail: z.email(),
|
||||
studentFullname: z.string().min(1),
|
||||
orgName: z.string().min(1)
|
||||
orgName: z.string().min(1),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
@@ -21,6 +23,6 @@ export const studentProvePaymentEmail = defineEmail({
|
||||
<p>${fields.orgName}</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const submissionGradedEmail = defineEmail({
|
||||
id: 'submissionGraded',
|
||||
subject: 'Your exercise submission has been updated',
|
||||
schema: z.object({
|
||||
orgName: z.string().min(1),
|
||||
studentName: z.string().min(1),
|
||||
exerciseTitle: z.string().min(1),
|
||||
courseName: z.string().min(1),
|
||||
statusText: z.string().min(1),
|
||||
exerciseLink: z.string().min(1),
|
||||
score: z.string().optional(),
|
||||
lessonTitle: z.string().optional(),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
let content = `
|
||||
<p>Hello ${fields.studentName},</p>
|
||||
<p>The status of your submitted exercise on <strong>${fields.exerciseTitle}</strong> has been updated to <strong>${fields.statusText}</strong>.</p>
|
||||
`;
|
||||
|
||||
if (fields.score) {
|
||||
content += `
|
||||
<p>Your score was <strong>${fields.score}</strong>.</p>
|
||||
<div>
|
||||
<a class="button" href="${fields.exerciseLink}">View your result</a>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
content += `
|
||||
<div>
|
||||
<a class="button" href="${fields.exerciseLink}">Open exercise</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (fields.lessonTitle) {
|
||||
content += `
|
||||
<p>This exercise is for <strong>${fields.lessonTitle}</strong> in a course you are taking titled <strong>${fields.courseName}</strong>.</p>
|
||||
`;
|
||||
} else {
|
||||
content += `
|
||||
<p>This exercise is in a course you are taking titled <strong>${fields.courseName}</strong>.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const submissionReceivedEmail = defineEmail({
|
||||
id: 'submissionReceived',
|
||||
subject: 'New exercise submission',
|
||||
schema: z.object({
|
||||
orgName: z.string().min(1),
|
||||
studentName: z.string().min(1),
|
||||
exerciseTitle: z.string().min(1),
|
||||
exerciseLink: z.string().min(1),
|
||||
submissionLink: z.string().min(1),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
<p>Hello,</p>
|
||||
<p>A student <strong>${fields.studentName}</strong> just submitted an exercise <a href="${fields.exerciseLink}">${fields.exerciseTitle}</a>.</p>
|
||||
<p>You can get started grading by clicking "Open Submissions".</p>
|
||||
<div>
|
||||
<a class="button" href="${fields.submissionLink}">Open Submissions</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const teacherCourseWelcomeEmail = defineEmail({
|
||||
id: 'teacherCourseWelcome',
|
||||
@@ -10,7 +11,8 @@ export const teacherCourseWelcomeEmail = defineEmail({
|
||||
name: z.string().min(1),
|
||||
orgName: z.string().min(1),
|
||||
courseName: z.string().min(1),
|
||||
inviteLink: z.url()
|
||||
inviteLink: z.url(),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
@@ -22,6 +24,6 @@ export const teacherCourseWelcomeEmail = defineEmail({
|
||||
</div>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const teacherStudentBuyRequestEmail = defineEmail({
|
||||
id: 'teacherStudentBuyRequest',
|
||||
@@ -9,7 +10,8 @@ export const teacherStudentBuyRequestEmail = defineEmail({
|
||||
schema: z.object({
|
||||
courseName: z.string().min(1),
|
||||
studentEmail: z.string().email(),
|
||||
studentFullname: z.string().min(1)
|
||||
studentFullname: z.string().min(1),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
@@ -22,6 +24,6 @@ export const teacherStudentBuyRequestEmail = defineEmail({
|
||||
</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from 'zod';
|
||||
|
||||
import { defineEmail } from '../send';
|
||||
import { getDefaultTemplate } from '../templates';
|
||||
import { ZEmailBranding } from '../core/branding';
|
||||
|
||||
export const teacherStudentJoinedEmail = defineEmail({
|
||||
id: 'teacherStudentJoined',
|
||||
@@ -9,7 +10,8 @@ export const teacherStudentJoinedEmail = defineEmail({
|
||||
schema: z.object({
|
||||
courseName: z.string().min(1),
|
||||
studentName: z.string().min(1),
|
||||
studentEmail: z.email()
|
||||
studentEmail: z.email(),
|
||||
branding: ZEmailBranding
|
||||
}),
|
||||
render: (fields) => {
|
||||
const content = `
|
||||
@@ -19,6 +21,6 @@ export const teacherStudentJoinedEmail = defineEmail({
|
||||
<p>If you run into any issues, please don't fail to reach out to us, we'd love to make your teaching experience as easy as possible.</p>
|
||||
`;
|
||||
|
||||
return getDefaultTemplate(content);
|
||||
return getDefaultTemplate(content, fields.branding);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Minimal hand-rolled iCalendar (.ics) builder for live-session invites.
|
||||
* No dependency — emits a single VEVENT (times in UTC; calendars localize for
|
||||
* the viewer) with optional DISPLAY alarms. A stable `uid` + incrementing
|
||||
* `sequence` lets a later send (METHOD:REQUEST) update the same calendar event
|
||||
* instead of creating a duplicate.
|
||||
*/
|
||||
|
||||
export interface SessionIcsInput {
|
||||
uid: string;
|
||||
/** Increment on each update so calendars supersede the prior version. */
|
||||
sequence?: number;
|
||||
method?: 'PUBLISH' | 'REQUEST' | 'CANCEL';
|
||||
/** Event start as an ISO string / absolute instant. */
|
||||
start: string;
|
||||
durationMinutes?: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
/** Minutes before start for DISPLAY alarms (e.g. [1440, 60]). */
|
||||
alarmsBeforeMinutes?: number[];
|
||||
}
|
||||
|
||||
function toIcsUtc(date: Date): string {
|
||||
// 2026-06-20T18:00:00.000Z -> 20260620T180000Z
|
||||
return date
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\.\d{3}/, '');
|
||||
}
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
||||
}
|
||||
|
||||
function triggerFromMinutes(minutes: number): string {
|
||||
if (minutes % 1440 === 0) return `-P${minutes / 1440}D`;
|
||||
if (minutes % 60 === 0) return `-PT${minutes / 60}H`;
|
||||
return `-PT${minutes}M`;
|
||||
}
|
||||
|
||||
export function buildSessionIcs(input: SessionIcsInput): string {
|
||||
const {
|
||||
uid,
|
||||
sequence = 0,
|
||||
method = 'PUBLISH',
|
||||
start,
|
||||
durationMinutes = 60,
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
alarmsBeforeMinutes = []
|
||||
} = input;
|
||||
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(startDate.getTime() + durationMinutes * 60 * 1000);
|
||||
const stamp = toIcsUtc(startDate);
|
||||
|
||||
const lines: string[] = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//ClassroomIO//Live Session//EN',
|
||||
'CALSCALE:GREGORIAN',
|
||||
`METHOD:${method}`,
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${uid}`,
|
||||
`SEQUENCE:${sequence}`,
|
||||
`DTSTAMP:${stamp}`,
|
||||
`DTSTART:${toIcsUtc(startDate)}`,
|
||||
`DTEND:${toIcsUtc(endDate)}`,
|
||||
`SUMMARY:${escapeText(title)}`
|
||||
];
|
||||
|
||||
if (description) lines.push(`DESCRIPTION:${escapeText(description)}`);
|
||||
if (url) lines.push(`URL:${escapeText(url)}`);
|
||||
if (method === 'CANCEL') lines.push('STATUS:CANCELLED');
|
||||
|
||||
for (const minutes of alarmsBeforeMinutes) {
|
||||
lines.push(
|
||||
'BEGIN:VALARM',
|
||||
'ACTION:DISPLAY',
|
||||
`DESCRIPTION:${escapeText(title)}`,
|
||||
`TRIGGER:${triggerFromMinutes(minutes)}`,
|
||||
'END:VALARM'
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('END:VEVENT', 'END:VCALENDAR');
|
||||
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
@@ -32,6 +32,9 @@ export type { EmailId, EmailSchemaFor } from './utils/types';
|
||||
export { deliverEmail } from './send';
|
||||
export { buildEmailFromName } from './utils/functions/email-helpers';
|
||||
export { getDefaultTemplate } from './templates/default';
|
||||
export { buildEmailBranding, resolveThemeColor, ZEmailBranding } from './core/branding';
|
||||
export { buildSessionIcs, type SessionIcsInput } from './ics';
|
||||
export type { EmailBranding } from './core/branding';
|
||||
|
||||
// Export email registry for introspection (optional)
|
||||
export { EmailRegistry } from './core/registry';
|
||||
|
||||
@@ -94,7 +94,8 @@ export async function sendEmail<TEmailId extends EmailId>(
|
||||
template,
|
||||
fields: validatedFields,
|
||||
from: config.from ?? template.from,
|
||||
replyTo: config.replyTo ?? template.replyTo
|
||||
replyTo: config.replyTo ?? template.replyTo,
|
||||
ics: config.ics
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
export const getDefaultTemplate = (content: string): string =>
|
||||
`<!DOCTYPE html>
|
||||
import type { EmailBranding } from '../core/branding';
|
||||
|
||||
const DEFAULT_BUTTON_COLOR = '#1D4EE2';
|
||||
const CLASSROOMIO_LOGO = 'https://brand.cdn.clsrio.com/cio-bg-transparent.png';
|
||||
|
||||
function escapeHtmlAttr(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function buildMasthead(branding: EmailBranding): string {
|
||||
const logoUrl = branding?.logoUrl;
|
||||
const orgName = branding?.orgName ? escapeHtmlAttr(branding.orgName) : '';
|
||||
|
||||
if (logoUrl) {
|
||||
const nameHtml = orgName
|
||||
? `<span style="margin-left:10px;font-size:16px;font-weight:600;color:#111827;vertical-align:middle;">${orgName}</span>`
|
||||
: '';
|
||||
return `<img src="${logoUrl}" alt="${orgName || 'Logo'}" style="max-height:40px;width:auto;display:inline-block;vertical-align:middle;" />${nameHtml}`;
|
||||
}
|
||||
|
||||
if (orgName) {
|
||||
return `<span style="font-size:18px;font-weight:600;color:#111827;">${orgName}</span>`;
|
||||
}
|
||||
|
||||
return `<a href="https://classroomio.com" target="_blank">
|
||||
<img src="${CLASSROOMIO_LOGO}" alt="ClassroomIO" width="130" style="width:130px;height:auto;display:block;" />
|
||||
</a>`;
|
||||
}
|
||||
|
||||
export const getDefaultTemplate = (content: string, branding?: EmailBranding): string => {
|
||||
const buttonColor = branding?.themeColor || DEFAULT_BUTTON_COLOR;
|
||||
const masthead = buildMasthead(branding);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
@@ -57,7 +89,7 @@ export const getDefaultTemplate = (content: string): string =>
|
||||
|
||||
.button {
|
||||
margin-top: 12px;
|
||||
background: #1D4EE2;
|
||||
background: ${buttonColor};
|
||||
border-radius: 6px;
|
||||
text-decoration: none !important;
|
||||
color: #fff !important;
|
||||
@@ -105,9 +137,7 @@ export const getDefaultTemplate = (content: string): string =>
|
||||
">
|
||||
<div style="background-color:#fff;max-width:525px;margin:0 auto;border-radius:4px;">
|
||||
<div style="padding:10px;border-bottom:1px solid #f0f0f0;">
|
||||
<a href="https://classroomio.com" target="_blank">
|
||||
<img src="https://brand.cdn.clsrio.com/cio-bg-transparent.png" alt="ClassroomIO" width="130" style="width:130px;height:auto;display:block;" />
|
||||
</a>
|
||||
${masthead}
|
||||
</div>
|
||||
<div style="padding:10px 15px;">
|
||||
${content}
|
||||
@@ -131,3 +161,4 @@ export const getDefaultTemplate = (content: string): string =>
|
||||
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,11 @@ export const EMAIL_IDS = [
|
||||
'newsfeedPost',
|
||||
'onPasswordReset',
|
||||
'programGoalReminder',
|
||||
'quizAssigned',
|
||||
'sessionReminder',
|
||||
'sessionUpdated',
|
||||
'submissionGraded',
|
||||
'submissionReceived',
|
||||
'studentCourseInvite',
|
||||
'studentCourseCompletion',
|
||||
'studentCourseWelcome',
|
||||
|
||||
@@ -38,7 +38,7 @@ const setupTransporter = async () => {
|
||||
};
|
||||
|
||||
export async function sendWithNodemailer(emailData: TEmailData): Promise<EmailResponse> {
|
||||
const { from, to, subject, content, replyTo } = emailData;
|
||||
const { from, to, subject, content, replyTo, ics } = emailData;
|
||||
|
||||
if (!transporter) {
|
||||
transporter = await setupTransporter();
|
||||
@@ -57,7 +57,8 @@ export async function sendWithNodemailer(emailData: TEmailData): Promise<EmailRe
|
||||
to,
|
||||
subject,
|
||||
replyTo,
|
||||
html: content
|
||||
html: content,
|
||||
...(ics ? { icalEvent: { filename: 'session.ics', method: 'PUBLISH', content: ics } } : {})
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const zohoClient = new SendMailClient({
|
||||
});
|
||||
|
||||
export async function sendWithZoho(emailData: TEmailData): Promise<EmailResponse> {
|
||||
const { from, to, subject, content, replyTo } = emailData;
|
||||
const { from, to, subject, content, replyTo, ics } = emailData;
|
||||
|
||||
const fromData = extractNameAndEmail(from || '');
|
||||
|
||||
@@ -36,7 +36,18 @@ export async function sendWithZoho(emailData: TEmailData): Promise<EmailResponse
|
||||
],
|
||||
subject,
|
||||
htmlbody: content,
|
||||
reply_to: getReplyTo(replyTo)
|
||||
reply_to: getReplyTo(replyTo),
|
||||
...(ics
|
||||
? {
|
||||
attachments: [
|
||||
{
|
||||
content: Buffer.from(ics).toString('base64'),
|
||||
mime_type: 'text/calendar',
|
||||
name: 'session.ics'
|
||||
}
|
||||
]
|
||||
}
|
||||
: {})
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user