@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +836 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/bin/pi-mailbridge.mjs +31 -0
- package/config.example.json +54 -0
- package/dist/core/attachments.js +87 -0
- package/dist/core/authz.js +65 -0
- package/dist/core/commands.js +72 -0
- package/dist/core/config-freshness.js +24 -0
- package/dist/core/config.js +286 -0
- package/dist/core/deliveries.js +180 -0
- package/dist/core/discover.js +282 -0
- package/dist/core/header-encoding.js +70 -0
- package/dist/core/html.js +48 -0
- package/dist/core/inline-images.js +355 -0
- package/dist/core/mail-command.js +62 -0
- package/dist/core/mailer.js +126 -0
- package/dist/core/markdown.js +96 -0
- package/dist/core/oauth-device.js +119 -0
- package/dist/core/oauth-openid.js +48 -0
- package/dist/core/oauth.js +137 -0
- package/dist/core/prime.js +51 -0
- package/dist/core/reply-parse.js +134 -0
- package/dist/core/sent-index.js +135 -0
- package/dist/core/settle.js +122 -0
- package/dist/core/setup.js +100 -0
- package/dist/core/smoke.js +164 -0
- package/dist/core/status.js +56 -0
- package/dist/core/token-source.js +27 -0
- package/dist/core/token.js +196 -0
- package/dist/core/version.js +44 -0
- package/dist/daemon/api.js +46 -0
- package/dist/daemon/imapflow.js +75 -0
- package/dist/daemon/main.js +802 -0
- package/dist/daemon/process.js +270 -0
- package/dist/daemon/scan-queue.js +38 -0
- package/dist/daemon/script.js +44 -0
- package/dist/daemon/send-budget.js +30 -0
- package/dist/daemon/service.js +455 -0
- package/dist/daemon/source-proxy.js +64 -0
- package/dist/daemon/spawn.js +227 -0
- package/dist/daemon/watcher.js +308 -0
- package/dist/extension.js +1233 -0
- package/dist/store/journal.js +92 -0
- package/dist/store/mode-store.js +47 -0
- package/dist/store/oauth-store.js +50 -0
- package/dist/store/paths.js +33 -0
- package/dist/store/pending-store.js +212 -0
- package/dist/store/registry.js +61 -0
- package/dist/store/routes.js +223 -0
- package/dist/store/schema.js +93 -0
- package/dist/store/secret.js +70 -0
- package/dist/store/threads.js +145 -0
- package/dist/store/uid-cursor.js +112 -0
- package/docs/ARCHITECTURE.md +356 -0
- package/docs/MAPPING.md +201 -0
- package/docs/OAUTH.md +448 -0
- package/docs/SECURITY.md +273 -0
- package/docs/images/flow.svg +124 -0
- package/docs/images/notification.png +0 -0
- package/package.json +85 -4
- package/scripts/render-teaser.ts +56 -0
- package/src/core/attachments.ts +129 -0
- package/src/core/authz.ts +87 -0
- package/src/core/commands.ts +99 -0
- package/src/core/config-freshness.ts +33 -0
- package/src/core/config.ts +373 -0
- package/src/core/deliveries.ts +233 -0
- package/src/core/discover.ts +333 -0
- package/src/core/header-encoding.ts +75 -0
- package/src/core/html.ts +62 -0
- package/src/core/inline-images.ts +421 -0
- package/src/core/mail-command.ts +76 -0
- package/src/core/mailer.ts +193 -0
- package/src/core/markdown.ts +114 -0
- package/src/core/oauth-device.ts +195 -0
- package/src/core/oauth-openid.ts +61 -0
- package/src/core/oauth.ts +182 -0
- package/src/core/prime.ts +74 -0
- package/src/core/reply-parse.ts +153 -0
- package/src/core/sent-index.ts +164 -0
- package/src/core/settle.ts +179 -0
- package/src/core/setup.ts +139 -0
- package/src/core/smoke.ts +207 -0
- package/src/core/status.ts +75 -0
- package/src/core/token-source.ts +34 -0
- package/src/core/token.ts +240 -0
- package/src/core/version.ts +45 -0
- package/src/daemon/api.ts +52 -0
- package/src/daemon/imapflow.ts +101 -0
- package/src/daemon/main.ts +832 -0
- package/src/daemon/process.ts +348 -0
- package/src/daemon/scan-queue.ts +45 -0
- package/src/daemon/script.ts +45 -0
- package/src/daemon/send-budget.ts +36 -0
- package/src/daemon/service.ts +550 -0
- package/src/daemon/source-proxy.ts +88 -0
- package/src/daemon/spawn.ts +332 -0
- package/src/daemon/watcher.ts +427 -0
- package/src/extension.ts +1381 -0
- package/src/store/journal.ts +104 -0
- package/src/store/mode-store.ts +60 -0
- package/src/store/oauth-store.ts +55 -0
- package/src/store/paths.ts +38 -0
- package/src/store/pending-store.ts +282 -0
- package/src/store/registry.ts +91 -0
- package/src/store/routes.ts +267 -0
- package/src/store/schema.ts +104 -0
- package/src/store/secret.ts +69 -0
- package/src/store/threads.ts +177 -0
- package/src/store/uid-cursor.ts +135 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// IMAP IDLE watcher — the read side. Port of opencode-afk `core/watcher.js`.
|
|
2
|
+
//
|
|
3
|
+
// One connection per machine, owned by the daemon, exposed to the scan pipeline
|
|
4
|
+
// as a `MailSource`. Two things make this testable without a socket:
|
|
5
|
+
// - the client comes from an injectable factory (`ImapClientFactory`)
|
|
6
|
+
// - body decoding comes from an injectable parser (`BodyParser`)
|
|
7
|
+
//
|
|
8
|
+
// Design notes carried over from the reference implementation:
|
|
9
|
+
// - IDLE renews well under the server's ~29 minute timeout
|
|
10
|
+
// - reconnect is exponential and bounded
|
|
11
|
+
// - credentials are never logged
|
|
12
|
+
// - errors are summarised from responseStatus/responseText only
|
|
13
|
+
|
|
14
|
+
import { simpleParser } from "mailparser"
|
|
15
|
+
|
|
16
|
+
import type { ResolvedConfig } from "../core/config.ts"
|
|
17
|
+
import type { RawMessage, MailSource } from "./process.ts"
|
|
18
|
+
import type { HeaderLike } from "../core/reply-parse.ts"
|
|
19
|
+
import { decodeRfc2047 } from "../core/header-encoding.ts"
|
|
20
|
+
import type { RawAttachment } from "../core/attachments.ts"
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_IDLE_RENEW_MS = 25 * 60 * 1000
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Hard ceiling on a message we will parse. The attachment caps (4 x 1 MB) live well inside
|
|
26
|
+
* it; anything larger is refused before the body is downloaded.
|
|
27
|
+
*/
|
|
28
|
+
export const MAX_MESSAGE_BYTES = 8 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
const DEFAULT_BACKOFF_INITIAL_MS = 1_000
|
|
31
|
+
export const DEFAULT_BACKOFF_MAX_MS = 60_000
|
|
32
|
+
|
|
33
|
+
/** Headers the authorization and routing layers need. */
|
|
34
|
+
export const WANTED_HEADERS = [
|
|
35
|
+
"authentication-results",
|
|
36
|
+
"auto-submitted",
|
|
37
|
+
"precedence",
|
|
38
|
+
"x-auto-response-suppress",
|
|
39
|
+
"list-id",
|
|
40
|
+
"from",
|
|
41
|
+
"subject",
|
|
42
|
+
// Our own Message-ID and marker, so a notification that comes back is recognised as an
|
|
43
|
+
// echo even if the id was never recorded (a send that raced the scan).
|
|
44
|
+
"message-id",
|
|
45
|
+
"x-pi-mailbridge",
|
|
46
|
+
// Reply-chain fallback: threading headers, so a reply reaches the session even
|
|
47
|
+
// when the subject token was rewritten by a client or a human.
|
|
48
|
+
"in-reply-to",
|
|
49
|
+
"references",
|
|
50
|
+
] as const
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Pure helpers (exported for tests)
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* IMAP UID range for "everything after `cursor`". UIDs are 32-bit and a server
|
|
58
|
+
* may assign 0, so `1:*` is the safe floor.
|
|
59
|
+
*/
|
|
60
|
+
export function searchRangeFor(cursor: number): string {
|
|
61
|
+
return `${Math.max(1, cursor + 1)}:*`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Bounded exponential backoff. */
|
|
65
|
+
export function nextBackoff(attempt: number, initial = DEFAULT_BACKOFF_INITIAL_MS, max = DEFAULT_BACKOFF_MAX_MS): number {
|
|
66
|
+
const safeAttempt = Math.max(0, Math.min(attempt, 30))
|
|
67
|
+
return Math.min(max, initial * 2 ** safeAttempt)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every value of every header, in wire order, plus a raw `From`/`Subject`.
|
|
72
|
+
*
|
|
73
|
+
* Written by hand rather than delegated because `Authentication-Results` MUST
|
|
74
|
+
* keep its original order and multiplicity: authorization trusts only the
|
|
75
|
+
* topmost header whose authserv-id is ours (docs/SECURITY.md §2.1). Most MIME
|
|
76
|
+
* parsers collapse duplicates, which would silently discard that distinction.
|
|
77
|
+
*/
|
|
78
|
+
export interface ExtractedHeaders {
|
|
79
|
+
headerValues: Map<string, string[]>
|
|
80
|
+
from?: string
|
|
81
|
+
subject?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function extractHeaders(raw: Buffer | string): ExtractedHeaders {
|
|
85
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf8")
|
|
86
|
+
// Headers end at the first blank line.
|
|
87
|
+
const end = text.search(/\r?\n\r?\n/)
|
|
88
|
+
const head = end === -1 ? text : text.slice(0, end)
|
|
89
|
+
|
|
90
|
+
const unfolded = head.replace(/\r?\n[ \t]+/g, " ")
|
|
91
|
+
const headerValues = new Map<string, string[]>()
|
|
92
|
+
|
|
93
|
+
for (const line of unfolded.split(/\r?\n/)) {
|
|
94
|
+
const colon = line.indexOf(":")
|
|
95
|
+
if (colon <= 0) continue
|
|
96
|
+
const name = line.slice(0, colon).trim().toLowerCase()
|
|
97
|
+
const value = line.slice(colon + 1).trim()
|
|
98
|
+
if (!name) continue
|
|
99
|
+
const existing = headerValues.get(name)
|
|
100
|
+
if (existing) existing.push(value)
|
|
101
|
+
else headerValues.set(name, [value])
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const pick = (name: string): string | undefined => {
|
|
105
|
+
const values = headerValues.get(name)
|
|
106
|
+
if (!values) return undefined
|
|
107
|
+
// From/Subject are logically single-valued; servers may fold them, so join
|
|
108
|
+
// rather than silently using only the first instance. Then decode RFC 2047:
|
|
109
|
+
// a client that encodes a subject makes the routing token unreadable otherwise.
|
|
110
|
+
return decodeRfc2047(values.join(" "))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { headerValues, from: pick("from"), subject: pick("subject") }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Map extracted headers onto the shape the authorization layer consumes. */
|
|
117
|
+
export function toHeaderLike(extracted: ExtractedHeaders): HeaderLike {
|
|
118
|
+
const one = (name: string): string | undefined => extracted.headerValues.get(name)?.[0]
|
|
119
|
+
return {
|
|
120
|
+
from: extracted.from,
|
|
121
|
+
subject: extracted.subject,
|
|
122
|
+
authResults: extracted.headerValues.get("authentication-results"),
|
|
123
|
+
// message-id is what lets the scanner recognise our own notification coming back.
|
|
124
|
+
...(one("message-id") ? { messageId: one("message-id") } : {}),
|
|
125
|
+
...(one("x-pi-mailbridge") ? { piMailbridge: one("x-pi-mailbridge") } : {}),
|
|
126
|
+
...(one("in-reply-to") ? { inReplyTo: one("in-reply-to") } : {}),
|
|
127
|
+
...(extracted.headerValues.get("references") ? { references: extracted.headerValues.get("references") } : {}),
|
|
128
|
+
autoSubmitted: one("auto-submitted"),
|
|
129
|
+
precedence: one("precedence"),
|
|
130
|
+
autoResponseSuppress: one("x-auto-response-suppress"),
|
|
131
|
+
listId: one("list-id"),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Injectable seams
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
export interface BodyParser {
|
|
140
|
+
/**
|
|
141
|
+
* Decode the body (and any attachments) from a raw message.
|
|
142
|
+
*
|
|
143
|
+
* `html` is the fallback for a multipart message whose only body part is text/html -
|
|
144
|
+
* mailparser sets `text` to "" for that shape, and such a reply used to be rejected as
|
|
145
|
+
* empty-body with the cursor already past it.
|
|
146
|
+
*/
|
|
147
|
+
(raw: Buffer): Promise<{ text: string; html?: string; attachments?: RawAttachment[] }>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const defaultBodyParser: BodyParser = async (raw) => {
|
|
151
|
+
const parsed = await simpleParser(raw)
|
|
152
|
+
const attachments: RawAttachment[] = (parsed.attachments ?? []).map((a) => ({
|
|
153
|
+
...(a.filename ? { filename: a.filename } : {}),
|
|
154
|
+
contentType: a.contentType ?? "application/octet-stream",
|
|
155
|
+
content: a.content,
|
|
156
|
+
}))
|
|
157
|
+
return {
|
|
158
|
+
text: parsed.text ?? "",
|
|
159
|
+
...(parsed.html ? { html: parsed.html } : {}),
|
|
160
|
+
attachments,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Coarse HTML → text: enough to keep the human's words, not a renderer. */
|
|
165
|
+
export function htmlToText(html: string): string {
|
|
166
|
+
return html
|
|
167
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ")
|
|
168
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
169
|
+
.replace(/<\/(p|div|tr|li|h[1-6])>/gi, "\n")
|
|
170
|
+
.replace(/<[^>]+>/g, " ")
|
|
171
|
+
.replace(/ /gi, " ")
|
|
172
|
+
.replace(/&/gi, "&")
|
|
173
|
+
.replace(/</gi, "<")
|
|
174
|
+
.replace(/>/gi, ">")
|
|
175
|
+
.replace(/"/gi, '"')
|
|
176
|
+
.replace(/'/gi, "'")
|
|
177
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
178
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
179
|
+
.trim()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The subset of ImapFlow this module uses. */
|
|
183
|
+
export interface ImapClientLike {
|
|
184
|
+
connect(): Promise<void>
|
|
185
|
+
logout(): Promise<void>
|
|
186
|
+
mailboxOpen(path: string): Promise<unknown>
|
|
187
|
+
search(query: Record<string, unknown>, options?: { uid?: boolean }): Promise<number[]>
|
|
188
|
+
fetchOne(uid: number, query: Record<string, unknown>, options?: { uid?: boolean }): Promise<unknown>
|
|
189
|
+
/** Adds flags (e.g. \\Seen). Only called from the ack path. */
|
|
190
|
+
messageFlagsAdd?(uid: number, flags: string[], options?: { uid?: boolean }): Promise<unknown>
|
|
191
|
+
on(event: string, listener: (...args: unknown[]) => void): void
|
|
192
|
+
/** imapflow sets this false when the connection is gone. */
|
|
193
|
+
usable?: boolean
|
|
194
|
+
/** Populated once the mailbox is open; UID space identity. */
|
|
195
|
+
mailbox?: { uidValidity?: bigint | number; uidNext?: bigint | number }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface ImapClientFactory {
|
|
199
|
+
/** May be async: XOAUTH2 has to fetch an access token before connecting. */
|
|
200
|
+
(config: ResolvedConfig, tuning: { idleRenewMs: number }): ImapClientLike | Promise<ImapClientLike>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface WatcherTimers {
|
|
204
|
+
setTimeout(handler: () => void, ms: number): unknown
|
|
205
|
+
clearTimeout(handle: unknown): void
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const defaultTimers: WatcherTimers = {
|
|
209
|
+
setTimeout: (handler, ms) => setTimeout(handler, ms),
|
|
210
|
+
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface WatcherCallbacks {
|
|
214
|
+
/** New mail may be waiting. Must not throw. */
|
|
215
|
+
onMail: () => void
|
|
216
|
+
/** Connection is usable; run the catch-up scan. */
|
|
217
|
+
onReady: () => void
|
|
218
|
+
/** Reconnect planned, for logging. */
|
|
219
|
+
onError?: (error: Error, nextDelayMs: number) => void
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface WatcherOptions {
|
|
223
|
+
factory: ImapClientFactory
|
|
224
|
+
callbacks: WatcherCallbacks
|
|
225
|
+
parser?: BodyParser
|
|
226
|
+
timers?: WatcherTimers
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A fetched message as imapflow returns it. */
|
|
230
|
+
interface FetchResultLike {
|
|
231
|
+
source?: Buffer
|
|
232
|
+
envelope?: { from?: { address?: string }[]; subject?: string }
|
|
233
|
+
/** RFC822 size, when the fetch asked for it. */
|
|
234
|
+
size?: number
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Watcher
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/** imapflow reports these as bigint; normalise both to a safe number. */
|
|
242
|
+
function normaliseUid(raw: bigint | number | undefined): number | undefined {
|
|
243
|
+
if (raw === undefined) return undefined
|
|
244
|
+
const value = typeof raw === "bigint" ? Number(raw) : raw
|
|
245
|
+
return Number.isFinite(value) ? value : undefined
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface ImapWatcher extends MailSource {
|
|
249
|
+
start(): Promise<void>
|
|
250
|
+
stop(): Promise<void>
|
|
251
|
+
isHealthy(): boolean
|
|
252
|
+
/** The open mailbox's UIDVALIDITY, when the server exposes it. */
|
|
253
|
+
uidValidity(): number | undefined
|
|
254
|
+
/** The open mailbox's next UID, when the server exposes it. */
|
|
255
|
+
uidNext(): number | undefined
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function createImapWatcher(config: ResolvedConfig, options: WatcherOptions): ImapWatcher {
|
|
259
|
+
const { factory, callbacks } = options
|
|
260
|
+
const parser = options.parser ?? defaultBodyParser
|
|
261
|
+
const timers = options.timers ?? defaultTimers
|
|
262
|
+
const idleRenewMs = config.tuning.idleRenewMs ?? DEFAULT_IDLE_RENEW_MS
|
|
263
|
+
|
|
264
|
+
let client: ImapClientLike | undefined
|
|
265
|
+
let reconnectHandle: unknown
|
|
266
|
+
let attempt = 0
|
|
267
|
+
let stopped = false
|
|
268
|
+
let connecting = false
|
|
269
|
+
|
|
270
|
+
async function teardown(target?: ImapClientLike): Promise<void> {
|
|
271
|
+
// Take the connection explicitly when the caller still holds it: stop() may already
|
|
272
|
+
// have nulled `client` (and logged out nothing, because the dial had not finished),
|
|
273
|
+
// and tearing down `client` again would silently do nothing.
|
|
274
|
+
const c = target ?? client
|
|
275
|
+
// Clear it either way: after a stop during the dial, `client` was assigned the very
|
|
276
|
+
// connection we are about to close, and leaving it set lets search/fetch reach a
|
|
277
|
+
// logged-out socket instead of failing fast.
|
|
278
|
+
if (client === c) client = undefined
|
|
279
|
+
if (!c) return
|
|
280
|
+
try {
|
|
281
|
+
await c.logout()
|
|
282
|
+
} catch {
|
|
283
|
+
// A dead connection cannot log out; that is expected during reconnect.
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function scheduleReconnect(error: Error): void {
|
|
288
|
+
// A dying connection emits both "close" and "error", and connectOnce's catch adds a
|
|
289
|
+
// third path. Without this guard each one arms its own timer, so the watcher opens
|
|
290
|
+
// several connections and leaks all but the last.
|
|
291
|
+
if (stopped || reconnectHandle !== undefined || connecting) return
|
|
292
|
+
const delay = nextBackoff(attempt, config.tuning.backoffInitialMs, config.tuning.backoffMaxMs)
|
|
293
|
+
attempt++
|
|
294
|
+
callbacks.onError?.(error, delay)
|
|
295
|
+
reconnectHandle = timers.setTimeout(() => {
|
|
296
|
+
reconnectHandle = undefined
|
|
297
|
+
void connectOnce()
|
|
298
|
+
}, delay)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function connectOnce(): Promise<void> {
|
|
302
|
+
if (stopped || connecting) return
|
|
303
|
+
connecting = true
|
|
304
|
+
try {
|
|
305
|
+
const c = await factory(config, { idleRenewMs })
|
|
306
|
+
client = c
|
|
307
|
+
await c.connect()
|
|
308
|
+
await c.mailboxOpen(config.mail.imap.folder || "INBOX")
|
|
309
|
+
// stop() may have run while those were awaited; the entry check cannot see that.
|
|
310
|
+
// Without this, the connection stays open with handlers attached and nothing
|
|
311
|
+
// will ever tear it down (isHealthy() would meanwhile report false).
|
|
312
|
+
if (stopped) {
|
|
313
|
+
await teardown(c)
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
attempt = 0
|
|
317
|
+
c.on("exists", () => callbacks.onMail())
|
|
318
|
+
c.on("close", () => {
|
|
319
|
+
if (stopped) return
|
|
320
|
+
scheduleReconnect(new Error("imap connection closed"))
|
|
321
|
+
})
|
|
322
|
+
c.on("error", (error) => scheduleReconnect(error instanceof Error ? error : new Error(String(error))))
|
|
323
|
+
callbacks.onReady()
|
|
324
|
+
} catch (error) {
|
|
325
|
+
await teardown()
|
|
326
|
+
// Clear the in-flight flag first: scheduleReconnect refuses while a connect is
|
|
327
|
+
// pending, and this catch is one of the legs that must be able to arm it.
|
|
328
|
+
connecting = false
|
|
329
|
+
scheduleReconnect(error instanceof Error ? error : new Error(String(error)))
|
|
330
|
+
} finally {
|
|
331
|
+
connecting = false
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
async start() {
|
|
337
|
+
stopped = false
|
|
338
|
+
await connectOnce()
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
async stop() {
|
|
342
|
+
stopped = true
|
|
343
|
+
if (reconnectHandle !== undefined) {
|
|
344
|
+
timers.clearTimeout(reconnectHandle)
|
|
345
|
+
reconnectHandle = undefined
|
|
346
|
+
}
|
|
347
|
+
await teardown()
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
/** The open mailbox's UIDVALIDITY, when the client exposes one. */
|
|
351
|
+
uidValidity(): number | undefined {
|
|
352
|
+
return normaliseUid(client?.mailbox?.uidValidity)
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
/** The open mailbox's next UID, when the client exposes one. */
|
|
356
|
+
uidNext(): number | undefined {
|
|
357
|
+
return normaliseUid(client?.mailbox?.uidNext)
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
isHealthy() {
|
|
361
|
+
return !stopped && client !== undefined && client.usable !== false
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
async searchAfter(afterUid: number): Promise<number[]> {
|
|
365
|
+
const c = client
|
|
366
|
+
if (!c) throw new Error("imap watcher not connected")
|
|
367
|
+
const uids = await c.search({ uid: searchRangeFor(afterUid) }, { uid: true })
|
|
368
|
+
return uids.filter((n) => Number.isFinite(n) && n > afterUid).sort((a, b) => a - b)
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
async fetch(uid: number): Promise<RawMessage | undefined> {
|
|
372
|
+
const c = client
|
|
373
|
+
if (!c) throw new Error("imap watcher not connected")
|
|
374
|
+
|
|
375
|
+
// Ask for the size first: the full source is downloaded before authorization, so
|
|
376
|
+
// an oversized message from any sender - spam, a 200 MB newsletter - would cost the
|
|
377
|
+
// one shared daemon a full download and parse. `size` is cheap and needs no body.
|
|
378
|
+
const meta = (await c.fetchOne(uid, { size: true, envelope: true }, { uid: true })) as
|
|
379
|
+
| FetchResultLike
|
|
380
|
+
| false
|
|
381
|
+
| undefined
|
|
382
|
+
if (meta && typeof meta.size === "number" && meta.size > MAX_MESSAGE_BYTES) {
|
|
383
|
+
return {
|
|
384
|
+
uid,
|
|
385
|
+
from: meta.envelope?.from?.[0]?.address,
|
|
386
|
+
subject: meta.envelope?.subject,
|
|
387
|
+
text: "",
|
|
388
|
+
headers: { subject: meta.envelope?.subject },
|
|
389
|
+
receivedAt: Date.now(),
|
|
390
|
+
tooLarge: meta.size,
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const fetched = (await c.fetchOne(
|
|
395
|
+
uid,
|
|
396
|
+
{ source: true, envelope: true, headers: [...WANTED_HEADERS] },
|
|
397
|
+
{ uid: true },
|
|
398
|
+
)) as FetchResultLike | false | undefined
|
|
399
|
+
if (!fetched || !fetched.source) return undefined
|
|
400
|
+
|
|
401
|
+
const extracted = extractHeaders(fetched.source)
|
|
402
|
+
const parsed = await parser(fetched.source)
|
|
403
|
+
// A body with no text content falls back to the HTML part, tag-stripped. Testing for
|
|
404
|
+
// content, not for undefined: mailparser yields "" for a text/html-only multipart.
|
|
405
|
+
const bodyText = parsed.text?.trim() ? parsed.text : parsed.html ? htmlToText(parsed.html) : ""
|
|
406
|
+
const from = extracted.from ?? fetched.envelope?.from?.[0]?.address
|
|
407
|
+
const subject = extracted.subject ?? fetched.envelope?.subject
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
uid,
|
|
411
|
+
from,
|
|
412
|
+
subject,
|
|
413
|
+
text: bodyText,
|
|
414
|
+
...(parsed.attachments?.length ? { attachments: parsed.attachments } : {}),
|
|
415
|
+
headers: toHeaderLike(extracted),
|
|
416
|
+
receivedAt: Date.now(),
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
/** Mailbox hygiene, invoked by the daemon's /ack handler only. */
|
|
421
|
+
async markSeen(uid: number): Promise<void> {
|
|
422
|
+
const c = client
|
|
423
|
+
if (!c?.messageFlagsAdd) return
|
|
424
|
+
await c.messageFlagsAdd(uid, ["\\Seen"], { uid: true })
|
|
425
|
+
},
|
|
426
|
+
}
|
|
427
|
+
}
|