Stalwart advertises `maxDelayedSend` as a fixed 30 days, but its MTA
rejects any hold beyond its `futureRelease` limit, 7 days by default.
Now that scheduled send works on Stalwart, a time 8 to 30 days ahead
passed the checks and then failed with the raw "501 5.5.4 Requested
hold time exceeds maximum of 604800 seconds" reply.
Ported from the webmail:
- on Stalwart the advertised 30 days count as 7
- a submission or reschedule refused for its hold time throws
`ScheduleTooLateError`, and the limit it names replaces the advertised
one for that server
- the composer and the Scheduled screen show "Too far ahead" for it
instead of the SMTP reply
The composer's and the Scheduled screen's presets now only list times
within the limit, and the undo-send hold, from the composer and from
quick reply, is capped at it.
zustand's JSON storage serialises the whole partialized slice and
writes it to AsyncStorage on every set(), including ones that only
flip `loading`. Opening one unread message rewrote the mail cache
about five times.
`createPersistStorage` now coalesces writes per store:
- a slice whose fields are the same references as the last one is
dropped without serialising; the email and contacts slices are
built through `memoizeSlice`, so a set() that only touches UI flags
hands over the same slice
- other changes wait up to 1 s and only the newest is serialised, and
JSON identical to the stored row, or to the row just hydrated from,
isn't written again
- pending writes are flushed when the app leaves the foreground and
when an account is signed out
- the account registry is still written straight away, since the
headless push task reads it from AsyncStorage
- "Refresh cached data" removes the rows through the stores, so their
storage forgets the old JSON and writes the re-fetched data back
AsyncStorage caps its Android SQLite database at 6 MB unless
`AsyncStorage_db_size_in_MB` is set. The offline cache keeps message
bodies in that database, defaults to 50 MB and can be set to 500 MB,
so it filled the database long before its own limit, after which every
AsyncStorage write failed, including the account registry, the outbox
and the mail list cache. Raise the ceiling to 1 GB. It is a maximum,
not a preallocation, and needs a native rebuild to take effect.
The whole email cache is one AsyncStorage row, and Android can't read a
row over ~2 MB. The row held every window the user had paged through,
in every folder of every account, stored the open folder twice (as
`emails` and in `mailboxSnapshots`), kept a stale copy of the active
account in `accountSnapshots`, and rows seeded from the offline body
cache carried whole message bodies. Once it outgrew the limit the next
cold start could not load it.
- the active view is persisted the way an account switch tucks it away
(`snapshotFromActive`), so the open folder is stored once, and
`merge` rebuilds `emails` from it with `viewFromSnapshot`
- `boundEmailCache` keeps at most 100 rows per folder (the largest page
size, so the next refresh can still patch the window) and 500
overall, the open folder first; a window is cut to a prefix, which
keeps its queryState valid, and a folder past the budget is dropped
- body parts are stripped from persisted rows and the active
account's stale entry is left out of `accountSnapshots`
500 typical rows serialise to about 0.3 MB.
zustand only marks a persisted store hydrated when its storage read
succeeds, and `restoreSession` waited on the account, email, calendar
and contacts stores with no timeout. A read that failed, such as an
email-cache row too large for Android's ~2 MB CursorWindow or a row
that no longer parses, left the app on the splash screen for good.
- the four stores now use `createPersistStorage`
(`src/stores/persist-storage.ts`), whose read logs the failure and
resolves as nothing stored, so the store hydrates with its defaults
and its next write replaces the bad row
- `waitForHydration` gives up after `HYDRATION_TIMEOUT_MS` (5 s) and
carries on, covering a hydration that throws in migrate/merge or a
storage call that never settles
`docs/android-release.md` walks through generating the release key
with keytool, creating the debug-to-release signing lineage with
`apksigner rotate` and which capabilities the debug key keeps, the
five repository secrets, and how to check the first rotated release
on the emulators. It spells out what rotation protects and what it
does not: devices that have not updated yet, Android 7.0-8.1, which
still verify the debug-signed v2 signature, and when the lineage can
be dropped. It also covers local release builds and the `versionCode`
formula. The README links to it instead of the bare workflow file.
`versionCode` was hard-coded to 1, so every release looked like the
same version to Android. It is now derived from `VERSION` as
`major * 10000000 + minor * 10000 + patch` (0.1.62 gives 10062), which
is above the 1 existing installs have and keeps growing, so Android
never mistakes an update for a downgrade.
The formula only increases while patch stays below 10000 and minor
below 1000. `build.gradle` refuses a `VERSION` outside that range or
outside Android's 2100000000 ceiling, and `scripts/bump-version.mjs`,
which only ever bumps patch, refuses to create such a version before
anything is committed or tagged.
Every published APK up to 0.1.62 was signed with the React Native
template's debug keystore, whose key is public. Anyone could sign an
"update" that Android installs over the app, with access to its
SecureStore credentials and mail cache. The release build type fell
back to the debug signing config whenever no release key was passed,
and the workflow carried on silently when `ANDROID_KEYSTORE_BASE64`
was empty.
- `android/app/build.gradle` reads the release key from the
`BULWARK_RELEASE_*` gradle properties or environment variables and
has no fallback. Without them a release build fails in the new
`checkReleaseSigning` task. It only runs for release variants, so
debug builds and `expo run:android` are unchanged. A local test
build can opt in with `-PallowDebugSignedRelease=true`, which is
ignored whenever `CI` is set.
- The release workflow fails in its first step when a signing secret
is missing, checks the keystore, alias and password before the
build, and refuses a debug key uploaded as the release key.
- An optional `ANDROID_SIGNING_LINEAGE_BASE64` secret re-signs the APK
with APK Signature Scheme v3 key rotation from the debug key to the
release key, from API 28, so existing installs on Android 9+ can
update and stop trusting the debug key afterwards.
- Before anything is uploaded, `apksigner verify` has to show the
release key as the Android 9+ signer, never the debug certificate.
- `*.keystore` and `*.lineage` files are gitignored.
Audit item S2 in `docs/audit-2026-09.md`.
Some senders ship the images their HTML body embeds by `cid:` as
`application/octet-stream` with no disposition and no name. The body
rendered them, but the chip filter only hid parts declared inline with
an image type, so they also showed up as nameless "Attachment" chips.
Port the webmail's rule: scan the HTML the body actually renders for
`cid:` references and hide a referenced part when it is an image or
generically typed. An explicit attachment disposition keeps its chip,
as does a referenced part with a real non-image type. A plain-text
render references nothing, so nothing extra is hidden there.
Previews keep the body's paragraph breaks, so a line-clamped row showed
a dangling "…" line and rows came out at uneven heights, and a subject
carrying CR/LF rendered over several lines in the list and the viewer.
Fold every whitespace run to a single space before display with a new
`singleLine` helper, in the mailbox and unified inbox rows, the viewer
subject and the collapsed thread card preview.
Full-message fetches cap body values at 512 KB and never read
`isTruncated`, so a bigger body rendered whatever the server cut off
with no notice; for a 586 KB HTML report Stalwart returned a 59
character stub and the viewer showed a heading, one line and "…".
Port the webmail's refetch: when the first HTML or text body part
comes back truncated, `getFullEmail`, `getFullEmails` and
`getThreadEmails` fetch the HTML and text body values of those messages
again in one batched `Email/get` with an 8 MB cap, keeping the values
of other parts. Truncated text attachments don't trigger it. If the
body is still truncated, or the refetch fails, the viewer now says the
message is too large to show in full.
The HTML and plain-text body documents had no direction, so an Arabic
or Hebrew message was laid out left-to-right: left-aligned, with its
sentence-final punctuation on the wrong side. Port the webmail's
`dir="auto"` on the body of both documents so the first strong
character picks the direction. An email that declares its own `dir`
on its `<html>` or `<body>` tag keeps it; the parser merges those
attributes into ours only while ours has none.
The shrink-to-fit reporter widens its wrapper to the content's natural
width and scales it from the top left. In a right-to-left body that
over-wide block overflowed to the left instead, so a 600px newsletter
scaled into the space left of the screen. A negative right margin now
keeps the widened wrapper flush left in either direction, so the
existing origin, pan clamp and pinch anchoring still hold.
The body stylesheet forced `max-width: 100% !important` onto every image
and table, which also beat a sender's inline max-width. An image sized
with `max-width:200px; max-height:134px; width:100%` grew to the pane
width while its max-height still clamped the height, so it rendered
squashed, and a table with `max-width:300px` spanned the pane.
Only force the cap on images and tables that declare no max-width of
their own, as the webmail does. Content wider than the pane is still
scaled down by the injected reporter, and images keep
`height: auto !important`.
The vendored catalogs were last synced on 2026-08-29 and had fallen
about a hundred keys behind, including the German update and the
translations of `email_viewer.forward_as_attachment`.
- re-vendor all 27 `locales/<lang>/common.json` from webmail e33ab899
- drop the overlay's `email_composer.send_failed`, which the webmail
catalog now carries with translations
- keep 15 keys the webmail renamed or dropped but the app still uses,
moved into `locales/rn/en.json` by `npm run i18n:harvest`
Bulk mark read/unread and star/unstar from the list selection ran the
single-message action once per message, so a large selection sent one
`Email/set` per message against Stalwart's limit of four concurrent
requests. They now go through `setKeywordForEmails`, which sends one
`keywords/$seen` or `keywords/$flagged` patch for the whole selection
(chunked by `maxObjectsInSet`). It also keeps rows that leave an open
Unread or Starred view until the view is re-opened, as the single
actions do.
Every keyword change sent the message's whole `keywords` map, which the
server takes as a replacement. Opening a message that wasn't in the
loaded list sent `{"$seen": true}` and wiped its star and tags; starring
a listed message sent the list's stale map and reverted keywords another
client had changed since the list loaded.
Keyword changes now go out as `keywords/<name>` patch pointers (`true`
to set, `null` to clear) everywhere:
- mark read/unread, star, pin and tagging in the email store
- spam / not spam, trash-and-read, and the spam undo, which now puts
back only the keywords the action touched
- star, tag and unread from the message viewer
- the offline outbox, whose keyword ops carry a patch and merge per
keyword when coalesced
Ops queued by earlier builds still hold a whole map. They replay as a
patch that only sets the keywords in it (clearing the other half of
`$junk`/`$notjunk`), so an unread or unstar queued before the update is
lost but nothing gets erased. The list and the offline cache apply the
same patch locally instead of overwriting their keyword maps.
When a proxy redirects the session request (an http to https upgrade, a
trailing slash, another port), iOS and OkHttp follow it without the
Authorization header. The server then answers 401 or, like Stalwart, an
empty session, which the client reported as invalid credentials, so a
restored account was evicted and its stored credentials deleted. The
retry that was meant to catch this keyed on `response.redirected`,
which React Native's fetch never sets.
Session discovery now also detects a redirect by comparing the final
`response.url` with the requested URL, and on a 401 or an empty session
refetches the final URL with the header. Credentials only follow within
the same host or its https upgrade, the rule `secureFetch` applies; a
redirect elsewhere fails as a discovery error, which `loadAccount`
turns into a `NetworkError`, so the stored account is kept.
`sendEmail` threw on any method error in the response. Stalwart appends
one for the implicit `onSuccessUpdateEmail` Email/set that moves the
sent message from Drafts to Sent, so a message that had already gone
out showed "Send failed" and a retry sent it a second time. A method
error that follows a created submission is now returned as a filing
warning, like the record-level filing failures already were; errors
before the submission exists still fail the send.
Rescheduling a held message rebuilt the replacement submission's
envelope from the Email's To only, so Cc and Bcc recipients silently
stopped getting the message, and a catch-all From was used as the
envelope sender the server refuses. The replacement now reuses the held
submission's envelope (`rcptTo` and `mailFrom`), falling back to the
message's To, Cc and Bcc when the server returns none.
RFC 8621 puts the submission limits (`maxDelayedSend`,
`submissionExtensions`) and the mail limits
(`maxSizeAttachmentsPerEmail`) under each account's
`accountCapabilities`. Stalwart advertises an empty object for them at
session level, which is the only place the client looked, so scheduled
send was refused, the undo-send delay silently fell back to an instant
send, the Scheduled view listed nothing and the per-message attachment
cap was never enforced.
The client now reads these from the account that sends (the account's
own submission capability, else the session's primary submission
account, like the webmail), falling back to the session-level object
for servers that only advertise it there. Quick reply, which can send
from a shared account, now checks that account before holding the
message for the undo-send delay.
The mailto parser decoded `+` as a space, as in HTML form encoding, so
`mailto:alice+news@partner.example` became "alice news@…", failed
validation and opened nothing; a `+` in a subject or body was turned
into a space as well. RFC 6068 only uses percent-encoding, so a `+` now
stays a plus, like the webmail's `parseMailtoUrl`.
App compose links (`bulwarkmobile://compose?…`) went through the same
parser after `URLSearchParams.toString()`, which writes spaces as `+`.
They are now re-encoded with percent-encoding, and the recipient is read
from the `to=` parameter only, which also stops it from being added
twice.
Most apps start an email with `ACTION_SENDTO` and a `mailto:` URI. The
manifest offered the app for it, but React Native's Linking only
forwards VIEW intents, so the app opened on the inbox instead of the
composer.
MainActivity now rewrites a `SENDTO mailto:` intent into the equivalent
`VIEW mailto:` link before React Native sees it, on a cold start and
when the running app receives it. The EXTRA_EMAIL, EXTRA_CC, EXTRA_BCC,
EXTRA_SUBJECT and EXTRA_TEXT extras are folded into the link's query.
Bcc recipients of a mailto link now reach the composer too; they were
dropped for VIEW links as well.
Sharing a photo or document from another app into Bulwark failed every
time: the upload task was handed the share's `content://` URI, but it
can only stream from a file path, so it failed with "Directory for
'/external/images/media/…' doesn't exist". The attachment was also named
after the URI's last segment (a MediaStore row id such as "1000000027"),
and SEND_MULTIPLE shares only carried a wildcard type like `image/*`.
- `uploadBlob` copies `content://` URIs into the cache, uploads the copy
and deletes it afterwards, also when the upload fails
- `ShareIntentStore` asks the providing app for the display name, size
and MIME type when the intent arrives, falling back to the URI segment
plus an extension for the MIME type
- the composer gets the real name, type and size, so the size limit
check now applies to shared files too
`supportsSharing()` required `urn:ietf:params:jmap:principals:owner`,
which Stalwart advertises nowhere; its session only has `principals` and
`principals:availability`. The file menu therefore never showed
"Sharing & access", and `getPrincipals()` returned nothing, so the
calendar share sheet had no one to offer either.
- Gate sharing on `urn:ietf:params:jmap:principals`, like the webmail's
`supportsPrincipals()`.
- Keep `principals:owner` out of FileNode requests unless the server
advertises it, since an unknown capability in `using` fails the whole
request (why the gate had been tied to it).
- Page the principal lookup by `maxObjectsInGet`, as the webmail does:
one unbounded `Principal/query` fed every id to `Principal/get`, which
the server refuses once the directory has more than 500 entries.
`updateFileNode` and `deleteFileNodes` only looked for `notUpdated` and
`notDestroyed` in the first method response. A method-level `error`
reply (e.g. `forbidden`) has neither, so a refused rename, move or delete
reported success and the Files tab silently refreshed to the old state.
Every `FileNode/set` in `src/api/files.ts` now goes through
`requireMethodResult`, so the error reaches the existing alert in the
Files screen and the share sheet with the server's description.
The Files tab read the whole drive with `FileNode/get ids:null`, which
Stalwart silently caps at `maxObjectsInGet` (500 by default). Anything
past that, including folders created after the first 500 files and
everything inside them, never showed up, both for the own drive and for
shared accounts.
A full first page now counts as truncated, as in the webmail: the
remaining ids come from paging `FileNode/query`, are fetched in
`maxObjectsInGet`-sized `FileNode/get` batches packed into as few
requests as `maxCallsInRequest` allows, and parents that are still
unknown afterwards are fetched by id, which recovers folders on Stalwart
before 0.16.6 where the query returns files only. If the paging fails,
the first page is kept as before.
Adding a second account through the server's own OAuth opened the
authorization endpoint in the shared system browser with no `prompt`,
so the identity provider found the session of the account that was
already signed in and quietly handed back the same user.
When adding an account, the PKCE flow now sends
`prompt=select_account` like the webmail does (5771a213), or
`prompt=login` when the server's metadata lists no account picker, and
on iOS it runs the auth session ephemerally so the browser's cookies
don't carry over. The webmail hand-off gets the ephemeral session too.
Android's Custom Tabs have no private mode, so there the prompt is the
only guard.
With "Always send" the banner's auto-send ran in every page the viewer
pager keeps mounted, so opening one message also answered the receipt
requests of its unseen neighbours. The receipt is imported into Sent
before it is submitted, so a refused submission left a "Read: ..." copy
there, and the failure cleared the per-instance guard, so the next
render or remount imported another one.
- the pager marks the page on screen `active`, and the banner only
auto-sends there
- an automatic receipt is tried once per message per session, keyed by
account and id, so a failure or a remounted page does not send again
- `sendReadReceipt` destroys the imported copy when the submission
fails, which also covers the manual "Send receipt" button
The read receipt is assembled as raw RFC 5322 text, and every header
value in it comes from the message being answered. Stalwart returns
subjects already RFC 2047-decoded, so a sender could hide CRLF in an
encoded-word subject (or in the Message-ID or
Disposition-Notification-To) and have it interpolated verbatim. That
let them add headers such as Reply-To, or a whole body, to a message
the user's own account files in Sent and submits. `encodeHeaderWord`
passed any pure-ASCII value through untouched, CR/LF included.
Port the webmail fix: free-text values go through
`stripHeaderControls` (controls collapse to a space, then RFC 2047 if
non-ASCII), address-like values and message-ids through `headerAtom`
(controls and whitespace removed), and the From display name is quoted
when it uses specials.