Skip to content

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

Package listing

@kody/postmark

src/emails.ts

282 lines · 8.4 KB · TypeScript
import {
	mutationPreview,
	parseAction,
	postmarkRequest,
	type MutationGuardInput,
	type PostmarkAuthOptions,
	type PostmarkDryRun,
} from './core.ts'

export type TrackLinks = 'None' | 'HtmlAndText' | 'HtmlOnly' | 'TextOnly'

export type PostmarkAttachment = {
	name: string
	content: string
	contentType: string
	contentId?: string
}

export type SendEmailFields = {
	from: string
	to: string
	subject?: string
	htmlBody?: string
	textBody?: string
	cc?: string
	bcc?: string
	replyTo?: string
	tag?: string
	messageStream?: string
	trackOpens?: boolean
	trackLinks?: TrackLinks
	metadata?: Record<string, string>
	headers?: Array<{ name: string; value: string }>
	attachments?: PostmarkAttachment[]
}

export type SendEmailInput = MutationGuardInput & SendEmailFields

function firstString(...values: unknown[]): string {
	for (const value of values) {
		if (typeof value === 'string' && value.trim()) return value.trim()
	}
	return ''
}

function pickSendFields(input: Record<string, unknown>): SendEmailFields {
	return {
		from: firstString(input.from, input.From),
		to: firstString(input.to, input.To),
		subject: firstString(input.subject, input.Subject) || undefined,
		htmlBody: firstString(input.htmlBody, input.HtmlBody, input.html) || undefined,
		textBody: firstString(input.textBody, input.TextBody, input.text) || undefined,
		cc: firstString(input.cc, input.Cc) || undefined,
		bcc: firstString(input.bcc, input.Bcc) || undefined,
		replyTo: firstString(input.replyTo, input.ReplyTo) || undefined,
		tag: firstString(input.tag, input.Tag) || undefined,
		messageStream: firstString(input.messageStream, input.MessageStream) || undefined,
		trackOpens:
			typeof input.trackOpens === 'boolean'
				? input.trackOpens
				: typeof input.TrackOpens === 'boolean'
					? input.TrackOpens
					: undefined,
		trackLinks: (input.trackLinks ?? input.TrackLinks) as TrackLinks | undefined,
		metadata: (input.metadata ?? input.Metadata) as Record<string, string> | undefined,
		headers: (input.headers ?? input.Headers) as SendEmailFields['headers'],
		attachments: (input.attachments ?? input.Attachments) as SendEmailFields['attachments'],
	}
}

export function requireSendFields(input: Partial<SendEmailFields>, action: string) {
	if (!input.from) {
		throw new Error(
			action +
				': from is required (a confirmed sender on the caller\'s Postmark server). This package has no baked-in from-address.',
		)
	}
	if (!input.to) throw new Error(action + ': to is required')
	if (!input.htmlBody && !input.textBody) {
		throw new Error(action + ': provide htmlBody or textBody')
	}
}

function emailBody(input: SendEmailFields) {
	return {
		From: input.from,
		To: input.to,
		Subject: input.subject,
		HtmlBody: input.htmlBody,
		TextBody: input.textBody,
		Cc: input.cc,
		Bcc: input.bcc,
		ReplyTo: input.replyTo,
		Tag: input.tag,
		MessageStream: input.messageStream,
		TrackOpens: input.trackOpens,
		TrackLinks: input.trackLinks,
		Metadata: input.metadata,
		Headers: input.headers?.map((header) => ({
			Name: header.name ?? (header as { Name?: string }).Name,
			Value: header.value ?? (header as { Value?: string }).Value,
		})),
		Attachments: input.attachments?.map((attachment) => ({
			Name: attachment.name,
			Content: attachment.content,
			ContentType: attachment.contentType,
			ContentID: attachment.contentId,
		})),
	}
}

/**
 * POST /email — send one transactional email. Sends real mail unless
 * `dryRun: true`. Live sends also require `confirm: true`.
 */
export async function sendEmail(input: SendEmailInput): Promise<PostmarkDryRun | unknown> {
	const fields = pickSendFields(input as Record<string, unknown>)
	requireSendFields(fields, 'sendEmail')
	if (!fields.subject) throw new Error('sendEmail: subject is required')
	const body = emailBody(fields)
	const preview = mutationPreview(input, {
		action: 'send email',
		method: 'POST',
		path: '/email',
		body,
		requireConfirm: true,
		tokenKind: 'server',
	})
	if (preview) return preview
	return await postmarkRequest({
		path: '/email',
		method: 'POST',
		body,
		account: input.account,
		secretName: input.secretName,
		tokenKind: 'server',
	})
}

export type SendBatchInput = MutationGuardInput & {
	emails: Array<SendEmailFields & Record<string, unknown>>
}

