Merge branch 'main' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 01:30:03 +01:00
782 changed files with 202548 additions and 95749 deletions
+138
View File
@@ -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();
+68 -2
View File
@@ -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', () => {
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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() {