Compare commits

...
11 Commits
Author SHA1 Message Date
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
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
40 changed files with 1860 additions and 458 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.56
0.1.58
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.56",
"version": "0.1.58",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.56",
"version": "0.1.58",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.56",
"version": "0.1.58",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+99 -1
View File
@@ -5,12 +5,16 @@ vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getAccountName: vi.fn(() => 'me@example.com'),
getSharedMailAccounts: vi.fn(() => []),
getMaxCallsInRequest: vi.fn(() => 16),
},
}));
import { jmapClient } from '../jmap-client';
import {
getMailboxes,
getSharedMailboxes,
queryEmails,
getEmails,
getFullEmail,
@@ -41,13 +45,107 @@ describe('email operations', () => {
const result = await getMailboxes();
expect(result).toEqual(mailboxes);
// Own folders keep their raw ids and pick up ownership metadata.
expect(result).toEqual(mailboxes.map((m) => ({
...m,
accountId: 'acc-1',
accountName: 'me@example.com',
isShared: false,
})));
expect(mockRequest).toHaveBeenCalledWith(
[['Mailbox/get', { accountId: 'acc-1' }, '0']],
);
});
});
describe('getSharedMailboxes', () => {
const mockShared = jmapClient.getSharedMailAccounts as ReturnType<typeof vi.fn>;
it('returns nothing when the session has no shared accounts', async () => {
mockShared.mockReturnValue([]);
expect(await getSharedMailboxes()).toEqual([]);
expect(mockRequest).not.toHaveBeenCalled();
});
it('prefixes ids and parent links per owning account', async () => {
mockShared.mockReturnValue([{ id: 'grp-1', name: 'Support' }]);
mockRequest.mockResolvedValue({
methodResponses: [['Mailbox/get', {
list: [
{ id: 'mb-1', name: 'Inbox', role: 'inbox', totalEmails: 4, unreadEmails: 2 },
{ id: 'mb-2', name: 'Escalations', parentId: 'mb-1', totalEmails: 1, unreadEmails: 0 },
],
}, '0']],
});
const result = await getSharedMailboxes();
expect(mockRequest).toHaveBeenCalledWith(
[['Mailbox/get', { accountId: 'grp-1' }, '0']],
);
expect(result).toEqual([
{
id: 'grp-1:mb-1',
originalId: 'mb-1',
name: 'Inbox',
role: 'inbox',
parentId: undefined,
totalEmails: 4,
unreadEmails: 2,
accountId: 'grp-1',
accountName: 'Support',
isShared: true,
},
{
id: 'grp-1:mb-2',
originalId: 'mb-2',
name: 'Escalations',
parentId: 'grp-1:mb-1',
totalEmails: 1,
unreadEmails: 0,
accountId: 'grp-1',
accountName: 'Support',
isShared: true,
},
]);
});
it('maps each response back to its account and skips the ones that errored', async () => {
mockShared.mockReturnValue([
{ id: 'grp-1', name: 'Support' },
{ id: 'grp-2', name: 'Sales' },
]);
mockRequest.mockResolvedValue({
methodResponses: [
['error', { type: 'accountNotFound' }, '0'],
['Mailbox/get', { list: [{ id: 'mb-9', name: 'Inbox', role: 'inbox' }] }, '1'],
],
});
const result = await getSharedMailboxes();
expect(result.map((m) => m.id)).toEqual(['grp-2:mb-9']);
expect(result[0].accountName).toBe('Sales');
});
it('chunks requests to maxCallsInRequest', async () => {
mockShared.mockReturnValue([
{ id: 'grp-1', name: 'Support' },
{ id: 'grp-2', name: 'Sales' },
{ id: 'grp-3', name: 'Billing' },
]);
(jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>).mockReturnValue(2);
mockRequest.mockResolvedValue({ methodResponses: [] });
await getSharedMailboxes();
expect(mockRequest).toHaveBeenCalledTimes(2);
expect(mockRequest.mock.calls[0][0]).toHaveLength(2);
expect(mockRequest.mock.calls[1][0]).toHaveLength(1);
});
});
describe('queryEmails', () => {
it('should query emails with default options', async () => {
mockRequest.mockResolvedValue({
+6 -2
View File
@@ -67,13 +67,17 @@ export async function uploadBlob(
export async function uploadBytes(
bytes: Uint8Array,
type: string,
// Blobs are account-scoped: a message imported into a shared/group account's
// folder has to be uploaded to that account, not the user's own.
accountId?: string,
): Promise<{ blobId: string; size: number; type: string }> {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
const targetAccountId = accountId ?? jmapClient.accountId;
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(jmapClient.accountId),
encodeURIComponent(targetAccountId),
);
const response = await secureFetch(uploadUrl, {
@@ -99,7 +103,7 @@ export async function uploadBytes(
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
};
}
const nested = raw[jmapClient.accountId] as
const nested = raw[targetAccountId] as
| { blobId?: string; type?: string; size?: number }
| undefined;
if (nested?.blobId) {
+146 -44
View File
@@ -14,33 +14,112 @@ const EMAIL_FULL_PROPERTIES = [
'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt',
];
export async function getMailboxes(): Promise<Mailbox[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId }, '0']],
);
return res.methodResponses[0][1].list;
// Prefix a shared account's folder ids so they can't collide with the user's
// own. Matches the webmail's `${accountId}:${mailboxId}` scheme.
function sharedMailboxId(accountId: string, rawId: string): string {
return `${accountId}:${rawId}`;
}
/** Inverse of {@link sharedMailboxId}; a no-op for the user's own folders. */
export function unprefixMailboxId(id: string, accountId?: string): string {
if (!accountId) return id;
const prefix = `${accountId}:`;
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
}
// Stamp ownership onto a raw Mailbox/get result. Own folders keep their raw
// ids; a shared account's are prefixed and remember their `originalId` so
// server calls can still address them.
function tagMailbox(
mailbox: Mailbox,
accountId: string,
accountName: string | undefined,
isShared: boolean,
): Mailbox {
if (!isShared) {
return { ...mailbox, accountId, accountName, isShared: false };
}
return {
...mailbox,
id: sharedMailboxId(accountId, mailbox.id),
originalId: mailbox.id,
parentId: mailbox.parentId ? sharedMailboxId(accountId, mailbox.parentId) : mailbox.parentId,
accountId,
accountName,
isShared: true,
};
}
export async function getMailboxes(accountId?: string): Promise<Mailbox[]> {
return (await getMailboxesWithState(accountId)).list;
}
// Variant that also returns the JMAP `state` token so callers can later issue
// Mailbox/changes(sinceState=…) to fetch only what changed.
export async function getMailboxesWithState(): Promise<{ list: Mailbox[]; state: string }> {
const accountId = jmapClient.accountId;
export async function getMailboxesWithState(
accountIdOverride?: string,
): Promise<{ list: Mailbox[]; state: string }> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const isShared = accountId !== jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId }, '0']],
);
const body = res.methodResponses[0][1];
return { list: body.list as Mailbox[], state: body.state as string };
const list = ((body.list as Mailbox[]) ?? []).map((m) =>
tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared),
);
return { list, state: body.state as string };
}
export async function getMailboxesByIds(ids: string[]): Promise<{ list: Mailbox[]; state: string }> {
/**
* Every folder of every shared/group account in this session, ready to merge
* into the sidebar next to the user's own. One request carrying a Mailbox/get
* per account, chunked to the server's maxCallsInRequest. An account that
* errors (revoked access, server hiccup) is skipped rather than sinking the
* whole list.
*/
export async function getSharedMailboxes(): Promise<Mailbox[]> {
const accounts = jmapClient.getSharedMailAccounts();
if (accounts.length === 0) return [];
const perRequest = Math.max(1, jmapClient.getMaxCallsInRequest());
const out: Mailbox[] = [];
for (let i = 0; i < accounts.length; i += perRequest) {
const chunk = accounts.slice(i, i + perRequest);
const res = await jmapClient.request(
chunk.map((acc, idx): JMAPMethodCall => [
'Mailbox/get',
{ accountId: acc.id },
String(idx),
]),
);
for (const [name, body, callId] of res.methodResponses) {
if (name !== 'Mailbox/get') continue;
const acc = chunk[Number(callId)];
if (!acc) continue;
for (const m of (body.list as Mailbox[]) ?? []) {
out.push(tagMailbox(m, acc.id, acc.name, true));
}
}
}
return out;
}
export async function getMailboxesByIds(
ids: string[],
accountIdOverride?: string,
): Promise<{ list: Mailbox[]; state: string }> {
if (ids.length === 0) return { list: [], state: '' };
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const isShared = accountId !== jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId, ids }, '0']],
);
const body = res.methodResponses[0][1];
return { list: body.list as Mailbox[], state: body.state as string };
const list = ((body.list as Mailbox[]) ?? []).map((m) =>
tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared),
);
return { list, state: body.state as string };
}
export interface MailboxChangesResult {
@@ -54,8 +133,11 @@ export interface MailboxChangesResult {
// Returns null when the server can't compute the diff (typically
// `cannotCalculateChanges`); callers should fall back to a full Mailbox/get.
export async function getMailboxChanges(sinceState: string): Promise<MailboxChangesResult | null> {
const accountId = jmapClient.accountId;
export async function getMailboxChanges(
sinceState: string,
accountIdOverride?: string,
): Promise<MailboxChangesResult | null> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/changes', { accountId, sinceState }, '0']],
);
@@ -73,8 +155,9 @@ export async function getMailboxChanges(sinceState: string): Promise<MailboxChan
export async function createMailbox(
data: { name: string; parentId?: string | null },
accountIdOverride?: string,
): Promise<string> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const cid = 'new-mailbox';
const res = await jmapClient.request([
['Mailbox/set', {
@@ -102,8 +185,9 @@ export async function createMailbox(
export async function updateMailbox(
id: string,
changes: { name?: string; parentId?: string | null },
accountIdOverride?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Mailbox/set', { accountId, update: { [id]: changes } }, '0'],
]);
@@ -117,8 +201,8 @@ export async function updateMailbox(
}
}
export async function deleteMailbox(id: string): Promise<void> {
const accountId = jmapClient.accountId;
export async function deleteMailbox(id: string, accountIdOverride?: string): Promise<void> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Mailbox/set', { accountId, destroy: [id] }, '0'],
]);
@@ -155,9 +239,11 @@ export async function queryEmails(
limit?: number;
sort?: Array<{ property: string; isAscending: boolean }>;
filter?: Record<string, unknown>;
/** Owning JMAP account when the mailbox belongs to a shared account. */
accountId?: string;
},
): Promise<{ ids: string[]; total: number; queryState?: string }> {
const accountId = jmapClient.accountId;
const accountId = options?.accountId ?? jmapClient.accountId;
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const res = await jmapClient.request([
['Email/query', {
@@ -196,9 +282,11 @@ export async function getEmailQueryChanges(
filter?: Record<string, unknown>;
upToId?: string;
maxChanges?: number;
/** Owning JMAP account when the mailbox belongs to a shared account. */
accountId?: string;
},
): Promise<EmailQueryChangesResult | null> {
const accountId = jmapClient.accountId;
const accountId = options?.accountId ?? jmapClient.accountId;
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const args: Record<string, unknown> = {
accountId,
@@ -222,8 +310,8 @@ export async function getEmailQueryChanges(
};
}
export async function getEmails(ids: string[]): Promise<Email[]> {
const accountId = jmapClient.accountId;
export async function getEmails(ids: string[], accountIdOverride?: string): Promise<Email[]> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
@@ -233,18 +321,21 @@ export async function getEmails(ids: string[]): Promise<Email[]> {
// Returns the Email/get response with the JMAP `state` token. Used by the
// store so we can later issue Email/changes(sinceState=…) for incremental
// updates instead of re-fetching the full list.
export async function getEmailsWithState(ids: string[]): Promise<{ list: Email[]; state: string }> {
export async function getEmailsWithState(
ids: string[],
accountIdOverride?: string,
): Promise<{ list: Email[]; state: string }> {
if (ids.length === 0) {
// Email/get with an empty id list still returns a state token; useful for
// priming the store after an empty mailbox query.
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
const body = res.methodResponses[0][1];
return { list: [], state: body.state as string };
}
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
@@ -266,8 +357,9 @@ export interface EmailChangesResult {
export async function getEmailChanges(
sinceState: string,
maxChanges?: number,
accountIdOverride?: string,
): Promise<EmailChangesResult | null> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const args: Record<string, unknown> = { accountId, sinceState };
if (maxChanges) args.maxChanges = maxChanges;
const res = await jmapClient.request([['Email/changes', args, '0']]);
@@ -306,9 +398,9 @@ export async function getFullEmail(id: string, accountIdOverride?: string): Prom
// Batch variant for offline sync. JMAP servers cap how many objects can be
// returned in a single Email/get; the caller should chunk to that ceiling
// (the client exposes maxObjectsInGet via getMaxObjectsInGet()).
export async function getFullEmails(ids: string[]): Promise<Email[]> {
export async function getFullEmails(ids: string[], accountIdOverride?: string): Promise<Email[]> {
if (ids.length === 0) return [];
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', {
accountId,
@@ -332,8 +424,9 @@ export async function importEmailBlob(
blobId: string,
mailboxId: string,
keywords: Record<string, boolean> = { $seen: true },
accountIdOverride?: string,
): Promise<string> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/import', {
accountId,
@@ -352,8 +445,8 @@ export async function importEmailBlob(
return id;
}
export async function getThread(threadId: string): Promise<Thread> {
const accountId = jmapClient.accountId;
export async function getThread(threadId: string, accountIdOverride?: string): Promise<Thread> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request(
[['Thread/get', { accountId, ids: [threadId] }, '0']],
);
@@ -375,8 +468,9 @@ export async function moveEmail(
emailId: string,
fromMailboxId: string,
toMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
await jmapClient.request([
['Email/set', {
accountId,
@@ -395,9 +489,10 @@ export async function moveEmails(
ids: string[],
fromMailboxId: string,
toMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const update: Record<string, Record<string, unknown>> = {};
for (const id of ids) {
update[id] = {
@@ -413,22 +508,24 @@ export async function deleteEmails(
ids: string[],
trashMailboxId: string,
currentMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
if (ids.length === 0) return;
if (currentMailboxId === trashMailboxId) {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
} else {
await moveEmails(ids, currentMailboxId, trashMailboxId);
await moveEmails(ids, currentMailboxId, trashMailboxId, accountIdOverride);
}
}
// Apply keyword maps to several emails in one round-trip.
export async function setKeywordsForEmails(
updates: Array<{ id: string; keywords: Record<string, boolean> }>,
accountIdOverride?: string,
): Promise<void> {
if (updates.length === 0) return;
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const update: Record<string, { keywords: Record<string, boolean> }> = {};
for (const u of updates) update[u.id] = { keywords: u.keywords };
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
@@ -439,9 +536,10 @@ export async function setKeywordsForEmails(
// the entire map, so we don't need to compute a diff against the current state.
export async function restoreEmailMailboxes(
items: Array<{ id: string; mailboxIds: Record<string, boolean> }>,
accountIdOverride?: string,
): Promise<void> {
if (items.length === 0) return;
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const update: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const item of items) {
const onlyTrue: Record<string, true> = {};
@@ -462,15 +560,16 @@ export async function restoreEmailMailboxes(
export async function setEmailMailboxes(
emailId: string,
mailboxIds: Record<string, boolean>,
accountIdOverride?: string,
): Promise<void> {
await restoreEmailMailboxes([{ id: emailId, mailboxIds }]);
await restoreEmailMailboxes([{ id: emailId, mailboxIds }], accountIdOverride);
}
// Permanently destroy emails (no move-to-trash). Idempotent: destroying an
// already-gone id is a no-op on replay.
export async function destroyEmails(ids: string[]): Promise<void> {
export async function destroyEmails(ids: string[], accountIdOverride?: string): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
}
@@ -482,9 +581,10 @@ export async function archiveEmails(
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
accountIdOverride?: string,
): Promise<void> {
if (emails.length === 0) return;
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
if (mode === 'single') {
const updates = Object.fromEntries(
@@ -595,14 +695,15 @@ export async function deleteEmail(
emailId: string,
trashMailboxId: string,
currentMailboxId: string,
accountIdOverride?: string,
): Promise<void> {
if (currentMailboxId === trashMailboxId) {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
await jmapClient.request([
['Email/set', { accountId, destroy: [emailId] }, '0'],
]);
} else {
await moveEmail(emailId, currentMailboxId, trashMailboxId);
await moveEmail(emailId, currentMailboxId, trashMailboxId, accountIdOverride);
}
}
@@ -610,8 +711,9 @@ export async function searchEmails(
query: string,
mailboxId?: string,
limit = 30,
accountIdOverride?: string,
): Promise<string[]> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
const filter: Record<string, unknown> = { text: toWildcardQuery(query) };
if (mailboxId) filter.inMailbox = mailboxId;
+48 -4
View File
@@ -459,6 +459,43 @@ export class JMAPClient {
return core?.maxObjectsInGet || 500;
}
getMaxCallsInRequest(): number {
const core = this.session?.capabilities?.[CAPABILITIES.CORE] as
| { maxCallsInRequest?: number }
| undefined;
return core?.maxCallsInRequest || 16;
}
// ── Shared / group accounts ───────────────────────────
// A JMAP session lists every account the credentials can reach: the user's
// own, plus any shared/group accounts (Stalwart "group accounts") they are a
// member of. Non-personal accounts are the shared mailboxes.
/**
* Mail-capable accounts other than the primary one. Stalwart doesn't always
* populate `accountCapabilities` on shared accounts, so an account counts as
* mail-capable when it advertises the mail capability OR is non-personal —
* the same rule the webmail's getSharedAccounts() applies.
*/
getSharedMailAccounts(): { id: string; name: string }[] {
if (!this.session || !this._accountId) return [];
const primaryId = this._accountId;
const out: { id: string; name: string }[] = [];
for (const [id, info] of Object.entries(this.session.accounts ?? {})) {
if (id === primaryId) continue;
const advertisesMail = info.accountCapabilities
? CAPABILITIES.MAIL in info.accountCapabilities
: false;
if (!advertisesMail && info.isPersonal) continue;
out.push({ id, name: info.name || id });
}
return out;
}
getAccountName(accountId: string): string | undefined {
return this.session?.accounts?.[accountId]?.name;
}
// ── Scheduled send (FUTURERELEASE) ────────────────────
// The JMAP submission capability advertises `maxDelayedSend` (max hold in
// seconds) and a `submissionExtensions` map; FUTURERELEASE support is what
@@ -515,19 +552,26 @@ export class JMAPClient {
// ── Blob Download ─────────────────────────────────────
// Expand the RFC 6570 level-1 template the JMAP server advertises.
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
// `accountId` targets a shared/group account, whose blobs aren't reachable
// through the user's own account id.
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string {
if (!this.session?.downloadUrl) {
throw new Error('Download URL not available - not connected');
}
return this.session.downloadUrl
.replace('{accountId}', encodeURIComponent(this.accountId))
.replace('{accountId}', encodeURIComponent(accountId ?? this.accountId))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(name || 'download'))
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
}
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type);
async fetchBlobArrayBuffer(
blobId: string,
name?: string,
type?: string,
accountId?: string,
): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
await this.ensureFreshToken();
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } });
let response = await doFetch();
+14
View File
@@ -99,7 +99,15 @@ export interface MailboxRights {
}
export interface Mailbox {
/**
* Unique within the app. For the user's own folders this is the raw JMAP id;
* for a shared (group) account's folders it's `<accountId>:<rawId>` so ids
* from different accounts can't collide. Use `originalId` when talking to
* the server. Mirrors the webmail's scheme in [lib/jmap/client.ts].
*/
id: string;
/** Raw JMAP id — set only on shared folders, where `id` carries a prefix. */
originalId?: string;
name: string;
parentId?: string | null;
role?: string | null;
@@ -110,6 +118,12 @@ export interface Mailbox {
unreadThreads: number;
myRights: MailboxRights;
isSubscribed?: boolean;
/** JMAP account the folder lives under (primary or a shared/group account). */
accountId?: string;
/** Display name of the owning account, used as the sidebar group header. */
accountName?: string;
/** True when the folder belongs to a shared/group account, not the user. */
isShared?: boolean;
}
export interface Thread {
+9 -3
View File
@@ -27,6 +27,8 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
interface EmailBodyViewProps {
email: Email;
senderEmail?: string;
/** Owning account when the message lives in a shared/group mailbox. */
jmapAccountId?: string;
// The body is a native WebView, which on Android swallows horizontal touches
// before the surrounding pager can see them. We detect a clear horizontal
// swipe inside the page and report it here so the pager can change messages.
@@ -457,7 +459,9 @@ const PINCH_ZOOM = `
})();
`;
export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChange }: EmailBodyViewProps) {
export default function EmailBodyView({
email, senderEmail, jmapAccountId, onSwipe, onZoomChange,
}: EmailBodyViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
@@ -561,7 +565,9 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang
await Promise.all(
toFetch.map(async ({ ref, att }) => {
try {
const buf = await jmapClient.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
const buf = await jmapClient.fetchBlobArrayBuffer(
att.blobId, att.name, att.type, jmapAccountId,
);
if (cancelled) return;
const b64 = arrayBufferToBase64(buf);
next[ref] = `data:${att.type || 'application/octet-stream'};base64,${b64}`;
@@ -575,7 +581,7 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang
}
})();
return () => { cancelled = true; };
}, [rawHtml, email.attachments]);
}, [rawHtml, email.attachments, jmapAccountId]);
const source = React.useMemo(() => {
if (rawHtml) {
+3 -1
View File
@@ -98,7 +98,9 @@ export function MoveSheet({
{visibleNodes.map((node) => {
const Icon = moveTargetIcon(node.role, node.name);
const isCurrent = node.id === currentMailboxId;
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
// A shared account's header is a grouping row, not a folder.
const canTarget =
!node.isAccountNode && node.myRights?.mayAddItems !== false && !isCurrent;
return (
<Pressable
key={node.id}
+37 -21
View File
@@ -4,7 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useColors, useResolvedTheme } from '../theme/colors';
import Button from './Button';
interface QrScanModalProps {
@@ -17,7 +17,8 @@ interface QrScanModalProps {
export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c, theme), [c, theme]);
const [permission, requestPermission] = useCameraPermissions();
// Guards against the camera firing onBarcodeScanned dozens of times for the
// same code before the modal tears down.
@@ -48,18 +49,17 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
/>
) : null}
<SafeAreaView style={styles.overlay}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
<X size={24} color="#ffffff" />
</Pressable>
<Text style={styles.title}>Sign-in code</Text>
<View style={styles.closeButton} />
</View>
{permission?.granted ? (
<View style={styles.frameWrap}>
{permission?.granted ? (
// Dims everything except the scan square. Four scrim regions leave a
// transparent hole so the camera shows through only inside the frame.
<View style={StyleSheet.absoluteFill} pointerEvents="none">
<View style={styles.scrim} />
<View style={styles.scrimCenterRow}>
<View style={styles.scrimSide} />
<View style={styles.frame} />
<View style={styles.scrimSide} />
</View>
<View style={[styles.scrim, styles.scrimBottom]}>
<View style={styles.hintWrap}>
<Text style={styles.hintTitle}>Open Bulwark on your computer</Text>
<Text style={styles.hint}>
@@ -67,7 +67,19 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
</Text>
</View>
</View>
) : (
</View>
) : null}
<SafeAreaView style={styles.overlay}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
<X size={24} color={c.text} />
</Pressable>
<Text style={styles.title}>Sign-in code</Text>
<View style={styles.closeButton} />
</View>
{!permission?.granted ? (
<View style={styles.permissionWrap}>
<Text style={styles.permissionText}>
{permission && !permission.canAskAgain
@@ -78,14 +90,15 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
Allow camera access
</Button>
</View>
)}
) : null}
</SafeAreaView>
</View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
function makeStyles(c: ThemePalette, theme: 'light' | 'dark') {
const scrimColor = theme === 'light' ? 'rgba(248,250,252,0.72)' : 'rgba(0,0,0,0.6)';
return StyleSheet.create({
container: { flex: 1, backgroundColor: '#000000' },
overlay: { flex: 1, justifyContent: 'flex-start' },
@@ -98,21 +111,24 @@ function makeStyles(c: ThemePalette) {
paddingBottom: spacing.md,
},
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
title: { ...typography.h3, color: '#ffffff' },
frameWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg },
title: { ...typography.h3, color: c.text },
scrim: { flex: 1, backgroundColor: scrimColor },
scrimCenterRow: { flexDirection: 'row', height: 240 },
scrimSide: { flex: 1, backgroundColor: scrimColor },
scrimBottom: { alignItems: 'center', paddingTop: spacing.xl },
frame: {
width: 240,
height: 240,
borderWidth: 3,
borderColor: '#ffffff',
borderColor: c.text,
borderRadius: radius.lg,
backgroundColor: 'transparent',
},
hintWrap: { alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.xxl },
hintTitle: { ...typography.bodySemibold, color: '#ffffff', textAlign: 'center' },
hintTitle: { ...typography.bodySemibold, color: c.text, textAlign: 'center' },
hint: {
...typography.caption,
color: 'rgba(255,255,255,0.8)',
color: c.textSecondary,
textAlign: 'center',
},
permissionWrap: {
+34 -7
View File
@@ -8,7 +8,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
Clock, Layers,
Clock, Layers, Users,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -83,6 +83,8 @@ interface SidebarRowProps {
label: string;
depth: number;
isSelected: boolean;
/** Header for a shared/group account: styled as a section, never selected. */
isAccountHeader?: boolean;
unread: number;
total: number;
hasChildren: boolean;
@@ -92,7 +94,7 @@ interface SidebarRowProps {
}
function SidebarRow({
icon, label, depth, isSelected, unread, total,
icon, label, depth, isSelected, isAccountHeader, unread, total,
hasChildren, isExpanded, onPress, onToggleExpand,
}: SidebarRowProps) {
const c = useColors();
@@ -126,7 +128,11 @@ function SidebarRow({
</View>
<View style={styles.rowIcon}>{icon}</View>
<Text
style={[styles.rowLabel, isSelected && styles.rowLabelSelected]}
style={[
styles.rowLabel,
isSelected && styles.rowLabelSelected,
isAccountHeader && styles.rowLabelAccount,
]}
numberOfLines={1}
>
{label}
@@ -467,20 +473,34 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
visibleNodes.map((node) => {
const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id);
const Icon = iconFor(node.role, node.name, hasChildren, isExpanded);
const isSelected = node.id === currentMailboxId;
// A shared/group account's header has no mailbox behind it —
// tapping it only opens or closes that account's folders.
const Icon = node.isAccountNode
? Users
: iconFor(node.role, node.name, hasChildren, isExpanded);
const isSelected = !node.isAccountNode && node.id === currentMailboxId;
return (
<SidebarRow
key={node.id}
icon={<Icon size={16} color={iconColor(c, node.role, isSelected)} />}
icon={
<Icon
size={16}
color={node.isAccountNode ? c.textMuted : iconColor(c, node.role, isSelected)}
/>
}
label={node.name}
depth={node.depth}
isSelected={isSelected}
isAccountHeader={node.isAccountNode}
unread={node.unreadEmails}
total={node.totalEmails}
hasChildren={hasChildren}
isExpanded={isExpanded}
onPress={() => handleSelect(node.id)}
onPress={
node.isAccountNode
? () => toggleExpand(node.id)
: () => handleSelect(node.id)
}
onToggleExpand={() => toggleExpand(node.id)}
/>
);
@@ -741,6 +761,13 @@ function makeStyles(c: ThemePalette) {
...typography.bodySemibold,
color: c.text,
},
rowLabelAccount: {
...typography.caption,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
color: c.textSecondary,
},
counts: {
flexDirection: 'row',
alignItems: 'baseline',
+74 -89
View File
@@ -9,6 +9,11 @@ import {
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
import {
shouldClaimGesture, dragOffset, resolveRelease, exitsRow,
COMMIT_THRESHOLD, REVEAL_WIDTH,
type SwipeConfig,
} from './swipe-gesture';
interface SwipeableRowProps {
children: React.ReactNode;
@@ -26,17 +31,6 @@ interface SwipeableRowProps {
mode?: SwipeMode;
}
// Instant mode: distance the user must drag past on release to fire the action.
const COMMIT_THRESHOLD = 96;
// Reveal mode: drag past this on release to snap to the open (revealed) state.
const ACTIVATION_THRESHOLD = 32;
// Reveal mode: width of the action band shown when revealed.
const REVEAL_WIDTH = 88;
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
const MIN_DX_TO_CLAIM = 6;
const MAX_DRAG_OVERSHOOT_INSTANT = 240;
const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
// Distance the row flies off-screen by before the action callback fires.
const EXIT_DISTANCE = 600;
@@ -58,12 +52,6 @@ function actionLabel(action: SwipeAction, context: { unread: boolean; starred: b
return ACTION_META[action].defaultLabel;
}
// Actions that visibly remove the row from the list (so the row should fly off
// instead of snapping back). Toggle-style actions stay in place.
function exitsRow(action: SwipeAction): boolean {
return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move';
}
export function SwipeableRow({
children, leftAction, rightAction, context, onAction, mode = 'instant',
}: SwipeableRowProps) {
@@ -73,19 +61,37 @@ export function SwipeableRow({
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);
// The PanResponder below is built once, so its handlers close over the props
// of the render that built it. Reading them through a ref instead keeps the
// gesture in step with the row it is actually attached to. Three ways that
// went wrong before: changing a swipe action in settings kept firing the old
// one; entering selection mode (which passes 'none') still fired actions;
// and `onAction` captured a `handleSwipeAction` from before the mailbox ids
// had loaded, so on a slow connection swipe did nothing even untouched.
// Same idiom as RichTextEditor's callback refs.
const latest = useRef<SwipeConfig & { onAction: (a: SwipeAction) => void }>({
leftAction, rightAction, mode, onAction,
});
latest.current = { leftAction, rightAction, mode, onAction };
// Reveal-mode-only state: which side (if any) is currently sitting open.
const openSideRef = useRef<'left' | 'right' | null>(null);
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
const springTo = (toValue: number) => {
Animated.spring(dx, { toValue, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
};
// Spring back to rest without changing which side is open.
const settle = () => springTo(0);
const close = () => {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
springTo(0);
openSideRef.current = null;
setOpenSide(null);
};
const openTo = (side: 'left' | 'right') => {
const target = side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH;
Animated.spring(dx, { toValue: target, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
springTo(side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH);
openSideRef.current = side;
setOpenSide(side);
};
@@ -101,14 +107,14 @@ export function SwipeableRow({
duration: 180,
useNativeDriver: true,
}).start(() => {
onAction(action);
latest.current.onAction(action);
// The row is about to be removed; reset translation in case it isn't
// (e.g. the action failed silently) so we don't leave it off-screen.
dx.setValue(0);
});
} else {
onAction(action);
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
latest.current.onAction(action);
settle();
}
};
@@ -117,77 +123,56 @@ export function SwipeableRow({
if (action === 'none') return;
if (exitsRow(action)) {
// Let the row collapse a frame, then fire so the parent's list update
// has a clean starting point.
requestAnimationFrame(() => onAction(action));
// has a clean starting point. Deferred, so read the callback on arrival
// rather than capturing today's.
requestAnimationFrame(() => latest.current.onAction(action));
} else {
onAction(action);
latest.current.onAction(action);
}
};
const responder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => false,
onMoveShouldSetPanResponder: (_, g) => {
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
// Reveal mode: if a side is already open, allow the gesture so the
// user can drag it closed.
if (mode === 'reveal' && openSideRef.current === null) {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
} else if (mode !== 'reveal') {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
}
claimed.current = true;
return true;
},
onPanResponderMove: (_, g) => {
const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
const base =
mode === 'reveal'
? openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0
: 0;
const next = base + g.dx;
const clamped = Math.max(-overshoot, Math.min(overshoot, next));
dx.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
claimed.current = false;
if (mode === 'reveal') {
const base =
openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0;
const finalDx = base + g.dx;
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
openTo('right');
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
openTo('left');
} else {
close();
// Built once. Every handler reads `latest.current`, so a rebuild would be a
// no-op anyway — which is what makes useMemo (free to discard its cache)
// safe here.
const responder = React.useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => false,
onMoveShouldSetPanResponder: (_, g) => {
if (!shouldClaimGesture(g, latest.current, openSideRef.current)) return false;
claimed.current = true;
return true;
},
onPanResponderMove: (_, g) => {
dx.setValue(dragOffset(g, latest.current, openSideRef.current));
},
onPanResponderRelease: (_, g) => {
claimed.current = false;
const outcome = resolveRelease(g, latest.current, openSideRef.current);
switch (outcome.kind) {
case 'open':
openTo(outcome.side);
break;
case 'close':
close();
break;
case 'fire':
fly(outcome.direction * (widthRef.current || EXIT_DISTANCE), outcome.action);
break;
case 'settle':
settle();
break;
}
return;
}
// Instant mode: fire on release-past-threshold, no second tap.
if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && rightAction !== 'none') {
fly(widthRef.current || EXIT_DISTANCE, rightAction);
} else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && leftAction !== 'none') {
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
} else {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
},
onPanResponderTerminate: () => {
if (mode === 'reveal') close();
else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
claimed.current = false;
},
onPanResponderTerminationRequest: () => !claimed.current,
}),
).current;
},
onPanResponderTerminate: () => {
if (latest.current.mode === 'reveal') close();
else settle();
claimed.current = false;
},
onPanResponderTerminationRequest: () => !claimed.current,
}),
[],
);
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
widthRef.current = e.nativeEvent.layout.width;
@@ -0,0 +1,162 @@
import { describe, it, expect } from 'vitest';
import {
shouldClaimGesture,
dragOffset,
resolveRelease,
restingOffset,
exitsRow,
COMMIT_THRESHOLD,
ACTIVATION_THRESHOLD,
REVEAL_WIDTH,
type SwipeConfig,
} from '../swipe-gesture';
const instant = (over: Partial<SwipeConfig> = {}): SwipeConfig => ({
mode: 'instant',
leftAction: 'delete',
rightAction: 'archive',
...over,
});
const reveal = (over: Partial<SwipeConfig> = {}): SwipeConfig => instant({ mode: 'reveal', ...over });
// PanResponder hands over dx/dy/vx; the rest of the gesture state is unused.
const g = (dx: number, opts: { dy?: number; vx?: number } = {}) => ({
dx,
dy: opts.dy ?? 0,
vx: opts.vx ?? 0,
});
describe('shouldClaimGesture', () => {
it('ignores drags too short to be intentional', () => {
expect(shouldClaimGesture(g(4), instant(), null)).toBe(false);
expect(shouldClaimGesture(g(-4), instant(), null)).toBe(false);
});
it('leaves mostly-vertical drags to the list scroll', () => {
expect(shouldClaimGesture(g(20, { dy: 40 }), instant(), null)).toBe(false);
expect(shouldClaimGesture(g(40, { dy: 20 }), instant(), null)).toBe(true);
});
it("declines the direction whose action is 'none'", () => {
const config = instant({ rightAction: 'none' });
expect(shouldClaimGesture(g(50), config, null)).toBe(false);
expect(shouldClaimGesture(g(-50), config, null)).toBe(true);
});
it("declines both directions in selection mode, where both actions are 'none'", () => {
const config = instant({ leftAction: 'none', rightAction: 'none' });
expect(shouldClaimGesture(g(50), config, null)).toBe(false);
expect(shouldClaimGesture(g(-50), config, null)).toBe(false);
});
it('claims any direction while a reveal band is open, so it can be dragged shut', () => {
const config = reveal({ leftAction: 'none', rightAction: 'archive' });
expect(shouldClaimGesture(g(-50), config, 'right')).toBe(true);
// ...but not once it is closed again.
expect(shouldClaimGesture(g(-50), config, null)).toBe(false);
});
});
describe('dragOffset', () => {
it('tracks the finger in instant mode', () => {
expect(dragOffset(g(70), instant(), null)).toBe(70);
expect(dragOffset(g(-70), instant(), null)).toBe(-70);
});
it('clamps to the overshoot limit', () => {
expect(dragOffset(g(9999), instant(), null)).toBe(240);
expect(dragOffset(g(-9999), instant(), null)).toBe(-240);
// Reveal mode allows much less overshoot than instant.
expect(dragOffset(g(9999), reveal(), null)).toBeCloseTo(REVEAL_WIDTH * 1.4);
});
it('offsets from the open position in reveal mode', () => {
// Already open to the right; dragging back left should approach 0.
expect(dragOffset(g(-REVEAL_WIDTH), reveal(), 'right')).toBe(0);
expect(dragOffset(g(-10), reveal(), 'right')).toBe(REVEAL_WIDTH - 10);
});
});
describe('resolveRelease — instant mode', () => {
it('fires the action for the direction dragged past the threshold', () => {
expect(resolveRelease(g(COMMIT_THRESHOLD), instant(), null)).toEqual({
kind: 'fire',
action: 'archive',
direction: 1,
});
expect(resolveRelease(g(-COMMIT_THRESHOLD), instant(), null)).toEqual({
kind: 'fire',
action: 'delete',
direction: -1,
});
});
it('accepts a short fast flick as a commit', () => {
expect(resolveRelease(g(50, { vx: 0.8 }), instant(), null)).toMatchObject({ kind: 'fire' });
// Same distance, too slow.
expect(resolveRelease(g(50, { vx: 0.2 }), instant(), null)).toEqual({ kind: 'settle' });
});
it('springs back when the drag stops short', () => {
expect(resolveRelease(g(COMMIT_THRESHOLD - 1), instant(), null)).toEqual({ kind: 'settle' });
});
it("never fires an action configured as 'none'", () => {
const config = instant({ rightAction: 'none' });
expect(resolveRelease(g(999, { vx: 5 }), config, null)).toEqual({ kind: 'settle' });
});
it('fires nothing in selection mode, however hard the row is flung', () => {
const config = instant({ leftAction: 'none', rightAction: 'none' });
expect(resolveRelease(g(999, { vx: 5 }), config, null)).toEqual({ kind: 'settle' });
expect(resolveRelease(g(-999, { vx: -5 }), config, null)).toEqual({ kind: 'settle' });
});
});
describe('resolveRelease — reveal mode', () => {
it('opens the band rather than firing', () => {
expect(resolveRelease(g(ACTIVATION_THRESHOLD + 1), reveal(), null)).toEqual({
kind: 'open',
side: 'right',
});
expect(resolveRelease(g(-ACTIVATION_THRESHOLD - 1), reveal(), null)).toEqual({
kind: 'open',
side: 'left',
});
});
it('closes when the drag stops short', () => {
expect(resolveRelease(g(ACTIVATION_THRESHOLD - 1), reveal(), null)).toEqual({ kind: 'close' });
});
it('measures from the open position, so a small drag keeps it open', () => {
// Open to the right, nudged 5px further right: still well past the threshold.
expect(resolveRelease(g(5), reveal(), 'right')).toEqual({ kind: 'open', side: 'right' });
// Dragged back past the resting point: closes.
expect(resolveRelease(g(-REVEAL_WIDTH), reveal(), 'right')).toEqual({ kind: 'close' });
});
it("closes instead of opening a side whose action is 'none'", () => {
const config = reveal({ rightAction: 'none' });
expect(resolveRelease(g(999), config, null)).toEqual({ kind: 'close' });
});
});
describe('restingOffset', () => {
it('maps the open side to the row translation it sits at', () => {
expect(restingOffset('right')).toBe(REVEAL_WIDTH);
expect(restingOffset('left')).toBe(-REVEAL_WIDTH);
expect(restingOffset(null)).toBe(0);
});
});
describe('exitsRow', () => {
it('flags the actions that remove the row from the list', () => {
expect((['archive', 'delete', 'spam', 'move'] as const).every(exitsRow)).toBe(true);
});
it('keeps toggle actions in place', () => {
expect((['read', 'star', 'pin', 'none'] as const).some(exitsRow)).toBe(false);
});
});
+5 -1
View File
@@ -12,6 +12,7 @@ import { useSettingsStore } from '../../stores/settings-store';
import { useFilterStore } from '../../stores/filter-store';
import { useVacationStore } from '../../stores/vacation-store';
import { useEmailStore } from '../../stores/email-store';
import { ownMailboxes } from '../../lib/mailbox-tree';
import { useLocaleStore } from '../../stores/locale-store';
import { FilterRuleModal } from '../filters/FilterRuleModal';
import { SieveEditorSheet } from '../filters/SieveEditorSheet';
@@ -48,7 +49,10 @@ export function FilterSettings() {
const expandedView = useSettingsStore((s) => s.filtersExpandedView);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const mailboxes = useEmailStore((s) => s.mailboxes);
// Sieve rules run on the user's own account, so only own folders are
// valid "file into" targets.
const allMailboxes = useEmailStore((s) => s.mailboxes);
const mailboxes = useMemo(() => ownMailboxes(allMailboxes), [allMailboxes]);
const vacationEnabled = useVacationStore((s) => s.isEnabled);
const {
+5 -1
View File
@@ -18,6 +18,7 @@ import { SettingsSection } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { ownMailboxes } from '../../lib/mailbox-tree';
import { useEmailStore } from '../../stores/email-store';
import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
import type { Mailbox } from '../../api/types';
@@ -43,7 +44,10 @@ type Editor =
export function FolderSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const mailboxes = useEmailStore((s) => s.mailboxes);
// Folder management (create/rename/delete) targets the user's own account;
// shared (group account) folders are read-only here.
const allMailboxes = useEmailStore((s) => s.mailboxes);
const mailboxes = React.useMemo(() => ownMailboxes(allMailboxes), [allMailboxes]);
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
const [editor, setEditor] = useState<Editor | null>(null);
+4 -2
View File
@@ -14,6 +14,7 @@ import {
} from '../../stores/settings-store';
import { useEmailStore } from '../../stores/email-store';
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
import { ownMailboxes } from '../../lib/mailbox-tree';
function MailLayoutPreview({ value }: { value: MailLayout }) {
const c = useColors();
@@ -103,7 +104,8 @@ export function ReadingSettings() {
const handleReorganizeArchive = async () => {
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
const archiveMailbox = mailboxes.find(
// Reorganising runs against the user's own archive only.
const archiveMailbox = ownMailboxes(mailboxes).find(
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
);
if (!archiveMailbox) {
@@ -130,7 +132,7 @@ export function ReadingSettings() {
if (ids.length === 0) break;
const list = await getEmails(ids);
const refreshed = useEmailStore.getState().mailboxes;
const refreshed = ownMailboxes(useEmailStore.getState().mailboxes);
await archiveEmails(
list.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
archiveMailbox.id,
+132
View File
@@ -0,0 +1,132 @@
// Pure decision logic behind SwipeableRow's pan gesture.
//
// This lives outside the component for a reason beyond tidiness: the row's
// PanResponder is created once and its handlers therefore close over whatever
// props existed at mount. Taking the config as an argument makes staleness
// structurally impossible here — there is nothing to capture — and leaves the
// component with one job, keeping the current config somewhere the handlers
// can reach.
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
// Instant mode: distance the user must drag past on release to fire the action.
export const COMMIT_THRESHOLD = 96;
// Reveal mode: drag past this on release to snap to the open (revealed) state.
export const ACTIVATION_THRESHOLD = 32;
// Reveal mode: width of the action band shown when revealed.
export const REVEAL_WIDTH = 88;
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
const MIN_DX_TO_CLAIM = 6;
const MAX_DRAG_OVERSHOOT_INSTANT = 240;
const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
// A short, fast drag commits too, so the gesture doesn't demand the full
// COMMIT_THRESHOLD when the user clearly flicks.
const FLING_DX = 40;
const FLING_VX = 0.5;
/**
* The row's current swipe configuration.
*
* Note the naming: `rightAction` fires on a rightward (positive dx) drag and
* its band sits at the *left* edge; `leftAction` is the mirror image.
*/
export interface SwipeConfig {
leftAction: SwipeAction;
rightAction: SwipeAction;
mode: SwipeMode;
}
/** The subset of RN's PanResponder gesture state this logic needs. */
export interface SwipeGestureState {
dx: number;
dy: number;
vx: number;
}
/** Which side's action band is currently held open (reveal mode only). */
export type OpenSide = 'left' | 'right' | null;
export type ReleaseOutcome =
/** Reveal mode: snap open so the band can be tapped. */
| { kind: 'open'; side: 'left' | 'right' }
/** Reveal mode: snap shut. */
| { kind: 'close' }
/** Instant mode: run the action; `direction` is the sign to fly the row off by. */
| { kind: 'fire'; action: SwipeAction; direction: 1 | -1 }
/** Not far enough — spring back to rest without firing. */
| { kind: 'settle' };
/**
* Whether the pan should be claimed from whatever else might want it (the
* list's vertical scroll, mainly). Declining leaves the gesture to the parent.
*/
export function shouldClaimGesture(
g: SwipeGestureState,
config: SwipeConfig,
openSide: OpenSide,
): boolean {
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
// An already-open row claims any horizontal drag so the user can drag it
// shut, even in the direction whose action is 'none'.
if (config.mode === 'reveal' && openSide !== null) return true;
if (g.dx > 0 && config.rightAction === 'none') return false;
if (g.dx < 0 && config.leftAction === 'none') return false;
return true;
}
/** Row translation for an in-progress drag, clamped to the mode's overshoot. */
export function dragOffset(
g: SwipeGestureState,
config: SwipeConfig,
openSide: OpenSide,
): number {
const reveal = config.mode === 'reveal';
const overshoot = reveal ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
const base = reveal ? restingOffset(openSide) : 0;
return Math.max(-overshoot, Math.min(overshoot, base + g.dx));
}
/** Where the row sits at rest, given which side is open. */
export function restingOffset(openSide: OpenSide): number {
if (openSide === 'right') return REVEAL_WIDTH;
if (openSide === 'left') return -REVEAL_WIDTH;
return 0;
}
/** What releasing the drag here should do. */
export function resolveRelease(
g: SwipeGestureState,
config: SwipeConfig,
openSide: OpenSide,
): ReleaseOutcome {
if (config.mode === 'reveal') {
const finalDx = restingOffset(openSide) + g.dx;
if (finalDx > ACTIVATION_THRESHOLD && config.rightAction !== 'none') {
return { kind: 'open', side: 'right' };
}
if (finalDx < -ACTIVATION_THRESHOLD && config.leftAction !== 'none') {
return { kind: 'open', side: 'left' };
}
return { kind: 'close' };
}
// Instant mode: fire on release-past-threshold, no second tap.
const committedRight = g.dx >= COMMIT_THRESHOLD || (g.dx > FLING_DX && g.vx >= FLING_VX);
if (committedRight && config.rightAction !== 'none') {
return { kind: 'fire', action: config.rightAction, direction: 1 };
}
const committedLeft = g.dx <= -COMMIT_THRESHOLD || (g.dx < -FLING_DX && g.vx <= -FLING_VX);
if (committedLeft && config.leftAction !== 'none') {
return { kind: 'fire', action: config.leftAction, direction: -1 };
}
return { kind: 'settle' };
}
/**
* Actions that visibly remove the row from the list, so the row should fly off
* instead of snapping back. Toggle-style actions stay in place.
*/
export function exitsRow(action: SwipeAction): boolean {
return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move';
}
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Platform.OS is flipped per test, so hoist a mutable object into the module
// mock rather than using the fixed 'android' stub from test-setup.
const { platform } = vi.hoisted(() => ({ platform: { OS: 'android' as string, Version: 33 } }));
vi.mock('react-native', () => ({
Platform: platform,
NativeModules: {},
NativeEventEmitter: class {
addListener() {
return { remove: () => undefined };
}
},
}));
// A `File` that records both URL forms, so tests can assert which one each
// handoff path was given: expo-sharing needs `file://`, an Intent needs
// `content://`.
vi.mock('expo-file-system', () => {
class File {
name: string;
uri: string;
contentUri: string;
exists = true;
constructor(dir: { uri: string }, name: string) {
this.name = name;
this.uri = `${dir.uri}${name}`;
this.contentUri = `content://org.bulwarkmail.mobile.FileSystemFileProvider/${name}`;
}
create() {}
delete() {}
write() {}
static downloadFileAsync = vi.fn(async () => undefined);
}
return {
File,
Directory: class {},
Paths: { cache: { uri: 'file:///cache/' }, document: { uri: 'file:///documents/' } },
};
});
vi.mock('expo-intent-launcher', () => ({
startActivityAsync: vi.fn(async () => ({ resultCode: -1 })),
}));
vi.mock('expo-sharing', () => ({
isAvailableAsync: vi.fn(async () => true),
shareAsync: vi.fn(async () => undefined),
}));
vi.mock('../client-cert', () => ({
getClientCertAlias: vi.fn(async () => null),
secureFetch: vi.fn(),
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { authHeader: 'Bearer token' },
}));
vi.mock('../../api/blob', () => ({
getDownloadUrl: vi.fn((blobId: string, name: string) => `https://mail.example.com/${blobId}/${name}`),
}));
import * as IntentLauncher from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing';
import { shareAttachment, downloadAttachment } from '../email-export';
const VIEW = 'android.intent.action.VIEW';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
describe('shareAttachment (preview)', () => {
beforeEach(() => {
platform.OS = 'android';
vi.mocked(IntentLauncher.startActivityAsync).mockClear().mockResolvedValue({ resultCode: -1 });
vi.mocked(Sharing.shareAsync).mockClear();
});
it('hands the file to a viewer app via a VIEW intent on Android', async () => {
await shareAttachment('blob-1', 'report.pdf', 'application/pdf');
expect(IntentLauncher.startActivityAsync).toHaveBeenCalledWith(VIEW, {
data: 'content://org.bulwarkmail.mobile.FileSystemFileProvider/report.pdf',
type: 'application/pdf',
flags: FLAG_GRANT_READ_URI_PERMISSION,
});
// The share sheet is what the direct handoff exists to avoid.
expect(Sharing.shareAsync).not.toHaveBeenCalled();
});
it('falls back to the share sheet when no app handles the type', async () => {
vi.mocked(IntentLauncher.startActivityAsync).mockRejectedValue(
new Error('No Activity found to handle Intent'),
);
await shareAttachment('blob-2', 'weird.xyz', 'application/x-weird');
// file:// — expo-sharing rejects content:// URLs outright.
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///cache/weird.xyz', {
mimeType: 'application/x-weird',
dialogTitle: 'weird.xyz',
});
});
it('surfaces a download failure instead of silently falling back', async () => {
const { File } = await import('expo-file-system');
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404'));
await expect(shareAttachment('blob-3', 'gone.pdf', 'application/pdf')).rejects.toThrow(
'Download failed: 404',
);
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
});
it('uses the share sheet on iOS', async () => {
platform.OS = 'ios';
await shareAttachment('blob-4', 'photo.jpg', 'image/jpeg');
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///cache/photo.jpg', {
mimeType: 'image/jpeg',
dialogTitle: 'photo.jpg',
});
});
});
describe('downloadAttachment (save a copy)', () => {
beforeEach(() => {
platform.OS = 'android';
vi.mocked(IntentLauncher.startActivityAsync).mockClear().mockResolvedValue({ resultCode: -1 });
vi.mocked(Sharing.shareAsync).mockClear();
});
it('keeps the share sheet on Android so the save dialog stays reachable', async () => {
await downloadAttachment('blob-5', 'report.pdf', 'application/pdf');
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///documents/report.pdf', {
mimeType: 'application/pdf',
dialogTitle: 'report.pdf',
});
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect } from 'vitest';
import {
buildMailboxTree,
flattenVisible,
mailboxesForSiblingOf,
ownMailboxes,
SHARED_ACCOUNT_NODE_PREFIX,
} from '../mailbox-tree';
import type { Mailbox } from '../../api/types';
const RIGHTS: Mailbox['myRights'] = {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
};
function own(id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox {
return {
id,
name,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
myRights: RIGHTS,
accountId: 'acc-1',
isShared: false,
...extra,
};
}
function shared(
accountId: string,
accountName: string,
rawId: string,
name: string,
extra: Partial<Mailbox> = {},
): Mailbox {
return {
...own(`${accountId}:${rawId}`, name, extra),
originalId: rawId,
accountId,
accountName,
isShared: true,
};
}
describe('buildMailboxTree with shared accounts', () => {
it('keeps own folders at the root and groups each shared account under its own node', () => {
const tree = buildMailboxTree([
own('inbox', 'Inbox', { role: 'inbox' }),
own('sent', 'Sent', { role: 'sent' }),
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox', unreadEmails: 3 }),
shared('grp-1', 'Support', 'arch', 'Archive', { role: 'archive', unreadEmails: 1 }),
]);
expect(tree.map((n) => n.id)).toEqual([
'inbox',
'sent',
`${SHARED_ACCOUNT_NODE_PREFIX}grp-1`,
]);
const accountNode = tree[2];
expect(accountNode.isAccountNode).toBe(true);
expect(accountNode.name).toBe('Support');
// The header rolls up its account's unread so a collapsed section still
// signals waiting mail.
expect(accountNode.unreadEmails).toBe(4);
expect(accountNode.children.map((n) => n.id)).toEqual(['grp-1:inbox', 'grp-1:arch']);
expect(accountNode.children.every((n) => n.depth === 1)).toBe(true);
});
it('nests a shared account\'s subfolders under their own parent, not the user\'s', () => {
const tree = buildMailboxTree([
own('inbox', 'Inbox', { role: 'inbox' }),
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }),
shared('grp-1', 'Support', 'sub', 'Escalations', { parentId: 'grp-1:inbox' }),
]);
const accountNode = tree[1];
expect(accountNode.children).toHaveLength(1);
expect(accountNode.children[0].children.map((n) => n.name)).toEqual(['Escalations']);
expect(accountNode.children[0].children[0].depth).toBe(2);
});
it('gives each shared account its own node', () => {
const tree = buildMailboxTree([
shared('grp-2', 'Sales', 'inbox', 'Inbox', { role: 'inbox' }),
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }),
]);
expect(tree.map((n) => n.name)).toEqual(['Sales', 'Support']);
});
it('hides a collapsed shared account\'s folders', () => {
const tree = buildMailboxTree([
own('inbox', 'Inbox', { role: 'inbox' }),
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }),
]);
const collapsed = flattenVisible(tree, new Set());
expect(collapsed.map((n) => n.id)).toEqual(['inbox', `${SHARED_ACCOUNT_NODE_PREFIX}grp-1`]);
const expanded = flattenVisible(tree, new Set([`${SHARED_ACCOUNT_NODE_PREFIX}grp-1`]));
expect(expanded.map((n) => n.id)).toEqual([
'inbox',
`${SHARED_ACCOUNT_NODE_PREFIX}grp-1`,
'grp-1:inbox',
]);
});
});
describe('account scoping helpers', () => {
const all = [
own('inbox', 'Inbox', { role: 'inbox' }),
own('trash', 'Trash', { role: 'trash' }),
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }),
shared('grp-1', 'Support', 'trash', 'Trash', { role: 'trash' }),
shared('grp-2', 'Sales', 'inbox', 'Inbox', { role: 'inbox' }),
];
it('ownMailboxes drops every shared folder', () => {
expect(ownMailboxes(all).map((m) => m.id)).toEqual(['inbox', 'trash']);
});
it('scopes to the owning shared account when the current folder is shared', () => {
expect(mailboxesForSiblingOf(all, 'grp-1:inbox').map((m) => m.id))
.toEqual(['grp-1:inbox', 'grp-1:trash']);
});
it('scopes to the user\'s own folders for an own or unknown folder', () => {
expect(mailboxesForSiblingOf(all, 'inbox').map((m) => m.id)).toEqual(['inbox', 'trash']);
expect(mailboxesForSiblingOf(all, 'gone').map((m) => m.id)).toEqual(['inbox', 'trash']);
expect(mailboxesForSiblingOf(all, null).map((m) => m.id)).toEqual(['inbox', 'trash']);
});
});
+41 -6
View File
@@ -1,4 +1,6 @@
import { Platform } from 'react-native';
import { Directory, File, Paths } from 'expo-file-system';
import * as IntentLauncher from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
import { getDownloadUrl } from '../api/blob';
@@ -12,6 +14,7 @@ import {
import { getClientCertAlias, secureFetch } from './client-cert';
const RFC822 = 'message/rfc822';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
// Read the user's filename template + transform preferences for exports.
function emailFileOptions(): EmailFilenameOptions {
@@ -48,7 +51,32 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
// expo-sharing only accepts `file://` URLs and rejects `content://` with
// "Only local file URLs are supported". On Android it then wraps the file
// itself with its bundled SharingFileProvider before launching the share
// intent, so we must NOT pre-translate to `file.contentUri`.
// intent, so every `Sharing.shareAsync` below gets `downloaded.uri` and must
// NOT be pre-translated to `downloaded.contentUri`. `openWithViewer` is the
// one exception: it assembles the intent itself, so there it's the reverse —
// only a content URI is grantable to another app.
// Android-only: hand the file to whichever app owns the type (PDF viewer,
// gallery, video player, ...) rather than to the share sheet. Returns false
// when the handoff didn't happen so the caller can fall back to sharing —
// most often because no installed app handles the MIME type, in which case
// startActivityAsync rejects with ActivityNotFoundException.
async function openWithViewer(file: File, mimeType: string): Promise<boolean> {
try {
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
// Read-only grant, scoped to the receiving app for the life of the
// intent. Deliberately no FLAG_ACTIVITY_NEW_TASK: expo-intent-launcher
// uses startActivityForResult, and a new task cancels that result.
data: file.contentUri,
type: mimeType,
flags: FLAG_GRANT_READ_URI_PERMISSION,
});
return true;
} catch (e) {
console.warn('[attachments] no viewer for', mimeType, '- falling back to share:', e);
return false;
}
}
// Routes the download via the client-cert-aware native module when the user
// has picked a cert, and via expo-file-system's native streaming downloader
@@ -99,6 +127,10 @@ export async function shareAttachment(
const dest = new File(Paths.cache, filename);
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
const downloaded = await downloadInto(url, dest, Paths.cache);
if (Platform.OS === 'android' && (await openWithViewer(downloaded, mimeType))) {
return;
}
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
@@ -111,8 +143,9 @@ export async function shareAttachment(
// Save-to-disk variant. iOS doesn't expose a user-visible "Downloads" folder,
// so on both platforms we land the file in the document directory and hand it
// to the share sheet — which on iOS surfaces "Save to Files" and on Android
// surfaces the system save dialog. The 'preview' counterpart (shareAttachment)
// uses cache + share, which on most viewers opens directly without prompting.
// surfaces the system save dialog. Unlike the 'preview' counterpart
// (shareAttachment), this one keeps the share sheet on Android as well: "save
// a copy" is a share-sheet destination, not something a viewer app handles.
export async function downloadAttachment(
blobId: string,
name: string | undefined,
@@ -137,8 +170,8 @@ export async function downloadAttachment(
});
}
export async function fetchRawEmail(blobId: string): Promise<string> {
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
export async function fetchRawEmail(blobId: string, accountId?: string): Promise<string> {
const url = getDownloadUrl(blobId, 'email.eml', RFC822, accountId);
const r = await secureFetch(url, { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
return r.text();
@@ -153,12 +186,14 @@ export async function shareEmailEml(
blobId: string,
email?: Email | null,
subjectFallback?: string,
// Owning account when the message lives in a shared/group mailbox.
accountId?: string,
): Promise<void> {
const filename = email
? emailExportFilename(email, emailFileOptions())
: safeFilename(subjectFallback);
const dest = new File(Paths.cache, filename);
const url = getDownloadUrl(blobId, dest.name, RFC822);
const url = getDownloadUrl(blobId, dest.name, RFC822, accountId);
const downloaded = await downloadInto(url, dest, Paths.cache);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
+104 -8
View File
@@ -3,6 +3,33 @@ import type { Mailbox } from '../api/types';
export interface MailboxNode extends Mailbox {
children: MailboxNode[];
depth: number;
/**
* True for the virtual node that wraps a shared/group account's folders.
* It has no server-side mailbox behind it — it can be expanded but never
* selected or used as a move target.
*/
isAccountNode?: boolean;
}
// Id prefix for the virtual per-shared-account node. Matches the webmail's
// `shared-account-<accountId>` convention in [lib/utils.ts].
export const SHARED_ACCOUNT_NODE_PREFIX = 'shared-account-';
/** The user's own folders — everything not owned by a shared/group account. */
export function ownMailboxes(mailboxes: Mailbox[]): Mailbox[] {
return mailboxes.filter((m) => !m.isShared);
}
/**
* Folders belonging to the same account as `mailboxId`. Role lookups (trash,
* archive, junk…) must run inside one account: moving a message out of a
* shared folder into the user's own Trash isn't a thing JMAP can express.
* Falls back to the user's own folders when the id isn't known.
*/
export function mailboxesForSiblingOf(mailboxes: Mailbox[], mailboxId: string | null): Mailbox[] {
const current = mailboxId ? mailboxes.find((m) => m.id === mailboxId) : undefined;
if (!current?.isShared) return ownMailboxes(mailboxes);
return mailboxes.filter((m) => m.isShared && m.accountId === current.accountId);
}
// Matches `ROLE_PRIORITY` from [lib/utils.ts] in the webmail.
@@ -53,7 +80,16 @@ function sortNodes(nodes: MailboxNode[]): void {
for (const node of nodes) sortNodes(node.children);
}
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
function recalcDepths(nodes: MailboxNode[], base: number): void {
for (const n of nodes) {
n.depth = base;
if (n.children.length > 0) recalcDepths(n.children, base + 1);
}
}
// Build the root nodes for one account's folders (parent links only resolve
// within an account — a shared folder can't nest under an own folder).
function buildRoots(mailboxes: Mailbox[]): MailboxNode[] {
const deduped = deduplicate(mailboxes);
const map = new Map<string, MailboxNode>();
const roots: MailboxNode[] = [];
@@ -69,16 +105,76 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
roots.push(node);
}
}
return roots;
}
const recalcDepths = (nodes: MailboxNode[], base: number) => {
for (const n of nodes) {
n.depth = base;
if (n.children.length > 0) recalcDepths(n.children, base + 1);
}
};
const ACCOUNT_NODE_RIGHTS: Mailbox['myRights'] = {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: false,
maySetKeywords: false,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
};
/**
* Own folders first, then one collapsible node per shared/group account
* holding that account's folders — the same shape the webmail sidebar uses
* (GitHub #151), so a Stalwart group mailbox shows up as its own section
* rather than being mixed into the user's folder list.
*/
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
const own = mailboxes.filter((m) => !m.isShared);
const shared = mailboxes.filter((m) => m.isShared);
const roots = buildRoots(own);
recalcDepths(roots, 0);
sortNodes(roots);
return roots;
if (shared.length === 0) return roots;
const byAccount = new Map<string, Mailbox[]>();
for (const m of shared) {
const accountId = m.accountId ?? 'unknown';
const list = byAccount.get(accountId);
if (list) list.push(m);
else byAccount.set(accountId, [m]);
}
const accountNodes: MailboxNode[] = [];
for (const [accountId, accountMailboxes] of byAccount) {
const accountRoots = buildRoots(accountMailboxes);
// Children sit at depth 1 so the account node reads as their header.
recalcDepths(accountRoots, 1);
sortNodes(accountRoots);
const accountName = accountMailboxes[0]?.accountName || accountId;
accountNodes.push({
id: `${SHARED_ACCOUNT_NODE_PREFIX}${accountId}`,
name: accountName,
sortOrder: 1000,
totalEmails: 0,
// Roll the account's unread up to the header so a collapsed section
// still shows there's something waiting.
unreadEmails: accountMailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0),
totalThreads: 0,
unreadThreads: 0,
myRights: ACCOUNT_NODE_RIGHTS,
isSubscribed: true,
accountId,
accountName,
isShared: true,
isAccountNode: true,
children: accountRoots,
depth: 0,
});
}
accountNodes.sort((a, b) => a.name.localeCompare(b.name));
return [...roots, ...accountNodes];
}
// Flatten the tree in traversal order, skipping children of collapsed nodes.
+1 -1
View File
@@ -3,7 +3,7 @@ import type { EmailAddress } from '../api/types';
export type RootStackParamList = {
MainTabs: undefined;
EmailThread: { emailId: string; threadId: string; subject?: string; jmapAccountId?: string };
EmailSource: { emailId: string; blobId: string; subject?: string };
EmailSource: { emailId: string; blobId: string; subject?: string; jmapAccountId?: string };
Compose:
| {
mode?: 'reply' | 'replyAll' | 'forward';
+4 -1
View File
@@ -23,6 +23,7 @@ import RichTextEditor, {
type RichTextSelectionState,
} from '../components/RichTextEditor';
import { useEmailStore } from '../stores/email-store';
import { ownMailboxes } from '../lib/mailbox-tree';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
@@ -263,8 +264,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
const mode = route.params?.mode ?? 'compose';
const prefillTo = route.params?.prefillTo;
const mailboxes = useEmailStore((s) => s.mailboxes);
// Always the user's own Sent — composing on behalf of a shared account
// isn't supported, so a group account's Sent must never be picked up here.
const sentMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'sent'),
() => ownMailboxes(mailboxes).find((m) => m.role === 'sent'),
[mailboxes],
);
+30 -13
View File
@@ -24,7 +24,9 @@ import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
import { useKeywordsStore } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-store';
import { formatListDate } from '../lib/date-format';
import { findTrashMailbox, findArchiveMailbox, findJunkMailbox } from '../lib/mailbox-tree';
import {
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes,
} from '../lib/mailbox-tree';
import type { Email } from '../api/types';
function getSenderName(email: Email): string {
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
@@ -243,24 +245,31 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
return counts;
}, [emails, disableThreading]);
// Role folders must come from the account the open folder belongs to: a
// shared (group account) message can only be filed into that account's
// Archive/Trash/Junk, never the user's own.
const scopedMailboxes = React.useMemo(
() => mailboxesForSiblingOf(mailboxes, currentMailboxId),
[mailboxes, currentMailboxId],
);
const archiveMailboxId = React.useMemo(
() => findArchiveMailbox(mailboxes)?.id ?? null,
[mailboxes],
() => findArchiveMailbox(scopedMailboxes)?.id ?? null,
[scopedMailboxes],
);
const trashMailboxId = React.useMemo(
() => findTrashMailbox(mailboxes)?.id ?? null,
[mailboxes],
() => findTrashMailbox(scopedMailboxes)?.id ?? null,
[scopedMailboxes],
);
const junkMailboxId = React.useMemo(
() => findJunkMailbox(mailboxes)?.id ?? null,
[mailboxes],
() => findJunkMailbox(scopedMailboxes)?.id ?? null,
[scopedMailboxes],
);
// Import .eml / .zip files into the current mailbox (falls back to Inbox).
const [importing, setImporting] = React.useState(false);
const handleImport = React.useCallback(async () => {
const targetMailboxId =
currentMailboxId ?? mailboxes.find((m) => m.role === 'inbox')?.id ?? null;
currentMailboxId ?? ownMailboxes(mailboxes).find((m) => m.role === 'inbox')?.id ?? null;
if (!targetMailboxId || importing) return;
let result: DocumentPicker.DocumentPickerResult;
try {
@@ -460,7 +469,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
};
const handleBulkDelete = async () => {
const trash = findTrashMailbox(mailboxes);
const trash = findTrashMailbox(scopedMailboxes);
if (!trash || !currentMailboxId) {
clearSelection();
return;
@@ -548,7 +557,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
React.useEffect(() => {
if (mailboxes.length > 0 && !currentMailboxId) {
const inbox = mailboxes.find((m) => m.role === 'inbox') || mailboxes[0];
const own = ownMailboxes(mailboxes);
const inbox = own.find((m) => m.role === 'inbox') || own[0];
if (!inbox) return;
void selectMailbox(inbox.id);
}
}, [mailboxes, currentMailboxId, selectMailbox]);
@@ -620,7 +631,13 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
<Menu size={20} color={c.textMuted} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{currentMailbox?.name ?? 'Inbox'}</Text>
<Text style={styles.headerTitle} numberOfLines={1}>
{currentMailbox?.isShared && currentMailbox.accountName
// Name the owning group account — a bare "Inbox" would look like
// the user's own.
? `${currentMailbox.accountName} · ${currentMailbox.name}`
: currentMailbox?.name ?? 'Inbox'}
</Text>
<View style={{ flex: 1 }} />
<Pressable
onPress={() => { void handleImport(); }}
@@ -1013,7 +1030,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<MoveSheet
visible={pendingMoveId !== null}
onClose={() => setPendingMoveId(null)}
mailboxes={mailboxes}
mailboxes={scopedMailboxes}
currentMailboxId={currentMailboxId}
onPick={(toId) => {
const id = pendingMoveId;
@@ -1027,7 +1044,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<MoveSheet
visible={batchMoveOpen}
onClose={() => setBatchMoveOpen(false)}
mailboxes={mailboxes}
mailboxes={scopedMailboxes}
currentMailboxId={currentMailboxId}
onPick={handleBatchMovePick}
/>
+6 -4
View File
@@ -15,7 +15,9 @@ type Props = NativeStackScreenProps<RootStackParamList, 'EmailSource'>;
export default function EmailSourceScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { blobId, subject } = route.params;
// Blobs are account-scoped, so a message from a shared/group mailbox has
// to be fetched against its owning account.
const { blobId, subject, jmapAccountId } = route.params;
const [raw, setRaw] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
@@ -23,18 +25,18 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
let cancelled = false;
void (async () => {
try {
const text = await fetchRawEmail(blobId);
const text = await fetchRawEmail(blobId, jmapAccountId);
if (!cancelled) setRaw(text);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load email source');
}
})();
return () => { cancelled = true; };
}, [blobId]);
}, [blobId, jmapAccountId]);
const onShare = async () => {
try {
await shareEmailEml(blobId, undefined, subject);
await shareEmailEml(blobId, undefined, subject, jmapAccountId);
} catch (e) {
Alert.alert('Share failed', e instanceof Error ? e.message : String(e));
}
+51 -26
View File
@@ -30,7 +30,7 @@ import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { findTrashMailbox } from '../lib/mailbox-tree';
import { findTrashMailbox, mailboxesForSiblingOf } from '../lib/mailbox-tree';
import type { Email, Mailbox } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
@@ -84,6 +84,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const emails = useEmailStore((s) => s.emails);
// The JMAP account the open message belongs to: the route param when the
// unified inbox opened a group message, otherwise the account behind the
// folder it was opened from. Undefined means the user's own mail.
const ownerAccountId = React.useMemo(() => {
if (jmapAccountId) return jmapAccountId;
const current = currentMailboxId
? mailboxes.find((m) => m.id === currentMailboxId)
: undefined;
return current?.isShared ? current.accountId : undefined;
}, [jmapAccountId, mailboxes, currentMailboxId]);
const currentIndex = emails.findIndex((e) => e.id === activeEmailId);
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
@@ -128,13 +139,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const ensureDetail = React.useCallback((id: string): Promise<Email | null> => {
const pending = inFlight.get(id);
if (pending) return pending;
const p = getEmailDetail(id, jmapAccountId)
const p = getEmailDetail(id, ownerAccountId)
.then((fetched) => { detailCache.set(id, fetched); bumpCache(); return fetched; })
.catch(() => detailCache.get(id) ?? null)
.finally(() => { inFlight.delete(id); });
inFlight.set(id, p);
return p;
}, [getEmailDetail, jmapAccountId, detailCache, bumpCache, inFlight]);
}, [getEmailDetail, ownerAccountId, detailCache, bumpCache, inFlight]);
// Slide to a page by index and reflect it in the toolbar immediately; a swipe
// that settles on the same page confirms the same id via onMomentumEnd.
@@ -190,9 +201,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
setDownloadingBlobId(blobId);
try {
if (mailAttachmentAction === 'download') {
await downloadAttachment(blobId, name, type, target);
await downloadAttachment(blobId, name, type, target, ownerAccountId);
} else {
await shareAttachment(blobId, name, type, target);
await shareAttachment(blobId, name, type, target, ownerAccountId);
}
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
@@ -200,7 +211,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
setDownloadingBlobId(null);
}
},
[downloadingBlobId, mailAttachmentAction],
[downloadingBlobId, mailAttachmentAction, ownerAccountId],
);
const keywordDefs = useKeywordsStore((s) => s.keywords);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
@@ -226,10 +237,10 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (!fetched.keywords?.$seen) {
if (markAsReadDelay > 0) {
readTimer = setTimeout(() => {
if (!cancelled) void markRead(activeEmailId, jmapAccountId);
if (!cancelled) void markRead(activeEmailId, ownerAccountId);
}, markAsReadDelay);
} else {
void markRead(activeEmailId, jmapAccountId);
void markRead(activeEmailId, ownerAccountId);
}
}
})();
@@ -237,24 +248,33 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
cancelled = true;
if (readTimer) clearTimeout(readTimer);
};
}, [activeEmailId, ensureDetail, markRead, markAsReadDelay, jmapAccountId, detailCache]);
}, [activeEmailId, ensureDetail, markRead, markAsReadDelay, ownerAccountId, detailCache]);
const starred = !!email?.keywords?.$flagged;
const unread = !!email && !email.keywords?.$seen;
// Move/archive/spam targets have to live in the same account as the folder
// the message was opened from — a shared (group account) message can't be
// filed into the user's own folders.
const scopedMailboxes = React.useMemo(
() => mailboxesForSiblingOf(mailboxes, currentMailboxId),
[mailboxes, currentMailboxId],
);
const archiveMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'archive'),
[mailboxes],
() => scopedMailboxes.find((m) => m.role === 'archive'),
[scopedMailboxes],
);
const junkMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam'),
[mailboxes],
() => scopedMailboxes.find((m) => m.role === 'junk' || m.role === 'spam'),
[scopedMailboxes],
);
const inboxMailbox = React.useMemo(
() => mailboxes.find((m) => m.role === 'inbox'),
[mailboxes],
() => scopedMailboxes.find((m) => m.role === 'inbox'),
[scopedMailboxes],
);
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
// `mailboxIds` comes back from the server unprefixed, so compare on the
// folder's raw id rather than the sidebar key.
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.originalId ?? junkMailbox.id]);
// Optimistically write a message's keywords into the cache (the single source
// of truth for every pane) and bump the version so the panes re-render.
@@ -271,7 +291,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (next[token]) delete next[token];
else next[token] = true;
updateLocalKeywords(email.id, next);
void setEmailKeywords(email.id, next);
void setEmailKeywords(email.id, next, ownerAccountId);
};
// Toggle the star on a specific message — used both by the toolbar (current
@@ -282,27 +302,27 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
else next.$flagged = true;
const prev = detailCache.get(target.id);
if (prev) { detailCache.set(target.id, { ...prev, keywords: next }); bumpCache(); }
void setEmailKeywords(target.id, next);
}, [detailCache, bumpCache]);
void setEmailKeywords(target.id, next, ownerAccountId);
}, [detailCache, bumpCache, ownerAccountId]);
const onToggleStar = () => { if (email) toggleStarFor(email); };
const onToggleUnread = () => {
if (!email) return;
if (unread) {
void markRead(email.id);
void markRead(email.id, ownerAccountId);
updateLocalKeywords(email.id, { ...email.keywords, $seen: true });
} else {
const next = { ...email.keywords };
delete next.$seen;
updateLocalKeywords(email.id, next);
void setEmailKeywords(email.id, next);
void setEmailKeywords(email.id, next, ownerAccountId);
}
};
const onDelete = () => {
if (!email || !currentMailboxId) return;
const trash = findTrashMailbox(mailboxes);
const trash = findTrashMailbox(scopedMailboxes);
if (!trash) {
Alert.alert(
t('email_list.error', 'Error'),
@@ -325,7 +345,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (!email || !currentMailboxId) return;
setMoreMenuOpen(false);
if (isInJunk) {
const target = inboxMailbox ?? mailboxes.find((m) => m.id !== currentMailboxId);
const target = inboxMailbox ?? scopedMailboxes.find((m) => m.id !== currentMailboxId);
if (!target) return;
void moveToMailbox(email.id, currentMailboxId, target.id);
} else {
@@ -534,6 +554,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<EmailPane
id={item.id}
email={detailCache.get(item.id) ?? null}
jmapAccountId={ownerAccountId}
ensureDetail={ensureDetail}
c={c}
styles={styles}
@@ -604,6 +625,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
emailId: email.id,
blobId: email.blobId,
subject: email.subject,
jmapAccountId: ownerAccountId,
});
}
}}
@@ -611,7 +633,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
setMoreMenuOpen(false);
if (!email?.blobId) return;
try {
await shareEmailEml(email.blobId, email);
await shareEmailEml(email.blobId, email, undefined, ownerAccountId);
} catch (e) {
Alert.alert('Export failed', e instanceof Error ? e.message : String(e));
}
@@ -621,7 +643,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
<MoveSheet
visible={moveMenuOpen}
onClose={() => setMoveMenuOpen(false)}
mailboxes={mailboxes}
mailboxes={scopedMailboxes}
currentMailboxId={currentMailboxId}
onPick={onMoveToMailbox}
/>
@@ -640,6 +662,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
interface EmailPaneProps {
id: string;
email: Email | null;
/** Owning account when the message lives in a shared/group mailbox. */
jmapAccountId?: string;
ensureDetail: (id: string) => Promise<Email | null>;
c: ThemePalette;
styles: ReturnType<typeof makeStyles>;
@@ -659,7 +683,7 @@ interface EmailPaneProps {
// scroll position and "show all attachments" toggle, and renders directly from
// the email passed to it — neighbours show real content, not a placeholder.
function EmailPane({
id, email, ensureDetail, c, styles, bottomBarHeight, attachmentPosition,
id, email, jmapAccountId, ensureDetail, c, styles, bottomBarHeight, attachmentPosition,
hideInlineImageAttachments, downloadingBlobId, onToggleStar, onPressAttachment, onSwipe,
onZoomChange,
}: EmailPaneProps) {
@@ -806,6 +830,7 @@ function EmailPane({
<EmailBodyView
email={email}
senderEmail={from?.email}
jmapAccountId={jmapAccountId}
onSwipe={onSwipe}
onZoomChange={(z) => { setPinching(z.pinching); onZoomChange(z); }}
/>
+7
View File
@@ -51,6 +51,9 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
const [email, setEmail] = React.useState('');
const [serverInput, setServerInput] = React.useState('');
const [serverUrl, setServerUrl] = React.useState('');
// Drives the confirm step's heading: we only claim to have "found" a server
// when discovery actually found one.
const [serverDiscovered, setServerDiscovered] = React.useState(false);
const [password, setPassword] = React.useState('');
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
@@ -158,6 +161,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
if (found) {
setServerUrl(found);
setServerInput(found);
setServerDiscovered(true);
goTo('confirm');
} else {
setFailedDomain(emailDomain(value));
@@ -179,6 +183,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
return;
}
setServerUrl(normalized);
setServerDiscovered(false);
goTo('confirm');
}, [goTo, serverInput]);
@@ -279,6 +284,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
if (!knownServerUrl) return;
setServerUrl(knownServerUrl);
setServerInput(knownServerUrl);
setServerDiscovered(false);
goTo('confirm');
}}
onManualSetup={() => {
@@ -334,6 +340,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
{step === 'confirm' ? (
<ConfirmStep
serverUrl={serverUrl}
discovered={serverDiscovered}
onContinue={() => void runHandoff(serverUrl)}
onChangeServer={() => {
setFailedDomain(null);
+7 -9
View File
@@ -71,8 +71,8 @@ export default function ChooseStep({
style={styles.logo}
resizeMode="contain"
/>
<Text style={styles.appName}>Bulwark Mail</Text>
<Text style={styles.tagline}>Secure. Private. Yours.</Text>
<Text style={styles.title}>Bulwark Mail</Text>
<Text style={styles.subtitle}>Choose how you&apos;d like to sign in.</Text>
</View>
)}
@@ -107,7 +107,7 @@ export default function ChooseStep({
<OptionTile
emphasis="primary"
title={`Another account on ${hostOf(knownServerUrl)}`}
description="Server already known — no setup needed"
description="We already know this server, so there's no setup"
renderIcon={(color, size) => <Plus size={size} color={color} />}
onPress={onUseKnownServer}
disabled={disabled}
@@ -116,7 +116,7 @@ export default function ChooseStep({
<OptionTile
emphasis="primary"
title="Scan a sign-in code"
description="Already signed in on the web? Fastest way in."
description="The fastest way in if you're signed in on the web"
renderIcon={(color, size) => <QrCode size={size} color={color} />}
onPress={onScan}
disabled={disabled}
@@ -152,10 +152,8 @@ export default function ChooseStep({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
root: { gap: spacing.xl },
branding: { alignItems: 'center', gap: spacing.xs, marginBottom: spacing.md },
logo: { width: 64, height: 64, marginBottom: spacing.sm },
appName: { ...typography.h1, color: c.text },
tagline: { ...typography.caption, color: c.textMuted },
branding: { gap: spacing.xs, marginBottom: spacing.md },
logo: { width: 40, height: 40, marginBottom: spacing.sm },
addHeading: { gap: spacing.xs },
title: { ...typography.h1, color: c.text },
@@ -164,7 +162,7 @@ function makeStyles(c: ThemePalette) {
accountList: { gap: spacing.md },
accountRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
avatar: { width: 36, height: 36, borderRadius: radius.full, alignItems: 'center', justifyContent: 'center' },
avatarText: { ...typography.captionMedium, color: '#ffffff' },
avatarText: { ...typography.bodySemibold, color: c.primaryForeground },
accountText: { flex: 1 },
accountName: { ...typography.bodyMedium, color: c.text },
accountEmail: { ...typography.caption, color: c.textMuted },
+20 -22
View File
@@ -8,6 +8,8 @@ import LoginNotice from './LoginNotice';
interface ConfirmStepProps {
serverUrl: string;
/** False when the user supplied the address — we didn't find anything. */
discovered: boolean;
onContinue: () => void;
onChangeServer: () => void;
onUsePassword: () => void;
@@ -25,6 +27,7 @@ function hostOf(url: string): string {
*/
export default function ConfirmStep({
serverUrl,
discovered,
onContinue,
onChangeServer,
onUsePassword,
@@ -36,36 +39,38 @@ export default function ConfirmStep({
return (
<View style={styles.root}>
<View style={styles.heading}>
<Text style={styles.title}>Found your mail server</Text>
<Text style={styles.title}>
{discovered ? 'Found your mail server' : 'Check this looks right'}
</Text>
<Text style={styles.subtitle}>Next you&apos;ll sign in on your provider&apos;s own page.</Text>
</View>
<View style={styles.card}>
<View style={styles.cardIcon}>
<Globe size={19} color={c.primary} />
<Globe size={20} color={c.primary} />
</View>
<View style={styles.cardText}>
<Text style={styles.cardTitle} numberOfLines={1}>
{hostOf(serverUrl)}
</Text>
<View style={styles.cardMetaRow}>
<View style={styles.dot} />
<Text style={styles.cardMeta}>
{serverUrl.startsWith('https://') ? 'JMAP · secure connection' : 'JMAP · unencrypted connection'}
</Text>
</View>
{!serverUrl.startsWith('https://') ? (
<View style={styles.cardMetaRow}>
<View style={styles.dot} />
<Text style={styles.cardMeta}>Unencrypted connection</Text>
</View>
) : null}
</View>
</View>
<LoginNotice
tone="info"
title="Your password stays with your provider"
detail="You'll type it on their page, not into this app. Bulwark keeps a token it can revoke."
detail="You'll type it on their page. Bulwark only stores a sign-in token, which you can revoke at any time."
/>
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="lg" onPress={onContinue}>
<Button variant="default" size="md" onPress={onContinue}>
Continue
</Button>
@@ -94,22 +99,15 @@ function makeStyles(c: ThemePalette) {
gap: spacing.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.card,
borderRadius: radius.lg,
padding: spacing.lg,
},
cardIcon: {
width: 38,
height: 38,
borderRadius: radius.md,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.md,
},
cardIcon: { width: 24, alignItems: 'center' },
cardText: { flex: 1, gap: 2 },
cardTitle: { ...typography.baseMedium, color: c.text },
cardTitle: { ...typography.bodyMedium, color: c.text },
cardMetaRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
dot: { width: 5, height: 5, borderRadius: radius.full, backgroundColor: c.success },
dot: { width: 5, height: 5, borderRadius: radius.full, backgroundColor: c.warning },
cardMeta: { ...typography.caption, color: c.textSecondary },
links: { alignItems: 'center', gap: spacing.md, paddingVertical: spacing.xs },
+3 -3
View File
@@ -39,7 +39,7 @@ export default function EmailStep({
{isAddMode ? "What's the other address?" : "What's your email address?"}
</Text>
<Text style={styles.subtitle}>
That&apos;s usually all we need — we&apos;ll look up the mail server for you.
That&apos;s usually all we need to find your mail server.
</Text>
</View>
@@ -59,11 +59,11 @@ export default function EmailStep({
leftIcon={<Mail size={17} color={c.textMuted} />}
/>
<Text style={styles.hint}>Works with Bulwark, Stalwart, and any JMAP mail server.</Text>
<Text style={styles.hint}>Works with Stalwart and any other JMAP mail server.</Text>
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="lg" onPress={onSubmit} loading={isSearching}>
<Button variant="default" size="md" onPress={onSubmit} loading={isSearching}>
{isSearching ? 'Looking up your server…' : 'Continue'}
</Button>
+3 -3
View File
@@ -11,7 +11,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { ArrowLeft, X } from 'lucide-react-native';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
@@ -93,11 +93,11 @@ function makeStyles(c: ThemePalette) {
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
height: 44,
height: componentSizes.headerHeight,
},
headerButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
content: {
paddingHorizontal: spacing.xxl,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xxxl,
},
contentCentered: { flexGrow: 1, justifyContent: 'center' },
+17 -30
View File
@@ -7,7 +7,7 @@ import { useColors } from '../../theme/colors';
interface OptionTileProps {
title: string;
description?: string;
/** Receives the colour the tile's text renders in. */
/** Receives the colour the tile's icon renders in. */
renderIcon: (color: string, size: number) => React.ReactNode;
onPress: () => void;
emphasis?: 'primary' | 'default';
@@ -17,7 +17,8 @@ interface OptionTileProps {
/**
* A full-width choice on the sign-in screen: icon, what it does, and what it
* costs you. Used instead of a stack of buttons so each route can say enough
* to be picked without trial and error.
* to be picked without trial and error. Emphasis follows the settings-row
* convention — a tinted icon chip, not a solid card.
*/
export default function OptionTile({
title,
@@ -30,7 +31,7 @@ export default function OptionTile({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isPrimary = emphasis === 'primary';
const contentColor = isPrimary ? c.primaryForeground : c.text;
const iconColor = isPrimary ? c.primary : c.textSecondary;
return (
<Pressable
@@ -40,27 +41,16 @@ export default function OptionTile({
accessibilityLabel={description ? `${title}. ${description}` : title}
style={({ pressed }) => [
styles.tile,
isPrimary ? styles.tilePrimary : styles.tileDefault,
pressed && !disabled && (isPrimary ? styles.pressedPrimary : styles.pressedDefault),
pressed && !disabled && styles.pressed,
disabled && styles.disabled,
]}
>
<View style={styles.icon}>{renderIcon(contentColor, 22)}</View>
<View style={styles.icon}>{renderIcon(iconColor, 20)}</View>
<View style={styles.text}>
<Text style={[styles.title, { color: contentColor }]}>{title}</Text>
{description ? (
<Text
style={[
styles.description,
{ color: isPrimary ? c.primaryForeground : c.textSecondary },
isPrimary && styles.descriptionPrimary,
]}
>
{description}
</Text>
) : null}
<Text style={styles.title}>{title}</Text>
{description ? <Text style={styles.description}>{description}</Text> : null}
</View>
<ChevronRight size={18} color={contentColor} style={styles.chevron} />
<ChevronRight size={18} color={c.textMuted} />
</Pressable>
);
}
@@ -71,21 +61,18 @@ function makeStyles(c: ThemePalette) {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
backgroundColor: c.card,
borderRadius: radius.lg,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.lg,
borderWidth: 1,
borderColor: c.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
},
tileDefault: { backgroundColor: c.surface, borderColor: c.border },
tilePrimary: { backgroundColor: c.primary, borderColor: c.primary },
pressedDefault: { backgroundColor: c.surfaceHover },
pressedPrimary: { backgroundColor: c.primaryDark },
pressed: { backgroundColor: c.surfaceHover },
disabled: { opacity: 0.5 },
icon: { width: 22, alignItems: 'center' },
icon: { width: 24, alignItems: 'center' },
text: { flex: 1, gap: 2 },
title: { ...typography.baseMedium },
description: { ...typography.caption },
descriptionPrimary: { opacity: 0.8 },
chevron: { opacity: 0.5 },
title: { ...typography.bodyMedium, color: c.text },
description: { ...typography.caption, color: c.mutedForeground },
});
}
+1 -1
View File
@@ -81,7 +81,7 @@ export default function PasswordStep({
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="lg" onPress={onSubmit}>
<Button variant="default" size="md" onPress={onSubmit}>
Sign in
</Button>
</View>
+3 -3
View File
@@ -35,7 +35,7 @@ export default function ServerStep({
return (
<View style={styles.root}>
<View style={styles.heading}>
<Text style={styles.title}>Where does your mail live?</Text>
<Text style={styles.title}>What&apos;s your server address?</Text>
<Text style={styles.subtitle}>
{failedDomain
? `We couldn't find a server for ${failedDomain}. If you self-host, the address is the one you use for webmail.`
@@ -61,14 +61,14 @@ export default function ServerStep({
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="lg" onPress={onSubmit}>
<Button variant="default" size="md" onPress={onSubmit}>
Continue
</Button>
<Pressable onPress={onScan} hitSlop={8} style={styles.scanRow}>
<QrCode size={15} color={c.textMuted} />
<Text style={styles.scanText}>
Not sure? Scan a sign-in code instead — it carries the address with it.
If you&apos;re not sure, scan a sign-in code instead. It carries the server address.
</Text>
</Pressable>
</View>
+6 -6
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, ActivityIndicator, Image } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors';
// The white mark disappears on the light palette, so pick per theme.
@@ -80,20 +80,20 @@ function makeStyles(c: ThemePalette) {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.xxl,
paddingHorizontal: spacing.lg,
gap: spacing.xs,
},
avatar: {
width: 60,
height: 60,
width: componentSizes.avatarLg,
height: componentSizes.avatarLg,
borderRadius: radius.full,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
avatarText: { ...typography.h2, color: c.primaryForeground },
logo: { width: 60, height: 60, marginBottom: spacing.md },
avatarText: { ...typography.h3, color: c.primaryForeground },
logo: { width: componentSizes.avatarLg, height: componentSizes.avatarLg, marginBottom: spacing.md },
email: { ...typography.caption, color: c.textMuted },
title: { ...typography.h2, color: c.text, textAlign: 'center' },
detail: { ...typography.body, color: c.textSecondary, textAlign: 'center' },
+101 -8
View File
@@ -5,6 +5,7 @@ vi.mock('../../api/email', () => ({
// New helpers used by the incremental-sync path. Default behavior: behave
// like a first-ever load — no prior state, full re-query expected.
getMailboxesWithState: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
getSharedMailboxes: vi.fn(async () => []),
getMailboxesByIds: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
getMailboxChanges: vi.fn(async () => null),
queryEmails: vi.fn(),
@@ -24,6 +25,8 @@ vi.mock('../../api/email', () => ({
deleteEmail: vi.fn(),
deleteEmails: vi.fn(),
searchEmails: vi.fn(),
unprefixMailboxId: (id: string, accountId?: string) =>
(accountId && id.startsWith(`${accountId}:`) ? id.slice(accountId.length + 1) : id),
}));
// The mutations now route through the offline outbox. In tests we want the
@@ -31,11 +34,11 @@ vi.mock('../../api/email', () => ({
// op's primitive) immediately so the existing api-call assertions still hold,
// without pulling in network-store / NetInfo.
vi.mock('../outbox-store', async () => {
const api = await import('../../api/email') as Record<string, (...args: unknown[]) => Promise<unknown>>;
const runOp = async (op: { kind: string; emailId: string; keywords?: unknown; mailboxIds?: unknown }) => {
if (op.kind === 'keywords') return api.setEmailKeywords(op.emailId, op.keywords);
if (op.kind === 'mailboxes') return api.setEmailMailboxes(op.emailId, op.mailboxIds);
if (op.kind === 'destroy') return api.destroyEmails([op.emailId]);
const api = await import('../../api/email') as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>;
const runOp = async (op: { kind: string; emailId: string; accountId?: string; keywords?: unknown; mailboxIds?: unknown }) => {
if (op.kind === 'keywords') return api.setEmailKeywords(op.emailId, op.keywords, op.accountId);
if (op.kind === 'mailboxes') return api.setEmailMailboxes(op.emailId, op.mailboxIds, op.accountId);
if (op.kind === 'destroy') return api.destroyEmails([op.emailId], op.accountId);
};
const applyOrQueueBatch = async (ops: any[], onlineRun?: () => Promise<void>) => {
if (onlineRun) await onlineRun();
@@ -100,6 +103,8 @@ vi.mock('../../api/jmap-client', () => ({
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
// refreshEmails / loadMoreEmails chunk by this value when fetching ids.
getMaxObjectsInGet: () => 500,
getMaxCallsInRequest: () => 16,
getSharedMailAccounts: () => [{ id: 'grp-1', name: 'Support' }],
},
}));
@@ -110,6 +115,7 @@ import * as emailApi from '../../api/email';
import { useEmailStore } from '../email-store';
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
const mockGetSharedMailboxes = emailApi.getSharedMailboxes as ReturnType<typeof vi.fn>;
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType<typeof vi.fn>;
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
@@ -224,7 +230,7 @@ describe('email-store', () => {
await useEmailStore.getState().markRead('e1');
expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $seen: true });
expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $seen: true }, undefined);
expect(useEmailStore.getState().emails[0].keywords.$seen).toBe(true);
});
});
@@ -238,7 +244,7 @@ describe('email-store', () => {
await useEmailStore.getState().markUnread('e1');
expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $flagged: true });
expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $flagged: true }, undefined);
expect(useEmailStore.getState().emails[0].keywords.$seen).toBeUndefined();
});
});
@@ -276,7 +282,7 @@ describe('email-store', () => {
await useEmailStore.getState().moveToMailbox('e1', 'inbox', 'archive');
expect(mockMoveEmail).toHaveBeenCalledWith('e1', 'inbox', 'archive');
expect(mockMoveEmail).toHaveBeenCalledWith('e1', 'inbox', 'archive', undefined);
expect(useEmailStore.getState().emails).toHaveLength(1);
expect(useEmailStore.getState().emails[0].id).toBe('e2');
});
@@ -467,4 +473,91 @@ describe('email-store', () => {
expect(result).toEqual(fullEmail);
});
});
// ── Shared (Stalwart group account) mailboxes ────────────────────────────
describe('shared mailboxes', () => {
const RIGHTS = {
mayReadItems: true, mayAddItems: true, mayRemoveItems: true,
maySetSeen: true, maySetKeywords: true, mayCreateChild: true,
mayRename: true, mayDelete: true, maySubmit: true,
};
const ownInbox = {
id: 'mb-1', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0,
totalThreads: 0, unreadThreads: 0, myRights: RIGHTS,
accountId: 'acc-1', isShared: false,
} as any;
const sharedInbox = {
id: 'grp-1:s-inbox', originalId: 's-inbox', name: 'Inbox', role: 'inbox',
totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
myRights: RIGHTS, accountId: 'grp-1', accountName: 'Support', isShared: true,
} as any;
const sharedTrash = {
...sharedInbox, id: 'grp-1:s-trash', originalId: 's-trash', name: 'Trash', role: 'trash',
} as any;
it('merges shared folders in alongside the own ones', async () => {
mockGetMailboxesWithState.mockResolvedValue({ list: [ownInbox], state: 'mb-1' });
mockGetSharedMailboxes.mockResolvedValue([sharedInbox]);
await useEmailStore.getState().fetchMailboxes();
expect(useEmailStore.getState().mailboxes).toEqual([ownInbox, sharedInbox]);
});
it('keeps the own folders when a shared account cannot be reached', async () => {
mockGetMailboxesWithState.mockResolvedValue({ list: [ownInbox], state: 'mb-1' });
mockGetSharedMailboxes.mockRejectedValue(new Error('Session expired'));
await useEmailStore.getState().fetchMailboxes();
const state = useEmailStore.getState();
expect(state.mailboxes).toEqual([ownInbox]);
expect(state.error).toBeNull();
});
it('queries a shared folder against its owning account by raw id', async () => {
useEmailStore.setState({ mailboxes: [ownInbox, sharedInbox] });
mockQueryEmails.mockResolvedValue({ ids: ['e1'], total: 1, queryState: 'q-1' });
mockGetEmailsWithState.mockResolvedValue({
list: [{ id: 'e1', subject: 'Hi' }], state: 'em-1',
});
await useEmailStore.getState().selectMailbox('grp-1:s-inbox');
expect(mockQueryEmails).toHaveBeenCalledWith('s-inbox', expect.objectContaining({
accountId: 'grp-1',
filter: { inMailbox: 's-inbox' },
}));
expect(mockGetEmailsWithState).toHaveBeenCalledWith(['e1'], 'grp-1');
// Email state tokens are per-account, so the shared account gets its own.
expect(useEmailStore.getState().emailStates).toEqual({ 'grp-1': 'em-1' });
});
it('deletes from a shared folder against the owning account', async () => {
useEmailStore.setState({
mailboxes: [ownInbox, sharedInbox, sharedTrash],
currentMailboxId: 'grp-1:s-inbox',
emails: [{ id: 'e1', keywords: {}, mailboxIds: { 's-inbox': true } } as any],
});
await useEmailStore.getState().deleteEmail('e1', 'grp-1:s-trash', 'grp-1:s-inbox');
expect(mockDeleteEmail).toHaveBeenCalledWith('e1', 's-trash', 's-inbox', 'grp-1');
expect(useEmailStore.getState().emails).toHaveLength(0);
});
it('refuses to move a message between accounts', async () => {
useEmailStore.setState({
mailboxes: [ownInbox, sharedInbox],
currentMailboxId: 'grp-1:s-inbox',
emails: [{ id: 'e1', keywords: {}, mailboxIds: { 's-inbox': true } } as any],
});
await useEmailStore.getState().moveToMailbox('e1', 'grp-1:s-inbox', 'mb-1');
expect(mockMoveEmail).not.toHaveBeenCalled();
const state = useEmailStore.getState();
expect(state.error).toMatch(/same account/);
expect(state.emails).toHaveLength(1);
});
});
});
+4 -4
View File
@@ -79,7 +79,7 @@ describe('applyOrQueue', () => {
it('runs immediately when online with an empty queue', async () => {
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
expect(result.queued).toBe(false);
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true });
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true }, undefined);
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
@@ -137,9 +137,9 @@ describe('flush', () => {
useNetworkStore.setState({ online: true });
await useOutboxStore.getState().flush();
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true });
expect(setEmailMailboxes).toHaveBeenCalledWith('e2', { trash: true });
expect(destroyEmails).toHaveBeenCalledWith(['e3']);
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true }, undefined);
expect(setEmailMailboxes).toHaveBeenCalledWith('e2', { trash: true }, undefined);
expect(destroyEmails).toHaveBeenCalledWith(['e3'], undefined);
expect(useOutboxStore.getState().entries).toHaveLength(0);
});
+367 -120
View File
@@ -6,6 +6,7 @@ import { jmapClient } from '../api/jmap-client';
import {
getMailboxes as fetchMailboxes,
getMailboxesWithState,
getSharedMailboxes,
getMailboxesByIds,
getMailboxChanges,
queryEmails,
@@ -24,7 +25,9 @@ import {
deleteEmails as apiDeleteEmails,
restoreEmailMailboxes,
searchEmails as apiSearchEmails,
unprefixMailboxId,
} from '../api/email';
import { mailboxesForSiblingOf } from '../lib/mailbox-tree';
import { toWildcardQuery } from '../lib/search-utils';
import { generateAccountId } from '../lib/account-utils';
import { useSettingsStore } from './settings-store';
@@ -54,6 +57,69 @@ function mailboxesAfterMove(
return next;
}
// Where a folder actually lives. The user's own folders keep their raw JMAP id
// and get no account override, so every existing call path stays exactly as it
// was; a shared (Stalwart group account) folder resolves to its owning account
// plus the unprefixed id the server knows it by.
interface MailboxRef {
/** JMAP account override — undefined for the user's own folders. */
accountId?: string;
/** Id to send to the server, with any `<accountId>:` prefix stripped. */
id: string;
}
function refFor(mailboxes: Mailbox[], mailboxId: string): MailboxRef {
const mailbox = mailboxes.find((m) => m.id === mailboxId);
if (!mailbox?.isShared) return { id: mailboxId };
return { accountId: mailbox.accountId, id: mailbox.originalId ?? mailboxId };
}
function rawMailboxId(mailboxes: Mailbox[], mailboxId: string): string {
return refFor(mailboxes, mailboxId).id;
}
// The JMAP account behind the folder currently on screen. Undefined for the
// user's own folders, which keeps every own-mail call on the default path.
function currentAccountId(state: EmailState): string | undefined {
if (!state.currentMailboxId) return undefined;
return refFor(state.mailboxes, state.currentMailboxId).accountId;
}
// Strip the shared-folder id prefix off a whole list. Server-side folder
// matching (archive year/month auto-foldering) compares ids and parent links
// against what Mailbox/set returns, which is always unprefixed.
function toRawMailboxes(mailboxes: Mailbox[]): Mailbox[] {
return mailboxes.map((m) => (m.isShared
? {
...m,
id: m.originalId ?? m.id,
parentId: m.parentId ? unprefixMailboxId(m.parentId, m.accountId) : m.parentId,
}
: m));
}
// Key for `emailStates`. JMAP Email state tokens are per-account, so the
// primary account and each shared account track their own.
const PRIMARY_STATE_KEY = '@primary';
function stateKey(accountId?: string): string {
return accountId ?? PRIMARY_STATE_KEY;
}
// Record (or forget, when the server said cannotCalculateChanges) one
// account's Email state without disturbing the others'.
function withEmailState(
states: Record<string, string>,
accountId: string | undefined,
value: string | undefined,
): Record<string, string> {
const key = stateKey(accountId);
if (value === undefined) {
const { [key]: _drop, ...rest } = states;
return rest;
}
return { ...states, [key]: value };
}
// True only when the JMAP client is actually serving the email-store's active
// account. During an account switch there's a window between
// setActiveAccount() (which swaps the email-store view immediately) and
@@ -91,6 +157,8 @@ export interface UndoEntry {
label: string;
/** Time the entry was created - the snackbar uses this to drive its timer. */
createdAt: number;
/** JMAP account the messages live under; unset for the user's own mail. */
accountId?: string;
/** Each item is one email's pre-action mailboxIds, used to restore it. */
items: Array<{ email: Email; originalMailboxIds: Record<string, boolean> }>;
}
@@ -109,7 +177,9 @@ interface MailboxSnapshot {
interface AccountSnapshot {
mailboxes: Mailbox[];
mailboxState?: string; // JMAP Mailbox state (drives Mailbox/changes)
emailState?: string; // JMAP Email state (drives Email/changes)
// JMAP Email state per JMAP account (drives Email/changes). Keyed by
// `stateKey()`: the primary account plus any shared/group accounts.
emailStates: Record<string, string>;
currentMailboxId: string | null;
mailboxSnapshots: Record<string, MailboxSnapshot>;
}
@@ -118,7 +188,7 @@ export interface EmailState {
// ── Per-account persisted caches ──────────────────────────────
// accountSnapshots is the source of truth for accounts the user is *not*
// currently viewing. The active account's data lives in the top-level
// fields below (`mailboxes`, `mailboxSnapshots`, `mailboxState`, `emailState`,
// fields below (`mailboxes`, `mailboxSnapshots`, `mailboxState`, `emailStates`,
// `currentMailboxId`, `emails`, `totalEmails`, `queryState`) so consumers
// keep reading the same shape they always have.
accountSnapshots: Record<string, AccountSnapshot>;
@@ -127,7 +197,7 @@ export interface EmailState {
// ── Active view (the currently-shown account/mailbox) ─────────
mailboxes: Mailbox[];
mailboxState?: string;
emailState?: string;
emailStates: Record<string, string>;
currentMailboxId: string | null;
mailboxSnapshots: Record<string, MailboxSnapshot>;
emails: Email[];
@@ -251,7 +321,7 @@ function snapshotFromActive(state: EmailState): AccountSnapshot {
return {
mailboxes: state.mailboxes,
mailboxState: state.mailboxState,
emailState: state.emailState,
emailStates: state.emailStates,
currentMailboxId: state.currentMailboxId,
mailboxSnapshots,
};
@@ -260,7 +330,7 @@ function snapshotFromActive(state: EmailState): AccountSnapshot {
function viewFromSnapshot(snap: AccountSnapshot | null): {
mailboxes: Mailbox[];
mailboxState?: string;
emailState?: string;
emailStates: Record<string, string>;
currentMailboxId: string | null;
mailboxSnapshots: Record<string, MailboxSnapshot>;
emails: Email[];
@@ -271,7 +341,7 @@ function viewFromSnapshot(snap: AccountSnapshot | null): {
return {
mailboxes: [],
mailboxState: undefined,
emailState: undefined,
emailStates: {},
currentMailboxId: null,
mailboxSnapshots: {},
emails: [],
@@ -285,7 +355,7 @@ function viewFromSnapshot(snap: AccountSnapshot | null): {
return {
mailboxes: snap.mailboxes,
mailboxState: snap.mailboxState,
emailState: snap.emailState,
emailStates: snap.emailStates ?? {},
currentMailboxId: snap.currentMailboxId,
mailboxSnapshots: snap.mailboxSnapshots,
emails: mailboxSnap?.emails ?? [],
@@ -297,14 +367,14 @@ function viewFromSnapshot(snap: AccountSnapshot | null): {
// JMAP's maxObjectsInGet bounds how many ids we can pull in one Email/get.
// Chunk to that ceiling (with a small safety fallback) so large change sets
// don't trip 429/413 responses.
async function fetchEmailsChunked(ids: string[]): Promise<Email[]> {
async function fetchEmailsChunked(ids: string[], accountId?: string): Promise<Email[]> {
if (ids.length === 0) return [];
const cap = Math.max(1, jmapClient.getMaxObjectsInGet());
const chunk = Math.min(cap, 200);
if (ids.length <= chunk) return fetchEmails(ids);
if (ids.length <= chunk) return fetchEmails(ids, accountId);
const out: Email[] = [];
for (let i = 0; i < ids.length; i += chunk) {
const slice = await fetchEmails(ids.slice(i, i + chunk));
const slice = await fetchEmails(ids.slice(i, i + chunk), accountId);
out.push(...slice);
}
return out;
@@ -340,7 +410,7 @@ export const useEmailStore = create<EmailState>()(
mailboxes: [],
mailboxState: undefined,
emailState: undefined,
emailStates: {},
currentMailboxId: null,
mailboxSnapshots: {},
emails: [],
@@ -403,7 +473,7 @@ export const useEmailStore = create<EmailState>()(
activeAccountId: null,
mailboxes: [],
mailboxState: undefined,
emailState: undefined,
emailStates: {},
currentMailboxId: null,
mailboxSnapshots: {},
emails: [],
@@ -426,7 +496,7 @@ export const useEmailStore = create<EmailState>()(
activeAccountId: null,
mailboxes: [],
mailboxState: undefined,
emailState: undefined,
emailStates: {},
currentMailboxId: null,
mailboxSnapshots: {},
emails: [],
@@ -455,16 +525,30 @@ export const useEmailStore = create<EmailState>()(
if (!jmapClientServesActiveAccount(activeAccountId)) return;
const prevState = get().mailboxState;
// Swap in a freshly-synced set of own folders while leaving the shared
// (group account) ones alone, and vice versa — the two are fetched by
// separate calls and must not clobber each other.
const replaceOwn = (own: Mailbox[], mailboxState?: string) => {
set({
mailboxes: [...own, ...get().mailboxes.filter((m) => m.isShared)],
...(mailboxState !== undefined ? { mailboxState } : {}),
});
};
try {
let drainAgain = false;
// Incremental path: ask for just what changed since last time. Fall
// through to a full refetch when the server can't compute the diff or
// we have no previous state to compare against.
let syncedOwn = false;
if (prevState) {
const changes = await getMailboxChanges(prevState);
// Bail if the user switched accounts during the await — anything we
// set() now would land in the wrong account's bucket.
if (get().activeAccountId !== activeAccountId) return;
if (changes) {
syncedOwn = true;
// No changes at all — keep the cached list, just bump the state.
if (
changes.created.length === 0 &&
@@ -472,35 +556,50 @@ export const useEmailStore = create<EmailState>()(
changes.destroyed.length === 0
) {
set({ mailboxState: changes.newState });
return;
} else {
const toFetch = [...changes.created, ...changes.updated];
const fetched = toFetch.length > 0
? (await getMailboxesByIds(toFetch)).list
: [];
if (get().activeAccountId !== activeAccountId) return;
const destroyed = new Set(changes.destroyed);
const byId = new Map<string, Mailbox>();
for (const m of get().mailboxes) {
if (!m.isShared) byId.set(m.id, m);
}
for (const m of fetched) byId.set(m.id, m);
for (const id of destroyed) byId.delete(id);
replaceOwn(
Array.from(byId.values()),
changes.hasMoreChanges ? prevState : changes.newState,
);
// hasMoreChanges = there are still pending changes past the
// server's response cap. Run the same path again to drain.
drainAgain = changes.hasMoreChanges;
}
const toFetch = [...changes.created, ...changes.updated];
const fetched = toFetch.length > 0
? (await getMailboxesByIds(toFetch)).list
: [];
if (get().activeAccountId !== activeAccountId) return;
const destroyed = new Set(changes.destroyed);
const byId = new Map<string, Mailbox>();
for (const m of get().mailboxes) byId.set(m.id, m);
for (const m of fetched) byId.set(m.id, m);
for (const id of destroyed) byId.delete(id);
set({
mailboxes: Array.from(byId.values()),
mailboxState: changes.hasMoreChanges ? prevState : changes.newState,
});
// hasMoreChanges = there are still pending changes past the
// server's response cap. Run the same path again to drain.
if (changes.hasMoreChanges) {
void get().fetchMailboxes();
}
return;
}
// changes === null → cannotCalculateChanges. Fall through to full.
}
const { list, state } = await getMailboxesWithState();
if (get().activeAccountId !== activeAccountId) return;
set({ mailboxes: list, mailboxState: state });
if (!syncedOwn) {
const { list, state } = await getMailboxesWithState();
if (get().activeAccountId !== activeAccountId) return;
replaceOwn(list, state);
}
// Shared/group accounts have their own Mailbox state tokens, and there
// are only ever a handful of them, so they're re-read in full rather
// than diffed. Failing to reach one must not lose the own folders we
// just synced, hence the separate try.
try {
const shared = await getSharedMailboxes();
if (get().activeAccountId !== activeAccountId) return;
set({ mailboxes: [...get().mailboxes.filter((m) => !m.isShared), ...shared] });
} catch (err) {
console.warn('[email-store] shared mailbox fetch failed:', err);
}
if (drainAgain) void get().fetchMailboxes();
} catch (err) {
console.warn('[email-store] fetchMailboxes failed:', err);
if (get().activeAccountId !== activeAccountId) return;
@@ -550,7 +649,12 @@ export const useEmailStore = create<EmailState>()(
if (cacheStore.totalCount() > 0) {
try {
const limit = useSettingsStore.getState().emailsPerPage;
seededEmails = await cacheStore.getEmailsInMailbox(mailboxId, Math.max(limit, 50));
// Cached messages carry raw JMAP mailboxIds, so look up by the
// unprefixed id rather than the sidebar's shared-folder key.
seededEmails = await cacheStore.getEmailsInMailbox(
rawMailboxId(state.mailboxes, mailboxId),
Math.max(limit, 50),
);
seededTotal = seededEmails.length;
} catch (err) {
console.warn('[email-store] cache seed failed:', err);
@@ -590,15 +694,17 @@ export const useEmailStore = create<EmailState>()(
set({ loading: true });
try {
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
const ref = refFor(get().mailboxes, currentMailboxId);
const filter = buildJmapFilter(ref.id, searchQuery, filters);
const limit = useSettingsStore.getState().emailsPerPage;
const { ids } = await queryEmails(currentMailboxId, {
const { ids } = await queryEmails(ref.id, {
position: emails.length,
limit,
filter,
accountId: ref.accountId,
});
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
const newEmails = ids.length > 0 ? await fetchEmailsChunked(ids) : [];
const newEmails = ids.length > 0 ? await fetchEmailsChunked(ids, ref.accountId) : [];
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
const merged = [...emails, ...newEmails];
const updates: Partial<EmailState> = { emails: merged, loading: false };
@@ -624,6 +730,9 @@ export const useEmailStore = create<EmailState>()(
// import time (that native dep can't load in the test/SSR environment).
const { uploadBytes } = await import('../api/blob');
const { expandImportableEml } = await import('../lib/eml-import');
// Both the blob upload and the import have to target the folder's owning
// account, or the import references a blob the server can't see.
const ref = refFor(get().mailboxes, mailboxId);
let imported = 0;
let failed = 0;
for (const file of files) {
@@ -633,8 +742,8 @@ export const useEmailStore = create<EmailState>()(
if (emls.length === 0) failed += 1;
for (const eml of emls) {
try {
const { blobId } = await uploadBytes(eml.bytes, 'message/rfc822');
await importEmailBlob(blobId, mailboxId);
const { blobId } = await uploadBytes(eml.bytes, 'message/rfc822', ref.accountId);
await importEmailBlob(blobId, ref.id, undefined, ref.accountId);
imported += 1;
} catch {
failed += 1;
@@ -653,12 +762,16 @@ export const useEmailStore = create<EmailState>()(
refreshEmails: async () => {
const state = get();
const { currentMailboxId, searchQuery, filters, emails: existing, emailState, activeAccountId } = state;
const { currentMailboxId, searchQuery, filters, emails: existing, activeAccountId } = state;
if (!currentMailboxId) return;
if (!jmapClientServesActiveAccount(activeAccountId)) return;
set({ loading: true, error: null });
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
// A shared (group account) folder is queried against its owning account
// with its unprefixed id; own folders resolve to no override at all.
const ref = refFor(state.mailboxes, currentMailboxId);
const emailState = state.emailStates[stateKey(ref.accountId)];
const filter = buildJmapFilter(ref.id, searchQuery, filters);
const limit = useSettingsStore.getState().emailsPerPage;
const baseView = isBaseView(searchQuery, filters);
@@ -692,8 +805,9 @@ export const useEmailStore = create<EmailState>()(
snap.emails.length >= Math.min(limit, snap.total)
) {
const baseEmails = snap.emails;
const queryChanges = await getEmailQueryChanges(currentMailboxId, snap.queryState, {
const queryChanges = await getEmailQueryChanges(ref.id, snap.queryState, {
filter: undefined,
accountId: ref.accountId,
});
if (queryChanges) {
// What's in the visible window now: drop removed ids, then apply
@@ -709,7 +823,7 @@ export const useEmailStore = create<EmailState>()(
let destroyedExtra: string[] = [];
let nextEmailState: string | undefined = emailState;
if (emailState) {
const ec = await getEmailChanges(emailState);
const ec = await getEmailChanges(emailState, undefined, ref.accountId);
if (ec) {
updatedIds = ec.updated;
destroyedExtra = ec.destroyed;
@@ -732,7 +846,7 @@ export const useEmailStore = create<EmailState>()(
let fetchState: string | undefined;
let fetched: Email[] = [];
if (idsToFetch.length > 0) {
const res = await getEmailsWithState(idsToFetch);
const res = await getEmailsWithState(idsToFetch, ref.accountId);
fetched = res.list;
fetchState = res.state;
}
@@ -768,7 +882,11 @@ export const useEmailStore = create<EmailState>()(
emails: trimmed,
totalEmails: nextTotal,
queryState: nextQueryState,
emailState: nextEmailState ?? fetchState ?? emailState,
emailStates: withEmailState(
get().emailStates,
ref.accountId,
nextEmailState ?? fetchState ?? emailState,
),
loading: false,
mailboxSnapshots: {
...get().mailboxSnapshots,
@@ -788,9 +906,9 @@ export const useEmailStore = create<EmailState>()(
// Full re-query path. Used when there's no prior queryState, when the
// user has search/filters active (queryState only tracks the base
// query), or when the server returned cannotCalculateChanges above.
const queryRes = await queryEmails(currentMailboxId, { limit, filter });
const queryRes = await queryEmails(ref.id, { limit, filter, accountId: ref.accountId });
const fetched = queryRes.ids.length > 0
? await getEmailsWithState(queryRes.ids)
? await getEmailsWithState(queryRes.ids, ref.accountId)
: { list: [], state: undefined as string | undefined };
if (viewChanged()) return;
@@ -802,7 +920,7 @@ export const useEmailStore = create<EmailState>()(
};
if (baseView) {
updates.queryState = queryRes.queryState;
updates.emailState = fetched.state;
updates.emailStates = withEmailState(get().emailStates, ref.accountId, fetched.state);
updates.mailboxSnapshots = {
...get().mailboxSnapshots,
[currentMailboxId]: {
@@ -825,7 +943,7 @@ export const useEmailStore = create<EmailState>()(
if (!cacheStore.hydrated) await cacheStore.hydrate();
if (cacheStore.totalCount() > 0) {
const cached = await cacheStore.getEmailsInMailbox(
currentMailboxId,
ref.id,
Math.max(limit, 50),
);
if (
@@ -854,12 +972,26 @@ export const useEmailStore = create<EmailState>()(
// currently showing (e.g. push notifications received during/just after
// an account switch).
if (!jmapClientServesActiveAccount(get().activeAccountId)) return;
const accountId = jmapClient.accountId;
const accountChanges = change.changed?.[accountId];
if (!accountChanges) return;
// Push/EventSource state changes cover every account in the session, so a
// shared (group account) mailbox reports under its own account id. Fold
// them all in: any account's Mailbox change refreshes the folder list, but
// only the account behind the open folder needs its message list re-read.
const primaryId = jmapClient.accountId;
const state = get();
const currentAccountId =
state.currentMailboxId
? refFor(state.mailboxes, state.currentMailboxId).accountId ?? primaryId
: primaryId;
const known = new Set([primaryId, ...jmapClient.getSharedMailAccounts().map((a) => a.id)]);
const mailboxChanged = 'Mailbox' in accountChanges;
const emailChanged = 'Email' in accountChanges || 'EmailDelivery' in accountChanges;
let mailboxChanged = false;
let emailChanged = false;
for (const [accountId, accountChanges] of Object.entries(change.changed ?? {})) {
if (!known.has(accountId) || !accountChanges) continue;
if ('Mailbox' in accountChanges) mailboxChanged = true;
if (accountId !== currentAccountId) continue;
if ('Email' in accountChanges || 'EmailDelivery' in accountChanges) emailChanged = true;
}
if (!mailboxChanged && !emailChanged) return;
if (mailboxChanged) {
@@ -925,7 +1057,8 @@ export const useEmailStore = create<EmailState>()(
},
markRead: async (emailId, accountId) => {
const email = get().emails.find((e) => e.id === emailId);
const state = get();
const email = state.emails.find((e) => e.id === emailId);
const nextKeywords = { ...(email?.keywords ?? {}), $seen: true };
// A group/shared message opened from the unified inbox lives under another
// JMAP account and isn't in the active list/cache or the (account-scoped)
@@ -934,7 +1067,8 @@ export const useEmailStore = create<EmailState>()(
await setEmailKeywords(emailId, nextKeywords, accountId);
return;
}
await applyOrQueue({ kind: 'keywords', emailId, keywords: nextKeywords });
const owner = accountId ?? currentAccountId(state);
await applyOrQueue({ kind: 'keywords', emailId, accountId: owner, keywords: nextKeywords });
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords: nextKeywords } : e,
@@ -944,10 +1078,16 @@ export const useEmailStore = create<EmailState>()(
},
markUnread: async (emailId) => {
const email = get().emails.find((e) => e.id === emailId);
const state = get();
const email = state.emails.find((e) => e.id === emailId);
if (!email) return;
const { $seen, ...rest } = email.keywords;
await applyOrQueue({ kind: 'keywords', emailId, keywords: rest });
await applyOrQueue({
kind: 'keywords',
emailId,
accountId: currentAccountId(state),
keywords: rest,
});
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords: rest } : e,
@@ -957,7 +1097,8 @@ export const useEmailStore = create<EmailState>()(
},
toggleStar: async (emailId, starred) => {
const email = get().emails.find((e) => e.id === emailId);
const state = get();
const email = state.emails.find((e) => e.id === emailId);
if (!email) return;
const keywords = { ...email.keywords };
if (starred) {
@@ -965,7 +1106,12 @@ export const useEmailStore = create<EmailState>()(
} else {
delete keywords.$flagged;
}
await applyOrQueue({ kind: 'keywords', emailId, keywords });
await applyOrQueue({
kind: 'keywords',
emailId,
accountId: currentAccountId(state),
keywords,
});
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords } : e,
@@ -975,7 +1121,8 @@ export const useEmailStore = create<EmailState>()(
},
togglePin: async (emailId, pinned) => {
const email = get().emails.find((e) => e.id === emailId);
const state = get();
const email = state.emails.find((e) => e.id === emailId);
if (!email) return;
const keywords = { ...email.keywords };
if (pinned) {
@@ -983,7 +1130,12 @@ export const useEmailStore = create<EmailState>()(
} else {
delete keywords.$important;
}
await applyOrQueue({ kind: 'keywords', emailId, keywords });
await applyOrQueue({
kind: 'keywords',
emailId,
accountId: currentAccountId(state),
keywords,
});
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords } : e,
@@ -993,13 +1145,23 @@ export const useEmailStore = create<EmailState>()(
},
moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => {
const email = get().emails.find((e) => e.id === emailId);
const state = get();
const email = state.emails.find((e) => e.id === emailId);
const from = refFor(state.mailboxes, fromMailboxId);
const to = refFor(state.mailboxes, toMailboxId);
// A single Email/set is scoped to one account, so there's no move between
// the user's own folders and a shared account's (or between two shared
// accounts) — that would be a copy-then-delete across accounts.
if (from.accountId !== to.accountId) {
set({ error: 'Messages can only be moved within the same account' });
return;
}
const original = email ? { ...email.mailboxIds } : null;
const target = mailboxesAfterMove(email?.mailboxIds, fromMailboxId, toMailboxId);
const target = mailboxesAfterMove(email?.mailboxIds, from.id, to.id);
await applyOrQueue(
{ kind: 'mailboxes', emailId, mailboxIds: target },
() => moveEmail(emailId, fromMailboxId, toMailboxId),
{ kind: 'mailboxes', emailId, accountId: from.accountId, mailboxIds: target },
() => moveEmail(emailId, from.id, to.id, from.accountId),
);
set({ emails: get().emails.filter((e) => e.id !== emailId) });
patchCache(emailId, { mailboxIds: target });
@@ -1011,6 +1173,7 @@ export const useEmailStore = create<EmailState>()(
kind: 'move',
label: targetName ? `Email moved to ${targetName}` : 'Email moved',
createdAt: Date.now(),
accountId: from.accountId,
items: [{ email, originalMailboxIds: original }],
},
});
@@ -1018,15 +1181,19 @@ export const useEmailStore = create<EmailState>()(
},
archiveEmail: async (emailId) => {
const { emails, mailboxes } = get();
const email = emails.find((e) => e.id === emailId);
const state = get();
const email = state.emails.find((e) => e.id === emailId);
if (!email) return;
const archiveMailbox = mailboxes.find(
// Archive into the *same account's* Archive folder — a shared mailbox's
// messages can't be filed into the user's own.
const scoped = mailboxesForSiblingOf(state.mailboxes, state.currentMailboxId);
const archiveMailbox = scoped.find(
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
);
if (!archiveMailbox) return;
if (email.mailboxIds?.[archiveMailbox.id]) return;
const archive = refFor(state.mailboxes, archiveMailbox.id);
if (email.mailboxIds?.[archive.id]) return;
const mode = useSettingsStore.getState().archiveMode;
const original = { ...email.mailboxIds };
@@ -1035,12 +1202,18 @@ export const useEmailStore = create<EmailState>()(
// archive root (we can't create folders without a connection); the queued
// op replays as a plain move into Archive.
const { queued } = await applyOrQueue(
{ kind: 'mailboxes', emailId, mailboxIds: { [archiveMailbox.id]: true } },
{
kind: 'mailboxes',
emailId,
accountId: archive.accountId,
mailboxIds: { [archive.id]: true },
},
() => apiArchiveEmails(
[{ id: email.id, receivedAt: email.receivedAt }],
archiveMailbox.id,
archive.id,
mode,
mailboxes,
toRawMailboxes(scoped),
archive.accountId,
),
);
@@ -1050,10 +1223,11 @@ export const useEmailStore = create<EmailState>()(
kind: 'archive',
label: 'Email archived',
createdAt: Date.now(),
accountId: archive.accountId,
items: [{ email, originalMailboxIds: original }],
},
});
patchCache(emailId, { mailboxIds: { [archiveMailbox.id]: true } });
patchCache(emailId, { mailboxIds: { [archive.id]: true } });
// Auto-sort modes may have created new year/month folders - refresh the
// mailbox list so the sidebar picks them up on the next render. Skip when
@@ -1064,12 +1238,16 @@ export const useEmailStore = create<EmailState>()(
},
deleteEmail: async (emailId, trashMailboxId, currentMailboxId) => {
const email = get().emails.find((e) => e.id === emailId);
const state = get();
const email = state.emails.find((e) => e.id === emailId);
const original = email ? { ...email.mailboxIds } : null;
const settings = useSettingsStore.getState();
const { mailboxes } = get();
const junkMailbox = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
const inJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
const trash = refFor(state.mailboxes, trashMailboxId);
const source = refFor(state.mailboxes, currentMailboxId);
const junkMailbox = mailboxesForSiblingOf(state.mailboxes, currentMailboxId)
.find((m) => m.role === 'junk' || m.role === 'spam');
const junkId = junkMailbox ? rawMailboxId(state.mailboxes, junkMailbox.id) : null;
const inJunk = !!(junkId && email?.mailboxIds?.[junkId]);
const inTrash = currentMailboxId === trashMailboxId;
// Resolve effective destination:
@@ -1086,21 +1264,26 @@ export const useEmailStore = create<EmailState>()(
// Use the trash mailbox as the "current" so apiDeleteEmail takes the
// destroy branch even when the source folder isn't trash.
await applyOrQueue(
{ kind: 'destroy', emailId },
() => apiDeleteEmail(emailId, trashMailboxId, trashMailboxId),
{ kind: 'destroy', emailId, accountId: trash.accountId },
() => apiDeleteEmail(emailId, trash.id, trash.id, trash.accountId),
);
dropFromCache([emailId]);
} else {
const target = mailboxesAfterMove(email?.mailboxIds, currentMailboxId, trashMailboxId);
const target = mailboxesAfterMove(email?.mailboxIds, source.id, trash.id);
await applyOrQueue(
{ kind: 'mailboxes', emailId, mailboxIds: target },
() => apiDeleteEmail(emailId, trashMailboxId, currentMailboxId),
{ kind: 'mailboxes', emailId, accountId: source.accountId, mailboxIds: target },
() => apiDeleteEmail(emailId, trash.id, source.id, source.accountId),
);
// "Move to Trash and mark as read" (#323): when the user picked that
// delete action, also clear unread state for messages moved to trash.
if (settings.deleteAction === 'trash-and-read' && email && !email.keywords?.$seen) {
const nextKeywords = { ...email.keywords, $seen: true };
await applyOrQueue({ kind: 'keywords', emailId, keywords: nextKeywords });
await applyOrQueue({
kind: 'keywords',
emailId,
accountId: source.accountId,
keywords: nextKeywords,
});
patchCache(emailId, { mailboxIds: target, keywords: nextKeywords });
} else {
patchCache(emailId, { mailboxIds: target });
@@ -1116,6 +1299,7 @@ export const useEmailStore = create<EmailState>()(
kind: 'delete',
label: 'Email moved to Trash',
createdAt: Date.now(),
accountId: source.accountId,
items: [{ email, originalMailboxIds: original }],
},
});
@@ -1127,27 +1311,35 @@ export const useEmailStore = create<EmailState>()(
// so a multi-select archive/move/delete is reversed with one snackbar tap.
archiveEmailsBatch: async (emailIds) => {
const { emails, mailboxes } = get();
const archiveMailbox = mailboxes.find(
const state = get();
const scoped = mailboxesForSiblingOf(state.mailboxes, state.currentMailboxId);
const archiveMailbox = scoped.find(
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
);
if (!archiveMailbox) return;
const targets = emails.filter(
(e) => emailIds.includes(e.id) && !e.mailboxIds?.[archiveMailbox.id],
const archive = refFor(state.mailboxes, archiveMailbox.id);
const targets = state.emails.filter(
(e) => emailIds.includes(e.id) && !e.mailboxIds?.[archive.id],
);
if (targets.length === 0) return;
const mode = useSettingsStore.getState().archiveMode;
const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } }));
const archiveTarget = { [archiveMailbox.id]: true };
const archiveTarget = { [archive.id]: true };
const { queued } = await applyOrQueueBatch(
targets.map((e): OutboxOp => ({ kind: 'mailboxes', emailId: e.id, mailboxIds: archiveTarget })),
targets.map((e): OutboxOp => ({
kind: 'mailboxes',
emailId: e.id,
accountId: archive.accountId,
mailboxIds: archiveTarget,
})),
() => apiArchiveEmails(
targets.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
archiveMailbox.id,
archive.id,
mode,
mailboxes,
toRawMailboxes(scoped),
archive.accountId,
),
);
@@ -1158,6 +1350,7 @@ export const useEmailStore = create<EmailState>()(
kind: 'archive',
label: targets.length === 1 ? 'Email archived' : `${targets.length} emails archived`,
createdAt: Date.now(),
accountId: archive.accountId,
items,
},
});
@@ -1169,6 +1362,13 @@ export const useEmailStore = create<EmailState>()(
moveEmailsToMailbox: async (emailIds, toMailboxId) => {
const { emails, currentMailboxId, mailboxes } = get();
if (!currentMailboxId || toMailboxId === currentMailboxId) return;
const source = refFor(mailboxes, currentMailboxId);
const to = refFor(mailboxes, toMailboxId);
// See moveToMailbox: one Email/set can't span two accounts.
if (source.accountId !== to.accountId) {
set({ error: 'Messages can only be moved within the same account' });
return;
}
const targets = emails.filter((e) => emailIds.includes(e.id));
if (targets.length === 0) return;
@@ -1178,14 +1378,15 @@ export const useEmailStore = create<EmailState>()(
targets.map((e): OutboxOp => ({
kind: 'mailboxes',
emailId: e.id,
mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, toMailboxId),
accountId: source.accountId,
mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, to.id),
})),
() => apiMoveEmails(targets.map((e) => e.id), currentMailboxId, toMailboxId),
() => apiMoveEmails(targets.map((e) => e.id), source.id, to.id, source.accountId),
);
const removed = new Set(targets.map((e) => e.id));
for (const e of targets) {
patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, toMailboxId) });
patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, to.id) });
}
const targetName = mailboxes.find((m) => m.id === toMailboxId)?.name;
set({
@@ -1196,6 +1397,7 @@ export const useEmailStore = create<EmailState>()(
? `${targets.length === 1 ? 'Email' : `${targets.length} emails`} moved to ${targetName}`
: 'Emails moved',
createdAt: Date.now(),
accountId: source.accountId,
items,
},
});
@@ -1204,7 +1406,11 @@ export const useEmailStore = create<EmailState>()(
deleteEmailsBatch: async (emailIds, trashMailboxId, currentMailboxId) => {
const { emails, mailboxes } = get();
const settings = useSettingsStore.getState();
const junkMailbox = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
const trash = refFor(mailboxes, trashMailboxId);
const source = refFor(mailboxes, currentMailboxId);
const junkMailbox = mailboxesForSiblingOf(mailboxes, currentMailboxId)
.find((m) => m.role === 'junk' || m.role === 'spam');
const junkId = junkMailbox ? rawMailboxId(mailboxes, junkMailbox.id) : null;
const inTrash = currentMailboxId === trashMailboxId;
const targets = emails.filter((e) => emailIds.includes(e.id));
if (targets.length === 0) return;
@@ -1215,7 +1421,7 @@ export const useEmailStore = create<EmailState>()(
const toDestroy: Email[] = [];
const toTrash: Email[] = [];
for (const e of targets) {
const inJunk = !!(junkMailbox && e.mailboxIds?.[junkMailbox.id]);
const inJunk = !!(junkId && e.mailboxIds?.[junkId]);
const destroy =
inTrash ||
settings.deleteAction === 'permanent' ||
@@ -1234,28 +1440,35 @@ export const useEmailStore = create<EmailState>()(
);
const ops: OutboxOp[] = [
...toDestroy.map((e): OutboxOp => ({ kind: 'destroy', emailId: e.id })),
...toDestroy.map((e): OutboxOp => ({
kind: 'destroy',
emailId: e.id,
accountId: trash.accountId,
})),
...toTrash.map((e): OutboxOp => ({
kind: 'mailboxes',
emailId: e.id,
mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, trashMailboxId),
accountId: source.accountId,
mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, trash.id),
})),
...toMarkRead.map((e): OutboxOp => ({
kind: 'keywords',
emailId: e.id,
accountId: source.accountId,
keywords: markReadKeywords.get(e.id)!,
})),
];
await applyOrQueueBatch(ops, async () => {
if (toDestroy.length > 0) {
await apiDeleteEmails(toDestroy.map((e) => e.id), trashMailboxId, trashMailboxId);
await apiDeleteEmails(toDestroy.map((e) => e.id), trash.id, trash.id, trash.accountId);
}
if (toTrash.length > 0) {
await apiMoveEmails(toTrash.map((e) => e.id), currentMailboxId, trashMailboxId);
await apiMoveEmails(toTrash.map((e) => e.id), source.id, trash.id, source.accountId);
}
if (toMarkRead.length > 0) {
await setKeywordsForEmails(
toMarkRead.map((e) => ({ id: e.id, keywords: markReadKeywords.get(e.id)! })),
source.accountId,
);
}
});
@@ -1263,7 +1476,7 @@ export const useEmailStore = create<EmailState>()(
if (toDestroy.length > 0) dropFromCache(toDestroy.map((e) => e.id));
for (const e of toTrash) {
patchCache(e.id, {
mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, trashMailboxId),
mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, trash.id),
...(markReadKeywords.has(e.id) ? { keywords: markReadKeywords.get(e.id) } : {}),
});
}
@@ -1278,6 +1491,7 @@ export const useEmailStore = create<EmailState>()(
kind: 'delete',
label: toTrash.length === 1 ? 'Email moved to Trash' : `${toTrash.length} emails moved to Trash`,
createdAt: Date.now(),
accountId: source.accountId,
items: toTrash.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } })),
},
});
@@ -1285,8 +1499,10 @@ export const useEmailStore = create<EmailState>()(
},
setKeywordForEmails: async (emailIds, token, on) => {
const targets = get().emails.filter((e) => emailIds.includes(e.id));
const state = get();
const targets = state.emails.filter((e) => emailIds.includes(e.id));
if (targets.length === 0) return;
const owner = currentAccountId(state);
const updates = targets.map((e) => {
const keywords = { ...e.keywords };
if (on) keywords[token] = true;
@@ -1294,8 +1510,13 @@ export const useEmailStore = create<EmailState>()(
return { id: e.id, keywords };
});
await applyOrQueueBatch(
updates.map((u): OutboxOp => ({ kind: 'keywords', emailId: u.id, keywords: u.keywords })),
() => setKeywordsForEmails(updates),
updates.map((u): OutboxOp => ({
kind: 'keywords',
emailId: u.id,
accountId: owner,
keywords: u.keywords,
})),
() => setKeywordsForEmails(updates, owner),
);
const byId = new Map(updates.map((u) => [u.id, u.keywords]));
set({
@@ -1316,10 +1537,12 @@ export const useEmailStore = create<EmailState>()(
entry.items.map((it): OutboxOp => ({
kind: 'mailboxes',
emailId: it.email.id,
accountId: entry.accountId,
mailboxIds: it.originalMailboxIds,
})),
() => restoreEmailMailboxes(
entry.items.map((it) => ({ id: it.email.id, mailboxIds: it.originalMailboxIds })),
entry.accountId,
),
);
for (const it of entry.items) {
@@ -1333,10 +1556,11 @@ export const useEmailStore = create<EmailState>()(
// Re-insert each restored email into the visible list if its original
// mailboxIds include the current view. Server is the source of truth for
// ordering, but local re-insertion gives the user instant feedback.
const { currentMailboxId, emails } = get();
const { currentMailboxId, emails, mailboxes } = get();
if (currentMailboxId) {
const currentRawId = rawMailboxId(mailboxes, currentMailboxId);
const restored = entry.items
.filter((it) => it.originalMailboxIds[currentMailboxId])
.filter((it) => it.originalMailboxIds[currentRawId])
.map((it) => ({ ...it.email, mailboxIds: it.originalMailboxIds }));
if (restored.length > 0) {
const merged = [...restored, ...emails].sort(
@@ -1350,15 +1574,18 @@ export const useEmailStore = create<EmailState>()(
clearUndo: () => set({ pendingUndo: null }),
searchEmails: async (query) => {
const ids = await apiSearchEmails(query);
// Search the account whose folder is open, so a shared mailbox searches
// its own messages rather than the user's.
const owner = currentAccountId(get());
const ids = await apiSearchEmails(query, undefined, 30, owner);
if (ids.length === 0) return [];
return fetchEmails(ids);
return fetchEmails(ids, owner);
},
reset: () => set({
mailboxes: [],
mailboxState: undefined,
emailState: undefined,
emailStates: {},
currentMailboxId: null,
mailboxSnapshots: {},
emails: [],
@@ -1377,19 +1604,39 @@ export const useEmailStore = create<EmailState>()(
// refresh once the session is ready.
name: 'email-cache',
storage: createJSONStorage(() => AsyncStorage),
version: 1,
version: 2,
// v0 → v1: drop every cached queryState. Pre-v1 builds could persist a
// search-result list next to the base view's queryState, and the
// incremental sync path would then "confirm" those search results as
// the whole mailbox and bake them into the snapshot (issue #10).
// Without a queryState the next refresh does a full re-query, which
// rebuilds any poisoned window from the server.
//
// v1 → v2: the single `emailState` became `emailStates`, keyed per JMAP
// account so shared (group account) folders track their own. The old
// token is dropped; the next refresh re-primes it from Email/get.
migrate: (persisted, version) => {
if (version >= 1) return persisted as EmailState;
const s = persisted as Pick<
EmailState,
'accountSnapshots' | 'mailboxSnapshots' | 'queryState'
> & Record<string, unknown>;
const dropEmailState = (input: Record<string, unknown>): Record<string, unknown> => {
const { emailState: _drop, ...rest } = input;
return { ...rest, emailStates: {} };
};
if (version >= 2) return persisted as EmailState;
if (version >= 1) {
const accountSnapshots: Record<string, AccountSnapshot> = {};
for (const [id, acc] of Object.entries(s.accountSnapshots ?? {})) {
accountSnapshots[id] = dropEmailState(
acc as unknown as Record<string, unknown>,
) as unknown as AccountSnapshot;
}
return { ...dropEmailState(s), accountSnapshots } as unknown as EmailState;
}
const stripQueryStates = (
snaps: Record<string, MailboxSnapshot> | undefined,
): Record<string, MailboxSnapshot> =>
@@ -1401,13 +1648,13 @@ export const useEmailStore = create<EmailState>()(
);
const accountSnapshots: Record<string, AccountSnapshot> = {};
for (const [id, acc] of Object.entries(s.accountSnapshots ?? {})) {
accountSnapshots[id] = {
accountSnapshots[id] = dropEmailState({
...acc,
mailboxSnapshots: stripQueryStates(acc.mailboxSnapshots),
};
}) as unknown as AccountSnapshot;
}
return {
...s,
...dropEmailState(s),
accountSnapshots,
mailboxSnapshots: stripQueryStates(s.mailboxSnapshots),
queryState: undefined,
@@ -1418,7 +1665,7 @@ export const useEmailStore = create<EmailState>()(
activeAccountId: state.activeAccountId,
mailboxes: state.mailboxes,
mailboxState: state.mailboxState,
emailState: state.emailState,
emailStates: state.emailStates,
currentMailboxId: state.currentMailboxId,
mailboxSnapshots: state.mailboxSnapshots,
emails: state.emails,
+16 -10
View File
@@ -34,10 +34,13 @@ function storageKey(accountId: string): string {
return `${KEY_PREFIX}${accountId}`;
}
// `accountId` is the JMAP account the message lives under. Absent for the
// user's own mail (the client's primary account); set when the message belongs
// to a shared/group account, so replay after a reconnect still targets it.
export type OutboxOp =
| { kind: 'keywords'; emailId: string; keywords: Record<string, boolean> }
| { kind: 'mailboxes'; emailId: string; mailboxIds: Record<string, boolean> }
| { kind: 'destroy'; emailId: string };
| { kind: 'keywords'; emailId: string; accountId?: string; keywords: Record<string, boolean> }
| { kind: 'mailboxes'; emailId: string; accountId?: string; mailboxIds: Record<string, boolean> }
| { kind: 'destroy'; emailId: string; accountId?: string };
export interface OutboxEntry {
id: string;
@@ -85,11 +88,11 @@ async function load(accountId: string): Promise<OutboxEntry[]> {
async function runOp(op: OutboxOp): Promise<void> {
switch (op.kind) {
case 'keywords':
return setEmailKeywords(op.emailId, op.keywords);
return setEmailKeywords(op.emailId, op.keywords, op.accountId);
case 'mailboxes':
return setEmailMailboxes(op.emailId, op.mailboxIds);
return setEmailMailboxes(op.emailId, op.mailboxIds, op.accountId);
case 'destroy':
return destroyEmails([op.emailId]);
return destroyEmails([op.emailId], op.accountId);
}
}
@@ -136,20 +139,23 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
return;
}
let entries = [...get().entries];
const { emailId } = op;
// Message identity is (account, id): JMAP ids are only unique within an
// account, and the queue can hold ops for shared accounts alongside own.
const sameMessage = (other: OutboxOp) =>
other.emailId === op.emailId && other.accountId === op.accountId;
if (op.kind === 'destroy') {
// Destroy is terminal — drop any pending edits for this message and
// append the destroy so it runs last.
entries = entries.filter((e) => e.op.emailId !== emailId);
entries = entries.filter((e) => !sameMessage(e.op));
entries.push({ id: generateUUID(), op, createdAt: Date.now(), attempts: 0 });
} else {
// A queued destroy wins; further edits to a doomed message are pointless.
if (entries.some((e) => e.op.emailId === emailId && e.op.kind === 'destroy')) return;
if (entries.some((e) => sameMessage(e.op) && e.op.kind === 'destroy')) return;
// Coalesce: replace any pending op of the same kind for this message
// (full-state replace makes the latest one authoritative). Keep its
// position so creation order is preserved for replay.
const idx = entries.findIndex((e) => e.op.emailId === emailId && e.op.kind === op.kind);
const idx = entries.findIndex((e) => sameMessage(e.op) && e.op.kind === op.kind);
if (idx >= 0) {
entries[idx] = { ...entries[idx], op, attempts: 0, lastError: undefined };
} else {