mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82b97fada4 | ||
|
|
428a1bf03d | ||
|
|
09350dd1f0 | ||
|
|
d2d13a37c9 | ||
|
|
be82bfe814 | ||
|
|
5c775ad82e | ||
|
|
941e5ede12 | ||
|
|
2577f4e64b | ||
|
|
ee21c15fec | ||
|
|
b7995fafde | ||
|
|
cde461999f |
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.56",
|
||||
"version": "0.1.58",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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); }}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'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 },
|
||||
|
||||
@@ -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'll sign in on your provider'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 },
|
||||
|
||||
@@ -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's usually all we need — we'll look up the mail server for you.
|
||||
That'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>
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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're not sure, scan a sign-in code instead. It carries the server address.
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user