mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
Stalwart runs the SMTP RCPT stage while it creates a submission and
records a refused recipient as `delivered: "no"` with its reply: a relay
it doesn't allow ("550 5.1.2 Relay not allowed.") or a used-up throttle
("452 4.4.5 Rate limit exceeded, try again later."), on 0.16 and 1.0
alike. The create still succeeds and onSuccessUpdateEmail files the
message in Sent. Bulwark read only the new record's id and sendAt, said
"Email sent", and on Stalwart destroyed the record right after, the only
trace of the refusal. The message was lost without a bounce.
- the send reads the new submission's delivery status in the same
request (`EmailSubmission/get` by its creation id; the create's answer
has none), before the record is destroyed
- every recipient refused: the send fails with the server's reply per
address, the copy filed in Sent is removed and the user's draft kept,
so the address can be fixed in the composer; held sends (undo send,
scheduled) are refused when created, so this covers them too
- some recipients refused: the others get the message, and a warning
names the refused ones with their replies
- "Send now" and "Reschedule" keep the original when every recipient of
the replacement is refused, instead of cancelling it
- the submission prune leaves a shared account that refuses its records
alone for the session, without a warning: 0.16 lists an account that
only shared an address book or a calendar with the submission
capability
1141 lines
35 KiB
TypeScript
1141 lines
35 KiB
TypeScript
export interface EmailHeader {
|
|
name: string;
|
|
value: string;
|
|
}
|
|
|
|
/**
|
|
* 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 it took the submission; the others
|
|
* get the message. When it refused every one, the send throws
|
|
* RecipientsRefusedError instead.
|
|
*/
|
|
refusedRecipients?: RefusedRecipient[];
|
|
}
|
|
|
|
/** A recipient the server refused to take a message for (EmailSubmission deliveryStatus `delivered: "no"`). */
|
|
export interface RefusedRecipient {
|
|
email: string;
|
|
/** The server's SMTP reply, e.g. "550 5.1.2 Relay not allowed." */
|
|
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;
|
|
};
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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[];
|
|
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.
|
|
*/
|
|
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
|
|
'junk', 'sent', 'archive', 'trash', 'drafts',
|
|
]);
|