Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/resend

src/emails.ts

281 lines · 8.4 KB · TypeScript
import {
	emailSummary,
	mutationPreview,
	parseAction,
	resendRequest,
	unwrapList,
	type MutationGuardInput,
	type ResendAuthOptions,
	type ResendDryRun,
} from './resend-core.ts'

export type SendEmailFields = {
	from: string
	to: string | string[]
	subject: string
	html?: string
	text?: string
	cc?: string | string[]
	bcc?: string | string[]
	reply_to?: string | string[]
	headers?: Record<string, string>
	attachments?: Array<{
		filename: string
		content?: string
		path?: string
		content_type?: string
	}>
	tags?: Array<{ name: string; value: string }>
	scheduled_at?: string
	template?: { id: string; variables?: Record<string, unknown> }
}

export type SendEmailInput = MutationGuardInput &
	SendEmailFields & {
		idempotencyKey?: string
	}

function requireSendFields(input: Partial<SendEmailFields>, action: string) {
	if (!input.from) throw new Error(action + ': from is required (e.g. "Name <hello@yourdomain.com>")')
	if (!input.to) throw new Error(action + ': to is required')
	if (!input.subject) throw new Error(action + ': subject is required')
	if (!input.html && !input.text && !input.template) {
		throw new Error(action + ': provide html, text, or template')
	}
}

function emailBody(input: SendEmailFields) {
	return {
		from: input.from,
		to: input.to,
		subject: input.subject,
		html: input.html,
		text: input.text,
		cc: input.cc,
		bcc: input.bcc,
		reply_to: input.reply_to,
		headers: input.headers,
		attachments: input.attachments,
		tags: input.tags,
		scheduled_at: input.scheduled_at,
		template: input.template,
	}
}

/**
 * POST /emails — send or schedule one email. Sends real mail unless
 * `dryRun: true`. Live sends also require `confirm: true`.
 */
export async function sendEmail(input: SendEmailInput): Promise<ResendDryRun | { id: string }> {
	requireSendFields(input, 'sendEmail')
	const body = emailBody(input)
	const preview = mutationPreview(input, {
		action: 'send email',
		method: 'POST',
		path: '/emails',
		body,
		requireConfirm: true,
	})
	if (preview) return preview
	return await resendRequest({
		path: '/emails',
		method: 'POST',
		body,
		idempotencyKey: input.idempotencyKey,
		account: input.account,
		secretName: input.secretName,
		integration: input.integration,
	})
}

export type SendBatchInput = MutationGuardInput & {
	emails: SendEmailFields[]
	idempotencyKey?: string
}

/**
 * POST /emails/batch — send up to 100 emails. Batch does not support
 * `scheduled_at` or `attachments`. Live sends require `confirm: true`.
 */
export async function sendBatch(input: SendBatchInput): Promise<ResendDryRun | unknown> {
	if (!Array.isArray(input.emails) || input.emails.length === 0) {
		throw new Error('sendBatch: pass a non-empty emails array')
	}
	if (input.emails.length > 100) throw new Error('sendBatch: max 100 emails per batch')
	for (const [index, email] of input.emails.entries()) {
		requireSendFields(email, 'sendBatch emails[' + index + ']')
	}
	const body = input.emails.map((email) => emailBody(email))
	const preview = mutationPreview(input, {
		action: 'send batch of ' + body.length + ' emails',
		method: 'POST',
		path: '/emails/batch',
		body,
		requireConfirm: true,
	})
	if (preview) return preview
	return await resendRequest({
		path: '/emails/batch',
		method: 'POST',
		body,
		idempotencyKey: input.idempotencyKey,
		account: input.account,
		secretName: input.secretName,
		integration: input.integration,
	})
}

/** GET /emails/{email_id} — full detail including delivery `last_event`. */
export async function getEmail(input: ResendAuthOptions & { emailId: string }) {
	if (!input.emailId) throw new Error('getEmail: emailId is required')
	return await resendRequest({
		path: '/emails/' + encodeURIComponent(input.emailId),
		account: input.account,
		secretName: input.secretName,
		integration: input.integration,
	})
}

export type ListEmailsInput = ResendAuthOptions & {
	limit?: number
	after?: string
	before?: string
}

/** GET /emails — one page of recent sent emails as compact summaries. */
export async function listEmails(input: ListEmailsInput = {}) {
	const body = await resendRequest({
		path: '/emails',
		query: { limit: input.limit, after: input.after, before: input.before },
		account: input.account,
		secretName: input.secretName,
		integration: input.integration,
	})
	return unwrapList(body).map((email) => emailSummary(email)).filter(Boolean)
}

