@push.rocks/smartmta 11.1.0 → 12.0.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/changelog.md +20 -0
- package/dist_rust/mailer-bin_linux_amd64.tsrust-build.json +4 -4
- package/dist_rust/mailer-bin_linux_arm64.tsrust-build.json +4 -4
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +15 -8
- package/dist_ts/mail/routing/classes.unified.email.server.js +68 -62
- package/dist_ts/plugins.d.ts +0 -2
- package/dist_ts/plugins.js +1 -4
- package/package.json +5 -7
- package/readme.md +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/mail/routing/classes.unified.email.server.ts +70 -62
- package/ts/plugins.ts +0 -7
package/dist_ts/plugins.d.ts
CHANGED
|
@@ -17,5 +17,3 @@ export { smartlog, smartmail, smartmailValidation, smartpath, smartrust };
|
|
|
17
17
|
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
|
18
18
|
import * as tsclass from '@tsclass/tsclass';
|
|
19
19
|
export { tsclass, };
|
|
20
|
-
import mailparser from 'mailparser';
|
|
21
|
-
export { mailparser, };
|
package/dist_ts/plugins.js
CHANGED
|
@@ -19,7 +19,4 @@ export { smartlog, smartmail, smartmailValidation, smartpath, smartrust };
|
|
|
19
19
|
// tsclass scope
|
|
20
20
|
import * as tsclass from '@tsclass/tsclass';
|
|
21
21
|
export { tsclass, };
|
|
22
|
-
|
|
23
|
-
import mailparser from 'mailparser';
|
|
24
|
-
export { mailparser, };
|
|
25
|
-
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsY0FBYztBQUNkLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBRTdCLE9BQU8sRUFDTCxHQUFHLEVBQ0gsTUFBTSxFQUNOLElBQUksRUFDSixHQUFHLEVBQ0gsRUFBRSxFQUNGLElBQUksRUFDSixHQUFHLEVBQ0gsR0FBRyxFQUNILElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxRQUFRLE1BQU0sc0JBQXNCLENBQUM7QUFDakQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssbUJBQW1CLE1BQU0sa0NBQWtDLENBQUM7QUFDeEUsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBRW5ELE9BQU8sRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLG1CQUFtQixFQUFFLFNBQVMsRUFBRSxTQUFTLEVBQUUsQ0FBQztBQUsxRSxnQkFBZ0I7QUFDaEIsT0FBTyxLQUFLLE9BQU8sTUFBTSxrQkFBa0IsQ0FBQztBQUU1QyxPQUFPLEVBQ0wsT0FBTyxHQUNSLENBQUE7QUFFRCxjQUFjO0FBQ2QsT0FBTyxVQUFVLE1BQU0sWUFBWSxDQUFDO0FBRXBDLE9BQU8sRUFDTCxVQUFVLEdBQ1gsQ0FBQSJ9
|
|
22
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsY0FBYztBQUNkLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBRTdCLE9BQU8sRUFDTCxHQUFHLEVBQ0gsTUFBTSxFQUNOLElBQUksRUFDSixHQUFHLEVBQ0gsRUFBRSxFQUNGLElBQUksRUFDSixHQUFHLEVBQ0gsR0FBRyxFQUNILElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxRQUFRLE1BQU0sc0JBQXNCLENBQUM7QUFDakQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssbUJBQW1CLE1BQU0sa0NBQWtDLENBQUM7QUFDeEUsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBRW5ELE9BQU8sRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLG1CQUFtQixFQUFFLFNBQVMsRUFBRSxTQUFTLEVBQUUsQ0FBQztBQUsxRSxnQkFBZ0I7QUFDaEIsT0FBTyxLQUFLLE9BQU8sTUFBTSxrQkFBa0IsQ0FBQztBQUU1QyxPQUFPLEVBQ0wsT0FBTyxHQUNSLENBQUEifQ==
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@push.rocks/smartmta",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "12.0.0",
|
|
4
4
|
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mta",
|
|
@@ -31,22 +31,20 @@
|
|
|
31
31
|
".": "./dist_ts/index.js"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@git.zone/cli": "^
|
|
34
|
+
"@git.zone/cli": "^9.0.1",
|
|
35
35
|
"@git.zone/tsbuild": "^6.0.2",
|
|
36
|
-
"@git.zone/tsrust": "^4.
|
|
36
|
+
"@git.zone/tsrust": "^4.6.0",
|
|
37
37
|
"@git.zone/tstest": "^8.0.0",
|
|
38
|
-
"@types/mailparser": "^3.4.6",
|
|
39
38
|
"@types/node": "^26.6.4",
|
|
40
39
|
"tsx": "^4.23.15"
|
|
41
40
|
},
|
|
42
41
|
"dependencies": {
|
|
43
42
|
"@push.rocks/smartlog": "^4.0.4",
|
|
44
|
-
"@push.rocks/smartmail": "^3.0
|
|
43
|
+
"@push.rocks/smartmail": "^3.1.0",
|
|
45
44
|
"@push.rocks/smartpath": "^6.0.0",
|
|
46
45
|
"@push.rocks/smartrust": "^2.6.0",
|
|
47
46
|
"@tsclass/tsclass": "^9.5.0",
|
|
48
|
-
"lru-cache": "^11.5.2"
|
|
49
|
-
"mailparser": "^3.9.14"
|
|
47
|
+
"lru-cache": "^11.5.2"
|
|
50
48
|
},
|
|
51
49
|
"files": [
|
|
52
50
|
"ts/**/*",
|
package/readme.md
CHANGED
|
@@ -430,7 +430,7 @@ server.updateOptions({ auth: { accounts } });
|
|
|
430
430
|
Enforcement semantics:
|
|
431
431
|
|
|
432
432
|
- **Credentials** — `PLAIN`/`LOGIN` passwords are verified against the stored verifier by deriving `StoredKey` from the presented password (PBKDF2-HMAC-SHA256 on the async crypto threadpool) and comparing in constant time. `SCRAM-SHA-256` clients are served the stored verifier fields directly, so the plaintext never needs to exist server-side. Accounts with `enabled: false` always fail authentication on both mechanisms. Verification is fully in-memory — no I/O inside the Rust auth callback window.
|
|
433
|
-
- **Sender scope ("send AS who")** — enforced twice. At the first `RCPT TO`, an out-of-scope `MAIL FROM` is refused with `553` before any recipient is accepted (with `smtp.recipientValidation: false` this check still runs, but only at `DATA`). At `DATA`, the envelope sender (taken from session envelope truth, never the parsed message alone) is re-checked per recipient, and **every**
|
|
433
|
+
- **Sender scope ("send AS who")** — enforced twice. At the first `RCPT TO`, an out-of-scope `MAIL FROM` is refused with `553` before any recipient is accepted (with `smtp.recipientValidation: false` this check still runs, but only at `DATA`). At `DATA`, the envelope sender (taken from session envelope truth, never the parsed message alone) is re-checked per recipient, and **every** header `From` mailbox must independently be in scope — header-From spoofing behind an in-scope envelope is refused with `553`, including multi-mailbox `From:` fields, group syntax (members are checked), repeated `From:` fields (all are checked) and `From:` fields that yield no mailbox. A message with no `From:` field at all is accepted, because its sender is the already-checked envelope `MAIL FROM`.
|
|
434
434
|
- **Recipient scope ("send TO who")** — with `recipients.mode: 'patterns'`, every `RCPT TO` matching no pattern is refused with `550`. `mode: 'any'` or an absent `recipients` block leaves recipients unrestricted.
|
|
435
435
|
- **Patterns** are exact addresses (`a@b.com`) or glob patterns (`*@b.com`), matched case-insensitively with identical semantics to route matching. A scoped account sending with an empty `MAIL FROM <>` (null sender) is refused unless a pattern explicitly matches — `senders: ['*']` is the documented escape hatch.
|
|
436
436
|
- **Route binding** — a route with `match.authenticatedUser: 'newsletter-service'` (or an array of usernames) matches only sessions authenticated as that account (exact, case-sensitive username comparison), so dedicated processing or relay routes can be attached per account. Unauthenticated sessions never match such routes.
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -2475,22 +2475,37 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2475
2475
|
}
|
|
2476
2476
|
|
|
2477
2477
|
/**
|
|
2478
|
-
*
|
|
2479
|
-
*
|
|
2480
|
-
*
|
|
2481
|
-
*
|
|
2482
|
-
*
|
|
2483
|
-
*
|
|
2478
|
+
* The mailbox addresses of every From header field, one list per field in
|
|
2479
|
+
* message order. Groups are flattened into their member mailboxes, and a
|
|
2480
|
+
* repeated From field is kept rather than collapsed to one occurrence,
|
|
2481
|
+
* because scope enforcement and Email.from must see every mailbox a
|
|
2482
|
+
* recipient's client might display. A field that yields no mailbox, such
|
|
2483
|
+
* as an empty group or an encoded word in place of an address, gives an
|
|
2484
|
+
* empty list.
|
|
2484
2485
|
*/
|
|
2485
|
-
private
|
|
2486
|
+
private parseFromHeaderFields(
|
|
2487
|
+
headers: ReadonlyArray<{ key: string; value: string }>,
|
|
2488
|
+
): string[][] {
|
|
2489
|
+
return headers
|
|
2490
|
+
.filter((header) => header.key === 'from')
|
|
2491
|
+
.map((header) =>
|
|
2492
|
+
plugins.smartmail.parseAddressList(header.value).map((mailbox) => mailbox.email),
|
|
2493
|
+
);
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
/**
|
|
2497
|
+
* The From header fields of the header section of a raw message, see
|
|
2498
|
+
* parseFromHeaderFields. Parsing stops at the first CRLFCRLF header
|
|
2499
|
+
* boundary when present (the Rust receiver normalizes line endings to
|
|
2500
|
+
* CRLF); a bodyless message without one is parsed whole.
|
|
2501
|
+
*/
|
|
2502
|
+
private async parseHeaderFromFields(rawMessage: Buffer): Promise<string[][]> {
|
|
2486
2503
|
const headerBoundary = rawMessage.indexOf('\r\n\r\n');
|
|
2487
2504
|
const headerSection = headerBoundary === -1
|
|
2488
2505
|
? rawMessage
|
|
2489
2506
|
: rawMessage.subarray(0, headerBoundary + 4);
|
|
2490
|
-
const parsed = await plugins.
|
|
2491
|
-
return (
|
|
2492
|
-
.map((mailbox: any) => mailbox?.address)
|
|
2493
|
-
.filter(Boolean) as string[];
|
|
2507
|
+
const parsed = await plugins.smartmail.parseMailMessage(headerSection);
|
|
2508
|
+
return this.parseFromHeaderFields(parsed.headers);
|
|
2494
2509
|
}
|
|
2495
2510
|
|
|
2496
2511
|
/**
|
|
@@ -2516,28 +2531,36 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
2516
2531
|
if (resolved.kind === 'unrestricted') return null;
|
|
2517
2532
|
if (resolved.kind === 'refused') return UnifiedEmailServer.authScopeUnavailableDecision();
|
|
2518
2533
|
const scope = resolved.scope;
|
|
2519
|
-
|
|
2534
|
+
const notPermitted: ISmtpDecision = {
|
|
2535
|
+
accepted: false,
|
|
2536
|
+
smtpCode: 553,
|
|
2537
|
+
smtpMessage: `5.7.1 Header From address not permitted for authenticated user ${username}`,
|
|
2538
|
+
};
|
|
2539
|
+
let fromFields: string[][];
|
|
2520
2540
|
try {
|
|
2521
|
-
|
|
2541
|
+
fromFields = await this.parseHeaderFromFields(rawMessage);
|
|
2522
2542
|
} catch {
|
|
2523
|
-
|
|
2543
|
+
// A header section that cannot be read cannot be shown to be in scope
|
|
2544
|
+
return notPermitted;
|
|
2524
2545
|
}
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
//
|
|
2546
|
+
let fromMailboxes: string[];
|
|
2547
|
+
if (fromFields.length === 0) {
|
|
2548
|
+
// A message without a From field is accepted deliberately: Email.from
|
|
2549
|
+
// falls back to the envelope MAIL FROM, which resolveRecipientPolicy has
|
|
2550
|
+
// already scope-checked, and no other sender identity is displayed.
|
|
2528
2551
|
fromMailboxes = [email.getFromAddress()];
|
|
2552
|
+
} else if (fromFields.some((field) => field.length === 0)) {
|
|
2553
|
+
// A From field without a mailbox, such as an empty group or an encoded
|
|
2554
|
+
// word in place of the address, still displays an identity this check
|
|
2555
|
+
// cannot verify.
|
|
2556
|
+
return notPermitted;
|
|
2557
|
+
} else {
|
|
2558
|
+
fromMailboxes = fromFields.flat();
|
|
2529
2559
|
}
|
|
2530
2560
|
const outOfScope = fromMailboxes.find(
|
|
2531
2561
|
(mailbox) => !this.matchesScopePatterns(mailbox, scope.senders),
|
|
2532
2562
|
);
|
|
2533
|
-
|
|
2534
|
-
return null;
|
|
2535
|
-
}
|
|
2536
|
-
return {
|
|
2537
|
-
accepted: false,
|
|
2538
|
-
smtpCode: 553,
|
|
2539
|
-
smtpMessage: `5.7.1 Header From address not permitted for authenticated user ${username}`,
|
|
2540
|
-
};
|
|
2563
|
+
return outOfScope === undefined ? null : notPermitted;
|
|
2541
2564
|
}
|
|
2542
2565
|
|
|
2543
2566
|
private getRecipientValidationTimeoutMs(): number {
|
|
@@ -3499,37 +3522,38 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
3499
3522
|
|
|
3500
3523
|
private async createEmailFromBuffer(emailData: Buffer, session: IExtendedSmtpSession): Promise<Email> {
|
|
3501
3524
|
try {
|
|
3502
|
-
|
|
3525
|
+
// Forwarded messages stay attachments of their bytes, so a relay hands them on unchanged
|
|
3526
|
+
const parsed = await plugins.smartmail.parseMailMessage(emailData, { rfc822Attachments: true });
|
|
3527
|
+
const headerFromMailboxes = this.parseFromHeaderFields(parsed.headers).flat();
|
|
3503
3528
|
const envelopeRecipients = session.envelope.rcptTo.map(recipient => recipient.address);
|
|
3504
|
-
const
|
|
3505
|
-
|
|
3506
|
-
.
|
|
3507
|
-
const parsedCc = ((parsed.cc as any)?.value || [])
|
|
3508
|
-
.map((recipient: any) => recipient.address)
|
|
3509
|
-
.filter(Boolean) as string[];
|
|
3510
|
-
const parsedHeaders: Record<string, string> = {};
|
|
3511
|
-
for (const [headerName, headerValue] of parsed.headers.entries()) {
|
|
3512
|
-
if (this.isStandardParsedHeader(headerName)) {
|
|
3529
|
+
const parsedHeaders = new Map<string, string>();
|
|
3530
|
+
for (const header of parsed.headers) {
|
|
3531
|
+
if (this.isStandardParsedHeader(header.key)) {
|
|
3513
3532
|
continue;
|
|
3514
3533
|
}
|
|
3515
|
-
|
|
3534
|
+
// Raw values as written; a repeated field joins its values with ', '
|
|
3535
|
+
const previous = parsedHeaders.get(header.key);
|
|
3536
|
+
parsedHeaders.set(header.key, previous === undefined ? header.value : `${previous}, ${header.value}`);
|
|
3516
3537
|
}
|
|
3517
3538
|
return new Email({
|
|
3518
|
-
|
|
3539
|
+
// The first mailbox of the From fields in message order, groups flattened
|
|
3540
|
+
from: headerFromMailboxes[0] || session.envelope.mailFrom.address,
|
|
3519
3541
|
to: envelopeRecipients.length > 0
|
|
3520
3542
|
? envelopeRecipients
|
|
3521
|
-
:
|
|
3522
|
-
cc:
|
|
3543
|
+
: parsed.to.map(recipient => recipient.email),
|
|
3544
|
+
cc: parsed.cc.map(recipient => recipient.email),
|
|
3523
3545
|
bcc: [],
|
|
3524
3546
|
subject: parsed.subject || '',
|
|
3525
|
-
|
|
3547
|
+
// A message that carries only HTML is read and scanned through its text
|
|
3548
|
+
text: parsed.text ?? (parsed.html ? plugins.smartmail.htmlToText(parsed.html) : ''),
|
|
3526
3549
|
html: parsed.html || undefined,
|
|
3527
|
-
headers: parsedHeaders,
|
|
3528
|
-
attachments: parsed.attachments
|
|
3529
|
-
filename:
|
|
3530
|
-
content:
|
|
3531
|
-
contentType:
|
|
3532
|
-
|
|
3550
|
+
headers: Object.fromEntries(parsedHeaders),
|
|
3551
|
+
attachments: parsed.attachments.map(attachment => ({
|
|
3552
|
+
filename: attachment.filename || '',
|
|
3553
|
+
content: Buffer.from(attachment.content),
|
|
3554
|
+
contentType: attachment.contentType,
|
|
3555
|
+
...(attachment.contentId ? { contentId: attachment.contentId } : {}),
|
|
3556
|
+
})),
|
|
3533
3557
|
});
|
|
3534
3558
|
} catch (error) {
|
|
3535
3559
|
logger.log('error', `Error parsing email data: ${(error as Error).message}`);
|
|
@@ -3555,22 +3579,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|
|
3555
3579
|
]).has(headerName.toLowerCase());
|
|
3556
3580
|
}
|
|
3557
3581
|
|
|
3558
|
-
private stringifyParsedHeaderValue(headerValue: unknown): string {
|
|
3559
|
-
if (Array.isArray(headerValue)) {
|
|
3560
|
-
return headerValue.map(value => this.stringifyParsedHeaderValue(value)).join(', ');
|
|
3561
|
-
}
|
|
3562
|
-
if (headerValue instanceof Date) {
|
|
3563
|
-
return headerValue.toUTCString();
|
|
3564
|
-
}
|
|
3565
|
-
if (typeof headerValue === 'string') {
|
|
3566
|
-
return headerValue;
|
|
3567
|
-
}
|
|
3568
|
-
if (headerValue && typeof headerValue === 'object' && 'text' in headerValue) {
|
|
3569
|
-
return String((headerValue as { text?: unknown }).text || '');
|
|
3570
|
-
}
|
|
3571
|
-
return headerValue === undefined || headerValue === null ? '' : String(headerValue);
|
|
3572
|
-
}
|
|
3573
|
-
|
|
3574
3582
|
/**
|
|
3575
3583
|
* Compute SCRAM-SHA-256 credential fields from a plaintext password with a
|
|
3576
3584
|
* fresh random salt (used for legacy plaintext credentials only; verifier
|