Files
webmail/lib/jmap/types.ts
T

1158 lines
35 KiB
TypeScript

/**
* A session-visible account (the user's own primary account plus any
* shared/group accounts delegated to them), tagged with the capabilities it
* advertises. Produced by client.getSharedAccounts(); drives the settings
* "Shared with me" list and the scoped-settings tab gating.
*/
export interface SharedAccount {
id: string;
name: string;
isPrimary: boolean;
capabilities: {
mail: boolean;
sieve: boolean;
calendars: boolean;
contacts: boolean;
filenode: boolean;
};
}
/**
* One account as the JMAP session describes it (RFC 8620 §2), unflattened:
* the raw per-account capability objects rather than SharedAccount's
* booleans, for callers that need a capability's fields.
*/
export interface AccountInfo {
/** Stalwart names an account after its principal's address, a group's too. */
name: string;
isPersonal: boolean;
isReadOnly: boolean;
accountCapabilities: Record<string, unknown>;
}
export interface Email {
id: string;
threadId: string;
mailboxIds: Record<string, boolean>;
keywords: Record<string, boolean>;
size: number;
receivedAt: string;
from?: EmailAddress[];
to?: EmailAddress[];
cc?: EmailAddress[];
bcc?: EmailAddress[];
replyTo?: EmailAddress[];
subject?: string;
sentAt?: string;
preview?: string;
// Server-side search highlighting (SearchSnippet/get, RFC 8621 §5): the
// subject / body excerpt with the matched terms in <mark>, set on search
// hits only. See lib/search-snippet.ts.
searchSnippet?: { subject: string | null; preview: string | null };
textBody?: EmailBodyPart[];
htmlBody?: EmailBodyPart[];
bodyValues?: Record<string, EmailBodyValue>;
attachments?: Attachment[];
hasAttachment: boolean;
// Extended header information
messageId?: string;
inReplyTo?: string[];
references?: string[];
headers?: Record<string, string | string[]>;
// Security headers parsed
authenticationResults?: AuthenticationResults;
spamScore?: number;
spamStatus?: string;
spamLLM?: {
verdict: string;
explanation: string;
};
// S/MIME support
blobId?: string;
bodyStructure?: EmailBodyPart;
// Unified mailbox support - set when displaying emails from multiple accounts.
// `accountId` is a DISPLAY-only reference (avatar color / label / badge) and may
// hold either an AccountEntry.id (personal) or the JMAP owner id (shared). For
// resolving the client + JMAP routing use the two dedicated fields below, which
// are always set on aggregated emails and unambiguous.
accountId?: string;
accountLabel?: string;
// AccountEntry.id of the logged-in client through which this email is reachable.
// Always a real login key → `useAuthStore.getClientForAccount(...)` resolves it.
// For personal sources this equals the account itself; for shared/group sources
// it is the delegating login (the shared account has no own login).
sourceClientAccountId?: string;
// JMAP account id of the email's owning account (personal: the client's primary;
// shared/group: the owner account). Always safe to pass as the JMAP `accountId`
// argument — equal to the client's primary for personal sources, so it is a no-op
// there, and triggers owner-scoped routing + mailbox-id namespacing for shared.
sourceAccountId?: string;
// Name of the email's originating folder, stamped for the aggregate "All …"
// views (All Mail, unified, cross-account) so the list can show where each
// message lives. Transient/client-only, not part of the JMAP object.
sourceFolder?: string;
// Client-only scheduled-send metadata, populated from EmailSubmission/query.
scheduledSendAt?: string;
emailSubmissionId?: string;
scheduledIdentityId?: string;
scheduledUndoStatus?: 'pending' | 'final' | 'canceled';
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
/**
* JMAP account that owns the EmailSubmission. Set when the scheduled send
* lives in a shared/group account rather than the primary submission
* account, so cancel/reschedule can be routed back to it.
*/
scheduledAccountId?: string;
isScheduled?: boolean;
isSmimeScheduled?: boolean;
}
export interface SendEmailResult {
scheduled: boolean;
emailId?: string;
emailSubmissionId?: string;
sendAt?: string;
isSmime?: boolean;
/**
* JMAP account the EmailSubmission was created in. Differs from the primary
* submission account when sending from a shared/group identity, and lets a
* later undo / send-now / cancel address the right account without having to
* search for it.
*/
submissionAccountId?: string;
/**
* Set when the submission succeeded but a post-send step was rejected
* (the implicit onSuccessUpdateEmail filing patch, or destroying the
* old draft). The mail left the server - callers should warn, not fail.
*/
filingError?: string;
/**
* Recipients the server refused while accepting the submission (its
* deliveryStatus says delivered "no"). The message went out to the others
* only - callers should name these instead of reporting a plain success.
* When it refused every one, the send throws RecipientsRejectedError
* instead.
*/
rejectedRecipients?: RejectedRecipient[];
}
/** A recipient refused at submission, with the SMTP reply the server gave. */
export interface RejectedRecipient {
email: string;
/** The server's SMTP reply, e.g. "550 5.1.2 Relay not allowed."; empty when it gave none. */
smtpReply: string;
}
export interface ScheduledEmail extends Email {
scheduledSendAt: string;
emailSubmissionId: string;
scheduledIdentityId: string;
scheduledUndoStatus: 'pending' | 'final' | 'canceled';
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
scheduledAccountId?: string;
isScheduled: true;
isSmimeScheduled: boolean;
}
export interface AuthenticationResults {
spf?: {
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
domain?: string;
ip?: string;
/**
* All SPF results when the server evaluated multiple identities (HELO and
* MAIL FROM). Present only when more than one result was found; `result`
* above is the most severe of these.
*/
all?: Array<{
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
domain?: string;
identity?: 'helo' | 'mailfrom';
}>;
};
dkim?: {
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
domain?: string;
selector?: string;
/**
* Every DKIM result when the message carried more than one signature;
* `result` above is the first.
*/
all?: Array<{
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
domain?: string;
selector?: string;
}>;
};
dmarc?: {
result: 'pass' | 'fail' | 'none';
policy?: 'reject' | 'quarantine' | 'none';
domain?: string;
};
iprev?: {
result: 'pass' | 'fail';
ip?: string;
};
}
export interface EmailBodyValue {
value: string;
isEncodingProblem?: boolean;
isTruncated?: boolean;
}
export interface EmailAddress {
name?: string;
email: string;
}
export interface EmailBodyPart {
partId: string;
blobId: string;
size: number;
name?: string;
type: string;
charset?: string;
disposition?: string;
cid?: string;
language?: string[];
location?: string;
subParts?: EmailBodyPart[];
}
export interface Attachment {
partId: string;
blobId: string;
size: number;
name?: string;
type: string;
charset?: string;
cid?: string;
disposition?: string;
}
export interface Mailbox {
id: string;
originalId?: string; // Original JMAP ID (for shared mailboxes)
name: string;
parentId?: string;
role?: string;
sortOrder: number;
totalEmails: number;
unreadEmails: number;
totalThreads: number;
unreadThreads: number;
myRights: MailboxRights;
isSubscribed: boolean;
// Sharing (urn:ietf:params:jmap:mail:share): principalId -> rights. Only
// present when explicitly requested (see getMailboxShareWith).
shareWith?: Record<string, MailboxRights> | null;
// Shared folder support
accountId?: string;
accountName?: string;
isShared?: boolean;
}
// Mailbox rights (RFC 8621 §2 myRights; `mayShare` from the mail:share
// extension). Also the value type of Mailbox.shareWith.
export interface MailboxRights {
mayReadItems: boolean;
mayAddItems: boolean;
mayRemoveItems: boolean;
maySetSeen: boolean;
maySetKeywords: boolean;
mayCreateChild: boolean;
mayRename: boolean;
mayDelete: boolean;
maySubmit: boolean;
mayShare?: boolean;
}
export interface Thread {
id: string;
emailIds: string[];
}
// Thread grouping for UI display
export interface ThreadGroup {
threadId: string; // JMAP thread id - unique only within its account; use for Thread/get
threadKey: string; // Client-side identity, unique across accounts (see threadKeyFor)
emails: Email[]; // Emails in this thread (sorted by receivedAt desc)
latestEmail: Email; // Most recent email
participantNames: string[];// Unique participant names
hasUnread: boolean; // Any unread emails in thread
hasStarred: boolean; // Any starred emails in thread
hasPinned: boolean; // Any pinned emails in thread ($pinned keyword)
hasAttachment: boolean; // Any email has attachment
hasAnswered: boolean; // Any email has been replied to
hasForwarded: boolean; // Any email has been forwarded
emailCount: number; // Total emails in thread
}
export interface Identity {
id: string;
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
mayDelete: boolean;
// See `Calendar.localAccountId` - set when the Pro shell aggregates
// identities from multiple connected accounts so we can route sends
// back through the owning JMAP client. `accountName` is the
// user-facing label for the dropdown's optgroup.
localAccountId?: string;
accountName?: string;
}
// RFC 9553 JSContact / RFC 9610 JMAP for Contacts
export interface ContactCard {
id: string;
originalId?: string;
uid?: string;
addressBookIds: Record<string, boolean>;
kind?: 'individual' | 'group' | 'org' | 'location' | 'device' | 'application';
accountId?: string;
accountName?: string;
isShared?: boolean;
// Local account-store ID - set when the Pro shell aggregates contacts
// from multiple connected accounts. See `Calendar.localAccountId`.
localAccountId?: string;
language?: string;
name?: ContactName;
nicknames?: Record<string, ContactNickname>;
emails?: Record<string, ContactEmail>;
phones?: Record<string, ContactPhone>;
onlineServices?: Record<string, ContactOnlineService>;
preferredLanguages?: Record<string, ContactLanguagePref>;
organizations?: Record<string, ContactOrganization>;
titles?: Record<string, ContactTitle>;
addresses?: Record<string, ContactAddress>;
anniversaries?: Record<string, ContactAnniversary>;
personalInfo?: Record<string, ContactPersonalInfo>;
notes?: Record<string, ContactNote>;
media?: Record<string, ContactMedia>;
cryptoKeys?: Record<string, ContactCryptoKey>;
directories?: Record<string, ContactDirectory>;
links?: Record<string, ContactLink>;
relatedTo?: Record<string, ContactRelation>;
keywords?: Record<string, boolean>;
members?: Record<string, boolean>;
speakToAs?: {
grammaticalGender?: string;
pronouns?: Record<string, { pronouns: string; pref?: number; contexts?: Record<string, boolean> }>;
};
calendars?: Record<string, { uri: string; kind?: 'calendar' | 'freeBusy'; mediaType?: string; contexts?: Record<string, boolean> }>;
schedulingAddresses?: Record<string, { uri: string; contexts?: Record<string, boolean>; pref?: number }>;
// Client-side flat views of `calendars`, `schedulingAddresses` and the
// `directories` entry (vCard CALURI / FBURL / CALADRURI / SOURCE). They are
// not RFC 9553 properties; lib/jmap/contact-wire.ts maps them.
calendarUri?: string;
schedulingUri?: string;
freeBusyUri?: string;
source?: string;
prodId?: string;
created?: string;
updated?: string;
}
export interface ContactName {
components?: NameComponent[];
isOrdered?: boolean;
full?: string;
defaultSeparator?: string;
}
export interface NameComponent {
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential' | 'title' | 'middle' | 'given2' | 'surname2' | 'generation';
value: string;
}
export interface ContactEmail {
address: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactPhone {
number: string;
contexts?: Record<string, boolean>;
features?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactOnlineService {
service?: string;
uri: string;
user?: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactLanguagePref {
language: string;
contexts?: Record<string, boolean>;
pref?: number;
}
export interface ContactOrganization {
name?: string;
units?: Array<{ name: string }>;
sortAs?: string;
}
export interface ContactTitle {
name: string;
kind?: 'title' | 'role';
organizationId?: string;
}
// RFC 9553 AddressComponent
export interface AddressComponent {
kind: 'room' | 'apartment' | 'floor' | 'building' | 'number' | 'name' | 'block' | 'subDistrict' | 'district' | 'locality' | 'region' | 'postcode' | 'country' | 'direction' | 'landmark' | 'postOfficeBox' | 'separator' | string;
value: string;
phonetic?: string;
}
export interface ContactAddress {
// RFC 9553 format
components?: AddressComponent[];
full?: string;
isOrdered?: boolean;
defaultSeparator?: string;
// Legacy flat fields (from vCard import)
street?: string;
locality?: string;
region?: string;
postcode?: string;
country?: string;
countryCode?: string;
fullAddress?: string;
coordinates?: string;
timeZone?: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactNickname {
name: string;
contexts?: Record<string, boolean>;
}
export interface ContactNote {
note: string;
created?: string;
author?: { name?: string; uri?: string };
}
// RFC 9553 Media. The bytes are either at `uri` (an https or data: URI) or in
// a blob: Stalwart 1.0 returns every embedded PHOTO, LOGO and SOUND as a
// `blobId` without `uri`, while 0.16 returns them as data: URIs.
export interface ContactMedia {
kind: 'photo' | 'sound' | 'logo';
uri?: string;
blobId?: string;
mediaType?: string;
label?: string;
pref?: number;
contexts?: Record<string, boolean>;
}
// RFC 9553 PartialDate
export interface PartialDate {
'@type'?: 'PartialDate';
year?: number;
month?: number;
day?: number;
calendarScale?: string;
}
// RFC 9553 Timestamp
export interface Timestamp {
'@type': 'Timestamp';
utc: string;
}
export type AnniversaryDate = string | PartialDate | Timestamp;
export interface ContactAnniversary {
'@type'?: 'Anniversary';
kind: 'birth' | 'death' | 'wedding' | 'other';
date: AnniversaryDate;
place?: ContactAddress;
}
export interface ContactPersonalInfo {
kind: 'expertise' | 'hobby' | 'interest' | 'other';
value: string;
level?: 'high' | 'medium' | 'low';
}
export interface ContactCryptoKey {
uri: string;
mediaType?: string;
contexts?: Record<string, boolean>;
}
export interface ContactDirectory {
uri: string;
kind?: 'directory' | 'entry';
mediaType?: string;
}
export interface ContactLink {
uri: string;
kind?: 'contact' | 'generic';
mediaType?: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactRelation {
relation?: Record<string, boolean>;
}
export interface AddressBook {
id: string;
originalId?: string;
name: string;
description?: string | null;
sortOrder?: number;
isDefault?: boolean;
isSubscribed?: boolean;
myRights?: AddressBookRights;
shareWith?: Record<string, AddressBookRights> | null;
accountId?: string;
accountName?: string;
isShared?: boolean;
// See `Calendar.localAccountId` - same purpose for address books.
localAccountId?: string;
}
export interface AddressBookRights {
mayRead: boolean;
mayWrite: boolean;
mayShare?: boolean;
mayDelete: boolean;
}
// JMAP Principals (RFC 9670)
export interface Principal {
id: string;
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
name: string;
description?: string | null;
email?: string | null;
timeZone?: string | null;
capabilities?: Record<string, unknown>;
accountId?: string;
}
/**
* How complete a principal listing is:
* - `ok`: the whole directory;
* - `restricted`: the server refused to list its directory (Stalwart 1.0 with
* directory queries off, or a role without the permission). The principals
* are what Principal/get without ids still returns, on 1.0 the user's own
* account, their groups and the accounts that shared something with them;
* - `error`: the listing failed part-way; the principals are what arrived.
*/
export type PrincipalDirectoryStatus = 'ok' | 'restricted' | 'error';
export interface PrincipalListing {
principals: Principal[];
status: PrincipalDirectoryStatus;
/**
* With `restricted`: the administrator turned directory queries off, as
* Stalwart 1.0 says in its refusal, rather than the user's role lacking
* the permission. 0.16 has no such refusal over JMAP.
*/
directoryQueriesOff?: boolean;
}
/**
* One busy interval from Principal/getAvailability (RFC 9670 §2.5 /
* draft-ietf-jmap-calendars). `busyStatus` null means the server did not
* classify the period (treated as busy).
*/
export interface BusyPeriod {
utcStart: string;
utcEnd: string;
busyStatus: 'confirmed' | 'tentative' | 'unavailable' | null;
}
/**
* One principal's answer to Principal/getAvailability: its busy periods, or
* the JMAP error type the call was refused with (`forbidden`, `rateLimit`,
* `notFound`, ...).
*/
export type PrincipalAvailability = { periods: BusyPeriod[] } | { error: string };
export interface VacationResponse {
id: string;
isEnabled: boolean;
fromDate: string | null;
toDate: string | null;
subject: string;
textBody: string;
htmlBody: string | null;
}
export interface EmailSubmission {
id: string;
identityId: string;
emailId: string;
threadId?: string;
envelope: {
mailFrom: EmailAddress;
rcptTo: EmailAddress[];
};
sendAt?: string;
undoStatus: "pending" | "final" | "canceled";
deliveryStatus?: Record<string, DeliveryStatus>;
dsnBlobIds?: string[];
mdnBlobIds?: string[];
}
export interface DeliveryStatus {
smtpReply: string;
delivered: "queued" | "yes" | "no" | "unknown";
displayed: "unknown" | "yes";
}
// JMAP Calendar Types (RFC 8984 JSCalendar + RFC 9553 JMAP Calendars)
export interface Calendar {
id: string;
originalId?: string;
name: string;
description: string | null;
color: string | null;
sortOrder: number;
isSubscribed: boolean;
isVisible: boolean;
isDefault: boolean;
includeInAvailability: 'all' | 'attending' | 'none';
defaultAlertsWithTime: Record<string, CalendarEventAlert> | null;
defaultAlertsWithoutTime: Record<string, CalendarEventAlert> | null;
timeZone: string | null;
shareWith: Record<string, CalendarRights> | null;
myRights: CalendarRights;
accountId?: string;
accountName?: string;
isShared?: boolean;
// Local account-store ID (per JMAP server connection). Populated when the
// Pro shell aggregates calendars from multiple connected accounts so we
// can route mutations to the right client. Distinct from `accountId`
// which is the JMAP server's own account UUID.
localAccountId?: string;
// Set when `color` has been replaced by the viewer's local override for a
// shared calendar (see lib/shared-calendar-colors). When true, the override
// wins over per-event colors so the whole shared calendar paints uniformly.
colorIsLocalOverride?: boolean;
// True when the calendar holds only tasks (VTODO) and no events, so it is
// hidden from the event calendar UI while remaining available to the tasks
// view (#761). Undefined means "not determined" (treated as not tasks-only).
isTasksOnly?: boolean;
}
/**
* iCalendar component types a calendar advertises through the CalDAV
* supported-calendar-component-set. Sync clients such as DAVx5 use it to
* decide whether a collection is offered to calendar apps, todo apps, or both
* (#760). Only settable while the collection is created.
*/
export type CalendarComponentType = 'VEVENT' | 'VTODO';
export interface CreateCalendarOptions {
/** Component set for the new calendar; defaults to events only (VEVENT). */
components?: CalendarComponentType[];
}
export interface CalendarRights {
mayReadFreeBusy: boolean;
mayReadItems: boolean;
mayWriteAll: boolean;
mayWriteOwn: boolean;
mayUpdatePrivate: boolean;
mayRSVP: boolean;
mayShare: boolean;
mayDelete: boolean;
}
export interface CalendarEvent {
id: string;
originalId?: string;
// Set by the server on every CalendarEvent/get result. For an occurrence
// handed out by CalendarEvent/query?expandRecurrences=true (a "synthetic"
// id) this is the id of the stored base event; for a base event it equals
// `id`. See lib/recurrence-instances.ts.
baseEventId?: string | null;
calendarIds: Record<string, boolean>;
originalCalendarIds?: Record<string, boolean>;
accountId?: string;
accountName?: string;
isShared?: boolean;
// See `Calendar.localAccountId` - same purpose for events.
localAccountId?: string;
isDraft: boolean;
isOrigin: boolean;
utcStart: string | null;
utcEnd: string | null;
'@type': 'Event';
uid: string;
title: string;
description: string;
descriptionContentType: string;
created: string | null;
updated: string;
sequence: number;
start: string;
duration: string;
timeZone: string | null;
showWithoutTime: boolean;
status: 'tentative' | 'confirmed' | 'cancelled';
freeBusyStatus: 'free' | 'busy';
privacy: 'public' | 'private' | 'secret';
color: string | null;
keywords: Record<string, boolean> | null;
categories: Record<string, boolean> | null;
locale: string | null;
replyTo: Record<string, string> | null;
organizerCalendarAddress: string | null;
participants: Record<string, CalendarParticipant> | null;
mayInviteSelf: boolean;
mayInviteOthers: boolean;
hideAttendees: boolean;
recurrenceId: string | null;
recurrenceIdTimeZone: string | null;
recurrenceRules: CalendarRecurrenceRule[] | null;
recurrenceOverrides: Record<string, Partial<CalendarEvent>> | null;
excludedRecurrenceRules: CalendarRecurrenceRule[] | null;
useDefaultAlerts: boolean;
alerts: Record<string, CalendarEventAlert> | null;
locations: Record<string, CalendarLocation> | null;
virtualLocations: Record<string, CalendarVirtualLocation> | null;
links: Record<string, CalendarLink> | null;
relatedTo: Record<string, CalendarRelation> | null;
}
export interface CalendarParticipant {
'@type': 'Participant';
name: string;
email: string;
calendarAddress: string | null;
description: string | null;
sendTo: Record<string, string> | null;
kind: 'individual' | 'group' | 'location' | 'resource';
roles: Record<string, boolean>;
participationStatus: 'accepted' | 'declined' | 'tentative' | 'delegated' | 'needs-action';
participationComment: string | null;
expectReply: boolean;
scheduleAgent: 'server' | 'client' | 'none';
scheduleForceSend: boolean;
scheduleId: string | null;
scheduleSequence: number;
scheduleStatus: string[] | null;
scheduleUpdated: string | null;
invitedBy: string | null;
delegatedTo: Record<string, boolean> | null;
delegatedFrom: Record<string, boolean> | null;
memberOf: Record<string, boolean> | null;
locationId: string | null;
language: string | null;
links: Record<string, CalendarLink> | null;
}
export interface CalendarRecurrenceRule {
'@type': 'RecurrenceRule';
frequency: 'yearly' | 'monthly' | 'weekly' | 'daily' | 'hourly' | 'minutely' | 'secondly';
interval: number;
// Optional and normally omitted: these RFC 7529 (RSCALE/SKIP) fields only
// apply to non-Gregorian scales / invalid-date handling. Emitting a default
// SKIP=OMIT breaks some CalDAV clients (DAVx5) - see lib/recurrence-rule.ts (#805).
rscale?: string;
skip?: 'omit' | 'backward' | 'forward';
firstDayOfWeek: 'mo' | 'tu' | 'we' | 'th' | 'fr' | 'sa' | 'su';
byDay: CalendarNDay[] | null;
byMonthDay: number[] | null;
byMonth: string[] | null;
byYearDay: number[] | null;
byWeekNo: number[] | null;
byHour: number[] | null;
byMinute: number[] | null;
bySecond: number[] | null;
bySetPosition: number[] | null;
count: number | null;
until: string | null;
}
export interface CalendarNDay {
day: string;
nthOfPeriod?: number;
}
export interface CalendarEventAlert {
'@type': 'Alert';
trigger: CalendarOffsetTrigger | CalendarAbsoluteTrigger;
action: 'display' | 'email';
acknowledged: string | null;
relatedTo: Record<string, CalendarRelation> | null;
}
export interface CalendarOffsetTrigger {
'@type': 'OffsetTrigger';
offset: string;
relativeTo: 'start' | 'end';
}
export interface CalendarAbsoluteTrigger {
'@type': 'AbsoluteTrigger';
when: string;
}
export interface CalendarLocation {
'@type': 'Location';
name: string;
description: string | null;
locationTypes: Record<string, boolean> | null;
coordinates: string | null;
timeZone: string | null;
links: Record<string, CalendarLink> | null;
relativeTo: 'start' | 'end' | null;
}
export interface CalendarVirtualLocation {
'@type': 'VirtualLocation';
name: string | null;
description: string | null;
uri: string;
features: Record<string, boolean> | null;
}
export interface CalendarLink {
'@type': 'Link';
href: string;
cid: string | null;
contentType: string | null;
size: number | null;
rel: string | null;
display: string | null;
title: string | null;
}
export interface CalendarRelation {
'@type': 'Relation';
relation: Record<string, boolean> | null;
}
export interface CalendarTask {
id: string;
calendarIds: Record<string, boolean>;
'@type': 'Task';
uid: string;
title: string;
description: string;
due: string | null;
start: string | null;
duration: string | null;
timeZone: string | null;
showWithoutTime: boolean;
progress: 'needs-action' | 'in-process' | 'completed' | 'cancelled';
progressUpdated?: string | null;
priority: number;
privacy: 'public' | 'private' | 'secret';
keywords: Record<string, boolean> | null;
categories: Record<string, boolean> | null;
color: string | null;
created: string | null;
updated: string;
recurrenceRules: CalendarRecurrenceRule[] | null;
// While on, Stalwart 1.0 fills the calendar's default alerts into `alerts`.
useDefaultAlerts?: boolean;
alerts: Record<string, CalendarEventAlert> | null;
relatedTo: Record<string, CalendarRelation> | null;
// Client-only, set on tasks from a shared calendar account. Their `id` and
// `calendarIds` are namespaced `accountId:id` like shared events.
originalId?: string;
accountId?: string;
isShared?: boolean;
}
/**
* A ParticipantIdentity (draft-ietf-jmap-calendars §6): one of the calendar
* addresses the user may act as when organising or replying to events. The
* server derives them from the account's addresses; the default one is what
* new invitations are sent from.
*/
export interface CalendarParticipantIdentity {
id: string;
name: string;
/** `mailto:` URI of the scheduling address. */
calendarAddress: string;
isDefault: boolean;
}
export interface CalendarEventNotification {
id: string;
created: string;
changedBy: {
name: string;
email: string;
principalId: string | null;
scheduleId: string | null;
};
comment: string | null;
type: 'created' | 'updated' | 'destroyed';
calendarEventId: string;
isDraft: boolean;
event?: CalendarEvent;
eventPatch?: Record<string, unknown>;
}
export interface CalendarEventFilter {
/**
* A FilterOperator (RFC 8620 §5.5) sets these and no property keys:
* Stalwart rejects a filter that mixes the two.
*/
operator?: 'AND' | 'OR' | 'NOT';
conditions?: CalendarEventFilter[];
inCalendars?: string[];
/** One calendar: what Stalwart (0.16 and 1.0) takes; 0.16 refuses `inCalendars`. */
inCalendar?: string;
after?: string;
before?: string;
text?: string;
title?: string;
description?: string;
location?: string;
owner?: string;
attendee?: string;
participationStatus?: string;
uid?: string;
types?: string[];
}
// JMAP Push Notification Types (RFC 8620 Section 7)
export interface StateChange {
'@type': 'StateChange';
changed: {
[accountId: string]: {
Email?: string;
Mailbox?: string;
Thread?: string;
EmailDelivery?: string;
EmailSubmission?: string;
Identity?: string;
ContactCard?: string;
AddressBook?: string;
Calendar?: string;
CalendarEvent?: string;
SieveScript?: string;
ShareNotification?: string;
CalendarEventNotification?: string;
};
};
}
/**
* A ShareNotification (RFC 9670 §3): someone changed this user's rights on
* one of their collections. `oldRights` null = newly shared, `newRights`
* null = access revoked.
*/
export interface ShareNotification {
id: string;
created: string;
changedBy: { name: string; email: string | null; principalId: string | null };
objectType: 'Mailbox' | 'Calendar' | 'AddressBook' | 'FileNode' | string;
objectAccountId: string;
objectId: string;
name: string;
oldRights: Record<string, boolean> | null;
newRights: Record<string, boolean> | 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';
}
export interface PushSubscription {
id: string;
deviceClientId: string;
url: string;
keys: {
p256dh: string;
auth: string;
} | null;
expires: string | null;
types: string[] | null;
// Only present when the server advertises urn:ietf:params:jmap:emailpush
// and the property was requested; null when the subscription has no
// per-account delivery filter.
emailPush?: Record<string, EmailPushConfig> | null;
}
// For tracking last known states
/**
* A `Foo/changes` response (RFC 8620 §5.2) as consumed by the stores' delta
* sync. `updatedProperties` is set when the server can tell that only those
* properties changed on the updated records (Stalwart reports the four
* Mailbox counters this way).
*/
export interface CollectionChanges {
oldState: string;
newState: string;
hasMoreChanges: boolean;
created: string[];
updated: string[];
destroyed: string[];
updatedProperties: string[] | null;
}
export interface AccountStates {
[accountId: string]: {
Email?: string;
Mailbox?: string;
Thread?: string;
};
}
// JMAP FileNode types (draft-ietf-jmap-filenode / Stalwart implementation)
export type FileNodeType = 'file' | 'directory' | 'symlink';
export interface FileNode {
id: string;
parentId: string | null;
name: string;
type: string; // "d" for directory, MIME type for files
// Returned by Stalwart 0.16.6+; 1.0 adds symlinks. Read it through
// fileNodeKind(), and never send it in FileNode/set.
nodeType?: FileNodeType;
blobId: string | null;
size: number;
created: string;
// Last content/metadata change, server-maintained. The property is named
// `modified` in draft-ietf-jmap-filenode and in Stalwart - there is no
// `updated` on a FileNode. Asking for the wrong name silently yields
// undefined, which made the UI show the creation date forever (#700).
modified: string;
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
// filenode capability and the properties are explicitly requested. A node is
// shared-out when `shareWith` has entries; `myRights` describes what the
// viewer may do (always full rights on owned nodes).
myRights?: FileNodeRights;
shareWith?: Record<string, FileNodeRights> | null;
// True when this node was fetched from another principal's account that was
// shared with the logged-in user (mirrors Calendar.isShared / AddressBook.isShared).
isShared?: boolean;
// A folder of another account that is listed only because something below
// it was shared with the user (Stalwart 1.0): name and place in the tree,
// no dates, no rights. It leads to the shared node but is not shown itself.
discoverableOnly?: boolean;
// Owning account's JMAP id and display name, set when aggregating nodes
// across connected/shared accounts so mutations route to the right account.
accountId?: string;
accountName?: string;
// Local account-store id (per JMAP connection) in multi-account contexts.
// See Calendar.localAccountId.
localAccountId?: string;
}
// FileNode rights as defined by Stalwart's JmapSharedObject implementation.
export interface FileNodeRights {
mayRead: boolean;
mayAddChildren: boolean;
mayRename: boolean;
mayDelete: boolean;
mayModifyContent: boolean;
mayShare: boolean;
}
export interface FileNodeFilter {
parentId?: string | null;
name?: string;
type?: string;
}
// Unified mailbox virtual IDs and types
export const UNIFIED_INBOX = '__unified_inbox__';
export const UNIFIED_SENT = '__unified_sent__';
export const UNIFIED_DRAFTS = '__unified_drafts__';
export const UNIFIED_TRASH = '__unified_trash__';
export const UNIFIED_ARCHIVE = '__unified_archive__';
export const UNIFIED_JUNK = '__unified_junk__';
export type UnifiedMailboxRole = 'inbox' | 'sent' | 'drafts' | 'trash' | 'archive' | 'junk';
export const UNIFIED_MAILBOX_IDS: Record<UnifiedMailboxRole, string> = {
inbox: UNIFIED_INBOX,
sent: UNIFIED_SENT,
drafts: UNIFIED_DRAFTS,
trash: UNIFIED_TRASH,
archive: UNIFIED_ARCHIVE,
junk: UNIFIED_JUNK,
};
export const UNIFIED_ROLE_BY_ID: Record<string, UnifiedMailboxRole> = Object.fromEntries(
Object.entries(UNIFIED_MAILBOX_IDS).map(([role, id]) => [id, role as UnifiedMailboxRole])
) as Record<string, UnifiedMailboxRole>;
export function isUnifiedMailboxId(id: string): boolean {
return id in UNIFIED_ROLE_BY_ID;
}
/**
* Cross views shown in the unified ("Unified Mailbox") section: All mail /
* Unread / Starred. Each merges messages across the account boundary (the active
* account + its shared folders by default, or every logged-in account when the
* cross-account sub-option is on), narrowed by the user's folder selection (see
* `allMailFolderIds`). Distinct from the per-role unified ids (one role across
* accounts).
*/
export const CROSS_UNREAD = '__cross_unread__';
export const CROSS_STARRED = '__cross_starred__';
export const CROSS_ALL = '__cross_all__';
export type CrossView = 'unread' | 'starred' | 'all';
export const CROSS_VIEW_IDS: Record<CrossView, string> = {
unread: CROSS_UNREAD,
starred: CROSS_STARRED,
all: CROSS_ALL,
};
export const CROSS_VIEW_BY_ID: Record<string, CrossView> = Object.fromEntries(
Object.entries(CROSS_VIEW_IDS).map(([view, id]) => [id, view as CrossView])
) as Record<string, CrossView>;
export function isCrossViewId(id: string): boolean {
return id in CROSS_VIEW_BY_ID;
}
/**
* Mailbox roles excluded from the cross-account views. Everything else (inbox
* and custom/no-role folders) is included. `all` (RFC 6154 \All) holds every
* message, so including it would bring back sent/archive mail and count inbox
* unread twice.
*/
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
'junk', 'sent', 'archive', 'trash', 'drafts', 'all',
]);