mailschema 0.3.1 → 0.3.3

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
@@ -3,9 +3,11 @@
3
3
  [Mail Action Protocol](https://mailschema.org) 0.3 for JavaScript. It parses and checks MAP descriptions, type contracts and Registry implementation records, and computes the RFC 8785 digests that identify contracts. The same code runs the MailSchema specification's own checks. It has no Node.js-only dependencies, so it also runs in browsers and workers.
4
4
 
5
5
  ```sh
6
- npm install mailschema@0.3.1
6
+ npm install mailschema@0.3.3
7
7
  ```
8
8
 
9
+ It needs Node.js 22 or later, as `engines` declares; browsers and workers run it as they are.
10
+
9
11
  ## Check a description against its contract
10
12
 
11
13
  ```js
@@ -20,6 +22,16 @@ if (errors.length) throw new Error(errors.join('\n'));
20
22
 
21
23
  `parseDescription` reads the bytes as MAP JSON (strict I-JSON within 64 KiB) and checks the core rules, or throws `InvalidDocument` with every reason. Obtain contracts from a catalogue you configure and pin them by digest; a message never chooses one.
22
24
 
25
+ The published types and their contracts are in the Registry, [`https://mailschema.org/registry/map-0.3.json`](https://mailschema.org/registry/map-0.3.json): each type's `contractHref` is its contract under `https://mailschema.org/artifacts/map-0.3/contracts/`, `exampleHref` is an example description of the type, and its Registry page is `href`.
26
+
27
+ ## Write a description
28
+
29
+ Start from the type's example. A description has these members, all required: `@context`, `@type` (`MailAction`), `@id`, `profile`, `type` (`id`, `version`, `contractDigest`), `issuedAt`, `expiresAt`, `inLanguage` (a BCP 47 tag such as `en`), `service` (`id`, an HTTPS origin with no path, and an optional `tenant`), `recipient`, `subject` (`id`, `title`), `terms` (`id`, `version`), `details` (the shape the contract's `detailsSchema` sets), `operations` and `human` (`url`). Times are UTC to the second, `YYYY-MM-DDTHH:MM:SSZ`. `type.contractDigest` is the contract's digest: the second line `mailschema contract <contract.json>` prints, and the type's `digest` in the Registry.
30
+
31
+ ## What a check covers
32
+
33
+ `parseDescription` and `Contract.descriptionErrors` check the document: the core rules, and that `details` and the operations are what the contract allows. They do not check what only the acting service knows. Whether the service, recipient, `details` and terms match a request recorded when the person started it (for an Email Address Confirmation, that `details.mailbox` is the recipient's mailbox), whether the current time is between `issuedAt` and `expiresAt`, and whether the message is authentic are the implementation's checks, under the contract's requirements and the [specification](https://mailschema.org/specification/core).
34
+
23
35
  ## API
24
36
 
25
37
  - `parse(input, maxBytes)`, `canonicalize(value)`, `digest(value)`: MAP JSON and RFC 8785 digests.
@@ -27,6 +39,7 @@ if (errors.length) throw new Error(errors.join('\n'));
27
39
  - `new Contract(value)`, `Contract.parse(input)`: a checked contract with `digest`, `operation(id)`, `detailsErrors(details)`, `inputErrors(operationId, input)` and `descriptionErrors(description)`.
28
40
  - `contractErrors(value)`: why a value is not a contract, including schema keywords, formats and patterns every implementation can enforce alike.
29
41
  - `implementationErrors(value)`, `parseImplementation(input)`: Registry implementation records.
42
+ - `extractMessage(input)`: the one MAP part of a received email, read from its original bytes. It returns `{ status: 'valid', description, bytes }`, `{ status: 'invalid', reason }` or `{ status: 'none' }`, and never throws for untrusted input.
30
43
  - `PROFILE`, `CONTEXT`, `DESCRIPTION_MEDIA_TYPE`, `EFFECTS`, `CAPABILITY_KINDS` and the size limits.
31
44
 
32
45
  The profile record, context and schemas are exported byte for byte: `mailschema/profile.json`, `mailschema/context.jsonld`, `mailschema/core.schema.json`, `mailschema/contract.schema.json` and `mailschema/implementation.schema.json`.
@@ -34,12 +47,13 @@ The profile record, context and schemas are exported byte for byte: `mailschema/
34
47
  ## Command line
35
48
 
36
49
  ```sh
37
- npx mailschema@0.3.1 contract campaign-send-approval-0.1.json
38
- npx mailschema@0.3.1 description description.json --contract campaign-send-approval-0.1.json
39
- npx mailschema@0.3.1 implementation record.json
50
+ npx mailschema@0.3.3 contract campaign-send-approval-0.1.json
51
+ npx mailschema@0.3.3 description description.json --contract campaign-send-approval-0.1.json
52
+ npx mailschema@0.3.3 implementation record.json
53
+ npx mailschema@0.3.3 message approval.eml > description.json
40
54
  ```
41
55
 
42
- Each command first prints a line naming what it checked. `contract` prints `Valid type contract <id> <version>` and, on the second line, the contract's digest. `description` prints `Valid MAP 0.3 description <@id>` and, on the second line, the description's digest; with `--contract`, it first checks that the description uses that contract as it allows. `implementation` prints `Valid implementation record for <service>`. A digest is `sha-256:` and the SHA-256 of the document's RFC 8785 canonical JSON, the value `digest()` returns; the description's digest is not the contract digest it names. An invalid file prints every reason on standard error and exits with status 1. Nothing is uploaded or changed.
56
+ `mailschema <command> --help` explains one command, `--version` prints the package version, and `--json` prints the result as one JSON object: `{"valid":true,...}` with the identifiers and digest, or `{"valid":false,"errors":[...]}`. Each command first prints a line naming what it checked. `contract` prints `Valid type contract <id> <version>` and, on the second line, the contract's digest. `description` prints `Valid MAP 0.3 description <@id>` and, on the second line, the description's digest; with `--contract`, it first checks that the description uses that contract as it allows. `implementation` prints `Valid implementation record for <service>`. `message` reads a received email (a saved `.eml`), writes its one MAP description to standard output exactly as the MAP part carries it, and prints the `description` lines on standard error. A digest is `sha-256:` and the SHA-256 of the document's RFC 8785 canonical JSON, the value `digest()` returns; the description's digest is not the contract digest it names. An invalid file prints every reason on standard error and exits with status 1: each names the JSON Pointer to fix, and where a schema offers alternatives, only the one the value chose. Nothing is uploaded or changed.
43
57
 
44
58
  Checking does not authenticate a message, establish a service's authority or perform an action. Those belong to the implementation, under the [specification](https://mailschema.org/specification/core).
45
59
 
package/bin/mailschema.js CHANGED
@@ -4,28 +4,61 @@ import { parseArgs } from 'node:util';
4
4
  import {
5
5
  Contract,
6
6
  CONTRACT_MAX_BYTES,
7
+ DESCRIPTION_MAX_BYTES,
7
8
  digest,
9
+ extractMessage,
8
10
  InvalidDocument,
9
11
  parseDescription,
10
12
  parseImplementation,
11
13
  } from '../dist/index.js';
12
14
 
13
- const help = `Mail Action Protocol 0.3 checks
15
+ const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
14
16
 
15
- mailschema contract <contract.json>
16
- mailschema description <description.json> [--contract <contract.json>]
17
- mailschema implementation <record.json>
17
+ const COMMANDS = {
18
+ contract: {
19
+ usage: 'mailschema contract <contract.json>',
20
+ about: `Checks a type contract and prints "Valid type contract <id> <version>" and the
21
+ contract's digest. type.contractDigest in a description names this digest.`,
22
+ },
23
+ description: {
24
+ usage: 'mailschema description <description.json> [--contract <contract.json>]',
25
+ about: `Checks a description against MAP 0.3 Core and, with --contract, that it uses that
26
+ contract as it allows: its details and operations. Prints "Valid MAP 0.3 description
27
+ <@id>" and the description's digest, which is not the contract digest it names. It
28
+ checks the document only: whether the service, recipient and terms match a request
29
+ recorded when the person started it, and whether the description is still in its
30
+ issuedAt to expiresAt window, are for the implementation that acts on it.`,
31
+ },
32
+ implementation: {
33
+ usage: 'mailschema implementation <record.json>',
34
+ about: 'Checks a Registry implementation record and names the service it describes.',
35
+ },
36
+ message: {
37
+ usage: 'mailschema message <message.eml>',
38
+ about: `Finds a received email's one MAP part, writes its description to standard output
39
+ exactly as the part carries it, and prints the description's digest on standard error.`,
40
+ },
41
+ };
18
42
 
19
- contract checks a type contract and prints its digest. description checks a
20
- description and, with --contract, that it uses that contract as it allows,
21
- then prints the description's digest. implementation checks a Registry
22
- implementation record. A digest is the SHA-256 of the document's RFC 8785
23
- canonical JSON. Nothing is uploaded or changed.`;
43
+ const help = `Mail Action Protocol 0.3 checks, mailschema ${version}
24
44
 
25
- async function read(path) {
45
+ ${Object.values(COMMANDS)
46
+ .map((command) => ` ${command.usage}`)
47
+ .join('\n')}
48
+
49
+ Options: --json prints the result as JSON, --help after a command explains it, and
50
+ --version prints the package version. A digest is the SHA-256 of the document's RFC 8785
51
+ canonical JSON. Nothing is uploaded or changed. An invalid document prints every reason
52
+ it is not valid, one per line, and exits 1.`;
53
+
54
+ const commandHelp = (name) => `${COMMANDS[name].usage}\n\n${COMMANDS[name].about}\n\nAdd --json for a JSON result.`;
55
+
56
+ // A whole email, attachments included, is far larger than any MAP document.
57
+ const MESSAGE_MAX_BYTES = 26214400;
58
+
59
+ async function read(path, maxBytes, kind) {
26
60
  const info = await stat(path);
27
- if (!info.isFile() || info.size > CONTRACT_MAX_BYTES)
28
- throw new Error(`Expected a JSON file of at most ${CONTRACT_MAX_BYTES} bytes.`);
61
+ if (!info.isFile() || info.size > maxBytes) throw new Error(`Expected ${kind} of at most ${maxBytes} bytes.`);
29
62
  return new Uint8Array(await readFile(path));
30
63
  }
31
64
 
@@ -34,28 +67,53 @@ function report(errors) {
34
67
  throw new InvalidDocument('Not valid.', errors);
35
68
  }
36
69
 
70
+ let json = false;
37
71
  try {
38
72
  const { values, positionals } = parseArgs({
39
73
  allowPositionals: true,
40
- options: { contract: { type: 'string' }, help: { type: 'boolean', short: 'h' } },
74
+ options: {
75
+ contract: { type: 'string' },
76
+ json: { type: 'boolean' },
77
+ help: { type: 'boolean', short: 'h' },
78
+ version: { type: 'boolean', short: 'v' },
79
+ },
41
80
  });
81
+ json = Boolean(values.json);
42
82
  const [command, path, ...extra] = positionals;
43
- if (values.help || !command) console.log(help);
83
+ if (values.version) console.log(version);
84
+ else if (values.help && Object.hasOwn(COMMANDS, command ?? '')) console.log(commandHelp(command));
85
+ else if (values.help || !command) console.log(help);
86
+ else if (!Object.hasOwn(COMMANDS, command)) throw new Error(`Unknown command ${command}.\n\n${help}`);
44
87
  else if (!path || extra.length || (values.contract && command !== 'description'))
45
- throw new Error(help);
88
+ throw new Error(commandHelp(command));
46
89
  else if (command === 'contract') {
47
- const contract = Contract.parse(await read(path));
48
- console.log(`Valid type contract ${contract.id} ${contract.version}\n${contract.digest}`);
90
+ const contract = Contract.parse(await read(path, CONTRACT_MAX_BYTES, 'a contract file'));
91
+ if (json) print({ valid: true, id: contract.id, version: contract.version, digest: contract.digest });
92
+ else console.log(`Valid type contract ${contract.id} ${contract.version}\n${contract.digest}`);
49
93
  } else if (command === 'description') {
50
- const description = parseDescription(await read(path));
94
+ const description = parseDescription(await read(path, DESCRIPTION_MAX_BYTES, 'a description file'));
51
95
  if (values.contract)
52
- report(Contract.parse(await read(values.contract)).descriptionErrors(description));
53
- console.log(`Valid MAP 0.3 description ${description['@id']}\n${digest(description)}`);
54
- } else if (command === 'implementation') {
55
- const record = parseImplementation(await read(path));
56
- console.log(`Valid implementation record for ${record.service}`);
57
- } else throw new Error(help);
96
+ report(Contract.parse(await read(values.contract, CONTRACT_MAX_BYTES, 'a contract file')).descriptionErrors(description));
97
+ if (json) print({ valid: true, id: description['@id'], digest: digest(description) });
98
+ else console.log(`Valid MAP 0.3 description ${description['@id']}\n${digest(description)}`);
99
+ } else if (command === 'message') {
100
+ const result = extractMessage(await read(path, MESSAGE_MAX_BYTES, 'an email file'));
101
+ if (result.status === 'none') throw new Error('The message has no MAP 0.3 part.');
102
+ if (result.status === 'invalid') throw new Error(`The message's MAP part cannot be used: ${result.reason}.`);
103
+ process.stdout.write(result.bytes);
104
+ console.error(`Valid MAP 0.3 description ${result.description['@id']}\n${digest(result.description)}`);
105
+ } else {
106
+ const record = parseImplementation(await read(path, CONTRACT_MAX_BYTES, 'a record file'));
107
+ if (json) print({ valid: true, service: record.service });
108
+ else console.log(`Valid implementation record for ${record.service}`);
109
+ }
58
110
  } catch (error) {
59
- console.error(error instanceof InvalidDocument ? error.errors.join('\n') : error.message);
111
+ const errors = error instanceof InvalidDocument ? error.errors : [error.message];
112
+ if (json) print({ valid: false, errors });
113
+ else console.error(errors.join('\n'));
60
114
  process.exitCode = 1;
61
115
  }
