* 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 <rotimi-best@users.noreply.github.com> * 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 <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
205 lines
6.3 KiB
Markdown
205 lines
6.3 KiB
Markdown
# @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
|
|
|
|
```typescript
|
|
import { sendEmail } from '@cio/email';
|
|
|
|
await send('forgotPassword', {
|
|
to: 'user@example.com',
|
|
fields: {
|
|
name: 'John Doe',
|
|
email: 'user@example.com',
|
|
link: 'https://app.classroomio.com/reset?token=abc123'
|
|
}
|
|
});
|
|
```
|
|
|
|
### With Overrides
|
|
|
|
```typescript
|
|
await send('forgotPassword', {
|
|
to: 'user@example.com',
|
|
fields: {
|
|
name: 'John Doe',
|
|
email: 'user@example.com',
|
|
link: 'https://app.classroomio.com/reset?token=abc123'
|
|
},
|
|
from: '"Support Team" <support@classroomio.com>',
|
|
replyTo: 'help@classroomio.com'
|
|
});
|
|
```
|
|
|
|
## 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 |
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
try {
|
|
await send('forgotPassword', {
|
|
to: 'user@example.com',
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
// 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;
|
|
```
|