@upyo/smtp 0.4.0-dev.65 → 0.4.0-dev.68
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 +47 -0
- package/dist/index.cjs +293 -6
- package/dist/index.d.cts +109 -2
- package/dist/index.d.ts +109 -2
- package/dist/index.js +293 -6
- package/package.json +2 -2
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
|
-
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.4.0-dev.68+c6f685e9",
|
|
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.
|
|
56
|
+
"@upyo/core": "0.4.0-dev.68+c6f685e9"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@dotenvx/dotenvx": "^1.47.3",
|