From c03c08fc84c0c77c8c84b5b4f27abe150178de90 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Sat, 11 Jul 2026 22:22:36 +0100 Subject: [PATCH] fix(email): make team org invites more transactional (#732) * fix(email): make team org invites more transactional Use org-branded sender and subject instead of the default "Best from ClassroomIO" marketing identity. Include the inviter name in the body, remove promotional emojis, and thread optional subject overrides through the email job pipeline. Co-authored-by: Rotimi Best * fix(email): escape invite fields and sanitize email headers Address Greptile review: escape inviter/org/role values in the team invite template, sanitize display names in buildEmailFromName, and strip control characters from dynamic subjects. Co-authored-by: Rotimi Best * fix(email): use RFC 5322 backslash escaping in display names Co-authored-by: Rotimi Best * test(email): add vitest coverage for email header helpers Cover escapeHtml, sanitizeEmailDisplayName, sanitizeEmailSubject, and buildEmailFromName. Collapse repeated whitespace when stripping control characters from header values. Co-authored-by: Rotimi Best * fix(email): strip all ASCII control chars from email headers Co-authored-by: Rotimi Best --------- Co-authored-by: Cursor Agent Co-authored-by: Rotimi Best --- apps/api/src/services/jobs/email-jobs.ts | 3 + apps/api/src/services/organization/invite.ts | 9 ++- apps/jobs/src/processors/emails/send.ts | 1 + packages/email/package.json | 7 ++- packages/email/src/emails/invite-teacher.ts | 22 +++++-- packages/email/src/index.ts | 2 +- .../src/utils/functions/email-helpers.ts | 37 ++++++++++- packages/email/tests/email-helpers.test.ts | 63 +++++++++++++++++++ packages/email/vitest.config.ts | 8 +++ packages/jobs/src/payloads/emails.ts | 2 + .../storybook/src/templates/email/fixtures.ts | 4 +- pnpm-lock.yaml | 16 ++++- 12 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 packages/email/tests/email-helpers.test.ts create mode 100644 packages/email/vitest.config.ts 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).

`; 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