🔖(jmap_email) release 0.3.0

Bump to version 0.3.0
This commit is contained in:
jbpenrath
2026-08-05 23:09:55 +02:00
parent d03e56de22
commit 1ab73890b1
5 changed files with 154 additions and 7 deletions
+7 -3
View File
@@ -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
+131 -1
View File
@@ -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 `<foo$@local@domain>` 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( <attacker@evil.co>` 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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+14 -1
View File
@@ -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" },