@luca-emmert/worker-mailer 1.3.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) zou-yu
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 ADDED
@@ -0,0 +1,386 @@
1
+ # Worker Mailer
2
+
3
+ [English](./README.md) | [įŽ€äŊ“中文](./README_zh-CN.md)
4
+
5
+ [![npm version](https://badge.fury.io/js/worker-mailer.svg)](https://badge.fury.io/js/worker-mailer)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ Worker Mailer is an SMTP client that runs on Cloudflare Workers. It leverages [Cloudflare TCP Sockets](https://developers.cloudflare.com/workers/runtime-apis/tcp-sockets/) and doesn't rely on any other dependencies.
9
+
10
+ ## Features
11
+
12
+ - 🚀 Completely built on the Cloudflare Workers runtime with no other dependencies
13
+ - 📝 Full TypeScript type support
14
+ - 📧 Supports sending plain text and HTML emails with attachments
15
+ - 🔒 Supports multiple SMTP authentication methods: `plain`, `login`, `CRAM-MD5` and `XOAUTH2`
16
+ - ⚡ Uses SMTP `PIPELINING` so a message with many recipients costs two round trips instead of one per recipient
17
+ - 📅 DSN support
18
+
19
+ ## Table of Contents
20
+
21
+ - [Installation](#installation)
22
+ - [Quick Start](#quick-start)
23
+ - [API Reference](#api-reference)
24
+ - [Limitations](#limitations)
25
+ - [Contributing](#contributing)
26
+ - [License](#license)
27
+
28
+ ## Installation
29
+
30
+ ```shell
31
+ npm i worker-mailer
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ 1. Configure your `wrangler.toml`:
37
+
38
+ ```toml
39
+ compatibility_flags = ["nodejs_compat"]
40
+ # or compatibility_flags = ["nodejs_compat_v2"]
41
+ ```
42
+
43
+ 2. Use in your code:
44
+
45
+ ```typescript
46
+ import { WorkerMailer } from 'worker-mailer'
47
+
48
+ // Connect to SMTP server
49
+ const mailer = await WorkerMailer.connect({
50
+ credentials: {
51
+ username: 'bob@acme.com',
52
+ password: 'password',
53
+ },
54
+ authType: 'plain',
55
+ host: 'smtp.acme.com',
56
+ port: 587,
57
+ secure: true,
58
+ // The name announced in EHLO. Receiving servers score it — set it to a domain
59
+ // you control, otherwise the connection announces itself as `[127.0.0.1]`.
60
+ ehloName: 'acme.com',
61
+ })
62
+
63
+ // Send email
64
+ await mailer.send({
65
+ from: { name: 'Bob', email: 'bob@acme.com' },
66
+ to: { name: 'Alice', email: 'alice@acme.com' },
67
+ subject: 'Hello from Worker Mailer',
68
+ text: 'This is a plain text message',
69
+ html: '<h1>Hello</h1><p>This is an HTML message</p>',
70
+ })
71
+ ```
72
+
73
+ 3. Using with modern JavaScript frameworks (Next.js, Nuxt, SvelteKit, etc.)
74
+
75
+ When working with frameworks that use Node.js as their development runtime, you'll need to handle the fact that Cloudflare Workers-specific APIs (like `cloudflare:sockets`) aren't available during local development.
76
+
77
+ The recommended approach is to use conditional dynamic imports. Here's an example for Nuxt.js:
78
+
79
+ ```typescript
80
+ export default defineEventHandler(async event => {
81
+ // Check if running in development environment
82
+ if (import.meta.dev) {
83
+ // Development: Use nodemailer (or any Node.js compatible email library)
84
+ const nodemailer = await import('nodemailer')
85
+ const transporter = nodemailer.default.createTransport()
86
+ return await transporter.sendMail()
87
+ } else {
88
+ // Production: Use worker-mailer in Cloudflare Workers environment
89
+ const { WorkerMailer } = await import('worker-mailer')
90
+ const mailer = await WorkerMailer.connect()
91
+ return await mailer.send()
92
+ }
93
+ })
94
+ ```
95
+
96
+ This pattern ensures your application works seamlessly in both development and production environments.
97
+
98
+ ## API Reference
99
+
100
+ ### WorkerMailer.connect(options)
101
+
102
+ Creates a new SMTP connection.
103
+
104
+ ```typescript
105
+ type WorkerMailerOptions = {
106
+ host: string // SMTP server hostname
107
+ port: number // SMTP server port (usually 587 or 465)
108
+ secure?: boolean // Use TLS (default: false)
109
+ startTls?: boolean // Upgrade to TLS if SMTP server supports (default: true)
110
+ requireTls?: boolean // Fail instead of sending unencrypted (default: false)
111
+ ehloName?: string // Name announced in EHLO (default: '[127.0.0.1]')
112
+ credentials?: {
113
+ // SMTP authentication credentials
114
+ username: string
115
+ password?: string // Required for plain, login and cram-md5
116
+ accessToken?: string // Required for xoauth2
117
+ }
118
+ authType?:
119
+ | 'plain'
120
+ | 'login'
121
+ | 'cram-md5'
122
+ | 'xoauth2'
123
+ | Array<'plain' | 'login' | 'cram-md5' | 'xoauth2'>
124
+ logLevel?: LogLevel // Logging level (default: LogLevel.INFO)
125
+ socketTimeoutMs?: number // Socket timeout in milliseconds (default: 60000)
126
+ responseTimeoutMs?: number // Server response timeout in milliseconds (default: 30000)
127
+ pipelining?: boolean // Batch MAIL/RCPT when the server supports it (default: true)
128
+ chunking?: boolean // Transfer with BDAT when the server supports it (default: false)
129
+ allowPartialRecipients?: boolean // Deliver even if some recipients are rejected (default: false)
130
+ dsn?: {
131
+ RET?: {
132
+ HEADERS?: boolean
133
+ FULL?: boolean
134
+ }
135
+ NOTIFY?: {
136
+ DELAY?: boolean
137
+ FAILURE?: boolean
138
+ SUCCESS?: boolean
139
+ }
140
+ }
141
+ }
142
+ ```
143
+
144
+ #### `ehloName`
145
+
146
+ Every SMTP session opens by announcing a name. Receiving servers feed that name
147
+ into their spam scoring, and some reject a name that is not a fully qualified
148
+ domain. Set it to a domain you control:
149
+
150
+ ```typescript
151
+ await WorkerMailer.connect({ host, port, ehloName: 'mail.acme.com' })
152
+ ```
153
+
154
+ A bare IP address is wrapped in the address literal syntax RFC 5321 requires, so
155
+ `'203.0.113.7'` is announced as `[203.0.113.7]`.
156
+
157
+ #### `requireTls`
158
+
159
+ By default the client upgrades to TLS when the server offers `STARTTLS` and
160
+ otherwise authenticates over a plaintext connection. Set `requireTls: true` to
161
+ fail the connection instead.
162
+
163
+ #### `pipelining` and `chunking`
164
+
165
+ `pipelining` sends `MAIL FROM` and every `RCPT TO` as a single batch when the
166
+ server advertises `PIPELINING` (RFC 2920), which is what virtually every server
167
+ does. A message to 20 recipients then costs two round trips instead of 22. Turn
168
+ it off only to debug against a server that mishandles it.
169
+
170
+ `chunking` transfers the message with `BDAT` (RFC 3030) instead of `DATA` when
171
+ the server advertises `CHUNKING`. Because the message is length delimited it
172
+ needs no dot-stuffing pass, which is worthwhile for large attachments. It is off
173
+ by default because far less mail traffic goes through `BDAT` than through
174
+ `DATA`.
175
+
176
+ ### mailer.send(options)
177
+
178
+ Sends an email.
179
+
180
+ ```typescript
181
+ type EmailOptions = {
182
+ from:
183
+ | string
184
+ | {
185
+ // Sender's email
186
+ name?: string
187
+ email: string
188
+ }
189
+ to:
190
+ | string
191
+ | string[]
192
+ | {
193
+ // Recipients (TO)
194
+ name?: string
195
+ email: string
196
+ }
197
+ | Array<{ name?: string; email: string }>
198
+ reply?:
199
+ | string
200
+ | {
201
+ // Reply-To address
202
+ name?: string
203
+ email: string
204
+ }
205
+ cc?:
206
+ | string
207
+ | string[]
208
+ | {
209
+ // Carbon Copy recipients
210
+ name?: string
211
+ email: string
212
+ }
213
+ | Array<{ name?: string; email: string }>
214
+ bcc?:
215
+ | string
216
+ | string[]
217
+ | {
218
+ // Blind Carbon Copy recipients
219
+ name?: string
220
+ email: string
221
+ }
222
+ | Array<{ name?: string; email: string }>
223
+ subject: string // Email subject
224
+ text?: string // Plain text content
225
+ html?: string // HTML content
226
+ headers?: Record<string, string> // Custom email headers
227
+ attachments?: {
228
+ filename: string
229
+ // Base64 string, or raw bytes which are base64-encoded for you
230
+ content: string | ArrayBuffer | Uint8Array
231
+ mimeType?: string // Inferred from the filename if not set
232
+ }[]
233
+ dsnOverride?: { // overrides dsn defined in WorkerMailer, if not set, it will take the WorkerMailer-Option.
234
+ envelopeId?: string | undefined
235
+ RET?: {
236
+ HEADERS?: boolean
237
+ FULL?: boolean
238
+ }
239
+ NOTIFY?: {
240
+ DELAY?: boolean
241
+ FAILURE?: boolean
242
+ SUCCESS?: boolean
243
+ }
244
+ }
245
+ }
246
+ ```
247
+
248
+ #### Return value
249
+
250
+ `send()` resolves with the outcome of the transaction:
251
+
252
+ ```typescript
253
+ type SendResult = {
254
+ accepted: User[] // Recipients the server accepted
255
+ rejected: { user: User; response: string }[] // Recipients it rejected, with the response
256
+ response: string // The server's final response to the message
257
+ }
258
+ ```
259
+
260
+ By default a single rejected recipient fails the whole message and `send()`
261
+ rejects, so `rejected` is only ever populated when `allowPartialRecipients` is
262
+ set:
263
+
264
+ ```typescript
265
+ const mailer = await WorkerMailer.connect({
266
+ host,
267
+ port,
268
+ allowPartialRecipients: true,
269
+ })
270
+ const { accepted, rejected } = await mailer.send({ from, to, subject, text })
271
+ if (rejected.length) {
272
+ console.warn(
273
+ 'Not delivered to',
274
+ rejected.map(r => r.user.email),
275
+ )
276
+ }
277
+ ```
278
+
279
+ #### Bcc
280
+
281
+ `bcc` recipients are sent in the SMTP envelope only and never written into the
282
+ message, so the other recipients cannot see them. If you deliberately want a
283
+ `Bcc` header in the message, set it yourself through `headers`.
284
+
285
+ ### Static Method: WorkerMailer.send()
286
+
287
+ Send a one-off email without maintaining the connection.
288
+
289
+ ```typescript
290
+ await WorkerMailer.send(
291
+ {
292
+ // WorkerMailerOptions
293
+ host: 'smtp.acme.com',
294
+ port: 587,
295
+ credentials: {
296
+ username: 'user',
297
+ password: 'pass',
298
+ },
299
+ },
300
+ {
301
+ // EmailOptions
302
+ from: 'sender@acme.com',
303
+ to: 'recipient@acme.com',
304
+ subject: 'Test',
305
+ text: 'Hello',
306
+ attachments: [
307
+ {
308
+ filename: 'test.txt',
309
+ content: 'SGVsbG8gV29ybGQ=', // base64-encoded string for "Hello World"
310
+ type: 'text/plain',
311
+ },
312
+ ],
313
+ },
314
+ )
315
+ ```
316
+
317
+ ## Limitations
318
+
319
+ - **Port Restrictions:** Cloudflare Workers cannot make outbound connections on port 25. You won't be able to send emails via port 25, but common ports like 587 and 465 are supported.
320
+ - **Connection Limits:** Each Worker instance has a limit on the number of concurrent TCP connections. Make sure to properly close connections when done.
321
+
322
+ ## Contributing
323
+
324
+ ### Development Workflow
325
+
326
+ > For major changes, please open an issue first to discuss what you would like to change.
327
+
328
+ 1. Fork and clone the repository
329
+ 2. Install dependencies:
330
+ ```bash
331
+ pnpm install
332
+ ```
333
+ 3. Create a new branch for your feature from `develop`:
334
+ ```bash
335
+ git checkout -b feat/your-feature-name
336
+ ```
337
+ 4. Make your changes and make sure all tests pass
338
+ 5. Update README.md & changelog `pnpm changeset` if needed
339
+ 6. Push your changes to your fork and create a pull request from your branch to `develop`
340
+
341
+ ### Testing
342
+
343
+ 1. Unit Tests:
344
+ ```bash
345
+ npm test
346
+ ```
347
+ 2. Integration Tests:
348
+ ```bash
349
+ pnpm dlx wrangler dev ./test/worker.ts
350
+ ```
351
+ Then, send a POST request to `http://127.0.0.1:8787` with the following JSON body:
352
+ ```json
353
+ {
354
+ "config": {
355
+ "credentials": {
356
+ "username": "xxx@xx.com",
357
+ "password": "xxxx"
358
+ },
359
+ "authType": "plain",
360
+ "host": "smtp.acme.com",
361
+ "port": 587,
362
+ "secure": false,
363
+ "startTls": true
364
+ },
365
+ "email": {
366
+ "from": "xxx@xx.com",
367
+ "to": "yyy@yy.com",
368
+ "subject": "Test Email",
369
+ "text": "Hello World"
370
+ }
371
+ }
372
+ ```
373
+
374
+ ### Reporting Issues
375
+
376
+ When reporting issues, please include:
377
+
378
+ - Version of worker-mailer you're using
379
+ - A clear description of the problem
380
+ - Steps to reproduce the issue
381
+ - Expected vs actual behavior
382
+ - Any relevant code snippets or error messages
383
+
384
+ ## License
385
+
386
+ This project is licensed under the MIT License.
@@ -0,0 +1,246 @@
1
+ declare function encodeHeader(text: string): string;
2
+ type User = {
3
+ name?: string;
4
+ email: string;
5
+ };
6
+ declare function formatAddress(user: User): string;
7
+ type AttachmentContent = string | ArrayBuffer | Uint8Array;
8
+ type Attachment = {
9
+ filename: string;
10
+ /** Base64 string, or raw bytes which are base64-encoded for you. */
11
+ content: AttachmentContent;
12
+ mimeType?: string;
13
+ };
14
+ type DsnOptions = {
15
+ envelopeId?: string;
16
+ RET?: {
17
+ HEADERS?: boolean;
18
+ FULL?: boolean;
19
+ };
20
+ NOTIFY?: {
21
+ DELAY?: boolean;
22
+ FAILURE?: boolean;
23
+ SUCCESS?: boolean;
24
+ };
25
+ };
26
+ type EmailOptions = {
27
+ from: string | User;
28
+ to: string | string[] | User | User[];
29
+ reply?: string | User;
30
+ cc?: string | string[] | User | User[];
31
+ bcc?: string | string[] | User | User[];
32
+ subject: string;
33
+ text?: string;
34
+ html?: string;
35
+ headers?: Record<string, string>;
36
+ attachments?: Attachment[];
37
+ dsnOverride?: DsnOptions;
38
+ };
39
+ type SendResult = {
40
+ /** Recipients the server accepted. */
41
+ accepted: User[];
42
+ /** Recipients the server rejected, with the response that rejected them. */
43
+ rejected: {
44
+ user: User;
45
+ response: string;
46
+ }[];
47
+ /** The server's final response to the message. */
48
+ response: string;
49
+ };
50
+ type SerializeOptions = {
51
+ /** Set when the server advertised 8BITMIME. */
52
+ allow8bit?: boolean;
53
+ };
54
+ declare class Email {
55
+ readonly from: User;
56
+ readonly to: User[];
57
+ readonly reply?: User;
58
+ readonly cc?: User[];
59
+ readonly bcc?: User[];
60
+ readonly subject: string;
61
+ readonly text?: string;
62
+ readonly html?: string;
63
+ readonly dsnOverride?: DsnOptions;
64
+ readonly attachments?: Attachment[];
65
+ readonly headers: Record<string, string>;
66
+ /** Populated once the server has accepted (or rejected) the message. */
67
+ result?: SendResult;
68
+ setSent: () => void;
69
+ setSentError: (e: unknown) => void;
70
+ sent: Promise<void>;
71
+ constructor(options: EmailOptions);
72
+ private static toUsers;
73
+ /** Every envelope recipient, in the order they are sent to the server. */
74
+ get recipients(): User[];
75
+ /**
76
+ * Rough upper bound on the wire size, used to reject a message locally when
77
+ * the server advertised a smaller SIZE limit.
78
+ */
79
+ estimateSize(): number;
80
+ /**
81
+ * Yields the message piece by piece. Every chunk starts on a line boundary so
82
+ * the caller can dot-stuff and write them one at a time instead of building
83
+ * the whole message — attachments included — as a single string.
84
+ */
85
+ chunks(options?: SerializeOptions): Generator<string>;
86
+ /** The complete, dot-stuffed message including the SMTP terminator. */
87
+ getEmailData(options?: SerializeOptions): string;
88
+ private buildContentPart;
89
+ private renderTextPart;
90
+ private renderAttachment;
91
+ private messageHeaders;
92
+ private hasHeader;
93
+ private resolveHeader;
94
+ private resolveAddressHeader;
95
+ private resolveSubject;
96
+ }
97
+
98
+ declare enum LogLevel {
99
+ DEBUG = 0,
100
+ INFO = 1,
101
+ WARN = 2,
102
+ ERROR = 3,
103
+ NONE = 4
104
+ }
105
+
106
+ type AuthType = 'plain' | 'login' | 'cram-md5' | 'xoauth2';
107
+ type Credentials = {
108
+ username: string;
109
+ password?: string;
110
+ /** OAuth 2.0 bearer token. Required for `xoauth2`, ignored otherwise. */
111
+ accessToken?: string;
112
+ };
113
+ type WorkerMailerOptions = {
114
+ host: string;
115
+ port: number;
116
+ secure?: boolean;
117
+ startTls?: boolean;
118
+ /** Refuse to authenticate or send over an unencrypted connection. */
119
+ requireTls?: boolean;
120
+ /**
121
+ * The name announced in EHLO. Should be a domain you control — receiving
122
+ * servers score it, and some reject a HELO name that is not a FQDN.
123
+ * A bare IP address is wrapped in the address literal syntax RFC 5321 wants.
124
+ */
125
+ ehloName?: string;
126
+ credentials?: Credentials;
127
+ authType?: AuthType | AuthType[];
128
+ logLevel?: LogLevel;
129
+ dsn?: DsnOptions | undefined;
130
+ socketTimeoutMs?: number;
131
+ responseTimeoutMs?: number;
132
+ /**
133
+ * Send MAIL FROM and RCPT TO as one batch when the server advertises
134
+ * PIPELINING (RFC 2920). Defaults to true.
135
+ */
136
+ pipelining?: boolean;
137
+ /**
138
+ * Transfer the message with BDAT when the server advertises CHUNKING
139
+ * (RFC 3030), which avoids the dot-stuffing pass over the whole message.
140
+ * Defaults to false, because far fewer servers see BDAT traffic than DATA.
141
+ */
142
+ chunking?: boolean;
143
+ /**
144
+ * Deliver to the recipients the server accepted even if it rejected others.
145
+ * Defaults to false, so a rejected recipient still fails the whole message.
146
+ */
147
+ allowPartialRecipients?: boolean;
148
+ };
149
+ declare class WorkerMailer {
150
+ private socket;
151
+ private readonly host;
152
+ private readonly port;
153
+ private readonly secure;
154
+ private readonly startTls;
155
+ private readonly requireTls;
156
+ private readonly ehloName;
157
+ private readonly authType;
158
+ private readonly credentials?;
159
+ private readonly socketTimeoutMs;
160
+ private readonly responseTimeoutMs;
161
+ private readonly pipelining;
162
+ private readonly chunking;
163
+ private readonly allowPartialRecipients;
164
+ private reader;
165
+ private writer;
166
+ private decoder;
167
+ /** Bytes received but not yet consumed as a complete response. */
168
+ private buffer;
169
+ private streamClosed;
170
+ private readonly logger;
171
+ private readonly dsn;
172
+ private active;
173
+ private tlsActive;
174
+ private emailSending;
175
+ private emailToBeSent;
176
+ /** SMTP server capabilities **/
177
+ private supportsDSN;
178
+ private allowAuth;
179
+ private authTypeSupported;
180
+ private supportsStartTls;
181
+ private supportsPipelining;
182
+ private supportsChunking;
183
+ private supports8BitMime;
184
+ private maxMessageSize;
185
+ private constructor();
186
+ /**
187
+ * RFC 5321 §4.1.3 wants either a fully qualified domain or a bracketed
188
+ * address literal. A bare `127.0.0.1` is neither, and strict servers say so.
189
+ */
190
+ private static resolveEhloName;
191
+ static connect(options: WorkerMailerOptions): Promise<WorkerMailer>;
192
+ send(options: EmailOptions): Promise<SendResult>;
193
+ static send(options: WorkerMailerOptions, email: EmailOptions): Promise<SendResult>;
194
+ private readTimeout;
195
+ /**
196
+ * Reads exactly one SMTP response. Anything the server sent beyond it stays
197
+ * buffered — with pipelining several responses routinely arrive in a single
198
+ * TCP segment, and dropping the remainder desynchronizes the connection.
199
+ */
200
+ private readResponse;
201
+ /** Removes one complete response from the buffer, or null if none is ready. */
202
+ private takeResponse;
203
+ private writeLine;
204
+ private write;
205
+ private writeBytes;
206
+ private initializeSmtpSession;
207
+ private start;
208
+ close(error?: Error): Promise<void>;
209
+ private waitForSocketConnected;
210
+ private greet;
211
+ private ehlo;
212
+ private helo;
213
+ private tls;
214
+ private parseCapabilities;
215
+ private defaultAuthOrder;
216
+ private selectAuthType;
217
+ private auth;
218
+ private requirePassword;
219
+ private authWithPlain;
220
+ private authWithLogin;
221
+ private authWithCramMD5;
222
+ private authWithXOAuth2;
223
+ /**
224
+ * Runs one mail transaction. MAIL FROM and every RCPT TO go out as a single
225
+ * batch when the server supports pipelining, turning N+2 round trips into 2.
226
+ */
227
+ private transaction;
228
+ private mailFromCommand;
229
+ private rcptToCommand;
230
+ private checkSize;
231
+ private serializeOptions;
232
+ /** Ends a data phase we entered but do not want to complete. */
233
+ private abortData;
234
+ private sendWithData;
235
+ /**
236
+ * RFC 3030 BDAT. The message is length-delimited, so it needs no dot-stuffing
237
+ * and no terminator scan. Commands are pipelined; responses are tiny and read
238
+ * once everything is on the wire.
239
+ */
240
+ private sendWithChunking;
241
+ private rset;
242
+ private notificationBuilder;
243
+ private retBuilder;
244
+ }
245
+
246
+ export { type Attachment, type AttachmentContent, type AuthType, type Credentials, type DsnOptions, Email, type EmailOptions, LogLevel, type SendResult, type SerializeOptions, type User, WorkerMailer, type WorkerMailerOptions, encodeHeader, formatAddress };
@@ -0,0 +1,246 @@
1
+ declare function encodeHeader(text: string): string;
2
+ type User = {
3
+ name?: string;
4
+ email: string;
5
+ };
6
+ declare function formatAddress(user: User): string;
7
+ type AttachmentContent = string | ArrayBuffer | Uint8Array;
8
+ type Attachment = {
9
+ filename: string;
10
+ /** Base64 string, or raw bytes which are base64-encoded for you. */
11
+ content: AttachmentContent;
12
+ mimeType?: string;
13
+ };
14
+ type DsnOptions = {
15
+ envelopeId?: string;
16
+ RET?: {
17
+ HEADERS?: boolean;
18
+ FULL?: boolean;
19
+ };
20
+ NOTIFY?: {
21
+ DELAY?: boolean;
22
+ FAILURE?: boolean;
23
+ SUCCESS?: boolean;
24
+ };
25
+ };
26
+ type EmailOptions = {
27
+ from: string | User;
28
+ to: string | string[] | User | User[];
29
+ reply?: string | User;
30
+ cc?: string | string[] | User | User[];
31
+ bcc?: string | string[] | User | User[];
32
+ subject: string;
33
+ text?: string;
34
+ html?: string;
35
+ headers?: Record<string, string>;
36
+ attachments?: Attachment[];
37
+ dsnOverride?: DsnOptions;
38
+ };
39
+ type SendResult = {
40
+ /** Recipients the server accepted. */
41
+ accepted: User[];
42
+ /** Recipients the server rejected, with the response that rejected them. */
43
+ rejected: {
44
+ user: User;
45
+ response: string;
46
+ }[];
47
+ /** The server's final response to the message. */
48
+ response: string;
49
+ };
50
+ type SerializeOptions = {
51
+ /** Set when the server advertised 8BITMIME. */
52
+ allow8bit?: boolean;
53
+ };
54
+ declare class Email {
55
+ readonly from: User;
56
+ readonly to: User[];
57
+ readonly reply?: User;
58
+ readonly cc?: User[];
59
+ readonly bcc?: User[];
60
+ readonly subject: string;
61
+ readonly text?: string;
62
+ readonly html?: string;
63
+ readonly dsnOverride?: DsnOptions;
64
+ readonly attachments?: Attachment[];
65
+ readonly headers: Record<string, string>;
66
+ /** Populated once the server has accepted (or rejected) the message. */
67
+ result?: SendResult;
68
+ setSent: () => void;
69
+ setSentError: (e: unknown) => void;
70
+ sent: Promise<void>;
71
+ constructor(options: EmailOptions);
72
+ private static toUsers;
73
+ /** Every envelope recipient, in the order they are sent to the server. */
74
+ get recipients(): User[];
75
+ /**
76
+ * Rough upper bound on the wire size, used to reject a message locally when
77
+ * the server advertised a smaller SIZE limit.
78
+ */
79
+ estimateSize(): number;
80
+ /**
81
+ * Yields the message piece by piece. Every chunk starts on a line boundary so
82
+ * the caller can dot-stuff and write them one at a time instead of building
83
+ * the whole message — attachments included — as a single string.
84
+ */
85
+ chunks(options?: SerializeOptions): Generator<string>;
86
+ /** The complete, dot-stuffed message including the SMTP terminator. */
87
+ getEmailData(options?: SerializeOptions): string;
88
+ private buildContentPart;
89
+ private renderTextPart;
90
+ private renderAttachment;
91
+ private messageHeaders;
92
+ private hasHeader;
93
+ private resolveHeader;
94
+ private resolveAddressHeader;
95
+ private resolveSubject;
96
+ }
97
+
98
+ declare enum LogLevel {
99
+ DEBUG = 0,
100
+ INFO = 1,
101
+ WARN = 2,
102
+ ERROR = 3,
103
+ NONE = 4
104
+ }
105
+
106
+ type AuthType = 'plain' | 'login' | 'cram-md5' | 'xoauth2';
107
+ type Credentials = {
108
+ username: string;
109
+ password?: string;
110
+ /** OAuth 2.0 bearer token. Required for `xoauth2`, ignored otherwise. */
111
+ accessToken?: string;
112
+ };
113
+ type WorkerMailerOptions = {
114
+ host: string;
115
+ port: number;
116
+ secure?: boolean;
117
+ startTls?: boolean;
118
+ /** Refuse to authenticate or send over an unencrypted connection. */
119
+ requireTls?: boolean;
120
+ /**
121
+ * The name announced in EHLO. Should be a domain you control — receiving
122
+ * servers score it, and some reject a HELO name that is not a FQDN.
123
+ * A bare IP address is wrapped in the address literal syntax RFC 5321 wants.
124
+ */
125
+ ehloName?: string;
126
+ credentials?: Credentials;
127
+ authType?: AuthType | AuthType[];
128
+ logLevel?: LogLevel;
129
+ dsn?: DsnOptions | undefined;
130
+ socketTimeoutMs?: number;
131
+ responseTimeoutMs?: number;
132
+ /**
133
+ * Send MAIL FROM and RCPT TO as one batch when the server advertises
134
+ * PIPELINING (RFC 2920). Defaults to true.
135
+ */
136
+ pipelining?: boolean;
137
+ /**
138
+ * Transfer the message with BDAT when the server advertises CHUNKING
139
+ * (RFC 3030), which avoids the dot-stuffing pass over the whole message.
140
+ * Defaults to false, because far fewer servers see BDAT traffic than DATA.
141
+ */
142
+ chunking?: boolean;
143
+ /**
144
+ * Deliver to the recipients the server accepted even if it rejected others.
145
+ * Defaults to false, so a rejected recipient still fails the whole message.
146
+ */
147
+ allowPartialRecipients?: boolean;
148
+ };
149
+ declare class WorkerMailer {
150
+ private socket;
151
+ private readonly host;
152
+ private readonly port;
153
+ private readonly secure;
154
+ private readonly startTls;
155
+ private readonly requireTls;
156
+ private readonly ehloName;
157
+ private readonly authType;
158
+ private readonly credentials?;
159
+ private readonly socketTimeoutMs;
160
+ private readonly responseTimeoutMs;
161
+ private readonly pipelining;
162
+ private readonly chunking;
163
+ private readonly allowPartialRecipients;
164
+ private reader;
165
+ private writer;
166
+ private decoder;
167
+ /** Bytes received but not yet consumed as a complete response. */
168
+ private buffer;
169
+ private streamClosed;
170
+ private readonly logger;
171
+ private readonly dsn;
172
+ private active;
173
+ private tlsActive;
174
+ private emailSending;
175
+ private emailToBeSent;
176
+ /** SMTP server capabilities **/
177
+ private supportsDSN;
178
+ private allowAuth;
179
+ private authTypeSupported;
180
+ private supportsStartTls;
181
+ private supportsPipelining;
182
+ private supportsChunking;
183
+ private supports8BitMime;
184
+ private maxMessageSize;
185
+ private constructor();
186
+ /**
187
+ * RFC 5321 §4.1.3 wants either a fully qualified domain or a bracketed
188
+ * address literal. A bare `127.0.0.1` is neither, and strict servers say so.
189
+ */
190
+ private static resolveEhloName;
191
+ static connect(options: WorkerMailerOptions): Promise<WorkerMailer>;
192
+ send(options: EmailOptions): Promise<SendResult>;
193
+ static send(options: WorkerMailerOptions, email: EmailOptions): Promise<SendResult>;
194
+ private readTimeout;
195
+ /**
196
+ * Reads exactly one SMTP response. Anything the server sent beyond it stays
197
+ * buffered — with pipelining several responses routinely arrive in a single
198
+ * TCP segment, and dropping the remainder desynchronizes the connection.
199
+ */
200
+ private readResponse;
201
+ /** Removes one complete response from the buffer, or null if none is ready. */
202
+ private takeResponse;
203
+ private writeLine;
204
+ private write;
205
+ private writeBytes;
206
+ private initializeSmtpSession;
207
+ private start;
208
+ close(error?: Error): Promise<void>;
209
+ private waitForSocketConnected;
210
+ private greet;
211
+ private ehlo;
212
+ private helo;
213
+ private tls;
214
+ private parseCapabilities;
215
+ private defaultAuthOrder;
216
+ private selectAuthType;
217
+ private auth;
218
+ private requirePassword;
219
+ private authWithPlain;
220
+ private authWithLogin;
221
+ private authWithCramMD5;
222
+ private authWithXOAuth2;
223
+ /**
224
+ * Runs one mail transaction. MAIL FROM and every RCPT TO go out as a single
225
+ * batch when the server supports pipelining, turning N+2 round trips into 2.
226
+ */
227
+ private transaction;
228
+ private mailFromCommand;
229
+ private rcptToCommand;
230
+ private checkSize;
231
+ private serializeOptions;
232
+ /** Ends a data phase we entered but do not want to complete. */
233
+ private abortData;
234
+ private sendWithData;
235
+ /**
236
+ * RFC 3030 BDAT. The message is length-delimited, so it needs no dot-stuffing
237
+ * and no terminator scan. Commands are pipelined; responses are tiny and read
238
+ * once everything is on the wire.
239
+ */
240
+ private sendWithChunking;
241
+ private rset;
242
+ private notificationBuilder;
243
+ private retBuilder;
244
+ }
245
+
246
+ export { type Attachment, type AttachmentContent, type AuthType, type Credentials, type DsnOptions, Email, type EmailOptions, LogLevel, type SendResult, type SerializeOptions, type User, WorkerMailer, type WorkerMailerOptions, encodeHeader, formatAddress };
package/dist/index.js ADDED
@@ -0,0 +1,49 @@
1
+ "use strict";var A=Object.defineProperty;var q=Object.getOwnPropertyDescriptor;var _=Object.getOwnPropertyNames;var Y=Object.prototype.hasOwnProperty;var G=(s,e)=>{for(var t in e)A(s,t,{get:e[t],enumerable:!0})},Q=(s,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of _(e))!Y.call(s,n)&&n!==t&&A(s,n,{get:()=>e[n],enumerable:!(r=q(e,n))||r.enumerable});return s};var X=s=>Q(A({},"__esModule",{value:!0}),s);var ae={};G(ae,{Email:()=>w,LogLevel:()=>U,WorkerMailer:()=>C,encodeHeader:()=>E,formatAddress:()=>B});module.exports=X(ae);var T=class{values=[];resolvers=[];enqueue(e){this.resolvers.length||this.addWrapper(),this.resolvers.shift()(e)}async dequeue(){return this.values.length||this.addWrapper(),this.values.shift()}get length(){return this.values.length}clear(){this.values=[],this.resolvers=[]}addWrapper(){this.values.push(new Promise(e=>{this.resolvers.push(e)}))}};async function x(s,e,t){let r;try{return await Promise.race([s,new Promise((n,i)=>{r=setTimeout(()=>i(t),e)})])}finally{clearTimeout(r)}}var V=new TextEncoder;function h(s){return V.encode(s)}var le=new TextDecoder("utf-8");function R(s){return s.replace(/\r\n|\r|\n/g,`\r
2
+ `)}var Z=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],J=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function D(s=new Date){let e=t=>String(t).padStart(2,"0");return`${Z[s.getUTCDay()]}, ${e(s.getUTCDate())} ${J[s.getUTCMonth()]} ${s.getUTCFullYear()} ${e(s.getUTCHours())}:${e(s.getUTCMinutes())}:${e(s.getUTCSeconds())} +0000`}function O(s){return s.replace(/\r\n[ \t]+/g," ").replace(/[\r\n]+/g," ")}function d(s,e,t=78){let r=O(e),n=`${s}: `;if(n.length+r.length<=t)return n+r;let i=[],a=n,o=!0;for(let l of r.split(" ")){if(l===""){a+=" ";continue}o?(a+=l,o=!1):a.length+1+l.length>t?(i.push(a),a=" "+l):a+=" "+l}return i.push(a),i.join(`\r
3
+ `)}var K=/[A-Za-z0-9!#$&+\-.^_`|~]/;function $(s,e){let t=O(e);if(!/[^\x20-\x7E]/.test(t))return`${s}="${t.replace(/([\\"])/g,"\\$1")}"`;let r="";for(let n of h(t)){let i=String.fromCharCode(n);r+=K.test(i)?i:`%${n.toString(16).toUpperCase().padStart(2,"0")}`}return`${s}*=UTF-8''${r}`}function p(s){let e="";for(let r=0;r<s.length;r+=32768)e+=String.fromCharCode(...s.subarray(r,r+32768));return btoa(e)}function M(s){let e=atob(s.replace(/\s+/g,"")),t=new Uint8Array(e.length);for(let r=0;r<e.length;r++)t[r]=e.charCodeAt(r);return t}function v(s,e=76){let t=s.replace(/\s+/g,"");if(t.length<=e)return t;let r=[];for(let n=0;n<t.length;n+=e)r.push(t.slice(n,n+e));return r.join(`\r
4
+ `)}function S(){let s=!0;return e=>{if(!e)return e;let t=e.replace(/\r\n\./g,`\r
5
+ ..`);return s&&t.startsWith(".")&&(t=`.${t}`),s=t.endsWith(`\r
6
+ `),t}}function W(s){return s>126||s===61||s<32&&s!==9&&s!==10&&s!==13}function L(s){let e=0;for(let t of s)W(t)&&e++;return s.length+e*2}function P(s,e=76){let t=h(s),r=e-3,n=[],i="",a=0,o=()=>{let l=i.charCodeAt(i.length-1);l===32?i=i.slice(0,-1)+"=20":l===9&&(i=i.slice(0,-1)+"=09"),n.push(i,`=\r
7
+ `),i=""};for(;a<t.length;){let l=t[a],c;if(l===10){n.push(i,`\r
8
+ `),i="",a++;continue}else if(l===13){if(a+1<t.length&&t[a+1]===10){n.push(i,`\r
9
+ `),i="",a+=2;continue}c="=0D"}if(c===void 0){let m=l===32||l===9,f=a+1>=t.length||t[a+1]===10||t[a+1]===13;c=W(l)||m&&f?`=${l.toString(16).toUpperCase().padStart(2,"0")}`:String.fromCharCode(l)}i.length+c.length>r&&o(),i+=c,a++}return n.push(i),n.join("")}var I="=?UTF-8?Q?",H="?=",ee=75-I.length-H.length;function E(s){if(!/[^\x20-\x7E]/.test(s))return s;let e=[],t="";for(let r of s){let n="";for(let i of h(r))i>=33&&i<=126&&i!==63&&i!==61&&i!==95?n+=String.fromCharCode(i):i===32?n+="_":n+=`=${i.toString(16).toUpperCase().padStart(2,"0")}`;t.length+n.length>ee&&(e.push(t),t=""),t+=n}return t&&e.push(t),e.map(r=>`${I}${r}${H}`).join(" ")}function B(s){if(!s.name)return s.email;let e=E(s.name);return e===s.name?`"${s.name.replace(/([\\"])/g,"\\$1")}" <${s.email}>`:`${e} <${s.email}>`}function F(s,e){let t=R(s),r=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(t),n=/[ \t]\r\n|[ \t]$/.test(t),i=0;for(let l of t.split(`\r
10
+ `))l.length>i&&(i=l.length);if(!r&&!n){if(i<=990&&!/[^\x00-\x7F]/.test(t))return{encoding:"7bit",render:()=>t};if(e&&i<=240)return{encoding:"8bit",render:()=>t}}let a=h(t),o=Math.ceil(a.length/3)*4;return L(a)<=o?{encoding:"quoted-printable",render:()=>P(t)}:{encoding:"base64",render:()=>v(p(a))}}function te(s){return s instanceof Uint8Array?s.length:s.byteLength}var w=class s{from;to;reply;cc;bcc;subject;text;html;dsnOverride;attachments;headers;result;setSent;setSentError;sent=new Promise((e,t)=>{this.setSent=e,this.setSentError=t});constructor(e){if(!e.text&&!e.html)throw new Error("At least one of text or html must be provided");typeof e.from=="string"?this.from={email:e.from}:this.from=e.from,typeof e.reply=="string"?this.reply={email:e.reply}:this.reply=e.reply,this.to=s.toUsers(e.to),this.cc=s.toUsers(e.cc),this.bcc=s.toUsers(e.bcc),this.subject=e.subject,this.text=e.text,this.html=e.html,this.attachments=e.attachments,this.dsnOverride=e.dsnOverride,this.headers=e.headers||{}}static toUsers(e){if(e)return typeof e=="string"?[{email:e}]:Array.isArray(e)?e.map(t=>typeof t=="string"?{email:t}:t):[e]}get recipients(){return[...this.to,...this.cc||[],...this.bcc||[]]}estimateSize(){let e=2048;this.text&&(e+=Math.ceil(h(this.text).length*1.4)),this.html&&(e+=Math.ceil(h(this.html).length*1.4));for(let t of this.attachments||[])e+=typeof t.content=="string"?t.content.length:Math.ceil(te(t.content)/3)*4,e+=512;return e}*chunks(e={}){let t=!!e.allow8bit,r=this.messageHeaders(),n=this.buildContentPart(t);if(!this.attachments?.length){yield`${[...r,...n.headers].join(`\r
11
+ `)}\r
12
+ \r
13
+ `,yield*n.body();return}let i=N("mixed");yield`${[...r,d("Content-Type",`multipart/mixed; boundary="${i}"`)].join(`\r
14
+ `)}\r
15
+ \r
16
+ `,yield`--${i}\r
17
+ ${n.headers.join(`\r
18
+ `)}\r
19
+ \r
20
+ `,yield*n.body();for(let a of this.attachments)yield*this.renderAttachment(i,a);yield`--${i}--\r
21
+ `}getEmailData(e={}){let t=S(),r="";for(let n of this.chunks(e))r+=t(n);return r.endsWith(`\r
22
+ `)||(r+=`\r
23
+ `),`${r}.\r
24
+ `}buildContentPart(e){let t=this.text,r=this.html;if(t!==void 0&&r!==void 0){let a=N("alternative"),o=this.renderTextPart.bind(this);return{headers:[d("Content-Type",`multipart/alternative; boundary="${a}"`)],*body(){yield*o(a,"text/plain",t,e),yield*o(a,"text/html",r,e),yield`--${a}--\r
25
+ `}}}let n=t!==void 0?"text/plain":"text/html",i=F(t??r,e);return{headers:[d("Content-Type",`${n}; charset="UTF-8"`),`Content-Transfer-Encoding: ${i.encoding}`],*body(){yield`${i.render()}\r
26
+ `}}}*renderTextPart(e,t,r,n){let i=F(r,n);yield`--${e}\r
27
+ `+d("Content-Type",`${t}; charset="UTF-8"`)+`\r
28
+ Content-Transfer-Encoding: ${i.encoding}\r
29
+ \r
30
+ `,yield`${i.render()}\r
31
+ `}*renderAttachment(e,t){let r=t.mimeType||ie(t.filename),n=[d("Content-Type",`${r}; ${$("name",t.filename)}`),d("Content-Description",E(t.filename)),d("Content-Disposition",`attachment; ${$("filename",t.filename)}`),"Content-Transfer-Encoding: base64"];if(yield`--${e}\r
32
+ ${n.join(`\r
33
+ `)}\r
34
+ \r
35
+ `,typeof t.content=="string"){yield`${v(t.content)}\r
36
+ `;return}let i=t.content instanceof Uint8Array?t.content:new Uint8Array(t.content),a=57*96;for(let o=0;o<i.length;o+=a)yield`${v(p(i.subarray(o,o+a)))}\r
37
+ `}messageHeaders(){this.resolveHeader();let e=["MIME-Version: 1.0"];for(let[t,r]of Object.entries(this.headers))e.push(d(t,r));return e}hasHeader(e){let t=e.toLowerCase();return Object.keys(this.headers).some(r=>r.toLowerCase()===t)}resolveHeader(){this.resolveAddressHeader("From",[this.from]),this.resolveAddressHeader("To",this.to),this.resolveAddressHeader("Reply-To",this.reply?[this.reply]:void 0),this.resolveAddressHeader("Cc",this.cc),this.resolveSubject(),this.hasHeader("Date")||(this.headers.Date=D()),this.hasHeader("Message-ID")||(this.headers["Message-ID"]=`<${crypto.randomUUID()}@${this.from.email.split("@").pop()}>`)}resolveAddressHeader(e,t){!t?.length||this.hasHeader(e)||(this.headers[e]=t.map(B).join(", "))}resolveSubject(){this.hasHeader("Subject")||!this.subject||(this.headers.Subject=E(this.subject))}};function N(s){let e=new Uint8Array(16);crypto.getRandomValues(e);let t=Array.from(e).map(r=>r.toString(16).padStart(2,"0")).join("");return`${s}_${t}`}var re={txt:"text/plain",html:"text/html",csv:"text/csv",pdf:"application/pdf",png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",zip:"application/zip"};function ie(s){let e=s.split(".").pop()?.toLowerCase();return re[e||"txt"]||"application/octet-stream"}var j=require("cloudflare:sockets");var U=(i=>(i[i.DEBUG=0]="DEBUG",i[i.INFO=1]="INFO",i[i.WARN=2]="WARN",i[i.ERROR=3]="ERROR",i[i.NONE=4]="NONE",i))(U||{}),y=class{constructor(e=1,t){this.level=e;this.prefix=t}prefix;debug(e,...t){this.level<=0&&console.debug(this.prefix+e,...t)}info(e,...t){this.level<=1&&console.info(this.prefix+e,...t)}warn(e,...t){this.level<=2&&console.warn(this.prefix+e,...t)}error(e,...t){this.level<=3&&console.error(this.prefix+e,...t)}};var se=262144,ne=32768,C=class s{socket;host;port;secure;startTls;requireTls;ehloName;authType;credentials;socketTimeoutMs;responseTimeoutMs;pipelining;chunking;allowPartialRecipients;reader;writer;decoder=new TextDecoder("utf-8");buffer="";streamClosed=!1;logger;dsn;active=!1;tlsActive=!1;emailSending=null;emailToBeSent=new T;supportsDSN=!1;allowAuth=!1;authTypeSupported=[];supportsStartTls=!1;supportsPipelining=!1;supportsChunking=!1;supports8BitMime=!1;maxMessageSize=0;constructor(e){this.port=e.port,this.host=e.host,this.secure=!!e.secure,Array.isArray(e.authType)?this.authType=e.authType:typeof e.authType=="string"?this.authType=[e.authType]:this.authType=[],this.startTls=e.startTls===void 0?!0:e.startTls,this.requireTls=!!e.requireTls,this.credentials=e.credentials,this.dsn=e.dsn||{},this.pipelining=e.pipelining!==!1,this.chunking=!!e.chunking,this.allowPartialRecipients=!!e.allowPartialRecipients,this.socketTimeoutMs=e.socketTimeoutMs||6e4,this.responseTimeoutMs=e.responseTimeoutMs||3e4,this.tlsActive=this.secure,this.socket=(0,j.connect)({hostname:this.host,port:this.port},{secureTransport:this.secure?"on":this.startTls?"starttls":"off",allowHalfOpen:!1}),this.reader=this.socket.readable.getReader(),this.writer=this.socket.writable.getWriter(),this.logger=new y(e.logLevel,`[WorkerMailer:${this.host}:${this.port}]`),this.ehloName=s.resolveEhloName(e.ehloName),e.ehloName||this.logger.warn("No ehloName set, announcing [127.0.0.1]. Set ehloName to a domain you control \u2014 receiving servers use it for spam scoring.")}static resolveEhloName(e){let t=e?.trim();return t?/^\[.+\]$/.test(t)?t:/^\d{1,3}(\.\d{1,3}){3}$/.test(t)?`[${t}]`:t.includes(":")?`[IPv6:${t}]`:t:"[127.0.0.1]"}static async connect(e){let t=new s(e);return await t.initializeSmtpSession(),t.start().catch(console.error),t}async send(e){if(!this.active)throw new Error("WorkerMailer is not connected");let t=new w(e);return this.emailToBeSent.enqueue(t),await t.sent,t.result}static async send(e,t){let r=await s.connect(e);try{return await r.send(t)}finally{await r.close()}}async readTimeout(){return x(this.readResponse(),this.responseTimeoutMs,new Error("Timeout while waiting for smtp server response"))}async readResponse(){for(;;){let e=this.takeResponse();if(e!==null)return e;if(this.streamClosed)throw new Error("SMTP server closed the connection");let{value:t,done:r}=await this.reader.read();if(r){this.streamClosed=!0;continue}if(!t)continue;let n=this.decoder.decode(t,{stream:!0});this.logger.debug(`SMTP server response:
38
+ `+n),this.buffer+=n}}takeResponse(){let e=0;for(;;){let t=this.buffer.indexOf(`
39
+ `,e);if(t===-1)return null;if(!/^\d{3}-/.test(this.buffer.slice(e,t))){let r=this.buffer.slice(0,t+1);return this.buffer=this.buffer.slice(t+1),r}e=t+1}}async writeLine(e){await this.write(`${e}\r
40
+ `)}async write(e,t=!0){this.logger.debug(t?`Write to socket:
41
+ `+e:`Write to socket: ${e.length} characters`),await this.writer.write(h(e))}async writeBytes(e){this.logger.debug(`Write to socket: ${e.byteLength} bytes`),await this.writer.write(e)}async initializeSmtpSession(){if(await this.waitForSocketConnected(),await this.greet(),await this.ehlo(),this.startTls&&!this.secure&&this.supportsStartTls&&(await this.tls(),await this.ehlo()),this.requireTls&&!this.tlsActive)throw new Error("requireTls is set but the connection is not encrypted (server does not support STARTTLS)");await this.auth(),this.active=!0}async start(){for(;this.active;){this.emailSending=await this.emailToBeSent.dequeue();try{this.emailSending.result=await this.transaction(this.emailSending),this.emailSending.setSent()}catch(e){if(this.logger.error("Failed to send email: "+e.message),!this.active)return;this.emailSending.setSentError(e);try{await this.rset()}catch(t){await this.close(t)}}this.emailSending=null}}async close(e){for(this.active=!1,this.logger.info("WorkerMailer is closed",e?.message||""),this.emailSending?.setSentError?.(e||new Error("WorkerMailer is shutting down"));this.emailToBeSent.length;)(await this.emailToBeSent.dequeue()).setSentError(e||new Error("WorkerMailer is shutting down"));try{await this.writeLine("QUIT"),await this.readTimeout(),this.socket.close().catch(()=>this.logger.error("Failed to close socket"))}catch{}}async waitForSocketConnected(){this.logger.info("Connecting to SMTP server"),await x(this.socket.opened,this.socketTimeoutMs,new Error("Socket timeout!")),this.logger.info("SMTP server connected")}async greet(){let e=await this.readTimeout();if(!e.startsWith("220"))throw new Error("Failed to connect to SMTP server: "+e)}async ehlo(){await this.writeLine(`EHLO ${this.ehloName}`);let e=await this.readTimeout();if(e.startsWith("421"))throw new Error(`Failed to EHLO. ${e}`);if(!e.startsWith("2")){await this.helo();return}this.parseCapabilities(e)}async helo(){await this.writeLine(`HELO ${this.ehloName}`);let e=await this.readTimeout();if(!e.startsWith("2"))throw new Error(`Failed to HELO. ${e}`)}async tls(){await this.writeLine("STARTTLS");let e=await this.readTimeout();if(!e.startsWith("220"))throw new Error("Failed to start TLS: "+e);this.reader.releaseLock(),this.writer.releaseLock(),this.socket=this.socket.startTls(),this.reader=this.socket.readable.getReader(),this.writer=this.socket.writable.getWriter(),this.decoder=new TextDecoder("utf-8"),this.buffer="",this.tlsActive=!0}parseCapabilities(e){this.allowAuth=!1,this.authTypeSupported=[],this.supportsStartTls=!1,this.supportsDSN=!1,this.supportsPipelining=!1,this.supportsChunking=!1,this.supports8BitMime=!1,this.maxMessageSize=0;let t=new Set;for(let r of e.split(/\r?\n/)){let n=r.replace(/^\d{3}[- ]?/,"").trim();if(!n)continue;let[i,...a]=n.split(/[ =]+/);switch(i.toUpperCase()){case"AUTH":this.allowAuth=!0;for(let o of a)switch(o.toUpperCase()){case"PLAIN":t.add("plain");break;case"LOGIN":t.add("login");break;case"CRAM-MD5":t.add("cram-md5");break;case"XOAUTH2":t.add("xoauth2");break}break;case"STARTTLS":this.supportsStartTls=!0;break;case"DSN":this.supportsDSN=!0;break;case"PIPELINING":this.supportsPipelining=!0;break;case"CHUNKING":this.supportsChunking=!0;break;case"8BITMIME":this.supports8BitMime=!0;break;case"SIZE":this.maxMessageSize=Number(a[0])||0;break}}this.authTypeSupported=[...t]}defaultAuthOrder(){return this.tlsActive?["plain","login","cram-md5"]:["cram-md5","plain","login"]}selectAuthType(){return(this.authType.length?this.authType:this.defaultAuthOrder()).find(t=>this.authTypeSupported.includes(t))}async auth(){if(this.allowAuth){if(!this.credentials)throw new Error("smtp server requires authentication, but no credentials found");switch(this.tlsActive||this.logger.warn("Authenticating over an unencrypted connection, credentials are sent in the clear"),this.selectAuthType()){case"plain":return this.authWithPlain();case"login":return this.authWithLogin();case"cram-md5":return this.authWithCramMD5();case"xoauth2":return this.authWithXOAuth2();default:throw new Error("No supported auth method found.")}}}requirePassword(){let e=this.credentials?.password;if(e===void 0)throw new Error("credentials.password is required for this auth method");return e}async authWithPlain(){let e=this.requirePassword(),t=p(h(`\0${this.credentials.username}\0${e}`));await this.writeLine(`AUTH PLAIN ${t}`);let r=await this.readTimeout();if(!r.startsWith("2"))throw new Error(`Failed to plain authentication: ${r}`)}async authWithLogin(){let e=this.requirePassword();await this.writeLine("AUTH LOGIN");let t=await this.readTimeout();if(!t.startsWith("3"))throw new Error("Invalid login: "+t);await this.writeLine(p(h(this.credentials.username)));let r=await this.readTimeout();if(!r.startsWith("3"))throw new Error("Failed to login authentication: "+r);await this.writeLine(p(h(e)));let n=await this.readTimeout();if(!n.startsWith("2"))throw new Error("Failed to login authentication: "+n)}async authWithCramMD5(){let e=this.requirePassword();await this.writeLine("AUTH CRAM-MD5");let t=await this.readTimeout(),r=t.match(/^334\s+(.+)$/m)?.pop()?.trim();if(!r)throw new Error("Invalid CRAM-MD5 challenge: "+t);let n=await crypto.subtle.importKey("raw",h(e),{name:"HMAC",hash:"MD5"},!1,["sign"]),i=await crypto.subtle.sign("HMAC",n,M(r)),a=Array.from(new Uint8Array(i)).map(l=>l.toString(16).padStart(2,"0")).join("");await this.writeLine(p(h(`${this.credentials.username} ${a}`)));let o=await this.readTimeout();if(!o.startsWith("2"))throw new Error(`Failed to cram-md5 authentication: ${o}`)}async authWithXOAuth2(){let e=this.credentials?.accessToken;if(!e)throw new Error("credentials.accessToken is required for xoauth2");let t=p(h(`user=${this.credentials.username}auth=Bearer ${e}`));await this.writeLine(`AUTH XOAUTH2 ${t}`);let r=await this.readTimeout();if(!r.startsWith("2"))throw r.startsWith("3")?(await this.writeLine(""),new Error(`Failed to xoauth2 authentication: ${await this.readTimeout()}`)):new Error(`Failed to xoauth2 authentication: ${r}`)}async transaction(e){let t=e.recipients;if(!t.length)throw new Error("Email has no recipients");this.checkSize(e);let r=this.chunking&&this.supportsChunking,n=this.mailFromCommand(e),i=t.map(u=>this.rcptToCommand(e,u)),a=!r&&(this.allowPartialRecipients||t.length===1),o,l=[],c=null;if(this.pipelining&&this.supportsPipelining){let u=[n,...i];a&&u.push("DATA"),await this.write(u.map(g=>`${g}\r
42
+ `).join("")),o=await this.readTimeout();for(let g of i)l.push(await this.readTimeout());a&&(c=await this.readTimeout())}else if(await this.writeLine(n),o=await this.readTimeout(),o.startsWith("2"))for(let u of i)await this.writeLine(u),l.push(await this.readTimeout());if(!o.startsWith("2"))throw new Error(`Invalid ${n} ${o}`);let m=[],f=[];if(t.forEach((u,g)=>{let k=l[g];k?.startsWith("2")?m.push(u):f.push({user:u,response:(k||"").trim()})}),f.length&&(!m.length||!this.allowPartialRecipients)){c?.startsWith("3")&&await this.abortData();let{user:u,response:g}=f[0];throw new Error(`Invalid RCPT TO: <${u.email}> ${g}`)}f.length&&this.logger.warn(`Server rejected ${f.length} of ${t.length} recipients`);let z=r?await this.sendWithChunking(e):await this.sendWithData(e,c);return{accepted:m,rejected:f,response:z.trim()}}mailFromCommand(e){let t=`MAIL FROM: <${e.from.email}>`;if(this.supportsDSN){let r=this.retBuilder(e);r&&(t+=` ${r}`),e.dsnOverride?.envelopeId&&(t+=` ENVID=${e.dsnOverride.envelopeId}`)}return t}rcptToCommand(e,t){let r=`RCPT TO: <${t.email}>`;return this.supportsDSN&&(r+=this.notificationBuilder(e)),r}checkSize(e){if(!this.maxMessageSize)return;let t=e.estimateSize();if(t>this.maxMessageSize)throw new Error(`Message is roughly ${t} bytes but the server accepts at most ${this.maxMessageSize} bytes`)}serializeOptions(){return{allow8bit:this.supports8BitMime}}async abortData(){await this.write(`\r
43
+ .\r
44
+ `),await this.readTimeout()}async sendWithData(e,t){let r=t;if(r===null&&(await this.writeLine("DATA"),r=await this.readTimeout()),!r.startsWith("3"))throw new Error(`Failed to send DATA: ${r}`);let n=S(),i="",a=!0;for(let l of e.chunks(this.serializeOptions())){let c=n(l);a=c.endsWith(`\r
45
+ `),i+=c,i.length>=ne&&(await this.write(i,!1),i="")}i+=a?`.\r
46
+ `:`\r
47
+ .\r
48
+ `,await this.write(i,!1);let o=await this.readTimeout();if(!o.startsWith("2"))throw new Error("Failed send email body: "+o);return o}async sendWithChunking(e){let t="",r=0,n=async a=>{let o=h(t);t="",await this.write(`BDAT ${o.byteLength}${a?" LAST":""}\r
49
+ `),await this.writeBytes(o),r++};for(let a of e.chunks(this.serializeOptions()))t+=a,t.length>=se&&await n(!1);await n(!0);let i="";for(let a=0;a<r;a++)if(i=await this.readTimeout(),!i.startsWith("2"))throw new Error("Failed send email body: "+i);return i}async rset(){await this.writeLine("RSET");let e=await this.readTimeout();if(!e.startsWith("2"))throw new Error(`Failed to reset: ${e}`)}notificationBuilder(e){let t=e.dsnOverride?.NOTIFY,r=t?void 0:this.dsn?.NOTIFY,n=[];return(t?.SUCCESS||r?.SUCCESS)&&n.push("SUCCESS"),(t?.FAILURE||r?.FAILURE)&&n.push("FAILURE"),(t?.DELAY||r?.DELAY)&&n.push("DELAY"),n.length>0?` NOTIFY=${n.join(",")}`:" NOTIFY=NEVER"}retBuilder(e){let t=e.dsnOverride?.RET,r=t?void 0:this.dsn?.RET,n=[];return(t?.HEADERS||r?.HEADERS)&&n.push("HDRS"),(t?.FULL||r?.FULL)&&n.push("FULL"),n.length>0?`RET=${n.join(",")}`:""}};0&&(module.exports={Email,LogLevel,WorkerMailer,encodeHeader,formatAddress});
package/dist/index.mjs ADDED
@@ -0,0 +1,49 @@
1
+ var y=class{values=[];resolvers=[];enqueue(e){this.resolvers.length||this.addWrapper(),this.resolvers.shift()(e)}async dequeue(){return this.values.length||this.addWrapper(),this.values.shift()}get length(){return this.values.length}clear(){this.values=[],this.resolvers=[]}addWrapper(){this.values.push(new Promise(e=>{this.resolvers.push(e)}))}};async function A(s,e,t){let r;try{return await Promise.race([s,new Promise((n,i)=>{r=setTimeout(()=>i(t),e)})])}finally{clearTimeout(r)}}var j=new TextEncoder;function h(s){return j.encode(s)}var ee=new TextDecoder("utf-8");function C(s){return s.replace(/\r\n|\r|\n/g,`\r
2
+ `)}var z=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],q=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function k(s=new Date){let e=t=>String(t).padStart(2,"0");return`${z[s.getUTCDay()]}, ${e(s.getUTCDate())} ${q[s.getUTCMonth()]} ${s.getUTCFullYear()} ${e(s.getUTCHours())}:${e(s.getUTCMinutes())}:${e(s.getUTCSeconds())} +0000`}function R(s){return s.replace(/\r\n[ \t]+/g," ").replace(/[\r\n]+/g," ")}function d(s,e,t=78){let r=R(e),n=`${s}: `;if(n.length+r.length<=t)return n+r;let i=[],a=n,o=!0;for(let l of r.split(" ")){if(l===""){a+=" ";continue}o?(a+=l,o=!1):a.length+1+l.length>t?(i.push(a),a=" "+l):a+=" "+l}return i.push(a),i.join(`\r
3
+ `)}var _=/[A-Za-z0-9!#$&+\-.^_`|~]/;function x(s,e){let t=R(e);if(!/[^\x20-\x7E]/.test(t))return`${s}="${t.replace(/([\\"])/g,"\\$1")}"`;let r="";for(let n of h(t)){let i=String.fromCharCode(n);r+=_.test(i)?i:`%${n.toString(16).toUpperCase().padStart(2,"0")}`}return`${s}*=UTF-8''${r}`}function p(s){let e="";for(let r=0;r<s.length;r+=32768)e+=String.fromCharCode(...s.subarray(r,r+32768));return btoa(e)}function D(s){let e=atob(s.replace(/\s+/g,"")),t=new Uint8Array(e.length);for(let r=0;r<e.length;r++)t[r]=e.charCodeAt(r);return t}function v(s,e=76){let t=s.replace(/\s+/g,"");if(t.length<=e)return t;let r=[];for(let n=0;n<t.length;n+=e)r.push(t.slice(n,n+e));return r.join(`\r
4
+ `)}function S(){let s=!0;return e=>{if(!e)return e;let t=e.replace(/\r\n\./g,`\r
5
+ ..`);return s&&t.startsWith(".")&&(t=`.${t}`),s=t.endsWith(`\r
6
+ `),t}}function O(s){return s>126||s===61||s<32&&s!==9&&s!==10&&s!==13}function M(s){let e=0;for(let t of s)O(t)&&e++;return s.length+e*2}function W(s,e=76){let t=h(s),r=e-3,n=[],i="",a=0,o=()=>{let l=i.charCodeAt(i.length-1);l===32?i=i.slice(0,-1)+"=20":l===9&&(i=i.slice(0,-1)+"=09"),n.push(i,`=\r
7
+ `),i=""};for(;a<t.length;){let l=t[a],c;if(l===10){n.push(i,`\r
8
+ `),i="",a++;continue}else if(l===13){if(a+1<t.length&&t[a+1]===10){n.push(i,`\r
9
+ `),i="",a+=2;continue}c="=0D"}if(c===void 0){let m=l===32||l===9,f=a+1>=t.length||t[a+1]===10||t[a+1]===13;c=O(l)||m&&f?`=${l.toString(16).toUpperCase().padStart(2,"0")}`:String.fromCharCode(l)}i.length+c.length>r&&o(),i+=c,a++}return n.push(i),n.join("")}var F="=?UTF-8?Q?",N="?=",Y=75-F.length-N.length;function $(s){if(!/[^\x20-\x7E]/.test(s))return s;let e=[],t="";for(let r of s){let n="";for(let i of h(r))i>=33&&i<=126&&i!==63&&i!==61&&i!==95?n+=String.fromCharCode(i):i===32?n+="_":n+=`=${i.toString(16).toUpperCase().padStart(2,"0")}`;t.length+n.length>Y&&(e.push(t),t=""),t+=n}return t&&e.push(t),e.map(r=>`${F}${r}${N}`).join(" ")}function G(s){if(!s.name)return s.email;let e=$(s.name);return e===s.name?`"${s.name.replace(/([\\"])/g,"\\$1")}" <${s.email}>`:`${e} <${s.email}>`}function L(s,e){let t=C(s),r=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(t),n=/[ \t]\r\n|[ \t]$/.test(t),i=0;for(let l of t.split(`\r
10
+ `))l.length>i&&(i=l.length);if(!r&&!n){if(i<=990&&!/[^\x00-\x7F]/.test(t))return{encoding:"7bit",render:()=>t};if(e&&i<=240)return{encoding:"8bit",render:()=>t}}let a=h(t),o=Math.ceil(a.length/3)*4;return M(a)<=o?{encoding:"quoted-printable",render:()=>W(t)}:{encoding:"base64",render:()=>v(p(a))}}function Q(s){return s instanceof Uint8Array?s.length:s.byteLength}var E=class s{from;to;reply;cc;bcc;subject;text;html;dsnOverride;attachments;headers;result;setSent;setSentError;sent=new Promise((e,t)=>{this.setSent=e,this.setSentError=t});constructor(e){if(!e.text&&!e.html)throw new Error("At least one of text or html must be provided");typeof e.from=="string"?this.from={email:e.from}:this.from=e.from,typeof e.reply=="string"?this.reply={email:e.reply}:this.reply=e.reply,this.to=s.toUsers(e.to),this.cc=s.toUsers(e.cc),this.bcc=s.toUsers(e.bcc),this.subject=e.subject,this.text=e.text,this.html=e.html,this.attachments=e.attachments,this.dsnOverride=e.dsnOverride,this.headers=e.headers||{}}static toUsers(e){if(e)return typeof e=="string"?[{email:e}]:Array.isArray(e)?e.map(t=>typeof t=="string"?{email:t}:t):[e]}get recipients(){return[...this.to,...this.cc||[],...this.bcc||[]]}estimateSize(){let e=2048;this.text&&(e+=Math.ceil(h(this.text).length*1.4)),this.html&&(e+=Math.ceil(h(this.html).length*1.4));for(let t of this.attachments||[])e+=typeof t.content=="string"?t.content.length:Math.ceil(Q(t.content)/3)*4,e+=512;return e}*chunks(e={}){let t=!!e.allow8bit,r=this.messageHeaders(),n=this.buildContentPart(t);if(!this.attachments?.length){yield`${[...r,...n.headers].join(`\r
11
+ `)}\r
12
+ \r
13
+ `,yield*n.body();return}let i=P("mixed");yield`${[...r,d("Content-Type",`multipart/mixed; boundary="${i}"`)].join(`\r
14
+ `)}\r
15
+ \r
16
+ `,yield`--${i}\r
17
+ ${n.headers.join(`\r
18
+ `)}\r
19
+ \r
20
+ `,yield*n.body();for(let a of this.attachments)yield*this.renderAttachment(i,a);yield`--${i}--\r
21
+ `}getEmailData(e={}){let t=S(),r="";for(let n of this.chunks(e))r+=t(n);return r.endsWith(`\r
22
+ `)||(r+=`\r
23
+ `),`${r}.\r
24
+ `}buildContentPart(e){let t=this.text,r=this.html;if(t!==void 0&&r!==void 0){let a=P("alternative"),o=this.renderTextPart.bind(this);return{headers:[d("Content-Type",`multipart/alternative; boundary="${a}"`)],*body(){yield*o(a,"text/plain",t,e),yield*o(a,"text/html",r,e),yield`--${a}--\r
25
+ `}}}let n=t!==void 0?"text/plain":"text/html",i=L(t??r,e);return{headers:[d("Content-Type",`${n}; charset="UTF-8"`),`Content-Transfer-Encoding: ${i.encoding}`],*body(){yield`${i.render()}\r
26
+ `}}}*renderTextPart(e,t,r,n){let i=L(r,n);yield`--${e}\r
27
+ `+d("Content-Type",`${t}; charset="UTF-8"`)+`\r
28
+ Content-Transfer-Encoding: ${i.encoding}\r
29
+ \r
30
+ `,yield`${i.render()}\r
31
+ `}*renderAttachment(e,t){let r=t.mimeType||V(t.filename),n=[d("Content-Type",`${r}; ${x("name",t.filename)}`),d("Content-Description",$(t.filename)),d("Content-Disposition",`attachment; ${x("filename",t.filename)}`),"Content-Transfer-Encoding: base64"];if(yield`--${e}\r
32
+ ${n.join(`\r
33
+ `)}\r
34
+ \r
35
+ `,typeof t.content=="string"){yield`${v(t.content)}\r
36
+ `;return}let i=t.content instanceof Uint8Array?t.content:new Uint8Array(t.content),a=57*96;for(let o=0;o<i.length;o+=a)yield`${v(p(i.subarray(o,o+a)))}\r
37
+ `}messageHeaders(){this.resolveHeader();let e=["MIME-Version: 1.0"];for(let[t,r]of Object.entries(this.headers))e.push(d(t,r));return e}hasHeader(e){let t=e.toLowerCase();return Object.keys(this.headers).some(r=>r.toLowerCase()===t)}resolveHeader(){this.resolveAddressHeader("From",[this.from]),this.resolveAddressHeader("To",this.to),this.resolveAddressHeader("Reply-To",this.reply?[this.reply]:void 0),this.resolveAddressHeader("Cc",this.cc),this.resolveSubject(),this.hasHeader("Date")||(this.headers.Date=k()),this.hasHeader("Message-ID")||(this.headers["Message-ID"]=`<${crypto.randomUUID()}@${this.from.email.split("@").pop()}>`)}resolveAddressHeader(e,t){!t?.length||this.hasHeader(e)||(this.headers[e]=t.map(G).join(", "))}resolveSubject(){this.hasHeader("Subject")||!this.subject||(this.headers.Subject=$(this.subject))}};function P(s){let e=new Uint8Array(16);crypto.getRandomValues(e);let t=Array.from(e).map(r=>r.toString(16).padStart(2,"0")).join("");return`${s}_${t}`}var X={txt:"text/plain",html:"text/html",csv:"text/csv",pdf:"application/pdf",png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",zip:"application/zip"};function V(s){let e=s.split(".").pop()?.toLowerCase();return X[e||"txt"]||"application/octet-stream"}import{connect as Z}from"cloudflare:sockets";var I=(i=>(i[i.DEBUG=0]="DEBUG",i[i.INFO=1]="INFO",i[i.WARN=2]="WARN",i[i.ERROR=3]="ERROR",i[i.NONE=4]="NONE",i))(I||{}),w=class{constructor(e=1,t){this.level=e;this.prefix=t}prefix;debug(e,...t){this.level<=0&&console.debug(this.prefix+e,...t)}info(e,...t){this.level<=1&&console.info(this.prefix+e,...t)}warn(e,...t){this.level<=2&&console.warn(this.prefix+e,...t)}error(e,...t){this.level<=3&&console.error(this.prefix+e,...t)}};var J=262144,K=32768,H=class s{socket;host;port;secure;startTls;requireTls;ehloName;authType;credentials;socketTimeoutMs;responseTimeoutMs;pipelining;chunking;allowPartialRecipients;reader;writer;decoder=new TextDecoder("utf-8");buffer="";streamClosed=!1;logger;dsn;active=!1;tlsActive=!1;emailSending=null;emailToBeSent=new y;supportsDSN=!1;allowAuth=!1;authTypeSupported=[];supportsStartTls=!1;supportsPipelining=!1;supportsChunking=!1;supports8BitMime=!1;maxMessageSize=0;constructor(e){this.port=e.port,this.host=e.host,this.secure=!!e.secure,Array.isArray(e.authType)?this.authType=e.authType:typeof e.authType=="string"?this.authType=[e.authType]:this.authType=[],this.startTls=e.startTls===void 0?!0:e.startTls,this.requireTls=!!e.requireTls,this.credentials=e.credentials,this.dsn=e.dsn||{},this.pipelining=e.pipelining!==!1,this.chunking=!!e.chunking,this.allowPartialRecipients=!!e.allowPartialRecipients,this.socketTimeoutMs=e.socketTimeoutMs||6e4,this.responseTimeoutMs=e.responseTimeoutMs||3e4,this.tlsActive=this.secure,this.socket=Z({hostname:this.host,port:this.port},{secureTransport:this.secure?"on":this.startTls?"starttls":"off",allowHalfOpen:!1}),this.reader=this.socket.readable.getReader(),this.writer=this.socket.writable.getWriter(),this.logger=new w(e.logLevel,`[WorkerMailer:${this.host}:${this.port}]`),this.ehloName=s.resolveEhloName(e.ehloName),e.ehloName||this.logger.warn("No ehloName set, announcing [127.0.0.1]. Set ehloName to a domain you control \u2014 receiving servers use it for spam scoring.")}static resolveEhloName(e){let t=e?.trim();return t?/^\[.+\]$/.test(t)?t:/^\d{1,3}(\.\d{1,3}){3}$/.test(t)?`[${t}]`:t.includes(":")?`[IPv6:${t}]`:t:"[127.0.0.1]"}static async connect(e){let t=new s(e);return await t.initializeSmtpSession(),t.start().catch(console.error),t}async send(e){if(!this.active)throw new Error("WorkerMailer is not connected");let t=new E(e);return this.emailToBeSent.enqueue(t),await t.sent,t.result}static async send(e,t){let r=await s.connect(e);try{return await r.send(t)}finally{await r.close()}}async readTimeout(){return A(this.readResponse(),this.responseTimeoutMs,new Error("Timeout while waiting for smtp server response"))}async readResponse(){for(;;){let e=this.takeResponse();if(e!==null)return e;if(this.streamClosed)throw new Error("SMTP server closed the connection");let{value:t,done:r}=await this.reader.read();if(r){this.streamClosed=!0;continue}if(!t)continue;let n=this.decoder.decode(t,{stream:!0});this.logger.debug(`SMTP server response:
38
+ `+n),this.buffer+=n}}takeResponse(){let e=0;for(;;){let t=this.buffer.indexOf(`
39
+ `,e);if(t===-1)return null;if(!/^\d{3}-/.test(this.buffer.slice(e,t))){let r=this.buffer.slice(0,t+1);return this.buffer=this.buffer.slice(t+1),r}e=t+1}}async writeLine(e){await this.write(`${e}\r
40
+ `)}async write(e,t=!0){this.logger.debug(t?`Write to socket:
41
+ `+e:`Write to socket: ${e.length} characters`),await this.writer.write(h(e))}async writeBytes(e){this.logger.debug(`Write to socket: ${e.byteLength} bytes`),await this.writer.write(e)}async initializeSmtpSession(){if(await this.waitForSocketConnected(),await this.greet(),await this.ehlo(),this.startTls&&!this.secure&&this.supportsStartTls&&(await this.tls(),await this.ehlo()),this.requireTls&&!this.tlsActive)throw new Error("requireTls is set but the connection is not encrypted (server does not support STARTTLS)");await this.auth(),this.active=!0}async start(){for(;this.active;){this.emailSending=await this.emailToBeSent.dequeue();try{this.emailSending.result=await this.transaction(this.emailSending),this.emailSending.setSent()}catch(e){if(this.logger.error("Failed to send email: "+e.message),!this.active)return;this.emailSending.setSentError(e);try{await this.rset()}catch(t){await this.close(t)}}this.emailSending=null}}async close(e){for(this.active=!1,this.logger.info("WorkerMailer is closed",e?.message||""),this.emailSending?.setSentError?.(e||new Error("WorkerMailer is shutting down"));this.emailToBeSent.length;)(await this.emailToBeSent.dequeue()).setSentError(e||new Error("WorkerMailer is shutting down"));try{await this.writeLine("QUIT"),await this.readTimeout(),this.socket.close().catch(()=>this.logger.error("Failed to close socket"))}catch{}}async waitForSocketConnected(){this.logger.info("Connecting to SMTP server"),await A(this.socket.opened,this.socketTimeoutMs,new Error("Socket timeout!")),this.logger.info("SMTP server connected")}async greet(){let e=await this.readTimeout();if(!e.startsWith("220"))throw new Error("Failed to connect to SMTP server: "+e)}async ehlo(){await this.writeLine(`EHLO ${this.ehloName}`);let e=await this.readTimeout();if(e.startsWith("421"))throw new Error(`Failed to EHLO. ${e}`);if(!e.startsWith("2")){await this.helo();return}this.parseCapabilities(e)}async helo(){await this.writeLine(`HELO ${this.ehloName}`);let e=await this.readTimeout();if(!e.startsWith("2"))throw new Error(`Failed to HELO. ${e}`)}async tls(){await this.writeLine("STARTTLS");let e=await this.readTimeout();if(!e.startsWith("220"))throw new Error("Failed to start TLS: "+e);this.reader.releaseLock(),this.writer.releaseLock(),this.socket=this.socket.startTls(),this.reader=this.socket.readable.getReader(),this.writer=this.socket.writable.getWriter(),this.decoder=new TextDecoder("utf-8"),this.buffer="",this.tlsActive=!0}parseCapabilities(e){this.allowAuth=!1,this.authTypeSupported=[],this.supportsStartTls=!1,this.supportsDSN=!1,this.supportsPipelining=!1,this.supportsChunking=!1,this.supports8BitMime=!1,this.maxMessageSize=0;let t=new Set;for(let r of e.split(/\r?\n/)){let n=r.replace(/^\d{3}[- ]?/,"").trim();if(!n)continue;let[i,...a]=n.split(/[ =]+/);switch(i.toUpperCase()){case"AUTH":this.allowAuth=!0;for(let o of a)switch(o.toUpperCase()){case"PLAIN":t.add("plain");break;case"LOGIN":t.add("login");break;case"CRAM-MD5":t.add("cram-md5");break;case"XOAUTH2":t.add("xoauth2");break}break;case"STARTTLS":this.supportsStartTls=!0;break;case"DSN":this.supportsDSN=!0;break;case"PIPELINING":this.supportsPipelining=!0;break;case"CHUNKING":this.supportsChunking=!0;break;case"8BITMIME":this.supports8BitMime=!0;break;case"SIZE":this.maxMessageSize=Number(a[0])||0;break}}this.authTypeSupported=[...t]}defaultAuthOrder(){return this.tlsActive?["plain","login","cram-md5"]:["cram-md5","plain","login"]}selectAuthType(){return(this.authType.length?this.authType:this.defaultAuthOrder()).find(t=>this.authTypeSupported.includes(t))}async auth(){if(this.allowAuth){if(!this.credentials)throw new Error("smtp server requires authentication, but no credentials found");switch(this.tlsActive||this.logger.warn("Authenticating over an unencrypted connection, credentials are sent in the clear"),this.selectAuthType()){case"plain":return this.authWithPlain();case"login":return this.authWithLogin();case"cram-md5":return this.authWithCramMD5();case"xoauth2":return this.authWithXOAuth2();default:throw new Error("No supported auth method found.")}}}requirePassword(){let e=this.credentials?.password;if(e===void 0)throw new Error("credentials.password is required for this auth method");return e}async authWithPlain(){let e=this.requirePassword(),t=p(h(`\0${this.credentials.username}\0${e}`));await this.writeLine(`AUTH PLAIN ${t}`);let r=await this.readTimeout();if(!r.startsWith("2"))throw new Error(`Failed to plain authentication: ${r}`)}async authWithLogin(){let e=this.requirePassword();await this.writeLine("AUTH LOGIN");let t=await this.readTimeout();if(!t.startsWith("3"))throw new Error("Invalid login: "+t);await this.writeLine(p(h(this.credentials.username)));let r=await this.readTimeout();if(!r.startsWith("3"))throw new Error("Failed to login authentication: "+r);await this.writeLine(p(h(e)));let n=await this.readTimeout();if(!n.startsWith("2"))throw new Error("Failed to login authentication: "+n)}async authWithCramMD5(){let e=this.requirePassword();await this.writeLine("AUTH CRAM-MD5");let t=await this.readTimeout(),r=t.match(/^334\s+(.+)$/m)?.pop()?.trim();if(!r)throw new Error("Invalid CRAM-MD5 challenge: "+t);let n=await crypto.subtle.importKey("raw",h(e),{name:"HMAC",hash:"MD5"},!1,["sign"]),i=await crypto.subtle.sign("HMAC",n,D(r)),a=Array.from(new Uint8Array(i)).map(l=>l.toString(16).padStart(2,"0")).join("");await this.writeLine(p(h(`${this.credentials.username} ${a}`)));let o=await this.readTimeout();if(!o.startsWith("2"))throw new Error(`Failed to cram-md5 authentication: ${o}`)}async authWithXOAuth2(){let e=this.credentials?.accessToken;if(!e)throw new Error("credentials.accessToken is required for xoauth2");let t=p(h(`user=${this.credentials.username}auth=Bearer ${e}`));await this.writeLine(`AUTH XOAUTH2 ${t}`);let r=await this.readTimeout();if(!r.startsWith("2"))throw r.startsWith("3")?(await this.writeLine(""),new Error(`Failed to xoauth2 authentication: ${await this.readTimeout()}`)):new Error(`Failed to xoauth2 authentication: ${r}`)}async transaction(e){let t=e.recipients;if(!t.length)throw new Error("Email has no recipients");this.checkSize(e);let r=this.chunking&&this.supportsChunking,n=this.mailFromCommand(e),i=t.map(u=>this.rcptToCommand(e,u)),a=!r&&(this.allowPartialRecipients||t.length===1),o,l=[],c=null;if(this.pipelining&&this.supportsPipelining){let u=[n,...i];a&&u.push("DATA"),await this.write(u.map(g=>`${g}\r
42
+ `).join("")),o=await this.readTimeout();for(let g of i)l.push(await this.readTimeout());a&&(c=await this.readTimeout())}else if(await this.writeLine(n),o=await this.readTimeout(),o.startsWith("2"))for(let u of i)await this.writeLine(u),l.push(await this.readTimeout());if(!o.startsWith("2"))throw new Error(`Invalid ${n} ${o}`);let m=[],f=[];if(t.forEach((u,g)=>{let U=l[g];U?.startsWith("2")?m.push(u):f.push({user:u,response:(U||"").trim()})}),f.length&&(!m.length||!this.allowPartialRecipients)){c?.startsWith("3")&&await this.abortData();let{user:u,response:g}=f[0];throw new Error(`Invalid RCPT TO: <${u.email}> ${g}`)}f.length&&this.logger.warn(`Server rejected ${f.length} of ${t.length} recipients`);let B=r?await this.sendWithChunking(e):await this.sendWithData(e,c);return{accepted:m,rejected:f,response:B.trim()}}mailFromCommand(e){let t=`MAIL FROM: <${e.from.email}>`;if(this.supportsDSN){let r=this.retBuilder(e);r&&(t+=` ${r}`),e.dsnOverride?.envelopeId&&(t+=` ENVID=${e.dsnOverride.envelopeId}`)}return t}rcptToCommand(e,t){let r=`RCPT TO: <${t.email}>`;return this.supportsDSN&&(r+=this.notificationBuilder(e)),r}checkSize(e){if(!this.maxMessageSize)return;let t=e.estimateSize();if(t>this.maxMessageSize)throw new Error(`Message is roughly ${t} bytes but the server accepts at most ${this.maxMessageSize} bytes`)}serializeOptions(){return{allow8bit:this.supports8BitMime}}async abortData(){await this.write(`\r
43
+ .\r
44
+ `),await this.readTimeout()}async sendWithData(e,t){let r=t;if(r===null&&(await this.writeLine("DATA"),r=await this.readTimeout()),!r.startsWith("3"))throw new Error(`Failed to send DATA: ${r}`);let n=S(),i="",a=!0;for(let l of e.chunks(this.serializeOptions())){let c=n(l);a=c.endsWith(`\r
45
+ `),i+=c,i.length>=K&&(await this.write(i,!1),i="")}i+=a?`.\r
46
+ `:`\r
47
+ .\r
48
+ `,await this.write(i,!1);let o=await this.readTimeout();if(!o.startsWith("2"))throw new Error("Failed send email body: "+o);return o}async sendWithChunking(e){let t="",r=0,n=async a=>{let o=h(t);t="",await this.write(`BDAT ${o.byteLength}${a?" LAST":""}\r
49
+ `),await this.writeBytes(o),r++};for(let a of e.chunks(this.serializeOptions()))t+=a,t.length>=J&&await n(!1);await n(!0);let i="";for(let a=0;a<r;a++)if(i=await this.readTimeout(),!i.startsWith("2"))throw new Error("Failed send email body: "+i);return i}async rset(){await this.writeLine("RSET");let e=await this.readTimeout();if(!e.startsWith("2"))throw new Error(`Failed to reset: ${e}`)}notificationBuilder(e){let t=e.dsnOverride?.NOTIFY,r=t?void 0:this.dsn?.NOTIFY,n=[];return(t?.SUCCESS||r?.SUCCESS)&&n.push("SUCCESS"),(t?.FAILURE||r?.FAILURE)&&n.push("FAILURE"),(t?.DELAY||r?.DELAY)&&n.push("DELAY"),n.length>0?` NOTIFY=${n.join(",")}`:" NOTIFY=NEVER"}retBuilder(e){let t=e.dsnOverride?.RET,r=t?void 0:this.dsn?.RET,n=[];return(t?.HEADERS||r?.HEADERS)&&n.push("HDRS"),(t?.FULL||r?.FULL)&&n.push("FULL"),n.length>0?`RET=${n.join(",")}`:""}};export{E as Email,I as LogLevel,H as WorkerMailer,$ as encodeHeader,G as formatAddress};
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@luca-emmert/worker-mailer",
3
+ "version": "1.3.0",
4
+ "main": "./dist/index.js",
5
+ "module": "./dist/index.mjs",
6
+ "types": "./dist/index.d.ts",
7
+ "keywords": [
8
+ "cloudflare",
9
+ "workers",
10
+ "cloudflare-workers",
11
+ "email",
12
+ "smtp"
13
+ ],
14
+ "author": "zou-yu, lucaemt",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/zou-yu/worker-mailer.git"
18
+ },
19
+ "license": "MIT",
20
+ "homepage": "https://github.com/zou-yu/worker-mailer",
21
+ "devDependencies": {
22
+ "@changesets/cli": "^2.27.9",
23
+ "@cloudflare/vitest-pool-workers": "^0.4.5",
24
+ "@cloudflare/workers-types": "^4.20240722.0",
25
+ "@types/libqp": "^1.1.3",
26
+ "@types/node": "^20.14.12",
27
+ "letterparser": "^0.1.8",
28
+ "libqp": "^2.1.1",
29
+ "prettier": "^3.3.3",
30
+ "tsup": "^8.2.3",
31
+ "typescript": "^5.5.2",
32
+ "vitest": "1.5.0",
33
+ "wrangler": "^3.60.3"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "test": "vitest",
38
+ "format": "prettier '**/*.{json,ts,js,cjs,mjs,md}' --write --ignore-path .gitignore"
39
+ }
40
+ }