diff --git a/App.tsx b/App.tsx index 134c43e..77b4635 100644 --- a/App.tsx +++ b/App.tsx @@ -143,6 +143,16 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps navigation.navigate('Compose')} onEmailPress={(email) => { + // An own-account draft opens back in the composer for editing, + // not in the read-only thread view. Drafts browsed in a shared + // account's folder stay read-only — the composer can't save + // into or destroy from another account. + const { currentMailboxId } = useEmailStore.getState(); + const currentMailbox = mailboxes.find((m) => m.id === currentMailboxId); + if (email.keywords?.$draft && !currentMailbox?.isShared) { + navigation.navigate('Compose', { draft: { emailId: email.id } }); + return; + } navigation.navigate('EmailThread', { emailId: email.id, threadId: email.threadId, diff --git a/locales/en/common.json b/locales/en/common.json index df03173..6c38451 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -455,6 +455,10 @@ "discard": "Discard", "discard_draft_title": "Discard draft?", "discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?", + "edit_draft": "Edit draft", + "save_draft": "Save draft", + "save_draft_failed": "Could not save draft", + "uploads_in_flight": "Wait for attachments to finish uploading.", "saving": "Saving...", "sending": "Sending...", "add_link": "Add link", diff --git a/locales/fr/common.json b/locales/fr/common.json index b37618c..a035bb0 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -451,6 +451,11 @@ "discard": "Supprimer", "discard_draft_title": "Supprimer le brouillon ?", "discard_draft_confirm": "Vous avez des modifications non enregistrées. Voulez-vous supprimer ce brouillon ?", + "edit_draft": "Modifier le brouillon", + "editor_unavailable": "Impossible de lire le contenu du message. Copiez votre texte, puis fermez et rouvrez l'éditeur.", + "save_draft": "Enregistrer le brouillon", + "save_draft_failed": "Impossible d'enregistrer le brouillon", + "uploads_in_flight": "Attendez la fin de l'envoi des pièces jointes.", "saving": "Enregistrement...", "draft_saved": "Brouillon enregistré", "save_failed": "Échec de l'enregistrement", diff --git a/src/api/__tests__/email.test.ts b/src/api/__tests__/email.test.ts index 684b544..7e0f572 100644 --- a/src/api/__tests__/email.test.ts +++ b/src/api/__tests__/email.test.ts @@ -24,6 +24,7 @@ import { deleteEmail, searchEmails, sendEmail, + saveDraft, } from '../email'; const mockRequest = jmapClient.request as ReturnType; @@ -406,4 +407,74 @@ describe('email operations', () => { expect(emailCreate['header:References:asText']).toBe(' '); }); }); + + describe('saveDraft', () => { + it('should create the draft in the drafts mailbox with the $draft keyword', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Email/set', { created: { draft: { id: 'e-draft' } } }, '0']], + }); + + const result = await saveDraft( + { + from: [{ email: 'me@example.com' }], + to: [{ email: 'you@example.com' }], + subject: 'WIP', + htmlBody: '

half-written

', + }, + 'drafts-mb', + ); + + expect(result).toEqual({ emailId: 'e-draft' }); + const setArgs = mockRequest.mock.calls[0][0][0][1]; + expect(setArgs.destroy).toBeUndefined(); + expect(setArgs.create.draft.mailboxIds).toEqual({ 'drafts-mb': true }); + expect(setArgs.create.draft.keywords).toEqual({ $draft: true, $seen: true }); + }); + + it('should destroy the replaced draft in the same Email/set call', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Email/set', { created: { draft: { id: 'e-new' } }, destroyed: ['e-old'] }, '0'], + ], + }); + + await saveDraft( + { from: [{ email: 'me@example.com' }], subject: '', textBody: 'note to self' }, + 'drafts-mb', + 'e-old', + ); + + const setArgs = mockRequest.mock.calls[0][0][0][1]; + expect(setArgs.destroy).toEqual(['e-old']); + }); + + it('should allow a draft without recipients', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Email/set', { created: { draft: { id: 'e-draft' } } }, '0']], + }); + + await saveDraft( + { from: [{ email: 'me@example.com' }], subject: 'no recipients yet', textBody: 'x' }, + 'drafts-mb', + ); + + const emailCreate = mockRequest.mock.calls[0][0][0][1].create.draft; + expect(emailCreate.to).toBeUndefined(); + }); + + it('should throw when the server rejects the create', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Email/set', { notCreated: { draft: { type: 'overQuota', description: 'Mailbox full' } } }, '0'], + ], + }); + + await expect( + saveDraft( + { from: [{ email: 'me@example.com' }], subject: 'x', textBody: 'y' }, + 'drafts-mb', + ), + ).rejects.toThrow('Mailbox full'); + }); + }); }); diff --git a/src/api/email.ts b/src/api/email.ts index db31d50..2fc74a4 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -766,35 +766,32 @@ export interface SendEmailResult { emailSubmissionId?: string; } -export async function sendEmail( - email: { - from: EmailAddress[]; - to: EmailAddress[]; - cc?: EmailAddress[]; - bcc?: EmailAddress[]; - subject: string; - htmlBody?: string; - textBody?: string; - attachments?: OutgoingAttachment[]; - inReplyTo?: string; - references?: string; - }, - identityId: string, - sentMailboxId: string, - // When > 0 the message is held for this many seconds before delivery via the - // SMTP HOLDFOR parameter (FUTURERELEASE). Used for both explicit "send later" - // scheduling and the global send-delay (undo-send) window. - holdForSeconds?: number, -): Promise { - const accountId = jmapClient.accountId; +export interface OutgoingEmailContent { + from: EmailAddress[]; + to?: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; + subject: string; + htmlBody?: string; + textBody?: string; + attachments?: OutgoingAttachment[]; + inReplyTo?: string; + references?: string; +} + +function buildEmailCreate( + email: OutgoingEmailContent, + mailboxId: string, + keywords: Record, +): Record { const emailCreate: Record = { from: email.from, to: email.to, cc: email.cc, bcc: email.bcc, subject: email.subject, - mailboxIds: { [sentMailboxId]: true }, - keywords: { $seen: true }, + mailboxIds: { [mailboxId]: true }, + keywords, }; const bodyValues: Record = {}; @@ -827,6 +824,21 @@ export async function sendEmail( emailCreate['header:References:asText'] = email.references ?? email.inReplyTo; } + return emailCreate; +} + +export async function sendEmail( + email: OutgoingEmailContent & { to: EmailAddress[] }, + identityId: string, + sentMailboxId: string, + // When > 0 the message is held for this many seconds before delivery via the + // SMTP HOLDFOR parameter (FUTURERELEASE). Used for both explicit "send later" + // scheduling and the global send-delay (undo-send) window. + holdForSeconds?: number, +): Promise { + const accountId = jmapClient.accountId; + const emailCreate = buildEmailCreate(email, sentMailboxId, { $seen: true }); + const submissionCreate: Record = { emailId: '#draft', identityId }; // 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 @@ -885,6 +897,40 @@ export async function sendEmail( }; } +/** + * Create (or re-create) a draft in the drafts mailbox. JMAP emails are + * immutable apart from keywords/mailboxIds (RFC 8621 §4), so "updating" a + * draft means creating a replacement and destroying the original in the same + * Email/set call. A failed destroy leaves a stale copy behind but never loses + * the new draft, so it is reported by the server yet not treated as an error. + */ +export async function saveDraft( + email: OutgoingEmailContent, + draftsMailboxId: string, + replaceEmailId?: string, +): Promise<{ emailId: string }> { + const accountId = jmapClient.accountId; + const emailCreate = buildEmailCreate(email, draftsMailboxId, { $draft: true, $seen: true }); + + const setArgs: Record = { accountId, create: { draft: emailCreate } }; + if (replaceEmailId) setArgs.destroy = [replaceEmailId]; + + const res = await jmapClient.request( + [['Email/set', setArgs, '0']], + [CAPABILITIES.CORE, CAPABILITIES.MAIL], + ); + + const [methodName, result] = res.methodResponses[0]; + if (methodName.endsWith('/error')) { + throw new Error((result as { description?: string }).description ?? 'Failed to save draft'); + } + const notCreated = (result as { notCreated?: Record }).notCreated?.draft; + if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to save draft'); + const emailId = (result as { created?: Record }).created?.draft?.id; + if (!emailId) throw new Error('Failed to save draft'); + return { emailId }; +} + export interface ScheduledEmail { emailSubmissionId: string; emailId: string; diff --git a/src/lib/__tests__/draft-compose.test.ts b/src/lib/__tests__/draft-compose.test.ts new file mode 100644 index 0000000..b6ca1ce --- /dev/null +++ b/src/lib/__tests__/draft-compose.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { draftToComposeInit, rewriteCidSrcToDataUrls } from '../draft-compose'; +import type { Email } from '../../api/types'; + +function makeDraft(overrides: Partial = {}): Email { + return { + id: 'e-1', + threadId: 't-1', + mailboxIds: { 'drafts-mb': true }, + keywords: { $draft: true }, + size: 1000, + receivedAt: '2026-08-01T10:00:00Z', + hasAttachment: false, + ...overrides, + }; +} + +describe('draftToComposeInit', () => { + it('maps recipients, subject and the html body', () => { + const init = draftToComposeInit(makeDraft({ + to: [{ email: 'you@example.com', name: 'You' }], + cc: [{ email: 'cc@example.com' }], + bcc: [{ email: 'hidden@example.com' }], + subject: 'WIP', + htmlBody: [{ partId: 'html', type: 'text/html' }], + bodyValues: { html: { value: '

half-written

' } }, + })); + + expect(init.to).toEqual([{ email: 'you@example.com', name: 'You' }]); + expect(init.cc).toEqual([{ email: 'cc@example.com' }]); + expect(init.bcc).toEqual([{ email: 'hidden@example.com' }]); + expect(init.subject).toBe('WIP'); + expect(init.bodyHtml).toBe('

half-written

'); + }); + + it('escapes a text-only body into html', () => { + const init = draftToComposeInit(makeDraft({ + textBody: [{ partId: 'text', type: 'text/plain' }], + bodyValues: { text: { value: 'line one\nwith & amp' } }, + })); + + expect(init.bodyHtml).toBe('line one
with <angle> & amp'); + }); + + it('strips dangerous tags from the stored html body', () => { + const init = draftToComposeInit(makeDraft({ + htmlBody: [{ partId: 'html', type: 'text/html' }], + bodyValues: { html: { value: '

ok

' } }, + })); + + expect(init.bodyHtml).not.toContain('