export type RescheduleEmailInput = MutationGuardInput & {
	emailId: string
	scheduledAt: string
}

/** PATCH /emails/{email_id} — reschedule a not-yet-sent scheduled email. */
export async function rescheduleEmail(input: RescheduleEmailInput) {
	if (!input.emailId) throw new Error('rescheduleEmail: emailId is required')
	if (!input.scheduledAt) throw new Error('rescheduleEmail: scheduledAt is required')
	const body = { scheduled_at: input.scheduledAt }
	const preview = mutationPreview(input, {
		action: 'reschedule email ' + input.emailId,
		method: 'PATCH',
		path: '/emails/' + input.emailId,
		body,
	})
	if (preview) return preview
	return await resendRequest({
		path: '/emails/' + encodeURIComponent(input.emailId),
		method: 'PATCH',
		body,
		account: input.account,
		secretName: input.secretName,
		integration: input.integration,
	})
}

/** POST /emails/{email_id}/cancel — cancel a scheduled email before it sends. */
export async function cancelScheduledEmail(
	input: MutationGuardInput & { emailId: string },
) {
	if (!input.emailId) throw new Error('cancelScheduledEmail: emailId is required')
	const preview = mutationPreview(input, {
		action: 'cancel scheduled email ' + input.emailId,
		method: 'POST',
		path: '/emails/' + input.emailId + '/cancel',
		requireConfirm: true,
	})
	if (preview) return preview
	return await resendRequest({
		path: '/emails/' + encodeURIComponent(input.emailId) + '/cancel',
		method: 'POST',
		account: input.account,
		secretName: input.secretName,
		integration: input.integration,
	})
}

/** GET /emails/receiving — inbound email (requires inbound on a domain). */
export async function listReceivedEmails(input: ListEmailsInput = {}) {
	const body = await resendRequest({
		path: '/emails/receiving',
		query: { limit: input.limit, after: input.after, before: input.before },
		account: input.account,
		secretName: input.secretName,
		integration: input.integration,
	})
	return unwrapList(body).map((email) => emailSummary(email)).filter(Boolean)
}

/** GET /emails/receiving/{email_id} */
export async function getReceivedEmail(input: ResendAuthOptions & { emailId: string }) {
	if (!input.emailId) throw new Error('getReceivedEmail: emailId is required')
	return await resendRequest({
		path: '/emails/receiving/' + encodeURIComponent(input.emailId),
		account: input.account,
		secretName: input.secretName,
		integration: input.integration,
	})
}

/** GET /emails/receiving/{email_id}/attachments */
export async function listReceivedEmailAttachments(
	input: ResendAuthOptions & { emailId: string },
) {
	if (!input.emailId) throw new Error('listReceivedEmailAttachments: emailId is required')
	const body = await resendRequest({
		path: '/emails/receiving/' + encodeURIComponent(input.emailId) + '/attachments',
		account: input.account,
		secretName: input.secretName,
		integration: input.integration,
	})
	return unwrapList(body)
}

const emailActions = [
	'list-emails',
	'get-email',
	'send-email',
	'send-batch',
	'reschedule-email',
	'cancel-scheduled-email',
	'list-received-emails',
	'get-received-email',
	'list-received-email-attachments',
] as const

/** Emails dispatcher. Defaults to list-emails. */
export default async function emails(input: Record<string, unknown> = {}) {
	const action = parseAction(input.action, emailActions, 'list-emails', 'emails')
	switch (action) {
		case 'list-emails':
			return await listEmails(input as ListEmailsInput)
		case 'get-email':
			return await getEmail(input as never)
		case 'send-email':
			return await sendEmail(input as SendEmailInput)
		case 'send-batch':
			return await sendBatch(input as SendBatchInput)
		case 'reschedule-email':
			return await rescheduleEmail(input as RescheduleEmailInput)
		case 'cancel-scheduled-email':
			return await cancelScheduledEmail(input as never)
		case 'list-received-emails':
			return await listReceivedEmails(input as ListEmailsInput)
		case 'get-received-email':
			return await getReceivedEmail(input as never)
		case 'list-received-email-attachments':
			return await listReceivedEmailAttachments(input as never)
		default: {
			const exhaustive: never = action
			throw new Error('Unhandled emails action: ' + String(exhaustive))
		}
	}
}