Skip to content

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

Package listing

@kody/postmark

src/core.ts

470 lines · 14.5 KB · TypeScript
/**
 * Shared Postmark transport: server vs account tokens, dry-run previews,
 * setup-aware errors, and token stripping.
 *
 * Default secrets are `postmark` (Server API token) and `postmark-account`
 * (Account API token). Extra accounts use `account: "work"` → `postmark-work`
 * / `postmark-account-work`. There are no hard-coded aliases, tokens, or
 * from-addresses.
 */

export const API_BASE_URL = 'https://api.postmarkapp.com'
export const API_HOST = 'api.postmarkapp.com'
export const DASHBOARD_URL = 'https://account.postmarkapp.com'
export const DASHBOARD_SERVERS_URL = 'https://account.postmarkapp.com/servers'
export const DEFAULT_SERVER_SECRET = 'postmark'
export const DEFAULT_ACCOUNT_SECRET = 'postmark-account'

const SECRET_SUFFIX = '(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?'
const SERVER_SECRET_PATTERN = new RegExp('^postmark' + SECRET_SUFFIX + '$')
const ACCOUNT_SECRET_PATTERN = new RegExp('^postmark-account' + SECRET_SUFFIX + '$')
const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/

export type TokenKind = 'server' | 'account'

export type PostmarkAuthOptions = {
	/**
	 * Extra account label. `work` reads `postmark-work` and
	 * `postmark-account-work`. Omit (or pass `default`) for the defaults.
	 */
	account?: string
	/** Override the Server API token secret. Must be `postmark` or `postmark-<label>`. */
	secretName?: string
	/** Override the Account API token secret. Must be `postmark-account` or `postmark-account-<label>`. */
	accountSecretName?: string
}

export type PostmarkObject = Record<string, any>

export class PostmarkApiError extends Error {
	status: number | null
	errorCode: number | null
	body: unknown
	method: string | null
	path: string | null
	setupUrl: string | null
	tokenKind: TokenKind

	constructor(
		message: string,
		meta: {
			status?: number | null
			errorCode?: number | null
			body?: unknown
			method?: string | null
			path?: string | null
			setupUrl?: string | null
			tokenKind?: TokenKind
		} = {},
	) {
		super(message)
		this.name = 'PostmarkApiError'
		this.status = meta.status ?? null
		this.errorCode = meta.errorCode ?? null
		this.body = meta.body ?? null
		this.method = meta.method ?? null
		this.path = meta.path ?? null
		this.setupUrl = meta.setupUrl ?? null
		this.tokenKind = meta.tokenKind ?? 'server'
	}
}

export function assertNever(value: never, message: string): never {
	throw new Error(message + String(value))
}

export function parseAction<T extends string>(
	value: unknown,
	allowed: readonly T[],
	fallback: T,
	label: string,
): T {
	const action = (value == null || value === '' ? fallback : value) as unknown
	if (typeof action === 'string' && (allowed as readonly string[]).includes(action)) {
		return action as T
	}
	throw new Error(
		'Unknown ' + label + ' action: ' + String(action) + '. Valid actions: ' + allowed.join(', '),
	)
}

function accountLabel(value: string | undefined): string | null {
	const trimmed = (value ?? '').trim()
	if (!trimmed || trimmed === 'default') return null
	if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
		throw new Error(
			'account must be a short label such as "work" or "live" (letters, numbers, _ or -).',
		)
	}
	return trimmed
}

function assertSecretName(name: string, kind: TokenKind): string {
	const pattern = kind === 'server' ? SERVER_SECRET_PATTERN : ACCOUNT_SECRET_PATTERN
	const expected =
		kind === 'server' ? 'postmark or postmark-<account>' : 'postmark-account or postmark-account-<account>'
	if (!pattern.test(name)) {
		throw new Error(
			(kind === 'server' ? 'secretName' : 'accountSecretName') +
				' must be ' +
				expected +
				'. Got ' +
				name +
				'. Save it at ' +
				secretSetupUrl(kind === 'server' ? DEFAULT_SERVER_SECRET : DEFAULT_ACCOUNT_SECRET, kind) +
				'.',
		)
	}
	return name
}

