diff --git a/apps/api/src/services/jobs/email-jobs.ts b/apps/api/src/services/jobs/email-jobs.ts index c1345c59..4e45ea2b 100644 --- a/apps/api/src/services/jobs/email-jobs.ts +++ b/apps/api/src/services/jobs/email-jobs.ts @@ -20,6 +20,8 @@ export interface EnqueueTemplateEmailInput extends CommonOp 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 attached as a text/calendar part. */ ics?: string; } @@ -84,6 +86,7 @@ export async function enqueueTransactionalEmail( fields: validatedFields, from: input.from, replyTo: input.replyTo, + subject: input.subject, ics: input.ics }, { idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) } diff --git a/apps/api/src/services/organization/invite.ts b/apps/api/src/services/organization/invite.ts index 2c526a88..b6f548ed 100644 --- a/apps/api/src/services/organization/invite.ts +++ b/apps/api/src/services/organization/invite.ts @@ -30,9 +30,9 @@ import crypto from 'node:crypto'; import { db, type DbOrTxClient } from '@cio/db/drizzle'; import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { parseCourseIdsFromInviteMetadata, parseCohortIdsFromInviteMetadata } from '@api/utils/org'; -import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile'; +import { getProfileById, markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile'; import { enqueueTransactionalEmail } from '@api/services/jobs'; -import { buildEmailBranding } from '@cio/email'; +import { buildEmailBranding, buildEmailFromName, sanitizeEmailSubject } from '@cio/email'; import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance'; type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED'; @@ -220,6 +220,8 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId: const expiresAt = new Date(Date.now() + ORG_INVITE_EXPIRY_MS).toISOString(); const roleName = getRoleLabel(roleId); + const inviterProfile = invitedByProfileId ? await getProfileById(invitedByProfileId) : null; + const inviterName = inviterProfile?.fullname?.trim() || undefined; for (const email of emailsToInvite) { try { @@ -259,10 +261,13 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId: orgName: organization.name, orgSiteName: organization.siteName, roleName, + inviterName, expiresAt: getExpiryLabel(expiresAt), inviteLink, branding: buildEmailBranding(organization) }, + from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), + subject: sanitizeEmailSubject(`You have been invited to join ${organization.name} on ClassroomIO`), idempotencyKey: `org-invite-teacher:${invite.id}` }); diff --git a/apps/jobs/src/processors/emails/send.ts b/apps/jobs/src/processors/emails/send.ts index f8787d4e..f0dfc7df 100644 --- a/apps/jobs/src/processors/emails/send.ts +++ b/apps/jobs/src/processors/emails/send.ts @@ -23,6 +23,7 @@ export async function processSendEmail(rawPayload: unknown): Promise fields: payload.fields as never, from: payload.from, replyTo: payload.replyTo, + subject: payload.subject, ics: payload.ics }); const result = extractProviderId(responses); diff --git a/packages/email/package.json b/packages/email/package.json index 1818dd9e..f0160075 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -10,7 +10,9 @@ "scripts": { "build": "tsc && tsc-alias", "dev": "tsc --watch", - "format": "prettier --write \"src/**/*.ts\"" + "format": "prettier --write \"src/**/*.ts\"", + "test": "vitest run", + "test:watch": "vitest" }, "exports": { ".": { @@ -42,6 +44,7 @@ "prettier": "^3.2.5", "tsc-alias": "^1.8.16", "tsx": "^4.7.1", - "typescript": "^5.5.2" + "typescript": "^5.5.2", + "vitest": "^1.2.2" } } diff --git a/packages/email/src/emails/invite-teacher.ts b/packages/email/src/emails/invite-teacher.ts index affdc155..1e67da2f 100644 --- a/packages/email/src/emails/invite-teacher.ts +++ b/packages/email/src/emails/invite-teacher.ts @@ -3,26 +3,38 @@ import * as z from 'zod'; import { defineEmail } from '../send'; import { getDefaultTemplate } from '../templates'; import { ZEmailBranding } from '../core/branding'; +import { escapeHtml } from '../utils/functions/email-helpers'; export const inviteTeacherEmail = defineEmail({ id: 'inviteTeacher', - subject: 'Join us on ClassroomIO 😃', + subject: 'You have been invited to join an organization on ClassroomIO', schema: z.object({ email: z.string().email(), orgName: z.string().min(1), orgSiteName: z.string().min(1), roleName: z.string().min(1), + inviterName: z.string().min(1).optional(), expiresAt: z.string().min(1), inviteLink: z.url(), branding: ZEmailBranding }), render: (fields) => { + const orgName = escapeHtml(fields.orgName); + const roleName = escapeHtml(fields.roleName); + const inviterName = fields.inviterName ? escapeHtml(fields.inviterName) : undefined; + const inviteLink = escapeHtml(fields.inviteLink); + const expiresAt = escapeHtml(fields.expiresAt); + + const invitationLine = inviterName + ? `

${inviterName} invited you to join ${orgName} on ClassroomIO as ${roleName}.

` + : `

You have been invited to join ${orgName} on ClassroomIO as ${roleName}.

`; + const content = ` -

Hey there,

-

You have been invited to join ${fields.orgName} on ClassroomIO as ${fields.roleName} 🎉🎉🎉.

-

This invite expires on ${fields.expiresAt} (UTC).

+

Hi there,

+ ${invitationLine} +

This invite expires on ${expiresAt} (UTC).

- Accept Invitation + Accept Invitation
`; diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts index 6fe86ea1..590818a6 100644 --- a/packages/email/src/index.ts +++ b/packages/email/src/index.ts @@ -30,7 +30,7 @@ export type { EmailId, EmailSchemaFor } from './utils/types'; // Re-export low-level utilities export { deliverEmail } from './send'; -export { buildEmailFromName } from './utils/functions/email-helpers'; +export { buildEmailFromName, escapeHtml, sanitizeEmailSubject } from './utils/functions/email-helpers'; export { getDefaultTemplate } from './templates/default'; export { buildEmailBranding, resolveThemeColor, ZEmailBranding } from './core/branding'; export { buildSessionIcs, type SessionIcsInput } from './ics'; diff --git a/packages/email/src/utils/functions/email-helpers.ts b/packages/email/src/utils/functions/email-helpers.ts index 36652eac..8529189d 100644 --- a/packages/email/src/utils/functions/email-helpers.ts +++ b/packages/email/src/utils/functions/email-helpers.ts @@ -1,6 +1,36 @@ import { FromData } from '../types'; import { EMAIL_FROM } from '../constants'; +const EMAIL_HEADER_CONTROL_CHARS = /[\x00-\x1F\x7F]/g; + +function stripEmailHeaderControlChars(value: string): string { + return value.replace(EMAIL_HEADER_CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim(); +} + +/** Strip control chars and backslash-escape quotes for RFC 5322 quoted display names. */ +export function sanitizeEmailDisplayName(name: string): string { + const normalized = stripEmailHeaderControlChars(name); + if (!normalized) { + return ''; + } + + return normalized.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +/** Strip control chars that could break or inject email headers. */ +export function sanitizeEmailSubject(subject: string): string { + return stripEmailHeaderControlChars(subject); +} + +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + // format: "ClassroomIO Developers (via ClassroomIO.com)" export function extractNameAndEmail(str: string): FromData | undefined { // Use regular expressions to match the name and email @@ -28,5 +58,10 @@ export function buildEmailFromName(name?: string): string { return EMAIL_FROM; } - return `"${name}" <${fromData.email}>`; + const displayName = sanitizeEmailDisplayName(name); + if (!displayName) { + return EMAIL_FROM; + } + + return `"${displayName}" <${fromData.email}>`; } diff --git a/packages/email/tests/email-helpers.test.ts b/packages/email/tests/email-helpers.test.ts new file mode 100644 index 00000000..dbb49be5 --- /dev/null +++ b/packages/email/tests/email-helpers.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildEmailFromName, + escapeHtml, + sanitizeEmailDisplayName, + sanitizeEmailSubject +} from '../src/utils/functions/email-helpers'; + +const DEFAULT_FROM = '"Best from ClassroomIO" '; + +describe('escapeHtml', () => { + it('escapes HTML metacharacters', () => { + expect(escapeHtml(`Tom & Jerry's "Lab"`)).toBe('Tom & Jerry's "Lab"'); + expect(escapeHtml('')).toBe('<img src=x onerror=alert(1)>'); + }); +}); + +describe('sanitizeEmailDisplayName', () => { + it('strips control characters and trims whitespace', () => { + expect(sanitizeEmailDisplayName(' Acme\r\nAcademy\u0000 ')).toBe('Acme Academy'); + expect(sanitizeEmailDisplayName('Acme\x01Academy\x7F')).toBe('Acme Academy'); + }); + + it('backslash-escapes embedded quotes per RFC 5322 quoted-strings', () => { + expect(sanitizeEmailDisplayName('Alice "Admin" Academy')).toBe('Alice \\"Admin\\" Academy'); + }); + + it('escapes backslashes before quotes', () => { + expect(sanitizeEmailDisplayName('Acme\\Corp "East"')).toBe('Acme\\\\Corp \\"East\\"'); + }); +}); + +describe('sanitizeEmailSubject', () => { + it('strips control characters and trims whitespace', () => { + expect(sanitizeEmailSubject(' Invite to Acme\r\n\rAcademy\u0000 ')).toBe('Invite to Acme Academy'); + expect(sanitizeEmailSubject('Invite\x01to\x7FAcme')).toBe('Invite to Acme'); + }); + + it('preserves quotes in subject text', () => { + expect(sanitizeEmailSubject('Join "Acme" today')).toBe('Join "Acme" today'); + }); +}); + +describe('buildEmailFromName', () => { + it('returns the default sender when name is missing or empty after sanitization', () => { + expect(buildEmailFromName()).toBe(DEFAULT_FROM); + expect(buildEmailFromName('')).toBe(DEFAULT_FROM); + expect(buildEmailFromName('\r\n\u0000')).toBe(DEFAULT_FROM); + }); + + it('wraps a plain display name in a quoted From header', () => { + expect(buildEmailFromName('Acme Academy (via ClassroomIO.com)')).toBe( + '"Acme Academy (via ClassroomIO.com)" ' + ); + }); + + it('sanitizes embedded quotes in the display name', () => { + expect(buildEmailFromName('Alice "Admin" Academy (via ClassroomIO.com)')).toBe( + '"Alice \\"Admin\\" Academy (via ClassroomIO.com)" ' + ); + }); +}); diff --git a/packages/email/vitest.config.ts b/packages/email/vitest.config.ts new file mode 100644 index 00000000..1147f870 --- /dev/null +++ b/packages/email/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'] + } +}); diff --git a/packages/jobs/src/payloads/emails.ts b/packages/jobs/src/payloads/emails.ts index 6d80cef6..f6c6a6cb 100644 --- a/packages/jobs/src/payloads/emails.ts +++ b/packages/jobs/src/payloads/emails.ts @@ -12,6 +12,8 @@ export const ZSendTemplateEmailPayload = z.object({ fields: z.record(z.string(), z.unknown()).default({}), from: z.string().optional(), replyTo: z.string().optional(), + /** Override the template's default subject (e.g. org-scoped transactional mail). */ + subject: z.string().min(1).optional(), /** Optional iCalendar (.ics) body attached as a text/calendar part. */ ics: z.string().optional() }); diff --git a/packages/storybook/src/templates/email/fixtures.ts b/packages/storybook/src/templates/email/fixtures.ts index de23f487..fbcb7875 100644 --- a/packages/storybook/src/templates/email/fixtures.ts +++ b/packages/storybook/src/templates/email/fixtures.ts @@ -36,8 +36,8 @@ const COURSE_WELCOME_CONTENT = ` `; const INVITE_TEACHER_CONTENT = ` -

Hey there,

-

You have been invited to join Acme Coding School on ClassroomIO as Admin 🎉🎉🎉.

+

Hi there,

+

Sarah Johnson invited you to join Acme Coding School on ClassroomIO as Admin.

This invite expires on Jul 15, 2026 (UTC).

`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e6b98fc..0f386fae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1260,6 +1260,9 @@ importers: typescript: specifier: ^5.5.2 version: 5.9.3 + vitest: + specifier: ^1.2.2 + version: 1.6.1(@types/node@24.10.4)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.32.0)(sass@1.97.1) packages/jobs: dependencies: @@ -8453,10 +8456,12 @@ packages: conventional-changelog-atom@2.0.8: resolution: {integrity: sha512-xo6v46icsFTK3bb7dY/8m2qvc8sZemRgdqLb/bjpBsH2UyOS8rKNTgcb5025Hri6IpANPApbXMg15QLb1LJpBw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-codemirror@2.0.8: resolution: {integrity: sha512-z5DAsn3uj1Vfp7po3gpt2Boc+Bdwmw2++ZHa5Ak9k0UKsYAO5mH1UBTN0qSCuJZREIhX6WU4E1p3IW2oRCNzQw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-config-spec@2.1.0: resolution: {integrity: sha512-IpVePh16EbbB02V+UA+HQnnPIohgXvJRxHcS5+Uwk4AT5LjzCZJm5sp/yqs5C6KZJ1jMsV4paEV13BN1pvDuxQ==} @@ -8468,26 +8473,32 @@ packages: conventional-changelog-core@4.2.4: resolution: {integrity: sha512-gDVS+zVJHE2v4SLc6B0sLsPiloR0ygU7HaDW14aNJE1v4SlqJPILPl/aJC7YdtRE4CybBf8gDwObBvKha8Xlyg==} engines: {node: '>=10'} + deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead. conventional-changelog-ember@2.0.9: resolution: {integrity: sha512-ulzIReoZEvZCBDhcNYfDIsLTHzYHc7awh+eI44ZtV5cx6LVxLlVtEmcO+2/kGIHGtw+qVabJYjdI5cJOQgXh1A==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-eslint@3.0.9: resolution: {integrity: sha512-6NpUCMgU8qmWmyAMSZO5NrRd7rTgErjrm4VASam2u5jrZS0n38V7Y9CzTtLT2qwz5xEChDR4BduoWIr8TfwvXA==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-express@2.0.6: resolution: {integrity: sha512-SDez2f3iVJw6V563O3pRtNwXtQaSmEfTCaTBPCqn0oG0mfkq0rX4hHBq5P7De2MncoRixrALj3u3oQsNK+Q0pQ==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-jquery@3.0.11: resolution: {integrity: sha512-x8AWz5/Td55F7+o/9LQ6cQIPwrCjfJQ5Zmfqi8thwUEKHstEn4kTIofXub7plf1xvFA2TqhZlq7fy5OmV6BOMw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-jshint@2.0.9: resolution: {integrity: sha512-wMLdaIzq6TNnMHMy31hql02OEQ8nCQfExw1SE0hYL5KvU+JCTuPaDO+7JiogGT2gJAxiUGATdtYYfh+nT+6riA==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-preset-loader@2.3.4: resolution: {integrity: sha512-GEKRWkrSAZeTq5+YjUZOYxdHq+ci4dNwHvpaBC3+ENalzFWuCWa9EZXSuZBpkr72sMdKB+1fyDV4takK1Lf58g==} @@ -9823,7 +9834,7 @@ packages: git-raw-commits@2.0.11: resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} engines: {node: '>=10'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-remote-origin-url@2.0.0: @@ -9833,7 +9844,7 @@ packages: git-semver-tags@4.1.1: resolution: {integrity: sha512-OWyMt5zBe7xFs8vglMmhM9lRQzCWL3WjHtxNNfJTMngGym7pC1kh8sP6jevfydJ6LP3ZvGxfb6ABYgPUM0mtsA==} engines: {node: '>=10'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true gitconfiglocal@1.0.0: @@ -13628,6 +13639,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0