* 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}
@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;