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:
Rotimi Best
2026-06-22 05:43:48 +01:00
committed by GitHub
parent 3547901de6
commit 81294a47f9
193 changed files with 5477 additions and 805 deletions
+2 -1
View File
@@ -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
}
]);
}
+86
View File
@@ -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 };
}
+2
View File
@@ -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;
}
/**
+13
View File
@@ -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;
};
+4 -2
View File
@@ -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);
}
});
+7 -4
View File
@@ -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);
}
});
+61
View File
@@ -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);
}
});
+91
View File
@@ -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');
}
+3
View File
@@ -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';
+2 -1
View File
@@ -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) {
+37 -6
View File
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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>
`;
};
+5
View File
@@ -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 {
+13 -2
View File
@@ -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 {