* 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 <[email protected]> * 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 <[email protected]> * fix(email): use RFC 5322 backslash escaping in display names Co-authored-by: Rotimi Best <[email protected]> * 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 <[email protected]> * fix(email): strip all ASCII control chars from email headers Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
@cio/emails
Overview
A unified email system using a single send() function that accepts an email ID string with full runtime validation via Zod schemas. All emails are defined declaratively with automatic registration.
Key Features
- Minimal imports: Single
import { send } from '@cio/email' - Type safety: Type-safe email IDs with autocomplete (TypeScript enforces valid IDs)
- Runtime validation: Zod schemas validate fields at runtime
- Auto-registration: Emails are automatically registered when defined
- Discoverability: All emails accessible via string ID
- Consistency: Unified pattern for all email templates
- Validation: Automatic Zod validation of fields with helpful error messages
Usage
Sending an Email
import { sendEmail } from '@cio/email';
await send('forgotPassword', {
to: '[email protected]',
fields: {
name: 'John Doe',
email: '[email protected]',
link: 'https://app.classroomio.com/reset?token=abc123'
}
});
With Overrides
await send('forgotPassword', {
to: '[email protected]',
fields: {
name: 'John Doe',
email: '[email protected]',
link: 'https://app.classroomio.com/reset?token=abc123'
},
from: '"Support Team" <[email protected]>',
replyTo: '[email protected]'
});
Creating Email Templates
Basic Example
// packages/email/src/emails/forgot-password.ts
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
export const forgotPasswordEmail = defineEmail({
id: 'forgotPassword',
subject: 'Password reset notification - ClassroomIO',
schema: z.object({
name: z.string().min(1),
email: z.string().email(),
link: z.url()
}),
render: (fields) => {
const content = `Hello ${fields.name},
<p>You are receiving this email because you have requested a password reset.</p>
<div>
<a class="button" href="${fields.link}">Reset my password</a>
</div>`;
return getDefaultTemplate(content);
}
});
TypeScript will provide autocomplete for valid email IDs and show errors for invalid ones. The EmailId type is defined in utils/types.ts based on the EMAIL_IDS constant in utils/constants.ts.
Note: When adding new emails, update both:
- The
EMAIL_IDSarray inutils/constants.ts - The
EmailSchemastype mapping inemails/index.ts
This ensures full type safety and schema inference.
Error Handling
The send() function automatically validates fields against the email's schema. If validation fails, a helpful error is thrown:
try {
await send('forgotPassword', {
to: '[email protected]',
fields: {
name: '', // Invalid: empty string
link: 'not-a-url' // Invalid: not a URL
}
});
} catch (error) {
// Error: Invalid fields for email "forgotPassword": name: String must contain at least 1 character(s), link: Invalid url
}
If an email ID doesn't exist:
try {
await send('nonexistentEmail', { ... });
} catch (error) {
// Error: Email template "nonexistentEmail" not found. Available emails: forgotPassword, welcome, verifyEmail
}
Architecture
Core Components
core/types.ts: Type definitions (EmailTemplate, SendConfig, etc.)core/registry.ts: Email registry that stores all templatescore/base.ts: Base send function that validates and sendssend.ts:defineEmail()andsend()functionsutils/constants.ts:EMAIL_IDSconstant array with all email IDsutils/types.ts:EmailIdtype derived fromEMAIL_IDSfor type safety, andEmailSchemaForconditional typeemails/*.ts: Individual email definitionsemails/index.ts: Auto-exports all emails for registration and definesEmailSchemastype mappingindex.ts: Main export point
Flow
- Email definitions use
defineEmail()which automatically registers them - All emails are imported via
emails/index.tswhen the package is loaded send()looks up the email by ID, validates fields, and sends via existing infrastructure- Existing
deliverEmail()function fromsend.tsis reused for actual delivery
Adding New Emails
When adding a new email template:
- Create a new email file in
emails/directory usingdefineEmail() - Export it from
emails/index.ts - Important: Add the email ID to the
EMAIL_IDSarray inutils/constants.ts - Important: Add the schema mapping to
EmailSchemastype inemails/index.tsfor type inference
Example:
// 1. Create emails/new-email.ts
export const newEmail = defineEmail({
id: 'newEmail',
subject: 'New Email',
schema: z.object({
name: z.string(),
code: z.string()
}),
render: (fields) => { ... }
});
// 2. Export from emails/index.ts
export * from './new-email';
// Import the email definition for type mapping
import type { newEmail } from './new-email';
// Add to EmailSchemas type mapping in emails/index.ts
export type EmailSchemas = {
forgotPassword: typeof forgotPasswordEmail.template.schema;
welcome: typeof welcomeEmail.template.schema;
verifyEmail: typeof verifyEmailEmail.template.schema;
newEmail: typeof newEmail.template.schema; // ← Add here for type inference
};
// 3. Add to utils/constants.ts
export const EMAIL_IDS = [
'forgotPassword',
'welcome',
'verifyEmail',
'newEmail' // ← Add here for type safety
] as const;