mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Merge branch 'cu-reader' into catch-up-2026-11
# Conflicts: # docs/native-sync-2026-11.md # docs/parity/11-webmail-1.19.md # locales/rn/en.json # src/stores/settings-store.ts
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({ jmapClient: { accountId: 'acc-1', request: vi.fn() } }));
|
||||
|
||||
import { EMAIL_FULL_PROPERTIES, EMAIL_LIST_PROPERTIES, THREAD_HEADER_PROPERTIES } from '../email';
|
||||
|
||||
// Sort by "Date sent" compares sentAt on the device for a list merged from
|
||||
// several accounts, but list rows never had it, so the order fell back to
|
||||
// the received date (webmail #1219).
|
||||
describe('list properties', () => {
|
||||
it('include sentAt, once', () => {
|
||||
for (const list of [EMAIL_LIST_PROPERTIES, EMAIL_FULL_PROPERTIES, THREAD_HEADER_PROPERTIES]) {
|
||||
expect(list.filter((p) => p === 'sentAt')).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,11 @@ import {
|
||||
searchEmails,
|
||||
sendEmail,
|
||||
MAILBOX_PROPERTIES,
|
||||
queryEmailPage,
|
||||
} from '../email';
|
||||
import { senderWarningOf } from '../../lib/email-headers';
|
||||
import { scheduledEmailIdsInThread } from '../thread-scheduled';
|
||||
import { fetchTagCounts, KEYWORDS_CAPABILITY } from '../tag-counts';
|
||||
import { uploadBytes } from '../blob';
|
||||
import { getIdentities, updateIdentity } from '../identity';
|
||||
import { isJMAPSetError } from '../jmap-result';
|
||||
@@ -460,4 +464,75 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('mail list (reader pass)', () => {
|
||||
it('lists the Authentication-Results headers so a forged From gets the warning shield (bulwarkmail/webmail#1439)', async () => {
|
||||
const subject = `forged ${mailTag}`;
|
||||
const raw = [
|
||||
'Authentication-Results: mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example',
|
||||
'From: Bank <ceo@bank.example>',
|
||||
`To: ${USER}`,
|
||||
`Subject: ${subject}`,
|
||||
`Date: ${new Date().toUTCString()}`,
|
||||
`Message-ID: <forged-${RUN}@live.test>`,
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'hello',
|
||||
'',
|
||||
].join('\r\n');
|
||||
const { blobId } = await uploadBytes(new TextEncoder().encode(raw), 'message/rfc822');
|
||||
const id = await importEmailBlob(blobId, scratch, {});
|
||||
const page = await queryEmailPage(scratch, { limit: 10 });
|
||||
const row = page.list.find((e) => e.id === id);
|
||||
expect(row).toBeDefined();
|
||||
expect(senderWarningOf(row!)?.status).toBe('failed');
|
||||
// The Date header, for sorting by date sent in merged lists (bulwarkmail/webmail#1219).
|
||||
expect(typeof row!.sentAt).toBe('string');
|
||||
// The full fetch reads the same verdict from `headers`.
|
||||
const [full] = await getFullEmails([id]);
|
||||
expect(senderWarningOf(full)?.status).toBe('failed');
|
||||
}, 60_000);
|
||||
|
||||
it("finds a conversation's message that waits for a scheduled send (bulwarkmail/webmail#1404)", async () => {
|
||||
const [identity] = await getIdentities();
|
||||
const result = await sendEmail(
|
||||
{ from: [{ email: USER }], to: [{ email: USER }], subject: `scheduled reply ${mailTag}`, textBody: 'live' },
|
||||
identity.id, sent, 3600, { draftsMailboxId: drafts },
|
||||
);
|
||||
try {
|
||||
const [filed] = await getFullEmails([result.emailId!]);
|
||||
const ids = await scheduledEmailIdsInThread(filed.threadId);
|
||||
expect([...ids]).toEqual([result.emailId]);
|
||||
} finally {
|
||||
await cancelScheduledSend(result.emailSubmissionId!).catch(() => undefined);
|
||||
}
|
||||
// Once cancelled it can be replied to again.
|
||||
const [filed] = await getFullEmails([result.emailId!]);
|
||||
expect((await scheduledEmailIdsInThread(filed.threadId)).size).toBe(0);
|
||||
}, 90_000);
|
||||
|
||||
it('leaves Trash out of the tag counts (bulwarkmail/webmail#1156)', async (ctx) => {
|
||||
const boxes = await getMailboxes();
|
||||
const trash = boxes.find((b) => b.role === 'trash')!.id;
|
||||
const tag = 'readerpass';
|
||||
const keyword = `$label:${tag}`;
|
||||
const live = await importMessage(scratch, `tag live ${mailTag}`, 'a');
|
||||
const gone = await importMessage(trash, `tag deleted ${mailTag}`, 'b', { $seen: true });
|
||||
// Set afterwards: 0.16 drops the keywords Email/import is given.
|
||||
await patchKeywordsForEmails([live, gone], { [keyword]: true });
|
||||
const flags = await getEmailFlags([live]);
|
||||
if (!flags.list[0]?.keywords?.[keyword]) {
|
||||
// Seen on the 0.16.27 test server: Email/set answers "updated" but
|
||||
// keeps only system keywords for this account. Nothing to count.
|
||||
ctx.skip('the server did not keep the tag keyword');
|
||||
return;
|
||||
}
|
||||
const all = await eventually(() => fetchTagCounts([tag]), ([c]) => c.total === 2);
|
||||
expect(all).toEqual([{ id: tag, total: 2, unread: 1 }]);
|
||||
expect(await fetchTagCounts([tag], [undefined], { '': [trash] })).toEqual([{ id: tag, total: 1, unread: 1 }]);
|
||||
// Stalwart answers Keyword/get for no keyword counters (no capability).
|
||||
expect(client.currentSession?.capabilities?.[KEYWORDS_CAPABILITY]).toBeUndefined();
|
||||
}, 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,13 @@ vi.mock('../jmap-client', () => ({
|
||||
accountId: 'c',
|
||||
request: vi.fn(),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
currentSession: { capabilities: {} as Record<string, unknown> },
|
||||
hasAccountCapability: vi.fn(() => true),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { fetchTagCounts } from '../tag-counts';
|
||||
import { fetchTagCounts, KEYWORDS_CAPABILITY } from '../tag-counts';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>;
|
||||
@@ -30,6 +32,7 @@ function answer(totals: Record<string, { total: number; unread: number }>, faili
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMaxCalls.mockReturnValue(16);
|
||||
jmapClient.currentSession!.capabilities = {};
|
||||
});
|
||||
|
||||
describe('fetchTagCounts', () => {
|
||||
@@ -39,11 +42,11 @@ describe('fetchTagCounts', () => {
|
||||
expect(await fetchTagCounts(['red'])).toEqual([{ id: 'red', total: 2, unread: 1 }]);
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
expect(calls).toEqual([
|
||||
['Email/query', { accountId: 'c', filter: { hasKeyword: '$label:red' }, limit: 0, calculateTotal: true }, 't0:red'],
|
||||
['Email/query', { accountId: 'c', filter: { hasKeyword: '$label:red' }, limit: 1, calculateTotal: true }, 't0:red'],
|
||||
['Email/query', {
|
||||
accountId: 'c',
|
||||
filter: { operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }] },
|
||||
limit: 0,
|
||||
limit: 1,
|
||||
calculateTotal: true,
|
||||
}, 'u0:red'],
|
||||
]);
|
||||
@@ -77,4 +80,53 @@ describe('fetchTagCounts', () => {
|
||||
|
||||
expect(await fetchTagCounts(['red'], [undefined, 'team'])).toEqual([{ id: 'red', total: 2, unread: 1 }]);
|
||||
});
|
||||
|
||||
// The tag view leaves Trash and Junk out; its badge counted them (#1156).
|
||||
it("leaves each account's Trash and Junk out of the counts", async () => {
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, string]>) => ({
|
||||
methodResponses: calls.map(([, , callId]) => ['Email/query', { ids: [], total: 1 }, callId]),
|
||||
}));
|
||||
|
||||
await fetchTagCounts(['red'], [undefined, 'team'], { '': ['trash', 'junk'], team: ['t-trash'] });
|
||||
|
||||
const filters = mockRequest.mock.calls[0][0].map((c: unknown[]) => (c[1] as { filter: unknown }).filter);
|
||||
expect(filters).toEqual([
|
||||
{ operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { inMailboxOtherThan: ['trash', 'junk'] }] },
|
||||
{ operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }, { inMailboxOtherThan: ['trash', 'junk'] }] },
|
||||
{ operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { inMailboxOtherThan: ['t-trash'] }] },
|
||||
{ operator: 'AND', conditions: [{ hasKeyword: '$label:red' }, { notKeyword: '$seen' }, { inMailboxOtherThan: ['t-trash'] }] },
|
||||
]);
|
||||
});
|
||||
|
||||
// One Keyword/get where the server counts keywords (#1399), else the queries.
|
||||
it('asks Keyword/get when the server counts keywords, and falls back to queries when it fails', async () => {
|
||||
jmapClient.currentSession!.capabilities = { [KEYWORDS_CAPABILITY]: { supportsCounts: true } };
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, string]>) => {
|
||||
if (calls[0][0] === 'Keyword/get') {
|
||||
return { methodResponses: [['Keyword/get', { list: [{ id: '$LABEL:Red', total: 7, unread: 3 }] }, '0']] };
|
||||
}
|
||||
return { methodResponses: calls.map(([, , callId]) => ['Email/query', { ids: [], total: 1 }, callId]) };
|
||||
});
|
||||
|
||||
expect(await fetchTagCounts(['red', 'blue'])).toEqual([
|
||||
{ id: 'red', total: 7, unread: 3 },
|
||||
{ id: 'blue', total: 0, unread: 0 },
|
||||
]);
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.mock.calls[0][0][0]).toEqual(['Keyword/get', { accountId: 'c', ids: ['$label:red', '$label:blue'] }, '0']);
|
||||
|
||||
mockRequest.mockClear();
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, string]>) => (
|
||||
calls[0][0] === 'Keyword/get'
|
||||
? { methodResponses: [['error', { type: 'unknownMethod' }, '0']] }
|
||||
: { methodResponses: calls.map(([, , callId]) => ['Email/query', { ids: [], total: 2 }, callId]) }
|
||||
));
|
||||
expect(await fetchTagCounts(['red'])).toEqual([{ id: 'red', total: 2, unread: 2 }]);
|
||||
|
||||
// Without supportsCounts nothing asks Keyword/get.
|
||||
jmapClient.currentSession!.capabilities = { [KEYWORDS_CAPABILITY]: {} };
|
||||
mockRequest.mockClear();
|
||||
await fetchTagCounts(['red']);
|
||||
expect(mockRequest.mock.calls[0][0][0][0]).toBe('Email/query');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,15 @@ describe('buildFilter (aggregated views)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFilter: a server's own all mailbox (bulwarkmail/webmail#1241)", () => {
|
||||
it('is left out of All mail and Unread', () => {
|
||||
const withAll = [...MAILBOXES, mailbox('every', 'all')];
|
||||
expect(buildFilter(withAll, { view: 'all' })).toEqual({
|
||||
operator: 'OR', conditions: [{ inMailbox: 'in' }, { inMailbox: 'projects' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFilter: searching from All mail', () => {
|
||||
const all = [...MAILBOXES, mailbox('arch', 'archive'), mailbox('drafts', 'drafts'), mailbox('spam', 'junk')];
|
||||
|
||||
|
||||
+18
-5
@@ -19,19 +19,32 @@ import { hasTruncatedDisplayedBody } from '../lib/email-body';
|
||||
import { withInlineBodyParts } from '../lib/email-inline-parts';
|
||||
import { stalwartStateWentBack } from './stalwart-state';
|
||||
import { isTransientNetworkError } from '../lib/network-error';
|
||||
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
|
||||
|
||||
export const EMAIL_LIST_PROPERTIES = [
|
||||
const EMAIL_SUMMARY_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
|
||||
// Sorting by "Date sent" compares it on the device for a list merged from
|
||||
// several accounts; without it the order fell back to receivedAt (#1219).
|
||||
'sentAt',
|
||||
];
|
||||
|
||||
export const EMAIL_LIST_PROPERTIES = [
|
||||
...EMAIL_SUMMARY_PROPERTIES,
|
||||
// The sender check, so a list row shows a warning shield instead of the
|
||||
// favicon or photo of a forged From (webmail #1439).
|
||||
LIST_AUTH_RESULTS_PROPERTY,
|
||||
];
|
||||
|
||||
// The viewer derives reply threading (In-Reply-To/References), Reply-To
|
||||
// handling, SPF/DKIM/DMARC chips, List-Unsubscribe and read-receipt requests
|
||||
// from these; without `messageId`/`headers` none of that can work.
|
||||
// from these; without `messageId`/`headers` none of that can work. The full
|
||||
// `headers` carry Authentication-Results, so the list's header property is
|
||||
// left out.
|
||||
export const EMAIL_FULL_PROPERTIES = [
|
||||
...EMAIL_LIST_PROPERTIES,
|
||||
...EMAIL_SUMMARY_PROPERTIES,
|
||||
'bodyStructure', 'textBody', 'htmlBody', 'bodyValues',
|
||||
'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt',
|
||||
'attachments', 'blobId', 'bcc', 'replyTo',
|
||||
'messageId', 'inReplyTo', 'references', 'headers',
|
||||
];
|
||||
|
||||
@@ -1014,7 +1027,7 @@ export async function getEmailFlags(
|
||||
}
|
||||
|
||||
// What a collapsed conversation card shows; `sentAt` is the date it prints.
|
||||
export const THREAD_HEADER_PROPERTIES = [...EMAIL_LIST_PROPERTIES, 'sentAt'];
|
||||
export const THREAD_HEADER_PROPERTIES = EMAIL_LIST_PROPERTIES;
|
||||
|
||||
/** A conversation's members, oldest first, without bodies. */
|
||||
export interface ThreadHeaders {
|
||||
|
||||
+64
-6
@@ -1,5 +1,8 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import type { JMAPMethodCall } from './types';
|
||||
import { CAPABILITIES, type JMAPMethodCall } from './types';
|
||||
|
||||
/** Bulwark's keyword counters (webmail lib/jmap/client.ts KEYWORDS_CAPABILITY). */
|
||||
export const KEYWORDS_CAPABILITY = 'https://bulwarkmail.com/ns/jmap/keywords';
|
||||
|
||||
export interface TagCount {
|
||||
/** Tag id (the part after `$label:`). */
|
||||
@@ -17,33 +20,61 @@ export interface TagCount {
|
||||
* allows. Port of the webmail's `fetchTagCounts` (stores/email-store.ts). A
|
||||
* call that fails, such as for an account that can't be reached, counts 0
|
||||
* rather than sinking the rest.
|
||||
*
|
||||
* `excluded` names each account's Trash and Junk (key '' = the own account):
|
||||
* the counts leave them out like the tag view does (webmail #1156). An
|
||||
* account whose server counts keywords itself (Bulwark's keywords capability
|
||||
* with `supportsCounts`) is asked once with Keyword/get instead (#1399); its
|
||||
* counters cover the whole account, as the webmail's do.
|
||||
*/
|
||||
export async function fetchTagCounts(
|
||||
tagIds: string[],
|
||||
accountIds: Array<string | undefined> = [undefined],
|
||||
excluded: Record<string, string[]> = {},
|
||||
): Promise<TagCount[]> {
|
||||
if (tagIds.length === 0) return [];
|
||||
const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest());
|
||||
const counts = new Map<string, TagCount>(tagIds.map((id) => [id, { id, total: 0, unread: 0 }]));
|
||||
|
||||
const queried: Array<string | undefined> = [];
|
||||
for (const override of accountIds) {
|
||||
const counted = countsKeywords(override ?? jmapClient.accountId)
|
||||
? await tagCountsFromKeywords(tagIds, override ?? jmapClient.accountId)
|
||||
: null;
|
||||
if (!counted) {
|
||||
queried.push(override);
|
||||
continue;
|
||||
}
|
||||
for (const c of counted) {
|
||||
const entry = counts.get(c.id)!;
|
||||
entry.total += c.total;
|
||||
entry.unread += c.unread;
|
||||
}
|
||||
}
|
||||
|
||||
const calls: JMAPMethodCall[] = [];
|
||||
const targets = new Map<string, { id: string; kind: 'total' | 'unread' }>();
|
||||
accountIds.forEach((override, index) => {
|
||||
queried.forEach((override, index) => {
|
||||
const accountId = override ?? jmapClient.accountId;
|
||||
const skip = excluded[override ?? ''] ?? [];
|
||||
const exclusion = skip.length > 0 ? [{ inMailboxOtherThan: skip }] : [];
|
||||
for (const id of tagIds) {
|
||||
const keyword = `$label:${id}`;
|
||||
targets.set(`t${index}:${id}`, { id, kind: 'total' });
|
||||
calls.push(['Email/query', {
|
||||
accountId,
|
||||
filter: { hasKeyword: keyword },
|
||||
limit: 0,
|
||||
filter: exclusion.length > 0
|
||||
? { operator: 'AND', conditions: [{ hasKeyword: keyword }, ...exclusion] }
|
||||
: { hasKeyword: keyword },
|
||||
// 1, not 0: Stalwart takes 0 as "no limit" and returns every id.
|
||||
limit: 1,
|
||||
calculateTotal: true,
|
||||
}, `t${index}:${id}`]);
|
||||
targets.set(`u${index}:${id}`, { id, kind: 'unread' });
|
||||
calls.push(['Email/query', {
|
||||
accountId,
|
||||
filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }] },
|
||||
limit: 0,
|
||||
filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }, ...exclusion] },
|
||||
limit: 1,
|
||||
calculateTotal: true,
|
||||
}, `u${index}:${id}`]);
|
||||
}
|
||||
@@ -63,3 +94,30 @@ export async function fetchTagCounts(
|
||||
}
|
||||
return tagIds.map((id) => counts.get(id)!);
|
||||
}
|
||||
|
||||
/** Whether the server reports every keyword's total and unread in Keyword/get. */
|
||||
function countsKeywords(accountId: string): boolean {
|
||||
const capability = jmapClient.currentSession?.capabilities?.[KEYWORDS_CAPABILITY] as { supportsCounts?: unknown } | undefined;
|
||||
return capability?.supportsCounts === true && jmapClient.hasAccountCapability(KEYWORDS_CAPABILITY, accountId);
|
||||
}
|
||||
|
||||
/** Counts from the server's keyword counters, or null so the caller queries instead. */
|
||||
async function tagCountsFromKeywords(tagIds: string[], accountId: string): Promise<TagCount[] | null> {
|
||||
try {
|
||||
const res = await jmapClient.request(
|
||||
[['Keyword/get', { accountId, ids: tagIds.map((id) => `$label:${id}`) }, '0']],
|
||||
[CAPABILITIES.CORE, KEYWORDS_CAPABILITY],
|
||||
);
|
||||
const [name, body] = res.methodResponses?.[0] ?? [];
|
||||
if (name !== 'Keyword/get' || !Array.isArray(body?.list)) return null;
|
||||
// Keywords are case-insensitive (RFC 8621 4.1.1).
|
||||
const byId = new Map((body.list as Array<{ id: string; total?: number; unread?: number }>)
|
||||
.map((k) => [k.id.toLowerCase(), k]));
|
||||
return tagIds.map((id) => {
|
||||
const k = byId.get(`$label:${id}`.toLowerCase());
|
||||
return { id, total: k?.total ?? 0, unread: k?.unread ?? 0 };
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { SUBMISSION_ONLY_USING } from './submissions';
|
||||
|
||||
/**
|
||||
* Ids of a conversation's messages that wait for a scheduled send: a pending
|
||||
* EmailSubmission whose send time is still ahead. The conversation hides
|
||||
* Reply on them and points the quick reply past them, as it does for drafts
|
||||
* (webmail 8b29254a6, #1404). Empty when the account can't schedule or the
|
||||
* server can't answer: the cards then stay as they were.
|
||||
*/
|
||||
export async function scheduledEmailIdsInThread(threadId: string, accountId?: string): Promise<Set<string>> {
|
||||
const account = accountId ?? jmapClient.accountId;
|
||||
if (!account || !jmapClient.hasDelayedSend(account)) return new Set();
|
||||
try {
|
||||
const res = await jmapClient.request([
|
||||
['EmailSubmission/query', { accountId: account, filter: { threadIds: [threadId], undoStatus: 'pending' } }, '0'],
|
||||
['EmailSubmission/get', {
|
||||
accountId: account,
|
||||
'#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' },
|
||||
properties: ['emailId', 'sendAt', 'undoStatus'],
|
||||
}, '1'],
|
||||
], SUBMISSION_ONLY_USING);
|
||||
const [name, body] = res.methodResponses?.[1] ?? [];
|
||||
if (name !== 'EmailSubmission/get') return new Set();
|
||||
const now = Date.now();
|
||||
const ids = new Set<string>();
|
||||
for (const s of (body.list as Array<{ emailId?: string; sendAt?: string; undoStatus?: string }>) ?? []) {
|
||||
if (!s.emailId || s.undoStatus !== 'pending') continue;
|
||||
const at = s.sendAt ? new Date(s.sendAt).getTime() : NaN;
|
||||
if (Number.isFinite(at) && at > now) ids.add(s.emailId);
|
||||
}
|
||||
return ids;
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { keywordPointer, mailboxPointer } from './patch-pointer';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { freshStoredCredentials } from '../lib/stored-token-refresh';
|
||||
import { textConditions } from '../lib/search-utils';
|
||||
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
|
||||
|
||||
// Aggregated views across accounts ("All inboxes", "All Sent", All mail /
|
||||
// Unread / Starred). Because the JMAP client is a single-account singleton
|
||||
@@ -20,7 +21,9 @@ import { textConditions } from '../lib/search-utils';
|
||||
|
||||
const EMAIL_LIST_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
|
||||
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment', 'sentAt',
|
||||
// The sender check, for the warning shield on a forged From (#1439).
|
||||
LIST_AUTH_RESULTS_PROPERTY,
|
||||
];
|
||||
|
||||
const SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
@@ -29,9 +32,13 @@ const MAILBOX_CACHE_TTL_MS = 60 * 1000;
|
||||
export type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash';
|
||||
export type CrossView = 'all' | 'unread' | 'starred';
|
||||
|
||||
/** Roles left out of the cross-account All mail / Unread / Starred views. */
|
||||
/**
|
||||
* Roles left out of the cross-account All mail / Unread / Starred views. A
|
||||
* server's own "all" mailbox holds every message again, so listing it beside
|
||||
* the real folders doubled them (webmail lib/jmap/types.ts, #1241).
|
||||
*/
|
||||
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
|
||||
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts',
|
||||
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts', 'all',
|
||||
]);
|
||||
|
||||
export interface UnifiedEmail extends Email {
|
||||
|
||||
Reference in New Issue
Block a user