mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: check JMAP method errors in every mail call, split requests to server limits and file sent mail through Drafts (#188, #234, #850)
This commit is contained in:
@@ -8,6 +8,8 @@ vi.mock('../jmap-client', () => ({
|
|||||||
getAccountName: vi.fn(() => 'me@example.com'),
|
getAccountName: vi.fn(() => 'me@example.com'),
|
||||||
getSharedMailAccounts: vi.fn(() => []),
|
getSharedMailAccounts: vi.fn(() => []),
|
||||||
getMaxCallsInRequest: vi.fn(() => 16),
|
getMaxCallsInRequest: vi.fn(() => 16),
|
||||||
|
getMaxObjectsInGet: vi.fn(() => 500),
|
||||||
|
getMaxObjectsInSet: vi.fn(() => 500),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -394,16 +396,18 @@ describe('email operations', () => {
|
|||||||
to: [{ email: 'you@example.com' }],
|
to: [{ email: 'you@example.com' }],
|
||||||
subject: 'Re: Hello',
|
subject: 'Re: Hello',
|
||||||
textBody: 'replying',
|
textBody: 'replying',
|
||||||
inReplyTo: '<msg-1@example.com>',
|
inReplyTo: ['<msg-1@example.com>'],
|
||||||
references: '<msg-0@example.com> <msg-1@example.com>',
|
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
|
||||||
},
|
},
|
||||||
'identity-1',
|
'identity-1',
|
||||||
'sent-mb',
|
'sent-mb',
|
||||||
);
|
);
|
||||||
|
|
||||||
const emailCreate = mockRequest.mock.calls[0][0][0][1].create.draft;
|
const emailCreate = mockRequest.mock.calls[0][0][0][1].create.draft;
|
||||||
expect(emailCreate['header:In-Reply-To:asText']).toBe('<msg-1@example.com>');
|
// RFC 8621 §4.1.2.3: JMAP arrays of bare msg-ids, brackets stripped.
|
||||||
expect(emailCreate['header:References:asText']).toBe('<msg-0@example.com> <msg-1@example.com>');
|
expect(emailCreate.inReplyTo).toEqual(['msg-1@example.com']);
|
||||||
|
expect(emailCreate.references).toEqual(['msg-0@example.com', 'msg-1@example.com']);
|
||||||
|
expect(emailCreate['header:In-Reply-To:asText']).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,9 +36,15 @@ describe('identity operations', () => {
|
|||||||
describe('createIdentity', () => {
|
describe('createIdentity', () => {
|
||||||
it('should create a new identity', async () => {
|
it('should create a new identity', async () => {
|
||||||
const created = { id: 'id-new', name: 'Work', email: 'work@example.com' };
|
const created = { id: 'id-new', name: 'Work', email: 'work@example.com' };
|
||||||
mockRequest.mockResolvedValue({
|
// The create echo carries only server-set properties; the API re-reads
|
||||||
methodResponses: [['Identity/set', { created: { 'new-identity': created } }, '0']],
|
// the full identity afterwards.
|
||||||
});
|
mockRequest
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'id-new' } } }, '0']],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
methodResponses: [['Identity/get', { list: [created] }, '0']],
|
||||||
|
});
|
||||||
|
|
||||||
const result = await createIdentity({ name: 'Work', email: 'work@example.com' });
|
const result = await createIdentity({ name: 'Work', email: 'work@example.com' });
|
||||||
expect(result).toEqual(created);
|
expect(result).toEqual(created);
|
||||||
|
|||||||
+641
-225
File diff suppressed because it is too large
Load Diff
+35
-18
@@ -1,28 +1,43 @@
|
|||||||
import { jmapClient } from './jmap-client';
|
import { jmapClient } from './jmap-client';
|
||||||
|
import { assertSetResult, requireMethodResult } from './jmap-result';
|
||||||
import { CAPABILITIES } from './types';
|
import { CAPABILITIES } from './types';
|
||||||
import type { Identity } from './types';
|
import type { Identity } from './types';
|
||||||
|
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
||||||
|
|
||||||
export async function getIdentities(): Promise<Identity[]> {
|
const USING = [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION];
|
||||||
const accountId = jmapClient.accountId;
|
|
||||||
const res = await jmapClient.request(
|
// A display name that carries an address or a line break produces a malformed
|
||||||
[['Identity/get', { accountId }, '0']],
|
// `From:` on every message sent with the identity (#672); reduce it to the
|
||||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
// bare name before it reaches the server.
|
||||||
);
|
function sanitizeIdentityPatch(identity: Partial<Identity>): Partial<Identity> {
|
||||||
return res.methodResponses[0][1].list;
|
if (typeof identity.name !== 'string') return identity;
|
||||||
|
return { ...identity, name: sanitizeDisplayName(identity.name) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIdentities(accountIdOverride?: string): Promise<Identity[]> {
|
||||||
|
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||||
|
const res = await jmapClient.request([['Identity/get', { accountId }, '0']], USING);
|
||||||
|
const body = requireMethodResult<{ list?: Identity[] }>(res, '0', 'Identity/get');
|
||||||
|
return body.list ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createIdentity(
|
export async function createIdentity(
|
||||||
identity: Partial<Identity>,
|
identity: Partial<Identity>,
|
||||||
): Promise<Identity> {
|
): Promise<Identity> {
|
||||||
const accountId = jmapClient.accountId;
|
const accountId = jmapClient.accountId;
|
||||||
|
const cid = 'new-identity';
|
||||||
const res = await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[['Identity/set', {
|
[['Identity/set', { accountId, create: { [cid]: sanitizeIdentityPatch(identity) } }, '0']],
|
||||||
accountId,
|
USING,
|
||||||
create: { 'new-identity': identity },
|
|
||||||
}, '0']],
|
|
||||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
|
||||||
);
|
);
|
||||||
return res.methodResponses[0][1].created['new-identity'];
|
const body = requireMethodResult(res, '0', 'Identity/set');
|
||||||
|
assertSetResult(body, [cid], 'identity');
|
||||||
|
const created = body.created?.[cid] as Partial<Identity> | undefined;
|
||||||
|
if (!created?.id) throw new Error('Identity/set returned no id');
|
||||||
|
// The create echo carries only server-set properties; re-read the full
|
||||||
|
// object so the caller gets name/email/signature back.
|
||||||
|
const all = await getIdentities();
|
||||||
|
return all.find((i) => i.id === created.id) ?? ({ ...identity, ...created } as Identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateIdentity(
|
export async function updateIdentity(
|
||||||
@@ -30,16 +45,18 @@ export async function updateIdentity(
|
|||||||
changes: Partial<Identity>,
|
changes: Partial<Identity>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const accountId = jmapClient.accountId;
|
const accountId = jmapClient.accountId;
|
||||||
await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[['Identity/set', { accountId, update: { [id]: changes } }, '0']],
|
[['Identity/set', { accountId, update: { [id]: sanitizeIdentityPatch(changes) } }, '0']],
|
||||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
USING,
|
||||||
);
|
);
|
||||||
|
assertSetResult(requireMethodResult(res, '0', 'Identity/set'), [id], 'identity');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteIdentity(id: string): Promise<void> {
|
export async function deleteIdentity(id: string): Promise<void> {
|
||||||
const accountId = jmapClient.accountId;
|
const accountId = jmapClient.accountId;
|
||||||
await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[['Identity/set', { accountId, destroy: [id] }, '0']],
|
[['Identity/set', { accountId, destroy: [id] }, '0']],
|
||||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
USING,
|
||||||
);
|
);
|
||||||
|
assertSetResult(requireMethodResult(res, '0', 'Identity/set'), [id], 'identity');
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-82
@@ -110,88 +110,9 @@ function fetchSessionDescribes(session: unknown): session is JMAPSession {
|
|||||||
return typeof s.apiUrl === 'string';
|
return typeof s.apiUrl === 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared response helpers ──────────────────────────────
|
// Shared response helpers live in ./jmap-result (dependency-free) and are
|
||||||
// Most RN api functions used to index `methodResponses[0][1]` blindly, so a
|
// re-exported here for convenience.
|
||||||
// method-level `['error', {...}]` reply produced a TypeError on reads and was
|
export { JMAPMethodError, requireMethodResult, assertSetResult, batched } from './jmap-result';
|
||||||
// silently treated as success on writes. Route every response through these.
|
|
||||||
|
|
||||||
type MethodResponse = [string, Record<string, any>, string];
|
|
||||||
|
|
||||||
export class JMAPMethodError extends Error {
|
|
||||||
type: string;
|
|
||||||
constructor(type: string, description?: string) {
|
|
||||||
super(description || type || 'JMAP method error');
|
|
||||||
this.name = 'JMAPMethodError';
|
|
||||||
this.type = type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pull the response body for `callId` (or the first response when omitted)
|
|
||||||
* and throw a `JMAPMethodError` when the server answered with the error
|
|
||||||
* envelope. Also verifies the method name when `expectMethod` is given.
|
|
||||||
*/
|
|
||||||
export function requireMethodResult<T = Record<string, any>>(
|
|
||||||
res: JMAPResponseBody,
|
|
||||||
callId?: string,
|
|
||||||
expectMethod?: string,
|
|
||||||
): T {
|
|
||||||
const responses = (res.methodResponses ?? []) as MethodResponse[];
|
|
||||||
const match = callId === undefined
|
|
||||||
? responses[0]
|
|
||||||
: responses.find((r) => r[2] === callId);
|
|
||||||
if (!match) {
|
|
||||||
throw new JMAPMethodError('missingResponse', `Missing JMAP response${callId !== undefined ? ` for call ${callId}` : ''}`);
|
|
||||||
}
|
|
||||||
if (match[0] === 'error') {
|
|
||||||
const err = match[1] as { type?: string; description?: string };
|
|
||||||
throw new JMAPMethodError(err.type ?? 'serverFail', err.description);
|
|
||||||
}
|
|
||||||
if (expectMethod && match[0] !== expectMethod) {
|
|
||||||
throw new JMAPMethodError('unexpectedMethod', `Expected ${expectMethod}, got ${match[0]}`);
|
|
||||||
}
|
|
||||||
return match[1] as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Throw when a /set response reports the given ids (or any id when `ids` is
|
|
||||||
* omitted) under `notCreated` / `notUpdated` / `notDestroyed`.
|
|
||||||
*/
|
|
||||||
export function assertSetResult(
|
|
||||||
body: Record<string, any>,
|
|
||||||
ids?: string[],
|
|
||||||
what = 'object',
|
|
||||||
): void {
|
|
||||||
const buckets = ['notCreated', 'notUpdated', 'notDestroyed'] as const;
|
|
||||||
for (const bucket of buckets) {
|
|
||||||
const failures = body?.[bucket] as
|
|
||||||
| Record<string, { type?: string; description?: string; properties?: string[] }>
|
|
||||||
| undefined;
|
|
||||||
if (!failures) continue;
|
|
||||||
const entries = Object.entries(failures).filter(([id]) => !ids || ids.includes(id));
|
|
||||||
if (entries.length === 0) continue;
|
|
||||||
const [id, err] = entries[0];
|
|
||||||
const verb = bucket === 'notCreated' ? 'create' : bucket === 'notUpdated' ? 'update' : 'destroy';
|
|
||||||
const detail = [err?.type ?? 'unknown'];
|
|
||||||
if (err?.properties?.length) detail.push(`properties=[${err.properties.join(', ')}]`);
|
|
||||||
if (err?.description) detail.push(err.description);
|
|
||||||
const more = entries.length > 1 ? ` (+${entries.length - 1} more)` : '';
|
|
||||||
throw new JMAPMethodError(
|
|
||||||
err?.type ?? 'setError',
|
|
||||||
`Failed to ${verb} ${what} ${id}${more}: ${detail.join(' – ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Split `items` into consecutive batches of at most `size` entries. */
|
|
||||||
export function batched<T>(items: T[], size: number): T[][] {
|
|
||||||
const step = Math.max(1, Math.floor(size));
|
|
||||||
const result: T[][] = [];
|
|
||||||
for (let i = 0; i < items.length; i += step) {
|
|
||||||
result.push(items.slice(i, i + step));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class JMAPClient {
|
export class JMAPClient {
|
||||||
private session: JMAPSession | null = null;
|
private session: JMAPSession | null = null;
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Shared JMAP response helpers. Kept dependency-free (no client import) so
|
||||||
|
// every `src/api/*.ts` module - and their tests, which mock `./jmap-client`
|
||||||
|
// wholesale - can use them.
|
||||||
|
//
|
||||||
|
// Most RN api functions used to index `methodResponses[0][1]` blindly, so a
|
||||||
|
// method-level `['error', {...}]` reply produced a TypeError on reads and was
|
||||||
|
// silently treated as success on writes (the outbox then dropped the op).
|
||||||
|
// Route every response through these instead.
|
||||||
|
|
||||||
|
import type { JMAPResponseBody } from './types';
|
||||||
|
|
||||||
|
type MethodResponse = [string, Record<string, any>, string];
|
||||||
|
|
||||||
|
export class JMAPMethodError extends Error {
|
||||||
|
type: string;
|
||||||
|
constructor(type: string, description?: string) {
|
||||||
|
super(description || type || 'JMAP method error');
|
||||||
|
this.name = 'JMAPMethodError';
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the response body for `callId` (or the first response when omitted)
|
||||||
|
* and throw a `JMAPMethodError` when the server answered with the error
|
||||||
|
* envelope. Also verifies the method name when `expectMethod` is given.
|
||||||
|
*/
|
||||||
|
export function requireMethodResult<T = Record<string, any>>(
|
||||||
|
res: JMAPResponseBody,
|
||||||
|
callId?: string,
|
||||||
|
expectMethod?: string,
|
||||||
|
): T {
|
||||||
|
const responses = (res?.methodResponses ?? []) as MethodResponse[];
|
||||||
|
const match = callId === undefined
|
||||||
|
? responses[0]
|
||||||
|
: responses.find((r) => r[2] === callId);
|
||||||
|
if (!match) {
|
||||||
|
throw new JMAPMethodError(
|
||||||
|
'missingResponse',
|
||||||
|
`Missing JMAP response${callId !== undefined ? ` for call ${callId}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (match[0] === 'error') {
|
||||||
|
const err = match[1] as { type?: string; description?: string };
|
||||||
|
throw new JMAPMethodError(err.type ?? 'serverFail', err.description);
|
||||||
|
}
|
||||||
|
if (expectMethod && match[0] !== expectMethod) {
|
||||||
|
throw new JMAPMethodError('unexpectedMethod', `Expected ${expectMethod}, got ${match[0]}`);
|
||||||
|
}
|
||||||
|
return match[1] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw when a /set response reports the given ids (or any id when `ids` is
|
||||||
|
* omitted) under `notCreated` / `notUpdated` / `notDestroyed`.
|
||||||
|
*/
|
||||||
|
export function assertSetResult(
|
||||||
|
body: Record<string, any>,
|
||||||
|
ids?: string[],
|
||||||
|
what = 'object',
|
||||||
|
): void {
|
||||||
|
const buckets = ['notCreated', 'notUpdated', 'notDestroyed'] as const;
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
const failures = body?.[bucket] as
|
||||||
|
| Record<string, { type?: string; description?: string; properties?: string[] }>
|
||||||
|
| undefined;
|
||||||
|
if (!failures) continue;
|
||||||
|
const entries = Object.entries(failures).filter(([id]) => !ids || ids.includes(id));
|
||||||
|
if (entries.length === 0) continue;
|
||||||
|
const [id, err] = entries[0];
|
||||||
|
const verb = bucket === 'notCreated' ? 'create' : bucket === 'notUpdated' ? 'update' : 'destroy';
|
||||||
|
const detail = [err?.type ?? 'unknown'];
|
||||||
|
if (err?.properties?.length) detail.push(`properties=[${err.properties.join(', ')}]`);
|
||||||
|
if (err?.description) detail.push(err.description);
|
||||||
|
const more = entries.length > 1 ? ` (+${entries.length - 1} more)` : '';
|
||||||
|
throw new JMAPMethodError(
|
||||||
|
err?.type ?? 'setError',
|
||||||
|
`Failed to ${verb} ${what} ${id}${more}: ${detail.join(' – ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split `items` into consecutive batches of at most `size` entries. */
|
||||||
|
export function batched<T>(items: T[], size: number): T[][] {
|
||||||
|
const step = Math.max(1, Math.floor(size));
|
||||||
|
const result: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += step) {
|
||||||
|
result.push(items.slice(i, i + step));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Naming one member of an object in a JMAP PatchObject. Port of the webmail's
|
||||||
|
* lib/jmap/patch-pointer.ts.
|
||||||
|
*
|
||||||
|
* A PatchObject key is a JSON Pointer (RFC 8620 section 5.3), so a slash in the
|
||||||
|
* member's own name starts a new path segment unless it is escaped. That bites
|
||||||
|
* keywords: a nested tag is stored as `$label:work/clients`, and the unescaped
|
||||||
|
* pointer `keywords/$label:work/clients` asks for the `clients` member of the
|
||||||
|
* `$label:work` keyword - not a boolean, so the patch is rejected or ignored
|
||||||
|
* and the tag never lands.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One JSON Pointer reference token: `~` -> `~0`, `/` -> `~1` (RFC 6901). */
|
||||||
|
export function pointerToken(value: string): string {
|
||||||
|
return value.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The member name a reference token stands for: the inverse of
|
||||||
|
* `pointerToken`. `~1` is read before `~0`, per RFC 6901 - the other order
|
||||||
|
* would turn the escaped `~1` of `~01` back into a slash.
|
||||||
|
*/
|
||||||
|
export function pointerTokenValue(token: string): string {
|
||||||
|
return token.replace(/~1/g, '/').replace(/~0/g, '~');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The PatchObject key that sets or clears the keyword `keyword`. */
|
||||||
|
export function keywordPointer(keyword: string): string {
|
||||||
|
return `keywords/${pointerToken(keyword)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The PatchObject key that adds or removes membership of `mailboxId`. */
|
||||||
|
export function mailboxPointer(mailboxId: string): string {
|
||||||
|
return `mailboxIds/${pointerToken(mailboxId)}`;
|
||||||
|
}
|
||||||
@@ -78,6 +78,14 @@ export interface Email {
|
|||||||
subject?: string;
|
subject?: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
hasAttachment: boolean;
|
hasAttachment: boolean;
|
||||||
|
// RFC 8621 §4.1.2.3 - bare msg-ids without angle brackets. Needed for
|
||||||
|
// reply threading (In-Reply-To / References) and read-receipt dedupe.
|
||||||
|
messageId?: string[] | null;
|
||||||
|
inReplyTo?: string[] | null;
|
||||||
|
references?: string[] | null;
|
||||||
|
// Raw headers (RFC 8621 §4.1.3) when fetched with `headers`; the viewer
|
||||||
|
// derives SPF/DKIM/DMARC chips, List-Unsubscribe, MDN requests from them.
|
||||||
|
headers?: Array<{ name: string; value: string }>;
|
||||||
textBody?: BodyPart[];
|
textBody?: BodyPart[];
|
||||||
htmlBody?: BodyPart[];
|
htmlBody?: BodyPart[];
|
||||||
bodyValues?: Record<string, { value: string; isEncodingProblem?: boolean }>;
|
bodyValues?: Record<string, { value: string; isEncodingProblem?: boolean }>;
|
||||||
@@ -357,6 +365,12 @@ export interface ContactCard {
|
|||||||
prodId?: string;
|
prodId?: string;
|
||||||
created?: string;
|
created?: string;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
|
// Client-only metadata for cards that live in a shared / group account.
|
||||||
|
// Never sent to the server (api/contacts.ts strips them). `id` is
|
||||||
|
// namespaced as `<accountId>:<originalId>` for shared cards.
|
||||||
|
accountId?: string;
|
||||||
|
accountName?: string;
|
||||||
|
isShared?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddressBookRights {
|
export interface AddressBookRights {
|
||||||
@@ -374,6 +388,15 @@ export interface AddressBook {
|
|||||||
isSubscribed?: boolean;
|
isSubscribed?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
myRights?: AddressBookRights;
|
myRights?: AddressBookRights;
|
||||||
|
// RFC 9610 share map: principal id → rights.
|
||||||
|
shareWith?: Record<string, AddressBookRights> | null;
|
||||||
|
// Client-only metadata for books that live in a shared / group account.
|
||||||
|
// `id` is namespaced as `<accountId>:<originalId>` for shared books so it
|
||||||
|
// cannot collide with the user's own "default" book.
|
||||||
|
originalId?: string;
|
||||||
|
accountId?: string;
|
||||||
|
accountName?: string;
|
||||||
|
isShared?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddressBookWithCount extends AddressBook {
|
export interface AddressBookWithCount extends AddressBook {
|
||||||
@@ -393,6 +416,8 @@ export interface Participant {
|
|||||||
roles?: Record<string, boolean>;
|
roles?: Record<string, boolean>;
|
||||||
participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated' | string;
|
participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated' | string;
|
||||||
participationComment?: string;
|
participationComment?: string;
|
||||||
|
// 'server' asks Stalwart to deliver the iTIP messages for this participant.
|
||||||
|
scheduleAgent?: 'server' | 'client' | 'none' | string;
|
||||||
scheduleStatus?: string[];
|
scheduleStatus?: string[];
|
||||||
expectReply?: boolean;
|
expectReply?: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -602,6 +627,17 @@ export interface PushSubscription {
|
|||||||
verificationCode?: string | null;
|
verificationCode?: string | null;
|
||||||
expires?: string | null;
|
expires?: string | null;
|
||||||
types?: string[] | null;
|
types?: string[] | null;
|
||||||
|
emailPush?: Record<string, EmailPushConfig> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// draft-ietf-jmap-emailpush EmailPushConfig: the server evaluates `filter`
|
||||||
|
// against every newly delivered message and only pushes when it matches, so a
|
||||||
|
// client can keep spam (Junk) out of its push channel server-side. Keyed by
|
||||||
|
// account id on PushSubscription.emailPush.
|
||||||
|
export interface EmailPushConfig {
|
||||||
|
filter: Record<string, unknown> | null;
|
||||||
|
properties: string[];
|
||||||
|
urgency?: 'very-low' | 'low' | 'normal' | 'high';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Capability URNs ────────────────────────────────────
|
// ─── Capability URNs ────────────────────────────────────
|
||||||
@@ -618,4 +654,5 @@ export const CAPABILITIES = {
|
|||||||
FILES: 'urn:ietf:params:jmap:filenode',
|
FILES: 'urn:ietf:params:jmap:filenode',
|
||||||
PRINCIPALS: 'urn:ietf:params:jmap:principals',
|
PRINCIPALS: 'urn:ietf:params:jmap:principals',
|
||||||
PRINCIPALS_OWNER: 'urn:ietf:params:jmap:principals:owner',
|
PRINCIPALS_OWNER: 'urn:ietf:params:jmap:principals:owner',
|
||||||
|
EMAIL_PUSH: 'urn:ietf:params:jmap:emailpush',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user