@c9up/goldstone 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +238 -2
- package/dist/GoldstoneProvider.d.ts +33 -0
- package/dist/GoldstoneProvider.d.ts.map +1 -0
- package/dist/GoldstoneProvider.js +55 -0
- package/dist/GoldstoneProvider.js.map +1 -0
- package/dist/InboxManager.d.ts +17 -0
- package/dist/InboxManager.d.ts.map +1 -0
- package/dist/InboxManager.js +39 -0
- package/dist/InboxManager.js.map +1 -0
- package/dist/Mailbox.d.ts +83 -0
- package/dist/Mailbox.d.ts.map +1 -0
- package/dist/Mailbox.js +326 -0
- package/dist/Mailbox.js.map +1 -0
- package/dist/ReceivedMessage.d.ts +152 -0
- package/dist/ReceivedMessage.d.ts.map +1 -0
- package/dist/ReceivedMessage.js +179 -0
- package/dist/ReceivedMessage.js.map +1 -0
- package/dist/augmentations.d.ts +18 -0
- package/dist/augmentations.d.ts.map +1 -0
- package/dist/augmentations.js +10 -0
- package/dist/augmentations.js.map +1 -0
- package/dist/authenticity.d.ts +81 -0
- package/dist/authenticity.d.ts.map +1 -0
- package/dist/authenticity.js +102 -0
- package/dist/authenticity.js.map +1 -0
- package/dist/config.d.ts +85 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +50 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +50 -0
- package/dist/errors.js.map +1 -0
- package/dist/inbound/context.d.ts +43 -0
- package/dist/inbound/context.d.ts.map +1 -0
- package/dist/inbound/context.js +56 -0
- package/dist/inbound/context.js.map +1 -0
- package/dist/inbound/mailgun.d.ts +57 -0
- package/dist/inbound/mailgun.d.ts.map +1 -0
- package/dist/inbound/mailgun.js +132 -0
- package/dist/inbound/mailgun.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/native/generated.d.ts +106 -0
- package/dist/native/generated.d.ts.map +1 -0
- package/dist/native/generated.js +7 -0
- package/dist/native/generated.js.map +1 -0
- package/dist/native.d.ts +23 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +45 -0
- package/dist/native.js.map +1 -0
- package/dist/services/main.d.ts +22 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +43 -0
- package/dist/services/main.js.map +1 -0
- package/dist/vendor/nativeBinary.d.ts +67 -0
- package/dist/vendor/nativeBinary.d.ts.map +1 -0
- package/dist/vendor/nativeBinary.js +156 -0
- package/dist/vendor/nativeBinary.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +67 -3
- package/src/GoldstoneProvider.ts +75 -0
- package/src/InboxManager.ts +51 -0
- package/src/Mailbox.ts +433 -0
- package/src/ReceivedMessage.ts +287 -0
- package/src/augmentations.ts +22 -0
- package/src/authenticity.ts +183 -0
- package/src/config.ts +132 -0
- package/src/errors.ts +57 -0
- package/src/inbound/context.ts +92 -0
- package/src/inbound/mailgun.ts +201 -0
- package/src/index.ts +53 -0
- package/src/native/generated.ts +141 -0
- package/src/native.ts +62 -0
- package/src/services/main.ts +51 -0
- package/src/vendor/nativeBinary.ts +186 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C9up
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,239 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @c9up/goldstone
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Reading mail for the Ream framework.
|
|
4
|
+
|
|
5
|
+
Goldstone reads IMAP mailboxes — in real time with IDLE — and receives the
|
|
6
|
+
inbound mail that providers post to a webhook. Every message it hands over has
|
|
7
|
+
been parsed as the hostile input it is: HTML sanitized for display, attachments
|
|
8
|
+
bounded, file names cleaned, and the sender's DKIM, SPF and DMARC verdict
|
|
9
|
+
attached.
|
|
10
|
+
|
|
11
|
+
Sending stays with `@c9up/rover`.
|
|
12
|
+
|
|
13
|
+
## Status
|
|
14
|
+
|
|
15
|
+
Here: reading IMAP mailboxes, receiving Mailgun's inbound mail, parsing what
|
|
16
|
+
arrives, and checking who sent it (DKIM, SPF, DMARC, ARC).
|
|
17
|
+
|
|
18
|
+
## Reading a mailbox
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// config/inbox.ts
|
|
22
|
+
import { defineConfig } from "@c9up/goldstone"
|
|
23
|
+
|
|
24
|
+
export default defineConfig({
|
|
25
|
+
mailbox: "support",
|
|
26
|
+
mailboxes: {
|
|
27
|
+
support: {
|
|
28
|
+
host: "imap.example.com",
|
|
29
|
+
auth: { user: env.get("IMAP_USER"), pass: env.get("IMAP_PASSWORD") },
|
|
30
|
+
},
|
|
31
|
+
billing: {
|
|
32
|
+
host: "outlook.office365.com",
|
|
33
|
+
// XOAUTH2. Called on every connection, so an expired token is asked
|
|
34
|
+
// for again when the connection is made anew.
|
|
35
|
+
auth: { user: "billing@example.com", accessToken: () => tokens.for("billing") },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// reamrc.ts
|
|
41
|
+
providers: [() => import("@c9up/goldstone/provider")]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import inbox from "@c9up/goldstone/services/main"
|
|
46
|
+
|
|
47
|
+
const support = inbox.mailbox() // the default; inbox.mailbox("billing")
|
|
48
|
+
|
|
49
|
+
for (const uid of await support.search({ seen: false })) {
|
|
50
|
+
const message = await support.fetch(uid) // parsed, see below
|
|
51
|
+
await support.markSeen(uid)
|
|
52
|
+
}
|
|
53
|
+
await support.move(uid, "Archive")
|
|
54
|
+
|
|
55
|
+
const stop = support.watch(async (message, { uid }) => {
|
|
56
|
+
// every new message, as it arrives
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- **TLS is required**: implicit TLS by default (993), or `starttls: true`
|
|
61
|
+
(143, plaintext upgraded: `secure` need not be set), which refuses to go on
|
|
62
|
+
when the server does not offer STARTTLS. `secure: true` with
|
|
63
|
+
`starttls: true` is refused at `defineConfig`. Plaintext
|
|
64
|
+
or an unchecked certificate need `allowInsecure`, for a local test server.
|
|
65
|
+
- A mailbox opens on first use. `watch()` keeps its own connection in IDLE;
|
|
66
|
+
when it is lost, it reconnects with a growing wait and delivers what
|
|
67
|
+
arrived meanwhile — messages are followed by UID, so none is skipped and
|
|
68
|
+
none delivered twice. A handler that throws is reported to `onError` and
|
|
69
|
+
the watch goes on.
|
|
70
|
+
- A message's size is asked of the server before it is downloaded: one over
|
|
71
|
+
`limits.maxBytes` is refused without being fetched. The answer is only a
|
|
72
|
+
claim, so the connection also refuses any message literal over
|
|
73
|
+
`maxBytes` (and caps every response) before buffering it: a server that
|
|
74
|
+
lies about the size, or leaves it out, is cut off with
|
|
75
|
+
`E_GOLDSTONE_MESSAGE_TOO_LARGE` instead of pushing a gigabyte into memory.
|
|
76
|
+
- The provider logs out of every mailbox on shutdown, so an IDLE connection
|
|
77
|
+
does not keep the process alive.
|
|
78
|
+
|
|
79
|
+
## Receiving inbound mail (Mailgun)
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { createMailgunInboundHandler } from "@c9up/goldstone"
|
|
83
|
+
|
|
84
|
+
router.post("/inbound/mailgun/mime", createMailgunInboundHandler({
|
|
85
|
+
signingKey: env.get("MAILGUN_SIGNING_KEY"),
|
|
86
|
+
async onMessage(message, { recipient, sender }) {
|
|
87
|
+
// parsed, checked (message.authenticity)
|
|
88
|
+
},
|
|
89
|
+
}))
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Point the Mailgun route's *forward* action at a URL ending in `mime`: that is
|
|
93
|
+
what makes Mailgun send the message as received.
|
|
94
|
+
|
|
95
|
+
The handler reads the fields from `request.body()`, which Ream fills only
|
|
96
|
+
when its body parser has read the request: the route must run behind
|
|
97
|
+
`BodyParserMiddleware` (registered globally in a scaffolded app). Mailgun
|
|
98
|
+
posts `multipart/form-data`, or urlencoded; raise both limits above the
|
|
99
|
+
largest message you accept (25 MiB by default; the parser's defaults are
|
|
100
|
+
1 MB for a form and 20 MB for multipart), in `config/bodyparser.ts` so the
|
|
101
|
+
server applies them too:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// config/bodyparser.ts
|
|
105
|
+
export default {
|
|
106
|
+
form: { limit: "30mb" },
|
|
107
|
+
multipart: { limit: "30mb" },
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Without the parser, every delivery is answered 401, its fields missing.
|
|
112
|
+
|
|
113
|
+
Mailgun signs a timestamp and a token — not the message. So the handler
|
|
114
|
+
refuses a timestamp older than two minutes (`maxAgeSeconds`, an integer
|
|
115
|
+
from 1 to 86 400 — anything else is refused when the handler is created,
|
|
116
|
+
since a `NaN` read from an unset variable would turn the check off) and
|
|
117
|
+
handles each token once,
|
|
118
|
+
claimed before handling so two racing requests cannot both get through; a
|
|
119
|
+
delivery whose handler throws gives its token back, and Mailgun's retry is
|
|
120
|
+
handled. Fields are read from the request body only, never the query string.
|
|
121
|
+
The tokens live in this process's memory; with several instances, pass a
|
|
122
|
+
shared `replayGuard`. A message the parser refuses is answered 406, which
|
|
123
|
+
stops Mailgun from retrying it.
|
|
124
|
+
|
|
125
|
+
## Parsing a received message
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { parseMessage } from "@c9up/goldstone"
|
|
129
|
+
|
|
130
|
+
const message = await parseMessage(raw) // raw RFC 5322 bytes
|
|
131
|
+
|
|
132
|
+
message.subject
|
|
133
|
+
message.from // [{ name, address }]
|
|
134
|
+
message.date // Date
|
|
135
|
+
message.text
|
|
136
|
+
message.html // sanitized: safe to render, loads nothing remote
|
|
137
|
+
message.attachments // [{ fileName, originalName, contentType, size, content, … }]
|
|
138
|
+
message.header("List-Unsubscribe")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Parsing runs in Rust ([mail-parser](https://github.com/stalwartlabs/mail-parser)),
|
|
142
|
+
in one call and off the JavaScript thread: the bytes go in, the message comes
|
|
143
|
+
out, and the event loop keeps serving requests meanwhile. A received mail is
|
|
144
|
+
input from whoever sent it, so:
|
|
145
|
+
|
|
146
|
+
- **the size is checked before a byte is parsed**, then the structure is
|
|
147
|
+
bounded — parts, nesting depth, header fields and header size, number and
|
|
148
|
+
size of attachments. Parts and nesting count through the messages a message
|
|
149
|
+
carries (message/rfc822), encoded or not. Parts and headers are bounded
|
|
150
|
+
before the parse too: the message is first walked the way the parser walks
|
|
151
|
+
it, keeping only counts, so a message made of millions of tiny parts or
|
|
152
|
+
headers is refused before it costs any memory.
|
|
153
|
+
Each limit has a default and can be set per call
|
|
154
|
+
(`parseMessage(raw, { limits: { maxBytes } })`); over one, a `GoldstoneError` names it
|
|
155
|
+
(`E_GOLDSTONE_MESSAGE_TOO_LARGE`, `…_TOO_MANY_PARTS`, `…_TOO_DEEP`,
|
|
156
|
+
`…_TOO_MANY_HEADERS`, `…_HEADERS_TOO_LARGE`, `…_TOO_MANY_ATTACHMENTS`,
|
|
157
|
+
`…_ATTACHMENT_TOO_LARGE`). A part starts only at a delimiter of the
|
|
158
|
+
boundary its multipart declared, or after the header of a carried message:
|
|
159
|
+
a `-- ` signature, a pasted log or a large attachment is body, whatever it
|
|
160
|
+
says. The search for those delimiters is bounded too: a multipart whose
|
|
161
|
+
boundary never occurs is searched for up to the end of the message, so
|
|
162
|
+
past four such scans of a `maxBytes` message the message is refused
|
|
163
|
+
(`E_GOLDSTONE_MISSING_BOUNDARIES`), and so is a boundary over 256 bytes
|
|
164
|
+
(`E_GOLDSTONE_BOUNDARY_TOO_LONG`; RFC 2046 allows 70);
|
|
165
|
+
- **`html` is sanitized** — no script, no event handler, no `javascript:` URL,
|
|
166
|
+
and nothing a browser would load by itself: remote images, backgrounds and
|
|
167
|
+
posters are removed, because loading one tells the sender the message was
|
|
168
|
+
opened, when and from where. `cid:` references to the message's own parts
|
|
169
|
+
stay, and so do links, which are only followed when clicked. An application
|
|
170
|
+
that shows remote content on purpose passes `remoteContent: "allow"` (to
|
|
171
|
+
`parseMessage`, `readMessage`, the inbox config or the Mailgun handler).
|
|
172
|
+
The HTML as received is only reachable through `unsanitizedHtml()`, for what
|
|
173
|
+
has to see the original; never render it;
|
|
174
|
+
- **the HTML is bounded before it is sanitized**: over `maxHtmlBytes`
|
|
175
|
+
(512 KiB by default), nested deeper than 1 000 elements, or building a tree
|
|
176
|
+
out of proportion to its markup, it is not sanitized at all — the HTML
|
|
177
|
+
standard's tree building costs more than linear time on such markup.
|
|
178
|
+
`html` is then absent and `htmlError` says why
|
|
179
|
+
(`E_GOLDSTONE_HTML_TOO_LARGE`, `…_HTML_TOO_DEEP`, `…_HTML_TOO_COMPLEX`);
|
|
180
|
+
the message, its text and its attachments are read as usual;
|
|
181
|
+
- **`fileName` is safe to save under**, on any system: the last path segment
|
|
182
|
+
of what the sender wrote, with control, invisible and bidi characters and
|
|
183
|
+
the characters Windows refuses (`< > : " | ? *` — a `:` would write into an
|
|
184
|
+
NTFS stream) replaced by `_`, without leading dots, trailing dots or
|
|
185
|
+
spaces, or a Windows device name (`CON`, `COM1`, `CONIN$`…). A right-to-left
|
|
186
|
+
override cannot dress `fdp.exe` up as `exe.pdf`. What the sender wrote stays in `originalName`, for display only.
|
|
187
|
+
|
|
188
|
+
The engine is a native binary, prebuilt for Linux, macOS and Windows; there is
|
|
189
|
+
no JavaScript fallback.
|
|
190
|
+
|
|
191
|
+
## Who sent it
|
|
192
|
+
|
|
193
|
+
The From header is whatever the sender typed. A message read from a mailbox
|
|
194
|
+
carries the verdict on `message.authenticity`; for raw bytes, `readMessage()`
|
|
195
|
+
parses and checks, `authenticate()` only checks:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { authenticate, readMessage } from "@c9up/goldstone"
|
|
199
|
+
|
|
200
|
+
const message = await readMessage(raw)
|
|
201
|
+
message.authenticity.summary // "pass" | "fail" | "none" | "temperror"
|
|
202
|
+
message.authenticity.dkim // [{ result, domain, selector, reason }]
|
|
203
|
+
message.authenticity.dmarc // and dmarcPolicy, dmarcDomain, spf, arc
|
|
204
|
+
|
|
205
|
+
// SPF needs to know who delivered the message — a webhook usually says:
|
|
206
|
+
await authenticate(raw, { ip: "203.0.113.7", helo: "mx.sender.test", mailFrom: "bounces@sender.test" })
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- `summary` is `pass` only when the From domain's DMARC passes, through a
|
|
210
|
+
DKIM signature or an SPF result aligned with that domain. A message signed
|
|
211
|
+
by another domain than its From does not pass.
|
|
212
|
+
- The verdict and the message come from one parse, and a pass speaks for the
|
|
213
|
+
From the message shows: exactly one From header, holding exactly one
|
|
214
|
+
address, whose domain is `authenticity.fromDomain`. A repeated From, a From
|
|
215
|
+
with several addresses, or a header allowed once (Subject, Date, To,
|
|
216
|
+
Message-ID…) that appears twice is a `fail`: an unsigned copy added to a
|
|
217
|
+
signed message is how a phisher rides on someone else's signature.
|
|
218
|
+
- Only the address's domain is checked, never the display name:
|
|
219
|
+
`"ceo@bank.test" <a@evil.test>` can pass — for `evil.test`. Show
|
|
220
|
+
`fromDomain`, or the address, next to the verdict.
|
|
221
|
+
- A repeated header is read from its first copy everywhere: `message.from`,
|
|
222
|
+
`message.subject` and `message.header("from")` agree, and every copy stays
|
|
223
|
+
in `message.headers`.
|
|
224
|
+
- A DKIM signature made with rsa-sha1 (RFC 8301), or whose `h=` leaves out
|
|
225
|
+
From (RFC 6376 §6.1.1), is a `permerror` whatever its cryptography says,
|
|
226
|
+
and DMARC cannot pass on it: SHA-1 is broken, and a From the signature does
|
|
227
|
+
not cover can be rewritten at will.
|
|
228
|
+
- A check that cannot complete — DNS down, too slow — is `temperror`,
|
|
229
|
+
never a pass. The whole check has a deadline (10 s by default), at most 5
|
|
230
|
+
DKIM signatures are verified (each is a lookup; the refused ones above are
|
|
231
|
+
not counted), and DNS answers are cached.
|
|
232
|
+
- Read over IMAP, a message usually does not carry who delivered it
|
|
233
|
+
reliably, so SPF is `none` there and DMARC rests on DKIM.
|
|
234
|
+
- Turn it off with `authenticate: false` in `config/inbox.ts`, or
|
|
235
|
+
`readMessage(raw, { authenticate: false })`; `parseMessage()` never checks.
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires `config/inbox.ts` into the container as `'inbox'`, and seats the
|
|
3
|
+
* manager on `@c9up/goldstone/services/main`.
|
|
4
|
+
*
|
|
5
|
+
* // reamrc.ts
|
|
6
|
+
* providers: [() => import('@c9up/goldstone/provider')]
|
|
7
|
+
*
|
|
8
|
+
* The host is duck-typed — this package stays publishable without importing
|
|
9
|
+
* `@c9up/ream`. `register` opens no connection: mailboxes open on first use.
|
|
10
|
+
*/
|
|
11
|
+
import "./augmentations.js";
|
|
12
|
+
interface GoldstoneContainer {
|
|
13
|
+
singleton(token: unknown, factory: () => unknown): void;
|
|
14
|
+
}
|
|
15
|
+
interface GoldstoneConfigStore {
|
|
16
|
+
get(key: string): unknown;
|
|
17
|
+
}
|
|
18
|
+
export interface GoldstoneAppContext {
|
|
19
|
+
container: GoldstoneContainer;
|
|
20
|
+
config: GoldstoneConfigStore;
|
|
21
|
+
}
|
|
22
|
+
export default class GoldstoneProvider {
|
|
23
|
+
#private;
|
|
24
|
+
constructor(app: GoldstoneAppContext);
|
|
25
|
+
register(): void;
|
|
26
|
+
/**
|
|
27
|
+
* Log out of every mailbox and stop every watch. Without it, an IDLE
|
|
28
|
+
* connection keeps the process alive and the server looks hung on stop.
|
|
29
|
+
*/
|
|
30
|
+
shutdown(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export {};
|
|
33
|
+
//# sourceMappingURL=GoldstoneProvider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"GoldstoneProvider.d.ts","sourceRoot":"","sources":["../src/GoldstoneProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,oBAAoB,CAAC;AAK5B,UAAU,kBAAkB;IAC3B,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,OAAO,GAAG,IAAI,CAAC;CACxD;AACD,UAAU,oBAAoB;IAC7B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AACD,MAAM,WAAW,mBAAmB;IACnC,SAAS,EAAE,kBAAkB,CAAC;IAC9B,MAAM,EAAE,oBAAoB,CAAC;CAC7B;AAcD,MAAM,CAAC,OAAO,OAAO,iBAAiB;;gBAIzB,GAAG,EAAE,mBAAmB;IAIpC,QAAQ,IAAI,IAAI;IAgBhB;;;OAGG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAO/B"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires `config/inbox.ts` into the container as `'inbox'`, and seats the
|
|
3
|
+
* manager on `@c9up/goldstone/services/main`.
|
|
4
|
+
*
|
|
5
|
+
* // reamrc.ts
|
|
6
|
+
* providers: [() => import('@c9up/goldstone/provider')]
|
|
7
|
+
*
|
|
8
|
+
* The host is duck-typed — this package stays publishable without importing
|
|
9
|
+
* `@c9up/ream`. `register` opens no connection: mailboxes open on first use.
|
|
10
|
+
*/
|
|
11
|
+
import "./augmentations.js";
|
|
12
|
+
import { InboxManager } from "./InboxManager.js";
|
|
13
|
+
import { clearInbox, setInbox } from "./services/main.js";
|
|
14
|
+
function isInboxConfig(value) {
|
|
15
|
+
if (typeof value !== "object" || value === null)
|
|
16
|
+
return false;
|
|
17
|
+
const mailboxes = Reflect.get(value, "mailboxes");
|
|
18
|
+
return (typeof Reflect.get(value, "mailbox") === "string" &&
|
|
19
|
+
typeof mailboxes === "object" &&
|
|
20
|
+
mailboxes !== null);
|
|
21
|
+
}
|
|
22
|
+
export default class GoldstoneProvider {
|
|
23
|
+
#app;
|
|
24
|
+
#manager;
|
|
25
|
+
constructor(app) {
|
|
26
|
+
this.#app = app;
|
|
27
|
+
}
|
|
28
|
+
register() {
|
|
29
|
+
const raw = this.#app.config.get("inbox");
|
|
30
|
+
if (raw === undefined)
|
|
31
|
+
throw new Error("[inbox] missing config/inbox.ts");
|
|
32
|
+
if (!isInboxConfig(raw)) {
|
|
33
|
+
throw new Error("[inbox] config/inbox.ts must export defineConfig({ mailbox, mailboxes }).");
|
|
34
|
+
}
|
|
35
|
+
const manager = new InboxManager(raw);
|
|
36
|
+
this.#manager = manager;
|
|
37
|
+
setInbox(manager);
|
|
38
|
+
this.#app.container.singleton(InboxManager, () => manager);
|
|
39
|
+
this.#app.container.singleton("goldstone.inbox", () => manager);
|
|
40
|
+
this.#app.container.singleton("inbox", () => manager);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Log out of every mailbox and stop every watch. Without it, an IDLE
|
|
44
|
+
* connection keeps the process alive and the server looks hung on stop.
|
|
45
|
+
*/
|
|
46
|
+
async shutdown() {
|
|
47
|
+
const manager = this.#manager;
|
|
48
|
+
if (!manager)
|
|
49
|
+
return;
|
|
50
|
+
await manager.closeAll();
|
|
51
|
+
clearInbox(manager);
|
|
52
|
+
this.#manager = undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=GoldstoneProvider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"GoldstoneProvider.js","sourceRoot":"","sources":["../src/GoldstoneProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,oBAAoB,CAAC;AAE5B,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAa1D,SAAS,aAAa,CACrB,KAAc;IAEd,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAC9D,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC;IAClD,OAAO,CACN,OAAO,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,SAAS,CAAC,KAAK,QAAQ;QACjD,OAAO,SAAS,KAAK,QAAQ;QAC7B,SAAS,KAAK,IAAI,CAClB,CAAC;AACH,CAAC;AAED,MAAM,CAAC,OAAO,OAAO,iBAAiB;IAC5B,IAAI,CAAsB;IACnC,QAAQ,CAA2B;IAEnC,YAAY,GAAwB;QACnC,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC;IACjB,CAAC;IAED,QAAQ;QACP,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAC1C,IAAI,GAAG,KAAK,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;QAC1E,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,EAAE,CAAC;YACzB,MAAM,IAAI,KAAK,CACd,2EAA2E,CAC3E,CAAC;QACH,CAAC;QACD,MAAM,OAAO,GAAG,IAAI,YAAY,CAAC,GAAG,CAAC,CAAC;QACtC,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;QACxB,QAAQ,CAAC,OAAO,CAAC,CAAC;QAClB,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,YAAY,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,CAAC;QAC3D,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,iBAAiB,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,CAAC;QAChE,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,CAAC;IACvD,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,QAAQ;QACb,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC;QAC9B,IAAI,CAAC,OAAO;YAAE,OAAO;QACrB,MAAM,OAAO,CAAC,QAAQ,EAAE,CAAC;QACzB,UAAU,CAAC,OAAO,CAAC,CAAC;QACpB,IAAI,CAAC,QAAQ,GAAG,SAAS,CAAC;IAC3B,CAAC;CACD"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The configured mailboxes, each opened on first use.
|
|
3
|
+
*
|
|
4
|
+
* inbox.mailbox() // the default one
|
|
5
|
+
* inbox.mailbox('billing')
|
|
6
|
+
*/
|
|
7
|
+
import type { InboxConfig, MailboxConfig } from "./config.js";
|
|
8
|
+
import { Mailbox } from "./Mailbox.js";
|
|
9
|
+
export declare class InboxManager<Mailboxes extends Record<string, MailboxConfig> = Record<string, MailboxConfig>> {
|
|
10
|
+
#private;
|
|
11
|
+
constructor(config: InboxConfig<Mailboxes>);
|
|
12
|
+
/** A mailbox by name; the default one without. Opens nothing yet. */
|
|
13
|
+
mailbox(name?: keyof Mailboxes & string): Mailbox;
|
|
14
|
+
/** Stop every watch and log out of every mailbox. */
|
|
15
|
+
closeAll(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=InboxManager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"InboxManager.d.ts","sourceRoot":"","sources":["../src/InboxManager.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE9D,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,qBAAa,YAAY,CACxB,SAAS,SAAS,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,GAAG,MAAM,CACvD,MAAM,EACN,aAAa,CACb;;gBAKW,MAAM,EAAE,WAAW,CAAC,SAAS,CAAC;IAI1C,qEAAqE;IACrE,OAAO,CAAC,IAAI,GAAE,MAAM,SAAS,GAAG,MAA6B,GAAG,OAAO;IAmBvE,qDAAqD;IAC/C,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAK/B"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The configured mailboxes, each opened on first use.
|
|
3
|
+
*
|
|
4
|
+
* inbox.mailbox() // the default one
|
|
5
|
+
* inbox.mailbox('billing')
|
|
6
|
+
*/
|
|
7
|
+
import { GoldstoneError } from "./errors.js";
|
|
8
|
+
import { Mailbox } from "./Mailbox.js";
|
|
9
|
+
export class InboxManager {
|
|
10
|
+
#config;
|
|
11
|
+
#open = new Map();
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.#config = config;
|
|
14
|
+
}
|
|
15
|
+
/** A mailbox by name; the default one without. Opens nothing yet. */
|
|
16
|
+
mailbox(name = this.#config.mailbox) {
|
|
17
|
+
const existing = this.#open.get(name);
|
|
18
|
+
if (existing)
|
|
19
|
+
return existing;
|
|
20
|
+
const config = this.#config.mailboxes[name];
|
|
21
|
+
if (config === undefined) {
|
|
22
|
+
throw new GoldstoneError("E_GOLDSTONE_UNKNOWN_MAILBOX", `[inbox] no mailbox "${name}" — got ${Object.keys(this.#config.mailboxes).join(", ")}`);
|
|
23
|
+
}
|
|
24
|
+
const mailbox = new Mailbox(name, config, {
|
|
25
|
+
limits: this.#config.limits,
|
|
26
|
+
authenticate: this.#config.authenticate,
|
|
27
|
+
remoteContent: this.#config.remoteContent,
|
|
28
|
+
});
|
|
29
|
+
this.#open.set(name, mailbox);
|
|
30
|
+
return mailbox;
|
|
31
|
+
}
|
|
32
|
+
/** Stop every watch and log out of every mailbox. */
|
|
33
|
+
async closeAll() {
|
|
34
|
+
const mailboxes = [...this.#open.values()];
|
|
35
|
+
this.#open.clear();
|
|
36
|
+
await Promise.all(mailboxes.map((mailbox) => mailbox.close()));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=InboxManager.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"InboxManager.js","sourceRoot":"","sources":["../src/InboxManager.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,MAAM,OAAO,YAAY;IAMf,OAAO,CAAyB;IAChC,KAAK,GAAG,IAAI,GAAG,EAAmB,CAAC;IAE5C,YAAY,MAA8B;QACzC,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;IACvB,CAAC;IAED,qEAAqE;IACrE,OAAO,CAAC,OAAiC,IAAI,CAAC,OAAO,CAAC,OAAO;QAC5D,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACtC,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAC;QAC9B,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QAC5C,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YAC1B,MAAM,IAAI,cAAc,CACvB,6BAA6B,EAC7B,uBAAuB,IAAI,WAAW,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CACtF,CAAC;QACH,CAAC;QACD,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE;YACzC,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,MAAM;YAC3B,YAAY,EAAE,IAAI,CAAC,OAAO,CAAC,YAAY;YACvC,aAAa,EAAE,IAAI,CAAC,OAAO,CAAC,aAAa;SACzC,CAAC,CAAC;QACH,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAC9B,OAAO,OAAO,CAAC;IAChB,CAAC;IAED,qDAAqD;IACrD,KAAK,CAAC,QAAQ;QACb,MAAM,SAAS,GAAG,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;QAC3C,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;QACnB,MAAM,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAChE,CAAC;CACD"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One IMAP mailbox: its connection, opened on first use, and what the
|
|
3
|
+
* application does with it.
|
|
4
|
+
*
|
|
5
|
+
* const support = inbox.mailbox('support')
|
|
6
|
+
* for (const uid of await support.search({ seen: false })) {
|
|
7
|
+
* const message = await support.fetch(uid)
|
|
8
|
+
* }
|
|
9
|
+
* const stop = support.watch(async (message, { uid }) => { … })
|
|
10
|
+
*
|
|
11
|
+
* Every message read goes through {@link readMessage}: its size is asked of
|
|
12
|
+
* the server first, so one over the limit is refused without being
|
|
13
|
+
* downloaded; it is then parsed, and its sender checked (DKIM, SPF, DMARC)
|
|
14
|
+
* unless `authenticate: false`.
|
|
15
|
+
*/
|
|
16
|
+
import { type SearchObject } from "imapflow";
|
|
17
|
+
import { type MailboxConfig } from "./config.js";
|
|
18
|
+
import { type Limits, type ParseOptions, type ReceivedMessage } from "./ReceivedMessage.js";
|
|
19
|
+
export type SearchCriteria = SearchObject;
|
|
20
|
+
export interface WatchOptions {
|
|
21
|
+
/** Default `INBOX`. */
|
|
22
|
+
folder?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Deliver messages after this UID. Default: none of those already there —
|
|
25
|
+
* only what arrives once watching started.
|
|
26
|
+
*/
|
|
27
|
+
afterUid?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Told about what does not stop the watch: a lost connection (it
|
|
30
|
+
* reconnects), a message refused by the limits, a handler that threw, a
|
|
31
|
+
* mailbox whose UIDs were reset. Default: `console.error`.
|
|
32
|
+
*/
|
|
33
|
+
onError?: (error: unknown) => void;
|
|
34
|
+
}
|
|
35
|
+
/** Wait before reconnect attempt `n`: 0.5 s, 1 s, 2 s… up to 30 s. */
|
|
36
|
+
export declare function reconnectDelay(attempt: number): number;
|
|
37
|
+
export interface MailboxOptions {
|
|
38
|
+
/** Bounds on every message read. */
|
|
39
|
+
limits?: Limits;
|
|
40
|
+
/**
|
|
41
|
+
* Check DKIM, SPF and DMARC on every message read — the verdict lands on
|
|
42
|
+
* `message.authenticity`. Default `true`: it costs DNS lookups (cached),
|
|
43
|
+
* and a From nobody checked is the From a phisher wrote.
|
|
44
|
+
*/
|
|
45
|
+
authenticate?: boolean;
|
|
46
|
+
/** Remote content in `message.html`; see {@link ParseOptions}. Default `"block"`. */
|
|
47
|
+
remoteContent?: ParseOptions["remoteContent"];
|
|
48
|
+
}
|
|
49
|
+
export declare class Mailbox {
|
|
50
|
+
#private;
|
|
51
|
+
readonly name: string;
|
|
52
|
+
constructor(name: string, config: MailboxConfig, options?: MailboxOptions);
|
|
53
|
+
/** Every folder's path, as the server names it. */
|
|
54
|
+
folders(): Promise<string[]>;
|
|
55
|
+
/** UIDs of the messages that match, in `folder` (default `INBOX`). */
|
|
56
|
+
search(criteria: SearchCriteria, folder?: string): Promise<number[]>;
|
|
57
|
+
/**
|
|
58
|
+
* One message, parsed — or `undefined` when no message has that UID.
|
|
59
|
+
* Refused with `E_GOLDSTONE_MESSAGE_TOO_LARGE` before download when the
|
|
60
|
+
* server says it is over the limit.
|
|
61
|
+
*/
|
|
62
|
+
fetch(uid: number, folder?: string): Promise<ReceivedMessage | undefined>;
|
|
63
|
+
markSeen(uids: number | number[], folder?: string): Promise<void>;
|
|
64
|
+
markUnseen(uids: number | number[], folder?: string): Promise<void>;
|
|
65
|
+
move(uids: number | number[], to: string, folder?: string): Promise<void>;
|
|
66
|
+
delete(uids: number | number[], folder?: string): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Call `handler` for every new message, as it arrives (IDLE), on a
|
|
69
|
+
* connection of its own. A lost connection is reopened with a growing
|
|
70
|
+
* wait, and what arrived meanwhile is delivered on return: messages are
|
|
71
|
+
* followed by UID, so none is skipped and none delivered twice.
|
|
72
|
+
*
|
|
73
|
+
* A handler that throws does not stop the watch, and the message is not
|
|
74
|
+
* offered again — a message that always fails would otherwise stall the
|
|
75
|
+
* mailbox. Resolves the returned function once the watch has stopped.
|
|
76
|
+
*/
|
|
77
|
+
watch(handler: (message: ReceivedMessage, meta: {
|
|
78
|
+
uid: number;
|
|
79
|
+
}) => unknown, options?: WatchOptions): () => Promise<void>;
|
|
80
|
+
/** Stop every watch and log out. */
|
|
81
|
+
close(): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=Mailbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Mailbox.d.ts","sourceRoot":"","sources":["../src/Mailbox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAY,KAAK,YAAY,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,EAAgB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAE/D,OAAO,EACN,KAAK,MAAM,EACX,KAAK,YAAY,EACjB,KAAK,eAAe,EAEpB,MAAM,sBAAsB,CAAC;AAE9B,MAAM,MAAM,cAAc,GAAG,YAAY,CAAC;AAE1C,MAAM,WAAW,YAAY;IAC5B,uBAAuB;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACnC;AAED,sEAAsE;AACtE,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEtD;AAyBD,MAAM,WAAW,cAAc;IAC9B,oCAAoC;IACpC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,qFAAqF;IACrF,aAAa,CAAC,EAAE,YAAY,CAAC,eAAe,CAAC,CAAC;CAC9C;AAED,qBAAa,OAAO;;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBAUrB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,aAAa,EACrB,OAAO,GAAE,cAAmB;IAU7B,mDAAmD;IAC7C,OAAO,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAKlC,sEAAsE;IAChE,MAAM,CAAC,QAAQ,EAAE,cAAc,EAAE,MAAM,SAAU,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAO3E;;;;OAIG;IACG,KAAK,CACV,GAAG,EAAE,MAAM,EACX,MAAM,SAAU,GACd,OAAO,CAAC,eAAe,GAAG,SAAS,CAAC;IAIjC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,MAAM,SAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAOlE,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,MAAM,SAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAOpE,IAAI,CACT,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,EACvB,EAAE,EAAE,MAAM,EACV,MAAM,SAAU,GACd,OAAO,CAAC,IAAI,CAAC;IAOV,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,MAAM,SAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAOtE;;;;;;;;;OASG;IACH,KAAK,CACJ,OAAO,EAAE,CAAC,OAAO,EAAE,eAAe,EAAE,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,EACrE,OAAO,GAAE,YAAiB,GACxB,MAAM,OAAO,CAAC,IAAI,CAAC;IA8GtB,oCAAoC;IAC9B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAqI5B"}
|