function resolveBaseName(input: PostmarkAuthOptions, kind: TokenKind): string {
	const account = accountLabel(input.account)
	if (kind === 'account') {
		return account ? DEFAULT_ACCOUNT_SECRET + '-' + account : DEFAULT_ACCOUNT_SECRET
	}
	return account ? DEFAULT_SERVER_SECRET + '-' + account : DEFAULT_SERVER_SECRET
}

/** Resolve the user-scoped Postmark Server API token secret name. */
export function resolveServerSecretName(input: PostmarkAuthOptions = {}): string {
	if (input.secretName != null && String(input.secretName).trim() !== '') {
		return assertSecretName(String(input.secretName).trim(), 'server')
	}
	return resolveBaseName(input, 'server')
}

/** Resolve the user-scoped Postmark Account API token secret name. */
export function resolveAccountSecretName(input: PostmarkAuthOptions = {}): string {
	if (input.accountSecretName != null && String(input.accountSecretName).trim() !== '') {
		return assertSecretName(String(input.accountSecretName).trim(), 'account')
	}
	return resolveBaseName(input, 'account')
}

export function secretSetupUrl(
	secretName: string = DEFAULT_SERVER_SECRET,
	kind: TokenKind = secretName.startsWith(DEFAULT_ACCOUNT_SECRET) ? 'account' : 'server',
): string {
	const description =
		kind === 'account'
			? 'Postmark Account API token for listing servers and managing domains'
			: 'Postmark Server API token for sending, the current server, and message search'
	const params = new URLSearchParams({
		name: secretName,
		description,
		allowedHosts: API_HOST,
		scope: 'user',
	})
	return 'https://kody.codes/account/secrets/new?' + params.toString()
}

export function nextSetupStep(auth: PostmarkAuthOptions, kind: TokenKind): string {
	const secretName = kind === 'account' ? resolveAccountSecretName(auth) : resolveServerSecretName(auth)
	const label = kind === 'account' ? 'Account API token' : 'Server API token'
	const dashboard =
		kind === 'account'
			? DASHBOARD_URL + ' (Account → API Tokens)'
			: DASHBOARD_SERVERS_URL + ' (open a server → API Tokens)'
	return (
		'Save a Postmark ' +
		label +
		' as secret ' +
		secretName +
		' at ' +
		secretSetupUrl(secretName, kind) +
		'. Copy it from ' +
		dashboard +
		'. Approve host ' +
		API_HOST +
		'.'
	)
}

export function requireConfirm(input: { confirm?: boolean }, action: string) {
	if (input.confirm !== true) {
		throw new Error(
			'Refusing to ' +
				action +
				' without confirm: true. This action changes live Postmark state; pass dryRun: true to preview, or confirm: true to proceed.',
		)
	}
}

export type PostmarkDryRun = {
	dryRun: true
	action: string
	method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'
	path: string
	body?: unknown
	tokenKind: TokenKind
}

export type MutationGuardInput = PostmarkAuthOptions & {
	dryRun?: boolean
	confirm?: boolean
}

/**
 * Preview a mutation when `dryRun: true`. Send and delete helpers also
 * require `confirm: true` before they contact Postmark.
 */
export function mutationPreview(
	input: MutationGuardInput,
	options: {
		action: string
		method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'
		path: string
		body?: unknown
		requireConfirm?: boolean
		tokenKind?: TokenKind
	},
): PostmarkDryRun | null {
	if (input.dryRun === true) {
		return {
			dryRun: true,
			action: options.action,
			method: options.method,
			path: options.path,
			body: options.body,
			tokenKind: options.tokenKind ?? 'server',
		}
	}
	if (options.requireConfirm) requireConfirm(input, options.action)
	return null
}

