diff --git a/src/api/__tests__/email-submission.test.ts b/src/api/__tests__/email-submission.test.ts index b3d65d19..7511d898 100644 --- a/src/api/__tests__/email-submission.test.ts +++ b/src/api/__tests__/email-submission.test.ts @@ -1,10 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('../jmap-client', () => ({ jmapClient: { accountId: 'acc-1', request: vi.fn(), learnHoldLimit: vi.fn(), + getSubmissionAccountIds: vi.fn(() => ['acc-1']), + hasDelayedSend: vi.fn(() => true), getMaxCallsInRequest: vi.fn(() => 16), getMaxObjectsInGet: vi.fn(() => 500), getMaxObjectsInSet: vi.fn(() => 500), @@ -12,7 +14,7 @@ vi.mock('../jmap-client', () => ({ })); import { jmapClient } from '../jmap-client'; -import { rescheduleScheduledSend, sendEmail } from '../email'; +import { cancelScheduledSend, listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email'; import { ScheduleTooLateError } from '../jmap-result'; const mockRequest = jmapClient.request as ReturnType; @@ -223,3 +225,89 @@ describe('hold-limit rejections', () => { expect(jmapClient.learnHoldLimit).toHaveBeenCalledWith(172_800); }); }); + +describe('scheduled sends in shared accounts (webmail #874)', () => { + const FUTURE = new Date(Date.now() + 3600_000).toISOString(); + const LATER = new Date(Date.now() + 7200_000).toISOString(); + const byAccount: Record = {}; + + beforeEach(() => { + (jmapClient.getSubmissionAccountIds as ReturnType).mockReturnValue(['acc-1', 'grp-1', 'grp-2']); + // grp-2 advertises submission but can't hold mail. + (jmapClient.hasDelayedSend as ReturnType).mockImplementation((id: string) => id !== 'grp-2'); + byAccount['acc-1'] = { + submissions: [{ id: 's-own', emailId: 'e-own', identityId: 'i-own', sendAt: LATER, undoStatus: 'pending' }], + emails: [{ id: 'e-own', subject: 'Own' }], + }; + byAccount['grp-1'] = { + submissions: [ + { id: 's-grp', emailId: 'e-grp', identityId: 'i-grp', sendAt: FUTURE, undoStatus: 'pending' }, + { id: 's-done', emailId: 'e-done', identityId: 'i-grp', sendAt: FUTURE, undoStatus: 'final' }, + ], + emails: [{ id: 'e-grp', subject: 'Team' }], + }; + mockRequest.mockImplementation(async (calls: Array<[string, { accountId: string }, string]>) => { + const [method, args] = calls[0]; + const data = byAccount[args.accountId]; + if (data instanceof Error) throw data; + if (!data) throw new Error(`unexpected account ${args.accountId}`); + if (method === 'EmailSubmission/query') { + return { methodResponses: [[method, { ids: (data.submissions as Array<{ id: string }>).map((s) => s.id) }, '0']] }; + } + if (method === 'EmailSubmission/get') return { methodResponses: [[method, { list: data.submissions }, '0']] }; + if (method === 'Email/get') return { methodResponses: [[method, { list: data.emails }, '0']] }; + throw new Error(`unexpected ${method}`); + }); + }); + + afterEach(() => { + mockRequest.mockReset(); + (jmapClient.getSubmissionAccountIds as ReturnType).mockReturnValue(['acc-1']); + (jmapClient.hasDelayedSend as ReturnType).mockReturnValue(true); + }); + + it('lists the pending sends of every account that can hold mail, tagged with their account', async () => { + const list = await listScheduledEmails(); + + expect(list.map((s) => [s.accountId, s.emailSubmissionId, s.subject])).toEqual([ + ['grp-1', 's-grp', 'Team'], + ['acc-1', 's-own', 'Own'], + ]); + const asked = mockRequest.mock.calls.map((c) => c[0][0][1].accountId); + expect(asked).not.toContain('grp-2'); + }); + + it("keeps the user's own scheduled sends when a shared account fails", async () => { + byAccount['grp-1'] = new Error('forbidden'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const list = await listScheduledEmails(); + + expect(list.map((s) => s.emailSubmissionId)).toEqual(['s-own']); + warn.mockRestore(); + }); + + it('still fails when the primary account fails', async () => { + byAccount['acc-1'] = new Error('server down'); + + await expect(listScheduledEmails()).rejects.toThrow('server down'); + }); + + it('cancels and reschedules in the account holding the submission', async () => { + mockRequest.mockReset(); + mockRequest.mockResolvedValueOnce({ + methodResponses: [['EmailSubmission/set', { updated: { 's-grp': null } }, '0']], + }); + await cancelScheduledSend('s-grp', 'grp-1'); + expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('grp-1'); + + mockRequest.mockResolvedValueOnce({ + methodResponses: [['EmailSubmission/set', { + updated: { 's-grp': null }, + created: { replacement: { id: 's-new', sendAt: FUTURE } }, + }, '0']], + }); + await rescheduleScheduledSend({ ...SCHEDULED, emailSubmissionId: 's-grp', accountId: 'grp-1' }, 0); + expect(mockRequest.mock.calls[1][0][0][1].accountId).toBe('grp-1'); + }); +}); diff --git a/src/api/__tests__/jmap-client.test.ts b/src/api/__tests__/jmap-client.test.ts index 3f9988d3..dc9b445e 100644 --- a/src/api/__tests__/jmap-client.test.ts +++ b/src/api/__tests__/jmap-client.test.ts @@ -352,6 +352,13 @@ describe('JMAPClient', () => { expect(client.getMaxDelayedSend()).toBe(0); }); + it('lists every account that can hold submissions, primary first (webmail #874)', async () => { + global.fetch = mockFetch([{ status: 200, json: STALWART_SESSION }]) as any; + await client.connect('https://mail.example.com', 'user', 'pass'); + + expect(client.getSubmissionAccountIds()).toEqual(['acc-1', 'shared-no-hold']); + }); + describe('hold limit', () => { const withSubmission = (maxDelayedSend: number, stalwart: boolean): JMAPSession => ({ ...STALWART_SESSION, diff --git a/src/api/email.ts b/src/api/email.ts index 1897c3ea..e3998caf 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -1331,6 +1331,8 @@ export async function createDraft( } export interface ScheduledEmail { + /** Account holding the submission: a shared account for a send from its identity. */ + accountId: string; emailSubmissionId: string; emailId: string; identityId: string; @@ -1345,10 +1347,27 @@ export interface ScheduledEmail { // List pending (not-yet-delivered, not cancelled) scheduled submissions whose // send time is still in the future, joined with a light Email/get so the UI -// can show subject/recipients. Empty when the server lacks FUTURERELEASE. +// can show subject/recipients. Covers every account that can hold mail: a +// send from a shared identity is held in the shared account, where a +// primary-only lookup never found it, so it could not be cancelled (webmail +// #874). Empty when no account supports FUTURERELEASE. export async function listScheduledEmails(): Promise { - if (!jmapClient.hasDelayedSend()) return []; - const accountId = jmapClient.accountId; + const accountIds = jmapClient.getSubmissionAccountIds().filter((id) => jmapClient.hasDelayedSend(id)); + const out: ScheduledEmail[] = []; + for (const accountId of accountIds) { + try { + out.push(...(await listScheduledEmailsIn(accountId))); + } catch (err) { + // A shared account that can't answer (revoked access, server error) + // must not hide the user's own scheduled mail. + if (accountId === jmapClient.accountId) throw err; + console.warn(`[email] scheduled sends of account ${accountId} unavailable:`, err); + } + } + return out.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()); +} + +async function listScheduledEmailsIn(accountId: string): Promise { const now = Date.now(); const queryRes = await jmapClient.request( @@ -1406,6 +1425,7 @@ export async function listScheduledEmails(): Promise { const e = emailById.get(s.emailId); if (!s.sendAt) return null; return { + accountId, emailSubmissionId: s.id, emailId: s.emailId, identityId: s.identityId, @@ -1418,14 +1438,14 @@ export async function listScheduledEmails(): Promise { preview: e?.preview, }; }) - .filter((s): s is ScheduledEmail => s !== null) - .sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()); + .filter((s): s is ScheduledEmail => s !== null); } // Cancel a pending scheduled send. The held message copy stays in Sent; only -// delivery is stopped (matches the webmail behaviour). -export async function cancelScheduledSend(emailSubmissionId: string): Promise { - const accountId = jmapClient.accountId; +// delivery is stopped (matches the webmail behaviour). `accountIdOverride` is +// the account holding the submission (see `ScheduledEmail.accountId`). +export async function cancelScheduledSend(emailSubmissionId: string, accountIdOverride?: string): Promise { + const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request( [['EmailSubmission/set', { accountId, @@ -1449,11 +1469,19 @@ export async function cancelScheduledSend(emailSubmissionId: string): Promise { - const accountId = jmapClient.accountId; + const accountId = scheduled.accountId ?? jmapClient.accountId; const create: Record = { emailId: scheduled.emailId, identityId: scheduled.identityId }; if (holdForSeconds > 0) { // Reuse the held submission's envelope: it names every recipient, diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 73823a36..af923ff7 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -1043,6 +1043,23 @@ export class JMAPClient { return this.session?.primaryAccounts?.[CAPABILITIES.SUBMISSION] || id; } + /** + * Every account whose scheduled sends this session can see: the primary + * submission account plus each shared/group account that advertises + * submission itself. A send from a shared identity is held in that + * account, so a lookup in the primary one alone never found it (webmail + * #874). + */ + getSubmissionAccountIds(): string[] { + const ids: string[] = []; + const primary = this.submissionAccountId(); + if (primary) ids.push(primary); + for (const [id, account] of Object.entries(this.session?.accounts ?? {})) { + if (!ids.includes(id) && account?.accountCapabilities?.[CAPABILITIES.SUBMISSION]) ids.push(id); + } + return ids; + } + private submissionCapability(accountId?: string): | { maxDelayedSend?: number; submissionExtensions?: unknown } | undefined { diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index 3602b217..5b055168 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -104,7 +104,7 @@ export default function ScheduledScreen({ navigation }: Props) { style: 'destructive', onPress: () => { void runAction(item, async () => { - await cancelScheduledSend(item.emailSubmissionId); + await cancelScheduledSend(item.emailSubmissionId, item.accountId); if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) { useSendUndoStore.getState().clear(); } @@ -135,7 +135,7 @@ export default function ScheduledScreen({ navigation }: Props) { ); return; } - const max = jmapClient.getMaxDelayedSend(); + const max = jmapClient.getMaxDelayedSend(item.accountId); if (max > 0 && seconds > max) { Alert.alert( t('email_composer.schedule_too_late_title', 'Too far ahead'), @@ -159,17 +159,28 @@ export default function ScheduledScreen({ navigation }: Props) { // the composer (webmail cancelScheduledEmailForEdit). const onEdit = (item: ScheduledEmail) => { void runAction(item, async () => { - await cancelScheduledSend(item.emailSubmissionId); + await cancelScheduledSend(item.emailSubmissionId, item.accountId); if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) { useSendUndoStore.getState().clear(); } - const own = ownMailboxes(mailboxes); - const drafts = own.find((m) => m.role === 'drafts'); - const sent = own.find((m) => m.role === 'sent'); - if (drafts) await restoreEmailToDraft(item.emailId, drafts.id, sent?.id); - const email = await getFullEmail(item.emailId); + // A message held in a shared account goes back to that account's Drafts. + const shared = item.accountId !== jmapClient.accountId; + const scope = shared + ? mailboxes.filter((m) => m.isShared && m.accountId === item.accountId) + : ownMailboxes(mailboxes); + const drafts = scope.find((m) => m.role === 'drafts'); + const sent = scope.find((m) => m.role === 'sent'); + if (drafts) { + await restoreEmailToDraft( + item.emailId, + drafts.originalId ?? drafts.id, + sent ? (sent.originalId ?? sent.id) : undefined, + item.accountId, + ); + } + const email = await getFullEmail(item.emailId, item.accountId); removeItem(item.emailSubmissionId); - navigation.replace('Compose', { draft: draftContextFromEmail(email) }); + navigation.replace('Compose', { draft: draftContextFromEmail(email, shared ? item.accountId : undefined) }); }, t('scheduled.edit_failed', 'Could not open the message for editing')); }; @@ -180,7 +191,7 @@ export default function ScheduledScreen({ navigation }: Props) { tomorrowMorning.setDate(tomorrowMorning.getDate() + 1); tomorrowMorning.setHours(8, 0, 0, 0); // Only offer times within the server's hold limit. - const maxMs = jmapClient.getMaxDelayedSend() * 1000; + const maxMs = jmapClient.getMaxDelayedSend(rescheduleFor?.accountId) * 1000; return [ { label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) }, { label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) },