Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,16 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<EmailListScreen
onComposePress={() => 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,
Expand Down
4 changes: 4 additions & 0 deletions locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions locales/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions src/api/__tests__/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
deleteEmail,
searchEmails,
sendEmail,
saveDraft,
} from '../email';

const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -406,4 +407,74 @@ describe('email operations', () => {
expect(emailCreate['header:References:asText']).toBe('<msg-0@example.com> <msg-1@example.com>');
});
});

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: '<p>half-written</p>',
},
'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');
});
});
});
92 changes: 69 additions & 23 deletions src/api/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SendEmailResult> {
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<string, boolean>,
): Record<string, unknown> {
const emailCreate: Record<string, unknown> = {
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<string, { value: string }> = {};
Expand Down Expand Up @@ -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<SendEmailResult> {
const accountId = jmapClient.accountId;
const emailCreate = buildEmailCreate(email, sentMailboxId, { $seen: true });

const submissionCreate: Record<string, unknown> = { 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
Expand Down Expand Up @@ -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<string, unknown> = { 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<string, { description?: string; type?: string }> }).notCreated?.draft;
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to save draft');
const emailId = (result as { created?: Record<string, { id?: string }> }).created?.draft?.id;
if (!emailId) throw new Error('Failed to save draft');
return { emailId };
}

export interface ScheduledEmail {
emailSubmissionId: string;
emailId: string;
Expand Down
106 changes: 106 additions & 0 deletions src/lib/__tests__/draft-compose.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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: '<p>half-written</p>' } },
}));

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('<p>half-written</p>');
});

it('escapes a text-only body into html', () => {
const init = draftToComposeInit(makeDraft({
textBody: [{ partId: 'text', type: 'text/plain' }],
bodyValues: { text: { value: 'line one\nwith <angle> & amp' } },
}));

expect(init.bodyHtml).toBe('line one<br>with &lt;angle&gt; &amp; amp');
});

it('strips dangerous tags from the stored html body', () => {
const init = draftToComposeInit(makeDraft({
htmlBody: [{ partId: 'html', type: 'text/html' }],
bodyValues: { html: { value: '<p>ok</p><script>alert(1)</script>' } },
}));

expect(init.bodyHtml).not.toContain('<script>');
expect(init.bodyHtml).toContain('<p>ok</p>');
});

it('classifies attachments and unwraps cid brackets', () => {
const init = draftToComposeInit(makeDraft({
attachments: [
{ blobId: 'b-1', type: 'application/pdf', name: 'doc.pdf', size: 42, disposition: 'attachment' },
{ blobId: 'b-2', type: 'image/png', name: 'pic.png', size: 7, disposition: 'inline', cid: '<img-1@local>' },
],
}));

expect(init.attachments).toEqual([
{ blobId: 'b-1', type: 'application/pdf', name: 'doc.pdf', size: 42, cid: undefined, inline: false },
{ blobId: 'b-2', type: 'image/png', name: 'pic.png', size: 7, cid: 'img-1@local', inline: true },
]);
});

it('keeps reply threading headers', () => {
const init = draftToComposeInit(makeDraft({
inReplyTo: ['<msg-1@example.com>'],
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
}));

expect(init.inReplyTo).toBe('<msg-1@example.com>');
expect(init.references).toBe('<msg-0@example.com> <msg-1@example.com>');
});

it('defaults everything on an empty draft', () => {
const init = draftToComposeInit(makeDraft());

expect(init.to).toEqual([]);
expect(init.subject).toBe('');
expect(init.bodyHtml).toBe('');
expect(init.attachments).toEqual([]);
});
});

describe('rewriteCidSrcToDataUrls', () => {
it('swaps cid: srcs for data urls and stamps data-cid', () => {
const html = '<p>x</p><img alt="a" src="cid:img-1@local" width="10">';
const out = rewriteCidSrcToDataUrls(html, { 'img-1@local': 'data:image/png;base64,AA==' });

expect(out).toBe('<p>x</p><img alt="a" src="data:image/png;base64,AA==" data-cid="img-1@local" width="10">');
});

it('leaves images whose cid was not hydrated untouched', () => {
const html = '<img src="cid:missing@local">';
expect(rewriteCidSrcToDataUrls(html, {})).toBe(html);
});

it('is a no-op on html without cid references', () => {
const html = '<p>plain</p><img src="https://example.com/x.png">';
expect(rewriteCidSrcToDataUrls(html, { any: 'data:x' })).toBe(html);
});
});
Loading