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
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,10 @@ lint-jmap-email: build-python-base ## lint the jmap-email library (ruff check +
@$(COMPOSE) run --build --rm --entrypoint pylint jmap-email-test jmap_email tests
.PHONY: lint-jmap-email

format-jmap-email: build-python-base ## lint the jmap-email library (ruff check + format check + pylint)
@$(COMPOSE) run --build --rm --entrypoint ruff jmap-email-test format jmap_email tests
.PHONY: format-jmap-email

typecheck-jmap-email: build-python-base ## type-check the jmap-email library with ty (Astral, Rust)
@$(COMPOSE) run --build --rm --entrypoint ty jmap-email-test check
.PHONY: typecheck-jmap-email
Expand Down
3 changes: 2 additions & 1 deletion bin/release-jmap-email.sh
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,9 @@ expected_wheel = {
"jmap_email/__init__.py",
"jmap_email/composer.py",
"jmap_email/helpers.py",
"jmap_email/limits.py",
"jmap_email/options.py",
"jmap_email/parser.py",
"jmap_email/preview.py",
"jmap_email/types.py",
"jmap_email/py.typed",
f"jmap_email-{VERSION}.dist-info/METADATA",
Expand Down
9 changes: 2 additions & 7 deletions src/backend/core/mda/autoreply.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,8 @@
from django.db import transaction
from django.utils import timezone

from jmap_email import (
JmapEmail,
find_header,
find_headers,
first_address_email,
has_header,
)
from jmap_email import find_header, find_headers, first_address_email, has_header
from jmap_email.types import JmapEmail

from core import models
from core.enums import (
Expand Down
3 changes: 2 additions & 1 deletion src/backend/core/mda/dispatch_webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
from django.db.models import Q

import jwt
from jmap_email import JmapEmail, parse_email
from jmap_email import parse_email
from jmap_email.types import JmapEmail

from core import enums, models
from core.mda.inbound_pipeline import (
Expand Down
3 changes: 2 additions & 1 deletion src/backend/core/mda/inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
from django.core.exceptions import ValidationError
from django.db.utils import Error as DjangoDbError

from jmap_email import JmapEmail, first_msgid
from jmap_email import first_msgid
from jmap_email.types import JmapEmail

from core import enums, models
from core.mda.inbound_tasks import process_inbound_message_task
Expand Down
3 changes: 2 additions & 1 deletion src/backend/core/mda/inbound_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
import re
from typing import Any

from jmap_email import JmapEmail, first_address_email
from jmap_email import first_address_email
from jmap_email.types import JmapEmail

from core.mda.signing import verify_message_dkim
from core.mda.utils import headers_blocks
Expand Down
2 changes: 1 addition & 1 deletion src/backend/core/mda/inbound_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
from django.utils import timezone

from jmap_email import (
JmapEmail,
first_address_email,
first_address_name,
first_msgid,
sent_at_to_datetime,
)
from jmap_email.types import JmapEmail

from core import enums, models
from core.ai.call_label import assign_label_to_thread
Expand Down
2 changes: 1 addition & 1 deletion src/backend/core/mda/inbound_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from django.db.models.functions import Lower
from django.utils import timezone

from jmap_email import JmapEmail
from jmap_email.types import JmapEmail

from core import enums, models
from core.mda import spam
Expand Down
3 changes: 2 additions & 1 deletion src/backend/core/mda/inbound_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@

from celery.exceptions import SoftTimeLimitExceeded
from celery.utils.log import get_task_logger
from jmap_email import JmapEmail, first_address_email, parse_email
from jmap_email import first_address_email, parse_email
from jmap_email.types import JmapEmail

from core import models
from core.mda.dispatch_webhooks import (
Expand Down
2 changes: 1 addition & 1 deletion src/backend/core/mda/replies.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@
from typing import Any

from jmap_email import (
JmapEmail,
first_msgid,
format_address,
format_address_list,
is_valid_msg_id,
msgid_chain,
)
from jmap_email.types import JmapEmail

# Bracket-aware tokeniser for a wire-form ``References`` chain. Splitting
# on whitespace would slice ``<bad id@x>`` into ``<bad`` + ``id@x>`` and
Expand Down
3 changes: 2 additions & 1 deletion src/backend/core/mda/spam.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
from typing import Any, Dict, Optional, Tuple

import requests
from jmap_email import JmapEmail, has_header
from jmap_email import has_header
from jmap_email.types import JmapEmail

from core.mda.utils import headers_blocks

Expand Down
3 changes: 2 additions & 1 deletion src/backend/core/mda/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@

from django.utils import timezone

from jmap_email import JmapEmail, body_part_text, decode_rfc2047_header
from jmap_email import body_part_text, decode_rfc2047_header
from jmap_email.types import JmapEmail

__all__ = [
"SNIPPET_MAX_LENGTH",
Expand Down
2 changes: 1 addition & 1 deletion src/backend/core/mda/webhook_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from datetime import datetime, timezone
from typing import Any, Dict, Optional

from jmap_email import JmapEmail
from jmap_email.types import JmapEmail


def _utcdate(value: Any) -> Optional[str]:
Expand Down
3 changes: 2 additions & 1 deletion src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@

import pyzstd
from encrypted_fields.fields import EncryptedJSONField, EncryptedTextField
from jmap_email import EmailHeader, JmapEmail, body_part_text, parse_email
from jmap_email import body_part_text, parse_email
from jmap_email.types import EmailHeader, JmapEmail
from timezone_field import TimeZoneField

from core.enums import (
Expand Down
3 changes: 2 additions & 1 deletion src/backend/core/services/exporter/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
from django.db.models import OuterRef, Subquery

from celery.utils.log import get_task_logger
from jmap_email import JmapEmail, compose_email, parse_email
from jmap_email import compose_email, parse_email
from jmap_email.types import JmapEmail
from sentry_sdk import capture_exception

from core.api.utils import generate_presigned_url
Expand Down
2 changes: 1 addition & 1 deletion src/backend/core/services/importer/labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import logging

from jmap_email import JmapEmail
from jmap_email.types import JmapEmail

from core import models
from core.mda.utils import gmail_labels
Expand Down
2 changes: 1 addition & 1 deletion src/backend/core/services/importer/mbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from datetime import timezone as dt_timezone

from celery.utils.log import get_task_logger
from jmap_email.parser import parse_date
from jmap_email import parse_date

from core.services.s3_seekable import BUFFER_CENTERED, S3SeekableReader

Expand Down
9 changes: 2 additions & 7 deletions src/backend/core/services/importer/pst.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,8 @@
from django.conf import settings

import pypff
from jmap_email import (
EmailAddress,
compose_email,
is_valid_msg_id,
parse_address,
parse_addresses,
)
from jmap_email import compose_email, is_valid_msg_id, parse_address, parse_addresses
from jmap_email.types import EmailAddress

from core.services.s3_seekable import BUFFER_NONE, S3SeekableReader

Expand Down
11 changes: 11 additions & 0 deletions src/backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ select = [
"S", # flake8-bandit
"SLF", # flake8-self
"T20", # flake8-print
"TID251", # flake8-tidy-imports: banned-api only (not TID252 relative-imports)
Comment thread
sylvinus marked this conversation as resolved.
"F" # pyflakes
]

Expand All @@ -153,6 +154,16 @@ sections = { messages=["core"], django=["django"] }
known-third-party = ["dns"]
extra-standard-library = ["tomllib"]

[tool.ruff.lint.flake8-tidy-imports.banned-api]
# jmap-email's public API is the top-level `jmap_email` package; the
# TypedDict shapes live in `jmap_email.types`. Everything else is private
# implementation — reaching into it couples us to internal layout.
"jmap_email.parser".msg = "Import from the top-level `jmap_email` package (shapes: `jmap_email.types`)."
"jmap_email.helpers".msg = "Import from the top-level `jmap_email` package (shapes: `jmap_email.types`)."
"jmap_email.composer".msg = "Import from the top-level `jmap_email` package (shapes: `jmap_email.types`)."
"jmap_email.options".msg = "Import from the top-level `jmap_email` package (shapes: `jmap_email.types`)."
"jmap_email.preview".msg = "Import from the top-level `jmap_email` package (shapes: `jmap_email.types`)."

[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]

Expand Down
58 changes: 58 additions & 0 deletions src/jmap-email/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,67 @@ 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.2.0] - 2026-07-22

### Added

- `ParseOptions.max_preview_chars` (default 256, the RFC 8621 §4.1.4
ceiling) caps the length of the server-set `preview` excerpt. Lower it
via `options=` to produce a shorter preview (e.g. a list-view snippet).
- `ParseOptions.max_preview_scan_bytes` (default 128 KiB) bounds the body
text scanned for the `preview`, so a body that is almost entirely markup
(which never reaches the text budget) can't run the extractor away.
- `preview_text` helper (also used internally for the `preview` field):
turns HTML/markdown body text into a single, display-ready line. It strips
HTML (dropping `<script>`/`<style>`/`<title>`/`<blockquote>` payloads and
unwrapping markdown autolinks), drops `>`-quoted text/plain lines, strips
markdown
(incl. ATX + setext headers), collapses whitespace, and removes control
characters, ANSI/terminal escape sequences, and invisible format-character
"preheader spacers" (soft hyphen, ZWSP/ZWNJ, word joiner, BOM).
- `preview_text(..., content_type=...)` selects the cleaning stages per wire
format. The `>`-quoted-line drop and the markdown strip are text/plain
conventions, so they are skipped for `text/html`, where `>`, `*` and `_`
are literal characters the sender meant to be shown and quoted history
arrives as `<blockquote>` (already suppressed). Without this, `&gt;`
decodes to `>` before the quote filter and a chevron line of an HTML body
is deleted whole, while `2*3=6` becomes `23=6`. The HTML strip and the
control-character hardening run for every type. Defaults to `"text/plain"`,
the thorough path, so an unrecognised type is still fully cleaned;
`parse_email` passes each part's own type.

### Changed

- **BREAKING:** the RFC 8621 `TypedDict` shapes (`JmapEmail`, `EmailAddress`,
`EmailBodyPart`, `EmailBodyValue`, `EmailHeader`, `Attachment`,
`JmapEmailExt`) are no longer re-exported from the top-level package;
import them from `jmap_email.types` instead.
- **BREAKING:** `ParseLimits` → `ParseOptions`, `DEFAULT_PARSE_LIMITS` →
`DEFAULT_PARSE_OPTIONS`, and the `limits=` keyword on `parse_email` /
`parse_addresses` → `options=`. The bundle now also carries format policy
the RFC leaves to the server (preview length), not just resource caps.
Renamed outright rather than aliased: the keyword only ever took a
`ParseLimits`, which is gone in the same release, so a `limits=` shim would
have accepted a call whose argument can no longer be constructed.
- `preview` (RFC 8621 §4.1.4) is now cleaned through `preview_text` to strip
HTML and markdownish syntax. The HTML strip interrupts itself once it has
collected a small multiple of the preview's worth of text, so the default
`preview=True` parse stays cheap on large messages instead of scanning the
whole body part. The multiple is what lets a body whose text carries heavy
indentation or markdown still fill the preview to its cap: the cleaning
stages only shrink the text, so collecting exactly the cap fell short
(measured 79% of the budget on a pretty-printed newsletter, 50% on
markdown).
- `preview` is now drawn from the HTML body when present (matching the part
clients render), falling back to the plain-text body when the HTML yields
no visible text (e.g. a link-/image-only part) rather than shipping blank.

## [0.1.0] - 2026-06-08

Initial release. Extracted from the
[Messages](https://github.com/suitenumerique/messages) project.

[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
Loading
Loading