* fix(api): use admin app URL for teacher auto-enroll links Teacher payment-request emails now build course and grant-access URLs with getAppBaseUrl() instead of getDashboardBaseUrl(org), so links point at app.classroomio.com rather than a custom or tenant domain. Document the teacher vs student email link rule in AGENTS.md. Co-authored-by: Rotimi Best <[email protected]> * docs: surface email link URL rules where emails are wired Document getAppBaseUrl vs getDashboardBaseUrl in dashboard-url.ts, packages/email/README.md, enqueueTransactionalEmail JSDoc, and AGENTS.md so staff vs learner hosts are discoverable when building email links. Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
6.3 KiB
@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]'
});
Email link URLs
Email templates receive fully-built URLs in their fields — templates do not choose the host. Callers in apps/api (or other services) must build links before calling enqueueTransactionalEmail or send.
Import helpers from @cio/core/config/dashboard-url:
| Recipient | Helper | Use for |
|---|---|---|
| Teacher, tutor, admin | getAppBaseUrl() |
Course management, grading, team invites, auto-enroll |
| Student, learner | getDashboardBaseUrl(org) |
Course enroll, audience invites, login |
import { getAppBaseUrl, getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
// Staff dashboard action (admin app — not org custom domain)
const autoEnrollUrl = `${getAppBaseUrl()}/courses/${courseId}/people?grantAccess=${encodeURIComponent(email)}`;
// Learner-facing invite (org public site)
const inviteUrl = `${getDashboardBaseUrl(org)}/invite/${encodeURIComponent(token)}`;
See also AGENTS.md § Email link URLs.
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;