mailschema 0.2.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cd3f1015f63294ef11a1d3a9ff43bcc18af342fd1a55e13e3d04d94b2091302e
4
+ data.tar.gz: 1f697fbcc3877ad1ea40d70c2ca33f2adf4aa6412ebcef98ea0d26968708cead
5
+ SHA512:
6
+ metadata.gz: e5a64d0eb6e599247ecd12397b3f370e55ac628d425dfc97f0a3b0450ecc1abf8496601b57ec25eae53d45d65c68ef550459d22972fe3db9065c902bdf0f0742
7
+ data.tar.gz: 0dd2ea492f2280c0dc443e46b655ad244e363243613599e61eab58b06f6145d03e794d0db9d59871f91c1686a15d42695a4c27394426f6c4357486c498997647
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.2.0] - 2026-09-26
6
+
7
+ - First release, for Mail Action Protocol 0.2.
8
+ - Parse MAP documents as I-JSON within the core limits, and canonicalize and digest them with RFC 8785.
9
+ - Verify a vendored type contract against its pinned digest, and check descriptions, requests, inputs and results against it.
10
+ - Build results, state transitions and problems, and apply result retention and approval expiry.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MailSchema contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # mailschema for Ruby
2
+
3
+ Mail Action Protocol 0.2 tooling for Ruby. The gem parses, canonicalizes, digests and validates MAP documents, verifies the type contracts an implementation vendors, and builds results and problems.
4
+
5
+ It does not establish endpoint trust, verify email authentication, grant authority or send email. Possession mode needs DKIM and DMARC checks on the raw message, which this gem leaves to the client.
6
+
7
+ ```sh
8
+ gem install mailschema
9
+ ```
10
+
11
+ Ruby 3.3 or later.
12
+
13
+ ## A service
14
+
15
+ Vendor the exact contract and request schema your service implements, and pin the contract by the digest you reviewed. The gem refuses any other contract.
16
+
17
+ ```ruby
18
+ require "mailschema"
19
+
20
+ CONTRACT = Mailschema::Contract.new(
21
+ JSON.parse(File.read("config/mailschema/content-review-0.3.json")),
22
+ JSON.parse(File.read("config/mailschema/content-review-0.3.schema.json")),
23
+ digest: "sha-256:…"
24
+ )
25
+ ```
26
+
27
+ Before sending a description, check it against every rule of the core and the contract, and store its digest or keep it rebuildable from the interaction:
28
+
29
+ ```ruby
30
+ CONTRACT.description_errors(description) # => []
31
+ Mailschema.digest(description) # => "sha-256:…"
32
+ ```
33
+
34
+ When a request arrives, check it in this order. No step applies an effect for a problem, and a refusal before the description is resolved and its digest matched leaves the request identifier unclaimed:
35
+
36
+ ```ruby
37
+ # Answer 415 unless Mailschema.json_request?(content_type). Under possession authority,
38
+ # answer an unknown capability with a plain 404 and a lapsed one with a plain 410
39
+ # before reading the body; under credential authority, authenticate the principal.
40
+ request = Mailschema.parse(body) # raises Mailschema::InvalidDocument
41
+ Mailschema.request_errors(request) # any errors: invalid-request
42
+
43
+ # Resolve the description you issued for request["interactionId"]. Refuse an unknown
44
+ # interaction, or a request whose descriptionDigest is not Mailschema.digest(description).
45
+
46
+ # Request identifiers are claimed within your tenant: the account, or the capability.
47
+ # If request["requestId"] is already claimed there, refuse another principal, and a
48
+ # principal whose permission has been revoked, with an uncorrelated refused problem
49
+ # (Mailschema.problem without request_id): the refusal says nothing about the request.
50
+ # Answer any change to the request, compared by Mailschema.digest, with
51
+ # idempotency-conflict. Otherwise settle the recorded result with Mailschema.settle and
52
+ # return it, or expired-interaction once Mailschema.retain_until has passed. Nothing is
53
+ # applied again.
54
+
55
+ problem = CONTRACT.request_problem(description, request, now: Time.now)
56
+ # problem.code is unsupported-type, unsupported-operation or expired-interaction,
57
+ # with the title and detail Mailschema.problem takes. Then your own state:
58
+ # already-decided, or stale-target.
59
+
60
+ CONTRACT.input_errors(description, request)
61
+ # => [{ "detail" => "...", "pointer" => "/feedback" }], a claimed invalid-request
62
+ ```
63
+
64
+ Then apply the approval lifecycle or the effect, and record the result:
65
+
66
+ ```ruby
67
+ Mailschema.result(
68
+ request,
69
+ state: "accepted",
70
+ target: description["target"],
71
+ result_url: Mailschema.result_url(description, request["requestId"]),
72
+ recorded_at: Time.now,
73
+ output: { "feedbackRecorded" => true, "feedbackId" => "fb-1" }
74
+ )
75
+ ```
76
+
77
+ ## API
78
+
79
+ | Call | Does |
80
+ | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
81
+ | `Mailschema.parse(json)` | Parses a MAP document as I-JSON within the core limits, integral numbers as Integers, or raises `Mailschema::InvalidDocument` |
82
+ | `Mailschema.canonicalize(value)`, `Mailschema.digest(value)` | RFC 8785 canonical JSON, and `sha-256:` over it |
83
+ | `Mailschema.map_errors(document)` | Checks a description, request, result or problem against the core schema |
84
+ | `Mailschema.description_errors`, `Mailschema.request_errors` | Check a description or a request against its core definition only |
85
+ | `Mailschema.result_errors`, `Mailschema.problem_errors` | Check a result or a problem against its core definition only |
86
+ | `Mailschema::Contract.new(contract, request_schema, digest:, dependencies: {})` | Checks the contract's format and profile, refuses any contract but the one pinned by `digest`, checks its pinned dependencies and request schema digest, validates every schema against JSON Schema 2020-12 and compiles every validator once, or raises `Mailschema::InvalidContract`; `dependencies:` supplies, by URL, any pinned schema the gem does not bundle |
87
+ | `contract.description_errors(description)` | Checks a description against every rule of the core and the contract |
88
+ | `contract.request_problem(description, request, now:)` | The exact type, an offered operation the authority permits, and expiry, as a `Contract::Problem` or nil |
89
+ | `contract.input_errors(description, request)` | Checks an operation's input and field bindings, with JSON Pointers into `input` |
90
+ | `contract.result_errors(result)` | Checks a result against the core, the declared output schema and the declared reason |
91
+ | `contract.type_reference`, `contract.operation(id)`, `contract.decision?(id)` | The type reference descriptions and requests name, one of its operations, and whether completing it decides the interaction |
92
+ | `contract.id`, `contract.version`, `contract.digest`, `contract.document`, `contract.request_schema` | The contract's identity, and frozen copies of the documents it verified |
93
+ | `Mailschema.result`, `Mailschema.transition`, `Mailschema.problem` | Build results, state transitions and problems with their correlation members, or raise `ArgumentError` rather than return a document the core refuses. A problem stays within the core limits: a title of at most 240 characters, a detail cut at 4000, and as many of the first 100 input errors, each bounded, as fit in 64 KiB as `JSON.generate` writes them, so send documents written that way |
94
+ | `Mailschema.result_url`, `Mailschema.result_status`, `Mailschema.request_id?` | The result resource of a request, a result's HTTP status, and whether a value is a request identifier, the only thing a result URL names |
95
+ | `Mailschema.json_request?(content_type)` | Whether a `Content-Type` admits a request rather than a 415 |
96
+ | `Mailschema::DESCRIPTION_MEDIA_TYPE`, `Mailschema.description_part?(media_type, profile_parameter)` | The `Content-Type` of the part that carries a description, and whether a designated part is labelled with this profile; a client processes the one such part outside any attached message and ignores every other structured part |
97
+ | `Mailschema.retain_until`, `Mailschema.settle`, `Mailschema.reached?` | Result retention, the expiry of an undecided approval, and whether a deadline has passed |
98
+ | `Mailschema.capability(description)`, `Mailschema.written_path(url)` | The possession capability: the last segment of the execution URL's path as written |
99
+ | `Mailschema.artifact(url)`, `Mailschema.document(url)` | The bundled core artifacts, as exact bytes or as a fresh parsed copy |
100
+
101
+ Type rules a contract cannot express, such as a meeting slot being one of the slots offered, belong to each type's implementation.
102
+
103
+ ## Bundled artifacts
104
+
105
+ The gem carries the MAP 0.2 core schema, its JSON-LD context, the type contract format and the form fields block, and the Registry contribution schema that every MailSchema package binds. Each is byte-identical to the file [mailschema.org](https://mailschema.org) publishes. The gem does not validate contributions; the JavaScript and Python packages do. Type contracts are not bundled: an implementation vendors the contracts it supports, and `Mailschema::Contract` verifies them by digest.
106
+
107
+ JSON Schema validation uses `json_schemer` with ECMA-262 regular expressions. Lexical forms such as date-times and addresses are core schema patterns, so `format` is never asserted. References resolve only against pinned schemas, never over the network.
108
+
109
+ ## Links
110
+
111
+ - [Specification](https://mailschema.org/specification/profile)
112
+ - [Source](https://github.com/mailschema/ruby)
113
+
114
+ MIT License.
@@ -0,0 +1,91 @@
1
+ {
2
+ "@context": {
3
+ "@version": 1.1,
4
+ "map": "https://mailschema.org/ns/map#",
5
+ "schema": "https://schema.org/",
6
+ "MailAction": "map:MailAction",
7
+ "profile": {
8
+ "@id": "map:profile",
9
+ "@type": "@id"
10
+ },
11
+ "type": {
12
+ "@id": "map:interactionType",
13
+ "@context": {
14
+ "id": {
15
+ "@id": "map:id",
16
+ "@type": "@id"
17
+ }
18
+ }
19
+ },
20
+ "version": "schema:version",
21
+ "contractDigest": "map:contractDigest",
22
+ "describedAt": {
23
+ "@id": "schema:dateCreated",
24
+ "@type": "http://www.w3.org/2001/XMLSchema#dateTime"
25
+ },
26
+ "expiresAt": {
27
+ "@id": "schema:expires",
28
+ "@type": "http://www.w3.org/2001/XMLSchema#dateTime"
29
+ },
30
+ "service": {
31
+ "@id": "schema:provider",
32
+ "@context": {
33
+ "id": {
34
+ "@id": "map:id",
35
+ "@type": "@id"
36
+ }
37
+ }
38
+ },
39
+ "name": "schema:name",
40
+ "authority": "map:authority",
41
+ "onBehalfOf": {
42
+ "@id": "map:onBehalfOf",
43
+ "@context": {
44
+ "id": {
45
+ "@id": "map:id",
46
+ "@type": "@id"
47
+ }
48
+ }
49
+ },
50
+ "resource": {
51
+ "@id": "map:resource",
52
+ "@type": "@id"
53
+ },
54
+ "execution": "map:execution",
55
+ "url": {
56
+ "@id": "schema:url",
57
+ "@type": "@id"
58
+ },
59
+ "resultUrlTemplate": "map:resultUrlTemplate",
60
+ "resultRetentionSeconds": "map:resultRetentionSeconds",
61
+ "humanUrl": {
62
+ "@id": "map:humanUrl",
63
+ "@type": "@id"
64
+ },
65
+ "target": {
66
+ "@id": "schema:object",
67
+ "@context": {
68
+ "id": {
69
+ "@id": "map:id",
70
+ "@type": "@id"
71
+ }
72
+ }
73
+ },
74
+ "recipient": "schema:recipient",
75
+ "details": {
76
+ "@id": "map:details",
77
+ "@type": "@json"
78
+ },
79
+ "revision": "schema:version",
80
+ "title": "schema:name",
81
+ "digest": "map:digest",
82
+ "operations": {
83
+ "@id": "schema:potentialAction",
84
+ "@container": "@set",
85
+ "@context": {
86
+ "id": "map:operationId"
87
+ }
88
+ },
89
+ "description": "schema:description"
90
+ }
91
+ }
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The bundled core artifacts and the validators built from them.
4
+ module Mailschema
5
+ PROFILE = "https://mailschema.org/profiles/map/0.2"
6
+ CORE_SCHEMA = "https://mailschema.org/schemas/map-0.2.schema.json"
7
+ FORMS_SCHEMA = "https://mailschema.org/schemas/forms-0.1.schema.json"
8
+ CONTRACT_FORMAT = "https://mailschema.org/schemas/type-contract-0.2.schema.json"
9
+ CONTEXT = "https://mailschema.org/contexts/map-0.2.jsonld"
10
+ # The Registry contribution schema every MailSchema package binds, carried as bytes.
11
+ CONTRIBUTION_SCHEMA = "https://mailschema.org/schemas/contribution.schema.json"
12
+
13
+ ROOT = File.expand_path("../..", __dir__)
14
+ # Canonical artifacts, byte for byte, by the URL MailSchema publishes them at.
15
+ ARTIFACTS = {
16
+ CORE_SCHEMA => "schemas/map-0.2.schema.json",
17
+ FORMS_SCHEMA => "schemas/forms-0.1.schema.json",
18
+ CONTRACT_FORMAT => "schemas/type-contract-0.2.schema.json",
19
+ CONTEXT => "contexts/map-0.2.jsonld",
20
+ CONTRIBUTION_SCHEMA => "schemas/contribution.schema.json"
21
+ }.freeze
22
+
23
+ # The exact bytes of a bundled artifact.
24
+ def self.artifact(url) = File.binread(File.join(ROOT, ARTIFACTS.fetch(url))).freeze
25
+
26
+ # A fresh parsed copy of a bundled artifact.
27
+ def self.document(url) = JSON.parse(artifact(url))
28
+
29
+ # The schemas a contract may depend on without supplying them itself.
30
+ BUNDLED = [CORE_SCHEMA, FORMS_SCHEMA].to_h { |url| [url, JSON.parse(artifact(url)).freeze] }.freeze
31
+
32
+ # JSON Schema 2020-12 as MAP reads it. Lexical forms are the core schema's patterns,
33
+ # so `format` stays an annotation: format checkers differ between validators. MAP
34
+ # patterns use a subset ECMA-262 and Ruby read alike, and references resolve only
35
+ # against schemas already in hand, never over the network.
36
+ module Validation
37
+ module_function
38
+
39
+ def schema(value, references = BUNDLED)
40
+ JSONSchemer.schema(
41
+ value,
42
+ regexp_resolver: "ecma",
43
+ format: false,
44
+ ref_resolver: proc { |uri| references[uri.to_s.delete_suffix("#")] }
45
+ )
46
+ end
47
+
48
+ # The first way a schema is not valid JSON Schema 2020-12, or nil.
49
+ def invalid_schema(value) = JSONSchemer.validate_schema(value).first&.fetch("error")
50
+
51
+ # Errors as `pointer message` lines, as the other MailSchema packages report them.
52
+ # `prefix` places the pointers inside an enclosing document.
53
+ def errors(schema, value, prefix = "")
54
+ schema.validate(value).first(100).map do |error|
55
+ pointer = "#{prefix}#{error.fetch("data_pointer")}"
56
+ "#{pointer.empty? ? "/" : pointer} #{error.fetch("error")}"
57
+ end
58
+ end
59
+ end
60
+
61
+ CORE = Validation.schema(BUNDLED.fetch(CORE_SCHEMA))
62
+ DEFINITIONS = %w[description request result problem].to_h { |name| [name, CORE.ref("#/$defs/#{name}")] }.freeze
63
+ DATE_TIME = CORE.ref("#/$defs/dateTime")
64
+ REQUEST_ID = CORE.ref("#/$defs/uuidUrn")
65
+ CONTRACTS = Validation.schema(document(CONTRACT_FORMAT))
66
+
67
+ private_constant :ROOT, :BUNDLED, :Validation, :CORE, :DEFINITIONS, :DATE_TIME, :REQUEST_ID, :CONTRACTS
68
+
69
+ # Whether a value is a request identifier: the core UUID URN form. Only one names a
70
+ # result resource. Anything else, bytes that are not text included, is not one.
71
+ def self.request_id?(value)
72
+ return false if value.is_a?(String) && !value.valid_encoding?
73
+
74
+ REQUEST_ID.valid?(value)
75
+ end
76
+
77
+ # Checks any MAP 0.2 description, request, result or problem against the core schema.
78
+ def self.map_errors(document) = Validation.errors(CORE, document)
79
+
80
+ # Checks a description against the core description definition only. A description
81
+ # of a type the caller has no contract for can be checked this far.
82
+ def self.description_errors(description) = Validation.errors(DEFINITIONS.fetch("description"), description)
83
+
84
+ # Checks a request against the core request definition. A request that fails here
85
+ # is not claimed: its identifier stays free.
86
+ def self.request_errors(request) = Validation.errors(DEFINITIONS.fetch("request"), request)
87
+
88
+ # Checks a result against the core result definition only.
89
+ def self.result_errors(result) = Validation.errors(DEFINITIONS.fetch("result"), result)
90
+
91
+ # Checks a problem against the core problem definition, including the agreement of
92
+ # its type, status and code.
93
+ def self.problem_errors(problem) = Validation.errors(DEFINITIONS.fetch("problem"), problem)
94
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The possession capability and the rules its URLs meet.
4
+ module Mailschema
5
+ # The possession capability: the last segment of the execution URL path as written.
6
+ def self.capability(description)
7
+ written_path(description.dig("service", "execution", "url")).split("/", -1).last.to_s
8
+ end
9
+
10
+ # The path of an absolute URL exactly as written, with no dot segments removed and
11
+ # nothing decoded.
12
+ def self.written_path(url) = url.to_s[%r{\A[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*([^?#]*)}, 1].to_s
13
+
14
+ # The rules a possession description's capability URLs must meet when it is issued.
15
+ module Capability
16
+ UNGUESSABLE = /\A[A-Za-z0-9_-]{22,}\z/
17
+ DOT_SEGMENT = /\A(?:\.|%2e){1,2}\z/i
18
+
19
+ module_function
20
+
21
+ def problems(description)
22
+ execution = description.dig("service", "execution")
23
+ capability = Mailschema.capability(description)
24
+ problems = []
25
+ problems << "The capability is too short to be unguessable." unless UNGUESSABLE.match?(capability)
26
+ if [execution.fetch("url"), execution.fetch("resultUrlTemplate")].any? { |url| dot_segment?(url) }
27
+ problems << "A capability URL must not contain dot segments."
28
+ end
29
+ unless execution.fetch("resultUrlTemplate").start_with?("#{execution.fetch("url")}/")
30
+ problems << "The result template must extend the execution URL and its capability."
31
+ end
32
+ if description.dig("service", "humanUrl").include?(capability)
33
+ problems << "The human route must not carry the capability."
34
+ end
35
+ problems
36
+ end
37
+
38
+ def dot_segment?(url) = Mailschema.written_path(url).split("/", -1).any? { |segment| DOT_SEGMENT.match?(segment) }
39
+ end
40
+ private_constant :Capability
41
+ end