Files
classroomio/packages/email/src/send.ts
T
Cursor AgentandRotimi Best a6d06f356f feat(email): make verify-email org-aware like other transactional mail
Resolve the tenant org from the verification link callback URL (custom
domain, tenant subdomain, or ?org=) and apply org logo, theme color, from
name, and subject. Falls back to ClassroomIO branding on the main app.

Co-authored-by: Rotimi Best <[email protected]>
2026-07-10 04:32:11 +00:00

149 lines
4.7 KiB
TypeScript

import * as z from 'zod';
import { ZDeliverEmail, type TDeliverEmail } from '@cio/utils/validation/mail';
import { env } from './config/env';
import { sendWithZoho } from './utils/services/zeptomail';
import { sendWithNodemailer } from './utils/services/nodemailer';
import type { EmailResponse, EmailId, EmailSchemaFor } from './utils/types';
import type { DefineEmailConfig, EmailDefinition, EmailTemplate, SendConfig } from './core/types';
import { EmailRegistry } from './core/registry';
import { ZodError } from 'zod';
import { sendTemplateEmail } from './core/base';
import { EMAIL_FROM, EMAIL_REPLY_TO } from './utils/constants';
/**
* Define an email template with schema validation
* The email is automatically registered in the registry for use with sendEmail()
*
* @example
* ```ts
* export const forgotPasswordEmail = defineEmail({
* id: 'forgotPassword',
* subject: 'Password reset notification',
* schema: z.object({
* name: z.string(),
* link: z.url()
* }),
* render: (fields) => `Hello ${fields.name}, reset here: ${fields.link}`
* });
* ```
*/
export function defineEmail<TSchema extends z.ZodType>(config: DefineEmailConfig<TSchema>): EmailDefinition<TSchema> {
const template: EmailTemplate<TSchema> = {
subject: config.subject,
schema: config.schema,
render: config.render,
from: config.from ?? EMAIL_FROM,
replyTo: config.replyTo ?? EMAIL_REPLY_TO
};
// Auto-register in registry
EmailRegistry.register(config.id, template);
return {
id: config.id,
template
};
}
/**
* Send an email template using its registered ID
*
* This function looks up the template by ID, validates the fields against the template's schema,
* renders the content, and delivers the email.
*
* The `fields` type is automatically inferred based on the `emailId` passed, providing
* full type safety and autocomplete.
*
* @param emailId - The ID of the email template (type-safe, only valid IDs accepted)
* @param config - Send configuration with recipient and fields (fields type is inferred from emailId)
* @returns Promise resolving to email send results
*
* @example
* ```ts
* await sendEmail('forgotPassword', {
* to: '[email protected]',
* fields: {
* name: 'John Doe',
* email: '[email protected]',
* link: 'https://app.classroomio.com/reset?token=abc123'
* }
* });
* ```
*/
export async function sendEmail<TEmailId extends EmailId>(
emailId: TEmailId,
config: SendConfig<EmailSchemaFor<TEmailId>>
): Promise<EmailResponse[]> {
const template = EmailRegistry.get(emailId);
if (!template) {
const availableIds = EmailRegistry.getAllIds();
throw new Error(
`Email template "${emailId}" not found. Available emails: ${availableIds.length > 0 ? availableIds.join(', ') : 'none registered'}`
);
}
try {
// Validate fields against template schema
const validatedFields = template.schema.parse(config.fields);
// Send using base function
return sendTemplateEmail({
to: config.to,
template,
fields: validatedFields,
from: config.from ?? template.from,
replyTo: config.replyTo ?? template.replyTo,
subject: config.subject,
ics: config.ics
});
} catch (error) {
if (error instanceof ZodError) {
console.log('error', error);
const errorMessages = error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
throw new Error(`Invalid fields for email "${emailId}": ${errorMessages}`);
}
throw error;
}
}
/**
* Deliver raw email data via email service (Zoho or Nodemailer)
*
* This is a low-level function that takes raw email data (to, subject, content, etc.)
* and delivers it through the configured email service. Use sendEmail() for
* sending template-based emails instead.
*
* @param args - Array of email data objects (to, subject, content, from, replyTo)
* @returns Promise resolving to email delivery results
*/
export const deliverEmail = async (args: TDeliverEmail): Promise<EmailResponse[]> => {
const emailItems = ZDeliverEmail.parse(args);
const results = await Promise.all(
emailItems.map(async (emailItem) => {
try {
const emailPayload = {
...emailItem,
from: emailItem.from ?? EMAIL_FROM
};
const res = env.ZOHO_TOKEN ? await sendWithZoho(emailPayload) : await sendWithNodemailer(emailPayload);
console.log('Email status', res);
return res;
} catch (error) {
console.error('Error sending email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
details: error
};
}
})
);
return results;
};