@bumail/mta-sts 0.0.0-stage → 0.1.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.
@@ -0,0 +1,25 @@
1
+ import { type ParseResult } from './errors';
2
+ /** The policy file of RFC 8461 §3.2. */
3
+ export interface StsPolicy {
4
+ readonly version: 'STSv1';
5
+ readonly mode: 'enforce' | 'testing' | 'none';
6
+ /** MX patterns, lowercase: a host name or `*.` and a suffix (§4.1). Empty only for `none`. */
7
+ readonly mx: readonly string[];
8
+ /** Seconds the policy may be cached, 0 to {@link MAX_AGE_LIMIT}. */
9
+ readonly maxAge: number;
10
+ }
11
+ export type PolicyErrorCode = 'MISSING_VERSION' | 'BAD_VERSION' | 'MISSING_MODE' | 'BAD_MODE' | 'MISSING_MX' | 'BAD_MX' | 'MISSING_MAX_AGE' | 'BAD_MAX_AGE' | 'BAD_LINE';
12
+ /** The largest `max_age` §3.2 allows: 31 557 600 seconds, about a year. */
13
+ export declare const MAX_AGE_LIMIT = 31557600;
14
+ /**
15
+ * Read a policy file: `key: value` lines, CRLF or LF, blank lines and
16
+ * unknown keys ignored, `mx` given once per pattern. Keys are lowercase; a
17
+ * key given twice keeps its first value, except `mx`. `mx` is required
18
+ * unless the mode is `none`.
19
+ */
20
+ export declare function parsePolicy(text: string): ParseResult<StsPolicy, PolicyErrorCode>;
21
+ /** The policy file, the inverse of {@link parsePolicy}: CRLF lines, the last ended too. */
22
+ export declare function formatPolicy(policy: Omit<StsPolicy, 'version'> & {
23
+ readonly version?: 'STSv1';
24
+ }): string;
25
+ //# sourceMappingURL=policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAE/D,wCAAwC;AACxC,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,SAAS,GAAG,MAAM,CAAC;IAC9C,8FAA8F;IAC9F,QAAQ,CAAC,EAAE,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,oEAAoE;IACpE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,eAAe,GACxB,iBAAiB,GACjB,aAAa,GACb,cAAc,GACd,UAAU,GACV,YAAY,GACZ,QAAQ,GACR,iBAAiB,GACjB,aAAa,GACb,UAAU,CAAC;AAEd,2EAA2E;AAC3E,eAAO,MAAM,aAAa,WAAa,CAAC;AAUxC;;;;;GAKG;AACH,wBAAgB,WAAW,CAC1B,IAAI,EAAE,MAAM,GACV,WAAW,CAAC,SAAS,EAAE,eAAe,CAAC,CA2DzC;AAED,2FAA2F;AAC3F,wBAAgB,YAAY,CAC3B,MAAM,EAAE,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,GAAG;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GACjE,MAAM,CAiCR"}
@@ -0,0 +1,59 @@
1
+ import { type TlsResultType } from './tls-result';
2
+ /** One failure line of a policy's `failure-details` (RFC 8460 §4.4). */
3
+ export interface TlsFailureDetail {
4
+ readonly resultType: TlsResultType;
5
+ readonly sendingMtaIp?: string;
6
+ readonly receivingMxHostname?: string;
7
+ readonly receivingMxHelo?: string;
8
+ readonly receivingIp?: string;
9
+ readonly failedSessionCount: number;
10
+ readonly additionalInformation?: string;
11
+ readonly failureReasonCode?: string;
12
+ }
13
+ export interface TlsReportPolicy {
14
+ readonly policy: {
15
+ readonly type: 'sts' | 'no-policy-found';
16
+ /** The policy file as fetched, text or its lines; for `sts`. */
17
+ readonly string?: string | readonly string[];
18
+ readonly domain: string;
19
+ readonly mxHost?: readonly string[];
20
+ };
21
+ readonly summary: {
22
+ readonly success: number;
23
+ readonly failure: number;
24
+ };
25
+ readonly failureDetails?: readonly TlsFailureDetail[];
26
+ }
27
+ export interface TlsReportInput {
28
+ readonly organizationName: string;
29
+ readonly contactInfo: string;
30
+ readonly reportId: string;
31
+ /** A `Date`, or text such as `2026-11-02T00:00:00Z`. */
32
+ readonly dateRange: {
33
+ readonly start: Date | string;
34
+ readonly end: Date | string;
35
+ };
36
+ readonly policies: readonly TlsReportPolicy[];
37
+ }
38
+ /** The report as RFC 8460 §4 writes it, with its hyphenated keys. */
39
+ export interface TlsReport {
40
+ readonly 'organization-name': string;
41
+ readonly 'date-range': {
42
+ readonly 'start-datetime': string;
43
+ readonly 'end-datetime': string;
44
+ };
45
+ readonly 'contact-info': string;
46
+ readonly 'report-id': string;
47
+ readonly policies: readonly Record<string, unknown>[];
48
+ }
49
+ /**
50
+ * The TLS report of RFC 8460 §4 for the given counts, validated: strings
51
+ * non-empty, counts whole numbers, dates as `YYYY-MM-DDTHH:MM:SSZ`, result
52
+ * types from §4.3, an `sts` policy with its policy text. Throws
53
+ * `MtaStsError` `INVALID_REPORT`, naming the field. Compressing it (§5.1)
54
+ * and sending it are the caller's.
55
+ */
56
+ export declare function tlsReport(input: TlsReportInput): TlsReport;
57
+ /** The report as JSON text (RFC 8460 §4: a JSON object, UTF-8). */
58
+ export declare function serialiseTlsReport(report: TlsReport): string;
59
+ //# sourceMappingURL=report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAEpE,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAChC,QAAQ,CAAC,UAAU,EAAE,aAAa,CAAC;IACnC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IACxC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,MAAM,EAAE;QAChB,QAAQ,CAAC,IAAI,EAAE,KAAK,GAAG,iBAAiB,CAAC;QACzC,gEAAgE;QAChE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC;QAC7C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;KACpC,CAAC;IACF,QAAQ,CAAC,OAAO,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACzE,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;CACtD;AAED,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,wDAAwD;IACxD,QAAQ,CAAC,SAAS,EAAE;QACnB,QAAQ,CAAC,KAAK,EAAE,IAAI,GAAG,MAAM,CAAC;QAC9B,QAAQ,CAAC,GAAG,EAAE,IAAI,GAAG,MAAM,CAAC;KAC5B,CAAC;IACF,QAAQ,CAAC,QAAQ,EAAE,SAAS,eAAe,EAAE,CAAC;CAC9C;AAED,qEAAqE;AACrE,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,QAAQ,CAAC,YAAY,EAAE;QACtB,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;QAClC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;KAChC,CAAC;IACF,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;CACtD;AAkHD;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,CAc1D;AAED,mEAAmE;AACnE,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAE5D"}
@@ -0,0 +1,21 @@
1
+ import { type ParseResult } from './errors';
2
+ import { type TxtAnswer } from './txt';
3
+ /**
4
+ * The `_mta-sts.<domain>` TXT record (RFC 8461 §3.1): `v=STSv1; id=<id>`.
5
+ */
6
+ export interface StsRecord {
7
+ /** 1 to 32 letters and digits; a change tells senders to refetch the policy. */
8
+ readonly id: string;
9
+ }
10
+ export type StsRecordErrorCode = 'NO_RECORD' | 'MULTIPLE_RECORDS' | 'MISSING_ID' | 'BAD_ID';
11
+ /**
12
+ * Read the `_mta-sts` TXT answer. Pass the strings of every TXT record of
13
+ * the name (one record's chunks joined), as strings or as `@bumail/dns`
14
+ * `TxtRecord`s; the ones that are not `v=STSv1`
15
+ * are ignored, and more than one that is makes the answer invalid (§3.1).
16
+ * Fields after the id are extensions and ignored.
17
+ */
18
+ export declare function parseStsRecord(txt: TxtAnswer): ParseResult<StsRecord, StsRecordErrorCode>;
19
+ /** The record's text, the inverse of {@link parseStsRecord}: `v=STSv1; id=<id>`. */
20
+ export declare function formatStsRecord(record: StsRecord): string;
21
+ //# sourceMappingURL=sts-record.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sts-record.d.ts","sourceRoot":"","sources":["../src/sts-record.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAC/D,OAAO,EAAE,KAAK,SAAS,EAAS,MAAM,OAAO,CAAC;AAE9C;;GAEG;AACH,MAAM,WAAW,SAAS;IACzB,gFAAgF;IAChF,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,kBAAkB,GAC3B,WAAW,GACX,kBAAkB,GAClB,YAAY,GACZ,QAAQ,CAAC;AAeZ;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC7B,GAAG,EAAE,SAAS,GACZ,WAAW,CAAC,SAAS,EAAE,kBAAkB,CAAC,CAwB5C;AAED,oFAAoF;AACpF,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAQzD"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The result types of RFC 8460 §4.3. `@bumail/smtp` names the same strings
3
+ * (`src/errors.ts`); this package peers on nothing, so it declares them
4
+ * itself. A deliberate duplication, see AGENTS.md: change both together;
5
+ * `tls-result.spec.ts` fails `typecheck` when they differ.
6
+ */
7
+ export declare const TLS_RESULT_TYPES: readonly ["starttls-not-supported", "certificate-host-mismatch", "certificate-expired", "certificate-not-trusted", "validation-failure", "tlsa-invalid", "dnssec-invalid", "dane-required", "sts-policy-fetch-error", "sts-policy-invalid", "sts-webpki-invalid"];
8
+ export type TlsResultType = (typeof TLS_RESULT_TYPES)[number];
9
+ //# sourceMappingURL=tls-result.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tls-result.d.ts","sourceRoot":"","sources":["../src/tls-result.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,mQAYnB,CAAC;AAEX,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC"}
@@ -0,0 +1,24 @@
1
+ import { type ParseResult } from './errors';
2
+ import { type TxtAnswer } from './txt';
3
+ /**
4
+ * The `_smtp._tls.<domain>` TXT record (RFC 8460 §3): where to send TLS
5
+ * reports, `v=TLSRPTv1; rua=mailto:tls@example.com,https://example.com/r`.
6
+ */
7
+ export interface TlsRptRecord {
8
+ /** One or more `mailto:` or `https:` URIs, in the record's order. */
9
+ readonly rua: readonly string[];
10
+ }
11
+ export type TlsRptRecordErrorCode = 'NO_RECORD' | 'MULTIPLE_RECORDS' | 'MISSING_RUA' | 'BAD_RUA';
12
+ /**
13
+ * Read the `_smtp._tls` TXT answer, like {@link parseStsRecord}: strings
14
+ * that are not `v=TLSRPTv1` are ignored, more than one that is makes it
15
+ * invalid, unknown fields are ignored. `rua` is a comma-separated list;
16
+ * the entries that are not a `mailto:` address or an `https:` URL (another
17
+ * scheme a sender may support, or a malformed one) are left out, and the
18
+ * record is `BAD_RUA` only when none is left (§3). Pass the TXT strings or
19
+ * `@bumail/dns` `TxtRecord`s.
20
+ */
21
+ export declare function parseTlsRptRecord(txt: TxtAnswer): ParseResult<TlsRptRecord, TlsRptRecordErrorCode>;
22
+ /** The record's text, the inverse of {@link parseTlsRptRecord}. */
23
+ export declare function formatTlsRptRecord(record: TlsRptRecord): string;
24
+ //# sourceMappingURL=tls-rpt-record.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tls-rpt-record.d.ts","sourceRoot":"","sources":["../src/tls-rpt-record.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AAC/D,OAAO,EAAE,KAAK,SAAS,EAAS,MAAM,OAAO,CAAC;AAE9C;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC5B,qEAAqE;IACrE,QAAQ,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,CAAC;CAChC;AAED,MAAM,MAAM,qBAAqB,GAC9B,WAAW,GACX,kBAAkB,GAClB,aAAa,GACb,SAAS,CAAC;AAiCb;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAChC,GAAG,EAAE,SAAS,GACZ,WAAW,CAAC,YAAY,EAAE,qBAAqB,CAAC,CA8BlD;AAED,mEAAmE;AACnE,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAiB/D"}
package/dist/txt.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The TXT answer of a name: its strings, or records with a `text` (what
3
+ * `@bumail/dns`'s `Resolver.txt` answers, matched by shape).
4
+ */
5
+ export type TxtAnswer = string | readonly (string | {
6
+ readonly text: string;
7
+ })[];
8
+ export declare function texts(answer: TxtAnswer): string[];
9
+ //# sourceMappingURL=txt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"txt.d.ts","sourceRoot":"","sources":["../src/txt.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,MAAM,SAAS,GAClB,MAAM,GACN,SAAS,CAAC,MAAM,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EAAE,CAAC;AAEnD,wBAAgB,KAAK,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,EAAE,CAGjD"}
package/docs/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # @bumail/mta-sts documentation
2
+
3
+ The [package README](../README.md) is the short version. This folder is the
4
+ long one.
5
+
6
+ | Page | Read it when |
7
+ | --- | --- |
8
+ | [Guide](guide.md) | publishing your own policy, reading another domain's, fetching it safely, or building a TLS report |
9
+ | [Troubleshooting](troubleshooting.md) | a parser, `fetchPolicy`, a formatter or `tlsReport` refused something |
10
+ | [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
package/docs/guide.md ADDED
@@ -0,0 +1,130 @@
1
+ # Guide
2
+
3
+ - [Publishing your policy](#publishing-your-policy)
4
+ - [Reading another domain's policy](#reading-another-domains-policy)
5
+ - [The fetch rules](#the-fetch-rules)
6
+ - [TLS-RPT: asking for reports](#tls-rpt-asking-for-reports)
7
+ - [Building a TLS report](#building-a-tls-report)
8
+ - [Specs: no network](#specs-no-network)
9
+
10
+ ## Publishing your policy
11
+
12
+ MTA-STS is three things: a TXT record `_mta-sts.<domain>`, a policy file
13
+ served at `https://mta-sts.<domain>/.well-known/mta-sts.txt`, and a
14
+ certificate for the `mta-sts.<domain>` host. This package writes the first
15
+ two.
16
+
17
+ ```ts
18
+ import { formatPolicy, formatStsRecord } from '@bumail/mta-sts';
19
+
20
+ // Start in testing: senders deliver anyway and report failures.
21
+ const file = formatPolicy({ mode: 'testing', mx: ['mail.example.com'], maxAge: 86400 });
22
+ // Serve `file` as text/plain at the well-known URL.
23
+
24
+ // Change `id` every time the file changes, or senders keep the cached one.
25
+ const txt = formatStsRecord({ id: String(Date.now()) });
26
+ // Publish `txt` as the TXT record of _mta-sts.example.com
27
+ ```
28
+
29
+ `mx` lists the hosts your MX records name. A pattern is a host name or
30
+ `*.` and a suffix. Move to `enforce` once the reports are clean, and to
31
+ `none` with a short `max_age` to retire the policy (`mx` may then be empty).
32
+
33
+ ## Reading another domain's policy
34
+
35
+ Look the TXT up with a `@bumail/dns` resolver, then fetch:
36
+
37
+ ```ts
38
+ import { nodeResolver } from '@bumail/dns';
39
+ import { fetchPolicy, parseStsRecord } from '@bumail/mta-sts';
40
+
41
+ const resolver = nodeResolver(); // txt(name) -> [{ text, ttl }, …], or throws a DnsError
42
+
43
+ const answer = await resolver.txt('_mta-sts.gmail.com');
44
+ const record = parseStsRecord(answer); // the TxtRecords go in as they are
45
+ if (record.ok) {
46
+ const result = await fetchPolicy('gmail.com');
47
+ // Cache result.policy for policy.maxAge seconds, or until record.value.id changes.
48
+ }
49
+ ```
50
+
51
+ `parseStsRecord` takes every TXT string of the name: strings that are not
52
+ `v=STSv1` are ignored, and two that are make the answer invalid (RFC 8461
53
+ §3.1), because a domain must not have two policies.
54
+
55
+ ## The fetch rules
56
+
57
+ `fetchPolicy` follows RFC 8461 §3.3 and refuses everything else:
58
+
59
+ | Rule | Error code | Result type reported |
60
+ | --- | --- | --- |
61
+ | a valid certificate for `mta-sts.<domain>` | `CERTIFICATE` | `sts-webpki-invalid` |
62
+ | no redirect, any 3xx is an error | `REDIRECT` | `sts-policy-fetch-error` |
63
+ | status 200 | `STATUS` | `sts-policy-fetch-error` |
64
+ | `Content-Type: text/plain` (a charset is fine) | `CONTENT_TYPE` | `sts-policy-fetch-error` |
65
+ | at most 64 KiB, counted while reading | `TOO_LARGE` | `sts-policy-fetch-error` |
66
+ | the whole fetch within `timeoutMs` (60 s by default) | `TIMEOUT` | `sts-policy-fetch-error` |
67
+ | any other network failure, a body that fails mid-read, or the caller's `signal` aborting | `NETWORK` | `sts-policy-fetch-error` |
68
+ | UTF-8 text | `NOT_UTF8` | `sts-policy-invalid` |
69
+ | a policy `parsePolicy` accepts | `INVALID_POLICY` | `sts-policy-invalid` |
70
+
71
+ `CONTENT_TYPE` and `TOO_LARGE` are reported as `sts-policy-fetch-error`, not
72
+ `sts-policy-invalid`: RFC 8460 §4.3.2 uses `sts-policy-invalid` for a policy
73
+ that was had and could not be read as one, and in both cases the fetch
74
+ (§3.3 of RFC 8461) failed before there was a policy to read. A body that
75
+ arrived, in the right type and size, and does not parse is the invalid one.
76
+
77
+ `fetchPolicy` always answers a result: a network failure, a misbehaving
78
+ `fetch`, a stream that errors, a non-string domain. Only an option out of
79
+ range throws (`MtaStsError` `INVALID_OPTION`, before any fetch), because it
80
+ is the caller's bug, not the network's. Pass `signal` to give up early; an
81
+ already-aborted signal answers at once without fetching.
82
+
83
+ The RFC fixes no timeout; 60 seconds is generous for a file of a few lines.
84
+ A queue that delivers on the spot will pass a shorter `timeoutMs` (1 to
85
+ 2147483647: a timer takes no longer delay).
86
+ `maxAgeCap` lowers the `max_age` of the policy you get back, so a peer
87
+ cannot make you cache for a year.
88
+
89
+ A certificate refusal is told from other failures by the error's `code`
90
+ (`CERT_HAS_EXPIRED`, `DEPTH_ZERO_SELF_SIGNED_CERT`,
91
+ `ERR_TLS_CERT_ALTNAME_INVALID`, …) or a message naming the certificate, on
92
+ the error or its `cause`. A runtime whose `fetch` reports it otherwise is
93
+ seen as a `NETWORK` failure; give a `fetch` that does.
94
+
95
+ ## TLS-RPT: asking for reports
96
+
97
+ ```ts
98
+ import { formatTlsRptRecord } from '@bumail/mta-sts';
99
+
100
+ formatTlsRptRecord({ rua: ['mailto:tls-reports@example.com'] });
101
+ // Publish as the TXT record of _smtp._tls.example.com
102
+ ```
103
+
104
+ `parseTlsRptRecord` reads a peer's, to know where to send yours. Each `rua`
105
+ is `mailto:` or `https:`.
106
+
107
+ ## Building a TLS report
108
+
109
+ `tlsReport` checks the counts and names, and returns the object of RFC 8460
110
+ §4 with its hyphenated keys (`organization-name`, `policy-type`,
111
+ `total-successful-session-count`, …). `policy.string` is the policy file as
112
+ fetched (`result.text`), written as the array of its lines. A `no-policy-found`
113
+ entry carries no policy text. Compress the JSON with gzip and send it to the
114
+ `rua` (RFC 8460 §5); this package does neither. `@bumail/server` does both,
115
+ when `[mtaSts] reports = true` (counting the queue's `tls` events per policy
116
+ domain and day, then mailing or POSTing the report).
117
+
118
+ ## Specs: no network
119
+
120
+ `fetchPolicy` calls the `fetch` you give it, with the URL and
121
+ `{ redirect: 'manual', signal, headers }`:
122
+
123
+ ```ts
124
+ const result = await fetchPolicy('example.com', {
125
+ fetch: async () =>
126
+ new Response('version: STSv1\nmode: none\nmax_age: 0\n', {
127
+ headers: { 'content-type': 'text/plain' },
128
+ }),
129
+ });
130
+ ```
@@ -0,0 +1,27 @@
1
+ # Roadmap
2
+
3
+ What `@bumail/mta-sts` gives a mail server, and what is coming. This page is
4
+ a direction, not a commitment: the version something shipped in is the only
5
+ number on it.
6
+
7
+ ## Now
8
+
9
+ ## Next
10
+
11
+ ## Later
12
+
13
+ - **Receiving reports**: reading the gzip JSON a peer sends to your `rua`.
14
+ - **A `tlsa` policy type** in reports, with DANE.
15
+
16
+ ## Not planned
17
+
18
+ - **DNS lookups, HTTP serving and caching.** The package is the format and
19
+ the fetch; the resolver is `@bumail/dns`, the cache is the caller's.
20
+
21
+ ## Shipped
22
+
23
+ - **Used by the queue, the server and `bumail dns`**: the queue enforces a
24
+ recipient domain's policy with `fetchPolicy`, `bumail dns` prints what
25
+ `formatStsRecord` and `formatTlsRptRecord` write, and `@bumail/server`
26
+ builds its daily TLS reports with `tlsReport` and sends them (opt-in).
27
+ This package stays the format and the fetch.
@@ -0,0 +1,199 @@
1
+ # Troubleshooting
2
+
3
+ Each entry is headed by the message you will see; the parts shown as … vary.
4
+ A parser or `fetchPolicy` answers `{ ok: false, error: { code, message } }`
5
+ (`code` is the group below); a formatter, `tlsReport` or a bad `fetchPolicy`
6
+ option throws `MtaStsError`, whose `code` is its group.
7
+
8
+ **NO_RECORD, MULTIPLE_RECORDS**
9
+
10
+ - [`No TXT record starts with v=STSv1`](#no-txt-record-starts-with-vstsv1)
11
+ - [`No TXT record starts with v=TLSRPTv1`](#no-txt-record-starts-with-vtlsrptv1)
12
+ - [`… TXT records start with v=STSv1; …`](#-txt-records-start-with-vstsv1-rfc-8461-31-treats-that-as-no-policy)
13
+ - [`… TXT records start with v=TLSRPTv1; …`](#-txt-records-start-with-vtlsrptv1-rfc-8460-3-treats-that-as-no-record)
14
+
15
+ **MISSING_ID, BAD_ID, MISSING_RUA, BAD_RUA**
16
+
17
+ - [`The record has no id field`](#the-record-has-no-id-field)
18
+ - [`id "…" is not 1 to 32 letters and digits`](#id--is-not-1-to-32-letters-and-digits)
19
+ - [`The record has no rua field`](#the-record-has-no-rua-field)
20
+ - [`rua "…" holds no mailto: or https: URI`](#rua--holds-no-mailto-or-https-uri)
21
+
22
+ **`MISSING_*`, `BAD_*` and `BAD_LINE` of the policy**
23
+
24
+ - the `The policy has no …` and `… is not …` entries from `version` to `"…" is not a key: value line`, below
25
+
26
+ **STATUS, REDIRECT, CONTENT_TYPE, TOO_LARGE, NOT_UTF8, INVALID_POLICY (fetch)**
27
+
28
+ - the `The policy host …`, `The policy was served …`, `The policy is …` entries, below
29
+
30
+ **TIMEOUT, NETWORK, CERTIFICATE, INVALID_DOMAIN (fetch)**
31
+
32
+ - the `The policy was not had …`, `The policy could not be …`, `The policy fetch was aborted …`, `The policy host's certificate …` and `"…" is not a domain …` entries, below
33
+
34
+ **INVALID_OPTION, INVALID_RECORD, INVALID_POLICY, INVALID_REPORT (thrown)**
35
+
36
+ - the `fetchPolicy(): …`, `formatStsRecord(): …`, `formatTlsRptRecord(): …`, `formatPolicy(): …` and `tlsReport(): …` entries, at the end
37
+
38
+ ## `No TXT record starts with v=STSv1`
39
+
40
+ `NO_RECORD`. The `_mta-sts` name has no TXT string beginning `v=STSv1`
41
+ (case-sensitive). The domain has no policy. Publish `formatStsRecord({ id })`.
42
+
43
+ ## `No TXT record starts with v=TLSRPTv1`
44
+
45
+ `NO_RECORD` of `parseTlsRptRecord`: the `_smtp._tls` name has no report
46
+ address. Publish `formatTlsRptRecord({ rua })`.
47
+
48
+ ## `… TXT records start with v=STSv1; RFC 8461 §3.1 treats that as no policy`
49
+
50
+ `MULTIPLE_RECORDS`. Two records on the name. Delete the old one.
51
+
52
+ ## `… TXT records start with v=TLSRPTv1; RFC 8460 §3 treats that as no record`
53
+
54
+ `MULTIPLE_RECORDS`. Same, on `_smtp._tls`.
55
+
56
+ ## `The record has no id field`
57
+
58
+ `MISSING_ID`. Write `v=STSv1; id=<id>`.
59
+
60
+ ## `id "…" is not 1 to 32 letters and digits`
61
+
62
+ `BAD_ID`. Use letters and digits only, such as a date and time:
63
+ `20260103T0900`.
64
+
65
+ ## `The record has no rua field`
66
+
67
+ `MISSING_RUA`. Write `v=TLSRPTv1; rua=mailto:…`.
68
+
69
+ ## `rua "…" holds no mailto: or https: URI`
70
+
71
+ `BAD_RUA`. Entries that are not `mailto:name@host` or an `https:` URL (another
72
+ scheme, `http:`, a malformed one) are left out; the record fails only when
73
+ none is left.
74
+
75
+ ## `The policy has no version line`
76
+
77
+ `MISSING_VERSION`. The first line is `version: STSv1`.
78
+
79
+ ## `version "…" is not STSv1`
80
+
81
+ `BAD_VERSION`.
82
+
83
+ ## `The policy has no mode line`
84
+
85
+ `MISSING_MODE`.
86
+
87
+ ## `mode "…" is not enforce, testing or none`
88
+
89
+ `BAD_MODE`.
90
+
91
+ ## `The policy has no max_age line`
92
+
93
+ `MISSING_MAX_AGE`.
94
+
95
+ ## `max_age "…" is not a whole number of seconds from 0 to 31557600`
96
+
97
+ `BAD_MAX_AGE`. Digits only, at most a year.
98
+
99
+ ## `A policy in mode … needs at least one mx line`
100
+
101
+ `MISSING_MX`. Only `mode: none` may omit `mx`.
102
+
103
+ ## `mx "…" is not a host name or a *. wildcard`
104
+
105
+ `BAD_MX`. A host name such as `mail.example.com`, or `*.example.com`.
106
+
107
+ ## `"…" is not a key: value line`
108
+
109
+ `BAD_LINE`. A non-blank line of the policy has no colon.
110
+
111
+ ## `The policy host answered …, not 200`
112
+
113
+ `STATUS`. The `mta-sts.<domain>` host answered 404 or another status. Serve
114
+ the file at `/.well-known/mta-sts.txt`.
115
+
116
+ ## `The policy host redirected; RFC 8461 §3.3 does not follow redirects`
117
+
118
+ `REDIRECT`. Serve the file from that URL itself, not through a redirect
119
+ (including http to https or www).
120
+
121
+ ## `The policy was served as "…", not text/plain`
122
+
123
+ `CONTENT_TYPE`. Set `Content-Type: text/plain`.
124
+
125
+ ## `The policy is larger than 65536 bytes`
126
+
127
+ `TOO_LARGE`. A policy is a few lines; something else is being served.
128
+
129
+ ## `The policy is not UTF-8 text`
130
+
131
+ `NOT_UTF8`. Serve the file as UTF-8.
132
+
133
+ ## `The policy is invalid: …`
134
+
135
+ `INVALID_POLICY`. The rest is one of the policy messages above.
136
+
137
+ ## `The policy was not had within … ms`
138
+
139
+ `TIMEOUT`. The host is slow or silent. Raise `timeoutMs` if it is only slow.
140
+
141
+ ## `The policy host's certificate was refused: …`
142
+
143
+ `CERTIFICATE`, reported as `sts-webpki-invalid`. The certificate of
144
+ `mta-sts.<domain>` is expired, self-signed, untrusted or for another name.
145
+
146
+ ## `The policy could not be fetched: …`
147
+
148
+ `NETWORK`. DNS, refused connection, reset. There is no `mta-sts` host, or it
149
+ is down.
150
+
151
+ ## `The policy fetch was aborted by the caller`
152
+
153
+ `NETWORK`. The `signal` you passed aborted, or was already aborted when
154
+ `fetchPolicy` was called (then nothing was fetched). Not a fault of the host.
155
+
156
+ ## `The policy could not be read: …`
157
+
158
+ `NETWORK`. The body's stream failed while it was read (a reset, a closed
159
+ connection). Try again later.
160
+
161
+ ## `"…" is not a domain to fetch a policy for`
162
+
163
+ `INVALID_DOMAIN`. Pass a plain domain name, no scheme, path or port.
164
+
165
+ ## `fetchPolicy(): timeoutMs must be a number of milliseconds from 1 to 2147483647`
166
+
167
+ `INVALID_OPTION`, thrown before any fetch. Pass a number from 1 to 2147483647
168
+ (about 24 days: a timer takes no longer delay), or leave it out for 60
169
+ seconds.
170
+
171
+ ## `fetchPolicy(): maxAgeCap must be a whole number of seconds from 0 to 31557600`
172
+
173
+ `INVALID_OPTION`, thrown before any fetch.
174
+
175
+ ## `formatStsRecord(): id must be 1 to 32 letters and digits`
176
+
177
+ `INVALID_RECORD`.
178
+
179
+ ## `formatTlsRptRecord(): rua must hold at least one URI`
180
+
181
+ `INVALID_RECORD`.
182
+
183
+ ## `formatTlsRptRecord(): rua "…" is not a mailto: or https: URI without a raw ! or , (write them %21 and %2C)`
184
+
185
+ `INVALID_RECORD`. RFC 8460 §3 reserves `!` and `,` in a `rua` URI; encode them
186
+ as `%21` and `%2C`.
187
+
188
+ ## `formatPolicy(): …`
189
+
190
+ `INVALID_POLICY`. The rest names the field: `mode must be enforce, testing or
191
+ none`, `maxAge must be a whole number of seconds from 0 to 31557600`,
192
+ `mx "…" is not a host name or a *. wildcard`, `mode … needs at least one mx`.
193
+
194
+ ## `tlsReport(): …`
195
+
196
+ `INVALID_REPORT`. The rest names the field, such as `policies[0].summary.success
197
+ must be a whole number, 0 or more`, `dateRange.start must be a Date or
198
+ YYYY-MM-DDTHH:MM:SSZ`, `policies must hold at least one policy`,
199
+ `policies[0].policy.string is required for an sts policy`.
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
- "name": "@bumail/mta-sts",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@bumail/mta-sts",
3
+ "version": "0.1.0",
4
+ "description": "MTA-STS (RFC 8461) and SMTP TLS reporting (RFC 8460): the _mta-sts and _smtp._tls TXT records, the policy file, a bounded policy fetch on an injected fetch, and the TLS report JSON",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "docs",
12
+ "README.md",
13
+ "package.json",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/softistx/bumail.git",
27
+ "directory": "packages/mta-sts"
28
+ },
29
+ "publishConfig": {
30
+ "registry": "https://registry.npmjs.org",
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "bun run ../../build.ts",
35
+ "test": "bun test src",
36
+ "typecheck": "tsc --noEmit"
37
+ },
38
+ "bumail": {
39
+ "entrypoints": [
40
+ "src/index.ts"
41
+ ]
42
+ },
43
+ "devDependencies": {
44
+ "@bumail/smtp": "^0.13.0",
45
+ "@types/bun": "^1.4.2"
46
+ },
47
+ "peerDependencies": {
48
+ "typescript": "^6.0.3 || ^7.0.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "typescript": {
52
+ "optional": true
53
+ }
54
+ }
55
+ }