/**
* 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 }
}