Files
classroomio/packages/email
Rotimi Best 8bed9f6b4e refactor(billing): system email + single-query backfill for the student cap (#735)
* refactor(billing): make student-limit email a system email; tighten backfill query

- Fetch free-plan orgs over the student limit in a single query
  (getFreePlanOrganizationsOverStudentLimit) instead of loading all orgs and
  counting each in memory; the backfill script now just fans out admin emails.
- Report a dry-run summary: how many free orgs are over the limit, how many
  with admins, total admin emails, and how many were skipped for having none.
- Reclassify the student-limit email as a system (ClassroomIO-branded) email
  rather than org-branded — it's the platform telling an admin about their
  plan. Drop the `branding` field and org masthead/theme; both callers updated.
- Reword the email to explain why it's sent (new students are being turned
  away) and that it's throttled to once a day, so it doesn't read as spam.
- Document the system-vs-org-branded email distinction in AGENTS.md.

* feat(billing): send student-limit emails on milestone crossings, once each

Stop emailing admins on every blocked attempt. Instead notify once when the
org crosses a student-count milestone:
- 50% of the limit → new studentLimitApproaching email (heads-up).
- 100% (limit reached) → studentLimitReached email.

Crossings are detected in assertStudentCapacityOrThrow on the allowed path
(blocked attempts no longer email). "Already notified" is persisted on
organization.settings.studentLimitNotified so each milestone fires at most
once, ever — surviving restarts, no per-day approximation. The backfill script
uses the same flag, making reruns idempotent (skips already-notified orgs and
marks them after sending). Email copy updated to explain the trigger and that
it's a one-time-per-milestone message.

* fix(email): use z.url() and drop the frequency footnotes on student-limit emails

Address PR review: `upgradeUrl` uses `z.url()`; remove the "once a day / once
per milestone" footnote lines from both the reached and approaching emails.

* set track={false}
2026-07-12 20:16:12 +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;