/**
 * POST /email/batch — send up to 500 emails. Live sends require `confirm: true`.
 */
export async function sendBatch(input: SendBatchInput): Promise<PostmarkDryRun | unknown> {
	if (!Array.isArray(input.emails) || input.emails.length === 0) {
		throw new Error('sendBatch: pass a non-empty emails array')
	}
	if (input.emails.length > 500) throw new Error('sendBatch: max 500 emails per batch')
	const bodies = input.emails.map((email, index) => {
		const fields = pickSendFields(email as Record<string, unknown>)
		requireSendFields(fields, 'sendBatch emails[' + index + ']')
		if (!fields.subject) throw new Error('sendBatch emails[' + index + ']: subject is required')
		return emailBody(fields)
	})
	const preview = mutationPreview(input, {
		action: 'send batch of ' + bodies.length + ' emails',
		method: 'POST',
		path: '/email/batch',
		body: bodies,
		requireConfirm: true,
		tokenKind: 'server',
	})
	if (preview) return preview
	return await postmarkRequest({
		path: '/email/batch',
		method: 'POST',
		body: bodies,
		account: input.account,
		secretName: input.secretName,
		tokenKind: 'server',
	})
}

export type SendTemplateEmailInput = MutationGuardInput & {
	from: string
	to: string
	templateId?: number
	templateAlias?: string
	templateModel?: Record<string, unknown>
	inlineCss?: boolean
	cc?: string
	bcc?: string
	replyTo?: string
	tag?: string
	messageStream?: string
	trackOpens?: boolean
	trackLinks?: TrackLinks
	metadata?: Record<string, string>
	headers?: Array<{ name: string; value: string }>
	attachments?: PostmarkAttachment[]
}

/**
 * POST /email/withTemplate — send one templated email. Live sends require
 * `confirm: true`. Pass `templateId` or `templateAlias`.
 */
export async function sendTemplateEmail(
	input: SendTemplateEmailInput,
): Promise<PostmarkDryRun | unknown> {
	const from = firstString(input.from, (input as { From?: string }).From)
	const to = firstString(input.to, (input as { To?: string }).To)
	if (!from) {
		throw new Error(
			'sendTemplateEmail: from is required. This package has no baked-in from-address.',
		)
	}
	if (!to) throw new Error('sendTemplateEmail: to is required')
	const templateId = input.templateId ?? (input as { TemplateId?: number }).TemplateId
	const templateAlias =
		input.templateAlias ?? (input as { TemplateAlias?: string }).TemplateAlias
	if (templateId == null && !templateAlias) {
		throw new Error('sendTemplateEmail: provide templateId or templateAlias')
	}
	const body = {
		From: from,
		To: to,
		TemplateId: templateId,
		TemplateAlias: templateAlias,
		TemplateModel: input.templateModel ?? (input as { TemplateModel?: unknown }).TemplateModel ?? {},
		InlineCss: input.inlineCss,
		Cc: input.cc,
		Bcc: input.bcc,
		ReplyTo: input.replyTo,
		Tag: input.tag,
		MessageStream: input.messageStream,
		TrackOpens: input.trackOpens,
		TrackLinks: input.trackLinks,
		Metadata: input.metadata,
		Headers: input.headers?.map((header) => ({ Name: header.name, Value: header.value })),
		Attachments: input.attachments?.map((attachment) => ({
			Name: attachment.name,
			Content: attachment.content,
			ContentType: attachment.contentType,
			ContentID: attachment.contentId,
		})),
	}
	const preview = mutationPreview(input, {
		action: 'send template email',
		method: 'POST',
		path: '/email/withTemplate',
		body,
		requireConfirm: true,
		tokenKind: 'server',
	})
	if (preview) return preview
	return await postmarkRequest({
		path: '/email/withTemplate',
		method: 'POST',
		body,
		account: input.account,
		secretName: input.secretName,
		tokenKind: 'server',
	})
}

export const EMAIL_ACTIONS = ['send-email', 'send-batch', 'send-template'] as const
export type EmailAction = (typeof EMAIL_ACTIONS)[number]

export async function emails(input: PostmarkAuthOptions & Record<string, unknown> = {}) {
	const action = parseAction(input.action, EMAIL_ACTIONS, 'send-email', 'emails')
	switch (action) {
		case 'send-email':
			return await sendEmail(input as SendEmailInput)
		case 'send-batch':
			return await sendBatch(input as SendBatchInput)
		case 'send-template':
			return await sendTemplateEmail(input as SendTemplateEmailInput)
		default: {
			const _exhaustive: never = action
			return assertNeverEmails(_exhaustive)
		}
	}
}

function assertNeverEmails(value: never): never {
	throw new Error('Unhandled emails action: ' + String(value))
}

export default emails