Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[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.

```sh
npm install mailschema@0.3.1
npm install mailschema@0.3.2
```

## Check a description against its contract
Expand All @@ -20,26 +20,30 @@ if (errors.length) throw new Error(errors.join('\n'));

`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.

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/`, and its Registry page (`href`) links an example description of the type.

## API

- `parse(input, maxBytes)`, `canonicalize(value)`, `digest(value)`: MAP JSON and RFC 8785 digests.
- `descriptionErrors(value)`, `parseDescription(input)`: the core description rules.
- `new Contract(value)`, `Contract.parse(input)`: a checked contract with `digest`, `operation(id)`, `detailsErrors(details)`, `inputErrors(operationId, input)` and `descriptionErrors(description)`.
- `contractErrors(value)`: why a value is not a contract, including schema keywords, formats and patterns every implementation can enforce alike.
- `implementationErrors(value)`, `parseImplementation(input)`: Registry implementation records.
- `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.
- `PROFILE`, `CONTEXT`, `DESCRIPTION_MEDIA_TYPE`, `EFFECTS`, `CAPABILITY_KINDS` and the size limits.

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`.

## Command line

```sh
npx mailschema@0.3.1 contract campaign-send-approval-0.1.json
npx mailschema@0.3.1 description description.json --contract campaign-send-approval-0.1.json
npx mailschema@0.3.1 implementation record.json
npx mailschema@0.3.2 contract campaign-send-approval-0.1.json
npx mailschema@0.3.2 description description.json --contract campaign-send-approval-0.1.json
npx mailschema@0.3.2 implementation record.json
npx mailschema@0.3.2 message approval.eml > description.json
```

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.
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. Nothing is uploaded or changed.

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).

Expand Down
2 changes: 1 addition & 1 deletion UPSTREAM.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Release provenance

