@upyo/smtp 0.4.0-dev.67 → 0.4.0-dev.72

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 CHANGED
@@ -31,6 +31,7 @@ Features
31
31
  - TypeScript support
32
32
  - Cross-runtime compatibility (Node.js, Bun, Deno)
33
33
  - STARTTLS support for secure connection upgrade
34
+ - DKIM signing support (RSA-SHA256 and Ed25519-SHA256)
34
35
 
35
36
 
36
37
  Installation
@@ -111,6 +112,7 @@ Configuration options
111
112
  | `localName` | `string` | `"localhost"` | Local hostname for `HELO`/`EHLO` |
112
113
  | `pool` | `boolean` | `true` | Enable connection pooling |
113
114
  | `poolSize` | `number` | `5` | Maximum pool connections |
115
+ | `dkim` | `DkimConfig` | | DKIM signing configuration |
114
116
 
115
117
  ### `SmtpAuth`
116
118
 
@@ -121,6 +123,51 @@ Configuration options
121
123
  | `method` | `"plain" \| "login" \| "cram-md5"` | `"plain"` | Auth method |
122
124
 
123
125
 
126
+ DKIM signing
127
+ ------------
128
+
129
+ DKIM (DomainKeys Identified Mail) signing is supported for improved email
130
+ deliverability and authentication:
131
+
132
+ ~~~~ typescript
133
+ import { createMessage } from "@upyo/core";
134
+ import { SmtpTransport } from "@upyo/smtp";
135
+ import { readFileSync } from "node:fs";
136
+
137
+ const transport = new SmtpTransport({
138
+ host: "smtp.example.com",
139
+ port: 587,
140
+ secure: false,
141
+ auth: { user: "user@example.com", pass: "password" },
142
+ dkim: {
143
+ signatures: [{
144
+ signingDomain: "example.com",
145
+ selector: "mail",
146
+ privateKey: readFileSync("./dkim-private.pem", "utf8"),
147
+ }],
148
+ },
149
+ });
150
+ ~~~~
151
+
152
+ ### `DkimConfig`
153
+
154
+ | Option | Type | Default | Description |
155
+ |--------------------|--------------------------------|-----------|---------------------------------------|
156
+ | `signatures` | `DkimSignature[]` | | Array of DKIM signature configs |
157
+ | `onSigningFailure` | `"throw" \| "send-unsigned"` | `"throw"` | Action when signing fails |
158
+
159
+ ### `DkimSignature`
160
+
161
+ | Option | Type | Default | Description |
162
+ |--------------------|------------------------------------|---------------------|-----------------------------------------|
163
+ | `signingDomain` | `string` | | Domain for DKIM key (d= tag) |
164
+ | `selector` | `string` | | DKIM selector (s= tag) |
165
+ | `privateKey` | `string \| CryptoKey` | | Private key (PEM string or `CryptoKey`) |
166
+ | `algorithm` | `"rsa-sha256" \| "ed25519-sha256"` | `"rsa-sha256"` | Signing algorithm |
167
+ | `canonicalization` | `string` | `"relaxed/relaxed"` | Header/body canonicalization |
168
+ | `headerFields` | `string[]` | From, To, ... | Headers to sign |
169
+
170
+
124
171
  Testing
125
172
  -------
126
173
 
package/dist/index.cjs CHANGED
@@ -48,7 +48,8 @@ function createSmtpConfig(config) {
48
48
  socketTimeout: config.socketTimeout ?? 6e4,
49
49
  localName: config.localName ?? "localhost",
50
50
  pool: config.pool ?? true,
51
- poolSize: config.poolSize ?? 5
51
+ poolSize: config.poolSize ?? 5,
52
+ dkim: config.dkim
52
53
  };
53
54
  }
54
55
 
@@ -297,9 +298,286 @@ var SmtpConnection = class {
297
298
  }
298
299
  };
299
300
 
