Files
classroomio/packages/email
Rotimi BestandRotimi Best bfafc685e0 fix(exercise): persist completion policy and improve dev build workflow (#754)
* fix(exercise): persist completion policy and pass threshold on save

buildExerciseUpdateFields accepted completionPolicy and passThreshold in
the validation schema but never mapped them to the database update, so
exercise settings appeared to save while reverting on refresh.

Co-authored-by: Rotimi Best <[email protected]>

* fix(db): build workspace deps before compiling

@cio/db imports @cio/utils/notifications, which only exists after
@cio/utils is built. Building db in isolation failed when utils dist
was missing or stale.

- Add a db prebuild step for question-types, utils, and email
- Clean dist and tsbuildinfo before utils and email builds so
  incremental tsc cannot skip emission after dist was removed

Co-authored-by: Rotimi Best <[email protected]>

* chore(dev): build api workspace deps before api:dev

Run turbo build for @cio/api and @cio/jobs-worker dependencies before
starting the dev servers, so missing dist/ folders do not break startup.

Co-authored-by: Rotimi Best <[email protected]>

* feat(course): improve exercise progression grading UX

---------

Co-authored-by: Rotimi Best <[email protected]>
2026-07-15 03:26:56 +01:00
..

@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:

  1. The EMAIL_IDS array in utils/constants.ts
  2. The EmailSchemas type mapping in emails/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 templates
  • core/base.ts: Base send function that validates and sends
  • send.ts: defineEmail() and send() functions
  • utils/constants.ts: EMAIL_IDS constant array with all email IDs
  • utils/types.ts: EmailId type derived from EMAIL_IDS for type safety, and EmailSchemaFor conditional type
  • emails/*.ts: Individual email definitions
  • emails/index.ts: Auto-exports all emails for registration and defines EmailSchemas type mapping
  • index.ts: Main export point

Flow

  1. Email definitions use defineEmail() which automatically registers them
  2. All emails are imported via emails/index.ts when the package is loaded
  3. send() looks up the email by ID, validates fields, and sends via existing infrastructure
  4. Existing deliverEmail() function from send.ts is reused for actual delivery

Adding New Emails

When adding a new email template:

  1. Create a new email file in emails/ directory using defineEmail()
  2. Export it from emails/index.ts
  3. Important: Add the email ID to the EMAIL_IDS array in utils/constants.ts
  4. Important: Add the schema mapping to EmailSchemas type in emails/index.ts for 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;