@shbernal/buzon 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ import { stringFlag, UsageError } from '../core/args.ts'
2
+ import { ApiError } from '../core/api.ts'
3
+ import { addressJwt } from '../core/credentials.ts'
4
+ import { bestLink, localCode, serverCode } from '../core/extract.ts'
5
+ import { deleteMail, getMail, getRawMail, listMails, type Mail } from '../core/mail.ts'
6
+ import { parseDuration } from '../core/time.ts'
7
+ import { type Context, type Input, resolveAddress } from './context.ts'
8
+
9
+ export type MailView = Omit<Mail, 'metadata'>
10
+
11
+ function view(mail: Mail): MailView {
12
+ const { metadata: _, ...rest } = mail
13
+ return rest
14
+ }
15
+
16
+ function mailId(raw: string | undefined, usage: string): number {
17
+ const id = Number(raw)
18
+ if (!raw || !Number.isSafeInteger(id) || id <= 0) throw new UsageError(usage)
19
+ return id
20
+ }
21
+
22
+ export function mailText(mail: MailView): string {
23
+ const lines = [
24
+ `id ${mail.id}`,
25
+ `from ${mail.from}`,
26
+ `subject ${mail.subject}`,
27
+ `date ${mail.date}`,
28
+ ]
29
+ if (mail.attachments.length > 0) {
30
+ lines.push(`files ${mail.attachments.map((file) => file.filename).join(', ')}`)
31
+ }
32
+ return `${lines.join('\n')}\n\n${mail.text}`
33
+ }
34
+
35
+ const READ_USAGE = 'usage: buzon read <address> [--latest | --id <n> | --all] [--raw]'
36
+
37
+ export type ReadResult = { mails: MailView[] } | { raw: string }
38
+
39
+ export async function read(
40
+ { positionals, flags }: Input,
41
+ { config, api }: Context,
42
+ ): Promise<ReadResult> {
43
+ const address = resolveAddress(positionals[0], config, READ_USAGE)
44
+ const idFlag = stringFlag(flags, 'id')
45
+ const all = flags.all === true
46
+ if ([idFlag !== undefined, all, flags.latest === true].filter(Boolean).length > 1) {
47
+ throw new UsageError(READ_USAGE)
48
+ }
49
+ if (all && flags.raw === true) throw new UsageError('--raw reads one mail, not --all')
50
+ const jwt = addressJwt(address)
51
+
52
+ let mails: Mail[]
53
+ if (idFlag !== undefined) {
54
+ const mail = await getMail(api, jwt, mailId(idFlag, READ_USAGE))
55
+ if (!mail) throw new Error(`no mail ${idFlag} for ${address}`)
56
+ mails = [mail]
57
+ } else {
58
+ mails = await listMails(api, jwt, all ? Number.POSITIVE_INFINITY : 1)
59
+ if (!all && mails.length === 0) throw new Error(`no mail for ${address}`)
60
+ }
61
+
62
+ if (flags.raw === true) {
63
+ const raw = await getRawMail(api, jwt, mails[0]!.id)
64
+ if (raw === undefined) throw new Error(`no raw message for mail ${mails[0]!.id}`)
65
+ return { raw }
66
+ }
67
+ return { mails: mails.map(view) }
68
+ }
69
+
70
+ export function readText(result: ReadResult): string {
71
+ if ('raw' in result) return result.raw
72
+ return result.mails.map(mailText).join('\n\n----\n\n') || 'no mail'
73
+ }
74
+
75
+ export type Clock = { now(): number; sleep(ms: number): Promise<void> }
76
+
77
+ const realClock: Clock = { now: () => Date.now(), sleep: (ms) => Bun.sleep(ms) }
78
+
79
+ /** Upstream's polling etiquette: start at 3 s, back off to 10 s. */
80
+ const FIRST_POLL = 3000
81
+ const LAST_POLL = 10_000
82
+ const BACKOFF = 1.5
83
+ /** Mails looked at per poll, newest first. */
84
+ const WINDOW = 20
85
+
86
+ const WAIT_USAGE =
87
+ 'usage: buzon wait <address> [--code | --link] [--from <regex>] [--subject <regex>] [--timeout 300] [--since now|<duration>|<date>]'
88
+
89
+ export class WaitTimeout extends Error {}
90
+
91
+ export type WaitResult = { id: number; code: string } | { id: number; link: string } | MailView
92
+
93
+ function regexFlag(flags: Input['flags'], name: string): RegExp | undefined {
94
+ const raw = stringFlag(flags, name)
95
+ if (raw === undefined) return undefined
96
+ try {
97
+ return new RegExp(raw, 'i')
98
+ } catch (error) {
99
+ throw new UsageError(`--${name} is not a valid regular expression: ${(error as Error).message}`)
100
+ }
101
+ }
102
+
103
+ /** `now` waits for mail after the first poll, by id, so a skewed local clock cannot matter. */
104
+ function sinceFlag(raw: string | undefined, now: number): number | 'now' {
105
+ if (raw === undefined || raw === 'now') return 'now'
106
+ if (/^\d+(\.\d+)?[smhd]?$/.test(raw)) return now - parseDuration(raw, 'since')
107
+ const date = Date.parse(raw)
108
+ if (Number.isNaN(date))
109
+ throw new UsageError('--since takes now, a duration such as 10m, or a date')
110
+ return date
111
+ }
112
+
113
+ /**
114
+ * Polls until a new mail passes the filters and yields what was asked for. A mail that
115
+ * passes the filters but has no code yet is looked at again on the next poll, since the
116
+ * instance saves its own code extraction just after the mail lands.
117
+ */
118
+ export async function wait(
119
+ { positionals, flags }: Input,
120
+ { config, api }: Context,
121
+ clock: Clock = realClock,
122
+ ): Promise<WaitResult> {
123
+ const address = resolveAddress(positionals[0], config, WAIT_USAGE)
124
+ const code = flags.code === true
125
+ const link = flags.link === true
126
+ if (code && link) throw new UsageError('--code and --link are exclusive')
127
+ const from = regexFlag(flags, 'from')
128
+ const subject = regexFlag(flags, 'subject')
129
+ const timeoutFlag = stringFlag(flags, 'timeout')
130
+ const timeout = timeoutFlag === undefined ? 300_000 : parseDuration(timeoutFlag, 'timeout')
131
+ const since = sinceFlag(stringFlag(flags, 'since'), clock.now())
132
+ const jwt = addressJwt(address)
133
+
134
+ const deadline = clock.now() + timeout
135
+ /** Whole seconds, the resolution of the instance's timestamps. */
136
+ const cutoff = since === 'now' ? undefined : Math.floor(since / 1000) * 1000
137
+ let baseline: number | undefined
138
+ let interval = FIRST_POLL
139
+
140
+ const match = (mail: Mail): WaitResult | undefined => {
141
+ const fresh = cutoff === undefined ? mail.id > baseline! : Date.parse(mail.date) >= cutoff
142
+ if (!fresh) return undefined
143
+ if (from && !from.test(mail.from)) return undefined
144
+ if (subject && !subject.test(mail.subject)) return undefined
145
+ if (code) {
146
+ const found = serverCode(mail.metadata) ?? localCode(mail.subject, mail.text)
147
+ return found ? { id: mail.id, code: found } : undefined
148
+ }
149
+ if (link) {
150
+ const found = bestLink(mail)
151
+ return found ? { id: mail.id, link: found } : undefined
152
+ }
153
+ return view(mail)
154
+ }
155
+
156
+ for (;;) {
157
+ let pause = interval
158
+ try {
159
+ const mails = await listMails(api, jwt, WINDOW)
160
+ if (cutoff === undefined && baseline === undefined) {
161
+ baseline = Math.max(0, ...mails.map((mail) => mail.id))
162
+ } else {
163
+ for (const mail of mails) {
164
+ const result = match(mail)
165
+ if (result) return result
166
+ }
167
+ }
168
+ interval = Math.min(interval * BACKOFF, LAST_POLL)
169
+ } catch (error) {
170
+ if (!(error instanceof ApiError && error.status === 429)) throw error
171
+ pause = Math.max(pause, (error.retryAfter ?? 0) * 1000)
172
+ }
173
+ const remaining = deadline - clock.now()
174
+ if (remaining <= 0) {
175
+ throw new WaitTimeout(`no matching mail for ${address} within ${timeout / 1000}s`)
176
+ }
177
+ await clock.sleep(Math.min(pause, remaining))
178
+ }
179
+ }
180
+
181
+ export function waitText(result: WaitResult): string {
182
+ if ('code' in result) return result.code
183
+ if ('link' in result) return result.link
184
+ return mailText(result)
185
+ }
186
+
187
+ const DELETE_MAIL_USAGE = 'usage: buzon delete-mail <address> <id>'
188
+
189
+ /** The instance answers success for an id it does not hold, so the mail is looked up first. */
190
+ export async function removeMail(
191
+ { positionals }: Input,
192
+ { config, api }: Context,
193
+ ): Promise<{ address: string; id: number }> {
194
+ const address = resolveAddress(positionals[0], config, DELETE_MAIL_USAGE)
195
+ const id = mailId(positionals[1], DELETE_MAIL_USAGE)
196
+ const jwt = addressJwt(address)
197
+ if (!(await getMail(api, jwt, id))) throw new Error(`no mail ${id} for ${address}`)
198
+ if (!(await deleteMail(api, jwt, id))) throw new Error(`the instance did not delete mail ${id}`)
199
+ return { address, id }
200
+ }
201
+
202
+ export const removeMailText = (result: { address: string; id: number }) =>
203
+ `deleted mail ${result.id} of ${result.address}`
@@ -0,0 +1,38 @@
1
+ import type { Api } from './api.ts'
2
+
3
+ /** A row of `GET /admin/address`. `name` is the full address. */
4
+ export type ServerAddress = { id: number; name: string; created_at: string }
5
+
6
+ type Page = { results: ServerAddress[] }
7
+
8
+ /** The largest page the instance serves. */
9
+ const LIMIT = 100
10
+
11
+ async function pages(api: Api, query?: string): Promise<ServerAddress[]> {
12
+ const out: ServerAddress[] = []
13
+ for (let offset = 0; ; offset += LIMIT) {
14
+ const params = new URLSearchParams({ limit: String(LIMIT), offset: String(offset) })
15
+ if (query) params.set('query', query)
16
+ const { results } = await api.request<Page>(`/admin/address?${params.toString()}`)
17
+ out.push(...results)
18
+ if (results.length < LIMIT) return out
19
+ }
20
+ }
21
+
22
+ export function listServerAddresses(api: Api): Promise<ServerAddress[]> {
23
+ return pages(api)
24
+ }
25
+
26
+ /** The search is a substring match, so the exact name is picked out of it. */
27
+ export async function findServerAddress(
28
+ api: Api,
29
+ address: string,
30
+ ): Promise<ServerAddress | undefined> {
31
+ return (await pages(api, address)).find((row) => row.name === address)
32
+ }
33
+
34
+ /** A fresh address JWT, signed by the instance for any address id. */
35
+ export async function mintJwt(api: Api, id: number): Promise<string> {
36
+ const { jwt } = await api.request<{ jwt: string }>(`/admin/show_password/${id}`)
37
+ return jwt
38
+ }
@@ -0,0 +1,101 @@
1
+ import type { Config } from './config.ts'
2
+ import { secret as keyringSecret, type SecretKey } from './secrets.ts'
3
+
4
+ export class ApiError extends Error {
5
+ constructor(
6
+ readonly status: number,
7
+ readonly path: string,
8
+ message: string,
9
+ /** Upstream error code, when the body was `{ code, message }` JSON instead of text. */
10
+ readonly code?: string,
11
+ /** Seconds from a `Retry-After` header, on a 429. */
12
+ readonly retryAfter?: number,
13
+ ) {
14
+ super(`${path}: ${status} ${message}`)
15
+ }
16
+ }
17
+
18
+ export type RequestOptions = {
19
+ method?: string
20
+ body?: unknown
21
+ /** Address JWT, sent as a bearer token. `/api/*` routes need one, except a few public ones. */
22
+ jwt?: string
23
+ /** Leave the site password out, for a probe that must not depend on it. */
24
+ anonymous?: boolean
25
+ }
26
+
27
+ export type Api = {
28
+ request<T>(path: string, options?: RequestOptions): Promise<T>
29
+ }
30
+
31
+ /**
32
+ * Upstream answers errors as plain text, or as `{ code, message }` JSON for auth failures.
33
+ * Both become an `ApiError` with the status kept.
34
+ */
35
+ async function failure(response: Response, path: string): Promise<ApiError> {
36
+ const text = (await response.text()).trim()
37
+ const retry = Number(response.headers.get('retry-after') ?? Number.NaN)
38
+ const retryAfter = Number.isFinite(retry) && retry >= 0 ? retry : undefined
39
+ try {
40
+ const body = JSON.parse(text) as { code?: unknown; message?: unknown }
41
+ if (typeof body.message === 'string') {
42
+ return new ApiError(
43
+ response.status,
44
+ path,
45
+ body.message,
46
+ typeof body.code === 'string' ? body.code : undefined,
47
+ retryAfter,
48
+ )
49
+ }
50
+ } catch {}
51
+ return new ApiError(response.status, path, text || response.statusText, undefined, retryAfter)
52
+ }
53
+
54
+ /** Secrets are read on first use, so a command that never calls `/admin/*` never needs one. */
55
+ export function createApi(
56
+ config: Pick<Config, 'url'>,
57
+ secret: (key: SecretKey) => string = keyringSecret,
58
+ fetcher: typeof fetch = fetch,
59
+ ): Api {
60
+ const cached = new Map<SecretKey, string>()
61
+ const get = (key: SecretKey) => {
62
+ let value = cached.get(key)
63
+ if (value === undefined) {
64
+ value = secret(key)
65
+ cached.set(key, value)
66
+ }
67
+ return value
68
+ }
69
+
70
+ return {
71
+ async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
72
+ const headers = new Headers({ accept: 'application/json' })
73
+ if (!options.anonymous) headers.set('x-custom-auth', get('site-password'))
74
+ if (options.jwt) headers.set('authorization', `Bearer ${options.jwt}`)
75
+ if (path.startsWith('/admin/')) headers.set('x-admin-auth', get('admin-password'))
76
+ let body: string | undefined
77
+ if (options.body !== undefined) {
78
+ headers.set('content-type', 'application/json')
79
+ body = JSON.stringify(options.body)
80
+ }
81
+
82
+ let response: Response
83
+ try {
84
+ response = await fetcher(`${config.url}${path}`, {
85
+ method: options.method ?? (body === undefined ? 'GET' : 'POST'),
86
+ headers,
87
+ body,
88
+ })
89
+ } catch (error) {
90
+ throw new Error(`${config.url}${path}: request failed`, { cause: error })
91
+ }
92
+ if (!response.ok) throw await failure(response, path)
93
+
94
+ const text = await response.text()
95
+ if (!response.headers.get('content-type')?.includes('application/json')) {
96
+ throw new ApiError(response.status, path, 'expected JSON, got something else')
97
+ }
98
+ return JSON.parse(text) as T
99
+ },
100
+ }
101
+ }
@@ -0,0 +1,57 @@
1
+ export class UsageError extends Error {}
2
+
3
+ export type Parsed = {
4
+ command: string
5
+ positionals: string[]
6
+ flags: Record<string, string | boolean>
7
+ }
8
+
9
+ const VALUE_FLAGS = new Set([
10
+ 'url',
11
+ 'domain',
12
+ 'site',
13
+ 'note',
14
+ 'tier',
15
+ 'id',
16
+ 'from',
17
+ 'subject',
18
+ 'timeout',
19
+ 'since',
20
+ 'tag',
21
+ 'to',
22
+ 'out',
23
+ ])
24
+
25
+ export function parseArgv(argv: string[]): Parsed {
26
+ const positionals: string[] = []
27
+ const flags: Record<string, string | boolean> = {}
28
+
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const arg = argv[i]!
31
+ if (!arg.startsWith('-')) {
32
+ positionals.push(arg)
33
+ continue
34
+ }
35
+ const flag = arg.replace(/^--?/, '')
36
+ const eq = flag.indexOf('=')
37
+ const name = eq < 0 ? flag : flag.slice(0, eq)
38
+ const inline = eq < 0 ? undefined : flag.slice(eq + 1)
39
+ if (VALUE_FLAGS.has(name)) {
40
+ const value = inline ?? argv[++i]
41
+ if (value === undefined) throw new UsageError(`--${name} needs a value`)
42
+ flags[name] = value
43
+ } else {
44
+ flags[name] = inline ?? true
45
+ }
46
+ }
47
+
48
+ return { command: positionals.shift() ?? '', positionals, flags }
49
+ }
50
+
51
+ /** A flag that must carry a value, or nothing: `--url` alone is a usage error, not `true`. */
52
+ export function stringFlag(flags: Parsed['flags'], name: string): string | undefined {
53
+ const value = flags[name]
54
+ if (value === undefined) return undefined
55
+ if (typeof value !== 'string' || value === '') throw new UsageError(`--${name} needs a value`)
56
+ return value
57
+ }
@@ -0,0 +1,84 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { paths } from './paths.ts'
3
+ import { writeAtomic } from './files.ts'
4
+
5
+ export type Config = {
6
+ /** Origin of the instance, without a trailing slash: `https://mail.example.com`. */
7
+ url: string
8
+ /** Domain the addresses are created on: `example.com`. */
9
+ domain: string
10
+ /** Upstream release the instance runs, as `buzon deploy` last shipped it: `v1.13.0`. */
11
+ tag?: string
12
+ }
13
+
14
+ export class ConfigMissingError extends Error {
15
+ constructor(file: string) {
16
+ super(`no config at ${file}. Run \`buzon init\` first.`)
17
+ }
18
+ }
19
+
20
+ export function normalizeUrl(raw: string): string {
21
+ let url: URL
22
+ try {
23
+ url = new URL(raw)
24
+ } catch {
25
+ throw new Error(`"${raw}" is not a URL`)
26
+ }
27
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
28
+ throw new Error(`"${raw}" is not an http(s) URL`)
29
+ }
30
+ return url.origin
31
+ }
32
+
33
+ export function normalizeDomain(raw: string): string {
34
+ const domain = raw.trim().toLowerCase()
35
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain)) throw new Error(`"${raw}" is not a domain`)
36
+ return domain
37
+ }
38
+
39
+ export function normalizeTag(raw: string): string {
40
+ const tag = raw.trim()
41
+ if (!/^v\d+\.\d+\.\d+$/.test(tag))
42
+ throw new Error(`"${raw}" is not a release tag such as v1.13.0`)
43
+ return tag
44
+ }
45
+
46
+ export function parseConfig(text: string, file = paths.config): Config {
47
+ let raw: Record<string, unknown>
48
+ try {
49
+ raw = Bun.TOML.parse(text) as Record<string, unknown>
50
+ } catch (error) {
51
+ throw new Error(`${file} is not valid TOML`, { cause: error })
52
+ }
53
+ const { url, domain, tag } = raw
54
+ if (typeof url !== 'string') throw new Error(`${file}: \`url\` is missing`)
55
+ if (typeof domain !== 'string') throw new Error(`${file}: \`domain\` is missing`)
56
+ const config: Config = { url: normalizeUrl(url), domain: normalizeDomain(domain) }
57
+ if (tag !== undefined) {
58
+ if (typeof tag !== 'string') throw new Error(`${file}: \`tag\` must be a string`)
59
+ config.tag = normalizeTag(tag)
60
+ }
61
+ return config
62
+ }
63
+
64
+ export function readConfig(file = paths.config): Config {
65
+ let text: string
66
+ try {
67
+ text = readFileSync(file, 'utf8')
68
+ } catch (error) {
69
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new ConfigMissingError(file)
70
+ throw error
71
+ }
72
+ return parseConfig(text, file)
73
+ }
74
+
75
+ /** JSON string escapes are valid TOML basic-string escapes, so `JSON.stringify` quotes safely. */
76
+ export function serializeConfig(config: Config): string {
77
+ const lines = [`url = ${JSON.stringify(config.url)}`, `domain = ${JSON.stringify(config.domain)}`]
78
+ if (config.tag) lines.push(`tag = ${JSON.stringify(config.tag)}`)
79
+ return `${lines.join('\n')}\n`
80
+ }
81
+
82
+ export function writeConfig(config: Config, file = paths.config): void {
83
+ writeAtomic(file, serializeConfig(config))
84
+ }
@@ -0,0 +1,19 @@
1
+ import { paths } from './paths.ts'
2
+ import { readJson, writeJson } from './files.ts'
3
+
4
+ /** Address JWTs by address. Owner-only, and outside the tracked data directory. */
5
+ export type Credentials = Record<string, string>
6
+
7
+ export function readCredentials(file = paths.credentials): Credentials {
8
+ return (readJson(file) as Credentials | undefined) ?? {}
9
+ }
10
+
11
+ export function writeCredentials(credentials: Credentials, file = paths.credentials): void {
12
+ writeJson(file, credentials, 0o600)
13
+ }
14
+
15
+ export function addressJwt(address: string, file = paths.credentials): string {
16
+ const jwt = readCredentials(file)[address]
17
+ if (!jwt) throw new Error(`no credential for ${address}: run \`buzon reissue ${address}\``)
18
+ return jwt
19
+ }
@@ -0,0 +1,19 @@
1
+ /** How many causes deep an error is followed. A wrapper and what it wraps is the usual case. */
2
+ const DEPTH = 3
3
+
4
+ function text(error: unknown): string {
5
+ if (!(error instanceof Error)) return String(error)
6
+ const code = (error as NodeJS.ErrnoException).code
7
+ return code && !error.message.includes(code) ? `${error.message} (${code})` : error.message
8
+ }
9
+
10
+ /** The message followed by its `cause` chain, so "fetch failed" can say DNS from TLS. */
11
+ export function causeChain(error: unknown): string[] {
12
+ const out = [text(error)]
13
+ let at = error instanceof Error ? error.cause : undefined
14
+ while (at !== undefined && out.length < DEPTH) {
15
+ out.push(text(at))
16
+ at = at instanceof Error ? at.cause : undefined
17
+ }
18
+ return out
19
+ }