Compare commits

...
1286 Commits
Author SHA1 Message Date
Linus Rath ec94ec5305 test: tap continue again when the sign-in link alert is still up in the ios flows 2026-10-31 23:46:19 +01:00
Linus Rath f27bbcb345 test: split the ios settings flow in two so each half stays under the flow time limit 2026-10-31 23:46:18 +01:00
Linus Rath 8d2117c39c test: find the selection more button by its full label in the ios move flow 2026-10-31 23:46:18 +01:00
Linus Rath 0b1698d078 test: retype the settings search up to three times when the simulator garbles it 2026-10-31 22:25:53 +01:00
Linus Rath b1d1b5f94d test: open the more menu for star when the tablet selection bar has no room in the ios move flow 2026-10-31 22:25:53 +01:00
Linus Rath 5666b11ecf test: expect the device-neutral wording in the sign-in code error 2026-10-31 20:52:14 +01:00
Linus Rath 76c256dd8e fix: say this device instead of this phone in two sign-in fallbacks 2026-10-31 20:48:55 +01:00
Linus Rath 9caed413d7 Merge branch 'cu-fixsettings' into catch-up-2026-11 2026-10-31 20:47:32 +01:00
Linus Rath 10f0066ed1 fix: name the server when two accounts with one address sync to this device 2026-10-31 20:46:09 +01:00
Linus Rath 3c95bba0d2 fix: give the folder name its own line next to the docked file preview 2026-10-31 20:46:09 +01:00
Linus Rath 34c65765ba fix: call a sharee without a name unknown person in every share sheet 2026-10-31 20:46:09 +01:00
Linus Rath a832f6ca3b fix: say this device instead of this phone, also on a tablet 2026-10-31 20:46:08 +01:00
Linus Rath 2c40bc6709 fix: forget an account's saved sender identities when it is signed out 2026-10-31 20:36:40 +01:00
Linus Rath 5d91243451 Merge branch 'cu-fixmail' into catch-up-2026-11 2026-10-31 20:34:10 +01:00
Linus Rath 2ebe5fc728 fix: open the soft keyboard for the reply body on android 2026-10-31 20:33:11 +01:00
Linus Rath 6a1c40e7eb fix: make the remove cross on a participant chip easier to tap 2026-10-31 20:31:23 +01:00
Linus Rath 81b2a7c485 fix: float the notification prompt in the list column on a tablet, clear of the reader 2026-10-31 20:31:02 +01:00
Linus Rath 3165b40127 fix: toggle a settings switch by tapping anywhere on its row 2026-10-31 20:29:26 +01:00
Linus Rath 1286e6cb82 fix: list the address books after an account switch and mark only the own default 2026-10-31 20:27:02 +01:00
Linus Rath 681e2546f0 fix: lay the global search sheet out from the top 2026-10-31 20:26:34 +01:00
Linus Rath 718fd00c6d fix: hide the pgp/mime control parts of an encrypted message from its attachments 2026-10-31 20:25:33 +01:00
Linus Rath cb0f50198e fix: keep the selection toolbar inside the list column on a tablet 2026-10-31 20:23:05 +01:00
Linus Rath 421fa6592a fix: draw the app text in the chosen reading font in release builds 2026-10-31 20:23:02 +01:00
Linus Rath d20532af91 fix: keep search everything reachable once mail search results load 2026-10-31 20:18:15 +01:00
Linus Rath 67cfe0a97d fix: decrypt and verify signed pgp mail on the device 2026-10-31 20:18:00 +01:00
Linus Rath db1fac4085 fix: read the year on the calendar's month button also in the current year 2026-10-31 20:17:11 +01:00
Linus Rath 93de202b38 fix: let a composer opened offline send from the last known identities 2026-10-31 20:15:59 +01:00
Linus Rath 305b836001 fix: close an untouched all-day event without asking to discard changes 2026-10-31 20:14:38 +01:00
Linus Rath ba67e67339 fix: show a saved reply draft in its conversation without a refresh 2026-10-31 20:12:56 +01:00
Linus Rath 818c45c280 fix: say cancelled when the organizer cancels an event 2026-10-31 20:12:12 +01:00
Linus Rath 3b3a2fc670 fix: show the server where two accounts with one address would read the same 2026-10-31 20:08:58 +01:00
Linus Rath d88d3dfbaf fix: scroll the new tag form above the keyboard in settings 2026-10-31 20:04:47 +01:00
Linus Rath bac51fbc46 fix: tell screen readers which tags the picker has checked 2026-10-31 20:01:07 +01:00
Linus Rath 7c39730616 fix: name the recipient on the composer chip remove button 2026-10-31 19:58:47 +01:00
Linus Rath 62e4806353 fix: save a settings sheet on the first tap while the keyboard is open 2026-10-31 19:57:03 +01:00
Linus Rath b9dcbdea52 test: tap the move menu row by its real label in the e2e move flow 2026-10-31 19:15:52 +01:00
Linus Rath 889e1c02f6 docs: write up the native catch-up with webmail 1.19.1 and stalwart 1.0.1 2026-10-31 19:12:28 +01:00
Linus Rath 835508c0c0 docs: tick the parity rows the catch-up closed 2026-10-31 19:06:32 +01:00
Linus Rath 633574203f feat: translate the key offer and already-sent messages 2026-10-31 18:57:09 +01:00
Linus Rath b188c2dba0 Merge branch 'cu-reviewfix' into catch-up-2026-11 2026-10-31 18:54:03 +01:00
Linus Rath 93c6307027 fix: stop saving pgp keys from messages on their own and offer to save a key only for the sender's address 2026-10-31 18:52:00 +01:00
Linus Rath 2c5d95184e fix: wait for the saved session check instead of loading the account beside it when the network comes back at start 2026-10-31 18:40:49 +01:00
Linus Rath 75213f6a55 fix: send a message queued or retried while the outbox is already sending 2026-10-31 18:32:21 +01:00
Linus Rath ac875ffe1d fix: don't move a queued message to drafts when an earlier attempt already sent it 2026-10-31 18:27:57 +01:00
Linus Rath 1c33f8daef fix: tell the user when a queued message gives up after unconfirmed attempts 2026-10-31 18:24:12 +01:00
Linus Rath e2d4915edc fix: keep a queued message without a drafts folder from passing as sent when its submission failed 2026-10-31 18:24:11 +01:00
Linus Rath b1a0eaf805 fix: turn sender favicons off on upgrade unless they were switched on by choice 2026-10-31 18:15:50 +01:00
Linus Rath 2e90f43181 test: catch the informal "passe an" in german strings 2026-10-31 18:13:53 +01:00
Linus Rath 3c0d7ad505 Merge branch 'cu-i18n' into catch-up-2026-11 2026-10-31 18:11:36 +01:00
Linus Rath a5b40d5a3a feat: translate the remaining app strings into Simplified and Traditional Chinese 2026-10-31 18:10:45 +01:00
Linus Rath 2662ad404e feat: translate the remaining app strings into Russian, Slovak, Thai, Turkish and Ukrainian 2026-10-31 18:10:44 +01:00
Linus Rath d2d6023084 feat: translate the remaining app strings into Norwegian, Dutch, Polish, Portuguese and Romanian 2026-10-31 18:10:43 +01:00
Linus Rath f21f6a126b feat: translate the remaining app strings into Italian, Japanese, Korean, Latvian and Mongolian 2026-10-31 18:10:40 +01:00
Linus Rath 17ed1b2309 feat: translate the remaining app strings into Estonian, Persian, French, Hebrew and Hungarian 2026-10-31 18:10:37 +01:00
Linus Rath cef88b9384 feat: translate the remaining app strings into Arabic, Catalan, Czech, Danish and Spanish 2026-10-31 18:10:35 +01:00
Linus Rath 1194bf284f fix: keep ios push renewal from reporting an extension the server refused 2026-10-31 18:10:20 +01:00
Linus Rath d2d44e0dec fix: stop the app from crashing at launch on android 14 and later 2026-10-31 18:06:42 +01:00
Linus Rath 1aba571ac3 fix: keep a signed or encrypted reply-to name with a comma as one address 2026-10-31 18:05:48 +01:00
Linus Rath 8345be43ef feat: translate the remaining app strings into German 2026-10-31 17:34:49 +01:00
Linus Rath 43bf8da760 test: decrypt pkcs#1 v1.5 in the node stand-in on node releases that refuse it 2026-10-31 17:32:40 +01:00
Linus Rath 9192311df8 fix: drop the composer's second policy import left by the merge 2026-10-31 17:26:57 +01:00
Linus Rath 5ead7a8164 chore: declare the end-to-end encryption for app store export compliance #58 2026-10-31 17:23:19 +01:00
Linus Rath e5027c45aa Merge branch 'cu-pgp' into catch-up-2026-11 2026-10-31 17:20:36 +01:00
Linus Rath 7a06ab03c0 fix: list a signed message's attachments once in the reader #58 2026-10-31 17:19:34 +01:00
Linus Rath be98acdc8b test: send and read pgp and s/mime mail between two accounts on live stalwart #58 2026-10-31 17:13:25 +01:00
Linus Rath b0997c0a13 feat: sign and encrypt mail with pgp or s/mime from the composer options #58 2026-10-31 17:10:01 +01:00
Linus Rath 8aa1d8e03c feat: read pgp and s/mime mail and manage the keys under settings, encryption #58 2026-10-31 17:10:01 +01:00
Linus Rath d9d7a46667 Merge branch 'cu-e2e' into catch-up-2026-11 2026-10-31 16:56:15 +01:00
Linus Rath 69f277e1ce test: move to the webmail password field with safari's next key in the ios sign-in flows 2026-10-31 16:53:40 +01:00
Linus Rath aabfe3f6a7 test: accept the camera pre-prompt wording in the ios scanner flow 2026-10-31 16:52:57 +01:00
Linus Rath da41b3f060 test: scroll sign out clear of the tab bar before tapping it in the ios flows 2026-10-31 16:52:29 +01:00
Linus Rath 6a52715704 test: search settings pages by words that keep their row above the keyboard in the ios settings flow 2026-10-31 16:52:07 +01:00
Linus Rath b6d364f732 test: expect delete to move the folder to the trash without a question in the ios files flow 2026-10-31 16:51:43 +01:00
Linus Rath 1b6384edec test: tap allow on the ios notification prompt once and start the calendar flow's event today 2026-10-31 16:51:23 +01:00
Linus Rath 1769a4aa62 test: customize the default reminders before adding one in the ios calendar flow 2026-10-31 16:50:46 +01:00
Linus Rath 897d4ae32d test: confirm cancel send under the new back-to-drafts message in the ios flow 2026-10-31 16:50:21 +01:00
Linus Rath b75b4faa69 test: expect the upload and attach from files options in the ios compose flow 2026-10-31 16:50:06 +01:00
Linus Rath 8fae4e6398 test: swipe the message row, not its avatar, in the ios swipe flow 2026-10-31 16:49:50 +01:00
Linus Rath 2acce326b7 test: open and select messages by their list row, not the avatar checkbox, in the ios flows 2026-10-31 16:49:40 +01:00
Linus Rath 05bc0f567b fix: give the widget and the notification extension their own copy of the shared keychain code so pod install succeeds 2026-10-31 16:49:09 +01:00
Linus Rath 4c818d64ea chore: provide webcrypto from react-native-quick-crypto instead of webcrypto-liner #58 2026-10-31 16:33:18 +01:00
Linus Rath 22893d327f test: follow the mention target setting in the composer wiring check 2026-10-31 16:30:59 +01:00
Linus Rath 48e427bfa1 Merge branch 'cu-followup3' into catch-up-2026-11 2026-10-31 16:25:05 +01:00
Linus Rath 48a0590a93 fix: describe the client certificate the iOS way on iPhone and iPad 2026-10-31 16:24:09 +01:00
Linus Rath d254d7b5ab fix: block remote content when the webmail allows no third-party traffic and hide address matching without custom From 2026-10-31 16:22:11 +01:00
Linus Rath c66e6209ed fix: keep accessibility presets off settings the organisation locks or limits 2026-10-31 16:13:07 +01:00
Linus Rath bc47c6a1af fix: follow the organisation policy for delegates and recovering deleted mail 2026-10-31 16:10:35 +01:00
Linus Rath ab226b03b4 feat: let @ mentions be turned off and choose where a mentioned contact is added 2026-10-31 16:05:14 +01:00
Linus Rath 051f6bf2d5 chore: add the openpgp and pkijs crypto libraries for pgp and s/mime #58 2026-10-31 16:03:54 +01:00
Linus Rath cfca6b9c24 feat: use the reading fonts for the app's own text on ios 2026-10-31 16:03:54 +01:00
Linus Rath 0ae33f3a07 feat: add a setting to block screenshots and hide the app in recent apps 2026-10-31 16:03:54 +01:00
Linus Rath f9a3b76174 chore: move to the latest expo sdk 54 patch release and refresh the lockfile 2026-10-31 16:02:02 +01:00
Linus Rath b2cd83b188 chore: update zustand, jszip and vitest to their latest patch releases 2026-10-31 16:02:02 +01:00
Linus Rath 38257f81c6 chore: update react navigation to 7.5 and drop its vulnerable url and id helpers 2026-10-31 16:02:01 +01:00
Linus Rath 368c90ce3e fix: stop crafted .eml headers from stalling the parser by moving to postal-mime 4.2 2026-10-31 16:02:01 +01:00
Linus Rath fe3f860e73 feat: add the organisation's signature and disclaimer to new mail and hide templates when they are off 2026-10-31 16:01:16 +01:00
Linus Rath 7e60d32449 fix: let screen readers reach the folder share sheet's loading state 2026-10-31 15:57:10 +01:00
Linus Rath 13dcbdc086 fix: hide every archive action when the organisation turns archiving off 2026-10-31 15:52:37 +01:00
Linus Rath 0865c1d6e7 Merge branch 'cu-tagsx' into catch-up-2026-11 2026-10-31 15:51:50 +01:00
Linus Rath e4295dab22 test: read the search-the-server row from the shared suggestion list 2026-10-31 15:51:50 +01:00
Linus Rath b43472dda4 fix: skip saved accounts without a server when picking an account id 2026-10-31 15:51:50 +01:00
Linus Rath 6b7e752c4b feat: show the sender's BIMI logo in the list, the unified inbox and the reader 2026-10-31 15:50:02 +01:00
Linus Rath f15b7bb3cf feat: search mail, contacts, events and files of every account at once 2026-10-31 15:50:02 +01:00
Linus Rath ff01246c92 Merge branch 'cu-policy2' into catch-up-2026-11 2026-10-31 15:46:19 +01:00
Linus Rath 83ddd13726 feat: show the sender's BIMI logo from the webmail when the message passes DMARC 2026-10-31 15:44:35 +01:00
Linus Rath 609d36b0d3 Merge branch 'cu-compx' into catch-up-2026-11 2026-10-31 15:42:13 +01:00
Linus Rath 44366848d1 test: clean up the live send tests without a subject search 2026-10-31 15:41:17 +01:00
Linus Rath f27e2cebb4 feat: lock or hide the settings the organisation pins or hides 2026-10-31 15:40:05 +01:00
Linus Rath 409ada9428 fix: keep delivery notifications and encrypted delivery when a scheduled send is moved 2026-10-31 15:39:13 +01:00
Linus Rath efb92593b2 feat: take archive out of the swipe and quick action settings when the organisation turns it off 2026-10-31 15:34:06 +01:00
Linus Rath 0ec809eb49 feat: keep mail sent offline in an Outbox and send it once back online 2026-10-31 15:34:02 +01:00
Linus Rath df1e211780 feat: hide settings export and import when the organisation turns them off 2026-10-31 15:31:27 +01:00
Linus Rath 62a6c22f10 Merge branch 'cu-native' into catch-up-2026-11 2026-10-31 15:31:09 +01:00
Linus Rath 1d654bbeaf feat: leave out the settings pages the organisation turns off 2026-10-31 15:30:15 +01:00
Linus Rath fbe5457604 feat: add an iOS inbox widget 2026-10-31 15:29:52 +01:00
Linus Rath 417f1843a2 feat: share a mail folder with other people from its menu 2026-10-31 15:29:35 +01:00
Linus Rath 83981e8b8d feat: hide or lock the external content option as the organisation sets it 2026-10-31 15:26:45 +01:00
Linus Rath 8ba2f27af2 feat: hide calendar tasks in settings when the organisation turns them off 2026-10-31 15:25:37 +01:00
Linus Rath a69bc071d1 feat: hide the themes the organisation turns off and keep a locked theme in place 2026-10-31 15:24:16 +01:00
Linus Rath ba150060cf feat: use TLS client certificates on iOS #3 2026-10-31 15:22:32 +01:00
Linus Rath b7b3dd4c4d feat: show the colour mode as set by the organisation when its policy locks it 2026-10-31 15:22:15 +01:00
Linus Rath 585c70a5db feat: nest tags under other tags and choose which tags the sidebar shows 2026-10-31 15:20:57 +01:00
Linus Rath c704c7e6af fix: keep iOS push alive when the app isn't opened for a week 2026-10-31 15:17:14 +01:00
Linus Rath e1ad5c0945 feat: pick recipients full screen in phone landscape, where suggestions had no room 2026-10-31 15:16:07 +01:00
Linus Rath 2b3e84a5a0 Merge branch 'cu-policy' into catch-up-2026-11 2026-10-31 15:13:58 +01:00
Linus Rath 9d9777738e feat: apply the organisation policy of the webmail an account signed in through 2026-10-31 15:13:02 +01:00
Linus Rath 6a27c59672 feat: play the Involved sound for new mail 2026-10-31 15:10:44 +01:00
Linus Rath 8389db8fcb feat: suggest recipients and contacts when typing @ in the message 2026-10-31 15:08:21 +01:00
Linus Rath 21dd5a2405 feat: draw calendar colours in the colour-blind-safe palette when that setting is on 2026-10-31 15:05:48 +01:00
Linus Rath 6e5980e4e0 Merge branch 'cu-readx' into catch-up-2026-11 2026-10-31 14:59:37 +01:00
Linus Rath 97b71e22f5 feat: offer plain language with simpler English and German wording 2026-10-31 14:58:04 +01:00
Linus Rath 649d3adc0f feat: let the composer set message priority and ask for delivery notifications 2026-10-31 14:57:54 +01:00
Linus Rath ab2e5a60f8 feat: redeem a one-time code from the webmail instead of reading the sign-in from the link 2026-10-31 14:53:57 +01:00
Linus Rath 10fb7ab758 fix: keep a saturday week start after a restart 2026-10-31 14:52:40 +01:00
Linus Rath c6501ec886 feat: draw tag colours in a colour-blind-safe palette when that setting is on 2026-10-31 14:52:39 +01:00
Linus Rath 66b4305e6d Merge branch 'cu-calx' into catch-up-2026-11 2026-10-31 14:51:48 +01:00
Linus Rath 7b249ab9d9 fix: keep a new default-port account apart from a kept host-only one 2026-10-31 14:50:25 +01:00
Linus Rath b4f1082915 feat: play the Involved sound for calendar reminders 2026-10-31 14:49:56 +01:00
Linus Rath 4724c28f53 feat: offer the reading fonts, including OpenDyslexic, for the app and plain-text mail 2026-10-31 14:49:21 +01:00
Linus Rath 77914b0a46 test: follow the drawer's unread label helper and the sheet inset hook 2026-10-31 14:46:40 +01:00
Linus Rath f1297b6be8 feat: show calendar months, days and dates in the Jalali calendar for Persian 2026-10-31 14:45:24 +01:00
Linus Rath 36cc49b706 Merge branch 'cu-iosperf' into catch-up-2026-11 2026-10-31 14:37:35 +01:00
Linus Rath 3704c66637 Merge remote-tracking branch 'origin/ios-perf' into cu-iosperf
# Conflicts:
#	src/api/jmap-client.ts
2026-10-31 14:36:41 +01:00
Linus Rath 23b2bf9732 feat: recolour HTML mail for dark mode instead of inverting it 2026-10-31 14:34:01 +01:00
Linus Rath 172d0f63e8 fix: let TalkBack read the unified inbox row as its name and unread count 2026-10-31 14:31:54 +01:00
Linus Rath ce950430ea fix: keep the new id token when push or the unified inbox refreshes a login 2026-10-31 14:28:59 +01:00
Linus Rath d663a5e282 feat: show when someone invites you to, changes or cancels a calendar event 2026-10-31 14:28:18 +01:00
Linus Rath 268b1105b8 fix: keep the unified views from reading an old account on another port through the live one 2026-10-31 14:27:41 +01:00
Linus Rath 9669e07e95 fix: let a notification's delete skip the unlock when the mail goes to trash #85 2026-10-31 14:25:21 +01:00
Linus Rath b803da5df5 fix: stop showing a delegate's server id under Unknown person 2026-10-31 14:23:19 +01:00
Linus Rath e6a89ad07a feat: show invited participants' free/busy in the event editor 2026-10-31 14:22:59 +01:00
Linus Rath 423e2780d5 fix: name a shared address book or calendar once when its name already has the account 2026-10-31 14:22:16 +01:00
Linus Rath 58c8054339 fix: show how many emails were moved instead of {count} 2026-10-31 14:18:40 +01:00
Linus Rath 350b0e8e74 fix: stop TalkBack reading a button as busy after it finishes loading 2026-10-31 14:14:42 +01:00
Linus Rath 9b0bba7c15 fix: keep folder, calendar and share sheets and compose prompts above the taskbar and keyboard 2026-10-31 14:12:58 +01:00
Linus Rath 5ecf6f3256 Merge branch 'pr-66' into catch-up-2026-11
# Conflicts:
#	locales/rn/en.json
#	src/components/SidebarDrawer.tsx
2026-10-31 14:07:30 +01:00
Linus Rath f606866dd1 Merge branch 'cu-mail' into catch-up-2026-11 2026-10-31 14:04:54 +01:00
Linus Rath 14ec671c4e Merge branch 'cu-accounts' into catch-up-2026-11 2026-10-31 14:04:53 +01:00
Linus Rath 73823292e2 Merge branch 'cu-sheets' into catch-up-2026-11 2026-10-31 14:04:53 +01:00
Linus Rath e69bfc502b fix: lay out the reader's pages at the pane's width so a tablet pane is not left empty 2026-10-31 13:56:46 +01:00
Linus Rath 5a301d62dc feat: sign out of the identity provider too when signing out of a browser sign-in 2026-10-31 13:55:47 +01:00
Linus Rath e0184cfb46 fix: keep the notification prompt clear of the reader's bottom bar on tablets 2026-10-31 13:54:48 +01:00
Linus Rath 56c8b98bb6 fix: show localized system folder names in the filter rule folder picker 2026-10-31 13:54:39 +01:00
Linus Rath ce21f273a3 fix: scale the reader's header and toolbar with the text size setting 2026-10-31 13:52:38 +01:00
Linus Rath f2513de025 fix: say a message deleted elsewhere is gone instead of failing to load it 2026-10-31 13:52:38 +01:00
Linus Rath 9bc7b4a5b8 fix: keep the reader's read state when the conversation loads after marking it read 2026-10-31 13:52:38 +01:00
Linus Rath 461e4da0b2 fix: open the keyboard in the body when replying on Android 2026-10-31 13:52:38 +01:00
Linus Rath 66cdc07d31 fix: let the search filter sheet scroll above the keyboard and clear fully 2026-10-31 13:52:38 +01:00
Linus Rath ff878fc6bf fix: show the sender on search hits from other folders when searching from Sent 2026-10-31 13:52:38 +01:00
Linus Rath ccf93ed667 fix: stop announcing the select all checkbox as mixed with nothing selected 2026-10-31 13:52:38 +01:00
Linus Rath 9f64f8abb3 fix: hide reply and forward on drafts in a conversation 2026-10-31 13:52:38 +01:00
Linus Rath 9763036c70 feat: offer undo after changing tags in the tag sheet and the reader 2026-10-31 13:52:37 +01:00
Linus Rath ecf814b66e fix: make undo after a star take back the star, not the tag set before it 2026-10-31 13:52:37 +01:00
Linus Rath d1c44f52ba fix: count down the seconds left on the undo-send bar 2026-10-31 13:52:37 +01:00
Linus Rath 7eb7289472 fix: stop a settings search for "recover" from offering Reading 2026-10-31 13:52:07 +01:00
Linus Rath b17d4d4069 fix: say in Composing that "Plain text only" also shows received mail as plain text 2026-10-31 13:49:05 +01:00
Linus Rath 47b922cab0 Merge branch 'cu-calcon' into catch-up-2026-11 2026-10-31 13:48:41 +01:00
Linus Rath 2fe8366938 fix: let the notification Delete skip the unlock when the mail goes to Trash #85 2026-10-31 13:47:05 +01:00
Linus Rath 9ce89e2672 fix: say "1 email selected" in the tag picker header 2026-10-31 13:47:05 +01:00
Linus Rath d6b9b342e9 fix: name the tag or person on each row's delete and remove-access button 2026-10-31 13:44:54 +01:00
Linus Rath 72b41eabde fix: look up a typed address in the share sheet and say why the server won't find it 2026-10-31 13:43:58 +01:00
Linus Rath 149e5bcee5 fix: ask to sign in again when a password change ends the session 2026-10-31 13:42:37 +01:00
Linus Rath 236e9117a8 fix: stop showing a share's principal id as its address when the server can't name the person 2026-10-31 13:42:08 +01:00
Linus Rath e7fd665bb4 fix: tell screen readers which undo-send delay, sort order and language is picked 2026-10-31 13:41:43 +01:00
Linus Rath d00605492a fix: keep an account whose saved sign-in stops working and ask to sign in again 2026-10-31 13:39:44 +01:00
Linus Rath 9781b30478 fix: show a scheduled out of office as scheduled, mark its placeholders and stop Save reading as busy 2026-10-31 13:38:46 +01:00
Linus Rath fe6781745f fix: open the app from a widget tap without asking which build to use 2026-10-31 13:37:10 +01:00
Linus Rath bd5c54d654 fix: stop repeating the account in a shared address book's name 2026-10-31 13:33:58 +01:00
Linus Rath 8e0b447d8f fix: name the month button by its month and the participant remove button by the person 2026-10-31 13:32:21 +01:00
Linus Rath e9816809f2 fix: name the filter rule fields by their labels for screen readers 2026-10-31 13:31:10 +01:00
Linus Rath 11fa0365f9 fix: clear the new event slot marker when the editor closes or the tab is left 2026-10-31 13:29:19 +01:00
Linus Rath 65a8ac3469 fix: show the file name in the tablet split view's preview header 2026-10-31 13:28:30 +01:00
Linus Rath 826d5ad2be fix: accept a calendar hex colour with or without # and show it with # after typing 2026-10-31 13:26:36 +01:00
Linus Rath dcca32a780 fix: give the Files recent and starred menus a Cancel and let Back close them 2026-10-31 13:25:53 +01:00
Linus Rath f9a36b2892 fix: open a notification or link for another account over that account's inbox 2026-10-31 13:24:56 +01:00
Linus Rath 8626cd11ef fix: name the calendar description field plainly instead of the desktop help text 2026-10-31 13:24:22 +01:00
Linus Rath e4eda0a840 fix: label the new folder parent picker "Parent folder" instead of "Inside {name}" 2026-10-31 13:22:56 +01:00
Linus Rath 75513969ef fix: keep all-day events all-day on Stalwart 0.16 2026-10-31 13:21:11 +01:00
Linus Rath 688e8bef3b fix: keep two accounts on the same host apart by port 2026-10-31 13:21:01 +01:00
Linus Rath 980243469c fix: keep sheet and dialog fields and buttons above the keyboard 2026-10-31 13:19:56 +01:00
Linus Rath 9ec58be4fb fix: add a typed participant once and show suggestions above the keyboard 2026-10-31 13:17:18 +01:00
Linus Rath 426f740359 fix: keep bottom sheet buttons above the tablet taskbar 2026-10-31 13:15:21 +01:00
Linus Rath db717dcd51 fix: stop the app from crashing when switching to an account left in Trash 2026-10-31 13:08:27 +01:00
Linus Rath 1e21540c6a docs: record the catch-up decisions 2026-10-31 13:06:12 +01:00
Linus Rath 7139390a85 Merge branch 'cu-calendar2' into catch-up-2026-11
# Conflicts:
#	docs/parity/10-webmail-1.10-to-1.18.md
#	docs/parity/11-webmail-1.19.md
2026-10-31 05:58:19 +01:00
Linus Rath 62867f3f3d docs: tick the rows the second calendar pass fixed 2026-10-31 05:57:18 +01:00
Linus Rath 6452b47ca2 test: follow the event editor's save into the offer to save without invitations 2026-10-31 05:56:22 +01:00
Linus Rath 50b91d67d5 test: check address book sharing and the contacts and files account lists on live servers 2026-10-31 05:54:25 +01:00
Linus Rath 7ba00be60e fix: give the contact, event and files fields a name for screen readers 2026-10-31 05:51:52 +01:00
Linus Rath 01578ac2fb feat: show calendar events in light tinted colours on request 2026-10-31 05:48:55 +01:00
Linus Rath a4e5f81f37 Merge branch 'cu-mailbits' into catch-up-2026-11 2026-10-31 05:45:17 +01:00
Linus Rath 90ce7c1b3c feat: size the week all-day area from the days in view and fold it to three rows (bulwarkmail/webmail#1122) 2026-10-31 05:44:28 +01:00
Linus Rath 9fd55d94d3 docs: note the trash link and list row fixes 2026-10-31 05:44:18 +01:00
Linus Rath 7467764e43 feat: lead from the trash to recover deleted items when the archive has some 2026-10-31 05:43:55 +01:00
Linus Rath b35d15cada fix: show own IDN domains as written in the mail list rows (bulwarkmail/webmail#1100) 2026-10-31 05:40:27 +01:00
Linus Rath ecf1f3ecd9 fix: forward out-of-office mail to a unicode domain in its ascii form (bulwarkmail/webmail#1100) 2026-10-31 05:38:43 +01:00
Linus Rath 254d192709 test: check the sharing and recovery panes against the settings search results 2026-10-31 05:37:59 +01:00
Linus Rath 96b163a117 feat: share an address book with other people 2026-10-31 05:36:52 +01:00
Linus Rath bc9680b014 docs: note the card mount and the consent step 2026-10-31 05:36:00 +01:00
Linus Rath cec68f0d91 Merge branch 'cu-followup' into catch-up-2026-11
# Conflicts:
#	docs/parity/11-webmail-1.19.md
2026-10-31 05:34:13 +01:00
Linus Rath beae63e144 docs: tick the rows the follow-up pass fixed 2026-10-31 05:33:10 +01:00
Linus Rath 287b0e58ac refactor: draw the booking and parcel card icons from the generated icon set 2026-10-31 05:31:34 +01:00
Linus Rath 3048525d9b Merge branch 'cu-recovery' into catch-up-2026-11 2026-10-31 05:30:58 +01:00
Linus Rath dae154431f docs: tick the recover deleted and delegate rows 2026-10-31 05:30:09 +01:00
Linus Rath 8a5bb72823 feat: give a delegate access to mail, calendars and contacts in one step 2026-10-31 05:29:50 +01:00
Linus Rath d684b00e9b feat: open a files link on the folder and file it names 2026-10-31 05:29:46 +01:00
Linus Rath 98b962a8d4 fix: name the composer, quick reply, folder and template search fields for screen readers 2026-10-31 05:29:38 +01:00
Linus Rath 6c1cb0f9ef docs: tick the search rows 2026-10-31 05:29:35 +01:00
Linus Rath 12b798c0c8 test: check search highlights and the size filter on stalwart 0.16 and 1.0 2026-10-31 05:29:01 +01:00
Linus Rath cad9296a84 feat: list starred folders and recent files at the top of files 2026-10-31 05:28:20 +01:00
Linus Rath a7a3757378 feat: suggest directory people and search the sent folder for recipients in the composer 2026-10-31 05:27:34 +01:00
Linus Rath 3c3274e4b7 fix: name the search and filter fields for screen readers 2026-10-31 05:27:19 +01:00
Linus Rath aac8a40367 feat: filter search results by message size 2026-10-31 05:26:03 +01:00
Linus Rath e3fc67e771 feat: step to the previous or next file in the files preview 2026-10-31 05:24:31 +01:00
Linus Rath 113e96bdec feat: recover mail deleted for good from the server's archive (bulwarkmail/webmail#1319) 2026-10-31 05:23:28 +01:00
Linus Rath 1342e2a0f8 feat: show where a search matched in each result 2026-10-31 05:22:58 +01:00
Linus Rath 8ac1997149 feat: delete filter rules, tags and trusted senders with undo and warn about the forward limit (bulwarkmail/webmail#1208) 2026-10-31 05:21:32 +01:00
Linus Rath ecefd1cbcc feat: open new-contact and edit-contact links in the contact form 2026-10-31 05:16:34 +01:00
Linus Rath 4932764a66 fix: send to addresses on internationalized domains (bulwarkmail/webmail#1100) 2026-10-31 05:14:55 +01:00
Linus Rath fe45537037 feat: open a calendar link on the view and day it names 2026-10-31 05:13:57 +01:00
Linus Rath 7a15c0c6ac fix: list contacts and files only for shared accounts that support them (bulwarkmail/webmail#1239) 2026-10-31 05:10:32 +01:00
Linus Rath 97173d4af0 fix: lead with the address when two accounts share a name in the sidebar account switcher 2026-10-31 05:10:14 +01:00
Linus Rath bb45198f23 feat: add a switch for the quick reply box under composing settings 2026-10-31 05:09:12 +01:00
Linus Rath 6da985a225 fix: keep sender favicons off and style sheets out of notification previews (bulwarkmail/webmail#1136) 2026-10-31 05:08:11 +01:00
Linus Rath 90e4e230af feat: show the booking, parcel and order cards above the message body 2026-10-31 05:03:28 +01:00
Linus Rath f643df8164 Merge branch 'cu-reader' into catch-up-2026-11
# Conflicts:
#	docs/native-sync-2026-11.md
#	docs/parity/11-webmail-1.19.md
#	locales/rn/en.json
#	src/stores/settings-store.ts
2026-10-31 05:02:08 +01:00
Linus Rath 3769b24b3a test: tap Allow on stalwart 1.0.1's consent page in the ios sign-in flows 2026-10-31 04:59:38 +01:00
Linus Rath 53f95945b1 docs: tick the parity rows the reader pass fixed 2026-10-31 04:59:33 +01:00
Linus Rath 30e0631582 Merge branch 'cu-cards' into catch-up-2026-11 2026-10-31 04:57:58 +01:00
Linus Rath 470f12d232 Merge branch 'cu-platform' into catch-up-2026-11
# Conflicts:
#	locales/rn/en.json
#	src/stores/settings-store.ts
2026-10-31 04:57:53 +01:00
Linus Rath 693c1c8c73 chore: harvest the reader strings 2026-10-31 04:56:57 +01:00
Linus Rath beff87440f fix: name each sort order with its own string so it gets translated 2026-10-31 04:56:54 +01:00
Linus Rath c6ad3fe1d2 chore: harvest the reader strings 2026-10-31 04:56:09 +01:00
Linus Rath d9a759b78e Merge branch 'cu-calendar' into catch-up-2026-11 2026-10-31 04:55:30 +01:00
Linus Rath e5c9433a0e fix: offer every folder as a tree in the search folder picker instead of the first twelve 2026-10-31 04:55:30 +01:00
Linus Rath 446a2eef38 docs: tick the booking cards parity row 2026-10-31 04:55:12 +01:00
Linus Rath 88d19db6ef docs: tick the parity rows the platform pass fixed 2026-10-31 04:54:57 +01:00
Linus Rath 5a5f708d82 docs: tick the parity rows the calendar pass fixed 2026-10-31 04:54:34 +01:00
Linus Rath c90a35c403 chore: harvest the card strings 2026-10-31 04:54:16 +01:00
Linus Rath 982a30effc feat: show booking cards for Apple Wallet passes attached to verified mail 2026-10-31 04:53:55 +01:00
Linus Rath 56e4a1e051 feat: let the quick reply under a conversation be turned off (bulwarkmail/webmail#1370) 2026-10-31 04:53:48 +01:00
Linus Rath 01dd7461f5 fix: keep toasts working where the accessibility bridge is missing 2026-10-31 04:52:44 +01:00
Linus Rath 542fff8872 chore: harvest the calendar strings 2026-10-31 04:52:02 +01:00
Linus Rath c7cb3f60b2 fix: spell the custom calendar colour strings like the rest of the app 2026-10-31 04:52:02 +01:00
Linus Rath fac54b415f fix: show the via badge only when the identity's name adds something to the sender 2026-10-31 04:51:26 +01:00
Linus Rath a0f586f9c2 feat: duplicate a folder in Files with everything in it 2026-10-31 04:51:01 +01:00
Linus Rath ed490b7188 fix: open the folder a mail folder link names instead of only the mail tab 2026-10-31 04:50:08 +01:00
Linus Rath 2055df8c21 feat: add, update and remove bookings from mail in the calendar 2026-10-31 04:49:41 +01:00
Linus Rath b943246373 chore: harvest the platform strings 2026-10-31 04:48:24 +01:00
Linus Rath e0f0db5d67 test: pin the error answer the push lookup fallback relies on (bulwarkmail/webmail#1041) 2026-10-31 04:48:14 +01:00
Linus Rath aa25d08dcc fix: start another folder, tag or search at the top of its list (bulwarkmail/webmail#1138) 2026-10-31 04:48:04 +01:00
Linus Rath 327b2cae1b fix: check push again when the app comes back to settings (bulwarkmail/webmail#1148) 2026-10-31 04:47:14 +01:00
Linus Rath 9067908fef fix: open the inbox on a cold start instead of the folder the app was left in 2026-10-31 04:46:53 +01:00
Linus Rath 18c6b03fc1 feat: filter contacts by company, job title, location, email domain, birthday month and details 2026-10-31 04:45:41 +01:00
Linus Rath 1821fda728 feat: add an inbox-only switch for push notifications (bulwarkmail/webmail#983) 2026-10-31 04:45:07 +01:00
Linus Rath 4470a4ba82 fix: drop the paperclip from messages with only inline images and keep list rows from growing when chips arrive 2026-10-31 04:45:01 +01:00
Linus Rath 219aef046a fix: drop a leading style sheet and padding from message previews (bulwarkmail/webmail#1136) 2026-10-31 04:42:37 +01:00
Linus Rath 65ffef7108 feat: delete a contact category with an undo 2026-10-31 04:41:51 +01:00
Linus Rath 04a962a69e fix: sort merged lists by date sent instead of falling back to the received date (bulwarkmail/webmail#1219) 2026-10-31 04:41:02 +01:00
Linus Rath 43bb5e474a fix: leave a server's own all mailbox out of all mail and unread (bulwarkmail/webmail#1241) 2026-10-31 04:41:02 +01:00
Linus Rath cd677e7787 feat: show a card with times and tracking above booking, shipping and order mail 2026-10-31 04:40:16 +01:00
Linus Rath 6715df7f44 feat: select a message by tapping its avatar in the list #91 2026-10-31 04:39:14 +01:00
Linus Rath 67732348c2 fix: offer filters and auto-reply only for shared accounts that support them (bulwarkmail/webmail#1239) 2026-10-31 04:38:56 +01:00
Linus Rath e988dc2928 feat: suggest contact groups and recent recipients when inviting people to an event 2026-10-31 04:38:20 +01:00
Linus Rath eb6bf6d38e fix: leave trash and spam out of tag views and tag counts, and count tags with Keyword/get where the server can (bulwarkmail/webmail#1156) 2026-10-31 04:37:42 +01:00
Linus Rath af86339bf1 fix: count storage use against the soft limit when the server sets one (bulwarkmail/webmail#1242) 2026-10-31 04:35:41 +01:00
Linus Rath baa3ff5956 feat: copy an event's title and add a note to it from the event details 2026-10-31 04:35:30 +01:00
Linus Rath f5875f87b1 feat: sign in with addresses on internationalized domains (bulwarkmail/webmail#1100) 2026-10-31 04:31:46 +01:00
Linus Rath 574db42652 feat: change the colour of the birthdays calendar 2026-10-31 04:31:14 +01:00
Linus Rath a6066a092c feat: read schema.org booking, parcel and order markup from verified mail 2026-10-31 04:31:01 +01:00
Linus Rath 5f2ef4c1c3 feat: read booking markup from mail HTML without a browser DOM 2026-10-31 04:30:14 +01:00
Linus Rath b6995bb638 fix: move a task into or within a shared calendar (bulwarkmail/webmail#1204) 2026-10-31 04:29:28 +01:00
Linus Rath 37e72c129f fix: explain a refused token exchange on a two-factor sign-in 2026-10-31 04:27:27 +01:00
Linus Rath e3a34eb112 fix: tell screen readers which folder is open and how many are unread in it 2026-10-31 04:25:47 +01:00
Linus Rath 9b5692a8ab feat: offer to save an event without invitations when the server refuses to send them 2026-10-31 04:25:10 +01:00
Linus Rath 7ecac4ab43 fix: ask before the identity editor drops unsaved changes 2026-10-31 04:25:06 +01:00
Linus Rath 1cbe28b18c feat: open the sort orders from the list header's sort button 2026-10-31 04:24:00 +01:00
Linus Rath c8f417dd8f fix: hide preview lines in the density sample when previews are off 2026-10-31 04:23:15 +01:00
Linus Rath d3f6d6aea3 feat: pick any calendar colour with a hex field and more shades #90 2026-10-31 04:22:48 +01:00
Linus Rath 505fbc59ad fix: tell two accounts with the same name apart in settings 2026-10-31 04:21:37 +01:00
Linus Rath da1da0ec6b feat: offer undo after marking read or unread, starring or tagging from the list 2026-10-31 04:21:00 +01:00
Linus Rath 64eb97f4c3 fix: keep a shared calendar's own colour instead of a random one (bulwarkmail/webmail#1414) 2026-10-31 04:19:56 +01:00
Linus Rath 294023e2b2 fix: read month days with their date and event count and events with title, date and time to screen readers 2026-10-31 04:18:17 +01:00
Linus Rath b205c13a6e fix: show the two-factor code prompt as a hint, not a red error (bulwarkmail/webmail#1422) 2026-10-31 04:18:10 +01:00
Linus Rath e09cd395a2 feat: make a new tag from the tag picker and put it on the message right away 2026-10-31 04:16:56 +01:00
Linus Rath 9bd4674c9b chore: harvest the platform strings 2026-10-31 04:15:56 +01:00
Linus Rath c9b3bdfea0 feat: add an accessibility page with presets, larger text sizes and a notification duration 2026-10-31 04:15:55 +01:00
Linus Rath 9a4515f905 fix: let search results from other folders move into the open folder (bulwarkmail/webmail#1452) 2026-10-31 04:13:42 +01:00
Linus Rath 6c6991ace8 fix: keep system autofill out of the event participant field 2026-10-31 04:13:13 +01:00
Linus Rath b95f2c296d feat: delete a filter rule at once with an undo in the filter store 2026-10-31 04:11:36 +01:00
Linus Rath 4849cf233e feat: show typed, coloured file icons on the reader's attachment chips 2026-10-31 04:11:17 +01:00
Linus Rath 73a6adf27c docs: tick the parity rows the compose pass fixed 2026-10-31 04:09:43 +01:00
Linus Rath 6d701c4b06 feat: stop animations and pause animated images in mail when motion is reduced 2026-10-31 04:09:39 +01:00
Linus Rath 813be0c10f test: follow the text part options in the composer wiring check 2026-10-31 04:08:58 +01:00
Linus Rath 2951672548 chore: harvest the compose strings 2026-10-31 04:08:15 +01:00
Linus Rath 357a03c062 docs: correct the composer's note on sending from a shared account 2026-10-31 04:07:49 +01:00
Linus Rath 10ad3a40c7 test: check the composed body and scheduled preset times on both Stalwarts 2026-10-31 04:07:41 +01:00
Linus Rath 6c3046c8dc fix: keep the text typed in the Sieve editor when the script refreshes (bulwarkmail/webmail#1162) 2026-10-31 04:07:12 +01:00
Linus Rath dcbfc907fa feat: warn about the server's forward limit and an auto-reply too long for it (bulwarkmail/webmail#1155, bulwarkmail/webmail#1208) 2026-10-31 04:05:57 +01:00
Linus Rath 43973608d6 fix: show plain-text mail in the dark theme's own colours instead of a fixed black 2026-10-31 04:05:45 +01:00
Linus Rath 25f9b810f2 fix: address the user formally in the german phone strings 2026-10-31 04:05:39 +01:00
Linus Rath 303dea01e9 feat: ask for a description when an image goes into a message 2026-10-31 04:04:35 +01:00
Linus Rath e8bc54ebaa feat: add thai and estonian 2026-10-31 04:03:57 +01:00
Linus Rath 6f66fa065a fix: draw avatar initials in black or white, whichever reads better on the colour 2026-10-31 04:03:46 +01:00
Linus Rath a4d3891140 fix: give user folders in the move sheet a folder icon even when their name looks like a system folder (bulwarkmail/webmail#1235) 2026-10-31 04:01:54 +01:00
Linus Rath d23af55e4b chore: sync the webmail translations 2026-10-31 04:01:44 +01:00
Linus Rath 2c1f266fba feat: open the template manager from the composer's template picker 2026-10-31 04:00:30 +01:00
Linus Rath 7772da8463 fix: leave bounce and receipt report parts out of the list's attachment chips 2026-10-31 04:00:21 +01:00
Linus Rath 352973793f fix: hide reply on drafts and scheduled messages in a conversation and quick reply to the newest real message (bulwarkmail/webmail#1404) 2026-10-31 03:59:16 +01:00
Linus Rath 9fd8099369 feat: fold the quoted history of a reply behind a toggle in the composer (bulwarkmail/webmail#1405) 2026-10-31 03:57:26 +01:00
Linus Rath f6e6f8f570 fix: show a new mail notification when the push lookup fails (bulwarkmail/webmail#1041) 2026-10-31 03:56:49 +01:00
Linus Rath 6e697baa46 fix: keep the recurrence id of a daily series in another zone on the device's DST day 2026-10-31 03:54:52 +01:00
Linus Rath 9794dc03f7 feat: ask before deleting or archiving when the confirm settings are on 2026-10-31 03:54:33 +01:00
Linus Rath a3650ebf34 test: give the mail text size test a valid message spacing 2026-10-31 03:54:33 +01:00
Linus Rath 853cb5d052 docs: note that stalwart 1.0.1 keeps drafts and sent mail unencrypted at rest 2026-10-31 03:53:25 +01:00
Linus Rath 1bef47199c fix: sign in when stalwart 1.0.1 asks for consent instead of reporting a wrong password 2026-10-31 03:53:25 +01:00
Linus Rath 482e9a2014 fix: leave bounce and read receipt reports out of a forward 2026-10-31 03:53:02 +01:00
Linus Rath a43b55ec25 fix: say that a send failed because the device is offline and the message is still there 2026-10-31 03:52:14 +01:00
Linus Rath 7b551fc3e0 feat: scale the mail body with the app text size and the system font size 2026-10-31 03:51:04 +01:00
Linus Rath ae2eb46b29 fix: keep an escaped quote in a pasted display name from splitting off a recipient 2026-10-31 03:49:55 +01:00
Linus Rath d812caf025 fix: read toasts, the undo bar and the offline banner out on ios 2026-10-31 03:49:09 +01:00
Linus Rath 8bb7b60728 fix: open a reply with the caret in the body and keep system autofill out of the address fields 2026-10-31 03:48:53 +01:00
Linus Rath 10e9f81dc1 fix: ask before the event and task editors drop typed changes on close or back 2026-10-31 03:48:23 +01:00
Linus Rath 7dc457780d feat: read mail as plain text when plain text only is on, or per message from the menu 2026-10-31 03:48:14 +01:00
Linus Rath 8fe5798bf5 fix: show scheduled times rounded to the minute and offer this morning, this afternoon and Monday presets (bulwarkmail/webmail#1390) 2026-10-31 03:46:43 +01:00
Linus Rath ff5a8c91d6 fix: give the text fields in settings and sign-in a name for screen readers 2026-10-31 03:45:35 +01:00
Linus Rath a19afe9b48 feat: offer to remove a cancelled meeting from the calendar in the cancellation mail (bulwarkmail/webmail#1425) 2026-10-31 03:44:02 +01:00
Linus Rath 1cc4ccca8e fix: ask about a missing subject once, close an autosaved draft without asking and add Discard 2026-10-31 03:43:26 +01:00
Linus Rath a930d17786 fix: drop direction-override characters from attachment names so a sender can't fake the file type 2026-10-31 03:43:18 +01:00
Linus Rath 1152299583 fix: raise grey text to 4.5:1 contrast in every built-in theme 2026-10-31 03:42:24 +01:00
Linus Rath 0647cbf171 fix: mark quotes, number lists and keep the signature delimiter in the text part of sent mail 2026-10-31 03:40:56 +01:00
Linus Rath 3243714db9 fix: stop a mail full of blockquotes from freezing the reader 2026-10-31 03:40:34 +01:00
Linus Rath 55d008a2e8 fix: let the user cancel a sign-in that hangs 2026-10-31 03:39:39 +01:00
Linus Rath 8e10e73e29 fix: keep sender favicons off by default so sender domains don't go to duckduckgo 2026-10-31 03:38:44 +01:00
Linus Rath 0b0fc021fb fix: show a warning shield instead of the sender photo when the sender check fails (bulwarkmail/webmail#1439) 2026-10-31 03:37:46 +01:00
Linus Rath 60d0e98fc7 fix: keep a hand-picked sender's own signature and swap it after typing under it 2026-10-31 03:35:30 +01:00
Linus Rath a2344ea0ae fix: keep a new calendar's name and colour until the next refresh 2026-10-31 03:34:36 +01:00
Linus Rath db8eb731f1 fix: run the placeholder, attachment and confirm checks before a quick reply goes out 2026-10-31 03:33:16 +01:00
Linus Rath 49d4372918 fix: count a dkim pass the receiver reports by header.i only (bulwarkmail/webmail#1420) 2026-10-31 03:30:38 +01:00
Linus Rath 0e69c86c51 chore: add the confirm before send, delete and archive settings 2026-10-31 03:24:30 +01:00
Linus Rath 948cad814f docs: inventory the native catch-up with webmail 1.19.1 and stalwart 1.0.1 2026-10-31 03:20:58 +01:00
Linus Rath 7a42a15f8a feat: sign in through the bulwark-mobile client on stalwart 1.0 (bulwarkmail/webmail#1459) 2026-10-31 00:57:06 +01:00
Linus Rath 9d547b9d16 fix: decode escaped colons in imported vcard notes 2026-10-29 19:52:28 +01:00
Linus Rath cd6724f754 chore: update version number to 0.1.87 2026-10-29 12:10:41 +01:00
Linus Rath e6e5e521cf docs: report the native sync with webmail 1.18.0 and Stalwart 1.0.0 2026-10-29 05:27:41 +01:00
Linus Rath fe33dfc53f docs: tick the webmail 1.10 to 1.18 parity items this pass fixed 2026-10-29 05:26:33 +01:00
Linus Rath 595df26e40 Merge branch 'fx/compose2' into feat/stalwart-1.0 2026-10-29 05:03:02 +01:00
Linus Rath c817f978a4 fix: reload Scheduled after a switch of accounts and refuse actions on the other account's rows 2026-10-29 05:01:12 +01:00
Linus Rath 51b3208b3b fix: drop a cancelled scheduled send from the list when it can't go back to Drafts, and say it stayed in Sent 2026-10-29 04:59:32 +01:00
Linus Rath 7594e45937 fix: stop the placeholder check from flagging text inside link addresses 2026-10-29 04:55:40 +01:00
Linus Rath 39e82f99e4 fix: save the attachment itself to Files on Stalwart 0.16, under a name the folder has free 2026-10-29 04:54:11 +01:00
Linus Rath 67407c1119 Merge branch 'fx/cal2' into feat/stalwart-1.0 2026-10-29 04:51:16 +01:00
Linus Rath b767df9fad fix: save a calendar's default reminders when another calendar uses the ids of the ones kept 2026-10-29 04:50:15 +01:00
Linus Rath 6133bcd951 fix: trust an invitation that passed dmarc even when a forwarder broke its first signature 2026-10-29 04:50:15 +01:00
Linus Rath 47f2971899 fix: keep a subscription's events when its feed arrives cut off or drops most of them at once 2026-10-29 04:50:15 +01:00
Linus Rath 98dc13fe9c fix: stop a series edit or delete after an account switch instead of finishing it in the next account 2026-10-29 04:50:15 +01:00
Linus Rath c8600e859a fix: attach a quoted inline image under a plain image type, not the part's raw header 2026-10-29 04:49:31 +01:00
Linus Rath fdf8eece62 fix: keep a rescheduled send from going out twice or not at all when the server's answer is lost 2026-10-29 04:47:20 +01:00
Linus Rath 4e3da668f4 fix: keep the auto-reply preview from navigating to a data: page 2026-10-29 04:45:35 +01:00
Linus Rath ddec876e36 Merge branch 'fx/read2' into feat/stalwart-1.0 2026-10-29 04:44:36 +01:00
Linus Rath 8adef1e13f fix: never count a helo spf pass as verifying the sender 2026-10-29 04:41:02 +01:00
Linus Rath 893f40a21d fix: send group mail through the group's identity on Send now and quick reply 2026-10-29 04:40:19 +01:00
Linus Rath 67cdb4e792 fix: leave a message unread when the server refuses to move it to trash across accounts 2026-10-29 04:38:26 +01:00
Linus Rath 918571c3c6 fix: send a mailto unsubscribe from the own account and only to one decoded address 2026-10-29 04:36:56 +01:00
Linus Rath e9a360d750 Merge branch 'fx/contacts2' into feat/stalwart-1.0 2026-10-29 04:36:23 +01:00
Linus Rath d201ca24f8 test: check if/elsif/else scripts and UID re-imports live on Stalwart 0.16 and 1.0 2026-10-29 04:34:56 +01:00
Linus Rath 3c156673d5 fix: ask before a contact import replaces a card with the same UID 2026-10-29 04:34:17 +01:00
Linus Rath ba67205052 fix: stop re-rendering every message row when the shared tags are re-read unchanged 2026-10-29 04:33:54 +01:00
Linus Rath 7b7236f05d fix: keep a rule made from a message out of a login switched to while the sheet was open 2026-10-29 04:33:16 +01:00
Linus Rath af1f8197d9 fix: keep a login switch during a vacation save out of the other login's filters 2026-10-29 04:32:26 +01:00
Linus Rath 2b5257bf6b fix: batch the default search across accounts into shared requests again 2026-10-29 04:32:20 +01:00
Linus Rath 54aa88425a fix: say so when the filters stay off after turning on the auto-reply 2026-10-29 04:31:18 +01:00
Linus Rath fc9c164d3d fix: keep if/elsif/else chains whole when filters are saved on the phone 2026-10-29 04:29:26 +01:00
Linus Rath 440a2e7ac7 fix: scrub only real stylesheets and keep their css escapes when blocking remote content 2026-10-29 04:29:23 +01:00
Linus Rath 47a1d24999 fix: keep a quoted stylesheet from ending the composer's editor script 2026-10-29 04:25:57 +01:00
Linus Rath e7e40c309e test: give the pairing hand-off tests the redirect-safe fetch the sign-in code now goes through 2026-10-29 04:22:20 +01:00
Linus Rath 07679cbf8c fix: keep a hostile inline image type out of the reader and the composer 2026-10-29 04:22:05 +01:00
Linus Rath 79ee9c34ca Merge branch 'fx/compose' into feat/stalwart-1.0 2026-10-29 04:20:07 +01:00
Linus Rath 91175a3382 Merge branch 'fx/cal' into feat/stalwart-1.0 2026-10-29 04:16:46 +01:00
Linus Rath 6414b0d6a9 fix: send from the own account when a group's identities can't be read, and clear a draft save stopped by an account switch 2026-10-29 04:16:08 +01:00
Linus Rath 1ffce863d9 test: check rescheduling, Cancel send, Files blobs and metadata templates live on Stalwart 0.16 and 1.0 2026-10-29 04:14:29 +01:00
Linus Rath 25425cb940 chore: add the event search texts the catalog lacked 2026-10-29 04:13:00 +01:00
Linus Rath b50270d8f3 feat: search events in the calendar 2026-10-29 04:12:48 +01:00
Linus Rath fac318dc75 feat: keep templates in the account on Stalwart 1.0 and offer team templates in the picker 2026-10-29 04:11:28 +01:00
Linus Rath 7dc9ee24db Merge branch 'fx/read' into feat/stalwart-1.0 2026-10-29 04:10:53 +01:00
Linus Rath 64ce86b372 Merge branch 'fx/contacts' into feat/stalwart-1.0 2026-10-29 04:08:31 +01:00
Linus Rath 9b87100a15 feat: set a calendar's default reminders and start new events with them 2026-10-29 04:02:40 +01:00
Linus Rath 1bce37bb56 fix: read only the receiving server's own authentication results when trusting an invitation 2026-10-29 03:56:54 +01:00
Linus Rath 8e07b7d891 feat: attach files from Files and save attachments to Files 2026-10-29 03:51:33 +01:00
Linus Rath 96c7c0455d fix: give user folders a folder icon even when their name looks like a system folder #80 2026-10-29 03:50:08 +01:00
Linus Rath ada20598d4 fix: keep only the refused rows of a delete or move across accounts and never fail a delete over its read flag 2026-10-29 03:48:04 +01:00
Linus Rath 61654d7c40 fix: keep a sign-in code from following a redirect to another host 2026-10-29 03:44:57 +01:00
Linus Rath 01983a3810 fix: keep the event editor's bottom fields above the keyboard #81 2026-10-29 03:44:43 +01:00
Linus Rath 81061c0d0e test: check contacts and filters live on Stalwart 0.16 and 1.0 2026-10-29 03:44:42 +01:00
Linus Rath 7a1031a990 fix: show the location and meeting link text in dark mode #82 2026-10-29 03:44:01 +01:00
Linus Rath 16bbb97ed2 Merge branch 'fx/auth3' into feat/stalwart-1.0 2026-10-29 03:43:39 +01:00
Linus Rath b7b9c7e076 feat: show the shared tags of group mailboxes, read from their metadata on Stalwart 1.0 2026-10-29 03:43:27 +01:00
Linus Rath 639aa4aa42 fix: drop a signed-out account's calendar feeds, and clear recent searches when an eviction removes the last account 2026-10-29 03:42:07 +01:00
Linus Rath d546d50923 feat: create filter rules from a message's sender, domain or mailing list 2026-10-29 03:41:18 +01:00
Linus Rath 31d8e4a4b7 feat: send and save drafts from a group or shared identity in that account 2026-10-29 03:40:42 +01:00
Linus Rath cfd81737e3 fix: drop tokens refreshed for an account that signed out meanwhile 2026-10-29 03:39:15 +01:00
Linus Rath acbd085ab4 fix: end a zero-length event at its start instead of at midnight 2026-10-29 03:38:25 +01:00
Linus Rath 06d68d73ee fix: keep refresh tokens, sign-in codes and OAuth metadata from following a redirect to another host 2026-10-29 03:36:49 +01:00
Linus Rath 0fd14a837a feat: browse the company directory in Contacts #1253 2026-10-29 03:34:44 +01:00
Linus Rath a8e7a3f49d feat: warn before sending a message with unfilled placeholders 2026-10-29 03:33:49 +01:00
Linus Rath fe4f92112d fix: sign in with password and two-factor code when the phone reports the server address without its default port or in punycode 2026-10-29 03:31:06 +01:00
Linus Rath f83284d7e5 fix: fold exported .ics lines by octets and quote attendee names 2026-10-29 03:30:14 +01:00
Linus Rath 484cac58b7 fix: search Sent, Archive and Drafts from the unified All mail view 2026-10-29 03:30:12 +01:00
Linus Rath 7c94039a7e Merge branch 'fx/auth' into feat/stalwart-1.0 2026-10-29 03:28:51 +01:00
Linus Rath a412b5163a feat: leave Spam and Trash out of search unless All folders is picked 2026-10-29 03:28:34 +01:00
Linus Rath e64f8629da Merge branch 'fx/push2' into feat/stalwart-1.0 2026-10-29 03:28:31 +01:00
Linus Rath 07fd03e284 fix: export contacts as vCard 4.0 that other apps read in full #1407 2026-10-29 03:28:10 +01:00
Linus Rath 32f70c103d fix: save contacts with calendar or scheduling links and re-import exported vCards by UID 2026-10-29 03:28:09 +01:00
Linus Rath 59c3278259 fix: leave blank participant names out of saved events 2026-10-29 03:27:44 +01:00
Linus Rath 152f308966 fix: tear down a removed account's push through its own client, not the open account's 2026-10-29 03:27:40 +01:00
Linus Rath 14d009de32 feat: open scheduled messages in a read-only view, and send cancelled ones back to Drafts #84 2026-10-29 03:26:32 +01:00
Linus Rath 04ff8728da Merge branch 'fx/files' into feat/stalwart-1.0 2026-10-29 03:26:03 +01:00
Linus Rath 0fb7c8cd09 fix: show an invitation's time in the user's time zone, with its name 2026-10-29 03:26:00 +01:00
Linus Rath b352e470c1 fix: keep a push for another login's group account out of the login whose own id it carries 2026-10-29 03:25:05 +01:00
Linus Rath 946304ab76 test: check the Files trash fixes live on Stalwart 0.16 and 1.0 2026-10-29 03:24:43 +01:00
Linus Rath 7a95d43f1a fix: trust an invitation only on checks aligned with its sender and stop flagging colleagues' invitations 2026-10-29 03:22:46 +01:00
Linus Rath 58bc4acac5 fix: keep loading older mail when the server sends no total bulwarkmail/webmail#1364 2026-10-29 03:20:49 +01:00
Linus Rath 22f2ac5b29 fix: cancel the original before scheduling its replacement on reschedule and send now, and put it back when that fails 2026-10-29 03:20:32 +01:00
Linus Rath 90e250d376 fix: go back to the previous push relay whenever a move to a new one fails 2026-10-29 03:20:06 +01:00
Linus Rath 3bfdf8cef9 fix: give a restored file with a clashing WebDAV name a free name the server accepts 2026-10-29 03:19:43 +01:00
Linus Rath 4de856923b fix: keep daily recurring events going past a DST gap 2026-10-29 03:18:00 +01:00
Linus Rath 5ea19a8581 fix: restore a trashed item into its folder when that folder is restored with it 2026-10-29 03:17:48 +01:00
Linus Rath b1550f143b feat: offer the code of sign-in and verification mails for copying in the list and the reader 2026-10-29 03:15:09 +01:00
Linus Rath 6f475c0c52 fix: remind of a task relative to its start when the reminder says so #83 2026-10-29 03:14:58 +01:00
Linus Rath 4a56e825e4 fix: work out where trashed items came from once per listing instead of once per row 2026-10-29 03:13:31 +01:00
Linus Rath 9d1337bf71 Merge branch 'fx/push' into feat/stalwart-1.0 2026-10-29 03:12:56 +01:00
Linus Rath d57bdfaace fix: send writes only once after a dropped connection, retry only batches of reads 2026-10-29 03:12:41 +01:00
Linus Rath 01abe1007c test: check the browser sign-in client, refresh after sign-out and the TOTP code for account changes live on Stalwart 0.16 and 1.0 2026-10-29 03:11:40 +01:00
Linus Rath 6b9552fcb4 fix: keep contacts loaded or saved for one account out of the next after a switch 2026-10-29 03:10:37 +01:00
Linus Rath d908f95fd7 fix: refuse .Trash as a name at the top of Files, where it would become the trash 2026-10-29 03:10:35 +01:00
Linus Rath c6c3d66a9b test: check feed refreshes and a task load overtaken by an account switch on the live servers 2026-10-29 03:10:30 +01:00
Linus Rath cc21317b5a feat: warn about unverified senders in the reader and stop offering Always trust for them 2026-10-29 03:09:04 +01:00
Linus Rath e1e6af3cca fix: offer to delete permanently when a Files delete can't go to the trash 2026-10-29 03:07:40 +01:00
Linus Rath df1a5ab4b6 feat: set up vacation forwarding, reply recipients, rule periods and all-messages rules on the phone #1152 2026-10-29 03:07:09 +01:00
Linus Rath 855d304b4f fix: stop a crafted winmail.dat from freezing the app 2026-10-29 03:05:04 +01:00
Linus Rath 3eac700aa2 fix: keep the draft and say so when the server does not confirm a send 2026-10-29 03:04:14 +01:00
Linus Rath 986216f4d6 fix: clear recent searches and calendar feeds when the last account signs out 2026-10-29 03:03:58 +01:00
Linus Rath 8be9743b80 fix: keep the Files trash out of the Recent files widget 2026-10-29 03:03:57 +01:00
Linus Rath c84c9ddde7 test: check that a range read stops at an account switch 2026-10-29 03:03:21 +01:00
Linus Rath 942f45179c fix: send a mailto unsubscribe to one address only and show what it sends 2026-10-29 03:02:33 +01:00
Linus Rath caeec2bdab fix: let Undo of a Files delete do nothing once another account is signed in 2026-10-29 03:00:35 +01:00
Linus Rath 99a4212804 fix: ask for the current code to change the password or turn off two-factor with TOTP on 2026-10-29 03:00:18 +01:00
Linus Rath a3b8b9fa9f test: check id reuse, the expiry read-back, the quota-before-address order and the Delete patch on live Stalwart 2026-10-29 03:00:16 +01:00
Linus Rath c0c3429c75 fix: forget a shared calendar refusal that arrives after a new sign-in 2026-10-29 02:59:53 +01:00
Linus Rath 4e57b19112 fix: keep attachments, sends and reply flags of an open draft out of a login switched to meanwhile 2026-10-29 02:59:42 +01:00
Linus Rath ef9561f16b fix: stop senders from faking a DKIM or DMARC pass in the authentication chips 2026-10-29 02:59:27 +01:00
Linus Rath d92a2ca835 fix: renew push for every signed-in account and show each account's push state in the notification settings 2026-10-29 02:57:24 +01:00
Linus Rath 323fea7451 fix: undo and restore against the drive as it is now, not the listing from before the delete 2026-10-29 02:57:10 +01:00
Linus Rath 41e8f00509 fix: keep a calendar write that outlasts an account switch out of the next account 2026-10-29 02:57:07 +01:00
Linus Rath 2167a9d700 fix: keep webmail filter periods, forwarding and exact address rules through a phone save #1152 2026-10-29 02:57:00 +01:00
Linus Rath e81f00714c fix: undo and send now of a delayed send only reach the login it was sent from 2026-10-29 02:56:19 +01:00
Linus Rath f5e997ba92 fix: drop event handlers that follow a slash and run no inline handlers in the reader and composer 2026-10-29 02:54:23 +01:00
Linus Rath be04fcb179 fix: delete permanently only what is still in the trash, and read the drive again on returning to Files 2026-10-29 02:53:53 +01:00
Linus Rath 9ccb62286e fix: show a plain new-mail notification instead of another login's message when two logins share a JMAP account id 2026-10-29 02:51:24 +01:00
Linus Rath e47c05c58a fix: choose the reply From by an address the original names only in Bcc #87 2026-10-29 02:50:16 +01:00
Linus Rath a2caa4cca5 fix: register the app's own client on Stalwart 1.0 behind another public address and say when the server can't be reached 2026-10-29 02:44:00 +01:00
Linus Rath e993540ae6 fix: load the calendar again after an account switch overtook its first load 2026-10-29 02:43:21 +01:00
Linus Rath 65f00640ba fix: mark a message read when the notification's Delete moves it to Trash and the delete action says so #86 2026-10-29 02:42:00 +01:00
Linus Rath 77327ea856 fix: never patch or judge another client's push subscription that reuses this device's subscription id 2026-10-29 02:40:40 +01:00
Linus Rath 8a71495e1a fix: apply subscribed feed changes to imported events and stop a feed sync when the account changes 2026-10-29 02:40:13 +01:00
Linus Rath 28d928f6e9 fix: stop a refresh after sign-out from writing the removed account's tokens back 2026-10-29 02:39:31 +01:00
Linus Rath ce47c73f33 fix: keep extending push in the background after the app refreshed the subscription at launch 2026-10-29 02:37:52 +01:00
Linus Rath 388f46b5b9 fix: let the composer send and save again in the account it was opened in 2026-10-29 02:36:09 +01:00
Linus Rath d3d28d551e fix: show the relay push still goes through after the mail server refused a new one 2026-10-29 02:35:36 +01:00
Linus Rath 867ef20e51 Merge branch 's10/mail' into feat/stalwart-1.0 2026-10-29 02:34:22 +01:00
Linus Rath 2c05e7bd00 fix: keep the password sign-in from following a redirect to another server 2026-10-29 02:33:41 +01:00
Linus Rath db47162367 fix: register with the previous push relay again when the mail server refuses a new one 2026-10-29 02:33:11 +01:00
Linus Rath 93ea948045 fix: keep the working push registration when a re-register at the subscription limit moves to another relay 2026-10-29 02:31:06 +01:00
Linus Rath 7c91418336 feat: keep deleted files in a Files trash with Restore, Delete permanently and Empty trash 2026-10-29 02:30:36 +01:00
Linus Rath 80962e3079 Merge branch 's10/cal' into feat/stalwart-1.0 2026-10-29 02:27:44 +01:00
Linus Rath 3047d5c495 Merge branch 's10/push' into feat/stalwart-1.0 2026-10-29 02:22:54 +01:00
Linus Rath 8893a9e21e Merge branch 's10/auth' into feat/stalwart-1.0 2026-10-29 02:22:07 +01:00
Linus Rath 111af93a09 docs: say that Stalwart 1.0.0 serves JMAP Metadata in the folder and Sieve property comments 2026-10-29 02:22:05 +01:00
Linus Rath d76f296587 test: check keywords, submissions, contacts, search, Sieve and identities live on Stalwart 0.16 and 1.0 2026-10-29 02:22:05 +01:00
Linus Rath 547d9e5939 fix: drop the deleted messages from the list when the server refuses others of a bulk delete 2026-10-29 02:20:10 +01:00
Linus Rath 9daa3441c8 fix: keep a composer left open across an account switch from saving or deleting drafts in the other account 2026-10-29 02:15:14 +01:00
Linus Rath f733a54777 test: check the calendar live on Stalwart 1.0.0 and 0.16 with series, overrides, invitations and shares #89 2026-10-29 02:13:04 +01:00
Linus Rath b6c0cf1820 docs: say that Stalwart 1.0.0 answers the Metadata properties the calendar list leaves out 2026-10-29 02:12:16 +01:00
Linus Rath 7c495d03ab fix: let files in a shared folder be renamed and deleted on Stalwart 1.0, which sends rights for folders only 2026-10-29 02:11:28 +01:00
Linus Rath e03a38001d test: check push subscriptions live on Stalwart 0.16 and 1.0 2026-10-29 02:11:09 +01:00
Linus Rath 15171637cd fix: keep the own and a group's scheduled mail apart when their ids are the same 2026-10-29 02:10:26 +01:00
Linus Rath 87472ae937 Merge branch 's10/files' into feat/stalwart-1.0 2026-10-29 02:09:48 +01:00
Linus Rath 2f43adbc07 test: check sign-in, token renewal, sign-out and account security live on Stalwart 0.16 and 1.0 2026-10-29 02:09:21 +01:00
Linus Rath 9804b49979 fix: keep push working when the app isn't opened for a week 2026-10-29 02:08:10 +01:00
Linus Rath 1f190c9402 fix: keep one account's tasks and events out of another's calendar after an account switch 2026-10-29 02:07:52 +01:00
Linus Rath c6eb02d4cd fix: say why account security is unavailable when Stalwart 1.0 limits the app's sign-in 2026-10-29 02:07:47 +01:00
Linus Rath 1456e15d5c fix: keep accounts signed in when Stalwart 1.0 rotates the refresh token 2026-10-29 02:07:46 +01:00
Linus Rath 6ffd0473e5 fix: sign in through the server's own sign-in page on Stalwart 1.0 2026-10-29 02:07:46 +01:00
Linus Rath 96ef85159b fix: sign in with password and two-factor code on Stalwart 1.0 2026-10-29 02:07:36 +01:00
Linus Rath ec7e8e0530 docs: check webmail 1.10.0 to 1.18.0 against the app 2026-10-29 02:07:01 +01:00
Linus Rath 4d545f8f58 fix: show inline images whose part name is a path 2026-10-29 02:06:12 +01:00
Linus Rath 4ec2ccac11 fix: say the mail server refuses push relays that are not on a public domain 2026-10-29 02:03:24 +01:00
Linus Rath 18b8aa096f fix: send writes in shared folders to the account that holds them and offer the ones its rights allow 2026-10-29 02:01:47 +01:00
Linus Rath 20be110537 fix: keep the device sync push types when a subscription write fails for another reason 2026-10-29 02:00:50 +01:00
Linus Rath 7c775571c9 fix: load the calendar on Stalwart 0.16 when a range starts or ends in the hour the clocks change 2026-10-29 01:58:24 +01:00
Linus Rath 7d8e073587 fix: list files on Stalwart 1.0 when the account holds more nodes than one get allows #1385 2026-10-29 01:54:55 +01:00
Linus Rath f3e11d4c7c fix: stop asking an account that shares only mail for its calendars on every load on Stalwart 1.0 2026-10-29 01:54:55 +01:00
Linus Rath 913b37661f fix: send the quoted inline images along with replies and forwards 2026-10-29 01:53:59 +01:00
Linus Rath d2bb479ac6 fix: keep inline images of drafts and sent mail on Stalwart 1.0 2026-10-29 01:53:57 +01:00
Linus Rath 174c3fc8fa fix: create folders and upload files on Stalwart 1.0 and tell its symlinks from folders 2026-10-29 01:53:51 +01:00
Linus Rath 24232bf2e9 test: seed the live Stalwart test's own mail so it runs on any account and on 1.0 2026-10-29 01:38:30 +01:00
Linus Rath df4cb7688d fix: offer only the vacation and favicon settings the app has in the settings search 2026-10-29 01:37:46 +01:00
Linus Rath f1db6b488a Merge branch 'main' into feat/stalwart-1.0 2026-10-29 01:30:03 +01:00
Linus Rath f358d60584 fix: place calendar events at their clock time on DST change days 2026-10-25 14:18:45 +01:00
Linus Rath 9857fe1711 chore: update version number to 0.1.86 2026-10-25 12:14:47 +01:00
Linus Rath b4331a9bf9 feat: sign in with an access token 2026-10-25 12:14:30 +01:00
Linus Rath c08e9d6fa8 chore: update version number to 0.1.85 2026-10-23 20:23:48 +02:00
Linus Rath 470e7fc3ba fix: keep the drawers' account header below the status bar and sheet rows above the home indicator on iPhone 2026-10-23 19:51:48 +02:00
Linus Rath b3bd19cc8c ci: roll every android release out to the internal and closed testing tracks on google play 2026-10-23 16:48:22 +02:00
Linus Rath 95cfa809ef chore: update version number to 0.1.84 2026-10-22 23:09:57 +02:00
Linus Rath 3d3f4aec5a feat: log why the iOS notification extension falls back to the placeholder 2026-10-22 22:53:43 +02:00
Linus Rath 8303ff769f feat: let the iOS notification extension reach the active account without push setup 2026-10-22 22:53:42 +02:00
Linus Rath 7078dfe806 ci: sign the iOS notification extension with its own App Store profile 2026-10-22 22:53:42 +02:00
Linus Rath f62849d1fe feat: turn on push notifications on iOS through the relay's APNs transport 2026-10-22 22:53:42 +02:00
Linus Rath 4bb846b726 feat: add an iOS notification extension that shows sender and subject 2026-10-22 22:53:42 +02:00
Linus Rath 2618680b21 feat: keep iOS credentials where a notification extension can read them 2026-10-22 22:53:42 +02:00
Linus Rath 3424bcf2e9 perf: show the cached inbox at start before the server's session arrives 2026-10-22 22:04:33 +02:00
Linus Rath d13769a9f2 perf: load screens that aren't on the way to the inbox on first visit 2026-10-22 22:04:33 +02:00
Linus Rath 0c8b8179b2 perf: load JS modules on first use instead of at startup 2026-10-22 22:04:33 +02:00
Linus Rath 533f176e88 test: time each module's evaluation at start-up in builds made for the performance harness 2026-10-22 22:04:29 +02:00
Linus Rath da72415ef3 test: mark the steps of the start-up restore for the performance harness 2026-10-22 22:04:29 +02:00
Linus Rath 0109273037 fix: start typing in the message body when the empty space under it is tapped 2026-10-22 22:04:29 +02:00
Linus Rath 33eb8f99e5 fix: show the undo button on the undo bar in dark mode 2026-10-22 22:04:29 +02:00
Linus Rath 721c2e34a1 fix: sign out of the last account instead of staying signed in 2026-10-22 22:04:28 +02:00
Linus Rath e06d92d88f fix: open the photo, camera and file pickers from the compose attach sheet on ios 2026-10-22 22:04:28 +02:00
Linus Rath 42e89122bd test: mark where the time goes when a message opens 2026-10-22 22:04:28 +02:00
Linus Rath 33fc33910a test: add test ids to the contact form's name and email fields 2026-10-22 22:04:28 +02:00
Linus Rath dde504f825 fix: let screen readers reach each option in sheets and menus 2026-10-22 22:04:28 +02:00
Linus Rath f5f3637d01 fix: keep full-screen modal headers below the status bar on iPhones with a notch 2026-10-22 22:04:28 +02:00
Linus Rath bdade8503d test: mark how long each persisted cache takes to load 2026-10-22 22:04:28 +02:00
Linus Rath 57ca1c8083 test: log timing marks for the performance harness when launched with -perfMarks 2026-10-22 22:04:28 +02:00
Linus Rath 32a41f42d3 ci: report the slowest modules at ios start-up in the perf leg 2026-10-22 22:04:24 +02:00
Linus Rath ad9db5cbff ci: tap undo at once, search for every settings page and scroll message rows clear of the ios tab bar 2026-10-22 21:57:31 +02:00
Linus Rath 99e4bd9614 ci: let ios runs with different legs on one branch run side by side 2026-10-22 20:19:48 +02:00
Linus Rath 239bf618b5 ci: search for ipad settings pages, tap the attachment chip by name and move to the body with return in the ios flows 2026-10-22 20:12:59 +02:00
Linus Rath 58e52421e9 ci: report the ios start-up timeline and cache load times in the perf leg 2026-10-22 18:16:33 +02:00
Linus Rath 96ffeaa0e2 ci: pick a photo in the ios compose flow, settle settings rows before tapping, answer prompts in portrait and tap undo sooner 2026-10-22 17:11:21 +02:00
Linus Rath 81a0723abd ci: report the first ios launch after install apart from the cold starts after it 2026-10-22 16:19:03 +02:00
Linus Rath 6103df86a5 ci: wait longer for the sign-in link prompt after a sign-out on ios 2026-10-22 14:18:30 +02:00
Linus Rath b97a877fce ci: let the first ios push banner go before tapping the next 2026-10-22 14:04:11 +02:00
Linus Rath 663f6f0007 ci: push and tap the ios banner again when the first tap misses 2026-10-22 13:56:52 +02:00
Linus Rath 5477cc039a ci: wait for the message toolbar instead of reply all when opening a message on ios 2026-10-22 13:51:07 +02:00
Linus Rath ab8e8d7e1e ci: answer the notification prompt, retry server continue and file delete, and accept a starred row in the ios flows 2026-10-22 13:50:35 +02:00
Linus Rath 49e57482cc ci: scroll ios settings pages to their check text and steady the calendar reminder and compose body steps 2026-10-22 13:49:11 +02:00
Linus Rath 5f06c3918c ci: tap the ios push banner right after the push 2026-10-22 13:38:54 +02:00
Linus Rath 96471cf13b ci: scroll to the seeded inbox row when checking the ios sign-in 2026-10-22 13:20:23 +02:00
Linus Rath 3575ed9450 ci: tap the ios notification again until the app opens 2026-10-22 13:04:59 +02:00
Linus Rath f181c73a56 ci: tap the ios push notification in notification center, not the fading banner 2026-10-22 12:56:58 +02:00
Linus Rath c85e32c9f4 ci: test the ios push banner and tap without the service extension under simctl 2026-10-22 11:58:19 +02:00
Linus Rath f2b98c6dba ci: erase and retype web sign-in fields instead of selecting their text 2026-10-22 11:52:00 +02:00
Linus Rath 653d5965ed ci: scroll the ipad settings column to sign out and retry simctl pbcopy 2026-10-22 11:43:34 +02:00
Linus Rath 01d15fd24f ci: retry the ios maestro warm-up once when the driver dies 2026-10-22 11:28:15 +02:00
Linus Rath 35e44318b9 ci: check the ipad message pane and scroll to rows before long-press in the ios flows 2026-10-22 11:27:29 +02:00
Linus Rath 3bdafefb65 ci: close the ios photo picker without losing the composer and focus the body first 2026-10-22 11:26:36 +02:00
Linus Rath 982a23cafd ci: fix the ios settings, calendar and swipe flow steps 2026-10-22 11:22:51 +02:00
Linus Rath c39e977de5 ci: catch the ios push banner and the extension's log 2026-10-22 10:36:14 +02:00
Linus Rath 24948cb717 ci: keep the app's js log and pin the sign-out dialog button 2026-10-22 10:30:39 +02:00
Linus Rath 6b434346ff ci: give the ios test legs more time 2026-10-22 10:17:49 +02:00
Linus Rath 1827f8eb34 ci: run the ios perf flows in one maestro process and report every round 2026-10-22 10:17:17 +02:00
Linus Rath 9c549c9d00 ci: keep the notification logs and settings of the ios push test 2026-10-22 10:16:34 +02:00
Linus Rath f00cd6a259 ci: check and retype web sign-in fields in the ios flows 2026-10-22 09:54:21 +02:00
Linus Rath 62931bcc53 ci: grant notification permission before the ios push test 2026-10-22 09:43:20 +02:00
Linus Rath 5622d732e9 ci: steady the ios flows around transitions, pickers and covered fields 2026-10-22 09:32:11 +02:00
Linus Rath db3c6b5ba1 ci: test ios push notifications on the simulator with simctl push 2026-10-22 09:04:27 +02:00
Linus Rath 680d4745ed ci: run each ios perf measurement as its own flow 2026-10-22 08:29:55 +02:00
Linus Rath 8533bd4f99 ci: fix ios feature flow steps for the drawer, search, selection, calendar and contacts 2026-10-22 08:20:49 +02:00
Linus Rath 52975bd715 ci: test the webmail hand-off with a webmail session left in the browser 2026-10-22 04:45:29 +02:00
Linus Rath bf226c3794 ci: fix ios sign-out, scanner and add-account flow steps 2026-10-22 04:44:23 +02:00
Linus Rath ca09708fd1 ci: match ios drawer rows by their joined label and keep failed-step hierarchies 2026-10-22 04:40:42 +02:00
Linus Rath 96d9d54cc9 ci: type the first character alone in ios flows and answer the webmail hand-over prompt 2026-10-22 03:55:43 +02:00
Linus Rath 02bd714afd ci: add an ios performance leg that compares two simulator builds 2026-10-22 03:11:39 +02:00
Linus Rath e57ee3d446 ci: sign the ios simulator build ad hoc so the keychain has its entitlements 2026-10-22 03:05:51 +02:00
Linus Rath 08e2f47187 ci: sign the ios simulator app ad hoc so the keychain works, and fix flow selectors 2026-10-22 02:54:00 +02:00
Linus Rath de414f7ed6 ci: run each ios flow in its own maestro process and add the feature flows 2026-10-22 02:15:35 +02:00
Linus Rath 273051e5f5 ci: run sign-in flows for every method on iphone and ipad simulators 2026-10-22 01:54:03 +02:00
Linus Rath fa68b2eb53 ci: type a test value in the ios warm-up flow and log what the driver does 2026-10-22 01:28:13 +02:00
Linus Rath 3748a64b10 ci: turn off the ios slide-to-type tip that swallows typed text in simulator tests 2026-10-22 01:15:24 +02:00
Linus Rath 2078388e6f ci: log why the maestro ios driver fails to start and allow reusing a built app 2026-10-22 00:49:38 +02:00
Linus Rath ea9bad7cff ci: run maestro flows on the ios simulator 2026-10-22 00:28:41 +02:00
Linus Rath ae0b76442a test: add stable test ids to sign-in fields, tabs and message rows 2026-10-22 00:28:41 +02:00
Linus Rath 341cdb0e61 feat: build a Google Play bundle alongside the GitHub APK
The app now has two product flavors. `github` is the sideloaded APK as
before, with the same package name and its in-app updater. `play` is a
bundle for Google Play as `org.bulwarkmail.mobile`, without the updater
or the installer permission, since Play forbids apps updating themselves.

- `release-android.yml` builds the Play bundle in a second job, checks
  its signer, package name and permissions, and keeps it as an artifact.
  With `PLAY_SERVICE_ACCOUNT_JSON` set it uploads it to internal testing.
- The Play flavor uses the new Firebase project; the GitHub flavor keeps
  the old one until the app re-registers its token when it changes.
- Widget clicks and the device-sync account type follow the package name.
- Photos come from the system picker without asking for media access,
  and the unused microphone permission from expo-camera is removed.
- A manual workflow run without a tag no longer replaces the APK of the
  latest release.
2026-10-20 12:56:44 +02:00
Linus Rath 1c9bbcc383 chore: update version number to 0.1.83 2026-10-19 13:16:57 +02:00
Linus Rath c7eda4735c feat: trust user-installed CAs and pick the TLS client certificate before the first sign-in #3 2026-10-19 13:16:47 +02:00
Linus Rath 5f5ffdaddb feat: mute notifications for single folders or whole accounts #78 2026-10-19 13:11:12 +02:00
Linus Rath 083ec958a0 chore: sync the locale catalogs from the webmail 2026-10-19 13:03:52 +02:00
Linus Rath 2b486cb1ef fix: show tags with no local definition in the message header, as the list does #79 2026-10-19 13:02:29 +02:00
Linus Rath 86400b124a chore: update version number to 0.1.82 2026-10-16 19:46:04 +02:00
Linus Rath 323797b4ee fix: release a PDF that finishes opening after its preview was closed 2026-10-16 19:42:04 +02:00
Linus Rath 9166b0fb3c docs: note which files the preview shows 2026-10-16 19:40:59 +02:00
Linus Rath 1f253e9488 fix: stop a PDF page rendered after its preview closed from leaving files behind 2026-10-16 19:40:59 +02:00
Linus Rath d22fb8d5d0 feat: open the files inside ZIP archives and attached messages 2026-10-16 19:39:57 +02:00
Linus Rath e0f18713aa chore: update version number to 0.1.81 2026-10-16 19:27:04 +02:00
Linus Rath 56d826c508 test: cover how the PDF preview reports protected and unreadable files 2026-10-16 19:24:19 +02:00
Linus Rath e5492b89c8 feat: show PDFs in the app on Android 2026-10-16 19:23:17 +02:00
Linus Rath b50436b1ba chore: update version number to 0.1.80 2026-10-16 19:12:43 +02:00
Linus Rath 11e74042e2 fix: hand files with a generic type to other apps by the type their name implies 2026-10-16 19:11:50 +02:00
Linus Rath 95580b84ea feat: play video and audio, and show SVG, HTML and Markdown files in the preview 2026-10-16 19:11:24 +02:00
Linus Rath 9a15334bef chore: update version number to 0.1.79 2026-10-16 18:49:43 +02:00
Linus Rath 3aca2d80c0 fix: preview .eml attachments that carry attachments of their own 2026-10-16 18:48:26 +02:00
Linus Rath 7c534301ee refactor: open files from mail, the list, Files and compose through one preview 2026-10-16 18:48:26 +02:00
Linus Rath 14dc5ff2d4 Merge pull request #76 from lucletoffe/feat/calendar-sort-order
feat(calendar): order calendars by sortOrder and move them from the drawer
2026-10-15 23:37:37 +02:00
Linus Rath 14ba28c79c Merge pull request #74 from lucletoffe/fix/compose-visual-viewport
fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
2026-10-15 23:23:40 +02:00
Linus Rath 598adc154d Merge pull request #72 from lucletoffe/fix/draft-part-blobids
fix(drafts): keep a re-opened draft's attachments on live blobs across saves and sends
2026-10-15 23:23:26 +02:00
lucletoffeandClaude Opus 5.5 3c0af52c4e feat(calendar): order calendars by sortOrder and move them from the drawer
Calendars were listed in the order the server returns them (creation
order on Stalwart) and sortOrder was never read. Sort them like the
webmail now does and as JMAP Calendars asks: lower sortOrder first,
equal sortOrder by name (case- and accent-insensitive, numbers by
value), each account's calendars kept together. The store keeps the list
sorted, so the drawer, the event editor and the other pickers agree.

The drawer's long-press panel gains Move up / Move down for the user's
own calendars. A move renumbers that account's calendars 1, 2, 3... and
writes sortOrder only where it changes; the panel stays open so a
calendar can be moved several places.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 22:07:33 +02:00
Linus Rath 7108431dbb chore: update version number to 0.1.78 2026-10-15 16:58:08 +02:00
Linus Rath 744b17908d fix: open a settings page at its top instead of the list's scroll position 2026-10-15 15:36:59 +02:00
Linus Rath c7edae9ed3 fix: move a setting's control under its label when a long label leaves no room 2026-10-15 15:29:13 +02:00
Linus Rath c07b39a070 fix: keep device sync free of expo by registering device locales from i18n #75 2026-10-15 15:19:18 +02:00
Linus Rath 7e5e296271 fix: use the app switch for the sender override in compose 2026-10-15 15:16:32 +02:00
Linus Rath ad6dff79ba fix: draw contact form fields like the webmail and quiet the letter headers in the contact list 2026-10-15 15:16:32 +02:00
Linus Rath a4ca72fb3d fix: draw event and task fields like the webmail and use the app switch in the event form 2026-10-15 15:16:32 +02:00
Linus Rath 107eb807e3 chore: update version number to 0.1.77 2026-10-15 15:15:29 +02:00
Linus Rath e731357b89 fix: format dates in the device region for the app language #75 2026-10-15 15:15:19 +02:00
Linus Rath 9cf1d3e849 fix: draw the text field in dialogs like the other inputs 2026-10-15 15:04:01 +02:00
Linus Rath d3dcc4e830 fix: show folder roles, filter origins and app modes as outline badges 2026-10-15 15:04:01 +02:00
Linus Rath e0d2b190d3 fix: give the Files header the same title size as Mail and Contacts 2026-10-15 15:04:00 +02:00
Linus Rath b048eaca03 fix: tint unread mail and draw the mail search, subject, quick reply and thread buttons like the webmail 2026-10-15 15:04:00 +02:00
Linus Rath 6f515e15f7 feat: rework settings into the picked list, About and Storage & backup pages
The settings list now has the account card on top, title-case group
labels and 56 dp rows that show each pane's current value. About and
"About & Data" were one placeholder page with a letter logo, a fixed
name and a store link that pointed at GitHub releases.

- About: the real logo, the app name from app.config.js, the update
  state, a source link that a build can set with SOURCE_CODE_URL, and
  rows for the server, update checks (Android), open-source licences,
  privacy and a prefilled GitHub issue. iPhone updates link TestFlight.
- Storage & backup: usage card, offline mail, settings export/import
  and reset, and a Troubleshooting page for refresh and debug logging.
- Sender favicons moved to Appearance, like the webmail.
- The S/MIME and Plugins stub pages are gone; both are webmail plugins.

The licence list is generated by `scripts/generate-licences.mjs`.
2026-10-15 14:49:37 +02:00
Linus Rath 0a176ab51c feat: draw buttons, switches, selects, inputs and badges like the webmail 2026-10-15 14:49:37 +02:00
Linus Rath 75299244cf refactor: draw the webmail's Tabler icons instead of Lucide 2026-10-15 14:13:02 +02:00
Linus Rath 6aaa479875 chore: update version number to 0.1.76 2026-10-15 08:32:45 +02:00
Linus Rath 124c0258ad docs: add the final report to the native ui and ux audit 2026-10-15 04:19:41 +02:00
Linus Rath ccffd6b10d docs: tick the latest audit findings and note the deferred ones 2026-10-15 04:09:03 +02:00
Linus Rath 0d96d8d044 fix: fit every selection action and the count in the mail list header on a phone 2026-10-15 04:08:33 +02:00
Linus Rath 95c31631fb fix: show event titles instead of bare start times in a narrow month view 2026-10-15 04:05:55 +02:00
Linus Rath cf7275c9c3 fix: write calendar dates in the order of the app language 2026-10-15 04:05:55 +02:00
Linus Rath e452f72a8a fix: keep the all-day label in the day and week grid on one line 2026-10-15 04:05:49 +02:00
Linus Rath 776447dd41 fix: give sheet close buttons, chips and small text buttons a full-size touch target 2026-10-15 04:05:40 +02:00
Linus Rath 52eb18dc33 fix: show unified inbox rows with the list's text size, density and preview settings 2026-10-15 03:37:58 +02:00
Linus Rath 33dc3cd0dc fix: move through the contact name fields with the return key 2026-10-15 03:37:58 +02:00
Linus Rath 72b4f4abac fix: keep the edits when the recurring event question is cancelled 2026-10-15 03:37:58 +02:00
Linus Rath de1b955bfd docs: tick more fixed findings in the ux audit 2026-10-15 03:30:37 +02:00
Linus Rath c8434b0610 fix: make small buttons easier to hit 2026-10-15 03:30:00 +02:00
Linus Rath e19229a5c6 fix: keep screens clear of the camera cutout and side navigation bar in landscape 2026-10-15 03:29:59 +02:00
Linus Rath d7cee7ca78 fix: label the star button Unstar on a starred message 2026-10-15 03:29:27 +02:00
Linus Rath b59d00f0b7 fix: let the tag sheet scroll 2026-10-15 03:29:26 +02:00
Linus Rath 975ed08c82 fix: keep every thread toolbar button on screen, icon-only in landscape 2026-10-15 03:29:26 +02:00
Linus Rath 76170648f5 fix: open a long conversation at its first unread message 2026-10-15 03:29:25 +02:00
Linus Rath 509583e729 fix: widen the composer's field labels with the font size 2026-10-15 03:21:18 +02:00
Linus Rath 7b93265b4f fix: stop text fields cutting off their text at a larger font size 2026-10-15 03:21:18 +02:00
Linus Rath b5c4d62bde fix: put the mail search in the header row on a phone in landscape 2026-10-15 03:20:48 +02:00
Linus Rath c6cb44db61 fix: apply the text filters once typing pauses instead of querying per keystroke 2026-10-15 03:20:48 +02:00
Linus Rath 0db4bf00aa fix: say when a swipe, bulk action or import fails instead of failing silently 2026-10-15 03:20:48 +02:00
Linus Rath e94b782f8e fix: keep mail lines a readable length on wide screens 2026-10-15 03:18:58 +02:00
Linus Rath ae1ef5ba5e fix: move from username to password to code with the return key when signing in 2026-10-15 03:18:24 +02:00
Linus Rath f891bf3abb fix: announce buttons as buttons with their disabled and busy state 2026-10-15 03:17:08 +02:00
Linus Rath af55e0acef fix: name every format button for screen readers 2026-10-15 03:17:07 +02:00
Linus Rath 1ebc5c66f7 fix: ask about unsaved contact changes on every way out of the form 2026-10-15 03:16:46 +02:00
Linus Rath 0b3710265b fix: keep the address book chips whole in landscape 2026-10-15 03:16:46 +02:00
Linus Rath 56e1593646 fix: move from recipients to subject to body with the return key 2026-10-15 03:00:06 +02:00
Linus Rath e7857ef806 fix: close an untouched reply without asking to save it 2026-10-15 03:00:05 +02:00
Linus Rath b3290c3202 fix: use the webmail's dark theme colours: white primary on neutral greys 2026-10-15 02:59:45 +02:00
Linus Rath f231d1c5c6 docs: tick the fixed findings in the ux audit 2026-10-15 02:54:38 +02:00
Linus Rath 984c359bb4 fix: end the unified inbox and scheduled lists above the gesture bar 2026-10-15 02:53:07 +02:00
Linus Rath 50ab7145c4 fix: keep the compose button clear of the undo bar 2026-10-15 02:53:07 +02:00
Linus Rath 8f68d24bdb fix: make the filter warnings readable on the light theme 2026-10-15 02:51:56 +02:00
Linus Rath f22e851c82 fix: give the vacation subject the full width instead of squeezing its label 2026-10-15 02:51:55 +02:00
Linus Rath d4982f6932 fix: ask before removing someone's access to a shared file 2026-10-15 02:51:55 +02:00
Linus Rath 275d674577 fix: ask before deleting a tag 2026-10-15 02:51:54 +02:00
Linus Rath 0460da51e5 fix: ask before deleting an event, a task or a calendar feed 2026-10-15 02:51:54 +02:00
Linus Rath 90170ea0e7 fix: show one signature separator when the signature brings its own 2026-10-15 02:36:11 +02:00
Linus Rath cbc41c963b fix: fit the files header under the status bar and clear of landscape insets 2026-10-15 02:36:00 +02:00
Linus Rath 63eefd7700 fix: show file dates in the app's language and time format 2026-10-15 02:36:00 +02:00
Linus Rath d97571cf0f fix: open folder settings from the drawer's folders icon 2026-10-15 02:35:31 +02:00
Linus Rath 8403843e76 fix: ask before signing out 2026-10-15 02:35:30 +02:00
Linus Rath 756ceaa436 fix: leave contact selection and search with back before leaving the tab 2026-10-15 02:35:21 +02:00
Linus Rath 99de74de93 fix: show the logo in the mail header on the light theme 2026-10-15 02:35:21 +02:00
Linus Rath 46d30169b2 fix: clear the mail search with back instead of leaving the app 2026-10-15 02:35:21 +02:00
Linus Rath 512a28c494 fix: show the app in the chosen language right after a cold start 2026-10-15 02:35:11 +02:00
Linus Rath 29c172f2af fix: use the theme's tag colours in the tag sheet 2026-10-15 02:35:11 +02:00
Linus Rath 9e463d3a66 fix: show the whole section labels on the contact page 2026-10-15 02:35:10 +02:00
Linus Rath ab712f11ba fix: say why an event couldn't be saved instead of failing silently 2026-10-15 02:35:10 +02:00
Linus Rath 053fde8d56 fix: keep the text typed in quick reply when opening the full composer 2026-10-15 02:35:09 +02:00
Linus Rath 250c0a9c26 feat: centre the sign-in steps on tablets 2026-10-15 02:35:09 +02:00
Linus Rath d62805f805 docs: tick the rotation and tablet layout findings 2026-10-15 02:11:08 +02:00
Linus Rath b9e6ba417d fix: keep the compose button and list ends above the tablet taskbar 2026-10-15 02:11:08 +02:00
Linus Rath 0087e4c8ff feat: open the composer as a centred panel on tablets 2026-10-15 02:08:18 +02:00
Linus Rath 2470e99dc8 feat: fit the files grid to the width and preview files beside the list on tablets 2026-10-15 02:07:49 +02:00
Linus Rath 8ab82cdeb7 feat: show settings sections and the open page side by side on tablets 2026-10-15 02:07:21 +02:00
Linus Rath 64a62b6306 feat: show contacts as list and details side by side on tablets 2026-10-15 02:06:50 +02:00
Linus Rath f4ff07da7a feat: centre sheets and float toasts bottom right on tablets 2026-10-15 02:06:19 +02:00
Linus Rath 2288d28e46 feat: show five or seven days in the multi-day calendar view on wider screens 2026-10-15 02:05:31 +02:00
Linus Rath 295e292e47 fix: stop the contact and group screens crashing after a delete 2026-10-15 01:49:49 +02:00
Linus Rath 802474d3f2 feat: show mail as folders, list and message side by side on tablets 2026-10-15 01:45:57 +02:00
Linus Rath bfa02f1177 feat: let the app rotate and work in landscape
The app was locked to portrait in app.config.js and the hand-kept
AndroidManifest.xml. Android 16 ignores that lock on screens 600 dp and
wider, so tablets and foldables rotated into layouts that were never
tested. This removes the lock in both places, keeps React state across
rotation and split-screen resizes (`smallestScreenSize` joins
`configChanges`), and fixes what broke in landscape:

- the thread pager sizes its pages from its own width and snaps back to
  the open message after a rotation instead of showing half a neighbour
- compose and the thread view share one keyboard tracker; a rotation
  with the keyboard open closes it, since Android sends no new keyboard
  frame and the old portrait one blanked the whole landscape composer
- in a short window with the keyboard up the composer hides the format
  bar and tightens its header so a few lines of text stay visible
- inputs with suggestions or context around them opt out of Android's
  full-screen landscape keyboard
- month views fit whole rows to the real height, keep the month across a
  remount, and the month dropdown re-measures and caps its height
- the week grid's header follows the remounted list after a rotation
- the event sheet's actions stay on screen in landscape
- every RN Modal allows all orientations, or iOS forces portrait
2026-10-15 01:45:23 +02:00
Linus Rath 4b29cdfd3c feat: add window size classes for tablet and landscape layouts 2026-10-15 01:44:45 +02:00
Linus Rath 2a47913d70 docs: add native ui and ux audit 2026-10-15 01:19:00 +02:00
lucletoffeandClaude Opus 5.5 8ce53816af fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
Android's WebView shrinks the editor page's visual viewport to the part of
the view that is on screen and pans it to reveal the caret while typing.
The editor is an auto-height WebView inside the composer's ScrollView, so
nothing ever resets that pan: scrolled back to the top, the body is still
drawn shifted up by the pan offset and its first lines sit hidden under the
header fields until a scroll to the very bottom clamps it away.

The page now undoes every pan as it happens (scrolling the page top into
view, since window.scrollTo only moves the layout viewport) and reports the
offset as a `viewportShift` message; the composer scrolls its ScrollView by
the same amount so the caret stays visible. Android only; iOS and the other
RichTextEditor hosts keep their behaviour (hosts without the callback still
get the reset).

Reproduced and verified on a Pixel 4 (Android 13, WebView 153): before,
visualViewport.offsetTop stayed at ~18 px at the top of the composer and
cut the first line; after, it stays 0 and the caret still follows typing
above the keyboard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:49:57 +02:00
lucletoffeandClaude Opus 5.5 767d4b9f9f fix: keep a re-opened draft's attachments on live blobs across saves and sends
A draft's attachments and inline images are MIME parts of that draft, and
their blobIds die with the version that holds them. Each save creates the
next version and then destroys the previous one (#849), but the composer kept
sending the blobIds it was seeded with. The first save worked, every later
save failed with "blobId ... does not exist on this server", and so did the
send. This is the second half of webmail #849, which the webmail fixed in
5d2b937a but which was never ported here.

- saveDraftVersion() reads the new version's attachment parts back in the
  same request (Email/get on the creation id "#draft"; Stalwart 0.16 does not
  resolve a result reference into Email/set "created"). createDraft() keeps
  its signature on top of it.
- The composer maps each held blobId to the matching part of the latest
  version (inline by Content-ID, the rest by name and size) and sends that.
  Uploads are re-pointed too, since an unreferenced upload expires. The map
  lives in a ref, so the dirty snapshot does not change on every save.
- Opening a draft with inline images no longer looks like an edit. The
  hydration swapped the editor body for its normalised DOM HTML but compared
  it with the raw server HTML, so the autosave replaced the draft about two
  seconds after it opened. That destroyed the opened version, and a plain
  "open, then Send" failed with the same blobNotFound.

Tests: unit tests for the matching and rebasing, a fake-server test that
reproduces the failure without the rebase, and an opt-in live Stalwart test
(verified against Stalwart 0.16.22).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:47:21 +02:00
Linus Rath e19885dc72 chore: update version number to 0.1.75 2026-10-13 18:52:35 +02:00
Linus Rath 4816f0e08b chore: update version number to 0.1.74 2026-10-13 18:07:46 +02:00
Linus Rath ef1d4bc387 fix: stop the app crashing when a notification opens a mail 2026-10-13 18:07:46 +02:00
Linus Rath 61690603a2 chore: update version number to 0.1.73 2026-10-10 23:54:40 +02:00
Linus Rath 765ff26aa5 Merge remote-tracking branch 'origin/main' 2026-10-10 23:20:25 +02:00
Linus Rath b832ac0c49 feat(calendar): tap an empty time slot to start a new event
In the day, 3 days and week grids an event could only be started with a
long press, which few people find. Tapping an empty slot now marks it
with a "New event" block, and tapping that block opens the editor at
that hour; a second tap elsewhere moves the block. Long press still
opens the editor straight away. Tapping a day's header in the week or
3 days grid opens that day.

The editor also started events from a slot half an hour late, because
it rounded a time already on the hour up to the next half hour.
2026-10-10 22:59:35 +02:00
Linus Rath f53cee7ec9 feat(calendar): lay out the agenda as a schedule with dates on the left
The agenda showed each day as a grey header bar above its events. It now
reads like a schedule: the weekday and date sit in a column on the left
of the day's first event (today in a filled circle), a heading marks
where each month starts and a quiet line names each week's range. An
empty today says "Nothing planned. Tap to create." and tapping it opens
a new event. In the drawer the view is now called Schedule.

Events that fill a whole day are listed as all day instead of
"00:00 – 00:00".
2026-10-10 22:53:40 +02:00
Linus Rath 21d469f7e8 feat(calendar): show the month as a full-screen grid of events
The month view used to be a small grid of dots with the selected day's
events listed underneath, so you only ever saw one day's events at a
time. It now fills the screen: each day is a cell with its events as
solid bars in the calendar colour and "+N" for the ones that don't fit.
Tapping a day opens it in the day view, and long-pressing still creates
an event there. Events that fill the whole day no longer show 00:00.

The dotted month stays for the month that drops down under the title.
2026-10-10 22:47:13 +02:00
Linus Rath c1e0cb36c9 feat(calendar): move views into the drawer and add a create button
The calendar header was crowded: four icon toggles, a separate row of
arrows and a Today button, and a small create button squeezed in next
to them. Navigation now follows Google Calendar's layout while keeping
the app's own look:

- the header shows the month; tapping it drops down a month you can
  swipe through and pick a day from
- a calendar icon with today's date jumps to today
- Schedule, Day, 3 days, Week and Month are listed at the top of the
  drawer, styled like the mail drawer's folders, with Refresh below
- a round create button sits in the corner like the mail compose
  button; with tasks on it offers Event or Task
- a new 3 days view shows three columns of the time grid

The arrows stay in the header when free scrolling is off, since the
views can't be swiped then.
2026-10-10 22:41:46 +02:00
Linus Rath d8879617ed fix: never destroy mail from the notification Delete action #68
With no Trash mailbox, or when Mailbox/get came back as an error, the
Delete action fell through to destroying the email. A shared account
without its own Trash lost mail permanently on one tap.

- only move to Trash; do nothing when there is no Trash or the lookup
  failed
- check that Email/set actually updated the email
- show "(No Subject)" in the collapsed text instead of a blank line
- require unlock for Delete (Android 12+)
2026-10-10 19:20:53 +02:00
Linus Rath 7b62755aee Merge pull request #68 from arnauda-gh/feat/notification-preview-and-actions
feat(notifications): add rich preview snippet and quick actions (mark read, delete, reply)
2026-10-10 19:20:46 +02:00
Linus Rath 96e1cc1c3f fix: open a list mail without mounting every pane before it #67
The tap fix makes a notification open a single page, so the viewer no
longer needs to render up to the opened index. That setting mounted
about N mail panes on every list open, on iOS too. Also drop the
onScrollToIndexFailed handler, which FlatList never calls when
getItemLayout is set.
2026-10-10 19:20:46 +02:00
Linus Rath ec137e931b Merge pull request #67 from arnauda-gh/fix/notification-open-target-email
fix(notifications): ensure notification tap always opens target email
2026-10-10 19:20:40 +02:00
Linus Rath cf78b07939 Merge pull request #69 from arnauda-gh/fix/quick-reply-keyboard-avoidance
fix(viewer): adapt screen and auto-scroll quick reply box when keyboard appears
2026-10-10 19:20:39 +02:00
arnauda-gh 5237d9004d fix(viewer): adapt screen and auto-scroll quick reply box when keyboard appears 2026-10-09 15:00:51 +02:00
arnauda-gh be15af5d4c fix(notifications): execute background actions directly via HeadlessJs without service 2026-10-09 12:41:53 +02:00
arnauda-gh 90fe43882a feat(notifications): add email preview snippet and quick actions (mark read, delete, reply) 2026-10-09 09:16:53 +02:00
arnauda-gh 465eeffd1e fix(viewer): use info.index * windowWidth for onScrollToIndexFailed offset 2026-10-09 09:05:27 +02:00
arnauda-gh 200404483c fix(notifications): ensure notification tap always opens target email 2026-10-09 09:01:28 +02:00
lucletoffeandClaude Opus 5.5 45b4feeb58 feat(unified): let a single account turn on All mail / All unread / All starred
The drawer hid the whole Unified section unless a second account (with
cross-account on) or a merged group inbox fed it (#843), so a single
account never reached the cross-folder views - although they span the
inbox and every custom folder and repeat no single folder. Sieve-sorted
mail could only be read folder by folder.

Port the webmail's per-view toggles (enableCrossUnreadView,
enableCrossStarredView, enableCrossAllView; off by default) to Settings >
Layout. A turned-on view shows in the Unified section even when the
section would otherwise stay hidden; "All inboxes" and the per-role rows
keep the #843 rule. Alongside the section all three still show, as
before.

The toggles are labelled with the drawer rows' names: their
`label_mobile` in each RN overlay is that language's
`sidebar.unified_all_*` from the webmail catalog, so settings search
lands on the row. Descriptions are the webmail's own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 16:40:35 +02:00
Linus Rath 28f6787279 fix: confirm a copied event location on iOS too #59 2026-10-06 23:32:04 +02:00
Linus Rath 89f0524ad4 fix: only offer meeting links the app can open #59 2026-10-06 23:30:57 +02:00
Linus Rath 172110f7b0 Merge pull request #59 from lucletoffe/fix/calendar-location-links
fix(calendar): join the meeting and open the location from the event sheet
2026-10-06 23:30:07 +02:00
Linus Rath aa612e6f0e Merge pull request #55 from michaelbrusegard/fix/external-idp-handoff
fix(auth): verify external identity providers during handoff
2026-10-06 23:29:29 +02:00
Linus Rath 76180b332d ci: put every iOS release on the public TestFlight link
Uploads only reached internal testers. The public link's "external" group
still served 0.1.66, because nothing added later builds to it or
submitted them for Beta App Review.

A new `public-testflight` job runs after the upload on a Linux runner.
It waits for App Store Connect to process the build, adds it to the
external group, sets "What to Test" to the GitHub release and submits
it for review (`scripts/testflight-public.mjs`, safe to re-run). The
README links the TestFlight beta and the latest APK.
2026-10-02 16:33:07 +02:00
Linus Rath f5c491f6d7 chore: update version number to 0.1.72 2026-10-02 15:02:12 +02:00
Linus Rath 23af324d79 fix: verify app updates without a second slow progress bar
Since releases ship an `.apk.sha256` file, every in-app update checks
the downloaded APK against it. The check read the 40 MB file in base64
chunks and hashed it in JS, which took longer than the download, and it
reset the progress bar to 0 under the "Downloading" label, so the update
looked like it downloaded twice, the second time slowly.

The file is now read and hashed natively through expo-crypto (the JS
hasher remains as a fallback), and the bar stays full under a
"Verifying…" label while the check runs.
2026-10-02 15:02:10 +02:00
Linus Rath 761c35918f chore: update version number to 0.1.71 2026-10-02 14:09:36 +02:00
Linus Rath 86e33b9afe fix: sync trusted senders to the address book by default
The "Sync with address book" toggle under Content & Senders was off until
the user turned it on, so senders trusted on the phone stayed in the
local allow-list and never reached the "Trusted Senders" address book the
other devices read. A setting the user never chose now counts as on;
only an explicit opt-out, or a server without contacts, turns it off.
2026-10-02 14:09:34 +02:00
Linus Rath 2a1265a9ac chore: update version number to 0.1.70 2026-10-02 13:50:49 +02:00
Linus Rath aba5c70b3e docs: record the widget checks after the fixes on Stalwart 0.16 and 1.0 #52
Adds the phase 2 and 3 results to the verification checklist: the
corrections to phase 1 (two link failures were the test rig), every
action and link re-run on both servers, which commit fixes which cause,
the layout review and what is still open.
2026-10-02 13:45:57 +02:00
Linus Rath ccd8030af0 fix: round the widget corners less #52
Widgets had 22dp corners, rounder than the launcher's own widgets and
the app's cards. They now use 16dp, the Pixel launcher's widget radius,
on the surface, its frame and the picker previews.
2026-10-02 13:35:47 +02:00
Linus Rath ad10d4a8aa feat: show contact photos and sender logos in the widget avatars #52
The widgets drew every sender as initials, while the app and the
webmail show the contact's photo or the sender's logo first. The
widget avatars now follow the same order: an embedded contact photo,
then the logo of a non-personal sender domain when sender favicons are
on, then the initials.

A widget draws from the stored snapshot and cannot load anything while
it draws, so the pictures are fetched when the snapshot is built and
kept in it as small data URIs. Logos have a cache of their own (a week
for a logo, a day for a domain without one) that survives being
offline, and pictures over 40 KB or in a format Android cannot decode
stay out.
2026-10-02 13:34:30 +02:00
Linus Rath 9de8c56743 fix: work out avatar initials the way the webmail does, everywhere in the app
The app had six ways to turn a name into avatar initials, and none of
them was the webmail's. The webmail's Avatar skips punctuation and
symbols in front of each name part ("Sofia" Russo is SR, not "S), takes
two letters of a single name and the first letter of the address
without one. Senders, accounts (sidebar and sign-in), contacts, compose
suggestions and the home-screen widgets all use that now.
2026-10-02 13:18:29 +02:00
Linus Rath 5fcf9367a6 fix: show the widgets' current layouts in the widget picker #52
The picker previews still showed the layouts from before the fixes in
this series (Month's arrows, the Vacation switch, the old task rows and
triage header). Redrawn from the sample data at each widget's default
size, light theme, with the rounded corners and frame the launcher
draws.
2026-10-02 13:11:29 +02:00
Linus Rath bba0274a40 feat: translate the home-screen widgets into German #52 2026-10-02 13:05:14 +02:00
Linus Rath d179e1de82 fix: keep "Tap to try again" visible on a widget notice about a long task #52 2026-10-02 13:05:07 +02:00
Linus Rath 63f2e07254 feat: show more of the message on taller triage and latest-message widgets #52
The triage card kept its preview to two lines however tall it was
resized, leaving most of a large card empty, and Latest message showed
no preview at all. Both now use the room they have for the start of
the message.
2026-10-02 13:04:38 +02:00
Linus Rath 80b57161ca fix: say what an empty folder list or a finished task is on the widgets #52
Folder counts showed only its "Folders" heading when there was no
folder to list, which looks broken; it now says there are no folders to
show. A finished task past its due date still read "Overdue" on the
Tasks widget; it shows its date instead.
2026-10-02 13:04:01 +02:00
Linus Rath ee20f9a06d fix: drop widget controls that look like they do something they do not #52
The Month widget drew back and forward arrows, and the Vacation widget
an on/off switch. A widget cannot page through months or switch the
vacation reply, so both only opened the app, which the rest of the
widget already does. The arrows are gone; the switch becomes a chevron,
and the Active/Inactive pill under the title still says which state
the reply is in.
2026-10-02 13:03:19 +02:00
Linus Rath 60b92e6ef4 fix: give widget buttons a 48dp tap area #52
Most widget buttons were 32dp tall (Archive, Delete, Reply and Next on
the triage card, Accept, Maybe and Decline, Send wishes, Mail my free
times), Up next's were 36dp and the round compose/new buttons in the
headers 28-32dp, all under the 48dp a finger needs, so taps landed
beside them and did nothing.

Button and Fab take a transparent tap area around what they draw
(`slop`), and the layouts give up the same amount of padding or gap
next to them, so the widgets look the same but the buttons take a 48dp
tap.
2026-10-02 13:01:57 +02:00
Linus Rath c520e0a125 fix: keep widget text and counts inside small and translated widgets #52
Checked every widget at its smallest size with long names, big counts
and German labels. What spilled over:

- the Inbox header at two cells: the count pushed the compose button
  off the edge (search now goes at that width, the title truncates
  first, and only the unread count stays);
- a long sender pushed the triage card's time and position out;
- German button labels were cut ("Archivie", "Lösc"); the triage
  buttons now drop to icons, Archive and Delete first, when the labels
  do not fit;
- five-digit counts overflowed Unread count, Tag and Mail and next, and
  "/ 23000" wrapped a character per line; counts from 10,000 up are
  shortened (10K, 12,3K) and the total goes where it does not fit;
- "ungelesen" was cut beside the number in Mail and next and moves
  under it instead.

Every widget header now lets its title give way before the buttons on
its right.
2026-10-02 13:01:05 +02:00
Linus Rath c9a0718acf fix: keep the app from closing when a files widget opens it offline #52 2026-10-02 12:57:43 +02:00
Linus Rath c5143975db fix: finish the widget refresh after leaving the app #52
The previous fix started the refresh as the app went to the background,
but it still stopped after its first request: fetch hands every response
over through a JS timer, and React Native pauses timers while the app's
activity is in the background, so the answer was only read once the app
came back. The widgets kept the previous account after a switch and
Home, and changes made in the app reached them late.

The refresh now runs as a headless task of its own,
BulwarkWidgetRefresh, started through BulwarkWidgetRefreshService. A
running headless task keeps the timers going, and the service keeps the
process from being frozen before the widgets are redrawn. Where Android
refuses to start the service the app refreshes in place as before. A
refresh started for a change also no longer joins one that began before
it.
2026-10-02 12:01:31 +02:00
Linus Rath ae3121a746 fix: load calendar and tasks into the widgets when the app is not running #52
The calendar, tasks, files and scheduled-send parts of the widgets only
refreshed while the app's UI was running, because they reuse the app's
stores, which are bound to its JMAP client. A widget event or push that
woke the app without its UI refreshed mail only, so invitations
answered elsewhere, tasks ticked on another device and new events
stayed as the app last left them. A tick or RSVP the server refused in
that state was also never undone, since nothing reloaded those parts.

When no UI has started in the runtime, nothing else uses that client,
so the widget refresh now signs it in to the active account and loads
calendar, tasks, files and scheduled sends the way the app does. Once
the UI starts it owns the client again.
2026-10-02 12:01:20 +02:00
Linus Rath 2cec096040 fix: open a widget's event in the account it belongs to #52
Event links from the widgets named only the event (and the JMAP account
of a shared calendar), never the signed-in account it was shown for.
With another account active by then, the Calendar tab opened without
the event, or with an id both accounts use, on the other account's
event. The links now carry `appAccount`, and the app switches to that
account first, as it already did for message links.
2026-10-02 11:46:53 +02:00
Linus Rath a694751944 fix: send widget RSVPs and task ticks to the item the tap was made on #52
An RSVP or task tick named only the item's id and looked it up in the
stored snapshot when the tap arrived, then used the snapshot's active
account. Between a refresh and the redraw that follows, a tap made on
the old drawing could reach a snapshot of another account, and JMAP ids
are short per-account sequences, so the lookup could match that
account's task or event with the same id. A tick also flipped whatever
the stored state was, so a quick second tap on a drawing that had not
caught up yet undid the first.

The tap now carries what the call needs: the item's account, its server
ids and, for a tick, the state the user asked for. The change laid over
the widget data only touches items of that account. Drawings made
before this keep working through the old lookup.
2026-10-02 11:45:48 +02:00
Linus Rath 6c797f6e2c fix: refresh the widgets when the app goes to the background #52
Leaving the app scheduled the widget refresh with a zero-delay timer.
React Native pauses JS timers while the activity is in the background
(timers in the bridgeless runtime have no immediate path for a zero
delay), so the refresh only ran once the app came back or a widget task
happened to start. Switching accounts and pressing Home left the
widgets on the previous account's mail. The refresh now starts directly
from the app state change.
2026-10-02 11:42:50 +02:00
Linus Rath 7fe4a1ba27 feat: show on the widget when an action could not reach the server #52
A widget button whose JMAP call failed (offline, refused, the account's
credentials unavailable) only logged a console warning, so to the user
the button had simply done nothing, or had worked. The change is now
undone on the widget and a notice says what failed: "Couldn't archive.
Tap to try again." on the triage card, which goes back to the message,
and the same for RSVPs on the invitation card and task ticks on Tasks
and Today overview. Tapping the notice repeats the tap; any new tap or
ten minutes clear it.
2026-10-02 11:41:22 +02:00
Linus Rath 79c53e713c fix: make widget task ticks easy to hit #52
The tick on the Tasks widget was a 22dp circle, and the rest of the row
did nothing: a tap on the title or just beside the circle was lost. It
now takes the row's full height and 50dp of its width, and the rest of
the row opens the calendar. Today overview's task rows grow from 32dp
to 48dp for the same reason; the widget fits fewer of them where space
is short.
2026-10-02 11:32:50 +02:00
Linus Rath 26d0cf3ccc fix: keep a widget archive when a second tap's refresh lands before it #52
Every widget tap runs as its own headless task in the same JS runtime,
and the tasks interleave at every await. Each one loaded the stored
snapshot, edited it and saved it back, then refreshed from the server.
That went wrong in three ways:

- two taps loaded the same snapshot and the second save dropped the
  first change;
- the refresh of the first tap read the server before the second tap's
  archive landed and was stored anyway, so the message came back (seen
  on the emulator with a slow `Email/set`);
- a redraw of an older snapshot could land after a newer one, leaving
  buttons that act on a message the widget no longer shows.

The snapshot now only holds what the server last said. A button's
change is kept as a pending op next to it and laid over it when a
widget draws; it stays until a refresh that started after the server
took it has been stored (calendar and task changes only count as caught
up by a refresh that loaded calendar and tasks). A call that fails
drops its op, which also undoes an archive made offline: before, the
failed refresh kept the edited snapshot and the message stayed hidden
although it was never archived. Reads, writes and redraws of the
widget data run one at a time, and every redraw draws the latest view.
2026-10-02 11:32:38 +02:00
Linus Rath bc74d3bbac fix: keep the app from closing when a calendar widget opens it offline #52 2026-10-02 11:22:10 +02:00
Linus Rath 80f7278a6f fix: stop widget buttons from going dead when two are drawn in the same millisecond #52
react-native-android-widget registers every button's PendingIntent with
the current time in milliseconds as its request code and
FLAG_CANCEL_CURRENT. All buttons of a widget share the same action and
provider, so the request code is the only thing telling them apart: two
buttons registered within one millisecond got the same intent, and the
second registration cancelled the first. That button then did nothing
when tapped. The emulator takes 3-10 ms per button, a current phone well
under one, so which button died depended on timing.

The library is patched with patch-package (run on postinstall) to hand
out request codes that count up from the time, unique in the process
and clear of the codes an earlier process left on the launcher. The
patch keeps the library's CRLF line endings, hence `-text` for
`patches/*.patch`.
2026-10-02 11:14:40 +02:00
Linus Rath 604843a408 docs: list every widget tap target and how each one fails today #52 2026-10-02 11:08:05 +02:00
Linus Rath 1b4b185ac8 chore: allow app store distribution under a provisional AGPL permission
App store terms put limits on recipients, such as device caps and DRM,
that section 10 of the AGPL does not allow, so the app could not be
shipped through the Apple App Store or Google Play without breaking the
licence.

Add a "Bulwark Mobile" header and a section 7 additional permission to
LICENSE that allows app store distribution as long as the source of
every shipped version is public, free, linked from the listing or the
app, and not further restricted.

The permission is not final yet: not every earlier contributor has
answered the consent request. A reservation limits it to contributions
made on or after 30 September 2026 and to older parts whose authors
have agreed. The README licence line says the same.
2026-09-30 12:13:28 +02:00
Linus Rath dfaf1f3e5e chore: update version number to 0.1.69 2026-09-27 18:30:20 +02:00
Linus Rath 97c4f58379 feat: change the app icon to the white mark on raspberry 2026-09-27 18:29:50 +02:00
Linus Rath 5dfe869acb ci: publish a checksum and signed build provenance with the release apk 2026-09-27 15:23:51 +02:00
Linus Rath ab8a911863 chore: update version number to 0.1.68 2026-09-27 12:47:00 +02:00
Linus Rath dca9417aee feat: restyle calendar events and toasts to the app design 2026-09-27 12:34:58 +02:00
Linus Rath 8f604bf02e feat: merge contacts and calendar sync with the phone on Android #34
Brings feat/device-sync into main: on Android, each account's address
books and calendars can sync both ways with the phone's Contacts and
Calendar apps (Settings -> Contacts / Calendar -> Sync to this
device), through a "Bulwark" Android account with a contacts and a
calendar sync adapter.

- native side: the account, both sync adapters, provider access scoped
  to the account, push routing to the syncs, sync problem notices
- the sync engine in `src/device-sync`, run as a headless task
- the settings sections, sign-out and removal handling, translations
- design, limitations and verification in `docs/device-sync.md`
2026-09-27 12:14:37 +02:00
Linus Rath cf93f68fd3 docs: add the device checks of the review fixes to device sync's verification #34
The fixes since the first device runs were checked on the emulators
too: moves that stay the same event, statuses, server deletions, items
moved away before a deletion, quiet runs, the permission flow,
uninstalling and reinstalling, and the settings' collection count. The
list of what only tests cover shrinks accordingly.

The device runs also showed that Stalwart stores a calendar's default
alerts on the series, so removing every reminder of an occurrence is
put back and reported for such a series as well, and that Google
Calendar's first edit of an occurrence brings its reminders back
without a report.
2026-09-27 11:59:20 +02:00
Linus Rath 2ee056749c test: give the big meeting tests time on a busy machine #34
The planner tests that build meetings of 25 attendees on 52 overrides
take 0.3-1.8 s alone, but one of them passed the default 5 s limit in
a full run next to a release build. The two slowest tests of the file
already had 60 s; the other heavy ones get the same.
2026-09-27 11:57:56 +02:00
Linus Rath 10edc331a5 fix: keep device sync's notes out of the warning toast of debug builds #34 2026-09-27 11:23:44 +02:00
Linus Rath 157ea398b8 fix: update the sync settings' collection count after the chooser listed new ones #34
The account row in the sync settings counts the synced address books
or calendars from its own copy of the list, loaded when the row
appears. The chooser loads a fresh list when it opens, so a calendar
created meanwhile showed there but not in the count ("4 of 5 sync"
stayed after a sixth appeared, and turning it on or off changed
nothing) until the settings page was opened again.

Every fresh list now reaches every view of that account's collections,
so the count follows the chooser.
2026-09-27 11:23:44 +02:00
Linus Rath e09640cfc2 docs: describe device sync after the last round of fixes #34
Brings the doc up to the code after the second round of fixes and the
code review: moves of meetings, deletions of items another client
moved away, big meetings written in several transactions, occurrence
colours and reminders, the stored status of runs that did nothing,
push types by server capability, reminders while the phone's calendar
can't sync, and sign-out waiting for a turn-on in progress.

The known limitations gain what the fixers and the device runs found
but left alone: pairs whose patch fails, a field the server changes
during a pending move, Google Calendar's copy-and-delete moves and its
copied reminders, group cards deleted after they moved away, and a
status line that shows item errors but not conflicts.
2026-09-27 10:32:25 +02:00
Linus Rath ae679a7d3d docs: list contacts and calendar sync with the phone as done on Android #34 2026-09-27 10:24:06 +02:00
Linus Rath 156e79b348 fix: keep a card or event another app moved away when it is deleted on the phone #34
A contact or event deleted on the phone that another client meanwhile
filed in an address book or calendar the phone doesn't sync was
destroyed on the server by the phone's next sync, and the other
client's move with it. 0faee56 made the sync decide such a deletion on
the server's version, but the planners still destroyed it: the
contacts planner saw the card in no synced book and destroyed it, and
the calendar planner could not take the new version at all (it placed
the event before looking at the deletion), so the old one decided.

The deletion is now moot when the server's version holds none of the
collections the rows were written for and none the phone syncs: only
the rows go, nothing is uploaded, and the object stays where the other
client put it. A deletion still goes up when the book or calendar was
deselected on the phone before the upload, and when the object was
moved into another collection the phone syncs, since the phone would
otherwise miss an object it should show. The calendar planner now lets
a deleted row's shadow follow the server before it places the event.
2026-09-27 10:20:56 +02:00
Linus Rath df3175ff72 refactor: decide in one place whether a card is in read-only books only #34 2026-09-27 10:20:55 +02:00
Linus Rath 800c4e1442 fix: stop waiting for React Native once a sync gave up on it #34
A sync that had to start React Native left a listener on the React
host until a context came up. When the start failed (a debug build
without Metro) or never finished, every retried sync added another
one, each holding its run, account and extras, for the life of the
process, and they all fired at once when a context finally came up.

The wait now ends once, and removes its listener, for whatever
reason: a ready context, a start task that faulted, or 10 minutes
(no sync or push job waits longer; JobScheduler stops them). A
context that comes up later runs nothing of it. The adapter's own
check of its run in the callback still covers a run it gave up
sooner.
2026-09-27 10:20:55 +02:00
Linus Rath 5e62cf71f4 fix: add no extra account named after its id when Android refuses one #34
Turning device sync on retried with the app account's registry id as
the Android account name whenever adding the account failed. That
fallback is for one case only: the login name turned out to belong to
another app account's Android account, which the native module
reports with the `conflict` code. A passing AccountManager failure or
a permission error instead created a second account named after the
opaque id, which then showed in the Contacts and Calendar apps.

Only a `conflict` rejection takes the fallback now; any other failure
fails the turn-on as before.
2026-09-27 10:20:55 +02:00
Linus Rath ef96acd05f fix: finish turning sync on or off before signing an account out #34
Turning device sync on or off runs one step after the other per
account, but signing out did not wait its turn. A sign-out that
started while a turn-on was still setting up (or queued behind a slow
turn-off) found no Android account yet and went ahead; the turn-on
then created the Android account, stored it and requested a sync for
the signed-out account, and the next check posted "Sign in to keep
syncing". A turn-off still tearing down could likewise store its
preferences after the sign-out had forgotten the account.

The release before a sign-out is now a step in the queue of every
account it releases, taken in a fixed order: it waits for the steps
under way and releases what they set up. A turn-on or turn-off asked
for while the sign-out runs comes after it, and does nothing once the
account was released; if the user stays signed in it goes ahead.
2026-09-27 10:20:55 +02:00
Linus Rath 0664a31f8b fix: keep reminding of synced events while the phone's calendar can't sync #34
With calendar sync on and the calendar app reminding, Bulwark skipped
its own reminders for the synced calendars whenever sync was on in the
app, even when the phone's calendar received nothing: its calendar sync
paused in Android's account settings, Android's auto-sync off, or the
calendar permission revoked. Events created on the server meanwhile
never reached the phone, and nobody reminded of them.

The reminder filter now keeps what Android last said about each
account (the permission, `getMasterSyncAutomatically` and the
calendar's `getSyncAutomatically`) and stays quiet only while the
phone's calendar receives events. Auto-sync off counts as not
receiving: Android then runs manual syncs only, and the periodic,
pushed and app-triggered syncs are not manual. Android's side is read
when the scheduler subscribes and again whenever the filter is asked
for; a changed answer makes the scheduler reschedule. Until it was
read, Bulwark reminds: a double reminder is better than a missed one.
2026-09-27 10:20:55 +02:00
Linus Rath 217bb59d57 fix: forget a contact created and deleted on the phone between two syncs #34
ContactsProvider soft-deletes every raw contact and group of a sync
account, also one that never reached the server (DELETED=1, SOURCE_ID
NULL). When a contact or group was created and deleted on the phone
before a run claimed it, no upload phase took the row: without an
identity or a pending create it belongs to no JMAP account, and the
claim step skipped deleted rows. The row stayed for good, and the
upload-only check and the teardown counted it as a waiting change:
every upload sync the providers schedule became a full run, and
turning sync off or signing out always asked about a change that would
be lost.

The claim step now hands such a row to the planner, whose plan for it
is a purge, and `hasLocalWork` and `countPending` leave out deleted
rows that have neither an identity nor a pending create. Nothing is
uploaded for them. Calendar has no such gap: CalendarProvider
hard-deletes an app-deleted event without `_SYNC_ID`, and a claimed
event carries `~pending/<uid>`, so it is soft-deleted with its pending
create and destroyed by uid as before.
2026-09-27 10:20:55 +02:00
Linus Rath eebb00238c fix: look for new mail when a push names changes it can't read #34
The native push router and the push task decided separately whether a
push may carry mail, and disagreed. For an account entry that is not
an object, or a map that names no type, the router started the mail
task as possible mail, but the task's guard read the same payload as
"no mail" and returned, so new mail it announced was never looked for.
A push without a kind was judged by its map in the router but taken
for mail by the task.

Both now apply the same rules: anything but a StateChange, a map that
is missing, unreadable or names no type, an unreadable account entry,
or a mail type means the mail task runs. Only a readable StateChange
naming contact, calendar or other non-mail types skips it.

Checked with one table of payloads run against the router's decision
and the task's guard alike.
2026-09-27 10:20:55 +02:00
Linus Rath 41e2bdf922 fix: notify new mail again when a push names Email but not EmailDelivery #34
On main every push ran the mail task, whose legacy path notifies the
newest unread inbox mail. Device sync narrowed that to pushes naming
EmailDelivery, so contact and calendar changes never notify. But a
subscription made by an older build listens to Email and Mailbox until
the app is opened again, and a server without EmailDelivery reports
new mail only as those changes. Their new mail no longer showed a
notification while the app was closed.

Email and Mailbox now count as possible mail again, in the native push
router and in the push task alike. A push that names only contact or
calendar types still never takes the mail path.

Checked with the push router and push task tests: an Email or Mailbox
change starts the task and the legacy path notifies the message,
next to the syncs a mixed map asks for.
2026-09-27 10:20:55 +02:00
Linus Rath 11b897177d fix: keep mail push on servers without contacts or calendars #34
The push subscription got the contact and calendar types whenever the
account had device sync on, without asking whether its server has
contacts or calendars. Sync can be on for such an account: the settings
list every signed-in account, and a server can drop a capability later.
A server that refuses unknown types then refused the update and the
replacement, and push setup failed: a new account got no mail push, an
existing one lost it when its subscription expired.

The types are now added only for a capability the account's session
advertises, and a write the server refuses while it carries device
sync's types is retried with EmailDelivery alone, so mail push never
depends on device sync.

Checked with the push setup tests against a fake server that refuses
unknown types: a new subscription is created mail-only, an existing
one is kept and refreshed, and another account's subscription is left
alone when its server lacks the capability.
2026-09-27 10:20:54 +02:00
Linus Rath 029eba4562 fix: keep the last sync's result when a sync after it finds nothing to do #34
The settings show the stored status of the last sync. A sync that
finds nothing to do still replaced it, so a change the server refused
and the phone put back, or a conflict, was shown for a second or two
and then gone. The previous fix covered only the upload sync Android
starts right after a manual one. When changes arrive during a sync,
the adapter runs a full sync right after it, which finds nothing new
on the server and replaced the status 1-2 s after the real one; a
manual sync right after another does the same.

Now what counts is what a run did, not how it started. A run that
wrote nothing either way and reported no conflict, item error, note or
failure keeps the details of the run before it and moves only the time
on. After a failed run it shows the sync as working again, without the
failure's message. Any other run replaces the status, so a problem
stays only until the next run that does something.
2026-09-27 10:20:54 +02:00
Linus Rath 66789251e3 fix: keep a meeting moved in Etar the same event instead of cancelling and re-inviting #34
Etar moves an event by inserting a copy from its editor and deleting
the old row. Its editor keeps the user's own attendee row out of the
attendee list, so the copy of a meeting that lists the user has no row
for the user, and the copy of one that does not gets the user as its
organizer. The pairing compared the attendee addresses of both rows
exactly, so a moved meeting never paired: it was deleted on the server
and created again under a new uid, the other attendees' answers were
lost, and everyone got a cancellation and a new invitation.

The pairing now leaves the user's own addresses out of that
comparison. Anyone else still has to be on both rows, so a copy that
lost or gained another attendee is not taken for the same event.

Checked with planner tests for both kinds of meeting and a run through
the engine with a fake Etar move.
2026-09-27 10:20:54 +02:00
Linus Rath 117823f746 test: make the device sync fakes as strict as Android and Stalwart #34
The fake providers and the fake server were more lenient than the
real ones, so three bugs showed only on a device: an event inserted
without a status, small contact photos, and meetings too big for one
provider transaction.

The fake CalendarProvider leaves STATUS NULL when an app writes none.
The fake ContactsProvider keeps a photo within the 96 px thumbnail as
the thumbnail alone, without a PHOTO_FILE_ID, and serves it to
readPhoto from there. A batch fails with tooLarge when it weighs more
than one Binder transaction, a photo counted by its bytes. The fake
server drops empty maps inside recurrence overrides, as Stalwart does.
Tests now read photos the way the engine does, and the helpers that
made up for the old fakes are gone.
2026-09-27 10:20:54 +02:00
Linus Rath 3ab131dbe8 fix: keep the last sync's result when an upload sync finds nothing to do #34
Android starts an upload-only sync right after a manual sync, and 30 s
after any app write. When nothing waited on the device it ended at
once, but it still stored its status, so the settings showed a plain
"synced" and lost the conflicts and item errors of the run just
before it.

Such a run did nothing and now leaves the stored status alone.
2026-09-27 10:20:54 +02:00
Linus Rath 9439a4ffc0 fix: keep an occurrence's colour cleared, and report reminders that can't go #34
Clearing the colour of one occurrence of a coloured series removed the
occurrence's own colour, so it showed the series' colour again after
the sync. The occurrence now gets an empty colour, which Stalwart keeps
and which shows as the calendar's colour, as on the phone.

Removing every reminder of one occurrence sent an empty set of alerts,
which Stalwart does not store: the occurrence then showed the series'
reminders again, without a word. When the series uses the calendar's
default reminders, the occurrence leaves the defaults and has none, as
before. When the series has reminders of its own, the occurrence would
show them whatever is sent, so the change is put back and the report
says why (`remindersNotRepresentable`).

Checked against calcard 0.3.14, with planner tests and runs through
the engine and the fake server.
2026-09-27 10:20:54 +02:00
Linus Rath 4bcca70548 fix: write big meetings in several provider transactions #34
A recurring meeting with many attendees on many occurrences (25 on 52,
100 on 12) is about 2 MB of provider operations, and one Android
provider transaction carries 1 MB. Every event was written as one
group that the engine never splits, so such a meeting was refused every
run and never reached a phone that synced it for the first time.

An item too big for one group now comes as a chain of groups that the
engine applies one by one, each in a transaction of its own. A new
event is written master first, with its identity, then its occurrences
in groups that fit, then its shadow; a write into an existing event
writes the occurrences first and the series with its new shadow last.
When a run stops between two groups, the next one finds the event by
its identity and writes only what is missing, planned from the old
shadow, so no row is written twice and no server change is lost.

Checked with planner tests for both meeting sizes and every cut point,
and runs through the engine with a provider that refuses transactions
over 1 MB, a crash after every batch included.
2026-09-27 10:20:54 +02:00
Linus Rath 0110eebb78 fix: upload an edited event another client moved out of synced calendars #34
When another client moved an event into a calendar this device does
not sync while the device had an unsent edit of it, the calendar
planner refused to merge the server's version, since the event is in
no synced calendar any more. The edit then waited on every run as a
planner error, and the event never left the device.

The server's version now merges into the rows where they are, so the
edit uploads against it without undoing the other client's changes,
and the rows go once the server has taken the edit.

Checked with a planner test and a run through the engine and the fake
server: the series rescheduled on the device keeps the occurrence the
other client added, and no rows are left.
2026-09-27 10:20:54 +02:00
Linus Rath 724c3b80ee chore: update version number to 0.1.67 2026-09-27 10:15:08 +02:00
Linus Rath f24d919fdb docs: describe device sync after the review fixes and how it was verified #34
The device sync doc still described the feature as first built. The
review fixes since then changed pairs, merges of items outside the
synced collections, the order of uploads, group handling, photos, the
provider-op scope checks, the settings actions and what the report
says about changes put back on the phone.

Each fix's doc notes are applied against the code as it is now, the
known limitations gain what the device runs found (an identity as
organizer, Stalwart refusing answers in read-only calendars, Fossify
Calendar around DST changes, AOSP's one-line address edits), and a new
Verification section lists what was checked on emulators and the scale
run's numbers.
2026-09-27 09:15:53 +02:00
Linus Rath 13d5d1733b fix: say "1 of 3 syncs" when one collection of several syncs #34 2026-09-27 08:50:59 +02:00
Linus Rath 84f6b5777e feat: translate device sync into all 26 languages #34
The strings of device sync were English only: the "Sync to this
device" sections in the Contacts and Calendar settings with their
dialogs, and the notifications about signing in again and about sync
problems. Each language keeps the form of address its file already
uses, the app's own terms, and Android's names for its settings,
auto-sync and the Contacts and Calendar apps.

Checked: every message parses as ICU with the same arguments as the
English one and the plural categories of its language,
`node scripts/rn-missing-translations.mjs <lang>` lists no device sync
key for any language, `npm run i18n:check` and the i18n tests pass.
2026-09-27 08:50:15 +02:00
Linus Rath 7453513e08 fix: stop waiting for group memberships in read-only address books #34
A contact added on the phone to a group in a read-only address book
(one shared with the user) could never reach the server, yet it stayed
waiting on every run: the planner puts such a membership back, but the
engine never told it which groups are read-only, so only groups of
another account were recognised. Turning sync off or signing out then
always asked about a change that could never go up.

The engine now tells the planner which synced groups are in read-only
books only, from the group cards it holds and the books' rights. The
membership is put back and reported, and nothing is left waiting.
2026-09-27 08:44:58 +02:00
Linus Rath d1ae713b1f fix: report changes the phone puts back because the server can't take them #34
A change the server cannot take was put back on the phone without a
word: an edit in a read-only calendar or address book, an answer
where answers are not allowed, an event moved to another account's
calendar, an occurrence the server keeps without its series, a
contact added to a group this device can't change. The change just
vanished.

The planners now say why they put a change back, and the sync report
lists the item with that reason (`readOnly`, `rsvpRefused`,
`instanceOnly`, `crossAccountMove`, `groupNotWritable`). A contact
whose only change still uploads through a group card is not listed.

A refused edit was also reported as "invitations not sent", as if it
had been stored without them; the note now appears only when the
change was stored without its scheduling messages.
2026-09-27 08:44:58 +02:00
Linus Rath 1c75da8733 fix: remove an event created in a read-only calendar and say so #34
An event created on the phone in a calendar the user may only read was
meant to be removed, since the server takes no changes there, but the
claim step ignored that answer: the event stayed on the phone, was
never sent or reported, and turning sync off always warned that a
change had not reached the server.

The claim step now applies it: the row goes, the report lists it as
read-only, and nothing is left waiting.
2026-09-27 08:44:58 +02:00
Linus Rath 1a348792c0 fix: let the server's deletion win over an event moved on the phone #34
Etar moves an event by deleting the row and inserting a copy. When the
event was deleted on the server meanwhile, the sync removed the deleted
row but kept the copy, which then uploaded as a new event: the event
the server had deleted came back in the target calendar, for everyone.
That happened whether the download found the event gone first, which
is the usual case, or the patch of the move did.

A move is an edit, and the server's deletion wins over an edit: the
copy that pairs with the deleted row now goes with it, and the report
counts the conflict. A copy edited beyond the move stays a new event.

Checked through the engine with the event destroyed before the run,
while the move was sent, and with the patch answered notFound.
2026-09-27 08:44:58 +02:00
Linus Rath f24ff7da9e fix: keep the other edits of an event moved to another calendar #34
Etar moves an event, or turns a series into a single event, by deleting
the row and inserting a copy. The pair uploaded the move as a patch of
the event, but then took the copy as it stood for the new baseline and
wrote the server's version over it, so everything else saved with the
move was dropped: a changed reminder came back, and an edit made before
the move never reached the server. The synced occurrences, which Etar
does not copy, were deleted with the old row, losing an occurrence edit
that was still waiting.

The copy now starts from the old row's baseline with only what the move
changed, so any other difference stays dirty and uploads right after
the move as an ordinary update. The old row's occurrences move to the
new row with what they wait to upload. Attendee rows of the copy are
the app's re-insert (Etar writes everyone as required, without a
status) and are not read as edits.

Checked with planner tests and runs through the engine and the fake
server: a reminder saved with the move and a pending occurrence edit
both reach the server, the event keeps its uid, and the echo writes
nothing.
2026-09-27 08:44:58 +02:00
Linus Rath 0faee568cc fix: let a contact deleted on the phone and moved away meanwhile survive #34
When another client moved a card out of every address book the device
syncs while the device deleted the contact, the engine noted the card
for dropping but never showed its new version to the planner, so the
deletion was decided on the old copy and destroyed the card. A deleted
item is now merged like any changed one first; the contacts planner
then leaves a card that is in no synced book. When the planner cannot
place the object, the deletion stays as it was.
2026-09-27 07:53:37 +02:00
Linus Rath 654675d90b fix: keep unsynced changes waiting when sync is turned off before the first sync ended #34
Turning sync off uploads what the device changed without downloading
first; every upload carries the state the device last synced, so the
server refuses it when something changed meanwhile and the engine
downloads first. Before the first sync of an account had ended there
was no such state, and edits and deletions went up unguarded, able to
overwrite what another client had changed. They now wait, and the app
asks before they are dropped; new items still go up.
2026-09-27 07:53:37 +02:00
Linus Rath 153e85cb80 fix: finish turning sync off when the app goes to the background #34
Turning sync off first uploads what the device changed, then removes
the synced rows. Started from the settings screen, that work yielded
the JS thread with timers between its steps, and React Native pauses
timers while the app is in the background and no headless task runs:
leaving the app stopped it halfway, holding the account's sync lock,
until the app came back. The teardown now waits without timers.
2026-09-27 07:53:37 +02:00
Linus Rath b86e0a118e fix: size provider batches by what the Binder transaction carries #34
The batch budget counted JSON characters, but a Parcel carries strings
as UTF-16 plus a few hundred bytes per operation, so a full batch was
about 700 KB of a 1 MB Binder buffer the whole process shares. Batches
are now weighed at two bytes per character plus 300 bytes per op and
kept under 500 KB of that. The state op no longer joins a batch whose
last item already fills a provider yield window of 499 operations,
which ContactsProvider refused.
2026-09-27 07:53:37 +02:00
Linus Rath 1ee6bc459d fix: move group members on the phone after an interrupted sync #34
When a group's members change on the server, the download fetches the
contacts whose membership flipped in a chunk after the group's. The
group's chunk already stored its new version, so when the sync stopped
before the members' chunk, the next sync found no flip any more and
the phone kept the old members. Those contacts are now listed with the
group's rows, as objects to fetch first, until their own chunk ran.
2026-09-27 07:53:36 +02:00
Linus Rath fe0aaa1247 fix: load a book selected while the first sync was cut short #34
A full reconcile that ran out of time resumes after the last object it
processed, in the sorted order of every listed id. It listed the
collections selected at the time it resumed and marked them all loaded
at its end, so a book turned on in between lost the objects that sorted
before that point, and was never loaded again. The reconcile now keeps
the collections it started with; one selected since is loaded after it
like any newly selected collection.
2026-09-27 07:53:36 +02:00
Linus Rath 78700401a2 test: keep a contact's groups when an upload runs without a download #34
A sync with nothing to download, and the upload before sync is turned
off, used to plan uploads with an empty group index, so the write after
the upload deleted the uploaded contact's membership rows on the phone.
The previous commit's refresh of the index before planning uploads
fixes both; these tests cover the two paths so it stays fixed.

Checked: both tests fail without that refresh.
2026-09-27 07:53:36 +02:00
Linus Rath 2e1cb58548 fix: finish loading or removing a book after an interrupted sync #34
The SyncState's `selected` said which collections were on the device,
and changed only when a load or a removal had finished. A book turned
off and on again while the device was still removing it looked loaded,
so the contacts removed so far never came back; a calendar came back
empty. A book turned on and off again while it was still loading never
looked loaded, so the contacts loaded so far were never removed.

`selected` now names only collections whose rows are all on the device:
a collection leaves it before its first row is removed, and the
contacts SyncState lists collections that are loaded or removed only in
part, so the next run loads them again when they are selected, or
removes the rest when they are not. A book that still holds a change
waiting for its upload stays in that list instead of `selected`.
2026-09-27 07:53:36 +02:00
Linus Rath ecbd218c34 fix: sync only the primary account's collections by default #34
The settings chooser and the reminder filter treat only the capability's
primary account as the user's own, while the engine also counted any
account the server marks as personal. On a server that lists a second
such account, its address books and calendars synced although the
chooser showed them off. The engine now uses the same rule as the app.
2026-09-27 07:53:36 +02:00
Linus Rath 474eae9992 fix: never duplicate an event's reminders after a busy calendar database #34
A provider that is busy may commit a batch up to a yield point and then
fail it. The engine retried every group of such a batch and relied on
the groups' asserts to refuse one that was already committed, but a
download into an unchanged event asserts only the event's own columns:
when it had only added a reminder or an attendee, the retry added it a
second time.

A download is now planned again from a new read after a failed batch
instead of being sent again, which turns a committed group into an echo.
The fake provider can commit a batch up to a yield point to test it.
2026-09-27 07:53:36 +02:00
Linus Rath 7701a67dff fix: keep favourites and joins when the server refuses an edit #34
When the server refused an edit made on the device (a book or calendar
that turned read-only, an event in a calendar where only one's own
events may be changed), the engine put the item back by deleting its
rows and inserting a fresh copy. A contact lost its star, ringtone and
the link to the contacts it was joined with; an event got a new row id.

The rows are now rewritten in place from the server's version, as after
an accepted upload. A refused deletion still brings the item back as a
new copy.
2026-09-27 07:53:36 +02:00
Linus Rath 2e9e5bb785 fix: remove items the server deleted right after this device created them #34
After an upload the stored state stays where the download left it, and
the engine relied on /changes to report its own creates back. /changes
leaves out an object that was created and destroyed after the state it
is asked from (Stalwart does, RFC 8620 allows it), so an event or
contact created on the device and deleted on the server before the next
download stayed on the device for good.

The SyncState now lists the objects our uploads created, written with
their identity, and the next run fetches them first: one that is gone
loses its rows, an unchanged one writes nothing. An upload-only sync
runs in full while the list is not empty.
2026-09-27 07:53:36 +02:00
Linus Rath fad7b30dbf fix: upload new contacts whose address book was deleted meanwhile #34
A new contact is claimed for an address book before its create is
sent, so a retry after a lost response finds it again. When that book
was deleted on the server before the create went up, every attempt
failed and was retried after up to a day, forever. A book that is gone
can hold nothing of an earlier attempt, so the contact is now claimed
again for the book the settings choose.

An event cannot move on its own to another calendar: a create into a
calendar that is gone is no longer sent, and the report says why, until
the user moves the event in the calendar app.
2026-09-27 07:53:36 +02:00
Linus Rath 34236f677b fix: never resend a group's members from an outdated copy #34
A group whose latest server version the device could not store waits
on the stale list, and nothing uploads for it until it has been fetched
again. Membership edits bypassed that check: adding a contact to such a
group sent the whole member list as the device last knew it, which
removed members another client had added meanwhile. Membership edits
for a stale group now wait for that fetch, and the report lists them.
2026-09-27 07:53:36 +02:00
Linus Rath 09c2ff1f67 fix: upload a contact's removal from a group made on the phone #34
Whether a membership edit still has to go up is decided against the
group cards on the server, which the engine indexes from their shadows.
The index was only read before planning a download that held contacts,
so in a run that downloaded no contact, such as the upload sync after
an edit, it was empty: removing a contact from a group looked already
done on the server, the contact was marked clean and the removal was
never sent. The engine now reads the index before planning uploads too.
2026-09-27 07:53:35 +02:00
Linus Rath d088dd14e9 fix: stop asking about waiting changes after a group edit reached the server #34
A contact stays marked as changed until the group cards on the server
show its membership edits. The engine uploaded those edits as patches
of the group cards after the contacts, but never looked at the contact
again in that run, so it stayed marked until the next sync. Turning
contacts sync off or signing out right after adding a contact to a
group therefore asked whether to drop one waiting change, although
nothing was waiting.

Once the membership patches are accepted, the contacts that have
nothing else to upload are now cleared in the same run, behind the
same version check as any other clean write.
2026-09-27 07:53:35 +02:00
Linus Rath 58b74527b2 fix: keep group cards on the server when a sync stops while dropping them #34
The contacts SyncState lists the group rows on the device, so a group
an app hard-deleted (Fossify does) is found by its absence and deleted
on the server. The list changed only with a later state op, not with
the batch that inserted or deleted the rows. When a run stopped in
between (cancelled, out of time, a crash, or turning sync off while a
deselected book was being dropped), a group the engine had removed was
still listed, taken for a hard delete by the next run or by the
teardown's upload, and its card destroyed on the server; a group it had
inserted was never listed, so a later hard delete never went up.

A batch that inserts or deletes a group row now ends with a state op
holding that change, retries included, so the list and the rows never
disagree. A group a download finds unchanged is listed too, for rows
whose batch lost its state op at a provider yield point. And an absent
group whose card is no longer a group or is in no synced book is only
forgotten, never destroyed.

Checked with a crash at every checkpoint of a deselection, a teardown
and a deadline stop during one, a server-side move in a page of several
chunks, and a group written before an interruption and then deleted in
Fossify.
2026-09-27 07:53:35 +02:00
Linus Rath 7187cab904 fix: never undo server changes to a card moved out of synced books #34
When another client moved a contact or an event out of every address
book or calendar the device syncs while the device had an unsent edit
of it, the download only noted it for dropping after the upload. Its
shadow stayed at the old version while the stored state moved past the
change, so the upload passed `ifInState` and resent whole maps built
from the old shadow: a number or an occurrence added on the server
meanwhile was lost. A full reconcile that found such an object did the
same.

The download now merges the new version into the dirty rows like any
other change, so the upload builds on it, and the rows still go once
it is accepted. A contact's collections are read from its shadow:
SYNC1 lists only the selected books and is empty for a card that left
them all, so such a contact was never dropped after its upload. The
calendar planner cannot merge an event that is in no synced calendar
yet; until it can, such an event waits on the device and is reported
instead of overwriting the server.

Checked with the real planners through `/changes` and through a full
reconcile.
2026-09-27 07:53:35 +02:00
Linus Rath 7dbbd1ce25 fix: move events between calendars without recreating them on the server #34
Etar moves an event to another calendar, and turns a series into a
single event, by deleting the row and inserting a copy. The engine was
meant to pair the two rows and upload a patch of the existing event,
but it claimed every new row (a uid and a pending create) before it
asked the planner for pairs, and the planner pairs only unclaimed rows.
No pair ever formed: the event was destroyed and created again under a
new uid, which lost what the device cannot show (keywords, virtual
locations, links, the other attendees' answers, alerts relative to the
end) and sent every attendee a cancellation and a new invitation.

Pairs now upload before new rows are claimed, and a paired deletion no
longer counts toward the deletion threshold, since it is a move. When
an app changes the rows while the patch is on its way, they are read
and paired again; rows that no longer pair are left without the
identity instead of having the server's version written into the new
row, which overwrote the edit and left two rows with one identity.

Checked with the real planners: a move and a series turned single keep
their uid, keywords and attendee answers and send nothing; an edit made
during the upload is kept; an unchanged rewrite still pairs.
2026-09-27 07:53:35 +02:00
Linus Rath 78684e2492 fix: keep the last sync time current while the sync settings stay open #34 2026-09-27 07:52:12 +02:00
Linus Rath c635ccd586 fix: open the account's sync page from the sync settings #34
"Android account settings" opened Android's page for the Bulwark
account through `ACCOUNT_SYNC_SETTINGS` with the account as the
`account` extra. The Settings app of Android 12L and later passes only
`:settings:show_fragment_args` on to that page, which then found no
account and closed at once ("Account provided does not exist: null").
Since starting it succeeded, the fallback to the list of synced
accounts never ran, and the button seemed to do nothing.

The account now also goes in the fragment arguments, where those
Settings apps read it; older ones still read the plain extra.

Checked on the API 32 and API 34 emulators: the button opens the
account's "Account sync" page with its Contacts and Calendar switches.
2026-09-27 07:49:53 +02:00
Linus Rath 534cb8239e fix: keep the groups of a contact created on the device #34
A contact created on the device and put into a group before its first
sync lost that group: the write after its create took the rows' own
memberships as what the server already had, found them missing there
and deleted the rows, so the group card was never patched.

The create's write now starts from no memberships, as its fallback
already did: the device's memberships count as additions, stay on the
device, keep the contact dirty until the group cards show them, and
upload as patches of the group cards.

Checked with a planner test and end to end through the engine.
2026-09-27 07:19:27 +02:00
Linus Rath 6f574de0b9 fix: keep small contact photos on the device and on the server #34
ContactsProvider keeps a photo within its 96 px thumbnail as the
thumbnail alone, without a PHOTO_FILE_ID, and the thumbnail does not
read back. Such a Photo row looked empty to the planner. Every
download of the card inserted the photo again (two, then three
rows), and any edit of the contact on the device deleted the photo on
the server.

A Photo row now always counts as the contact's photo. Photo rows that
earlier downloads added again are deleted by the contact's next clean
write, one row is kept, and nothing reaches the server for them. A
photo set on the device is still uploaded.

Checked with planner tests and end to end through the engine, with a
provider that stores small photos as thumbnails as the device does.
2026-09-27 07:19:27 +02:00
Linus Rath d34641d1f9 fix: sync contacts whose photo is too large for the device #34
A contact's rows are written in one provider batch, and one batch is
one Binder transaction of at most 1 MB. A contact with a larger photo
failed with `tooLarge` on every run, stayed on `stale`, never showed
on the device, and its device edits never uploaded.

Photos above about 512 KiB are now kept on the server only: the
contact is written without the photo. The shadow records that the
device holds no row for the card's photo, so the missing row is not
read as a deletion. The same applies to a photo whose bytes could not
be fetched, which an unrelated edit of the contact used to delete from
the server. A photo set on the device still replaces the server's, and
one deleted there is still deleted on the server.

Checked with planner tests for the insert, the size limit, a photo
that could not be fetched and a merge, and end to end through the
engine with the probe's 900 KB photo.
2026-09-27 07:19:27 +02:00
Linus Rath 00f1a66095 fix: keep phone, email and website types as picked on the device #34
Types Android has but RFC 9553 lacks uploaded as a label naming them,
yet every label read back as a custom type, and MMS and company main
read back as Other and Main. A number typed Car showed up as
"Custom: car" right after the sync, an MMS number as Other, a mobile
email as "Custom: mobile" and a blog link as "Custom: blog".

Such a label now names its type again, for phones only next to the
feature the type uploads with, so a custom label stays custom. MMS
uploads its `mms` label next to `text`, which keeps a text-capable
number from reading as MMS, and `main-number` with `work` reads as
company main.

Checked with a round trip of every phone, email and website type,
both for a contact created on the device and for a type changed on
a synced entry.
2026-09-27 07:19:27 +02:00
Linus Rath 05e3605ec7 fix: keep a group rename when a membership change of the group uploads first #34
Accepting a membership patch of a group card wrote the server's title
over the device's and cleared DIRTY. A rename that had not reached the
server in the same run, because the server refused it or the user made
it after the group's own upload was planned, was silently undone.

The accepted write now keeps a pending rename that the accepted card
does not show: it stores the new shadow but leaves the title and
DIRTY, so the rename uploads against it. A card that shows the
device's title, or a different one from a concurrent server rename,
is taken as before.

Checked with a planner test and end to end through the engine with a
refused rename and a membership change in the same run.
2026-09-27 07:19:27 +02:00
Linus Rath 20effab062 fix: keep an address's parts in step with a one-line edit on the device #34
AOSP Contacts edits a postal address as one formatted line; the
provider then copies it into the street and empties the other parts.
The upload sent only the new `full`, so the components on the server
kept the old values: CardDAV clients and the app's editor, which show
the parts, still showed the old city.

A changed line now uploads with the parts it implies. They are read
back along the old line: each of its segments must be some of the old
parts, and a segment whose change is confined to one part rewrites
that part like an in-place edit of its field. When the layout changed
or a change spans two parts, the address keeps the whole line as its
street, which is what the device holds, rather than an address
without parts that the app's editor would show empty and drop on its
next save. An unchanged line still uploads nothing, and editors that
write the parts themselves are handled as before.

Checked with planner tests for the edit from the device test, a
street-only edit, the fallback, a re-inserted line and an editor
that keeps the parts, and with unit tests of the read-back.
2026-09-27 07:19:27 +02:00
Linus Rath c30b4a23d2 fix: stop contacts waiting forever on group memberships the server can't take #34
A contact added to or removed from a group this device can't change
stayed dirty on every run: the membership upload skips such groups,
while the contact's clean write kept the change pending until the
server showed it. Turning sync off and signing out then always warned
about changes waiting for the server.

The clean write now puts such a membership back to what the server
has, as it does for any edit of a read-only contact, and clears DIRTY;
when that was the contact's only change the upload counts it as
skipped. Memberships of writable groups in the contact's own account
still wait until the group card shows them.

This covers groups of another JMAP account. For groups in read-only
address books the planner is ready, but the engine does not tell it a
group's rights yet; that follows separately.

Checked with planner tests for additions, removals, both kinds of
group and a change next to an edit that uploads, and end to end
through the engine for a group of a shared account, where turning
sync off afterwards finds nothing waiting.
2026-09-27 07:19:26 +02:00
Linus Rath 13264d6479 fix: keep contacts the server also filed in unsynced books when deleted on the device #34
A contact or group deleted on the device was uploaded against the
shadow of its last sync: a download into a deleted item wrote nothing.
When the server had meanwhile also filed the card in an address book
this device does not sync, the deletion still saw only the synced book
and destroyed the card, its copy in the other book included.

A download into a deleted contact or group now writes the new server
card into its shadow, and takes over an identity that was never
written, so deleting our own lost create goes by id rather than by a
uid lookup. The rows and the group title stay as they are: the device
delete still wins. The upload then removes only the synced memberships
of a card that is also in other books, as the calendar planner already
does for events.

Checked with planner tests for contacts and groups and end to end
through the engine with the real planner: the card stays in the
unsynced book, the device rows are purged, and an echo writes nothing.
2026-09-27 07:19:26 +02:00
Linus Rath 7a53cd5d84 fix: let the user delete or restore held-back deletions from the sync settings #34
When a sync held back many deletions made on the device (Android's
2026-09-27 07:19:26 +02:00
Linus Rath 2b9d19521f fix: keep sync on when it is turned back on during a turn-off #34
Turning device sync off uploads what the device changed and then deletes
2026-09-27 07:19:26 +02:00
Linus Rath c777dc4089 fix: keep syncing a big meeting after it was edited on the phone #34
After an edit on the phone, every write into a recurring event asserted
each attendee and reminder row of every occurrence. For a meeting with
25 attendees and 52 occurrences with details of their own that alone
made the write 1.6 MB, over Android's 1 MB transaction limit: the event
could no longer be written, went stale in every run and kept its edit.

Only rows that are dirty themselves now assert their attendees and
reminders. A clean row's DIRTY flag already catches an app edit of
them, since CalendarProvider sets it on every app write to an event,
its attendees or reminders, so such a write still fails and is planned
again. The same meeting's writes now take about 0.65 MB.

Checked with the meeting above through the planner, and with an app
edit of a clean occurrence's attendees between planning and writing.
2026-09-27 07:10:47 +02:00
Linus Rath 6785276377 fix: put back an event moved in place to another account's calendar #34
An app that moves an event by changing its calendar in place can move
it into a calendar of another account on the device. A patch cannot
move an event between accounts, so the move was dropped; with nothing
else changed, the row was then marked clean where it stood. The phone
showed the event in the other account's calendar while the server kept
it in the old one, until the next server change moved it back.

The row now goes back to its calendar at once, rewritten from the last
server version. Changes made together with the move still upload, and
their accepted write puts the row back as before.

Checked with a run through the engine and the fake server with a shared
account: the event is back in its calendar and clean, and the server
and the other account are untouched.
2026-09-27 07:10:47 +02:00
Linus Rath d3262ff387 fix: send an answer to one occurrence in an RSVP-only calendar #34
In a calendar the user may only answer (mayRSVP without write rights),
an answer to the whole series was uploaded, but an answer to a single
occurrence goes into the occurrence's override and counted as an edit:
it was undone on the next sync without a word.

A change that holds nothing but the user's own answer, to the series
or to occurrences, now counts as an RSVP and is uploaded. Stalwart
0.16.23 refuses every update in a shared calendar without write rights,
answers included; the engine then puts the rows back from the server
and reports the refusal, as it already did for an answer to the series,
so the answer is no longer lost silently.

Checked with a planner test (a new and an existing override, and an
extra edit that is still reverted) and a run through the engine and
the fake server.
2026-09-27 07:10:47 +02:00
Linus Rath 7736b3b551 fix: keep a reminder changed on one occurrence of a series #34
In a series that uses the calendar's default reminders, a reminder set
on one occurrence went into the occurrence's override, but the
occurrence still used the defaults it inherits from the series, which
ignore an event's own alerts. The phone showed the default reminder
again after the sync while the server held the new one.

The override now also leaves the defaults (`useDefaultAlerts: false`),
so only that occurrence uses its own alerts and the series keeps the
calendar's defaults. Stalwart stores the flag of an override with the
override (as a JSPROP) and serves it back.

Checked with a planner test (an existing override and a new "this
event" edit) and a run through the engine and the fake server.
2026-09-27 07:10:47 +02:00
Linus Rath 2870a59971 fix: stop events from Google Calendar arriving as tentative #34
Google Calendar leaves STATUS empty on the events and exceptions it
creates, and CalendarProvider has no default for it. Device sync read
the empty value as 0, STATUS_TENTATIVE, so every event and every "this
event" edit made there uploaded as "tentative" and came back to the
phone that way; a copy made with "Copy to" also never paired with the
deleted original.

An empty STATUS now sets no status: a create leaves `status` out, so
the server keeps its default of confirmed, an override gets none of its
own, an edited event never uploads one, a merge takes the server's
without counting a conflict, and a pair ignores it. After the upload
the rows hold the server's status.

A new series from Etar's "this and following" also no longer uploads
the old series' exclusions from before its start, which Etar copies
into the new row's EXDATE.

Checked with planner tests for each path and a run through the engine
and the fake server.
2026-09-27 07:10:47 +02:00
Linus Rath 232e892328 fix: keep an occurrence's description or location empty once cleared #34
Clearing the description or location of one occurrence of a series in a
calendar app removed the value from the occurrence's override. An
override without a value of its own shows the series' one, so the write
after the upload put "Daily agenda" or "Room 1" back into the occurrence
and made it clean: the user's edit was gone without a trace.

An existing override now gets an explicit empty value while the series
has one to fall back to: the empty text for the description, and a
location without a name, the only empty forms Stalwart keeps (calcard
writes no LOCATION line for an empty map, which then reads back as the
series' locations). Without a series value the override's own is removed
as before. New exception rows keep treating an empty value as "inherit".

Checked with a planner test and a run through the engine and the fake
server: the row stays cleared and clean, and the echo writes nothing.
2026-09-27 07:10:47 +02:00
Linus Rath 49c5d05c30 fix: keep every provider write of device sync inside its own account #34
The native side ANDs the account's scope onto each selection the engine
sends, as `(where) AND (scope)`. A selection such as `1) OR (1` closed
the scope's parenthesis itself: on the calendar tables, where
CalendarProvider adds no account filter of its own, an update or delete
built that way would have reached the events, attendees and calendars
of other accounts, e.g. the user's Google calendar. The engine only
sends constant selections, so this guards against bugs rather than
anything an outside app could send, which is what the scope is for.

- Selections and sort orders must balance their parentheses outside
  quoted literals, and may not end the statement, carry comments or
  query other tables (`SELECT`, `UNION`, `ATTACH`, `PRAGMA`).
- An update of a data row by id that writes `data1` without restating
  the mimetype has the row's mimetype looked up: on a membership row
  `data1` names a group, which must be the account's, as on insert.
  Such writes by selection are refused.
- `data14`, a photo row's file id, is never written: another account's
  file id would have let `readPhoto` read that account's photo.

Checked: 58 JVM tests (`gradlew :app:testDebugUnitTest`, three new), the
debug build, and every selection the engine and planners send against
the new rules.
2026-09-27 06:12:08 +02:00
Linus Rath d135bb2449 docs: describe device sync as it was built #34
The design in f666699 was written before the code, and building it
changed a number of details, several of them after runs against the
real providers on the emulators. The doc now says what the code does:

- a sync run's steps in the order they run, the stale list, the
  reconcile cursor, the owner check, the time margins, and accounts that
  are left out when they refuse to list their collections;
- uploads in phases across accounts, the `ifInState` catch-up, account
  refusals, series splits and copied events, creates adopting only in
  their target collection, and poison markers;
- the contacts and calendar mappings as built (membership rows, text
  limits, postal and relation rules, read-only rows and calendars,
  exceptions, reminders under the old owner), and the columns the
  providers refuse to return;
- the native module's scoping, the app's store, lifecycle and triggers,
  and the failure matrix;
- decisions and limitations, with the questions still open.

Checked: every statement against the code of this branch.
2026-09-27 05:41:26 +02:00
Linus Rath 417ed5a326 fix: log why a device sync run failed #34
The sync adapter logged only the SyncResult counters of a run, so a
failed sync showed up in logcat as `ioErrors=1` and nothing else. The
reason was in the run's report, and JS warnings from a headless run
don't reach logcat; finding out that a run failed on "Invalid column
raw_contact_is_read_only" took reading the app's storage. The log line
now carries the report's outcome, its message (up to 300 characters)
and the number of failed items.

Checked: 55 JVM tests (`gradlew :app:testDebugUnitTest`, one new) and
the debug build.
2026-09-27 05:27:13 +02:00
Linus Rath 77fd494633 feat: turn on contacts and calendar sync to this device in settings #34
On Android, Settings > Contacts and Settings > Calendar get a "Sync to
this device" section with a row per signed-in account. This makes device
sync reachable:

- The switch asks for the contacts or calendar permission after a short
  explanation (denied: sync stays off and says why, with a link to the
  app's system settings once Android stops asking), creates the
  account's Bulwark account in Android and syncs right away. Turning it
  off uploads what the device changed first and asks before anything
  that could not go up is lost. The switch shows Android's own state, so
  sync turned off in Android Settings shows as paused.
- Which address books or calendars sync: the personal account's are on
  by default, shared ones and the Trusted Senders book off; read-only
  ones are marked, and a note says when another signed-in account
  already syncs the same shared one.
- The address book new contacts from other apps go to, who reminds of
  synced events (the calendar app or Bulwark, asked when calendar sync
  is turned on while Bulwark's reminders are on), and how often Android
  syncs on its own (every 15 minutes to every 6 hours, or not at all).
- "Sync now", the last sync with its result, or the error with what to
  do about it ("Sign in again", "Grant access", "Review deletions"), and
  a link to the account in Android Settings.
- Accounts the app signed out while they synced are listed with "Sign
  in again" and "Remove from this device".

The settings search finds both sections. iOS, and Android builds without
the native module, show nothing new.

Checked: the collection listing and status tests, the settings search
and i18n tests, `npm run typecheck`, `npm test` and `npm run i18n:check`
on this commit, and on the API 32 emulator with usera on the local
server: the section, turning contacts sync on (Android account created,
152 contacts on the device, one per card) and the status after a run.
2026-09-27 05:15:32 +02:00
Linus Rath 9d83b2fb62 fix: stop warning about shared accounts that share only mail #34
434c554 left an account that refuses to list its address books or
calendars out of the run, but reported it as an item error. The sync
settings then said "1 item could not be synced" after every run for an
account that merely shares a mailbox: nothing the user can fix, and no
item behind it. Such an account is now only logged.

Checked: the engine tests, `npm run typecheck` and `npm test` on this
commit, and the sync settings of usera on the API 32 emulator after a
new run.
2026-09-27 05:14:04 +02:00
Linus Rath a27d014c59 fix: ask before a sign-in link from another app signs in
Any web page (one tap) or installed app (no tap) could fire a
`bulwarkmail://pair` link at the app, which then silently added the
sender's mailbox and switched to it, so the user might send or save mail
into an attacker's account. A `connect` link likewise opened any
server's sign-in page.

- Links that arrive from outside the app now ask first, naming the
  webmail's host and whether the link adds an account. Cancel discards
  the link for good; a link delivered again while the question is open
  is dropped. Codes scanned or pasted inside the app still go straight
  through.
- With the account registry full, a link shows the limit instead of
  opening "Add account".
- A pairing link for a plain-http webmail says the connection would be
  unencrypted instead of "That code isn't a Bulwark sign-in code", or
  nothing at all for a tapped link.
2026-09-27 05:11:07 +02:00
Linus Rath 26bb947940 fix: keep the account limit from leaving the app signed in as the wrong account
Signing in connects the shared client to the new account and stores its
credentials before the account registry takes it. With ten accounts the
registry refused, but nothing undid the connect: every request then went
out as the new account while the app still showed the old one. A
sign-in link made this easy to reach, since it opened "Add account"
without the limit check the menus have.

- A failed registration now restores the previous connection and drops
  credentials the registry never took.
- Adding an account to a full registry is refused before a sign-in code
  is spent or a browser sign-in starts, with "Remove an account in
  Settings to add another one."
2026-09-27 05:10:50 +02:00
Linus Rath ad0f83c72f fix: stop reading the read-only flag the contacts provider never returns #34
The contacts planner read RAW_CONTACT_IS_READ_ONLY with every raw
contact, but ContactsProvider takes that column only on writes: every
projection and selection that names it fails with "Invalid column", on
API 32 and API 35 alike. Every contacts sync on a device therefore
failed before it wrote a row. The fake provider accepted the column, so
no test noticed.

The flag is now only written, along with any raw contact write that
happens anyway, so echoes stay write-free. Whether a contact's device
edits are put back is decided from the server's current rights on the
card, which is also right when only the rights changed. The fake now
refuses the column in queries and selections like the real providers,
and every other column the planners query was checked against the real
providers on both emulators.

Checked: the contacts, engine and fake provider tests, `npm run
typecheck` and `npm test` on this commit, and a contacts sync of usera
on the API 32 emulator, which now completes.
2026-09-27 05:07:58 +02:00
Linus Rath 434c554038 fix: keep syncing when a shared account refuses access to its address books or calendars #34
Stalwart lists an account shared with the user for mail only with every
capability, and answers `forbidden` when the user asks for its address
books or calendars. The engine treated that like any failed request, so
on the local test server every sync of usera ended with an I/O error and
never reached the device, only because userb shares a mailbox.

An account that refuses to list its collections (`forbidden`,
`accountNotFound`, `accountNotSupportedByMethod`) is now left out of the
run and reported as an item error; the other accounts sync as before.
The rows written for such an account stay on the device: a refusal can
pass, and dropping the account's contacts and events for one refused
request would throw away device edits that are still waiting.

Checked: three new engine tests (a shared account that never listed,
one that stops listing its address books, one that stops listing its
calendars), which fail without the fix, `npm run typecheck` and
`npm test` on this commit, and a contacts sync of usera on the API 32
emulator, which now ends `ok` with one item error.
2026-09-27 05:06:36 +02:00
Linus Rath 1c574a5a93 feat: leave reminders of synced events to the device's calendar app when asked #34
When an account's calendars sync to the device and the user chose the
device's calendar app to remind them, that app gets the events'
reminders. Bulwark's own reminder scheduler now skips the events of
those calendars, which would otherwise remind the user twice. Tasks
never sync and keep Bulwark's reminders, and a change of who reminds, or
of which calendars sync, reschedules right away.

Checked: the reminder filter and scheduler tests, `npm run typecheck`
and `npm test` on this commit.
2026-09-27 04:39:53 +02:00
Linus Rath eb8b33160c feat: remove synced contacts and calendars from the device on sign-out #34
Signing out of an account (or of all of them, or removing one) now first
uploads what the device changed in its synced contacts and calendars,
then deletes them and removes its Android account, while the credentials
still work. When changes could not be uploaded (offline, server errors)
the app asks before signing out and losing them; staying signed in
keeps the account syncing. Several accounts are released in parallel,
and an internal error never keeps anyone from signing out: the next
check against Android's accounts stops what was left from syncing.

A successful sign-in clears the account's "Sign in again" notification
and resumes an account that stopped syncing when it was signed out.

Checked: `npm run typecheck` and `npm test` on this commit (the release
and resume paths are covered by the lifecycle tests of e67e240).
2026-09-27 04:38:34 +02:00
Linus Rath e67e240840 feat: ask android to sync when contacts or calendars change #34
With device sync on, Android should hear about a change as soon as the
app does, not only at the next periodic sync. This adds what tells it:

- The app's own contact and calendar edits, StateChanges on the live
  stream for `AddressBook`/`ContactCard` and `Calendar`/`CalendarEvent`,
  and the app coming back to the foreground request a sync of that
  account and authority. Requests within 5 s fold into one, and a
  StateChange the engine caused itself (its own upload echoing back) is
  skipped.
- While the app is closed, the account's push subscription carries
  those types too. The FCM and UnifiedPush services hand every push to
  the native router from f743ab0, which requests the syncs, and start
  the mail push task only when a push has `EmailDelivery`; the task also
  returns early for such pushes, so a contact change can never show the
  "newest unread mail" notification.
- At launch, on foreground and when Android's accounts change, the app
  checks Android's accounts against its own: an account removed in
  Android Settings shows sync off and is not recreated, and one whose app
  account is gone stops syncing and posts "Sign in again", keeping its
  data.

The functions that turn sync on and off (`src/device-sync/app/lifecycle.ts`)
come with this; the settings that call them land in a later commit, so
nothing here runs before an account has device sync on.

Checked: the new store, lifecycle, trigger and push tests, `npm run
typecheck`, `npm test` and `npm run i18n:check` on this commit, and the
debug build.
2026-09-27 04:36:51 +02:00
Linus Rath a948738506 feat: map contacts and groups to android's contacts provider #34
This is the planner for contacts (`src/device-sync/contacts`), the last
piece the sync task was waiting for: it turns JSContact cards into raw
contacts and data rows, and device edits back into JMAP writes.

- Names, nicknames, organizations and titles, emails, phones, postal
  addresses, websites, birthdays and anniversaries (also without a
  year), relations, notes and photos map both ways, with their types and
  custom labels. Group cards become groups, and their members become
  group memberships.
- Device edits upload as patches of the fields whose rows changed; a
  whole map goes up only where Stalwart needs one. Whatever a card holds
  that Android can't show (pronouns, online services, keywords, a second
  note, an address's coordinates) is never touched, so an edit on the
  phone leaves it intact on the server.
- Rows are matched to their card's entries by the keys they carry and,
  where an editor re-inserted them without (Fossify saves that way), by
  their values. An entry the editor didn't show is never read as
  deleted, and the keys Stalwart assigns by position to cards from other
  clients are followed when they shift.
- Contacts in read-only address books are put back when the device
  changes them. Text beyond what the provider stores is cut on the device
  and never uploaded.

Checked: 151 contacts tests (mappings both ways, one test per edited
field, Fossify and AOSP editing, re-keyed cards, merges, groups,
read-only books, a full lifecycle), the engine's tests with both real
planners, and `npm run typecheck`, `npm test` and `npm run i18n:check`
on this commit.
2026-09-27 04:23:22 +02:00
Linus Rath 1131073323 feat: open sign-in links from the camera app and paste them into the app
The webmail's QR encodes a `bulwarkmail://pair?...` link, but nothing
registered that scheme: scanning it with the system camera or Google
Lens did nothing, and without a camera there was no way in at all.

- `bulwarkmail` is registered in `app.config.js` (iOS) and the committed
  AndroidManifest. Links are parked in a small store and picked up by
  the sign-in screen, or by "Add account" when already signed in, once
  per launch.
- "Paste sign-in link" on the sign-in screen (and next to the server
  step's scan hint) takes a link copied from the webmail.
- While pairing, the waiting screen names the webmail being paired with.
2026-09-27 04:23:01 +02:00
Linus Rath 59cfe50793 fix: say why a sign-in code failed and accept password pairing from the webmail
Every failed redeem read "That code has expired": a code already used,
a webmail without pairing (404), a rate limit, a server error and a
network failure all looked the same, and a non-JSON answer surfaced as a
raw parse error. The app now reads the webmail's `invalid_code`,
`expired_code` and `used_code` answers (webmail 1.11's catch-all 400
reads as "expired or already used"), gives up after 15 s, and names the
host involved; a failure after the code was redeemed says the phone was
signed in but could not reach the mail server.

- `loginViaPairing` accepts `flow: 'password'` bundles (an app password,
  or the account password from servers without OAuth) in normal and
  add-account mode, and never redeems the same code twice.
- A `pair` link must point at an https webmail (loopback http for
  development); `pair/?` is accepted too.
- A token refresh that answers 200 with a non-JSON body keeps the
  account instead of signing it out.
2026-09-27 04:22:33 +02:00
Linus Rath 3ed5fa53af feat: map calendars and events to android's calendar provider #34
The engine hands every event it syncs to a planner that turns it into
CalendarContract rows, and device edits back into JMAP writes. This is
that planner for calendars (`src/device-sync/calendar`), now used by the
sync task:

- Calendars become calendar rows with what the apps need (SYNC_EVENTS=1,
  an owner, colours, the reminders allowed); calendars of shared accounts
  carry the account's name.
- Events keep their time zone, floating and all-day times, recurrence
  rules, excluded and changed instances, reminders, attendees and the
  user's own reply. A rule Android can't hold (RSCALE, SKIP, leap months)
  stays on the server untouched, and local times Stalwart can't resolve
  (a start in a repeated hour, an UNTIL in a skipped one) are written in
  a form it keeps.
- Device edits upload as patches of what differs from the last synced
  state, never a whole event, and only what the device can show is ever
  sent. A single-instance edit or deletion becomes an override of its
  series; "this and following" caps the series and creates a new one.
  How Etar and Fossify save (a cancelled instance instead of an
  exclusion, attendee rows deleted and re-inserted) is read as the edit
  it stands for, not as a deletion. Invitations go out on the changes a
  calendar app would notify about.
- Events in read-only calendars and subscribed feeds are put back when
  the device changes them; only the user's own reply goes up, where the
  server allows it. Tasks, task-only calendars and the birthday calendar
  never reach the device.
- Reminders are written as Reminders rows when the calendar app is to
  remind the user, and left out when Bulwark does.

Checked: 179 calendar tests (download, upload, exceptions, the edits
Etar, Fossify and CONTENT_EXCEPTION_URI make, echoes that write nothing,
time zone and DST edges), and `npm run typecheck`, `npm test` and `npm
run i18n:check` on this commit.
2026-09-27 04:21:30 +02:00
Linus Rath 96a6b38321 feat: sync the device's contacts and calendars with the server in the background #34
The sync adapters from ed2525e start a headless task for every sync, but
the task only reported "disabled". This adds the engine it runs
(`src/device-sync/engine`, `src/device-sync/jmap`): for one Android
account and authority it downloads the server's changes, uploads what
the device changed, and hands a report back to SyncManager.

- Downloads follow `/changes` from the state kept in the provider's
  SyncState. Rows and the state they reach are written in one batch, so
  a killed run resumes where it stopped. Without a state, after
  `cannotCalculateChanges` or after the user discarded deletions, a full
  reconcile compares ids instead.
- Uploads go in three phases across accounts (creates, updates,
  deletions), so moving an item to another account never destroys it
  before the copy exists. A create carries a uid written to the device
  first, and the server is asked for that uid before creating, so a lost
  response or a killed run never makes a duplicate. Updates are patches
  guarded by `ifInState`; nothing is replaced whole.
- More than 50 deletions that are also more than a fifth of the synced
  items hold every deletion back and report `tooManyDeletions`, so
  Android asks the user; "undo" restores the items from the server.
- An item the server refuses is marked and not sent again until it
  changes or its back-off (1 hour, doubling to a day) ends. Such
  refusals and planner errors stay with their item; the rest of the run
  goes on.
- Runs of one account and authority take a lock, stop at checkpoints
  when cancelled or close to the deadline, and never throw: every
  failure becomes an outcome, and a rejected sign-in posts one "Sign in
  again" notification.
- The JMAP side uses a client of its own per app account, never the
  UI's, since a run can share the JS runtime with the app.

Turning sync off or signing out runs the same upload first
(`teardownAuthority`) and deletes the rows only once nothing is waiting,
or when the user agreed to lose it.

The planners that map items to rows land in the next commits; until
then a run fails before it touches a row.

Checked: 94 engine tests against the fake provider and JMAP server
(sync flows, failures, crashes and retries, teardown), and `npm run
typecheck`, `npm test` and `npm run i18n:check` on this commit.
2026-09-27 04:19:21 +02:00
Linus Rath f743ab0f7d feat: give device sync scoped access to the contacts and calendar providers #34
The engine plans its provider reads and writes in JS, but only native
code can reach ContactsProvider and CalendarProvider. This adds that
bridge to the BulwarkDeviceSync module, with the rest of what the engine
and the app need from it:

- `query`, `applyBatch`, `readSyncState` and `readPhoto` take the
  `ProviderQuery`/`ProviderOp[]` JSON of `src/device-sync/types.ts`,
  check it (`sync/ProviderOps.kt`) and run it for one Android account of
  our type (`sync/ProviderScope.kt`, `sync/ProviderIo.kt`). Every
  selection gets the account ANDed in, every row an op names by id or
  points to as a parent must be the account's, and every URI carries
  `caller_is_syncadapter`, so nothing the sync writes marks rows dirty or
  schedules an upload. The calls run one at a time on the module's own
  thread through an unstable provider client, so a long batch never
  stalls other module calls and a provider that dies fails the call
  instead of the app.
- `showSyncProblem` and `clearSyncProblem` post and clear the one
  notification per account that needs the user, e.g. "Sign in again".
- `setPushRoutes` stores which Android accounts each JMAP account feeds,
  for the push router in `sync/PushRouting.kt`; the push services start
  using it in a later commit.
- A run now ends exactly once and refuses a report that comes after
  that. A sync requested while a run is open makes the run ask for
  another sync when it ends, because SyncManager drops such requests as
  duplicates. `onPerformSync` lets nothing escape any more: an exception
  there killed the whole process, UI included.
- `ensureAccount` no longer hands an existing Android account to another
  app account (`conflict`): its rows and SyncState belong to the first.

Payloads cross as JSON strings, which the bridge moves much faster than
nested maps. `src/device-sync/native.ts` is the typed JS side. The fake
provider follows the rules the emulator runs turned up: contacts
`data1`..`data14` read back as text, events and calendars the sync
adapter inserts have DIRTY NULL, attendee rows are addressed by id, an
op by id expects one row, and a vanished parent fails with `assert`
while another account's fails with `scope`.

Checked: 54 JVM tests (`gradlew :app:testDebugUnitTest`), debug and
release (R8) builds, `npm run typecheck` and `npm test` on this commit,
and on the API 32 emulator through the debug receiver: inserts with
back-references and group memberships, SyncState, photo writes and
scaled reads, asserts and expected counts, both scope refusals, the
499-op yield boundary, `tooLarge` for a 1.3 MB batch, and the
notification.
2026-09-27 04:04:00 +02:00
Linus Rath 95c8324f12 fix: let contacts apps edit contacts of the bulwark account #34
AOSP Contacts, and the apps built on its account-type parser, check the
EditSchema in `xml/contacts.xml` and drop the whole account type when it
breaks one of their rules: every data kind with a type column needs
`<Type>` children, the other kinds must have none, and name, photo,
nickname, organization, note and group membership need maxOccurs="1".
The schema from ed2525e listed phone, email, postal, event and
relationship without types, so the editors would not have offered the
Bulwark account at all.

The kinds now list the types device sync maps to JSContact, and the
rules are written down in the file so the next edit keeps them.

Checked on the API 32 emulator (AOSP Contacts): with a Bulwark account
present, "Create new contact" offers it and opens the editor saving to
it.
2026-09-27 04:03:16 +02:00
Linus Rath 59fcf386b9 test: add a fake device provider and jmap server for the sync tests #34
The device sync engine reaches Android's providers through a native
bridge and the server through a detached JMAP client, and neither runs
in vitest. This adds the stand-ins the engine and the mappers are tested
against, and the contracts they implement:
- `src/device-sync/types.ts`, `planner.ts`, `wire.ts`,
  `android-columns.ts`: the bridge, the provider op model, the JMAP
  port, and the interface between the engine and the contacts and
  calendar planners
- a fake ContactsProvider and CalendarProvider with the behaviour the
  design relies on: the DIRTY and VERSION rules, soft and hard deletes,
  exception linking, normalisation, asserts, the yield-point limit, and
  a "user" face that edits the way AOSP Contacts, Fossify and Etar do
- a fake Stalwart with `/changes` paging and compaction,
  `cannotCalculateChanges`, `ifInState`, uid checks behind a lagging
  search index, positional re-keying and fault injection (lost
  responses, 429, auth, SetErrors)
- shared pure helpers (canonical JSON, PatchObjects with Stalwart's
  missing-parent rule, key and uid minting) and a test that walks the
  import graph, so no mapper or engine module pulls in React Native
- `src/stores/device-sync-store.ts`: the per-account preferences and
  last run status the engine and the settings will share

Checked: `npx vitest run src/device-sync` (91 tests), the full suite
(2209 tests) and `npm run typecheck`.
2026-09-27 02:53:22 +02:00
Linus Rath f66669964a docs: describe how contacts and calendars will sync with the device #34
Issue #34 asks for contacts and calendars that show up in Android's own
apps and whose edits there reach the server. docs/device-sync.md is the
design the implementation follows: Android's sync framework drives the
runs, a thin Kotlin shell starts a headless JS task, and the engine is
TypeScript on a detached JMAP client, so token refresh, mTLS and session
discovery keep a single owner. A pure-Kotlin engine was rejected: it
would have had to redeem rotating refresh tokens outside that owner and
re-implement the client.

It pins down what every provider column holds, the JSContact and
JSCalendar mapping tables, how device edits are told apart from provider
normalisation (a baseline per row), how rows are matched when an editor
re-inserts them, the merge and conflict rules, uploads as patches with
`ifInState`, the native API, the failure matrix and the limitations.

The platform and server facts it relies on come from AOSP's providers
and calendar/contacts apps, Fossify, Etar and Stalwart v0.16.23's
source. These were checked on the local test server:
- map keys written through JMAP survive patches; `uid` duplicates are
  refused; unknown and plural recurrence properties are rejected
- a start or `until` in a DST gap or overlap is silently dropped (a
  daily rule came back unbounded); `Etc/GMT-2` keeps both
- `ifInState` yields `stateMismatch`; `relatedTo/<key>: null` does not
  delete; result references to `/set` are refused

An adversarial review of the first draft found 20 paths that could lose
data, duplicate items or loop (own creates duplicated after a lost
response, unsynced edits dropped on deselection or eviction, Fossify
rewrites uploaded as edits, Etar moves turned into destroy + create,
and more); the design was changed for each before any engine code.
2026-09-27 02:49:46 +02:00
Linus Rath ed2525e5cb feat: register a bulwark account with android's contacts and calendar sync #34
Android syncs the contacts and calendars of other services through
accounts and sync adapters. This adds the Android side of device sync:
a "Bulwark" account type (`${applicationId}.account`, set in
build.gradle), an authenticator that sends Settings → Add account to
the app instead of asking for credentials, and one sync adapter each
for the contacts and the calendar provider.

An adapter does no syncing itself. It starts the BulwarkDeviceSync
headless JS task from the main thread, through the helper the push job
uses, waits for the task's report and hands it to SyncManager as the
SyncResult. The engine that will write the report runs in JS, so the
existing client stays the only owner of token refresh, mTLS and
session discovery; for now the task only reports that device sync is
off. Nothing in the app creates an account yet, so the feature stays
unreachable until its settings land. Debug builds get an adb hook for
the device tests (DeviceSyncDebugReceiver, shell only).

Hard errors are reported without progress: SyncManager retries a
failed sync at once when it made progress, which would loop an auth
failure.

Checked on the API 34 and API 32 emulators with the debug build, from
logcat and `dumpsys content`:
- app in the foreground: report after 319-334 ms
- app in the background: 320-327 ms
- process killed: SyncManager starts it for the sync service, React
  Native starts headless, and the report arrives after 16.2 s (API 34)
  and 8.5 s (API 32), most of it the debug bundle download from Metro
- an io report is retried with back-off and an auth report is not
  retried; too many deletions shows the system's "Attempted to delete
  too many Contacts." notification
- with Metro unreachable the adapter gives up after 2 s instead of
  waiting for its deadline
- `gradlew :app:testDebugUnitTest`: 10 JVM tests for the report mapping
2026-09-27 01:53:40 +02:00
Linus Rath 1f7acaf08a refactor: start headless tasks for push and device sync the same way
The code that starts React Native for a headless JS task, when the
process was woken without any UI, lived inside BulwarkPushJobService.
The device sync adapters need the same start, so it moves to a shared
HeadlessJs object, with a small startTask helper next to it.

Push behaviour does not change:
- onStartJob still calls the start code on the main thread, and the
  listener-then-ready-check order is the same code, moved verbatim
- the task still starts from `UiThreadUtil.runOnUiThread`, its id is
  still recorded in that same UI-thread turn, and a refusal because the
  app came to the foreground still logs and finishes the job
- the start code now returns React Native's start task, so a caller can
  notice a start that fails (a debug build with no Metro); the push job
  ignores it

The helper documents that it must run on the main thread: the ReactHost
is created on first use without a lock, so a background caller racing
MainActivity could create a second one.

Checked with `gradlew assembleDebug -PreactNativeArchitectures=x86_64`.
2026-09-27 01:19:17 +02:00
Linus Rath db07acc37c chore: update version number to 0.1.66 2026-09-27 00:34:53 +02:00
Linus Rath 39628d84dc fix: keep widget corners and border even when the launcher crops the widget 2026-09-27 00:34:29 +02:00
Linus Rath 78901f2375 chore: update version number to 0.1.65 2026-09-26 20:30:38 +02:00
Linus Rath a4985a63a8 fix: open replies over the message so widget links work offline 2026-09-26 20:25:21 +02:00
Linus Rath f7ec09942d chore: update version number to 0.1.64 2026-09-26 18:53:38 +02:00
Linus Rath c165fef588 feat: add android home-screen widgets for mail, calendar, tasks and files 2026-09-26 18:53:25 +02:00
Linus Rath e420e3d576 fix: keep an occurrence's own change when another one moves onto its day
Changing every occurrence of a series takes its changed and cancelled
occurrences along, and two can still meet on one occurrence (bda118b).
A series on Mondays and Tuesdays that starts a day and an hour later
takes its first Monday's change onto the Tuesday, at the new time,
where the Tuesday's own override lands as well. Only an override that
stayed exactly where it was won such a meeting. Here neither did, so
the order the server listed the overrides in decided, and it could
keep the Monday's change and lose the Tuesday's cancellation.

The override that stays where it is now wins, then the one that stays
on its own day, then the first; the others are left out and logged, as
in the web app. The rest of a series split "this and following" keys
its overrides the same way.
2026-09-26 05:10:27 +02:00
Linus Rath 726e087795 fix: keep offline edits when the app cannot read an account's server record
The app records which server filled each account's caches and checks it
with every new session (a330d77). A read of that record that failed -
a storage error, such as a busy database - answered "none recorded",
so the session counted as the first sight of the account: its queued
offline edits were dropped, with a notice that the mail server had
changed when it had not, and the record was written anew. Had the
server been replaced, that record hid the change from every later
check.

A read that fails now fails the check: nothing is dropped or written,
and the next session checks again. As with any failed check, signing
in goes on.
2026-09-26 05:09:49 +02:00
Linus Rath b307ad1b26 fix: keep a working account when a switch fails during sign-in or start-up
A switch that began while an account was being added, or while a cold
start still brought the last account up, made that sign-in or restore
give up (efd31de). When the switch then failed as well, what it put
back was no working account:

- during an add-account sign-in, the switch fell back on a snapshot of
  the client, which served no account before the new session was up
  and the new account after. The app stayed on the account in use while
  every call failed with "Not connected", the session retry saw a
  session and returned, and tapping the account did nothing; or the
  client served the new account under the one in use, whose calendars,
  contacts and identities then came from it, and whose sends went
  through it.
- a cold start shows the last account's cached mail, drawer and all,
  while its session loads. A switch from there that failed left the
  app on the sign-in screen, with every account still registered.

A switch now falls back on the connection an add-account sign-in under
way saved, and resumes the queue of offline edits the sign-in held, as
a failed sign-in does itself. It waits for a restore under way and
switches from the account restored. A sign-in waits for switches under
way and a restore for neither, so none of them waits on another.
2026-09-26 05:09:12 +02:00
Linus Rath 8918b789e9 fix: keep the organizer and list occurrences once in the rest of a split series
Two faults in the new series "This and following events" makes, found
on 0.16.23 with the app's own code against the test server (1.0 did
not show them):

- The new series named its organizer only when the editor wrote one,
  which it does only for an event that had none. Created without it,
  0.16.23 makes the user who creates the event its organizer, whatever
  the owner participant says: a sharee who split the owner's meeting
  became the organizer of the rest (organizerCalendarAddress
  mailto:usera, isOrigin false, the invitation sent from the sharee),
  and a series organized under an alias would have moved to the user's
  main address. 1.0 takes the owner participant. The new series now
  carries the series' organizer address whenever it has participants.
- The overrides the new series takes over kept the old series' SEQUENCE
  while the new series starts at its own, and 0.16.23 listed each such
  occurrence twice, as changed and as the series has it, for the
  organizer and the attendee alike. The overrides now leave the old
  number behind.
2026-09-26 04:03:48 +02:00
Linus Rath bda118bc55 fix: keep occurrence changes on their days when a series moves or ends
Three faults in how a series' changed and cancelled occurrences follow a
change of the whole series (found in review, live on 1.0 and 0.16.23 for
the web's version of the same code):

- A rule that names its days - a series on Mondays and Tuesdays, one on
  the 5th and the 6th of each month - keeps its occurrences on those days
  when its start moves. Moving every occurrence by a day moved each
  override along anyway, so the Monday's landed on the Tuesday's key and
  one silently overwrote the other: the cancelled Tuesday came back with
  the Monday's title, and the Monday lost its change. Under such a rule
  every override but the first occurrence's now stays on its day, at
  the series' new time; the same holds for the new series of "this and
  following". Two overrides that still meet on one key (an hourly series
  turned all-day) no longer overwrite each other: the one that stays
  where it is, else the first, is kept, and the others are left out and
  logged.
- "Does not repeat" for every occurrence kept the first occurrence's
  override on the single event, because a series' start counts as an
  occurrence of any rule: cancelled, 1.0 left the event out of every
  range query (still stored); retitled, 0.16.23 listed it twice. A series
  made single now keeps no overrides.
- The SEQUENCE a change of every occurrence raises (0.16.6 to 0.16.23
  refuse a lower one) came from the series as the app held it. A series
  opened from a reminder or a link was that copy, possibly older than the
  server's. The store now reads a series from the server every time a
  scope edit asks for it, so the raise, the overrides a split or a move
  rewrites and the rule all start from what the server holds.
2026-09-26 04:03:05 +02:00
Linus Rath e6baa751a9 fix: keep single edits off a series opened as itself and end series west of UTC
More edits of a recurring meeting went wrong at the edges of a series
(0.16 and 1.0 alike, attendees included):

- A series the app shows as itself - opened from a reminder or an
  event link, or one an edit just made recurring (0.16 before 0.16.20
  showed it until the next reload) - took "This event only" as a change
  of the whole series: `CalendarEvent/set` on the series' id with the
  editor's title, start and rule. "Delete this event only" deleted the
  series. The series now stands for its first occurrence: a change
  becomes an override of it, in two writes when the user organizes the
  meeting, sends invitations and an attendee has answered (Stalwart
  sends a new override only to attendees who have not), and a delete
  excludes it. "This and following" from the first occurrence changes,
  or deletes, the whole series instead of ending it before its first
  occurrence. A series opened from a reminder on a calendar shared with
  the user is now known to the store, so the change goes to the owner's
  account under the series' own id.
- An edit that makes an event recurring, or single again, now loads the
  range again, so the grid shows the occurrences instead of the series
  itself.
- 0.16 (0.16.23 checked) reads a rule's UNTIL as the series' wall clock
  taken for UTC, so in a zone behind UTC a split or "delete this and
  following" kept the occurrence it was to end before. The UNTIL is now
  earlier by the zone's offset (the larger of the split's day and the
  day before), which ends the series there on 0.16 and 1.0 alike.
- 0.16.6 to 0.16.23 send a change of one occurrence with its SEQUENCE
  raised but do not keep the raise, so a later change of every
  occurrence - a new title - went out with a lower number for that
  occurrence and the attendee's server refused it. A change of every
  occurrence that the user as organizer sends, of something the
  attendees see, on a series with changed occurrences, now raises the
  SEQUENCE of the series and of each changed occurrence by one. 1.0
  raises and keeps the number itself; the result there is the same. The
  app's update sends `sequence` as it is, so nothing has to keep it.
- An existing override written on the base event (0.16 before 0.16.20,
  and ranges the device expands) gets the series' organizer again when
  it has none: 0.16.19 refuses an answer to such an occurrence. 1.0
  refuses the key inside an override once, and the store writes the
  override again without it.
2026-09-26 04:02:22 +02:00
Linus Rath 9025efee77 fix: move a series' changed and cancelled occurrences along when it moves or splits
A series keeps its changed and cancelled occurrences as overrides keyed
by recurrence id, where the rule puts each occurrence. The app's series
edits left them behind and made more of a series than was asked (0.16
and 1.0 alike, attendees included):

- "All events" that moved the series kept the overrides under the old
  ids: a cancelled occurrence came back, a retitled one showed twice.
  It also wrote everything the editor showed to the series, so a new
  room on an occurrence with a title of its own renamed the series,
  and it measured a move on the calendar's clock, so across a week in
  which only one of two zones changes to or from summer time the series
  moved an hour more or less than picked.
- "This and following events" ended the rule but not the overrides
  after the split, which showed on next to the new series; the new
  series got the whole COUNT again and started at the opened
  occurrence's own time in the calendar's zone. Deleting "this and
  following" left the later overrides too.
- A failed create of the rest of the series put back only the rule, and
  without telling the attendees the truncation had told.

Now, as on the web (`lib/recurrence-scope-edit.ts`, a port of the
webmail module of the same name):

- "All" moves each override with its occurrence in the same update: by
  the series' move, onto the same day at the new time when the series
  turns all-day or timed. An occurrence moved on its own keeps its time,
  except the one the editor was opened on; a new rule drops the
  overrides it no longer has, and overrides that move ask their
  attendees again when the series does. Only the fields the user
  changed go to the series, and the move is measured on the series'
  own clock.
- The split removes the overrides from the split on together with the
  rule and gives them to the new series, re-keyed and with its
  participants (0.16 keeps an override as an event of its own). The new
  series goes on from the split's recurrence id in the series' zone,
  moved only by the editor's move; its COUNT is what is left, an UNTIL
  stays, and a rule the user changed is taken as it is. It takes the
  old series' details, not the opened occurrence's own ones.
- A failed create puts back the rule and the whole overrides map (a
  pointer fails on a series left without overrides), with the
  truncation's scheduling, and fails the save; a store that answers
  with nothing counts as failed.
- The truncation tells the attendees only when the user organizes the
  series and sends invitations; an attendee ends their own copy
  quietly.

The scope handling moved from CalendarScreen into the new module, where
it is tested. The editor hands its dates to the save. An invitation to
one occurrence alone, which has no series, is changed or deleted as
itself for every scope instead of making a second event.
2026-09-26 04:01:39 +02:00
Linus Rath 198e952f61 fix: tell attendees when someone with write access changes the owner's meeting
Someone who writes to a calendar shared with them (read & write) changes
the owner's meeting there on the owner's behalf: Stalwart sends the
change from the addresses of the account the event is in. The app
decided who organizes by the user's own addresses only, so it took the
sharee for an attendee (0.16 and 1.0 alike):

- the editor wrote no participants, hid "Send invitations" and sent
  nothing, so the attendees kept the old time and their answers
- "All events" asked nobody again, and "This and following events"
  made the rest a new series that kept the old answers, which Stalwart
  sends to nobody who had answered

The app now reads `isOrigin`, which both versions compute from the
addresses of the account the event is in: true for the owner's meeting
and every expanded occurrence of it, false for an invitation the owner
got. `userOrganizes` counts such a copy as the organizer's; an
invitation is still changed without messages. "This and following"
asks by the old series, since the new one carries no `isOrigin` yet.

The editor no longer leaves the user's own main address off the
attendees it opens with, only the organizer's (or the user's, for an
event that names none yet): a user who invited their main address to a
meeting organized under an alias lost it on the next save.
2026-09-26 04:00:59 +02:00
Linus Rath 3e6e3d28e1 fix: keep a scheduled message in the app when its new time loses a recipient
"Send now" and "Reschedule" create a replacement submission and then
cancel the original. The server checks the recipients again for the
replacement, and a throttle used up since the original was taken ("452
4.4.5 Rate limit exceeded") can refuse some of them. The app kept the
original only when every recipient was refused; on a partial refusal it
cancelled it, and a recipient the original had taken lost the message.
Port of the webmail's aa99f038.

- the original's delivery status is read with its envelope, in the same
  request; when the replacement refuses a recipient the original took,
  the replacement is withdrawn (cancelled, or only its record destroyed
  when it took nobody) and the original goes at its old time to
  everyone it took
- a recipient the original was refused for as well loses nothing, so
  the reschedule goes ahead and names it; without a delivery status of
  the original, every recipient counts as taken
- a replacement that can't be withdrawn lets the reschedule go ahead,
  naming the refused recipients: "Send now" holds it for a second only,
  so it may be out already, and keeping both would send the others two
  copies; a withdraw whose answer got lost is read back, and when it
  went through the original is kept, since going ahead would reach
  nobody
- the alert in Scheduled and the undo bar's toast lead with "The
  message is still scheduled for ..." and say "Nothing was changed: the
  server refused ..."; they used to open with "Not sent", which invited
  sending the message a second time

Checked live on 0.16.23 and 1.0 with a relay-refused recipient: the
lookup, going ahead, and withdrawing a partly refused replacement while
the original stays at its old time. One new string, with the webmail's
translations in all 27 languages.
2026-09-26 02:51:36 +02:00
Linus Rath 5dcf5991ab fix: open the composer before the session is up without crashing 2026-09-26 01:46:42 +02:00
Linus Rath 7b18063630 fix: stop a start-up crash, and stale views after sign-out or a new server
- The app crashed on every cold start whose cached folders held a
  shared or group folder, which on Stalwart 1.0 every group member has:
  the sidebar works out the accounts to span while it renders, before
  the session is up, and the JMAP client's account id throws until one
  is. The client now also names its account without throwing, for code
  that runs while rendering; the mail list's account helpers (the
  sidebar's accounts, a row's account, the viewer's pages) use it, and
  a read receipt sent by itself waits for the session. API calls still
  throw without a session.
- Signing out of the last account left the app on an empty mail view
  ("No mailboxes found") with nothing to retry: the account list was
  read before the account was removed, so the app switched to the
  account it had just signed out of, which did nothing. It is read
  again now, and the sign-in screen shows. A failed switch to the next
  account no longer puts the signed-out one back: when its server does
  not answer, that account shows offline, as on a cold start, and one
  whose sign-in is refused is skipped.
- After signing in with the same address on another server (another
  port) or on a server reinstalled at the address (0.16 to 1.0), Files
  kept showing the old server's drive without asking the new one, and
  actions on its rows would have used the old server's ids. The local
  account id leaves the port out and stays the same there, so the
  drive now also starts over when the server behind the session
  changes, and is read again with every new session. The account id
  itself is unchanged: it keys the stored credentials and every
  per-account cache, and changing it would sign such accounts out and
  orphan their data.
2026-09-26 01:44:47 +02:00
Linus Rath 1caf73a3e9 fix: never send an occurrence change to the next occurrence
A first change to one occurrence that keeps the attendees' answers goes
out in two writes: the override as the occurrence stands, quietly, then
the change as an update of it. The occurrence is looked up again in
between, because the new override can give it another synthetic id.
When that lookup failed, the change went to the old id. Up to Stalwart
0.16.20 synthetic ids are positions in the series' expansion
(calendar_event/mod.rs), so after the new override the old id can name
the next occurrence, which then got the change and its scheduling
messages. 0.16.21 and 1.0 key the ids by recurrence id, and the id
stays (checked live on 1.0 and 0.16.23).

Now, when the occurrence is not found again, found twice, or the lookup
fails, nothing more is sent and the editor says the change was not
saved. The quiet override changes nothing that shows; the calendar is
read again so a retry uses the ids the series has now, and goes out as
an update of that override, which reaches every attendee. The message
is translated into all 26 languages.
2026-09-26 01:44:05 +02:00
Linus Rath a330d77d6d fix: keep push, queued edits and iCal feeds safe on a replaced server
The app compares the server behind an account with the one it recorded
and drops what came from a replaced one. The released version recorded
none, so on the first start of this one there is nothing to compare
with, and a server replaced before the update (Stalwart 1.0 is a fresh
install) went unnoticed:

- the push subscription expiry the released version recorded made the
  subscription the new server lacks look revoked, and push was turned
  off for good; it is now forgotten on the first sight of an account, so
  the subscription is re-created
- the queue of offline edits the released version left was replayed
  against the new server, where its ids name other messages, so a
  queued delete deleted another message; it is now discarded on the
  first sight, with the notice a replaced server shows. Nothing proves
  a queued edit belongs to the server: the queue records none, a
  reinstalled Stalwart may accept old JMAP states, and a queued move or
  delete took its message out of the cached list, so there is no copy
  to compare the server's message with

iCal subscriptions recognise their mirror calendar by name, but a sync
still imported the feed into whatever calendar the recorded id named:

- the feed now goes only into a calendar that carries the
  subscription's name, and otherwise into a new mirror; when the name
  can't be read, the sync fails and is tried again
- a rename the server refused renamed the subscription anyway, which
  then no longer recognised its mirror: pruning stopped and removing the
  subscription kept the calendar. Now nothing changes and the error is
  shown
- a replaced server claimed subscriptions made before they recorded
  their account for every account with the same JMAP id, another
  server's account included. They now go only when no other signed-in
  account has that id, or may have it, and a sync that finds their
  mirror records the account on them
2026-09-26 01:43:22 +02:00
Linus Rath efd31de051 fix: never send an account's offline edits to another account
Adding an account put the new account's session in the JMAP client as
soon as it was up, while the queue of offline edits (outbox) stayed on
the account in use, and ready to replay, until the new account's server
was checked. A retry, a network flip or the return from the OAuth
browser in that window replayed the queue through the new account, and
edits to the user's own mail took its JMAP account id. Stalwart's ids
repeat across accounts, so a queued move or delete of one account's
message "x" hit the other account's "x". An edit made in that window ran
the same way, and so did the rest of a replay already under way.

Account switches could overlap too: tapping B, then C before B's session
was up, left the mail list on C while B's switch, finishing last, put B
in the client and its queue in the outbox, so edits in C's list went to
B under C's ids. An older switch that failed last put back the account
it had left, under the newer one.

- the outbox replays and runs edits only while the client serves the
  account the queue belongs to, checked before every op, and holds the
  queue while another account signs in; a failed sign-in gives the
  client and the queue back to the account in use
- the outbox resumes only for the account the mail list shows
- every account change (a switch, a sign-in, a sign-out) takes a
  generation, and one a newer change overtook touches neither the client
  nor the stores; the client no longer installs the session of a load or
  sign-in that a newer one overtook
- a failed switch puts back the account the first of overlapping
  switches left, and tapping that account while they are under way goes
  straight back to it
- a session retry gives way to a switch or sign-in under way
- tokens refreshed for an account being left stay with that account;
  they were saved into the credentials of the account loaded meanwhile,
  which then signed in as the other one
2026-09-26 01:42:35 +02:00
Linus Rath 206756e13c fix: stop treating narrow refusals as total ones in the app's sends and exports
Two places took a narrow refusal from the server for a complete one.
Port of the webmail's 7977a6ed.

- Sending at a full submission quota (Stalwart 1.0): when making room
  found nothing, no search ran for five minutes, for any send. Scheduled
  mail and undo-send holds can go once their message is out, and until
  the next search every send failed with "ask your administrator". A
  send the user starts, a read receipt included, now always looks again;
  the pause stays for automatic read receipts.
- vCard export: Stalwart answers Blob/get only in accounts the user is a
  member of, so for an address book shared with the user it came back
  `forbidden` ("You are not an owner of account ...") and the export fell
  back to one photo download after the other. It checks the access to
  each blob by itself, whichever account the request names, so
  `getBlobsAsBase64` now asks again in the user's own account, on 0.16
  and 1.0 alike.
2026-09-26 00:52:43 +02:00
Linus Rath 7326fec611 fix: stop reporting mail as sent in the app when the server refused its recipients
Stalwart runs the SMTP RCPT stage while it creates a submission and
records a refused recipient as `delivered: "no"` with its reply: a relay
it doesn't allow ("550 5.1.2 Relay not allowed.") or a used-up throttle
("452 4.4.5 Rate limit exceeded, try again later."), on 0.16 and 1.0
alike. The create still succeeds and onSuccessUpdateEmail files the
message in Sent. The app read only the new record's id and sendAt, said
"Email sent", and on Stalwart destroyed the record right after, the only
trace of the refusal. The message was lost without a bounce. Port of
the webmail's e0b0dfe0.

- the send reads the new submission's delivery status in the same
  request (`EmailSubmission/get` of `#sub-1`, its creation id; the
  create's own answer has none), also with the retry after a full
  submission quota, before the record is destroyed
- every recipient refused: the send fails with the server's reply per
  address, the copy filed in Sent is removed and the previous draft
  kept, so the composer or the quick reply keeps the text and the
  address can be fixed; held sends (undo send, scheduled) are refused
  when they are created, so this covers them too
- some recipients refused: the others get the message, and a warning
  names the refused ones with their replies
- "Send now" and "Reschedule" check the replacement the same way and
  keep the original, which still goes out at its old time, when every
  recipient of the replacement is refused; a partial refusal there gets
  the warning as well
- the submission prune leaves a shared account that refuses its records
  with `forbidden` alone while the app runs, for that login, with a
  debug log instead of a warning, and the scheduled list skips it:
  0.16.23 lists an account that only shared an address book or a
  calendar with the submission capability

`RecipientsRefusedError` and `RefusedRecipient` are added to
`jmap-result.ts`. Five new strings in all 27 languages, four of them
with the webmail's translations.
2026-09-26 00:51:59 +02:00
Linus Rath 00619751d7 fix: never send a rescheduled message twice and handle partial refusals
"Send now" and "Reschedule" at a full submission quota (Stalwart 1.0)
made room and then created the replacement again. Making room can take
a while - a prune, a walk through the change log - and the original
could go out meanwhile, so the replacement sent it a second time, with
a HOLDFOR that still counted from before the wait. The original is now
read again first: nothing is resubmitted unless it is still pending
("This message was already sent" or "no longer scheduled" otherwise,
and the undo bar lets go of it), a failed read changes nothing, and the
hold is worked out again from the time the user picked. Port of the
webmail's 65eb4b10.

- the Stalwart explanation of a full quota (Bulwark removed what it
  could, an administrator has to raise the limit) needs
  `urn:stalwart:jmap` on the submission account; another server's
  `overQuota` gets a generic sentence with its own description
- when making room finds nothing that can go, the account is not
  searched again for five minutes; each send, reschedule and automatic
  read receipt at a full quota used to run the prune, the change-log
  walk and the gets again
- unsubscribing by mail at a full quota explains the quota instead of
  only saying "Unable to unsubscribe"
- push on Stalwart 1.0 (`principals:owner` on the account) takes the
  emailpush capability as the answer to which accounts notify: a group
  with only mail no longer counts as unreachable, and a refusal
  remembered under 0.16 no longer keeps a group out of the map
- a push re-registration the user asked for at the server's
  subscription limit removes the old record first and tries once more;
  the old record counted against the limit, so it could never be
  replaced
- "Mark folder as read" on a folder and its subfolders goes on past a
  folder the server refuses and reports the refusal at the end; the
  folders and the list refresh either way
- marking, starring or tagging a selection writes every account before
  reporting a refusal and shows only the messages the server updated;
  one refusing account or message used to leave the rest unwritten,
  and what was written looked unchanged
- a new photo for a contact opened from the cache is not saved when the
  card's media can't be read from the server either; it would have
  replaced the card's other media (a logo, more photos)
2026-09-25 23:51:39 +02:00
Linus Rath a562ea59b8 fix: keep attendees' answers on save and deliver occurrence changes
Every save in the event editor rebuilt the participants with new ids
and put every attendee back to needs-action, the user's own answer on
an invitation included. Saves now keep each attendee's id and answer;
new attendees are asked. As on the web, only an organizer who moves an
event (its start, end, all-day or recurrence rule; not a time zone
rewrite, and not a zero-length event) asks the others again.

The series scopes did not fit that:

- "All events" wrote the opened occurrence's start, length, zone and
  participants onto the series: a stretched occurrence stretched every
  one, and one day's decline spread to the whole series. The series now
  keeps its own list and takes only the attendees the user added or
  removed, and its times change only when the occurrence moved (by the
  same delta), its rule only when it changed.
- "This and following" makes a new series, which Stalwart sends only to
  attendees who have not answered: its attendees are asked again.
- Stalwart sends a new occurrence override, the first change to one
  occurrence, only to attendees who have not answered (0.16.6 to 1.0).
  A change that keeps answers now goes out in two writes: the override
  as the occurrence stands without scheduling, then the change as an
  update of it, to the occurrence looked up again. Checked live on 1.0
  and 0.16.23.
- A save of an invitation someone else organizes writes no participants
  while the user left them alone, and never asks for scheduling, so the
  organizer gets no reply for a changed alert. The switch to send
  invitations is hidden there.

Also:

- a reminder change on one occurrence of a series that uses the
  calendar's default alerts turns the defaults off for the series first
  (keeping what it shows under ids of its own); 1.0 kept showing the
  defaults
- all-day and floating occurrences expanded on the device no longer
  carry the master's utcStart/utcEnd, so their reminders are scheduled
  for each day; master instants that are not the master's own (0.16.23's
  year 32548, or the next occurrence's) are ignored
- the editor warns when the attendees and the organizer go over the
  calendars capability's maxParticipantsPerEvent, and a refusal for it
  (Stalwart 1.0 enforces it, answers included) is explained in the
  editor, the series dialogs, the detail sheet and the invitation
  banner, in all languages; a failed save used to fail silently
- refused event creates, updates and answers keep their SetError type
- Calendar/get asks for its properties by name
2026-09-25 23:50:29 +02:00
Linus Rath f490886a8b fix: keep iCal subscriptions safe when the mail server was replaced
When the server behind an account is replaced (a fresh Stalwart 1.0
install at the address 0.16 ran on), its ids start over. The app
already dropped its caches then, but kept what the user set up with
server ids inside:

- iCal subscriptions kept the ids of their mirror calendars. The next
  sync deleted the "stale" events of whatever calendar now had the id
  and filled it with the feed, and "Remove subscription" destroyed it.
  Now the account's subscriptions keep their feed, name, colour and
  interval but lose their mirror, and the next sync makes a new one as
  adding a subscription does. Renaming or removing an unlinked one
  touches no calendar. Events are only pruned from, and remove only
  destroys, a calendar that still carries the subscription's name.
  Subscriptions now record the signed-in account, so accounts on two
  servers that got the same JMAP id no longer share them.
- the preferred sender identity kept its id, which a new server can
  give to another address (live: identity "b" is usera@ on 0.16.23 and
  team@ on 1.0). It is forgotten for the account's old and new JMAP
  id, which the app now records with the server fingerprint.
- hidden calendars kept their ids. The list is shared by all accounts,
  so it is cleared.

Offline changes the drop discards are now announced once ("3 offline
changes were discarded because the mail server changed"), in all
languages.

Revoking this device from another one turns push off here again: a
subscription gone before its recorded expiry counts as revoked, as it
did before the server check came in. A replaced server forgets the
recorded expiry, so there the subscription is re-created instead.
2026-09-25 23:49:36 +02:00
Linus Rath a2ca98e0e8 fix: send offline edits after a restart and never to another account
The queue of offline edits (outbox) and the offline mail cache were
attached to an account only when the email store switched to it. A cold
start restores the view of the account it showed last, so there was no
switch: the queue from the last session was never loaded or sent, an
edit made meanwhile was dropped with a console warning, and the offline
cache neither stored nor served bodies.

On an account switch the queue was loaded and flushed right away,
before the new account's session was up and before the check for a
replaced server. The JMAP client still served the previous account
then, so queued edits could reach it.

- the email store attaches the outbox and the offline cache whenever it
  is pointed at an account, also the one it already shows
- the outbox holds its ops until the auth store resumes it, once the
  account's session is up and its server was checked; edits made before
  that are queued, and edits made while the queue loads are kept
- the JMAP client serves no account while it loads or signs in to
  another one; a failed switch puts the previous account and its queue
  back
- dropping an account's queue or cache for a replaced server cancels a
  read of it still under way
- the offline sync stops when the cache or the client moves to another
  account, instead of filing one account's mail under another
2026-09-25 23:48:51 +02:00
Linus Rath 2e26ff7b64 fix: report refused tags and re-check the sort order after a server change
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.
2026-09-25 20:59:19 +02:00
Linus Rath 42751a6b29 fix: keep the app's requests away from Stalwart 1.0's unfinished JMAP Metadata
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.
2026-09-25 20:58:33 +02:00
Linus Rath 97daf76f05 fix: start over when the mail server behind an account was replaced
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.
2026-09-25 20:57:46 +02:00
Linus Rath e8a9c58d18 fix: keep push notifications working when a mailbox is shared with you
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.
2026-09-25 20:56:57 +02:00
Linus Rath 3f4a81b7ad fix: handle shared folders, symlinks and file names on Stalwart 1.0
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.
2026-09-25 20:46:41 +02:00
Linus Rath 1129ba339b fix: save filters whose rules contain */, line breaks or odd header names
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
2026-09-25 20:46:00 +02:00
Linus Rath e2c76d46c7 fix: stop the app re-reading the same folder changes forever
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.
2026-09-25 20:45:18 +02:00
Linus Rath 90b78da8c8 fix: keep time zones, default alerts and calendar renames working on 1.0
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.
2026-09-25 20:44:34 +02:00
Linus Rath 6eeee8df54 fix: explain sharing when Stalwart 1.0 keeps its directory closed
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.
2026-09-25 20:42:07 +02:00
Linus Rath 6be51a2bab fix: find multi-word, address and long-word searches in the app on Stalwart 1.0
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.
2026-09-25 20:41:11 +02:00
Linus Rath 2af3892fec fix: apply recurrence overrides as patches when the app expands a series
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.
2026-09-25 20:38:43 +02:00
Linus Rath 2a3febcc79 fix: edit or answer one occurrence of a series in the app on Stalwart 1.0
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.
2026-09-25 20:36:58 +02:00
Linus Rath ef6f1540dd fix: keep the app's calendar filled past a year on Stalwart 1.0
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.
2026-09-25 20:35:00 +02:00
Linus Rath f52ef7db7e fix: show and keep contact photos in the app on Stalwart 1.0
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.
2026-09-25 20:32:13 +02:00
Linus Rath fe0facfea5 fix: keep sending working in the app past Stalwart 1.0's submission limit
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`.
2026-09-25 19:46:15 +02:00
Linus Rath 28ade775e3 fix: load calendar events and tasks in the app on Stalwart 1.0
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.
2026-09-25 19:45:19 +02:00
Linus Rath 4052523ba9 ci: read the apk signer from newer apksigner output in the release check 2026-09-24 22:45:52 +02:00
Linus Rath e30749a50e ci: stop the android release failing on the runner's new sdk tools 2026-09-24 22:31:01 +02:00
Linus Rath 53c0bc0fc2 chore: update version number to 0.1.63 2026-09-24 22:18:42 +02:00
Linus Rath a8e74c8b6d Merge branch 'main' of https://github.com/bulwarkmail/native 2026-09-24 22:18:06 +02:00
Linus Rath 949bb10ac5 docs: record the device run, the r8 and label fixes and the push crash it found 2026-09-24 22:16:46 +02:00
Linus Rath e5659ed5e8 ci: attach the r8 mapping to android releases
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`.
2026-09-24 22:14:35 +02:00
Linus Rath a764a07c65 fix: show a white splash in light mode
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.
2026-09-24 22:14:35 +02:00
Linus Rath 5ddc2df64b fix: stop holding a wake lock for 30 seconds after every push
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
2026-09-24 22:14:35 +02:00
Linus Rath fef23e4c33 fix: stop a background push from crashing the app on android 12 and later
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
2026-09-24 22:14:35 +02:00
Linus Rath 4a3bce154c feat: translate the new app strings and screen-reader labels into all 26 languages 2026-09-24 22:06:28 +02:00
Linus Rath 13a3e95a0a chore: drop unused app strings and retranslate the ones that became plurals 2026-09-24 21:45:29 +02:00
Linus Rath fd55e16492 fix: translate the last export errors, dates and participant counts
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.
2026-09-24 21:44:47 +02:00
Linus Rath 5e557266d1 fix: count the files selection and delete prompt with plurals
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.
2026-09-24 21:44:47 +02:00
Linus Rath b178ee8d07 fix: translate the tab bar and label the unified inbox and drawer buttons
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.
2026-09-24 21:44:47 +02:00
Linus Rath 1cbf763a36 fix: send calendar reminders in the app language
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.
2026-09-24 21:44:46 +02:00
Linus Rath 84fef142b9 fix: translate the calendar sheets and label their buttons
- 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.
2026-09-24 21:44:46 +02:00
Linus Rath b3a82f1241 fix: translate the week view and label the calendar toolbar
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.
2026-09-24 21:44:46 +02:00
Linus Rath 13cba64854 fix: translate the viewer's leftovers and label its icon buttons
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.
2026-09-24 21:44:46 +02:00
Linus Rath e3501f939d fix: offer undo for replies sent from the viewer
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.
2026-09-24 21:16:59 +02:00
Linus Rath 5e3cbf2419 fix: shrink the android release with r8
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.
2026-09-24 21:13:42 +02:00
Linus Rath 9c031c8761 docs: update the fix-pass notes for the labels and strings that landed since 2026-09-24 21:02:09 +02:00
Linus Rath c81dc5179f docs: tick the parity items this pass completed
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.
2026-09-24 21:02:09 +02:00
Linus Rath d72aab2519 docs: correct the stale parity checklist entries
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
2026-09-24 21:02:09 +02:00
Linus Rath d4ac1e1df4 docs: record what still needs a device check and a decision after the fix pass
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
2026-09-24 21:02:08 +02:00
Linus Rath 06ce86e31a docs: tick the audit items fixed in this pass
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.
2026-09-24 21:02:08 +02:00
Linus Rath 6fe907b4bc fix: translate the Themes and Updates settings tab names 2026-09-24 20:49:56 +02:00
Linus Rath c1e5cd6c8b fix: label the remaining settings icon buttons for screen readers
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.
2026-09-24 20:49:56 +02:00
Linus Rath 455608f9f0 fix: translate push setup errors and the open-link prompt
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.
2026-09-24 20:49:55 +02:00
Linus Rath 5801d6c97a fix: show a translated notice when the sign-in session expired
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.
2026-09-24 20:49:55 +02:00
Linus Rath 771558d8c7 fix: translate the composer's leftovers and label its header buttons
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).
2026-09-24 20:49:55 +02:00
Linus Rath 9c0c35c7ba fix: translate the mail list and label its icon buttons
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.
2026-09-24 20:49:55 +02:00
Linus Rath ccdc4dee56 fix: add the template settings strings to the catalog
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.
2026-09-24 20:49:55 +02:00
Linus Rath aab29da148 fix: translate the file sharing roles and label the files toolbar
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.
2026-09-24 20:49:55 +02:00
Linus Rath d59c571d93 fix: translate the contacts screens
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.
2026-09-24 20:49:55 +02:00
Linus Rath cb5c152cb3 fix: name the settings switches for screen readers
A settings switch had a role and a checked state but no label, so
TalkBack only announced "switch, off". A switch inside a SettingItem now
takes that row's label, and the switches outside one (filter rules,
expanded view, template and sidebar app editors, filter rule options)
get their visible label passed explicitly.
2026-09-24 20:49:55 +02:00
Linus Rath 6e6fd95e5e fix: translate the contacts and content settings
The Contacts and Content & Senders panes, the swipe behaviour row in
Layout, the device time zone option in Calendar and the identity editor
placeholders were written as English literals, so they stayed English
in every language. They now go through t(), reusing the webmail's
settings.contacts, contacts.address_books and
settings.email_behavior.trusted_senders keys where the wording matches.
Counts use ICU plurals instead of `contact${n === 1 ? '' : 's'}`, and the
address book icon buttons get screen reader labels.

The settings i18n test now parses each pane with the TypeScript compiler
and also flags JSX text, alerts and accessibility labels, and covers
the newly cleaned panes.
2026-09-24 20:49:55 +02:00
Linus Rath 8af163a6a3 fix: show today and the now-line in the calendar time zone
The calendar placed events by the calendar time-zone setting, but
"today", the now-line and the "Today" button still went by the device
clock. With the setting away from the device zone, the grid could
mark yesterday as today, draw the now-line hours off and jump to the
wrong day.

- the views, the agenda's "Today"/"Tomorrow" headers and today anchor,
  the selected day, the scroll window's start and the "Today" button
  use `displayNow`, `isDisplayToday`, `isDisplayTomorrow` and
  `displayNowMinutes`
- a long press at an hour already hands the editor a calendar-zone
  time, which it converts when it saves
- changing the setting re-buckets the days and reloads the loaded
  range, since the server reads floating times and the range bounds
  in that zone
2026-09-24 20:40:51 +02:00
Linus Rath b7f711050e fix: translate the calendar import sheet 2026-09-24 20:34:35 +02:00
Linus Rath 9282ee6d72 fix: open the event a calendar link points at 2026-09-24 20:34:35 +02:00
Linus Rath a8e6f6e8c4 fix: keep event and task times when the calendar time zone is not the device's
With the calendar time-zone setting away from the device zone, the
event and task editors showed device-local times but saved them
labelled with the calendar zone, so opening an event and saving it
unchanged moved it by the difference between the two zones.

The calendar now works in display dates like the webmail
(`toDisplayDate` / `fromDisplayDate`): Dates whose local fields read
as the wall clock in the calendar zone. They are the identity while
the calendar follows the device.

- `getEventStartDate` / `getEventEndDate` return display dates, so the
  views, the detail sheet and the editor show calendar-zone times
- the editors convert back through `eventTimeFieldsToSave` and
  `buildTaskEditorChanges`: a start, end or due the user left alone is
  saved exactly as stored, a changed one is converted into the zone the
  event or task lives in (new, floating and formerly all-day ones take
  the calendar zone), and durations count whole days as calendar days
  in that zone
- `getTaskDueDate` stays the real instant for reminders; the tasks
  sheet uses `getTaskDueDisplayDate`, `displayNow` and
  `isDisplayToday`
- wall clocks are parsed from their digits, so one inside the device's
  own DST gap still converts right
- duplicating an event keeps its wall clock in its own zone instead of
  reading the device time
2026-09-24 20:34:35 +02:00
Linus Rath b7a5953255 fix: ask for both neighbouring conversations in one request
When the pages beside the open message were let go, each asked for its
conversation on its own, so every open sent two `Thread/get` requests
for its neighbours.

Conversations asked for in the same tick for the same account now go
out together: `getThreadsHeaders` takes several thread ids in one
back-referenced Thread/get → Email/get, and the cache batches its
callers into it. A conversation the server does not know, or a failed
request, still falls back to the copy held from before.

`getThreadEmails`, which downloaded every member's full body, had no
callers left; it is removed and its tests moved to `getFullEmails` and
`getThreadsHeaders`.
2026-09-24 20:29:41 +02:00
Linus Rath 0cee422373 fix: stop reloading an invitation when the message is marked read
The invitation banner loaded the .ics whenever it was handed a new
message object, so marking an invitation read or starring it
downloaded and parsed the calendar part again (and fetched its raw
text when the method had to be read from it).

It now loads once per message, account and calendar part
(`calendarInvitationKey`), and reads the current message only when the
load runs.
2026-09-24 20:29:41 +02:00
Linus Rath 644443193c fix: drop the mail the viewer held in memory on sign-out
The viewer's message cache, the rendered documents of their bodies and
their measured heights stayed in memory after signing out or removing
an account. Their keys name the server and login, so no other account
saw them, but a signed-out user's mail should not stay around.

Signing out (one account or all) and removing an account now clear all
three. A load still in flight when the cache is cleared no longer
stores what it brings back.
2026-09-24 20:29:41 +02:00
Linus Rath d2066dad07 fix: act on the exact row picked in "All folders" and tag lists #1082
An "All folders" search or a tag view holds rows of the own and the
shared accounts, and Stalwart hands out ids per account, so an own and a
team message can share an id. The list still named its rows by bare id:
selecting one of such a pair selected both, a batch action hit both, and
a swipe or single-row action took whichever came first, in the other
account.

The list now names every row by `rowKeyOf` (its id, scoped by its
account in a list spanning accounts; the bare id in a folder, so nothing
changes there) end to end:
- FlatList keys, row presses, swipes, selection and select-all
- thread expansion of a selection (`expandThreadSelection`)
- every store action the list calls: read, unread, star, pin, the
  per-account keyword batch, move, archive, delete and spam, and the
  rows they drop or keep in view
- the viewer's pages, which now come only from the rows of the opened
  message's account (`listRowsOfAccount`), and its mark-read, which
  goes through the store when the list holds that account's row

A key whose row is no longer loaded is ignored rather than guessed onto
the open folder's account. The unified inbox already keys its rows by
account and needed no change.
2026-09-24 20:28:43 +02:00
Linus Rath dd0969d48b chore: drop translations of app strings the calendar agenda no longer uses 2026-09-24 20:27:58 +02:00
Linus Rath 96bffa51e8 refactor: check only the days a timed event spans for the all-day strip
`buildTimedFullDayWeekSegments` tested every event against every day it
was given. That was fine for the paged week, but the scrolling week and
day grids hand in months of days. It now tests each event only on the
days it spans, with the same `isTimedEventFullDayOnDate` check, so the
strip and the timed grid still agree on every day (DST days included).
The time grid's own copy of this is gone.
2026-09-24 20:25:41 +02:00
Linus Rath 5434656e03 feat: bring a tapped reminder's day into view in the calendar 2026-09-24 20:25:41 +02:00
Linus Rath de3d697aa9 feat: scroll the calendar week and day views sideways #759
The week view showed one week and only the arrows moved it, and there
was no day view at all (a "day" default view opened the agenda). With
free scrolling on (the default) both are now one strip of day columns
that scrolls sideways, as in the webmail (15a5497a):

- the week view shows seven columns and snaps to single days; the new
  day view shows one column and moves a day per swipe; the title
  follows the first day in view and the arrows step from it
- reaching either end adds days and loads only their events; days added
  at the start keep the columns on screen in place
- the day headers and the all-day strip follow the sideways scroll on
  the native thread; all-day bars are cut per week (per day in the day
  view) so each part carries its title
- columns are windowed and lay out their events once per change

The day view is offered as a default view again. With free scrolling
off, or in right-to-left layouts, the week view pages as before and the
day view shows a single day.
2026-09-24 20:25:40 +02:00
Linus Rath caf61ece79 feat: scroll the calendar month view continuously #759
The month view showed one month at a time and only the arrows moved it.
With the new "Free scrolling" setting (on by default, the webmail's
`calendarFreeScroll`) it is one continuous list of weeks, as in the
webmail (15a5497a, ae403b06):

- the month under the upper part of the grid is drawn at full strength,
  names the title and is where the arrows step from; the first day of
  each month carries the month's name
- reaching the top or the bottom adds weeks there and loads only their
  events; weeks added above keep the rows on screen in place
- rows have a fixed height and snap to whole weeks, so the list stays
  windowed and navigation lands exactly on the month

Turning the setting off brings back the paged month and a fixed 30-day
agenda. The week view still pages.
2026-09-24 20:25:40 +02:00
Linus Rath 71522b9b92 feat: scroll the calendar agenda endlessly instead of a fixed 30 days
The agenda listed 30 days from the current date and the arrows paged
through it 30 days at a time. It now keeps a window of days that grows
as the user scrolls, like the webmail's agenda (60e05c00):

- reaching the end loads further ahead, doubling that side up to a year
- "Show earlier events" loads the past without moving what is on screen
- "Today" and the arrows scroll to their day while it lies inside the
  window and start a fresh window there otherwise; the title follows the
  day in view
- the "Today" row only anchors the list when today was actually fetched

Growing the window fetches only the new days: `extendRange` in the
calendar store loads the missing part and merges it into the loaded
window (up to about three years) instead of refetching all of it.

Navigation now goes through one focus for every view, so the week
view's arrows move the grid (it kept showing the selected day's week)
and the month arrows carry the selected day along.
2026-09-24 20:25:40 +02:00
Linus Rath 67935d2edc feat: translate the app-only strings into all 26 languages
Every language now carries its own `locales/rn/<lang>.json` with the
roughly 600 strings the webmail catalog does not have, so settings,
sign-in, files, calendar and push screens no longer fall back to
English.

Terms and the form of address follow each language's vendored webmail
catalog, and plural messages use the language's own CLDR categories.
Wording that had no precedent in the catalog would benefit from a
native speaker's review.
2026-09-24 20:25:40 +02:00
Linus Rath d7c890869b fix: reuse the startup identity read for the first message open 2026-09-24 20:20:59 +02:00
Linus Rath 4f3940f1d3 fix: catch up on a folder's changes in one request
Refreshing an open folder that was already loaded took up to four
round trips in a row: `Email/queryChanges`, `Email/changes`, `Email/get`
of the added and changed rows, and a `Thread/get` for their
conversation sizes.

They now go in one request, the `Email/get` of the added messages and
the `Thread/get` of their threads chained by result reference to
`/added/*/id` and `/list/*/threadId`. The list's own query and Email
states still drive it. Each part can fail on its own: a query the
server cannot diff falls back to the full re-query as before, an Email
state it cannot diff is forgotten as before, and added messages the
chained `Email/get` refused (more than one call takes) are fetched
apart. Only further `Email/changes` pages and rows that changed in
place without being re-added cost more requests.
2026-09-24 20:20:59 +02:00
Linus Rath 9c2f819eac fix: load the folder list once, and right away, when signing in
Signing in sent two full `Mailbox/get`s before the inbox loaded, and
the first only went out once the main screen had mounted and painted.
There is no deliberate delay behind the gap: the mail screen's mount
effect started the folder load, and the push setup read the same
folders again with a `Mailbox/get` of its own (plus the shared
accounts') just to find the Junk folders for its filter. The latter
also happened on every start with push notifications on.

- Sign-in (password and OAuth/pairing) starts the folder load as soon
  as the session is up, like a restored session already did.
- The mail screen's mount now joins a load on its way
  (`ensureMailboxes`) instead of queueing another behind it.
- The push filter takes the folders from the mail store (joining its
  load) and only fetches them itself when the store has none for the
  account.
2026-09-24 20:20:58 +02:00
Linus Rath 9fa931c589 fix: sync at once on resume and stop pinging the server while live updates run
Coming back to the app first waited for a `Core/echo` (sometimes two:
the keep-alive timer that was due fired alongside) and for the event
stream to be set up before the catch-up sync started. A failed echo
only led to `retrySession`, which does nothing while the session is
live. On top of that, a `Core/echo` went out every 30 s although the
event stream's own pings already show the server is reachable.

- On resume the stream reconnects and the folders and open list are
  synced right away; one echo runs alongside for the connection dot.
- The 30 s echo is only sent while the event stream is not open
  (polling fallback, reconnecting, client certificate). An open stream
  whose pings arrive (the live-updates handle's new `healthy`) marks
  the account connected instead.
- Echoes never overlap, and a periodic one right after the resume one
  is skipped.
2026-09-24 20:20:58 +02:00
Linus Rath 1196ad56c2 fix: probe the server's keyword sort order once instead of on every start
The list sorts pinned (and unread, starred or tagged, with an order
preset) messages first with `hasKeyword` comparators, and Stalwart reads
their direction the other way round (#718). To find out, the app sent a
probe request before the first list load of every start, because the
verdict was only kept in memory.

A conclusive verdict (RFC order, inverted, or keyword sorts refused) is
now stored per server and account under a versioned key and reused on
later starts. It is probed again once it is a week old, since a server
upgrade may change it. An inconclusive probe is still only kept in
memory and retried after a few minutes.
2026-09-24 20:20:58 +02:00
Linus Rath 3bf2c30c99 fix: stop asking for the Stalwart principal on every start and message open
Every sign-in and app start read the account's full name with
`x:Account/get`, and the first message opened (invitation or not)
looked up the account aliases with it too. Stalwart refuses that method
to everyone but admins, so for most users both were wasted requests
that always came back `forbidden`, and the full name was never
refreshed.

- The full name now comes from `x:AccountSettings/get` (its
  `description`), which every user can read, like the webmail (#900).
- The alias lookup only runs for a message that carries a calendar
  invitation (and the calendar).
- A refusal of `x:Account/get` is remembered per account for the
  session, so it is asked at most once.
2026-09-24 20:20:58 +02:00
Linus Rath 018accd865 fix: stop re-reading the mail list when the server echoes our own changes
Every change the app made (read, star, move, send, a draft save) came
back as a push, and the push re-read the open folder and the whole
folder list: `Mailbox/changes`, `Mailbox/get`, the shared accounts'
`Mailbox/get`, `Email/queryChanges`, `Email/changes`, `Email/get` and
`Thread/get`, often twice. One send cost about 10 requests.

- `jmapClient.request` now logs the Email states each of our own
  `Email/set` (also the implicit one of `onSuccessUpdateEmail`),
  `Email/import` and `Email/copy` responses moved between, with what
  they created, patched and destroyed.
- When a push reports an Email state that only our own writes led to
  from the state the open folder was read at, the store applies those
  writes to the rows (the optimistic updates mostly did already), moves
  the list's state on and skips the refresh. It still re-reads when
  anything else changed, when new mail was delivered, when a write adds
  a message to the folder or changes a keyword the list is sorted on,
  and when the folder's count shows a message it does not hold left.
  A push that overtakes its write's response waits for it briefly.
- A folder push re-reads only the part of the folder list that
  changed: the own folders or the shared accounts', and nothing for a
  Mailbox state it already holds.
2026-09-24 20:20:58 +02:00
Linus Rath 1c7c2a3be8 fix: stop reloading every account when returning to the unified inbox
The unified inbox reloaded page 1 of every account each time it came
back into focus, so opening a message and going back cost one
`Email/query` per account even when nothing had changed.

It now reloads on focus only when something may have changed since the
last load: the inputs or the active account changed, the live session
reported an `Email` state change, the opened message was unread (the
thread screen marks it read), the app was in the background (the event
stream is closed there), or accounts without a live session are shown
and the last load is over a minute old. An `Email` change that lands
within a few seconds of a focus that skipped the reload (the echo of an
archive or delete from the thread screen) reloads then.
2026-09-24 20:20:58 +02:00
Linus Rath 49ed48791d fix: name the webmail's Link Mobile App page in the QR sign-in hints 2026-09-24 20:16:36 +02:00
Linus Rath ef143a4dbb fix: take a conversation's size from the list row's own account
The placeholder cards held for a loading conversation looked its size up
by bare thread id whenever the open folder was the message's account.
Rows of an "All folders" search or a tag view now come from several
accounts and the list keys their conversation sizes by account, so the
viewer looks the size up the same way, and only for pages that are the
list's own rows.
2026-09-24 20:12:17 +02:00
Linus Rath 2a055ddfe2 fix: keep a star set while the mark-as-read delay runs 2026-09-24 20:12:17 +02:00
Linus Rath cd911ada79 fix: show toasts on every screen, including the Unified Inbox and contacts
Only the tab screens and the viewer had a toast host, so a viewer
action that failed after going back to the Unified Inbox or a contact
(both stack screens of their own) raised a toast nobody saw.

Every stack screen now gets a host through the navigator's
`screenLayout`, and only the focused screen's host renders, so the
screen kept live under the top one never shows the same toast a second
time. The tab screens' and the viewer's own hosts are gone.
2026-09-24 20:12:17 +02:00
Linus Rath e0b72883ac fix: show the tapped message when the viewer is already open
Opening the viewer while it was on top, e.g. by tapping a notification,
did not push a new screen: React Navigation handed the new target to
the same route as new params, and the viewer, which fixes its message,
account and page list when it opens, ignored them. The tap did nothing.

The route now mounts the viewer afresh for every new target it is
given, keyed by the message, its account and the open, so the new
message is shown with its own page snapshot. Params that stay the same
keep the viewer as it is.
2026-09-24 20:12:17 +02:00
Linus Rath 54b169c6c7 fix: open a message at its full height and keep the quick reply still
Every body started as a 120 px strip that grew once the page had
measured itself, so an open showed a skeleton, then a clipped strip of
the body, then the full body, and the quick reply box below it jumped
down as the WebView grew. A conversation arriving after the message
swapped the single-message layout for cards, which reloaded the body
and pushed it down under a "Loading conversation" row.

- `lib/body-heights` remembers the last measured height of each body
  (per width); a body shown before starts at that height.
- A page showing one message stretches its body over the space below
  the header until it has measured itself, and the quick reply sits at
  the bottom of the page, so it does not move whether the body turns
  out shorter or longer. Conversation cards start from an estimate
  (most of a screen for HTML, the wrapped line count for plain text).
- A single message is rendered through the conversation's card list,
  so its body stays mounted when the conversation arrives; while it
  loads, the size the folder list knows for it holds the older cards'
  places, and the loading indicator sits in the subject row.
- An open card whose body has not arrived shows its header over
  placeholder lines, with its reply buttons disabled until it has.
2026-09-24 20:12:17 +02:00
Linus Rath 6ebadf738f fix: sanitise a message body once instead of on every mount
The body view reset its inline-image map to a new empty object right
after mounting, which rebuilt the sandboxed document, so every body was
sanitised twice per mount. A body that mounted again (the page beside
the open one becoming the open one, a card closed and reopened, the
message opened a second time) was sanitised from scratch as well.

The document is now built by `lib/email-body-document`, which keeps the
last ten per message, body and rendering settings and hands back the
same one when they match, and the mount no longer replaces the empty
map.
2026-09-24 20:12:17 +02:00
Linus Rath 01fc6fbba5 fix: let the pages beside an opened message wait until it has loaded
The pager renders the pages before and after the open one, and each
fetched its message and conversation and mounted its own WebView at the
same time as the message the user had tapped, which competed with it
for the network, the JS thread and the WebView process.

The neighbouring pages now show their header and placeholder lines
until the open message's body has reported its height, the user starts
to swipe, or 1.5 s have passed. Their details are then loaded together
in one request, and their conversations and WebViews follow.
2026-09-24 20:12:17 +02:00
Linus Rath 7cdebc465a fix: stop asking for the identities on every message open
The viewer and the read-receipt banner each sent `Identity/get`
whenever the identity list was empty. Identities are not persisted, so
the first open of every session asked twice, and an account the server
lists no identities for asked on every open.

The settings store now remembers which signed-in account (server,
login, JMAP account) its identities were read for, and the viewer and
the banner only ask when they are not held for the current account.
Concurrent reads share one request. Switching accounts drops the old
account's identities instead of letting the quick reply send through
one the new account does not have.
2026-09-24 20:12:17 +02:00
Linus Rath ad1f053f72 fix: offer archiving in the viewer for a folder named Archive without the role 2026-09-24 20:12:17 +02:00
Linus Rath 1348e7d20a fix: start loading a message on the tap, before the viewer opens
The detail and conversation requests only went out once the viewer had
mounted and its effects ran, after the navigation had rendered.

A tap on a row in the folder list, the Unified Inbox or a contact's
activity, and a notification tap, now start the message's detail and
its conversation (when threading is on) through the shared cache, and
the viewer joins the requests already in flight. It runs on the tap and
not on press-in, because on a phone a touch that starts a scroll
presses rows too.

The Unified Inbox and contact activity also leave their rows in the
cache, so pages handed over by id get their header painted from the row
(and the right thread id) instead of a skeleton.
2026-09-24 20:12:17 +02:00
Linus Rath ac8e3fb2e9 fix: paint the message header from the list row while the body loads
The viewer showed a full skeleton until the message's detail had come
back, although the list row it was opened from already holds the
subject, sender, date and avatar. The header appeared about 0.4 s after
the tap.

A page whose list row is known now renders the real subject and header
from that row straight away, with placeholder lines where the body
goes; the body (and the star, reply and quick reply that need the full
message) follows when the detail arrives. Pages handed over by id only
(notification, deep link) keep the skeleton.
2026-09-24 20:12:17 +02:00
Linus Rath 33e303e248 fix: open a message from the cache and fetch only the open cards' bodies
Every open went to the network for the full message, even for a message
the viewer had just shown, and the conversation fetch then downloaded
every member's full body again, the opened one included. The pager's
three panes each started their own detail and thread fetch, so one open
from the Unified Inbox fetched the body twice and the thread twice.

Messages and conversations now live in a shared cache
(`lib/email-detail-cache`), keyed by server, login and JMAP account:

- a held copy renders at once; bodies never change, so it is only
  checked, with a keywords/folders-only `Email/get`, when the account's
  Email state (from push or our own reads) moved on or the list row the
  message was opened from disagrees with it;
- a copy in the offline cache is shown before anything goes out and is
  checked the same way;
- a conversation is listed from its members' headers (`Thread/get` and
  list properties in one request) and bodies are downloaded only for
  the open cards, in one batched `Email/get`;
- concurrent loads of the same message or thread share one request.

The store's network-first `getEmailDetail` is gone; its offline-copy
refresh moved into the cache.
2026-09-24 20:12:16 +02:00
Linus Rath 96c5a3f046 feat: let attendees answer a single occurrence of a recurring event
An answer to an invitation always went to the whole series, even when
given on one occurrence. The webmail asks which occurrences it covers
(#1086); the app now does too.

- answering on an occurrence opens the scope dialog with "This event
  only" and "All events" (an attendee cannot split the organizer's
  series, so "This and following" is not offered)
- "This event only" writes the answer to that occurrence: through its
  synthetic id, or as an override on the base event for one expanded
  on the device, with the whole participant map, the organizer and
  the sequence (`buildOccurrenceRsvpPatch`), so Stalwart replies with
  the occurrence's RECURRENCE-ID
- "All events" and answers on single events work as before
2026-09-24 20:10:08 +02:00
Linus Rath f397ee3e1b feat: change or delete one occurrence through its own id
"This event only" wrote a `recurrenceOverrides/<id>` pointer on the
master for every occurrence. That pointer fails while the series has
no overrides yet, replaced whatever the occurrence already overrode,
and left the new override without the occurrence's title, place or
reminders. Deleting one occurrence went the same way.

The store now keeps a change to an occurrence on that occurrence, the
way the webmail's `resolveMutationTarget` does:

- a server-expanded occurrence is updated or destroyed through its
  synthetic id; a server that refuses synthetic ids gets the same
  change as an override on the base event, and is remembered
- an occurrence expanded on the device becomes an override on its base
  event that keeps the existing override, pins start and duration,
  and is sent as the whole map when the series has none yet
- a new override carries the occurrence's details and sequence
  (`withNewOverrideDetails`), so it neither loses them nor ranks below
  the series
- "All events" and "This and following" still go to the master

The calendar's scope dialog hands "This event only" to `updateEvent`
and `deleteEvent` with the occurrence's id, and
`buildRecurrenceOverridePatch` is gone.
2026-09-24 20:10:08 +02:00
Linus Rath 84548898c1 feat: let the server expand recurring events when it can
Recurring series were always fetched whole and expanded on the device,
the webmail's fallback for Stalwart before 0.16.20. Newer servers
expand them with `expandRecurrences` and accept the synthetic
occurrence ids in `CalendarEvent/set`; the webmail uses that since
`lib/recurrence-instances.ts`.

- `supportsSyntheticCalendarIds` probes once per server and account
  with an update of an id that cannot exist (`notFound` means
  supported); the probe rides along with the first `Calendar/get`
- when supported, a range is loaded with `queryExpandedEvents`, the
  occurrences are fetched with `baseEventId` and hydrated with their
  base event's rules, overrides, all-day flag and duration; duplicate
  listings of one occurrence are collapsed like the webmail does
- a range the server refuses to expand ("expanded recurrences
  exceeds") and older servers keep the device-side expansion, which
  now passes server occurrences through untouched
- changes, deletes and RSVPs resolve a synthetic id to its base event
  (`resolveSeriesTarget`), including a master fetched for an
  occurrence of a shared calendar, and reload the range afterwards
  since synthetic ids reshuffle
- reminder keys use the base event and recurrence id, so a reshuffle
  does not reschedule them
- client-only fields are stripped from `CalendarEvent/set` payloads
2026-09-24 20:10:08 +02:00
Linus Rath 1a651a9c25 fix: read floating task dues in the calendar time zone like events
A task due without a time zone was read as device wall time, while
Stalwart computes a floating event's start in the calendar time-zone
setting the app sends with every query. With that setting away from
the device zone, a floating task and a floating event at the same
wall time showed hours apart, and task reminders fired at the device
reading.

`getTaskDueDate` now resolves a floating timed due in the effective
calendar zone; dues with their own zone and date-only or all-day dues
are unchanged. The device zone is cached for 30 seconds, since task
sorting resolves a due per comparison and building an
`Intl.DateTimeFormat` is slow on Hermes.
2026-09-24 20:10:08 +02:00
Linus Rath 83db70ac86 fix: open the event when a calendar reminder is tapped
Tapping a reminder notification only brought the app to the front:
nothing listened for expo-notifications responses, so the event never
opened.

- reminders now carry the event's server id, account, occurrence,
  start and the signed-in account alongside the store id
- `startCalendarReminderTapHandling` picks up the reminder that
  launched the app and any tapped while it runs; it only acts on
  reminders it scheduled, and mail push taps keep coming through the
  native tap store
- the app switches to the reminder's account if needed, parks the
  target in `pending-calendar-open` and opens the Calendar tab, which
  shows the event in the detail sheet or the task in the tasks sheet
- the event is looked up by its server identity, first in the loaded
  window, then in the day around its start, then as the stored event,
  so an occurrence outside the visible month still opens; a deleted
  one says "This event is no longer available."
2026-09-24 20:10:08 +02:00
Linus Rath a4ac6cad5c fix: list the events an .ics import could not bring in
A partial import dropped the refused events silently: the sheet said
"Imported 3 events" for a file of five, and the server's reason never
reached the user. The webmail reports how many events failed and why.

- `batchCreateEvents` returns the refused events by index with the
  server's reason instead of just the created count
- `importEvents` resolves with `{ imported, refused }`, also counting a
  refused link of a UID that lives in another calendar and every event
  of a chunk whose request failed; when nothing got in it rejects with
  an `ImportRefusedError` carrying the list, so the invitation banner
  still never reads a refusal as "Already in your calendar"
- the import sheet shows the imported count and lists each refused
  event with its date and reason
2026-09-24 20:10:07 +02:00
Linus Rath a7e4e84f5d fix: say when saving a task fails and keep what was typed
Saving in the tasks sheet ran `onCreate`/`onUpdate` in a `try/finally`
without a `catch`, so a refused create or update became an unhandled
rejection: the spinner stopped, nothing was shown, and the user could
not tell whether the task was saved.

The editor state and the save logic move to `src/lib/task-editor.ts`.
`submitTaskEditor` returns the server's reason instead of throwing; the
sheet shows it in an alert and leaves the editor untouched so the input
can be fixed or saved again. It is cleared only after a successful save.
2026-09-24 20:10:07 +02:00
Linus Rath e0a79ca5c6 test: keep the locale tests independent of which overlay keys are translated 2026-09-24 20:07:00 +02:00
Linus Rath edab181e60 fix: load list attachment chips from each search hit's own account #1082
The attachment chips on list rows fetched every row's parts from the
account of the open folder. In an "All folders" search or a tag view the
rows come from several accounts, so a hit from the team inbox asked the
user's own account, which answers for another message with the same id,
or not at all.

A row that carries an account stamp now loads its chips, and opens an
attachment from them, in its own account; the chips a row remembers are
keyed by that account too.
2026-09-24 20:05:38 +02:00
Linus Rath d097ae9ec3 fix: stop re-counting every tag each time the sidebar opens
Every drawer open sent the whole set of tag count queries again (14
Email/query calls for 7 tags, twice that with a shared account), although
nothing had changed since the last open.

The counts now live in a small store, keyed by the login, the accounts
and the tags they were fetched for. Opening the drawer only fetches when
there are no counts for that key yet or when an Email change was
reported for one of the accounts since (push or the poll fallback); a
change that arrives while the drawer is open refreshes them there and
then. Overlapping opens share one request, and a change that lands while
the counts are in flight leaves them due for another fetch.
2026-09-24 20:05:38 +02:00
Linus Rath 8d5bced7f3 fix: list and count tagged mail from every account in the tag view #1038
A tag sits on messages in the user's own account and in the shared
(group) accounts alike, but the tag view only asked the account of the
open folder, and the sidebar badge only counted the own account: with 2
own and 2 team messages tagged Red, the Red view showed 2, and which 2
depended on the folder it was opened from.

The tag view now fans out like the "All folders" search (#1082), which
it is: every folder of the own account and of each shared account with
folders in the sidebar, in one request, with rows stamped with their
account so opening, untagging and every other action reach the right
one. A search inside the tag view narrows it in every account.

The tag badges sum the counts of the same accounts; the query pairs for
all of them go out packed into as few requests as `maxCallsInRequest`
allows, and an account that can't be reached counts nothing instead of
failing the badges.
2026-09-24 20:05:38 +02:00
Linus Rath ee50418bac fix: search the shared accounts' folders too with "All folders" #1082
"All folders" sent its Email/query only to the account of the open
folder, so mail in a shared (group) account the same login reaches never
showed up: "zephyr" found the 3 own messages and missed the 5 in the team
inbox, although that inbox sits right there in the sidebar.

The search now asks the own account and every shared account with
folders in the sidebar, like the webmail's `searchAcrossAccounts`:

- one request carries Email/query, Email/get and Thread/get for each
  account (`queryEmailPagesAcrossAccounts`), and the pages are merged
  under the list order
- each row is stamped with its JMAP account (`Email.jmapAccountId`), so
  opening a hit opens it in that account and read, star, tag, archive,
  delete, spam and move act there, filing into that account's own
  Archive, Trash or Junk; one undo restores rows in every account
- "load more" continues each account from its own rows on screen rather
  than one shared offset, which would skip messages of the account with
  more hits
- an account that fails leaves the others' hits on screen with a notice
- a push for any account re-runs the search
- list keys, thread grouping and conversation sizes are scoped by
  account, as JMAP ids are only unique per account
- deleting a hit judges Trash and Junk by the hit's own folders, not the
  open folder, both for the move and for the permanent-delete confirm

A search scoped to one folder still asks only that folder's account.
2026-09-24 20:05:38 +02:00
Linus Rath ddff2f7b66 fix: queue star, tag and unread from the viewer while offline
The viewer's star, tag and mark-unread buttons called the API
directly, so offline they failed and the list row behind the viewer
kept its old state. They now go through the email store's keyword
action like the list's selection actions: offline the change waits in
the outbox, and the list row follows. The store action takes the
viewed message and its account, so it only touches the list when the
list holds that account and never a same-id row of another one.
2026-09-24 19:45:07 +02:00
Linus Rath d8db4a5597 fix: show the right image when two attachments share a name
The reader's attachment preview wrote every file to the same
`bulwark-exports/<name>`. React Native caches images by path, so a
second attachment with the same name (such as Outlook's `image001.png`)
could show the first one's picture. The preview now uses a temp folder
of its own per preview, as the Files tab does, also for parts unpacked
on the device, and deletes it when the preview closes unless the file
was shared to another app. A download that finishes after its preview
was closed no longer reopens it.
2026-09-24 19:45:07 +02:00
Linus Rath 1fb60a5c61 fix: tell the user when an automatic read receipt fails to send 2026-09-24 19:45:07 +02:00
Linus Rath 115e162b86 fix: delete large file selections in batches the server accepts
Deleting files sent every selected id in one FileNode/set. A selection
larger than the server's `maxObjectsInSet` was refused as a whole, so
nothing was deleted. The ids now go out in batches of that size; a node
already removed with its folder by an earlier batch counts as deleted,
and a refused item no longer stops the remaining batches.
2026-09-24 19:45:07 +02:00
Linus Rath a3193ebaf7 fix: create new groups in the address book being viewed 2026-09-24 19:45:07 +02:00
Linus Rath 40ace883b7 fix: keep a contact's recent-email previews on one line 2026-09-24 19:45:07 +02:00
Linus Rath 9135deed80 fix: translate the no-subject fallback in mail lists and notifications
The mail list, the unified inbox, a contact's recent emails and new-mail
notifications showed a hard-coded English "(no subject)". They now use
the webmail's `email_viewer.no_subject` (and `contacts.activity.no_subject`
for the contact view). The notification task reads the in-app language
override itself, as it cannot use the locale store.
2026-09-24 19:45:06 +02:00
Linus Rath 3c7b96e11a fix: remove shared-file copies left behind by an upload cut short
Files shared into the app are copied into the cache as `upload-*`
before they are uploaded. When the app was killed mid-upload the copy
was never deleted. The launch-time sweep of stale export files now also
removes upload copies made by an earlier run of the app; copies of the
current run may still be uploading and are left alone.
2026-09-24 19:45:06 +02:00
Linus Rath 2e78c9870c fix: remove the unsent copy when the server refuses a send
A send creates the message in Drafts and submits it in the same request.
When the server refused the submission, the created copy stayed in
Drafts without anyone tracking it, so sending again created a second
copy and left the first behind as a stray draft. The copy is now
destroyed again when the submission is refused; the composer still
holds the message and the previous draft version is left alone.
2026-09-24 19:45:06 +02:00
Linus Rath 46e285b3c9 feat: search settings from the Settings screen
The webmail's Settings screen has a search field; the app had none, so
finding a setting meant opening panes one by one. Settings now has a
search field above the pane list that narrows the list to the panes
that match and lists up to six matching settings under each.

The index follows the webmail's `lib/settings-search.ts`: per pane, the
translation keys it renders plus a few English keywords, walked for
label/description pairs. The paths list what the native panes show
rather than the webmail's, the walker prefers the `*_mobile` variants
the panes render, and every label goes through t() for the current
language. A test checks that each result is a key its pane renders,
so results do not point at webmail-only settings.

Tapping a result opens the pane, scrolls to the matching row and tints
it briefly. Back clears the search before leaving Settings.
2026-09-24 19:40:08 +02:00
Linus Rath 4c9efbc44d fix: stop an open settings page from catching back on other tabs 2026-09-24 19:40:08 +02:00
Linus Rath 7964e646c4 feat: add the Roundcube Elastic and Aurora Glass themes
The Themes pane offered four of the webmail's built-in themes. The
webmail also ships Roundcube Elastic and Aurora Glass, so their colour
tokens are now mapped onto the native palette the same way as the
other four, for both light and dark.

Aurora Glass dark uses translucent surfaces that the webmail frosts
with a backdrop blur. RN has no blur, so those fill tokens are
composited onto the theme background instead of letting the content
underneath show through. The skins (fonts, radii, Elastic's dark task
rail, Aurora's gradient mesh) and the "Flat fields" theme are not
ported.
2026-09-24 19:40:08 +02:00
Linus Rath 008bff29f8 feat: load app-only strings in every language, not just English
The RN overlay (`locales/rn/en.json`, the strings the webmail catalog
does not carry) only existed in English, so those 600 or so labels
showed in English whatever language the app was set to.

- load `locales/rn/<lang>.json` for every language next to its
  vendored catalog; an untranslated key still falls back to English
- a test keeps each translation to English keys and the same ICU
  arguments
- `node scripts/rn-missing-translations.mjs [lang]` lists what a
  language still lacks
2026-09-24 19:39:48 +02:00
Linus Rath af6290fd4c fix: only show the unified mailbox when it can hold more than one account's mail #843
The drawer showed "All inboxes" and the Unified section as soon as a
second account was signed in, but with cross-account off the unified
views only cover the active account, so they repeated its own folders.
They now follow the webmail's rule: shown with cross-account on and
several accounts, or when group inboxes are merged in.
2026-09-24 19:36:41 +02:00
Linus Rath 4e6426171a feat: add a setting to clear the search when switching folders 2026-09-24 19:36:41 +02:00
Linus Rath b1fb2e9b22 feat: show attachment chips on mail list rows
A message with attachments only showed a paperclip, so reaching a file
meant opening the message and scrolling to the bottom. Rows now list the
attached files as chips under the preview, two at a time with the rest
as a count, like the webmail (0e1c34cd). A tap opens the file the way
the reader's chips do: the attachment click setting saves it or
previews it in-app, and other types go to the platform viewer. Inline
and Content-ID parts (signature images, spacers) are left out.

The list request still only asks for `hasAttachment`: the webmail found
that asking for `attachments` there makes Stalwart parse every message's
raw blob (#1089). Rows with a paperclip ask for their parts once
rendered; rows that render together are coalesced into one Email/get
per account and the answers are cached, so scrolling back costs nothing.
2026-09-24 19:36:41 +02:00
Linus Rath 25c088383f feat: show the inbox unread count on the app icon
Ports the webmail's app-icon badge (`useAppBadge`): the icon shows the
unread count of the active account's own inbox, and a new "Unread count
on app icon" toggle (on by default, exported as the webmail's
`faviconUnreadBadge`) turns it off. Signing out of the last account
clears it.

This is iOS only. On Android expo-notifications badges through
ShortcutBadger, which few launchers honour, and implements a count of 0
as `NotificationManager.cancelAll()`, so clearing the badge would also
dismiss other accounts' mail notifications and calendar reminders.
Android launchers already badge the icon from the notifications on
display. For the same reason the Android-only background push task
does not update the badge.
2026-09-24 19:36:41 +02:00
Linus Rath 9f75e70218 fix: only offer archiving when the archive folder can take the mail #578
The list resolved the Archive folder with a loose name match ("Archives",
"Archived" or any name containing "archive"), while the store's archive
actions only accept the `archive` role or a folder named exactly
"Archive". With a role-less "Archives" folder the swipe and bulk archive
were offered but silently did nothing.

`findArchiveMailbox` now uses the webmail's rules (role first, then the
exact name, case-insensitive) and both store archive actions resolve
through it, so the list offers archiving only when it will run and
otherwise shows the existing "no Archive folder" message.
2026-09-24 19:36:41 +02:00
Linus Rath 30aea63a66 fix: subscribe folders created on the phone so IMAP clients show them #951 2026-09-24 19:36:40 +02:00
Linus Rath 85f23dfdf4 test: check that session restore waits for the persisted stores together
The audit listed the four hydration waits in `restoreSession()` as
serial. They are already awaited together with `Promise.all`, and
restore only reads the stores after all four have settled, so the order
they finish in does not matter. The remaining queueing is inside
AsyncStorage, whose native side runs reads one at a time.

This test pins the concurrent wait: with all four stores stuck, restore
gives up after one `HYDRATION_TIMEOUT_MS`, not four.
2026-09-24 19:35:47 +02:00
Linus Rath 4a5d837cd7 fix: stop re-rendering hidden tabs and the mail list on every mailbox fetch
The tab navigator subscribed to the whole `mailboxes` array to show one
badge. Every mailbox fetch sets a new array, so each refresh re-rendered
the navigator and, through its inline render callback, the mail list,
also while a message was open on top of it. It now selects the inbox's
unread count, a number that only changes when the badge does.

Both navigators also set `freezeOnBlur`, so tabs out of view stop
re-rendering until they are shown again, and so do stack screens more
than one level below the top. On Fabric, native-stack deliberately
keeps the screen directly below the top live so the back transition
can show it, so the list under an open message still re-renders
through its own store subscriptions.
2026-09-24 19:35:47 +02:00
Linus Rath f2dd3a8b4b fix: float the notification prompt instead of pushing the list down
The push onboarding card was laid out above the tab navigator. It
arrives 1.5 s into a launch, so it pushed the whole mail list down, and
it drew under the status bar because it ignored the top inset.

It now floats above the tab bar like the webmail's card and the toasts,
respecting the bottom inset, and fades and slides in (instantly when
animations are off), so nothing else moves when it appears or goes.
2026-09-24 19:35:47 +02:00
Linus Rath b9db3764ff fix: stop the cold start flashing white and the mail header jumping in
On a cold start the splash was followed by about a second of blank
white, then mail rows whose header sat under the status bar until it
popped in and pushed the list down.

- `AppTheme` had no `android:windowBackground`, so while JS loaded the
  window showed the platform default (near-white, or grey at night).
  It is now the app's own background, #ffffff by day and #09090b at
  night (`values-night`), matching `src/theme/tokens.ts`.
- `NavigationContainer` had no theme, so React Navigation painted its
  light default behind and between screens even in dark mode. It now
  uses the palette's background.
- The mail list took its top inset from the native `SafeAreaView`,
  which on Fabric only learns the insets a few frames after mounting.
  It now pads with `useSafeAreaInsets()`, which the navigator's
  safe-area provider fills from the initial window metrics on the first
  render; the value is the same, it just arrives in time.
2026-09-24 19:35:47 +02:00
Linus Rath 82ca359df7 fix: stop loading the whole app for a background push
`index.ts` imported `App` at the top, so when an FCM data message
started the headless push task in a cold process, the bundle evaluated
every screen, store, catalog and icon before the task could show its
notification.

The headless task is still registered at the top of `index.ts`, but the
root component now requires `App` when the UI first renders. A headless
start therefore only evaluates the task and its own imports; a normal
launch evaluates the same modules as before, one render later.

Measured with `npx expo export --platform android` (modules reached
through top-level requires from the entry, i.e. what a headless start
evaluates): 1,516 modules, 7,238 KB -> 202 modules, 745 KB.
2026-09-24 19:35:47 +02:00
Linus Rath 5bcfe1fe4d fix: load only the active language's calendar date locale
`src/lib/calendar-locale.ts` imported its 27 date-fns locales from the
`date-fns/locale` barrel, which re-exports all of date-fns' ~100
locales. Metro bundled every one of them and evaluated them at startup
through the calendar screens.

Each locale is now required from its own `date-fns/locale/<code>`
module the first time `getDateFnsLocale()` asks for it; en-US stays a
static import as the fallback. A test checks that every UI language
still maps to the right date-fns locale.

Measured with `npx expo export --platform android`:
- modules in the bundle: 2,469 -> 2,114 (date-fns locale modules:
  531 -> 170, 1,213 KB -> 358 KB unminified)
- Hermes bundle: 8.19 MB -> 7.61 MB
- modules evaluated at startup: 2,038 -> 1,516 (date-fns locale
  modules: 531 -> 10, i.e. only en-US)
2026-09-24 19:35:47 +02:00
Linus Rath 063432bf28 fix: bundle and load only the icons the app uses
93 files import icons from the `lucide-react-native` barrel. Metro has
no tree shaking, so the barrel pulled all 1,694 icons into the bundle
and evaluated every one of them at startup.

A small Babel plugin (`scripts/babel-plugin-lucide-imports.js`, wired
up in the new `babel.config.js` on top of `babel-preset-expo`) rewrites
each named import to the per-icon module the barrel re-exports, e.g.
`import { Mail } from 'lucide-react-native'` becomes a default import of
`lucide-react-native/dist/esm/icons/mail.js`. The name table is read
from the package's own barrel, so aliases such as `MailIcon` or renamed
icons keep resolving; an unknown name fails the build. The package's
`exports` map only lists the barrel, so the new `metro.config.js`
resolves these deep paths as plain files instead of warning about each.
A test checks that every icon imported in the app maps to an existing
module and that no default or namespace import brings the barrel back.

Measured with `npx expo export --platform android`:
- modules in the bundle: 3,995 -> 2,469 (lucide: 1,701 -> 175)
- Hermes bundle: 9.34 MB -> 8.19 MB
- modules evaluated at startup: 3,564 -> 2,038

Metro does not key its transform cache on the Babel config, so a warm
dev cache keeps serving the old barrel imports until
`npx expo start --clear`.
2026-09-24 19:35:47 +02:00
Linus Rath 604eb39340 fix: load only the active language's catalog at startup
All 27 locale catalogs were imported statically by `src/i18n/index.ts`,
so every cold start evaluated about 4.3 MB of JSON before the first
frame, although only English (the fallback) and the active language are
ever read.

Each catalog is now required from a per-locale loader the first time
`translate()` or `getDictionary()` asks for it. `t()` stays synchronous;
after a language switch the next render loads the new catalog. English
stays a static import because every lookup falls back to it, and the RN
overlay is merged when a catalog is first built, as before.

Measured with `npx expo export --platform android` (unminified bundle,
counting modules reached through top-level requires from the entry):
- catalogs evaluated at startup: 28 modules, 4,287 KB -> 2 modules,
  193 KB (English and the RN overlay; a non-English user also builds
  their own catalog on the first render)
- modules evaluated at startup: 3,590 -> 3,564; their code: 15,219 KB
  -> 11,122 KB
- the Hermes bundle keeps its size (9.34 MB); the catalogs are still in
  it, they are just not evaluated until used
2026-09-24 19:35:47 +02:00
Linus Rath 6c31c8d0c6 fix: stop filters saved on the phone from moving spam out of Junk
Stalwart only files spam into Junk when no rule decided where the
message goes, so the webmail now adds
`not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"`
to every move and copy rule when the server supports `spamtestplus`
and `relational`, with a per-rule "Also move messages marked as spam"
opt-in stored as `includeSpam` (6241ed61). Saving filters on the phone
dropped the guard and the opt-in, so every folder rule started pulling
spam out of Junk again, and opted-in rules lost their setting.

Port the guard and keep `includeSpam` in the rules metadata. The rule
editor gets the opt-in switch while the rule moves or copies mail.

The round-trip fixtures now cover the guard and a script with every
newer feature plus a hand-written rule. Both clients add two blank
lines before a script's external rules on each save, so that fixture is
compared with what the webmail itself writes when it saves it again.
2026-09-24 19:29:36 +02:00
Linus Rath 3f88868aad fix: keep folder ids, forward copies and flag order in filters saved on the phone
The webmail now stores the target folder's id on move and copy actions
and writes `fileinto :mailboxid "<id>" "<path>"` when the server lists
`mailboxid`, so a rule keeps working after the folder is renamed. It
also offers "Keep a copy" on forwards (`redirect :copy`) and sets flags
before the move, because `fileinto` stores the flags set at that moment
(dadfb08f). The phone did none of this: saving filters on the phone
turned every `:mailboxid` target back into a bare path, forwards with a
copy into plain redirects, and a rule that stars and moves mail stopped
starring it if the move was picked first.

Port the generator and parser changes and pass the server's Sieve
extensions to the generator. In the rule editor:
- folders are picked by id, and saving stores the id next to the path
  and refreshes the path from it, so older rules pick up the id;
- a folder that isn't listed stays selected instead of being replaced;
- forwards get the "Keep a copy" switch;
- changing an action's type drops the id and the copy flag it no
  longer uses.
2026-09-24 19:29:36 +02:00
Linus Rath d53bfec68a fix: keep the auto-reply running when filters are saved on the phone
Stalwart runs one active Sieve script and turns the auto-reply on by
activating its own `vacation` script. The webmail therefore re-activates
the filters script with `include :personal :optional "vacation"` and
records `includeVacation` in the rules metadata (198a3c0d). The phone
dropped both, so saving any filter on the phone silently switched the
auto-reply off, and turning the auto-reply on from the phone switched
every filter off.

Port the webmail's handling:
- the generator writes the include (and `require "include"`) and the
  parser reads the flag back, dropping the `include` require once the
  auto-reply is off;
- loading filters while the vacation script is active keeps it through
  the include, as does every save;
- switching the auto-reply on or off in the vacation settings adds or
  removes the include, and the settings read the include as "on",
  because VacationResponse reports it off in that state;
- the "vacation active" banner also shows for an include.

Scripts the webmail wrote with the include now save back unchanged;
`fixtures/webmail/` holds scripts from its current generator.
2026-09-24 19:29:36 +02:00
Linus Rath 4fdf39aa08 fix: go up one folder on Android back in the Files tab
Back inside a Files subfolder switched to the Mail tab. It now closes an
open preview, then leaves selection mode, then goes up one folder, and
only switches tabs at the root. `filesBackStep` holds that order.
2026-09-24 19:28:37 +02:00
Linus Rath 409cdb139e feat: preview images, text and PDFs in the Files tab
Tapping a file in the Files tab downloaded it and went straight to the
Android "Open with" chooser or the iOS share sheet. It now opens the
reader's attachment preview for the types that preview can show, and
hands only the rest to another app, as before.

- `filePreviewKind` (`src/lib/file-preview.ts`) builds on the reader's
  `previewKindFor` and adds source code and config files as text; HTML,
  SVG, images React Native cannot decode and PDFs on Android (no in-app
  renderer) still go to another app
- files over a cap per type (text 512 KB, .eml 10 MB, images 25 MB,
  PDFs 50 MB) open in another app instead; when a node has no size,
  the downloaded file is checked
- the preview downloads the file once, with the same authenticated
  download as Download, into a temp folder of its own; Open with, Share
  and Download reuse that copy, so they work without another fetch
- the folder is deleted when the preview closes, or left to the
  stale-export sweep, which now also clears preview folders, once
  another app got the file
- the per-file menu offers Preview and "Open with…" instead of the
  combined "Preview / Share"
- the shared preview gains a header Open button, an optional Download
  button, labels on its icon buttons and a fallback when an image does
  not decode; the .eml parsing moves into `emlPreviewFromBytes`, which
  the reader uses too
2026-09-24 19:28:37 +02:00
Linus Rath 1d755fc066 fix: list event participants once with contact names and organizer status
Ported from webmail 8cbc5bc5 and 6f99c017. Stalwart can emit the
organizer both as the ORGANIZER-derived participant and as an ATTENDEE,
so the detail sheet listed them twice and counted a phantom
participant. It also drops the organizer's display name, leaving a bare
address, and the organizer without a status got the pending dot.

- fold participants with the same address into one entry, keeping a
  real RSVP over needs-action, and count addresses in
  `getParticipantCount`
- show the organizer as accepted when they have no status, with the
  status label like everyone else
- fill missing names from contact cards (exact address, then the same
  local part on a related domain), then the account and identity
  names, via `useContactNameResolver`; the editor uses it for the
  organizer name of a new invitation
2026-09-24 19:12:03 +02:00
Linus Rath a6ef0abc2a fix: report calendar deletes and imports the server refused
`deleteEvents` never looked at the `CalendarEvent/set` answer, so a
refused delete (read-only calendar, method error) removed the event
from the screen and said nothing until the next refresh brought it
back. Clearing a calendar ignored `notDestroyed`/`notUpdated`, and an
import the server refused entirely returned 0, which the invitation
banner showed as "Already in your calendar".

- check the destroy result in `deleteEvents` and `clearCalendarEvents`
  with `assertSetResult`, and the unlink result as well
- throw from `batchCreateEvents` when nothing was created and the
  server refused events; a partial import still returns its count
- show an alert when deleting an event or a task fails, and refresh
  after a partly failed import or clear
2026-09-24 19:12:02 +02:00
Linus Rath c0582ab950 fix: send the RSVP for invitations outside the loaded calendar window
Accepting an invitation looked the event up in the calendar store,
which only holds the window the calendar last showed. For an event
further out the lookup came back empty, no response was sent, and the
banner still said "Added to calendar".

- find the event on the server with a `uid` filter, like webmail's
  `queryCalendarEvents({ uid })`, both when the banner opens (so it
  shows as already in the calendar) and after importing it
- let `rsvpEvent` take the event when it isn't in the store, so the
  organizer repair still applies
- show "Could not send your response" whenever no response went out
2026-09-24 19:12:02 +02:00
Linus Rath 15717f137a fix: keep what was typed in the event editor when calendars refresh
The editor reset its form whenever `calendars` or `currentUserEmails`
got a new identity, which happens when a calendar push refetches the
calendars or the alias lookup finishes, so the title and times the user
had typed were replaced mid-edit. Reset only when the editor opens or
moves to another event, and fill in the calendar separately when the
calendars arrive after a new-event form opened.
2026-09-24 19:12:02 +02:00
Linus Rath 4b903ed179 fix: keep calendar reminders when the calendar view moves
Reminders were rebuilt from the events of the window the calendar
showed, and everything else was cancelled, so looking at another month
dropped every pending reminder. The sync only started once the Calendar
tab opened, and without a notification handler a reminder that fired
while the app was open was never shown.

- load the events for reminders from their own window (now to a week
  past the horizon), independent of the view, via `loadEventsInRange`
  factored out of `fetchEvents`
- leave scheduled reminders alone when that window can't be loaded
  (offline, no session yet), cancel only ours, and cancel them all on
  sign-out
- start the sync at launch and rerun it after sign-in or an account
  switch, on `Calendar`/`CalendarEvent` state changes, on in-app edits
  and when the app returns to the foreground; only the Calendar tab may
  ask for notification permission
- install a foreground handler that shows our reminders; mail push is
  posted natively and doesn't go through expo-notifications
2026-09-24 19:12:02 +02:00
Linus Rath 6a975b822c fix: show a task's due time in local time like events
A task's `due` is wall time in the task's `timeZone`, but the tasks
sheet, the calendar chips and task reminders read it as device time,
so a task due at 17:00 Europe/Berlin showed and fired at 17:00
wherever the phone was. Events already go through `utcStart`.

- resolve the due in its zone with a new `getTaskDueDate` and use it
  for the list, sorting, overdue, the chips and reminder times
- save an edited timed due with the effective time zone, as the event
  editor does, so the converted local time isn't read back in the
  task's old zone
2026-09-24 19:12:02 +02:00
Linus Rath 1b64c14280 fix: treat CalDAV tasks marked COMPLETED, NEEDS_ACTION or canceled as such
Tasks created over CalDAV can come back with their iCalendar STATUS
spelling or as `canceled` (stalwartlabs/calcard#20). The app compared
against the JSCalendar values only, so a completed CalDAV task counted
as pending, still raised its reminder, and a tap on it completed it
instead of reopening it.

- normalize the known spellings when events are read, like webmail's
  `normalizeCalendarTask` (5f1d2795), and keep unknown values as they
  are
- reopen a cancelled task on tap, since the tasks sheet shows it ticked
2026-09-24 19:12:02 +02:00
Linus Rath dcdda5ed9f fix: let tasks be completed again and say when the server refuses #958
Ticking a task sent `progressUpdated`, which JSCalendar 2.0 dropped and
Stalwart rejects with `invalidProperties`, so the whole update failed.
The checkbox fired the toggle with `void`, so the task silently stayed
open.

- drop `progressUpdated` from the toggle patch, like webmail 6b30e91c
- flip the checkbox optimistically and put it back when the update
  fails
- show a "Failed to update task" alert with the server's reason
2026-09-24 19:12:02 +02:00
Linus Rath 548b647f15 feat: add background colour and the webmail's 16 text colours to the composer
The composer offered 8 text colours and no background colour. It now
has the webmail's 2 x 8 palette (7e0a86a6, 3957b358) for text colour,
and a background colour button with the same palette and a "Remove
background color" action, applied with the editor's `hiliteColor`.

The webmail's font-size picker is not ported: the editor has no mark
model to scope a size to, so it needs a selection-rewriting command in
the page script that can only be checked on a device.
2026-09-24 19:10:46 +02:00
Linus Rath fc00781b47 fix: stop each keystroke in the composer re-rendering the whole screen twice
The editor page posted its full `innerHTML` on every input event and its
formatting state on every selection change, which fires on every
keystroke too. Each message re-rendered the whole composer, where a
`JSON.stringify` snapshot of the body ran on every render (PF8).

- typing now posts at most one `change` per 400 ms: the first keystroke
  at once, so the composer knows it is dirty, then the latest content
  when the window ends; toolbar commands and blur post straight away
- the formatting state is posted only when it changes
- the empty-placeholder check no longer serializes the body per
  keystroke, and the dirty-tracking snapshot is memoized
- autosave reads the live editor DOM, like send, save-and-close and the
  format switch already do, so the throttle can't make it save a
  slightly older body

The body stays in state rather than a ref: the Send button and the
close guard render from it.
2026-09-24 19:10:45 +02:00
Linus Rath 3faac98627 fix: sign quick replies with the sending identity's signature
Quick replies went out with no signature at all. They are now signed
the way the composer signs a plain-text reply: between the reply and
the quote or at the end, following the signature position setting, with
the "-- " separator when it is on, and with the primary identity's
signature when the sending alias has none of its own.

The quick reply also resolves its sending identity like the composer
(webmail 4fc7b8b5): recipients in To, Cc, Bcc order, and a reply to our
own message from the identity that sent it. It still never takes the
catch-all From rewrite, having no From row to show it.
2026-09-24 19:10:45 +02:00
Linus Rath 472c4d8f26 feat: add per-message HTML/plain-text toggle #1022
The composer's format came only from the "plain text only" setting, and
a reopened draft was forced into it: with plain text on, an HTML draft
was flattened on its first autosave, and with it off a text draft opened
in the rich-text editor.

Ported from the webmail (f6ad8a59, `compose-format.ts`):

- a reopened draft opens in the format it was written in; an empty
  draft and new messages still follow the setting
- a toolbar button switches just this message between rich and plain
  text, converting the body in place; the signature keeps its place
  (end or above the quote) in the target format, so it is never
  doubled or lost
- images inserted into the rich-text body stay on as ordinary
  attachments after a switch to plain text instead of being dropped
2026-09-24 19:10:45 +02:00
Linus Rath 0ffdcacea9 fix: start a new message in a shared folder from that mailbox's identity
A new message composed while a shared or group folder was open always
defaulted to the user's own identity: `findComposeIdentityId` was
ported from the webmail but never called.

The composer now reads the folder that was open when it was started and,
for a shared folder whose account name is an address the user has an
identity for, preselects that identity (webmail 129f545a, a971d5a1).
Own folders resolve to nothing and keep the preferred identity. Unlike
the webmail this isn't gated on "Reply from received address": the
webmail gates it only because it falls back to the login address, which
would override a chosen default identity (#507), and this path has no
such fallback.
2026-09-24 19:10:45 +02:00
Linus Rath 2d907ffab8 feat: choose exact-address or same-domain matching for reply from received address #1000
Ported from the webmail (565d5c10). With "Reply from received address"
on, a new "Received address matching" choice limits the catch-all From
rewrite: "Exact address only" keeps replies to configured identities,
"Any address on my domains" (the default, and the old behaviour) also
sends from other addresses on an identity's domain. Deployments whose
extra addresses are distribution lists rather than aliases want exact.

The setting is `replyIdentityMatch`, the webmail's key, so it round-trips
through settings export and import. The toggle's description now says
that identities always match, since that no longer depends on it.
2026-09-24 19:10:45 +02:00
Linus Rath a0c2bd7f4a fix: reply and forward from the address the message was delivered to
Replies went out from the default identity unless "Reply from received
address" was on, because that setting gated both the match against the
user's own identities and the catch-all From rewrite. Forwards took the
rewrite too, and a reply to one's own sent message resolved the identity
from the other party's address. Ported from the webmail (4fc7b8b5):

- the own-identity match is unconditional and applies to forwards;
  only the catch-all From rewrite stays behind the setting, and never
  on a forward
- a reply to our own message keeps the identity that sent it (#703)
- `findReplyIdentityId` scans recipients in To, Cc, Bcc order instead
  of the identity list's, and prefers an untagged identity over a
  differently tagged sibling
2026-09-24 19:10:44 +02:00
Linus Rath 235f4ea77f fix: stop replies and forwards loading the remote images the viewer blocked
The composer's editor page had no Content-Security-Policy, so quoting a
message loaded every remote image and tracking pixel in it, even when
the viewer had shown that message with remote content blocked.

The editor page now carries a CSP that mirrors the viewer's: nothing
loads but our inline script, styles and images, and remote images and
fonts are blocked whenever the quoted original is one the viewer would
block (untrusted sender and a policy other than "allow"). A reopened
draft that carries a quote counts as untrusted. Inline and pasted
images (`data:`, `blob:`) keep working, and the sent HTML is unchanged.
2026-09-24 19:10:44 +02:00
Linus Rath 92b42e4a4f fix: keep the composer open when its draft can't be saved
"Save draft" returned early without saving when the identities hadn't
loaded (offline when the composer opened) or the account had no Drafts
folder, and the close dialog then closed the composer as if the save had
worked, dropping the text.

The close dialog now offers only Cancel and Discard in that case and
says why the draft can't be saved; the save path refuses to close too.
The close guard moves from a bare `beforeRemove` listener to
`usePreventRemove`, which also stops the iOS modal swipe-down that the
listener could not prevent.
2026-09-24 19:10:44 +02:00
Linus Rath 191f4f32cd fix: stop the date and time pickers offering times past the server's hold limit 2026-09-24 19:10:43 +02:00
Linus Rath 7e598c5bc9 fix: stop a failed reschedule from dropping or double-sending the message
Rescheduling cancelled the original submission and created its
replacement in one `EmailSubmission/set`, whose create and update the
server applies independently. A refused replacement (a hold over the
server's limit, a quota) still cancelled the original, so the message
silently never went out, and a refused cancel (the original had just
gone out) still let the replacement go, so it was sent twice.

Ported the webmail's order:
- the replacement is created first; if it is refused, the original
  stays scheduled
- the original is cancelled only once the replacement exists
- if the original can't be cancelled, the replacement is withdrawn
  again, unless a cancel whose answer got lost did go through
- "Send now" is a 1-second hold, so the replacement can still be
  withdrawn
2026-09-24 19:10:43 +02:00
Linus Rath ebd8dacdfd fix: stop re-rendering every mail row when the list re-renders
The mail list passed `onEndReached` and `onRefresh` as inline arrows,
so FlatList (a pure component) re-rendered on every render of the
screen, and without `strictMode` it then handed its cells a new render
function, so every mounted row re-rendered too. Each row also built a
fresh swipe `context` object and `onAction` closure, so its
`SwipeableRow` could never skip a render. Loading the next page while
scrolling flips `loading` and appends rows, re-rendering up to a couple
of hundred rows mid-fling.

- `onEndReached` and `onRefresh` are stable callbacks and FlatList runs
  with `strictMode`, which the default React Native typings lack, so
  `src/types/react-native-flatlist.d.ts` declares it
- each row is an `EmailListItem`, memoized on primitives and stable
  callbacks, which builds its swipe context and callback once per row
- rows share one swipe callback that reads the current handler through
  a ref, so rebuilding the handler no longer re-renders every row
2026-09-24 19:10:43 +02:00
Linus Rath aa250759e5 fix: keep swipe bands unmounted until a mail row is swiped
Every row in the mail list rendered both swipe action bands behind its
content, with an icon, a label and four fresh `Animated` interpolations
on each render, although a band is only visible while the row is
dragged or held open. `SwipeableRow` was also not memoized.

- the bands now mount when a drag is granted and unmount once the row
  springs back to rest (or has flown off), where they are covered and
  fully transparent, so the swipe looks the same
- the band interpolations are built once per row
- `SwipeableRow` is memoized; `context` compares by its fields since
  callers build it per row
2026-09-24 19:10:43 +02:00
Linus Rath cb9a741244 fix: reuse the list's date formatters instead of building them per row
Every mail row formatted its date through `toLocaleTimeString` and
`toLocaleDateString`, which build a new Intl formatter on each call; a
row from the last week built two, on every render. On Hermes that goes
through ICU over JNI and is the costly part of formatting a date.

`formatListDate` now keeps one `Intl.DateTimeFormat` per locale and
format in a module-level map. The output is unchanged, since those
methods format exactly as `Intl.DateTimeFormat` with the same options;
a test checks the two against each other across locales, formats and
ages. The cache starts over when the device's UTC offset changes, so a
new time zone still shows as it did when every call picked it up.
2026-09-24 19:10:43 +02:00
Linus Rath 695aa23563 fix: show the Bulwark shield instead of the stock envelope in the status bar 2026-09-24 19:09:36 +02:00
Linus Rath 8618abf1d7 fix: open a group mailbox notification's message in the group account #839
The notification only carried the local account it was posted for, so
tapping one for a message in a group or shared mailbox opened the
thread against the user's own JMAP account, where the message doesn't
exist.

The background task now passes the JMAP account the message was
fetched from as `jmapAccountId`. The native module stores it as an
intent extra, validated like the other ids, and hands it back with the
tap. `App.tsx` routes the open with it, the way the unified inbox does
for group messages. Notifications posted before this change have no
such extra and open against the account's own mail as before.
2026-09-24 19:09:36 +02:00
Linus Rath a3d3af797c fix: keep push off after it is turned off or this device is revoked
The launch-time effect in `App.tsx` re-ran the full setup for the
active account whenever a relay URL was stored, and that URL is shared
by every account on the device. Turning push off for one account, or
revoking this device, lasted only until the next launch or token
rotation. A subscription revoked from another device was re-created
without asking.

- Turning push off in settings and revoking this device now go through
  `disablePushForAccount`, which tears the registration down and marks
  the account as opted out. Enabling push again clears the mark.
- The effect calls `resyncPushNotifications`, which skips opted-out
  accounts. If the server no longer has the recorded subscription
  although it still had a day or more to live, it treats the
  subscription as revoked and turns push off for the account instead
  of registering again. A subscription that lapsed is re-created as
  before.
- Setup records the expiry the server reports: from the create
  response (`createPushSubscription` now returns it) and from every
  listing.
2026-09-24 19:09:35 +02:00
Linus Rath ddfe85a5b2 fix: show an error when the server refuses a push registration change
The PushSubscription calls read the first method response without
checking it, so an `error` response listed no devices, and a refused
verify or destroy (revoking another device) was reported as done. Route
every call through `requireMethodResult` and `assertSetResult`; a
destroy the server answers with `notFound` still counts as done.
2026-09-24 19:09:35 +02:00
Linus Rath ccaa24ecd3 fix: stop re-patching the push filter on every launch
`PushSubscription/get` ran with the default properties, which leave out
`emailPush`, so the stored filter never compared equal to the wanted one
and every launch sent it again. The calls also never named the
emailpush capability in `using`.

Ask for `emailPush` explicitly and send
`urn:ietf:params:jmap:emailpush` with every call that reads or writes
it, as the webmail's `lib/jmap/client.ts` does. Servers without the
capability keep the default properties and never see the property, so
the filter is only patched when it differs from what the server holds.
2026-09-24 19:09:35 +02:00
Linus Rath d8046a8aad fix: keep push on for users with mailboxes shared to them by ACL
The emailPush map named every account with mailboxes. Stalwart refuses
the whole map as `forbidden` when it names an account the user neither
owns nor belongs to as a group member, so a user with a mailbox shared
by ACL had every refresh rejected. The refresh path then destroyed the
working subscription and the recreate was refused the same way, leaving
push off for good.

- On a `forbidden` refusal, retry with a narrower map: first the
  accounts that look owned (Stalwart sets `mayCreateCalendar` and its
  siblings only on the user's own and group accounts), then the primary
  account alone. Stalwart only fans a subscription out to owned accounts,
  so nothing is lost.
- Remember the refused accounts per account and leave them out of the
  map on later runs; a forced re-register re-learns them.
- Keep the old subscription until its replacement is verified, so a
  refused or failed replacement never leaves the device without push.
- `createPushSubscription` and `updatePushSubscription` now throw a
  `JMAPMethodError` carrying the SetError type.

The JMAP part is shared by FCM and UnifiedPush, so both get the fix.
2026-09-24 19:09:35 +02:00
Linus Rath 9e95273c14 feat: tell the user when a viewer action fails and when mail is sent
The viewer fired its store actions with `void` and went back to the
list, so a refused delete, archive, move, spam report, star, tag or
read change failed silently, and nothing confirmed a sent message.

- each viewer action now reports a failure as an error toast, and a
  refused star, tag or read change is taken back on screen
- the viewer mounts its own toast host, since the list's sits under it
- an immediate send from the composer or the quick reply box shows
  "Email sent"; a send held for the undo delay keeps its undo bar
  (webmail `ec67ab99`, `b03a0c1d`)
2026-09-24 19:06:55 +02:00
Linus Rath 33d26744b6 fix: archive from the viewer when the list does not hold the message
Viewer Archive looked the message up in the loaded list and silently
did nothing when it was not there: for messages opened from the Unified
Inbox, a notification or a deep link, and for any message past the
loaded page. It also resolved the Archive folder from the folder open
in the list, so it was only offered for mail of that folder's account.

`archiveEmail` now takes the viewed message like delete, move and spam
do: it archives the viewer's copy into the Archive folder of the
message's own account (year/month foldering included) and leaves the
open list alone when that is another account.
2026-09-24 19:06:55 +02:00
Linus Rath 5e42c6737e fix: report spam from the viewer the way the list does #695
The viewer's Spam and Not spam buttons were a plain move into Junk or
Inbox: they sent no `$junk`/`$notjunk` flip, so the server's classifier
never learned from them, and they ignored the "trash-and-read" delete
action. They now call the store's `markSpam`/`unmarkSpam`, the path the
list and swipe actions use, which take the viewed message and file it
in its own account's Junk or Inbox, also when the list does not hold
it. Not spam is only offered when that account has an Inbox.
2026-09-24 19:06:55 +02:00
Linus Rath 9c31de2e80 fix: keep the viewer on the opened message when new mail arrives
The viewer's pager followed the store's live list. New mail or a
refresh shifted the rows under the fixed scroll position, so the
screen switched by itself to another message while Star, Delete and
Archive kept acting on the one that had been opened.

The pages are now taken once when the viewer opens, in `viewerPages`,
so the page on screen and the message the toolbar acts on stay the
same. Delete, archive, move and spam leave the viewer as before, so no
page is left showing a message that is gone.
2026-09-24 19:06:54 +02:00
Linus Rath 5c7301b3f1 fix: stop viewer actions from hitting another account's message with the same id
Stalwart ids are only unique per account. When its opener named no
account, the viewer used the account of the folder open in the list,
and it resolved Trash, Junk and move targets from that folder as well.
Deleting a team message from the Unified Inbox therefore sent
`Email/set` to the user's own account and trashed an unrelated message
with the same id, and a message opened from a notification, deep link
or contact while a shared folder was open went to the shared account.

- the list names the open folder's account when it opens the viewer,
  and the viewer takes the account from its opener only, fixed at open
- Trash, Junk, Inbox and move targets come from the message's account,
  and delete and move take the message out of its own folder
- the store's delete and move take the viewed message: they only touch
  the list's row when the list shows that account, and refuse folders
  of any other account
- another account's message is marked read on the server directly
  instead of through the open folder's list
- quick reply, unsubscribe and read receipts file into the message's
  account's folders
- undo no longer slots another account's message into the open list
- Archive is only offered for mail of the open folder's account, since
  it still resolves the archive folder from there
2026-09-24 19:06:54 +02:00
Linus Rath d987930034 fix: load each list page and search in one request
Opening a folder, searching or loading more took three sequential
round trips: `Email/query`, then `Email/get` of its ids, then a
`Thread/get` the list screen fired after rendering to size the
conversation badges.

- `queryEmailPage` sends all three in one request, chaining
  `Email/get` to the query's `/ids` and `Thread/get` to the fetched
  messages' `/list/*/threadId` (left out while threading is off). The
  page is capped at `maxObjectsInGet` so the chained get can't be
  refused, and a failed `Thread/get` keeps the page.
- The full refresh, search and load-more use it; load-more no longer
  needs its own chunked `Email/get`.
- The thread sizes now live in the email store (`threadCounts`,
  cleared on folder or account change) and the list screen reads them
  instead of fetching them itself. Rows restored from a snapshot or
  added by an incremental refresh get theirs with one `Thread/get`
  after the refresh.
- The chained `Email/get` also reports the Email state for an empty
  folder, so that list gets its own `Email/changes` baseline.
2026-09-24 19:04:37 +02:00
Linus Rath 35e0792558 fix: stop "load more" skipping messages in the Unread and Starred views
Rows read in the Unread view (or unstarred in Starred, untagged in a
tag view) stay on screen, but they have left the server's result.
`loadMoreEmails` still counted them in `position`, so the next page
started that many messages too late, and the `emails.length >= total`
guard could stop paging while server rows were still unloaded.

Page from the number of loaded rows the query still returns, like the
webmail, and take the total from the load-more query so a list that
shrank under the user doesn't keep asking for a page that isn't there.
2026-09-24 19:04:36 +02:00
Linus Rath 0a8686a008 fix: keep messages in the list after a background refresh
`inMailbox` is a mutable filter, so Stalwart's `Email/queryChanges`
reports every updated message as removed and re-added. The incremental
refresh dropped those rows and never re-fetched them because they
"already existed"; they only came back after a pull-to-refresh. It
worked only while `Email/changes` happened to list the same ids, and
the account-wide Email state that call needed was deleted whenever a
folder query returned no ids, so visiting an empty folder was enough
to break the next refresh.

- Fetch every id `Email/queryChanges` adds, including rows already in
  the list, and put them back at their new index with fresh keywords.
- Keep the Email state per folder list (keyed by store mailbox id) and
  run `Email/changes` from the list's own state, like the webmail's
  `emailListSync`, so refreshing another folder can't move it past
  changes this list hasn't seen. A list without a state is re-queried.
- An empty query result keeps the list's previous state instead of
  discarding it.
- An added index past the loaded window is left to load-more instead
  of being appended after a gap.
2026-09-24 19:04:36 +02:00
Linus Rath 668e01023c fix: stop the contacts tab downloading every card on each open
Opening Contacts re-ran the full `ContactCard/query` + `ContactCard/get`
for every account (58 KB on the audit server) although the cards are
loaded at startup and every live `ContactCard` state change already
refetches them.

The tab now only fetches when no complete fetch happened this session,
after a reset or account switch, after a filtered load, or when the
last full fetch is more than 5 minutes old, which covers changes missed
while the app was in the background or live updates were down. Pull
to refresh and the Refresh buttons still force a reload (PF6).
2026-09-24 19:03:35 +02:00
Linus Rath c888e95c5a fix: create new contacts in the address book being viewed 2026-09-24 19:03:35 +02:00
Linus Rath e6be8922b7 fix: stop re-sorting the contact list on every search keystroke
Typing in the Contacts search re-ran the full sort on each keystroke,
with `localeCompare` computing both display names and setting up a
collator for every comparison. The list is now sorted once per
contacts, category or sort-order change and search only filters the
sorted list; the sort itself computes each sort name once and reuses
a single `Intl.Collator` (PF10).
2026-09-24 19:03:34 +02:00
Linus Rath 182774884e feat: add a setting to sort contacts by last name #963
Port of the webmail's `sortContactsByLastName` (fb24ef5d). With the new
"Sort by last name" switch in Settings > Contacts, the contact list is
ordered and letter-grouped by "Surname, Given Middle", so family members
sit together. Cards without a structured surname fall back to the last
word of `name.full`; organizations, nicknames and bare addresses keep
their display name. The setting defaults to off, is persisted, and is
exported and imported under the webmail's key.
2026-09-24 19:03:34 +02:00
Linus Rath a734d0ade1 fix: always write the contact's full name so vCards carry FN #430
`name.full` feeds the vCard FN, which is mandatory; strict CardDAV
clients such as Apple Contacts drop cards without it. The app left it
out or let it go stale on several write paths:

- the contact form (and "Add to contacts", which goes through it) only
  sent `full` when the optional display-name field was filled in
- editing a card put its stored `full` into that field, so renaming
  the person wrote the old name back as FN
- vCard import dropped FN once it had split it into components

Port the webmail's fix (a210b22e): the form derives `full` from the
name components in display order unless a display name of its own is
set, and the edit form only shows `full` when it differs from what
would be derived, so a derived one is recomputed on every save. Import
keeps FN verbatim whether it comes before or after N, and a card with
N but no FN gets a derived `full` too.
2026-09-24 19:03:34 +02:00
Linus Rath 95c24a4214 fix: stop notifications fetching sender favicons when favicons are off
The "Sender Favicons" setting only covered the in-app avatars. Push
notifications always fetched the sender's favicon from
icons.duckduckgo.com, so with the setting off every notification still
sent the sender's domain and the device's IP to a third party.

The webmail has the same setting, on by default, and fetches through
its own `/api/favicon` proxy, so DuckDuckGo sees the server rather than
the user. Native has no proxy, but turning the default off would
silently drop the logos existing users see and diverge from the
webmail, which is a product call. So the default stays on and the one
existing switch now covers every favicon fetch: the notification task
reads it from the persisted settings and shows initials when it is off,
or when the settings cannot be read.
2026-09-24 19:03:34 +02:00
Linus Rath eca3871545 feat: show the sender's contact photo as their avatar #54
Sender avatars ignored address-book photos, so a contact whose photo
shows in Contacts still got initials or a favicon in the mail list,
the viewer and the thread cards. Like the webmail's `Avatar`, the
contact photo now wins over the favicon and the initials; `disableImages`
(Junk) still shows initials only.

The lookup goes through a lowercased email -> photo index that is built
once per contacts array, and each avatar selects just its own photo
string, so a list of avatars neither rescans the address book per row
nor re-renders when an unrelated contact changes. Stalwart returns
photos as inline data URIs, which the index normalizes the same way
Contacts does, so no authenticated download is involved.
2026-09-24 19:03:34 +02:00
Linus Rath d89e33110a fix: load remote content only for senders in the trusted senders book
With "Sync trusted senders to address book" on, the viewer trusted every
address in every address book, so remote images and tracking pixels
loaded for any contact, including mail with a spoofed From that uses a
contact's address. The toggle also switched itself on the first time
Content & Senders was opened on a contacts-capable account, so merely
visiting settings widened trust to the whole address book.

Now only the local allow-list and, while sync is on, the dedicated
"Trusted Senders" book count, as in the webmail viewer. The setting
stays off until the user turns it on, and the viewer only fetches the
book while sync is on.
2026-09-24 19:03:33 +02:00
Linus Rath ac58b36a77 test: give the translation key scans a 30 s timeout so they pass under load 2026-09-24 19:01:24 +02:00
Linus Rath e6549d31da fix: use the right plural forms on Android instead of the English ones
Hermes has no `Intl.PluralRules`, so `src/i18n/format.ts` fell back to
the English rule (1 is "one", everything else "other") for every
language. Russian showed "5 письма" instead of "5 писем", Polish,
Czech, Arabic and the other languages with more than two forms were
wrong in the same way, and French treated 0 as plural.

Add the CLDR 48 cardinal rules for the app's 27 locales and use them
whenever `Intl.PluralRules` is missing or rejects the locale. Engines
that have it keep using it; a test checks the built-in rules against
it for every locale.
2026-09-24 19:01:24 +02:00
Linus Rath 2511df1059 fix: translate the plain text font, message spacing and read receipt settings 2026-09-24 19:01:24 +02:00
Linus Rath a86cd1d0eb feat: manage filters and vacation of shared and group accounts
The phone only ever read and wrote Sieve scripts and the vacation
responder of the user's own account, and the "Shared with me" list in
Account settings said to use the webmail for the rest. Port the
webmail's scoped settings (4c6c1aab) for these two panes:

- `api/sieve.ts` and `api/vacation.ts` take an optional account id and
  gate on the account's own `accountCapabilities` for
  `urn:ietf:params:jmap:sieve` and `urn:ietf:params:jmap:vacationresponse`,
  treating non-personal accounts as capable because Stalwart doesn't
  always advertise capabilities on them. Script blobs are uploaded and
  downloaded against the same account.
- Tapping a shared account in Account settings scopes Settings to it:
  only the Filters and Vacation Responder panes it supports are listed,
  behind a "Managing: <name>" banner that leads back to the own account.
  The scope is dropped when the signed-in account changes.
- The filter and vacation stores remember which account they hold,
  reset when it changes and drop replies for an account the user has
  already left, so one account's rules are never saved into another.
- The rule editor offers the managed account's folders as targets, and
  the "vacation active" banner only shows for the own account.
2026-09-24 19:01:24 +02:00
Linus Rath f63d739fb3 fix: file "Keep" filter rules into the inbox so allow-lists beat the spam filter #1027
A bare `keep;` delivers to the default place, which on Stalwart is Junk
for a message its spam filter has already classified, so an allow-list
rule made with "Keep in inbox" changed nothing. The webmail now writes
`fileinto "INBOX";` for it (62465e1e), but saving filters on the phone
wrote `keep;` again and undid that.

The action stays `keep` in the metadata, so scripts written by either
client read back as "Keep in inbox" and save unchanged.
2026-09-24 19:01:23 +02:00
Linus Rath 72f48db4e5 fix: list and cancel scheduled sends held in shared accounts
A send from a shared or group account's identity (quick reply with the
undo-send delay, or a message scheduled in the webmail) is held in that
account. The Scheduled screen only looked in the primary account, so it
never listed such a message, and it could not be cancelled, rescheduled
or edited before it went out (webmail #874).

The Scheduled list now covers every account that advertises submission
and can hold mail. Each entry carries its account, and cancel,
reschedule, "Send now" and edit act there. Edit restores the message
into that account's Drafts and opens it as a shared draft. A shared
account that fails to answer is skipped, so the user's own scheduled
mail still shows.
2026-09-24 19:00:09 +02:00
Linus Rath 4e7ae2fa30 fix: only offer scheduled-send times the server accepts
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.
2026-09-24 19:00:08 +02:00
Linus Rath 48825e919a fix: stop rewriting the mail, contacts and calendar caches on every state change
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
2026-09-24 18:56:38 +02:00
Linus Rath 24849baca5 fix: let the offline mail cache grow past 6 MB on android
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.
2026-09-24 18:56:37 +02:00
Linus Rath e37621be5e fix: cap the cached mail list so android can still read it back
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.
2026-09-24 18:56:37 +02:00
Linus Rath 29169c3426 fix: stop session restore hanging when a persisted cache can't be read
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
2026-09-24 18:56:37 +02:00
Linus Rath 5237848312 docs: add the android release signing and key rotation guide
`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.
2026-09-24 18:52:15 +02:00
Linus Rath 746572c784 fix: raise the android versionCode with every release
`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.
2026-09-24 18:52:15 +02:00
Linus Rath 6f0abb4384 fix: stop signing release apks with the public debug key
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`.
2026-09-24 18:52:15 +02:00
Linus Rath 1d1d6a5bfc fix: hide body-embedded cid parts typed octet-stream from the attachment chips
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.
2026-09-24 18:50:55 +02:00
Linus Rath d45c7996d2 fix: keep list previews and subjects on one line
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.
2026-09-24 18:50:55 +02:00
Linus Rath 476780553c fix: show large message bodies in full instead of a cut-off stub #884
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.
2026-09-24 18:50:55 +02:00
Linus Rath 910c503f4a fix: render right-to-left mail right-to-left
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.
2026-09-24 18:50:55 +02:00
Linus Rath 7531118727 fix: let sender images and tables keep their own max-width #790
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`.
2026-09-24 18:50:55 +02:00
Linus Rath 55f1ec431f chore: sync the locale catalogs from the webmail
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`
2026-09-24 18:46:59 +02:00
Linus Rath cb2f03bc09 fix: mark read and star a list selection in one request
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.
2026-09-24 18:44:29 +02:00
Linus Rath 50418971f4 fix: stop mark-read, star, pin and tag from erasing other keywords
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.
2026-09-24 18:44:28 +02:00
Linus Rath 859770b814 fix: stop signing out on launch when a redirect drops the auth header
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.
2026-09-24 18:43:56 +02:00
Linus Rath c51a848dfb fix: stop reporting a send as failed when only filing it into Sent failed
`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.
2026-09-24 18:43:56 +02:00
Linus Rath e67947aa93 fix: keep cc and bcc recipients when rescheduling a 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.
2026-09-24 18:43:56 +02:00
Linus Rath 802b19916c fix: hide the schedule button when the server can't schedule sends 2026-09-24 18:43:56 +02:00
Linus Rath e28e8b4d9c fix: turn scheduled send and the undo-send delay back on for Stalwart #57
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.
2026-09-24 18:43:56 +02:00
Linus Rath ee652d39df fix: open mailto links whose address contains a plus
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.
2026-09-24 18:43:43 +02:00
Linus Rath 06e89422bd fix: open the composer for mailto links sent with SENDTO
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.
2026-09-24 18:43:43 +02:00
Linus Rath cd4ce9d08b fix: upload files shared into the app with their real name and type
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
2026-09-24 18:43:43 +02:00
Linus Rath 8d7fbfc99c fix: clear the Files selection on Android back instead of switching to Mail 2026-09-24 18:41:23 +02:00
Linus Rath caea3d0c60 fix: offer sharing files and calendars with other users on Stalwart
`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.
2026-09-24 18:41:23 +02:00
Linus Rath 00ad2f49fb fix: show an error when the server refuses a file rename, move or delete
`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.
2026-09-24 18:41:23 +02:00
Linus Rath 4eb8c47670 fix: list every file when the account holds more than 500 nodes #1069
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.
2026-09-24 18:41:23 +02:00
Linus Rath bd42206686 fix: accept a pasted JMAP session URL as the server address #971 2026-09-24 18:40:43 +02:00
Linus Rath ab845783ab fix: stop adding an OAuth account from signing in the current user again
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.
2026-09-24 18:40:43 +02:00
Linus Rath 5f3f584ca8 fix: auto-send read receipts only for the message on screen and clean up failed ones
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
2026-09-24 18:36:15 +02:00
Linus Rath cb6ddd6fcf fix: strip CR/LF from read-receipt header values (GHSA-w38p-hpqv-g89c)
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.
2026-09-24 18:36:15 +02:00
Linus Rath ee04e95d4f feat: add UnifiedPush as an alternative to FCM for push notifications 2026-09-24 18:17:43 +02:00
lucletoffeandClaude Opus 5.5 bd1b2f3fd3 fix(calendar): keep app-scheme meeting links and match service labels exactly
A virtual location in an app scheme (msteams:, zoommtg:) is kept as a last
resort instead of hiding the meeting row; script schemes never are. A
location only joins the meeting when the whole label is a service name, so
"Salle Teams" still opens the maps. Recognise Zoom's /wc/ join path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:59:58 +02:00
lucletoffeandClaude Opus 5.5 7c4b584d9c fix(calendar): join the meeting and open the location from the event sheet
Invitations - Teams above all - leave virtualLocations empty: LOCATION reads
"Microsoft Teams Meeting" and the join URL sits in the description between
help and dial-in links, so the sheet showed no meeting button. Find the
join URL of known conferencing services (Teams, Zoom, Meet, Webex, Jitsi,
Whereby, GoTo) in the location or description, unwrapping Outlook Safe
Links, and offer it as the meeting link. Nothing is written to the event.

The location was plain text. Tapping it now opens it: a URL opens as a
link, a location that only names the service joins the meeting, anything
else is searched in the maps app (geo: intent on Android, Google Maps
universal link elsewhere). Long-press copies it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:47:15 +02:00
Michael Brusegard e35cd82f5f fix(auth): verify external identity providers during handoff
Allow webmail-configured OAuth providers on separate hosts without trusting callback-supplied endpoints or broadly allowing sibling domains. Bind the client, JMAP server, issuer and token endpoint to HTTPS metadata from the selected webmail for both handoff and pairing.
2026-09-18 04:01:58 -07:00
Linus Rath 5b8c4353d8 Merge pull request #53 from lucletoffe/fix/calendar-description-links
fix(calendar): make URLs in the event description tappable
2026-09-09 22:20:23 +02:00
lucletoffeandClaude Opus 5 c2584c2bd5 fix(calendar): make URLs in the event description tappable
A meeting invitation puts the join URL in the description - Teams, Meet,
Zoom and the plain iTIP fallbacks all do - and the event detail sheet
rendered it as bare <Text>. The one actionable thing in the event could
not be tapped: you had to select the URL by hand and paste it into a
browser. The mail body already linkifies (plainTextToSafeHtml, rendered
in the WebView); only the calendar was left out.

`splitTextLinks` (lib/linkify-text) splits the text into runs and marks
the http(s) URLs, using the same rule as plainTextToSafeHtml so a message
linkifies identically in the body and in the calendar. DetailRow gains an
opt-in `linkify` prop - set on the description row only, not on rows the
app composed itself - and hands each URL to `openExternalUrl` with
`confirm: true`, the same guard the "Open link" button on the video row
already uses: scheme allow-list plus the host confirmation, since this
text arrives in someone else's data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QPPAw4SK4M9Z7euVPM2cL
2026-09-04 09:49:44 +02:00
Linus Rath 6c82130267 chore: update version number to 0.1.62 2026-08-30 00:37:37 +02:00
Linus Rath f228257ec9 fix: stop the viewer crashing on any message with attachments by keeping store selectors referentially stable 2026-08-29 22:41:09 +02:00
Linus Rath a02e7f8a49 test: add an opt-in live Stalwart integration suite 2026-08-29 22:28:42 +02:00
Linus Rath ea1f53f550 fix: keep the created message id when the server answers a send with an implicit filing update 2026-08-29 22:28:41 +02:00
Linus Rath 4e035f3011 chore: update version number to 0.1.61 2026-08-29 20:25:25 +02:00
Linus Rath e04bea451a fix: check the GitHub CLI login before bumping so a failed release does not leave a bare tag 2026-08-29 20:20:54 +02:00
Linus Rath 5cb28f5d5d chore: update version number to 0.1.60 2026-08-29 20:20:15 +02:00
Linus Rath 94c9a2e7a2 chore: update version number to 0.1.59 2026-08-29 20:19:36 +02:00
Linus Rath 3f2dfe83ee chore: merge upstream fixes for reply threading, iOS session auth drop, calendar paging and the camera prompt (#35, #38, #39, #40) 2026-08-29 20:16:33 +02:00
Linus Rath 99a20777b7 docs: tick the P1 summary and record the fix-pass status in the parity index 2026-08-29 18:56:47 +02:00
Linus Rath 96272b6e17 fix: confirm the host before opening meeting links from calendar events and invitations 2026-08-29 18:56:46 +02:00
Linus Rath 8e33808ded docs: tick the calendar parity items fixed in this pass 2026-08-29 18:55:31 +02:00
Linus Rath 2abea9742e feat: add a send-invitations switch and custom reminder offsets, keeping alerts the picker cannot show 2026-08-29 18:53:58 +02:00
Linus Rath c705227611 chore: add the harvested and hand-written RN locale keys 2026-08-29 18:49:38 +02:00
Linus Rath 5fe8e407c7 feat: duplicate, share as .ics and copy the meeting link of an event; link imported UIDs instead of skipping them (#113) 2026-08-29 18:49:08 +02:00
Linus Rath 0159d6acc8 docs: tick the outbox and unified-inbox core items 2026-08-29 18:48:51 +02:00
Linus Rath 8fd709d308 docs: record the commit hashes for the mail-list parity items 2026-08-29 18:47:21 +02:00
Linus Rath 0205aa0e1c feat: cross-account moves via blob import, shared-account offline sync, parked failed outbox ops with retry and back-off, archive replay with foldering, localized relative dates 2026-08-29 18:47:12 +02:00
Linus Rath 09c24fa805 feat: edit tasks with description, priority and due time, filter the list and overlay due tasks on the calendar 2026-08-29 18:44:36 +02:00
Linus Rath 86c3ab9f28 fix: escape the line-break regex in the quick-reply prefill 2026-08-29 18:42:26 +02:00
Linus Rath 9398b46795 docs: tick the viewer-side core items 2026-08-29 18:41:49 +02:00
Linus Rath f9ae4423e9 fix: sweep stale export files at launch and carry quick-reply text into the composer 2026-08-29 18:41:49 +02:00
Linus Rath 8e1d03c746 feat: fire calendar and task reminders as local notifications 2026-08-29 18:41:06 +02:00
Linus Rath a222e89611 refactor: merge the quick reply's email-body imports 2026-08-29 18:40:42 +02:00
Linus Rath 26786ca3e4 fix: add the reader's load-failed translation key 2026-08-29 18:40:02 +02:00
Linus Rath 6ae21d5690 feat: folder actions, tags and unified views in the drawer, folder parent/role editing, tag discovery and account-routed unified actions (#404, #498, #495, #175, #658, #855) 2026-08-29 18:39:45 +02:00
Linus Rath d6670c2b60 docs: tick the reader parity items 2026-08-29 18:39:29 +02:00
Linus Rath d2ed27fd3b feat: conversation view with collapsible cards, header details, auth chips, unsubscribe and read-receipt banners, attachment preview and zip, embedded-message and TNEF unwrap in the reader (#480, #830, #891) 2026-08-29 18:38:53 +02:00
Linus Rath ee3b3ffcc3 test: mock the Expo native modules the stores reach transitively 2026-08-29 18:36:08 +02:00
Linus Rath 0f80e98fbc feat: localize the calendar, add Saturday week start, ISO week numbers, month chips and 12h labels (#666) 2026-08-29 18:35:46 +02:00
Linus Rath 2b44936a17 chore: harvest new RN-only translation keys 2026-08-29 18:34:18 +02:00
Linus Rath bf92a00199 test: cover the draft route-param builder and tick the composer parity items 2026-08-29 18:34:09 +02:00
Linus Rath 00fd0696b2 docs: note the deferred date-locale item in the parity checklist 2026-08-29 18:33:27 +02:00
Linus Rath c659b5489c feat: signature, read-receipt, empty-subject, autosave and sub-address settings, identity Reply-To/Bcc/HTML signature with a default identity, template category/favourite, and reschedule/send-now/edit on the scheduled screen (#507) 2026-08-29 18:33:22 +02:00
Linus Rath 3a8264c7f7 feat: translate the account security pane 2026-08-29 18:33:16 +02:00
Linus Rath dc3ac4c7ed feat: translate the downloads pane 2026-08-29 18:30:32 +02:00
Linus Rath ae31a71e01 docs: tick settings pane, theme and sidebar-app items in the parity checklist 2026-08-29 18:30:09 +02:00
Linus Rath 0e63d45599 feat: expose a mobile sidebar-apps selector for the drawer 2026-08-29 18:30:08 +02:00
Linus Rath 22697fd111 feat: translate the appearance, reading, composing, identity, template, keyword and sidebar-app panes 2026-08-29 18:29:34 +02:00
Linus Rath fe45a444d3 feat: port the webmail's built-in colour themes to the Themes pane 2026-08-29 18:29:18 +02:00
Linus Rath bea3fc4c42 feat: message-list order presets with the Stalwart keyword-sort polarity probe, avatars-in-junk and folder-count toggles (#718, #498) 2026-08-29 18:28:43 +02:00
Linus Rath 5b72c4d314 feat: drafts with autosave and a save/discard close guard, Bcc, signatures, templates, sub-addressing, From override, read receipts, upload progress, inline-image hydration and an undo-send snackbar in the composer (#188, #849, #848, #540, #570, #684, #163) 2026-08-29 18:28:38 +02:00
Linus Rath 3194b5ece1 feat: tags, pins, recipients, not-spam, delete confirm, folder-scoped search and draft editing in the mail list 2026-08-29 18:23:08 +02:00
Linus Rath f07e039ef0 feat: flip $junk/$notjunk on spam, search all folders by default, keep the search across folders and coalesce refreshes in the mail store (#850, #788, #553, #175) 2026-08-29 18:22:14 +02:00
Linus Rath 8206893860 feat: assess invitation trust, read the iTIP method from the ICS and pick the target calendar in the invitation banner 2026-08-29 18:22:00 +02:00
Linus Rath 65c73d82a1 docs: tick misc UI items in the parity checklist 2026-08-29 18:21:34 +02:00
Linus Rath 1d1ff35d1b chore: harvest new RN-only translation keys 2026-08-29 18:21:17 +02:00
Linus Rath 18e56163de test: compare locale coverage against the vendored catalog and add missing overlay keys 2026-08-29 18:20:47 +02:00
Linus Rath 3ae9bbe702 feat: add a toast host, prompt dialog, haptics helper and reduce-motion aware animations 2026-08-29 18:20:01 +02:00
Linus Rath 5802caeea9 feat: open deep links, mailto: URLs and share-sheet payloads in the app 2026-08-29 18:20:01 +02:00
Linus Rath 06742ef7d5 fix: render HTML-only mail as HTML, keep stylesheets and block every external-content vector in the reader (#46, #49) 2026-08-29 18:15:22 +02:00
Linus Rath dae5700688 feat: manage calendars from the drawer and scope, edit and auto-refresh iCal subscriptions 2026-08-29 18:14:48 +02:00
Linus Rath 42ab246cb9 feat: port recipient parsing, reply-identity, sub-addressing, signature, quote-header and template helpers; drop the dead submission api 2026-08-29 18:12:15 +02:00
Linus Rath 2bf2e89813 docs: tick account, security and push items in the parity checklists 2026-08-29 18:11:28 +02:00
Linus Rath ddae1e63f2 feat: manage public keys and encryption at rest from account security 2026-08-29 18:11:13 +02:00
Linus Rath 490355c65e feat: list logged-in accounts, storage quota and auth method in account settings 2026-08-29 18:11:13 +02:00
Linus Rath 1f2df5f079 fix: run the push background task on a detached JMAP session instead of re-binding the client 2026-08-29 18:11:13 +02:00
Linus Rath 2586eccaca docs: tick the Android backup item 2026-08-29 18:11:08 +02:00
Linus Rath 7f9f5af7e6 fix: keep the mail cache and outbox out of Android auto-backup 2026-08-29 18:11:07 +02:00
Linus Rath 78532211f3 docs: tick the login localization items 2026-08-29 18:10:25 +02:00
Linus Rath 240471764a feat: localize the sign-in flow and offer previously used addresses 2026-08-29 18:10:25 +02:00
Linus Rath 63bf3921b0 fix: only open safe URL schemes from contact cards 2026-08-29 18:07:15 +02:00
Linus Rath 56f8dceb8f docs: tick the auth and JMAP core parity items 2026-08-29 18:06:48 +02:00
Linus Rath 0d94d7643c fix: finish a webmail hand-off with a TOTP code instead of failing 2026-08-29 18:06:48 +02:00
Linus Rath 99eb525d22 docs: tick the contacts parity items 2026-08-29 18:05:59 +02:00
Linus Rath 58e3ef6827 fix: query events by date window, scan tasks separately and stop re-probing denied shared accounts (#907) 2026-08-29 18:05:56 +02:00
Linus Rath 4bebf70482 feat: sign in with OAuth (PKCE) directly against the mail server and revoke refresh tokens on sign-out 2026-08-29 18:05:16 +02:00
Linus Rath 0e514659be fix: query the server for a contact's upcoming events instead of the calendar cache 2026-08-29 18:04:48 +02:00
Linus Rath 78114b620a fix: show and remove synced trusted senders in settings and add the address-book sync toggle (#730) 2026-08-29 18:04:47 +02:00
Linus Rath bc4c57e734 feat: set a default address book from settings and guard it from deletion 2026-08-29 18:04:47 +02:00
Linus Rath 358bdb93ea feat: bucket the contact index by any letter, add select-all/export, a shared-from drawer section and category rename 2026-08-29 18:04:47 +02:00
Linus Rath 8bf48f6465 docs: tick settings-store and updater items in the parity checklist 2026-08-29 18:04:44 +02:00
Linus Rath 431facef0b feat: add update severity, quiet rate-limit backoff and lazy checksum fetch to the updater 2026-08-29 18:04:21 +02:00
Linus Rath 3d0d440673 refactor: drop dead mail layout, S/MIME and plugin controls from settings 2026-08-29 18:04:21 +02:00
Linus Rath 8deff66274 feat: validate settings on hydrate, align defaults with the webmail and add export/import/reset 2026-08-29 18:04:21 +02:00
Linus Rath a071d0581c feat: refetch filters and vacation on push and fan state changes out to screens 2026-08-29 18:03:24 +02:00
Linus Rath 6f747db858 fix: resolve redirects in JS for client-certificate requests and pass blob deadlines to the native path (#627) 2026-08-29 18:03:24 +02:00
Linus Rath 69bedf0b26 feat: manage public keys and encryption at rest through the Stalwart API 2026-08-29 18:03:23 +02:00
Linus Rath ce97232eb2 fix: allow cleartext server URLs only on local hosts, name the mail domain as TOTP issuer and guard external links 2026-08-29 18:03:23 +02:00
Linus Rath 516c9c931a fix: only drop exact role-folder name duplicates from the folder tree (#771) 2026-08-29 18:02:37 +02:00
Linus Rath c8be383c05 fix: page the viewer over the list a unified-inbox message was opened from 2026-08-29 18:01:53 +02:00
Linus Rath 9ae91a6ab6 docs: tick the filters, vacation and files parity items 2026-08-29 18:00:36 +02:00
Linus Rath c0e1d6b0a8 feat: files screen with search, sort, move/duplicate, multi-file upload progress, batch download, thumbnails and localization 2026-08-29 17:59:44 +02:00
Linus Rath a0ce9258cd feat: files API reads modified dates, decodes WebDAV names, copies/moves nodes and streams uploads (#700, #869, #563) 2026-08-29 17:59:44 +02:00
Linus Rath 1cef53af5a feat: add contacts to groups from the detail sheet, dedupe group recipients and drop the UID when duplicating 2026-08-29 17:59:11 +02:00
Linus Rath 9800ad07c2 feat: rebuild the contact form around PartialDate, org cards, group editing and cleared-field patches (#224, #307, #701) 2026-08-29 17:58:54 +02:00
Linus Rath 3fd5f10afe fix: gate event editing on calendar rights and keep read-only calendars out of the pickers (#762) 2026-08-29 17:57:54 +02:00
Linus Rath edc26ce1ca fix: reconnect live updates after network blips, token refresh, account switch and app resume 2026-08-29 17:57:33 +02:00
Linus Rath 032732cea1 fix: check the account-level Stalwart capability and keep the stored password in step after a change (#47) 2026-08-29 17:57:33 +02:00
Linus Rath b52768e6fb fix: keep the live session on a failed add or switch, recover from revoked sessions, refresh display names and sign in with a TOTP code (#900) 2026-08-29 17:57:32 +02:00
Linus Rath e70e57bf47 docs: tick push notification items in the parity checklist 2026-08-29 17:54:17 +02:00
Linus Rath f6f26f7950 feat: add push device list, re-register, onboarding prompt and an i18n key harvester 2026-08-29 17:53:59 +02:00
Linus Rath 00faceb25f fix: subscribe to EmailDelivery only, use the relay's email ids and route pushes per account (#45) 2026-08-29 17:53:53 +02:00
Linus Rath 7f956d60f7 fix: reply to Reply-To, drop own addresses on reply-all, thread with the Message-ID and keep attachments on forward (#234, #703) 2026-08-29 17:52:34 +02:00
Linus Rath 5dc40706fa fix: send invitations with a server-scheduled organizer and match RSVPs against identities and aliases (#731) 2026-08-29 17:52:26 +02:00
Linus Rath 26a34d5900 feat: list shared address books, gate first-touch contact loads and add recent-recipient autocomplete data (#907) 2026-08-29 17:50:44 +02:00
Linus Rath 580e010b33 fix: check ContactCard/set results, re-fetch created cards, assign UIDs and paginate contact queries (#644) 2026-08-29 17:50:44 +02:00
Linus Rath ccbe67c1bc fix: check JMAP method errors in every mail call, split requests to server limits and file sent mail through Drafts (#188, #234, #850) 2026-08-29 17:49:23 +02:00
Linus Rath 506a4b1a84 feat: port the reply-recipient, subject-prefix, threading and mailbox-parsing helpers from the webmail 2026-08-29 17:49:23 +02:00
Linus Rath b538a0e53d feat: vacation responder sends UTC dates, edits an HTML body and is fully localized 2026-08-29 17:48:18 +02:00
Linus Rath 58a6f45c15 fix: save all-day events with the right length, a time zone and explicit nulls for cleared fields (#805) 2026-08-29 17:45:43 +02:00
Linus Rath feb4affea6 docs: tick i18n locale items in the parity checklist 2026-08-29 17:44:22 +02:00
Linus Rath 3ffcef2ec1 feat: actionable vacation banner, expanded IF/THEN rule view and capability-gated filter tabs 2026-08-29 17:44:14 +02:00
Linus Rath 990cd84ee3 feat: add ICU interpolation, all 27 webmail locales and a key-parity test to i18n 2026-08-29 17:44:03 +02:00
Linus Rath 0b1c240802 fix: harden the JMAP client with request deadlines, retries, rate-limit state and method-error helpers (#702, #780, #907) 2026-08-29 17:42:32 +02:00
Linus Rath 2c0dbd15d1 fix: use a CSPRNG for OAuth state, TOTP secrets and UUIDs and validate handoff endpoints 2026-08-29 17:42:31 +02:00
Linus Rath 161baf1708 fix: edit or delete a single occurrence or the rest of a series instead of the whole series 2026-08-29 17:42:22 +02:00
Linus Rath 1c6aa680c5 feat: port the extended Sieve rules (attachment field, multi-value conditions) from the webmail 2026-08-29 17:39:43 +02:00
Linus Rath e650cb2d0a fix: import vCard dates as RFC 9553 PartialDate and map vendor X- extensions (#224, #701) 2026-08-29 17:37:44 +02:00
Linus Rath eb1dd6c844 feat: sort the mail list oldest-first or newest-first (#5) 2026-08-29 17:31:16 +02:00
Linus Rath fb4796e188 ci: run typecheck and unit tests on push and pull request 2026-08-29 17:31:00 +02:00
Linus Rath b84d4d8e9e fix: mock netinfo in the vitest setup so the auth-store suite loads 2026-08-29 17:30:59 +02:00
Linus Rath eaa8249e76 docs: add webmail parity checklist 2026-08-29 17:30:37 +02:00
Linus Rath a63ba7cb81 Merge pull request #35 from lucletoffe/fix/reply-threading-msgid
fix(reply): send the parent's RFC msg-id in In-Reply-To, not its JMAP id
2026-08-06 16:25:28 +02:00
Linus Rath beec3322b8 Merge pull request #39 from koolninad/fix/jmap-session-ios-auth-drop
fix(jmap): request /jmap/session directly to survive iOS auth-drop on redirect
2026-08-06 16:25:12 +02:00
Linus Rath 21bc02709a Merge pull request #40 from lucletoffe/fix/calendar-date-paging
fix(calendar): page a server-side date filter through CalendarEvent/query
2026-08-06 12:26:24 +02:00
lucletoffe 32b5298cdd fix(calendar): page a server-side date filter through CalendarEvent/query
queryEvents used to ignore its after/before arguments and ask Stalwart
for every event in the account with limit:1000. When an account holds
more than one page of events, the response was silently capped and the
grid rendered empty for the visible month — the missing events sat past
the truncation, never fetched. Meanwhile any recurring master whose
base start predates the window was returned exactly the same way, so
the client-side recurrence expander (already wired) never saw it.

Now the query sends after/before as a real filter ANDed with the
inCalendar restriction, and iterates position by page size until a
short page comes back (or 20 pages, as a bug backstop). Stalwart's
matcher accepts a master when any occurrence overlaps the window, so
the expander gets the raw event and produces the visible occurrences.

The refactor factors buildEventFilter() so the calendar / no-calendar
and window / no-window combinations produce the right filter shape
(single condition, AND, or absent). Tests updated to lock the new
filter shape and to cover the paging path (regression: 1000 events
that used to become 1000, now 1002).

Picked up from Jim Preston's uncommitted diff in the sister worktree
during the 2026-08-03 diagnostic session (artifact PkOWkT8C).
2026-08-06 09:20:40 +02:00
Ninad Chandorkar da1ff62725 fix(jmap): request /jmap/session directly to survive iOS auth-drop on redirect
Signing in from the iOS build against a Stalwart server fails with "No
account found in JMAP session".

Stalwart's discovery endpoint /.well-known/jmap answers with a 307 to
/jmap/session. iOS NSURLSession strips the Authorization header when it
auto-follows that redirect, so the app gets an unauthenticated session
back — HTTP 200, empty accounts — and resolveAccountId throws. Browsers
keep the auth header on same-origin redirects, which is why the webmail
never saw this; it only bites the native app on iOS.

Request /jmap/session directly so the header stays attached, falling
back to /.well-known/jmap on 404 for servers that don't expose the
Stalwart path.

Also treat a 200 with no accounts as an auth failure. Stalwart answers
that way for missing or invalid credentials instead of sending a 401, so
a user who fat-fingers their password currently gets "No account found
in JMAP session" rather than "Invalid credentials".

Tests updated for both behaviours; full suite passes (the pre-existing
auth-store.test.ts parse failure on main is unrelated).
2026-08-05 23:34:53 +05:30
Linus Rath 077b590851 Merge pull request #38 from koolninad/fix/camera-prompt-and-drawer-first-open
fix: camera pre-prompt wording (App Store 5.1.1(iv)) and drawer first open
2026-08-05 18:39:36 +02:00
Ninad Chandorkar 71af9d105d fix: camera pre-prompt wording (App Store 5.1.1(iv)) and drawer first open
Two fixes App Review and testers hit on the iOS build of this app.

**Camera pre-prompt.** App Review rejects a custom pre-permission screen
whose button says "Allow" under guideline 5.1.1(iv) — it reads as an
imitation of the system dialog. Verbatim from the rejection: "A custom
message appears before the permission request, and to proceed users press
an 'Allow' button. Use words like 'Continue' or 'Next' on the button
instead."

The QR scanner's permission screen now says "Continue", and the copy
above it explains what the camera is for instead of asking for access.
When the OS stops allowing a re-prompt (canAskAgain is false) the button
becomes "Open Settings" and calls Linking.openSettings(), which is the
recovery path the same guideline suggests.

**Drawer first open.** The sidebar drawers park at -screenWidth and start
the slide-in from the effect that watches `visible`. That effect runs
before the Modal's native view is attached, so the very first open loses
its animation and the drawer renders almost entirely off-screen; opening
it again works because the view now exists. The open animation also runs
from the Modal's onShow, which commits the final offset once the modal is
really on screen and is a no-op on later opens. Applied to the mail,
calendar and contacts drawers, which all share the pattern.
2026-08-05 21:52:13 +05:30
Linus Rath 82b97fada4 chore: update version number to 0.1.58 2026-08-02 20:55:51 +02:00
Linus Rath 428a1bf03d chore: update version number to 0.1.57 2026-08-02 20:54:30 +02:00
Linus Rath 09350dd1f0 refactor: polish sign-in flow UI and theme-aware QR scanner 2026-08-02 20:54:21 +02:00
Linus Rath d2d13a37c9 fix: only say "Found your mail server" when discovery found it 2026-08-02 20:17:57 +02:00
Linus Rath be82bfe814 feat: show shared mailboxes (group accounts) in the sidebar #37 2026-08-02 20:17:29 +02:00
Linus Rath 5c775ad82e Merge pull request #31 from arnauda-gh/fix/swipe-action-desync
fix(ui): synchronize swipe action gesture handlers with latest props
2026-08-02 16:29:07 +02:00
Linus Rath 941e5ede12 refactor(ui): move swipe gesture decisions into a pure module
Keeps the fix — gesture handlers now read current props — but makes the
class of bug structurally impossible rather than patched case by case.

The PanResponder is created once, so its handlers closed over the props of
the render that built it. Four call sites had to be corrected individually,
and nothing stopped a fifth from being added stale. Now the decisions live
in swipe-gesture.ts as pure functions taking the config as an argument, so
there is nothing left for a handler to capture; the component keeps one
`latest` ref pointing at the current props and hands it over on each event.

Also:
- Replace the placeholder test, which asserted that assigning to a locally
  declared `{current}` object works and passed against the unfixed
  component, with 20 tests over the real gesture logic: claim/decline,
  overshoot clamping, commit thresholds, flick velocity, reveal open/close
  from an offset base, and the 'none' cases.
- Build the responder in a useMemo instead of re-running PanResponder.create
  on every render of every visible row and discarding the result.
- Fold the four repeated Animated.spring calls into springTo/settle.

Beyond the reported symptom, the same staleness broke two more cases:
entering selection mode passes 'none' for both actions, but a swipe still
fired the old action; and onAction captured a handleSwipeAction from before
the mailbox ids resolved, so on a slow connection swipe silently did nothing
without anyone touching the setting — which matches the deeper problem
Arnaud suspected on the PR.
2026-08-02 16:28:48 +02:00
Linus Rath 2577f4e64b Merge pull request #33 from arnauda-gh/fix/attachment-direct-view
fix(android): open attachments directly with default viewer app
2026-08-02 15:37:03 +02:00
Linus Rath ee21c15fec refactor(android): tidy the attachment direct-view handoff
Follow-up to the direct-view fix. No behaviour change on the happy path,
but the handoff is now easier to reason about and covered by tests.

- Use `File.contentUri` from the expo-file-system 19 API instead of the
  legacy `getContentUriAsync`, dropping the `expo-file-system/legacy`
  import. Same FileProvider, one less round trip through the bridge.
- Pull the intent launch into `openWithViewer`, which returns false when
  the handoff didn't happen, so the fallback reads as a decision rather
  than as an empty catch. It also no longer swallows the reason silently.
- Rewrite the expo-sharing comment above `shareAttachment`: it warns
  against pre-translating to a content URI, which is still true for every
  `Sharing.shareAsync` call but is exactly what the new VIEW path has to
  do. Note why `downloadAttachment` keeps the share sheet as well.
- Record why FLAG_ACTIVITY_NEW_TASK is deliberately absent: the module
  launches via startActivityForResult, which a new task would cancel.
2026-08-02 15:36:31 +02:00
Linus Rath e574fd9e11 chore: update version number to 0.1.56 2026-08-01 14:25:31 +02:00
Linus Rath dfa278f7a8 fix: sign iOS archives manually with an App Store provisioning profile 2026-08-01 13:47:49 +02:00
Linus Rath 4eeda34d95 fix: sign iOS archives with the distribution identity 2026-08-01 13:37:34 +02:00
Linus Rath 22ad291202 Merge branch 'main' of https://github.com/bulwarkmail/native 2026-08-01 13:29:18 +02:00
Linus Rath f040a48e2f feat: iOS TestFlight release pipeline 2026-08-01 13:29:15 +02:00
Linus Rath c29cbcd549 feat: step-by-step sign-in with server discovery from the email address 2026-08-01 13:28:31 +02:00
Linus Rath bf67b97176 Merge pull request #36 from lucletoffe/fix/unread-dot-anchor
fix(list): anchor the unread dot on the first line, not the row midpoint
2026-07-31 21:15:30 +02:00
lucletoffeandClaude Opus 5 93761e67ef fix(reply): send the parent's RFC msg-id in In-Reply-To, not its JMAP id
Replying put `email.id` — the server-local JMAP handle, e.g. `gos2aabtux` —
into In-Reply-To/References. No recipient can match that against any
Message-ID, so the conversation splits in two: the original sits alone in
its thread while the reply (and everything answering it) starts a fresh one.

Proven against a live Stalwart account: the reply carried
`In-Reply-To: gos2aabtux` and landed in thread `btuz` while its parent stayed
in `btux` — Stalwart hashes the referenced msg-ids at ingest and found no
match.

Fetch `messageId`/`inReplyTo`/`references` with the full email and build the
headers from them (bracketed, ancestors first, parent closing the chain).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTRyrFCR2N9qkpKK37GgWb
2026-07-31 12:39:01 +02:00
lucletoffeandClaude Opus 5 a2484f3508 fix(list): anchor the unread dot on the first line, not the row midpoint
The dot added in #27 is positioned with `top: '50%'`, so it centres on the
whole row. Rows are three lines tall (sender / subject / preview), so the dot
lands a full line below the checkbox and the avatar it reads as a column
with — the taller the row, the worse it looks.

Anchor it on the first line instead: row padding + half an avatar, following
the density (and the text line height when the avatar is hidden in
extra-compact). The webmail's list carries the same `top-1/2` and the same
drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTRyrFCR2N9qkpKK37GgWb
2026-07-31 12:39:01 +02:00
Linus Rath 82997e27e4 docs: fix doubled slash in logo URLs 2026-07-25 18:23:17 +02:00
arnauda-gh b7995fafde fix(android): open attachments directly with default viewer app
Fixes #32.

On Android, Sharing.shareAsync triggers Intent.ACTION_SEND (Share sheet). Updated shareAttachment to convert the file URI to a content URI and launch android.intent.action.VIEW via expo-intent-launcher, with fallback to shareAsync if no viewer application is installed for the MIME type.
2026-07-24 12:03:28 +02:00
arnauda-gh cde461999f fix(ui): synchronize swipe action gesture handlers with latest props
Fixes #30.

PanResponder in SwipeableRow was created once on mount and captured initial props in a stale closure. Using mutable refs ensures gesture handlers always read and execute the latest leftAction, rightAction, mode, and onAction props.
2026-07-24 11:52:07 +02:00
Linus Rath 802e1e1c71 fix: send-time editor readback, base-view restore after search, Stalwart recurrence #9, #10, #13 2026-07-22 22:17:40 +02:00
Linus Rath 2427bdbd79 fix: keep VTODO-only task lists out of the Calendars drawer #28
Stalwart exposes task lists (supported-calendar-component-set = VTODO)
as sibling CalDAV collections, so Calendar/get returns them mixed in
with event calendars and the drawer showed confusing duplicate entries
(Todoist Inbox, per-project task lists colliding with same-named
event calendars).

JMAP Calendar/get doesn't surface the CalDAV component set, so the
store now infers the collection kind from its contents: a calendar
whose objects are all Tasks (>=1 task, no events) is flagged as a task
list. Flagged calendars are excluded from the Calendars drawer, the
event-creation calendar picker, and the ICS import target picker, and
sorted first in the Tasks sheet so new tasks default into a dedicated
task list. Empty calendars and mixed event+task calendars are
untouched.
2026-07-22 21:37:27 +02:00
Linus Rath 05e2af23dd fix: add unread indicator dot to email list and unified inbox rows #27
The only unread cues were a font-weight bump and a grey-to-white shift
on the sender name, which barely render on some device fonts and are
nearly invisible in the dark theme. Mirror the webmail's dedicated
indicator (email-list-item.tsx): an 8px circle in the theme's existing
unread color, vertically centered in the row's start gutter, shown in
both the mailbox list and the unified inbox.

Verified on a Pixel 7 API 34 emulator in dark theme: unread rows are
unambiguous at a glance, read rows unchanged.
2026-07-21 20:17:41 +02:00
Linus Rath 42f18a78b2 chore: update version number to 0.1.54 2026-07-21 19:55:27 +02:00
Linus Rath 406d2c208a Merge origin/main: keep escape-free inline editor over asset-split fix
PR #20 (c5e5092) fixed #11 on Android by moving the editor page to
android/app/src/main/assets/editor.html, but the real bug was never
'webview scripting broken in release builds': the template literal in
buildEditorHtml cooked the insertLink scheme-regex escapes into an
invalid regex, and that SyntaxError killed the whole inline script.
The PR's buildEditorScript (injectedJavaScript) still contains the
cooked regex, so its theme/init injection dies on Android and the
editor stays completely dead on iOS, whose page no longer has any
inline script.

Resolve by keeping the lib-based editor from the local fix (valid,
escape-free inline script for both platforms, regression-tested in
src/lib/__tests__/editor-html.test.ts, verified on a Pixel 7 API 34
release build) and dropping the now-unused editor.html asset. All
other upstream fixes are kept.
2026-07-21 19:46:55 +02:00
Linus Rath 6f712bf43f fix: compose editor inline script died on parse, keeping Send disabled #11
The template literal in buildEditorHtml cooked the backslash escapes in
the insertLink scheme regex (\/ and \? became bare / and ?), so the page
received /^(https?:|mailto:|tel:|sms:|/|?|#)/i — an invalid regex. The
resulting SyntaxError killed the entire inline editor script: no change
messages ever reached RN, bodyHtml stayed empty, canSend never enabled
without an attachment, and mails sent with one had empty bodies.
Regressed in 29b06a3.

Rewrite the scheme check without backslash escapes and move the page
builder into src/lib/editor-html.ts so a unit test can assert the emitted
script parses and stays backslash-free.

Verified on a Pixel 7 API 34 emulator (release build): typing now posts
change messages and the plain-text body extraction sees the content.
2026-07-21 19:34:51 +02:00
Linus Rath 844cb703c8 Merge pull request #20 from arnauda-gh/fix/android-webview-release-editor
fix(editor): resolve disabled send button and webview scripting on android release builds
2026-07-19 10:12:31 +02:00
Linus Rath 11e5963149 Merge pull request #26 from arnauda-gh/fix/contact-suggestions-view
fix(composer): improve contact suggestion layout and prevent focus/duplicate issues
2026-07-16 19:32:33 +02:00
arnauda-gh 07438e010e fix(composer): improve contact suggestion layout and prevent focus loss & duplicate recipients 2026-07-16 19:05:47 +02:00
Linus Rath 3e33efb71f Merge pull request #17 from arnauda-gh/fix/snackbar-zindex
fix: ensure UndoSnackbar renders on top of compose FAB
2026-07-16 17:57:55 +02:00
Linus Rath 376ee3d678 Merge pull request #18 from arnauda-gh/fix/sender-addresses-scroll
fix: replace identity alert picker with scrollable bottom sheet modal
2026-07-16 17:57:31 +02:00
Linus Rath 6f19cdbd17 Merge pull request #22 from arnauda-gh/fix/duplicate-recipients
fix(composer): prevent duplicate recipients from being added on done/blur action
2026-07-16 17:55:18 +02:00
Linus Rath 6985a7d5f6 Merge pull request #24 from arnauda-gh/fix/swipe-actions-notification
fix(mailboxes): prevent silent swipe failures by notifying user of missing folders
2026-07-16 17:55:05 +02:00
arnauda-gh 8231a55acb fix(mailboxes): show error notifications when special folders are missing on JMAP server 2026-07-16 10:27:30 +02:00
arnauda-gh 1b0740eab4 fix(composer): prevent duplicate recipients from being added on done/blur action 2026-07-16 10:15:04 +02:00
arnauda-gh c5e509274d fix(editor): resolve disabled send button and webview scripting on android release builds 2026-07-16 10:08:17 +02:00
arnauda-gh 1bf5920b04 fix: pre-select primary sender identity matching active account email 2026-07-15 19:43:34 +02:00
arnauda-gh 2547c6f199 fix: replace identity alert picker with scrollable bottom sheet modal 2026-07-15 19:31:54 +02:00
arnauda-gh 69e27a386c fix: ensure UndoSnackbar renders on top of compose FAB 2026-07-15 18:49:52 +02:00
Linus Rath 7390096567 Merge pull request #15 from arnauda-gh/fix/coalesce-token-refresh
fix(auth): coalesce concurrent token refresh requests
2026-07-13 21:18:13 +02:00
arnauda-gh 58c642f27b fix(auth): coalesce concurrent token refresh requests
Coalesces concurrent OAuth token refreshes using a Promise-based lock map keyed by the refresh token. This prevents the app from firing duplicate refresh requests with the same expired refresh token, which causes replay revocations on OIDC providers (e.g. Authelia/Hydra) enforcing strict rotation.

Also optimizes persistRefreshedTokens and ensureFreshCredentials to prevent redundant SecureStore/disk writes when concurrent operations complete.

Fixes #14
2026-07-13 09:05:45 +02:00
Linus Rath b1d1e84666 chore: update version number to 0.1.53 2026-06-17 09:40:10 +02:00
Linus Rath 55484453a1 fix: coalesce concurrent push setups to prevent subscription swarm 2026-06-17 09:39:20 +02:00
Linus Rath b525c52870 chore: update version number to 0.1.52 2026-06-17 09:07:55 +02:00
Linus Rath b64afb462f fix: reap dead push subscriptions from other devices via relay liveness check 2026-06-17 09:07:42 +02:00
Linus Rath b92f85a236 chore: update version number to 0.1.51 2026-06-14 14:46:22 +02:00
Linus Rath 643d704b74 fix: namespace shared calendar ids to avoid collisions with own calendars 2026-06-14 14:46:15 +02:00
Linus Rath 9e383c3616 chore: update version number to 0.1.50 2026-06-14 13:43:36 +02:00
Linus Rath 1543db49d1 fix: match webmail event display logic 2026-06-14 13:43:27 +02:00
Linus Rath 910e5418e9 chore: update version number to 0.1.49 2026-06-12 17:16:03 +02:00
Linus Rath a019d9e775 chore: update version number to 0.1.48 2026-06-12 17:15:53 +02:00
Linus Rath e6d47b6a16 feat: shared calendars, custom recurrence editor, and file sharing 2026-06-12 17:15:50 +02:00
1708 changed files with 498763 additions and 51049 deletions
+3
View File
@@ -0,0 +1,3 @@
# patch-package patches must keep the patched files' line endings byte for byte
# (react-native-android-widget ships CRLF sources).
patches/*.patch -text
+92
View File
@@ -0,0 +1,92 @@
name: iOS simulator test leg
description: Boot a simulator, install the app and run one suite of Maestro flows
inputs:
suite:
description: Folder under .maestro (signin or features)
required: true
name:
description: Leg name, used for the artifact
required: true
app_run_id:
description: Take the simulator app from this run instead of the current one
required: false
default: ""
flows:
description: Comma list of flow names to run instead of the whole suite
required: false
default: ""
playwright:
description: Install Playwright for the helper's webmail pairing
required: false
default: "false"
runs:
using: composite
steps:
- name: Download the simulator app (this run)
if: inputs.app_run_id == ''
uses: actions/download-artifact@v4
with:
name: simulator-app
path: ${{ runner.temp }}
- name: Download the simulator app (earlier run)
if: inputs.app_run_id != ''
uses: actions/download-artifact@v4
with:
name: simulator-app
run-id: ${{ inputs.app_run_id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}
- name: Unpack the simulator app
shell: bash
run: |
mkdir -p "$RUNNER_TEMP/app"
tar -C "$RUNNER_TEMP/app" -xzf "$RUNNER_TEMP/simulator-app.tar.gz"
echo "APP=$RUNNER_TEMP/app/BulwarkMobile.app" >> "$GITHUB_ENV"
- name: Boot a simulator
shell: bash
run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP"
- name: Install Maestro
shell: bash
run: |
curl -fsSL "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
- name: Install Playwright for the helper
if: inputs.playwright == 'true'
shell: bash
run: |
mkdir -p "$RUNNER_TEMP/pw"
cd "$RUNNER_TEMP/pw"
npm init -y >/dev/null
npm install --no-audit --no-fund playwright@1.59.1 >/dev/null
npx playwright install chromium >/dev/null
echo "PLAYWRIGHT_DIR=$RUNNER_TEMP/pw/node_modules" >> "$GITHUB_ENV"
- name: Start the test helper
shell: bash
run: |
mkdir -p "$OUT/helper"
HELPER_SHOTS="$OUT/helper" nohup node scripts/e2e/helper.mjs > "$OUT/helper/helper.log" 2>&1 &
for i in $(seq 1 20); do
curl -fs http://127.0.0.1:8787/health >/dev/null && break
sleep 0.5
done
curl -fs http://127.0.0.1:8787/health
- name: Run the flows
shell: bash
run: bash scripts/e2e/run-flows.sh "${{ inputs.suite }}" "${{ inputs.flows }}"
- name: Upload screenshots, recording and logs
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-ios-${{ inputs.name }}
path: e2e-output/
if-no-files-found: ignore
+26
View File
@@ -0,0 +1,26 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Unit tests
run: npm test
+349
View File
@@ -0,0 +1,349 @@
name: iOS simulator tests
# Builds the app for the iOS Simulator (no signing needed) and runs the
# Maestro flows in .maestro/ on iPhone and iPad simulators:
#
# - signin: every sign-in method (.maestro/signin), on an iPhone and an iPad
# - features: the app feature by feature (.maestro/features), on iPhone and
# iPad in light and dark, plus the iPad in landscape (split layout)
#
# Each leg signs in with its own account on the test server (e2e-* accounts
# on VM 106, password E2E_PASSWORD), so parallel legs never share a mailbox.
# scripts/e2e/helper.mjs runs next to the simulator for what a flow can't do
# itself: TOTP codes, fresh webmail pairing links, mail sent by userb.
#
# Artifacts per leg: screenshots, a screen recording, the app log and a JUnit
# report. They are public; see scripts/e2e/run-flows.sh for how secrets are
# kept out of them.
on:
workflow_dispatch:
inputs:
app_run_id:
description: "Reuse the simulator app from this earlier run instead of building (run id)"
type: string
required: false
legs:
description: "Legs to run: all, signin, features, perf, or a comma list (signin-phone, features-tablet-dark, ...)"
type: string
default: all
flows:
description: "Only these flows (comma list of file names without .yaml); empty runs all"
type: string
required: false
perf_base_run_id:
description: "perf leg: compare against the simulator app of this run (run id)"
type: string
required: false
perf_rounds:
description: "perf leg: rounds (each: 3 cold starts and the interaction flow per app)"
type: string
default: "5"
required: false
release:
types: [published]
concurrency:
# Per branch and set of legs: a perf run doesn't cancel a feature run.
group: e2e-ios-${{ github.ref }}-${{ inputs.legs || 'all' }}
cancel-in-progress: true
env:
EXPO_NO_TELEMETRY: "1"
MAESTRO_CLI_NO_ANALYTICS: "1"
# The XCTest driver takes minutes to come up on a cold runner.
MAESTRO_DRIVER_STARTUP_TIMEOUT: "600000"
jobs:
plan:
runs-on: ubuntu-latest
outputs:
signin: ${{ steps.plan.outputs.signin }}
features: ${{ steps.plan.outputs.features }}
steps:
- id: plan
env:
LEGS: ${{ inputs.legs || 'all' }}
run: |
node -e '
const legs = (process.env.LEGS || "all").split(",").map((s) => s.trim()).filter(Boolean);
const want = (name, group) => legs.includes("all") || legs.includes(group) || legs.includes(name);
const signin = ["phone", "tablet"]
.map((device) => ({ name: `signin-${device}`, device }))
.filter((l) => want(l.name, "signin"));
const features = [
{ name: "features-phone-light", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-phone-light" },
{ name: "features-phone-dark", device: "phone", appearance: "dark", orientation: "portrait", account: "e2e-phone-dark" },
{ name: "features-tablet-light", device: "tablet", appearance: "light", orientation: "portrait", account: "e2e-tablet-light" },
{ name: "features-tablet-dark", device: "tablet", appearance: "dark", orientation: "portrait", account: "e2e-tablet-dark" },
{ name: "features-tablet-landscape", device: "tablet", appearance: "light", orientation: "landscape", account: "e2e-tablet-land" },
].filter((l) => want(l.name, "features"));
// Push on iOS (.maestro/push): only on request, the app needs the
// notification extension (plugins/ios-push).
if (legs.includes("push")) {
features.push({ name: "push-phone", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-perf", suite: "push" });
}
const fs = require("fs");
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`signin=${JSON.stringify({ include: signin })}\nfeatures=${JSON.stringify({ include: features })}\n`);
console.log({ signin: signin.map((l) => l.name), features: features.map((l) => l.name) });
'
build:
if: inputs.app_run_id == ''
runs-on: macos-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: |
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: pods-${{ runner.os }}-
- name: Install JS dependencies
run: npm ci
- name: Generate the iOS project
env:
# Simulator device tokens belong to the APNs sandbox.
APS_ENVIRONMENT: development
run: npx expo prebuild --platform ios --clean
- name: Build for the simulator
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# The bundle times each module's evaluation for the perf leg, and
# Metro writes which id is which module (metro.config.js; a branch
# without that support ignores both).
BULWARK_MODULE_TIMINGS: '1'
BULWARK_MODULE_MAP: ${{ runner.temp }}/module-map.json
run: |
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
mkdir -p e2e-build
# Release bundles the JS into the app, so no Metro server is needed.
# arm64 only: the runner is Apple silicon, and a second simulator
# architecture would double the build time.
if ! xcodebuild build \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration Release \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
ARCHS=arm64 \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY=- \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
PROVISIONING_PROFILE_SPECIFIER= \
> e2e-build/xcodebuild.log 2>&1; then
grep -E "error:|fatal error" e2e-build/xcodebuild.log | head -n 50 || true
tail -n 80 e2e-build/xcodebuild.log
exit 1
fi
# Signed ad hoc ("Sign to Run Locally"): no certificate needed, and
# Xcode embeds the simulator entitlements the keychain needs. An
# unsigned build has none, and every expo-secure-store call fails
# with "A required entitlement isn't present".
APP_PATH=build/Build/Products/Release-iphonesimulator/BulwarkMobile.app
echo "Simulated entitlements:"
xcrun otool -X -s __TEXT __entitlements "$APP_PATH/BulwarkMobile" | head -20 || true
codesign -d --entitlements - "$APP_PATH" 2>&1 | head -20 || true
# Packed as a tar: artifacts drop the executable bit, which would
# leave a reused app unable to launch.
tar -C build/Build/Products/Release-iphonesimulator -cf "$RUNNER_TEMP/simulator-app.tar" BulwarkMobile.app
if [ -f "$RUNNER_TEMP/module-map.json" ]; then
tar -C "$RUNNER_TEMP" -rf "$RUNNER_TEMP/simulator-app.tar" module-map.json
else
echo "no module map (branch without module timings)"
fi
gzip -f "$RUNNER_TEMP/simulator-app.tar"
ls -la "$RUNNER_TEMP/simulator-app.tar.gz"
- name: Upload the simulator app
uses: actions/upload-artifact@v4
with:
name: simulator-app
path: ${{ runner.temp }}/simulator-app.tar.gz
if-no-files-found: error
- name: Upload the build log
if: failure()
uses: actions/upload-artifact@v4
with:
name: xcodebuild-log
path: e2e-build/
signin:
needs: [plan, build]
if: >-
always() && !cancelled() &&
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
fromJson(needs.plan.outputs.signin).include[0] != null
runs-on: macos-latest
timeout-minutes: 150
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.plan.outputs.signin) }}
env:
OUT: ${{ github.workspace }}/e2e-output
DEVICE: ${{ matrix.device }}
APPEARANCE: light
ORIENTATION: portrait
E2E_SERVER: ${{ vars.E2E_SERVER }}
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
E2E_ACCOUNT: e2e-login-${{ matrix.device }}
E2E_ACCOUNT2: e2e-second-${{ matrix.device }}
E2E_TOTP_ACCOUNT: e2e-totp-${{ matrix.device }}
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/e2e-ios-leg
with:
suite: signin
app_run_id: ${{ inputs.app_run_id }}
flows: ${{ inputs.flows }}
playwright: "true"
name: ${{ matrix.name }}
features:
needs: [plan, build]
if: >-
always() && !cancelled() &&
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
fromJson(needs.plan.outputs.features).include[0] != null
runs-on: macos-latest
timeout-minutes: 120
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.plan.outputs.features) }}
env:
OUT: ${{ github.workspace }}/e2e-output
DEVICE: ${{ matrix.device }}
APPEARANCE: ${{ matrix.appearance }}
ORIENTATION: ${{ matrix.orientation }}
E2E_SERVER: ${{ vars.E2E_SERVER }}
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
E2E_ACCOUNT: ${{ matrix.account }}
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/e2e-ios-leg
with:
suite: ${{ matrix.suite || 'features' }}
app_run_id: ${{ inputs.app_run_id }}
flows: ${{ inputs.flows }}
playwright: "false"
name: ${{ matrix.name }}
# Part 2 of docs/ios-test-2026-10.md: timing marks from the app
# (src/lib/perf-marks.ts), read by scripts/e2e/perf.mjs. Compares the app
# of this run (or app_run_id) with the app of perf_base_run_id, alternating
# them on one simulator so both sides share the runner.
perf:
needs: [plan, build]
if: >-
always() && !cancelled() &&
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
contains(inputs.legs, 'perf')
runs-on: macos-latest
timeout-minutes: 120
permissions:
contents: read
actions: read
env:
OUT: ${{ github.workspace }}/e2e-output
DEVICE: phone
APPEARANCE: light
ORIENTATION: portrait
E2E_SERVER: ${{ vars.E2E_SERVER }}
E2E_ACCOUNT: e2e-bulk
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
steps:
- uses: actions/checkout@v4
- name: Download the app under test (this run)
if: inputs.app_run_id == ''
uses: actions/download-artifact@v4
with:
name: simulator-app
path: ${{ runner.temp }}/cand
- name: Download the app under test (earlier run)
if: inputs.app_run_id != ''
uses: actions/download-artifact@v4
with:
name: simulator-app
run-id: ${{ inputs.app_run_id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/cand
- name: Download the baseline app
if: inputs.perf_base_run_id != ''
uses: actions/download-artifact@v4
with:
name: simulator-app
run-id: ${{ inputs.perf_base_run_id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/base
- name: Unpack
run: |
for side in cand base; do
[ -f "$RUNNER_TEMP/$side/simulator-app.tar.gz" ] || continue
mkdir -p "$RUNNER_TEMP/$side/app"
tar -C "$RUNNER_TEMP/$side/app" -xzf "$RUNNER_TEMP/$side/simulator-app.tar.gz"
done
echo "APP=$RUNNER_TEMP/cand/app/BulwarkMobile.app" >> "$GITHUB_ENV"
- name: Boot a simulator
run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP"
- name: Install Maestro
run: |
curl -fsSL "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
- name: Sign in once
run: |
mkdir -p "$OUT/screenshots"
cd "$OUT/screenshots"
# Warm-up for the driver, then the sign-in (keychain survives reinstalls).
maestro --device "$UDID" test "$GITHUB_WORKSPACE/.maestro/launch.yaml"
maestro --device "$UDID" test -e "SERVER=$E2E_SERVER" -e "PASSWORD=$E2E_PASSWORD" \
-e "ACCOUNT=$E2E_ACCOUNT" -e "ORIENTATION=portrait" "$GITHUB_WORKSPACE/.maestro/perf/sign-in.yaml"
- name: Measure
run: |
APPS=(--app "cand=$RUNNER_TEMP/cand/app/BulwarkMobile.app")
if [ -d "$RUNNER_TEMP/base/app/BulwarkMobile.app" ]; then
APPS=(--app "base=$RUNNER_TEMP/base/app/BulwarkMobile.app" "${APPS[@]}")
fi
node scripts/e2e/perf.mjs "${APPS[@]}" --rounds "${{ inputs.perf_rounds || 5 }}" --cold 5 \
--flows "$(ls "$GITHUB_WORKSPACE"/.maestro/perf/[0-9]-*.yaml | paste -sd, -)" --out "$OUT"
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-ios-perf
path: e2e-output/
if-no-files-found: ignore
+353 -18
View File
@@ -1,4 +1,4 @@
name: Release Android APK
name: Release Android
on:
release:
@@ -10,13 +10,42 @@ on:
required: false
default: ""
# Signing is described in docs/android-release.md.
env:
# Certificate SHA-256 of android/app/debug.keystore, the stock React Native
# template key (CN=Android Debug, SHA-1 5E:8F:16:06:...:F6:25). It is public,
# so Android 9+ must never see it as the signer of a published build.
DEBUG_CERT_SHA256: fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: write
id-token: write
attestations: write
steps:
# Fail in seconds instead of after a 20-minute build. There is no
# unsigned or debug-signed fallback: anything signed with the debug key
# can be "updated" by anyone.
- name: Require the release signing secrets
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
missing=""
[ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64"
[ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD"
[ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS"
[ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD"
if [ -n "$missing" ]; then
echo "::error title=Release signing is not configured::Missing repository secrets:$missing. Release APKs are never signed with the public debug key; see docs/android-release.md."
exit 1
fi
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -30,7 +59,12 @@ jobs:
java-version: 17
cache: gradle
# The action installs the legacy `tools` package by default, which the
# runner's cmdline-tools 16 no longer carries ("Failed to find package
# 'tools'"). Gradle fetches the platforms and build-tools it needs.
- uses: android-actions/setup-android@v3
with:
packages: platform-tools
- name: Cache Android NDK / CMake
uses: actions/cache@v4
@@ -66,39 +100,186 @@ jobs:
- name: Install JS dependencies
run: npm ci
- name: Decode release keystore
id: keystore
# Checks the password and alias before the long build, and refuses a
# debug key that was uploaded as the release key by mistake.
- name: Decode and check the release key
id: key
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
run: |
if [ -n "$KEYSTORE_BASE64" ]; then
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
echo "path=$RUNNER_TEMP/release.keystore" >> "$GITHUB_OUTPUT"
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
set -euo pipefail
KEYSTORE="$RUNNER_TEMP/release.keystore"
CERT="$RUNNER_TEMP/release-cert.der"
echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE"
if ! keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \
-storepass:env KEYSTORE_PASSWORD -file "$CERT"; then
echo "::error title=Release key unreadable::Could not read alias ANDROID_KEY_ALIAS from ANDROID_KEYSTORE_BASE64 with ANDROID_KEYSTORE_PASSWORD."
exit 1
fi
CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1)
OWNER=$(keytool -printcert -file "$CERT")
if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ] || [[ "$OWNER" == *"CN=Android Debug"* ]]; then
echo "::error title=Release key is a debug key::ANDROID_KEYSTORE_BASE64 holds an Android debug key. Generate a release key as described in docs/android-release.md."
exit 1
fi
echo "Release certificate SHA-256: $CERT_SHA256"
echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT"
echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT"
- name: Build release APK
working-directory: android
env:
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.keystore.outputs.path }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
chmod +x ./gradlew
./gradlew assembleRelease --no-daemon
./gradlew assembleGithubRelease --no-daemon
- name: Rename APK with version
- name: Put apksigner on the PATH
run: |
BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -n 1)
echo "Using $BUILD_TOOLS"
echo "$BUILD_TOOLS" >> "$GITHUB_PATH"
# Optional v3 key rotation away from the debug key that signed every
# release up to 0.1.62. Gradle signed the APK with the release key only,
# which existing installs reject as a different app. With a lineage the
# APK is re-signed so that Android 9+ sees the release key plus proof
# that the debug key handed over to it, while Android 7-8.1 (v2 only)
# still see the debug key. docs/android-release.md has the details.
- name: Apply the signing key rotation
id: rotation
env:
LINEAGE_BASE64: ${{ secrets.ANDROID_SIGNING_LINEAGE_BASE64 }}
KEYSTORE: ${{ steps.key.outputs.store_file }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
run: |
set -euo pipefail
if [ -z "$LINEAGE_BASE64" ]; then
echo "No ANDROID_SIGNING_LINEAGE_BASE64: keeping the release-key-only signature."
echo "rotated=false" >> "$GITHUB_OUTPUT"
exit 0
fi
LINEAGE="$RUNNER_TEMP/signing.lineage"
echo "$LINEAGE_BASE64" | base64 -d > "$LINEAGE"
apksigner lineage --in "$LINEAGE" --print-certs | tee "$RUNNER_TEMP/lineage.txt"
DIGESTS=$(sed -n 's/^Signer #[0-9]* in lineage certificate SHA-256 digest: //p' "$RUNNER_TEMP/lineage.txt")
if [ "$(sed -n '1p' <<< "$DIGESTS")" != "$DEBUG_CERT_SHA256" ] \
|| [ "$(sed -n '$p' <<< "$DIGESTS")" != "$RELEASE_CERT_SHA256" ]; then
echo "::error title=Wrong signing lineage::ANDROID_SIGNING_LINEAGE_BASE64 must lead from the debug key to the key in ANDROID_KEYSTORE_BASE64."
exit 1
fi
# Rollback on an old signer lets anything signed with it take the app
# back after the rotation, which would undo the whole point.
PAST_ROLLBACK=$(grep 'Has rollback capability' "$RUNNER_TEMP/lineage.txt" | sed -e 's/.*: *//' -e '$d')
if [[ "$PAST_ROLLBACK" == *true* ]]; then
echo "::error title=Unsafe signing lineage::An old signer in the lineage has the rollback capability. Recreate it with --set-rollback false."
exit 1
fi
# Oldest signer first: apksigner uses it for v2, and the rotated key
# for v3 from API 28. Without --rotation-min-sdk-version it would
# only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key.
APK=android/app/build/outputs/apk/github/release/app-github-release.apk
apksigner sign \
--v4-signing-enabled false \
--ks android/app/debug.keystore --ks-key-alias androiddebugkey \
--ks-pass pass:android --key-pass pass:android \
--next-signer \
--ks "$KEYSTORE" --ks-key-alias "$KEY_ALIAS" \
--ks-pass env:KEYSTORE_PASSWORD --key-pass env:KEY_PASSWORD \
--lineage "$LINEAGE" \
--rotation-min-sdk-version 28 \
--out "$RUNNER_TEMP/app-release-rotated.apk" \
"$APK"
mv "$RUNNER_TEMP/app-release-rotated.apk" "$APK"
echo "rotated=true" >> "$GITHUB_OUTPUT"
# The last gate before anything is uploaded.
- name: Verify the APK signature
env:
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
ROTATED: ${{ steps.rotation.outputs.rotated }}
run: |
set -euo pipefail
APK=android/app/build/outputs/apk/github/release/app-github-release.apk
apksigner verify --verbose --print-certs "$APK"
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
signers() {
apksigner verify --print-certs "$@" "$APK" \
| sed -n -E 's/^(Signer #[0-9]+|V[0-9.]+ Signer:) certificate SHA-256 digest: //p' \
| sort -u
}
MODERN=$(signers --min-sdk-version 28)
LEGACY=$(signers --max-sdk-version 27)
echo "Signer on Android 9+: $MODERN"
echo "Signer on Android 7-8.1: $LEGACY"
if [[ "$MODERN" == *"$DEBUG_CERT_SHA256"* ]]; then
echo "::error title=APK is debug-signed::Android 9+ would see the public debug key as the signer."
exit 1
fi
if [ "$MODERN" != "$RELEASE_CERT_SHA256" ]; then
echo "::error title=Wrong APK signer::Android 9+ would see '$MODERN', expected only the release key $RELEASE_CERT_SHA256."
exit 1
fi
if [ "$ROTATED" = "true" ]; then
echo "::warning title=Android 7-8.1 still trust the debug key::The v2 signature is made with the debug key so that installs on Android 7-8.1 can update. Those devices remain exposed; see docs/android-release.md."
elif [ "$LEGACY" != "$RELEASE_CERT_SHA256" ]; then
echo "::error title=Wrong APK signer::Android 7-8.1 would see '$LEGACY', expected only the release key $RELEASE_CERT_SHA256."
exit 1
fi
aapt2 dump badging "$APK" | grep '^package:' || true
# The R8 mapping goes out with the APK: without the one from this exact
# build, the obfuscated frames in a release stack trace cannot be
# decoded (docs/android-release.md, "Stack traces").
- name: Rename APK and R8 mapping with version
id: artifact
run: |
set -euo pipefail
VERSION=$(cat VERSION | tr -d '[:space:]')
SRC=android/app/build/outputs/apk/release/app-release.apk
SRC=android/app/build/outputs/apk/github/release/app-github-release.apk
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
cp "$SRC" "$DEST"
sha256sum "$DEST" > "$DEST.sha256"
MAPPING_SRC=android/app/build/outputs/mapping/githubRelease/mapping.txt
MAPPING=""
if [ -f "$MAPPING_SRC" ]; then
MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
cp "$MAPPING_SRC" "$MAPPING"
elif grep -Eq '^android\.enableMinifyInReleaseBuilds=true\s*$' android/gradle.properties; then
echo "::error title=R8 mapping missing::R8 is on but the build wrote no $MAPPING_SRC, so stack traces from this APK could not be decoded."
exit 1
fi
echo "path=$DEST" >> "$GITHUB_OUTPUT"
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
{
echo "files<<FILES_EOF"
echo "$DEST"
echo "$DEST.sha256"
if [ -n "$MAPPING" ]; then echo "$MAPPING"; fi
echo "FILES_EOF"
} >> "$GITHUB_OUTPUT"
# Ties the APK to this commit and workflow run, which the signing key
# alone does not: `gh attestation verify <apk> --repo bulwarkmail/native`.
- name: Attest build provenance
uses: actions/attest@v4
with:
subject-path: ${{ steps.artifact.outputs.path }}
- uses: actions/upload-artifact@v4
with:
@@ -106,17 +287,24 @@ jobs:
path: ${{ steps.artifact.outputs.path }}
if-no-files-found: error
- uses: actions/upload-artifact@v4
if: steps.artifact.outputs.mapping != ''
with:
name: ${{ steps.artifact.outputs.mapping }}
path: ${{ steps.artifact.outputs.mapping }}
if-no-files-found: error
- name: Resolve target release tag
id: target_tag
env:
GH_TOKEN: ${{ github.token }}
run: |
# A manual run with no tag only builds: it must not replace the APK
# of the latest release with one from another commit.
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then
TAG="${{ github.event.inputs.attach_to_release_tag }}"
else
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)
TAG="${{ github.event.inputs.attach_to_release_tag }}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if [ -n "$TAG" ]; then
@@ -125,9 +313,156 @@ jobs:
echo "No release tag resolved - APK is in workflow artifacts only."
fi
- name: Attach APK to release
- name: Attach APK and R8 mapping to release
if: steps.target_tag.outputs.tag != ''
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.target_tag.outputs.tag }}
files: ${{ steps.artifact.outputs.path }}
files: ${{ steps.artifact.outputs.files }}
# The Google Play bundle (docs/android-release.md, "Google Play"). It is
# signed with the release key, which Play knows as the upload key; Play
# re-signs what it delivers with its own app signing key. A separate job so
# the GitHub APK isn't held up by the three-ABI native build.
play-bundle:
runs-on: ubuntu-latest
timeout-minutes: 75
permissions:
contents: read
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
steps:
- name: Require the release signing secrets
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
missing=""
[ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64"
[ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD"
[ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS"
[ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD"
if [ -n "$missing" ]; then
echo "::error title=Release signing is not configured::Missing repository secrets:$missing. See docs/android-release.md."
exit 1
fi
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
cache: gradle
- uses: android-actions/setup-android@v3
with:
packages: platform-tools
- name: Install JS dependencies
run: npm ci
- name: Decode and check the upload key
id: key
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
run: |
set -euo pipefail
KEYSTORE="$RUNNER_TEMP/release.keystore"
CERT="$RUNNER_TEMP/release-cert.der"
echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE"
keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \
-storepass:env KEYSTORE_PASSWORD -file "$CERT"
CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1)
if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ]; then
echo "::error title=Upload key is a debug key::ANDROID_KEYSTORE_BASE64 holds the Android debug key."
exit 1
fi
echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT"
echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT"
# Play splits a bundle per ABI, so the extra architectures cost a phone
# nothing and let 32-bit ARM phones and x86_64 Chromebooks install it.
- name: Build the Play bundle
working-directory: android
env:
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
chmod +x ./gradlew
./gradlew bundlePlayRelease --no-daemon -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86_64
# Play rejects a bundle signed with another key than the registered
# upload key, and its policies reject a self-updater and the media
# permission. Catch all three here instead of in the Play Console.
- name: Check the bundle
env:
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
run: |
set -euo pipefail
AAB=android/app/build/outputs/bundle/playRelease/app-play-release.aab
SIGNER=$(keytool -printcert -jarfile "$AAB" | sed -n 's/^[[:space:]]*SHA256: //p' | head -n 1 | tr -d ':' | tr 'A-F' 'a-f')
echo "Bundle signer SHA-256: $SIGNER"
if [ "$SIGNER" != "$RELEASE_CERT_SHA256" ]; then
echo "::error title=Wrong bundle signer::Expected the upload key $RELEASE_CERT_SHA256, got '$SIGNER'."
exit 1
fi
MANIFEST=$(unzip -p "$AAB" base/manifest/AndroidManifest.xml | tr -c '[:print:]' '\n')
if ! grep -q 'org.bulwarkmail.mobile' <<< "$MANIFEST"; then
echo "::error title=Wrong package::The bundle is not org.bulwarkmail.mobile."
exit 1
fi
for permission in REQUEST_INSTALL_PACKAGES READ_MEDIA_IMAGES READ_MEDIA_VIDEO RECORD_AUDIO; do
if grep -q "android.permission.$permission" <<< "$MANIFEST"; then
echo "::error title=Permission not allowed on Play::The Play bundle declares $permission."
exit 1
fi
done
- name: Name the bundle and R8 mapping with version
id: artifact
run: |
set -euo pipefail
VERSION=$(cat VERSION | tr -d '[:space:]')
AAB="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}.aab"
MAPPING="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
cp android/app/build/outputs/bundle/playRelease/app-play-release.aab "$AAB"
cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING"
echo "aab=$AAB" >> "$GITHUB_OUTPUT"
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: ${{ steps.artifact.outputs.aab }}
path: |
${{ steps.artifact.outputs.aab }}
${{ steps.artifact.outputs.mapping }}
if-no-files-found: error
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set (Play only accepts API
# uploads after the first bundle was uploaded by hand in the Console).
# Every release goes out to the internal and closed (beta) testers right
# away; closed testing may wait for Google's review first.
- name: Upload to Google Play (internal and closed testing)
if: env.PLAY_SERVICE_ACCOUNT_JSON != ''
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: org.bulwarkmail.mobile
releaseFiles: ${{ steps.artifact.outputs.aab }}
mappingFile: ${{ steps.artifact.outputs.mapping }}
releaseName: ${{ steps.artifact.outputs.version }}
tracks: internal,beta
status: completed
+331
View File
@@ -0,0 +1,331 @@
name: Release iOS (TestFlight)
# The iOS project is NOT committed - unlike android/, which is hand-maintained,
# ios/ is gitignored and generated fresh by `expo prebuild` on every run. Keep
# any native iOS change in app.config.js (or a config plugin), never in ios/.
on:
release:
types: [published]
workflow_dispatch:
inputs:
upload_to_testflight:
description: "Upload the build to TestFlight (off = build the IPA only)"
type: boolean
required: false
default: true
concurrency:
group: release-ios-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
runs-on: macos-latest
timeout-minutes: 90
permissions:
contents: write
outputs:
version: ${{ steps.export.outputs.version }}
uploaded: ${{ steps.upload.outputs.done }}
env:
EXPO_NO_TELEMETRY: "1"
# App Store Connect refuses a CFBundleVersion it has already seen for this
# marketing version, so every run needs a fresh, increasing number.
IOS_BUILD_NUMBER: ${{ github.run_number }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Show toolchain versions
run: |
xcodebuild -version
pod --version
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: |
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: pods-${{ runner.os }}-
- name: Install JS dependencies
run: npm ci
- name: Check for signing credentials
id: creds
env:
CERT: ${{ secrets.IOS_DIST_CERT_BASE64 }}
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
TEAM: ${{ secrets.APPLE_TEAM_ID }}
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
run: |
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
if [ -z "$NSE_PROFILE" ]; then
# The notification service extension (plugins/ios-push) is its
# own App ID and needs its own App Store profile.
echo "::error::IOS_NSE_PROVISIONING_PROFILE_BASE64 is not set: the notification extension (org.bulwarkmail.mobile.NotificationService) can't be signed."
exit 1
fi
echo "signed=true" >> "$GITHUB_OUTPUT"
else
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
fi
- name: Install the provisioning profiles
if: steps.creds.outputs.signed == 'true'
id: profiles
env:
APP_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
run: |
# Xcode moved the profile directory in 16; write both so this does not
# silently stop working on a runner image bump.
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
# One App Store profile per target: the app and its notification
# service extension (plugins/ios-push).
for side in app nse; do
if [ "$side" = app ]; then DATA="$APP_PROFILE_BASE64"; else DATA="$NSE_PROFILE_BASE64"; fi
PROFILE="$RUNNER_TEMP/$side.mobileprovision"
echo "$DATA" | base64 -d > "$PROFILE"
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
# before anything inside it can be read.
PLIST=$(security cms -D -i "$PROFILE")
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
# application-identifier is "TEAMID.bundle.id" - deriving the bundle
# ID from the profile keeps ExportOptions honest if app.config.js
# changes.
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
BUNDLE_ID="${APP_ID#*.}"
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
echo "${side}_uuid=$UUID" >> "$GITHUB_OUTPUT"
echo "${side}_name=$NAME" >> "$GITHUB_OUTPUT"
echo "${side}_bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
echo "$side: profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
done
- name: Generate the iOS project
env:
# plugins/ios-push names each target's profile: the command line
# can't, it would give every target the same one.
IOS_APP_PROFILE: ${{ steps.profiles.outputs.app_uuid }}
IOS_NSE_PROFILE: ${{ steps.profiles.outputs.nse_uuid }}
run: npx expo prebuild --platform ios --clean
- name: Resolve workspace and scheme
id: xcode
run: |
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
echo "workspace=$WORKSPACE" >> "$GITHUB_OUTPUT"
echo "scheme=$SCHEME" >> "$GITHUB_OUTPUT"
echo "Using workspace $WORKSPACE, scheme $SCHEME"
# ── Unsigned path: a compile check only, so the workflow is still useful
# on forks and before the Apple secrets exist.
- name: Build without signing
if: steps.creds.outputs.signed != 'true'
run: |
xcodebuild build \
-workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release \
-destination 'generic/platform=iOS' \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=""
# ── Signed path ────────────────────────────────────────────
- name: Import the distribution certificate
if: steps.creds.outputs.signed == 'true'
env:
CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }}
CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
# Ephemeral runner, ephemeral keychain - this password protects nothing
# beyond the life of the job.
KEYCHAIN_PASSWORD=$(uuidgen)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
echo "$CERT_BASE64" | base64 -d > "$RUNNER_TEMP/dist.p12"
security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security -f pkcs12
rm -f "$RUNNER_TEMP/dist.p12"
# Without this, codesign triggers an interactive keychain prompt that
# nothing can answer on a headless runner.
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
# Put our keychain first in the search list, keeping the login one so
# Xcode can still reach Apple's intermediate certificates.
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/"//g')
security find-identity -v -p codesigning "$KEYCHAIN"
- name: Install the App Store Connect API key
if: steps.creds.outputs.signed == 'true'
env:
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
run: |
# altool looks the key up by ID in this directory.
mkdir -p ~/private_keys
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
- name: Archive
if: steps.creds.outputs.signed == 'true'
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
# Manual signing, deliberately. Automatic signing always signs the
# archive with a *development* identity and relies on -exportArchive to
# re-sign it for distribution afterwards - which needs a development
# certificate and a registered device that a CI-only TestFlight
# pipeline has no reason to own. Naming the App Store profile up front
# skips that dance entirely.
xcodebuild archive \
-workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_IDENTITY="Apple Distribution"
# Signing failures report a symptom ("no profiles found") rather than the
# setting that caused it, and a re-run costs ~30 minutes. Dump the
# resolved values instead of guessing twice.
- name: Dump signing settings
if: failure() && steps.creds.outputs.signed == 'true'
continue-on-error: true
run: |
echo "── identities in the keychain ──"
security find-identity -v -p codesigning || true
echo "── resolved build settings ──"
xcodebuild -workspace "${{ steps.xcode.outputs.workspace }}" \
-scheme "${{ steps.xcode.outputs.scheme }}" \
-configuration Release -showBuildSettings 2>/dev/null \
| grep -Ei 'CODE_SIGN|PROVISIONING|DEVELOPMENT_TEAM|PRODUCT_BUNDLE_ID' | sort -u || true
- name: Export the IPA
if: steps.creds.outputs.signed == 'true'
id: export
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PROFILE_NAME: ${{ steps.profiles.outputs.app_name }}
BUNDLE_ID: ${{ steps.profiles.outputs.app_bundle_id }}
NSE_PROFILE_NAME: ${{ steps.profiles.outputs.nse_name }}
NSE_BUNDLE_ID: ${{ steps.profiles.outputs.nse_bundle_id }}
run: |
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store</string>
<key>teamID</key><string>${TEAM_ID}</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
<key>${NSE_BUNDLE_ID}</key><string>${NSE_PROFILE_NAME}</string>
</dict>
<key>uploadSymbols</key><true/>
<key>destination</key><string>export</string>
</dict>
</plist>
PLIST
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
-exportPath "$RUNNER_TEMP/export" \
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist"
VERSION=$(cat VERSION | tr -d '[:space:]')
SRC=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.ipa"
cp "$SRC" "$DEST"
echo "path=$DEST" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
if: steps.creds.outputs.signed == 'true'
with:
name: ${{ steps.export.outputs.path }}
path: ${{ steps.export.outputs.path }}
if-no-files-found: error
- name: Upload to TestFlight
id: upload
# The IPA is not attached to the GitHub release: without an Apple device
# provisioned for it, it is not installable, and TestFlight is the only
# distribution channel for it.
if: >-
steps.creds.outputs.signed == 'true' &&
(github.event_name == 'release' || inputs.upload_to_testflight)
env:
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
IPA="${{ steps.export.outputs.path }}"
# Validate first - it catches missing Info.plist keys and entitlement
# mistakes in seconds, instead of via a rejection email 20 minutes later.
xcrun altool --validate-app --type ios --file "$IPA" \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
xcrun altool --upload-app --type ios --file "$IPA" \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
echo "done=true" >> "$GITHUB_OUTPUT"
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
# Puts the upload on the public TestFlight link (README): waits for App Store
# Connect to process it, adds it to the external group and submits it for
# beta review. On Linux, because most of this job is waiting.
public-testflight:
needs: build
if: needs.build.outputs.uploaded == 'true'
runs-on: ubuntu-latest
timeout-minutes: 75
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install the App Store Connect API key
env:
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
run: |
mkdir -p ~/private_keys
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
- name: Add the build to the public group and submit it for beta review
env:
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: node scripts/testflight-public.mjs "${{ needs.build.outputs.version }}" "${{ github.run_number }}"
+3
View File
@@ -17,6 +17,9 @@ expo-env.d.ts
*.p12
*.key
*.mobileprovision
*.keystore
!/android/app/debug.keystore
*.lineage
# Metro / Expo dev-server logs
.metro-health-check*
+36
View File
@@ -0,0 +1,36 @@
# Mailbox list and folders: the drawer (or docked sidebar) lists the role
# folders and custom ones; opening a folder shows its messages.
appId: org.bulwarkmail.mobile
name: Folders
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- takeScreenshot: folders-01-inbox
- assertVisible: ".*Your verification code.*"
- runFlow: ../subflows/open-drawer.yaml
- takeScreenshot: folders-02-drawer
- assertVisible: "Inbox([ ,].*)?"
- assertVisible: "Sent([ ,].*)?"
- assertVisible: "Drafts([ ,].*)?"
- assertVisible: "Archive([ ,].*)?"
# Parent folders read "Collapse, Projects" (the chevron comes first).
- assertVisible: ".*Projects.*"
- tapOn: "Archive([ ,].*)?"
- extendedWaitUntil:
visible: ".*Invoice 2025-12.*"
timeout: 20000
- takeScreenshot: folders-03-archive
- runFlow: ../subflows/open-drawer.yaml
# Projects/Alpha holds the seeded thread.
- runFlow:
when:
notVisible: "Alpha([ ,].*)?"
commands:
- tapOn: "Expand, Projects.*"
- tapOn: "Alpha([ ,].*)?"
- extendedWaitUntil:
visible: ".*Project Alpha kickoff.*"
timeout: 20000
- takeScreenshot: folders-04-alpha
- runFlow: ../subflows/go-to-inbox.yaml
+75
View File
@@ -0,0 +1,75 @@
# Opening messages: plain text, HTML with blocked remote content, a thread,
# and a PDF attachment in the in-app preview.
appId: org.bulwarkmail.mobile
name: Read messages
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
# Plain text
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Grüße aus München
- extendedWaitUntil:
visible: ".*Jürgen.*"
timeout: 10000
- takeScreenshot: read-01-plain
- runFlow: ../subflows/close-message.yaml
# HTML newsletter
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Weekly digest
- extendedWaitUntil:
visible: ".*5 new articles.*|Weekly digest"
timeout: 20000
- takeScreenshot: read-02-html
- runFlow:
when:
visible: "Load images"
commands:
- tapOn: "Load images"
- takeScreenshot: read-03-html-images
- runFlow: ../subflows/close-message.yaml
# Attachment and its preview
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Q3 report
- extendedWaitUntil:
visible: ".*q3-report.pdf.*"
timeout: 20000
- takeScreenshot: read-04-attachment
# The message's attachment chip, labelled with the file name alone (on the
# iPad the list row next to it mentions the name in its longer label).
- tapOn: "q3-report.pdf"
- extendedWaitUntil:
visible: "Share"
timeout: 30000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: read-05-attachment-preview
- assertVisible: "q3-report.pdf"
- tapOn: "Close"
# Back on the message (the iPad pane has a quick reply instead of Reply All).
- extendedWaitUntil:
notVisible: "Share"
timeout: 10000
- assertVisible: ".*q3-report.pdf.*"
- runFlow: ../subflows/close-message.yaml
# A thread (Projects/Alpha)
- runFlow:
file: ../subflows/go-to-folder.yaml
env:
FOLDER: Alpha
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Project Alpha kickoff
- takeScreenshot: read-06-thread
- runFlow: ../subflows/go-to-inbox.yaml
+103
View File
@@ -0,0 +1,103 @@
# Compose a new message and send it to userb; userb's mailbox (checked over
# JMAP) must get it.
appId: org.bulwarkmail.mobile
name: Compose and send
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.subject = 'E2E compose ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
- tapOn: "Compose"
- extendedWaitUntil:
visible: "New Message"
timeout: 15000
- tapOn: "Recipient email addresses"
- inputText: "userb@example.org"
- pressKey: Enter
- tapOn:
text: "Subject"
index: 1
- inputText: ${output.subject}
- runFlow:
file: ../subflows/compose-body.yaml
env:
FROM_SUBJECT: "yes"
BODY: "Hello from the iOS simulator test."
- takeScreenshot: compose-01-filled
# The attach sheet offers its options one by one (it used to be a single
# accessibility element, and picking one opened no picker on iOS).
- tapOn: "Attach"
- extendedWaitUntil:
visible: "Photos & Videos"
timeout: 10000
- assertVisible: "Camera"
# A server with Files offers "Upload from this device" and "Attach from
# Files" instead of a plain "Files".
- assertVisible: "Upload from this device|Files"
- takeScreenshot: compose-02-attach-sheet
- evalScript: ${output.pickPhoto = ORIENTATION != 'landscape'}
# The system photo picker runs in another process: XCTest sees none of its
# elements, but taps by position reach it. Pick the first photo (the app
# icon, added by boot-sim.sh) and confirm with the check mark. The iPad in
# landscape lays the picker out differently, so it only closes the sheet.
- runFlow:
when:
true: ${!output.pickPhoto}
commands:
- tapOn: "Cancel"
- runFlow:
when:
true: ${output.pickPhoto}
commands:
- tapOn: "Photos & Videos"
- extendedWaitUntil:
notVisible: "Draft saved|New Message"
timeout: 10000
optional: true
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: compose-03-photo-picker
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- tapOn:
point: "44%,33%"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
point: "90%,26%"
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- tapOn:
point: "16%,25%"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
point: "90%,13%"
- extendedWaitUntil:
visible: "New Message"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: compose-04-with-photo
- tapOn: "Send"
- extendedWaitUntil:
visible: "Email sent successfully"
timeout: 20000
optional: true
- runFlow:
file: ../subflows/assert-delivered.yaml
env:
SUBJECT: ${output.subject}
- runFlow:
when:
true: ${output.pickPhoto}
commands:
- assertTrue:
condition: ${output.helper.attachments.length >= 1}
label: "${output.subject} carries the picked photo"
@@ -0,0 +1,72 @@
# Incoming mail while the app is open (sent by userb over JMAP), then reply
# and forward it; userb receives both, the forward with the attachment.
appId: org.bulwarkmail.mobile
name: Incoming, reply, forward
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.incoming = 'E2E incoming ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
- runScript:
file: ../scripts/helper.js
env:
CALL: /send?to=${ACCOUNT}&subject=${encodeURIComponent(output.incoming)}&attach=1&html=1
# Arrives without a pull to refresh (push over EventSource).
- extendedWaitUntil:
visible: ".*${output.incoming}.*"
timeout: 90000
- takeScreenshot: incoming-01-arrived
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: ${output.incoming}
- takeScreenshot: incoming-02-open
# Reply
- tapOn:
text: "Reply"
index: 0
- extendedWaitUntil:
visible: "Re: ${output.incoming}"
timeout: 15000
- runFlow:
file: ../subflows/compose-body.yaml
env:
BODY: "Reply from the iOS simulator test."
- takeScreenshot: incoming-03-reply
- tapOn: "Send"
# The toast is gone after a few seconds; delivery is checked below.
- extendedWaitUntil:
visible: "Email sent successfully"
timeout: 20000
optional: true
- runFlow:
file: ../subflows/assert-delivered.yaml
env:
SUBJECT: "Re: ${output.incoming}"
# Forward with the attachment
- extendedWaitUntil:
visible: "Forward"
timeout: 15000
- tapOn:
text: "Forward"
index: 0
- extendedWaitUntil:
visible: "Fwd: ${output.incoming}"
timeout: 15000
- tapOn: "Recipient email addresses"
- inputText: "userb@example.org"
- pressKey: Enter
- takeScreenshot: incoming-04-forward
- tapOn: "Send"
- extendedWaitUntil:
visible: "Email sent successfully"
timeout: 20000
optional: true
- runFlow:
file: ../subflows/assert-delivered.yaml
env:
SUBJECT: "Fwd: ${output.incoming}"
ATTACHMENT: e2e-attachment.pdf
- runFlow: ../subflows/close-message.yaml
+27
View File
@@ -0,0 +1,27 @@
# Search: a hit, a miss, clearing.
appId: org.bulwarkmail.mobile
name: Search
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- tapOn: "Search mail..."
- inputText: "verification"
- pressKey: Enter
- extendedWaitUntil:
visible: ".*Your verification code.*"
timeout: 30000
- assertNotVisible: ".*Weekly digest.*"
- takeScreenshot: search-01-hit
- tapOn: "Clear search"
- tapOn: "Search mail..."
- inputText: "zzqx-no-such-mail"
- pressKey: Enter
- extendedWaitUntil:
visible: "No results found"
timeout: 30000
- takeScreenshot: search-02-miss
- tapOn: "Clear"
- extendedWaitUntil:
visible: ".*Weekly digest.*"
timeout: 30000
+49
View File
@@ -0,0 +1,49 @@
# Swipe actions with undo: swipe left archives (undo brings it back), swipe
# right toggles read.
appId: org.bulwarkmail.mobile
name: Swipe actions and undo
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- scrollUntilVisible:
element: ".*Grüße aus München.*"
direction: DOWN
centerElement: true
timeout: 20000
# Swipe the row itself: the avatar checkbox's label has the subject too, and
# a swipe that starts on it only selects the row (#91).
- swipe:
from:
id: email-row
text: ".*Grüße aus München.*"
direction: LEFT
duration: 400
# The undo bar shows for 5 s; when it goes, the Compose button slides into
# its place, and a late tap on UNDO opens the composer. So tap at once and
# check the rest afterwards.
- tapOn:
text: "UNDO"
waitToSettleTimeoutMs: 500
- extendedWaitUntil:
visible: ".*Grüße aus München.*"
timeout: 15000
- takeScreenshot: swipe-02-undone
- swipe:
from:
id: email-row
text: ".*Grüße aus München.*"
direction: RIGHT
duration: 400
- waitForAnimationToEnd:
timeout: 3000
- takeScreenshot: swipe-03-read-toggled
- swipe:
from:
id: email-row
text: ".*Grüße aus München.*"
direction: RIGHT
duration: 400
- waitForAnimationToEnd:
timeout: 3000
+103
View File
@@ -0,0 +1,103 @@
# Message actions with undo: move to a folder, delete to Trash, select and
# star from the list.
appId: org.bulwarkmail.mobile
name: Move, delete, select
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
# Move via More → Move to
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Weekly digest
- tapOn: "more"
- extendedWaitUntil:
visible: "More actions"
timeout: 10000
- takeScreenshot: actions-01-more
# Both the menu row and the sheet title read "Move to" (webmail catalog),
# so wait for the folder list instead of the title.
- tapOn: "Move to"
- extendedWaitUntil:
visible: "Projects"
timeout: 10000
- tapOn:
text: "Projects"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- takeScreenshot: actions-02-moved
# Tap UNDO at once (see 06-swipe-undo: a late tap opens the composer).
- tapOn:
text: "UNDO"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- runFlow: ../subflows/close-message.yaml
- extendedWaitUntil:
visible: ".*Weekly digest.*"
timeout: 15000
# Delete (to Trash) and undo
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Weekly digest
- takeScreenshot: actions-03a-before-delete
- tapOn:
text: "Delete"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- takeScreenshot: actions-03-deleted
- tapOn:
text: "UNDO"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- runFlow: ../subflows/close-message.yaml
- extendedWaitUntil:
visible: ".*Weekly digest.*"
timeout: 15000
# Select from the list and star, then unstar. On a phone the row can be
# below the fold (earlier runs left mail on top).
- scrollUntilVisible:
element: ".*Grüße aus München.*"
direction: DOWN
centerElement: true
timeout: 20000
# The row, not its avatar checkbox (whose label has the subject too).
- longPressOn:
id: email-row
text: ".*Grüße aus München.*"
- extendedWaitUntil:
visible: "Clear selection"
timeout: 10000
- takeScreenshot: actions-04-selected
# Star (or Unstar, when an earlier run left it starred), then toggle back.
# On a tablet the list column is narrow and Star sits in the More menu.
- runFlow:
when:
notVisible: "Star|Unstar"
commands:
- tapOn: "More actions.*"
- tapOn: "Star|Unstar"
# A bulk action ends selection mode by itself.
- runFlow:
when:
visible: "Clear selection"
commands:
- tapOn: "Clear selection"
- longPressOn:
id: email-row
text: ".*Grüße aus München.*"
- runFlow:
when:
notVisible: "Unstar|Star"
commands:
- tapOn: "More actions.*"
- tapOn: "Unstar|Star"
- runFlow:
when:
visible: "Clear selection"
commands:
- tapOn: "Clear selection"
+60
View File
@@ -0,0 +1,60 @@
# Scheduled send: schedule a message for later, find it under Scheduled,
# cancel it.
appId: org.bulwarkmail.mobile
name: Scheduled send
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.subject = 'E2E scheduled ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
- tapOn: "Compose"
- extendedWaitUntil:
visible: "New Message"
timeout: 15000
- tapOn: "Recipient email addresses"
- inputText: "userb@example.org"
- pressKey: Enter
- tapOn:
text: "Subject"
index: 1
- inputText: ${output.subject}
- runFlow:
file: ../subflows/compose-body.yaml
env:
FROM_SUBJECT: "yes"
BODY: "Scheduled from the iOS simulator test."
- tapOn: "Schedule send"
- extendedWaitUntil:
visible: "Tomorrow morning.*"
timeout: 10000
- takeScreenshot: scheduled-01-options
- tapOn: "Tomorrow morning.*"
- extendedWaitUntil:
visible: "Scheduled"
timeout: 30000
- takeScreenshot: scheduled-02-confirmed
- tapOn: "OK"
- runFlow:
file: ../subflows/go-to-folder.yaml
env:
FOLDER: Scheduled
- extendedWaitUntil:
visible: ".*${output.subject}.*"
timeout: 30000
- takeScreenshot: scheduled-03-list
- tapOn: ".*${output.subject}.*"
- tapOn: "Cancel send"
- extendedWaitUntil:
visible: "Keep scheduled"
timeout: 10000
# The dialog's button (below its message), not its title or the button
# behind it. Cancelling now moves the message back to Drafts.
- tapOn:
text: "Cancel send"
below: ".*will not be (sent|delivered).*"
- extendedWaitUntil:
notVisible: ".*${output.subject}.*"
timeout: 30000
- takeScreenshot: scheduled-04-cancelled
- runFlow: ../subflows/go-to-inbox.yaml
+144
View File
@@ -0,0 +1,144 @@
# Calendar: the views, create an event with a reminder, edit it, delete it.
appId: org.bulwarkmail.mobile
name: Calendar
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.event = 'E2E event ' + DEVICE + ' ' + Date.now() % 100000}
- tapOn:
id: tab-calendar
# The first visit asks for notification permission (event reminders).
- extendedWaitUntil:
visible: ".*Would Like to Send You Notifications.*|Go to today"
timeout: 30000
- runFlow: ../subflows/allow-notifications.yaml
- extendedWaitUntil:
visible: "Go to today"
timeout: 30000
- takeScreenshot: calendar-01-month
- tapOn: "Open menu"
- tapOn: "Schedule"
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: calendar-02-schedule
- tapOn: "Open menu"
- tapOn: "Day"
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: calendar-03-day
- tapOn: "Open menu"
- tapOn: "Week"
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: calendar-04-week
# Create. The button starts the event on the selected day: make that today
# (a stray tap, such as one through the notification prompt, can select
# another day), so the schedule below finds it after Go to today.
- tapOn: "Go to today"
- waitForAnimationToEnd:
timeout: 3000
- tapOn: "New event"
- extendedWaitUntil:
visible: "Save"
timeout: 10000
- tapOn: "Title"
- inputText: ${output.event}
# Close the keyboard first: a tap while it slides away lands on a form that
# is still moving.
- pressKey: Enter
- waitForAnimationToEnd:
timeout: 3000
# A new event starts on the calendar's default reminders; Customize turns
# them into the event's own list, which has Add reminder.
- scrollUntilVisible:
element: "Customize|Add reminder"
direction: DOWN
timeout: 10000
- runFlow:
when:
visible: "Customize"
commands:
- tapOn: "Customize"
- waitForAnimationToEnd:
timeout: 3000
- scrollUntilVisible:
element: "Add reminder"
direction: DOWN
timeout: 10000
- repeat:
while:
notVisible: "15 minutes before"
times: 2
commands:
- tapOn: "Add reminder"
- waitForAnimationToEnd:
timeout: 3000
- scrollUntilVisible:
element: "15 minutes before"
direction: DOWN
timeout: 10000
- tapOn: "15 minutes before"
- takeScreenshot: calendar-05-editor
- tapOn: "Save"
# The editor closes (its Save goes; the FAB is labelled "New event" too).
- extendedWaitUntil:
notVisible: "Save"
timeout: 20000
# Find it in the schedule and open it
- tapOn: "Open menu"
- tapOn: "Schedule"
- tapOn: "Go to today"
- scrollUntilVisible:
element: ".*${output.event}.*"
direction: DOWN
timeout: 30000
- tapOn: ".*${output.event}.*"
- extendedWaitUntil:
visible: "Edit"
timeout: 10000
- takeScreenshot: calendar-06-detail
- assertVisible: ".*15 minutes before.*"
# Edit: add a location (an empty field, so no cursor games in the title)
- tapOn: "Edit"
- extendedWaitUntil:
visible: "Edit event"
timeout: 10000
- scrollUntilVisible:
element: "Place or address"
direction: DOWN
timeout: 10000
- tapOn: "Place or address"
- inputText: "Room 42"
- tapOn: "Save"
- extendedWaitUntil:
visible: ".*Room 42.*"
timeout: 20000
- takeScreenshot: calendar-07-edited
# Delete (the detail sheet is still open after saving)
- runFlow:
when:
notVisible: "Delete"
commands:
- tapOn: ".*${output.event}.*"
- extendedWaitUntil:
visible: "Delete"
timeout: 10000
- tapOn: "Delete"
- extendedWaitUntil:
visible: "Delete this event.*"
timeout: 10000
- tapOn:
text: "Delete"
below: "Delete this event.*"
- extendedWaitUntil:
notVisible: ".*${output.event}.*"
timeout: 20000
- takeScreenshot: calendar-08-deleted
- tapOn:
id: tab-mail
+70
View File
@@ -0,0 +1,70 @@
# Contacts: the list, a contact's detail, create one and delete it again.
appId: org.bulwarkmail.mobile
name: Contacts
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.last = 'Tester' + Date.now() % 100000}
- tapOn:
id: tab-contacts
- extendedWaitUntil:
visible: "All Contacts"
timeout: 30000
- takeScreenshot: contacts-01-list
- tapOn: ".*Alice.*"
- extendedWaitUntil:
visible: "Email"
timeout: 10000
- takeScreenshot: contacts-02-detail
- runFlow:
when:
visible: "Back"
commands:
- tapOn: "Back"
- tapOn: "New Contact"
- extendedWaitUntil:
visible: "Save"
timeout: 10000
- runFlow:
file: ../subflows/type.yaml
env:
FIELD: contact-first-name
TEXT: E2E
# Return moves to Middle name, then Last name: tapping the field would hit
# the keyboard covering it.
- pressKey: Enter
- pressKey: Enter
- runFlow:
file: ../subflows/type.yaml
env:
FIELD: contact-last-name
TAP: "no"
TEXT: ${output.last}
- takeScreenshot: contacts-03-form
- tapOn: "Save"
- extendedWaitUntil:
visible: ".*${output.last}.*"
timeout: 20000
- takeScreenshot: contacts-04-created
- runFlow:
when:
notVisible: "More actions"
commands:
- tapOn: ".*E2E ${output.last}.*"
- tapOn: "More actions"
- tapOn: "Delete"
- extendedWaitUntil:
visible: "Delete contact"
timeout: 10000
- tapOn:
text: "Delete"
below: "Are you sure.*"
- extendedWaitUntil:
notVisible: ".*E2E ${output.last}.*"
timeout: 20000
- takeScreenshot: contacts-05-deleted
- tapOn:
id: tab-mail
+67
View File
@@ -0,0 +1,67 @@
# Files: open a folder, preview a text and a PDF file, create and delete a
# folder.
appId: org.bulwarkmail.mobile
name: Files
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.folder = 'E2E folder ' + Date.now() % 100000}
- tapOn:
id: tab-files
- extendedWaitUntil:
visible: ".*Documents.*"
timeout: 30000
- takeScreenshot: files-01-root
- tapOn: ".*Documents.*"
- extendedWaitUntil:
visible: ".*readme.txt.*"
timeout: 20000
- tapOn: ".*readme.txt.*"
- extendedWaitUntil:
visible: "readme.txt"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: files-02-text-preview
- tapOn: "Close"
- tapOn: ".*q3-report.pdf.*"
- extendedWaitUntil:
visible: "q3-report.pdf"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: files-03-pdf-preview
- tapOn: "Close"
- tapOn: "Back"
- tapOn: "New Folder"
- tapOn: "Folder name"
- inputText: ${output.folder}
- tapOn: "Create"
- extendedWaitUntil:
visible: ".*${output.folder}.*"
timeout: 20000
- takeScreenshot: files-04-folder-created
- longPressOn: ".*${output.folder}.*"
# Delete moves it to the trash at once, with Undo and no question (like the
# webmail). A tap while the actions sheet still slides in can miss: tap
# again while Delete is still there.
- extendedWaitUntil:
visible: "Delete"
timeout: 10000
- repeat:
while:
visible: "Delete"
times: 3
commands:
- tapOn:
text: "Delete"
optional: true
- waitForAnimationToEnd:
timeout: 3000
- extendedWaitUntil:
notVisible: ".*${output.folder}.*"
timeout: 20000
- tapOn:
id: tab-mail
+33
View File
@@ -0,0 +1,33 @@
# Every settings page opens and shows its content, first half (13-settings-more
# has the rest: one flow for all 24 pages ran into the 15-minute flow limit on
# the iPad simulator).
appId: org.bulwarkmail.mobile
name: Settings pages
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- tapOn:
id: tab-settings
- extendedWaitUntil:
visible: "Search settings"
timeout: 20000
- takeScreenshot: settings-00-list
# The search also lists the settings inside each page under it, so a common
# word ("Folders", "Calendar") pushes the page's row below the keyboard.
# Each Q matches its page near the top. A PROOF the search lists under the
# row would pass on the iPad without the page, so those differ. The Themes
# cards are one element each, named by the theme: check the page's text.
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: account, ROW: ".*@example.org, .*", PROOF: "Logged-in accounts|Display Name"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: language, ROW: "Language, Region & Time, .*", Q: "Language", PROOF: "Date Format"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", Q: "Notifications", PROOF: "Email Notifications"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", Q: "Appearance", PROOF: "Choose your preferred color scheme"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", Q: "Layout", PROOF: "Tune the email list interactions for mobile."}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", Q: "Reading", PROOF: "Mark as Read"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", Q: "Composing", PROOF: "Composer"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", Q: "Identities", PROOF: "Sending Identities"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", Q: "Office", PROOF: "Reply automatically"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", Q: "Filters", PROOF: "Email Filters"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", Q: "Templates", PROOF: "Email Templates"}}
- tapOn:
id: tab-mail
+32
View File
@@ -0,0 +1,32 @@
# Every settings page opens and shows its content, second half (see 12-settings).
appId: org.bulwarkmail.mobile
name: Settings pages, continued
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- tapOn:
id: tab-settings
- extendedWaitUntil:
visible: "Search settings"
timeout: 20000
- takeScreenshot: settings-00-list-more
# The search also lists the settings inside each page under it, so a common
# word ("Folders", "Calendar") pushes the page's row below the keyboard.
# Each Q matches its page near the top. A PROOF the search lists under the
# row would pass on the iPad without the page, so those differ. The Themes
# cards are one element each, named by the theme: check the page's text.
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", Q: "New folder", PROOF: ".*Inbox.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", Q: "Tags", PROOF: "Email Tags"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", Q: "Downloads", PROOF: "Email .*eml.* filename"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", Q: "Security", PROOF: "Account Security|Detecting server capabilities.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", Q: "Content &", PROOF: "External Content"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", Q: "Week start", PROOF: "Default view"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", Q: "Group by", PROOF: "Sort by last name"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", Q: "Files", PROOF: "Default View"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", Q: "Sidebar", PROOF: "Keep Apps Loaded"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", Q: "Themes", PROOF: "Pick a colou?r theme.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", Q: "Storage", PROOF: "Offline mail.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", Q: "About", PROOF: "This device"}}
- tapOn:
id: tab-mail
+30
View File
@@ -0,0 +1,30 @@
# Fresh install: the app starts and shows the sign-in choices, and typing
# into a field works. Needs no server, so it runs before every suite as the
# warm-up for the XCTest driver. It types only a made-up address, so its
# debug log is safe to print.
appId: org.bulwarkmail.mobile
name: Launch to sign-in
tags:
- smoke
---
- launchApp:
clearState: true
clearKeychain: true
# A cold start of a release build on a CI simulator can be slow.
- extendedWaitUntil:
visible: "Enter server details manually"
timeout: 120000
- takeScreenshot: warmup-01-sign-in-choices
- tapOn: "Enter server details manually"
- extendedWaitUntil:
visible:
id: login-server-input
timeout: 10000
- runFlow: subflows/dismiss-keyboard-tip.yaml
- runFlow:
file: subflows/type.yaml
env:
FIELD: login-server-input
TAP: "no"
TEXT: typing-probe.example.net
- takeScreenshot: warmup-02-typed
+27
View File
@@ -0,0 +1,27 @@
# Perf: open a message and go back, five times. The app is already running
# with -perfMarks YES (no launchApp here: it would drop the launch argument).
appId: org.bulwarkmail.mobile
name: Perf open message
---
- extendedWaitUntil:
visible:
id: email-row
timeout: 60000
- repeat:
times: 5
commands:
- tapOn:
id: email-row
index: 1
- extendedWaitUntil:
visible: "Reply All"
timeout: 30000
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
visible: "Back"
commands:
- tapOn: "Back"
- waitForAnimationToEnd:
timeout: 3000
+33
View File
@@ -0,0 +1,33 @@
# Perf: switch through the tabs, three times.
appId: org.bulwarkmail.mobile
name: Perf tab switches
---
- repeat:
times: 3
commands:
- tapOn:
id: tab-calendar
- runFlow:
when:
visible: ".*Would Like to Send You Notifications.*"
commands:
- tapOn: "Allow"
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
id: tab-contacts
- waitForAnimationToEnd:
timeout: 3000
- takeScreenshot: perf-contacts
- tapOn:
id: tab-files
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
id: tab-settings
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
id: tab-mail
- waitForAnimationToEnd:
timeout: 3000
+23
View File
@@ -0,0 +1,23 @@
# Perf: open the composer and close it untouched, five times.
appId: org.bulwarkmail.mobile
name: Perf compose open
---
- tapOn:
id: tab-mail
- repeat:
times: 5
commands:
- tapOn: "Compose"
- extendedWaitUntil:
visible: "New Message"
timeout: 20000
- waitForAnimationToEnd:
timeout: 3000
- tapOn: "Close"
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
visible: "Discard"
commands:
- tapOn: "Discard"
+28
View File
@@ -0,0 +1,28 @@
# Perf: fling through the long inbox (thousands of messages), then back up.
appId: org.bulwarkmail.mobile
name: Perf list scroll
---
- tapOn:
id: tab-mail
- extendedWaitUntil:
visible:
id: email-row
timeout: 30000
- repeat:
times: 8
commands:
- swipe:
start: 50%,80%
end: 50%,20%
duration: 250
- waitForAnimationToEnd:
timeout: 4000
- repeat:
times: 8
commands:
- swipe:
start: 50%,20%
end: 50%,80%
duration: 250
- waitForAnimationToEnd:
timeout: 4000
+11
View File
@@ -0,0 +1,11 @@
# Performance runs: sign in once (ACCOUNT, the bulk account with thousands of
# messages). Later installs of other builds keep the data and the keychain.
appId: org.bulwarkmail.mobile
name: Perf sign-in
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
INBOX_TEXT: ".*Bulk.*"
+79
View File
@@ -0,0 +1,79 @@
# Push on iOS: the relay's payload carries only ids and a placeholder text.
# A tap opens the message, with the app running and from a cold start. The
# helper sends the message as userb and pushes the relay's payload with
# simctl, so this needs no APNs key.
#
# simctl push hands the payload to the simulator as a local notification
# request: the notification service extension (plugins/ios-push), which
# puts sender and subject in, never runs that way (the log shows no
# NotificationService process). So the banner shows the placeholder here;
# the extension needs a real APNs push to be tested.
appId: org.bulwarkmail.mobile
name: Push notification on iOS
tags:
- push
---
- runFlow: ../subflows/signed-in.yaml
# Without notification permission iOS drops the push silently, and the
# launch-time grant doesn't reach it on these simulators: let the app ask
# (the Calendar tab does, for event reminders) and allow.
- tapOn:
id: tab-calendar
- extendedWaitUntil:
visible: ".*Would Like to Send You Notifications.*|Go to today"
timeout: 30000
- runFlow:
when:
visible: ".*Would Like to Send You Notifications.*"
commands:
- tapOn:
text: "Allow"
optional: true
- tapOn:
id: tab-mail
- waitForAnimationToEnd:
timeout: 3000
- pressKey: Home
# 1. The banner shows (and what it says).
- evalScript: ${output.subject0 = 'E2E push banner ' + Date.now()}
- runScript:
file: ../scripts/helper.js
env:
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject0)}
- extendedWaitUntil:
visible: ".*${output.subject0}.*|.*New message.*"
timeout: 30000
- takeScreenshot: push-01-banner
# Let it go, so the next tap can't hit this one.
- extendedWaitUntil:
notVisible: ".*${output.subject0}.*|.*New message.*"
timeout: 20000
# 2. A tap opens the message, app in the background. The banner shows for a
# few seconds and finding it takes Maestro about as long; taps on the lock
# screen list do nothing on these simulators. So tap where the banner
# appears, right after the push (push-and-tap.yaml).
- evalScript: ${output.subject = 'E2E push ' + Date.now()}
- runFlow:
file: ../subflows/push-and-tap.yaml
env:
SUBJECT: ${output.subject}
- extendedWaitUntil:
visible: "Reply All"
timeout: 60000
- assertVisible: ".*${output.subject}.*"
- takeScreenshot: push-02-opened-warm
# 3. The same from a cold start: the app isn't running.
- stopApp
- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()}
- runFlow:
file: ../subflows/push-and-tap.yaml
env:
SUBJECT: ${output.subject2}
- extendedWaitUntil:
visible: "Reply All"
timeout: 90000
- assertVisible: ".*${output.subject2}.*"
- takeScreenshot: push-04-opened-cold
+8
View File
@@ -0,0 +1,8 @@
// Calls the test helper (scripts/e2e/helper.mjs) on the runner.
// env CALL: path and query, e.g. "/totp" or "/pair?user=x&deliver=openurl".
// The answer lands in output.helper.
const response = http.get(HELPER + CALL);
if (!response.ok) {
throw new Error('helper ' + CALL.split('?')[0] + ' failed: ' + response.status + ' ' + response.body);
}
output.helper = json(response.body);
+32
View File
@@ -0,0 +1,32 @@
# Password sign-in as the first account, sign out, sign in again.
appId: org.bulwarkmail.mobile
name: Password, sign out, sign in again
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- takeScreenshot: password-01-choices
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${SERVER}
- takeScreenshot: password-02-confirm
- tapOn: "Sign in with a password instead"
- runFlow:
file: ../subflows/password-form.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: password-03-inbox
- runFlow: ../subflows/sign-out.yaml
- extendedWaitUntil:
visible: "Enter server details manually"
timeout: 30000
- takeScreenshot: password-04-signed-out
- runFlow:
file: ../subflows/password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- takeScreenshot: password-05-signed-in-again
+58
View File
@@ -0,0 +1,58 @@
# A wrong password and an unreachable server: the app says what went wrong
# and stays on the form.
appId: org.bulwarkmail.mobile
name: Password errors
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${SERVER}
- tapOn: "Sign in with a password instead"
- runFlow:
file: ../subflows/password-form.yaml
env:
LOGIN: ${ACCOUNT}@example.org
PASSWORD: wrong-password-123
- extendedWaitUntil:
visible: "That didn't work"
timeout: 30000
- takeScreenshot: errors-01-wrong-password
- assertVisible:
id: login-password-input
# Back to the server step, then a host that doesn't resolve.
- tapOn: "Back"
- tapOn: "Back"
- runFlow:
file: ../subflows/type.yaml
env:
FIELD: login-server-input
TAP: "no"
ERASE: "yes"
TEXT: mail.does-not-exist.invalid
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
- tapOn: "Sign in with a password instead"
- extendedWaitUntil:
visible:
id: login-password-input
timeout: 10000
- runFlow:
file: ../subflows/type-secret.yaml
env:
FIELD: login-password-input
# The email is filled in, so the password field focuses itself.
TAP: "no"
TEXT: ${PASSWORD}
- pressKey: Enter
- extendedWaitUntil:
visible: "Can't reach mail.does-not-exist.invalid|No mail server at mail.does-not-exist.invalid|Sign-in failed"
timeout: 60000
- takeScreenshot: errors-02-unreachable
- assertVisible:
id: login-password-input
+24
View File
@@ -0,0 +1,24 @@
# A TOTP account signs in with a password: Stalwart answers 402 for the right
# password, and the app asks for the code.
appId: org.bulwarkmail.mobile
name: Password with TOTP
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${SERVER}
- tapOn: "Sign in with a password instead"
- runFlow:
file: ../subflows/password-form.yaml
env:
LOGIN: ${TOTP_ACCOUNT}@example.org
- extendedWaitUntil:
visible: "Authentication code"
timeout: 30000
- takeScreenshot: totp-01-code-asked
- runFlow: ../subflows/two-factor.yaml
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: totp-02-inbox
+20
View File
@@ -0,0 +1,20 @@
# OAuth with Stalwart's own authorization server: the confirm step's
# Continue opens an ASWebAuthenticationSession on Stalwart's sign-in page.
appId: org.bulwarkmail.mobile
name: OAuth (Stalwart)
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${SERVER}
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- runFlow:
file: ../subflows/stalwart-login-page.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: oauth-01-inbox
+39
View File
@@ -0,0 +1,39 @@
# Backing out of OAuth: Cancel on the system alert, and closing the sheet
# halfway. Both leave the user on the confirm step without an error.
appId: org.bulwarkmail.mobile
name: OAuth cancelled
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${SERVER}
- tapOn: "Continue"
- extendedWaitUntil:
visible: ".*Wants to Use.*to Sign In.*"
timeout: 30000
- tapOn:
text: "Cancel"
below: ".*Wants to Use.*to Sign In.*"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
- assertNotVisible: "Sign-in failed"
- assertNotVisible: "Sign-in was interrupted"
- takeScreenshot: oauth-cancel-01-alert-cancelled
# Accept the alert, then close the sheet on Stalwart's page.
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- extendedWaitUntil:
visible: "you@example.com"
timeout: 60000
- tapOn: "Cancel"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
- assertNotVisible: "Sign-in failed"
- assertNotVisible: "Sign-in was interrupted"
- takeScreenshot: oauth-cancel-02-sheet-closed
+20
View File
@@ -0,0 +1,20 @@
# Sign in via webmail: the confirm step on a Bulwark webmail address hands
# off to the webmail's sign-in page in an auth session.
appId: org.bulwarkmail.mobile
name: Webmail hand-off
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${WEBMAIL}
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- runFlow:
file: ../subflows/webmail-login-page.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: webmail-01-inbox
+24
View File
@@ -0,0 +1,24 @@
# Webmail hand-off for a TOTP account: the webmail asks for the code; if it
# hands back the password, the app finishes with a code of its own. Uses the
# beta webmail: the stable one still has 06's session in the browser, which
# strands the hand-off (see 20-webmail-session.yaml).
appId: org.bulwarkmail.mobile
name: Webmail hand-off with TOTP
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${WEBMAIL_BETA}
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- runFlow:
file: ../subflows/webmail-login-page.yaml
env:
LOGIN: ${TOTP_ACCOUNT}@example.org
TOTP: "yes"
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: webmail-totp-01-inbox
+17
View File
@@ -0,0 +1,17 @@
# Sign-in link from another app (simctl openurl), password account. The
# helper signs in to the test webmail, creates a fresh "Link mobile app" code
# and opens its bulwarkmail://pair link on the simulator.
appId: org.bulwarkmail.mobile
name: Sign-in link from another app
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair?user=${ACCOUNT}&deliver=openurl
- runFlow: ../subflows/confirm-link.yaml
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: link-password-01-inbox
+16
View File
@@ -0,0 +1,16 @@
# "Paste sign-in link" with a fresh pairing link on the clipboard (simctl
# pbcopy), password account.
appId: org.bulwarkmail.mobile
name: Paste sign-in link
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair?user=${ACCOUNT}&deliver=pbcopy
- runFlow: ../subflows/paste-link.yaml
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: paste-password-01-inbox
+15
View File
@@ -0,0 +1,15 @@
# Sign-in link from another app for a TOTP account.
appId: org.bulwarkmail.mobile
name: Sign-in link, TOTP account
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair?user=${TOTP_ACCOUNT}&deliver=openurl
- runFlow: ../subflows/confirm-link.yaml
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: link-totp-01-inbox
+15
View File
@@ -0,0 +1,15 @@
# "Paste sign-in link" for a TOTP account.
appId: org.bulwarkmail.mobile
name: Paste sign-in link, TOTP account
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair?user=${TOTP_ACCOUNT}&deliver=pbcopy
- runFlow: ../subflows/paste-link.yaml
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: paste-totp-01-inbox
+21
View File
@@ -0,0 +1,21 @@
# An expired code (codes live 2 minutes): the helper creates one now and
# opens it after 130 s. The app says so and stays on the sign-in screen.
appId: org.bulwarkmail.mobile
name: Expired sign-in code
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair-later?user=${ACCOUNT}&deliver=openurl&delay=130
- runFlow:
file: ../subflows/confirm-link.yaml
env:
LINK_WAIT: 200000
- extendedWaitUntil:
visible: "That code has expired.*"
timeout: 30000
- takeScreenshot: link-expired-01-notice
- assertVisible: "Enter server details manually"
+44
View File
@@ -0,0 +1,44 @@
# A code that was already used: sign in with it, sign out, open the same link
# again (same app process, then after a restart, which asks the server).
appId: org.bulwarkmail.mobile
name: Used sign-in code
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair?user=${ACCOUNT}&deliver=openurl
- runFlow: ../subflows/confirm-link.yaml
- runFlow: ../subflows/inbox.yaml
- runFlow: ../subflows/sign-out.yaml
- extendedWaitUntil:
visible: "Enter server details manually"
timeout: 30000
- runScript:
file: ../scripts/helper.js
env:
CALL: /redeliver?user=${ACCOUNT}&deliver=openurl
- runFlow: ../subflows/confirm-link.yaml
- extendedWaitUntil:
visible: "That code was already used|That code has expired or was already used"
timeout: 30000
- takeScreenshot: link-used-01-same-process
- stopApp
- launchApp
- extendedWaitUntil:
visible: "Enter server details manually"
timeout: 60000
- runScript:
file: ../scripts/helper.js
env:
CALL: /redeliver?user=${ACCOUNT}&deliver=openurl
- runFlow: ../subflows/confirm-link.yaml
- extendedWaitUntil:
visible: "That code was already used|That code has expired or was already used"
timeout: 30000
- takeScreenshot: link-used-02-after-restart
- assertVisible: "Enter server details manually"
+35
View File
@@ -0,0 +1,35 @@
# The QR scanner with camera access denied, then allowed (the simulator has
# no camera, so nothing can be scanned). Neither may crash the app.
appId: org.bulwarkmail.mobile
name: Scanner without camera
tags:
- signin
---
- runFlow:
file: ../subflows/start.yaml
env:
CAMERA: deny
- tapOn: "Scan a sign-in code.*"
# Denied for good: "Camera access is disabled..."; not asked yet (the
# simulator doesn't always apply the denial up front): "Bulwark Mail needs
# camera access..." (English catalog) or the built-in fallback wording.
- extendedWaitUntil:
visible: "Camera access is.*|.*needs camera access.*|Scanning a sign-in QR code uses the camera.*"
timeout: 15000
- takeScreenshot: scanner-01-denied
- tapOn: "Close"
- assertVisible: "Enter server details manually"
- runFlow:
file: ../subflows/start.yaml
env:
CAMERA: allow
- tapOn: "Scan a sign-in code.*"
- extendedWaitUntil:
visible: "Sign-in code"
timeout: 15000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: scanner-02-allowed
- tapOn: "Close"
- assertVisible: "Enter server details manually"
+23
View File
@@ -0,0 +1,23 @@
# "Use my email address": example.org has no autodiscovery, so the app moves
# on to the server step and names the domain.
appId: org.bulwarkmail.mobile
name: Email address discovery
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- tapOn: "Use my email address.*"
- extendedWaitUntil:
visible: "What's your email address."
timeout: 10000
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${ACCOUNT}@example.org
- assertVisible: ${ACCOUNT}@example.org
- tapOn: "Continue"
- extendedWaitUntil:
visible: "We couldn't find a server for example.org.*"
timeout: 60000
- takeScreenshot: discovery-01-not-found
- assertVisible:
id: login-server-input
+29
View File
@@ -0,0 +1,29 @@
# Second account by password via "Another account on <host>", then switch
# between the two.
appId: org.bulwarkmail.mobile
name: Add account, password
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: ../subflows/open-add-account.yaml
- takeScreenshot: add-password-01-choices
- tapOn: "Another account on .*"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
- tapOn: "Sign in with a password instead"
- runFlow:
file: ../subflows/password-form.yaml
env:
LOGIN: ${ACCOUNT2}@example.org
- runFlow: ../subflows/inbox.yaml
- runFlow:
file: ../subflows/check-active-account.yaml
env:
ACTIVE: ${ACCOUNT2}@example.org
- takeScreenshot: add-password-02-two-accounts
+41
View File
@@ -0,0 +1,41 @@
# Second account by OAuth. Adding uses an ephemeral auth session, so the
# first account's Stalwart sign-in must not be reused.
appId: org.bulwarkmail.mobile
name: Add account, OAuth
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${SERVER}
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- runFlow:
file: ../subflows/stalwart-login-page.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: ../subflows/inbox.yaml
- runFlow: ../subflows/open-add-account.yaml
- tapOn: "Another account on .*"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
- tapOn: "Continue"
- runFlow:
when:
visible: ".*Wants to Use.*to Sign In.*"
commands:
- runFlow: ../subflows/allow-auth-session.yaml
- runFlow:
file: ../subflows/stalwart-login-page.yaml
env:
LOGIN: ${ACCOUNT2}@example.org
- runFlow: ../subflows/inbox.yaml
- runFlow:
file: ../subflows/check-active-account.yaml
env:
ACTIVE: ${ACCOUNT2}@example.org
- takeScreenshot: add-oauth-01-two-accounts
+43
View File
@@ -0,0 +1,43 @@
# Second account via the webmail hand-off, on top of a password account.
appId: org.bulwarkmail.mobile
name: Add account, webmail
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: ../subflows/open-add-account.yaml
- tapOn: "An account somewhere else.*"
- extendedWaitUntil:
visible: "What's the other address."
timeout: 10000
- tapOn: "I know my server address"
- runFlow:
file: ../subflows/type.yaml
env:
FIELD: login-server-input
TAP: "no"
TEXT: ${WEBMAIL}
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
- tapOn: "Continue"
- runFlow:
when:
visible: ".*Wants to Use.*to Sign In.*"
commands:
- runFlow: ../subflows/allow-auth-session.yaml
- runFlow:
file: ../subflows/webmail-login-page.yaml
env:
LOGIN: ${ACCOUNT2}@example.org
- runFlow: ../subflows/inbox.yaml
- runFlow:
file: ../subflows/check-active-account.yaml
env:
ACTIVE: ${ACCOUNT2}@example.org
- takeScreenshot: add-webmail-01-two-accounts
+37
View File
@@ -0,0 +1,37 @@
# Second account from a sign-in link opened by another app while signed in.
appId: org.bulwarkmail.mobile
name: Add account, sign-in link
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair?user=${ACCOUNT2}&deliver=openurl
- extendedWaitUntil:
visible: "Open in .*Bulwark Mobile.*|Sign in with a link."
timeout: 60000
- runFlow:
when:
visible: "Open in .*Bulwark Mobile.*"
commands:
- tapOn: "Open"
- extendedWaitUntil:
visible: "Sign in with a link."
timeout: 20000
- takeScreenshot: add-link-01-prompt
- assertVisible: "This link adds an account.*"
- tapOn:
text: "Continue"
below: "Sign in with a link."
- runFlow: ../subflows/inbox.yaml
- runFlow:
file: ../subflows/check-active-account.yaml
env:
ACTIVE: ${ACCOUNT2}@example.org
- takeScreenshot: add-link-02-two-accounts
+52
View File
@@ -0,0 +1,52 @@
# Sign in via webmail while the browser still has a webmail session (from
# 06-webmail, or from using the webmail in Safari): the hand-off must still
# reach the app. Before the fix, the webmail's login page redirected an
# existing session to its own inbox inside the sign-in sheet, and the app
# never got the sign-in.
appId: org.bulwarkmail.mobile
name: Webmail hand-off with an existing webmail session
tags:
- signin
---
# Leaves a webmail session in the shared browser cookies.
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${WEBMAIL}
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- extendedWaitUntil:
visible: "user@example.com|Hand over to the app|.*Inbox.*"
timeout: 60000
- runFlow:
when:
visible: "user@example.com"
commands:
- runFlow:
file: ../subflows/webmail-login-page.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow:
when:
visible: "Hand over to the app"
commands:
- tapOn: "Hand over to the app"
- runFlow: ../subflows/inbox.yaml
- runFlow: ../subflows/sign-out.yaml
# Again: the webmail now knows the user.
- extendedWaitUntil:
visible: "Enter server details manually"
timeout: 30000
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${WEBMAIL}
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- extendedWaitUntil:
visible: "user@example.com|Hand over to the app|Search mail.*"
timeout: 60000
- takeScreenshot: webmail-session-01-second-visit
- assertNotVisible: "Search mail.*"
+11
View File
@@ -0,0 +1,11 @@
# iOS asks before an app opens a sign-in session in the system browser:
# "“Bulwark Mobile” Wants to Use “<domain>” to Sign In". Accept it.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible: ".*Wants to Use.*to Sign In.*"
timeout: 30000
- takeScreenshot: auth-session-alert
- tapOn:
text: "Continue"
below: ".*Wants to Use.*to Sign In.*"
@@ -0,0 +1,25 @@
# The app asks for notification permission now and then (calendar
# reminders, push). The system alert covers the whole app, so XCTest sees
# nothing else until it is answered: allow it when it is up. Taps on system
# alerts miss in landscape, so answer it in portrait.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
visible: ".*Would Like to Send You Notifications.*"
commands:
- setOrientation: PORTRAIT
- waitForAnimationToEnd:
timeout: 3000
# Maestro repeats a tap that seems to change nothing; here the repeat
# landed on the app once the alert was gone (a day in the calendar's
# month grid, which opened that day). Tap once.
- tapOn:
text: "Allow"
optional: true
retryTapIfNoChange: false
- runFlow:
when:
true: ${ORIENTATION == 'landscape'}
commands:
- setOrientation: LANDSCAPE_LEFT
+18
View File
@@ -0,0 +1,18 @@
# The helper checks over JMAP that AS (default userb) received SUBJECT; with
# ATTACHMENT set, that the message carries that file.
appId: org.bulwarkmail.mobile
---
- runScript:
file: ../scripts/helper.js
env:
CALL: /find?as=${AS || 'userb'}&subject=${encodeURIComponent(SUBJECT)}&timeout=60
- assertTrue:
condition: ${output.helper.found}
label: "${SUBJECT} delivered to ${AS || 'userb'}"
- runFlow:
when:
true: ${!!ATTACHMENT}
commands:
- assertTrue:
condition: ${output.helper.attachments.indexOf(ATTACHMENT) >= 0}
label: "${SUBJECT} carries ${ATTACHMENT}"
@@ -0,0 +1,17 @@
# The Settings account card names the active account ACTIVE, and the Account
# page lists another account to switch to. Goes back to Mail afterwards.
appId: org.bulwarkmail.mobile
---
- tapOn:
id: tab-settings
- extendedWaitUntil:
visible: ".*${ACTIVE}.*"
timeout: 20000
- tapOn: ".*${ACTIVE}.*"
- scrollUntilVisible:
element: "Switch to this account"
direction: DOWN
timeout: 20000
- takeScreenshot: accounts-${ACTIVE}
- tapOn:
id: tab-mail
+11
View File
@@ -0,0 +1,11 @@
# Empty the field with id FIELD. A tap puts the cursor where it lands and
# eraseText only deletes before the cursor, so erase in passes: each tap
# lands in the middle of what is left.
appId: org.bulwarkmail.mobile
---
- repeat:
times: 4
commands:
- tapOn:
id: ${FIELD}
- eraseText: 100
+11
View File
@@ -0,0 +1,11 @@
# Leave the message view: Back on a phone; the split layout keeps the list.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
visible: "Back"
commands:
- tapOn: "Back"
# A tap during the transition lands on stale coordinates.
- waitForAnimationToEnd:
timeout: 5000
+41
View File
@@ -0,0 +1,41 @@
# In the composer: type BODY into the editor. The rich editor is a WebView
# whose placeholder is CSS, so it may not be exposed; then tap the middle of
# the screen, which is the body area below Subject (above the keyboard).
# FROM_SUBJECT=yes: the Subject field has the focus; Return there moves it
# to the body (an iPad in landscape shows hardly any body with the keyboard
# up, so there is nothing to tap).
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
true: ${FROM_SUBJECT == 'yes'}
commands:
- pressKey: Enter
- waitForAnimationToEnd:
timeout: 2000
- inputText: ${BODY}
- runFlow:
when:
true: ${FROM_SUBJECT != 'yes'}
commands:
- runFlow:
when:
visible: "Write your message.*"
commands:
- tapOn: "Write your message.*"
# A tap on the WebView doesn't always take the focus from the Subject field
# (on the iPad the body once went into Subject); a second tap does.
# (hideKeyboard fails here: the composer's keyboard has no dismiss key.)
- runFlow:
when:
notVisible: "Write your message.*"
commands:
- tapOn:
point: "50%,50%"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
point: "50%,50%"
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${BODY}
+31
View File
@@ -0,0 +1,31 @@
# A sign-in link from another app asks first. Continue.
appId: org.bulwarkmail.mobile
---
# simctl openurl acts like a link from another app: iOS asks first.
- extendedWaitUntil:
visible: "Open in .*Bulwark Mobile.*|Sign in with a link.|Open the sign-in page of.*"
timeout: ${LINK_WAIT || 60000}
- runFlow:
when:
visible: "Open in .*Bulwark Mobile.*"
commands:
- tapOn: "Open"
# Right after a sign-out the app can take a while to get to the link.
- extendedWaitUntil:
visible: "Sign in with a link.|Open the sign-in page of.*"
timeout: 60000
- takeScreenshot: link-confirm-prompt
- tapOn:
text: "Continue"
below: "Sign in with a link."
# A tap while the alert is still animating in can go nowhere: tap again if
# it is still up.
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
visible: "Sign in with a link."
commands:
- tapOn:
text: "Continue"
below: "Sign in with a link."
@@ -0,0 +1,12 @@
# iOS shows a one-time slide-to-type tip over the keyboard on a fresh
# simulator. The workflow turns it off, but dismiss it if it still shows up.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
visible: "Speed up your typing by sliding.*"
commands:
# The sign-in screen has its own Continue button; take the tip's.
- tapOn:
text: "Continue"
below: "Speed up your typing by sliding.*"
@@ -0,0 +1,12 @@
# Some routes hand the app a password for a TOTP account; the app then asks
# for a code itself. Answer it when it shows up.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible: "Authentication code|Inbox"
timeout: 90000
- runFlow:
when:
visible: "Authentication code"
commands:
- runFlow: two-factor.yaml
+9
View File
@@ -0,0 +1,9 @@
# Open folder FOLDER from the drawer or docked sidebar.
appId: org.bulwarkmail.mobile
---
- runFlow: open-drawer.yaml
# A folder row's label joins its texts: "<name>, <unread>, /, <total>".
- tapOn: ${FOLDER}([ ,].*)?
- extendedWaitUntil:
visible: ${FOLDER}
timeout: 15000
+16
View File
@@ -0,0 +1,16 @@
# Back to the inbox list, from wherever in the Mail tab.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
visible: "Back"
commands:
- tapOn: "Back"
- runFlow:
file: go-to-folder.yaml
env:
FOLDER: Inbox
- extendedWaitUntil:
visible:
id: email-row
timeout: 60000
+26
View File
@@ -0,0 +1,26 @@
# Signed in: the tab bar is there and the inbox shows messages. Dismisses the
# one-time prompts on the way.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible:
id: tab-mail
timeout: 90000
- runFlow:
when:
visible: "Not now"
commands:
- tapOn: "Not now"
# Adding an account from Settings returns to Settings.
- tapOn:
id: tab-mail
- extendedWaitUntil:
visible:
id: email-row
timeout: 60000
# Rows are one element: sender, subject and preview together. Mail the
# tests sent earlier can push the seeded row below the fold.
- scrollUntilVisible:
element: ${INBOX_TEXT || '.*Your verification code.*'}
direction: DOWN
timeout: 30000
+31
View File
@@ -0,0 +1,31 @@
# From the sign-in choices: enter a server address by hand and continue to the
# confirm step. env HOST: the address as a user types it.
appId: org.bulwarkmail.mobile
---
- tapOn: "Enter server details manually"
- extendedWaitUntil:
visible:
id: login-server-input
timeout: 10000
- runFlow:
file: type.yaml
env:
FIELD: login-server-input
TAP: "no"
TEXT: ${HOST}
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
optional: true
# Once in a while the tap doesn't reach the button (it moves as the keyboard
# goes away): tap again if the form is still there.
- runFlow:
when:
visible:
id: login-server-input
commands:
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
+19
View File
@@ -0,0 +1,19 @@
# Signed in: Settings → account card → Account page → Add account, up to the
# add-mode choices. (On the iPad split layout the Account page is already
# open; tapping the card again is harmless.)
appId: org.bulwarkmail.mobile
---
- tapOn:
id: tab-settings
- extendedWaitUntil:
visible: ".*@example.org, .*"
timeout: 10000
- tapOn: ".*@example.org, .*"
- scrollUntilVisible:
element: "Add account"
direction: DOWN
timeout: 20000
- tapOn: "Add account"
- extendedWaitUntil:
visible: "Add another account"
timeout: 10000
+24
View File
@@ -0,0 +1,24 @@
# Show the folder list: the modal drawer, or nothing to do where the sidebar
# is docked (iPad landscape).
appId: org.bulwarkmail.mobile
---
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
visible: "Toggle menu"
commands:
- tapOn: "Toggle menu"
- extendedWaitUntil:
visible: "Scheduled.*"
timeout: 5000
optional: true
# Once more if the first tap went to a list still settling.
- runFlow:
when:
notVisible: "Scheduled.*"
commands:
- tapOn: "Toggle menu"
- extendedWaitUntil:
visible: "Scheduled.*"
timeout: 10000
+45
View File
@@ -0,0 +1,45 @@
# From a message list: open the message whose row mentions SUBJECT.
# A row near the bottom can count as visible while the phone's tab bar
# covers it (the tap then opens another tab): on the phone, scroll it to the
# middle. The iPad's list is a column Maestro's scroll (in the middle of the
# screen) doesn't reach; its rows fit on screen.
# The avatar's "Select <sender>: <subject>" checkbox mentions the subject
# too, and a tap on it selects the row instead of opening it (#91): match
# the row itself by its id.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- scrollUntilVisible:
element:
id: email-row
text: ".*${SUBJECT}.*"
direction: DOWN
centerElement: true
visibilityPercentage: 100
timeout: 30000
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- scrollUntilVisible:
element:
id: email-row
text: ".*${SUBJECT}.*"
direction: DOWN
timeout: 30000
# A tap while the list still moves lands on another row.
- waitForAnimationToEnd:
timeout: 5000
- tapOn:
id: email-row
text: ".*${SUBJECT}.*"
# The message toolbar's "more" button: "Reply All" shows on the phone, but
# the iPad pane has a quick reply box instead for mail to one recipient.
- extendedWaitUntil:
visible: "more"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
+19
View File
@@ -0,0 +1,19 @@
# On the password step: fill in LOGIN and PASSWORD and sign in.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible:
id: login-username-input
timeout: 20000
- runFlow:
file: type.yaml
env:
FIELD: login-username-input
TEXT: ${LOGIN}
- runFlow:
file: type-secret.yaml
env:
FIELD: login-password-input
TEXT: ${PASSWORD}
# Return on the password field signs in.
- pressKey: Enter
+11
View File
@@ -0,0 +1,11 @@
# From the sign-in choices to the inbox: manual server, password.
# env LOGIN: the account's address.
appId: org.bulwarkmail.mobile
---
- runFlow:
file: manual-server.yaml
env:
HOST: ${SERVER}
- tapOn: "Sign in with a password instead"
- runFlow: password-form.yaml
- runFlow: inbox.yaml
+22
View File
@@ -0,0 +1,22 @@
# The helper has put a sign-in link on the simulator's clipboard: open
# "Paste sign-in link", paste it and sign in.
appId: org.bulwarkmail.mobile
---
- tapOn: "Paste sign-in link.*"
- extendedWaitUntil:
visible: "Paste"
timeout: 10000
- tapOn: "Paste"
# iOS asks before an app reads the clipboard.
- runFlow:
when:
visible: "Allow Paste"
commands:
- tapOn: "Allow Paste"
- extendedWaitUntil:
visible: "bulwarkmail://pair.*"
timeout: 10000
- takeScreenshot: paste-link-filled
- tapOn:
text: "Sign in"
below: "bulwarkmail://pair.*"
+23
View File
@@ -0,0 +1,23 @@
# Push a message with SUBJECT to ACCOUNT (helper /simpush) and tap its banner
# where it appears, at once (it shows for a few seconds, about as long as
# Maestro takes to find it). If the tap came too early or too late, push
# again: up to three tries until the message is open.
appId: org.bulwarkmail.mobile
---
- repeat:
while:
notVisible: "Reply All"
times: 3
commands:
- runScript:
file: ../scripts/helper.js
env:
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(SUBJECT)}
- tapOn:
point: "50%,9%"
waitToSettleTimeoutMs: 500
retryTapIfNoChange: false
- extendedWaitUntil:
visible: "Reply All"
timeout: 30000
optional: true
+89
View File
@@ -0,0 +1,89 @@
# Settings list: find the row ROW (regex on "Title, summary"), open it,
# check PROOF shows, screenshot, and go back on a phone.
#
# Rows count as visible while the phone's tab bar or header covers them or
# while they hang half off the iPad's screen, and a tap then misses; the
# iPad's list is a left column Maestro's scroll (in the middle of the screen)
# doesn't reach. So search for the page (Q, a word of its title): the row
# then sits right under the search box. The account card (no Q) is always
# at the top.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
true: ${!!Q}
commands:
- tapOn: "Search settings"
- eraseText: 40
- inputText: ${Q}
- waitForAnimationToEnd:
timeout: 3000
# Now and then the simulator drops or garbles a typed character
# (seen: "Storagew,"): type it again, a few times if need be.
- repeat:
while:
notVisible: ${ROW}
times: 3
commands:
- tapOn: "Search settings"
- eraseText: 40
- inputText: ${Q}
- waitForAnimationToEnd:
timeout: 3000
# A tap while the list still coasts from the swipe only stops it.
- waitForAnimationToEnd:
timeout: 5000
- tapOn: ${ROW}
- waitForAnimationToEnd:
timeout: 5000
# On the phone the page replaces the list: make sure it opened before
# scrolling (else the swipes below scroll the list away).
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- runFlow:
when:
notVisible: "Back"
commands:
- tapOn: ${ROW}
- extendedWaitUntil:
visible: "Back"
timeout: 10000
# PROOF can be further down the page: scroll the page (the right-hand pane
# on the iPad) until it shows.
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- repeat:
while:
notVisible: ${PROOF}
times: 6
commands:
- swipe:
start: 75%,80%
end: 75%,40%
duration: 600
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- repeat:
while:
notVisible: ${PROOF}
times: 6
commands:
- swipe:
start: 50%,80%
end: 50%,40%
duration: 600
- extendedWaitUntil:
visible: ${PROOF}
timeout: 20000
- takeScreenshot: settings-${NAME}
- runFlow:
when:
visible: "Back"
commands:
- tapOn: "Back"
+45
View File
@@ -0,0 +1,45 @@
# Signed in: sign out of the active account from the end of Settings.
# On the iPad the Settings list is the left column; scroll that one.
appId: org.bulwarkmail.mobile
---
- tapOn:
id: tab-settings
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- repeat:
while:
notVisible: "Sign out"
times: 12
commands:
- swipe:
start: 30%,85%
end: 30%,30%
duration: 500
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- scrollUntilVisible:
element: "Sign out"
direction: DOWN
timeout: 30000
# The scroll can stop with the button half under the tab bar, where
# a tap reaches the tab bar instead: scroll on to the end of the list.
- swipe:
start: 50%,70%
end: 50%,30%
duration: 400
- waitForAnimationToEnd:
timeout: 3000
- tapOn: "Sign out"
- extendedWaitUntil:
visible: "The account is removed from this device.*"
timeout: 10000
# The dialog's button, not the Settings row of the same name behind it.
- tapOn:
text: "Sign out"
rightOf: "Cancel"
below: "The account is removed from this device.*"
- takeScreenshot: sign-out-tapped
+36
View File
@@ -0,0 +1,36 @@
# Feature flows start here: the app signed in to ACCOUNT, on the inbox.
# Keeps the app's data between flows; signs in only when needed.
appId: org.bulwarkmail.mobile
---
- launchApp:
permissions:
notifications: allow
photos: allow
camera: allow
# Answer the notification prompt (if the app asks) before turning to
# landscape: rotated, taps on system alerts miss.
- setOrientation: PORTRAIT
- extendedWaitUntil:
visible: "Enter server details manually|Inbox|.*Would Like to Send You Notifications.*"
timeout: 120000
- runFlow: allow-notifications.yaml
- runFlow:
when:
true: ${ORIENTATION == 'landscape'}
commands:
- setOrientation: LANDSCAPE_LEFT
- extendedWaitUntil:
visible: "Enter server details manually|Inbox"
timeout: 30000
- runFlow:
when:
visible: "Enter server details manually"
commands:
- runFlow:
file: password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: allow-notifications.yaml
- tapOn:
id: tab-mail
- runFlow: go-to-inbox.yaml
@@ -0,0 +1,38 @@
# Stalwart's own sign-in page inside the auth session. env LOGIN, TOTP (any
# non-empty value: the account needs a one-time code).
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible: "you@example.com"
timeout: 60000
- takeScreenshot: stalwart-login-page
- runFlow:
file: type-web.yaml
env:
HINT: "you@example.com"
TEXT: ${LOGIN}
- tapOn: "••••••••"
- inputText: ${PASSWORD}
- runFlow:
when:
true: ${TOTP == 'yes'}
commands:
- runScript:
file: ../scripts/helper.js
env:
CALL: /totp
- tapOn: "000000"
- inputText: ${output.helper.code}
- tapOn:
text: "Sign in"
index: 1
# Stalwart 1.0.1 asks for consent after Sign in, once per client grant;
# 0.16 and repeat sign-ins go straight back to the app.
- waitForAnimationToEnd:
timeout: 5000
- runFlow:
when:
visible: "Allow"
commands:
- takeScreenshot: stalwart-consent-page
- tapOn: "Allow"
+27
View File
@@ -0,0 +1,27 @@
# Fresh install state, rotated for the landscape leg, on the sign-in choices.
# env CAMERA: allow (default) or deny.
appId: org.bulwarkmail.mobile
---
# A system prompt left over from an earlier flow ("Open in …?") stays on
# screen across app launches.
- runFlow:
when:
visible: "Open in .*"
commands:
- tapOn: "Cancel"
- launchApp:
clearState: true
clearKeychain: true
permissions:
notifications: allow
camera: ${CAMERA || 'allow'}
photos: allow
- runFlow:
when:
true: ${ORIENTATION == 'landscape'}
commands:
- setOrientation: LANDSCAPE_LEFT
# A cold start of a release build on a CI simulator can be slow.
- extendedWaitUntil:
visible: "Enter server details manually"
timeout: 120000
+21
View File
@@ -0,0 +1,21 @@
# The two-factor field is showing (it focuses itself): type a fresh code from
# the helper and sign in.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible: "Authentication code"
timeout: 30000
- runScript:
file: ../scripts/helper.js
env:
CALL: /totp
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${output.helper.code}
# The number pad has no return key.
- runFlow:
when:
notVisible: "Sign in"
commands:
- hideKeyboard
- tapOn: "Sign in"
+22
View File
@@ -0,0 +1,22 @@
# Type a secret into the field with id FIELD (TAP=no for a field that focuses
# itself; see type.yaml for why the first character goes alone). The value
# can't be checked (a secure field shows dots) and must never be shown, so a
# lost keystroke surfaces as a failed sign-in.
appId: org.bulwarkmail.mobile
---
- evalScript: ${output.secretHead = TEXT.slice(0, 1); output.secretTail = TEXT.slice(1)}
- runFlow:
when:
true: ${TAP != 'no'}
commands:
- tapOn:
id: ${FIELD}
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${output.secretHead}
# A short pause: wait for something that never shows.
- extendedWaitUntil:
visible: "zz-no-such-element-zz"
timeout: 1000
optional: true
- inputText: ${output.secretTail}
+32
View File
@@ -0,0 +1,32 @@
# Type TEXT into a web page field found by its placeholder HINT (the sign-in
# pages in the auth session), and check it arrived as typed. Now and then a
# stray character lands in front of it on the CI simulator; then erase the
# field and type again. The caret is still at the end of what was just
# typed, so eraseText (which deletes before the caret) empties it; selecting
# the text with a long press didn't work and appended instead. SECRET=yes
# skips the check (a password field shows dots). TAP=no types into the
# field that has the focus already.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
true: ${TAP != 'no'}
commands:
- tapOn: ${HINT}
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${TEXT}
- runFlow:
when:
true: ${SECRET != 'yes'}
commands:
- repeat:
while:
notVisible: ${TEXT}
times: 2
commands:
- eraseText: 150
- waitForAnimationToEnd:
timeout: 2000
- inputText: ${TEXT}
- assertVisible: ${TEXT}
+54
View File
@@ -0,0 +1,54 @@
# Type TEXT into the field with id FIELD and check it arrived in full.
#
# iOS drops keystrokes in two situations seen on the CI simulator: while the
# edit menu (the "AutoFill" bubble) opens, which happens when a flow taps a
# field that already has focus, and right after the first character, while
# the field re-renders. So: no tap for a field that focuses itself (TAP=no;
# Maestro can't see a React Native field's focus), type the first character,
# wait until it shows, then type the rest. Retyped up to twice.
appId: org.bulwarkmail.mobile
---
- evalScript: ${output.typeHead = TEXT.slice(0, 1); output.typeTail = TEXT.slice(1)}
- runFlow:
when:
true: ${TAP != 'no'}
commands:
- tapOn:
id: ${FIELD}
- waitForAnimationToEnd:
timeout: 3000
# ERASE=yes: the field already holds text.
- runFlow:
when:
true: ${ERASE == 'yes'}
commands:
- runFlow: clear-field.yaml
- inputText: ${output.typeHead}
- extendedWaitUntil:
visible:
id: ${FIELD}
text: "${output.typeHead}.*"
timeout: 5000
optional: true
- inputText: ${output.typeTail}
- runFlow:
when:
notVisible:
id: ${FIELD}
text: ${TEXT_PATTERN || TEXT}
commands:
- retry:
maxRetries: 2
commands:
- runFlow: clear-field.yaml
- inputText: ${output.typeHead}
- extendedWaitUntil:
visible:
id: ${FIELD}
text: "${output.typeHead}.*"
timeout: 5000
optional: true
- inputText: ${output.typeTail}
- assertVisible:
id: ${FIELD}
text: ${TEXT_PATTERN || TEXT}
+54
View File
@@ -0,0 +1,54 @@
# The Bulwark webmail's sign-in page inside the auth session (the app's
# "Sign in via webmail" hand-off). env LOGIN, TOTP=yes for a TOTP account.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible: "user@example.com"
timeout: 60000
- takeScreenshot: webmail-login-page
- runFlow:
file: type-web.yaml
env:
HINT: "user@example.com"
TEXT: ${LOGIN}
# On the iPhone, Safari's keyboard toolbar (Previous, Next, Done) can sit
# over the password field, and a tap there leaves the focus in Email (the
# password then went into Email). Its Next moves the focus to the password.
- evalScript: ${output.webNext = false}
- runFlow:
when:
visible: "Next"
commands:
- tapOn: "Next"
- waitForAnimationToEnd:
timeout: 2000
- evalScript: ${output.webNext = true}
- runFlow:
file: type-web.yaml
env:
HINT: "Enter your password"
TEXT: ${PASSWORD}
SECRET: "yes"
TAP: "${output.webNext ? 'no' : 'yes'}"
- tapOn: "Sign in"
- runFlow:
when:
true: ${TOTP == 'yes'}
commands:
- extendedWaitUntil:
visible: "000000"
timeout: 30000
- runScript:
file: ../scripts/helper.js
env:
CALL: /totp
- tapOn: "000000"
- inputText: ${output.helper.code}
- tapOn: "Sign in|Verify"
# Before handing over, the webmail asks whether the app may take the sign-in.
- extendedWaitUntil:
visible: "Hand over to the app"
timeout: 60000
- takeScreenshot: webmail-handover-prompt
- tapOn: "Hand over to the app"
+565 -90
View File
@@ -1,82 +1,216 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useColorScheme } from 'react-native';
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
import {
DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused,
} from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/icons';
import { startPushUpdates } from './src/api/push';
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
import { jmapClient } from './src/api/jmap-client';
import { pruneEmailSubmissions } from './src/api/submissions';
import type { StateChange } from './src/api/types';
import { dispatchStateChange } from './src/lib/state-change-bus';
import {
startCalendarNotificationSync,
startCalendarReminderTapHandling,
} from './src/lib/calendar-notifications';
import { startLivenessMonitor } from './src/lib/connection-liveness';
import { startDeviceSyncTriggers } from './src/device-sync/app/triggers';
import {
setPendingCalendarOpen,
type CalendarReminderTarget,
} from './src/navigation/pending-calendar-open';
import { sweepStaleExportFiles } from './src/lib/email-export';
import { useFilterStore } from './src/stores/filter-store';
import { useVacationStore } from './src/stores/vacation-store';
import {
addMessageListener,
addNotificationTapListener,
addTokenRefreshListener,
getInitialNotificationTap,
getStoredRelayBaseUrl,
setupPushNotifications,
notificationTapJmapAccountId,
renewOtherPushAccounts,
resyncPushNotifications,
teardownPushNotificationsForAccount,
type NotificationTapPayload,
} from './src/lib/push-notifications';
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
// Pause between a mute change and the push-filter update it sends (#78).
const MUTES_RESYNC_DELAY_MS = 1_000;
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
import EmailSourceScreen from './src/screens/EmailSourceScreen';
import LoginScreen from './src/screens/LoginScreen';
import EmailListScreen from './src/screens/EmailListScreen';
import FilesScreen from './src/screens/FilesScreen';
import CalendarScreen from './src/screens/CalendarScreen';
import ContactsScreen from './src/screens/ContactsScreen';
import MailTab from './src/screens/MailTab';
import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import ScheduledScreen from './src/screens/ScheduledScreen';
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
// Screens that aren't on the way to the inbox load on first visit
// (getComponent below), so their modules stay out of the startup work.
import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore } from './src/stores/email-store';
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
import { useSettingsStore } from './src/stores/settings-store';
import { startPolicySync, usePolicyFeature } from './src/stores/policy-store';
import { startScreenPrivacySync } from './src/lib/screen-privacy';
import { useLocaleStore } from './src/stores/locale-store';
import { useLayout } from './src/lib/use-layout';
import { toast } from './src/stores/toast-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
import { ToastHost } from './src/components/ToastHost';
import { UndoSnackbar } from './src/components/UndoSnackbar';
import { AppIconBadge } from './src/components/AppIconBadge';
import { getEmails } from './src/api/email';
import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
import { draftContextFromEmail } from './src/lib/draft-context';
import {
acceptSignInLink,
enterLinkAccount,
handleDeepLink,
parseDeepLink,
routeParkedSignInLink,
shareToDeepLink,
type AccountSwitchResult,
type DeepLink,
} from './src/navigation/linking';
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
import { MAX_ACCOUNTS } from './src/lib/account-utils';
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { useOutboxStore } from './src/stores/outbox-store';
import { runOfflineSync } from './src/lib/offline-sync';
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
import { perfEndAfterPaint, perfMarksEnabled, perfStart } from './src/lib/perf-marks';
import { useColors } from './src/theme/colors';
const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabsParamList>();
const navigationRef = createNavigationContainerRef<RootStackParamList>();
// The launch URL is read once per process: Android hands the same intent back
// on every getInitialURL(), and a sign-in link must not run twice.
let initialSignInLinkRead = false;
// Move to the account a notification, reminder or link names. An account
// that was signed out meanwhile has nothing safe to open.
async function switchToAccount(accountId: string): Promise<AccountSwitchResult> {
const auth = useAuthStore.getState();
if (auth.activeAccountId === accountId) return true;
if (!useAccountStore.getState().getAccountById(accountId)) return false;
await auth.switchAccount(accountId);
return useAuthStore.getState().activeAccountId === accountId ? 'switched' : false;
}
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
if (!navigationRef.isReady()) return;
// The notification carries the account it was generated for. If the user
// has since switched to a different account (or had a different one active
// when the notification arrived), opening EmailThread under the active
// account would fetch the email from the wrong server and fail.
const auth = useAuthStore.getState();
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
const account = useAccountStore.getState().getAccountById(payload.accountId);
if (!account) return; // account was logged out — nothing safe to open.
await auth.switchAccount(payload.accountId);
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
}
// account would fetch the email from the wrong server and fail. After a
// switch the stack starts over on that account's inbox, so Back from the
// message lands there instead of on the old account's screens.
if (!(await enterLinkAccount(navigationRef, payload.accountId, switchToAccount))) return;
prefetchMessage({ id: payload.emailId, threadId: payload.threadId });
navigationRef.navigate('EmailThread', {
emailId: payload.emailId,
threadId: payload.threadId,
emailIds: [payload.emailId],
subject: payload.subject,
// A group mailbox's message lives under another JMAP account (#839).
jmapAccountId: notificationTapJmapAccountId(payload),
...(payload.action === 'reply' ? { action: 'reply' as const } : {}),
});
}
// A tapped calendar reminder opens its event (or task) in the Calendar tab,
// under the account it was scheduled for. The target is parked for
// CalendarScreen, which also picks it up when it mounts after a cold start.
async function openCalendarReminder(target: CalendarReminderTarget): Promise<void> {
if (!(await enterLinkAccount(navigationRef, target.appAccountId, switchToAccount))) return;
setPendingCalendarOpen(target);
// On a cold start the navigator mounts right after the auth gate flips.
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (navigationRef.isReady()) {
navigationRef.navigate('MainTabs', { screen: 'Calendar' } as never);
}
}
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
// Android share-sheet payloads all end up here once the navigator is ready.
async function openDeepLink(link: DeepLink): Promise<void> {
const opened = await handleDeepLink(link, {
navigation: navigationRef,
resolveThreadId: async (emailId, jmapAccountId) => {
try {
const [email] = await getEmails([emailId], jmapAccountId);
return email?.threadId ?? null;
} catch {
return null;
}
},
openDraft: async (emailId, jmapAccountId) => {
try {
// Cache first, so a draft read before still opens offline.
const draft = draftContextFromEmail(await loadDetail(emailId, jmapAccountId), jmapAccountId);
if (!navigationRef.isReady()) return false;
navigationRef.navigate('Compose', { draft });
return true;
} catch {
return false;
}
},
switchAccount: switchToAccount,
});
// A widget or a link elsewhere brought the app up for this; say it failed
// rather than leave the user wondering why the app just opened.
if (!opened) toast.error(useLocaleStore.getState().t('deep_link.open_failed', "Couldn't open this"));
}
// Toasts and the undo bar show on whichever stack screen is up, so a failure
// that lands after leaving the screen it came from (a viewer action finishing
// on the Unified Inbox or a contact) is still seen, and a reply held by the
// undo-send delay can be undone from the viewer it was sent from. Every
// screen carries the hosts, and only the focused one renders them, so
// neither ever shows twice (the screen below the top one is kept live).
// The composer gets no undo bar: its Undo would hand the open composer
// another draft. A held send's bar shows on the screen below once it closes.
function FocusedHosts({ undo }: { undo: boolean }) {
if (!useIsFocused()) return null;
return (
<>
{undo && <UndoSnackbar />}
<ToastHost />
</>
);
}
function withHosts({ route, children }: { route: { name: string }; children: React.ReactElement }) {
return (
<>
{children}
<FocusedHosts undo={route.name !== 'Compose'} />
</>
);
}
function LoadingScreen({ message }: { message: string }) {
const c = useColors();
return (
@@ -89,24 +223,69 @@ function LoadingScreen({ message }: { message: string }) {
function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParamList, 'MainTabs'>) {
const c = useColors();
const mailboxes = useEmailStore((state) => state.mailboxes);
// Select the count, not the list: every mailbox fetch sets a new array, and
// re-rendering here re-renders the mail list with it.
const inboxUnreadCount = useEmailStore(
(state) => state.mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0,
);
const logout = useAuthStore((state) => state.logout);
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
const hasCalendar = useHasCalendar();
const hasContacts = useHasContacts();
const hasFiles = useHasFiles();
const disabledTabStyle = { opacity: 0.4 } as const;
// The organisation policy can turn a whole area off (webmail feature
// gates); its tab goes away rather than showing as unavailable.
const calendarAllowed = usePolicyFeature('calendarEnabled');
const contactsAllowed = usePolicyFeature('contactsEnabled');
const filesAllowed = usePolicyFeature('filesEnabled');
const hiddenTab = { tabBarItemStyle: { display: 'none' as const }, tabBarButton: () => null };
const t = useLocaleStore((state) => state.t);
// The webmail's navigation names; a tab the server lacks says so.
const tabLabels = {
mail: t('sidebar.mail', 'Mail'),
calendar: t('sidebar.calendar', 'Calendar'),
contacts: t('sidebar.contacts', 'Contacts'),
files: t('sidebar.files', 'Files'),
settings: t('sidebar.settings', 'Settings'),
};
const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name });
// Side by side layouts move the tabs into a rail on the left, like the
// webmail's navigation rail.
const { isSplit } = useLayout();
// A bottom tab bar keeps the screens clear of the gesture bar or taskbar;
// with the tabs in a side rail the whole tab area has to.
const bottomInset = useSafeAreaInsets().bottom;
return (
<View style={{ flex: 1, backgroundColor: c.background }}>
<View style={{ flex: 1, backgroundColor: c.background, paddingBottom: isSplit ? bottomInset : 0 }}>
<UpdateBanner />
<OfflineCacheBanner />
<PushOnboardingPrompt />
<AppIconBadge />
<Tab.Navigator
screenListeners={perfMarksEnabled() ? ({ route }) => ({
tabPress: () => perfStart('tab-switch'),
focus: () => perfEndAfterPaint('tab-switch', { to: route.name }),
}) : undefined}
screenOptions={{
headerShown: false,
// Tabs out of view stop re-rendering until they are shown again.
freezeOnBlur: true,
tabBarPosition: isSplit ? 'left' : 'bottom',
tabBarVariant: isSplit ? 'material' : 'uikit',
// A narrow rail (icon over label), not a wide sidebar with labels
// beside the icons.
...(isSplit ? { tabBarLabelPosition: 'below-icon' as const } : null),
tabBarActiveTintColor: c.text,
tabBarInactiveTintColor: c.textSecondary,
tabBarStyle: {
tabBarStyle: isSplit ? {
backgroundColor: c.secondary,
borderRightColor: c.border,
borderRightWidth: 1,
elevation: 0,
shadowOpacity: 0,
shadowColor: 'transparent',
} : {
backgroundColor: c.background,
borderTopColor: c.border,
borderTopWidth: 1,
@@ -123,6 +302,8 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<Tab.Screen
name="Mail"
options={{
title: tabLabels.mail,
tabBarButtonTestID: 'tab-mail',
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
tabBarBadgeStyle: {
@@ -139,64 +320,64 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
},
}}
>
{() => (
<EmailListScreen
onComposePress={() => navigation.navigate('Compose')}
onEmailPress={(email) => {
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject,
});
}}
/>
)}
{() => <MailTab navigation={navigation} />}
</Tab.Screen>
<Tab.Screen
name="Calendar"
component={CalendarScreen}
getComponent={() => require('./src/screens/CalendarScreen').default}
options={{
title: tabLabels.calendar,
tabBarButtonTestID: 'tab-calendar',
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)',
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
...(calendarAllowed ? null : hiddenTab),
}}
listeners={{
tabPress: (e) => {
if (!hasCalendar) e.preventDefault();
if (!hasCalendar || !calendarAllowed) e.preventDefault();
},
}}
/>
<Tab.Screen
name="Contacts"
component={ContactsScreen}
getComponent={() => require('./src/screens/ContactsTab').default}
options={{
title: tabLabels.contacts,
tabBarButtonTestID: 'tab-contacts',
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)',
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
...(contactsAllowed ? null : hiddenTab),
}}
listeners={{
tabPress: (e) => {
if (!hasContacts) e.preventDefault();
if (!hasContacts || !contactsAllowed) e.preventDefault();
},
}}
/>
<Tab.Screen
name="Files"
component={FilesScreen}
getComponent={() => require('./src/screens/FilesScreen').default}
options={{
title: tabLabels.files,
tabBarButtonTestID: 'tab-files',
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)',
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
...(filesAllowed ? null : hiddenTab),
}}
listeners={{
tabPress: (e) => {
if (!hasFiles) e.preventDefault();
if (!hasFiles || !filesAllowed) e.preventDefault();
},
}}
/>
<Tab.Screen
name="Settings"
options={{
title: tabLabels.settings,
tabBarButtonTestID: 'tab-settings',
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
}}
>
@@ -212,6 +393,7 @@ export default function App() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const client = useAuthStore((state) => state.client);
const restoreSession = useAuthStore((state) => state.restoreSession);
const t = useLocaleStore((state) => state.t);
// Resolve the user's theme preference to a concrete light/dark style for the
// system status bar. The rest of the app's colors are still hard-coded dark
@@ -221,11 +403,19 @@ export default function App() {
const resolvedScheme: 'light' | 'dark' =
themePref === 'system' ? (systemScheme === 'light' ? 'light' : 'dark') : themePref;
const statusBarStyle: 'light' | 'dark' = resolvedScheme === 'light' ? 'dark' : 'light';
// React Navigation's default theme is light: without this its containers
// paint white behind and between screens, even in dark mode.
const background = useColors().background;
const navigationTheme = React.useMemo(() => {
const base = resolvedScheme === 'light' ? DefaultTheme : DarkTheme;
return { ...base, colors: { ...base.colors, background, card: background } };
}, [resolvedScheme, background]);
// Persisted active account is the signal that the user was already signed
// in on the previous launch. When present we render the main UI with the
// cached mail list instead of the "Restoring session" spinner; the real
// JMAP session comes up in the background.
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
const { isSplit } = useLayout();
React.useEffect(() => {
if (!hasRestoredSession) {
@@ -236,9 +426,33 @@ export default function App() {
React.useEffect(() => {
void useSettingsStore.getState().hydrate();
void useLocaleStore.getState().hydrate();
return useNetworkStore.getState().init();
// Calendar reminders are kept scheduled from launch, not only once the
// Calendar tab has been opened.
startCalendarNotificationSync();
// Device sync (Android, #34): live changes, the app's own edits and the
// foreground ask Android to sync; accounts are checked against Android's.
startDeviceSyncTriggers();
// The organisation policy of the active account's webmail: the cached
// one at once, a fresh one on start and on every account switch.
const stopPolicySync = startPolicySync();
// Settings > Security > Block screenshots.
const stopScreenPrivacy = startScreenPrivacySync();
const stopNetwork = useNetworkStore.getState().init();
return () => {
stopPolicySync();
stopScreenPrivacy();
stopNetwork?.();
};
}, []);
// Home-screen widgets follow the signed-in data; once the last account is
// gone they are wiped so no mail stays visible on the launcher.
React.useEffect(() => {
if (isAuthenticated) return startWidgetSync();
if (hasRestoredSession) void signOutWidgets();
return undefined;
}, [isAuthenticated, hasRestoredSession]);
// When the network flips back on while we're authenticated-but-offline
// (no live JMAP session), retry the session so the user lands back on
// live data without needing to relaunch.
@@ -273,6 +487,9 @@ export default function App() {
const haveLiveSession = useAuthStore((s) => s.session != null);
React.useEffect(() => {
void useOfflineCacheStore.getState().hydrate();
// Attachments shared out of the app linger in the cache dir; drop the
// ones older than a day so a granted content URI can't read them forever.
void sweepStaleExportFiles();
}, []);
React.useEffect(() => {
if (!offlineCacheEnabled || !haveLiveSession) return;
@@ -294,12 +511,21 @@ export default function App() {
});
}, [haveLiveSession]);
// Listen for FCM messages so the app can refresh state even when woken
// from the background - the FirebaseMessagingService also posts the system
// notification so the user sees it regardless of RN runtime state.
// Foreground FCM messages: the Kotlin service skips the headless task while
// the app is visible, so feed the relay's StateChange straight into the
// stores. SSE normally beats it, but this covers the window where the SSE
// socket is down and the poll fallback has not fired yet.
React.useEffect(() => {
const unsubscribe = addMessageListener((payload) => {
console.log('[push] fcm message', payload.title);
const raw = payload?.data?.changed;
if (!raw) return;
try {
const changed = JSON.parse(raw) as Record<string, Record<string, string>>;
if (!changed || typeof changed !== 'object') return;
void useEmailStore.getState().handleStateChange({ '@type': 'StateChange', changed });
} catch {
// malformed payload - ignore
}
});
return unsubscribe;
}, []);
@@ -328,17 +554,140 @@ export default function App() {
};
}, [isAuthenticated]);
// Calendar reminders are local expo notifications; their taps come through
// expo-notifications, not the native tap store used for mail push above.
React.useEffect(() => {
if (!isAuthenticated) return;
return startCalendarReminderTapHandling((target) => {
void openCalendarReminder(target);
});
}, [isAuthenticated]);
// Deep links and share-sheet payloads. The cold-start URL / share is read
// once auth is restored so the target screen has credentials.
React.useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
const open = (link: DeepLink | null) => {
if (!link || cancelled) return;
// Give the navigator a tick to mount after the auth gate flips.
setTimeout(() => { void openDeepLink(link); }, 50);
};
void Linking.getInitialURL().then((url) => open(url ? parseDeepLink(url) : null));
void getInitialShare().then((share) => {
if (!share) return;
const link = shareToDeepLink(share);
if (link.kind === 'compose' && share.uris?.length) {
navigationRef.isReady() && navigationRef.navigate('Compose', {
prefillTo: link.to,
prefillSubject: link.subject,
prefillBody: link.body,
prefillAttachments: shareAttachments(share),
});
return;
}
open(link);
});
const urlSub = Linking.addEventListener('url', ({ url }) => open(parseDeepLink(url)));
const shareSub = addShareListener((share) => {
const link = shareToDeepLink(share);
if (link.kind === 'compose' && share.uris?.length && navigationRef.isReady()) {
navigationRef.navigate('Compose', {
prefillTo: link.to,
prefillSubject: link.subject,
prefillBody: link.body,
prefillAttachments: shareAttachments(share),
});
return;
}
open(link);
});
return () => {
cancelled = true;
urlSub.remove();
shareSub();
};
}, [isAuthenticated]);
// Sign-in links (`bulwarkmail://pair?…` from the webmail's Link Mobile App,
// `bulwarkmail://connect?…`) work signed out too, so unlike the links above
// they are listened for from launch. They are parked for the login screen
// (the signed-out one takes them as it mounts), marked as coming from
// outside the app: any page or app can fire one, so the login screen asks
// before it redeems or opens anything.
React.useEffect(() => {
if (!initialSignInLinkRead) {
initialSignInLinkRead = true;
void Linking.getInitialURL().then((url) => { acceptSignInLink(url); }).catch(() => undefined);
}
const subscription = Linking.addEventListener('url', ({ url }) => { acceptSignInLink(url); });
return () => subscription.remove();
}, []);
// Signed in, a sign-in link adds an account: open Add account, whose login
// screen takes the parked link (or says why a refused one won't be used).
// On a cold start the navigator mounts right after the auth gate flips, so
// wait for it like a reminder tap does.
const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null || s.refusal !== null);
const addAccountForLink = isAuthenticated && signInLinkPending;
React.useEffect(() => {
if (!addAccountForLink) return;
let cancelled = false;
void (async () => {
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (cancelled) return;
}
if (cancelled || !navigationRef.isReady()) return;
const route = routeParkedSignInLink(useAccountStore.getState().accounts.length);
if (route === 'account-limit') {
// Said like the Add account buttons say it, instead of opening a
// screen whose sign-in can only fail.
const t = useLocaleStore.getState().t;
toast.error(
t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS }),
t('login.mobile.err_account_limit_detail', 'Remove an account in Settings to add another one.'),
);
return;
}
if (route === 'add-account') navigationRef.navigate('AddAccount');
})();
return () => { cancelled = true; };
}, [addAccountForLink]);
// The server refused the stored sign-in of an account (auth store
// keepForSignIn): open the sign-in to it, filled in, over what is cached.
const reauthTarget = useAuthStore((s) => s.reauth);
React.useEffect(() => {
if (!isAuthenticated || !reauthTarget) return;
let cancelled = false;
void (async () => {
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (cancelled) return;
}
if (cancelled || !navigationRef.isReady()) return;
if (navigationRef.getCurrentRoute()?.name === 'AddAccount') return;
navigationRef.navigate('AddAccount');
})();
return () => { cancelled = true; };
}, [isAuthenticated, reauthTarget]);
// Re-register the device with the configured relay once authenticated,
// and whenever the FCM token rotates. Honours the user's notification
// preference - flipping it off tears down THIS account's subscription so
// notifications stop arriving for it. Other logged-in accounts keep their
// setups intact.
// setups intact. An account the user turned push off for on this device
// stays off (see resyncPushNotifications).
const emailNotificationsEnabled = useSettingsStore(
(s) => s.emailNotificationsEnabled,
);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
React.useEffect(() => {
if (!isAuthenticated || !client) return;
// The setup asks the server through the client: it waits for the
// session, and runs again once one comes up (an account added or
// switched to, a cold start that began offline).
if (!isAuthenticated || !client || !haveLiveSession) return;
let cancelled = false;
const doSetup = async () => {
@@ -353,69 +702,192 @@ export default function App() {
const relayBaseUrl = await getStoredRelayBaseUrl();
if (!relayBaseUrl) return;
try {
await setupPushNotifications({
await resyncPushNotifications({
relayBaseUrl,
accountLabel: client.username ?? undefined,
});
if (cancelled) return;
} catch (error) {
console.warn(
'[push] relay setup failed:',
error instanceof Error ? error.message : error,
);
}
if (cancelled) return;
// The other signed-in accounts' subscriptions expire too (webmail #1054); each
// is renewed through a client of its own.
await renewOtherPushAccounts().catch(() => undefined);
};
void doSetup();
const unsubscribe = addTokenRefreshListener(() => {
void doSetup();
});
// UnifiedPush equivalent of an FCM token rotation: the distributor handed
// out a new endpoint, so re-register it with the relay.
const unsubscribeUp = addUnifiedPushEndpointListener(() => {
void doSetup();
});
return () => {
cancelled = true;
unsubscribe();
unsubscribeUp();
};
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId, haveLiveSession]);
// Muting a folder or account changes the server-side push filter (#78),
// which the setup above would only send at the next launch. Send it now,
// after a short pause so a few taps in a row make one update. Compared by
// content, since hydration hands back an equal but new object; the value
// seen first after hydration is the baseline, not a change.
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
// The Inbox-only switch changes the same filter (webmail #983).
const pushInboxOnly = useSettingsStore((s) => s.pushNotifyInboxOnly);
const settingsHydrated = useSettingsStore((s) => s.hydrated);
const mutesKey = JSON.stringify([notificationMutes, pushInboxOnly]);
const lastMutesKey = React.useRef<string | null>(null);
React.useEffect(() => {
if (!isAuthenticated || !client) {
if (!settingsHydrated) return;
if (lastMutesKey.current === null || lastMutesKey.current === mutesKey) {
lastMutesKey.current = mutesKey;
return;
}
lastMutesKey.current = mutesKey;
if (!isAuthenticated || !client || !emailNotificationsEnabled) return;
let cancelled = false;
const timer = setTimeout(async () => {
const relayBaseUrl = await getStoredRelayBaseUrl();
if (!relayBaseUrl || cancelled) return;
try {
await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined });
} catch (error) {
console.warn('[push] mute update failed:', error instanceof Error ? error.message : error);
}
}, MUTES_RESYNC_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
// Only a change of the mutes should send an update; the rest is read live.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mutesKey, settingsHydrated]);
// Live updates (SSE with polling fallback), re-armed on every account
// switch and every re-established session — the singleton `client` object
// never changes identity, so it cannot be the dependency on its own.
// Backgrounding closes the stream (no socket + server pings while the app
// is asleep); foregrounding reconnects it and refreshes what the user is
// looking at, since events that happened in between are gone for good.
React.useEffect(() => {
if (!isAuthenticated || !client || !haveLiveSession) {
return;
}
let mounted = true;
let cleanup: (() => void) | null = null;
let handle: LiveUpdatesHandle | null = null;
let appActive = AppState.currentState !== 'background' && AppState.currentState !== 'inactive';
void (async () => {
const onStateChange = async (change: StateChange) => {
// Filters / vacation edited elsewhere (webmail, another device): refetch
// so the settings panes don't save stale state over the newer script.
const primary = (() => { try { return jmapClient.accountId; } catch { return null; } })();
const own = primary ? change.changed?.[primary] : undefined;
const extra: Promise<unknown>[] = [];
if (own?.SieveScript) extra.push(useFilterStore.getState().fetchFilters().catch(() => undefined));
if (own?.VacationResponse) extra.push(useVacationStore.getState().fetch().catch(() => undefined));
dispatchStateChange(change);
await Promise.all([
useEmailStore.getState().handleStateChange(change),
useContactsStore.getState().handleStateChange(change),
useCalendarStore.getState().handleStateChange(change),
...extra,
]);
};
const start = async () => {
try {
const stopPushUpdates = await startPushUpdates(client, {
onStateChange: async (change) => {
await Promise.all([
useEmailStore.getState().handleStateChange(change),
useContactsStore.getState().handleStateChange(change),
useCalendarStore.getState().handleStateChange(change),
]);
},
onError: (error) => {
console.warn(error.message);
},
const next = await startLiveUpdates({
onStateChange: (change) => { void onStateChange(change); },
onError: (error) => { console.warn('[push]', error.message); },
onFallback: (reason) => { console.warn('[push] falling back to polling:', reason); },
isActive: () => appActive,
});
if (!mounted) {
stopPushUpdates();
next.close();
return;
}
cleanup = stopPushUpdates;
handle = next;
} catch (error) {
console.warn(error instanceof Error ? error.message : 'Failed to start JMAP push updates');
}
})();
};
const refreshAfterResume = () => {
const email = useEmailStore.getState();
void email.fetchMailboxes();
if (email.currentMailboxId) void email.refreshEmails();
void useOutboxStore.getState().flush();
void pruneEmailSubmissions();
};
// Foreground liveness for the per-account connection dot: the open
// stream's pings, or a `Core/echo` every 30 s while there is none (see
// startLivenessMonitor). NetInfo cannot tell an unreachable server.
const liveness = startLivenessMonitor({
ping: () => jmapClient.ping(),
streamHealthy: () => handle?.healthy ?? false,
isActive: () => appActive,
isOnline: () => useNetworkStore.getState().online,
onConnected: (connected) => {
if (!mounted || !activeAccountId) return;
const account = useAccountStore.getState().getAccountById(activeAccountId);
if (account && account.isConnected !== connected) {
useAccountStore.getState().updateAccount(activeAccountId, { isConnected: connected });
}
},
});
void start();
// Stalwart 1.0 stops accepting sends once an account holds
// `maxSubmissions` submission records, so remove the ones that can no
// longer be cancelled (throttled per account; a resume or a send runs it
// again).
void pruneEmailSubmissions();
const subscription = AppState.addEventListener('change', (state) => {
const nowActive = state === 'active';
if (nowActive === appActive) return;
appActive = nowActive;
if (!nowActive) {
handle?.close();
handle = null;
return;
}
// Coming back: reconnect and catch up on what was missed at once;
// one echo alongside checks the server for the connection dot.
if (!handle) void start();
else handle.reconnect();
refreshAfterResume();
void liveness.check();
});
// Reconnect when the network comes back while foregrounded.
const unsubscribeNetwork = useNetworkStore.subscribe((state, prev) => {
if (state.online && !prev.online && appActive) {
if (handle) handle.reconnect();
else void start();
}
});
return () => {
mounted = false;
cleanup?.();
subscription.remove();
unsubscribeNetwork();
liveness.stop();
handle?.close();
handle = null;
};
}, [client, isAuthenticated]);
}, [client, isAuthenticated, haveLiveSession, activeAccountId]);
// Skip the "Restoring session" flash for returning users: if we already
// have a persisted active account, render the main UI immediately with
@@ -425,7 +897,7 @@ export default function App() {
return (
<>
<StatusBar style={statusBarStyle} />
<LoadingScreen message="Loading..." />
<LoadingScreen message={t('common.loading', 'Loading...')} />
</>
);
}
@@ -440,32 +912,35 @@ export default function App() {
}
return (
<NavigationContainer ref={navigationRef}>
<NavigationContainer ref={navigationRef} theme={navigationTheme}>
<StatusBar style={statusBarStyle} />
<Stack.Navigator screenOptions={{ headerShown: false }}>
{/* On Fabric, native-stack keeps the screen right below the top live and
freezes the ones further down. */}
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
<Stack.Screen name="EmailSource" getComponent={() => require('./src/screens/EmailSourceScreen').default} />
<Stack.Screen
name="Compose"
component={ComposeScreen}
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
getComponent={() => require('./src/screens/ComposeScreen').default}
// A tablet shows the composer as a panel over the screen it came
// from, so that screen stays drawn behind the dimmed backdrop.
options={isSplit
? { presentation: 'transparentModal', animation: 'fade' }
: { presentation: 'modal', animation: 'slide_from_bottom' }}
/>
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
<Stack.Screen
name="ContactForm"
component={ContactFormScreen}
getComponent={() => require('./src/screens/ContactFormScreen').default}
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
<Stack.Screen name="Scheduled" getComponent={() => require('./src/screens/ScheduledScreen').default} />
<Stack.Screen name="UnifiedInbox" getComponent={() => require('./src/screens/UnifiedInboxScreen').default} />
<Stack.Screen
name="AddAccount"
options={{
+61
View File
@@ -1,3 +1,64 @@
Bulwark Mobile
Copyright (C) 2026 Bulwark Project Authors
This project is licensed under the GNU Affero General Public License,
version 3 only, with the additional permission for app store
distribution below. That permission is provisional; see the
reservation at its end.
Additional permission under GNU AGPL version 3 section 7
The copyright holders of this Program give you additional permission to
convey a covered work (this Program, a modified version of it, or a work
that contains parts of it) through an app store, even though the app
store's terms of service, usage rules or technical measures place
restrictions on recipients that section 10 of this License would otherwise
not allow, such as a limit on the number of devices a copy may be installed
on, or digital rights management applied to the copy.
This permission applies only if you:
a) make the Corresponding Source of each version you convey through the
app store available, at no charge and under the terms of this
License, from a network server that anyone can access;
b) state, in the app store listing or inside the application, where
that Corresponding Source can be obtained; and
c) do not impose any further restrictions of your own on the exercise
of the rights granted under this License.
"App store" means a digital distribution service for applications that is
operated by a third party, such as the Apple App Store and TestFlight,
Google Play, the Microsoft Store, the Samsung Galaxy Store or the Huawei
AppGallery.
This permission covers only the parts of a covered work that its copyright
holders license under this License; it does not extend to parts licensed
under other terms. As section 7 of this License allows, you may remove this
permission from copies you convey and from your modified versions.
Reservation
This additional permission has not been finalized. The contributors of
earlier parts of this Program have been asked whether they agree to it:
https://github.com/orgs/bulwarkmail/discussions/1113
Until that request is settled, the permission is granted subject to this
reservation:
- It applies to all contributions made on or after 30 September 2026.
These contributions are accepted under this License together with
this permission.
- It applies to parts of this Program written before that date only
where their copyright holders have agreed to it. Parts whose
copyright holders do not agree will be rewritten or removed.
This reservation will be removed once every earlier contributor has
agreed or their parts have been replaced.
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
+243
View File
@@ -0,0 +1,243 @@
# Webmail parity checklist
Audit of **Bulwark Mobile** (`bulwarkmail/native`, v0.1.58 @ `82b97fa`) against
**Bulwark Webmail** (`bulwarkmail/webmail`, v1.9.2 @ `39dcc6b2`), taken on
2026-08-29. The goal is feature *and* bugfix parity: every user-facing webmail
behaviour, and every webmail changelog fix since the native app was started
(1.4.5 → 1.9.2), was checked against the native code.
The audit was done by reading code on both sides, not by running the app. Every
finding cites `file:line` on both sides so it can be re-verified quickly; a
sample of the 32 P1 findings was additionally spot-checked by hand.
## How to use this document
- The detailed findings live in [docs/parity/](docs/parity/), one file per area.
Tick the `- [ ]` boxes there as you fix things; this file is the index.
- Each finding has a **priority** and a **category**:
| Priority | Meaning |
|---|---|
| `P1` | data loss, wrong send/delete, security, crash, or a core flow that is blocked |
| `P2` | notable feature missing or visibly wrong |
| `P3` | polish, nice-to-have, minor divergence |
| Category | Meaning |
|---|---|
| `missing` | webmail has it, native does not |
| `partial` | native has a reduced version |
| `bug` | native has it, but it is wrong |
| `bugfix-parity` | a bug the webmail fixed (changelog / issue number cited) that is still live in native |
| `rn-only-bug` | a bug that only exists in native (found while reading) |
- `WEB` paths are relative to the webmail repo, `RN` paths to this repo.
Issue numbers `#NNN` refer to the **webmail** tracker unless prefixed
"native #NN".
- Each area file ends with **Verified at parity** (do not redo) and
**N/A on mobile** (deliberately out of scope) lists.
- A few findings appear in two areas because they sit on a boundary (e.g.
"forward drops attachments" is in both viewer and composer). Fix once, tick
both.
## Baseline health of this repo (2026-08-29)
- [x] **`npm test` is red on `main`** — `P2` — `rn-only-bug` *(fixed in b84d4d8)*
- `src/stores/__tests__/auth-store.test.ts` fails to load with
`SyntaxError: Unexpected token 'typeof'`. The chain
`auth-store → email-store → outbox-store → network-store` imports
`@react-native-community/netinfo`, which `src/test-setup.ts` never mocks
(it mocks `react-native`, `expo-secure-store`, `expo-web-browser`,
AsyncStorage only). Broken since `5d47f47` (2026-05-30) when the outbox
import was added; `email-store.test.ts` only passes because it mocks
`../outbox-store` itself.
- Fix hint: add `vi.mock('@react-native-community/netinfo', () => ({ default: { addEventListener: vi.fn(() => () => {}), fetch: vi.fn(async () => ({ isConnected: true, isInternetReachable: true })) } }))`
to `src/test-setup.ts`. Expected afterwards: 31/31 suites (432+ tests).
- [x] **CI runs neither `tsc` nor `vitest`** — `P2` — `missing` *(fixed in fb4796e)*
- `.github/workflows/` only has `release-android.yml` and `release-ios.yml`.
Add a `ci.yml` running `npm ci && npm run typecheck && npm test` on push/PR.
- `npx tsc --noEmit` is clean.
- Uncommitted work in the tree at audit time: ascending/descending mail sort
(native #5) in `src/stores/email-store.ts`, `src/stores/settings-store.ts`,
`src/screens/EmailListScreen.tsx`, `src/stores/__tests__/email-store.test.ts`.
Findings that touch list ordering assume that lands.
- [x] **README is stale** — `P3` — it says "Filters & rules, S/MIME, plugins, *(fixed in 9ae91a6)*
themes, file storage – UI stubs only". Filters, Files and file sharing are
real (see areas 07); S/MIME, plugins, themes and sidebar apps are still
stubs (area 08).
## Status after the 2026-08-29 fix pass
Worked down the same day, area by area, by one orchestrating session plus one
agent per area, each change committed on its own (`type: short description`).
Per-area counts of ticked items are in the area files; every tick carries the
commit hash. The remaining `- [ ]` items are either deferred with a note
("deferred: …") or need work outside this repo (webmail hand-off `flow=oauth`
for TOTP accounts, relay APNs/UnifiedPush transports, a server-side settings
store for native #1).
## Status after the 2026-09-24 fix pass
The native audit of 2026-09-22 ([docs/audit-2026-09.md](docs/audit-2026-09.md))
spot-checked this list and found 14 ticks that were broken or overstated and 11
open items that were already done. The 2026-09-24 fix pass fixed 13 of the 14
(their ticks now also name the fixing commit; the date-locale item is open
again), dropped three obsolete items and ticked what it completed: 380 of 415
items are done, 35 open. What still needs a device check or a decision is in
the audit's "Fix pass, 2026-09-24" section.
## Areas
| # | Area | File | Items | Done | Open | P1 | P2 | P3 |
|---|---|---|---|---|---|---|---|---|
| 01 | Authentication, login, session, multi-account | [docs/parity/01-auth-accounts.md](docs/parity/01-auth-accounts.md) | 30 | 29 | 1 | 4 | 8 | 18 |
| 02 | Mail list, folders, unified views, search, tags | [docs/parity/02-mail-list-folders.md](docs/parity/02-mail-list-folders.md) | 54 | 50 | 4 | 1 | 20 | 33 |
| 03 | Email viewer, thread view, rendering, attachments | [docs/parity/03-email-viewer.md](docs/parity/03-email-viewer.md) | 46 | 45 | 1 | 6 | 13 | 26 |
| 04 | Composer, drafts, sending, identities, templates, scheduled send | [docs/parity/04-composer-send.md](docs/parity/04-composer-send.md) | 51 | 46 | 5 | 5 | 14 | 32 |
| 05 | Calendar and tasks | [docs/parity/05-calendar.md](docs/parity/05-calendar.md) | 50 | 43 | 7 | 5 | 20 | 25 |
| 06 | Contacts and address books | [docs/parity/06-contacts.md](docs/parity/06-contacts.md) | 50 | 46 | 4 | 1 | 18 | 31 |
| 07 | Filters (Sieve), vacation responder, Files | [docs/parity/07-filters-vacation-files.md](docs/parity/07-filters-vacation-files.md) | 32 | 29 | 3 | 3 | 8 | 21 |
| 08 | Settings, sync, push, i18n, themes, updates, misc UI | [docs/parity/08-settings-push-i18n-ui.md](docs/parity/08-settings-push-i18n-ui.md) | 46 | 38 | 8 | 0 | 14 | 32 |
| 09 | JMAP client core, live sync, offline, security, S/MIME | [docs/parity/09-jmap-core-sync-security.md](docs/parity/09-jmap-core-sync-security.md) | 56 | 54 | 2 | 7 | 23 | 26 |
| 10 | Webmail 1.10.0 to 1.18.0 and Unreleased (all areas) | [docs/parity/10-webmail-1.10-to-1.18.md](docs/parity/10-webmail-1.10-to-1.18.md) | 112 | 52 | 60 | 10 | 40 | 62 |
| | **Total** | | **527** | **432** | **95** | **42** | **178** | **306** |
Counts are of the `- [ ]` and `- [x]` items per file as of 2026-09-24 (row 10:
2026-10-29). Done and
Open split them by tick; the P columns count the priority tags on those items
(one item carries none).
## All P1 findings (fix these first)
### Data loss / wrong send
- [x] No draft autosave, no save-on-close, and the OS back gesture bypasses the discard guard — closing a compose loses everything. → [04](docs/parity/04-composer-send.md) *(fixed in 5b72c4d)*
- [x] Cannot open or edit an existing draft (Drafts folder opens the read-only viewer). → [04](docs/parity/04-composer-send.md) *(fixed in 5b72c4d)*
- [x] Message is filed into **Sent before `EmailSubmission/set`**; a failed send leaves a never-sent copy in Sent and a retry duplicates it. → [04](docs/parity/04-composer-send.md) *(fixed in ccbe67c)*
- [x] `In-Reply-To` / `References` are written with the **JMAP email id** instead of the RFC Message-ID (`messageId`/`references` are never even fetched) — every reply breaks threading in recipients' clients. → [03](docs/parity/03-email-viewer.md), [04](docs/parity/04-composer-send.md) *(fixed in 7f956d6)*
- [x] Reply addressing: `Reply-To` ignored, the user's own address kept on reply-all, wrong recipients on self-sent replies (#703, 1.9.0). → [03](docs/parity/03-email-viewer.md) *(fixed in 7f956d6)*
- [x] **Forward drops every attachment.** → [03](docs/parity/03-email-viewer.md), [04](docs/parity/04-composer-send.md) *(fixed in 7f956d6)*
- [x] Sieve: RN generator has no `attachment` field and emits `header :contains "undefined" ""` for webmail-authored attachment rules — **any save on the phone corrupts the shared server script**. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
- [x] Sieve: multi-value conditions (`string[]`, webmail 1.7.3) make the whole script "opaque" on RN; "Reset to visual builder" then wipes all rules. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
- [x] `FilterRuleModal` is always mounted and never re-seeds: editing rule B shows and **saves rule A's fields under B's id**. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
- [x] Calendar recurrence scope dialog is a stub: "This event" / "This and following" act as "All events" — deleting one occurrence **destroys the series**. → [05](docs/parity/05-calendar.md) *(fixed in 161baf1)*
- [x] Editing an expanded occurrence rewrites the master's `start` to the occurrence date (series shifts, earlier occurrences vanish). → [05](docs/parity/05-calendar.md) *(fixed in 161baf1)*
- [x] All-day events grow by one day on every create-via-toggle and every re-save. → [05](docs/parity/05-calendar.md) *(fixed in 58a6f45)*
- [x] Events are created without `timeZone` (floating time). → [05](docs/parity/05-calendar.md) *(fixed in 58a6f45)*
- [x] Invitations are never sent: participants use the retired `sendTo`, no organizer participant, no `organizerCalendarAddress` (#500, #731). → [05](docs/parity/05-calendar.md) *(fixed in 5dc4070)*
- [x] vCard import writes dates as raw strings instead of RFC 9553 PartialDate (#224) — Stalwart rejects the whole card, so any contact with a birthday is lost on import. → [06](docs/parity/06-contacts.md) *(fixed in e650cb2)*
### Core flow blocked / wrong content shown
- [x] TOTP / 2FA password login is impossible (402 is shown as a generic failure). → [01](docs/parity/01-auth-accounts.md) *(fixed in b52768e)*
- [x] Webmail password handoff breaks for TOTP-protected accounts (cross-repo: webmail should hand off `flow=oauth` after a TOTP-upgraded login). → [01](docs/parity/01-auth-accounts.md) *(fixed in 0d94d76)*
- [x] A failed "add account" resets the singleton client before connecting, killing the live session until relaunch. → [01](docs/parity/01-auth-accounts.md) *(fixed in b52768e)*
- [x] Account Security screen is dead against every Stalwart server: it checks `session.capabilities` but Stalwart advertises `urn:stalwart:jmap` only in `accountCapabilities` (verified on 0.16.19) — **native #47**. → [01](docs/parity/01-auth-accounts.md) *(fixed in 032732c)*
- [x] Thread view: only the newest message of a thread is reachable; older messages cannot be opened unless threading is disabled. → [03](docs/parity/03-email-viewer.md) *(fixed in d2ed27f)*
- [x] Pager shows another message's body (or nothing) when the opened id is not in the active folder page — unified inbox, group inboxes, contact activity. → [02](docs/parity/02-mail-list-folders.md), [03](docs/parity/03-email-viewer.md) *(fixed in c8be383)*
- [x] HTML-only messages are rendered — and quoted in replies — as raw HTML source (**native #46**). `<style>` stripping and an old dark-mode CSS revision are the likely cause of **native #49**. → [03](docs/parity/03-email-viewer.md) *(fixed in 06742ef)*
### JMAP client, live sync, security
- [x] No request deadline on any fetch — a stalled socket (iOS background suspension, dead pooled connection) hangs send/save forever (#702). → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in 0b1c240)*
- [x] Most `src/api/*.ts` functions index `methodResponses[0][1]` blindly and never check `error` / `notUpdated` / `notDestroyed` — a rejected mutation is reported as success and the outbox drops it. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in ccbe67c)*
- [x] Password change leaves the stored credential stale; the next launch evicts the account. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in 032732c)*
- [x] No `AppState` handling: SSE is never paused in background nor recovered on foreground, and `react-native-sse` never reconnects after a status-0 network error — push is silently dead after the first blip. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
- [x] SSE `Authorization` header is captured once; after an OAuth refresh every reconnect sends the stale token (401 → re-poll every 5 s forever). → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
- [x] Push effect is keyed on the singleton client, so the SSE stream stays bound to the previous account after `switchAccount`. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
- [x] Webmail password handoff sends the clear-text password in a custom-scheme redirect fragment that any app can register; OAuth `state` uses `Math.random`; `server_url`/`token_endpoint` in the callback are trusted as-is. → [09](docs/parity/09-jmap-core-sync-security.md), [01](docs/parity/01-auth-accounts.md) *(fixed in 2c0dbd1)*
### Repo health
- [x] `npm test` is red on `main` (see Baseline health above). *(fixed in b84d4d8)*
## Native issues mapped to findings
| Native issue | Where it is explained |
|---|---|
| #49 Unreadable text in dark-mode emails | [03](docs/parity/03-email-viewer.md) `<style>` stripping + stale dark-mode CSS |
| #48 / #44 UnifiedPush / non-FCM push | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
| #47 Account security not accessible | [01](docs/parity/01-auth-accounts.md) capability map bug (P1) |
| #46 email preview shows css | [03](docs/parity/03-email-viewer.md) HTML-only body detection (P1) |
| #45 error when re-registering to relay | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
| #34 Native contacts/calendar sync adapters | done on Android: Settings → Contacts / Calendar → "Sync to this device" syncs both ways with the device's Contacts and Calendar apps ([design](docs/device-sync.md)); iOS has no API for it |
| #5 Sort ascending/descending | in progress in the working tree; ordering presets in [02](docs/parity/02-mail-list-folders.md) |
| #3 TLS client auth | Android-only today; gaps in [01](docs/parity/01-auth-accounts.md) and [09](docs/parity/09-jmap-core-sync-security.md) |
| #1 Shared settings between webmail and native | [08](docs/parity/08-settings-push-i18n-ui.md) — webmail sync is server-side/cookie-bound; a JMAP-blob design is sketched there |
## Cross-cutting themes
These come up in nearly every area; fixing them centrally pays off more than
per-screen patches.
1. **Email headers are never fetched.** `EMAIL_FULL_PROPERTIES` lacks
`messageId`, `inReplyTo`, `references`, `headers`, `replyTo` usage. This
single gap causes the broken threading headers, missing Reply-To handling,
no SPF/DKIM/DMARC chips, no List-Unsubscribe, no read receipts, no details
panel. (03, 04)
2. **No thread model.** The list collapses threads but the viewer is a
single-message pager over the folder page. (02, 03)
3. **Hard-coded English.** RN has an i18n layer and 15 vendored locales, but
84 of 94 screen/component files never call `t()`; calendar, files,
vacation, scheduled, login and every settings pane except Language and
Filters are English-only. Catalogs are ~6 weeks behind webmail (640 missing
keys), and there is no interpolation, plurals or RTL. (all areas, 08)
4. **Single-account assumptions.** Contacts, calendars (subscriptions), filters,
vacation and Files are primary-account only; shared/group accounts work
for mail folders but not elsewhere. (01, 05, 06, 07)
5. **Webmail bugfixes that are one-line ports:** `$junk`/`$notjunk` flip on
spam (#850), search defaults to all folders (#788), folder substring dedup
(#771), permanent-delete confirmation, `$pinned` vs `$important`, FileNode
`modified` vs `updated` (#700), percent-decoding WebDAV names (#869),
RSCALE/SKIP=OMIT removal (#805), first-touch gate for lazy default
calendar/address book creation (#907), `supported-calendar-component-set`
pinning (#760), request timeouts (#702), relative session URL resolution.
6. **Settings that do nothing.** 16–17 persisted keys are never read outside
the settings UI (layout, sound, calendar notifications, sidebar apps,
S/MIME, plugins, theme id, files thumbnails, …). Either wire them or remove
the toggles. (08, table in that file)
7. **Deep links / intents.** `bulwarkmobile://` is registered but nothing
handles it; no `mailto:` or share-to-app intent filters. (04, 08)
8. **Push.** Subscribes to `Email`+`Mailbox` instead of `EmailDelivery`, no
`emailPush` spam filter, ignores relay-forwarded ids and guesses "newest
unread", never matches the payload to an account, no device list/revoke,
Android/FCM only. (08)
9. **JMAP client robustness.** `src/api/jmap-client.ts` is a thin transport
with none of the layers webmail added since 1.4.5: request deadlines,
`maxConcurrentRequests` back-off, transient retry, rate-limit pause,
`maxObjectsInSet` / `maxSizeUpload` chunking, keep-alive monitoring,
first-touch serialisation, and — most importantly — method-error checking.
A shared `requireMethodResult` / `assertSetResult` helper used by every
`src/api/*.ts` function fixes a whole class of silent failures. (09)
10. **Live sync lifecycle.** No `AppState` or connectivity hooks around the
EventSource; reconnect, token refresh, account switch and client-cert
users all leave the stream dead or stale. (09)
## Suggested order of work
1. Repo health: fix `test-setup.ts`, add CI. Half a day.
2. The P1 list above, roughly in the order given (composer/send correctness,
Sieve corruption, calendar write path, vCard dates, auth blockers, viewer,
JMAP client + SSE lifecycle).
3. Cross-cutting themes 1, 2 and 9 (fetch headers, thread view, method-error
checking + request deadline) — they unlock a large slice of the P2 items in
03/04/09 and make the outbox trustworthy.
4. P2 items per area, starting with 02 (spam keywords, search scope, unified
inbox actions/paging, tags in rows) and 08 (push correctness, i18n
catalogs + `t()` sweep).
5. P3 items opportunistically while touching the same files.
## Explicitly out of scope on mobile
Collected from the per-area N/A lists so nobody re-litigates them: keyboard
shortcuts, drag & drop, three-pane / Pro split-screen shell, browser
back/forward and permalinks in the address bar, PWA install / service worker,
print, guided tour, CSP nonces / server-side SSRF guards, admin dashboard and
setup wizard, plugin sandbox and marketplace (a minimal "sidebar apps in a
WebView" subset is described in 08), impersonation, TOTP re-auth dialog for
cookie sessions, server-side telemetry, OpenGraph cards. Where a webmail
feature is server-mediated (settings sync, iCal fetch proxy, TOTP token
exchange, push relay list) the area file says what the native equivalent
would be.
## Keeping this current
Re-run the audit after each webmail release: read the new changelog "Fixes"
bullets, decide `RN affected / not affected / N/A`, and append to the matching
area file. When a finding is fixed, tick it and add the native commit hash in
the same line so the next audit can skip it.
+16 -9
View File
@@ -1,9 +1,9 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture>
</div>
@@ -14,6 +14,11 @@
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
## Try it
- **iPhone and iPad:** join the beta on TestFlight: https://testflight.apple.com/join/rumcjNSp. A new release shows up there once Apple has reviewed it, usually within a day.
- **Android:** download the APK from the [latest release](https://github.com/bulwarkmail/native/releases/latest).
## What works today
- Sign in to any JMAP server (e.g. Stalwart)
@@ -21,15 +26,16 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
- Email list, threads, compose
- Calendar (basic)
- Contacts (basic)
- Push notifications via FCM relay
- Push notifications via the Bulwark relay - FCM by default, or [UnifiedPush](https://unifiedpush.org) (e.g. ntfy) for devices without Google Play services
- Android: contacts and calendars sync both ways with the phone's Contacts and Calendar apps (Settings → Contacts / Calendar → Sync to this device)
- In-app sideload updates from GitHub Releases
## What's missing or rough
- iOS build is untested
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
- iOS builds, but push notifications, client certificates and syncing with the phone's contacts and calendars are Android-only so far
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
- Calendar editing is partial; contacts editing is basic
- No Play Store distribution yet (sideload APK from Releases)
- No Play Store or App Store listing yet: Android is a sideloaded APK, iOS a TestFlight beta (see [Try it](#try-it))
## Run locally
@@ -40,8 +46,9 @@ npx expo start
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
For release APK builds and signing see [docs/android-release.md](docs/android-release.md).
For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-release.md).
## License
AGPL-3.0-only. See [LICENSE](LICENSE).
AGPL-3.0-only, with an additional permission to distribute the app through app stores such as the Apple App Store and Google Play. See [LICENSE](LICENSE). Contributions are accepted under the same terms. The permission is provisional until every earlier contributor has agreed to it ([consent request](https://github.com/orgs/bulwarkmail/discussions/1113)); contributions made since 30 September 2026 are already covered.
+1 -1
View File
@@ -1 +1 @@
0.1.47
0.1.87
+155 -10
View File
@@ -7,6 +7,62 @@ def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
def appVersionName = file("${projectRoot}/VERSION").text.trim()
/**
* versionCode has to grow with every release, or Android refuses the update as
* a downgrade. It is derived from VERSION as
*
* major * 10000000 + minor * 10000 + patch (0.1.62 -> 10062)
*
* which keeps increasing as long as patch stays below 10000 and minor below
* 1000 (scripts/bump-version.mjs, which only ever bumps patch, enforces both),
* and stays under Android's 2100000000 ceiling up to major 209.
*/
def versionParts = (appVersionName =~ /^(\d+)\.(\d+)\.(\d+)$/)
if (!versionParts.matches()) {
throw new GradleException("VERSION must be MAJOR.MINOR.PATCH, got '${appVersionName}'")
}
def (versionMajor, versionMinor, versionPatch) = (1..3).collect { versionParts.group(it) as long }
if (versionMinor > 999 || versionPatch > 9999) {
throw new GradleException("VERSION ${appVersionName}: minor must stay below 1000 and patch below 10000, " +
"or versionCode stops increasing. Move to the next minor or major version instead.")
}
def appVersionCode = versionMajor * 10000000L + versionMinor * 10000L + versionPatch
if (appVersionCode < 1 || appVersionCode > 2100000000L) {
throw new GradleException("VERSION ${appVersionName} gives versionCode ${appVersionCode}, " +
"outside the 1..2100000000 range Android accepts.")
}
/**
* Release signing. The key never lives in the repo: CI passes it as
* ORG_GRADLE_PROJECT_BULWARK_RELEASE_* environment variables, and a local
* release build can set the same names with -P, in ~/.gradle/gradle.properties
* or as plain environment variables. See docs/android-release.md.
*
* There is deliberately no automatic fallback to the debug key. It is the
* public React Native template key, so an APK signed with it can be replaced
* by an "update" anyone can sign.
*/
def releaseSigningNames = [
'BULWARK_RELEASE_STORE_FILE',
'BULWARK_RELEASE_STORE_PASSWORD',
'BULWARK_RELEASE_KEY_ALIAS',
'BULWARK_RELEASE_KEY_PASSWORD',
]
def releaseSigning = releaseSigningNames.collectEntries { name ->
[(name): (findProperty(name) ?: System.getenv(name))?.toString()]
}
def missingReleaseSigning = releaseSigningNames.findAll { !releaseSigning[it]?.trim() }
def releaseSigningConfigured = missingReleaseSigning.isEmpty()
def releaseStoreFile = releaseSigningConfigured ? file(releaseSigning.BULWARK_RELEASE_STORE_FILE.trim()) : null
// Escape hatch for a local release build that never leaves the machine
// (profiling, checking R8). It is refused whenever CI is set, so a stray flag
// cannot turn a published build back into a debug-signed one.
def ciEnv = System.getenv('CI')?.trim()?.toLowerCase()
def onCi = ciEnv && !(ciEnv in ['false', '0'])
def allowDebugSignedRelease = (findProperty('allowDebugSignedRelease') ?: 'false').toString().trim().toBoolean()
def debugSignedRelease = !releaseSigningConfigured && allowDebugSignedRelease && !onCi
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
@@ -34,8 +90,8 @@ react {
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
// The distribution flavors (github, play) need their debug variants listed.
debuggableVariants = ["githubDebug", "playDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
@@ -95,11 +151,42 @@ android {
applicationId 'com.anonymous.bulwarkmobile'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionCode appVersionCode.toInteger()
versionName appVersionName
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
/**
* Where the build is distributed. The two are separate apps on a phone:
*
* - github: the APK on GitHub Releases, sideloaded, updated by the in-app
* updater. Keeps the original package name so existing installs update.
* - play: the Google Play bundle. Play forbids an app updating itself
* outside Play, so this flavor has no installer permission and the JS
* hides the updater (BuildConfig.DISTRIBUTION, lib/platform-capabilities).
*
* See docs/android-release.md.
*/
flavorDimensions = ["distribution"]
productFlavors {
github {
dimension "distribution"
applicationId 'com.anonymous.bulwarkmobile'
buildConfigField "String", "DISTRIBUTION", '"github"'
}
play {
dimension "distribution"
applicationId 'org.bulwarkmail.mobile'
buildConfigField "String", "DISTRIBUTION", '"play"'
}
}
applicationVariants.all { variant ->
// Android account type of device sync (#34), derived from the package
// name of each flavor. The XML in res/xml and the Kotlin in sync/ read
// it as @string/device_sync_account_type.
variant.resValue "string", "device_sync_account_type", "${variant.applicationId}.account"
}
signingConfigs {
debug {
storeFile file('debug.keystore')
@@ -107,13 +194,12 @@ android {
keyAlias 'androiddebugkey'
keyPassword 'android'
}
if (project.hasProperty('BULWARK_RELEASE_STORE_FILE')
&& project.property('BULWARK_RELEASE_STORE_FILE')?.trim()) {
if (releaseSigningConfigured) {
release {
storeFile file(project.property('BULWARK_RELEASE_STORE_FILE'))
storePassword project.property('BULWARK_RELEASE_STORE_PASSWORD')
keyAlias project.property('BULWARK_RELEASE_KEY_ALIAS')
keyPassword project.property('BULWARK_RELEASE_KEY_PASSWORD')
storeFile releaseStoreFile
storePassword releaseSigning.BULWARK_RELEASE_STORE_PASSWORD
keyAlias releaseSigning.BULWARK_RELEASE_KEY_ALIAS
keyPassword releaseSigning.BULWARK_RELEASE_KEY_PASSWORD
}
}
}
@@ -122,7 +208,13 @@ android {
signingConfig signingConfigs.debug
}
release {
signingConfig signingConfigs.findByName('release') ?: signingConfigs.debug
// With neither a key nor the opt-in there is no signing config at
// all; checkReleaseSigning below stops the build before packaging.
if (releaseSigningConfigured) {
signingConfig signingConfigs.release
} else if (debugSignedRelease) {
signingConfig signingConfigs.debug
}
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
@@ -142,6 +234,48 @@ android {
}
}
// Stop a release build that has no real key. This runs only when a release
// variant is actually built, so `expo run:android`, assembleDebug and IDE sync
// keep working with no key configured.
def releaseSigningHelp = [
"Release signing is not configured (missing: ${missingReleaseSigning.join(', ')}).",
"",
"Release builds are never signed with the debug key: it is the public React Native",
"template key, so an APK signed with it can be replaced by anyone. Provide the release",
"key as gradle properties (-P or ~/.gradle/gradle.properties) or environment variables:",
" BULWARK_RELEASE_STORE_FILE path to the keystore",
" BULWARK_RELEASE_STORE_PASSWORD",
" BULWARK_RELEASE_KEY_ALIAS",
" BULWARK_RELEASE_KEY_PASSWORD",
"",
onCi && allowDebugSignedRelease
? "allowDebugSignedRelease is ignored because CI is set: published builds must use the release key."
: "For a local test build that is never installed anywhere else, pass -PallowDebugSignedRelease=true.",
"See docs/android-release.md.",
].join('\n')
def checkReleaseSigning = tasks.register('checkReleaseSigning') {
group = 'verification'
description = 'Fails a release build that has no release signing key.'
doLast {
if (releaseSigningConfigured) {
if (!releaseStoreFile.isFile()) {
throw new GradleException("BULWARK_RELEASE_STORE_FILE points to ${releaseStoreFile}, which does not exist.")
}
} else if (debugSignedRelease) {
logger.warn("WARNING: this release build is signed with the PUBLIC debug key " +
"(allowDebugSignedRelease). Do not distribute it.")
} else {
throw new GradleException(releaseSigningHelp)
}
}
}
tasks.configureEach { task ->
if (task.name ==~ /pre\w*ReleaseBuild/) {
task.dependsOn(checkReleaseSigning)
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
@@ -171,6 +305,17 @@ dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
// UnifiedPush - Google-free push via a user-chosen distributor app such
// as ntfy. Coexists with FCM; the user picks the transport in settings.
// NOTE: 3.3.2-3.3.4 are built with Kotlin 2.3 and break this project's
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
implementation("org.unifiedpush.android:connector:3.3.5")
// JVM unit tests for the logic in the Kotlin shell of device sync (src/test).
// android.jar only stubs org.json, so the tests use the real one.
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
+8 -8
View File
@@ -1,13 +1,13 @@
{
"project_info": {
"project_number": "504486613721",
"project_id": "bulwark-mobile",
"storage_bucket": "bulwark-mobile.firebasestorage.app"
"project_number": "752109678859",
"project_id": "bulwark-20c72",
"storage_bucket": "bulwark-20c72.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8",
"mobilesdk_app_id": "1:752109678859:android:952e8373ad508e780cf20e",
"android_client_info": {
"package_name": "com.anonymous.bulwarkmobile"
}
@@ -15,7 +15,7 @@
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
"current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
}
],
"services": {
@@ -26,15 +26,15 @@
},
{
"client_info": {
"mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8",
"mobilesdk_app_id": "1:752109678859:android:2692d26569e5d9950cf20e",
"android_client_info": {
"package_name": "com.bulwark.bulwarkmobile"
"package_name": "org.bulwarkmail.mobile"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
"current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
}
],
"services": {
+9
View File
@@ -12,3 +12,12 @@
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:
# The app's own native code (com.anonymous.bulwarkmobile). What R8 must not
# drop or rename there is already covered elsewhere: React Native's rules keep
# every NativeModule, whose @ReactMethod methods JS calls by name, and AAPT
# keeps the application, activity and services listed in AndroidManifest.xml
# (FCM, UnifiedPush and the headless JS task). Keeping the package whole on
# top of that costs a few KB and leaves its class and method names readable
# in a crash report from an obfuscated release build.
-keep class com.anonymous.bulwarkmobile.** { *; }
+4 -1
View File
@@ -3,5 +3,8 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic">
<!-- adb hooks for the device sync tests (#34). DUMP: only the shell may send them. -->
<receiver android:name=".sync.DeviceSyncDebugReceiver" android:exported="true" android:permission="android.permission.DUMP" />
</application>
</manifest>
@@ -0,0 +1,108 @@
package com.anonymous.bulwarkmobile.sync
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Base64
import android.util.Log
import java.io.File
import kotlin.concurrent.thread
/**
* Debug builds only: lets adb set up device sync accounts and drive the
* provider bridge without the settings UI or JS, for the device tests in
* docs/device-sync.md ("Testing"). Protected by android.permission.DUMP,
* which only the shell and the system hold. Syncs themselves are triggered
* with `adb shell requestsync`.
*
* adb shell am broadcast -n com.anonymous.bulwarkmobile/.sync.DeviceSyncDebugReceiver \
* --es cmd ensure --es name usera@example.org --es registry usera@example.org@10.0.2.2 \
* --es enable contacts,calendar
*
* Commands: `ensure` (name, registry, optional enable), `enable` (name,
* authority, optional --ez on false), `remove` (name), `list`, and the
* provider calls `query` and `batch` (name, authority, and the
* `ProviderQuery`/`ProviderOp[]` JSON base64-encoded in `b64`, which spares
* the shell's quoting, or pushed to the app's external files directory and
* named by `file` when it is too long for a command line), `syncstate`
* (name, authority) and `photo` (name, `--el id`, optional `--ei max`); and
* the sync-problem notification, `problem` (name, title, text, uri, optional
* channel) and `clearproblem` (name). Results go to logcat (tag
* BulwarkDeviceSync) and the broadcast's result data.
*/
class DeviceSyncDebugReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
// Provider calls block; keep them off the main thread.
thread(name = "DeviceSyncDebugReceiver") {
val result = try {
run(context, intent)
} catch (e: Exception) {
"error: ${e.javaClass.simpleName}: ${e.message}"
}
Log.i(TAG, "debug command: $result")
pending.resultData = result
pending.finish()
}
}
private fun run(context: Context, intent: Intent): String {
val name = intent.getStringExtra("name")
return when (intent.getStringExtra("cmd")) {
"ensure" -> {
val created = DeviceSyncAccounts.ensure(context, name!!, intent.getStringExtra("registry")!!)
for (which in intent.getStringExtra("enable").orEmpty().split(',').filter { it.isNotBlank() }) {
DeviceSyncAccounts.setSyncEnabled(context, name, authority(which), true)
}
"created=$created"
}
"enable" -> {
val on = intent.getBooleanExtra("on", true)
DeviceSyncAccounts.setSyncEnabled(context, name!!, authority(intent.getStringExtra("authority")!!), on)
"enabled=$on"
}
"remove" -> "removed=${DeviceSyncAccounts.remove(context, name!!)}"
"list" -> DeviceSyncAccounts.list(context).joinToString(";") { (account, registryId) -> "${account.name}=$registryId" }
"query" -> io(context, intent) { it.query(json(context, intent)) }
"batch" -> io(context, intent) { it.applyBatch(json(context, intent)) }
"syncstate" -> io(context, intent) { it.readSyncState().toString() }
"photo" -> ProviderIo(context, name!!, DeviceSyncAccounts.CONTACTS_AUTHORITY).use {
it.readPhoto(intent.getLongExtra("id", -1), intent.getIntExtra("max", 512)).toString()
}
"problem" -> {
SyncProblemNotifier.show(
context,
name!!,
intent.getStringExtra("title").orEmpty(),
intent.getStringExtra("text").orEmpty(),
intent.getStringExtra("uri").orEmpty(),
intent.getStringExtra("channel"),
)
"shown"
}
"clearproblem" -> {
SyncProblemNotifier.clear(context, name!!)
"cleared"
}
else -> "unknown cmd"
}
}
private fun io(context: Context, intent: Intent, call: (ProviderIo) -> String): String =
ProviderIo(context, intent.getStringExtra("name")!!, authority(intent.getStringExtra("authority")!!)).use(call)
private fun json(context: Context, intent: Intent): String {
intent.getStringExtra("file")?.let { return File(context.getExternalFilesDir(null), it).readText() }
return String(Base64.decode(intent.getStringExtra("b64")!!, Base64.DEFAULT), Charsets.UTF_8)
}
private fun authority(which: String) = when (which) {
"contacts" -> DeviceSyncAccounts.CONTACTS_AUTHORITY
"calendar" -> DeviceSyncAccounts.CALENDAR_AUTHORITY
else -> which
}
companion object {
private const val TAG = "BulwarkDeviceSync"
}
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug builds load the bundle from Metro over http, which the main config
forbids (a network security config replaces the manifest's
usesCleartextTraffic). Same trust anchors as the release config.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The in-app updater hands the downloaded release APK to the system
installer (src/lib/install-update.ts). Only the sideloaded GitHub build
updates itself; the Play build is updated by Play. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
</manifest>
@@ -0,0 +1,48 @@
{
"project_info": {
"project_number": "504486613721",
"project_id": "bulwark-mobile",
"storage_bucket": "bulwark-mobile.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8",
"android_client_info": {
"package_name": "com.anonymous.bulwarkmobile"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8",
"android_client_info": {
"package_name": "com.bulwark.bulwarkmobile"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+314 -4
View File
@@ -2,10 +2,25 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<!-- REQUEST_INSTALL_PACKAGES (in-app updater) is declared by the github flavor only. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<!-- Photos are picked with the system photo picker, which needs no media
permission; Play's photo and video policy rejects READ_MEDIA_IMAGES for that. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<!-- expo-camera declares the microphone for video; QR scanning never records audio. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove"/>
<!-- expo-screen-capture registers its screenshot listener on Android 14+ as soon
as it loads; without this permission the app crashes at launch. -->
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
@@ -13,11 +28,11 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules" android:networkSecurityConfig="@xml/network_security_config">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
@@ -28,6 +43,44 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmobile"/>
</intent-filter>
<!-- Sign-in links from the webmail (bulwarkmail://pair?..., bulwarkmail://connect?...). -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmail"/>
</intent-filter>
<!-- Browser sign-in callback of the client the app registers at Stalwart 1.0 (org.bulwarkmail.mobile:/auth/callback). -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="org.bulwarkmail.mobile"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<action android:name="android.intent.action.SENDTO"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="mailto"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/*"/>
<data android:mimeType="image/*"/>
<data android:mimeType="application/*"/>
<data android:mimeType="audio/*"/>
<data android:mimeType="video/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
<data android:mimeType="application/*"/>
<data android:mimeType="audio/*"/>
<data android:mimeType="video/*"/>
</intent-filter>
</activity>
<service android:name=".BulwarkMessagingService" android:exported="false">
<intent-filter>
@@ -35,6 +88,263 @@
</intent-filter>
</service>
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
<receiver android:name=".BulwarkNotificationActionReceiver" android:exported="false"/>
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
</intent-filter>
</service>
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
<!-- Device sync (#34), see docs/device-sync.md. The system binds these, so they are exported.
They run in the app's main process on purpose: the adapters start the sync engine as a
headless JS task in the same React Native runtime as the UI. -->
<service android:name=".sync.BulwarkAuthenticatorService" android:exported="true">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data android:name="android.accounts.AccountAuthenticator" android:resource="@xml/authenticator"/>
</service>
<service android:name=".sync.ContactsSyncService" android:exported="true">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_contacts"/>
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contacts"/>
</service>
<service android:name=".sync.CalendarSyncService" android:exported="true">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_calendar"/>
</service>
<!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
</receiver>
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
</receiver>
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
</receiver>
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
</receiver>
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
</receiver>
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
</receiver>
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
</receiver>
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
</receiver>
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
</receiver>
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
</receiver>
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
</receiver>
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
</receiver>
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
</receiver>
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
</receiver>
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
</receiver>
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
</receiver>
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
</receiver>
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
</receiver>
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
</receiver>
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
</receiver>
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
</receiver>
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
</receiver>
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
</receiver>
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
</receiver>
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
</receiver>
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
</receiver>
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
</receiver>
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
</receiver>
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
</receiver>
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
</receiver>
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
</receiver>
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
</receiver>
<!-- END WIDGETS -->
</application>
</manifest>
Binary file not shown.
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
The reading fonts in this folder (Settings > Accessibility > Font) are
licensed under the SIL Open Font License, Version 1.1, copied below.
AtkinsonHyperlegibleNext*.ttf:
Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors
(https://github.com/googlefonts/atkinson-hyperlegible-next)
Lexend*.ttf:
Copyright 2018 The Lexend Project Authors (https://github.com/googlefonts/lexend),
with Reserved Font Name "RevReading Lexend".
OpenDyslexic*.ttf:
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
with Reserved Font Name OpenDyslexic.
Copyright (c) 12/2012 - 2019
The files are built by scripts/build-reading-fonts.py from the woff2 files
the webmail bundles (from Fontsource, https://fontsource.org): the variable
Atkinson Hyperlegible Next and Lexend fonts as static regular and bold
instances, OpenDyslexic unchanged apart from the woff2 decompression.
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More