116
+
117
+ function print(result) {
118
+ console.log(JSON.stringify(result));
119
+ }
@@ -26,5 +26,50 @@ export function createValidator() {
26
26
  ajv.removeKeyword(keyword);
27
27
  return ajv;
28
28
  }
29
- /** Each validation error as its JSON Pointer and message. @internal */
30
- export const schemaErrors = (errors, prefix = '') => (errors ?? []).map((error) => `${prefix + error.instancePath || '/'}: ${error.message}`);
29
+ /**
30
+ * Each validation error as its JSON Pointer and message, reduced to the ones that say what to
31
+ * fix: an enum or const error names the values it allows, an unexpected member is named, an
32
+ * if/then wrapper is left to the error inside it, and a oneOf whose branches are told apart by
33
+ * a const reports only the branch the value chose. @internal
34
+ */
35
+ export const schemaErrors = (errors, prefix = '') => actionable(errors ?? []).map((error) => `${prefix + error.instancePath || '/'}: ${schemaMessage(error)}`);
36
+ function schemaMessage(error) {
37
+ if (error.keyword === 'enum')
38
+ return `must be one of ${listed(error.params.allowedValues)}`;
39
+ if (error.keyword === 'const')
40
+ return `must be ${JSON.stringify(error.params.allowedValue)}`;
41
+ if (error.keyword === 'additionalProperties')
42
+ return `must not have the member ${JSON.stringify(error.params.additionalProperty)}`;
43
+ return error.message;
44
+ }
45
+ const listed = (values) => values.map((value) => JSON.stringify(value)).join(', ');
46
+ function actionable(errors) {
47
+ let kept = errors.filter((error) => error.keyword !== 'if');
48
+ for (const oneOf of kept.filter((error) => error.keyword === 'oneOf' && !error.params.passingSchemas)) {
49
+ const branchOf = (error) => error.schemaPath.startsWith(`${oneOf.schemaPath}/`)
50
+ ? Number(error.schemaPath.slice(oneOf.schemaPath.length + 1).split('/')[0])
51
+ : undefined;
52
+ const branches = new Map();
53
+ for (const error of kept) {
54
+ const branch = branchOf(error);
55
+ if (branch !== undefined)
56
+ branches.set(branch, [...(branches.get(branch) ?? []), error]);
57
+ }
58
+ // The discriminator: a const one level below the oneOf's value that a branch refused.
59
+ const discriminator = (branch) => branch.find((error) => error.keyword === 'const' &&
60
+ error.instancePath.startsWith(`${oneOf.instancePath}/`) &&
61
+ !error.instancePath.slice(oneOf.instancePath.length + 1).includes('/'));
62
+ const refused = [...branches.values()].map(discriminator);
63
+ const chosen = [...branches.keys()].filter((_, index) => !refused[index]);
64
+ const sharedPointer = refused.find(Boolean)?.instancePath;
65
+ if (chosen.length === 1 && refused.filter(Boolean).length === branches.size - 1) {
66
+ kept = kept.filter((error) => error !== oneOf && (branchOf(error) ?? chosen[0]) === chosen[0]);
67
+ }
68
+ else if (chosen.length === 0 && sharedPointer && refused.every((error) => error?.instancePath === sharedPointer)) {
69
+ const allowed = refused.map((error) => error.params.allowedValue);
70
+ const combined = { ...refused[0], keyword: 'enum', params: { allowedValues: allowed } };
71
+ kept = [...kept.filter((error) => error !== oneOf && branchOf(error) === undefined), combined];
72
+ }
73
+ }
74
+ return kept;
75
+ }
@@ -37,8 +37,9 @@ export interface Description {
37
37
  }
