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