mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Merge branch 'main' into feat/stalwart-1.0
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
getMaxObjectsInGet: vi.fn(() => 500),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { saveDraftVersion, type OutgoingAttachment, type OutgoingEmail } from '../email';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* A tiny server with Stalwart's blob semantics, as measured on 0.16.22 (2026-10-14):
|
||||
* an email's part blobIds are derived from that email and stop resolving the
|
||||
* moment it is destroyed ("blobId … does not exist on this server"); creating
|
||||
* an email copies the referenced bytes into new parts with new blobIds.
|
||||
*/
|
||||
function fakeStalwart() {
|
||||
let seq = 0;
|
||||
const emails = new Map<string, Array<{ blobId: string; name: string; size: number; cid?: string; type: string }>>();
|
||||
const live = (blobId: string) => [...emails.values()].some((parts) => parts.some((p) => p.blobId === blobId));
|
||||
const seed = (id: string, parts: OutgoingAttachment[]) => {
|
||||
emails.set(id, parts.map((p) => ({ blobId: p.blobId, name: p.name, size: p.size ?? 0, cid: p.cid, type: p.type })));
|
||||
};
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, any>, string]>) => {
|
||||
const out: Array<[string, unknown, string]> = [];
|
||||
let createdId: string | undefined;
|
||||
for (const [method, args, callId] of calls) {
|
||||
if (method === 'Email/set' && args.create) {
|
||||
const create = args.create.draft;
|
||||
const dead = (create.attachments ?? []).find((a: { blobId: string }) => !live(a.blobId));
|
||||
if (dead) {
|
||||
out.push(['Email/set', { notCreated: { draft: {
|
||||
type: 'blobNotFound', description: `blobId ${dead.blobId} does not exist on this server.`,
|
||||
} } }, callId]);
|
||||
continue;
|
||||
}
|
||||
createdId = `e${++seq}`;
|
||||
emails.set(createdId, (create.attachments ?? []).map((a: OutgoingAttachment, i: number) => ({
|
||||
blobId: `${createdId}-part${i}`, name: a.name, size: a.size ?? 0, cid: a.cid ? `<${a.cid}>` : undefined, type: a.type,
|
||||
})));
|
||||
out.push(['Email/set', { created: { draft: { id: createdId } } }, callId]);
|
||||
} else if (method === 'Email/set' && args.destroy) {
|
||||
for (const id of args.destroy) emails.delete(id);
|
||||
out.push(['Email/set', { destroyed: args.destroy }, callId]);
|
||||
} else if (method === 'Email/get') {
|
||||
if (!createdId || args.ids?.[0] !== '#draft') {
|
||||
out.push(['error', { type: 'invalidResultReference' }, callId]);
|
||||
continue;
|
||||
}
|
||||
out.push(['Email/get', { list: [{ id: createdId, attachments: emails.get(createdId) }] }, callId]);
|
||||
}
|
||||
}
|
||||
return { methodResponses: out };
|
||||
});
|
||||
return { seed, emails };
|
||||
}
|
||||
|
||||
const IMG_A: OutgoingAttachment = { blobId: 'd0-part-a', name: 'schema-a.png', size: 136574, type: 'image/png', disposition: 'inline', cid: 'a@undust.co' };
|
||||
const IMG_B: OutgoingAttachment = { blobId: 'd0-part-b', name: 'schema-b.png', size: 175155, type: 'image/png', disposition: 'inline', cid: 'b@undust.co' };
|
||||
|
||||
const draftWith = (attachments: OutgoingAttachment[]): OutgoingEmail => ({
|
||||
from: [{ email: 'me@undust.co' }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: 'RE: draft with two inline images',
|
||||
htmlBody: '<p>x <img src="cid:a@undust.co"> <img src="cid:b@undust.co"></p>',
|
||||
textBody: 'x',
|
||||
attachments,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('saveDraftVersion', () => {
|
||||
it('reads the new version\'s parts back in the same request, by creation id', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
const saved = await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
expect(calls.map((c: [string]) => c[0])).toEqual(['Email/set', 'Email/get']);
|
||||
expect(calls[1][1].ids).toEqual(['#draft']);
|
||||
expect(saved.id).toBe('e1');
|
||||
expect(saved.attachments.map((a) => a.blobId)).toEqual(['e1-part0']);
|
||||
});
|
||||
|
||||
it('destroys the previous version only after the new one exists', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const destroyCall = mockRequest.mock.calls[1][0][0];
|
||||
expect(destroyCall[0]).toBe('Email/set');
|
||||
expect(destroyCall[1].destroy).toEqual(['d0']);
|
||||
});
|
||||
|
||||
it('still returns the id when the read-back fails', async () => {
|
||||
mockRequest.mockResolvedValueOnce({ methodResponses: [
|
||||
['Email/set', { created: { draft: { id: 'e9' } } }, '0'],
|
||||
['error', { type: 'serverFail' }, '1'],
|
||||
] });
|
||||
const saved = await saveDraftVersion(draftWith([]), 'drafts');
|
||||
expect(saved).toEqual({ id: 'e9', attachments: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-opened draft with inline images: save, save again, send', () => {
|
||||
// What ComposeScreen does: buildOutgoing maps each held blobId through the
|
||||
// state, saveDraftOnce rebases it on the parts read back.
|
||||
async function editSession(rebase: boolean) {
|
||||
const server = fakeStalwart();
|
||||
server.seed('d0', [IMG_A, IMG_B]); // the draft as deposited (IMAP APPEND or an earlier save)
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = 'd0';
|
||||
const outgoing = () => draftWith([IMG_A, IMG_B].map((a) => ({ ...a, blobId: liveBlobId(state, a.blobId) })));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = outgoing();
|
||||
const saved = await saveDraftVersion(sent, 'drafts', draftId);
|
||||
draftId = saved.id;
|
||||
if (rebase) state = rebaseDraftPartBlobs(state, sent.attachments ?? [], saved.attachments);
|
||||
}
|
||||
return { server, draftId };
|
||||
}
|
||||
|
||||
it('reproduces the bug without the rebase: the second save references the destroyed draft\'s parts', async () => {
|
||||
await expect(editSession(false)).rejects.toThrow('blobId d0-part-a does not exist on this server.');
|
||||
});
|
||||
|
||||
it('keeps every save (and so the send, which builds the same attachments) on live blobs', async () => {
|
||||
const { server, draftId } = await editSession(true);
|
||||
expect([...server.emails.keys()]).toEqual([draftId]);
|
||||
expect(server.emails.get(draftId)!.map((p) => p.name)).toEqual(['schema-a.png', 'schema-b.png']);
|
||||
});
|
||||
});
|
||||
@@ -450,6 +450,40 @@ describe('JMAPClient hardening', () => {
|
||||
expect(client.authHeader).toBe(`Basic ${btoa('c@example.com:pw')}`);
|
||||
});
|
||||
|
||||
// On iOS the notification extension refreshes OAuth tokens too and writes
|
||||
// the new bundle back. Refreshing again with the refresh token in memory
|
||||
// would spend a token the server has already replaced.
|
||||
it('retries a 401 with a newer stored token instead of refreshing again', async () => {
|
||||
const SecureStore = await import('expo-secure-store');
|
||||
const client = new JMAPClient();
|
||||
mockFetch([{ status: 200, json: SESSION }]);
|
||||
await client.connectWithOAuth('https://mail.example.com', {
|
||||
accessToken: 'old',
|
||||
refreshToken: 'r1',
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
tokenEndpoint: 'https://mail.example.com/auth/token',
|
||||
clientId: 'c',
|
||||
});
|
||||
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify({
|
||||
serverUrl: 'https://mail.example.com',
|
||||
username: 'user@example.com',
|
||||
password: '',
|
||||
accessToken: 'from-extension',
|
||||
refreshToken: 'r2',
|
||||
expiresAt: Date.now() + 7_200_000,
|
||||
tokenEndpoint: 'https://mail.example.com/auth/token',
|
||||
clientId: 'c',
|
||||
}));
|
||||
const fetch = mockFetch([
|
||||
{ status: 401 },
|
||||
{ status: 200, json: { methodResponses: [['Mailbox/get', {}, '0']] } },
|
||||
]);
|
||||
await client.request([['Mailbox/get', {}, '0']]);
|
||||
expect(mockRefresh).not.toHaveBeenCalled();
|
||||
expect((fetch.mock.calls[1][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer from-extension' });
|
||||
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockReset();
|
||||
});
|
||||
|
||||
it('updatePassword rewrites the stored credential', async () => {
|
||||
const SecureStore = await import('expo-secure-store');
|
||||
const client = await connected();
|
||||
|
||||
@@ -8,7 +8,7 @@ vi.mock('expo-secure-store', () => ({
|
||||
}));
|
||||
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { JMAPClient, AuthenticationError, RateLimitError, SessionSupersededError, parseHoldLimit } from '../jmap-client';
|
||||
import { JMAPClient, AuthenticationError, InvalidTokenError, RateLimitError, SessionSupersededError, parseHoldLimit } from '../jmap-client';
|
||||
import type { JMAPSession } from '../types';
|
||||
|
||||
const MOCK_SESSION: JMAPSession = {
|
||||
@@ -113,8 +113,10 @@ describe('JMAPClient', () => {
|
||||
});
|
||||
|
||||
describe('connectWithToken', () => {
|
||||
const TOKEN_SESSION = { ...MOCK_SESSION, username: 'user@example.com' };
|
||||
|
||||
it('should use Bearer auth', async () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
|
||||
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
|
||||
|
||||
await client.connectWithToken('https://mail.example.com', 'my-token');
|
||||
|
||||
@@ -128,6 +130,70 @@ describe('JMAPClient', () => {
|
||||
);
|
||||
expect(client.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('names the account after the session and remembers the token', async () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
|
||||
|
||||
const { username, accountId } = await client.connectWithToken('https://mail.example.com/', 'my-token');
|
||||
|
||||
expect(username).toBe('user@example.com');
|
||||
expect(accountId).toBe('user@example.com@mail.example.com');
|
||||
const [key, stored] = vi.mocked(SecureStore.setItemAsync).mock.calls.at(-1)!;
|
||||
expect(key).toContain('user_example.com');
|
||||
expect(JSON.parse(stored)).toMatchObject({
|
||||
serverUrl: 'https://mail.example.com',
|
||||
username: 'user@example.com',
|
||||
accessToken: 'my-token',
|
||||
});
|
||||
expect(client.usesPastedToken).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a token the server rejects', async () => {
|
||||
global.fetch = mockFetch([{ status: 401 }]) as any;
|
||||
|
||||
await expect(client.connectWithToken('https://mail.example.com', 'bad')).rejects.toThrow(InvalidTokenError);
|
||||
expect(client.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a session that does not name the account', async () => {
|
||||
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
|
||||
|
||||
await expect(client.connectWithToken('https://mail.example.com', 'my-token')).rejects.toThrow('did not name the account');
|
||||
expect(client.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a download host the server runs on another HTTPS origin', async () => {
|
||||
global.fetch = mockFetch([{
|
||||
status: 200,
|
||||
json: {
|
||||
...TOKEN_SESSION,
|
||||
apiUrl: 'https://api.fastmail.com/jmap/api/',
|
||||
downloadUrl: 'https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}',
|
||||
uploadUrl: 'https://api.fastmail.com/jmap/upload/{accountId}/',
|
||||
},
|
||||
}]) as any;
|
||||
|
||||
const { session } = await client.connectWithToken('https://api.fastmail.com', 'my-token');
|
||||
|
||||
expect(session.downloadUrl).toBe('https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}');
|
||||
expect(session.uploadUrl).toBe('https://api.fastmail.com/jmap/upload/{accountId}/');
|
||||
});
|
||||
|
||||
it('still moves self-reported internal hosts onto the server address', async () => {
|
||||
global.fetch = mockFetch([{
|
||||
status: 200,
|
||||
json: {
|
||||
...TOKEN_SESSION,
|
||||
apiUrl: 'http://localhost:8080/jmap/',
|
||||
downloadUrl: 'http://localhost:8080/download/{accountId}/{blobId}/{name}?type={type}',
|
||||
},
|
||||
}]) as any;
|
||||
|
||||
const { session } = await client.connectWithToken('https://mail.example.com', 'my-token');
|
||||
|
||||
expect(session.apiUrl).toBe('https://mail.example.com/jmap/');
|
||||
expect(session.downloadUrl).toBe('https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('request', () => {
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
getFullEmails,
|
||||
getThreadsHeaders,
|
||||
createDraft,
|
||||
saveDraftVersion,
|
||||
destroyEmails,
|
||||
sendEmail,
|
||||
markAsSpam,
|
||||
undoSpam,
|
||||
@@ -33,6 +35,9 @@ import {
|
||||
EMAIL_FULL_PROPERTIES,
|
||||
} from '../email';
|
||||
import { getIdentities } from '../identity';
|
||||
import { uploadBytes } from '../blob';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
import type { OutgoingAttachment } from '../email';
|
||||
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
|
||||
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
|
||||
|
||||
@@ -192,6 +197,44 @@ live('live Stalwart', () => {
|
||||
await destroyEmails([id]);
|
||||
}, 60_000);
|
||||
|
||||
it('keeps a re-opened draft with an inline image saveable across saves (#849 part blobs)', async () => {
|
||||
// A 1x1 PNG uploaded once; D0 stands for the draft as it sits on the server.
|
||||
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
|
||||
const up = await uploadBytes(png, 'image/png');
|
||||
const base = {
|
||||
from: [{ email: USER }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: '[TEST bulwark-blob — supprimable]',
|
||||
htmlBody: '<p>live <img src="cid:img1@live.test"></p>',
|
||||
textBody: 'live',
|
||||
};
|
||||
const img = (blobId: string): OutgoingAttachment => ({
|
||||
blobId, type: 'image/png', name: 'img1.png', size: png.byteLength, disposition: 'inline', cid: 'img1@live.test',
|
||||
});
|
||||
const d0 = await saveDraftVersion({ ...base, attachments: [img(up.blobId)] }, drafts);
|
||||
const created = [d0.id];
|
||||
try {
|
||||
// Re-open D0: the composer holds D0's part blobId.
|
||||
const seeded = d0.attachments[0].blobId;
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = d0.id;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = [img(liveBlobId(state, seeded))];
|
||||
const saved = await saveDraftVersion({ ...base, attachments: sent }, drafts, draftId);
|
||||
created.push(saved.id);
|
||||
draftId = saved.id;
|
||||
state = rebaseDraftPartBlobs(state, sent, saved.attachments);
|
||||
}
|
||||
// Without the rebase the seeded part blob is gone with D0.
|
||||
await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts))
|
||||
.rejects.toThrow(/does not exist|blobNotFound/);
|
||||
const [last] = await getFullEmails([draftId]);
|
||||
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
|
||||
} finally {
|
||||
await destroyEmails(created).catch(() => undefined);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
|
||||
const identities = await getIdentities();
|
||||
expect(identities.length).toBeGreaterThan(0);
|
||||
|
||||
+48
-5
@@ -1867,31 +1867,64 @@ export async function sendEmail(
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved draft version and the attachment parts the server built for it. */
|
||||
export interface SavedDraftVersion {
|
||||
id: string;
|
||||
/**
|
||||
* Attachment and inline parts of the new version, with their blobIds. A
|
||||
* part's blobId only lives as long as this version: re-opened drafts must
|
||||
* send these (not the previous version's) on their next save or send.
|
||||
* Empty when the server did not answer the read-back.
|
||||
*/
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
|
||||
|
||||
/**
|
||||
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
|
||||
* is given it is destroyed only AFTER the replacement was created (#849): a
|
||||
* combined create+destroy would delete the last good copy when the create
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the
|
||||
* new draft id.
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
|
||||
* version's attachment parts are read back in the same request, so the
|
||||
* composer can re-point its draft-part blobIds before the old version (and
|
||||
* its part blobs) is destroyed.
|
||||
*/
|
||||
export async function createDraft(
|
||||
export async function saveDraftVersion(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
): Promise<SavedDraftVersion> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const emailCreate = buildEmailCreate(email);
|
||||
emailCreate.mailboxIds = { [draftsMailboxId]: true };
|
||||
emailCreate.keywords = { $seen: true, $draft: true };
|
||||
const res = await jmapClient.request([
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
|
||||
// resolve a result reference into an Email/set `created` map
|
||||
// (invalidResultReference), but it does resolve the creation id.
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids: ['#draft'],
|
||||
properties: ['id', 'attachments'],
|
||||
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
|
||||
}, '1'],
|
||||
]);
|
||||
const body = requireMethodResult(res, '0', 'Email/set');
|
||||
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
|
||||
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
|
||||
const id = body.created?.draft?.id as string | undefined;
|
||||
if (!id) throw new Error('Draft save returned no id');
|
||||
let attachments: Attachment[] = [];
|
||||
try {
|
||||
const got = requireMethodResult(res, '1', 'Email/get');
|
||||
const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id);
|
||||
attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId);
|
||||
} catch (err) {
|
||||
console.warn('[email] could not read back the saved draft parts:', err);
|
||||
}
|
||||
if (previousDraftId && previousDraftId !== id) {
|
||||
try {
|
||||
await destroyEmails([previousDraftId], accountId);
|
||||
@@ -1899,7 +1932,17 @@ export async function createDraft(
|
||||
console.warn('[email] failed to destroy previous draft version:', err);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
return { id, attachments };
|
||||
}
|
||||
|
||||
/** {@link saveDraftVersion} for callers that only need the new draft id. */
|
||||
export async function createDraft(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
|
||||
}
|
||||
|
||||
export interface ScheduledEmail {
|
||||
|
||||
+115
-31
@@ -15,6 +15,7 @@ import {
|
||||
type OAuthTokenSource,
|
||||
} from '../lib/oauth';
|
||||
import { FirstTouchGate } from './first-touch-gate';
|
||||
import { writeSharedSecret, moveToSharedGroup } from '../lib/ios-push-shared';
|
||||
import { beginOwnWrite, recordOwnEmailWrites } from './own-writes';
|
||||
|
||||
// Refresh OAuth access tokens this many ms before they actually expire so
|
||||
@@ -40,7 +41,7 @@ const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||
|
||||
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
|
||||
function credentialsKey(accountId: string): string {
|
||||
export function credentialsKey(accountId: string): string {
|
||||
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
@@ -194,6 +195,12 @@ export class JMAPClient {
|
||||
return !!this.credentials?.accessToken;
|
||||
}
|
||||
|
||||
// A pasted access token, as opposed to an OAuth bundle (which always names
|
||||
// the token endpoint it renews at).
|
||||
get usesPastedToken(): boolean {
|
||||
return !!this.credentials?.accessToken && !this.credentials.tokenEndpoint;
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.session !== null && this._accountId !== null;
|
||||
}
|
||||
@@ -283,10 +290,7 @@ export class JMAPClient {
|
||||
this.firstTouchGate.reset();
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(credentials),
|
||||
);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
|
||||
|
||||
return session;
|
||||
}
|
||||
@@ -323,19 +327,50 @@ export class JMAPClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
async connectWithToken(serverUrl: string, accessToken: string): Promise<JMAPSession> {
|
||||
/**
|
||||
* Access-token login (an API token the user pasted, e.g. from Fastmail).
|
||||
* The token is the whole credential: it names the account, so the username
|
||||
* comes from the session, and with no refresh token it lasts until the
|
||||
* provider expires or revokes it.
|
||||
*/
|
||||
async connectWithToken(
|
||||
serverUrl: string,
|
||||
accessToken: string,
|
||||
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
|
||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||
const epoch = this.detachSession();
|
||||
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
|
||||
|
||||
const fetched = await this.fetchSession(baseUrl);
|
||||
let fetched: JMAPSession;
|
||||
try {
|
||||
fetched = await this.fetchSession(baseUrl);
|
||||
} catch (err) {
|
||||
// A newer account change owns the credentials now.
|
||||
this.assertCurrentSession(epoch);
|
||||
this.credentials = null;
|
||||
if (err instanceof AuthenticationError || (err instanceof Error && /^Session discovery failed: 403\b/.test(err.message))) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
this.assertCurrentSession(epoch);
|
||||
const session = this.rewriteSessionUrls(fetched, baseUrl);
|
||||
this.session = session;
|
||||
this._accountId = this.resolveAccountId(session);
|
||||
this.firstTouchGate.reset();
|
||||
|
||||
return session;
|
||||
const username = session.username;
|
||||
if (!username) {
|
||||
this.reset();
|
||||
throw new Error('The server did not name the account');
|
||||
}
|
||||
const credentials: StoredCredentials = { ...this.credentials!, username };
|
||||
this.credentials = credentials;
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
|
||||
|
||||
return { session, username, accountId };
|
||||
}
|
||||
|
||||
// OAuth login via webmail handoff. The webmail did the OAuth dance against
|
||||
@@ -379,10 +414,7 @@ export class JMAPClient {
|
||||
this.credentials = credentials;
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(credentials),
|
||||
);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(credentials));
|
||||
|
||||
return { session, username, accountId };
|
||||
}
|
||||
@@ -421,16 +453,39 @@ export class JMAPClient {
|
||||
clientId: next.clientId,
|
||||
};
|
||||
if (inUse) this.credentials = updated;
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(updated));
|
||||
if (!inUse) return;
|
||||
for (const l of this.tokenRefreshListeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the stored OAuth tokens when they are newer than the ones in memory:
|
||||
* on iOS the notification extension refreshes them too and writes the new
|
||||
* bundle back. True when the in-memory tokens changed.
|
||||
*/
|
||||
private async adoptStoredTokens(): Promise<boolean> {
|
||||
const current = this.credentials;
|
||||
if (!current?.accessToken || !current.refreshToken) return false;
|
||||
const accountId = generateAccountId(current.username, current.serverUrl);
|
||||
const stored = await this.getStoredCredentials(accountId).catch(() => null);
|
||||
// Another account may have been opened meanwhile.
|
||||
if (this.credentials !== current) return false;
|
||||
if (!stored?.accessToken || stored.accessToken === current.accessToken) return false;
|
||||
if ((stored.expiresAt ?? 0) <= (current.expiresAt ?? 0)) return false;
|
||||
this.credentials = {
|
||||
...current,
|
||||
accessToken: stored.accessToken,
|
||||
refreshToken: stored.refreshToken ?? current.refreshToken,
|
||||
expiresAt: stored.expiresAt,
|
||||
};
|
||||
for (const l of this.tokenRefreshListeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private currentOAuthTokens(): { credentials: StoredCredentials; tokens: OAuthTokens } | null {
|
||||
const credentials = this.credentials;
|
||||
if (
|
||||
@@ -473,14 +528,21 @@ export class JMAPClient {
|
||||
// consumers of `authHeader` (SSE stream, file downloads) can make sure the
|
||||
// header they capture is not about to expire.
|
||||
async ensureFreshToken(): Promise<void> {
|
||||
const current = this.currentOAuthTokens();
|
||||
let current = this.currentOAuthTokens();
|
||||
if (!current) return;
|
||||
const { credentials, tokens } = current;
|
||||
if (tokens.expiresAt == null) return;
|
||||
if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
|
||||
const { expiresAt } = current.tokens;
|
||||
if (expiresAt == null) return;
|
||||
if (expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
|
||||
// Another process may have refreshed already (the iOS notification
|
||||
// extension); use its tokens rather than spend a refresh token the
|
||||
// server has replaced.
|
||||
if (await this.adoptStoredTokens()) {
|
||||
current = this.currentOAuthTokens();
|
||||
if (!current || (current.tokens.expiresAt != null && current.tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS)) return;
|
||||
}
|
||||
try {
|
||||
const next = await refreshOAuthAccessToken(tokens);
|
||||
await this.persistRefreshedTokens(credentials, next);
|
||||
const next = await refreshOAuthAccessToken(current.tokens);
|
||||
await this.persistRefreshedTokens(current.credentials, next);
|
||||
} catch {
|
||||
// Surface as AuthenticationError on the next 401; refresh may be
|
||||
// temporarily failing (network) and the reactive retry path catches it.
|
||||
@@ -493,6 +555,10 @@ export class JMAPClient {
|
||||
// the account instead of evicting it (webmail 1.7.6 "keep the session when
|
||||
// the auth server is briefly unreachable").
|
||||
async forceRefreshToken(): Promise<boolean> {
|
||||
if (!this.currentOAuthTokens()) return false;
|
||||
// A newer token in storage (refreshed by the iOS notification extension)
|
||||
// is worth a retry before refreshing again.
|
||||
if (await this.adoptStoredTokens()) return true;
|
||||
const current = this.currentOAuthTokens();
|
||||
if (!current) return false;
|
||||
try {
|
||||
@@ -519,10 +585,7 @@ export class JMAPClient {
|
||||
this.credentials.username,
|
||||
this.credentials.serverUrl,
|
||||
);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||
}
|
||||
|
||||
// Legacy single-slot restore - kept for backward-compat tests. New code
|
||||
@@ -553,6 +616,9 @@ export class JMAPClient {
|
||||
// A newer load may have put its credentials in by now.
|
||||
this.assertCurrentSession(epoch);
|
||||
if (!stored) return false;
|
||||
// iOS: credentials saved before the notification extension existed move
|
||||
// to the keychain group it can read (a no-op elsewhere and afterwards).
|
||||
void moveToSharedGroup(credentialsKey(accountId)).catch(() => undefined);
|
||||
|
||||
let creds: StoredCredentials;
|
||||
try {
|
||||
@@ -658,7 +724,7 @@ export class JMAPClient {
|
||||
const creds: StoredCredentials = JSON.parse(stored);
|
||||
// Re-save under the per-account key before dropping the legacy entry
|
||||
const accountId = generateAccountId(creds.username, creds.serverUrl);
|
||||
await SecureStore.setItemAsync(credentialsKey(accountId), stored);
|
||||
await writeSharedSecret(credentialsKey(accountId), stored);
|
||||
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
|
||||
return creds;
|
||||
} catch {
|
||||
@@ -677,16 +743,26 @@ export class JMAPClient {
|
||||
// URL polyfill mutates inputs (appends trailing slashes, normalises
|
||||
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
|
||||
// before the caller has a chance to substitute values into them.
|
||||
//
|
||||
// A URL on another HTTPS origin than the server's own apiUrl is one it
|
||||
// deliberately hosts elsewhere (Fastmail serves downloads from a separate
|
||||
// domain) and is kept; everything else moves onto the origin we connected to.
|
||||
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
|
||||
const serverOrigin = extractOrigin(serverUrl);
|
||||
const reportedOrigin = session.apiUrl ? extractOrigin(session.apiUrl) : null;
|
||||
const rewrite = (url: string | undefined): string | undefined =>
|
||||
rewriteSessionUrl(url, serverOrigin);
|
||||
const rewriteHosted = (url: string | undefined): string | undefined => {
|
||||
const origin = url ? extractOrigin(url) : null;
|
||||
const hostedElsewhere = !!origin && !!reportedOrigin && origin !== reportedOrigin && /^https:/i.test(origin);
|
||||
return hostedElsewhere ? url : rewrite(url);
|
||||
};
|
||||
return {
|
||||
...session,
|
||||
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
|
||||
downloadUrl: rewrite(session.downloadUrl) ?? session.downloadUrl,
|
||||
uploadUrl: rewrite(session.uploadUrl) ?? session.uploadUrl,
|
||||
eventSourceUrl: rewrite(session.eventSourceUrl) ?? session.eventSourceUrl,
|
||||
downloadUrl: rewriteHosted(session.downloadUrl) ?? session.downloadUrl,
|
||||
uploadUrl: rewriteHosted(session.uploadUrl) ?? session.uploadUrl,
|
||||
eventSourceUrl: rewriteHosted(session.eventSourceUrl) ?? session.eventSourceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1256,7 +1332,7 @@ export class JMAPClient {
|
||||
}
|
||||
|
||||
async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise<void> {
|
||||
await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds));
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(creds));
|
||||
}
|
||||
|
||||
// ── Blob Download ─────────────────────────────────────
|
||||
@@ -1353,6 +1429,14 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// The server refused a pasted access token (wrong, expired or revoked).
|
||||
export class InvalidTokenError extends Error {
|
||||
constructor() {
|
||||
super('Invalid access token');
|
||||
this.name = 'InvalidTokenError';
|
||||
}
|
||||
}
|
||||
|
||||
// The server wants a second factor before it will hand out a session.
|
||||
export class TotpRequiredError extends AuthenticationError {
|
||||
constructor() {
|
||||
|
||||
Reference in New Issue
Block a user