301
+ //#endregion
302
+ //#region src/dkim/canonicalize.ts
303
+ /**
304
+ * DKIM Canonicalization algorithms per RFC 6376 Section 3.4.
305
+ *
306
+ * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4
307
+ * @since 0.4.0
308
+ */
309
+ /**
310
+ * Simple header canonicalization.
311
+ *
312
+ * The "simple" header canonicalization algorithm does not change header
313
+ * fields in any way. Header fields are presented to the signing or
314
+ * verification algorithm exactly as they are in the message.
315
+ *
316
+ * @param name - The header field name
317
+ * @param value - The header field value
318
+ * @returns The canonicalized header line (name:value)
319
+ * @see RFC 6376 Section 3.4.1
320
+ * @since 0.4.0
321
+ */
322
+ function canonicalizeHeaderSimple(name, value) {
323
+ return `${name}:${value}`;
324
+ }
325
+ /**
326
+ * Relaxed header canonicalization.
327
+ *
328
+ * The "relaxed" header canonicalization algorithm:
329
+ * - Convert header field names to lowercase
330
+ * - Unfold header field continuation lines
331
+ * - Collapse whitespace sequences to a single space
332
+ * - Remove leading and trailing whitespace from header field values
333
+ *
334
+ * @param name - The header field name
335
+ * @param value - The header field value
336
+ * @returns The canonicalized header line (name:value)
337
+ * @see RFC 6376 Section 3.4.2
338
+ * @since 0.4.0
339
+ */
340
+ function canonicalizeHeaderRelaxed(name, value) {
341
+ const canonicalName = name.toLowerCase();
342
+ const canonicalValue = value.replace(/\r\n[\t ]+/g, " ").replace(/[\t ]+/g, " ").trim();
343
+ return `${canonicalName}:${canonicalValue}`;
344
+ }
345
+ /**
346
+ * Simple body canonicalization.
347
+ *
348
+ * The "simple" body canonicalization algorithm:
349
+ * - Ignores all empty lines at the end of the message body
350
+ * - If the body is empty, a single CRLF is appended
351
+ * - If there is no trailing CRLF on the body, a CRLF is added
352
+ *
353
+ * @param body - The message body
354
+ * @returns The canonicalized body
355
+ * @see RFC 6376 Section 3.4.3
356
+ * @since 0.4.0
357
+ */
358
+ function canonicalizeBodySimple(body) {
359
+ if (body === "") return "\r\n";
360
+ let result = body.replace(/(\r\n)+$/, "");
361
+ if (!result.endsWith("\r\n")) result += "\r\n";
362
+ return result;
363
+ }
364
+ /**
365
+ * Relaxed body canonicalization.
366
+ *
367
+ * The "relaxed" body canonicalization algorithm:
368
+ * - Reduce all sequences of WSP within a line to a single SP
369
+ * - Remove all trailing WSP at the end of each line (before CRLF)
370
+ * - Ignore all empty lines at the end of the message body
371
+ * - If the body is non-empty and doesn't end with CRLF, add CRLF
372
+ *
373
+ * @param body - The message body
374
+ * @returns The canonicalized body
375
+ * @see RFC 6376 Section 3.4.4
376
+ * @since 0.4.0
377
+ */
378
+ function canonicalizeBodyRelaxed(body) {
379
+ if (body === "") return "";
380
+ let processedBody = body;
381
+ if (!processedBody.endsWith("\r\n")) processedBody += "\r\n";
382
+ const lines = processedBody.split("\r\n");
383
+ const canonicalizedLines = [];
384
+ for (let i = 0; i < lines.length - 1; i++) {
385
+ let line = lines[i];
386
+ line = line.replace(/[\t ]+/g, " ");
387
+ line = line.replace(/[\t ]+$/, "");
388
+ canonicalizedLines.push(line);
389
+ }
390
+ while (canonicalizedLines.length > 0 && canonicalizedLines[canonicalizedLines.length - 1] === "") canonicalizedLines.pop();
391
+ if (canonicalizedLines.length === 0) return "";
392
+ return canonicalizedLines.join("\r\n") + "\r\n";
393
+ }
394
+
395
+ //#endregion
396
+ //#region src/dkim/types.ts
397
+ /**
398
+ * Default header fields to sign if not specified.
399
+ *
400
+ * @since 0.4.0
401
+ */
402
+ const DEFAULT_SIGNED_HEADERS = [
403
+ "from",
404
+ "to",
405
+ "subject",
406
+ "date"
407
+ ];
408
+ /**
409
+ * Default DKIM algorithm.
410
+ *
411
+ * @since 0.4.0
412
+ */
413
+ const DEFAULT_ALGORITHM = "rsa-sha256";
414
+ /**
415
+ * Default canonicalization method.
416
+ *
417
+ * @since 0.4.0
418
+ */
419
+ const DEFAULT_CANONICALIZATION = "relaxed/relaxed";
420
+
421
+ //#endregion
422
+ //#region src/dkim/sign.ts
423
+ /**
424
+ * Signs a raw email message with DKIM.
425
+ *
426
+ * @param rawMessage - The complete raw email message (headers + body)
427
+ * @param config - DKIM signature configuration
428
+ * @returns The DKIM-Signature header result
429
+ * @throws Error if signing fails (e.g., invalid private key)
430
+ * @since 0.4.0
431
+ */
432
+ async function signMessage(rawMessage, config) {
433
+ const algorithm = config.algorithm ?? DEFAULT_ALGORITHM;
434
+ const canonicalization = config.canonicalization ?? DEFAULT_CANONICALIZATION;
435
+ const headerFields = config.headerFields ?? DEFAULT_SIGNED_HEADERS;
436
+ const { headers, body } = parseMessage(rawMessage);
437
+ const [headerCanon, bodyCanon] = canonicalization.split("/");
438
+ const privateKey = await getPrivateKey(config.privateKey, algorithm);
439
+ const bodyHash = await computeBodyHash(body, bodyCanon);
440
+ const dkimHeaderValue = buildDkimHeaderValue({
441
+ algorithm,
442
+ canonicalization,
443
+ signingDomain: config.signingDomain,
444
+ selector: config.selector,
445
+ headerFields,
446
+ bodyHash
447
+ });
448
+ const signatureData = buildSignatureData(headers, headerFields, headerCanon, dkimHeaderValue);
449
+ const signature = await signData(signatureData, privateKey, algorithm);
450
+ return {
451
+ headerName: "DKIM-Signature",
452
+ signature: `${dkimHeaderValue} b=${signature}`
453
+ };
454
+ }
455
+ /**
456
+ * Parses a raw email message into headers and body.
457
+ */
458
+ function parseMessage(rawMessage) {
459
+ const separatorIndex = rawMessage.indexOf("\r\n\r\n");
460
+ if (separatorIndex === -1) return {
461
+ headers: parseHeaders(rawMessage),
462
+ body: ""
463
+ };
464
+ const headerSection = rawMessage.substring(0, separatorIndex);
465
+ const body = rawMessage.substring(separatorIndex + 4);
466
+ return {
467
+ headers: parseHeaders(headerSection),
468
+ body
469
+ };
470
+ }
471
+ /**
472
+ * Parses header section into a map of header name to value.
473
+ * Handles folded headers (continuation lines).
474
+ */
475
+ function parseHeaders(headerSection) {
476
+ const headers = /* @__PURE__ */ new Map();
477
+ const lines = headerSection.split("\r\n");
478
+ let currentName = "";
479
+ let currentValue = "";
480
+ for (const line of lines) if (line.startsWith(" ") || line.startsWith(" ")) currentValue += "\r\n" + line;
481
+ else {
482
+ if (currentName) headers.set(currentName.toLowerCase(), currentValue);
483
+ const colonIndex = line.indexOf(":");
484
+ if (colonIndex > 0) {
485
+ currentName = line.substring(0, colonIndex);
486
+ currentValue = line.substring(colonIndex + 1);
487
+ }
488
+ }
489
+ if (currentName) headers.set(currentName.toLowerCase(), currentValue);
490
+ return headers;
491
+ }
492
+ /**
493
+ * Gets the private key, either using a provided CryptoKey or importing from PEM.
494
+ */
495
+ function getPrivateKey(key, algorithm) {
496
+ if (typeof key !== "string") return key;
497
+ return importPrivateKey(key, algorithm);
498
+ }
499
+ /**
500
+ * Imports a PEM-encoded private key for use with Web Crypto API.
501
+ */
502
+ async function importPrivateKey(pem, algorithm) {
503
+ try {
504
+ const pemContents = pem.replace(/-----BEGIN (?:RSA )?PRIVATE KEY-----/, "").replace(/-----END (?:RSA )?PRIVATE KEY-----/, "").replace(/\s/g, "");
505
+ const binaryString = atob(pemContents);
506
+ const bytes = new Uint8Array(binaryString.length);
507
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
508
+ const keyAlgorithm = algorithm === "ed25519-sha256" ? { name: "Ed25519" } : {
509
+ name: "RSASSA-PKCS1-v1_5",
510
+ hash: "SHA-256"
511
+ };
512
+ return await crypto.subtle.importKey("pkcs8", bytes, keyAlgorithm, false, ["sign"]);
513
+ } catch (error) {
514
+ throw new Error(`Failed to import private key: ${error instanceof Error ? error.message : String(error)}`);
515
+ }
516
+ }
517
+ /**
518
+ * Computes the body hash (bh= tag value).
519
+ */
520
+ async function computeBodyHash(body, canonMethod) {
521
+ const canonicalBody = canonMethod === "relaxed" ? canonicalizeBodyRelaxed(body) : canonicalizeBodySimple(body);
522
+ const encoder = new TextEncoder();
523
+ const data = encoder.encode(canonicalBody);
524
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
525
+ return arrayBufferToBase64(hashBuffer);
526
+ }
527
+ /**
528
+ * Builds the DKIM-Signature header value without the b= signature.
529
+ */
530
+ function buildDkimHeaderValue(params) {
531
+ const parts = [
532
+ "v=1",
533
+ `a=${params.algorithm}`,
534
+ `c=${params.canonicalization}`,
535
+ `d=${params.signingDomain}`,
536
+ `s=${params.selector}`,
537
+ `h=${params.headerFields.join(":")}`,
538
+ `bh=${params.bodyHash};`
539
+ ];
540
+ return parts.join("; ");
541
+ }
542
+ /**
543
+ * Builds the data to be signed (canonicalized headers + DKIM-Signature header).
544
+ */
545
+ function buildSignatureData(headers, headerFields, canonMethod, dkimHeaderValue) {
546
+ const lines = [];
547
+ for (const field of headerFields) {
548
+ const value = headers.get(field.toLowerCase());
549
+ if (value !== void 0) {
550
+ const canonicalized = canonMethod === "relaxed" ? canonicalizeHeaderRelaxed(field, value) : canonicalizeHeaderSimple(field, value);
551
+ lines.push(canonicalized);
552
+ }
553
+ }
554
+ const dkimHeader = canonMethod === "relaxed" ? canonicalizeHeaderRelaxed("DKIM-Signature", " " + dkimHeaderValue + " b=") : canonicalizeHeaderSimple("DKIM-Signature", " " + dkimHeaderValue + " b=");
555
+ lines.push(dkimHeader);
556
+ return lines.join("\r\n");
557
+ }
558
+ /**
559
+ * Signs data using the appropriate algorithm.
560
+ */
561
+ async function signData(data, privateKey, algorithm) {
562
+ const encoder = new TextEncoder();
563
+ const dataBuffer = encoder.encode(data);
564
+ const signAlgorithm = algorithm === "ed25519-sha256" ? "Ed25519" : "RSASSA-PKCS1-v1_5";
565
+ const signature = await crypto.subtle.sign(signAlgorithm, privateKey, dataBuffer);
566
+ return arrayBufferToBase64(signature);
567
+ }
568
+ /**
569
+ * Converts an ArrayBuffer to a Base64 string.
570
+ */
571
+ function arrayBufferToBase64(buffer) {
572
+ const bytes = new Uint8Array(buffer);
573
+ let binary = "";
574
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
575
+ return btoa(binary);
576
+ }
577
+
300
578
  //#endregion
