mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
Merge branch 'cu-compx' into catch-up-2026-11
This commit is contained in:
@@ -922,6 +922,28 @@
|
|||||||
"personal_in_account": "Your templates are kept in your account, so you have them on every device you sign in on. Anyone you share your Inbox with can read them.",
|
"personal_in_account": "Your templates are kept in your account, so you have them on every device you sign in on. Anyone you share your Inbox with can read them.",
|
||||||
"section_personal": "My templates"
|
"section_personal": "My templates"
|
||||||
},
|
},
|
||||||
|
"outbox": {
|
||||||
|
"discard": "Discard",
|
||||||
|
"discard_body": "It will not be sent, and its text is lost.",
|
||||||
|
"discard_title": "Discard this message?",
|
||||||
|
"failed_count": "{count, plural, one {# message} other {# messages}} not sent",
|
||||||
|
"move_failed_body": "The message stays in the Outbox. Try again when the connection is back.",
|
||||||
|
"move_failed_title": "Could not move to Drafts",
|
||||||
|
"move_to_drafts": "Move to Drafts",
|
||||||
|
"open_hint": "Opens the Outbox",
|
||||||
|
"queued_body": "It goes out as soon as you are back online.",
|
||||||
|
"queued_title": "Saved to the Outbox",
|
||||||
|
"retry": "Try again",
|
||||||
|
"send_failed": "A message in the Outbox could not be sent",
|
||||||
|
"sent": "Message from the Outbox sent",
|
||||||
|
"status_failed": "Not sent",
|
||||||
|
"status_failed_reason": "Not sent: {reason}",
|
||||||
|
"status_offline": "Waiting for a connection",
|
||||||
|
"status_retrying": "Waiting to send",
|
||||||
|
"status_sending": "Sending…",
|
||||||
|
"title": "Outbox",
|
||||||
|
"waiting": "{count, plural, one {# message} other {# messages}} in the Outbox"
|
||||||
|
},
|
||||||
"push": {
|
"push": {
|
||||||
"ios": {
|
"ios": {
|
||||||
"more_messages": "and {count} more",
|
"more_messages": "and {count} more",
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// The composer's message priority (webmail #970) and the DSN / REQUIRETLS
|
||||||
|
// submission options (webmail submissionEnvelopeParameters): what the
|
||||||
|
// Email/set create and the EmailSubmission envelope carry.
|
||||||
|
|
||||||
|
vi.mock('../jmap-client', () => ({
|
||||||
|
jmapClient: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
request: vi.fn(),
|
||||||
|
learnHoldLimit: vi.fn(),
|
||||||
|
getSubmissionAccountIds: vi.fn(() => ['acc-1']),
|
||||||
|
hasDelayedSend: vi.fn(() => true),
|
||||||
|
hasAccountCapability: vi.fn(() => false),
|
||||||
|
getMaxCallsInRequest: vi.fn(() => 16),
|
||||||
|
getMaxObjectsInGet: vi.fn(() => 500),
|
||||||
|
getMaxObjectsInSet: vi.fn(() => 500),
|
||||||
|
getAccountCapability: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { jmapClient } from '../jmap-client';
|
||||||
|
import { rescheduleScheduledSend, saveDraftVersion, sendEmail } from '../email';
|
||||||
|
import { draftContextFromEmail } from '../../lib/draft-context';
|
||||||
|
import type { Email } from '../types';
|
||||||
|
|
||||||
|
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const OUTGOING = {
|
||||||
|
from: [{ email: 'me@example.com' }],
|
||||||
|
to: [{ email: 'you@example.com' }],
|
||||||
|
cc: [{ email: 'cc@example.com' }],
|
||||||
|
subject: 'Hello',
|
||||||
|
textBody: 'Hi',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SENT = {
|
||||||
|
methodResponses: [
|
||||||
|
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
|
||||||
|
['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function sentCalls() {
|
||||||
|
const calls = mockRequest.mock.calls[0][0] as Array<[string, Record<string, any>, string]>;
|
||||||
|
const create = calls.find(([name]) => name === 'Email/set')![1].create.draft;
|
||||||
|
const submission = calls.find(([name]) => name === 'EmailSubmission/set')![1].create['sub-1'];
|
||||||
|
return { create, submission };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockRequest.mockResolvedValue(SENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message priority', () => {
|
||||||
|
it('writes X-Priority and Importance for a high-priority message', async () => {
|
||||||
|
await sendEmail({ ...OUTGOING, priority: 'high' }, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts' });
|
||||||
|
const { create } = sentCalls();
|
||||||
|
expect(create['header:X-Priority:asText']).toBe('1 (Highest)');
|
||||||
|
expect(create['header:Importance:asText']).toBe('high');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the low values for a low-priority message', async () => {
|
||||||
|
await sendEmail({ ...OUTGOING, priority: 'low' }, 'id-1', 'sent');
|
||||||
|
const { create } = sentCalls();
|
||||||
|
expect(create['header:X-Priority:asText']).toBe('5 (Lowest)');
|
||||||
|
expect(create['header:Importance:asText']).toBe('low');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes no priority header for a normal message', async () => {
|
||||||
|
await sendEmail({ ...OUTGOING, priority: 'normal' }, 'id-1', 'sent');
|
||||||
|
const { create } = sentCalls();
|
||||||
|
expect(Object.keys(create).some((k) => /Priority|Importance/.test(k))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves the priority with a draft, and a re-opened draft reads it back', async () => {
|
||||||
|
mockRequest.mockResolvedValueOnce({
|
||||||
|
methodResponses: [
|
||||||
|
['Email/set', { created: { draft: { id: 'd-1' } } }, '0'],
|
||||||
|
['Email/get', { list: [] }, '1'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await saveDraftVersion({ ...OUTGOING, priority: 'high' }, 'drafts');
|
||||||
|
const create = (mockRequest.mock.calls[0][0] as any[])[0][1].create.draft;
|
||||||
|
expect(create['header:X-Priority:asText']).toBe('1 (Highest)');
|
||||||
|
|
||||||
|
const reopened = draftContextFromEmail({
|
||||||
|
id: 'd-1',
|
||||||
|
headers: [{ name: 'X-Priority', value: ' 1 (Highest)' }, { name: 'Importance', value: ' high' }],
|
||||||
|
} as unknown as Email);
|
||||||
|
expect(reopened.priority).toBe('high');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delivery status notifications and REQUIRETLS', () => {
|
||||||
|
it('asks every recipient for a DSN with headers only', async () => {
|
||||||
|
await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts', requestDsn: true });
|
||||||
|
const { submission } = sentCalls();
|
||||||
|
expect(submission.envelope).toEqual({
|
||||||
|
mailFrom: { email: 'me@example.com', parameters: { RET: 'HDRS' } },
|
||||||
|
rcptTo: [
|
||||||
|
{ email: 'you@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY' } },
|
||||||
|
{ email: 'cc@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts REQUIRETLS next to the HOLDFOR of a held send', async () => {
|
||||||
|
await sendEmail(OUTGOING, 'id-1', 'sent', 30, { draftsMailboxId: 'drafts', requireTls: true });
|
||||||
|
const { submission } = sentCalls();
|
||||||
|
expect(submission.envelope.mailFrom.parameters).toEqual({ HOLDFOR: '30', REQUIRETLS: null });
|
||||||
|
expect(submission.envelope.rcptTo).toEqual([{ email: 'you@example.com' }, { email: 'cc@example.com' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the envelope to the server without options, hold or From override', async () => {
|
||||||
|
await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts' });
|
||||||
|
expect(sentCalls().submission.envelope).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moving a held send', () => {
|
||||||
|
it('keeps its delivery notification and REQUIRETLS, and only those', async () => {
|
||||||
|
const set = (body: Record<string, unknown>) => ({ methodResponses: [['EmailSubmission/set', body, '0']] });
|
||||||
|
mockRequest.mockReset();
|
||||||
|
mockRequest
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
methodResponses: [
|
||||||
|
['EmailSubmission/get', {
|
||||||
|
list: [{
|
||||||
|
id: 'sub-1',
|
||||||
|
envelope: {
|
||||||
|
mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '3600', RET: 'HDRS', REQUIRETLS: null, SIZE: '1234' } },
|
||||||
|
rcptTo: [{ email: 'you@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY', ORCPT: 'x' } }],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}, '0'],
|
||||||
|
['Email/get', { list: [{ id: 'e-1', to: [{ email: 'you@example.com' }] }] }, '1'],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(set({ updated: { 'sub-1': null } }))
|
||||||
|
.mockResolvedValueOnce(set({ destroyed: ['sub-1'] }))
|
||||||
|
.mockResolvedValueOnce(set({ created: { replacement: { id: 'sub-2', sendAt: '2026-11-01T08:00:00Z' } } }));
|
||||||
|
|
||||||
|
await rescheduleScheduledSend(
|
||||||
|
{ emailSubmissionId: 'sub-1', emailId: 'e-1', identityId: 'id-1', from: [{ email: 'me@example.com' }], to: [{ email: 'you@example.com' }] } as never,
|
||||||
|
7200,
|
||||||
|
);
|
||||||
|
const create = mockRequest.mock.calls
|
||||||
|
.map((c) => c[0][0])
|
||||||
|
.find(([method, args]: [string, { create?: object }]) => method === 'EmailSubmission/set' && args.create)[1];
|
||||||
|
expect(create.create.replacement.envelope).toEqual({
|
||||||
|
mailFrom: { email: 'me@example.com', parameters: { RET: 'HDRS', REQUIRETLS: null, HOLDFOR: '7200' } },
|
||||||
|
rcptTo: [{ email: 'you@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY' } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Live checks of the composer's send options against a real Stalwart: the
|
||||||
|
* priority headers (webmail #970), the read-receipt request and the DSN /
|
||||||
|
* REQUIRETLS envelope parameters (webmail submissionEnvelopeParameters).
|
||||||
|
*
|
||||||
|
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and
|
||||||
|
* `BULWARK_LIVE_PASS` are set.
|
||||||
|
*
|
||||||
|
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
|
||||||
|
* BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
|
||||||
|
* npx vitest run src/api/__tests__/live-stalwart-send-options.test.ts
|
||||||
|
*
|
||||||
|
* Mail goes to the user's own address; everything it creates is removed.
|
||||||
|
* Verified against Stalwart 0.16.27 and 1.0.1 (2026-10-31).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
|
||||||
|
import { JMAPClient } from '../jmap-client';
|
||||||
|
import { cancelScheduledSend, destroyEmails, getMailboxes, listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email';
|
||||||
|
import { getIdentities } from '../identity';
|
||||||
|
import { supportsSubmissionExtension } from '../../lib/submission-options';
|
||||||
|
|
||||||
|
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||||
|
const USER = process.env.BULWARK_LIVE_USER ?? 'e2e-perf@example.org';
|
||||||
|
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
||||||
|
const live = SERVER && PASS ? describe : describe.skip;
|
||||||
|
|
||||||
|
const RUN_STARTED = Date.now();
|
||||||
|
const RUN = RUN_STARTED.toString(36);
|
||||||
|
|
||||||
|
let singleton: JMAPClient;
|
||||||
|
|
||||||
|
live('live Stalwart: composer send options', () => {
|
||||||
|
let drafts = '';
|
||||||
|
let sent = '';
|
||||||
|
const tag = `bulwark-sendopts-${RUN}`;
|
||||||
|
const createdEmails: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const mod = await import('../jmap-client');
|
||||||
|
singleton = mod.jmapClient as unknown as JMAPClient;
|
||||||
|
const client = new JMAPClient();
|
||||||
|
await client.connect(SERVER!, USER, PASS);
|
||||||
|
singleton.restoreSnapshot(client.snapshot());
|
||||||
|
const boxes = await getMailboxes();
|
||||||
|
drafts = boxes.find((b) => b.role === 'drafts')?.id ?? '';
|
||||||
|
sent = boxes.find((b) => b.role === 'sent')?.id ?? '';
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const held = await listScheduledEmails().catch(() => []);
|
||||||
|
for (const s of held.filter((h) => createdEmails.includes(h.emailId))) {
|
||||||
|
await cancelScheduledSend(s.emailSubmissionId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
// Not a subject search: Stalwart answers that from a full-text index
|
||||||
|
// that lags. Every message since the run began is read and compared.
|
||||||
|
const res = await singleton.request([
|
||||||
|
['Email/query', { accountId: singleton.accountId, filter: { after: new Date(RUN_STARTED - 60_000).toISOString() }, limit: 500 }, '0'],
|
||||||
|
['Email/get', {
|
||||||
|
accountId: singleton.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'],
|
||||||
|
}, '1'],
|
||||||
|
]);
|
||||||
|
const matching = (res.methodResponses[1][1].list as Array<{ id: string; subject?: string }> ?? [])
|
||||||
|
.filter((e) => e.subject?.includes(tag)).map((e) => e.id);
|
||||||
|
const leftovers = [...new Set([...(matching), ...createdEmails])];
|
||||||
|
if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('lists DSN and REQUIRETLS in the submission extensions', () => {
|
||||||
|
expect(supportsSubmissionExtension('DSN')).toBe(true);
|
||||||
|
expect(supportsSubmissionExtension('REQUIRETLS')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('files the priority and read-receipt headers with the sent message', async () => {
|
||||||
|
const [identity] = await getIdentities();
|
||||||
|
const result = await sendEmail(
|
||||||
|
{
|
||||||
|
from: [{ email: identity.email }], to: [{ email: identity.email }],
|
||||||
|
subject: `priority ${tag}`, textBody: 'live', priority: 'high', requestReadReceipt: true,
|
||||||
|
},
|
||||||
|
identity.id, sent, undefined, { draftsMailboxId: drafts, requestDsn: true },
|
||||||
|
);
|
||||||
|
createdEmails.push(result.emailId!);
|
||||||
|
expect(result.emailSubmissionId).toBeTruthy();
|
||||||
|
const res = await singleton.request([['Email/get', {
|
||||||
|
accountId: singleton.accountId,
|
||||||
|
ids: [result.emailId],
|
||||||
|
properties: ['mailboxIds', 'header:X-Priority:asText', 'header:Importance:asText', 'header:Disposition-Notification-To:asText'],
|
||||||
|
}, '0']]);
|
||||||
|
const [email] = res.methodResponses[0][1].list;
|
||||||
|
expect(email.mailboxIds[sent]).toBe(true);
|
||||||
|
expect(email['header:X-Priority:asText']?.trim()).toBe('1 (Highest)');
|
||||||
|
expect(email['header:Importance:asText']?.trim()).toBe('high');
|
||||||
|
expect(email['header:Disposition-Notification-To:asText']?.trim()).toBe(identity.email);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('submits with RET/NOTIFY and REQUIRETLS on the envelope', async () => {
|
||||||
|
const [identity] = await getIdentities();
|
||||||
|
// Held, so the submission record (and its envelope) can be read back.
|
||||||
|
const result = await sendEmail(
|
||||||
|
{ from: [{ email: identity.email }], to: [{ email: identity.email }], subject: `dsn ${tag}`, textBody: 'live' },
|
||||||
|
identity.id, sent, 3600, { draftsMailboxId: drafts, requestDsn: true, requireTls: true },
|
||||||
|
);
|
||||||
|
createdEmails.push(result.emailId!);
|
||||||
|
const res = await singleton.request([['EmailSubmission/get', {
|
||||||
|
accountId: singleton.accountId, ids: [result.emailSubmissionId], properties: ['envelope', 'undoStatus'],
|
||||||
|
}, '0']], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
|
||||||
|
const [submission] = res.methodResponses[0][1].list;
|
||||||
|
expect(submission.undoStatus).toBe('pending');
|
||||||
|
expect(submission.envelope.mailFrom.email).toBe(identity.email);
|
||||||
|
expect(submission.envelope.mailFrom.parameters).toMatchObject({ RET: 'HDRS', HOLDFOR: '3600' });
|
||||||
|
expect('REQUIRETLS' in submission.envelope.mailFrom.parameters).toBe(true);
|
||||||
|
expect(submission.envelope.rcptTo).toEqual([
|
||||||
|
expect.objectContaining({ email: identity.email, parameters: expect.objectContaining({ NOTIFY: 'SUCCESS,FAILURE,DELAY' }) }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Moved to another time, it keeps them.
|
||||||
|
const held = (await listScheduledEmails()).find((s) => s.emailSubmissionId === result.emailSubmissionId)!;
|
||||||
|
const moved = await rescheduleScheduledSend(held, 7200);
|
||||||
|
const after = await singleton.request([['EmailSubmission/get', {
|
||||||
|
accountId: singleton.accountId, ids: [moved.emailSubmissionId], properties: ['envelope'],
|
||||||
|
}, '0']], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
|
||||||
|
const [replacement] = after.methodResponses[0][1].list;
|
||||||
|
expect(replacement.envelope.mailFrom.parameters).toMatchObject({ RET: 'HDRS', HOLDFOR: '7200' });
|
||||||
|
expect('REQUIRETLS' in replacement.envelope.mailFrom.parameters).toBe(true);
|
||||||
|
expect(replacement.envelope.rcptTo[0].parameters).toMatchObject({ NOTIFY: 'SUCCESS,FAILURE,DELAY' });
|
||||||
|
await cancelScheduledSend(moved.emailSubmissionId!);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Live checks of the offline send queue's rule against sending twice: a
|
||||||
|
* message is found by its Message-ID, a copy that went out stops a resend,
|
||||||
|
* and the autosaved draft alone does not.
|
||||||
|
*
|
||||||
|
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and
|
||||||
|
* `BULWARK_LIVE_PASS` are set.
|
||||||
|
*
|
||||||
|
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
|
||||||
|
* BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
|
||||||
|
* npx vitest run src/api/__tests__/live-stalwart-send-queue.test.ts
|
||||||
|
*
|
||||||
|
* Mail goes to the user's own address; everything it creates is removed.
|
||||||
|
* Verified against Stalwart 0.16.27 and 1.0.1 (2026-10-31).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
|
||||||
|
import { JMAPClient } from '../jmap-client';
|
||||||
|
import { destroyEmails, getMailboxes, saveDraftVersion, sendEmail } from '../email';
|
||||||
|
import { getIdentities } from '../identity';
|
||||||
|
import { findCopiesByMessageId, runQueuedSend } from '../queued-send';
|
||||||
|
import { generateMessageId } from '../../lib/email-threading';
|
||||||
|
import { newQueuedSend } from '../../lib/send-queue';
|
||||||
|
import type { Identity, Mailbox } from '../types';
|
||||||
|
|
||||||
|
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||||
|
const USER = process.env.BULWARK_LIVE_USER ?? 'e2e-perf@example.org';
|
||||||
|
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
||||||
|
const live = SERVER && PASS ? describe : describe.skip;
|
||||||
|
|
||||||
|
const RUN_STARTED = Date.now();
|
||||||
|
const RUN = RUN_STARTED.toString(36);
|
||||||
|
|
||||||
|
let singleton: JMAPClient;
|
||||||
|
|
||||||
|
live('live Stalwart: offline send queue', () => {
|
||||||
|
let drafts = '';
|
||||||
|
let sent = '';
|
||||||
|
let boxes: Mailbox[] = [];
|
||||||
|
let identity: Identity;
|
||||||
|
const tag = `bulwark-sendqueue-${RUN}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const mod = await import('../jmap-client');
|
||||||
|
singleton = mod.jmapClient as unknown as JMAPClient;
|
||||||
|
const client = new JMAPClient();
|
||||||
|
await client.connect(SERVER!, USER, PASS);
|
||||||
|
singleton.restoreSnapshot(client.snapshot());
|
||||||
|
boxes = await getMailboxes();
|
||||||
|
drafts = boxes.find((b) => b.role === 'drafts')?.id ?? '';
|
||||||
|
sent = boxes.find((b) => b.role === 'sent')?.id ?? '';
|
||||||
|
[identity] = await getIdentities();
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Not a subject search: Stalwart answers that from a full-text index
|
||||||
|
// that lags. Every message since the run began is read and compared.
|
||||||
|
const res = await singleton.request([
|
||||||
|
['Email/query', { accountId: singleton.accountId, filter: { after: new Date(RUN_STARTED - 60_000).toISOString() }, limit: 500 }, '0'],
|
||||||
|
['Email/get', {
|
||||||
|
accountId: singleton.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'],
|
||||||
|
}, '1'],
|
||||||
|
]);
|
||||||
|
const matching = (res.methodResponses[1][1].list as Array<{ id: string; subject?: string }> ?? [])
|
||||||
|
.filter((e) => e.subject?.includes(tag)).map((e) => e.id);
|
||||||
|
const leftovers = matching;
|
||||||
|
if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
const message = (subject: string) => ({
|
||||||
|
from: [{ email: identity.email }],
|
||||||
|
to: [{ email: identity.email }],
|
||||||
|
subject: `${subject} ${tag}`,
|
||||||
|
textBody: 'live',
|
||||||
|
messageId: generateMessageId(identity.email),
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
const lookup = () => ({ mailboxIds: [sent, drafts], since: started });
|
||||||
|
|
||||||
|
const payloadFor = (outgoing: ReturnType<typeof message>, draftId?: string) => ({
|
||||||
|
outgoing, identity, fromEmail: identity.email, sentMailboxId: sent, draftsMailboxId: drafts, draftId,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds a message by its Message-ID, and only that one', async () => {
|
||||||
|
const outgoing = message('lookup');
|
||||||
|
const result = await sendEmail(outgoing, identity.id, sent, undefined, { draftsMailboxId: drafts });
|
||||||
|
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
|
||||||
|
expect(copies.map((c) => c.id)).toEqual([result.emailId]);
|
||||||
|
expect(copies[0].keywords?.$draft).toBeFalsy();
|
||||||
|
expect(await findCopiesByMessageId(generateMessageId(identity.email), lookup())).toEqual([]);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('does not send again a message an unanswered attempt already sent', async () => {
|
||||||
|
const outgoing = message('sent-once');
|
||||||
|
const first = await sendEmail(outgoing, identity.id, sent, undefined, { draftsMailboxId: drafts });
|
||||||
|
// The answer to that send was lost: the queued entry is still marked.
|
||||||
|
const outcome = await runQueuedSend(newQueuedSend('q1', payloadFor(outgoing), Date.now(), true), boxes);
|
||||||
|
expect(outcome.kind).toBe('alreadySent');
|
||||||
|
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
|
||||||
|
expect(copies.map((c) => c.id)).toEqual([first.emailId]);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('sends a marked message whose only copy is its draft, and removes the draft', async () => {
|
||||||
|
const outgoing = message('draft-only');
|
||||||
|
const draft = await saveDraftVersion(outgoing, drafts);
|
||||||
|
const outcome = await runQueuedSend(newQueuedSend('q2', payloadFor(outgoing, draft.id), Date.now(), true), boxes);
|
||||||
|
expect(outcome.kind).toBe('sent');
|
||||||
|
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
|
||||||
|
expect(copies).toHaveLength(1);
|
||||||
|
expect(copies[0].id).not.toBe(draft.id);
|
||||||
|
expect(copies[0].keywords?.$draft).toBeFalsy();
|
||||||
|
const res = await singleton.request([['Email/get', { accountId: singleton.accountId, ids: [copies[0].id], properties: ['mailboxIds'] }, '0']]);
|
||||||
|
expect(res.methodResponses[0][1].list[0].mailboxIds[sent]).toBe(true);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Sending from the offline queue: a message an unanswered attempt may have
|
||||||
|
// sent is looked up by its Message-ID first and not sent again when a copy
|
||||||
|
// went out.
|
||||||
|
|
||||||
|
vi.mock('../jmap-client', () => ({
|
||||||
|
jmapClient: { accountId: 'acc-1', request: vi.fn(), getAccountCapability: vi.fn(() => undefined) },
|
||||||
|
}));
|
||||||
|
const sendEmail = vi.fn(async (..._a: any[]) => ({ scheduled: false, emailId: 'e-new', emailSubmissionId: 's-1' }));
|
||||||
|
const destroyEmails = vi.fn(async (..._a: any[]) => undefined);
|
||||||
|
vi.mock('../email', () => ({
|
||||||
|
sendEmail: (...a: any[]) => sendEmail(...a),
|
||||||
|
destroyEmails: (...a: any[]) => destroyEmails(...a),
|
||||||
|
}));
|
||||||
|
vi.mock('../../lib/compose-send-account', () => ({
|
||||||
|
sendTargetFor: vi.fn(async (identity: { id: string }) => ({ identityId: identity.id, sentMailboxId: 'sent', draftsMailboxId: 'drafts' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { jmapClient } from '../jmap-client';
|
||||||
|
import { findCopiesByMessageId, runQueuedSend } from '../queued-send';
|
||||||
|
import { newQueuedSend, type QueuedSendPayload } from '../../lib/send-queue';
|
||||||
|
|
||||||
|
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const PAYLOAD = {
|
||||||
|
outgoing: { from: [{ email: 'me@x.de' }], to: [{ email: 'you@x.de' }], subject: 'Hi', textBody: 'x', messageId: 'm1@x.de' },
|
||||||
|
identity: { id: 'id-1', email: 'me@x.de', name: 'Me' },
|
||||||
|
fromEmail: 'me@x.de',
|
||||||
|
sentMailboxId: 'sent',
|
||||||
|
draftsMailboxId: 'drafts',
|
||||||
|
draftId: 'draft-1',
|
||||||
|
requestDsn: true,
|
||||||
|
} as unknown as QueuedSendPayload;
|
||||||
|
|
||||||
|
const found = (list: Array<{ id: string; keywords?: Record<string, boolean>; messageId?: string[] }>) => ({
|
||||||
|
methodResponses: [
|
||||||
|
['Email/query', { ids: list.map((e) => e.id) }, '0'],
|
||||||
|
['Email/get', { list }, '1'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runQueuedSend', () => {
|
||||||
|
it('sends a message no attempt reached the server with, without looking first', async () => {
|
||||||
|
const outcome = await runQueuedSend(newQueuedSend('q', PAYLOAD, 0, false), []);
|
||||||
|
expect(outcome.kind).toBe('sent');
|
||||||
|
expect(mockRequest).not.toHaveBeenCalled();
|
||||||
|
expect(sendEmail).toHaveBeenCalledWith(
|
||||||
|
PAYLOAD.outgoing, 'id-1', 'sent', undefined,
|
||||||
|
// DSN only where the account lists it; this one lists nothing.
|
||||||
|
expect.objectContaining({ draftsMailboxId: 'drafts', draftId: 'draft-1', requestDsn: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send again a message whose earlier attempt went out, and removes its old draft', async () => {
|
||||||
|
mockRequest.mockResolvedValueOnce(found([
|
||||||
|
{ id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
|
||||||
|
{ id: 'sent-1', keywords: { $seen: true }, messageId: ['<m1@x.de>'] },
|
||||||
|
]));
|
||||||
|
const outcome = await runQueuedSend(newQueuedSend('q', PAYLOAD, 0, true), []);
|
||||||
|
expect(outcome.kind).toBe('alreadySent');
|
||||||
|
expect(sendEmail).not.toHaveBeenCalled();
|
||||||
|
expect(destroyEmails).toHaveBeenCalledWith(['draft-1'], 'acc-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a copy a refused attempt left behind, then sends', async () => {
|
||||||
|
mockRequest.mockResolvedValueOnce(found([
|
||||||
|
{ id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
|
||||||
|
{ id: 'left-1', keywords: { $draft: true, $seen: true }, messageId: ['m1@x.de'] },
|
||||||
|
]));
|
||||||
|
const outcome = await runQueuedSend(newQueuedSend('q', PAYLOAD, 0, true), []);
|
||||||
|
expect(outcome.kind).toBe('sent');
|
||||||
|
expect(destroyEmails).toHaveBeenCalledWith(['left-1'], 'acc-1');
|
||||||
|
expect(sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a scheduled message for the time that is left', async () => {
|
||||||
|
const now = Date.parse('2026-10-31T12:00:00Z');
|
||||||
|
await runQueuedSend(newQueuedSend('q', { ...PAYLOAD, scheduledAt: '2026-10-31T13:00:00Z' }, now, false), [], now);
|
||||||
|
expect(sendEmail.mock.calls[0][3]).toBe(3600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findCopiesByMessageId', () => {
|
||||||
|
it('reads the recent messages of Sent and Drafts and keeps exact matches only', async () => {
|
||||||
|
mockRequest.mockResolvedValueOnce(found([
|
||||||
|
{ id: 'a', keywords: {}, messageId: ['m1@x.de'] },
|
||||||
|
{ id: 'b', keywords: {}, messageId: ['other-m1@x.de'] },
|
||||||
|
{ id: 'c', keywords: {}, messageId: [] },
|
||||||
|
]));
|
||||||
|
const copies = await findCopiesByMessageId('<m1@x.de>', { mailboxIds: ['sent', 'drafts'], since: Date.parse('2026-10-31T12:00:00Z') });
|
||||||
|
expect(copies.map((c) => c.id)).toEqual(['a']);
|
||||||
|
const [query] = mockRequest.mock.calls[0][0];
|
||||||
|
// Not the header filter: Stalwart answers that from a lagging index.
|
||||||
|
expect(query[1].filter).toEqual({
|
||||||
|
operator: 'AND',
|
||||||
|
conditions: [
|
||||||
|
{ after: '2026-10-31T11:50:00.000Z' },
|
||||||
|
{ operator: 'OR', conditions: [{ inMailbox: 'sent' }, { inMailbox: 'drafts' }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+59
-9
@@ -21,6 +21,8 @@ import { withInlineBodyParts } from '../lib/email-inline-parts';
|
|||||||
import { stalwartStateWentBack } from './stalwart-state';
|
import { stalwartStateWentBack } from './stalwart-state';
|
||||||
import { isTransientNetworkError } from '../lib/network-error';
|
import { isTransientNetworkError } from '../lib/network-error';
|
||||||
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
|
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
|
||||||
|
import { messagePriorityHeaders, type MessagePriority } from '../lib/message-priority';
|
||||||
|
import { submissionEnvelopeParameters } from '../lib/submission-options';
|
||||||
|
|
||||||
const EMAIL_SUMMARY_PROPERTIES = [
|
const EMAIL_SUMMARY_PROPERTIES = [
|
||||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||||
@@ -1581,6 +1583,8 @@ export interface OutgoingEmail {
|
|||||||
// Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent.
|
// Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent.
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
requestReadReceipt?: boolean;
|
requestReadReceipt?: boolean;
|
||||||
|
/** Importance / X-Priority headers (webmail #970); normal writes none. */
|
||||||
|
priority?: MessagePriority;
|
||||||
/**
|
/**
|
||||||
* SMTP MAIL FROM when it must differ from the header From - a catch-all
|
* SMTP MAIL FROM when it must differ from the header From - a catch-all
|
||||||
* alias on an owned domain is sent through the identity's envelope while
|
* alias on an owned domain is sent through the identity's envelope while
|
||||||
@@ -1691,6 +1695,9 @@ function buildEmailCreate(email: OutgoingEmail): Record<string, unknown> {
|
|||||||
emailCreate['header:Disposition-Notification-To:asText'] = from[0].email;
|
emailCreate['header:Disposition-Notification-To:asText'] = from[0].email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drafts carry it too, so a re-opened draft reads its priority back.
|
||||||
|
Object.assign(emailCreate, messagePriorityHeaders(email.priority));
|
||||||
|
|
||||||
return emailCreate;
|
return emailCreate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1740,6 +1747,13 @@ export interface SendEmailOptions {
|
|||||||
draftAccountId?: string;
|
draftAccountId?: string;
|
||||||
/** Submitting account (shared/group account); defaults to the primary. */
|
/** Submitting account (shared/group account); defaults to the primary. */
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
/**
|
||||||
|
* Ask for delivery status notifications (RFC 3461 RET/NOTIFY); only when
|
||||||
|
* the account lists the DSN submission extension.
|
||||||
|
*/
|
||||||
|
requestDsn?: boolean;
|
||||||
|
/** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */
|
||||||
|
requireTls?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendEmail(
|
export async function sendEmail(
|
||||||
@@ -1766,18 +1780,26 @@ export async function sendEmail(
|
|||||||
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
|
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
|
||||||
// For a deferred send the envelope must be set explicitly so the HOLDFOR
|
// For a deferred send the envelope must be set explicitly so the HOLDFOR
|
||||||
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
|
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
|
||||||
// server derive mailFrom from the Identity, dropping our parameter). An
|
// server derive mailFrom from the Identity, dropping our parameter). The
|
||||||
// explicit envelope sender (catch-all From override) needs it as well.
|
// DSN and REQUIRETLS parameters ride the same way (webmail
|
||||||
|
// submissionEnvelopeParameters), and an explicit envelope sender
|
||||||
|
// (catch-all From override) needs it as well.
|
||||||
const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0;
|
const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0;
|
||||||
if (holdFor > 0 || email.envelopeMailFrom) {
|
const parameters = submissionEnvelopeParameters(
|
||||||
|
{ requestDsn: opts?.requestDsn, requireTls: opts?.requireTls },
|
||||||
|
holdFor,
|
||||||
|
);
|
||||||
|
const hasMailFromParams = Object.keys(parameters.mailFrom).length > 0;
|
||||||
|
const hasRcptToParams = Object.keys(parameters.rcptTo).length > 0;
|
||||||
|
if (hasMailFromParams || hasRcptToParams || email.envelopeMailFrom) {
|
||||||
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
|
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
|
||||||
.map((r) => r.email.trim())
|
.map((r) => r.email.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((address) => ({ email: address }));
|
.map((address) => (hasRcptToParams ? { email: address, parameters: parameters.rcptTo } : { email: address }));
|
||||||
const mailFrom: Record<string, unknown> = {
|
const mailFrom: Record<string, unknown> = {
|
||||||
email: email.envelopeMailFrom || email.from[0]?.email,
|
email: email.envelopeMailFrom || email.from[0]?.email,
|
||||||
};
|
};
|
||||||
if (holdFor > 0) mailFrom.parameters = { HOLDFOR: String(holdFor) };
|
if (hasMailFromParams) mailFrom.parameters = parameters.mailFrom;
|
||||||
submissionCreate.envelope = { mailFrom, rcptTo };
|
submissionCreate.envelope = { mailFrom, rcptTo };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2267,6 +2289,22 @@ export class RescheduleLostError extends Error {
|
|||||||
* and send time, and the recipients the server refused for the replacement
|
* and send time, and the recipients the server refused for the replacement
|
||||||
* when it took the others.
|
* when it took the others.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The SMTP parameters among `names` that a held submission's envelope
|
||||||
|
* carries, or undefined with none. Only the ones the composer sets are kept,
|
||||||
|
* never parameters the server may have added.
|
||||||
|
*/
|
||||||
|
function keptSubmissionParameters(
|
||||||
|
parameters: Record<string, string | null> | null | undefined,
|
||||||
|
names: string[],
|
||||||
|
): Record<string, string | null> | undefined {
|
||||||
|
if (!parameters) return undefined;
|
||||||
|
const kept = Object.fromEntries(
|
||||||
|
Object.entries(parameters).filter(([key]) => names.includes(key.toUpperCase())),
|
||||||
|
);
|
||||||
|
return Object.keys(kept).length > 0 ? kept : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export async function rescheduleScheduledSend(
|
export async function rescheduleScheduledSend(
|
||||||
scheduled: {
|
scheduled: {
|
||||||
emailSubmissionId: string;
|
emailSubmissionId: string;
|
||||||
@@ -2299,8 +2337,9 @@ export async function rescheduleScheduledSend(
|
|||||||
],
|
],
|
||||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
|
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
|
||||||
);
|
);
|
||||||
|
type EnvelopeAddress = { email?: string; parameters?: Record<string, string | null> | null };
|
||||||
const original = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{
|
const original = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{
|
||||||
envelope?: { mailFrom?: { email?: string }; rcptTo?: Array<{ email?: string }> } | null;
|
envelope?: { mailFrom?: EnvelopeAddress; rcptTo?: EnvelopeAddress[] } | null;
|
||||||
sendAt?: string;
|
sendAt?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
}> | undefined)?.[0];
|
}> | undefined)?.[0];
|
||||||
@@ -2313,7 +2352,15 @@ export async function rescheduleScheduledSend(
|
|||||||
const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined;
|
const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined;
|
||||||
const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])];
|
const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])];
|
||||||
const source = recipients ?? (envelope?.rcptTo?.length ? envelope.rcptTo : headerRecipients);
|
const source = recipients ?? (envelope?.rcptTo?.length ? envelope.rcptTo : headerRecipients);
|
||||||
const rcptTo = source.map((r) => ({ email: (r.email ?? '').trim() })).filter((r) => r.email);
|
// The original's delivery notification (RET / NOTIFY) and REQUIRETLS
|
||||||
|
// ride along; dropping them turned a moved send into a plain one.
|
||||||
|
const keptMailFromParams = keptSubmissionParameters(envelope?.mailFrom?.parameters, ['RET', 'REQUIRETLS']);
|
||||||
|
const keptRcptParams = keptSubmissionParameters(
|
||||||
|
envelope?.rcptTo?.find((r) => r.parameters)?.parameters, ['NOTIFY'],
|
||||||
|
);
|
||||||
|
const rcptTo = source
|
||||||
|
.map((r) => ({ email: (r.email ?? '').trim(), ...(keptRcptParams ? { parameters: keptRcptParams } : {}) }))
|
||||||
|
.filter((r) => r.email);
|
||||||
const mailFrom = envelope?.mailFrom?.email || scheduled.from?.[0]?.email;
|
const mailFrom = envelope?.mailFrom?.email || scheduled.from?.[0]?.email;
|
||||||
// Without an answer the replacement is made without filing, and a message
|
// Without an answer the replacement is made without filing, and a message
|
||||||
// that can't be put back stays where it is.
|
// that can't be put back stays where it is.
|
||||||
@@ -2344,9 +2391,12 @@ export async function rescheduleScheduledSend(
|
|||||||
emailId: scheduled.emailId,
|
emailId: scheduled.emailId,
|
||||||
identityId,
|
identityId,
|
||||||
envelope: {
|
envelope: {
|
||||||
mailFrom: seconds === undefined
|
mailFrom: seconds === undefined && !keptMailFromParams
|
||||||
? { email: mailFrom }
|
? { email: mailFrom }
|
||||||
: { email: mailFrom, parameters: { HOLDFOR: String(seconds) } },
|
: {
|
||||||
|
email: mailFrom,
|
||||||
|
parameters: { ...keptMailFromParams, ...(seconds === undefined ? {} : { HOLDFOR: String(seconds) }) },
|
||||||
|
},
|
||||||
rcptTo,
|
rcptTo,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Sending a message from the offline send queue (see lib/send-queue.ts for
|
||||||
|
// the state machine and the rule against sending twice).
|
||||||
|
|
||||||
|
import { jmapClient } from './jmap-client';
|
||||||
|
import { destroyEmails, sendEmail, type SendEmailResult } from './email';
|
||||||
|
import { requireMethodResult } from './jmap-result';
|
||||||
|
import type { Mailbox } from './types';
|
||||||
|
import { sendTargetFor } from '../lib/compose-send-account';
|
||||||
|
import { supportsSubmissionExtension } from '../lib/submission-options';
|
||||||
|
import { stripMessageIdBrackets } from '../lib/email-threading';
|
||||||
|
import { holdForScheduled, verdictFromCopies, type FoundCopy, type QueuedSend } from '../lib/send-queue';
|
||||||
|
|
||||||
|
/** How far before the queueing a copy can be dated (the server's clock may differ). */
|
||||||
|
const CLOCK_SKEW_MS = 10 * 60_000;
|
||||||
|
/** Messages filed in Sent and Drafts since the queueing that are compared. */
|
||||||
|
const LOOKUP_LIMIT = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The messages in the given folders of `accountId` whose Message-ID is
|
||||||
|
* `messageId`, filed since `since` (ms). Not a header search: Stalwart
|
||||||
|
* answers `{ header: ['Message-ID', …] }` from its full-text index, which
|
||||||
|
* lags behind (0.16.27 and 1.0.1 found nothing seconds after the send), so
|
||||||
|
* the folders' recent messages are read and compared instead.
|
||||||
|
*/
|
||||||
|
export async function findCopiesByMessageId(
|
||||||
|
messageId: string,
|
||||||
|
opts: { accountId?: string; mailboxIds: string[]; since: number },
|
||||||
|
): Promise<FoundCopy[]> {
|
||||||
|
const id = stripMessageIdBrackets(messageId);
|
||||||
|
const mailboxIds = opts.mailboxIds.filter(Boolean);
|
||||||
|
if (!id || mailboxIds.length === 0) return [];
|
||||||
|
const account = opts.accountId ?? jmapClient.accountId;
|
||||||
|
const inFolders = mailboxIds.length === 1
|
||||||
|
? { inMailbox: mailboxIds[0] }
|
||||||
|
: { operator: 'OR', conditions: mailboxIds.map((m) => ({ inMailbox: m })) };
|
||||||
|
const filter = {
|
||||||
|
operator: 'AND',
|
||||||
|
conditions: [{ after: new Date(opts.since - CLOCK_SKEW_MS).toISOString() }, inFolders],
|
||||||
|
};
|
||||||
|
const res = await jmapClient.request([
|
||||||
|
['Email/query', {
|
||||||
|
accountId: account, filter, sort: [{ property: 'receivedAt', isAscending: false }], limit: LOOKUP_LIMIT,
|
||||||
|
}, '0'],
|
||||||
|
['Email/get', {
|
||||||
|
accountId: account,
|
||||||
|
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||||
|
properties: ['id', 'keywords', 'messageId'],
|
||||||
|
}, '1'],
|
||||||
|
]);
|
||||||
|
requireMethodResult(res, '0', 'Email/query');
|
||||||
|
const list = (requireMethodResult(res, '1', 'Email/get').list ?? []) as Array<{
|
||||||
|
id: string; keywords?: Record<string, boolean>; messageId?: string[] | null;
|
||||||
|
}>;
|
||||||
|
return list
|
||||||
|
.filter((e) => (e.messageId ?? []).some((m) => stripMessageIdBrackets(m) === id))
|
||||||
|
.map((e) => ({ id: e.id, keywords: e.keywords ?? {} }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueuedSendOutcome =
|
||||||
|
| { kind: 'sent'; result: SendEmailResult; accountId?: string }
|
||||||
|
/** An earlier attempt went out; nothing was sent now. */
|
||||||
|
| { kind: 'alreadySent'; accountId?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a queued message. The account, identity and folders are worked out
|
||||||
|
* as the composer does (sendTargetFor, #1090). A message an earlier attempt
|
||||||
|
* may have sent is looked up first, and is not sent again when a copy of it
|
||||||
|
* went out; copies a refused attempt left in Drafts are removed before the
|
||||||
|
* new attempt.
|
||||||
|
*/
|
||||||
|
export async function runQueuedSend(send: QueuedSend, mailboxes: Mailbox[], now = Date.now()): Promise<QueuedSendOutcome> {
|
||||||
|
const p = send.payload;
|
||||||
|
const target = await sendTargetFor(p.identity, p.fromEmail, mailboxes);
|
||||||
|
const accountId = target.accountId ?? jmapClient.accountId;
|
||||||
|
const draftAccountId = p.draftAccountId ?? jmapClient.accountId;
|
||||||
|
|
||||||
|
const sentMailboxId = target.sentMailboxId ?? p.sentMailboxId;
|
||||||
|
const draftsMailboxId = target.accountId ? target.draftsMailboxId : p.draftsMailboxId;
|
||||||
|
|
||||||
|
if (send.maybeSent && p.outgoing.messageId) {
|
||||||
|
const copies = await findCopiesByMessageId(p.outgoing.messageId, {
|
||||||
|
accountId: target.accountId,
|
||||||
|
mailboxIds: [sentMailboxId, draftsMailboxId ?? ''],
|
||||||
|
since: send.createdAt,
|
||||||
|
});
|
||||||
|
// The autosaved draft lives in this account only when the draft did.
|
||||||
|
const draftHere = p.draftId && draftAccountId === accountId ? p.draftId : undefined;
|
||||||
|
const verdict = verdictFromCopies(copies, draftHere);
|
||||||
|
if (verdict.kind === 'sent') {
|
||||||
|
// The send went out but its answer was lost, so the old draft it would
|
||||||
|
// have removed is still there.
|
||||||
|
if (p.draftId && p.draftId !== verdict.copyId) {
|
||||||
|
await destroyEmails([p.draftId], draftAccountId).catch((err) => {
|
||||||
|
console.warn('[send-queue] could not remove the draft of a sent message', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { kind: 'alreadySent', accountId: target.accountId };
|
||||||
|
}
|
||||||
|
if (verdict.staleIds.length > 0) {
|
||||||
|
await destroyEmails(verdict.staleIds, accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendEmail(
|
||||||
|
p.outgoing,
|
||||||
|
target.identityId,
|
||||||
|
sentMailboxId,
|
||||||
|
holdForScheduled(p.scheduledAt, now),
|
||||||
|
{
|
||||||
|
draftsMailboxId,
|
||||||
|
draftId: p.draftId,
|
||||||
|
draftAccountId: p.draftId ? p.draftAccountId : undefined,
|
||||||
|
accountId: target.accountId,
|
||||||
|
// Only what the submitting account offers, as the composer does.
|
||||||
|
requestDsn: !!p.requestDsn && supportsSubmissionExtension('DSN', target.accountId),
|
||||||
|
requireTls: !!p.requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { kind: 'sent', result, accountId: target.accountId };
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||||
import { CloudOff } from './icons';
|
import { CloudOff, Send } from './icons';
|
||||||
import { useNetworkStore } from '../stores/network-store';
|
import { useNetworkStore } from '../stores/network-store';
|
||||||
import { useOutboxStore } from '../stores/outbox-store';
|
import { useOutboxStore } from '../stores/outbox-store';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { announceConnectivity } from '../lib/announce';
|
import { announceConnectivity } from '../lib/announce';
|
||||||
|
import { OutboxSheet } from './compose/OutboxSheet';
|
||||||
|
import { outboxHint } from '../lib/send-queue';
|
||||||
|
|
||||||
interface OfflineBannerProps {
|
interface OfflineBannerProps {
|
||||||
/** Optional extra notice (e.g. "Showing cached mail"). */
|
/** Optional extra notice (e.g. "Showing cached mail"). */
|
||||||
@@ -20,26 +22,58 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
|
|||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
// Pending offline mutations that will replay once we're back online.
|
// Pending offline mutations that will replay once we're back online.
|
||||||
const queued = useOutboxStore((s) => s.entries.length);
|
const queued = useOutboxStore((s) => s.entries.length);
|
||||||
|
// Messages sent while offline (the Outbox). Counted outside the selector:
|
||||||
|
// a selector must not build a new value on every call.
|
||||||
|
const sends = useOutboxStore((s) => s.sends);
|
||||||
|
const waitingSends = sends.filter((s) => s.status !== 'failed').length;
|
||||||
|
const failedSends = sends.length - waitingSends;
|
||||||
|
const [outboxOpen, setOutboxOpen] = React.useState(false);
|
||||||
const offlineLabel = t('offline.banner', 'You are offline');
|
const offlineLabel = t('offline.banner', 'You are offline');
|
||||||
// VoiceOver hears the drop too (Android reads the live region below).
|
// VoiceOver hears the drop too (Android reads the live region below).
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
announceConnectivity(online, offlineLabel);
|
announceConnectivity(online, offlineLabel);
|
||||||
}, [online, offlineLabel]);
|
}, [online, offlineLabel]);
|
||||||
if (online) return null;
|
const sendsHint = outboxHint(waitingSends, failedSends, t);
|
||||||
const queuedHint = queued > 0
|
if (online && !sendsHint) return null;
|
||||||
|
const queuedHint = !online && queued > 0
|
||||||
? t(
|
? t(
|
||||||
'offline.queued_changes',
|
'offline.queued_changes',
|
||||||
'{count, plural, one {# change} other {# changes}} will sync when you reconnect',
|
'{count, plural, one {# change} other {# changes}} will sync when you reconnect',
|
||||||
{ count: queued },
|
{ count: queued },
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
|
const label = online ? sendsHint! : offlineLabel;
|
||||||
const label = offlineLabel;
|
const suffix = [online ? null : hint, queuedHint, online ? null : sendsHint].filter(Boolean).join(' · ');
|
||||||
|
const text = suffix ? `${label} — ${suffix}` : label;
|
||||||
|
const sheet = <OutboxSheet visible={outboxOpen} onClose={() => setOutboxOpen(false)} />;
|
||||||
|
if (sends.length > 0) {
|
||||||
|
// With messages in the Outbox the bar opens it.
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.bar, online && styles.barOutbox]}
|
||||||
|
onPress={() => setOutboxOpen(true)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
accessibilityHint={t('outbox.open_hint', 'Opens the Outbox')}
|
||||||
|
accessibilityLiveRegion="polite"
|
||||||
|
>
|
||||||
|
{online
|
||||||
|
? <Send size={14} color={failedSends > 0 ? c.error : c.textSecondary} />
|
||||||
|
: <CloudOff size={14} color={c.warningForeground} />}
|
||||||
|
<Text style={[styles.text, online && styles.textOutbox, online && failedSends > 0 && styles.textFailed]} numberOfLines={1}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{sheet}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<View style={styles.bar} accessibilityRole="alert" accessibilityLiveRegion="polite">
|
<View style={styles.bar} accessibilityRole="alert" accessibilityLiveRegion="polite">
|
||||||
<CloudOff size={14} color={c.warningForeground} />
|
<CloudOff size={14} color={c.warningForeground} />
|
||||||
<Text style={styles.text} numberOfLines={1}>
|
<Text style={styles.text} numberOfLines={1}>
|
||||||
{suffix ? `${label} — ${suffix}` : label}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -60,5 +94,11 @@ function makeStyles(c: ThemePalette) {
|
|||||||
color: c.warningForeground,
|
color: c.warningForeground,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
barOutbox: {
|
||||||
|
backgroundColor: c.surfaceActive,
|
||||||
|
minHeight: 32,
|
||||||
|
},
|
||||||
|
textOutbox: { color: c.text },
|
||||||
|
textFailed: { color: c.error },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ export interface RichTextEditorHandle {
|
|||||||
setImageAlt(cid: string, alt: string): void;
|
setImageAlt(cid: string, alt: string): void;
|
||||||
/** Insert an HTML fragment at the caret (template bodies, tables). */
|
/** Insert an HTML fragment at the caret (template bodies, tables). */
|
||||||
insertHtml(html: string): void;
|
insertHtml(html: string): void;
|
||||||
|
/**
|
||||||
|
* Replace the "@..." being typed with a mention link to `href` reading
|
||||||
|
* "@label" (needs `mentions`).
|
||||||
|
*/
|
||||||
|
insertMention(label: string, href: string): void;
|
||||||
setHtml(html: string): void;
|
setHtml(html: string): void;
|
||||||
/**
|
/**
|
||||||
* Read the live editor DOM content. Resolves with the current innerHTML or
|
* Read the live editor DOM content. Resolves with the current innerHTML or
|
||||||
@@ -94,12 +99,16 @@ interface Props {
|
|||||||
collapseQuote?: boolean;
|
collapseQuote?: boolean;
|
||||||
/** The quote toggle's accessible names. */
|
/** The quote toggle's accessible names. */
|
||||||
quoteToggleLabels?: { show: string; hide: string };
|
quoteToggleLabels?: { show: string; hide: string };
|
||||||
|
/** Report "@" mentions being typed (read once, on mount). */
|
||||||
|
mentions?: boolean;
|
||||||
|
/** What follows the "@" being typed, or null once there is none. */
|
||||||
|
onMentionQuery?: (query: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
||||||
{
|
{
|
||||||
initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift,
|
initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift,
|
||||||
collapseQuote = false, quoteToggleLabels,
|
collapseQuote = false, quoteToggleLabels, mentions = false, onMentionQuery,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
@@ -113,6 +122,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
const onBlurRef = React.useRef(onBlur);
|
const onBlurRef = React.useRef(onBlur);
|
||||||
const onViewportShiftRef = React.useRef(onViewportShift);
|
const onViewportShiftRef = React.useRef(onViewportShift);
|
||||||
onViewportShiftRef.current = onViewportShift;
|
onViewportShiftRef.current = onViewportShift;
|
||||||
|
const onMentionQueryRef = React.useRef(onMentionQuery);
|
||||||
|
onMentionQueryRef.current = onMentionQuery;
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
onSelectionRef.current = onSelectionChange;
|
onSelectionRef.current = onSelectionChange;
|
||||||
onFocusRef.current = onFocus;
|
onFocusRef.current = onFocus;
|
||||||
@@ -123,7 +134,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
const html = React.useMemo(
|
const html = React.useMemo(
|
||||||
() => buildEditorHtml({
|
() => buildEditorHtml({
|
||||||
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
|
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
|
||||||
collapseQuote, quoteToggleLabels,
|
collapseQuote, quoteToggleLabels, mentions,
|
||||||
}),
|
}),
|
||||||
// c reference changes per theme switch but we don't want to lose user
|
// c reference changes per theme switch but we don't want to lose user
|
||||||
// edits on a theme change. Bake in the initial colors only.
|
// edits on a theme change. Bake in the initial colors only.
|
||||||
@@ -202,6 +213,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
insertHtml: (html) => {
|
insertHtml: (html) => {
|
||||||
call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`);
|
call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`);
|
||||||
},
|
},
|
||||||
|
insertMention: (label, href) => {
|
||||||
|
call(`window.__rne && window.__rne.insertMention(${JSON.stringify(label)}, ${JSON.stringify(href)})`);
|
||||||
|
},
|
||||||
setHtml: (next) => {
|
setHtml: (next) => {
|
||||||
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
||||||
},
|
},
|
||||||
@@ -276,6 +290,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
case 'blur':
|
case 'blur':
|
||||||
onBlurRef.current?.();
|
onBlurRef.current?.();
|
||||||
break;
|
break;
|
||||||
|
case 'mention':
|
||||||
|
onMentionQueryRef.current?.(typeof data.payload === 'string' ? data.payload : null);
|
||||||
|
break;
|
||||||
case 'viewportShift': {
|
case 'viewportShift': {
|
||||||
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
||||||
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
|
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Check } from '../icons';
|
||||||
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
import { MESSAGE_PRIORITIES, type MessagePriority } from '../../lib/message-priority';
|
||||||
|
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
|
||||||
|
|
||||||
|
export interface ComposerOptions {
|
||||||
|
requestReadReceipt: boolean;
|
||||||
|
requestDsn: boolean;
|
||||||
|
requireTls: boolean;
|
||||||
|
priority: MessagePriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many of the options differ from a plain message, for the badge on the
|
||||||
|
* toolbar button (webmail composer options menu `activeCount`).
|
||||||
|
*/
|
||||||
|
export function activeComposerOptionCount(options: ComposerOptions): number {
|
||||||
|
return [options.requestReadReceipt, options.requestDsn, options.requireTls, options.priority !== 'normal']
|
||||||
|
.filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The composer's per-message options (webmail email-composer.tsx options
|
||||||
|
* menu): read receipt, delivery status notification and REQUIRETLS - the
|
||||||
|
* last two only when the account's submission extensions list them - and
|
||||||
|
* the message priority.
|
||||||
|
*/
|
||||||
|
export function ComposerOptionsSheet({
|
||||||
|
visible, options, dsnAvailable, requireTlsAvailable, onChange, onClose,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
options: ComposerOptions;
|
||||||
|
dsnAvailable: boolean;
|
||||||
|
requireTlsAvailable: boolean;
|
||||||
|
onChange: (next: Partial<ComposerOptions>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const bottomInset = useSheetBottomInset(visible);
|
||||||
|
|
||||||
|
const toggleRow = (label: string, hint: string, value: boolean, key: 'requestReadReceipt' | 'requestDsn' | 'requireTls') => (
|
||||||
|
<Pressable
|
||||||
|
style={styles.row}
|
||||||
|
onPress={() => onChange({ [key]: !value })}
|
||||||
|
accessibilityRole="switch"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityHint={hint}
|
||||||
|
accessibilityState={{ checked: value }}
|
||||||
|
>
|
||||||
|
<View style={styles.rowText}>
|
||||||
|
<Text style={styles.rowLabel}>{label}</Text>
|
||||||
|
<Text style={styles.rowHint}>{hint}</Text>
|
||||||
|
</View>
|
||||||
|
<ToggleSwitch value={value} onValueChange={(v) => onChange({ [key]: v })} accessibilityLabel={label} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable accessible={false} style={[styles.backdrop, { paddingBottom: spacing.lg + bottomInset }]} onPress={onClose}>
|
||||||
|
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||||
|
<Text style={styles.title} accessibilityRole="header">{t('email_composer.options', 'Options')}</Text>
|
||||||
|
{toggleRow(
|
||||||
|
t('email_composer.option_read_receipt', 'Request read receipt'),
|
||||||
|
t('email_composer.option_read_receipt_hint', 'Ask the recipient to confirm they opened it'),
|
||||||
|
options.requestReadReceipt,
|
||||||
|
'requestReadReceipt',
|
||||||
|
)}
|
||||||
|
{dsnAvailable && toggleRow(
|
||||||
|
t('email_composer.option_dsn', 'Delivery notification'),
|
||||||
|
t('email_composer.option_dsn_hint', 'Get a report when the server delivers it'),
|
||||||
|
options.requestDsn,
|
||||||
|
'requestDsn',
|
||||||
|
)}
|
||||||
|
{requireTlsAvailable && toggleRow(
|
||||||
|
t('email_composer.option_require_tls', 'Require encrypted delivery'),
|
||||||
|
t('email_composer.option_require_tls_hint', 'Only deliver over TLS, otherwise bounce'),
|
||||||
|
options.requireTls,
|
||||||
|
'requireTls',
|
||||||
|
)}
|
||||||
|
<View style={styles.separator} />
|
||||||
|
<Text style={styles.groupLabel}>{t('email_composer.option_priority', 'Priority')}</Text>
|
||||||
|
<View accessibilityRole="radiogroup" accessibilityLabel={t('email_composer.option_priority', 'Priority')}>
|
||||||
|
{MESSAGE_PRIORITIES.map((level) => {
|
||||||
|
const selected = options.priority === level;
|
||||||
|
const label = level === 'high'
|
||||||
|
? t('email_composer.option_priority_high', 'High')
|
||||||
|
: level === 'low'
|
||||||
|
? t('email_composer.option_priority_low', 'Low')
|
||||||
|
: t('email_composer.option_priority_normal', 'Normal');
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={level}
|
||||||
|
style={styles.row}
|
||||||
|
onPress={() => onChange({ priority: level })}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityState={{ checked: selected, selected }}
|
||||||
|
testID={`composer-priority-${level}`}
|
||||||
|
>
|
||||||
|
<Text style={[styles.rowLabel, styles.rowText]}>{label}</Text>
|
||||||
|
{selected && <Check size={18} color={c.primary} />}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
<Pressable style={styles.done} onPress={onClose} accessibilityRole="button">
|
||||||
|
<Text style={styles.doneText}>{t('common.done', 'Done')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 420,
|
||||||
|
backgroundColor: c.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
title: { ...typography.h3, color: c.text, marginBottom: spacing.xs },
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.md,
|
||||||
|
minHeight: 48,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
},
|
||||||
|
rowText: { flex: 1 },
|
||||||
|
rowLabel: { ...typography.body, color: c.text },
|
||||||
|
rowHint: { ...typography.caption, color: c.textMuted },
|
||||||
|
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.borderLight, marginVertical: spacing.xs },
|
||||||
|
groupLabel: { ...typography.captionMedium, color: c.textSecondary },
|
||||||
|
done: { alignItems: 'center', paddingVertical: spacing.sm, marginTop: spacing.xs },
|
||||||
|
doneText: { ...typography.bodyMedium, color: c.primary },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { getEmailInitials } from '../../lib/avatar-utils';
|
||||||
|
import type { MentionCandidate } from '../../lib/recipient-mentions';
|
||||||
|
|
||||||
|
/** Rows of the list above the keyboard; a short window gets fewer. */
|
||||||
|
export function mentionListMaxHeight(shortWindow: boolean): number {
|
||||||
|
return (shortWindow ? 2 : 4) * ROW_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_HEIGHT = 48;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who an "@" in the body can name (webmail RecipientMentionList): the
|
||||||
|
* recipients first, then contacts, who picking adds to To. Sits between the
|
||||||
|
* body and the format bar, above the keyboard.
|
||||||
|
*/
|
||||||
|
export function MentionSuggestionList({
|
||||||
|
items, onPick, shortWindow,
|
||||||
|
}: {
|
||||||
|
items: MentionCandidate[];
|
||||||
|
onPick: (item: MentionCandidate) => void;
|
||||||
|
shortWindow: boolean;
|
||||||
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.box, { maxHeight: mentionListMaxHeight(shortWindow) }]}
|
||||||
|
accessibilityLabel={t('email_composer.mention_recipients', 'Recipients')}
|
||||||
|
>
|
||||||
|
<ScrollView keyboardShouldPersistTaps="always" nestedScrollEnabled>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Pressable
|
||||||
|
key={item.email}
|
||||||
|
onPress={() => onPick(item)}
|
||||||
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`@${item.label}, ${item.name && item.name !== item.label ? `${item.name}, ` : ''}${item.email}`}
|
||||||
|
>
|
||||||
|
<View style={styles.avatar}>
|
||||||
|
<Text style={styles.avatarText}>{getEmailInitials((item.name ?? '').trim(), item.email)}</Text>
|
||||||
|
</View>
|
||||||
|
{/* What goes in comes first: for "Nagy János" that may be the
|
||||||
|
surname, which the full name alone would not show. */}
|
||||||
|
<Text style={styles.label} numberOfLines={1}>@{item.label}</Text>
|
||||||
|
<Text style={styles.detail} numberOfLines={1}>
|
||||||
|
{item.name && item.name !== item.label ? `${item.name} · ${item.email}` : item.email}
|
||||||
|
</Text>
|
||||||
|
{/* Picking a contact also adds them as a recipient; say so first. */}
|
||||||
|
{item.addsTo && (
|
||||||
|
<Text style={styles.addsTo}>
|
||||||
|
+ {item.addsTo === 'cc' ? t('email_composer.cc', 'Cc') : t('email_composer.to', 'To')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
box: {
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: c.borderLight,
|
||||||
|
backgroundColor: c.card,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
minHeight: ROW_HEIGHT,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
rowPressed: { backgroundColor: c.surfaceHover },
|
||||||
|
avatar: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: c.primaryBg,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
avatarText: { ...typography.captionMedium, color: c.primary },
|
||||||
|
label: { ...typography.bodyMedium, color: c.text, flexShrink: 0, maxWidth: '45%' },
|
||||||
|
detail: { ...typography.caption, color: c.textSecondary, flex: 1 },
|
||||||
|
addsTo: {
|
||||||
|
...typography.caption,
|
||||||
|
color: c.textSecondary,
|
||||||
|
backgroundColor: c.surfaceActive,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { useNetworkStore } from '../../stores/network-store';
|
||||||
|
import { useOutboxStore } from '../../stores/outbox-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
|
||||||
|
import { canCancel, queuedSendStatus, type QueuedSend } from '../../lib/send-queue';
|
||||||
|
|
||||||
|
function recipientsOf(send: QueuedSend): string {
|
||||||
|
const { to, cc, bcc } = send.payload.outgoing;
|
||||||
|
return [...to, ...(cc ?? []), ...(bcc ?? [])].map((r) => r.name || r.email).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Outbox: messages sent while offline that haven't gone out yet. Each
|
||||||
|
* can be moved to Drafts (online) or discarded, and a failed one tried
|
||||||
|
* again; none of that while it is on its way.
|
||||||
|
*/
|
||||||
|
export function OutboxSheet({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const online = useNetworkStore((s) => s.online);
|
||||||
|
const sends = useOutboxStore((s) => s.sends);
|
||||||
|
const bottomInset = useSheetBottomInset(visible);
|
||||||
|
const [busyId, setBusyId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (visible && sends.length === 0) onClose();
|
||||||
|
}, [visible, sends.length, onClose]);
|
||||||
|
|
||||||
|
const moveToDrafts = async (send: QueuedSend) => {
|
||||||
|
setBusyId(send.id);
|
||||||
|
const ok = await useOutboxStore.getState().cancelSend(send.id, { keepAsDraft: true });
|
||||||
|
setBusyId(null);
|
||||||
|
if (!ok) {
|
||||||
|
Alert.alert(
|
||||||
|
t('outbox.move_failed_title', 'Could not move to Drafts'),
|
||||||
|
t('outbox.move_failed_body', 'The message stays in the Outbox. Try again when the connection is back.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const discard = (send: QueuedSend) => {
|
||||||
|
Alert.alert(
|
||||||
|
t('outbox.discard_title', 'Discard this message?'),
|
||||||
|
t('outbox.discard_body', 'It will not be sent, and its text is lost.'),
|
||||||
|
[
|
||||||
|
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: t('outbox.discard', 'Discard'),
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => { void useOutboxStore.getState().cancelSend(send.id); },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
|
<Pressable accessible={false} style={[styles.backdrop, { paddingBottom: spacing.lg + bottomInset }]} onPress={onClose}>
|
||||||
|
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||||
|
<Text style={styles.title} accessibilityRole="header">{t('outbox.title', 'Outbox')}</Text>
|
||||||
|
<ScrollView style={styles.list}>
|
||||||
|
{sends.map((send) => {
|
||||||
|
const busy = busyId === send.id || send.status === 'sending';
|
||||||
|
const cancellable = canCancel(send) && busyId !== send.id;
|
||||||
|
return (
|
||||||
|
<View key={send.id} style={styles.row}>
|
||||||
|
<Text style={styles.subject} numberOfLines={1}>
|
||||||
|
{send.payload.outgoing.subject || t('email_viewer.no_subject', '(No subject)')}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.recipients} numberOfLines={1}>{recipientsOf(send)}</Text>
|
||||||
|
<View style={styles.statusRow}>
|
||||||
|
{busy && <ActivityIndicator size="small" color={c.textSecondary} />}
|
||||||
|
<Text
|
||||||
|
style={[styles.status, send.status === 'failed' && styles.statusFailed]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{queuedSendStatus(send, online, t)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{send.status === 'failed' && (
|
||||||
|
<Pressable
|
||||||
|
style={styles.action}
|
||||||
|
onPress={() => { void useOutboxStore.getState().retrySend(send.id); }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
<Text style={styles.actionPrimary}>{t('outbox.retry', 'Try again')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
{online && (
|
||||||
|
<Pressable
|
||||||
|
style={styles.action}
|
||||||
|
disabled={!cancellable}
|
||||||
|
onPress={() => { void moveToDrafts(send); }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: !cancellable }}
|
||||||
|
>
|
||||||
|
<Text style={[styles.actionText, !cancellable && styles.actionDisabled]}>
|
||||||
|
{t('outbox.move_to_drafts', 'Move to Drafts')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
<Pressable
|
||||||
|
style={styles.action}
|
||||||
|
disabled={!cancellable}
|
||||||
|
onPress={() => discard(send)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: !cancellable }}
|
||||||
|
>
|
||||||
|
<Text style={[styles.actionDestructive, !cancellable && styles.actionDisabled]}>
|
||||||
|
{t('outbox.discard', 'Discard')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
<Pressable style={styles.close} onPress={onClose} accessibilityRole="button">
|
||||||
|
<Text style={styles.closeText}>{t('common.close', 'Close')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 480,
|
||||||
|
maxHeight: '90%',
|
||||||
|
backgroundColor: c.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
padding: spacing.lg,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
title: { ...typography.h3, color: c.text },
|
||||||
|
list: { flexGrow: 0 },
|
||||||
|
row: {
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: c.borderLight,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
subject: { ...typography.bodyMedium, color: c.text },
|
||||||
|
recipients: { ...typography.caption, color: c.textSecondary },
|
||||||
|
statusRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
||||||
|
status: { ...typography.caption, color: c.textMuted, flex: 1 },
|
||||||
|
statusFailed: { color: c.error },
|
||||||
|
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.md, marginTop: spacing.xs },
|
||||||
|
action: { minHeight: 40, justifyContent: 'center' },
|
||||||
|
actionText: { ...typography.bodyMedium, color: c.textSecondary },
|
||||||
|
actionPrimary: { ...typography.bodyMedium, color: c.primary },
|
||||||
|
actionDestructive: { ...typography.bodyMedium, color: c.error },
|
||||||
|
actionDisabled: { opacity: 0.4 },
|
||||||
|
close: { alignItems: 'center', paddingVertical: spacing.sm },
|
||||||
|
closeText: { ...typography.bodyMedium, color: c.textSecondary },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { ArrowLeft } from '../icons';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset';
|
||||||
|
import type { RecipientSuggestion } from '../../stores/contacts-store';
|
||||||
|
import { RecipientChip, RecipientSuggestionList, type ChipRecipient } from './RecipientSuggestionList';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen recipient picker (U-069) for where the inline suggestions have
|
||||||
|
* no room: a phone in landscape with the keyboard up. The field sits at the
|
||||||
|
* top and the same suggestions fill the space down to the keyboard; Done or
|
||||||
|
* Back goes back to the composer with what was typed turned into chips.
|
||||||
|
*/
|
||||||
|
export function RecipientPicker<R extends ChipRecipient>({
|
||||||
|
visible, label, placeholder, recipients, isValid, input, onChangeInput, onSubmitInput,
|
||||||
|
suggestions, onPick, onSearchServer, searchingServer, onRemove, onDone,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
/** "To", "Cc" or "Bcc". */
|
||||||
|
label: string;
|
||||||
|
placeholder: string;
|
||||||
|
recipients: R[];
|
||||||
|
isValid: (r: R) => boolean;
|
||||||
|
input: string;
|
||||||
|
onChangeInput: (text: string) => void;
|
||||||
|
/** Return in the field: turn the typed text into a chip. */
|
||||||
|
onSubmitInput: () => void;
|
||||||
|
suggestions: RecipientSuggestion[];
|
||||||
|
onPick: (s: RecipientSuggestion) => void;
|
||||||
|
onSearchServer?: () => void;
|
||||||
|
searchingServer?: boolean;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const keyboardPadding = useModalKeyboardPadding(visible);
|
||||||
|
const inputRef = React.useRef<TextInput>(null);
|
||||||
|
const showList = suggestions.length > 0 || (!!onSearchServer && !!input.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaModal
|
||||||
|
supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
|
visible={visible}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onDone}
|
||||||
|
onShow={() => inputRef.current?.focus()}
|
||||||
|
>
|
||||||
|
<SafeAreaView style={styles.screen} edges={['top', 'bottom', 'left', 'right']}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={[styles.screen, { paddingBottom: keyboardPadding }]}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onDone}
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.headerBtn}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.back', 'Back')}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={22} color={c.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.title} numberOfLines={1} accessibilityRole="header">{label}</Text>
|
||||||
|
<Pressable onPress={onDone} hitSlop={10} style={styles.headerBtn} accessibilityRole="button">
|
||||||
|
<Text style={styles.done}>{t('common.done', 'Done')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View style={styles.field}>
|
||||||
|
{recipients.map((r, i) => (
|
||||||
|
<RecipientChip
|
||||||
|
key={`${r.email}-${i}`}
|
||||||
|
recipient={r}
|
||||||
|
invalid={!isValid(r)}
|
||||||
|
onRemove={() => onRemove(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
disableFullscreenUI
|
||||||
|
accessibilityLabel={label}
|
||||||
|
style={styles.input}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={c.textMuted}
|
||||||
|
value={input}
|
||||||
|
onChangeText={onChangeInput}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={() => {
|
||||||
|
if (input.trim()) onSubmitInput();
|
||||||
|
else onDone();
|
||||||
|
}}
|
||||||
|
blurOnSubmit={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="off"
|
||||||
|
importantForAutofill="no"
|
||||||
|
textContentType="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{showList ? (
|
||||||
|
<RecipientSuggestionList
|
||||||
|
fill
|
||||||
|
suggestions={suggestions}
|
||||||
|
onPick={onPick}
|
||||||
|
onSearchServer={onSearchServer}
|
||||||
|
searchingServer={searchingServer}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.empty} />
|
||||||
|
)}
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</SafeAreaModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
screen: { flex: 1, backgroundColor: c.background },
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.md,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: c.borderLight,
|
||||||
|
},
|
||||||
|
headerBtn: { minHeight: 40, minWidth: 40, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
title: { ...typography.h3, color: c.text, flex: 1 },
|
||||||
|
done: { ...typography.bodyMedium, color: c.primary },
|
||||||
|
field: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: c.borderLight,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 140,
|
||||||
|
...typography.body,
|
||||||
|
color: c.text,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
empty: { flex: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Search, Users, X } from '../icons';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { getEmailInitials } from '../../lib/avatar-utils';
|
||||||
|
import { SUGGESTION_ROW_HEIGHT } from '../../lib/recipient-picker';
|
||||||
|
import type { RecipientSuggestion } from '../../stores/contacts-store';
|
||||||
|
|
||||||
|
/** A recipient chip's data. Group chips carry their resolved members and no email. */
|
||||||
|
export interface ChipRecipient {
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
group?: { members: Array<{ name?: string; email: string }> };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecipientChip({
|
||||||
|
recipient, invalid, onRemove, onLongPress,
|
||||||
|
}: {
|
||||||
|
recipient: ChipRecipient;
|
||||||
|
invalid?: boolean;
|
||||||
|
onRemove: () => void;
|
||||||
|
onLongPress?: () => void;
|
||||||
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const label = recipient.group
|
||||||
|
? t('email_composer.group_chip', '{name} ({count})', {
|
||||||
|
name: recipient.name || t('contacts.group', 'Group'),
|
||||||
|
count: recipient.group.members.length,
|
||||||
|
})
|
||||||
|
: recipient.name || recipient.email;
|
||||||
|
return (
|
||||||
|
<Pressable onLongPress={onLongPress} delayLongPress={350} style={[styles.chip, invalid && styles.chipInvalid]}>
|
||||||
|
{recipient.group && <Users size={12} color={c.textSecondary} />}
|
||||||
|
<Text style={[styles.chipText, invalid && styles.chipTextInvalid]} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onRemove}
|
||||||
|
// A 12 px icon: the touch area reaches about 40 dp around it.
|
||||||
|
hitSlop={14}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('email_composer.recipient_remove', 'Remove')}
|
||||||
|
>
|
||||||
|
<X size={12} color={invalid ? c.error : c.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contact suggestions for the address being typed. Inline it drops down
|
||||||
|
* under the field; `fill` makes it the body of the full-screen recipient
|
||||||
|
* picker, where it takes the room above the keyboard.
|
||||||
|
*/
|
||||||
|
export function RecipientSuggestionList({
|
||||||
|
suggestions, onPick, onPressIn, onSearchServer, searchingServer, fill = false,
|
||||||
|
}: {
|
||||||
|
suggestions: RecipientSuggestion[];
|
||||||
|
onPick: (s: RecipientSuggestion) => void;
|
||||||
|
onPressIn?: () => void;
|
||||||
|
/** Search the Sent folder on the server for more (webmail: "Search the server"). */
|
||||||
|
onSearchServer?: () => void;
|
||||||
|
searchingServer?: boolean;
|
||||||
|
fill?: boolean;
|
||||||
|
}) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
return (
|
||||||
|
<View style={fill ? styles.fillBox : styles.suggestionBox}>
|
||||||
|
<ScrollView
|
||||||
|
style={fill ? styles.fillScroll : styles.suggestionScroll}
|
||||||
|
keyboardShouldPersistTaps="always"
|
||||||
|
nestedScrollEnabled
|
||||||
|
>
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<Pressable
|
||||||
|
key={`${s.group?.id ?? s.email}-${i}`}
|
||||||
|
onPressIn={onPressIn}
|
||||||
|
onPress={() => onPick(s)}
|
||||||
|
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
|
||||||
|
>
|
||||||
|
<View style={styles.suggestionAvatar}>
|
||||||
|
{s.group
|
||||||
|
? <Users size={14} color={c.primary} />
|
||||||
|
: <Text style={styles.suggestionAvatarText}>{getEmailInitials(s.name.trim(), s.email)}</Text>}
|
||||||
|
</View>
|
||||||
|
<View style={styles.suggestionText}>
|
||||||
|
<Text style={styles.suggestionName} numberOfLines={1}>
|
||||||
|
{s.name || s.email}
|
||||||
|
</Text>
|
||||||
|
{s.group ? (
|
||||||
|
<Text style={styles.suggestionEmail} numberOfLines={1}>
|
||||||
|
{t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: s.group.memberCount })}
|
||||||
|
</Text>
|
||||||
|
) : !!s.name && (
|
||||||
|
<Text style={styles.suggestionEmail} numberOfLines={1}>{s.email}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
{onSearchServer && (
|
||||||
|
<Pressable
|
||||||
|
onPressIn={onPressIn}
|
||||||
|
onPress={() => { if (!searchingServer) onSearchServer(); }}
|
||||||
|
disabled={searchingServer}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.suggestionRow,
|
||||||
|
suggestions.length > 0 && styles.suggestionSearchRowDivided,
|
||||||
|
pressed && styles.suggestionRowPressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.suggestionSearchIcon}>
|
||||||
|
{searchingServer
|
||||||
|
? <ActivityIndicator size="small" color={c.textSecondary} />
|
||||||
|
: <Search size={16} color={c.textSecondary} />}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.suggestionSearchText} numberOfLines={1}>
|
||||||
|
{searchingServer
|
||||||
|
? t('email_composer.autocomplete_searching', 'Searching...')
|
||||||
|
: t('email_composer.autocomplete_search_server', 'Search the server')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
chip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
backgroundColor: c.surfaceActive,
|
||||||
|
maxWidth: 220,
|
||||||
|
},
|
||||||
|
chipInvalid: { backgroundColor: c.errorBg, borderWidth: 1, borderColor: c.error },
|
||||||
|
chipText: { ...typography.caption, color: c.text, flexShrink: 1 },
|
||||||
|
chipTextInvalid: { color: c.error },
|
||||||
|
suggestionBox: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: spacing.lg + 56 + spacing.md,
|
||||||
|
right: spacing.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.borderLight,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
backgroundColor: c.card,
|
||||||
|
zIndex: 20,
|
||||||
|
elevation: 5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
fillBox: { flex: 1, backgroundColor: c.background },
|
||||||
|
suggestionRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
minHeight: SUGGESTION_ROW_HEIGHT,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
suggestionRowPressed: { backgroundColor: c.surfaceHover },
|
||||||
|
suggestionScroll: { maxHeight: 320 },
|
||||||
|
fillScroll: { flex: 1 },
|
||||||
|
suggestionSearchRowDivided: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: c.borderLight },
|
||||||
|
suggestionSearchIcon: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
suggestionSearchText: { ...typography.body, color: c.textSecondary, flex: 1 },
|
||||||
|
suggestionAvatar: {
|
||||||
|
width: 32, height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: c.primaryBg,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
suggestionAvatarText: { ...typography.captionMedium, color: c.primary },
|
||||||
|
suggestionText: { flex: 1 },
|
||||||
|
suggestionName: { ...typography.bodyMedium, color: c.text },
|
||||||
|
suggestionEmail: { ...typography.caption, color: c.textSecondary },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -185,6 +185,11 @@ describe('htmlToPlainText links', () => {
|
|||||||
it('drops the mailto: prefix when the text is the address', () => {
|
it('drops the mailto: prefix when the text is the address', () => {
|
||||||
expect(htmlToPlainText('<a href="mailto:a@b.c">a@b.c</a>')).toBe('a@b.c');
|
expect(htmlToPlainText('<a href="mailto:a@b.c">a@b.c</a>')).toBe('a@b.c');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes an @mention as the name alone, as the webmail does', () => {
|
||||||
|
expect(htmlToPlainText('<p>Hi <a href="mailto:max@x.de" class="mention">@Max</a> how</p>', { mailMarkup: true }))
|
||||||
|
.toBe('Hi @Max how');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extractUserAuthoredText', () => {
|
describe('extractUserAuthoredText', () => {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// "@" mentions as wired in ComposeScreen (webmail email-composer.tsx
|
||||||
|
// mentionCandidates / findMentionContacts / addMentionedRecipient), checked
|
||||||
|
// on its source (the suite has no RN renderer).
|
||||||
|
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
|
||||||
|
|
||||||
|
describe('ComposeScreen mention wiring', () => {
|
||||||
|
it('turns mentions on in the message editor and follows its query', () => {
|
||||||
|
const at = source.search(/<RichTextEditor\s/);
|
||||||
|
const editor = source.slice(at, source.indexOf('/>', at));
|
||||||
|
expect(editor).toMatch(/\bmentions\b/);
|
||||||
|
expect(editor).toMatch(/onMentionQuery=\{setMentionQuery\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers To and Cc, never Bcc, and leaves out contacts already on the message', () => {
|
||||||
|
expect(source).toMatch(/buildMentionCandidates\(toRecipients, ccRecipients\)/);
|
||||||
|
const items = source.slice(source.indexOf('const mentionItems'), source.indexOf('const pickMention'));
|
||||||
|
expect(items).toMatch(/\.\.\.bccRecipients/);
|
||||||
|
expect(items).toMatch(/primaryIdentity\?\.bcc/);
|
||||||
|
expect(items).toMatch(/!onMessage\.has\(s\.email\.trim\(\)\.toLowerCase\(\)\)/);
|
||||||
|
expect(items).toMatch(/mentionSuggestions\(mentionCandidates, contacts, mentionQuery, 'to'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts the picked mention as a mailto link and adds a contact to To', () => {
|
||||||
|
const pick = source.slice(source.indexOf('const pickMention'), source.indexOf('const pickMention') + 800);
|
||||||
|
expect(pick).toMatch(/editorRef\.current\?\.insertMention\(candidate\.label, mentionHref\(candidate\.email\)\)/);
|
||||||
|
expect(pick).toMatch(/setToRecipients\(\(prev\) => addMentionedRecipient\(prev, candidate,/);
|
||||||
|
expect(source).toMatch(/<MentionSuggestionList items=\{mentionItems\} onPick=\{pickMention\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// The composer's options sheet (priority, DSN, REQUIRETLS) as wired in
|
||||||
|
// ComposeScreen, checked on its source (the suite has no RN renderer).
|
||||||
|
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
|
||||||
|
|
||||||
|
describe('ComposeScreen options wiring', () => {
|
||||||
|
it('opens the options sheet from the toolbar', () => {
|
||||||
|
expect(source).toMatch(/onPress=\{\(\) => setOptionsSheetOpen\(true\)\}/);
|
||||||
|
expect(source).toMatch(/<ComposerOptionsSheet[\s\S]{0,200}onChange=\{onComposerOptionsChange\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the priority with the message and saves it with the draft', () => {
|
||||||
|
const build = source.slice(source.indexOf('const buildOutgoing'), source.indexOf('// Save one draft version'));
|
||||||
|
expect(build).toMatch(/priority: priority !== 'normal' \? priority : undefined,/);
|
||||||
|
// A priority change alone makes the message dirty.
|
||||||
|
expect(source).toMatch(/pri: s\.priority \?\? 'normal',/);
|
||||||
|
expect(source).toMatch(/const initialPriority: MessagePriority = draft\?\.priority \?\? 'normal';/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for DSN and REQUIRETLS only where the submitting account offers them', () => {
|
||||||
|
const send = source.slice(source.indexOf('const performSendInner'));
|
||||||
|
expect(send).toMatch(/requestDsn: requestDsn && supportsSubmissionExtension\('DSN', target\.accountId\),/);
|
||||||
|
expect(send).toMatch(/requireTls: requireTls && supportsSubmissionExtension\('REQUIRETLS', target\.accountId\),/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Offline send as wired in ComposeScreen and the offline banner, checked on
|
||||||
|
// their source (the suite has no RN renderer).
|
||||||
|
const compose = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
|
||||||
|
const banner = fs.readFileSync(path.resolve(__dirname, '../../components/OfflineBanner.tsx'), 'utf8');
|
||||||
|
|
||||||
|
describe('ComposeScreen offline send', () => {
|
||||||
|
const send = compose.slice(compose.indexOf('const performSendInner'), compose.indexOf('const titleKey'));
|
||||||
|
|
||||||
|
it('queues the message instead of trying while offline', () => {
|
||||||
|
const offline = send.indexOf('if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return;');
|
||||||
|
expect(offline).toBeGreaterThan(-1);
|
||||||
|
// Before anything goes to the server.
|
||||||
|
expect(offline).toBeLessThan(send.indexOf('await sendTargetFor('));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues a send whose connection failed, marked as possibly sent, ahead of the other failure messages', () => {
|
||||||
|
const queued = send.indexOf('shouldQueueFailedSend(classifySendFailure(e, isTransientNetworkError))');
|
||||||
|
expect(queued).toBeGreaterThan(-1);
|
||||||
|
expect(send).toMatch(/&& queueForLater\(queuedOutgoing, true\)\) \{\s+return;/);
|
||||||
|
expect(queued).toBeLessThan(send.indexOf('if (e instanceof RequestTimeoutError)'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues into the login the message was written in, and hands the Outbox the autosaved draft", () => {
|
||||||
|
const queue = send.slice(send.indexOf('const queueForLater'), send.indexOf('setSending(true);'));
|
||||||
|
expect(queue).toMatch(/if \(outbox\.activeAccountId !== ownerLoginRef\.current\) return false;/);
|
||||||
|
expect(queue).toMatch(/draftId: draftIdRef\.current \?\? undefined,/);
|
||||||
|
expect(queue).toMatch(/if \(!queuedId\) return false;\s+draftIdRef\.current = null;/);
|
||||||
|
expect(queue).toMatch(/navigation\.goBack\(\);/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OfflineBanner Outbox', () => {
|
||||||
|
it('shows the Outbox while messages wait, online too, and opens it', () => {
|
||||||
|
expect(banner).toMatch(/const sends = useOutboxStore\(\(s\) => s\.sends\);/);
|
||||||
|
expect(banner).toMatch(/if \(online && !sendsHint\) return null;/);
|
||||||
|
expect(banner).toMatch(/onPress=\{\(\) => setOutboxOpen\(true\)\}/);
|
||||||
|
expect(banner).toMatch(/<OutboxSheet visible=\{outboxOpen\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildEditorHtml, MENTION_QUERY_SOURCE } from '../editor-html';
|
||||||
|
import { LIGHT_COLORS } from '../../theme/tokens';
|
||||||
|
|
||||||
|
// "@" mentions in the composer's editor page (webmail #1134): the page
|
||||||
|
// reports the "@..." being typed and swaps it for a mention link on request.
|
||||||
|
|
||||||
|
function extractScript(html: string): string {
|
||||||
|
const m = html.match(/<script(?: nonce="[0-9a-f]+")?>([\s\S]*?)<\/script>/);
|
||||||
|
return m![1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionQueryIn = new Function(`${MENTION_QUERY_SOURCE}; return mentionQueryIn;`)() as
|
||||||
|
(text: string) => { at: number; query: string } | null;
|
||||||
|
|
||||||
|
describe('mentionQueryIn', () => {
|
||||||
|
it('starts at an @ at the start of a word', () => {
|
||||||
|
expect(mentionQueryIn('@')).toEqual({ at: 0, query: '' });
|
||||||
|
expect(mentionQueryIn('Hi @ma')).toEqual({ at: 3, query: 'ma' });
|
||||||
|
expect(mentionQueryIn(`Hi${String.fromCharCode(160)}@jü`)).toEqual({ at: 3, query: 'jü' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never starts inside an address, and ends at a space', () => {
|
||||||
|
expect(mentionQueryIn('info@example')).toBeNull();
|
||||||
|
expect(mentionQueryIn('Hi @max and')).toBeNull();
|
||||||
|
expect(mentionQueryIn('no at sign')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FakeNode { nodeType: number; tagName?: string; data?: string; parentNode?: unknown; textContent?: string; attrs?: Record<string, string> }
|
||||||
|
|
||||||
|
function boot(mentions: boolean) {
|
||||||
|
const listeners: Record<string, Array<() => void>> = {};
|
||||||
|
const docListeners: Record<string, Array<() => void>> = {};
|
||||||
|
const posted: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
const ops: string[] = [];
|
||||||
|
const editor = {
|
||||||
|
nodeType: 1,
|
||||||
|
tagName: 'DIV',
|
||||||
|
innerHTML: '',
|
||||||
|
get textContent() { return this.innerHTML.replace(/<[^>]*>/g, ''); },
|
||||||
|
scrollHeight: 100,
|
||||||
|
querySelector: () => null,
|
||||||
|
setAttribute: () => undefined,
|
||||||
|
focus: () => undefined,
|
||||||
|
addEventListener: (name: string, fn: () => void) => { (listeners[name] ??= []).push(fn); },
|
||||||
|
};
|
||||||
|
const text: FakeNode = { nodeType: 3, data: 'Hi @ma', parentNode: editor };
|
||||||
|
let selection = { rangeCount: 1, isCollapsed: true, anchorNode: text as unknown, anchorOffset: 6 };
|
||||||
|
const range = {
|
||||||
|
setStart: (n: FakeNode, o: number) => ops.push(`start ${n === text ? 'text' : '?'} ${o}`),
|
||||||
|
setEnd: (n: FakeNode, o: number) => ops.push(`end ${n === text ? 'text' : '?'} ${o}`),
|
||||||
|
deleteContents: () => ops.push('delete'),
|
||||||
|
insertNode: (n: FakeNode) => ops.push(`insert ${n.tagName ?? JSON.stringify(n.data)}`),
|
||||||
|
setStartAfter: (n: FakeNode) => ops.push(`caret after ${n.tagName ?? JSON.stringify(n.data)}`),
|
||||||
|
collapse: () => undefined,
|
||||||
|
};
|
||||||
|
const created: FakeNode[] = [];
|
||||||
|
const document = {
|
||||||
|
activeElement: editor as unknown,
|
||||||
|
getElementById: () => editor,
|
||||||
|
addEventListener: (name: string, fn: () => void) => { (docListeners[name] ??= []).push(fn); },
|
||||||
|
queryCommandState: () => false,
|
||||||
|
createRange: () => range,
|
||||||
|
createElement: (tag: string) => {
|
||||||
|
const el: FakeNode = { nodeType: 1, tagName: tag.toUpperCase(), attrs: {} };
|
||||||
|
(el as unknown as { setAttribute: (k: string, v: string) => void }).setAttribute = (k, v) => { el.attrs![k] = v; };
|
||||||
|
created.push(el);
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
createTextNode: (data: string) => ({ nodeType: 3, data }),
|
||||||
|
};
|
||||||
|
const window: Record<string, unknown> = {
|
||||||
|
ReactNativeWebView: { postMessage: (msg: string) => posted.push(JSON.parse(msg)) },
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
getSelection: () => ({ ...selection, removeAllRanges: () => ops.push('clear'), addRange: () => ops.push('add range') }),
|
||||||
|
};
|
||||||
|
const script = extractScript(buildEditorHtml({ initialHtml: '<p><br></p>', placeholder: '', c: LIGHT_COLORS, mentions }));
|
||||||
|
new Function('window', 'document', script)(window, document);
|
||||||
|
const mentionsPosted = () => posted.filter((m) => m.type === 'mention').map((m) => m.payload);
|
||||||
|
const input = () => listeners.input.forEach((fn) => fn());
|
||||||
|
const blur = () => listeners.blur.forEach((fn) => fn());
|
||||||
|
const setCaret = (data: string, offset = data.length) => {
|
||||||
|
text.data = data;
|
||||||
|
selection = { ...selection, anchorOffset: offset };
|
||||||
|
};
|
||||||
|
const bridge = window.__rne as { insertMention: (label: string, href: string) => void };
|
||||||
|
return { mentionsPosted, input, blur, setCaret, bridge, ops, created, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('editor page mentions', () => {
|
||||||
|
it('reports what follows the @ while it is typed, and null once it ends', () => {
|
||||||
|
const page = boot(true);
|
||||||
|
page.input();
|
||||||
|
page.setCaret('Hi @max');
|
||||||
|
page.input();
|
||||||
|
page.setCaret('Hi @max ');
|
||||||
|
page.input();
|
||||||
|
expect(page.mentionsPosted()).toEqual(['ma', 'max', null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the list when the editor loses focus', () => {
|
||||||
|
const page = boot(true);
|
||||||
|
page.input();
|
||||||
|
page.blur();
|
||||||
|
expect(page.mentionsPosted()).toEqual(['ma', null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the typed @query with a mailto link and a space, caret after it', () => {
|
||||||
|
const page = boot(true);
|
||||||
|
page.bridge.insertMention('Max <b>', 'mailto:max@dornig.de');
|
||||||
|
expect(page.ops).toEqual([
|
||||||
|
'start text 3', 'end text 6', 'delete',
|
||||||
|
`insert ${JSON.stringify(String.fromCharCode(160))}`, 'insert A',
|
||||||
|
`caret after ${JSON.stringify(String.fromCharCode(160))}`, 'clear', 'add range',
|
||||||
|
]);
|
||||||
|
const [link] = page.created;
|
||||||
|
expect(link.attrs).toEqual({ href: 'mailto:max@dornig.de', class: 'mention' });
|
||||||
|
// The label goes in as text, never as markup.
|
||||||
|
expect(link.textContent).toBe('@Max <b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing in editors that are not a message', () => {
|
||||||
|
const page = boot(false);
|
||||||
|
page.input();
|
||||||
|
page.bridge.insertMention('Max', 'mailto:max@dornig.de');
|
||||||
|
expect(page.mentionsPosted()).toEqual([]);
|
||||||
|
expect(page.ops).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
// Ported from the webmail's lib/__tests__/recipient-mentions.test.ts, plus
|
||||||
|
// the app's own helpers at the end.
|
||||||
|
import {
|
||||||
|
addMentionedRecipient, buildMentionCandidates, filterMentionCandidates, mentionHref, mentionLabel, mentionSuggestions,
|
||||||
|
} from '../recipient-mentions';
|
||||||
|
|
||||||
|
describe('mentionLabel', () => {
|
||||||
|
it('takes the first name from a display name', () => {
|
||||||
|
expect(mentionLabel('Max Mustermann', 'mm@dornig.de')).toBe('Max');
|
||||||
|
expect(mentionLabel('Mustermann, Max', 'mm@dornig.de')).toBe('Max');
|
||||||
|
expect(mentionLabel('Dr. Max Mustermann', 'mm@dornig.de')).toBe('Max');
|
||||||
|
expect(mentionLabel('Prof. Dr.-Ing. Max Mustermann', 'mm@dornig.de')).toBe('Max');
|
||||||
|
expect(mentionLabel('Jürgen Müller', 'jm@dornig.de')).toBe('Jürgen');
|
||||||
|
expect(mentionLabel("'Max Mustermann'", 'mm@dornig.de')).toBe('Max');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the first name from a firstname.lastname address when no name is known', () => {
|
||||||
|
expect(mentionLabel(undefined, 'max.mustermann@dornig.de')).toBe('Max');
|
||||||
|
expect(mentionLabel('', 'anna-lena.schmidt@dornig.de')).toBe('Anna-Lena');
|
||||||
|
expect(mentionLabel(undefined, 'max.mustermann+rechnungen@dornig.de')).toBe('Max');
|
||||||
|
expect(mentionLabel(undefined, 'juergen.mueller@dornig.de')).toBe('Juergen');
|
||||||
|
expect(mentionLabel(undefined, 'info@dornig.de')).toBe('Info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a firstname.lastname address tell which word of the name is the first name', () => {
|
||||||
|
expect(mentionLabel('Mustermann Max', 'max.mustermann@dornig.de')).toBe('Max');
|
||||||
|
expect(mentionLabel('Müller Jürgen', 'juergen.mueller@dornig.de')).toBe('Jürgen');
|
||||||
|
expect(mentionLabel('Dornig GmbH - Max Mustermann', 'max.mustermann@dornig.de')).toBe('Max');
|
||||||
|
// A one-part mailbox name confirms nothing: it may be the last name.
|
||||||
|
expect(mentionLabel('Max Mustermann', 'mustermann@dornig.de')).toBe('Max');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a display name that is an address or mailbox name like the address', () => {
|
||||||
|
expect(mentionLabel('max.mustermann@dornig.de', 'max.mustermann@dornig.de')).toBe('Max');
|
||||||
|
expect(mentionLabel('max.mustermann', 'mm@dornig.de')).toBe('Max');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the whole mailbox name when the first name is a bare initial', () => {
|
||||||
|
expect(mentionLabel(undefined, 'm.mustermann@dornig.de')).toBe('m.mustermann');
|
||||||
|
expect(mentionLabel(undefined, 'm.mustermann+news@dornig.de')).toBe('m.mustermann');
|
||||||
|
expect(mentionLabel('M. Mustermann', 'm.mustermann@dornig.de')).toBe('m.mustermann');
|
||||||
|
// The address may know more than the display name.
|
||||||
|
expect(mentionLabel('M. Mustermann', 'max.mustermann@dornig.de')).toBe('Max');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays fast on a crafted display name from an incoming message', () => {
|
||||||
|
// Uncapped, trimming this token takes seconds (quadratic backtracking).
|
||||||
|
const crafted = `a${'!'.repeat(100_000)}b`;
|
||||||
|
const started = performance.now();
|
||||||
|
expect(mentionLabel(crafted, 'max.mustermann@dornig.de')).toBe('Max');
|
||||||
|
expect(performance.now() - started).toBeLessThan(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildMentionCandidates', () => {
|
||||||
|
it('offers To and Cc recipients in order, each address once', () => {
|
||||||
|
const candidates = buildMentionCandidates(
|
||||||
|
[{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }, { email: 'anna.schmidt@dornig.de' }],
|
||||||
|
[{ email: 'MAX.MUSTERMANN@dornig.de' }, { name: 'Eva Weber', email: 'eva.weber@dornig.de' }],
|
||||||
|
);
|
||||||
|
expect(candidates).toEqual([
|
||||||
|
{ label: 'Max', name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
|
||||||
|
{ label: 'Anna', name: undefined, email: 'anna.schmidt@dornig.de' },
|
||||||
|
{ label: 'Eva', name: 'Eva Weber', email: 'eva.weber@dornig.de' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands contact groups into their members and skips entries without an address', () => {
|
||||||
|
const candidates = buildMentionCandidates(
|
||||||
|
[{
|
||||||
|
name: 'Team',
|
||||||
|
email: '',
|
||||||
|
group: { members: [{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }, { email: 'eva.weber@dornig.de' }] },
|
||||||
|
}],
|
||||||
|
[{ email: 'not-an-address' }],
|
||||||
|
);
|
||||||
|
expect(candidates.map((c) => c.label)).toEqual(['Max', 'Eva']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells apart recipients who would share a first name', () => {
|
||||||
|
const labels = (to: Array<{ name?: string; email: string }>) => buildMentionCandidates(to, []).map((c) => c.label);
|
||||||
|
expect(labels([
|
||||||
|
{ name: 'Max Mustermann', email: 'a@dornig.de' },
|
||||||
|
{ name: 'Mueller, Max', email: 'b@dornig.de' },
|
||||||
|
{ name: 'Eva Weber', email: 'c@dornig.de' },
|
||||||
|
])).toEqual(['Max Mustermann', 'Max Mueller', 'Eva']);
|
||||||
|
expect(labels([
|
||||||
|
{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
|
||||||
|
{ email: 'max.mueller@dornig.de' },
|
||||||
|
])).toEqual(['Max Mustermann', 'max.mueller']);
|
||||||
|
expect(labels([
|
||||||
|
{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
|
||||||
|
{ name: 'Max Mustermann', email: 'max.mustermann2@dornig.de' },
|
||||||
|
])).toEqual(['max.mustermann', 'max.mustermann2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterMentionCandidates', () => {
|
||||||
|
const candidates = buildMentionCandidates(
|
||||||
|
[
|
||||||
|
{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
|
||||||
|
{ name: 'Jürgen Müller', email: 'juergen.mueller@dornig.de' },
|
||||||
|
{ email: 'anna-lena.schmidt@dornig.de' },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const labels = (query: string) => filterMentionCandidates(candidates, query).map((c) => c.label);
|
||||||
|
|
||||||
|
it('offers everyone for a bare @', () => {
|
||||||
|
expect(labels('')).toEqual(['Max', 'Jürgen', 'Anna-Lena']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the start of the label, a name word or a mailbox part, ignoring case', () => {
|
||||||
|
expect(labels('ma')).toEqual(['Max']);
|
||||||
|
expect(labels('MUSTER')).toEqual(['Max']);
|
||||||
|
expect(labels('lena')).toEqual(['Anna-Lena']);
|
||||||
|
expect(labels('schm')).toEqual(['Anna-Lena']);
|
||||||
|
expect(labels('xyz')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds umlaut names typed either way', () => {
|
||||||
|
expect(labels('jü')).toEqual(['Jürgen']);
|
||||||
|
expect(labels('jue')).toEqual(['Jürgen']);
|
||||||
|
expect(labels('mül')).toEqual(['Jürgen']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mentionSuggestions', () => {
|
||||||
|
const recipients = buildMentionCandidates(
|
||||||
|
[{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }],
|
||||||
|
[{ name: 'Eva Weber', email: 'eva.weber@dornig.de' }],
|
||||||
|
);
|
||||||
|
const shown = (list: ReturnType<typeof mentionSuggestions>) => list.map((c) => `${c.label} ${c.email} ${c.addsTo ?? '-'}`);
|
||||||
|
|
||||||
|
it('offers matching recipients first, then contacts marked with the field they go to', () => {
|
||||||
|
const contacts = [{ name: 'Marta Klein', email: 'marta@dornig.de' }];
|
||||||
|
expect(shown(mentionSuggestions(recipients, contacts, 'ma', 'to'))).toEqual([
|
||||||
|
'Max max.mustermann@dornig.de -',
|
||||||
|
'Marta marta@dornig.de to',
|
||||||
|
]);
|
||||||
|
expect(mentionSuggestions(recipients, contacts, 'ma', 'cc')[1].addsTo).toBe('cc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out contacts who are already recipients', () => {
|
||||||
|
const contacts = [{ name: 'Eva W.', email: 'Eva.Weber@dornig.de' }];
|
||||||
|
expect(shown(mentionSuggestions(recipients, contacts, 'ev', 'to'))).toEqual(['Eva eva.weber@dornig.de -']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops contacts that only matched in the middle of a word', () => {
|
||||||
|
// The address autocomplete matches anywhere; an @ matches word starts.
|
||||||
|
const contacts = [{ name: 'Thomas Bauer', email: 'thomas@dornig.de' }];
|
||||||
|
expect(mentionSuggestions(recipients, contacts, 'ma', 'to').map((c) => c.email)).toEqual(['max.mustermann@dornig.de']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips groups and entries without a usable address', () => {
|
||||||
|
const contacts = [{ name: 'Marketing', email: '' }, { name: 'Mara', email: 'mara' }];
|
||||||
|
expect(mentionSuggestions([], contacts, 'ma', 'to')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames a contact who shares a first name with a recipient, not the recipient', () => {
|
||||||
|
const contacts = [{ name: 'Max Müller', email: 'max.mueller@dornig.de' }];
|
||||||
|
expect(shown(mentionSuggestions(recipients, contacts, 'max', 'to'))).toEqual([
|
||||||
|
'Max max.mustermann@dornig.de -',
|
||||||
|
'Max Müller max.mueller@dornig.de to',
|
||||||
|
]);
|
||||||
|
// The recipient list itself keeps its labels.
|
||||||
|
expect(recipients[0].label).toBe('Max');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the contacts it offers', () => {
|
||||||
|
const contacts = Array.from({ length: 20 }, (_, i) => ({ name: `Mia ${i}`, email: `mia${i}@dornig.de` }));
|
||||||
|
expect(mentionSuggestions([], contacts, 'mia', 'to')).toHaveLength(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mentionHref', () => {
|
||||||
|
it('addresses only the one mailbox, whatever the address carries', () => {
|
||||||
|
expect(mentionHref('max@dornig.de')).toBe('mailto:max@dornig.de');
|
||||||
|
expect(mentionHref('a?cc=evil@x.de#f@dornig.de')).toBe('mailto:a%3Fcc=evil@x.de%23f@dornig.de');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addMentionedRecipient', () => {
|
||||||
|
const make = (c: { name?: string; email: string }): { name?: string; email: string } => ({ name: c.name, email: c.email });
|
||||||
|
|
||||||
|
it('adds a contact who is not on the message yet', () => {
|
||||||
|
const next = addMentionedRecipient<{ name?: string; email: string }>([{ email: 'eva@dornig.de' }], { label: 'Max', name: 'Max M', email: 'max@dornig.de', addsTo: 'to' }, make);
|
||||||
|
expect(next).toEqual([{ email: 'eva@dornig.de' }, { name: 'Max M', email: 'max@dornig.de' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the field alone when the address is there already, also inside a group', () => {
|
||||||
|
const list = [{ name: 'Team', email: '', group: { members: [{ email: 'MAX@dornig.de' }] } }];
|
||||||
|
expect(addMentionedRecipient(list, { label: 'Max', email: 'max@dornig.de', addsTo: 'to' }, make)).toBe(list);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
isPickerReopenTooSoon, MIN_SUGGESTION_ROOM, needsRecipientPicker, PICKER_REOPEN_GRACE_MS, suggestionRoom,
|
||||||
|
} from '../recipient-picker';
|
||||||
|
|
||||||
|
// U-069: in phone landscape the keyboard leaves about 50 dp under the To
|
||||||
|
// field, so the inline suggestions hid behind it. The composer hands the
|
||||||
|
// field to a full-screen picker there, and wherever the measured room under
|
||||||
|
// the field is too small for the list.
|
||||||
|
|
||||||
|
describe('needsRecipientPicker', () => {
|
||||||
|
it('always uses the picker in a phone-landscape window', () => {
|
||||||
|
// use-layout's shortHeight: a window under 480 dp tall.
|
||||||
|
expect(needsRecipientPicker({ shortHeight: true, windowHeight: 360, keyboardObstruction: 0, fieldBottom: null })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the inline list in portrait, where it has room above the keyboard', () => {
|
||||||
|
expect(needsRecipientPicker({ shortHeight: false, windowHeight: 844, keyboardObstruction: 300, fieldBottom: 160 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the picker when less than two rows fit between field and keyboard', () => {
|
||||||
|
// A short split-screen window: 520 tall, keyboard 300, field ends at 140.
|
||||||
|
expect(suggestionRoom(520, 300, 140)).toBe(80);
|
||||||
|
expect(80).toBeLessThan(MIN_SUGGESTION_ROOM);
|
||||||
|
expect(needsRecipientPicker({ shortHeight: false, windowHeight: 520, keyboardObstruction: 300, fieldBottom: 140 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for the keyboard and a measurement before deciding on the room', () => {
|
||||||
|
expect(needsRecipientPicker({ shortHeight: false, windowHeight: 520, keyboardObstruction: 0, fieldBottom: 140 })).toBe(false);
|
||||||
|
expect(needsRecipientPicker({ shortHeight: false, windowHeight: 520, keyboardObstruction: 300, fieldBottom: null })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reopen the picker from the focus the field gets back as it closes', () => {
|
||||||
|
expect(isPickerReopenTooSoon(1000, 1000 + PICKER_REOPEN_GRACE_MS - 1)).toBe(true);
|
||||||
|
expect(isPickerReopenTooSoon(1000, 1000 + PICKER_REOPEN_GRACE_MS)).toBe(false);
|
||||||
|
expect(isPickerReopenTooSoon(null, 5)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ComposeScreen recipient picker wiring', () => {
|
||||||
|
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
|
||||||
|
const field = source.slice(source.indexOf('const renderRecipientField'), source.indexOf('const composer = ('));
|
||||||
|
|
||||||
|
it('opens the picker when an address field takes the focus in a short window', () => {
|
||||||
|
expect(field).toMatch(/needsRecipientPicker\(\{ shortHeight, windowHeight, keyboardObstruction: kbObstruction, fieldBottom: null \}\)[\s\S]{0,40}openRecipientPicker\(field\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the inline list while the picker has the field, and feeds the picker the same suggestions', () => {
|
||||||
|
expect(field).toMatch(/activeField === field && !pickerField &&/);
|
||||||
|
expect(source).toMatch(/const suggestField = pickerField \?\? activeField;\s+const suggestionQuery = inputFor\(suggestField\);/);
|
||||||
|
const picker = source.slice(source.indexOf('<RecipientPicker'), source.indexOf('<ComposerOptionsSheet'));
|
||||||
|
expect(picker).toMatch(/suggestions=\{suggestions\}/);
|
||||||
|
expect(picker).toMatch(/onPick=\{pickSuggestion\}/);
|
||||||
|
expect(picker).toMatch(/onDone=\{closeRecipientPicker\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns what was typed into chips when the picker closes', () => {
|
||||||
|
const close = source.slice(source.indexOf('const closeRecipientPicker'), source.indexOf('const closeRecipientPicker') + 400);
|
||||||
|
expect(close).toMatch(/if \(inputFor\(field\)\.trim\(\)\) addTyped\(field\);/);
|
||||||
|
expect(close).toMatch(/pickerClosedAtRef\.current = Date\.now\(\);/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
afterFailure, canCancel, classifySendFailure, holdForScheduled, MAX_SEND_ATTEMPTS, newQueuedSend, outboxHint,
|
||||||
|
queuedSendStatus, restoreAfterRestart, shouldQueueFailedSend, startAttempt, verdictFromCopies,
|
||||||
|
type QueuedSendPayload,
|
||||||
|
} from '../send-queue';
|
||||||
|
|
||||||
|
// The offline send queue's state machine and its rule against sending a
|
||||||
|
// message twice (parity 04/09/10 "offline send").
|
||||||
|
|
||||||
|
const PAYLOAD = {
|
||||||
|
outgoing: { from: [{ email: 'me@x.de' }], to: [{ email: 'you@x.de' }], subject: 'Hi', textBody: 'x', messageId: 'm1@x.de' },
|
||||||
|
identity: { id: 'id-1', email: 'me@x.de', name: 'Me' },
|
||||||
|
fromEmail: 'me@x.de',
|
||||||
|
sentMailboxId: 'sent',
|
||||||
|
} as unknown as QueuedSendPayload;
|
||||||
|
|
||||||
|
const named = (name: string) => Object.assign(new Error(name), { name });
|
||||||
|
const transient = (err: unknown) => err instanceof Error && err.name === 'NetworkError';
|
||||||
|
|
||||||
|
describe('classifySendFailure', () => {
|
||||||
|
it('tells the four outcomes apart', () => {
|
||||||
|
expect(classifySendFailure(named('AuthenticationError'), transient)).toBe('auth');
|
||||||
|
expect(classifySendFailure(named('RequestTimeoutError'), transient)).toBe('unconfirmed');
|
||||||
|
expect(classifySendFailure(named('SendUnconfirmedError'), transient)).toBe('unconfirmed');
|
||||||
|
expect(classifySendFailure(named('NetworkError'), transient)).toBe('offline');
|
||||||
|
expect(classifySendFailure(new Error('Invalid envelope'), transient)).toBe('refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues only a send whose connection failed; a deadline keeps its "check Sent" warning', () => {
|
||||||
|
expect(shouldQueueFailedSend('offline')).toBe(true);
|
||||||
|
expect(shouldQueueFailedSend('unconfirmed')).toBe(false);
|
||||||
|
expect(shouldQueueFailedSend('refused')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('queued send transitions', () => {
|
||||||
|
const fresh = newQueuedSend('q1', PAYLOAD, 1000, false);
|
||||||
|
|
||||||
|
it('marks an attempt as possibly sent before its request leaves', () => {
|
||||||
|
expect(startAttempt(fresh)).toMatchObject({ status: 'sending', maybeSent: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an offline failure queued and marked, without counting it', () => {
|
||||||
|
const next = afterFailure(startAttempt(fresh), 'offline', 'Network request failed');
|
||||||
|
expect(next).toMatchObject({ status: 'queued', maybeSent: true, attempts: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the mark when the server turned the request away', () => {
|
||||||
|
expect(afterFailure(startAttempt(fresh), 'auth', 'Session expired')).toMatchObject({ status: 'queued', maybeSent: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails a refused send at once, with the reason; nothing went out', () => {
|
||||||
|
expect(afterFailure(startAttempt(fresh), 'refused', 'Invalid envelope'))
|
||||||
|
.toMatchObject({ status: 'failed', maybeSent: false, attempts: 1, lastError: 'Invalid envelope' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up after repeated unconfirmed attempts, still marked', () => {
|
||||||
|
let send = fresh;
|
||||||
|
for (let i = 0; i < MAX_SEND_ATTEMPTS - 1; i++) {
|
||||||
|
send = afterFailure(startAttempt(send), 'unconfirmed', 'no answer');
|
||||||
|
expect(send.status).toBe('queued');
|
||||||
|
}
|
||||||
|
send = afterFailure(startAttempt(send), 'unconfirmed', 'no answer');
|
||||||
|
expect(send).toMatchObject({ status: 'failed', maybeSent: true, attempts: MAX_SEND_ATTEMPTS });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts a send the app was killed in back into the queue, marked', () => {
|
||||||
|
const [restored, other] = restoreAfterRestart([startAttempt(fresh), { ...fresh, id: 'q2' }]);
|
||||||
|
expect(restored).toMatchObject({ status: 'queued', maybeSent: true });
|
||||||
|
expect(other).toMatchObject({ status: 'queued', maybeSent: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot be cancelled while it is on its way', () => {
|
||||||
|
expect(canCancel(startAttempt(fresh))).toBe(false);
|
||||||
|
expect(canCancel(fresh)).toBe(true);
|
||||||
|
expect(canCancel(afterFailure(fresh, 'refused', 'x'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verdictFromCopies', () => {
|
||||||
|
it('counts a copy without $draft as sent', () => {
|
||||||
|
expect(verdictFromCopies([{ id: 'd1', keywords: { $draft: true } }, { id: 's1', keywords: { $seen: true } }], 'd1'))
|
||||||
|
.toEqual({ kind: 'sent', copyId: 's1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends again when only the autosaved draft is there', () => {
|
||||||
|
expect(verdictFromCopies([{ id: 'd1', keywords: { $draft: true } }], 'd1')).toEqual({ kind: 'resend', staleIds: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes copies a refused attempt left in Drafts, but never the autosaved draft', () => {
|
||||||
|
expect(verdictFromCopies([{ id: 'd1', keywords: { $draft: true } }, { id: 'x1', keywords: { $draft: true } }], 'd1'))
|
||||||
|
.toEqual({ kind: 'resend', staleIds: ['x1'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends when nothing is found', () => {
|
||||||
|
expect(verdictFromCopies([], undefined)).toEqual({ kind: 'resend', staleIds: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('holdForScheduled', () => {
|
||||||
|
it('holds a scheduled send for the time left, and sends at once once it has passed', () => {
|
||||||
|
expect(holdForScheduled('2026-10-31T12:00:30Z', Date.parse('2026-10-31T12:00:00Z'))).toBe(30);
|
||||||
|
expect(holdForScheduled('2026-10-31T11:00:00Z', Date.parse('2026-10-31T12:00:00Z'))).toBeUndefined();
|
||||||
|
expect(holdForScheduled(undefined, 0)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Outbox texts', () => {
|
||||||
|
const t = (_key: string, fallback?: string, params?: Record<string, string | number>) =>
|
||||||
|
(fallback ?? '').replace(/\{count, plural, one \{# (\w+)\} other \{# (\w+)\}\}/, (_m, one, other) =>
|
||||||
|
`${params?.count} ${params?.count === 1 ? one : other}`).replace(/\{(\w+)\}/g, (_m, k) => String(params?.[k] ?? ''));
|
||||||
|
|
||||||
|
it('counts waiting and failed messages', () => {
|
||||||
|
expect(outboxHint(2, 0, t)).toBe('2 messages in the Outbox');
|
||||||
|
expect(outboxHint(1, 1, t)).toBe('1 message in the Outbox · 1 message not sent');
|
||||||
|
expect(outboxHint(0, 0, t)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says why a message did not go out', () => {
|
||||||
|
const failed = afterFailure(newQueuedSend('q', PAYLOAD, 0, false), 'refused', 'Invalid envelope');
|
||||||
|
expect(queuedSendStatus(failed, true, t)).toBe('Not sent: Invalid envelope');
|
||||||
|
expect(queuedSendStatus(newQueuedSend('q', PAYLOAD, 0, false), false, t)).toBe('Waiting for a connection');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const caps = vi.hoisted(() => ({ value: undefined as unknown }));
|
||||||
|
vi.mock('../../api/jmap-client', () => ({
|
||||||
|
jmapClient: { getAccountCapability: vi.fn(() => caps.value) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { hasSubmissionExtension, submissionEnvelopeParameters, supportsSubmissionExtension } from '../submission-options';
|
||||||
|
import { messagePriorityFromHeaders, messagePriorityHeaders } from '../message-priority';
|
||||||
|
|
||||||
|
describe('submissionEnvelopeParameters', () => {
|
||||||
|
it('asks for DSN with RET=HDRS and NOTIFY on every recipient', () => {
|
||||||
|
expect(submissionEnvelopeParameters({ requestDsn: true })).toEqual({
|
||||||
|
mailFrom: { RET: 'HDRS' },
|
||||||
|
rcptTo: { NOTIFY: 'SUCCESS,FAILURE,DELAY' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes REQUIRETLS without a value and keeps the hold', () => {
|
||||||
|
expect(submissionEnvelopeParameters({ requireTls: true }, 90)).toEqual({
|
||||||
|
mailFrom: { HOLDFOR: '90', REQUIRETLS: null },
|
||||||
|
rcptTo: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds nothing without options', () => {
|
||||||
|
expect(submissionEnvelopeParameters(undefined)).toEqual({ mailFrom: {}, rcptTo: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('submission extensions', () => {
|
||||||
|
it('reads the RFC 8621 map, any case', () => {
|
||||||
|
expect(hasSubmissionExtension({ dsn: [], REQUIRETLS: [] }, 'DSN')).toBe(true);
|
||||||
|
expect(hasSubmissionExtension({ FUTURERELEASE: [] }, 'DSN')).toBe(false);
|
||||||
|
expect(hasSubmissionExtension(['DSN'], 'dsn')).toBe(true);
|
||||||
|
expect(hasSubmissionExtension(null, 'DSN')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers DSN only when the account's submission capability lists it (Stalwart 0.16 and 1.0 do)", () => {
|
||||||
|
caps.value = { submissionExtensions: { FUTURERELEASE: [], DSN: [], 'MT-PRIORITY': ['MIXER'], REQUIRETLS: [] } };
|
||||||
|
expect(supportsSubmissionExtension('DSN')).toBe(true);
|
||||||
|
expect(supportsSubmissionExtension('REQUIRETLS')).toBe(true);
|
||||||
|
caps.value = { submissionExtensions: { FUTURERELEASE: [] } };
|
||||||
|
expect(supportsSubmissionExtension('DSN')).toBe(false);
|
||||||
|
caps.value = undefined;
|
||||||
|
expect(supportsSubmissionExtension('DSN')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message priority headers', () => {
|
||||||
|
it('maps high and low to X-Priority and Importance, normal to nothing', () => {
|
||||||
|
expect(messagePriorityHeaders('high')).toEqual({ 'header:X-Priority:asText': '1 (Highest)', 'header:Importance:asText': 'high' });
|
||||||
|
expect(messagePriorityHeaders('low')).toEqual({ 'header:X-Priority:asText': '5 (Lowest)', 'header:Importance:asText': 'low' });
|
||||||
|
expect(messagePriorityHeaders('normal')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a priority back, X-Priority first', () => {
|
||||||
|
expect(messagePriorityFromHeaders([{ name: 'x-priority', value: ' 2' }])).toBe('high');
|
||||||
|
expect(messagePriorityFromHeaders([{ name: 'X-Priority', value: '3 (Normal)' }, { name: 'Importance', value: 'high' }])).toBe('normal');
|
||||||
|
expect(messagePriorityFromHeaders([{ name: 'Importance', value: ' Low' }])).toBe('low');
|
||||||
|
expect(messagePriorityFromHeaders(undefined)).toBe('normal');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -154,6 +154,9 @@ function blocksToPlainText(html: string, mailMarkup: boolean): string {
|
|||||||
if (!href) return inner;
|
if (!href) return inner;
|
||||||
const normalizedHref = href.replace(/^mailto:/i, '');
|
const normalizedHref = href.replace(/^mailto:/i, '');
|
||||||
if (!text || text === href || text === normalizedHref) return text || href;
|
if (!text || text === href || text === normalizedHref) return text || href;
|
||||||
|
// A mention: a mailto: link reading "@Name" is "@Name" in the text part,
|
||||||
|
// as the webmail's mention chip writes it (renderText, #1134).
|
||||||
|
if (/^mailto:/i.test(href) && text.startsWith('@') && text.length > 1) return text;
|
||||||
// Angle brackets are restored after the tag strip below.
|
// Angle brackets are restored after the tag strip below.
|
||||||
return `${text} \u0001${href}\u0002`;
|
return `${text} \u0001${href}\u0002`;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import type { Email } from '../api/types';
|
import type { Email } from '../api/types';
|
||||||
import type { ComposeDraftContext } from '../navigation/types';
|
import type { ComposeDraftContext } from '../navigation/types';
|
||||||
|
import { messagePriorityFromHeaders } from './message-priority';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the draft's bodies by MIME type. A plain-text-only message lists its
|
* Pick the draft's bodies by MIME type. A plain-text-only message lists its
|
||||||
@@ -37,6 +38,8 @@ export function draftContextFromEmail(email: Email, jmapAccountId?: string): Com
|
|||||||
messageId: email.messageId ?? undefined,
|
messageId: email.messageId ?? undefined,
|
||||||
inReplyTo: email.inReplyTo ?? undefined,
|
inReplyTo: email.inReplyTo ?? undefined,
|
||||||
references: email.references ?? undefined,
|
references: email.references ?? undefined,
|
||||||
|
// Read back from the headers it was saved with (webmail #970).
|
||||||
|
priority: messagePriorityFromHeaders(email.headers),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+99
-1
@@ -100,6 +100,31 @@ export const FOCUS_END_SOURCE = `function focusEnd(editor) {
|
|||||||
sel.addRange(range);
|
sel.addRange(range);
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-script function for "@" mentions (webmail #1134): given the text of
|
||||||
|
* the caret's text node up to the caret, where the "@" being typed starts
|
||||||
|
* and what follows it, or null. Like the webmail's suggestion it only
|
||||||
|
* starts at the beginning of a word, so info@example.com never triggers,
|
||||||
|
* and the query ends at the first space. A string, embedded in the page
|
||||||
|
* script and run by the unit test. No regex: see the template-cooking
|
||||||
|
* warning at the top.
|
||||||
|
*/
|
||||||
|
export const MENTION_QUERY_SOURCE = `function mentionQueryIn(text) {
|
||||||
|
var at = text.lastIndexOf('@');
|
||||||
|
if (at < 0) return null;
|
||||||
|
if (at > 0) {
|
||||||
|
var before = text.charCodeAt(at - 1);
|
||||||
|
if (before !== 32 && before !== 160) return null;
|
||||||
|
}
|
||||||
|
var query = text.slice(at + 1);
|
||||||
|
if (query.length > 64) return null;
|
||||||
|
for (var i = 0; i < query.length; i++) {
|
||||||
|
var code = query.charCodeAt(i);
|
||||||
|
if (code === 32 || code === 160 || code === 10 || code === 9 || code === 64) return null;
|
||||||
|
}
|
||||||
|
return { at: at, query: query };
|
||||||
|
}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the editor WebView may load `url`: only its initial about:blank
|
* Whether the editor WebView may load `url`: only its initial about:blank
|
||||||
* document (`source={{ html }}` loads as about:blank on both platforms, as
|
* document (`source={{ html }}` loads as about:blank on both platforms, as
|
||||||
@@ -150,8 +175,14 @@ export function buildEditorHtml(opts: {
|
|||||||
collapseQuote?: boolean;
|
collapseQuote?: boolean;
|
||||||
/** The toggle's accessible names. */
|
/** The toggle's accessible names. */
|
||||||
quoteToggleLabels?: { show: string; hide: string };
|
quoteToggleLabels?: { show: string; hide: string };
|
||||||
|
/**
|
||||||
|
* Report an "@" being typed at the start of a word as `mention` messages
|
||||||
|
* (the query, or null once it ends) and accept `insertMention` (webmail
|
||||||
|
* recipient mentions, #1134). Off for editors that aren't a message.
|
||||||
|
*/
|
||||||
|
mentions?: boolean;
|
||||||
}): string {
|
}): string {
|
||||||
const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false, collapseQuote = false } = opts;
|
const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false, collapseQuote = false, mentions = false } = opts;
|
||||||
const quoteLabels = opts.quoteToggleLabels ?? { show: 'Show quoted text', hide: 'Hide quoted text' };
|
const quoteLabels = opts.quoteToggleLabels ?? { show: 'Show quoted text', hide: 'Hide quoted text' };
|
||||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||||
// are safely embedded (no template literal collision with the script body,
|
// are safely embedded (no template literal collision with the script body,
|
||||||
@@ -202,6 +233,7 @@ export function buildEditorHtml(opts: {
|
|||||||
color: ${c.textSecondary};
|
color: ${c.textSecondary};
|
||||||
}
|
}
|
||||||
#editor a { color: ${c.primary}; text-decoration: underline; }
|
#editor a { color: ${c.primary}; text-decoration: underline; }
|
||||||
|
#editor a.mention { text-decoration: none; font-weight: 600; }
|
||||||
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
|
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||||
#editor pre, #editor code {
|
#editor pre, #editor code {
|
||||||
background: ${c.surfaceActive};
|
background: ${c.surfaceActive};
|
||||||
@@ -417,12 +449,46 @@ export function buildEditorHtml(opts: {
|
|||||||
setQuoteCollapsed(true);
|
setQuoteCollapsed(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── "@" mentions ───────────────────────────────────────────────────
|
||||||
|
var MENTIONS = ${mentions ? 'true' : 'false'};
|
||||||
|
var lastMention = null;
|
||||||
|
${MENTION_QUERY_SOURCE}
|
||||||
|
|
||||||
|
// The "@..." being typed at the caret: its text node and range, or null.
|
||||||
|
// Never inside a link (a mention already made) or code.
|
||||||
|
function currentMention() {
|
||||||
|
var sel = window.getSelection();
|
||||||
|
if (!sel || !sel.rangeCount || !sel.isCollapsed) return null;
|
||||||
|
var node = sel.anchorNode;
|
||||||
|
if (!node || node.nodeType !== 3) return null;
|
||||||
|
var parent = node.parentNode;
|
||||||
|
while (parent && parent !== editor) {
|
||||||
|
if (parent.nodeType === 1 && (parent.tagName === 'A' || parent.tagName === 'CODE' || parent.tagName === 'PRE')) return null;
|
||||||
|
parent = parent.parentNode;
|
||||||
|
}
|
||||||
|
if (parent !== editor) return null;
|
||||||
|
var hit = mentionQueryIn(node.data.slice(0, sel.anchorOffset));
|
||||||
|
if (!hit) return null;
|
||||||
|
return { node: node, start: hit.at, end: sel.anchorOffset, query: hit.query };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportMention(force) {
|
||||||
|
if (!MENTIONS) return;
|
||||||
|
var m = force === null ? null : currentMention();
|
||||||
|
var query = m ? m.query : null;
|
||||||
|
if (query === lastMention) return;
|
||||||
|
lastMention = query;
|
||||||
|
post('mention', query);
|
||||||
|
}
|
||||||
|
|
||||||
editor.addEventListener('input', function () {
|
editor.addEventListener('input', function () {
|
||||||
scheduleChange();
|
scheduleChange();
|
||||||
reportHeight();
|
reportHeight();
|
||||||
|
reportMention();
|
||||||
});
|
});
|
||||||
editor.addEventListener('blur', function () {
|
editor.addEventListener('blur', function () {
|
||||||
flushChange();
|
flushChange();
|
||||||
|
reportMention(null);
|
||||||
post('blur', null);
|
post('blur', null);
|
||||||
});
|
});
|
||||||
editor.addEventListener('focus', function () { post('focus', null); });
|
editor.addEventListener('focus', function () { post('focus', null); });
|
||||||
@@ -430,6 +496,7 @@ export function buildEditorHtml(opts: {
|
|||||||
if (document.activeElement === editor) {
|
if (document.activeElement === editor) {
|
||||||
revealQuoteAtCaret();
|
revealQuoteAtCaret();
|
||||||
reportSelection();
|
reportSelection();
|
||||||
|
reportMention();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -541,6 +608,37 @@ export function buildEditorHtml(opts: {
|
|||||||
},
|
},
|
||||||
focus: function () { editor.focus(); },
|
focus: function () { editor.focus(); },
|
||||||
focusEnd: function () { focusEnd(editor); },
|
focusEnd: function () { focusEnd(editor); },
|
||||||
|
// Replace the "@..." being typed with a mention link and a space, the
|
||||||
|
// caret after it (webmail insertMention). The label and href go in as
|
||||||
|
// text and attribute, never as markup: a label comes from a display
|
||||||
|
// name, which a reply-all takes from the incoming message.
|
||||||
|
insertMention: function (label, href) {
|
||||||
|
if (!MENTIONS) return;
|
||||||
|
var m = currentMention();
|
||||||
|
if (!m) return;
|
||||||
|
var range = document.createRange();
|
||||||
|
range.setStart(m.node, m.start);
|
||||||
|
range.setEnd(m.node, m.end);
|
||||||
|
range.deleteContents();
|
||||||
|
var link = document.createElement('a');
|
||||||
|
link.setAttribute('href', href);
|
||||||
|
link.setAttribute('class', 'mention');
|
||||||
|
link.textContent = '@' + label;
|
||||||
|
var space = document.createTextNode(String.fromCharCode(160));
|
||||||
|
range.insertNode(space);
|
||||||
|
range.insertNode(link);
|
||||||
|
var sel = window.getSelection();
|
||||||
|
var caret = document.createRange();
|
||||||
|
caret.setStartAfter(space);
|
||||||
|
caret.collapse(true);
|
||||||
|
if (sel) {
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(caret);
|
||||||
|
}
|
||||||
|
reportChange();
|
||||||
|
reportHeight();
|
||||||
|
reportMention();
|
||||||
|
},
|
||||||
// The description of the inline image with this cid; "" is decorative.
|
// The description of the inline image with this cid; "" is decorative.
|
||||||
setImageAlt: function (cid, alt) {
|
setImageAlt: function (cid, alt) {
|
||||||
var images = editor.getElementsByTagName('img');
|
var images = editor.getElementsByTagName('img');
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Message priority set in the composer (webmail #970, lib/message-priority.ts).
|
||||||
|
*
|
||||||
|
* Clients disagree on which header carries it: Outlook reads Importance
|
||||||
|
* (RFC 4021), Thunderbird and most others read the de-facto X-Priority
|
||||||
|
* (1 highest ... 5 lowest). Both are written so the hint survives either
|
||||||
|
* way. Priority (RFC 4021, MIXER) is left out: it is meant to steer
|
||||||
|
* transmission, not display. Normal writes nothing, so ordinary mail is
|
||||||
|
* unchanged.
|
||||||
|
*/
|
||||||
|
export type MessagePriority = 'high' | 'normal' | 'low';
|
||||||
|
|
||||||
|
export const MESSAGE_PRIORITIES: readonly MessagePriority[] = ['high', 'normal', 'low'];
|
||||||
|
|
||||||
|
/** JMAP Email/set create properties (RFC 8621 §4.1.3) for a priority. */
|
||||||
|
export function messagePriorityHeaders(priority: MessagePriority | undefined): Record<string, string> {
|
||||||
|
if (priority === 'high') {
|
||||||
|
return {
|
||||||
|
'header:X-Priority:asText': '1 (Highest)',
|
||||||
|
'header:Importance:asText': 'high',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (priority === 'low') {
|
||||||
|
return {
|
||||||
|
'header:X-Priority:asText': '5 (Lowest)',
|
||||||
|
'header:Importance:asText': 'low',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerValue(headers: Array<{ name: string; value: string }>, name: string): string | undefined {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
for (const header of headers) {
|
||||||
|
if (header?.name?.toLowerCase() !== lower) continue;
|
||||||
|
const value = typeof header.value === 'string' ? header.value.trim() : '';
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The priority a message's raw headers (JMAP `headers`) carry, for a
|
||||||
|
* re-opened draft. X-Priority wins over Importance; 1-2 is high, 4-5 low,
|
||||||
|
* anything else normal.
|
||||||
|
*/
|
||||||
|
export function messagePriorityFromHeaders(
|
||||||
|
headers: Array<{ name: string; value: string }> | undefined | null,
|
||||||
|
): MessagePriority {
|
||||||
|
if (!Array.isArray(headers)) return 'normal';
|
||||||
|
const xPriority = headerValue(headers, 'X-Priority');
|
||||||
|
const level = xPriority ? Number.parseInt(xPriority, 10) : NaN;
|
||||||
|
if (level === 1 || level === 2) return 'high';
|
||||||
|
if (level === 4 || level === 5) return 'low';
|
||||||
|
if (!Number.isNaN(level)) return 'normal';
|
||||||
|
const importance = headerValue(headers, 'Importance')?.toLowerCase();
|
||||||
|
if (importance === 'high') return 'high';
|
||||||
|
if (importance === 'low') return 'low';
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// "@" mentions in the message body (webmail #1134), ported from the webmail's
|
||||||
|
// lib/recipient-mentions.ts. Typing "@" at the start of a word offers the
|
||||||
|
// message's To and Cc recipients, then contacts who aren't on the message
|
||||||
|
// yet; picking one inserts "@FirstName" as a mailto: link and adds a
|
||||||
|
// contact to To (webmail default `mentionAddsRecipientTo: 'to'`).
|
||||||
|
|
||||||
|
import { expandRecipients, type Recipient } from './recipients';
|
||||||
|
|
||||||
|
/** A recipient an @ in the message body can address. */
|
||||||
|
export interface MentionCandidate {
|
||||||
|
/** What goes after the @ in the body. */
|
||||||
|
label: string;
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
/** Not a recipient yet: picking them adds them to this field. */
|
||||||
|
addsTo?: 'to' | 'cc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Someone from the contacts, the directory or recent recipients. */
|
||||||
|
export interface MentionContact {
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many contacts an @ offers after the recipients. */
|
||||||
|
const CONTACT_LIMIT = 8;
|
||||||
|
|
||||||
|
function letterCount(value: string): number {
|
||||||
|
return value.match(/\p{L}/gu)?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mailbox part of an address, without a +subaddress tag. */
|
||||||
|
function localPartOf(email: string): string {
|
||||||
|
const local = email.slice(0, email.lastIndexOf('@'));
|
||||||
|
return local.split('+')[0] || local;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Mustermann, Max" -> "Max Mustermann"; any other name as is. */
|
||||||
|
function naturalOrder(name: string): string {
|
||||||
|
const parts = name.split(',');
|
||||||
|
if (parts.length !== 2 || !parts[1].trim()) return name;
|
||||||
|
return `${parts[1].trim()} ${parts[0].trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "max.mustermann" -> "Max", "anna-lena.schmidt" -> "Anna-Lena". Null when
|
||||||
|
* the first part is a bare initial ("m.mustermann").
|
||||||
|
*/
|
||||||
|
function givenNameFromLocalPart(localPart: string): string | null {
|
||||||
|
const first = localPart.split(/[._]/)[0];
|
||||||
|
if (letterCount(first) <= 1) return null;
|
||||||
|
return first.replace(/(^|-)(\p{Ll})/gu, (_, sep: string, letter: string) => sep + letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The words of a display name, without the punctuation around them. */
|
||||||
|
function nameWords(name: string): string[] {
|
||||||
|
return naturalOrder(name)
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((raw) => raw.replace(/^[^\p{L}\p{N}]+/u, '').replace(/[^\p{L}\p{N}.]+$/u, ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The given name in a display name: "Max Mustermann", "Mustermann, Max" and
|
||||||
|
* "Dr. Max Mustermann" all give "Max". Null when the name opens with a bare
|
||||||
|
* initial ("M. Mustermann").
|
||||||
|
*/
|
||||||
|
function givenNameFromDisplayName(name: string): string | null {
|
||||||
|
// Some systems put the mailbox name in the display name.
|
||||||
|
if (!/\s/.test(name) && /\p{L}[._]\p{L}/u.test(name)) return givenNameFromLocalPart(name);
|
||||||
|
for (const word of nameWords(name)) {
|
||||||
|
if (letterCount(word) <= 1) return null;
|
||||||
|
// Titles and abbreviations: "Dr.", "Prof.", "Dipl.-Ing."
|
||||||
|
if (word.endsWith('.')) continue;
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UMLAUT_SPELLINGS: Record<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
|
||||||
|
|
||||||
|
/** Whether two names match as an address spells them: "Jürgen" and "juergen". */
|
||||||
|
function spelledAlike(a: string, b: string): boolean {
|
||||||
|
const key = (s: string) => fold(s.normalize('NFC').toLowerCase().replace(/[äöüß]/g, (c) => UMLAUT_SPELLINGS[c]));
|
||||||
|
return key(a) === key(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A display name worth deriving a label from: not empty, not an address.
|
||||||
|
* Capped because a reply-all takes it from the incoming message.
|
||||||
|
*/
|
||||||
|
function usableName(name: string | undefined): string | undefined {
|
||||||
|
const trimmed = name?.trim().slice(0, 200).replace(/^(['"])(.*)\1$/, '$2').trim();
|
||||||
|
return trimmed && !trimmed.includes('@') ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First name to put after the @. A firstname.lastname@ address tells which
|
||||||
|
* word of the display name is the first name; otherwise it is the display
|
||||||
|
* name's first word, or the address's first part when no name is known.
|
||||||
|
*/
|
||||||
|
export function mentionLabel(name: string | undefined, email: string): string {
|
||||||
|
const localPart = localPartOf(email);
|
||||||
|
const fromAddress = givenNameFromLocalPart(localPart);
|
||||||
|
const usable = usableName(name);
|
||||||
|
if (usable) {
|
||||||
|
const confirmed = fromAddress && /[._]/.test(localPart)
|
||||||
|
? nameWords(usable).find((word) => spelledAlike(word, fromAddress))
|
||||||
|
: undefined;
|
||||||
|
const given = confirmed || givenNameFromDisplayName(usable);
|
||||||
|
if (given) return given;
|
||||||
|
}
|
||||||
|
return fromAddress ?? localPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clashingLabels(candidates: MentionCandidate[]): Set<string> {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const clashes = new Set<string>();
|
||||||
|
for (const { label } of candidates) {
|
||||||
|
const key = label.toLowerCase();
|
||||||
|
if (seen.has(key)) clashes.add(key);
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
return clashes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two candidates who would both be "@Max" are told apart by full name, or
|
||||||
|
* by mailbox name where no name is known - and by mailbox name when even
|
||||||
|
* the full names match.
|
||||||
|
*/
|
||||||
|
function distinguishLabels(candidates: MentionCandidate[]): MentionCandidate[] {
|
||||||
|
const clashes = clashingLabels(candidates);
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (clashes.has(c.label.toLowerCase())) c.label = c.name ? naturalOrder(c.name) : localPartOf(c.email);
|
||||||
|
}
|
||||||
|
const stillClashing = clashingLabels(candidates);
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (stillClashing.has(c.label.toLowerCase())) c.label = localPartOf(c.email);
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recipients an @ in the message body can address: To and Cc, contact
|
||||||
|
* groups expanded into their members, each address once. Bcc is left out on
|
||||||
|
* purpose - naming a blind-copied recipient in the body would disclose them
|
||||||
|
* to everyone else on the message.
|
||||||
|
*/
|
||||||
|
export function buildMentionCandidates(to: Recipient[], cc: Recipient[]): MentionCandidate[] {
|
||||||
|
return distinguishLabels(expandRecipients([...to, ...cc])
|
||||||
|
.filter((r) => r.email.lastIndexOf('@') > 0)
|
||||||
|
.map((r) => {
|
||||||
|
const name = usableName(r.name);
|
||||||
|
return { label: mentionLabel(name, r.email), name, email: r.email };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an @ followed by `query` offers: the matching recipients, then
|
||||||
|
* matching contacts who aren't on the message yet, marked with the field
|
||||||
|
* picking them adds them to. `contacts` must already leave out Bcc
|
||||||
|
* recipients, who an @ never names.
|
||||||
|
*/
|
||||||
|
export function mentionSuggestions(
|
||||||
|
recipients: MentionCandidate[],
|
||||||
|
contacts: MentionContact[],
|
||||||
|
query: string,
|
||||||
|
addsTo: 'to' | 'cc',
|
||||||
|
): MentionCandidate[] {
|
||||||
|
const matched = filterMentionCandidates(recipients, query);
|
||||||
|
const seen = new Set(recipients.map((r) => r.email.toLowerCase()));
|
||||||
|
const extra: MentionCandidate[] = [];
|
||||||
|
for (const contact of contacts) {
|
||||||
|
if (extra.length >= CONTACT_LIMIT) break;
|
||||||
|
const key = contact.email.toLowerCase();
|
||||||
|
if (contact.email.lastIndexOf('@') <= 0 || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
const name = usableName(contact.name);
|
||||||
|
const candidate = { label: mentionLabel(name, contact.email), name, email: contact.email, addsTo };
|
||||||
|
if (filterMentionCandidates([candidate], query).length > 0) extra.push(candidate);
|
||||||
|
}
|
||||||
|
if (extra.length === 0) return matched;
|
||||||
|
// A contact who would also be "@Max" next to a recipient Max goes by full
|
||||||
|
// name, or mailbox name. The recipient keeps "@Max".
|
||||||
|
const taken = new Set(recipients.map((r) => r.label.toLowerCase()));
|
||||||
|
distinguishLabels(extra);
|
||||||
|
for (const c of extra) {
|
||||||
|
if (!taken.has(c.label.toLowerCase())) continue;
|
||||||
|
c.label = c.name && !taken.has(naturalOrder(c.name).toLowerCase()) ? naturalOrder(c.name) : localPartOf(c.email);
|
||||||
|
}
|
||||||
|
return [...matched, ...extra];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fold(value: string): string {
|
||||||
|
return value.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidates whose label, a word of their name, their address or a part of
|
||||||
|
* their mailbox name starts with what was typed after the @ - case and
|
||||||
|
* accents ignored, so "jue" finds juergen.mueller@ and "jü" finds Jürgen.
|
||||||
|
*/
|
||||||
|
export function filterMentionCandidates(candidates: MentionCandidate[], query: string): MentionCandidate[] {
|
||||||
|
const typed = fold(query);
|
||||||
|
if (!typed) return candidates;
|
||||||
|
return candidates.filter((c) =>
|
||||||
|
[c.label, c.email, ...(c.name?.split(/\s+/) ?? []), ...localPartOf(c.email).split(/[._-]/)]
|
||||||
|
.some((term) => fold(term).startsWith(typed)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mailto: href for the address. Characters that would start a query or
|
||||||
|
* fragment (a crafted reply-all address) are escaped, so the link can only
|
||||||
|
* ever address this one mailbox.
|
||||||
|
*/
|
||||||
|
export function mentionHref(email: string): string {
|
||||||
|
return `mailto:${email.replace(/[%?#&\s"'<>]/g, (c) => encodeURIComponent(c))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a mentioned contact to To (or Cc) unless the address is on the message
|
||||||
|
* already, groups included (webmail addMentionedRecipient). Returns the same
|
||||||
|
* list when nothing changes; `make` builds the field's entry.
|
||||||
|
*/
|
||||||
|
export function addMentionedRecipient<T extends Recipient>(
|
||||||
|
list: T[],
|
||||||
|
candidate: MentionCandidate,
|
||||||
|
make: (candidate: MentionCandidate) => T,
|
||||||
|
): T[] {
|
||||||
|
const key = candidate.email.trim().toLowerCase();
|
||||||
|
if (expandRecipients(list).some((r) => r.email.trim().toLowerCase() === key)) return list;
|
||||||
|
return [...list, make(candidate)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// When the composer's recipient suggestions can't drop down under the field
|
||||||
|
// (U-069): a phone in landscape has about 50 dp left under To with the
|
||||||
|
// keyboard up, so the list hid behind the keyboard. There the composer opens
|
||||||
|
// a full-screen recipient picker instead, whose suggestions take the room
|
||||||
|
// between its own field and the keyboard.
|
||||||
|
|
||||||
|
/** One suggestion row: a 32 dp avatar and 8 dp padding above and below. */
|
||||||
|
export const SUGGESTION_ROW_HEIGHT = 48;
|
||||||
|
|
||||||
|
/** The inline list needs room for at least this much to be of use. */
|
||||||
|
export const MIN_SUGGESTION_ROOM = 2 * SUGGESTION_ROW_HEIGHT;
|
||||||
|
|
||||||
|
/** Room between the bottom of the field and the top of the keyboard (dp). */
|
||||||
|
export function suggestionRoom(windowHeight: number, keyboardObstruction: number, fieldBottom: number): number {
|
||||||
|
return Math.max(0, windowHeight - keyboardObstruction - fieldBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the address field should hand over to the full-screen picker.
|
||||||
|
* A short window (a phone in landscape) always does: the keyboard leaves no
|
||||||
|
* room. Elsewhere it does once the keyboard is up and the measured room
|
||||||
|
* under the field is less than two rows (a small split-screen window).
|
||||||
|
*/
|
||||||
|
export function needsRecipientPicker(opts: {
|
||||||
|
shortHeight: boolean;
|
||||||
|
windowHeight: number;
|
||||||
|
keyboardObstruction: number;
|
||||||
|
/** The field row's bottom edge in window coordinates, when measured. */
|
||||||
|
fieldBottom: number | null;
|
||||||
|
}): boolean {
|
||||||
|
if (opts.shortHeight) return true;
|
||||||
|
if (opts.keyboardObstruction <= 0 || opts.fieldBottom == null) return false;
|
||||||
|
return suggestionRoom(opts.windowHeight, opts.keyboardObstruction, opts.fieldBottom) < MIN_SUGGESTION_ROOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A focus that lands right after the picker closed is the field getting its
|
||||||
|
* focus back, not the user asking for the picker again.
|
||||||
|
*/
|
||||||
|
export const PICKER_REOPEN_GRACE_MS = 600;
|
||||||
|
|
||||||
|
export function isPickerReopenTooSoon(closedAt: number | null, now: number): boolean {
|
||||||
|
return closedAt != null && now - closedAt < PICKER_REOPEN_GRACE_MS;
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// The offline send queue's state machine (parity 04/09/10 "offline send").
|
||||||
|
//
|
||||||
|
// The webmail has no offline outbox: an offline send says so and keeps the
|
||||||
|
// composer open (email-composer.tsx, `send_offline`). On a phone the natural
|
||||||
|
// behaviour is to queue the message and send it once the connection is back,
|
||||||
|
// so the app keeps queued messages next to its mutation outbox
|
||||||
|
// (stores/outbox-store.ts), persisted per account, and replays them from the
|
||||||
|
// same flush.
|
||||||
|
//
|
||||||
|
// The rule that keeps a message from going out twice: a send attempt is
|
||||||
|
// marked `maybeSent` before its request leaves, and only an answer that
|
||||||
|
// says how it ended clears the mark. A queued message still marked (the
|
||||||
|
// connection dropped mid-request, the app was killed, the server did not
|
||||||
|
// confirm) is looked up by its Message-ID before it is sent again: a copy
|
||||||
|
// that is no longer a draft means it went out.
|
||||||
|
|
||||||
|
import type { OutgoingEmail } from '../api/email';
|
||||||
|
import type { Identity } from '../api/types';
|
||||||
|
|
||||||
|
/** Everything needed to send the message later, without the composer. */
|
||||||
|
export interface QueuedSendPayload {
|
||||||
|
/** The message as the composer built it; `messageId` is always set. */
|
||||||
|
outgoing: OutgoingEmail;
|
||||||
|
/** The composer's identity; the send target is worked out again at send time. */
|
||||||
|
identity: Identity;
|
||||||
|
/** The header From, which picks a group or shared account (#1090). */
|
||||||
|
fromEmail: string;
|
||||||
|
/** The login's own Sent and Drafts when queued, for when folders aren't loaded. */
|
||||||
|
sentMailboxId: string;
|
||||||
|
draftsMailboxId?: string;
|
||||||
|
/** The autosaved draft version this message replaces, removed once it is sent. */
|
||||||
|
draftId?: string;
|
||||||
|
draftAccountId?: string;
|
||||||
|
requestDsn?: boolean;
|
||||||
|
requireTls?: boolean;
|
||||||
|
/** An explicit "send later": the hold is worked out from it at send time. */
|
||||||
|
scheduledAt?: string;
|
||||||
|
/** The message replied to or forwarded, flagged once this one is sent. */
|
||||||
|
original?: { emailId: string; accountId?: string; keyword: '$answered' | '$forwarded' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueuedSendStatus = 'queued' | 'sending' | 'failed';
|
||||||
|
|
||||||
|
export interface QueuedSend {
|
||||||
|
id: string;
|
||||||
|
createdAt: number;
|
||||||
|
payload: QueuedSendPayload;
|
||||||
|
status: QueuedSendStatus;
|
||||||
|
/** Attempts that ended without the message going out for sure. */
|
||||||
|
attempts: number;
|
||||||
|
/**
|
||||||
|
* A request may have reached the server without an answer saying how it
|
||||||
|
* ended: look for the message before sending it again.
|
||||||
|
*/
|
||||||
|
maybeSent: boolean;
|
||||||
|
lastError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Give up (and let the user decide) after this many unconfirmed attempts. */
|
||||||
|
export const MAX_SEND_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
export function newQueuedSend(id: string, payload: QueuedSendPayload, now: number, maybeSent: boolean): QueuedSend {
|
||||||
|
return { id, createdAt: now, payload, status: 'queued', attempts: 0, maybeSent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a send attempt failed, by error name so this stays free of the API
|
||||||
|
* modules:
|
||||||
|
* - `auth`: the credentials are gone; nothing reached the mail queue.
|
||||||
|
* - `unconfirmed`: an answer that never came (deadline) or a send the
|
||||||
|
* server did not confirm; the message may be out.
|
||||||
|
* - `offline`: the connection failed; the request may have got through.
|
||||||
|
* - `refused`: the server answered and refused; nothing went out (the send
|
||||||
|
* removes the copy it made).
|
||||||
|
*/
|
||||||
|
export type SendFailureKind = 'auth' | 'unconfirmed' | 'offline' | 'refused';
|
||||||
|
|
||||||
|
export function classifySendFailure(err: unknown, isTransient: (err: unknown) => boolean): SendFailureKind {
|
||||||
|
const name = err instanceof Error ? err.name : '';
|
||||||
|
if (name === 'AuthenticationError' || name === 'TotpRequiredError') return 'auth';
|
||||||
|
if (name === 'RequestTimeoutError' || name === 'SendUnconfirmedError') return 'unconfirmed';
|
||||||
|
if (isTransient(err)) return 'offline';
|
||||||
|
return 'refused';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the composer queues a send that failed this way instead of
|
||||||
|
* reporting it: only when the connection failed. A deadline or an
|
||||||
|
* unconfirmed send keeps its "check Sent first" warning (#702, #1019).
|
||||||
|
*/
|
||||||
|
export function shouldQueueFailedSend(kind: SendFailureKind): boolean {
|
||||||
|
return kind === 'offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The attempt is about to leave: from here on it may reach the server. */
|
||||||
|
export function startAttempt(send: QueuedSend): QueuedSend {
|
||||||
|
return { ...send, status: 'sending', maybeSent: true, lastError: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The state after an attempt failed this way. */
|
||||||
|
export function afterFailure(send: QueuedSend, kind: SendFailureKind, message: string): QueuedSend {
|
||||||
|
switch (kind) {
|
||||||
|
case 'auth':
|
||||||
|
// Turned away at the door: nothing was taken. Wait for a sign-in.
|
||||||
|
return { ...send, status: 'queued', maybeSent: false, lastError: message };
|
||||||
|
case 'offline':
|
||||||
|
// May have got through; the next attempt looks first. Not counted:
|
||||||
|
// being offline is what the queue is for.
|
||||||
|
return { ...send, status: 'queued', maybeSent: true, lastError: message };
|
||||||
|
case 'unconfirmed': {
|
||||||
|
const attempts = send.attempts + 1;
|
||||||
|
return {
|
||||||
|
...send, status: attempts >= MAX_SEND_ATTEMPTS ? 'failed' : 'queued', maybeSent: true, attempts, lastError: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'refused':
|
||||||
|
return { ...send, status: 'failed', maybeSent: false, attempts: send.attempts + 1, lastError: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A message found by its Message-ID. */
|
||||||
|
export interface FoundCopy {
|
||||||
|
id: string;
|
||||||
|
keywords?: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CopyVerdict =
|
||||||
|
| { kind: 'sent'; copyId: string }
|
||||||
|
| { kind: 'resend'; staleIds: string[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the copies with the message's Message-ID say. A send creates the
|
||||||
|
* message as a draft and the submission's onSuccessUpdateEmail files it in
|
||||||
|
* Sent without `$draft` in the same request, so a copy without `$draft` went
|
||||||
|
* out (or is held for a scheduled time). Copies still marked `$draft` are
|
||||||
|
* the autosaved draft (`draftId`, kept until the send succeeds) and copies
|
||||||
|
* a refused submission left behind with its answer lost: those go before
|
||||||
|
* the message is sent again.
|
||||||
|
*/
|
||||||
|
export function verdictFromCopies(copies: FoundCopy[], draftId?: string): CopyVerdict {
|
||||||
|
const sentCopy = copies.find((c) => c.id !== draftId && !c.keywords?.$draft);
|
||||||
|
if (sentCopy) return { kind: 'sent', copyId: sentCopy.id };
|
||||||
|
return { kind: 'resend', staleIds: copies.filter((c) => c.id !== draftId).map((c) => c.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds to hold a scheduled send for, now; none once its time has passed. */
|
||||||
|
export function holdForScheduled(scheduledAt: string | undefined, now: number): number | undefined {
|
||||||
|
if (!scheduledAt) return undefined;
|
||||||
|
const at = Date.parse(scheduledAt);
|
||||||
|
if (!Number.isFinite(at)) return undefined;
|
||||||
|
const seconds = Math.ceil((at - now) / 1000);
|
||||||
|
return seconds > 0 ? seconds : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends left in `sending` by an app that was killed go back to the queue, still marked. */
|
||||||
|
export function restoreAfterRestart(sends: QueuedSend[]): QueuedSend[] {
|
||||||
|
return sends.map((s) => (s.status === 'sending' ? { ...s, status: 'queued', maybeSent: true } : s));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
/** The status line under a message in the Outbox. */
|
||||||
|
export function queuedSendStatus(send: QueuedSend, online: boolean, t: Translate): string {
|
||||||
|
if (send.status === 'sending') return t('outbox.status_sending', 'Sending…');
|
||||||
|
if (send.status === 'failed') {
|
||||||
|
return send.lastError
|
||||||
|
? t('outbox.status_failed_reason', 'Not sent: {reason}', { reason: send.lastError })
|
||||||
|
: t('outbox.status_failed', 'Not sent');
|
||||||
|
}
|
||||||
|
return online
|
||||||
|
? t('outbox.status_retrying', 'Waiting to send')
|
||||||
|
: t('outbox.status_offline', 'Waiting for a connection');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Outbox part of the offline banner: how many messages wait to go out
|
||||||
|
* and how many could not be sent, or null without any.
|
||||||
|
*/
|
||||||
|
export function outboxHint(waiting: number, failed: number, t: Translate): string | null {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (waiting > 0) {
|
||||||
|
parts.push(t('outbox.waiting', '{count, plural, one {# message} other {# messages}} in the Outbox', { count: waiting }));
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
parts.push(t('outbox.failed_count', '{count, plural, one {# message} other {# messages}} not sent', { count: failed }));
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join(' · ') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the user can take the message back: not while it is on its way. */
|
||||||
|
export function canCancel(send: QueuedSend): boolean {
|
||||||
|
return send.status !== 'sending';
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Per-message SMTP submission options (RFC 8621 §7.1 envelope parameters),
|
||||||
|
// ported from the webmail's lib/jmap/client.ts (submissionEnvelopeParameters,
|
||||||
|
// supportsSubmissionExtension).
|
||||||
|
//
|
||||||
|
// `requestDsn` asks the next hops for delivery status notifications (RFC 3461
|
||||||
|
// NOTIFY / RET); `requireTls` refuses relaying over an unencrypted hop (RFC
|
||||||
|
// 8689 REQUIRETLS). Both need the matching entry in the submission account's
|
||||||
|
// `submissionExtensions`, so the composer only offers what the server lists.
|
||||||
|
|
||||||
|
import { jmapClient } from '../api/jmap-client';
|
||||||
|
import { CAPABILITIES } from '../api/types';
|
||||||
|
|
||||||
|
export interface SubmissionSendOptions {
|
||||||
|
requestDsn?: boolean;
|
||||||
|
requireTls?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The MAIL FROM / RCPT TO parameters the options and a hold translate to. */
|
||||||
|
export function submissionEnvelopeParameters(
|
||||||
|
options: SubmissionSendOptions | undefined,
|
||||||
|
holdForSeconds?: number,
|
||||||
|
): { mailFrom: Record<string, string | null>; rcptTo: Record<string, string | null> } {
|
||||||
|
const mailFrom: Record<string, string | null> = {};
|
||||||
|
const rcptTo: Record<string, string | null> = {};
|
||||||
|
if (holdForSeconds && holdForSeconds > 0) mailFrom.HOLDFOR = String(Math.ceil(holdForSeconds));
|
||||||
|
if (options?.requireTls) mailFrom.REQUIRETLS = null;
|
||||||
|
if (options?.requestDsn) {
|
||||||
|
// Return the headers only (not the full message) with the notification.
|
||||||
|
mailFrom.RET = 'HDRS';
|
||||||
|
rcptTo.NOTIFY = 'SUCCESS,FAILURE,DELAY';
|
||||||
|
}
|
||||||
|
return { mailFrom, rcptTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a `submissionExtensions` value lists `extension`. RFC 8621 makes it
|
||||||
|
* a map of extension name to its arguments; an array of names is accepted
|
||||||
|
* too, as the webmail does.
|
||||||
|
*/
|
||||||
|
export function hasSubmissionExtension(submissionExtensions: unknown, extension: string): boolean {
|
||||||
|
const target = extension.toUpperCase();
|
||||||
|
if (Array.isArray(submissionExtensions)) {
|
||||||
|
return submissionExtensions.some((item) => typeof item === 'string' && item.toUpperCase() === target);
|
||||||
|
}
|
||||||
|
if (submissionExtensions && typeof submissionExtensions === 'object') {
|
||||||
|
return Object.entries(submissionExtensions as Record<string, unknown>)
|
||||||
|
.some(([key, value]) => key.toUpperCase() === target && value !== false && value != null);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the account (default: the primary) can submit with `extension`, e.g. "DSN". */
|
||||||
|
export function supportsSubmissionExtension(extension: string, accountId?: string): boolean {
|
||||||
|
const cap = jmapClient.getAccountCapability(CAPABILITIES.SUBMISSION, accountId) as
|
||||||
|
| { submissionExtensions?: unknown }
|
||||||
|
| undefined;
|
||||||
|
return hasSubmissionExtension(cap?.submissionExtensions, extension);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Attachment, EmailAddress } from '../api/types';
|
import type { Attachment, EmailAddress } from '../api/types';
|
||||||
|
import type { MessagePriority } from '../lib/message-priority';
|
||||||
|
|
||||||
/** The message a reply / forward / draft edit starts from. */
|
/** The message a reply / forward / draft edit starts from. */
|
||||||
export interface ComposeReplyContext {
|
export interface ComposeReplyContext {
|
||||||
@@ -47,6 +48,8 @@ export interface ComposeDraftContext {
|
|||||||
messageId?: string[] | null;
|
messageId?: string[] | null;
|
||||||
inReplyTo?: string[] | null;
|
inReplyTo?: string[] | null;
|
||||||
references?: string[] | null;
|
references?: string[] | null;
|
||||||
|
/** Read back from the draft's X-Priority / Importance headers (webmail #970). */
|
||||||
|
priority?: MessagePriority;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
|
|||||||
+259
-185
@@ -11,7 +11,7 @@ import {
|
|||||||
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
|
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
|
||||||
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
|
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
|
||||||
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
|
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
|
||||||
Users, Tag, Type, Highlighter, Trash2, Search,
|
Tag, Type, Highlighter, Trash2, Settings,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
@@ -28,7 +28,6 @@ import RichTextEditor, {
|
|||||||
} from '../components/RichTextEditor';
|
} from '../components/RichTextEditor';
|
||||||
import { useEmailStore } from '../stores/email-store';
|
import { useEmailStore } from '../stores/email-store';
|
||||||
import { ownMailboxes } from '../lib/mailbox-tree';
|
import { ownMailboxes } from '../lib/mailbox-tree';
|
||||||
import { getEmailInitials } from '../lib/avatar-utils';
|
|
||||||
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
|
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
|
||||||
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
||||||
import { useLayout } from '../lib/use-layout';
|
import { useLayout } from '../lib/use-layout';
|
||||||
@@ -77,6 +76,20 @@ import { inlineImageType } from '../lib/compose-inline-type';
|
|||||||
import { attachmentFromFileNode } from '../lib/compose-files';
|
import { attachmentFromFileNode } from '../lib/compose-files';
|
||||||
import { supportsFiles } from '../api/files';
|
import { supportsFiles } from '../api/files';
|
||||||
import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
|
import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
|
||||||
|
import { ComposerOptionsSheet, activeComposerOptionCount, type ComposerOptions } from '../components/compose/ComposerOptionsSheet';
|
||||||
|
import type { MessagePriority } from '../lib/message-priority';
|
||||||
|
import { supportsSubmissionExtension } from '../lib/submission-options';
|
||||||
|
import {
|
||||||
|
addMentionedRecipient, buildMentionCandidates, mentionHref, mentionSuggestions, type MentionCandidate,
|
||||||
|
} from '../lib/recipient-mentions';
|
||||||
|
import { MentionSuggestionList } from '../components/compose/MentionSuggestionList';
|
||||||
|
import { RecipientChip, RecipientSuggestionList } from '../components/compose/RecipientSuggestionList';
|
||||||
|
import { RecipientPicker } from '../components/compose/RecipientPicker';
|
||||||
|
import { isPickerReopenTooSoon, needsRecipientPicker } from '../lib/recipient-picker';
|
||||||
|
import { useOutboxStore } from '../stores/outbox-store';
|
||||||
|
import { useNetworkStore } from '../stores/network-store';
|
||||||
|
import { isTransientNetworkError } from '../lib/network-error';
|
||||||
|
import { classifySendFailure, shouldQueueFailedSend } from '../lib/send-queue';
|
||||||
import {
|
import {
|
||||||
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
|
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
|
||||||
} from '../lib/compose-login';
|
} from '../lib/compose-login';
|
||||||
@@ -211,118 +224,6 @@ function quoteLines(text: string): string {
|
|||||||
return text.split('\n').map((l) => `> ${l}`).join('\n');
|
return text.split('\n').map((l) => `> ${l}`).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecipientChip({
|
|
||||||
recipient, invalid, onRemove, onLongPress,
|
|
||||||
}: {
|
|
||||||
recipient: Recipient;
|
|
||||||
invalid?: boolean;
|
|
||||||
onRemove: () => void;
|
|
||||||
onLongPress?: () => void;
|
|
||||||
}) {
|
|
||||||
const c = useColors();
|
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
||||||
const t = useLocaleStore((s) => s.t);
|
|
||||||
const label = recipient.group
|
|
||||||
? t('email_composer.group_chip', '{name} ({count})', {
|
|
||||||
name: recipient.name || t('contacts.group', 'Group'),
|
|
||||||
count: recipient.group.members.length,
|
|
||||||
})
|
|
||||||
: recipient.name || recipient.email;
|
|
||||||
return (
|
|
||||||
<Pressable onLongPress={onLongPress} delayLongPress={350} style={[styles.chip, invalid && styles.chipInvalid]}>
|
|
||||||
{recipient.group && <Users size={12} color={c.textSecondary} />}
|
|
||||||
<Text style={[styles.chipText, invalid && styles.chipTextInvalid]} numberOfLines={1}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
onPress={onRemove}
|
|
||||||
// A 12 px icon: the touch area reaches about 40 dp around it.
|
|
||||||
hitSlop={14}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={t('email_composer.recipient_remove', 'Remove')}
|
|
||||||
>
|
|
||||||
<X size={12} color={invalid ? c.error : c.textMuted} />
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialsOf(name: string, email: string): string {
|
|
||||||
return getEmailInitials(name.trim(), email);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SuggestionList({
|
|
||||||
suggestions, onPick, onPressIn, onSearchServer, searchingServer,
|
|
||||||
}: {
|
|
||||||
suggestions: RecipientSuggestion[];
|
|
||||||
onPick: (s: RecipientSuggestion) => void;
|
|
||||||
onPressIn?: () => void;
|
|
||||||
/** Search the Sent folder on the server for more (webmail: "Search the server"). */
|
|
||||||
onSearchServer?: () => void;
|
|
||||||
searchingServer?: boolean;
|
|
||||||
}) {
|
|
||||||
const c = useColors();
|
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
||||||
const t = useLocaleStore((s) => s.t);
|
|
||||||
return (
|
|
||||||
<View style={styles.suggestionBox}>
|
|
||||||
<ScrollView style={styles.suggestionScroll} keyboardShouldPersistTaps="always" nestedScrollEnabled>
|
|
||||||
{suggestions.map((s, i) => (
|
|
||||||
<Pressable
|
|
||||||
key={`${s.group?.id ?? s.email}-${i}`}
|
|
||||||
onPressIn={onPressIn}
|
|
||||||
onPress={() => onPick(s)}
|
|
||||||
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
|
|
||||||
>
|
|
||||||
<View style={styles.suggestionAvatar}>
|
|
||||||
{s.group
|
|
||||||
? <Users size={14} color={c.primary} />
|
|
||||||
: <Text style={styles.suggestionAvatarText}>{initialsOf(s.name, s.email)}</Text>}
|
|
||||||
</View>
|
|
||||||
<View style={styles.suggestionText}>
|
|
||||||
<Text style={styles.suggestionName} numberOfLines={1}>
|
|
||||||
{s.name || s.email}
|
|
||||||
</Text>
|
|
||||||
{s.group ? (
|
|
||||||
<Text style={styles.suggestionEmail} numberOfLines={1}>
|
|
||||||
{t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: s.group.memberCount })}
|
|
||||||
</Text>
|
|
||||||
) : !!s.name && (
|
|
||||||
<Text style={styles.suggestionEmail} numberOfLines={1}>{s.email}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
{onSearchServer && (
|
|
||||||
<Pressable
|
|
||||||
onPressIn={onPressIn}
|
|
||||||
onPress={() => { if (!searchingServer) onSearchServer(); }}
|
|
||||||
disabled={searchingServer}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.suggestionRow,
|
|
||||||
suggestions.length > 0 && styles.suggestionSearchRowDivided,
|
|
||||||
pressed && styles.suggestionRowPressed,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.suggestionSearchIcon}>
|
|
||||||
{searchingServer
|
|
||||||
? <ActivityIndicator size="small" color={c.textSecondary} />
|
|
||||||
: <Search size={16} color={c.textSecondary} />}
|
|
||||||
</View>
|
|
||||||
<Text style={styles.suggestionSearchText} numberOfLines={1}>
|
|
||||||
{searchingServer
|
|
||||||
? t('email_composer.autocomplete_searching', 'Searching...')
|
|
||||||
: t('email_composer.autocomplete_search_server', 'Search the server')}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AttachmentChip({
|
function AttachmentChip({
|
||||||
attachment, onRemove, onPress,
|
attachment, onRemove, onPress,
|
||||||
}: {
|
}: {
|
||||||
@@ -722,6 +623,27 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
const [activeField, setActiveField] = React.useState<Field | null>(null);
|
const [activeField, setActiveField] = React.useState<Field | null>(null);
|
||||||
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>(initialAttachments);
|
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>(initialAttachments);
|
||||||
const [requestReadReceipt, setRequestReadReceipt] = React.useState(requestReadReceiptDefault);
|
const [requestReadReceipt, setRequestReadReceipt] = React.useState(requestReadReceiptDefault);
|
||||||
|
// Per-message submission options and priority (webmail composer options
|
||||||
|
// menu): DSN and REQUIRETLS ride on the envelope, priority on the headers.
|
||||||
|
const [requestDsn, setRequestDsn] = React.useState(false);
|
||||||
|
const [requireTls, setRequireTls] = React.useState(false);
|
||||||
|
const initialPriority: MessagePriority = draft?.priority ?? 'normal';
|
||||||
|
const [priority, setPriority] = React.useState<MessagePriority>(initialPriority);
|
||||||
|
const [optionsSheetOpen, setOptionsSheetOpen] = React.useState(false);
|
||||||
|
const composerOptions = React.useMemo<ComposerOptions>(
|
||||||
|
() => ({ requestReadReceipt, requestDsn, requireTls, priority }),
|
||||||
|
[requestReadReceipt, requestDsn, requireTls, priority],
|
||||||
|
);
|
||||||
|
// Read when the sheet opens: the session may have arrived since the
|
||||||
|
// composer did.
|
||||||
|
const dsnAvailable = optionsSheetOpen && supportsSubmissionExtension('DSN');
|
||||||
|
const requireTlsAvailable = optionsSheetOpen && supportsSubmissionExtension('REQUIRETLS');
|
||||||
|
const onComposerOptionsChange = React.useCallback((next: Partial<ComposerOptions>) => {
|
||||||
|
if (next.requestReadReceipt !== undefined) setRequestReadReceipt(next.requestReadReceipt);
|
||||||
|
if (next.requestDsn !== undefined) setRequestDsn(next.requestDsn);
|
||||||
|
if (next.requireTls !== undefined) setRequireTls(next.requireTls);
|
||||||
|
if (next.priority !== undefined) setPriority(next.priority);
|
||||||
|
}, []);
|
||||||
const [subAddressTag, setSubAddressTag] = React.useState('');
|
const [subAddressTag, setSubAddressTag] = React.useState('');
|
||||||
const [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null);
|
const [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null);
|
||||||
const [selState, setSelState] = React.useState<RichTextSelectionState>({
|
const [selState, setSelState] = React.useState<RichTextSelectionState>({
|
||||||
@@ -738,7 +660,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
}), [t]);
|
}), [t]);
|
||||||
// The label column grows with the system font size: at 1.3 "Betreff"
|
// The label column grows with the system font size: at 1.3 "Betreff"
|
||||||
// wrapped into "Betref" / "f" in a fixed 56 dp column.
|
// wrapped into "Betref" / "f" in a fixed 56 dp column.
|
||||||
const { fontScale } = useWindowDimensions();
|
const { fontScale, height: windowHeight } = useWindowDimensions();
|
||||||
const fieldLabelWidth = React.useMemo(() => ({ width: Math.round(56 * Math.max(1, fontScale)) }), [fontScale]);
|
const fieldLabelWidth = React.useMemo(() => ({ width: Math.round(56 * Math.max(1, fontScale)) }), [fontScale]);
|
||||||
const subjectRef = React.useRef<TextInput>(null);
|
const subjectRef = React.useRef<TextInput>(null);
|
||||||
const plainBodyRef = React.useRef<TextInput>(null);
|
const plainBodyRef = React.useRef<TextInput>(null);
|
||||||
@@ -795,7 +717,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
const inputFor = (field: Field | null) =>
|
const inputFor = (field: Field | null) =>
|
||||||
field === 'to' ? toInput : field === 'cc' ? ccInput : field === 'bcc' ? bccInput : '';
|
field === 'to' ? toInput : field === 'cc' ? ccInput : field === 'bcc' ? bccInput : '';
|
||||||
const suggestionQuery = inputFor(activeField);
|
// The full-screen recipient picker (U-069) for a field whose inline
|
||||||
|
// suggestions would have no room; it owns the suggestions while open.
|
||||||
|
const [pickerField, setPickerField] = React.useState<Field | null>(null);
|
||||||
|
const pickerClosedAtRef = React.useRef<number | null>(null);
|
||||||
|
// The bottom edge of each address row in window coordinates, measured
|
||||||
|
// while it has the focus, to tell whether the list fits under it.
|
||||||
|
const fieldRowRefs = React.useRef<Partial<Record<Field, View | null>>>({});
|
||||||
|
const [activeFieldBottom, setActiveFieldBottom] = React.useState<number | null>(null);
|
||||||
|
const suggestField = pickerField ?? activeField;
|
||||||
|
const suggestionQuery = inputFor(suggestField);
|
||||||
const alreadySelected = React.useMemo(
|
const alreadySelected = React.useMemo(
|
||||||
() => new Set(
|
() => new Set(
|
||||||
[...toRecipients, ...ccRecipients, ...bccRecipients]
|
[...toRecipients, ...ccRecipients, ...bccRecipients]
|
||||||
@@ -841,7 +772,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
const pickSuggestion = (s: RecipientSuggestion) => {
|
const pickSuggestion = (s: RecipientSuggestion) => {
|
||||||
isPickingSuggestion.current = true;
|
isPickingSuggestion.current = true;
|
||||||
const field = activeField ?? 'to';
|
const field = suggestField ?? 'to';
|
||||||
let recipient: Recipient;
|
let recipient: Recipient;
|
||||||
if (s.group) {
|
if (s.group) {
|
||||||
const members = getGroupRecipients(s.group.id)
|
const members = getGroupRecipients(s.group.id)
|
||||||
@@ -1062,6 +993,47 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity;
|
return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity;
|
||||||
}, [identities, selectedIdentityId]);
|
}, [identities, selectedIdentityId]);
|
||||||
|
|
||||||
|
// ── "@" mentions in the body (webmail #1134) ──────────────────────────
|
||||||
|
// What follows the "@" being typed in the editor, null when none is.
|
||||||
|
const [mentionQuery, setMentionQuery] = React.useState<string | null>(null);
|
||||||
|
// Who an "@" offers: To and Cc. Never Bcc - naming a blind-copied
|
||||||
|
// recipient in the text would disclose them to everyone else.
|
||||||
|
const mentionCandidates = React.useMemo(
|
||||||
|
() => buildMentionCandidates(toRecipients, ccRecipients),
|
||||||
|
[toRecipients, ccRecipients],
|
||||||
|
);
|
||||||
|
// Contacts come after the recipients; anyone already on the message is
|
||||||
|
// left out, Bcc and the identity's own Bcc included.
|
||||||
|
const mentionItems = React.useMemo<MentionCandidate[]>(() => {
|
||||||
|
if (mentionQuery === null) return [];
|
||||||
|
const onMessage = new Set(
|
||||||
|
[...toRecipients, ...ccRecipients, ...bccRecipients]
|
||||||
|
.flatMap((r) => (r.group ? r.group.members.map((m) => m.email) : [r.email]))
|
||||||
|
.concat((primaryIdentity?.bcc ?? []).map((r) => r.email))
|
||||||
|
.map((e) => e.trim().toLowerCase()),
|
||||||
|
);
|
||||||
|
const contacts = mentionQuery
|
||||||
|
? getAutocomplete(mentionQuery, 16)
|
||||||
|
.filter((s) => !s.group && !!s.email && !onMessage.has(s.email.trim().toLowerCase()))
|
||||||
|
.map((s) => ({ name: s.name || undefined, email: s.email }))
|
||||||
|
: [];
|
||||||
|
return mentionSuggestions(mentionCandidates, contacts, mentionQuery, 'to');
|
||||||
|
// contactsVersion/recentVersion re-run the lookup when the store loads.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mentionQuery, mentionCandidates, toRecipients, ccRecipients, bccRecipients, primaryIdentity, getAutocomplete, contactsVersion, recentVersion]);
|
||||||
|
const pickMention = (candidate: MentionCandidate) => {
|
||||||
|
editorRef.current?.insertMention(candidate.label, mentionHref(candidate.email));
|
||||||
|
// A contact who isn't on the message yet joins To (webmail default).
|
||||||
|
if (candidate.addsTo === 'cc') {
|
||||||
|
setCcRecipients((prev) => addMentionedRecipient(prev, candidate, (c) => ({ name: c.name ?? '', email: c.email })));
|
||||||
|
setCcVisible(true);
|
||||||
|
} else if (candidate.addsTo) {
|
||||||
|
setToRecipients((prev) => addMentionedRecipient(prev, candidate, (c) => ({ name: c.name ?? '', email: c.email })));
|
||||||
|
}
|
||||||
|
setMentionQuery(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Set once the user picks From by hand: their pick then brings its own
|
// Set once the user picks From by hand: their pick then brings its own
|
||||||
// signature, also when it has none (webmail identityPickedByUser).
|
// signature, also when it has none (webmail identityPickedByUser).
|
||||||
const [identityPickedByUser, setIdentityPickedByUser] = React.useState(false);
|
const [identityPickedByUser, setIdentityPickedByUser] = React.useState(false);
|
||||||
@@ -1187,6 +1159,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
const snapshotOf = (s: {
|
const snapshotOf = (s: {
|
||||||
to: Recipient[]; cc: Recipient[]; bcc: Recipient[]; subject: string; body: string;
|
to: Recipient[]; cc: Recipient[]; bcc: Recipient[]; subject: string; body: string;
|
||||||
attachments: AttachmentEntry[];
|
attachments: AttachmentEntry[];
|
||||||
|
/** A priority change alone is an edit worth saving (webmail #970). */
|
||||||
|
priority?: MessagePriority;
|
||||||
}) => JSON.stringify({
|
}) => JSON.stringify({
|
||||||
to: s.to.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
|
to: s.to.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
|
||||||
cc: s.cc.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
|
cc: s.cc.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
|
||||||
@@ -1194,18 +1168,20 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
subject: s.subject,
|
subject: s.subject,
|
||||||
body: s.body,
|
body: s.body,
|
||||||
att: s.attachments.filter((a) => !a.inline).map((a) => a.blobId ?? a.localId),
|
att: s.attachments.filter((a) => !a.inline).map((a) => a.blobId ?? a.localId),
|
||||||
|
pri: s.priority ?? 'normal',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoized: the snapshot copies the whole body, and the screen re-renders
|
// Memoized: the snapshot copies the whole body, and the screen re-renders
|
||||||
// for plenty that doesn't touch it (PF8).
|
// for plenty that doesn't touch it (PF8).
|
||||||
const currentSnapshot = React.useMemo(() => snapshotOf({
|
const currentSnapshot = React.useMemo(() => snapshotOf({
|
||||||
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
|
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
|
||||||
body: plainTextMode ? plainBody : bodyHtml, attachments,
|
body: plainTextMode ? plainBody : bodyHtml, attachments, priority,
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}), [finalTo, finalCc, finalBcc, subject, plainTextMode, plainBody, bodyHtml, attachments]);
|
}), [finalTo, finalCc, finalBcc, subject, plainTextMode, plainBody, bodyHtml, attachments, priority]);
|
||||||
const baselineRef = React.useRef(snapshotOf({
|
const baselineRef = React.useRef(snapshotOf({
|
||||||
to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject,
|
to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject,
|
||||||
body: plainTextMode ? initialPlainBody : initialBodyHtml, attachments: initialAttachments,
|
body: plainTextMode ? initialPlainBody : initialBodyHtml, attachments: initialAttachments,
|
||||||
|
priority: initialPriority,
|
||||||
}));
|
}));
|
||||||
// Snapshot at the last successful draft save (null = never saved).
|
// Snapshot at the last successful draft save (null = never saved).
|
||||||
const lastSavedRef = React.useRef<string | null>(draft ? baselineRef.current : null);
|
const lastSavedRef = React.useRef<string | null>(draft ? baselineRef.current : null);
|
||||||
@@ -1464,9 +1440,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
references: threading?.references,
|
references: threading?.references,
|
||||||
messageId: messageIdRef.current,
|
messageId: messageIdRef.current,
|
||||||
requestReadReceipt,
|
requestReadReceipt,
|
||||||
|
priority: priority !== 'normal' ? priority : undefined,
|
||||||
envelopeMailFrom,
|
envelopeMailFrom,
|
||||||
};
|
};
|
||||||
}, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, t]);
|
}, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, priority, t]);
|
||||||
|
|
||||||
// Save one draft version (create, then destroy the previous one - #849).
|
// Save one draft version (create, then destroy the previous one - #849).
|
||||||
const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
|
const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
|
||||||
@@ -1485,7 +1462,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
}
|
}
|
||||||
const snapshot = snapshotOf({
|
const snapshot = snapshotOf({
|
||||||
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
|
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
|
||||||
body: plainTextMode ? plainBody : html, attachments,
|
body: plainTextMode ? plainBody : html, attachments, priority,
|
||||||
});
|
});
|
||||||
if (snapshot === lastSavedRef.current) return draftIdRef.current;
|
if (snapshot === lastSavedRef.current) return draftIdRef.current;
|
||||||
if (snapshot === baselineRef.current && !draftIdRef.current) return null;
|
if (snapshot === baselineRef.current && !draftIdRef.current) return null;
|
||||||
@@ -1682,7 +1659,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
}
|
}
|
||||||
const snapshot = snapshotOf({
|
const snapshot = snapshotOf({
|
||||||
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
|
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
|
||||||
body: plainTextMode ? latestRef.current.plainBody : html, attachments,
|
body: plainTextMode ? latestRef.current.plainBody : html, attachments, priority,
|
||||||
});
|
});
|
||||||
return snapshot === lastSavedRef.current;
|
return snapshot === lastSavedRef.current;
|
||||||
};
|
};
|
||||||
@@ -2542,9 +2519,48 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
// notification tap to switch logins.
|
// notification tap to switch logins.
|
||||||
if (refuseSendAfterSwitch()) return;
|
if (refuseSendAfterSwitch()) return;
|
||||||
|
|
||||||
|
// Offline, or the connection fails on the way: the message waits in the
|
||||||
|
// Outbox and goes out once the app is back online (parity 04/09/10
|
||||||
|
// "offline send"). The Outbox takes over the autosaved draft, which it
|
||||||
|
// removes once the message is sent.
|
||||||
|
let queuedOutgoing: OutgoingEmail | null = null;
|
||||||
|
const queueForLater = (outgoing: OutgoingEmail, maybeSent: boolean): boolean => {
|
||||||
|
if (!primaryIdentity || !sentMailbox || loginMoved()) return false;
|
||||||
|
const outbox = useOutboxStore.getState();
|
||||||
|
// Only into the queue of the login the message was written in.
|
||||||
|
if (outbox.activeAccountId !== ownerLoginRef.current) return false;
|
||||||
|
const queuedId = outbox.enqueueSend({
|
||||||
|
outgoing,
|
||||||
|
identity: primaryIdentity,
|
||||||
|
fromEmail: outgoing.from[0]?.email ?? primaryIdentity.email,
|
||||||
|
sentMailboxId: sentMailbox.id,
|
||||||
|
draftsMailboxId: draftsMailbox?.id,
|
||||||
|
draftId: draftIdRef.current ?? undefined,
|
||||||
|
draftAccountId: draftIdRef.current ? draftAccount() : undefined,
|
||||||
|
requestDsn,
|
||||||
|
requireTls,
|
||||||
|
scheduledAt: scheduledAt?.toISOString(),
|
||||||
|
original: replyTo?.originalEmailId
|
||||||
|
? { emailId: replyTo.originalEmailId, accountId: replyTo.jmapAccountId, keyword: mode === 'forward' ? '$forwarded' : '$answered' }
|
||||||
|
: undefined,
|
||||||
|
}, { maybeSent });
|
||||||
|
if (!queuedId) return false;
|
||||||
|
draftIdRef.current = null;
|
||||||
|
lastSavedRef.current = null;
|
||||||
|
toast.info(
|
||||||
|
t('outbox.queued_title', 'Saved to the Outbox'),
|
||||||
|
{ message: t('outbox.queued_body', 'It goes out as soon as you are back online.') },
|
||||||
|
);
|
||||||
|
allowLeaveRef.current = true;
|
||||||
|
navigation.goBack();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false });
|
const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false });
|
||||||
|
queuedOutgoing = outgoing;
|
||||||
|
if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return;
|
||||||
// The account the submission is held in, for Undo (read before the
|
// The account the submission is held in, for Undo (read before the
|
||||||
// request: a switch may land while it is out).
|
// request: a switch may land while it is out).
|
||||||
// A group or shared identity sends from its own account, and its Sent
|
// A group or shared identity sends from its own account, and its Sent
|
||||||
@@ -2562,6 +2578,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
draftId: draftIdRef.current ?? undefined,
|
draftId: draftIdRef.current ?? undefined,
|
||||||
draftAccountId: draftIdRef.current ? draftAccount() : undefined,
|
draftAccountId: draftIdRef.current ? draftAccount() : undefined,
|
||||||
accountId: target.accountId,
|
accountId: target.accountId,
|
||||||
|
// Only what the submitting account offers (webmail: the menu
|
||||||
|
// hides the toggles the account's submission extensions lack).
|
||||||
|
requestDsn: requestDsn && supportsSubmissionExtension('DSN', target.accountId),
|
||||||
|
requireTls: requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
draftIdRef.current = null;
|
draftIdRef.current = null;
|
||||||
@@ -2631,6 +2651,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
allowLeaveRef.current = true;
|
allowLeaveRef.current = true;
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// The connection failed: the request may have got through, so the
|
||||||
|
// Outbox looks for the message before it sends it again.
|
||||||
|
if (queuedOutgoing && shouldQueueFailedSend(classifySendFailure(e, isTransientNetworkError))
|
||||||
|
&& queueForLater(queuedOutgoing, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e instanceof RequestTimeoutError) {
|
if (e instanceof RequestTimeoutError) {
|
||||||
// The request may have reached the server; a blind retry would send
|
// The request may have reached the server; a blind retry would send
|
||||||
// the message twice (#702).
|
// the message twice (#702).
|
||||||
@@ -2690,13 +2716,68 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
: draftStatus === 'failed' ? t('email_composer.save_failed', 'Failed to save')
|
: draftStatus === 'failed' ? t('email_composer.save_failed', 'Failed to save')
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Typing in an address field, inline or in the picker. A separator ends
|
||||||
|
// the entry: commit immediately so a pasted list turns into chips as it
|
||||||
|
// lands.
|
||||||
|
const changeFieldInput = (field: Field, text: string) => {
|
||||||
|
const setInput = inputSetterFor(field);
|
||||||
|
setInput(text);
|
||||||
|
if (/[,;\n]$/.test(text) && text.trim().length > 1) {
|
||||||
|
const { valid, invalid } = splitPastedRecipients(text, Array.from(alreadySelected));
|
||||||
|
if (valid.length) {
|
||||||
|
setterFor(field)((prev) => [...prev, ...valid.map(fromParsed)]);
|
||||||
|
setInput(invalid.join(' '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const measureFieldRow = (field: Field) => {
|
||||||
|
fieldRowRefs.current[field]?.measureInWindow((_x, y, _w, h) => {
|
||||||
|
if (Number.isFinite(y) && Number.isFinite(h)) setActiveFieldBottom(y + h);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRecipientPicker = (field: Field) => {
|
||||||
|
if (pickerField) return;
|
||||||
|
if (isPickerReopenTooSoon(pickerClosedAtRef.current, Date.now())) return;
|
||||||
|
// The picker takes over the field; the inline blur must not commit
|
||||||
|
// half-typed text behind it.
|
||||||
|
isPickingSuggestion.current = true;
|
||||||
|
setPickerField(field);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRecipientPicker = () => {
|
||||||
|
const field = pickerField;
|
||||||
|
if (!field) return;
|
||||||
|
if (inputFor(field).trim()) addTyped(field);
|
||||||
|
pickerClosedAtRef.current = Date.now();
|
||||||
|
isPickingSuggestion.current = false;
|
||||||
|
setPickerField(null);
|
||||||
|
setActiveField(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typing inline where the list has no room under the field (a small
|
||||||
|
// split-screen window) hands over to the picker too.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!activeField || pickerField) return;
|
||||||
|
if (!inputFor(activeField).trim() || suggestions.length === 0) return;
|
||||||
|
if (needsRecipientPicker({
|
||||||
|
shortHeight: false, windowHeight, keyboardObstruction: kbObstruction, fieldBottom: activeFieldBottom,
|
||||||
|
})) {
|
||||||
|
openRecipientPicker(activeField);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeField, pickerField, suggestions.length, windowHeight, kbObstruction, activeFieldBottom]);
|
||||||
|
|
||||||
const renderRecipientField = (field: Field, label: string, placeholder: string, zIndex: number) => {
|
const renderRecipientField = (field: Field, label: string, placeholder: string, zIndex: number) => {
|
||||||
const list = field === 'to' ? toRecipients : field === 'cc' ? ccRecipients : bccRecipients;
|
const list = field === 'to' ? toRecipients : field === 'cc' ? ccRecipients : bccRecipients;
|
||||||
const input = inputFor(field);
|
const input = inputFor(field);
|
||||||
const setInput = inputSetterFor(field);
|
|
||||||
const invalidInput = typedRecipients[field].invalid.length > 0 && !typedRecipients[field].valid.length && input.trim().length > 0 && activeField !== field;
|
const invalidInput = typedRecipients[field].invalid.length > 0 && !typedRecipients[field].valid.length && input.trim().length > 0 && activeField !== field;
|
||||||
return (
|
return (
|
||||||
<View style={[styles.fieldRow, { zIndex }]}>
|
<View
|
||||||
|
style={[styles.fieldRow, { zIndex }]}
|
||||||
|
ref={(view) => { fieldRowRefs.current[field] = view; }}
|
||||||
|
>
|
||||||
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{label}</Text>
|
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{label}</Text>
|
||||||
<View style={styles.recipientField}>
|
<View style={styles.recipientField}>
|
||||||
{list.map((r, i) => (
|
{list.map((r, i) => (
|
||||||
@@ -2718,18 +2799,19 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
value={input}
|
value={input}
|
||||||
onChangeText={(text) => {
|
onChangeText={(text) => {
|
||||||
// A separator ends the entry: commit immediately so a pasted
|
changeFieldInput(field, text);
|
||||||
// list turns into chips as it lands.
|
measureFieldRow(field);
|
||||||
setInput(text);
|
}}
|
||||||
if (/[,;\n]$/.test(text) && text.trim().length > 1) {
|
onFocus={() => {
|
||||||
const { valid, invalid } = splitPastedRecipients(text, Array.from(alreadySelected));
|
setActiveField(field);
|
||||||
if (valid.length) {
|
// A phone in landscape: no room for suggestions above the
|
||||||
setterFor(field)((prev) => [...prev, ...valid.map(fromParsed)]);
|
// keyboard, so the full-screen picker takes the typing (U-069).
|
||||||
setInput(invalid.join(' '));
|
if (needsRecipientPicker({ shortHeight, windowHeight, keyboardObstruction: kbObstruction, fieldBottom: null })) {
|
||||||
}
|
openRecipientPicker(field);
|
||||||
|
} else {
|
||||||
|
measureFieldRow(field);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onFocus={() => setActiveField(field)}
|
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!isPickingSuggestion.current) {
|
if (!isPickingSuggestion.current) {
|
||||||
@@ -2757,8 +2839,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
textContentType="none"
|
textContentType="none"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && (
|
{activeField === field && !pickerField && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && (
|
||||||
<SuggestionList
|
<RecipientSuggestionList
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
onPick={pickSuggestion}
|
onPick={pickSuggestion}
|
||||||
onPressIn={() => {
|
onPressIn={() => {
|
||||||
@@ -2967,6 +3049,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
onChange={setBodyHtml}
|
onChange={setBodyHtml}
|
||||||
onSelectionChange={setSelState}
|
onSelectionChange={setSelState}
|
||||||
onViewportShift={onEditorViewportShift}
|
onViewportShift={onEditorViewportShift}
|
||||||
|
mentions
|
||||||
|
onMentionQuery={setMentionQuery}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* The empty space under the body looks like part of it (on a
|
{/* The empty space under the body looks like part of it (on a
|
||||||
@@ -2981,6 +3065,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{!plainTextMode && (
|
||||||
|
<MentionSuggestionList items={mentionItems} onPick={pickMention} shortWindow={typingInShortWindow} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* A phone in landscape with the keyboard up has room for a few lines
|
{/* A phone in landscape with the keyboard up has room for a few lines
|
||||||
of text only; the format bar comes back with the keyboard down. */}
|
of text only; the format bar comes back with the keyboard down. */}
|
||||||
{!typingInShortWindow && (
|
{!typingInShortWindow && (
|
||||||
@@ -2997,6 +3085,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
|
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
|
||||||
label={t('email_composer.read_receipt_off', 'Request a read receipt')}
|
label={t('email_composer.read_receipt_off', 'Request a read receipt')}
|
||||||
icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} />
|
icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} />
|
||||||
|
<ToolbarButton active={activeComposerOptionCount(composerOptions) > 0} onPress={() => setOptionsSheetOpen(true)}
|
||||||
|
label={t('email_composer.options', 'Options')}
|
||||||
|
icon={<Settings size={18} color={activeComposerOptionCount(composerOptions) > 0 ? c.primary : c.textSecondary} />} />
|
||||||
<ToolbarButton active={plainTextMode} onPress={() => { void togglePlainTextMode(); }}
|
<ToolbarButton active={plainTextMode} onPress={() => { void togglePlainTextMode(); }}
|
||||||
label={plainTextMode
|
label={plainTextMode
|
||||||
? t('email_composer.format_rich_text', 'Switch to rich text (HTML)')
|
? t('email_composer.format_rich_text', 'Switch to rich text (HTML)')
|
||||||
@@ -3479,6 +3570,38 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{pickerField && (
|
||||||
|
<RecipientPicker
|
||||||
|
visible
|
||||||
|
label={pickerField === 'to' ? t('email_composer.to', 'To') : pickerField === 'cc' ? t('email_composer.cc', 'Cc') : t('email_composer.bcc', 'Bcc')}
|
||||||
|
placeholder={pickerField === 'to'
|
||||||
|
? t('email_composer.to_placeholder', 'Recipient email addresses')
|
||||||
|
: pickerField === 'cc'
|
||||||
|
? t('email_composer.cc_placeholder', 'Cc recipients')
|
||||||
|
: t('email_composer.bcc_placeholder', 'Bcc recipients')}
|
||||||
|
recipients={pickerField === 'to' ? toRecipients : pickerField === 'cc' ? ccRecipients : bccRecipients}
|
||||||
|
isValid={chipIsValid}
|
||||||
|
input={inputFor(pickerField)}
|
||||||
|
onChangeInput={(text) => changeFieldInput(pickerField, text)}
|
||||||
|
onSubmitInput={() => addTyped(pickerField)}
|
||||||
|
suggestions={suggestions}
|
||||||
|
onPick={pickSuggestion}
|
||||||
|
onSearchServer={canSearchServer ? () => { void searchServer(); } : undefined}
|
||||||
|
searchingServer={searchingServer}
|
||||||
|
onRemove={(index) => removeChip(pickerField, index)}
|
||||||
|
onDone={closeRecipientPicker}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ComposerOptionsSheet
|
||||||
|
visible={optionsSheetOpen}
|
||||||
|
options={composerOptions}
|
||||||
|
dsnAvailable={dsnAvailable}
|
||||||
|
requireTlsAvailable={requireTlsAvailable}
|
||||||
|
onChange={onComposerOptionsChange}
|
||||||
|
onClose={() => setOptionsSheetOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FilesPickerSheet
|
<FilesPickerSheet
|
||||||
visible={filesPickerOpen}
|
visible={filesPickerOpen}
|
||||||
mode="files"
|
mode="files"
|
||||||
@@ -3597,19 +3720,6 @@ function makeStyles(c: ThemePalette) {
|
|||||||
gap: spacing.xs,
|
gap: spacing.xs,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
},
|
},
|
||||||
chip: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
paddingHorizontal: spacing.sm,
|
|
||||||
paddingVertical: 4,
|
|
||||||
borderRadius: radius.full,
|
|
||||||
backgroundColor: c.surfaceActive,
|
|
||||||
maxWidth: 220,
|
|
||||||
},
|
|
||||||
chipInvalid: { backgroundColor: c.errorBg, borderWidth: 1, borderColor: c.error },
|
|
||||||
chipText: { ...typography.caption, color: c.text, flexShrink: 1 },
|
|
||||||
chipTextInvalid: { color: c.error },
|
|
||||||
recipientInput: {
|
recipientInput: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
@@ -3618,42 +3728,6 @@ function makeStyles(c: ThemePalette) {
|
|||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
},
|
},
|
||||||
recipientInputInvalid: { color: c.error },
|
recipientInputInvalid: { color: c.error },
|
||||||
suggestionBox: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '100%',
|
|
||||||
left: spacing.lg + 56 + spacing.md,
|
|
||||||
right: spacing.lg,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: c.borderLight,
|
|
||||||
borderRadius: radius.sm,
|
|
||||||
backgroundColor: c.card,
|
|
||||||
zIndex: 20,
|
|
||||||
elevation: 5,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
suggestionRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: spacing.sm,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
suggestionRowPressed: { backgroundColor: c.surfaceHover },
|
|
||||||
suggestionScroll: { maxHeight: 320 },
|
|
||||||
suggestionSearchRowDivided: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: c.borderLight },
|
|
||||||
suggestionSearchIcon: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
suggestionSearchText: { ...typography.body, color: c.textSecondary, flex: 1 },
|
|
||||||
suggestionAvatar: {
|
|
||||||
width: 32, height: 32,
|
|
||||||
borderRadius: 16,
|
|
||||||
backgroundColor: c.primaryBg,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
suggestionAvatarText: { ...typography.captionMedium, color: c.primary },
|
|
||||||
suggestionText: { flex: 1 },
|
|
||||||
suggestionName: { ...typography.bodyMedium, color: c.text },
|
|
||||||
suggestionEmail: { ...typography.caption, color: c.textSecondary },
|
|
||||||
ccToggle: {
|
ccToggle: {
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// The offline send queue in the outbox store: messages wait while offline,
|
||||||
|
// survive a restart, go out once, and an attempt that may have got through
|
||||||
|
// is sent again only through runQueuedSend's lookup (maybeSent).
|
||||||
|
|
||||||
|
const client = vi.hoisted(() => ({ served: 'user1@mail.example.org' as string | null }));
|
||||||
|
vi.mock('../../api/jmap-client', () => ({
|
||||||
|
jmapClient: {
|
||||||
|
get isConnected() { return client.served !== null; },
|
||||||
|
get username() { return client.served ? client.served.split('@')[0] : null; },
|
||||||
|
get serverUrl() { return client.served ? `https://${client.served.split('@')[1]}` : null; },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const patchKeywordsForEmails = vi.fn(async (..._a: any[]) => undefined);
|
||||||
|
const saveDraftVersion = vi.fn(async (..._a: any[]) => ({ id: 'draft-2', attachments: [] }));
|
||||||
|
vi.mock('../../api/email', () => ({
|
||||||
|
patchKeywordsForEmails: (...a: any[]) => patchKeywordsForEmails(...a),
|
||||||
|
setEmailMailboxes: vi.fn(),
|
||||||
|
destroyEmails: vi.fn(),
|
||||||
|
archiveEmails: vi.fn(),
|
||||||
|
unprefixMailboxId: (id: string) => id,
|
||||||
|
saveDraftVersion: (...a: any[]) => saveDraftVersion(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runQueuedSend = vi.fn(async (..._a: any[]): Promise<any> => ({ kind: 'sent', result: { scheduled: false } }));
|
||||||
|
vi.mock('../../api/queued-send', () => ({
|
||||||
|
runQueuedSend: (...a: any[]) => runQueuedSend(...a),
|
||||||
|
}));
|
||||||
|
vi.mock('../email-store', () => ({
|
||||||
|
useEmailStore: { getState: () => ({ mailboxes: [{ id: 'sent', role: 'sent' }] }) },
|
||||||
|
}));
|
||||||
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||||
|
vi.mock('../toast-store', () => ({ toast }));
|
||||||
|
vi.mock('../locale-store', () => ({
|
||||||
|
useLocaleStore: { getState: () => ({ t: (_k: string, fallback?: string) => fallback ?? _k }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { useOutboxStore } from '../outbox-store';
|
||||||
|
import { useNetworkStore } from '../network-store';
|
||||||
|
import type { QueuedSendPayload } from '../../lib/send-queue';
|
||||||
|
|
||||||
|
const ACCOUNT = 'user1@mail.example.org';
|
||||||
|
const SENDS_KEY = `webmail:outbox:v1:${ACCOUNT}:sends`;
|
||||||
|
|
||||||
|
const payload = (subject = 'Hello'): QueuedSendPayload => ({
|
||||||
|
outgoing: { from: [{ email: 'me@x.de' }], to: [{ email: 'you@x.de' }], subject, textBody: 'Hi', messageId: `${subject}@x.de` },
|
||||||
|
identity: { id: 'id-1', email: 'me@x.de', name: 'Me' } as QueuedSendPayload['identity'],
|
||||||
|
fromEmail: 'me@x.de',
|
||||||
|
sentMailboxId: 'sent',
|
||||||
|
draftsMailboxId: 'drafts',
|
||||||
|
draftId: 'draft-1',
|
||||||
|
original: { emailId: 'orig-1', keyword: '$answered' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const named = (name: string, message = name) => Object.assign(new Error(message), { name });
|
||||||
|
const flushed = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
client.served = ACCOUNT;
|
||||||
|
useNetworkStore.setState({ online: false, connected: false });
|
||||||
|
await useOutboxStore.getState().setAccount(null);
|
||||||
|
await useOutboxStore.getState().setAccount(ACCOUNT);
|
||||||
|
await useOutboxStore.getState().clear();
|
||||||
|
await useOutboxStore.getState().resume(ACCOUNT);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('offline send queue', () => {
|
||||||
|
it('keeps a message while offline and sends it once when the connection is back', async () => {
|
||||||
|
useOutboxStore.getState().enqueueSend(payload());
|
||||||
|
await useOutboxStore.getState().flush();
|
||||||
|
expect(runQueuedSend).not.toHaveBeenCalled();
|
||||||
|
expect(useOutboxStore.getState().sends).toHaveLength(1);
|
||||||
|
|
||||||
|
useNetworkStore.setState({ online: true, connected: true });
|
||||||
|
await useOutboxStore.getState().flush();
|
||||||
|
expect(runQueuedSend).toHaveBeenCalledTimes(1);
|
||||||
|
// Marked before the request left.
|
||||||
|
expect(runQueuedSend.mock.calls[0][0]).toMatchObject({ status: 'sending', maybeSent: true });
|
||||||
|
expect(useOutboxStore.getState().sends).toEqual([]);
|
||||||
|
expect(JSON.parse((await AsyncStorage.getItem(SENDS_KEY))!)).toEqual([]);
|
||||||
|
|
||||||
|
await useOutboxStore.getState().flush();
|
||||||
|
expect(runQueuedSend).toHaveBeenCalledTimes(1);
|
||||||
|
await flushed();
|
||||||
|
// The original gets its reply arrow.
|
||||||
|
expect(patchKeywordsForEmails).toHaveBeenCalledWith(['orig-1'], { $answered: true }, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a restart of the app', async () => {
|
||||||
|
useOutboxStore.getState().enqueueSend(payload('kept'));
|
||||||
|
await flushed();
|
||||||
|
await useOutboxStore.getState().setAccount(null);
|
||||||
|
expect(useOutboxStore.getState().sends).toEqual([]);
|
||||||
|
await useOutboxStore.getState().setAccount(ACCOUNT);
|
||||||
|
expect(useOutboxStore.getState().sends.map((s) => s.payload.outgoing.subject)).toEqual(['kept']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a message whose attempt the app was killed in, so the next attempt looks for it first', async () => {
|
||||||
|
const id = useOutboxStore.getState().enqueueSend(payload())!;
|
||||||
|
await flushed();
|
||||||
|
// What the store persisted right before the request left.
|
||||||
|
const stored = JSON.parse((await AsyncStorage.getItem(SENDS_KEY))!);
|
||||||
|
stored[0] = { ...stored[0], status: 'sending', maybeSent: true };
|
||||||
|
await AsyncStorage.setItem(SENDS_KEY, JSON.stringify(stored));
|
||||||
|
await useOutboxStore.getState().setAccount(null);
|
||||||
|
await useOutboxStore.getState().setAccount(ACCOUNT);
|
||||||
|
expect(useOutboxStore.getState().sends[0]).toMatchObject({ id, status: 'queued', maybeSent: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a message queued and marked when the connection breaks mid-send', async () => {
|
||||||
|
useNetworkStore.setState({ online: true, connected: true });
|
||||||
|
runQueuedSend.mockRejectedValueOnce(named('NetworkError', 'Network request failed'));
|
||||||
|
useOutboxStore.getState().enqueueSend(payload());
|
||||||
|
await flushed();
|
||||||
|
await flushed();
|
||||||
|
expect(useOutboxStore.getState().sends[0]).toMatchObject({ status: 'queued', maybeSent: true, attempts: 0 });
|
||||||
|
|
||||||
|
// The retry hands the mark on: runQueuedSend looks for the message first.
|
||||||
|
useOutboxStore.setState({ flushing: false });
|
||||||
|
await useOutboxStore.getState().flush();
|
||||||
|
expect(runQueuedSend).toHaveBeenCalledTimes(2);
|
||||||
|
expect(useOutboxStore.getState().sends).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never sends one message twice from overlapping flushes', async () => {
|
||||||
|
useOutboxStore.getState().enqueueSend(payload());
|
||||||
|
let release!: () => void;
|
||||||
|
runQueuedSend.mockImplementationOnce(() => new Promise((r) => { release = () => r({ kind: 'sent', result: {} }); }));
|
||||||
|
useNetworkStore.setState({ online: true, connected: true });
|
||||||
|
const first = useOutboxStore.getState().flush();
|
||||||
|
await flushed();
|
||||||
|
const second = useOutboxStore.getState().flush();
|
||||||
|
release();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
expect(runQueuedSend).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parks a refused message with its reason and leaves it until the user tries again', async () => {
|
||||||
|
useNetworkStore.setState({ online: true, connected: true });
|
||||||
|
runQueuedSend.mockRejectedValueOnce(new Error('Invalid envelope'));
|
||||||
|
const id = useOutboxStore.getState().enqueueSend(payload())!;
|
||||||
|
await flushed();
|
||||||
|
await flushed();
|
||||||
|
expect(useOutboxStore.getState().sends[0]).toMatchObject({ status: 'failed', lastError: 'Invalid envelope', maybeSent: false });
|
||||||
|
await useOutboxStore.getState().flush();
|
||||||
|
expect(runQueuedSend).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await useOutboxStore.getState().retrySend(id);
|
||||||
|
expect(runQueuedSend).toHaveBeenCalledTimes(2);
|
||||||
|
expect(useOutboxStore.getState().sends).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops after an expired session without failing the message', async () => {
|
||||||
|
useNetworkStore.setState({ online: true, connected: true });
|
||||||
|
runQueuedSend.mockRejectedValueOnce(named('AuthenticationError', 'Session expired'));
|
||||||
|
useOutboxStore.getState().enqueueSend(payload());
|
||||||
|
await flushed();
|
||||||
|
await flushed();
|
||||||
|
expect(useOutboxStore.getState().sends[0]).toMatchObject({ status: 'queued', maybeSent: false });
|
||||||
|
expect(useOutboxStore.getState().paused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the user discard a waiting message, but not one on its way', async () => {
|
||||||
|
const id = useOutboxStore.getState().enqueueSend(payload())!;
|
||||||
|
const sending = { ...useOutboxStore.getState().sends[0], status: 'sending' as const };
|
||||||
|
useOutboxStore.setState({ sends: [sending] });
|
||||||
|
expect(await useOutboxStore.getState().cancelSend(id)).toBe(false);
|
||||||
|
useOutboxStore.setState({ sends: [{ ...sending, status: 'queued' }] });
|
||||||
|
expect(await useOutboxStore.getState().cancelSend(id)).toBe(true);
|
||||||
|
expect(useOutboxStore.getState().sends).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a message to Drafts in place of its autosaved version, and keeps it when that fails', async () => {
|
||||||
|
const id = useOutboxStore.getState().enqueueSend(payload('to-drafts'))!;
|
||||||
|
expect(await useOutboxStore.getState().cancelSend(id, { keepAsDraft: true })).toBe(true);
|
||||||
|
expect(saveDraftVersion).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ subject: 'to-drafts' }), 'drafts', 'draft-1', undefined, undefined,
|
||||||
|
);
|
||||||
|
expect(useOutboxStore.getState().sends).toEqual([]);
|
||||||
|
|
||||||
|
saveDraftVersion.mockRejectedValueOnce(named('NetworkError', 'Network request failed'));
|
||||||
|
const again = useOutboxStore.getState().enqueueSend(payload('stays'))!;
|
||||||
|
expect(await useOutboxStore.getState().cancelSend(again, { keepAsDraft: true })).toBe(false);
|
||||||
|
expect(useOutboxStore.getState().sends[0]).toMatchObject({ id: again, status: 'failed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the queued messages with the rest when the server behind the account changed', async () => {
|
||||||
|
useOutboxStore.getState().enqueueSend(payload());
|
||||||
|
expect(await useOutboxStore.getState().clearAccount(ACCOUNT)).toBe(1);
|
||||||
|
expect(await AsyncStorage.getItem(SENDS_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+228
-10
@@ -19,6 +19,11 @@
|
|||||||
// replayed by flush() once connectivity returns, and not before the auth store
|
// replayed by flush() once connectivity returns, and not before the auth store
|
||||||
// has resumed the account (see `resumed`). Storage is keyed per account,
|
// has resumed the account (see `resumed`). Storage is keyed per account,
|
||||||
// mirroring offline-cache-store, so queues never leak between logins.
|
// mirroring offline-cache-store, so queues never leak between logins.
|
||||||
|
//
|
||||||
|
// Messages sent while offline wait here too (`sends`, see lib/send-queue.ts):
|
||||||
|
// the same flush sends them after the mutation ops, once each, looking for
|
||||||
|
// a copy of the message first whenever an earlier attempt may have got
|
||||||
|
// through.
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
@@ -32,9 +37,14 @@ import {
|
|||||||
} from '../api/email';
|
} from '../api/email';
|
||||||
import type { KeywordPatch } from '../lib/keyword-patch';
|
import type { KeywordPatch } from '../lib/keyword-patch';
|
||||||
import type { ArchiveMode } from './settings-store';
|
import type { ArchiveMode } from './settings-store';
|
||||||
|
import {
|
||||||
|
afterFailure, canCancel, classifySendFailure, newQueuedSend, restoreAfterRestart, startAttempt,
|
||||||
|
type QueuedSend, type QueuedSendPayload,
|
||||||
|
} from '../lib/send-queue';
|
||||||
|
|
||||||
const KEY_PREFIX = 'webmail:outbox:v1:';
|
const KEY_PREFIX = 'webmail:outbox:v1:';
|
||||||
const FAILED_SUFFIX = ':failed';
|
const FAILED_SUFFIX = ':failed';
|
||||||
|
const SENDS_SUFFIX = ':sends';
|
||||||
// Give up on an op that the server keeps rejecting (a non-transient failure)
|
// Give up on an op that the server keeps rejecting (a non-transient failure)
|
||||||
// after this many attempts so one poison entry can't wedge the whole queue.
|
// after this many attempts so one poison entry can't wedge the whole queue.
|
||||||
const MAX_ATTEMPTS = 5;
|
const MAX_ATTEMPTS = 5;
|
||||||
@@ -86,6 +96,12 @@ interface OutboxState {
|
|||||||
* op lands here so the optimistic edit is reverted.
|
* op lands here so the optimistic edit is reverted.
|
||||||
*/
|
*/
|
||||||
failed: OutboxEntry[];
|
failed: OutboxEntry[];
|
||||||
|
/**
|
||||||
|
* Messages sent while offline, oldest first: waiting, on their way, or
|
||||||
|
* failed (kept until the user retries, moves them to Drafts or discards
|
||||||
|
* them).
|
||||||
|
*/
|
||||||
|
sends: QueuedSend[];
|
||||||
hydrated: boolean;
|
hydrated: boolean;
|
||||||
flushing: boolean;
|
flushing: boolean;
|
||||||
/** Set after an authentication failure; cleared by setAccount()/resume(). */
|
/** Set after an authentication failure; cleared by setAccount()/resume(). */
|
||||||
@@ -120,6 +136,20 @@ interface OutboxState {
|
|||||||
flush: () => Promise<void>;
|
flush: () => Promise<void>;
|
||||||
/** Move every failed op back into the queue and flush. */
|
/** Move every failed op back into the queue and flush. */
|
||||||
retryFailed: () => Promise<void>;
|
retryFailed: () => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Queue a message to send once online. `maybeSent`: an attempt already
|
||||||
|
* went out without an answer. Returns the entry id, or null with no
|
||||||
|
* account attached.
|
||||||
|
*/
|
||||||
|
enqueueSend: (payload: QueuedSendPayload, opts?: { maybeSent?: boolean }) => string | null;
|
||||||
|
/** Try a failed message again. */
|
||||||
|
retrySend: (id: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Take a message out of the queue, not while it is on its way. With
|
||||||
|
* `keepAsDraft` it is saved to Drafts first (needs the server); it stays
|
||||||
|
* queued, as failed, when that fails. Resolves to whether it went.
|
||||||
|
*/
|
||||||
|
cancelSend: (id: string, opts?: { keepAsDraft?: boolean }) => Promise<boolean>;
|
||||||
discardFailed: () => void;
|
discardFailed: () => void;
|
||||||
clear: () => Promise<void>;
|
clear: () => Promise<void>;
|
||||||
/**
|
/**
|
||||||
@@ -136,6 +166,23 @@ function persist(accountId: string, entries: OutboxEntry[]): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistSends(accountId: string, sends: QueuedSend[]): void {
|
||||||
|
void AsyncStorage.setItem(storageKey(accountId) + SENDS_SUFFIX, JSON.stringify(sends)).catch((err) => {
|
||||||
|
console.warn('[outbox] persist sends failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSends(accountId: string): Promise<QueuedSend[]> {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(storageKey(accountId) + SENDS_SUFFIX);
|
||||||
|
const parsed = raw ? JSON.parse(raw) : [];
|
||||||
|
return Array.isArray(parsed) ? restoreAfterRestart(parsed as QueuedSend[]) : [];
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[outbox] hydrate sends failed', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function persistFailed(accountId: string, failed: OutboxEntry[]): void {
|
function persistFailed(accountId: string, failed: OutboxEntry[]): void {
|
||||||
void AsyncStorage.setItem(storageKey(accountId) + FAILED_SUFFIX, JSON.stringify(failed)).catch((err) => {
|
void AsyncStorage.setItem(storageKey(accountId) + FAILED_SUFFIX, JSON.stringify(failed)).catch((err) => {
|
||||||
console.warn('[outbox] persist failed-list failed', err);
|
console.warn('[outbox] persist failed-list failed', err);
|
||||||
@@ -264,7 +311,9 @@ function loadBucket(accountId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
const epoch = ++loadEpoch;
|
const epoch = ++loadEpoch;
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]);
|
const [entries, failed, sends] = await Promise.all([
|
||||||
|
load(accountId), load(accountId, FAILED_SUFFIX), loadSends(accountId),
|
||||||
|
]);
|
||||||
const store = useOutboxStore.getState();
|
const store = useOutboxStore.getState();
|
||||||
if (epoch !== loadEpoch || store.activeAccountId !== accountId) return;
|
if (epoch !== loadEpoch || store.activeAccountId !== accountId) return;
|
||||||
// Ops the user made while the bucket was loading go after the stored
|
// Ops the user made while the bucket was loading go after the stored
|
||||||
@@ -272,8 +321,11 @@ function loadBucket(accountId: string): Promise<void> {
|
|||||||
// replaced it with only the new ops.
|
// replaced it with only the new ops.
|
||||||
const early = store.entries;
|
const early = store.entries;
|
||||||
const merged = early.length > 0 ? [...entries, ...early] : entries;
|
const merged = early.length > 0 ? [...entries, ...early] : entries;
|
||||||
useOutboxStore.setState({ entries: merged, failed, hydrated: true });
|
const earlySends = store.sends;
|
||||||
|
const mergedSends = earlySends.length > 0 ? [...sends, ...earlySends] : sends;
|
||||||
|
useOutboxStore.setState({ entries: merged, failed, sends: mergedSends, hydrated: true });
|
||||||
if (early.length > 0) persist(accountId, merged);
|
if (early.length > 0) persist(accountId, merged);
|
||||||
|
if (earlySends.length > 0) persistSends(accountId, mergedSends);
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
if (bucketLoad?.promise === promise) bucketLoad = null;
|
if (bucketLoad?.promise === promise) bucketLoad = null;
|
||||||
});
|
});
|
||||||
@@ -285,6 +337,7 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
|
|||||||
activeAccountId: null,
|
activeAccountId: null,
|
||||||
entries: [],
|
entries: [],
|
||||||
failed: [],
|
failed: [],
|
||||||
|
sends: [],
|
||||||
hydrated: false,
|
hydrated: false,
|
||||||
flushing: false,
|
flushing: false,
|
||||||
paused: false,
|
paused: false,
|
||||||
@@ -301,7 +354,7 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
|
|||||||
retryAttempt = 0;
|
retryAttempt = 0;
|
||||||
// Each attach reads the bucket afresh; a read from before is dropped.
|
// Each attach reads the bucket afresh; a read from before is dropped.
|
||||||
loadEpoch += 1;
|
loadEpoch += 1;
|
||||||
set({ activeAccountId: accountId, entries: [], failed: [], hydrated: false, paused: false, resumed: false });
|
set({ activeAccountId: accountId, entries: [], failed: [], sends: [], hydrated: false, paused: false, resumed: false });
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
await loadBucket(accountId);
|
await loadBucket(accountId);
|
||||||
} else {
|
} else {
|
||||||
@@ -386,7 +439,7 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
|
|||||||
if (get().flushing || get().paused || !get().resumed || !get().hydrated) return;
|
if (get().flushing || get().paused || !get().resumed || !get().hydrated) return;
|
||||||
const accountId = get().activeAccountId;
|
const accountId = get().activeAccountId;
|
||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
if (get().entries.length === 0) return;
|
if (get().entries.length === 0 && !hasWaitingSends()) return;
|
||||||
// Need a live client that's actually serving this account, and a network.
|
// Need a live client that's actually serving this account, and a network.
|
||||||
if (!clientServes(accountId)) return;
|
if (!clientServes(accountId)) return;
|
||||||
if (!useNetworkStore.getState().online) return;
|
if (!useNetworkStore.getState().online) return;
|
||||||
@@ -446,10 +499,16 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Queued messages go after the mutation ops, and only while the
|
||||||
|
// connection holds.
|
||||||
|
if (!brokeTransient && !get().paused && hasWaitingSends()) {
|
||||||
|
const result = await flushSends(accountId);
|
||||||
|
if (result === 'transient') brokeTransient = true;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
set({ flushing: false });
|
set({ flushing: false });
|
||||||
}
|
}
|
||||||
if (brokeTransient && get().entries.length > 0) scheduleRetry();
|
if (brokeTransient && (get().entries.length > 0 || hasWaitingSends())) scheduleRetry();
|
||||||
else if (changedInFlight) void get().flush();
|
else if (changedInFlight) void get().flush();
|
||||||
if (dropped) void refreshAfterDrop();
|
if (dropped) void refreshAfterDrop();
|
||||||
},
|
},
|
||||||
@@ -469,6 +528,56 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
|
|||||||
await get().flush();
|
await get().flush();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
enqueueSend: (payload, opts) => {
|
||||||
|
const accountId = get().activeAccountId;
|
||||||
|
if (!accountId) {
|
||||||
|
console.warn('[outbox] enqueueSend with no active account');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const send = newQueuedSend(generateUUID(), payload, Date.now(), !!opts?.maybeSent);
|
||||||
|
const sends = [...get().sends, send];
|
||||||
|
set({ sends });
|
||||||
|
persistSends(accountId, sends);
|
||||||
|
void get().flush();
|
||||||
|
return send.id;
|
||||||
|
},
|
||||||
|
|
||||||
|
retrySend: async (id) => {
|
||||||
|
const accountId = get().activeAccountId;
|
||||||
|
const send = get().sends.find((s) => s.id === id);
|
||||||
|
if (!accountId || !send || send.status !== 'failed') return;
|
||||||
|
// The attempts start over; an unconfirmed one keeps its mark, so the
|
||||||
|
// message is looked for first.
|
||||||
|
updateSend(accountId, { ...send, status: 'queued', attempts: 0, lastError: undefined });
|
||||||
|
set({ paused: false });
|
||||||
|
await get().flush();
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelSend: async (id, opts) => {
|
||||||
|
const accountId = get().activeAccountId;
|
||||||
|
const send = get().sends.find((s) => s.id === id);
|
||||||
|
if (!accountId || !send || !canCancel(send)) return false;
|
||||||
|
// Out of the queue first, so a flush can't send it while it is saved.
|
||||||
|
removeSend(accountId, id);
|
||||||
|
if (!opts?.keepAsDraft) return true;
|
||||||
|
const p = send.payload;
|
||||||
|
try {
|
||||||
|
if (!p.draftsMailboxId) throw new Error('No Drafts folder');
|
||||||
|
const { saveDraftVersion } = await import('../api/email');
|
||||||
|
await saveDraftVersion(p.outgoing, p.draftsMailboxId, p.draftId, undefined, p.draftAccountId);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (get().activeAccountId === accountId) {
|
||||||
|
const sends = [...get().sends, { ...send, status: 'failed' as const, lastError: message }]
|
||||||
|
.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
set({ sends });
|
||||||
|
persistSends(accountId, sends);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
discardFailed: () => {
|
discardFailed: () => {
|
||||||
const accountId = get().activeAccountId;
|
const accountId = get().activeAccountId;
|
||||||
set({ failed: [] });
|
set({ failed: [] });
|
||||||
@@ -478,29 +587,33 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
|
|||||||
clear: async () => {
|
clear: async () => {
|
||||||
const accountId = get().activeAccountId;
|
const accountId = get().activeAccountId;
|
||||||
clearRetry();
|
clearRetry();
|
||||||
set({ entries: [], failed: [] });
|
set({ entries: [], failed: [], sends: [] });
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined);
|
await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined);
|
||||||
await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined);
|
await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined);
|
||||||
|
await AsyncStorage.removeItem(storageKey(accountId) + SENDS_SUFFIX).catch(() => undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAccount: async (accountId) => {
|
clearAccount: async (accountId) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const loaded = state.activeAccountId === accountId && state.hydrated;
|
const loaded = state.activeAccountId === accountId && state.hydrated;
|
||||||
let dropped = loaded ? state.entries.length + state.failed.length : 0;
|
let dropped = loaded ? state.entries.length + state.failed.length + state.sends.length : 0;
|
||||||
if (state.activeAccountId === accountId) {
|
if (state.activeAccountId === accountId) {
|
||||||
clearRetry();
|
clearRetry();
|
||||||
// A read of the bucket still under way must not bring the ops back.
|
// A read of the bucket still under way must not bring the ops back.
|
||||||
loadEpoch += 1;
|
loadEpoch += 1;
|
||||||
set({ entries: [], failed: [], hydrated: true });
|
set({ entries: [], failed: [], sends: [], hydrated: true });
|
||||||
}
|
}
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]);
|
const [entries, failed, sends] = await Promise.all([
|
||||||
dropped = entries.length + failed.length;
|
load(accountId), load(accountId, FAILED_SUFFIX), loadSends(accountId),
|
||||||
|
]);
|
||||||
|
dropped = entries.length + failed.length + sends.length;
|
||||||
}
|
}
|
||||||
await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined);
|
await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined);
|
||||||
await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined);
|
await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined);
|
||||||
|
await AsyncStorage.removeItem(storageKey(accountId) + SENDS_SUFFIX).catch(() => undefined);
|
||||||
return dropped;
|
return dropped;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -523,6 +636,111 @@ function removeEntry(accountId: string, id: string, sent?: OutboxOp): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Queued messages ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function hasWaitingSends(): boolean {
|
||||||
|
return useOutboxStore.getState().sends.some((s) => s.status === 'queued');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSend(accountId: string, next: QueuedSend): void {
|
||||||
|
const store = useOutboxStore.getState();
|
||||||
|
if (store.activeAccountId !== accountId) return;
|
||||||
|
if (!store.sends.some((s) => s.id === next.id)) return;
|
||||||
|
const sends = store.sends.map((s) => (s.id === next.id ? next : s));
|
||||||
|
useOutboxStore.setState({ sends });
|
||||||
|
persistSends(accountId, sends);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSend(accountId: string, id: string): void {
|
||||||
|
const store = useOutboxStore.getState();
|
||||||
|
if (store.activeAccountId !== accountId) return;
|
||||||
|
const sends = store.sends.filter((s) => s.id !== id);
|
||||||
|
useOutboxStore.setState({ sends });
|
||||||
|
persistSends(accountId, sends);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the waiting messages, oldest first. 'transient' when the connection
|
||||||
|
* broke (or an answer never came), so the flush retries with back-off.
|
||||||
|
*/
|
||||||
|
async function flushSends(accountId: string): Promise<'done' | 'transient'> {
|
||||||
|
const waiting = useOutboxStore.getState().sends
|
||||||
|
.filter((s) => s.status === 'queued')
|
||||||
|
.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
if (waiting.length === 0) return 'done';
|
||||||
|
const [{ runQueuedSend }, { useEmailStore }] = await Promise.all([
|
||||||
|
import('../api/queued-send'),
|
||||||
|
import('./email-store'),
|
||||||
|
]);
|
||||||
|
for (const snapshot of waiting) {
|
||||||
|
const store = useOutboxStore.getState();
|
||||||
|
if (store.activeAccountId !== accountId || !store.resumed || store.paused) break;
|
||||||
|
if (!useNetworkStore.getState().online || !clientServes(accountId)) break;
|
||||||
|
const current = store.sends.find((s) => s.id === snapshot.id);
|
||||||
|
if (!current || current.status !== 'queued') continue; // cancelled or retried meanwhile
|
||||||
|
|
||||||
|
// Marked before the request leaves: from here on it may reach the server.
|
||||||
|
const started = startAttempt(current);
|
||||||
|
updateSend(accountId, started);
|
||||||
|
try {
|
||||||
|
const outcome = await runQueuedSend(started, useEmailStore.getState().mailboxes);
|
||||||
|
removeSend(accountId, current.id);
|
||||||
|
retryAttempt = 0;
|
||||||
|
void afterQueuedSend(started, outcome);
|
||||||
|
} catch (err) {
|
||||||
|
const kind = classifySendFailure(err, isTransientNetworkError);
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
updateSend(accountId, afterFailure(started, kind, message));
|
||||||
|
if (kind === 'auth') {
|
||||||
|
useOutboxStore.setState({ paused: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (kind === 'offline' || kind === 'unconfirmed') return 'transient';
|
||||||
|
// Refused: this one waits for the user; the others go on.
|
||||||
|
void notifyQueuedSendFailed(started, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'done';
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueuedSendOutcomeLike = { kind: 'sent'; result: { refusedRecipients?: Array<{ email: string }> } } | { kind: 'alreadySent' };
|
||||||
|
|
||||||
|
// Flag the original and say the message went. Lazy imports: these pull in
|
||||||
|
// the UI stores, which the queue itself doesn't need.
|
||||||
|
async function afterQueuedSend(send: QueuedSend, outcome: QueuedSendOutcomeLike): Promise<void> {
|
||||||
|
const original = send.payload.original;
|
||||||
|
if (original) {
|
||||||
|
patchKeywordsForEmails([original.emailId], { [original.keyword]: true }, original.accountId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [{ toast }, { useLocaleStore }, { partlyRefusedWarning }] = await Promise.all([
|
||||||
|
import('./toast-store'), import('./locale-store'), import('../lib/send-failure-message'),
|
||||||
|
]);
|
||||||
|
const t = useLocaleStore.getState().t;
|
||||||
|
const refused = outcome.kind === 'sent' ? outcome.result.refusedRecipients ?? [] : [];
|
||||||
|
if (refused.length > 0) {
|
||||||
|
const { title, message } = partlyRefusedWarning(refused as Parameters<typeof partlyRefusedWarning>[0], t);
|
||||||
|
toast.warning(title, { message, duration: 10_000 });
|
||||||
|
} else {
|
||||||
|
toast.success(t('outbox.sent', 'Message from the Outbox sent'), { message: send.payload.outgoing.subject || undefined });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[outbox] could not report a queued send', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyQueuedSendFailed(send: QueuedSend, message: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [{ toast }, { useLocaleStore }] = await Promise.all([import('./toast-store'), import('./locale-store')]);
|
||||||
|
const t = useLocaleStore.getState().t;
|
||||||
|
toast.error(t('outbox.send_failed', 'A message in the Outbox could not be sent'), {
|
||||||
|
message: [send.payload.outgoing.subject, message].filter(Boolean).join(': '),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[outbox] could not report a failed queued send', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function moveToFailed(accountId: string, id: string, err: unknown): void {
|
function moveToFailed(accountId: string, id: string, err: unknown): void {
|
||||||
const store = useOutboxStore.getState();
|
const store = useOutboxStore.getState();
|
||||||
if (store.activeAccountId !== accountId) return;
|
if (store.activeAccountId !== accountId) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user