Skip to content

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

Package listing

@kody/resend

src/resend-core.ts

476 lines · 14.1 KB · TypeScript
/**
 * Shared Resend transport: secret-backed API keys or a saved OAuth
 * integration, list unwrapping, dry-run previews, and setup-aware errors.
 *
 * Default names are `resend` (secret or integration). Extra accounts use
 * `resend-<label>` such as `resend-work`. There are no hard-coded aliases.
 */

export const API_BASE_URL = 'https://api.resend.com'
export const API_HOST = 'api.resend.com'
export const DASHBOARD_URL = 'https://resend.com'
export const DASHBOARD_API_KEYS_URL = 'https://resend.com/api-keys'
export const OAUTH_AUTHORIZE_URL = 'https://api.resend.com/oauth/authorize'
export const OAUTH_TOKEN_URL = 'https://api.resend.com/oauth/token'
export const DEFAULT_AUTH_NAME = 'resend'

export const OAUTH_SCOPES = {
	sendOnly: 'emails:send',
	fullAccess: 'full_access',
} as const

const AUTH_NAME_PATTERN = /^resend(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/

export type ResendAuthOptions = {
	/**
	 * Extra account label. `work` reads secret/integration `resend-work`.
	 * Omit (or pass `default`) for `resend`.
	 */
	account?: string
	/** Override the API key secret name. Must be `resend` or `resend-<label>`. */
	secretName?: string
	/**
	 * Saved OAuth integration name (`resend` or `resend-<label>`). When set,
	 * calls use `createAuthenticatedFetch` instead of the API key secret.
	 */
	integration?: string
}

export type ResendObject = Record<string, any>

export class ResendApiError extends Error {
	status: number | null
	body: unknown
	method: string | null
	path: string | null
	setupUrl: string | null

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

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 assertAuthName(name: string, kind: 'secretName' | 'integration'): string {
	if (!AUTH_NAME_PATTERN.test(name)) {
		throw new Error(
			kind +
				' must be resend or resend-<account>. Got ' +
				name +
				'. Save it at ' +
				(kind === 'integration' ? oauthConnectUrl(name) : secretSetupUrl(name)) +
				'.',
		)
	}
	return name
}

function resolveBaseName(input: ResendAuthOptions = {}): string {
	const account = accountLabel(input.account)
	return account ? DEFAULT_AUTH_NAME + '-' + account : DEFAULT_AUTH_NAME
}

/** Resolve the user-scoped Resend API key secret name for this call. */
export function resolveSecretName(input: ResendAuthOptions = {}): string {
	if (input.secretName != null && String(input.secretName).trim() !== '') {
		return assertAuthName(String(input.secretName).trim(), 'secretName')
	}
	return resolveBaseName(input)
}

/** Resolve a saved OAuth integration name, or undefined for the API-key lane. */
export function resolveIntegrationName(input: ResendAuthOptions = {}): string | undefined {
	if (input.integration == null || String(input.integration).trim() === '') return undefined
	return assertAuthName(String(input.integration).trim().toLowerCase(), 'integration')
}

export function secretSetupUrl(secretName: string = DEFAULT_AUTH_NAME): string {
	const params = new URLSearchParams({
		name: secretName,
		description:
			'Resend API key (re_) for transactional email, domains, audiences, and templates',
		allowedHosts: API_HOST,
		scope: 'user',
	})
	return 'https://kody.codes/account/secrets/new?' + params.toString()
}

export function oauthConnectUrl(
	provider: string = DEFAULT_AUTH_NAME,
	scope: string = OAUTH_SCOPES.fullAccess,
): string {
	const params = new URLSearchParams({
		provider,
		authorizeUrl: OAUTH_AUTHORIZE_URL,
		tokenUrl: OAUTH_TOKEN_URL,
		scopes: scope,
		allowedHosts: API_HOST,
		apiBaseUrl: API_BASE_URL,
		dashboardUrl: DASHBOARD_URL,
	})
	return 'https://kody.codes/connect/oauth?' + params.toString()
}

export function nextSetupStep(
	auth: ResendAuthOptions,
	kind: 'missing-token' | 'missing-scope' | 'unverified-domain',
	scope?: string,
): string {
	const integration = resolveIntegrationName(auth)
	if (integration) {
		if (kind === 'missing-scope' && scope) {
			return (
				'Reconnect the ' +
				integration +
				' OAuth integration with the ' +
				scope +
				' scope: ' +
				oauthConnectUrl(integration, scope)
			)
		}
		if (kind === 'unverified-domain') {
			return (
				'The from address must use a verified sending domain. Call listDomains(), add the DNS records, then verifyDomain. Dashboard: ' +
				DASHBOARD_URL +
				'/domains'
			)
		}
		return 'Reconnect the ' + integration + ' OAuth integration: ' + oauthConnectUrl(integration)
	}
	const secretName = resolveSecretName(auth)
	if (kind === 'unverified-domain') {
		return (
			'The from address must use a verified sending domain. Call listDomains(), add the DNS records, then verifyDomain. Dashboard: ' +
			DASHBOARD_URL +
			'/domains'
		)
	}
	if (kind === 'missing-scope' && scope) {
		return (
			'Create a Resend API key with access that covers ' +
			scope +
			', then update secret ' +
			secretName +
			' at ' +
			secretSetupUrl(secretName)
		)
	}
	return (
		'Save a Resend API key as secret ' +
		secretName +
		' at ' +
		secretSetupUrl(secretName) +
		' (create one at ' +
		DASHBOARD_API_KEYS_URL +
		').'
	)
}

export function unwrapList(body: unknown): ResendObject[] {
	if (Array.isArray(body)) return body
	const record = (body ?? {}) as { data?: unknown }
	return Array.isArray(record.data) ? record.data : []
}

export function emailSummary(email: ResendObject | null | undefined) {
	if (!email?.id) return null
	return {
		id: email.id,
		from: email.from ?? null,
		to: email.to ?? null,
		subject: email.subject ?? null,
		last_event: email.last_event ?? null,
		created_at: email.created_at ?? null,
		scheduled_at: email.scheduled_at ?? null,
	}
}

export function domainSummary(domain: ResendObject | null | undefined) {
	if (!domain?.id) return null
	return {
		id: domain.id,
		name: domain.name ?? null,
		status: domain.status ?? null,
		region: domain.region ?? null,
		created_at: domain.created_at ?? null,
	}
}

export function contactSummary(contact: ResendObject | null | undefined) {
	if (!contact?.id) return null
	return {
		id: contact.id,
		email: contact.email ?? null,
		first_name: contact.first_name ?? null,
		last_name: contact.last_name ?? null,
		unsubscribed: contact.unsubscribed ?? null,
		created_at: contact.created_at ?? null,
	}
}

export function broadcastSummary(broadcast: ResendObject | null | undefined) {
	if (!broadcast?.id) return null
	return {
		id: broadcast.id,
		name: broadcast.name ?? null,
		subject: broadcast.subject ?? null,
		status: broadcast.status ?? null,
		created_at: broadcast.created_at ?? null,
		scheduled_at: broadcast.scheduled_at ?? null,
		editUrl: DASHBOARD_URL + '/broadcasts/' + broadcast.id,
	}
}

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 Resend state; pass dryRun: true to preview, or confirm: true to proceed.',
		)
	}
}

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

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

