From 1ab73890b16fb16458991df11be27dbe666b2f75 Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Wed, 5 Aug 2026 22:51:41 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=96(jmap=5Femail)=20release=200.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump to version 0.3.0 --- bin/release-jmap-email.sh | 10 +- src/jmap-email/CHANGELOG.md | 132 +++++++++++++++++++++++++- src/jmap-email/jmap_email/__init__.py | 2 +- src/jmap-email/pyproject.toml | 2 +- src/jmap-email/uv.lock | 15 ++- 5 files changed, 154 insertions(+), 7 deletions(-) diff --git a/bin/release-jmap-email.sh b/bin/release-jmap-email.sh index a47d1736..ea69b1e6 100755 --- a/bin/release-jmap-email.sh +++ b/bin/release-jmap-email.sh @@ -139,7 +139,9 @@ with zipfile.ZipFile(WHEEL) as zf: expected_wheel = { "jmap_email/__init__.py", + "jmap_email/addresses.py", "jmap_email/composer.py", + "jmap_email/filenames.py", "jmap_email/helpers.py", "jmap_email/options.py", "jmap_email/parser.py", @@ -293,9 +295,10 @@ echo " https://test.pypi.org/project/jmap-email/${VERSION}/" # ── 5. smoke install ────────────────────────────────────────────────────── say "→ Smoke-installing jmap-email==${VERSION} from TestPyPI" -# TestPyPI's index has limited transitive coverage; jmap-email has zero -# runtime deps so a bare TestPyPI install is fine. The retry loop covers -# the index-propagation lag (~30s after upload). +# TestPyPI's index has limited transitive coverage, so the runtime dep +# (idna) is resolved from real PyPI via --extra-index-url; only the +# package under test comes from TestPyPI. The retry loop covers the +# index-propagation lag (~30s after upload). docker run --rm -t \ "${PYTHON_IMAGE}" \ bash -c " @@ -303,6 +306,7 @@ docker run --rm -t \ for i in 1 2 3 4 5; do if pip install --quiet --no-cache-dir \ --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ jmap-email==${VERSION}; then break fi diff --git a/src/jmap-email/CHANGELOG.md b/src/jmap-email/CHANGELOG.md index 8cdc8482..3e30da46 100644 --- a/src/jmap-email/CHANGELOG.md +++ b/src/jmap-email/CHANGELOG.md @@ -5,7 +5,136 @@ All notable changes to `jmap-email` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.3.0] - 2026-08-05 + +### Changed + +- **Breaking:** the package is no longer dependency-free: IDNA encoding + (`ComposeOptions(idna_encode_domains=True)`) now uses the + [`idna`](https://pypi.org/project/idna/) package (UTS 46, + non-transitional) instead of the stdlib IDNA2003 codec. The accepted + range is `idna>=3.7,<4` — the floor is the CVE-2024-3651 fix, the cap + keeps a future UTS 46 revision from changing what a domain encodes to + without a release here. Deviation code points are no longer + folded — `faß.de` encodes to `xn--fa-hia.de` instead of silently + becoming the *distinct* registrable domain `fass.de` (likewise the + Greek final sigma) — and labels IDNA2008 disallows (emoji) are now + refused. Empty labels, over-long labels, and a trailing root dot are + refused as before. + +- **Breaking:** `parse_email` returns `None` when any header field + exceeds `max_header_value_bytes` (previously the value was truncated + and the message parsed). +- `decode_rfc2047_header` bounds its own input at + `max_header_value_bytes`: the stdlib's `decode_header` is O(n²) in the + number of encoded-words, and attachment filenames reach it untruncated. +- The unexported `MAX_*` mirror constants on `jmap_email.parser` are + removed; read the fields on `DEFAULT_PARSE_OPTIONS` instead. +- **Breaking:** `compose_email`'s `keep_bcc` argument moved into a + `ComposeOptions` bundle as `emit_bcc`, for symmetry with `ParseOptions`: + `compose_email(jmap, options=ComposeOptions(emit_bcc=True))`. + `in_reply_to` and `prepend_headers` stay keyword arguments. +- **Breaking:** `compose_email`'s `allow_extensions` argument is removed; + `_ext` is always accepted. The composer never read it, so the flag could + reject input but never change output. +- `msgid_chain` drops entries it cannot write into a header (line + terminator, internal whitespace, nested angle bracket) instead of + emitting them. Well-formed ids, including `` and + `<12345>`, are unaffected. +- `sent_at_to_datetime` always returns a tz-aware datetime; naive input is + stamped UTC. A naive return made comparison with an aware datetime raise. +- `preview_text` no longer lets a line-anchored markdown construct span + lines: `"alpha\n-\n-\n-\nbravo"` was previewing as `alpha bravo`, + now `alpha - - - bravo`. +- The shape accessors accept only a `list`; a tuple or generator now + returns the empty default. + +### Added + +- `ComposeOptions` / `DEFAULT_COMPOSE_OPTIONS`, the compose-side peer of + `ParseOptions`: frozen, hashable, `dataclasses.replace`-able. + - `idna_encode_domains` (default `False`) — IDNA-encodes a non-ASCII + **domain** to its A-label (`contact@exemplé.fr` → + `contact@xn--exempl-gva.fr`), the only form 7-bit SMTP and the MX + lookup carry. Nothing in the stdlib does this conversion. + - `allow_8bit` (default `False`) — emits non-ASCII bodies as 8-bit. + Needs 8BITMIME (RFC 6152) on the hop. + - `allow_smtputf8` (default `False`) — emits UTF-8 headers (RFC 6532) + and permits a non-ASCII local part. Needs SMTPUTF8 (RFC 6531) on the + hop. Implies `allow_8bit`. + + The `allow_*` pair names hop capabilities this library does not + discover; the defaults assume nothing and emit pure-ASCII 7-bit. They + are permissions, not instructions — an all-ASCII message composes + byte-identically either way. See the README for the + compose-both-and-fall-back pattern. +- `sanitize_filename` is now public, for names that never went through + `parse_email`. Returns `None`, never `""`. A nameless part still reports + `name: null`; synthesizing a placeholder stays consumer policy. +- `is_valid_addr_spec` is now public and shared by the parser and the + composer: `True` means one well-formed mailbox, usable as it stands. +- Sixteen `_ext.defects` markers for MIME constructs different parsers + resolve differently, from the *Inbox Invasion* (CCS '24) and *Email + Smuggling* (2025) evasion catalogues. See the README table. + `message/rfc822` is deliberately unmarked — it fires on every forward. + +### Fixed + +- **Remote DoS: deeply nested MIME.** `BufferedSubFile.readline` tests + every body line against every ancestor predicate, making the stdlib + parse O(depth × lines). `_FastSubFile` skips that scan for lines that + cannot be a delimiter: cost flat in depth, identical output. +- **Sender forgery via header truncation.** Header values were cut at a + byte bound and *then* parsed, so a padded `From` could be made to + parse to an address nobody sent — which became the stored sender and + the DKIM alignment domain. Over-long fields are now rejected; address + lists cut back to a top-level separator and record + `AddressListTruncatedDefect`. +- **A display name could become a recipient**, two ways: the quoting check + treated a lone `"` as an already-quoted name, and RFC 2047 decoding + happened after the quoting decision, so `=?utf-8?B?ZXZpbEB4LmNv?=` + went out unquoted and decoded to a second mailbox. +- **An unclosed comment let a display name become the parsed sender.** + `From: victim@bank.com( ` reported `victim@bank.com`. + CVE-2023-27043 by another route; such headers are now refused. +- **Addr-spec validation was too loose.** A comma made one value two + recipients; RFC 5322 specials (`( ) [ ] : \`) unquoted changed the + recipient count or mutated the address; an unterminated quoted-string + local-part swallowed the next recipient; a control character was + silently cleaned and a different address emitted. +- **Non-ASCII addr-specs were silently mangled** into encoded-words that + RFC 2047 §5 forbids. Now converted under `idna_encode_domains` / + `allow_smtputf8`, or refused. +- **MIME boundaries now come from a CSPRNG**, not Mersenne Twister. +- **Quadratic matching reachable from attacker input.** The angle-addr + check ran `<[^<>]*@[^<>]*>`, where both halves can match `@`, so a `<` + followed by a run of `@` made the engine try every split point — one + 96 KiB message cost ~43s of CPU in `parse_email`. The markdown link and + autolink patterns had the same shape: at `max_chars=65536` a body of + repeated `[` or `<` cost 65s and 114s in `preview_text`. All are now + anchored or bounded, and linear. +- **Malformed shapes raised `AttributeError` before being wrapped.** + `format_address_list` and the attachment partition called `.get` on + entries that need not be dicts, so `{"to": "x"}` logged a full traceback + on every call — a log-flooding vector. Address entries that are not + dicts are skipped; a non-dict attachment raises `AttachmentError`, + since dropping one is invisible data loss. +- **The null-safe accessors could raise** on a truthy non-iterable + `headers` or a non-string header name. +- `parse_email` threads `options=` into address parsing, so + `max_address_list_bytes` applies on the entry point that meets hostile + mail; and defects are harvested after body parsing, so decode-time + defects are no longer dropped. +- RFC 6266 §4.3: `filename*` now wins over `filename`. + +### Internal + +- Four new Hypothesis suites under `pytest -m fuzz` (parse/compose seam, + wire round-trip, filenames, helpers) plus `test_ambiguity_defects`. + Fuzz phases are Hypothesis's defaults now, so failures shrink and + replay instead of vanishing. +- `preview_text` gained wall-clock complexity guards: the line-anchored + patterns were quadratic in the head, whose size scales with `max_chars`. ## [0.2.0] - 2026-07-22 @@ -67,5 +196,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Initial release. Extracted from the [Messages](https://github.com/suitenumerique/messages) project. +[0.3.0]: https://github.com/suitenumerique/messages/releases/tag/jmap-email-0.3.0 [0.2.0]: https://github.com/suitenumerique/messages/releases/tag/jmap-email-0.2.0 [0.1.0]: https://github.com/suitenumerique/messages/releases/tag/jmap-email-0.1.0 diff --git a/src/jmap-email/jmap_email/__init__.py b/src/jmap-email/jmap_email/__init__.py index 9ab6bc12..ab91085f 100644 --- a/src/jmap-email/jmap_email/__init__.py +++ b/src/jmap-email/jmap_email/__init__.py @@ -16,7 +16,7 @@ Versioning: semantic. Public API is everything exported below; anything prefixed with ``_`` is internal. """ -__version__ = "0.2.0" +__version__ = "0.3.0" # The RFC 8621 ``TypedDict`` shapes are annotation-only and live in their # own namespace — ``from jmap_email.types import JmapEmail`` — rather than diff --git a/src/jmap-email/pyproject.toml b/src/jmap-email/pyproject.toml index 8c25bfdd..e8aaa7f3 100644 --- a/src/jmap-email/pyproject.toml +++ b/src/jmap-email/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "jmap-email" -version = "0.2.0" +version = "0.3.0" description = "A strict-JMAP RFC 8621 Email object library for Python 3.14+ with lenient RFC 5322 / MIME parsing and strict-by-design composition. One runtime dependency: idna (UTS 46 domain encoding)." readme = "README.md" license = "MIT" diff --git a/src/jmap-email/uv.lock b/src/jmap-email/uv.lock index a26ecbf0..942672db 100644 --- a/src/jmap-email/uv.lock +++ b/src/jmap-email/uv.lock @@ -103,6 +103,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/0f/b61fd8f8f98c8cc6c99f3df670b379e907ef842d3290a9c86ad1441e627e/hypothesis-6.159.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d966f544231ed420e6b8d59ba97539d767bc848c4de311f213023050e9b6d2da", size = 654269, upload-time = "2026-07-22T08:59:55.251Z" }, ] +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -123,8 +132,11 @@ wheels = [ [[package]] name = "jmap-email" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } +dependencies = [ + { name = "idna" }, +] [package.optional-dependencies] dev = [ @@ -139,6 +151,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.151.0" }, + { name = "idna", specifier = ">=3.7,<4" }, { name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" },