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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* fix(email): use RFC 5322 backslash escaping in display names

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* fix(email): strip all ASCII control chars from email headers

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-07-11 22:22:36 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent 1a9c240501
commit c03c08fc84
12 changed files with 159 additions and 15 deletions
+3
View File
@@ -20,6 +20,8 @@ export interface EnqueueTemplateEmailInput<TId extends EmailId> extends CommonOp
fields: z.infer<EmailSchemaFor<TId>>;
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<TId extends EmailId>(
fields: validatedFields,
from: input.from,
replyTo: input.replyTo,
subject: input.subject,
ics: input.ics
},
{ idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) }
+7 -2
View File
@@ -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}`
});
+1
View File
@@ -23,6 +23,7 @@ export async function processSendEmail(rawPayload: unknown): Promise<SendResult>
fields: payload.fields as never,
from: payload.from,
replyTo: payload.replyTo,
subject: payload.subject,
ics: payload.ics
});
const result = extractProviderId(responses);
+5 -2
View File
@@ -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"
}
}
+17 -5
View File
@@ -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
? `<p><strong>${inviterName}</strong> invited you to join <strong>${orgName}</strong> on ClassroomIO as ${roleName}.</p>`
: `<p>You have been invited to join <strong>${orgName}</strong> on ClassroomIO as ${roleName}.</p>`;
const content = `
<p>Hey there,</p>
<p>You have been invited to join ${fields.orgName} on ClassroomIO as ${fields.roleName} 🎉🎉🎉.</p>
<p>This invite expires on ${fields.expiresAt} (UTC).</p>
<p>Hi there,</p>
${invitationLine}
<p>This invite expires on ${expiresAt} (UTC).</p>
<div>
<a class="button" href="${fields.inviteLink}">Accept Invitation</a>
<a class="button" href="${inviteLink}">Accept Invitation</a>
</div>
`;
+1 -1
View File
@@ -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';
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// format: "ClassroomIO Developers (via ClassroomIO.com)" <notify@mail.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}>`;
}
@@ -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" <notify@mail.classroomio.com>';
describe('escapeHtml', () => {
it('escapes HTML metacharacters', () => {
expect(escapeHtml(`Tom & Jerry's "Lab"`)).toBe('Tom &amp; Jerry&#39;s &quot;Lab&quot;');
expect(escapeHtml('<img src=x onerror=alert(1)>')).toBe('&lt;img src=x onerror=alert(1)&gt;');
});
});
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)" <notify@mail.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)" <notify@mail.classroomio.com>'
);
});
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.ts']
}
});
+2
View File
@@ -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()
});
@@ -36,8 +36,8 @@ const COURSE_WELCOME_CONTENT = `
`;
const INVITE_TEACHER_CONTENT = `
<p>Hey there,</p>
<p>You have been invited to join <strong>Acme Coding School</strong> on ClassroomIO as <strong>Admin</strong> 🎉🎉🎉.</p>
<p>Hi there,</p>
<p><strong>Sarah Johnson</strong> invited you to join <strong>Acme Coding School</strong> on ClassroomIO as <strong>Admin</strong>.</p>
<p>This invite expires on <strong>Jul 15, 2026</strong> (UTC).</p>
<div><a class="button" href="#">Accept Invitation</a></div>
`;
+14 -2
View File
@@ -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