38
38
  /**
39
39
  * Why the value is not a MAP 0.3 description, or no reasons. Beyond the core schema:
40
- * expiry follows issuance, operation identifiers are unique, and no security-sensitive
41
- * identifier contains whitespace, a control or a formatting character.
40
+ * expiry follows issuance, inLanguage is a well-formed BCP 47 tag, operation identifiers are
41
+ * unique, and no security-sensitive identifier contains whitespace, a control or a
42
+ * formatting character.
42
43
  */
43
44
  export declare function descriptionErrors(value: unknown): string[];
44
45
  /** The description in the bytes or text, or InvalidDocument with every reason it is not one. */
@@ -4,10 +4,17 @@ import { InvalidDocument, parse } from "./json.js";
4
4
  const validate = createValidator().compile(coreSchema);
5
5
  // Whitespace, controls, bidirectional and invisible formatting characters.
6
6
  const UNSAFE = /[\p{White_Space}\p{Cc}\p{Cf}]/u;
7
+ // A well-formed BCP 47 language tag: RFC 5646's langtag, privateuse and grandfathered forms.
8
+ const LANGUAGE_TAG = new RegExp('^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4}|[a-z]{5,8})(?:-[a-z]{4})?(?:-(?:[a-z]{2}|[0-9]{3}))?' +
9
+ '(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?' +
10
+ '|x(?:-[a-z0-9]{1,8})+' +
11
+ '|en-gb-oed|i-(?:ami|bnn|default|enochian|hak|klingon|lux|mingo|navajo|pwn|tao|tay|tsu)' +
12
+ '|sgn-(?:be-fr|be-nl|ch-de)|art-lojban|cel-gaulish|no-(?:bok|nyn)|zh-(?:guoyu|hakka|min|min-nan|xiang))$', 'i');
7
13
  /**
8
14
  * Why the value is not a MAP 0.3 description, or no reasons. Beyond the core schema:
9
- * expiry follows issuance, operation identifiers are unique, and no security-sensitive
10
- * identifier contains whitespace, a control or a formatting character.
15
+ * expiry follows issuance, inLanguage is a well-formed BCP 47 tag, operation identifiers are
16
+ * unique, and no security-sensitive identifier contains whitespace, a control or a
17
+ * formatting character.
11
18
  */