/** Strip server API tokens from Postmark payloads so they never leave the helper. */
export function sanitizePostmarkPayload(value: unknown): unknown {
	if (Array.isArray(value)) return value.map((item) => sanitizePostmarkPayload(item))
	if (!value || typeof value !== 'object') return value
	const record = value as Record<string, unknown>
	const out: Record<string, unknown> = {}
	for (const [key, nested] of Object.entries(record)) {
		if (key === 'ApiTokens' || key === 'apiTokens') continue
		out[key] = sanitizePostmarkPayload(nested)
	}
	return out
}

export function serverSummary(server: PostmarkObject | null | undefined) {
	if (!server || server.ID == null) return null
	return {
		id: server.ID ?? null,
		name: server.Name ?? null,
		color: server.Color ?? null,
		deliveryType: server.DeliveryType ?? null,
		serverLink: server.ServerLink ?? null,
		inboundAddress: server.InboundAddress ?? null,
		trackOpens: server.TrackOpens ?? null,
		trackLinks: server.TrackLinks ?? null,
		smtpApiActivated: server.SmtpApiActivated ?? null,
	}
}

export function domainSummary(domain: PostmarkObject | null | undefined) {
	if (!domain || domain.ID == null) return null
	return {
		id: domain.ID ?? null,
		name: domain.Name ?? null,
		dkimVerified: domain.DKIMVerified ?? null,
		returnPathDomainVerified: domain.ReturnPathDomainVerified ?? null,
		weakDkim: domain.WeakDKIM ?? null,
	}
}

export function messageSummary(message: PostmarkObject | null | undefined) {
	if (!message || !message.MessageID) return null
	return {
		messageId: message.MessageID ?? null,
		messageStream: message.MessageStream ?? null,
		from: message.From ?? null,
		subject: message.Subject ?? null,
		status: message.Status ?? null,
		receivedAt: message.ReceivedAt ?? message.Date ?? null,
		recipients: message.Recipients ?? null,
		tag: message.Tag ?? null,
	}
}

function hasHeader(headers: Record<string, string>, name: string) {
	const lower = name.toLowerCase()
	return Object.keys(headers).some((key) => key.toLowerCase() === lower)
}

function detailMessage(body: unknown): string | null {
	if (!body || typeof body !== 'object') {
		return typeof body === 'string' && body.length > 0 ? body : null
	}
	const record = body as { Message?: unknown; message?: unknown }
	if (typeof record.Message === 'string' && record.Message) return record.Message
	if (typeof record.message === 'string' && record.message) return record.message
	return null
}

function errorCodeOf(body: unknown): number | null {
	if (!body || typeof body !== 'object') return null
	const record = body as { ErrorCode?: unknown }
	return typeof record.ErrorCode === 'number' ? record.ErrorCode : null
}

function toPostmarkApiError(
	response: Response,
	parsed: unknown,
	auth: PostmarkAuthOptions,
	meta: { method: string; path: string; tokenKind: TokenKind },
): PostmarkApiError {
	const detail = detailMessage(parsed)
	const setupUrl = secretSetupUrl(
		meta.tokenKind === 'account' ? resolveAccountSecretName(auth) : resolveServerSecretName(auth),
		meta.tokenKind,
	)
	if (response.status === 401) {
		return new PostmarkApiError(
			'Postmark authentication failed (HTTP 401). The ' +
				(meta.tokenKind === 'account' ? 'Account' : 'Server') +
				' API token is missing, invalid, or the wrong token type for this endpoint. ' +
				nextSetupStep(auth, meta.tokenKind),
			{
				status: 401,
				errorCode: errorCodeOf(parsed),
				body: sanitizePostmarkPayload(parsed),
				method: meta.method,
				path: meta.path,
				setupUrl,
				tokenKind: meta.tokenKind,
			},
		)
	}
	return new PostmarkApiError(
		detail ??
			'Postmark API ' +
				response.status +
				(meta.method ? ' ' + meta.method : '') +
				(meta.path ? ' ' + meta.path : ''),
		{
			status: response.status,
			errorCode: errorCodeOf(parsed),
			body: sanitizePostmarkPayload(parsed),
			method: meta.method,
			path: meta.path,
			setupUrl,
			tokenKind: meta.tokenKind,
		},
	)
}