/**
 * Preview a mutation when `dryRun: true`. Send/delete helpers also require
 * `confirm: true` before they contact Resend.
 */
export function mutationPreview(
	input: MutationGuardInput,
	options: {
		action: string
		method: 'POST' | 'PATCH' | 'DELETE'
		path: string
		body?: unknown
		requireConfirm?: boolean
	},
): ResendDryRun | null {
	if (input.dryRun === true) {
		return {
			dryRun: true,
			action: options.action,
			method: options.method,
			path: options.path,
			body: options.body,
		}
	}
	if (options.requireConfirm) requireConfirm(input, options.action)
	return 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; name?: unknown }
	if (typeof record.message === 'string' && record.message) {
		return (typeof record.name === 'string' && record.name ? record.name + ': ' : '') + record.message
	}
	return null
}

function toResendApiError(
	response: Response,
	parsed: unknown,
	auth: ResendAuthOptions,
	meta: { method: string; path: string },
): ResendApiError {
	const detail = detailMessage(parsed)
	const setupUrl = resolveIntegrationName(auth)
		? oauthConnectUrl(resolveIntegrationName(auth))
		: secretSetupUrl(resolveSecretName(auth))
	const lower = (detail ?? '').toLowerCase()

	if (response.status === 401) {
		return new ResendApiError(
			'Resend authentication failed (HTTP 401). The API key or OAuth token is missing, invalid, or expired. ' +
				nextSetupStep(auth, 'missing-token'),
			{ status: 401, body: parsed, method: meta.method, path: meta.path, setupUrl },
		)
	}

	if (response.status === 403) {
		const domainIssue =
			lower.includes('domain') || lower.includes('from') || lower.includes('not verified')
		const scopeIssue = lower.includes('scope') || lower.includes('permission') || lower.includes('unauthorized')
		const kind = domainIssue ? 'unverified-domain' : scopeIssue ? 'missing-scope' : 'missing-token'
		const scope = lower.includes('full_access')
			? OAUTH_SCOPES.fullAccess
			: lower.includes('emails:send')
				? OAUTH_SCOPES.sendOnly
				: OAUTH_SCOPES.fullAccess
		return new ResendApiError(
			(detail ? detail + ' ' : 'Resend request was forbidden (HTTP 403). ') +
				nextSetupStep(auth, kind, scope),
			{ status: 403, body: parsed, method: meta.method, path: meta.path, setupUrl },
		)
	}

	return new ResendApiError(
		detail ??
			'Resend API ' +
				response.status +
				(meta.method ? ' ' + meta.method : '') +
				(meta.path ? ' ' + meta.path : ''),
		{ status: response.status, body: parsed, method: meta.method, path: meta.path, setupUrl },
	)
}

const integrationFetchCache = new Map<string, typeof fetch>()

async function getIntegrationFetch(name: string): Promise<typeof fetch> {
	const cached = integrationFetchCache.get(name)
	if (cached) return cached
	const { createAuthenticatedFetch } = await import('kody:runtime')
	const authed = await createAuthenticatedFetch(name)
	integrationFetchCache.set(name, authed)
	return authed
}

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

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

/** Authenticated Resend request. Reads always execute; helpers gate writes. */
export async function resendRequest(input: ResendRequestInput): Promise<any> {
	const method = input.method ?? 'GET'
	const url = buildUrl(input.path, input.query)
	const integration = resolveIntegrationName(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 (input.idempotencyKey) headers['Idempotency-Key'] = input.idempotencyKey

	let fetchImpl = input.fetchImpl ?? fetch
	if (!input.fetchImpl && integration) {
		fetchImpl = await getIntegrationFetch(integration)
	} else if (!integration) {
		headers.Authorization = 'Bearer {{secret:' + resolveSecretName(input) + '|scope=user}}'
	}

	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 toResendApiError(response, parsed, input, { method, path: input.path })
	}
	return parsed
}

export type GenericRequestInput = ResendRequestInput & {
	/** 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 resendCall(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,
			},
		}
	}
	return resendRequest(input)
}