12
19
  export function descriptionErrors(value) {
13
20
  if (!validate(value))
@@ -15,6 +22,8 @@ export function descriptionErrors(value) {
15
22
  const errors = [];
16
23
  if (Date.parse(value.expiresAt) <= Date.parse(value.issuedAt))
17
24
  errors.push('/expiresAt: must be later than issuedAt');
25
+ if (!LANGUAGE_TAG.test(value.inLanguage))
26
+ errors.push('/inLanguage: must be a well-formed BCP 47 language tag');
18
27
  const ids = value.operations.map((operation) => operation.id);
19
28
  if (new Set(ids).size !== ids.length)
20
29
  errors.push('/operations: identifiers must be unique');
@@ -4,4 +4,5 @@ export { sha256 } from './sha256.ts';
4
4
  export { descriptionErrors, parseDescription, type Description } from './description.ts';
5
5
  export { CAPABILITY_KINDS, Contract, contractErrors, type CapabilityKind, type ContractDocument, type Operation, } from './contract.ts';
6
6
  export { implementationErrors, parseImplementation, type ImplementationRecord, } from './implementation.ts';
7
+ export { extractMessage, type MessageProblem, type MessageResult } from './message.ts';
7
8
  export { unportablePattern } from './patterns.ts';
@@ -6,4 +6,5 @@ export { sha256 } from "./sha256.js";
6
6
  export { descriptionErrors, parseDescription } from "./description.js";
7
7
  export { CAPABILITY_KINDS, Contract, contractErrors, } from "./contract.js";
8
8
  export { implementationErrors, parseImplementation, } from "./implementation.js";
9
+ export { extractMessage } from "./message.js";
9
10
  export { unportablePattern } from "./patterns.js";
@@ -0,0 +1,27 @@
1
+ import { type Description } from './description.ts';
2
+ /**
3
+ * Why a message's MAP part cannot be used: more than one designated part, a header that
4
+ * readers could read differently, a transfer encoding other than base64 or quoted-printable,
5
+ * a part not labelled exactly with this profile, a part outside a multipart/related entity
6
+ * whose root is readable, an invalid description, or a message that cannot be read.
7
+ */
8
+ export type MessageProblem = 'multiple_parts' | 'ambiguous_headers' | 'transfer_encoding' | 'media_type' | 'not_partial_representation' | 'invalid_description' | 'unreadable';
9
+ /** What a message carries: no MAP part, an unusable one and why, or its description. */
10
+ export type MessageResult = {
11
+ status: 'none';
12
+ } | {
13
+ status: 'invalid';
14
+ reason: MessageProblem;
15
+ } | {
16
+ status: 'valid';
17
+ description: Description;
18
+ bytes: Uint8Array;
19
+ };
20
+ /**
21
+ * The MAP part of a received email (Core, "Email representation"), read from its original
22
+ * bytes: the one designated description of this profile, in a multipart/related entity whose
23
+ * root is the readable content it describes. A description inside an attachment or an
24
+ * attached message is never read. It never throws for untrusted input. `bytes` are the
25
+ * description exactly as the part carries it, after its transfer encoding is removed.
26
+ */
27
+ export declare function extractMessage(input: string | Uint8Array): MessageResult;
@@ -0,0 +1,262 @@
1
+ import { PROFILE } from "./artifacts.js";
2
+ import { parseDescription } from "./description.js";
3
+ import { InvalidDocument } from "./json.js";
4
+ const READABLE = new Set(['text/plain', 'text/html']);
5
+ const TRANSFER_ENCODINGS = new Set(['base64', 'quoted-printable']);
6
+ const SINGLE_FIELDS = ['content-type', 'content-purpose', 'content-transfer-encoding'];
7
+ const FIELD = /^([!-9;-~]+):[ \t]*([\s\S]*)$/;
8
+ const PARAMETER = /^\s*([!#$%&'*+.^_`|~0-9A-Za-z-]+)\s*=/;
9
+ /**
10
+ * The MAP part of a received email (Core, "Email representation"), read from its original
11
+ * bytes: the one designated description of this profile, in a multipart/related entity whose
12
+ * root is the readable content it describes. A description inside an attachment or an
13
+ * attached message is never read. It never throws for untrusted input. `bytes` are the
14
+ * description exactly as the part carries it, after its transfer encoding is removed.
15
+ */
16
+ export function extractMessage(input) {
17
+ try {
18
+ const raw = typeof input === 'string' ? input : binary(input);
19
+ const designated = entities(entity(raw)).filter(([part]) => isDesignated(part));
20
+ if (!designated.length)
21
+ return { status: 'none' };
22
+ if (designated.length > 1)
23
+ return invalid('multiple_parts');
24
+ const [part, parent] = designated[0];
25
+ const reason = problem(part, parent);
26
+ if (reason)
27
+ return invalid(reason);
28
+ const bytes = decoded(part);
29
+ try {
30
+ return { status: 'valid', description: parseDescription(bytes), bytes };
31
+ }
32
+ catch (error) {
33
+ if (error instanceof InvalidDocument)
34
+ return invalid('invalid_description');
35
+ throw error;
36
+ }
37
+ }
38
+ catch {
39
+ return invalid('unreadable');
40
+ }
41
+ }
42
+ function invalid(reason) {
43
+ return { status: 'invalid', reason };
44
+ }
45
+ // Every part with its parent, at any depth. An attachment or an attached message is a leaf,
46
+ // so nothing inside one is read.
47
+ function entities(parent) {
48
+ return parent.parts.flatMap((part) => attached(part) || part.mimeType === 'message/rfc822' ? [] : [[part, parent], ...entities(part)]);
49
+ }
50
+ // A designated part labelled with this profile, judged from its original header lines,
51
+ // before a parser picks one of repeated fields or parameters.
52
+ function isDesignated(part) {
53
+ const purposes = values(part, 'content-purpose');
54
+ if (!purposes.some((value) => value.trim().toLowerCase() === 'machine-readable'))
55
+ return false;
56
+ const types = values(part, 'content-type');
57
+ return types.some((value) => value.toLowerCase().includes('application/ld+json')) && types.some((value) => value.includes(PROFILE));
58
+ }
59
+ function problem(part, parent) {
60
+ if (!unambiguous(part))
61
+ return 'ambiguous_headers';
62
+ if (!TRANSFER_ENCODINGS.has(first(part, 'content-transfer-encoding').trim().toLowerCase()))
63
+ return 'transfer_encoding';
64
+ if (part.mimeType !== 'application/ld+json' || part.parameters.get('profile') !== PROFILE)
65
+ return 'media_type';
66
+ if (!partialRepresentation(parent))
67
+ return 'not_partial_representation';
68
+ return undefined;
69
+ }
70
+ // Each field that decides how the part is read appears once, with each parameter once.
71
+ function unambiguous(part) {
72
+ if (!part.fields)
73
+ return false;
74
+ const counts = new Map();
75
+ for (const [name] of part.fields)
76
+ counts.set(name, (counts.get(name) ?? 0) + 1);
77
+ return SINGLE_FIELDS.every((name) => counts.get(name) === 1) && uniqueParameters(first(part, 'content-type'));
78
+ }
79
+ // A multipart/related entity with one unambiguous Content-Type, whose root (its start
80
+ // parameter's part, or else its first) is readable.
81
+ function partialRepresentation(parent) {
82
+ if (parent.mimeType !== 'multipart/related')
83
+ return false;
84
+ const types = values(parent, 'content-type');
85
+ if (types.length !== 1 || !uniqueParameters(types[0]))
86
+ return false;
87
+ const start = parent.parameters.get('start') ?? '';
88
+ const roots = start ? parent.parts.filter((part) => first(part, 'content-id').trim() === start) : parent.parts.slice(0, 1);
89
+ return roots.length === 1 && readable(roots[0]);
90
+ }
91
+ function readable(part) {
92
+ if (attached(part))
93
+ return false;
94
+ if (part.mimeType === 'multipart/alternative')
95
+ return part.parts.some(readable);
96
+ return READABLE.has(part.mimeType) && binary(decoded(part)).trim().length > 0;
97
+ }
98
+ function attached(part) {
99
+ return first(part, 'content-disposition').trim().toLowerCase().startsWith('attachment');
100
+ }
101
+ function values(part, name) {
102
+ return (part.fields ?? []).filter(([field]) => field === name).map(([, value]) => value);
103
+ }
104
+ function first(part, name) {
105
+ return values(part, name)[0] ?? '';
106
+ }
107
+ // One entity from its raw bytes (a binary string, one character per byte): its header,
108
+ // Content-Type and, for a multipart entity, its parts.
109
+ function entity(raw) {
110
+ const fields = headerFields(raw);
111
+ const body = split(raw)?.[1] ?? '';
112
+ const contentType = (fields ?? []).find(([name]) => name === 'content-type')?.[1] ?? 'text/plain';
113
+ const [type, ...rest] = segments(contentType) ?? [contentType];
114
+ const mimeType = type.trim().toLowerCase();
115
+ const parameters = new Map();
116
+ for (const segment of rest) {
117
+ const name = PARAMETER.exec(segment)?.[1];
118
+ if (!name)
119
+ continue;
120
+ const key = name.toLowerCase();
121
+ if (!parameters.has(key))
122
+ parameters.set(key, unquote(segment.slice(segment.indexOf('=') + 1).trim()));
123
+ }
124
+ const boundary = parameters.get('boundary');
125
+ const parts = mimeType.startsWith('multipart/') && boundary ? multipart(body, boundary).map(entity) : [];
126
+ return { fields, mimeType, parameters, body, parts };
127
+ }
128
+ // The raw bytes of each body part between the boundary's delimiter lines; the preamble and
129
+ // epilogue are not parts.
130
+ function multipart(body, boundary) {
131
+ const delimiter = `--${boundary}`;
132
+ const parts = [];
133
+ let current = null;
134
+ // The line break before a delimiter line belongs to the delimiter, not to the part.
135
+ const close = () => {
136
+ if (current)
137
+ parts.push(current.join('').replace(/\r?\n$/, ''));
138
+ };
139
+ for (const line of body.split(/(?<=\n)/)) {
140
+ const content = line.replace(/\r?\n$/, '').replace(/[ \t]+$/, '');
141
+ if (content === `${delimiter}--`) {
142
+ close();
143
+ return parts;
144
+ }
145
+ if (content === delimiter) {
146
+ close();
147
+ current = [];
148
+ }
149
+ else
150
+ current?.push(line);
151
+ }
152
+ close();
153
+ return parts;
154
+ }
155
+ // An entity's header block and body, split at the first empty line; a part that begins with
156
+ // an empty line has no header fields. Null when there is no empty line.
157
+ function split(raw) {
158
+ const leading = /^\r?\n/.exec(raw);
159
+ if (leading)
160
+ return ['', raw.slice(leading[0].length)];
161
+ const separator = /\r?\n\r?\n/.exec(raw);
162
+ return separator ? [raw.slice(0, separator.index), raw.slice(separator.index + separator[0].length)] : null;
163
+ }
164
+ // The unfolded fields of an entity's header block as [lowercase name, value] pairs, or null
165
+ // when it is malformed. Lines end with CRLF or LF.
166
+ function headerFields(raw) {
167
+ const parts = split(raw);
168
+ if (!parts)
169
+ return null;
170
+ const lines = parts[0] ? parts[0].split(/\r?\n/) : [];
171
+ const fields = [];
172
+ for (const line of lines) {
173
+ if (/[\r\n]/.test(line))
174
+ return null;
175
+ if (line.startsWith(' ') || line.startsWith('\t')) {
176
+ if (!fields.length)
177
+ return null;
178
+ fields[fields.length - 1][1] += ` ${line.trim()}`;
179
+ }
180
+ else {
181
+ const match = FIELD.exec(line);
182
+ if (!match)
183
+ return null;
184
+ fields.push([match[1].toLowerCase(), match[2]]);
185
+ }
186
+ }
187
+ return fields;
188
+ }
189
+ // Whether every parameter of a Content-Type value has its own name. RFC 2231 sections and
190
+ // charset markers belong to the name they extend, so profile and profile* are one name.
191
+ function uniqueParameters(value) {
192
+ const parts = segments(value);
193
+ if (!parts)
194
+ return false;
195
+ const names = [];
196
+ for (const segment of parts.slice(1)) {
197
+ const name = PARAMETER.exec(segment)?.[1];
198
+ if (!name)
199
+ return false;
200
+ names.push(name.toLowerCase().replace(/\*.*$/, ''));
201
+ }
202
+ return new Set(names).size === names.length;
203
+ }
204
+ // The value split at each semicolon outside a quoted string, or null when a quoted string or
205
+ // an escape is left open.
206
+ function segments(value) {
207
+ const parts = [''];
208
+ let quoted = false;
209
+ let escaped = false;
210
+ for (const char of value) {
211
+ if (char === ';' && !quoted) {
212
+ parts.push('');
213
+ continue;
214
+ }
215
+ if (escaped)
216
+ escaped = false;
217
+ else if (quoted && char === '\\')
218
+ escaped = true;
219
+ else if (char === '"')
220
+ quoted = !quoted;
221
+ parts[parts.length - 1] += char;
222
+ }
223
+ return quoted || escaped ? null : parts;
224
+ }
225
+ function unquote(value) {
226
+ return value.startsWith('"') && value.endsWith('"') && value.length >= 2
227
+ ? value.slice(1, -1).replace(/\\(.)/g, '$1')
228
+ : value;
229
+ }
230
+ // The part's body with its transfer encoding removed.
231
+ function decoded(part) {
232
+ const encoding = first(part, 'content-transfer-encoding').trim().toLowerCase();
233
+ if (encoding === 'base64')
234
+ return base64(part.body);
235
+ if (encoding === 'quoted-printable')
236
+ return quotedPrintable(part.body);
237
+ return bytesOf(part.body);
238
+ }
239
+ // Characters outside the alphabet are skipped, as mail readers do, so a damaged body reads as
240
+ // an invalid description rather than an unreadable message.
241
+ function base64(body) {
242
+ let clean = body.replace(/[^A-Za-z0-9+/]/g, '');
243
+ if (clean.length % 4 === 1)
244
+ clean = clean.slice(0, -1);
245
+ return bytesOf(atob(clean.padEnd(Math.ceil(clean.length / 4) * 4, '=')));
246
+ }
247
+ function quotedPrintable(body) {
248
+ const text = body.replace(/[ \t]+(?=\r?\n)/g, '').replace(/=\r?\n/g, '');
249
+ return bytesOf(text.replace(/=([0-9A-Fa-f]{2})/g, (_match, hex) => String.fromCharCode(parseInt(hex, 16))));
250
+ }
251
+ function binary(bytes) {
252
+ let text = '';
253
+ for (let index = 0; index < bytes.length; index += 0x8000)
254
+ text += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
255
+ return text;
256
+ }
257
+ function bytesOf(text) {
258
+ const bytes = new Uint8Array(text.length);
259
+ for (let index = 0; index < text.length; index += 1)
260
+ bytes[index] = text.charCodeAt(index) & 0xff;
261
+ return bytes;
262
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailschema",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Mail Action Protocol 0.3: parse and check descriptions, type contracts and implementation records",
5
5
  "type": "module",
6
6
  "license": "MIT",