301
579
  //#region src/message-converter.ts
302
- async function convertMessage(message) {
580
+ async function convertMessage(message, dkimConfig) {
303
581
  const envelope = {
304
582
  from: message.sender.address,
305
583
  to: [
@@ -308,7 +586,16 @@ async function convertMessage(message) {
308
586
  ...message.bccRecipients.map((r) => r.address)
309
587
  ]
310
588
  };
311
- const raw = await buildRawMessage(message);
589
+ let raw = await buildRawMessage(message);
590
+ if (dkimConfig) try {
591
+ for (const sig of dkimConfig.signatures) {
592
+ const result = await signMessage(raw, sig);
593
+ raw = `${result.headerName}: ${result.signature}\r\n${raw}`;
594
+ }
595
+ } catch (error) {
596
+ if (dkimConfig.onSigningFailure === "send-unsigned") console.warn("DKIM signing failed, sending unsigned:", error);
597
+ else throw error;
598
+ }
312
599
  return {
313
600
  envelope,
314
601
  raw
@@ -547,7 +834,7 @@ var SmtpTransport = class {
547
834
  const connection = await this.getConnection(options?.signal);
548
835
  try {
549
836
  options?.signal?.throwIfAborted();
550
- const smtpMessage = await convertMessage(message);
837
+ const smtpMessage = await convertMessage(message, this.config.dkim);
551
838
  options?.signal?.throwIfAborted();
552
839
  const messageId = await connection.sendMessage(smtpMessage, options?.signal);
553
840
  await this.returnConnection(connection);
@@ -607,7 +894,7 @@ var SmtpTransport = class {
607
894
  continue;
608
895
  }
609
896
  try {
610
- const smtpMessage = await convertMessage(message);
897
+ const smtpMessage = await convertMessage(message, this.config.dkim);
611
898
  options?.signal?.throwIfAborted();
612
899
  const messageId = await connection.sendMessage(smtpMessage, options?.signal);
613
900
  yield {
@@ -632,7 +919,7 @@ var SmtpTransport = class {
632
919
  continue;
633
920
  }
634
921
  try {
635
- const smtpMessage = await convertMessage(message);
922
+ const smtpMessage = await convertMessage(message, this.config.dkim);
636
923
  options?.signal?.throwIfAborted();
637
924
  const messageId = await connection.sendMessage(smtpMessage, options?.signal);
638
925
  yield {
package/dist/index.d.cts CHANGED
@@ -1,7 +1,108 @@
1
1
  import { Message, Receipt, Transport, TransportOptions } from "@upyo/core";
2
2
 
3
- //#region src/config.d.ts
3
+ //#region src/dkim/types.d.ts
4
4
 
5
+ /**
6
+ * Supported DKIM signing algorithms.
7
+ *
8
+ * - `rsa-sha256`: RSA with SHA-256, most widely used (RFC 6376)
9
+ * - `ed25519-sha256`: Ed25519 with SHA-256, shorter keys (RFC 8463)
10
+ *
11
+ * @since 0.4.0
12
+ */
13
+ type DkimAlgorithm = "rsa-sha256" | "ed25519-sha256";
14
+ /**
15
+ * DKIM canonicalization methods for header and body.
16
+ *
17
+ * Format: "header-method/body-method"
18
+ *
19
+ * - `simple`: No modifications (preserves exact content)
20
+ * - `relaxed`: Normalizes whitespace and case
21
+ *
22
+ * @see RFC 6376 Section 3.4
23
+ * @since 0.4.0
24
+ */
25
+ type DkimCanonicalization = "relaxed/relaxed" | "relaxed/simple" | "simple/relaxed" | "simple/simple";
26
+ /**
27
+ * Configuration for a single DKIM signature.
28
+ *
29
+ * Each signature represents one DKIM-Signature header that will be added
30
+ * to the email. Multiple signatures can be used for different domains
31
+ * or selectors.
32
+ *
33
+ * @since 0.4.0
34
+ */
35
+ interface DkimSignature {
36
+ /**
37
+ * The domain name for the DKIM key (d= tag).
38
+ * This is the domain claiming responsibility for the message.
39
+ */
40
+ readonly signingDomain: string;
41
+ /**
42
+ * The DKIM selector (s= tag).
43
+ * Used to locate the public key in DNS: `selector._domainkey.signingDomain`
44
+ */
45
+ readonly selector: string;
46
+ /**
47
+ * The private key for signing.
48
+ *
49
+ * Can be either:
50
+ * - A PEM-encoded private key string (PKCS#8 format)
51
+ * - A `CryptoKey` object from Web Crypto API
52
+ *
53
+ * For RSA keys, use PKCS#8 PEM format or import via `crypto.subtle.importKey`.
54
+ * For Ed25519 keys, use PKCS#8 PEM format or import via `crypto.subtle.importKey`.
55
+ */
56
+ readonly privateKey: string | CryptoKey;
57
+ /**
58
+ * The signing algorithm (a= tag).
59
+ * @default "rsa-sha256"
60
+ */
61
+ readonly algorithm?: DkimAlgorithm;
62
+ /**
63
+ * The canonicalization method for header and body (c= tag).
64
+ * @default "relaxed/relaxed"
65
+ */
66
+ readonly canonicalization?: DkimCanonicalization;
67
+ /**
68
+ * Header fields to include in the signature (h= tag).
69
+ * @default ["from", "to", "subject", "date"]
70
+ */
71
+ readonly headerFields?: readonly string[];
72
+ }
73
+ /**
74
+ * Action to take when DKIM signing fails.
75
+ *
76
+ * - `throw`: Throw an error and abort sending (default)
77
+ * - `send-unsigned`: Log a warning and send the email without DKIM signature
78
+ *
79
+ * @since 0.4.0
80
+ */
81
+ type DkimSigningFailureAction = "throw" | "send-unsigned";
82
+ /**
83
+ * Configuration for DKIM signing in SMTP transport.
84
+ *
85
+ * @since 0.4.0
86
+ */
87
+ interface DkimConfig {
88
+ /**
89
+ * Array of DKIM signature configurations.
90
+ * Each configuration will result in one DKIM-Signature header.
91
+ */
92
+ readonly signatures: readonly DkimSignature[];
93
+ /**
94
+ * Action to take when DKIM signing fails.
95
+ * @default "throw"
96
+ */
97
+ readonly onSigningFailure?: DkimSigningFailureAction;
98
+ }
99
+ /**
100
+ * Result of DKIM signing operation.
101
+ *
102
+ * @since 0.4.0
103
+ */
104
+ //#endregion
105
+ //#region src/config.d.ts
5
106
  /**
6
107
  * Configuration interface for SMTP transport connection settings.
7
108
  *
@@ -71,6 +172,12 @@ interface SmtpConfig {
71
172
  * @default 5
72
173
  */
73
174
  readonly poolSize?: number;
175
+ /**
176
+ * DKIM signing configuration.
177
+ * When provided, all outgoing emails will be signed with DKIM.
178
+ * @since 0.4.0
179
+ */
180
+ readonly dkim?: DkimConfig;
74
181
  }
75
182
  /**
76
183
  * Authentication configuration for SMTP connections.
@@ -295,4 +402,4 @@ declare class SmtpTransport implements Transport, AsyncDisposable {
295
402
  [Symbol.asyncDispose](): Promise<void>;
296
403
  }
297
404
  //#endregion
298
- export { SmtpAuth, SmtpConfig, SmtpTlsOptions, SmtpTransport };
405
+ export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, SmtpAuth, SmtpConfig, SmtpTlsOptions, SmtpTransport };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,108 @@
1
1
  import { Message, Receipt, Transport, TransportOptions } from "@upyo/core";
2
2
 
3
- //#region src/config.d.ts
3
+ //#region src/dkim/types.d.ts
4
4
 
5
+ /**
6
+ * Supported DKIM signing algorithms.
7
+ *
8
+ * - `rsa-sha256`: RSA with SHA-256, most widely used (RFC 6376)
9
+ * - `ed25519-sha256`: Ed25519 with SHA-256, shorter keys (RFC 8463)
10
+ *
11
+ * @since 0.4.0
12
+ */
13
+ type DkimAlgorithm = "rsa-sha256" | "ed25519-sha256";
14
+ /**
15
+ * DKIM canonicalization methods for header and body.
16
+ *
17
+ * Format: "header-method/body-method"
18
+ *
19
+ * - `simple`: No modifications (preserves exact content)
20
+ * - `relaxed`: Normalizes whitespace and case
21
+ *
22
+ * @see RFC 6376 Section 3.4
23
+ * @since 0.4.0
24
+ */
25
+ type DkimCanonicalization = "relaxed/relaxed" | "relaxed/simple" | "simple/relaxed" | "simple/simple";
26
+ /**
27
+ * Configuration for a single DKIM signature.
28
+ *
29
+ * Each signature represents one DKIM-Signature header that will be added
30
+ * to the email. Multiple signatures can be used for different domains
31
+ * or selectors.
32
+ *
33
+ * @since 0.4.0
34
+ */
35
+ interface DkimSignature {
36
+ /**
37
+ * The domain name for the DKIM key (d= tag).
38
+ * This is the domain claiming responsibility for the message.
39
+ */
40
+ readonly signingDomain: string;
41
+ /**
42
+ * The DKIM selector (s= tag).
43
+ * Used to locate the public key in DNS: `selector._domainkey.signingDomain`
44
+ */
45
+ readonly selector: string;
46
+ /**
47
+ * The private key for signing.
48
+ *
49
+ * Can be either:
50
+ * - A PEM-encoded private key string (PKCS#8 format)
51
+ * - A `CryptoKey` object from Web Crypto API
52
+ *
53
+ * For RSA keys, use PKCS#8 PEM format or import via `crypto.subtle.importKey`.
54
+ * For Ed25519 keys, use PKCS#8 PEM format or import via `crypto.subtle.importKey`.
55
+ */
56
+ readonly privateKey: string | CryptoKey;
57
+ /**
58
+ * The signing algorithm (a= tag).
59
+ * @default "rsa-sha256"
60
+ */
61
+ readonly algorithm?: DkimAlgorithm;
62
+ /**
63
+ * The canonicalization method for header and body (c= tag).
64
+ * @default "relaxed/relaxed"
65
+ */
66
+ readonly canonicalization?: DkimCanonicalization;
67
+ /**
68
+ * Header fields to include in the signature (h= tag).
69
+ * @default ["from", "to", "subject", "date"]
70
+ */
71
+ readonly headerFields?: readonly string[];
72
+ }
73
+ /**
74
+ * Action to take when DKIM signing fails.
75
+ *
76
+ * - `throw`: Throw an error and abort sending (default)
77
+ * - `send-unsigned`: Log a warning and send the email without DKIM signature
78
+ *
79
+ * @since 0.4.0
80
+ */
81
+ type DkimSigningFailureAction = "throw" | "send-unsigned";
82
+ /**
83
+ * Configuration for DKIM signing in SMTP transport.
84
+ *
85
+ * @since 0.4.0
86
+ */
87
+ interface DkimConfig {
88
+ /**
89
+ * Array of DKIM signature configurations.
90
+ * Each configuration will result in one DKIM-Signature header.
91
+ */
92
+ readonly signatures: readonly DkimSignature[];
93
+ /**
94
+ * Action to take when DKIM signing fails.
95
+ * @default "throw"
96
+ */
97
+ readonly onSigningFailure?: DkimSigningFailureAction;
98
+ }
99
+ /**
100
+ * Result of DKIM signing operation.
101
+ *
102
+ * @since 0.4.0
103
+ */
104
+ //#endregion
105
+ //#region src/config.d.ts
5
106
  /**
6
107
  * Configuration interface for SMTP transport connection settings.
7
108
  *
@@ -71,6 +172,12 @@ interface SmtpConfig {
71
172
  * @default 5
72
173
  */
73
174
  readonly poolSize?: number;
175
+ /**
176
+ * DKIM signing configuration.
177
+ * When provided, all outgoing emails will be signed with DKIM.
178
+ * @since 0.4.0
179
+ */
180
+ readonly dkim?: DkimConfig;
74
181
  }
75
182
  /**
76
183
  * Authentication configuration for SMTP connections.
@@ -295,4 +402,4 @@ declare class SmtpTransport implements Transport, AsyncDisposable {
295
402
  [Symbol.asyncDispose](): Promise<void>;
296
403
  }
297
404
  //#endregion
298
- export { SmtpAuth, SmtpConfig, SmtpTlsOptions, SmtpTransport };
405
+ export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, SmtpAuth, SmtpConfig, SmtpTlsOptions, SmtpTransport };
package/dist/index.js CHANGED
@@ -25,7 +25,8 @@ function createSmtpConfig(config) {
25
25
  socketTimeout: config.socketTimeout ?? 6e4,
26
26
  localName: config.localName ?? "localhost",
27
27
  pool: config.pool ?? true,
28
- poolSize: config.poolSize ?? 5
28
+ poolSize: config.poolSize ?? 5,
29
+ dkim: config.dkim
29
30
  };
30
31
  }
31
32
 
@@ -274,9 +275,286 @@ var SmtpConnection = class {
274
275
  }
275
276
  };
276
277
 
278
+ //#endregion
279
+ //#region src/dkim/canonicalize.ts
280
+ /**
281
+ * DKIM Canonicalization algorithms per RFC 6376 Section 3.4.
282
+ *
283
+ * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4
284
+ * @since 0.4.0
285
+ */
286
+ /**
287
+ * Simple header canonicalization.
288
+ *
289
+ * The "simple" header canonicalization algorithm does not change header
290
+ * fields in any way. Header fields are presented to the signing or
291
+ * verification algorithm exactly as they are in the message.
292
+ *
293
+ * @param name - The header field name
294
+ * @param value - The header field value
295
+ * @returns The canonicalized header line (name:value)
296
+ * @see RFC 6376 Section 3.4.1
297
+ * @since 0.4.0
298
+ */
299
+ function canonicalizeHeaderSimple(name, value) {
300
+ return `${name}:${value}`;
301
+ }
302
+ /**
303
+ * Relaxed header canonicalization.
304
+ *
305
+ * The "relaxed" header canonicalization algorithm:
306
+ * - Convert header field names to lowercase
307
+ * - Unfold header field continuation lines
308
+ * - Collapse whitespace sequences to a single space
309
+ * - Remove leading and trailing whitespace from header field values
310
+ *
311
+ * @param name - The header field name
312
+ * @param value - The header field value
313
+ * @returns The canonicalized header line (name:value)
314
+ * @see RFC 6376 Section 3.4.2
315
+ * @since 0.4.0
316
+ */
317
+ function canonicalizeHeaderRelaxed(name, value) {
318
+ const canonicalName = name.toLowerCase();
319
+ const canonicalValue = value.replace(/\r\n[\t ]+/g, " ").replace(/[\t ]+/g, " ").trim();
320
+ return `${canonicalName}:${canonicalValue}`;
321
+ }
322
+ /**
323
+ * Simple body canonicalization.
324
+ *
325
+ * The "simple" body canonicalization algorithm:
326
+ * - Ignores all empty lines at the end of the message body
327
+ * - If the body is empty, a single CRLF is appended
328
+ * - If there is no trailing CRLF on the body, a CRLF is added
329
+ *
330
+ * @param body - The message body
331
+ * @returns The canonicalized body
332
+ * @see RFC 6376 Section 3.4.3
333
+ * @since 0.4.0
334
+ */
335
+ function canonicalizeBodySimple(body) {
336
+ if (body === "") return "\r\n";
337
+ let result = body.replace(/(\r\n)+$/, "");
338
+ if (!result.endsWith("\r\n")) result += "\r\n";
339
+ return result;
340
+ }
341
+ /**
342
+ * Relaxed body canonicalization.
343
+ *
344
+ * The "relaxed" body canonicalization algorithm:
345
+ * - Reduce all sequences of WSP within a line to a single SP
346
+ * - Remove all trailing WSP at the end of each line (before CRLF)
347
+ * - Ignore all empty lines at the end of the message body
348
+ * - If the body is non-empty and doesn't end with CRLF, add CRLF
349
+ *
350
+ * @param body - The message body
351
+ * @returns The canonicalized body
352
+ * @see RFC 6376 Section 3.4.4
353
+ * @since 0.4.0
354
+ */
355
+ function canonicalizeBodyRelaxed(body) {
356
+ if (body === "") return "";
357
+ let processedBody = body;
358
+ if (!processedBody.endsWith("\r\n")) processedBody += "\r\n";
359
+ const lines = processedBody.split("\r\n");
360
+ const canonicalizedLines = [];
361
+ for (let i = 0; i < lines.length - 1; i++) {
362
+ let line = lines[i];
363
+ line = line.replace(/[\t ]+/g, " ");
364
+ line = line.replace(/[\t ]+$/, "");
365
+ canonicalizedLines.push(line);
366
+ }
367
+ while (canonicalizedLines.length > 0 && canonicalizedLines[canonicalizedLines.length - 1] === "") canonicalizedLines.pop();
368
+ if (canonicalizedLines.length === 0) return "";
369
+ return canonicalizedLines.join("\r\n") + "\r\n";
370
+ }
371
+
372
+ //#endregion
373
+ //#region src/dkim/types.ts
374
+ /**
375
+ * Default header fields to sign if not specified.
376
+ *
377
+ * @since 0.4.0
378
+ */
379
+ const DEFAULT_SIGNED_HEADERS = [
380
+ "from",
381
+ "to",
382
+ "subject",
383
+ "date"
384
+ ];
385
+ /**
386
+ * Default DKIM algorithm.
387
+ *
388
+ * @since 0.4.0
389
+ */
390
+ const DEFAULT_ALGORITHM = "rsa-sha256";
391
+ /**
392
+ * Default canonicalization method.
393
+ *
394
+ * @since 0.4.0
395
+ */
396
+ const DEFAULT_CANONICALIZATION = "relaxed/relaxed";
397
+
398
+ //#endregion
399
+ //#region src/dkim/sign.ts
400
+ /**
401
+ * Signs a raw email message with DKIM.
402
+ *
403
+ * @param rawMessage - The complete raw email message (headers + body)
404
+ * @param config - DKIM signature configuration
405
+ * @returns The DKIM-Signature header result
406
+ * @throws Error if signing fails (e.g., invalid private key)
407
+ * @since 0.4.0
408
+ */
409
+ async function signMessage(rawMessage, config) {
410
+ const algorithm = config.algorithm ?? DEFAULT_ALGORITHM;
411
+ const canonicalization = config.canonicalization ?? DEFAULT_CANONICALIZATION;
412
+ const headerFields = config.headerFields ?? DEFAULT_SIGNED_HEADERS;
413
+ const { headers, body } = parseMessage(rawMessage);
414
+ const [headerCanon, bodyCanon] = canonicalization.split("/");
415
+ const privateKey = await getPrivateKey(config.privateKey, algorithm);
416
+ const bodyHash = await computeBodyHash(body, bodyCanon);
417
+ const dkimHeaderValue = buildDkimHeaderValue({
418
+ algorithm,
419
+ canonicalization,
420
+ signingDomain: config.signingDomain,
421
+ selector: config.selector,
422
+ headerFields,
423
+ bodyHash
424
+ });
425
+ const signatureData = buildSignatureData(headers, headerFields, headerCanon, dkimHeaderValue);
426
+ const signature = await signData(signatureData, privateKey, algorithm);
427
+ return {
428
+ headerName: "DKIM-Signature",
429
+ signature: `${dkimHeaderValue} b=${signature}`
430
+ };
431
+ }
432
+ /**
433
+ * Parses a raw email message into headers and body.
434
+ */
435
+ function parseMessage(rawMessage) {
436
+ const separatorIndex = rawMessage.indexOf("\r\n\r\n");
437
+ if (separatorIndex === -1) return {
438
+ headers: parseHeaders(rawMessage),
439
+ body: ""
440
+ };
441
+ const headerSection = rawMessage.substring(0, separatorIndex);
442
+ const body = rawMessage.substring(separatorIndex + 4);
443
+ return {
444
+ headers: parseHeaders(headerSection),
445
+ body
446
+ };
447
+ }
448
+ /**
449
+ * Parses header section into a map of header name to value.
450
+ * Handles folded headers (continuation lines).
451
+ */
452
+ function parseHeaders(headerSection) {
453
+ const headers = /* @__PURE__ */ new Map();
454
+ const lines = headerSection.split("\r\n");
455
+ let currentName = "";
456
+ let currentValue = "";
457
+ for (const line of lines) if (line.startsWith(" ") || line.startsWith(" ")) currentValue += "\r\n" + line;
458
+ else {
459
+ if (currentName) headers.set(currentName.toLowerCase(), currentValue);
460
+ const colonIndex = line.indexOf(":");
461
+ if (colonIndex > 0) {
462
+ currentName = line.substring(0, colonIndex);
463
+ currentValue = line.substring(colonIndex + 1);
464
+ }
465
+ }
466
+ if (currentName) headers.set(currentName.toLowerCase(), currentValue);
467
+ return headers;
468
+ }
469
+ /**
470
+ * Gets the private key, either using a provided CryptoKey or importing from PEM.
471
+ */
472
+ function getPrivateKey(key, algorithm) {
473
+ if (typeof key !== "string") return key;
474
+ return importPrivateKey(key, algorithm);
475
+ }
476
+ /**
477
+ * Imports a PEM-encoded private key for use with Web Crypto API.
478
+ */
479
+ async function importPrivateKey(pem, algorithm) {
480
+ try {
481
+ const pemContents = pem.replace(/-----BEGIN (?:RSA )?PRIVATE KEY-----/, "").replace(/-----END (?:RSA )?PRIVATE KEY-----/, "").replace(/\s/g, "");
482
+ const binaryString = atob(pemContents);
483
+ const bytes = new Uint8Array(binaryString.length);
484
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
485
+ const keyAlgorithm = algorithm === "ed25519-sha256" ? { name: "Ed25519" } : {
486
+ name: "RSASSA-PKCS1-v1_5",
487
+ hash: "SHA-256"
488
+ };
489
+ return await crypto.subtle.importKey("pkcs8", bytes, keyAlgorithm, false, ["sign"]);
490
+ } catch (error) {
491
+ throw new Error(`Failed to import private key: ${error instanceof Error ? error.message : String(error)}`);
492
+ }
493
+ }
494
+ /**
495
+ * Computes the body hash (bh= tag value).
496
+ */
497
+ async function computeBodyHash(body, canonMethod) {
498
+ const canonicalBody = canonMethod === "relaxed" ? canonicalizeBodyRelaxed(body) : canonicalizeBodySimple(body);
499
+ const encoder = new TextEncoder();
500
+ const data = encoder.encode(canonicalBody);
501
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
502
+ return arrayBufferToBase64(hashBuffer);
503
+ }
504
+ /**
505
+ * Builds the DKIM-Signature header value without the b= signature.
506
+ */
507
+ function buildDkimHeaderValue(params) {
508
+ const parts = [
509
+ "v=1",
510
+ `a=${params.algorithm}`,
511
+ `c=${params.canonicalization}`,
512
+ `d=${params.signingDomain}`,
513
+ `s=${params.selector}`,
514
+ `h=${params.headerFields.join(":")}`,
515
+ `bh=${params.bodyHash};`
516
+ ];
517
+ return parts.join("; ");
518
+ }
519
+ /**
520
+ * Builds the data to be signed (canonicalized headers + DKIM-Signature header).
521
+ */
522
+ function buildSignatureData(headers, headerFields, canonMethod, dkimHeaderValue) {
523
+ const lines = [];
524
+ for (const field of headerFields) {
525
+ const value = headers.get(field.toLowerCase());
526
+ if (value !== void 0) {
527
+ const canonicalized = canonMethod === "relaxed" ? canonicalizeHeaderRelaxed(field, value) : canonicalizeHeaderSimple(field, value);
528
+ lines.push(canonicalized);
529
+ }
530
+ }
531
+ const dkimHeader = canonMethod === "relaxed" ? canonicalizeHeaderRelaxed("DKIM-Signature", " " + dkimHeaderValue + " b=") : canonicalizeHeaderSimple("DKIM-Signature", " " + dkimHeaderValue + " b=");
532
+ lines.push(dkimHeader);
533
+ return lines.join("\r\n");
534
+ }
535
+ /**
536
+ * Signs data using the appropriate algorithm.
537
+ */
538
+ async function signData(data, privateKey, algorithm) {
539
+ const encoder = new TextEncoder();
540
+ const dataBuffer = encoder.encode(data);
541
+ const signAlgorithm = algorithm === "ed25519-sha256" ? "Ed25519" : "RSASSA-PKCS1-v1_5";
542
+ const signature = await crypto.subtle.sign(signAlgorithm, privateKey, dataBuffer);
543
+ return arrayBufferToBase64(signature);
544
+ }
545
+ /**
546
+ * Converts an ArrayBuffer to a Base64 string.
547
+ */
548
+ function arrayBufferToBase64(buffer) {
549
+ const bytes = new Uint8Array(buffer);
550
+ let binary = "";
551
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
552
+ return btoa(binary);
553
+ }
554
+
277
555
  //#endregion
278
556
  //#region src/message-converter.ts
279
- async function convertMessage(message) {
557
+ async function convertMessage(message, dkimConfig) {
280
558
  const envelope = {
281
559
  from: message.sender.address,
282
560
  to: [
@@ -285,7 +563,16 @@ async function convertMessage(message) {
285
563
  ...message.bccRecipients.map((r) => r.address)
286
564
  ]
287
565
  };
288
- const raw = await buildRawMessage(message);
566
+ let raw = await buildRawMessage(message);
567
+ if (dkimConfig) try {
568
+ for (const sig of dkimConfig.signatures) {
569
+ const result = await signMessage(raw, sig);
570
+ raw = `${result.headerName}: ${result.signature}\r\n${raw}`;
571
+ }
572
+ } catch (error) {
573
+ if (dkimConfig.onSigningFailure === "send-unsigned") console.warn("DKIM signing failed, sending unsigned:", error);
574
+ else throw error;
575
+ }
289
576
  return {
290
577
  envelope,
291
578
  raw
@@ -524,7 +811,7 @@ var SmtpTransport = class {
524
811
  const connection = await this.getConnection(options?.signal);
525
812
  try {
526
813
  options?.signal?.throwIfAborted();
527
- const smtpMessage = await convertMessage(message);
814
+ const smtpMessage = await convertMessage(message, this.config.dkim);
528
815
  options?.signal?.throwIfAborted();
529
816
  const messageId = await connection.sendMessage(smtpMessage, options?.signal);
530
817
  await this.returnConnection(connection);
@@ -584,7 +871,7 @@ var SmtpTransport = class {
584
871
  continue;
585
872
  }
586
873
  try {
587
- const smtpMessage = await convertMessage(message);
874
+ const smtpMessage = await convertMessage(message, this.config.dkim);
588
875
  options?.signal?.throwIfAborted();
589
876
  const messageId = await connection.sendMessage(smtpMessage, options?.signal);
590
877
  yield {
@@ -609,7 +896,7 @@ var SmtpTransport = class {
609
896
  continue;
610
897
  }
611
898
  try {
612
- const smtpMessage = await convertMessage(message);
899
+ const smtpMessage = await convertMessage(message, this.config.dkim);
613
900
  options?.signal?.throwIfAborted();
614
901
  const messageId = await connection.sendMessage(smtpMessage, options?.signal);
615
902
  yield {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/smtp",
3
- "version": "0.4.0-dev.67+0f0eb30e",
3
+ "version": "0.4.0-dev.72+26b27bbe",
4
4
  "description": "SMTP transport for Upyo email library",
5
5
  "keywords": [
6
6
  "email",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "sideEffects": false,
55
55
  "peerDependencies": {
56
- "@upyo/core": "0.4.0-dev.67+0f0eb30e"
56
+ "@upyo/core": "0.4.0-dev.72+26b27bbe"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@dotenvx/dotenvx": "^1.47.3",