The 0.3.0 parser refuses what 0.1.0 truncated and the composer raises
where it silently mangled, so the app has to take a position at each
seam: a ComposeError on send becomes a 400 (a property of the draft,
not a server fault), an unparseable inbound message is abandoned
outright instead of retried for 48h (deterministic failure — logged
at error level since abandoned rows are purged after 7 days), and a
stored message the stricter parser now refuses is flagged unreadable
to the UI rather than rendered blank. Attachment display names move
to a single service so serializer, blob download and draft builder
synthesize the same name for a nameless MIME part — the bug that
started this branch. The inbound retry sweep gains age-based backoff
so a dependency outage is not polled harder the longer it lasts. The
dev compose mounts the jmap-email working tree over the installed
wheel so local edits propagate without a rebuild.
Archive reconstruction (PST) composes with allow_smtputf8: an EAI
address is legal in an Exchange archive and the reconstructed .eml is
stored, never retransmitted, so refusing it would exclude the message
from the import. The unquote-message reply patterns bound every
whitespace quantifier that could cross newlines: under the m flag an
unbounded \s* backtracks once per line start, quadratic in the line
count of an attacker-supplied body.
Fuzz testing and CVE research showed the 0.2.0 parser trusted its
input too much: a padded From could forge the stored sender and DKIM
alignment domain, a display name could smuggle in a second recipient,
crafted messages hit quadratic regexes and O(depth×lines) MIME
nesting. Over-long header fields are now refused instead of truncated,
addr-spec validation is shared between parser and composer, seventeen
_ext.defects markers surface the MIME ambiguities catalogued by Inbox
Invasion (CCS '24) and Email Smuggling (2025), and sanitize_filename /
is_valid_addr_spec go public so consumers apply the same policy.
IDNA encoding moves from the stdlib IDNA2003 codec to the idna package
(UTS 46, capped >=3.7,<4) because nameprep folding silently routed
mail to distinct registrable domains.
The 30s autosave tick could fire between the submit's awaits (draft
save, editor export) and the send mutation, dispatching a draft PUT
concurrently with POST /send/ — the client half of the recipient-rewrite
race fixed backend-side. Stop the timer before any await, wait for a
blur-triggered save to settle right before sending, and restore the
timer when the submit aborts since the draft stays open.
A draft PUT racing a send could pass its is_draft=True fetch before the
send finalized the message, then rewrite the MessageRecipient rows
(delete + recreate, new UUIDs) while the outbound worker held the old
rows. The worker's post-SMTP status save then crashed the delivery with
"Save with update_fields did not affect any rows", and the recreated
rows were left without delivery status, so the retry task re-sent an
already-delivered email.
The PUT now locks the message row and re-checks is_draft in the same
transaction as the rewrite, serializing it against the send's finalize.
The worker records statuses through a queryset UPDATE (warning instead
of crash when the row is gone), and the SMTP-failure fallback no longer
flips already-delivered recipients back to RETRY.
New "arc" inbound_auth mode derives dkim/dmarc only from an ARC-sealed
ARC-Authentication-Results whose outermost sealer is in
SPAM_CONFIG["trusted_arc_sealers"] (empty = any valid seal); plaintext
headers are never trusted.
New SPAM_CONFIG["rules"][..]["arc_verdict"] (trusted|untrusted) marks
or drops inbound mail lacking a valid trusted seal, so a publicly-reachable
mta-in effectively accepts only mail sealed by a trusted relay.
The chain is verified with dkimpy; a verification/DNS failure never spams or drops.
Config reference in docs/spam.md.
---------
Co-authored-by: Riël Notermans <riel@mosa.cloud>
Co-authored-by: Riël Notermans <riel@zzapps.nl>
Add a `preview_text` helper that is exposed by the lib.
It stripped html tags through a HTMLParser and also strip
markdownish syntax that can be found into text body.
Previously preview attribute could contains html/md noise,
now it is a clean display ready text string.
In some case, a user can be authenticated on the identity provider
but do not have account on Messages. In this precise case, we know
display a toast to explicit what's wrong.
When a user tries to access to a message route when it is not
authenticated, it is redirect on the homepage and have to authenticate.
Now in this case, we redirect on homepage and persist the previous route
within a next query param, in this way, we are able to automatically
redirect the user on the right view once it is authenticated.
Largely improves user & operator visibility on imports, resiliency with retries,
direct-to-offload storage, import cancellation (deleting imported messages) & more.
Remove the modal import and manage all within the import tab in the settings modal
Notably, we try to reduce disk usage by standardizind on common
base Docker images. We also improve node_modules by reducing
duplicate dependencies and install speed.
Store review cycles make shipping web-layer fixes through the stores
too slow, so the apps update their JS bundle over the air. The chain is
fully self-hosted to keep sovereignty: bundles and channel manifests
live on an anonymous-read S3 bucket (create_bucket --public / the
create-ota-bucket script) and the Capgo plugin is driven entirely from
JS against that manifest (autoUpdate off — no Capgo server involved).
Bundles are RSA-signed at publish time and verified against the
per-instance public key baked in at cap sync, so a tampered zip on the
public bucket is rejected.
Versions use a git-derived <count>-<sha> id stamped into the builtin
bundle so a fresh install does not re-download its own commit, and
channels (dev/staging/prod) are fully independent because
NEXT_PUBLIC_* vars are inlined at build time.
Also ships docs/mobile.md.
Ship the existing SPA as native iOS/Android apps without forking the
codebase: Capacitor wraps the web build, and every mobile-specific
behavior is gated behind isNativePlatform() so the web app is
untouched. The native shells route fetch/cookies through the native
HTTP layer (CapacitorHttp) — the WebView cookie jar is unreliable for
cross-origin sessions — which is why login runs in the system browser
(cross-app SSO via the shared IdP cookie) and finishes through the
backend session handoff, with the deep-link scheme pinned by
sso-invariants tests. Downloads/share go through the Filesystem/Share
plugins since WebView navigation would lose the session.
The backend sends acr_values=eidas1 on every authorization request
(OIDC_AUTH_REQUEST_EXTRA_PARAMS) but the dev realm had an empty
acr.loa.map, so Keycloak treated the value as an unknown essential acr
claim. Web logins survived it, but the mobile system-browser flow
(ASWebAuthenticationSession / Custom Tabs) failed the login round-trip,
breaking cross-app SSO in dev. Mapping eidas1 to LoA 1 mirrors what the
production IdP declares.
Capacitor apps must run the OIDC flow in the system browser (the IdP
cookie has to live there to provide cross-app SSO), but the browser's
cookies never reach the app's native HTTP layer, so the Django session
created by the callback would be stranded. The callback now redirects
to an allowlisted app deep link with a one-time token that the app
exchanges for its session cookie and CSRF token. The token is bound to
the initiating app instance with a PKCE S256 verifier, single-use,
short-lived (MOBILE_AUTH_TOKEN_TTL) and the anonymous exchange endpoint
is throttled per IP to cap brute-force guessing. An empty
MOBILE_AUTH_CALLBACK_SCHEMES (the default) keeps the whole handoff
disabled.
The upcoming Capacitor mobile shell replays the Django session cookie
through its native HTTP layer but not the `csrftoken` cookie, so
cookie-based CSRF would break every mutation on mobile. Enabling
CSRF_USE_SESSIONS moves the secret server-side and removes the need for
a JS-readable cookie: the token is now delivered on the authenticated
/users/me/ response, cached in memory by the SPA and echoed as
X-CSRFToken. On web this is equivalent or safer — the secret is no
longer readable by scripts nor overridable via cross-subdomain cookie
tossing.
Some of our users has reported issues using the application
with Chrome 109. Instead of polyfilling one by one each
method, we setup vite legacy plugin and configured a
browserlist. It adds a 23.5Kb Gzipped module but the
polyfill strategy management is more standard and robust.
Close#741
About the unquote-message logic, we encount a bug with a thread
implying Outlook Desktop quotes. Actually, for Outlook web we were looking for
a hr tag as quote separtor element. But sometimes this one can be wrap into a div
and we missed it.
Parse sanitized html before rendering to detect links that are just raw text then
transform them into anchor.
Furthermore, for security purpore, we catch event when a user clicks on a link then
display a confirmation modal displaying the real link.
Co-authored-by: Valentin Regnault <valentinregnault22@gmail.com>
When the user switches to anoter mailbox while it is on a search view,
we reset search params and go to to the inbox as it does not make
sens to keep search params on the new mailbox
Postfix was already removed as a mta-out dependency, this is the second step so we have a pure python, more auditable path for incoming emails. We plan to keep postfix as a compatible option for a while but it won't be the default once this is battle tested.
We are currently using react-email to generate html bodies. This
library aims to generate marketing email consistent in all mail
providers. For personal message, it generates too much custom styles
that can increase spam score of those messages.
Safari 26 changed `text-wrap: pretty` to re-optimize line breaks over
the whole paragraph (webkit.org/blog/16547). The global CSS reset
applies it to every paragraph, so BlockNote editor paragraphs get
re-balanced on every keystroke, wrapping lines well before the editor
width. Restore `text-wrap: wrap` inside .bn-editor, which covers the
message, signature and template composers. Received messages render in
an iframe and are not affected by the reset.
Fixes#735
Signed-off-by: Nicolas Aunai <nicolas.aunai@lpp.polytechnique.fr>
Co-authored-by: Jean-Baptiste Penrath <jbpenrath@gmail.com>
Display the count of unread messages next to the mailbox name
to help to quickly identify mailbox with new unread messages.
Resolve#738
Co-authored-by: Nicolas Aunai <nicolas.aunai@lpp.polytechnique.fr>
The relay section of main.cf.j2 is indented by one space. Jinja keeps that
leading whitespace in the rendered /etc/postfix/main.cf, and Postfix treats any
line starting with whitespace as a continuation of the previous parameter. So
the smtp_sasl_* directives get folded into the relayhost value instead of being
set as parameters, leaving smtp_sasl_auth_enable at its default (no) and the
relayhost value malformed.
Outbound mail through an authenticated relay then fails: the relay rejects it
with "502 5.7.0 Please authenticate first", and Postfix logs "fatal: valid
hostname or network address required in server description: =". De-indenting the
block to column 0 makes each directive a real parameter. Verified against
Scaleway TEM: postconf then shows smtp_sasl_auth_enable = yes and delivery
succeeds (250 queued).
Signed-off-by: Corentin Giraud <corentin@giraud.dev>
In message form, we prevent the user to press "Enter" to submit
the form by error when composing message. But this logic breaks
the line-break on chrome for android.
The openapi schema specifies that an attachment has always
a name but we have some case where this attribute is None.
To guarantee this contract, we use a fallback value `unnamed`
when name is None during serialization.
Also fix other issues of the same kind identified.
When a user clicks on the checkbox of the thread-item to select it,
the preventDefault call prevent the checkbox to update its state so
under the hood the thread was well added to the selection but the
user has a wrong state. Now we make the checkbox fully controlled
and non-interactive, in this way, user always interact with the
thread-item link and the selection state is used to set the
checkbox state