export type PostmarkRequestInput = PostmarkAuthOptions & {
	path: string
	method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
	query?: Record<string, string | number | boolean | null | undefined>
	body?: unknown
	headers?: Record<string, string>
	tokenKind?: TokenKind
	fetchImpl?: typeof fetch
}

function buildUrl(path: string, query: PostmarkRequestInput['query'] = {}) {
	const normalized = path.startsWith('/') ? path : '/' + path
	const url = new URL(API_BASE_URL + normalized)
	if (url.origin !== API_BASE_URL) {
		throw new Error('Postmark requests must stay on https://api.postmarkapp.com.')
	}
	for (const [key, value] of Object.entries(query ?? {})) {
		if (value === undefined || value === null || value === '') continue
		url.searchParams.append(key, String(value))
	}
	return url
}

function authHeaderName(kind: TokenKind): string {
	switch (kind) {
		case 'server':
			return 'X-Postmark-Server-Token'
		case 'account':
			return 'X-Postmark-Account-Token'
		default: {
			const _exhaustive: never = kind
			return assertNever(_exhaustive, 'Unknown Postmark token kind: ')
		}
	}
}

/** Authenticated Postmark request. Reads always execute; helpers gate writes. */
export async function postmarkRequest(input: PostmarkRequestInput): Promise<any> {
	const method = input.method ?? 'GET'
	const tokenKind = input.tokenKind ?? 'server'
	const url = buildUrl(input.path, input.query)
	const secretName =
		tokenKind === 'account' ? resolveAccountSecretName(input) : resolveServerSecretName(input)
	const headers: Record<string, string> = {
		Accept: 'application/json',
		...(input.headers ?? {}),
	}
	if (input.body !== undefined && !hasHeader(headers, 'content-type')) {
		headers['content-type'] = 'application/json'
	}
	if (!hasHeader(headers, authHeaderName(tokenKind))) {
		headers[authHeaderName(tokenKind)] = '{{secret:' + secretName + '|scope=user}}'
	}

	const fetchImpl = input.fetchImpl ?? fetch
	const response = await fetchImpl(url, {
		method,
		headers,
		body: input.body === undefined ? undefined : JSON.stringify(input.body),
	})
	const text = await response.text()
	let parsed: unknown = null
	try {
		parsed = text ? JSON.parse(text) : null
	} catch {
		parsed = text
	}
	if (!response.ok) {
		throw toPostmarkApiError(response, parsed, input, { method, path: input.path, tokenKind })
	}
	return sanitizePostmarkPayload(parsed)
}

export type GenericRequestInput = PostmarkRequestInput & {
	/** Writes default to dry-run. Pass `dryRun: false` to execute. */
	dryRun?: boolean
}

/**
 * Escape-hatch REST call. GET/HEAD/OPTIONS always execute. Other methods
 * default to `{ dryRun: true, wouldCall }` until `dryRun: false`.
 */
export async function postmarkCall(input: GenericRequestInput) {
	const method = (input.method ?? 'GET').toUpperCase()
	const isRead = method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
	if (!isRead && input.dryRun !== false) {
		return {
			dryRun: true,
			wouldCall: {
				method,
				path: input.path,
				query: input.query,
				body: input.body,
				tokenKind: input.tokenKind ?? 'server',
			},
		}
	}
	return postmarkRequest(input)
}

export function pagingQuery(input: { count?: number; offset?: number }) {
	const count = input.count == null ? 50 : Number(input.count)
	const offset = input.offset == null ? 0 : Number(input.offset)
	if (!Number.isFinite(count) || count < 1 || count > 500) {
		throw new Error('count must be an integer from 1 to 500.')
	}
	if (!Number.isFinite(offset) || offset < 0) {
		throw new Error('offset must be an integer >= 0.')
	}
	if (count + offset > 10_000) {
		throw new Error('count + offset cannot exceed 10,000.')
	}
	return { count, offset }
}