Stalwart 1.0 refuses keywords over 128 bytes and more than 100
keywords on one message, per message in Email/set `notUpdated`; a
read-only shared folder refuses any keyword on every version. The app
stopped a large selection at the first refused batch, "mark folder as
read" ignored refusals, and the list's swipe, bulk and tag actions let
the rejection go unhandled, so nothing was said. The keyword sort
polarity verdict was stored per server address for a week, so a server
reinstalled as 1.0 (which sorts as the RFC says, where 0.16.8-0.16.22
invert) kept the old reading.
- Email/set writes send every batch before reporting the first refused
message, and "mark folder as read" marks the rest, then reports the
refusal. The list shows "Tagging failed" or "Failed to update email"
for swipe, bulk and tag actions, and the viewer's automatic
mark-as-read puts the message back to unread when it is refused.
- The sort polarity verdict is keyed by the server fingerprint as well
(stored as `keyword-sort-polarity:v2`), so a replaced or upgraded
server is probed afresh. An upgrade that leaves the capabilities
alone, such as 0.16.22 to 0.16.23, still waits out the week.
- Tests pin that Stalwart 1.0's `$hasattachment` / `$hasnoattachment`
never show as tags or as tags to recover; the app lists no other
keywords, and tag ids stay within 128 bytes by construction.
Stalwart 1.0 advertises `urn:ietf:params:jmap:metadata` by default, but
its handlers are still `todo!()`: a get that selects `metadata` or
`privateMetadata`, or a /changes call with `ignoreMetadataOnlyChanges`,
panics the request on the server. The app never sends either, but its
Mailbox/get and SieveScript/get calls asked for the server's default
properties, which a later release could grow to include metadata.
- Mailbox/get (own folders, shared accounts' folders, by id, and the
polling fallback) and SieveScript/get ask for explicit RFC property
lists, the same as today's defaults on 0.16 and 1.0 (checked live).
- A test checks the requests the folder, filter, delta-sync and polling
paths build, on 0.16.23 and 1.0 sessions, and scans the app's sources
for metadata properties, the /changes argument and the capability.
Stalwart 1.0 has no migration from 0.16, so the way there is a fresh
install at the same address, where ids start over ("a", "b", ...) and
name other objects. The app persists a lot that only means something on
the server it came from: JMAP states and query states, offline bodies
keyed by message id, an outbox that replays stored ids (a `destroy`
included), the calendar cache and push de-dupe lists. It also turned
push off for good when the server no longer had the subscription, and
would have taken another device's subscription for its own once the
reinstalled server handed out the recorded id again.
- Email/changes, Email/queryChanges and Mailbox/changes answers whose
Stalwart state went below the one asked from count as not computable
and lead to a full fetch (`stalwartChangeId`, a port of the webmail
guard). It only reads accounts that list `urn:stalwart:jmap` and
states in the exact form Stalwart writes, so it never trips while
states grow or on other servers.
- Every time a session is established, a fingerprint of the server (API
URL, primary account id, the session's and the primary account's
capability names) is compared with the one recorded for the account.
On a change, before anything reads them, the account's folders,
lists, states and query states, offline bodies, queued and failed
outbox ops, the calendar and contacts caches and the push de-dupe
lists are dropped. Preferences, templates, tags and iCal
subscriptions stay. The first sight of an account only records it.
- The whole outbox goes: every op names a message by its server id.
Offline edits still waiting are lost, but never a message the user
wrote, because the outbox holds no sends.
- A subscription the server no longer has counts as expired and is
re-created, and the recorded one is only ours when its device client
id matches, on setup and on teardown. Revoking this device from
another one therefore no longer keeps push off here; turning it off
on the device does.
Stalwart refuses a push subscription's whole emailPush map as a
`forbidden` SetError when it names a mailbox shared by ACL (0.16.16 and
later, and 1.0), and 1.0 stops listing emailpush on such accounts. The
app narrowed the map on any `forbidden`, including a refused method
call, and put every account with folders in the first attempt, so 1.0
cost one refused call per install. Worse, a failed listing counted as
"the server has no subscription" and dropped the key of a working
registration, and any failed refresh - a network error, a timeout, rate
limiting - started a replacement.
- `jmap-result` gains `JMAPSetError` (a `JMAPMethodError` with the same
name, carrying `properties`) and `isJMAPSetError`. `assertSetResult`
throws it, so every refused object can be told apart from a refused
call; existing `instanceof` checks and the outbox's name-based error
classification are unchanged.
- The emailPush map holds the primary account and the accounts whose
`accountCapabilities` list `urn:ietf:params:jmap:emailpush`, which
leaves ACL shares out up front on 1.0. On 0.16.16-0.16.23 the refused
map still narrows to the accounts that look owned, then to the
primary account, but only on a `forbidden` SetError.
- A subscription ends only with a verified replacement, a user disable
or the server's verdict: a failed listing aborts the run, and only a
SetError on the refresh starts a replacement. Other failures leave
the subscription and its key alone and the next run tries again.
- `forbidden` and `overQuota` read as plain text in settings and the
onboarding prompt, and settings says that mailboxes others share
don't send background notifications. New strings in all 27 languages.
Files in the app assumed Stalwart 0.16 in several places:
- Stalwart 1.0 lists the owner's folders above a node shared with the
user, without dates and with every right false. The app took the
owner's top folder for the shared root, so the folder actually shared
sat several levels down, under folders where rename, delete and move
could only fail. Those placeholders are now left out of the root and
of folder listings.
- A node counted as a folder when it had no blobId, so 1.0's symlinks
showed up as empty folders that could be opened. Listings ask for
`nodeType` (returned since 0.16.6, never sent in FileNode/set), and
a symlink is an inert row with a link icon: it can be renamed, moved
and deleted, but not opened, downloaded, duplicated or shared.
- Only "/" was checked in names, and uploads were not checked at all,
so `<>:"\|?*` already failed on 0.16. New folder and rename now apply
the naming rules from the filenode capability plus 1.0's stricter
ones (control characters, 255 bytes, reserved stems such as
`con.txt`) and say what is wrong. Uploaded names are made acceptable
instead: `con.txt` becomes `con_.txt`, also on 0.16.6 to 0.16.23.
- One refused file stopped a whole upload. The batch now goes on and
reports the files that failed at the end.
- Every media type over 30 characters, which is every Office document,
was stored as application/octet-stream. New files get the bare
lowercase media type 1.0 requires, and no `size`, which 1.0 refuses
when it differs from the blob.
- File errors keep their JMAP type, and the alerts say why an action
failed: too many files or folders (`overQuota`), folders nested too
deeply, a refused name, missing rights, or a file too large.
The app stores its filter rules as JSON inside a `/* */` comment at the
top of the Sieve script. A `*/` anywhere in a rule name, a value or the
auto-reply text ended that comment early and turned the rest of the
rules into script, and a CR or LF in a rule name did the same to its
`# Rule:` comment. Stalwart (sieve-rs, on 0.16 and 1.0) also accepts
only the letters A-Z, digits, `-` and `_` in a header name. Any of these
made the server refuse the whole script, so saving any filter, and the
auto-reply with it, failed.
- `*/` in the embedded rules is written as `*\/`, which JSON reads back
as `*/`, so every client parses the rules as before
- the `# Rule:` comment carries the rule name without line breaks, and
the parser matches the comment back to its rule by that form
- the filter editor trims header names, turns autocorrect off for them
and refuses to save one the server would reject, with a translated
message; the generator leaves a rule with such a header name out of
the script, since dropping only the condition could widen an "all of"
rule, and keeps it in the stored rules for editing
When Mailbox/changes answered with `hasMoreChanges` (Stalwart caps one
answer at 5000 changes, other servers earlier), the folder sync applied
that page but kept the old state and ran again, which asked for and got
the same first page back, without end.
The sync now follows `newState` page by page, storing each page's state
as it applies it. After five pages, or when a page does not move the
state on or can't be calculated, it reads the whole folder list instead,
which takes one request whatever changed.
Four smaller calendar changes that Stalwart 1.0 needs.
- Time zone: 1.0 fails every CalendarEvent get and query whose
timeZone its database lacks, with invalidArguments "Invalid timeZone".
It matches IANA names case-sensitively and knows no offsets, so a
"+01:00" or "europe/berlin" that Intl accepts empties the calendar.
0.16 reads such a zone as UTC. The zone is now checked with a get of
no events that rides along with the first calendar list; once refused,
reads ask in UTC and log one warning, and a read refused before the
check answered is repeated once in UTC. The comment that said Stalwart
ignores bad values is corrected.
- Default alerts: 1.0 fills a calendar's default alerts into the
alerts of an event that uses them. The editor wrote them back with
useDefaultAlerts false on every save, freezing today's defaults into
the event. It now writes nothing while the reminders are unchanged; a
change turns the defaults off and saves the alerts under ids of their
own, since 1.0 drops alerts saved under a default alert's id.
- Calendar colour: renaming sent the colour along, and 1.0 accepts only
#rgb, #rrggbb or a CSS name, so a calendar another client coloured
with an 8-digit hex could not be renamed. The edit sheet and the iCal
subscription settings now send only the fields that changed.
- Reply stubs: 0.16 stored an attendee's answer to one occurrence
without a title, place or description, and 1.0 builds that occurrence
from the override alone. Hydration now takes a missing title from the
series, and the place and description when the override removes the
title.
Stalwart 1.0 refuses Principal/query with `forbidden` unless the
administrator allows directory queries, which is off by default. The
app took the missing Principal/get answer for an empty directory: the
calendar share sheet said "Sharing is not available on this server.",
the file share sheet said "No other users found", and existing shares
showed bare ids.
- `listPrincipals` says whether the listing is complete, `restricted`
or failed. On `forbidden` it falls back to Principal/get without ids,
which 1.0 still answers with the user's own account, their groups and
the accounts that shared something with them.
- Principal/get asks for `id`, `type`, `name`, `description` and `email`
only; 1.0's default list adds `capabilities` and `accounts`.
- Both share sheets explain a restricted directory under the search
box. Only 1.0's own refusal ("The administrator has disabled directory
queries.") gets the wording that points at Stalwart's sharing
settings; any other refusal gets a generic one, and a failed listing
says so.
- A sharee the app can't name shows as "Unknown user" with its id, and
can still be changed or removed.
On 0.16 with the default roles the listing is complete and the sheets
behave as before.
Stalwart 1.0 reads a text filter value that ends in `*` as one prefix
over the whole string, not split into words and capped at 16 bytes. The
app added `*` to every word it searched for, so on 1.0 `project budget`
became the prefix "project* budget", an address was no single word and
`Geschäftsführung` (18 bytes) was over the cap: all three found
nothing. Quoted phrases broke too, since `"annual report" 2024` went out
as one value ending in `*`.
The search text now goes out as typed, split the way the webmail does
it (5967a1c4): each quoted phrase and each `word*` becomes a condition
of its own, ANDed with one condition that holds the other words, so a
query without quotes or stars is sent unchanged. The folder, all-folders
and tag-view searches, the unified inbox and the from, to, subject and
body filters all use it, and `searchEmails` puts `inMailbox` next to
the text conditions in one AND instead of on an operator.
0.16's built-in index dropped the `*` anyway, so its results stay the
same; there a quoted phrase is an AND of its words. On 1.0 a typed
`word*` is now a real prefix.
The fake server of the store tests now matches text the way Stalwart's
built-in index does, which the old `*` after every word fails.
Stalwart 1.0 returns recurrence overrides as RFC 8984 PatchObjects,
with keys like participants/p1/participationStatus and nulls that
remove. Expanding a series on the device spread them onto the
occurrence, so a declined occurrence stayed accepted, a changed room
stayed the same and the pointer keys became junk properties. Moved or
resized occurrences also kept the time and length the rule gave them,
and were placed in the range by their original date.
- apply overrides as patches: pointer paths decoded per RFC 6901, null
deletes, shorter paths first, a whole participants map completed
from the series
- time an occurrence from its patched start, time zone and duration,
and range-check a moved occurrence by where it is now
- fall back to the JSCalendar duration when the master's utcStart or
utcEnd don't parse (0.16.23 returned the year 32548 for a counted
series), and never keep the master's instants on another occurrence
- merge a change into an existing override without keys that are
prefixes of one another, and match an occurrence listed twice
against the members its override patches
The device expands series on 0.16.6 to 0.16.19, after a created series
and when a range can't be expanded on the server. 0.16 returns whole
override values, which apply the same way.
Stalwart 1.0 refuses series properties such as the organizer on a
single occurrence unless they repeat the series' value, and refuses
them inside a recurrence override on the base event. The event editor
wrote the user's first address as organizerCalendarAddress on every
save of an event with attendees, so editing one occurrence failed with
"This property cannot be modified on a single occurrence." whenever the
series was organized by someone else, under another alias or in
another letter case. Removing every attendee from one occurrence failed
the same way.
- leave series keys and per-event metadata out of changes to one
occurrence, as Stalwart's validate.rs lists them, and pointers at a
participant's calendarAddress
- write overrides on the base event as before (0.16.19 needs the
organizer there) and, when the server refuses series keys inside
them, repeat the write once without them and remember that
- answer one occurrence through its synthetic id without the organizer
- let the editor keep the organizer of an event: an invitation the
user received is no longer rewritten with the user as organizer, and
organizerCalendarAddress is only written for an event that has none
- keep the SetError type on a refused event update
0.16.20 to 0.16.23 dropped these keys on synthetic ids anyway; the
base-event override payload for older servers is unchanged.
Stalwart 1.0 refuses an expanded CalendarEvent/query that spans more
than the account's maxExpandedQueryDuration (P52W1D by default) with
expandDurationTooLarge, and one that expands to more than 3000
occurrences with cannotCalculateOccurrences. The scrolling views load
windows of up to about 460 days, and refresh() reloads the whole
window on every calendar push, after series edits and on pull to
refresh, so the calendar showed a raw error over stale events from
then on. The fallback to expanding on the device only knew 0.16's
invalidArguments.
Expanded range queries are now cut into chunks a day shorter than the
advertised limit, with overlapping borders, sent together as far as
maxCallsInRequest allows, and their ids de-duplicated. A chunk the
server still refuses is halved; if a week still expands to too many
occurrences, the whole range is expanded on the device as before.
The expanded query keeps a filter of the two bounds only: 1.0 refuses
operators there, so filtering several calendars would take a query per
calendar, and the store loads every calendar anyway. 0.16 advertises
the same limit without enforcing it and returns the same events.
Stalwart 1.0 returns every photo, logo and sound in a contact card as a
blobId without a uri, including photos saved as data: URIs. The app read
only `uri`, so sender avatars, the contact list, the detail screen, the
form preview and vCard export showed nothing. Worse, the form rebuilt
`media` from the card on every save and sent it without the photo, or
as `null`, so editing a phone number deleted the photo on the server.
- `ContactMedia` carries `blobId`, `label`, `pref` and `contexts`, and
`uri` is optional. The photo shown is the photo entry with the lowest
`pref`, as a URI (0.16) or a blob (1.0).
- Blob photos load from the download URL with the Authorization
header, like file thumbnails. At most half of the server's
`maxConcurrentRequests` load at once, a failed one is tried once more
and then left alone for two minutes, and one that loaded skips the
queue from then on.
- The form leaves `media` out of the save unless the photo changed. A
changed photo is built on the card as the server has it now, so a
stale blobId can't make 1.0 refuse the update, and a new photo keeps
the key, `pref`, `label` and `contexts` of the one it replaces.
- A new photo is uploaded into the user's own account and sent as a
blobId where the blob capability lists ContactCard (1.0). Otherwise
it goes as a data: URI, and a refusal like 0.16's "blobIds in media
is not supported." retries once with the data: URI and is remembered
for the account.
- vCard export fetches blob photos with batched Blob/get requests and
writes them inline. It also writes photos whose data: URI names no
media type, which it dropped before. Duplicate and moving a card to
another account send the bytes instead of the source's blobIds.
- An update never clears `id`, `uid` or `addressBookIds`, and vCard
export writes MEMBER values that are already URIs as they are instead
of `urn:uuid:urn:uuid:…`.
The form's state and patch building moved to `src/lib/contact-form.ts`
so the save path can be tested.
Stalwart 1.0 counts every EmailSubmission record of an account against
`maxSubmissions` (500 by default), whatever its status or age, and its
own purge of old records never runs. The app never removed a record, so
after about 500 sends from an account every send, quick reply, read
receipt and "Send now" failed with Stalwart's raw "There are too many
email submissions, please delete some" error. This ports the webmail
fix (48382170).
The app now removes the records that can no longer be cancelled.
Destroying a record never stops its delivery, but a pending one could
no longer be cancelled, so a record EmailSubmission/get reports as
pending is never touched:
- an immediate send (no undo delay, no schedule) and a read receipt
destroy their own record right after the send, riding along with the
old-draft cleanup when there is one; only where `urn:stalwart:jmap`
is advertised, and never decided by the created undoStatus, which
0.16.10-0.16.16 report as final for held sends
- a throttled background prune (`src/api/submissions.ts`) destroys
canceled records and final records whose sendAt has passed, going by
EmailSubmission/get, in every submission account; it runs at app
start, on return to the foreground and after sends and undo
- a confirmed cancel (undo, cancel, reschedule, "Send now") destroys
the canceled record in a request of its own
- an `overQuota` on a submission create prunes at once, on Stalwart
walks `EmailSubmission/changes` from "n" for records older than the
query's three-day window, retries once, and otherwise throws
`SubmissionQuotaError`, shown as a translated explanation in the
composer, quick reply, read receipts, the undo bar and the scheduled
list; the copy the failed attempt made is removed as before
0.16 never enforces the limit, and destroy never cancels on any
version, so the same cleanup runs there unchanged.
The undo bar now also says when an undo or "Send now" failed instead
of only logging it. The scheduled list reads every page of
EmailSubmission/query with calculateTotal; it asked for 200 records,
oldest first, so the mail scheduled furthest ahead dropped off once the
three-day window held more than that.
`SubmissionQuotaError` is added to `jmap-result.ts` as a subclass of
`JMAPMethodError`.
Stalwart 1.0 refuses a CalendarEvent/get that asks for
recurrenceOverrides together with utcStart or utcEnd, and every event
read of the app did. The calendar showed a raw error over stale events,
and tasks, reminders, "all events" edits, contact activity, the
invitation banner's UID look-up and iCal subscriptions stopped working.
Subscriptions and imports also lost their UID check, so every sync tried
to create the whole feed again and ended in refusals.
- read a stored event's overrides with a second get in the same
request; server-expanded occurrences get them from their base event
as before
- read only ids, UIDs and calendars for the import and subscription
UID look-ups
- fail a read when either get answers with an error, so the calendar
keeps the events it has and shows the error
- keep the JMAP error type on calendar method errors (JMAPMethodError,
plus a shared jmapErrorType helper in jmap-result)
0.16 accepts the split reads and returns the same events.
Release APKs are obfuscated since R8 was turned on, and nothing kept
the mapping that decodes their stack traces. The workflow now copies
`android/app/build/outputs/mapping/release/mapping.txt` to
`bulwark-mobile-<version>-<commit>-mapping.txt`, uploads it as a
workflow artifact and attaches it to the release next to the APK. The
in-app updater only picks `.apk` assets, so it ignores the new file.
If R8 is on (`android.enableMinifyInReleaseBuilds=true`) and no mapping
was written, the step fails instead of publishing an APK whose crashes
cannot be read. docs/android-release.md explains how to decode a trace
with `retrace`.
The splash background was #09090b in both modes, so in light mode a
cold start went from a near-black splash to the white first frame.
It is now #ffffff by day and #09090b at night (`values-night`), as
`splash.backgroundColor` and `splash.dark.backgroundColor` in
app.config.js already ask for, so a later `expo prebuild` writes the
same colours. Checked on the API 35 emulator: light mode stays white
from splash to login screen, dark mode is unchanged.
Every background push kept the app awake for the full 30 s timeout of
the BulwarkPushTask headless task, however quickly the task was done.
AppRegistry reports a finished task through the native
HeadlessJsTaskSupport module, and React Native 0.81 registers that
module only for the old bridge (CoreModulesPackage); the bridgeless
runtime's CoreReactPackage leaves it out. JS could never say it was
done, so the service, or the job, and its wake lock waited for the
timeout. BulwarkHeadlessJsTaskSupportModule provides the module under
the same name when the app runs bridgeless.
The push path also leaked a wake lock now and then. startHeadlessTask
called HeadlessJsTaskService.acquireWakeLockNow on the FCM worker
thread while the service took the same static lock on the main thread.
The method is not thread-safe, so both could create a lock, and the one
that was overwritten stayed held. The FCM thread now takes its own
wake lock, timed at 10 s, and the service drops it as soon as it holds
React Native's.
Measured on the API 35 emulator with the R8 release build:
- push handled by the job: 30,000 ms before, 75-155 ms now
- push handled by the service: the service ran 31 s before, it is
gone within a second now
- `com.facebook.react.i` wake lock still held 5 s after a push: 2 of
4 pushes before, 0 of 8 now
A push that arrived while the app was in the background started
BulwarkPushTaskService with `startService`. Android 8+ allows that only
while the app is temporarily allowlisted, which a high-priority FCM
message grants. The relay does send FCM data messages at HIGH priority,
but FCM delivers some at normal priority anyway (it downgrades apps
whose high-priority messages rarely end in a notification), and a
UnifiedPush distributor may not raise the app at all. The call then
threw `BackgroundServiceStartNotAllowedException` and the app crashed,
from the FCM thread or, for UnifiedPush, on the main thread.
The service start is kept as the first choice, so a high-priority push
works as before. When Android refuses it, the same headless task now
runs from a JobScheduler job (BulwarkPushJobService): expedited on
Android 12+, which runs at once while the app has expedited-job quota
and as a regular job after that, and a regular job on Android 8-11.
JobScheduler is a platform API, so nothing is added to the build;
WorkManager is not in the dependency tree. A generic "new mail"
notification built natively was the other option, but the relay's
payload cannot tell new mail from other changes and the native side
knows neither the notification settings nor what was already shown.
Checked on the API 35 emulator with the R8 release build, the process
killed and the push delivered past the allowlist:
- FCM data message: before, FATAL EXCEPTION on
Firebase-Messaging-Intent-Handle; now the job is scheduled 18 ms
after the refusal and runs the task
- UnifiedPush message (connector 3.3.5, sent the way a distributor
does): before, FATAL EXCEPTION on main; now the same job path
- allowlisted FCM message: still starts BulwarkPushTaskService
A sweep of src for what the earlier passes missed:
- The message export errors ("Sharing is not available on this
device", "Download failed: 404") were English; they come from the
catalog now.
- Event cards and details glued the participant count to a lowercased
"Participants"; they use the webmail's participant plural.
- Contact activity dates ignored the app language.
- Imported filter rules from an unknown tool showed the parser's
English "External" instead of the translated label.
- The contact field remove button and the vacation editor's
bold/italic/underline/list buttons get screen reader labels.
The files screen filled `files.selected_count` and
`files.delete_items_title` with its own `{name}` substitution, a
leftover from before t() took arguments, so they could not be
plurals. Both are ICU plurals now and every message on the screen gets
its values through t(). The upload progress line is one message
instead of "Uploading..." followed by the numbers.
The bottom tabs showed their route names ("Mail", "Calendar", …) in
every language; they now use the webmail's navigation names, and an
unavailable tab says so with one translated message instead of an
English "(unavailable)" suffix. The first loading screen is
translated as well.
The Unified Inbox selection toolbar, its back and clear-search buttons,
and the drawer's close, expand/collapse and remove-account buttons get
screen reader labels. Its unknown-sender fallback, selection count and
"account(s) could not be loaded" banner are proper translations and
plurals now.
The key harvest and the translation coverage test did not look at
App.tsx, which sits next to src/; they do now.
Reminder notifications are worded when they are scheduled, outside
React, and were always English ("Starts in 15 min", "(No title)",
"Task due"). They now come from the catalog through the locale store,
with ICU plurals for the minutes, hours and days. Each scheduled
reminder records its language, so switching languages rewords the
pending ones, and the Android channel ("Calendar reminders", shown in
the system settings) is named and renamed in the app language.
The reminder labels picked the English form whenever a catalog entry
was an ICU plural, which is all of them; they now pass the count to
t(), and "minutes after" is one plural message instead of a number
glued to a fragment. The import refusal error, the exported alarm
text and the "sharing is not available" error are translated too.
- The custom recurrence editor and its summary were English throughout
(labels, units, weekday and month names, "on the third Thursday");
they now use the webmail's recurrence fragments and the app's date
locale. The interval and occurrence counts are ICU plurals, also in
the event details, which filled "Every {count} days" with
`.replace`.
- The iCal subscription sheet is translated with the webmail's
subscription strings, and its "last updated" time is relative in the
app language. The feed errors it shows come from the catalog too.
- The calendar share sheet asked for `calendar.share.role_*` keys no
catalog has; it now uses the webmail's sharing presets and title.
- Close, remove, refresh, unsubscribe, delete and new-task buttons get
screen reader labels, the event editor's switches are named, color
swatches read out their color, and task checkboxes and calendar rows
report whether they are checked.
The calendar i18n test now covers the sheets.
The week view's "all-day" label and its "Untitled" fallback were
hard-coded; they now use the catalog's "All day" and "(No title)" like
the scrolling time grid. The calendar's menu, tasks, view switch, new
event and previous/next buttons get screen reader labels (the view
buttons also say which one is selected), and a calendar i18n test
covers the calendar views.
The message details panel built its received line, attachment summary
and spam score by gluing strings together; each is now one ICU message
(the attachment count is a proper plural). The SPF/DKIM/DMARC results
use the webmail's translated result names, and an invitation's date is
formatted in the app language instead of always in English.
The quick reply box wrote its own "date, name <email>:" quote header;
it now uses the composer's translated "On {date}, {from} wrote:" line
so both kinds of reply look the same.
The back button, the subject and header star buttons, the sender
avatar and the More/Tag sheet close buttons get screen reader labels,
and the screens i18n test covers the viewer files.
A quick reply held by the undo-send delay could not be undone: the
undo bar lived only on the mail list, and the quick reply never
recorded the held send for it.
- The undo bar is now hosted on every stack screen through the
navigator's `screenLayout`, next to the toasts, and only the focused
screen's renders, so it shows wherever the send started and never
twice. The list's own instance is gone; archive, delete, move and
spam undo use the same bar as before. The composer gets none, since
its Undo reopens the composer.
- The quick reply records a held send through the same store action as
the composer, including the account it went out of, so Undo and
Send now reach a shared account's submission and Undo restores the
reply into that account's Drafts.
- The quick reply dismisses the keyboard after sending, which would
otherwise cover the bar.
Release builds had R8 and resource shrinking off, so every class of
every dependency went into the APK unminified: the published 0.1.62 is
42.8 MB for arm64, 25.85 MB of it dex (PF9 in the audit).
Turn both on through the properties build.gradle already reads
(`android.enableMinifyInReleaseBuilds` and
`android.enableShrinkResourcesInReleaseBuilds`). The consumer rules of
React Native, Expo, Firebase, ML Kit and the other libraries cover what
they load by reflection. The app's own package is kept whole, which
costs a few KB and keeps its frames readable in an obfuscated stack
trace.
Measured on a local x86_64 release build:
- APK: 44.7 MB -> 38.0 MB (download size 23.8 MB -> 17.2 MB)
- dex: 30.4 MB in 4 files -> 11.3 MB in 2 files
- classes: 27,631 -> 16,724
With `-dontobfuscate` the APK would be 38.8 MB (13.5 MB of dex), so
obfuscation stays on. Library frames in a release stack trace are now
obfuscated; retrace them with that build's
`android/app/build/outputs/mapping/release/mapping.txt`.
Checked on the emulator: cold start in light and dark mode, German and
Russian strings, SENDTO and VIEW mailto and SEND share intents, every
sign-in screen, the QR scanner with ML Kit, and an FCM data message
starting the headless push task.
Tick the parity items the fix pass finished (server-side recurrence,
filters and vacation for shared accounts, in-app file preview, the
trusted-senders toggle, UnifiedPush, sender taps in the viewer), and
note on the already ticked ones what this pass changed, such as the tag
view and search reaching shared accounts or contact photos as avatars.
Items that only moved forward say what is left.
Fix hints and "verified" lines that named removed functions
(`setEmailKeywords`, `getEmailDetail`, `fetchEmailsChunked`,
`buildRecurrenceOverridePatch`) or the old whole-map keyword writes now
point at what replaced them.
PARITY_CHECKLIST.md counts done and open items per area: 379 of 415 are
done, 36 open.
Apply the audit's "Stale parity checklist" section to docs/parity:
- ticked items the audit found broken keep their tick and name the
commit that fixed them; the date-locale item is unticked again, since
there is still no `dateLocale` setting
- the items that were done but unticked are ticked with where they
landed
- natural-language event input, print contact and the Sieve editor
gutter move to the N/A lists
- the text about linking, push types, RTL, `useTypography`, the Sieve
"Keep" and `FileNode/get ids:null` now describes main
The audit gains a "Fix pass, 2026-09-24" section after the summary. It
says how the pass was done and tested, and lists what the fix agents
could not settle from the code alone:
- the device and server checks that matter before a release
- the product calls, starting with the release signing key and secrets
that have to exist before the next version bump
- the follow-ups the agents left, one line each
Every audit item a commit on main fixed is ticked with the commit that
fixed it, including the "fix these ten first" list and the stale parity
entries that the fixes resolved. Items fixed only in part keep their box
and say what is left; U8, PF9, B2 and the product calls stay open.
The filter rule reorder, delete and remove-condition/action buttons,
the filter, Sieve and folder editor close buttons and the attachment
keyword remove buttons had no label. The Sieve editor's line count now
uses an ICU plural, and the settings i18n test covers every pane.
The Notifications pane shows a push setup failure as "<phase>: <message>"
with a translated phase but an English message, and the open-link
confirmation and the new-mail notification title were English too.
They now go through the locale store's t() (translate() in the
background task), with the technical detail passed as an argument.
The sign-in screen showed the auth store's raw English error ("Session
expired", "Invalid username or password") as the notice title. It now
runs it through describeLoginError like every other sign-in failure,
which also learns to explain an expired session with the webmail's
login.session_expired copy. The back and close buttons of the sign-in
steps get screen reader labels, and the email placeholders use the
webmail's translated example address.
Group recipient chips, the group size in suggestions, the upload
progress and the upload/send failure fallbacks were English or glued
together from a translated word and a number; they now use ICU
messages. The close, attach, schedule and sender-options buttons, the
recipient and attachment remove buttons and the From override switch
get screen reader labels (audit U8).
The filter sheet, the .eml import result, the selection count, the
folder totals and the offline sync banner were English literals or
built by gluing translated words to numbers. They now use t() with ICU
arguments and plurals; filter chips go through a "{field}: {value}"
message so translators control the order. The invitation trust
warnings used keys that no catalog had, so they are now literal t()
calls and harvested.
For screen readers (audit U8) the hamburger, select-all checkbox,
filter, sort and compose buttons, the selection toolbar, search and
chip clear buttons, and the close buttons of the move, tag, template,
identity, QR and action sheets get translated labels and roles.
TemplateSettings calls the translate function `tr` because `t` is a
template variable there, and both the key harvester and the coverage
test only looked for `t('...')`. Ten of its keys were never added to
locales/rn/en.json, so they could not be translated and the test did
not notice. Both now also match `tr('...')`, and the missing keys are
added.
The share sheet asked for files.share_role_* keys that no catalog has,
so Viewer, Editor and Manager stayed English everywhere; it now uses
the webmail's sharing.preset names and its "Share \"{name}\"" title.
The per-file menu, back and clear-selection buttons get screen reader
labels, and the "sharing is not available" error is translated.
The contact list, detail and edit screens, the group screen, the
import, picker and tag sheets and the contacts drawer were written in
English, including the activity panel ("Unknown", "Upcoming events"),
alert titles and every form label and option. They now go through
t(), reusing the webmail's contacts.* keys where the meaning matches,
with ICU plurals for member, contact and import counts instead of
`contact${n === 1 ? '' : 's'}`. JSContact context, phone feature,
personal-info and gender values on the detail screen are shown with
their translated labels instead of the raw keys.
Icon-only buttons on these screens (back, edit, more, bulk actions,
remove, close, clear search) get screen reader labels, and chips,
checkboxes and collapsible sections report their state.