Version `0.3.1` derives from [`mailschema/mailschema@67f9544`](https://github.com/mailschema/mailschema/commit/67f95447f6f2e640af8f515d6844f58d1b1d1fa2). Its sources, tests and bundled files are prepared from that commit by `npm run packages:prepare`.
Version `0.3.2` derives from [`mailschema/mailschema@1b41186`](https://github.com/mailschema/mailschema/commit/1b41186a0c843cd12a574038fa9e7bcfca913765). Its sources, tests and bundled files are prepared from that commit by `npm run packages:prepare`.

The bundled MAP 0.3 artifacts are exact copies of that commit's files and do not independently define MAP:

Expand Down
23 changes: 18 additions & 5 deletions bin/mailschema.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Contract,
CONTRACT_MAX_BYTES,
digest,
extractMessage,
InvalidDocument,
parseDescription,
parseImplementation,
Expand All @@ -15,17 +16,23 @@ const help = `Mail Action Protocol 0.3 checks
mailschema contract <contract.json>
mailschema description <description.json> [--contract <contract.json>]
mailschema implementation <record.json>
mailschema message <message.eml>

contract checks a type contract and prints its digest. description checks a
description and, with --contract, that it uses that contract as it allows,
then prints the description's digest. implementation checks a Registry
implementation record. A digest is the SHA-256 of the document's RFC 8785
canonical JSON. Nothing is uploaded or changed.`;
implementation record. message finds a received email's one MAP part, writes
its description to standard output exactly as the part carries it, and prints
the description's digest on standard error. A digest is the SHA-256 of the
document's RFC 8785 canonical JSON. Nothing is uploaded or changed.`;

async function read(path) {
// A whole email, attachments included, is far larger than any MAP document.
const MESSAGE_MAX_BYTES = 26214400;

async function read(path, maxBytes = CONTRACT_MAX_BYTES, kind = 'a JSON file') {
const info = await stat(path);
if (!info.isFile() || info.size > CONTRACT_MAX_BYTES)
throw new Error(`Expected a JSON file of at most ${CONTRACT_MAX_BYTES} bytes.`);
if (!info.isFile() || info.size > maxBytes)
throw new Error(`Expected ${kind} of at most ${maxBytes} bytes.`);
return new Uint8Array(await readFile(path));
}

Expand All @@ -51,6 +58,12 @@ try {
if (values.contract)
report(Contract.parse(await read(values.contract)).descriptionErrors(description));
console.log(`Valid MAP 0.3 description ${description['@id']}\n${digest(description)}`);
} else if (command === 'message') {
const result = extractMessage(await read(path, MESSAGE_MAX_BYTES, 'an email file'));
if (result.status === 'none') throw new Error('The message has no MAP 0.3 part.');
if (result.status === 'invalid') throw new Error(`The message's MAP part cannot be used: ${result.reason}.`);
process.stdout.write(result.bytes);
console.error(`Valid MAP 0.3 description ${result.description['@id']}\n${digest(result.description)}`);
} else if (command === 'implementation') {
const record = parseImplementation(await read(path));
console.log(`Valid implementation record for ${record.service}`);
Expand Down
14 changes: 12 additions & 2 deletions dist/core/artifacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,15 @@ export function createValidator() {
ajv.removeKeyword(keyword);
return ajv;
}
/** Each validation error as its JSON Pointer and message. @internal */
export const schemaErrors = (errors, prefix = '') => (errors ?? []).map((error) => `${prefix + error.instancePath || '/'}: ${error.message}`);
/**
* Each validation error as its JSON Pointer and message. An enum or const error names the
* values it allows, so the fix is in the message. @internal
*/
export const schemaErrors = (errors, prefix = '') => (errors ?? []).map((error) => `${prefix + error.instancePath || '/'}: ${schemaMessage(error)}`);
function schemaMessage(error) {
if (error.keyword === 'enum')
return `must be one of ${error.params.allowedValues.map((value) => JSON.stringify(value)).join(', ')}`;
if (error.keyword === 'const')
return `must be ${JSON.stringify(error.params.allowedValue)}`;
return error.message;
}
1 change: 1 addition & 0 deletions dist/core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export { sha256 } from './sha256.ts';
export { descriptionErrors, parseDescription, type Description } from './description.ts';
export { CAPABILITY_KINDS, Contract, contractErrors, type CapabilityKind, type ContractDocument, type Operation, } from './contract.ts';
export { implementationErrors, parseImplementation, type ImplementationRecord, } from './implementation.ts';
export { extractMessage, type MessageProblem, type MessageResult } from './message.ts';
export { unportablePattern } from './patterns.ts';
1 change: 1 addition & 0 deletions dist/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export { sha256 } from "./sha256.js";
export { descriptionErrors, parseDescription } from "./description.js";
export { CAPABILITY_KINDS, Contract, contractErrors, } from "./contract.js";
export { implementationErrors, parseImplementation, } from "./implementation.js";
export { extractMessage } from "./message.js";
export { unportablePattern } from "./patterns.js";
27 changes: 27 additions & 0 deletions dist/core/message.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { type Description } from './description.ts';
/**
* Why a message's MAP part cannot be used: more than one designated part, a header that
* readers could read differently, a transfer encoding other than base64 or quoted-printable,
* a part not labelled exactly with this profile, a part outside a multipart/related entity
* whose root is readable, an invalid description, or a message that cannot be read.
*/
export type MessageProblem = 'multiple_parts' | 'ambiguous_headers' | 'transfer_encoding' | 'media_type' | 'not_partial_representation' | 'invalid_description' | 'unreadable';
/** What a message carries: no MAP part, an unusable one and why, or its description. */
export type MessageResult = {
status: 'none';
} | {
status: 'invalid';
reason: MessageProblem;
} | {
status: 'valid';
description: Description;
bytes: Uint8Array;
};
/**
* The MAP part of a received email (Core, "Email representation"), read from its original
* bytes: the one designated description of this profile, in a multipart/related entity whose
* root is the readable content it describes. A description inside an attachment or an
* attached message is never read. It never throws for untrusted input. `bytes` are the
* description exactly as the part carries it, after its transfer encoding is removed.
*/
export declare function extractMessage(input: string | Uint8Array): MessageResult;
Loading
Loading