@cogitator-ai/channels 0.16.0 → 0.18.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/README.md +78 -14
- package/dist/approvals.d.ts +3 -0
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +1 -0
- package/dist/approvals.js.map +1 -1
- package/dist/assistant-config.d.ts +60 -0
- package/dist/assistant-config.d.ts.map +1 -1
- package/dist/assistant-config.js +102 -0
- package/dist/assistant-config.js.map +1 -1
- package/dist/channels/email/address.d.ts +44 -0
- package/dist/channels/email/address.d.ts.map +1 -0
- package/dist/channels/email/address.js +211 -0
- package/dist/channels/email/address.js.map +1 -0
- package/dist/channels/email/auth.d.ts +59 -0
- package/dist/channels/email/auth.d.ts.map +1 -0
- package/dist/channels/email/auth.js +212 -0
- package/dist/channels/email/auth.js.map +1 -0
- package/dist/channels/email/channel.d.ts +256 -0
- package/dist/channels/email/channel.d.ts.map +1 -0
- package/dist/channels/email/channel.js +701 -0
- package/dist/channels/email/channel.js.map +1 -0
- package/dist/channels/email/errors.d.ts +11 -0
- package/dist/channels/email/errors.d.ts.map +1 -0
- package/dist/channels/email/errors.js +13 -0
- package/dist/channels/email/errors.js.map +1 -0
- package/dist/channels/email/imap.d.ts +125 -0
- package/dist/channels/email/imap.d.ts.map +1 -0
- package/dist/channels/email/imap.js +309 -0
- package/dist/channels/email/imap.js.map +1 -0
- package/dist/channels/email/index.d.ts +7 -0
- package/dist/channels/email/index.d.ts.map +1 -0
- package/dist/channels/email/index.js +3 -0
- package/dist/channels/email/index.js.map +1 -0
- package/dist/channels/email/mime.d.ts +71 -0
- package/dist/channels/email/mime.d.ts.map +1 -0
- package/dist/channels/email/mime.js +83 -0
- package/dist/channels/email/mime.js.map +1 -0
- package/dist/channels/email/multipart.d.ts +28 -0
- package/dist/channels/email/multipart.d.ts.map +1 -0
- package/dist/channels/email/multipart.js +102 -0
- package/dist/channels/email/multipart.js.map +1 -0
- package/dist/channels/email/providers.d.ts +49 -0
- package/dist/channels/email/providers.d.ts.map +1 -0
- package/dist/channels/email/providers.js +292 -0
- package/dist/channels/email/providers.js.map +1 -0
- package/dist/channels/email/reply.d.ts +9 -0
- package/dist/channels/email/reply.d.ts.map +1 -0
- package/dist/channels/email/reply.js +122 -0
- package/dist/channels/email/reply.js.map +1 -0
- package/dist/channels/teams/activity.d.ts +108 -0
- package/dist/channels/teams/activity.d.ts.map +1 -0
- package/dist/channels/teams/activity.js +206 -0
- package/dist/channels/teams/activity.js.map +1 -0
- package/dist/channels/teams/auth.d.ts +65 -0
- package/dist/channels/teams/auth.d.ts.map +1 -0
- package/dist/channels/teams/auth.js +213 -0
- package/dist/channels/teams/auth.js.map +1 -0
- package/dist/channels/teams/cards.d.ts +23 -0
- package/dist/channels/teams/cards.d.ts.map +1 -0
- package/dist/channels/teams/cards.js +97 -0
- package/dist/channels/teams/cards.js.map +1 -0
- package/dist/channels/teams/channel.d.ts +303 -0
- package/dist/channels/teams/channel.d.ts.map +1 -0
- package/dist/channels/teams/channel.js +1071 -0
- package/dist/channels/teams/channel.js.map +1 -0
- package/dist/channels/teams/client.d.ts +72 -0
- package/dist/channels/teams/client.d.ts.map +1 -0
- package/dist/channels/teams/client.js +161 -0
- package/dist/channels/teams/client.js.map +1 -0
- package/dist/channels/teams/index.d.ts +7 -0
- package/dist/channels/teams/index.d.ts.map +1 -0
- package/dist/channels/teams/index.js +6 -0
- package/dist/channels/teams/index.js.map +1 -0
- package/dist/channels/teams/pacer.d.ts +47 -0
- package/dist/channels/teams/pacer.d.ts.map +1 -0
- package/dist/channels/teams/pacer.js +174 -0
- package/dist/channels/teams/pacer.js.map +1 -0
- package/dist/channels/teams/token.d.ts +47 -0
- package/dist/channels/teams/token.d.ts.map +1 -0
- package/dist/channels/teams/token.js +107 -0
- package/dist/channels/teams/token.js.map +1 -0
- package/dist/channels/telegram.d.ts.map +1 -1
- package/dist/channels/telegram.js +3 -0
- package/dist/channels/telegram.js.map +1 -1
- package/dist/formatters/html.d.ts +16 -0
- package/dist/formatters/html.d.ts.map +1 -0
- package/dist/formatters/html.js +535 -0
- package/dist/formatters/html.js.map +1 -0
- package/dist/formatters/markdown-images.d.ts +8 -0
- package/dist/formatters/markdown-images.d.ts.map +1 -0
- package/dist/formatters/markdown-images.js +181 -0
- package/dist/formatters/markdown-images.js.map +1 -0
- package/dist/formatters/markdown.d.ts.map +1 -1
- package/dist/formatters/markdown.js +22 -3
- package/dist/formatters/markdown.js.map +1 -1
- package/dist/gateway.d.ts +23 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +113 -16
- package/dist/gateway.js.map +1 -1
- package/dist/inbound-debounce.d.ts +4 -1
- package/dist/inbound-debounce.d.ts.map +1 -1
- package/dist/inbound-debounce.js +4 -2
- package/dist/inbound-debounce.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime-builder.d.ts +9 -0
- package/dist/runtime-builder.d.ts.map +1 -1
- package/dist/runtime-builder.js +145 -1
- package/dist/runtime-builder.js.map +1 -1
- package/package.json +25 -8
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { htmlToText, markdownToHtml } from '../../formatters/html.js';
|
|
4
|
+
import { markdownToPlainText } from '../../formatters/plain-text.js';
|
|
5
|
+
import { allowsSender, domainOf, headerOf, isAddress, normalizeAddress } from './address.js';
|
|
6
|
+
import { secretsEqual, verifyMailgunSignature } from './auth.js';
|
|
7
|
+
import { EmailDependencyError } from './errors.js';
|
|
8
|
+
import { ImapInbox } from './imap.js';
|
|
9
|
+
import { loadPostalMime, parseRawEmail } from './mime.js';
|
|
10
|
+
import { readFormFields } from './multipart.js';
|
|
11
|
+
import { bodyText, isForm, mailgunFormEmail, mailgunJsonEmail, postmarkEmail, requestHeader, sendgridEmail, } from './providers.js';
|
|
12
|
+
import { stripQuotedReply } from './reply.js';
|
|
13
|
+
/** The header the channel puts on every mail it sends, and skips mail that carries it. */
|
|
14
|
+
export const EMAIL_LOOP_HEADER = 'X-Cogitator';
|
|
15
|
+
/** The largest webhook body the channel's own server takes by default, in bytes. */
|
|
16
|
+
export const EMAIL_DEFAULT_MAX_BODY = 10 * 1024 * 1024;
|
|
17
|
+
/** The largest attachment handed to the agent by default, in bytes. */
|
|
18
|
+
export const EMAIL_DEFAULT_MAX_ATTACHMENT = 10 * 1024 * 1024;
|
|
19
|
+
/** How old a Mailgun signature may be by default, in seconds. */
|
|
20
|
+
export const MAILGUN_DEFAULT_MAX_AGE = 300;
|
|
21
|
+
const MAX_TRACKED = 1000;
|
|
22
|
+
const MAX_REFERENCES = 20;
|
|
23
|
+
/** The most Mailgun signatures remembered against replays, however many came in the window. */
|
|
24
|
+
const MAX_SIGNATURES = 100_000;
|
|
25
|
+
/**
|
|
26
|
+
* How long a webhook request waits for the gateway to take its mail, in ms. A gateway that queues
|
|
27
|
+
* takes it at once, and one that answers before it returns is answered `200` after this long.
|
|
28
|
+
*/
|
|
29
|
+
const HANDOVER_TIMEOUT = 5000;
|
|
30
|
+
const AUTOMATED_SENDER = /^(mailer-daemon|postmaster|no-?reply|do-?not-?reply|bounces?)([+-.].*)?@/i;
|
|
31
|
+
async function loadNodemailer() {
|
|
32
|
+
try {
|
|
33
|
+
const module = (await import('nodemailer'));
|
|
34
|
+
return 'createTransport' in module ? module : module.default;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
throw new EmailDependencyError('nodemailer', 'to send email over SMTP', { cause: error });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Resolves with `fallback` when `promise` takes longer than `ms`. */
|
|
41
|
+
function within(promise, ms, fallback) {
|
|
42
|
+
let timer;
|
|
43
|
+
const timeout = new Promise((resolve) => {
|
|
44
|
+
timer = setTimeout(() => resolve(fallback), ms);
|
|
45
|
+
});
|
|
46
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
47
|
+
}
|
|
48
|
+
function forgetOldest(collection) {
|
|
49
|
+
while (collection.size > MAX_TRACKED) {
|
|
50
|
+
const oldest = collection.keys().next().value;
|
|
51
|
+
if (oldest === undefined)
|
|
52
|
+
return;
|
|
53
|
+
collection.delete(oldest);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** `subject` as a reply's subject: with one `Re: `, whatever prefix it had. */
|
|
57
|
+
export function replySubject(subject) {
|
|
58
|
+
const base = subject.replace(/^(\s*(re|aw|sv|antw|ответ|отв)\s*(\[\d+\])?\s*:\s*)+/i, '').trim();
|
|
59
|
+
return base ? `Re: ${base}` : 'Re:';
|
|
60
|
+
}
|
|
61
|
+
/** The references of a reply: the first and the latest ids, since a long chain is cut. */
|
|
62
|
+
function trimReferences(ids) {
|
|
63
|
+
const unique = [...new Set(ids)];
|
|
64
|
+
if (unique.length <= MAX_REFERENCES)
|
|
65
|
+
return unique;
|
|
66
|
+
return [unique[0], ...unique.slice(unique.length - (MAX_REFERENCES - 1))];
|
|
67
|
+
}
|
|
68
|
+
/** Whether the mail is an automatic one: an auto-reply, a bounce, a list or bulk mailing. */
|
|
69
|
+
export function isAutomatedEmail(email) {
|
|
70
|
+
const header = (name) => headerOf(email.headers, name)?.trim().toLowerCase();
|
|
71
|
+
const autoSubmitted = header('auto-submitted');
|
|
72
|
+
if (autoSubmitted && autoSubmitted !== 'no')
|
|
73
|
+
return true;
|
|
74
|
+
const precedence = header('precedence');
|
|
75
|
+
if (precedence && /^(bulk|junk|list|auto_reply)/.test(precedence))
|
|
76
|
+
return true;
|
|
77
|
+
if (header('x-autoreply') || header('x-autorespond') || header('x-autogenerated'))
|
|
78
|
+
return true;
|
|
79
|
+
if (header('x-failed-recipients'))
|
|
80
|
+
return true;
|
|
81
|
+
if (header('content-type')?.startsWith('multipart/report'))
|
|
82
|
+
return true;
|
|
83
|
+
if (header('return-path') === '<>')
|
|
84
|
+
return true;
|
|
85
|
+
return !!email.from && AUTOMATED_SENDER.test(email.from.address);
|
|
86
|
+
}
|
|
87
|
+
class BodyTooLarge extends Error {
|
|
88
|
+
}
|
|
89
|
+
function readBody(req, limit) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let size = 0;
|
|
93
|
+
req.on('data', (chunk) => {
|
|
94
|
+
size += chunk.length;
|
|
95
|
+
if (size > limit) {
|
|
96
|
+
reject(new BodyTooLarge('Webhook body too large'));
|
|
97
|
+
req.pause();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
chunks.push(chunk);
|
|
101
|
+
});
|
|
102
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
103
|
+
req.on('error', reject);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* An email channel: an agent answers mail, in the thread of each mail. Mail comes from a mailbox
|
|
108
|
+
* over IMAP (`imapflow` and `postal-mime`), or from the inbound webhook of Mailgun, Postmark or
|
|
109
|
+
* SendGrid, and answers go out over SMTP (`nodemailer`) or any transport given, with
|
|
110
|
+
* `In-Reply-To` and `References` set, so mail clients keep them in the thread.
|
|
111
|
+
*
|
|
112
|
+
* A sender is a conversation partner: `channelId` and `userId` are the sender's address in lower
|
|
113
|
+
* case, and `topicId` is the thread's first `Message-ID`, so each email thread is a conversation
|
|
114
|
+
* of its own. Answers are Markdown, sent as HTML with a plain text alternative, and cannot be
|
|
115
|
+
* edited. Mail from the bot itself, mail that carries the `X-Cogitator` header the channel puts
|
|
116
|
+
* on what it sends, auto-replies, bounces and bulk mail are never answered.
|
|
117
|
+
*/
|
|
118
|
+
export class EmailChannel {
|
|
119
|
+
type = 'email';
|
|
120
|
+
editable = false;
|
|
121
|
+
nativeMarkdown = true;
|
|
122
|
+
maxMessageChars = Infinity;
|
|
123
|
+
config;
|
|
124
|
+
address;
|
|
125
|
+
ownAddresses;
|
|
126
|
+
owners;
|
|
127
|
+
handler = null;
|
|
128
|
+
/** Mail handed to the gateway whose handler has not resolved yet, by `Message-ID` */
|
|
129
|
+
inFlight = new Set();
|
|
130
|
+
/** Mail of `inFlight` the gateway handed back while it was being taken */
|
|
131
|
+
refused = new Set();
|
|
132
|
+
handled = new Set();
|
|
133
|
+
/** Mailgun signatures seen, with their timestamps, against replays */
|
|
134
|
+
signatures = new Map();
|
|
135
|
+
/** The IMAP UID of each mail taken from the mailbox, by `Message-ID`, to mark it unread again */
|
|
136
|
+
uids = new Map();
|
|
137
|
+
threads = new Map();
|
|
138
|
+
/** The latest mail of each thread, by `address|root` */
|
|
139
|
+
latest = new Map();
|
|
140
|
+
transport;
|
|
141
|
+
inbox;
|
|
142
|
+
server;
|
|
143
|
+
running = false;
|
|
144
|
+
stopped = false;
|
|
145
|
+
constructor(config) {
|
|
146
|
+
const address = normalizeAddress(config.address ?? '');
|
|
147
|
+
if (!isAddress(address)) {
|
|
148
|
+
throw new Error(`[email] "${config.address}" is not an email address: set address`);
|
|
149
|
+
}
|
|
150
|
+
if (!config.smtp)
|
|
151
|
+
throw new Error('[email] smtp is required: answers are sent with it');
|
|
152
|
+
const webhook = config.webhook;
|
|
153
|
+
if (webhook?.provider === 'mailgun' && !webhook.signingKey) {
|
|
154
|
+
throw new Error('[email] webhook.signingKey is required: Mailgun signs requests with it');
|
|
155
|
+
}
|
|
156
|
+
if (webhook &&
|
|
157
|
+
webhook.provider !== 'mailgun' &&
|
|
158
|
+
!webhook.token &&
|
|
159
|
+
!(webhook.basicAuth?.username && webhook.basicAuth.password)) {
|
|
160
|
+
throw new Error(`[email] ${webhook.provider} does not sign its webhook: set webhook.basicAuth or webhook.token`);
|
|
161
|
+
}
|
|
162
|
+
if (config.imap && config.senderAuth === 'required' && !config.trustedAuthservIds?.length) {
|
|
163
|
+
throw new Error("[email] senderAuth 'required' over IMAP needs trustedAuthservIds: the Authentication-Results of the receiving server, such as mx.google.com, are the only ones a sender cannot forge");
|
|
164
|
+
}
|
|
165
|
+
this.config = config;
|
|
166
|
+
this.address = address;
|
|
167
|
+
this.ownAddresses = new Set([address, ...(config.aliases ?? []).map(normalizeAddress)]);
|
|
168
|
+
this.owners = new Set((config.owners ?? []).map(normalizeAddress));
|
|
169
|
+
}
|
|
170
|
+
onMessage(handler) {
|
|
171
|
+
this.handler = handler;
|
|
172
|
+
}
|
|
173
|
+
async start() {
|
|
174
|
+
if (this.running)
|
|
175
|
+
return;
|
|
176
|
+
if (!this.config.allowFrom?.length) {
|
|
177
|
+
console.warn('[email] No allowFrom set: the bot answers nobody. List the addresses or domains it answers, or "*" for everyone');
|
|
178
|
+
}
|
|
179
|
+
else if (this.config.allowFrom.includes('*') && this.config.senderAuth === 'off') {
|
|
180
|
+
console.warn('[email] allowFrom is "*" and senderAuth is off: anyone can write to the agent, also as someone else');
|
|
181
|
+
}
|
|
182
|
+
if (!this.config.imap && !this.config.webhook) {
|
|
183
|
+
console.warn('[email] Neither imap nor webhook is set: the channel only sends mail');
|
|
184
|
+
}
|
|
185
|
+
if (this.config.imap &&
|
|
186
|
+
this.config.senderAuth !== 'off' &&
|
|
187
|
+
!this.config.trustedAuthservIds?.length) {
|
|
188
|
+
console.warn(`[email] No trustedAuthservIds: mail read over IMAP has no sender authentication, so a forged From passes${this.owners.size > 0 ? ' and mail from the owners is dropped' : ''}. Set them to the authserv-id the receiving server writes in Authentication-Results, such as mx.google.com`);
|
|
189
|
+
}
|
|
190
|
+
this.running = true;
|
|
191
|
+
this.stopped = false;
|
|
192
|
+
try {
|
|
193
|
+
if (this.config.imap)
|
|
194
|
+
await loadPostalMime();
|
|
195
|
+
if (!('transport' in this.config.smtp))
|
|
196
|
+
await this.transportOf();
|
|
197
|
+
const webhook = this.config.webhook;
|
|
198
|
+
if (webhook?.port !== undefined) {
|
|
199
|
+
await this.listen(webhook.port, webhook.path ?? '/email/webhook', webhook.host);
|
|
200
|
+
}
|
|
201
|
+
if (this.config.imap) {
|
|
202
|
+
this.inbox = new ImapInbox(this.config.imap, (raw, uid) => parseRawEmail(raw, this.parseOptions()).then((email) => ({
|
|
203
|
+
email: { ...email, raw: { uid, email: email.raw } },
|
|
204
|
+
uid,
|
|
205
|
+
})), async ({ email, uid }) => (await this.accept(email, uid)) !== 'refused');
|
|
206
|
+
await this.inbox.start();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
await this.stop();
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async stop() {
|
|
215
|
+
this.running = false;
|
|
216
|
+
this.stopped = true;
|
|
217
|
+
const server = this.server;
|
|
218
|
+
this.server = undefined;
|
|
219
|
+
const inbox = this.inbox;
|
|
220
|
+
this.inbox = undefined;
|
|
221
|
+
await Promise.all([
|
|
222
|
+
inbox?.stop(),
|
|
223
|
+
server &&
|
|
224
|
+
new Promise((resolve) => {
|
|
225
|
+
server.closeAllConnections();
|
|
226
|
+
server.close(() => resolve());
|
|
227
|
+
}),
|
|
228
|
+
]);
|
|
229
|
+
const transport = this.transport;
|
|
230
|
+
this.transport = undefined;
|
|
231
|
+
if (transport)
|
|
232
|
+
(await transport.catch(() => undefined))?.close?.();
|
|
233
|
+
}
|
|
234
|
+
/** Looks for unread mail over IMAP now, besides IDLE and the poll timer. */
|
|
235
|
+
async poll() {
|
|
236
|
+
await this.inbox?.sync();
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Takes back a mail the gateway does not answer because it stops. One it refuses while it is
|
|
240
|
+
* being handed over stays unread in the mailbox, and its webhook request is answered `503`, so
|
|
241
|
+
* the provider sends it again. One it dropped after taking it is marked unread again, so the
|
|
242
|
+
* next start reads it once more, or, when a webhook brought it, only logged.
|
|
243
|
+
*/
|
|
244
|
+
async redeliver(msg) {
|
|
245
|
+
if (this.inFlight.has(msg.id)) {
|
|
246
|
+
this.refused.add(msg.id);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const uid = this.uids.get(msg.id);
|
|
250
|
+
if (uid === undefined || !this.inbox) {
|
|
251
|
+
console.warn(`[email] The mail ${msg.id} from ${msg.channelId} was dropped unanswered`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.handled.delete(msg.id.toLowerCase());
|
|
255
|
+
this.uids.delete(msg.id);
|
|
256
|
+
await this.inbox.unsee(uid);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Answers a provider's inbound webhook request: checks its signature or secret, reads the
|
|
260
|
+
* mail, answers at once and hands the mail to the agent after. Mount it in any HTTP server
|
|
261
|
+
* with the raw body unparsed. It works before `start()` too and answers `503` once stopped.
|
|
262
|
+
*/
|
|
263
|
+
async handleWebhook(request) {
|
|
264
|
+
const webhook = this.config.webhook;
|
|
265
|
+
if (this.stopped)
|
|
266
|
+
return { status: 503, body: 'The channel is stopped' };
|
|
267
|
+
if (!webhook)
|
|
268
|
+
return { status: 404, body: 'The webhook is not configured' };
|
|
269
|
+
if (request.method !== 'POST')
|
|
270
|
+
return { status: 405, body: 'Method not allowed' };
|
|
271
|
+
const raw = request.rawBody ?? '';
|
|
272
|
+
const contentType = requestHeader(request.headers, 'content-type') ?? '';
|
|
273
|
+
if (webhook.provider !== 'mailgun' && !this.authorized(webhook, request)) {
|
|
274
|
+
return { status: 401, body: 'Unauthorized' };
|
|
275
|
+
}
|
|
276
|
+
let email;
|
|
277
|
+
let replayKey;
|
|
278
|
+
try {
|
|
279
|
+
if (webhook.provider === 'mailgun') {
|
|
280
|
+
const verified = await this.mailgunEmail(webhook, request, raw, contentType);
|
|
281
|
+
if ('status' in verified)
|
|
282
|
+
return verified;
|
|
283
|
+
({ email, replayKey } = verified);
|
|
284
|
+
}
|
|
285
|
+
else if (webhook.provider === 'postmark') {
|
|
286
|
+
email = postmarkEmail(JSON.parse(bodyText(raw)), this.parseOptions());
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
if (!isForm(contentType))
|
|
290
|
+
return { status: 415, body: 'Expected multipart/form-data' };
|
|
291
|
+
email = await sendgridEmail(readFormFields(raw, contentType), this.parseOptions());
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (error instanceof EmailDependencyError) {
|
|
296
|
+
console.error(`[email] ${error.message}`);
|
|
297
|
+
return { status: 500, body: 'Could not read the message' };
|
|
298
|
+
}
|
|
299
|
+
return { status: 400, body: 'Invalid payload' };
|
|
300
|
+
}
|
|
301
|
+
const accepted = this.accept(email).catch((error) => {
|
|
302
|
+
console.error('[email] Webhook mail failed:', error);
|
|
303
|
+
return 'taken';
|
|
304
|
+
});
|
|
305
|
+
const pending = Symbol('pending');
|
|
306
|
+
const handedOver = await within(accepted, HANDOVER_TIMEOUT, pending);
|
|
307
|
+
if (handedOver === 'refused') {
|
|
308
|
+
if (replayKey !== undefined)
|
|
309
|
+
this.signatures.delete(replayKey);
|
|
310
|
+
return { status: 503, body: 'Not taken now, send it again later' };
|
|
311
|
+
}
|
|
312
|
+
if (handedOver === pending) {
|
|
313
|
+
void accepted.then((late) => {
|
|
314
|
+
if (late === 'refused') {
|
|
315
|
+
console.warn(`[email] The mail ${email.messageId ?? ''} from ${email.from?.address ?? 'unknown'} was dropped unanswered after its webhook was answered, so the provider will not send it again`);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return { status: 200, body: 'OK' };
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* A Node request listener that answers webhook requests on any path it is mounted at. Bodies
|
|
323
|
+
* over `webhook.maxBody` (10 MB by default) are refused with `413`.
|
|
324
|
+
*/
|
|
325
|
+
webhookListener() {
|
|
326
|
+
const limit = this.config.webhook?.maxBody ?? EMAIL_DEFAULT_MAX_BODY;
|
|
327
|
+
return (req, res) => {
|
|
328
|
+
let query = {};
|
|
329
|
+
try {
|
|
330
|
+
query = Object.fromEntries(new URL(req.url ?? '/', 'http://localhost').searchParams);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
res.writeHead(400, { 'content-type': 'text/plain' }).end('Bad request');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
readBody(req, limit)
|
|
337
|
+
.then((rawBody) => this.handleWebhook({ method: req.method ?? 'GET', query, headers: req.headers, rawBody }))
|
|
338
|
+
.then((response) => res.writeHead(response.status, { 'content-type': 'text/plain' }).end(response.body))
|
|
339
|
+
.catch((error) => {
|
|
340
|
+
if (error instanceof BodyTooLarge) {
|
|
341
|
+
res.writeHead(413, { 'content-type': 'text/plain', connection: 'close' });
|
|
342
|
+
res.end('Payload too large');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
console.error('[email] Webhook request failed:', error);
|
|
346
|
+
if (!res.headersSent)
|
|
347
|
+
res.writeHead(500);
|
|
348
|
+
res.end();
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Sends `text` to the address `channelId`. With a `replyTo` of a mail the channel saw, or the
|
|
354
|
+
* `topicId` of a thread, it answers in that thread (`Re:` subject, `In-Reply-To`,
|
|
355
|
+
* `References`), and otherwise it starts a new one. Resolves with the new mail's `Message-ID`.
|
|
356
|
+
*/
|
|
357
|
+
async sendText(channelId, text, options) {
|
|
358
|
+
if (!text.trim())
|
|
359
|
+
throw new Error('[email] Nothing to send');
|
|
360
|
+
return this.send(channelId, this.bodies(text, options?.format), options);
|
|
361
|
+
}
|
|
362
|
+
editText() {
|
|
363
|
+
return Promise.reject(new Error('[email] Sent email cannot be edited'));
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Sends a file as an attachment, its caption as the text, in the thread as `sendText` does.
|
|
367
|
+
* A file needs a `buffer` or an `http(s)` url, which nodemailer downloads.
|
|
368
|
+
*/
|
|
369
|
+
async sendFile(channelId, file, options) {
|
|
370
|
+
const filename = file.filename ?? (file.type === 'image' ? 'image' : 'file');
|
|
371
|
+
let attachment;
|
|
372
|
+
if (file.buffer) {
|
|
373
|
+
attachment = { filename, content: Buffer.from(file.buffer), contentType: file.mimeType };
|
|
374
|
+
}
|
|
375
|
+
else if (file.url && /^https?:\/\//i.test(file.url)) {
|
|
376
|
+
attachment = { filename, href: file.url, contentType: file.mimeType };
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
throw new Error('[email] A file needs a buffer or an http(s) url');
|
|
380
|
+
}
|
|
381
|
+
const caption = file.caption?.trim() ? file.caption : filename;
|
|
382
|
+
return this.send(channelId, { ...this.bodies(caption, options?.format), attachment }, options);
|
|
383
|
+
}
|
|
384
|
+
async sendTyping() { }
|
|
385
|
+
parseOptions() {
|
|
386
|
+
return {
|
|
387
|
+
maxAttachmentBytes: this.config.maxAttachmentBytes ?? EMAIL_DEFAULT_MAX_ATTACHMENT,
|
|
388
|
+
...(this.config.trustedAuthservIds && {
|
|
389
|
+
trustedAuthservIds: this.config.trustedAuthservIds,
|
|
390
|
+
}),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
authorized(webhook, request) {
|
|
394
|
+
if (webhook.token) {
|
|
395
|
+
const given = request.query?.token ?? requestHeader(request.headers, 'x-webhook-token') ?? undefined;
|
|
396
|
+
if (given !== undefined && secretsEqual(given, webhook.token))
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
const credentials = webhook.basicAuth;
|
|
400
|
+
const authorization = requestHeader(request.headers, 'authorization');
|
|
401
|
+
if (credentials && authorization?.toLowerCase().startsWith('basic ')) {
|
|
402
|
+
const decoded = Buffer.from(authorization.slice(6).trim(), 'base64').toString('utf-8');
|
|
403
|
+
return secretsEqual(decoded, `${credentials.username}:${credentials.password}`);
|
|
404
|
+
}
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Checks a Mailgun request's signature, age and token, and reads its mail. The signature is
|
|
409
|
+
* checked before the mail is read: a form is only split into its fields first, where the
|
|
410
|
+
* signature is.
|
|
411
|
+
*/
|
|
412
|
+
async mailgunEmail(webhook, request, raw, contentType) {
|
|
413
|
+
let timestamp;
|
|
414
|
+
let token;
|
|
415
|
+
let signature;
|
|
416
|
+
let fields;
|
|
417
|
+
if (isForm(contentType)) {
|
|
418
|
+
fields = readFormFields(raw, contentType);
|
|
419
|
+
timestamp = fields.text('timestamp');
|
|
420
|
+
token = fields.text('token');
|
|
421
|
+
signature = fields.text('signature');
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
timestamp = requestHeader(request.headers, 'x-mailgun-timestamp');
|
|
425
|
+
signature = requestHeader(request.headers, 'x-mailgun-signature');
|
|
426
|
+
token = raw;
|
|
427
|
+
}
|
|
428
|
+
if (!timestamp ||
|
|
429
|
+
!signature ||
|
|
430
|
+
token === undefined ||
|
|
431
|
+
!verifyMailgunSignature(timestamp, token, signature, webhook.signingKey)) {
|
|
432
|
+
return { status: 401, body: 'Invalid signature' };
|
|
433
|
+
}
|
|
434
|
+
const maxAge = webhook.maxAge ?? MAILGUN_DEFAULT_MAX_AGE;
|
|
435
|
+
const now = Date.now() / 1000;
|
|
436
|
+
const sent = Number(timestamp);
|
|
437
|
+
if (!Number.isFinite(sent) || Math.abs(now - sent) > maxAge) {
|
|
438
|
+
return { status: 401, body: 'Stale signature' };
|
|
439
|
+
}
|
|
440
|
+
const replayKey = signature.toLowerCase();
|
|
441
|
+
if (this.signatures.has(replayKey))
|
|
442
|
+
return { status: 200, body: 'Already received' };
|
|
443
|
+
this.rememberSignature(replayKey, sent, now - maxAge);
|
|
444
|
+
try {
|
|
445
|
+
const email = fields
|
|
446
|
+
? await mailgunFormEmail(fields, this.parseOptions())
|
|
447
|
+
: await mailgunJsonEmail(JSON.parse(bodyText(raw)), this.parseOptions());
|
|
448
|
+
return { email, replayKey };
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
this.signatures.delete(replayKey);
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Remembers a signature for as long as its timestamp is within the replay window: older ones
|
|
457
|
+
* are refused as stale anyway. Past `MAX_SIGNATURES` the oldest are forgotten.
|
|
458
|
+
*/
|
|
459
|
+
rememberSignature(key, sent, oldestValid) {
|
|
460
|
+
for (const [seen, at] of this.signatures) {
|
|
461
|
+
if (at >= oldestValid && this.signatures.size < MAX_SIGNATURES)
|
|
462
|
+
break;
|
|
463
|
+
this.signatures.delete(seen);
|
|
464
|
+
}
|
|
465
|
+
this.signatures.set(key, sent);
|
|
466
|
+
}
|
|
467
|
+
/** Whether the mail may reach the agent: by its sender, its kind and its authentication. */
|
|
468
|
+
admits(email, sender) {
|
|
469
|
+
if (this.ownAddresses.has(sender))
|
|
470
|
+
return false;
|
|
471
|
+
if (headerOf(email.headers, EMAIL_LOOP_HEADER) !== undefined)
|
|
472
|
+
return false;
|
|
473
|
+
if (isAutomatedEmail(email))
|
|
474
|
+
return false;
|
|
475
|
+
const policy = this.config.senderAuth ?? 'when-reported';
|
|
476
|
+
if (policy !== 'off') {
|
|
477
|
+
if (email.authentication === 'fail')
|
|
478
|
+
return false;
|
|
479
|
+
if (policy === 'required' && email.authentication !== 'pass')
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
if (this.owners.has(sender))
|
|
483
|
+
return policy === 'off' || email.authentication === 'pass';
|
|
484
|
+
return allowsSender(this.config.allowFrom ?? [], sender);
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* The text the agent reads: the new text of the mail, starting with the subject on a thread's
|
|
488
|
+
* first mail. A command (`/status`) stays first, so the gateway still sees it, and a mail
|
|
489
|
+
* with only a command as its subject is that command.
|
|
490
|
+
*/
|
|
491
|
+
textOf(email) {
|
|
492
|
+
const body = stripQuotedReply(email.text?.trim() ? email.text : email.html ? htmlToText(email.html) : '');
|
|
493
|
+
const subject = email.subject.trim();
|
|
494
|
+
if (!body && subject.startsWith('/'))
|
|
495
|
+
return subject;
|
|
496
|
+
const starts = !email.inReplyTo && email.references.length === 0;
|
|
497
|
+
if (!starts || !subject || this.config.includeSubject === false || body.startsWith('/')) {
|
|
498
|
+
return body;
|
|
499
|
+
}
|
|
500
|
+
return `Subject: ${subject}${body ? `\n\n${body}` : ''}`;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Hands an inbound mail to the agent when it is one to answer. Resolves `refused` when the
|
|
504
|
+
* gateway did not take it, so it may come again.
|
|
505
|
+
*/
|
|
506
|
+
async accept(email, uid) {
|
|
507
|
+
if (!this.handler || !email.from)
|
|
508
|
+
return 'skipped';
|
|
509
|
+
const sender = email.from.address;
|
|
510
|
+
if (!this.admits(email, sender))
|
|
511
|
+
return 'skipped';
|
|
512
|
+
const messageId = email.messageId ?? this.fallbackId(email, sender);
|
|
513
|
+
const dedupeKey = messageId.toLowerCase();
|
|
514
|
+
if (this.handled.has(dedupeKey))
|
|
515
|
+
return 'skipped';
|
|
516
|
+
const text = this.textOf(email);
|
|
517
|
+
if (!text && email.attachments.length === 0)
|
|
518
|
+
return 'skipped';
|
|
519
|
+
this.handled.add(dedupeKey);
|
|
520
|
+
forgetOldest(this.handled);
|
|
521
|
+
const parent = email.inReplyTo ?? email.references[email.references.length - 1];
|
|
522
|
+
const root = (parent && this.threads.get(parent)?.root) ??
|
|
523
|
+
email.references[0] ??
|
|
524
|
+
email.inReplyTo ??
|
|
525
|
+
messageId;
|
|
526
|
+
this.remember(sender, messageId, {
|
|
527
|
+
subject: email.subject,
|
|
528
|
+
references: trimReferences([
|
|
529
|
+
...(email.references.length ? email.references : email.inReplyTo ? [email.inReplyTo] : []),
|
|
530
|
+
messageId,
|
|
531
|
+
]),
|
|
532
|
+
root,
|
|
533
|
+
});
|
|
534
|
+
const message = {
|
|
535
|
+
id: messageId,
|
|
536
|
+
channelType: this.type,
|
|
537
|
+
channelId: sender,
|
|
538
|
+
userId: sender,
|
|
539
|
+
...(email.from.name && { userName: email.from.name }),
|
|
540
|
+
text,
|
|
541
|
+
...(email.attachments.length > 0 && { attachments: email.attachments }),
|
|
542
|
+
...(email.inReplyTo && { replyTo: email.inReplyTo }),
|
|
543
|
+
topicId: root,
|
|
544
|
+
raw: email.raw,
|
|
545
|
+
};
|
|
546
|
+
if (uid !== undefined) {
|
|
547
|
+
this.uids.set(messageId, uid);
|
|
548
|
+
forgetOldest(this.uids);
|
|
549
|
+
}
|
|
550
|
+
this.inFlight.add(messageId);
|
|
551
|
+
try {
|
|
552
|
+
await this.handler(message);
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
console.error('[email] Message handler error:', error);
|
|
556
|
+
}
|
|
557
|
+
finally {
|
|
558
|
+
this.inFlight.delete(messageId);
|
|
559
|
+
}
|
|
560
|
+
if (!this.refused.delete(messageId))
|
|
561
|
+
return 'taken';
|
|
562
|
+
this.handled.delete(dedupeKey);
|
|
563
|
+
this.uids.delete(messageId);
|
|
564
|
+
return 'refused';
|
|
565
|
+
}
|
|
566
|
+
fallbackId(email, sender) {
|
|
567
|
+
const digest = createHash('sha256')
|
|
568
|
+
.update([
|
|
569
|
+
sender,
|
|
570
|
+
headerOf(email.headers, 'date') ?? '',
|
|
571
|
+
email.subject,
|
|
572
|
+
email.text ?? email.html ?? '',
|
|
573
|
+
].join('\n'))
|
|
574
|
+
.digest('hex')
|
|
575
|
+
.slice(0, 32);
|
|
576
|
+
return `<${digest}@cogitator.invalid>`;
|
|
577
|
+
}
|
|
578
|
+
remember(address, messageId, entry) {
|
|
579
|
+
this.threads.delete(messageId);
|
|
580
|
+
this.threads.set(messageId, entry);
|
|
581
|
+
forgetOldest(this.threads);
|
|
582
|
+
const key = `${address}|${entry.root}`;
|
|
583
|
+
this.latest.delete(key);
|
|
584
|
+
this.latest.set(key, messageId);
|
|
585
|
+
forgetOldest(this.latest);
|
|
586
|
+
}
|
|
587
|
+
/** The thread a mail to `to` goes in: the mail it answers, or the latest of its topic. */
|
|
588
|
+
threadOf(to, options) {
|
|
589
|
+
const replyTo = options?.replyTo;
|
|
590
|
+
const answered = replyTo ? this.threads.get(replyTo) : undefined;
|
|
591
|
+
if (replyTo && answered)
|
|
592
|
+
return { inReplyTo: replyTo, entry: answered, root: answered.root };
|
|
593
|
+
const topic = options?.topicId;
|
|
594
|
+
if (topic) {
|
|
595
|
+
const latest = this.latest.get(`${to}|${topic}`);
|
|
596
|
+
const entry = latest ? this.threads.get(latest) : undefined;
|
|
597
|
+
if (latest && entry)
|
|
598
|
+
return { inReplyTo: latest, entry, root: topic };
|
|
599
|
+
return { inReplyTo: replyTo ?? topic, root: topic };
|
|
600
|
+
}
|
|
601
|
+
return replyTo ? { inReplyTo: replyTo, root: replyTo } : {};
|
|
602
|
+
}
|
|
603
|
+
bodies(text, format) {
|
|
604
|
+
if (format === 'plain')
|
|
605
|
+
return { text };
|
|
606
|
+
if (format === 'html')
|
|
607
|
+
return { text: htmlToText(text), html: text };
|
|
608
|
+
return { text: markdownToPlainText(text), html: markdownToHtml(text) };
|
|
609
|
+
}
|
|
610
|
+
async send(channelId, content, options) {
|
|
611
|
+
const to = normalizeAddress(channelId);
|
|
612
|
+
if (!isAddress(to)) {
|
|
613
|
+
throw new Error(`[email] Unknown channel id "${channelId}": use the recipient's address`);
|
|
614
|
+
}
|
|
615
|
+
const thread = this.threadOf(to, options);
|
|
616
|
+
const ownSubject = this.config.subject || `Message from ${this.config.name ?? this.address}`;
|
|
617
|
+
const baseSubject = thread.entry?.subject || ownSubject;
|
|
618
|
+
const subject = thread.entry ? replySubject(baseSubject) : baseSubject;
|
|
619
|
+
const references = thread.inReplyTo
|
|
620
|
+
? trimReferences([...(thread.entry?.references ?? []), thread.inReplyTo])
|
|
621
|
+
: [];
|
|
622
|
+
const messageId = `<${randomUUID()}@${domainOf(this.address)}>`;
|
|
623
|
+
const transport = await this.transportOf();
|
|
624
|
+
await transport.sendMail({
|
|
625
|
+
from: this.config.name ? { name: this.config.name, address: this.address } : this.address,
|
|
626
|
+
to,
|
|
627
|
+
subject,
|
|
628
|
+
text: content.text,
|
|
629
|
+
...(content.html && { html: content.html }),
|
|
630
|
+
messageId,
|
|
631
|
+
...(thread.inReplyTo && { inReplyTo: thread.inReplyTo, references }),
|
|
632
|
+
headers: {
|
|
633
|
+
[EMAIL_LOOP_HEADER]: 'email',
|
|
634
|
+
'Auto-Submitted': thread.inReplyTo ? 'auto-replied' : 'auto-generated',
|
|
635
|
+
},
|
|
636
|
+
...(content.attachment && { attachments: [content.attachment] }),
|
|
637
|
+
});
|
|
638
|
+
this.remember(to, messageId, {
|
|
639
|
+
subject: thread.entry?.subject || baseSubject,
|
|
640
|
+
references: trimReferences([...references, messageId]),
|
|
641
|
+
root: thread.root ?? messageId,
|
|
642
|
+
});
|
|
643
|
+
return messageId;
|
|
644
|
+
}
|
|
645
|
+
transportOf() {
|
|
646
|
+
const smtp = this.config.smtp;
|
|
647
|
+
if ('transport' in smtp)
|
|
648
|
+
return Promise.resolve(smtp.transport);
|
|
649
|
+
this.transport ??= loadNodemailer()
|
|
650
|
+
.then((nodemailer) => {
|
|
651
|
+
const secure = smtp.secure ?? smtp.port === 465;
|
|
652
|
+
return nodemailer.createTransport({
|
|
653
|
+
host: smtp.host,
|
|
654
|
+
port: smtp.port ?? (secure ? 465 : 587),
|
|
655
|
+
secure,
|
|
656
|
+
...(smtp.auth && { auth: smtp.auth }),
|
|
657
|
+
...smtp.options,
|
|
658
|
+
});
|
|
659
|
+
})
|
|
660
|
+
.catch((error) => {
|
|
661
|
+
this.transport = undefined;
|
|
662
|
+
throw error;
|
|
663
|
+
});
|
|
664
|
+
return this.transport;
|
|
665
|
+
}
|
|
666
|
+
listen(port, path, host) {
|
|
667
|
+
const listener = this.webhookListener();
|
|
668
|
+
const server = createServer((req, res) => {
|
|
669
|
+
let pathname;
|
|
670
|
+
try {
|
|
671
|
+
pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
res.writeHead(400, { 'content-type': 'text/plain' }).end('Bad request');
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (pathname !== path) {
|
|
678
|
+
res.writeHead(404).end();
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
listener(req, res);
|
|
682
|
+
});
|
|
683
|
+
this.server = server;
|
|
684
|
+
return new Promise((resolve, reject) => {
|
|
685
|
+
const failed = (error) => {
|
|
686
|
+
this.server = undefined;
|
|
687
|
+
reject(error);
|
|
688
|
+
};
|
|
689
|
+
server.once('error', failed);
|
|
690
|
+
server.listen(port, host, () => {
|
|
691
|
+
server.off('error', failed);
|
|
692
|
+
resolve();
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/** An email channel that answers mail in its thread. */
|
|
698
|
+
export function emailChannel(config) {
|
|
699
|
+
return new EmailChannel(config);
|
|
700
|
+
}
|
|
701
|
+
//# sourceMappingURL=channel.js.map
|