@dsbot-channel/mail 0.0.0-stage → 0.7.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zerodegress
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.
@@ -0,0 +1,21 @@
1
+ ### mail transport
2
+
3
+ | Field | Default | Meaning |
4
+ | --- | --- | --- |
5
+ | `imap.host` / `imap.port` / `imap.user` / `imap.password` | required | the mailbox the link reads; `password` is a credential reference |
6
+ | `imap.secure` | `true` | TLS to the mailbox; `false` reads plaintext |
7
+ | `imap.mailbox` | `INBOX` | folder to poll |
8
+ | `imap.pollMs` | `30000` | how often the folder is read |
9
+ | `smtp.host` / `smtp.port` / `smtp.user` / `smtp.password` | required | where an answer is sent; `password` is a credential reference |
10
+ | `smtp.secure` | `true` | `true`, `false` or `starttls` |
11
+ | `from` | required | the address answers are sent from |
12
+ | `signature` | off | text appended after the answer |
13
+ | `listUnsubscribe` | off | `List-Unsubscribe` value on an answer |
14
+ | `bots` | `[]` | senders counted as bot speech |
15
+ | `lists` | `[]` | addresses that count as lists when the headers do not name one |
16
+ | `maxAttachmentBytes` | `10485760` | largest attachment recorded; larger ones are dropped with a warning |
17
+
18
+ A thread is a room keyed by its root: `mail:<thread-root>`; the root is the first `References` entry, else `In-Reply-To`, else the message's own `Message-ID`. An answer carries `In-Reply-To` and the whole `References` chain; a list message answers the list, a direct message answers its sender. HTML is read as text with scripts, styles and images dropped; an attachment is not passed to the model, only its name, type and size are recorded. The IMAP and SMTP links run through `imapflow` and `nodemailer`; the fields do not depend on them.
19
+
20
+ `@dsbot-channel/mail` supplies this transport: mount it after `@dsbot-channel/core`, then write these fields under `transport` in this plugin's own `config`, beside `channel` and `botName`; `kind` is not written. Each entry is one instance, so the same package mounts twice with two different `channel` values. Without `@dsbot-channel/core` the plugin stays pending on `dsbot-channel.registry`.
21
+
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsbot-channel-mail
3
+ name: '@dsbot-channel/mail'
@@ -0,0 +1,72 @@
1
+ import type { AdapterStatus, CapabilitySet } from '@dsbot-channel/core/adapter-status.js';
2
+ import type { AdapterHandle, AdapterPorts, ChannelAdapter, SendInput, SendResult } from '@dsbot-channel/core/contract.js';
3
+ import type { MailTransport } from './transport.js';
4
+ export interface MailConfig {
5
+ channel: string;
6
+ botName: string;
7
+ from: string;
8
+ imap: {
9
+ host: string;
10
+ port: number;
11
+ secure: boolean;
12
+ user: string;
13
+ password: string;
14
+ mailbox: string;
15
+ pollMs: number;
16
+ };
17
+ smtp: {
18
+ host: string;
19
+ port: number;
20
+ secure: boolean | 'starttls';
21
+ user: string;
22
+ password: string;
23
+ };
24
+ signature?: string | undefined;
25
+ listUnsubscribe?: string | undefined;
26
+ bots: readonly string[];
27
+ lists: readonly string[];
28
+ maxAttachmentBytes?: number | undefined;
29
+ }
30
+ export interface MailOptions {
31
+ warn?: ((message: string) => void) | undefined;
32
+ now?: (() => number) | undefined;
33
+ transport?: MailTransport | undefined;
34
+ }
35
+ interface Origin {
36
+ subject: string;
37
+ messageId: string;
38
+ references: string;
39
+ }
40
+ /** A reply is plain text with the headers that hold the thread together. */
41
+ export declare function composeReply(input: {
42
+ from: string;
43
+ to: string;
44
+ subject: string;
45
+ messageId: string;
46
+ origin?: Origin | undefined;
47
+ text: string;
48
+ signature?: string | undefined;
49
+ listUnsubscribe?: string | undefined;
50
+ date: Date;
51
+ }): {
52
+ data: string;
53
+ messageId: string;
54
+ };
55
+ export declare class MailAdapter implements ChannelAdapter {
56
+ #private;
57
+ readonly kind = "mail";
58
+ readonly capabilities: CapabilitySet;
59
+ readonly guidance: {
60
+ peer: string;
61
+ group: string;
62
+ };
63
+ constructor(config: MailConfig, options?: MailOptions);
64
+ checkConfig(): void;
65
+ checkSessionKey(key: string): string | undefined;
66
+ checkTarget(target: string): string | undefined;
67
+ describeTargets(): string;
68
+ status(): AdapterStatus;
69
+ start(ports: AdapterPorts): Promise<AdapterHandle>;
70
+ send(input: SendInput): Promise<SendResult>;
71
+ }
72
+ export {};
@@ -0,0 +1,244 @@
1
+ import { NO_MEDIA, buildListenerHealth, buildStatus } from '@dsbot-channel/core/adapter-status.js';
2
+ import { ConfigError } from '@dsbot-channel/core/config.js';
3
+ import { ChannelStats, countersOf } from '@dsbot-channel/core/channel-stats.js';
4
+ import { parseMail } from './mime.js';
5
+ import { mapMail, newMessageId, replyHeadersOf } from './mapping.js';
6
+ import { ImapSmtpTransport } from './transport.js';
7
+ const ORIGINS_MAX = 200;
8
+ const DELIVERED_MAX = 1024;
9
+ const CAPABILITIES = {
10
+ text: true,
11
+ image: NO_MEDIA,
12
+ file: NO_MEDIA,
13
+ voice: NO_MEDIA,
14
+ recall: false,
15
+ history: { group: false, private: false },
16
+ mention: false,
17
+ reply: false,
18
+ stream: false,
19
+ outbound: true,
20
+ };
21
+ /** A reply is plain text with the headers that hold the thread together. */
22
+ export function composeReply(input) {
23
+ const headers = [
24
+ `From: ${input.from}`,
25
+ `To: ${input.to}`,
26
+ `Subject: ${input.subject}`,
27
+ `Date: ${input.date.toUTCString()}`,
28
+ `Message-ID: ${input.messageId}`,
29
+ ...(input.origin === undefined ? [] : [`In-Reply-To: <${input.origin.messageId}>`, `References: ${input.origin.references}`]),
30
+ 'MIME-Version: 1.0',
31
+ 'Content-Type: text/plain; charset=utf-8',
32
+ 'Content-Transfer-Encoding: 8bit',
33
+ ...(input.listUnsubscribe === undefined ? [] : [`List-Unsubscribe: <${input.listUnsubscribe}>`]),
34
+ ];
35
+ const body = input.signature === undefined ? input.text : `${input.text}\n\n-- \n${input.signature}`;
36
+ return { data: `${headers.join('\r\n')}\r\n\r\n${body.replace(/\r?\n/g, '\r\n')}\r\n`, messageId: input.messageId };
37
+ }
38
+ export class MailAdapter {
39
+ kind = 'mail';
40
+ capabilities = CAPABILITIES;
41
+ guidance = {
42
+ peer: 'Mail is a letter: answer in plain text, keep it short, and never quote a whole thread.',
43
+ group: 'A mailing list is a room: answer in plain text to the list, and do not answer your own mail.',
44
+ };
45
+ #config;
46
+ #warn;
47
+ #now;
48
+ #stats = new ChannelStats();
49
+ #origins = new Map();
50
+ #delivered = new Map();
51
+ #transport;
52
+ #cursor;
53
+ #timer;
54
+ #polling = false;
55
+ #stopped = false;
56
+ #lastPollAt = null;
57
+ constructor(config, options = {}) {
58
+ this.#config = config;
59
+ this.#warn = options.warn ?? (() => undefined);
60
+ this.#now = options.now ?? Date.now;
61
+ this.#transport = options.transport;
62
+ }
63
+ checkConfig() {
64
+ if (this.#config.from.length === 0)
65
+ this.#fail('transport.from is required', ['transport', 'from']);
66
+ if (this.#config.imap.pollMs < 1000)
67
+ this.#fail('transport.imap.pollMs must be at least 1000', ['transport', 'imap', 'pollMs']);
68
+ }
69
+ checkSessionKey(key) {
70
+ if (!key.startsWith('mail:') || key.length <= 'mail:'.length)
71
+ return 'a mail session key reads "mail:<thread-root>"';
72
+ return undefined;
73
+ }
74
+ checkTarget(target) {
75
+ return /^[^@\s]+@[^@\s]+$/.test(target) ? undefined : 'a mail reply target is one address';
76
+ }
77
+ describeTargets() {
78
+ return '<user@host>';
79
+ }
80
+ status() {
81
+ const snapshot = this.#stats.snapshot();
82
+ const counters = countersOf(snapshot);
83
+ return buildStatus({
84
+ channel: this.#config.channel,
85
+ account: this.#config.imap.user,
86
+ bot: this.#config.botName,
87
+ health: buildListenerHealth({
88
+ listenState: this.#stopped ? 'stopped' : this.#lastPollAt === null ? 'stopped' : 'listening',
89
+ boundPort: null,
90
+ lastAuthenticatedAt: snapshot.lastAuthenticatedAt,
91
+ queueDepth: 0,
92
+ }),
93
+ counters,
94
+ capabilities: this.capabilities,
95
+ attributes: { stableMessageId: true },
96
+ quota: { softLimit: 0, hardLimit: 0 },
97
+ sampledAt: this.#now(),
98
+ queueDepth: 0,
99
+ });
100
+ }
101
+ async start(ports) {
102
+ const imapPassword = await ports.resolveSecret(this.#config.imap.password);
103
+ if (imapPassword === undefined)
104
+ this.#fail(`credential "${this.#config.imap.password}" did not resolve`, ['transport', 'imap', 'password']);
105
+ const smtpPassword = await ports.resolveSecret(this.#config.smtp.password);
106
+ if (smtpPassword === undefined)
107
+ this.#fail(`credential "${this.#config.smtp.password}" did not resolve`, ['transport', 'smtp', 'password']);
108
+ if (this.#transport === undefined) {
109
+ this.#transport = new ImapSmtpTransport({ ...this.#config.imap, password: imapPassword }, { ...this.#config.smtp, password: smtpPassword });
110
+ }
111
+ this.#timer = setInterval(() => {
112
+ void this.#poll(ports);
113
+ }, this.#config.imap.pollMs);
114
+ this.#timer.unref?.();
115
+ void this.#poll(ports);
116
+ return {
117
+ stop: async () => {
118
+ this.#stopped = true;
119
+ if (this.#timer !== undefined)
120
+ clearInterval(this.#timer);
121
+ this.#timer = undefined;
122
+ await this.#transport?.close();
123
+ this.#stats.closed();
124
+ },
125
+ };
126
+ }
127
+ async send(input) {
128
+ const transport = this.#transport;
129
+ if (transport === undefined)
130
+ return { ok: false, code: 'no_url', reason: 'the mail link is not running', retryable: false };
131
+ if (!/^[^@\s]+@[^@\s]+$/.test(input.target)) {
132
+ return { ok: false, code: 'no_url', reason: `"${input.target}" is not a mail address`, retryable: false };
133
+ }
134
+ const origin = this.#origins.get(input.inReplyTo ?? '');
135
+ const subject = origin === undefined ? `Re: ${input.body.split('\n')[0]?.slice(0, 60) ?? ''}` : `${origin.subject}`;
136
+ const data = composeReply({
137
+ from: this.#config.from,
138
+ to: input.target,
139
+ subject,
140
+ messageId: newMessageId(this.#config.from),
141
+ origin,
142
+ text: input.body,
143
+ signature: this.#config.signature,
144
+ listUnsubscribe: this.#config.listUnsubscribe,
145
+ date: new Date(this.#now()),
146
+ });
147
+ try {
148
+ await transport.send({ from: this.#config.from, to: [input.target] }, Buffer.from(data.data));
149
+ return { ok: true, messageId: data.messageId, duplicate: false };
150
+ }
151
+ catch (error) {
152
+ const code = error.code;
153
+ const mapped = code === 'EAUTH' || code === 'EAUTHENTICATION' ? 'credential' : code === 'ETIMEDOUT' ? 'timeout' : 'unreachable';
154
+ this.#stats.sendFailed(mapped);
155
+ return { ok: false, code: mapped, reason: String(error), retryable: mapped !== 'credential' };
156
+ }
157
+ }
158
+ async #poll(ports) {
159
+ if (this.#polling || this.#stopped || this.#transport === undefined)
160
+ return;
161
+ this.#polling = true;
162
+ try {
163
+ const mailbox = this.#config.imap.mailbox;
164
+ const batch = await this.#transport.fetch(mailbox, this.#cursor);
165
+ this.#lastPollAt = this.#now();
166
+ this.#stats.authenticated();
167
+ if (this.#cursor === undefined) {
168
+ this.#cursor = { uidValidity: batch.uidValidity, uid: batch.maxUid };
169
+ return;
170
+ }
171
+ for (const item of batch.messages) {
172
+ if (this.#stopped)
173
+ break;
174
+ let message = undefined;
175
+ let mail = undefined;
176
+ try {
177
+ mail = parseMail(item.raw, this.#config.maxAttachmentBytes);
178
+ message = mapMail({
179
+ mail,
180
+ config: { channel: this.#config.channel, from: this.#config.from, bots: this.#config.bots, lists: this.#config.lists },
181
+ receivedAt: this.#now(),
182
+ });
183
+ }
184
+ catch (error) {
185
+ this.#stats.rejected(`mail ${item.uid} could not be read: ${String(error)}`);
186
+ this.#warn(`dsbot-channel: mail ${item.uid} could not be read and was skipped: ${String(error)}`);
187
+ this.#cursor = { uidValidity: batch.uidValidity, uid: item.uid };
188
+ continue;
189
+ }
190
+ if (this.#delivered.has(message.requestId)) {
191
+ this.#cursor = { uidValidity: batch.uidValidity, uid: item.uid };
192
+ continue;
193
+ }
194
+ if (mail.attachments.length > 0) {
195
+ this.#warn(`dsbot-channel: mail ${message.requestId} carries ${mail.attachments.length} attachment(s) that are not delivered`);
196
+ }
197
+ this.#remember(message.requestId, mail);
198
+ this.#stats.event(`mail.${message.source.scope}`);
199
+ const verdict = await ports.onMessage(message);
200
+ if (!verdict.ok && verdict.code === 'busy') {
201
+ this.#warn(`dsbot-channel: mail ${message.requestId} was not taken while busy; it stays for the next poll`);
202
+ break;
203
+ }
204
+ this.#delivered.set(message.requestId, true);
205
+ while (this.#delivered.size > DELIVERED_MAX) {
206
+ const oldest = this.#delivered.keys().next();
207
+ if (oldest.done === true)
208
+ break;
209
+ this.#delivered.delete(oldest.value);
210
+ }
211
+ if (verdict.ok) {
212
+ await this.#transport.markSeen(mailbox, [item.uid]).catch((error) => {
213
+ this.#warn(`dsbot-channel: mail ${message.requestId} was taken but could not be marked seen: ${String(error)}`);
214
+ });
215
+ }
216
+ this.#cursor = { uidValidity: batch.uidValidity, uid: item.uid };
217
+ }
218
+ }
219
+ catch (error) {
220
+ this.#stats.rejected(`poll failed: ${String(error)}`);
221
+ this.#warn(`dsbot-channel: mail poll failed: ${String(error)}`);
222
+ }
223
+ finally {
224
+ this.#polling = false;
225
+ }
226
+ }
227
+ #remember(requestId, mail) {
228
+ const headers = replyHeadersOf(mail);
229
+ this.#origins.set(requestId, {
230
+ subject: headers.subject,
231
+ messageId: headers.inReplyTo ?? '',
232
+ references: headers.references ?? '',
233
+ });
234
+ while (this.#origins.size > ORIGINS_MAX) {
235
+ const oldest = this.#origins.keys().next();
236
+ if (oldest.done === true)
237
+ break;
238
+ this.#origins.delete(oldest.value);
239
+ }
240
+ }
241
+ #fail(message, path) {
242
+ throw new ConfigError(message, path);
243
+ }
244
+ }
@@ -0,0 +1,107 @@
1
+ import type { AdapterFactory, AdapterMount } from '@dsbot-channel/core/adapter-registry.js';
2
+ import { MailAdapter } from './adapter.js';
3
+ import type { MailConfig } from './adapter.js';
4
+ /** Reads the mail block; every named field is checked, so a typo fails at mount. */
5
+ export declare function parseMailConfig(mount: AdapterMount): MailConfig;
6
+ export declare const createMailAdapter: AdapterFactory;
7
+ export { MailAdapter };
8
+ export type { MailConfig };
9
+ export declare const Config: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
10
+ channel: import("@deepseek-ai/schemastery").default<string, string, "plain">;
11
+ botName: import("@deepseek-ai/schemastery").default<string, string, "plain">;
12
+ anchor: import("@deepseek-ai/schemastery").default<string, string, "plain">;
13
+ session: import("@deepseek-ai/schemastery").default<string, string, "plain">;
14
+ model: import("@deepseek-ai/schemastery").default<string, string, "plain">;
15
+ reply: import("@deepseek-ai/schemastery").default<any, any, "plain">;
16
+ turn: import("@deepseek-ai/schemastery").default<any, any, "plain">;
17
+ delivery: import("@deepseek-ai/schemastery").default<any, any, "plain">;
18
+ policy: import("@deepseek-ai/schemastery").default<any, any, "plain">;
19
+ pipeline: import("@deepseek-ai/schemastery").default<any, any, "plain">;
20
+ events: import("@deepseek-ai/schemastery").default<any, any, "plain">;
21
+ status: import("@deepseek-ai/schemastery").default<boolean, boolean, "plain">;
22
+ transport: import("@deepseek-ai/schemastery").default<any, any, "plain">;
23
+ }>>, Schemastery.ObjectT<NoInfer<{
24
+ channel: import("@deepseek-ai/schemastery").default<string, string, "plain">;
25
+ botName: import("@deepseek-ai/schemastery").default<string, string, "plain">;
26
+ anchor: import("@deepseek-ai/schemastery").default<string, string, "plain">;
27
+ session: import("@deepseek-ai/schemastery").default<string, string, "plain">;
28
+ model: import("@deepseek-ai/schemastery").default<string, string, "plain">;
29
+ reply: import("@deepseek-ai/schemastery").default<any, any, "plain">;
30
+ turn: import("@deepseek-ai/schemastery").default<any, any, "plain">;
31
+ delivery: import("@deepseek-ai/schemastery").default<any, any, "plain">;
32
+ policy: import("@deepseek-ai/schemastery").default<any, any, "plain">;
33
+ pipeline: import("@deepseek-ai/schemastery").default<any, any, "plain">;
34
+ events: import("@deepseek-ai/schemastery").default<any, any, "plain">;
35
+ status: import("@deepseek-ai/schemastery").default<boolean, boolean, "plain">;
36
+ transport: import("@deepseek-ai/schemastery").default<any, any, "plain">;
37
+ }>>, "plain">;
38
+ export declare const apply: (ctx: import("@deepseek-ai/cordis").Context, config?: Record<string, unknown>) => void;
39
+ export declare const plugin: {
40
+ name: string;
41
+ inject: string[];
42
+ Config: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
43
+ channel: import("@deepseek-ai/schemastery").default<string, string, "plain">;
44
+ botName: import("@deepseek-ai/schemastery").default<string, string, "plain">;
45
+ anchor: import("@deepseek-ai/schemastery").default<string, string, "plain">;
46
+ session: import("@deepseek-ai/schemastery").default<string, string, "plain">;
47
+ model: import("@deepseek-ai/schemastery").default<string, string, "plain">;
48
+ reply: import("@deepseek-ai/schemastery").default<any, any, "plain">;
49
+ turn: import("@deepseek-ai/schemastery").default<any, any, "plain">;
50
+ delivery: import("@deepseek-ai/schemastery").default<any, any, "plain">;
51
+ policy: import("@deepseek-ai/schemastery").default<any, any, "plain">;
52
+ pipeline: import("@deepseek-ai/schemastery").default<any, any, "plain">;
53
+ events: import("@deepseek-ai/schemastery").default<any, any, "plain">;
54
+ status: import("@deepseek-ai/schemastery").default<boolean, boolean, "plain">;
55
+ transport: import("@deepseek-ai/schemastery").default<any, any, "plain">;
56
+ }>>, Schemastery.ObjectT<NoInfer<{
57
+ channel: import("@deepseek-ai/schemastery").default<string, string, "plain">;
58
+ botName: import("@deepseek-ai/schemastery").default<string, string, "plain">;
59
+ anchor: import("@deepseek-ai/schemastery").default<string, string, "plain">;
60
+ session: import("@deepseek-ai/schemastery").default<string, string, "plain">;
61
+ model: import("@deepseek-ai/schemastery").default<string, string, "plain">;
62
+ reply: import("@deepseek-ai/schemastery").default<any, any, "plain">;
63
+ turn: import("@deepseek-ai/schemastery").default<any, any, "plain">;
64
+ delivery: import("@deepseek-ai/schemastery").default<any, any, "plain">;
65
+ policy: import("@deepseek-ai/schemastery").default<any, any, "plain">;
66
+ pipeline: import("@deepseek-ai/schemastery").default<any, any, "plain">;
67
+ events: import("@deepseek-ai/schemastery").default<any, any, "plain">;
68
+ status: import("@deepseek-ai/schemastery").default<boolean, boolean, "plain">;
69
+ transport: import("@deepseek-ai/schemastery").default<any, any, "plain">;
70
+ }>>, "plain">;
71
+ apply: (ctx: import("@deepseek-ai/cordis").Context, config?: Record<string, unknown>) => void;
72
+ };
73
+ declare const _default: {
74
+ name: string;
75
+ inject: string[];
76
+ Config: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
77
+ channel: import("@deepseek-ai/schemastery").default<string, string, "plain">;
78
+ botName: import("@deepseek-ai/schemastery").default<string, string, "plain">;
79
+ anchor: import("@deepseek-ai/schemastery").default<string, string, "plain">;
80
+ session: import("@deepseek-ai/schemastery").default<string, string, "plain">;
81
+ model: import("@deepseek-ai/schemastery").default<string, string, "plain">;
82
+ reply: import("@deepseek-ai/schemastery").default<any, any, "plain">;
83
+ turn: import("@deepseek-ai/schemastery").default<any, any, "plain">;
84
+ delivery: import("@deepseek-ai/schemastery").default<any, any, "plain">;
85
+ policy: import("@deepseek-ai/schemastery").default<any, any, "plain">;
86
+ pipeline: import("@deepseek-ai/schemastery").default<any, any, "plain">;
87
+ events: import("@deepseek-ai/schemastery").default<any, any, "plain">;
88
+ status: import("@deepseek-ai/schemastery").default<boolean, boolean, "plain">;
89
+ transport: import("@deepseek-ai/schemastery").default<any, any, "plain">;
90
+ }>>, Schemastery.ObjectT<NoInfer<{
91
+ channel: import("@deepseek-ai/schemastery").default<string, string, "plain">;
92
+ botName: import("@deepseek-ai/schemastery").default<string, string, "plain">;
93
+ anchor: import("@deepseek-ai/schemastery").default<string, string, "plain">;
94
+ session: import("@deepseek-ai/schemastery").default<string, string, "plain">;
95
+ model: import("@deepseek-ai/schemastery").default<string, string, "plain">;
96
+ reply: import("@deepseek-ai/schemastery").default<any, any, "plain">;
97
+ turn: import("@deepseek-ai/schemastery").default<any, any, "plain">;
98
+ delivery: import("@deepseek-ai/schemastery").default<any, any, "plain">;
99
+ policy: import("@deepseek-ai/schemastery").default<any, any, "plain">;
100
+ pipeline: import("@deepseek-ai/schemastery").default<any, any, "plain">;
101
+ events: import("@deepseek-ai/schemastery").default<any, any, "plain">;
102
+ status: import("@deepseek-ai/schemastery").default<boolean, boolean, "plain">;
103
+ transport: import("@deepseek-ai/schemastery").default<any, any, "plain">;
104
+ }>>, "plain">;
105
+ apply: (ctx: import("@deepseek-ai/cordis").Context, config?: Record<string, unknown>) => void;
106
+ };
107
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,116 @@
1
+ import { makeAdapterPlugin } from '@dsbot-channel/core/adapter-plugin.js';
2
+ import { ConfigError } from '@dsbot-channel/core/config.js';
3
+ import { MailAdapter } from './adapter.js';
4
+ const FIELDS = new Set([
5
+ 'imap',
6
+ 'smtp',
7
+ 'from',
8
+ 'signature',
9
+ 'listUnsubscribe',
10
+ 'bots',
11
+ 'lists',
12
+ 'maxAttachmentBytes',
13
+ ]);
14
+ function refuse(message, path = ['transport']) {
15
+ throw new ConfigError(message, path);
16
+ }
17
+ function block(value, name) {
18
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
19
+ refuse(`transport.${name} must be a mapping`, ['transport', name]);
20
+ }
21
+ return value;
22
+ }
23
+ function host(value, name) {
24
+ if (typeof value !== 'string' || value.length === 0)
25
+ refuse(`transport.${name}.host is required`, ['transport', name, 'host']);
26
+ return value;
27
+ }
28
+ function port(value, name) {
29
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
30
+ refuse(`transport.${name}.port must be a whole number between 1 and 65535`, ['transport', name, 'port']);
31
+ }
32
+ return value;
33
+ }
34
+ function user(value, name) {
35
+ if (typeof value !== 'string' || value.length === 0)
36
+ refuse(`transport.${name}.user is required`, ['transport', name, 'user']);
37
+ return value;
38
+ }
39
+ function strings(value, name) {
40
+ if (value === undefined)
41
+ return [];
42
+ if (!Array.isArray(value))
43
+ refuse(`transport.${name} must be a list of names`, ['transport', name]);
44
+ return value.map((one, index) => {
45
+ if (typeof one !== 'string' || one.length === 0)
46
+ refuse(`transport.${name}[${index}] must be a non-empty string`, ['transport', name, index]);
47
+ return one;
48
+ });
49
+ }
50
+ /** Reads the mail block; every named field is checked, so a typo fails at mount. */
51
+ export function parseMailConfig(mount) {
52
+ const raw = mount.transport;
53
+ const unknown = Object.keys(raw).filter((key) => !FIELDS.has(key));
54
+ if (unknown.length > 0)
55
+ refuse(`transport names a field this adapter does not read: ${unknown.join(', ')}`);
56
+ const imap = block(raw.imap, 'imap');
57
+ const smtp = block(raw.smtp, 'smtp');
58
+ const from = raw.from;
59
+ if (typeof from !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(from)) {
60
+ refuse(`transport.from must be one mail address: ${String(from)}`, ['transport', 'from']);
61
+ }
62
+ const smtpSecure = smtp.secure;
63
+ if (smtpSecure !== undefined && typeof smtpSecure !== 'boolean' && smtpSecure !== 'starttls') {
64
+ refuse('transport.smtp.secure must be true, false or "starttls"', ['transport', 'smtp', 'secure']);
65
+ }
66
+ const imapSecure = imap.secure;
67
+ if (imapSecure !== undefined && typeof imapSecure !== 'boolean') {
68
+ refuse('transport.imap.secure must be true or false', ['transport', 'imap', 'secure']);
69
+ }
70
+ const pollMs = imap.pollMs;
71
+ if (pollMs !== undefined && (typeof pollMs !== 'number' || !Number.isInteger(pollMs) || pollMs < 1000)) {
72
+ refuse(`transport.imap.pollMs must be a whole number of milliseconds, at least 1000: ${String(pollMs)}`, ['transport', 'imap', 'pollMs']);
73
+ }
74
+ const maxAttachmentBytes = raw.maxAttachmentBytes;
75
+ if (maxAttachmentBytes !== undefined && (typeof maxAttachmentBytes !== 'number' || maxAttachmentBytes < 0)) {
76
+ refuse('transport.maxAttachmentBytes must be a positive number', ['transport', 'maxAttachmentBytes']);
77
+ }
78
+ return {
79
+ channel: mount.channel,
80
+ botName: mount.botName,
81
+ from,
82
+ imap: {
83
+ host: host(imap.host, 'imap'),
84
+ port: port(imap.port, 'imap'),
85
+ secure: imap.secure !== false,
86
+ user: user(imap.user, 'imap'),
87
+ password: user(imap.password, 'imap'),
88
+ mailbox: typeof imap.mailbox === 'string' && imap.mailbox.length > 0 ? imap.mailbox : 'INBOX',
89
+ pollMs: typeof pollMs === 'number' ? pollMs : 30_000,
90
+ },
91
+ smtp: {
92
+ host: host(smtp.host, 'smtp'),
93
+ port: port(smtp.port, 'smtp'),
94
+ secure: smtpSecure === undefined ? true : smtpSecure,
95
+ user: user(smtp.user, 'smtp'),
96
+ password: user(smtp.password, 'smtp'),
97
+ },
98
+ ...(typeof raw.signature === 'string' ? { signature: raw.signature } : {}),
99
+ ...(typeof raw.listUnsubscribe === 'string' ? { listUnsubscribe: raw.listUnsubscribe } : {}),
100
+ bots: strings(raw.bots, 'bots'),
101
+ lists: strings(raw.lists, 'lists'),
102
+ ...(typeof maxAttachmentBytes === 'number' ? { maxAttachmentBytes } : {}),
103
+ };
104
+ }
105
+ export const createMailAdapter = (mount, options) => new MailAdapter(parseMailConfig(mount), options);
106
+ export { MailAdapter };
107
+ const adapter = makeAdapterPlugin({
108
+ name: 'dsbot-channel/mail',
109
+ kind: 'mail',
110
+ factory: createMailAdapter,
111
+ check: parseMailConfig,
112
+ });
113
+ export const Config = adapter.Config;
114
+ export const apply = adapter.apply;
115
+ export const plugin = adapter.plugin;
116
+ export default adapter.plugin;
@@ -0,0 +1,35 @@
1
+ import type { InboundMessage } from '@dsbot-channel/core/contract.js';
2
+ import type { ParsedMail } from './mime.js';
3
+ export interface MailView {
4
+ channel: string;
5
+ from: string;
6
+ bots: readonly string[];
7
+ lists: readonly string[];
8
+ }
9
+ export declare const SESSION_MAX = 128;
10
+ export declare const MAIL_ID_MAX = 256;
11
+ export declare const REFERENCES_MAX = 20;
12
+ /** The thread a message belongs to: the first reference, else the parent, else the message itself. */
13
+ export declare function threadRootOf(mail: ParsedMail): string;
14
+ /** The message id core carries: no angle brackets, bounded, and stable for a redelivery. */
15
+ export declare function messageIdOf(mail: ParsedMail): string;
16
+ /** The address a reply goes to: the list when one is named, the sender otherwise. */
17
+ export declare function replyAddressOf(mail: ParsedMail, lists: readonly string[]): string;
18
+ /** A list is a room: it names itself in the headers, or the profile lists its address. */
19
+ export declare function isList(mail: ParsedMail, lists: readonly string[]): boolean;
20
+ export interface MailMappingInput {
21
+ mail: ParsedMail;
22
+ config: MailView;
23
+ receivedAt: number;
24
+ }
25
+ /** One mail on the wire becomes one inbound event. */
26
+ export declare function mapMail(input: MailMappingInput): InboundMessage;
27
+ export interface ReplyHeaders {
28
+ subject: string;
29
+ inReplyTo?: string;
30
+ references?: string;
31
+ }
32
+ /** The headers a reply carries so the thread stays one chain. */
33
+ export declare function replyHeadersOf(mail: ParsedMail): ReplyHeaders;
34
+ /** A fresh message id on the configured domain. */
35
+ export declare function newMessageId(from: string): string;
@@ -0,0 +1,104 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { addressOf, cleanId } from './mime.js';
3
+ export const SESSION_MAX = 128;
4
+ export const MAIL_ID_MAX = 256;
5
+ export const REFERENCES_MAX = 20;
6
+ function hashOf(value, length = 32) {
7
+ return createHash('sha256').update(value).digest('hex').slice(0, length);
8
+ }
9
+ /** The thread a message belongs to: the first reference, else the parent, else the message itself. */
10
+ export function threadRootOf(mail) {
11
+ const root = cleanId(mail.references[0]) || cleanId(mail.inReplyTo) || cleanId(mail.messageId);
12
+ return root === '' ? hashOf(`${addressOf(mail.from)}|${mail.subject}|${mail.date ?? 0}`, 16) : root;
13
+ }
14
+ /** The message id core carries: no angle brackets, bounded, and stable for a redelivery. */
15
+ export function messageIdOf(mail) {
16
+ const cleaned = cleanId(mail.messageId);
17
+ if (cleaned === '')
18
+ return hashOf(`${addressOf(mail.from)}|${mail.subject}|${mail.date ?? 0}|${mail.text.slice(0, 64)}`);
19
+ return cleaned.length > MAIL_ID_MAX ? hashOf(cleaned) : cleaned;
20
+ }
21
+ /** The address a reply goes to: the list when one is named, the sender otherwise. */
22
+ export function replyAddressOf(mail, lists) {
23
+ const listPost = mail.headers['list-post']?.[0];
24
+ const posted = listPost === undefined ? undefined : /<mailto:([^>]+)>/i.exec(listPost)?.[1];
25
+ if (posted !== undefined && posted.trim() !== '')
26
+ return posted.trim();
27
+ const replyTo = cleanId(mail.headers['reply-to']?.[0]);
28
+ if (replyTo !== '')
29
+ return addressOf(replyTo);
30
+ const from = addressOf(mail.from);
31
+ const listed = lists.find((one) => one.toLowerCase() === from.toLowerCase());
32
+ return listed ?? from;
33
+ }
34
+ /** A list is a room: it names itself in the headers, or the profile lists its address. */
35
+ export function isList(mail, lists) {
36
+ if (mail.headers['list-post'] !== undefined || mail.headers['list-id'] !== undefined)
37
+ return true;
38
+ const from = addressOf(mail.from).toLowerCase();
39
+ return lists.some((one) => one.toLowerCase() === from);
40
+ }
41
+ function attachmentMeta(attachments) {
42
+ return attachments.map((one) => ({ name: one.name, type: one.type, size: one.size }));
43
+ }
44
+ /** One mail on the wire becomes one inbound event. */
45
+ export function mapMail(input) {
46
+ const { mail, config } = input;
47
+ const messageId = messageIdOf(mail);
48
+ const root = threadRootOf(mail);
49
+ const sessionBase = `mail:${root}`;
50
+ const sessionKey = sessionBase.length > SESSION_MAX ? `mail:${hashOf(root, 32)}` : sessionBase;
51
+ const list = isList(mail, config.lists);
52
+ const target = replyAddressOf(mail, config.lists);
53
+ const from = addressOf(mail.from);
54
+ const isBot = config.bots.some((one) => one.toLowerCase() === from.toLowerCase());
55
+ const references = mail.references.map((one) => cleanId(one)).filter((one) => one !== '').slice(0, REFERENCES_MAX);
56
+ return {
57
+ eventKey: `mail:${hashOf(messageId, 32)}`,
58
+ kind: 'message',
59
+ event: { postType: 'mail', type: list ? 'list' : 'direct' },
60
+ sessionKey,
61
+ replyTarget: target,
62
+ requestId: messageId,
63
+ chainId: sessionKey,
64
+ source: {
65
+ kind: 'mail',
66
+ instance: config.channel,
67
+ peer: list ? target : from,
68
+ scope: list ? 'group' : 'c2c',
69
+ },
70
+ author: { senderId: from, kind: isBot ? 'bot' : 'person', personId: null },
71
+ rawText: mail.text,
72
+ body: { text: mail.text, media: [], replyTo: cleanId(mail.inReplyTo) || null },
73
+ transport: {
74
+ messageId,
75
+ timestamp: mail.date ?? input.receivedAt,
76
+ mentioned: { self: false, others: false, all: false },
77
+ isReply: mail.inReplyTo !== undefined,
78
+ signature: null,
79
+ },
80
+ declared: { nickname: mail.from, avatar: '' },
81
+ adapter: {
82
+ subject: mail.subject,
83
+ messageId,
84
+ references,
85
+ ...(list ? { list: target } : {}),
86
+ ...(mail.attachments.length === 0 ? {} : { attachments: attachmentMeta(mail.attachments) }),
87
+ },
88
+ receivedAt: input.receivedAt,
89
+ };
90
+ }
91
+ /** The headers a reply carries so the thread stays one chain. */
92
+ export function replyHeadersOf(mail) {
93
+ const subject = /^re:/i.test(mail.subject.trim()) ? mail.subject.trim() : `Re: ${mail.subject.trim()}`;
94
+ const messageId = cleanId(mail.messageId);
95
+ if (messageId === '')
96
+ return { subject };
97
+ const chain = [...mail.references.map((one) => cleanId(one)).filter((one) => one !== ''), messageId].join(' ');
98
+ return { subject, inReplyTo: messageId, references: chain };
99
+ }
100
+ /** A fresh message id on the configured domain. */
101
+ export function newMessageId(from) {
102
+ const domain = from.includes('@') ? from.slice(from.indexOf('@') + 1) : 'localhost';
103
+ return `<${randomUUID()}@${domain}>`;
104
+ }
package/dist/mime.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ export interface MailHeaders {
2
+ [key: string]: string[];
3
+ }
4
+ export interface MailPart {
5
+ headers: MailHeaders;
6
+ contentType: string;
7
+ body: Buffer;
8
+ }
9
+ export interface MailAttachment {
10
+ name: string;
11
+ type: string;
12
+ size: number;
13
+ handle: string;
14
+ }
15
+ export interface ParsedMail {
16
+ headers: MailHeaders;
17
+ from: string;
18
+ to: readonly string[];
19
+ subject: string;
20
+ messageId: string;
21
+ inReplyTo?: string;
22
+ references: readonly string[];
23
+ date?: number;
24
+ text: string;
25
+ attachments: readonly MailAttachment[];
26
+ }
27
+ export declare const MAX_ATTACHMENT_BYTES: number;
28
+ /** Splits a raw message into unfolded headers and the body; lower-cases the names. */
29
+ export declare function splitMessage(raw: string): {
30
+ headers: MailHeaders;
31
+ body: string;
32
+ };
33
+ export declare function header(headers: MailHeaders, name: string): string | undefined;
34
+ /** Decodes RFC 2047 words such as `=?utf-8?B?...?=`. */
35
+ export declare function decodeWords(value: string): string;
36
+ /** Decodes a transfer encoding; an unknown one is read as-is. */
37
+ export declare function decodeBody(body: string, encoding: string): Buffer;
38
+ export declare function contentTypeOf(headers: MailHeaders): string;
39
+ export declare function boundaryOf(headers: MailHeaders): string | undefined;
40
+ export declare function dispositionOf(headers: MailHeaders): string;
41
+ export declare function fileNameOf(headers: MailHeaders): string | undefined;
42
+ /** Splits a multipart body into its parts, keeping each part's own headers. */
43
+ export declare function splitParts(body: string, boundary: string): MailPart[];
44
+ /** Reads an html body as plain text: no scripts, no images, no tags. */
45
+ export declare function htmlToText(html: string): string;
46
+ export declare function charsetOf(headers: MailHeaders): BufferEncoding;
47
+ /** Picks the readable body: plain text wins over html, and inline images never count. */
48
+ export declare function chooseBody(part: MailPart, depth?: number): string;
49
+ /** Reads the headers a thread and a reply need, then the body and attachments. */
50
+ export declare function parseMail(input: Buffer | string, maxAttachmentBytes?: number): ParsedMail;
51
+ /** The address inside angle brackets, without them. */
52
+ export declare function cleanId(value: string | undefined): string;
53
+ /** The address part of `Name <user@host>`. */
54
+ export declare function addressOf(value: string): string;
package/dist/mime.js ADDED
@@ -0,0 +1,269 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
3
+ /** Splits a raw message into unfolded headers and the body; lower-cases the names. */
4
+ export function splitMessage(raw) {
5
+ const lines = raw.split(/\r?\n/);
6
+ const headers = {};
7
+ let index = 0;
8
+ let current;
9
+ for (; index < lines.length; index += 1) {
10
+ const line = lines[index] ?? '';
11
+ if (line === '') {
12
+ index += 1;
13
+ break;
14
+ }
15
+ if (/^[ \t]/.test(line) && current !== undefined) {
16
+ const list = headers[current];
17
+ if (list !== undefined && list.length > 0)
18
+ list[list.length - 1] = `${list[list.length - 1]} ${line.trim()}`;
19
+ continue;
20
+ }
21
+ const colon = line.indexOf(':');
22
+ if (colon <= 0)
23
+ continue;
24
+ const name = line.slice(0, colon).trim().toLowerCase();
25
+ const value = line.slice(colon + 1).trim();
26
+ current = name;
27
+ if (headers[name] === undefined)
28
+ headers[name] = [];
29
+ headers[name].push(value);
30
+ }
31
+ return { headers, body: lines.slice(index).join('\n') };
32
+ }
33
+ export function header(headers, name) {
34
+ return headers[name.toLowerCase()]?.[0];
35
+ }
36
+ /** Decodes RFC 2047 words such as `=?utf-8?B?...?=`. */
37
+ export function decodeWords(value) {
38
+ return value.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, (_whole, charset, encoding, payload) => {
39
+ try {
40
+ const bytes = encoding.toUpperCase() === 'B'
41
+ ? Buffer.from(payload, 'base64')
42
+ : Buffer.from(payload
43
+ .replace(/_/g, ' ')
44
+ .replace(/=([0-9A-Fa-f]{2})/g, (_match, hex) => String.fromCharCode(Number.parseInt(hex, 16))), 'binary');
45
+ return bytes.toString(charsetName(charset));
46
+ }
47
+ catch {
48
+ return payload;
49
+ }
50
+ });
51
+ }
52
+ function charsetName(charset) {
53
+ const name = charset.toLowerCase();
54
+ if (name.includes('utf-8'))
55
+ return 'utf8';
56
+ if (name.includes('iso-8859-1') || name.includes('latin1'))
57
+ return 'latin1';
58
+ if (name.includes('ascii'))
59
+ return 'ascii';
60
+ return 'utf8';
61
+ }
62
+ /** Decodes a transfer encoding; an unknown one is read as-is. */
63
+ export function decodeBody(body, encoding) {
64
+ const name = encoding.trim().toLowerCase();
65
+ if (name === 'base64')
66
+ return Buffer.from(body.replace(/\s+/g, ''), 'base64');
67
+ if (name === 'quoted-printable') {
68
+ const cleaned = body.replace(/=\r?\n/g, '');
69
+ const bytes = [];
70
+ for (let index = 0; index < cleaned.length; index += 1) {
71
+ const char = cleaned[index];
72
+ if (char === '=') {
73
+ const hex = cleaned.slice(index + 1, index + 3);
74
+ if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
75
+ bytes.push(Number.parseInt(hex, 16));
76
+ index += 2;
77
+ continue;
78
+ }
79
+ }
80
+ bytes.push(char.charCodeAt(0));
81
+ }
82
+ return Buffer.from(bytes);
83
+ }
84
+ return Buffer.from(body, 'binary');
85
+ }
86
+ export function contentTypeOf(headers) {
87
+ return (header(headers, 'content-type') ?? 'text/plain').trim();
88
+ }
89
+ export function boundaryOf(headers) {
90
+ const matched = /boundary="?([^";]+)"?/i.exec(contentTypeOf(headers));
91
+ return matched?.[1];
92
+ }
93
+ export function dispositionOf(headers) {
94
+ return (header(headers, 'content-disposition') ?? '').trim().toLowerCase();
95
+ }
96
+ export function fileNameOf(headers) {
97
+ const disposition = dispositionOf(headers);
98
+ const fromDisposition = /filename\*?=(?:utf-8'')?"?([^";]+)"?/i.exec(disposition);
99
+ if (fromDisposition !== null)
100
+ return decodeWords(fromDisposition[1]);
101
+ const fromType = /name="?([^";]+)"?/i.exec(contentTypeOf(headers));
102
+ return fromType === null ? undefined : decodeWords(fromType[1]);
103
+ }
104
+ /** Splits a multipart body into its parts, keeping each part's own headers. */
105
+ export function splitParts(body, boundary) {
106
+ const marker = `--${boundary}`;
107
+ const parts = [];
108
+ const segments = body.split(marker);
109
+ for (const segment of segments.slice(1)) {
110
+ if (segment.startsWith('--'))
111
+ break;
112
+ const trimmed = segment.replace(/^\r?\n/, '');
113
+ const { headers, body: content } = splitMessage(trimmed);
114
+ parts.push({
115
+ headers,
116
+ contentType: contentTypeOf(headers),
117
+ body: decodeBody(content, header(headers, 'content-transfer-encoding') ?? '7bit'),
118
+ });
119
+ }
120
+ return parts;
121
+ }
122
+ const ENTITIES = {
123
+ amp: '&',
124
+ lt: '<',
125
+ gt: '>',
126
+ quot: '"',
127
+ apos: "'",
128
+ nbsp: ' ',
129
+ ndash: '–',
130
+ mdash: '—',
131
+ hellip: '…',
132
+ };
133
+ /** Reads an html body as plain text: no scripts, no images, no tags. */
134
+ export function htmlToText(html) {
135
+ return html
136
+ .replace(/<!--[\s\S]*?-->/g, ' ')
137
+ .replace(/<(script|style|head)\b[\s\S]*?<\/\1>/gi, ' ')
138
+ .replace(/<img\b[^>]*>/gi, ' ')
139
+ .replace(/<br\s*\/?>/gi, '\n')
140
+ .replace(/<\/(p|div|li|tr|h[1-6]|blockquote|pre)>/gi, '\n')
141
+ .replace(/<[^>]+>/g, ' ')
142
+ .replace(/&#(\d+);/g, (_whole, code) => String.fromCodePoint(Number(code)))
143
+ .replace(/&#x([0-9a-f]+);/gi, (_whole, code) => String.fromCodePoint(Number.parseInt(code, 16)))
144
+ .replace(/&([a-z]+);/gi, (whole, name) => ENTITIES[name.toLowerCase()] ?? whole)
145
+ .replace(/[ \t]+/g, ' ')
146
+ .replace(/\s*\n\s*/g, '\n')
147
+ .replace(/\n{3,}/g, '\n\n')
148
+ .trim();
149
+ }
150
+ export function charsetOf(headers) {
151
+ const matched = /charset="?([^";]+)"?/i.exec(contentTypeOf(headers));
152
+ return matched === null ? 'utf8' : charsetName(matched[1]);
153
+ }
154
+ function textOf(part) {
155
+ const type = part.contentType.toLowerCase();
156
+ const body = part.body.toString(charsetOf(part.headers));
157
+ if (type.startsWith('text/html'))
158
+ return htmlToText(body);
159
+ return body;
160
+ }
161
+ function isText(part) {
162
+ const type = part.contentType.toLowerCase();
163
+ return type.startsWith('text/plain') || type.startsWith('text/html');
164
+ }
165
+ function isAttachment(part) {
166
+ return dispositionOf(part.headers).startsWith('attachment') || fileNameOf(part.headers) !== undefined;
167
+ }
168
+ /** Picks the readable body: plain text wins over html, and inline images never count. */
169
+ export function chooseBody(part, depth = 0) {
170
+ if (depth > 8)
171
+ return '';
172
+ const type = part.contentType.toLowerCase();
173
+ if (type.startsWith('multipart/')) {
174
+ const boundary = boundaryOf(part.headers);
175
+ if (boundary === undefined)
176
+ return '';
177
+ const children = splitParts(part.body.toString('binary'), boundary);
178
+ if (type.startsWith('multipart/alternative')) {
179
+ const plain = children.find((one) => one.contentType.toLowerCase().startsWith('text/plain'));
180
+ if (plain !== undefined)
181
+ return textOf(plain);
182
+ const html = children.find((one) => one.contentType.toLowerCase().startsWith('text/html'));
183
+ return html === undefined ? '' : textOf(html);
184
+ }
185
+ if (type.startsWith('multipart/related')) {
186
+ const first = children.find((one) => !isAttachment(one));
187
+ return first === undefined ? '' : chooseBody(first, depth + 1);
188
+ }
189
+ const text = children.find((one) => isText(one) && !isAttachment(one));
190
+ if (text !== undefined)
191
+ return textOf(text);
192
+ for (const child of children) {
193
+ if (isAttachment(child))
194
+ continue;
195
+ const nested = chooseBody(child, depth + 1);
196
+ if (nested !== '')
197
+ return nested;
198
+ }
199
+ return '';
200
+ }
201
+ return isText(part) ? textOf(part) : '';
202
+ }
203
+ function attachmentsOf(part, depth = 0) {
204
+ if (depth > 8)
205
+ return [];
206
+ const type = part.contentType.toLowerCase();
207
+ if (type.startsWith('multipart/')) {
208
+ const boundary = boundaryOf(part.headers);
209
+ if (boundary === undefined)
210
+ return [];
211
+ return splitParts(part.body.toString('binary'), boundary).flatMap((child) => attachmentsOf(child, depth + 1));
212
+ }
213
+ if (!isAttachment(part))
214
+ return [];
215
+ const name = fileNameOf(part.headers) ?? 'attachment';
216
+ const size = part.body.length;
217
+ return [
218
+ {
219
+ name,
220
+ type: type.split(';')[0] ?? 'application/octet-stream',
221
+ size,
222
+ handle: `mail:${createHash('sha256').update(part.body).digest('hex').slice(0, 16)}`,
223
+ },
224
+ ];
225
+ }
226
+ /** Reads the headers a thread and a reply need, then the body and attachments. */
227
+ export function parseMail(input, maxAttachmentBytes = MAX_ATTACHMENT_BYTES) {
228
+ const raw = Buffer.isBuffer(input) ? input.toString('latin1') : input;
229
+ const root = splitMessage(raw);
230
+ const top = { headers: root.headers, contentType: contentTypeOf(root.headers), body: Buffer.from(root.body, 'binary') };
231
+ const text = chooseBody(top).trimEnd();
232
+ const attachments = attachmentsOf(top).filter((one) => one.size <= maxAttachmentBytes);
233
+ const references = (header(root.headers, 'references') ?? '')
234
+ .split(/\s+/)
235
+ .map((one) => one.trim())
236
+ .filter((one) => one.length > 0);
237
+ const messageId = cleanId(header(root.headers, 'message-id'));
238
+ const inReplyTo = cleanId(header(root.headers, 'in-reply-to'));
239
+ const date = Date.parse(header(root.headers, 'date') ?? '');
240
+ const from = decodeWords(header(root.headers, 'from') ?? '');
241
+ const to = (header(root.headers, 'to') ?? '')
242
+ .split(',')
243
+ .map((one) => decodeWords(one).trim())
244
+ .filter((one) => one.length > 0);
245
+ return {
246
+ headers: root.headers,
247
+ from,
248
+ to,
249
+ subject: decodeWords(header(root.headers, 'subject') ?? ''),
250
+ messageId,
251
+ ...(inReplyTo === undefined ? {} : { inReplyTo }),
252
+ references,
253
+ ...(Number.isFinite(date) ? { date } : {}),
254
+ text,
255
+ attachments,
256
+ };
257
+ }
258
+ /** The address inside angle brackets, without them. */
259
+ export function cleanId(value) {
260
+ if (value === undefined)
261
+ return '';
262
+ const matched = /<([^>]+)>/.exec(value);
263
+ return matched === null ? value.trim() : matched[1].trim();
264
+ }
265
+ /** The address part of `Name <user@host>`. */
266
+ export function addressOf(value) {
267
+ const matched = /<([^>]+)>/.exec(value);
268
+ return matched === null ? value.trim() : matched[1].trim();
269
+ }
@@ -0,0 +1,55 @@
1
+ export interface ImapSettings {
2
+ host: string;
3
+ port: number;
4
+ secure: boolean;
5
+ user: string;
6
+ password: string;
7
+ mailbox: string;
8
+ }
9
+ export interface SmtpSettings {
10
+ host: string;
11
+ port: number;
12
+ secure: boolean | 'starttls';
13
+ user?: string | undefined;
14
+ password?: string | undefined;
15
+ }
16
+ export interface Cursor {
17
+ uidValidity: number;
18
+ uid: number;
19
+ }
20
+ export interface FetchedMail {
21
+ uid: number;
22
+ raw: Buffer;
23
+ }
24
+ /** The wire layers behind one interface, so a test can stand in for both protocols. */
25
+ export interface MailTransport {
26
+ fetch(mailbox: string, cursor: Cursor | undefined): Promise<{
27
+ uidValidity: number;
28
+ maxUid: number;
29
+ messages: FetchedMail[];
30
+ }>;
31
+ markSeen(mailbox: string, uids: readonly number[]): Promise<void>;
32
+ append(mailbox: string, raw: Buffer): Promise<void>;
33
+ send(envelope: {
34
+ from: string;
35
+ to: readonly string[];
36
+ }, raw: Buffer): Promise<void>;
37
+ close(): Promise<void>;
38
+ }
39
+ /** Reads with imapflow and writes with nodemailer; the MIME layer stays core's own. */
40
+ export declare class ImapSmtpTransport implements MailTransport {
41
+ #private;
42
+ constructor(imap: ImapSettings, smtp: SmtpSettings);
43
+ fetch(mailbox: string, cursor: Cursor | undefined): Promise<{
44
+ uidValidity: number;
45
+ maxUid: number;
46
+ messages: FetchedMail[];
47
+ }>;
48
+ markSeen(mailbox: string, uids: readonly number[]): Promise<void>;
49
+ append(mailbox: string, raw: Buffer): Promise<void>;
50
+ send(envelope: {
51
+ from: string;
52
+ to: readonly string[];
53
+ }, raw: Buffer): Promise<void>;
54
+ close(): Promise<void>;
55
+ }
@@ -0,0 +1,67 @@
1
+ import { ImapFlow } from 'imapflow';
2
+ import nodemailer from 'nodemailer';
3
+ /** Reads with imapflow and writes with nodemailer; the MIME layer stays core's own. */
4
+ export class ImapSmtpTransport {
5
+ #imap;
6
+ #smtp;
7
+ constructor(imap, smtp) {
8
+ this.#imap = new ImapFlow({
9
+ host: imap.host,
10
+ port: imap.port,
11
+ secure: imap.secure,
12
+ auth: { user: imap.user, pass: imap.password },
13
+ logger: false,
14
+ });
15
+ this.#smtp = nodemailer.createTransport({
16
+ host: smtp.host,
17
+ port: smtp.port,
18
+ secure: smtp.secure === true,
19
+ ...(smtp.secure === 'starttls' ? { requireTLS: true } : {}),
20
+ ...(smtp.user === undefined ? {} : { auth: { user: smtp.user, pass: smtp.password } }),
21
+ });
22
+ }
23
+ async fetch(mailbox, cursor) {
24
+ const lock = await this.#imap.getMailboxLock(mailbox);
25
+ try {
26
+ const opened = this.#imap.mailbox;
27
+ const uidValidity = Number(opened === false ? 0 : opened.uidValidity);
28
+ const maxUid = opened === false ? 0 : Math.max(0, Number(opened.uidNext) - 1);
29
+ if (cursor === undefined)
30
+ return { uidValidity, maxUid, messages: [] };
31
+ const start = cursor.uidValidity === uidValidity && cursor.uid > 0 ? cursor.uid + 1 : maxUid + 1;
32
+ const messages = [];
33
+ for await (const message of this.#imap.fetch(`${start}:*`, { uid: true, source: true })) {
34
+ const source = message.source;
35
+ if (source === undefined)
36
+ continue;
37
+ messages.push({ uid: Number(message.uid), raw: Buffer.from(source) });
38
+ }
39
+ messages.sort((left, right) => left.uid - right.uid);
40
+ return { uidValidity, maxUid, messages };
41
+ }
42
+ finally {
43
+ lock.release();
44
+ }
45
+ }
46
+ async markSeen(mailbox, uids) {
47
+ if (uids.length === 0)
48
+ return;
49
+ const lock = await this.#imap.getMailboxLock(mailbox);
50
+ try {
51
+ await this.#imap.messageFlagsAdd([...uids], ['\\Seen'], { uid: true });
52
+ }
53
+ finally {
54
+ lock.release();
55
+ }
56
+ }
57
+ async append(mailbox, raw) {
58
+ await this.#imap.append(mailbox, raw, ['\\Seen']);
59
+ }
60
+ async send(envelope, raw) {
61
+ await this.#smtp.sendMail({ envelope: { from: envelope.from, to: [...envelope.to] }, raw });
62
+ }
63
+ async close() {
64
+ this.#smtp.close();
65
+ await this.#imap.logout().catch(() => undefined);
66
+ }
67
+ }
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@dsbot-channel/mail",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.7.1",
4
+ "description": "Email transport for dsbot-channel.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./*.js": {
15
+ "types": "./dist/*.d.ts",
16
+ "default": "./dist/*.js"
17
+ },
18
+ "./package.json": "./package.json",
19
+ "./cordis.patch.yml": "./cordis.patch.yml"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "assets",
24
+ "cordis.patch.yml",
25
+ "LICENSE"
26
+ ],
27
+ "license": "MIT",
28
+ "publishConfig": {
29
+ "registry": "https://registry.npmjs.org/",
30
+ "access": "public"
31
+ },
32
+ "engines": {
33
+ "node": "^20.19.0 || >=22.12.0"
34
+ },
35
+ "dependencies": {
36
+ "imapflow": "2.10.2",
37
+ "nodemailer": "10.3.1",
38
+ "@dsbot-channel/core": "^0.7.1"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "~22.20.5",
42
+ "typescript": "~5.9.3"
43
+ },
44
+ "dsh": {
45
+ "bundle": {
46
+ "patch": "./cordis.patch.yml"
47
+ }
48
+ },
49
+ "scripts": {
50
+ "build": "pnpm clean && tsc -p tsconfig.build.json",
51
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
52
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
53
+ "verify:dist": "node ../../scripts/verify-dist.mjs packages/mail"
54
+ }
6
55
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.