@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +836 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. 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(/&nbsp;/gi, " ")
172
+ .replace(/&amp;/gi, "&")
173
+ .replace(/&lt;/gi, "<")
174
+ .replace(/&gt;/gi, ">")
175
+ .replace(/&quot;/gi, '"')
176
+ .replace(/&#39;/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
+ }