Files
jmap-webmail/lib/jmap/client.ts
T
Matthieu MALVACHE 17abe7ff56 feat(contacts): add address book with JMAP sync and local fallback
Implement Phase 1 of contacts support — the biggest missing feature
on the roadmap. Uses RFC 9553 (JSContact) data model and RFC 9610
(JMAP for Contacts) when the server advertises the capability,
falling back to localStorage for servers without contact support.

- JMAP types for ContactCard, AddressBook, NameComponent, etc.
- JMAP client methods: CRUD, search, address book listing
- Refactored request() to accept dynamic `using` capabilities
- Zustand contact store with dual-mode persistence
- Full contacts page with two-column layout (list + detail/edit)
- Contact form with multi-email/phone, context labels, validation
- Composer autocomplete on To/Cc/Bcc with keyboard nav and ARIA
- Sidebar navigation link to contacts
- Auth integration: fetch on login, clear on logout
- i18n: contacts.* namespace across all 8 languages
2026-02-16 17:07:45 +01:00

1855 lines
55 KiB
TypeScript

import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook } from "./types";
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
apiUrl: string;
downloadUrl: string;
uploadUrl?: string;
eventSourceUrl?: string;
primaryAccounts?: Record<string, string>;
accounts?: Record<string, JMAPAccount>;
capabilities?: Record<string, unknown>;
}
interface JMAPAccount {
name?: string;
isPersonal?: boolean;
isReadOnly?: boolean;
accountCapabilities?: Record<string, unknown>;
}
interface JMAPQuota {
resourceType?: string;
scope?: string;
used?: number;
hardLimit?: number;
limit?: number;
}
interface JMAPMailbox {
id: string;
name: string;
parentId?: string | null;
role?: string | null;
totalEmails?: number;
unreadEmails?: number;
totalThreads?: number;
unreadThreads?: number;
sortOrder?: number;
isSubscribed?: boolean;
myRights?: Record<string, boolean>;
}
interface JMAPEmailHeader {
name: string;
value: string;
}
// Generic JMAP method call type
type JMAPMethodCall = [string, Record<string, unknown>, string];
// JMAP response types - using flexible types due to protocol variations
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type JMAPResponseResult = Record<string, any>;
interface JMAPResponse {
methodResponses: Array<[string, JMAPResponseResult, string]>;
}
export class JMAPClient {
private serverUrl: string;
private username: string;
private password: string;
private authHeader: string;
private apiUrl: string = "";
private accountId: string = "";
private downloadUrl: string = "";
private capabilities: Record<string, unknown> = {};
private session: JMAPSession | null = null;
private lastPingTime: number = 0;
private pingInterval: NodeJS.Timeout | null = null;
private accounts: Record<string, JMAPAccount> = {}; // All accounts (primary + shared)
private eventSource: EventSource | null = null;
private stateChangeCallback: ((change: StateChange) => void) | null = null;
private lastStates: AccountStates = {};
constructor(serverUrl: string, username: string, password: string) {
this.serverUrl = serverUrl.replace(/\/$/, '');
this.username = username;
this.password = password;
this.authHeader = `Basic ${btoa(`${username}:${password}`)}`;
}
async connect(): Promise<void> {
// Get the session first
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
try {
const sessionResponse = await fetch(sessionUrl, {
method: 'GET',
headers: {
'Authorization': this.authHeader,
},
});
if (!sessionResponse.ok) {
if (sessionResponse.status === 401) {
throw new Error('Invalid username or password');
}
throw new Error(`Failed to get session: ${sessionResponse.status}`);
}
const session = await sessionResponse.json();
// Store the full session for reference
this.session = session;
// Extract and store capabilities
this.capabilities = session.capabilities || {};
// Extract the API URL
this.apiUrl = session.apiUrl;
// Extract the download URL
this.downloadUrl = session.downloadUrl;
// Extract and store all accounts (primary + shared)
this.accounts = session.accounts || {};
// Extract the primary account ID
const mailAccount = session.primaryAccounts?.["urn:ietf:params:jmap:mail"];
if (mailAccount) {
this.accountId = mailAccount;
} else {
// Try to find any account
if (this.accounts && Object.keys(this.accounts).length > 0) {
this.accountId = Object.keys(this.accounts)[0];
} else {
throw new Error('No mail account found in session');
}
}
// Start keep-alive mechanism
this.startKeepAlive();
} catch (error) {
console.error('Connection failed:', error);
throw error;
}
}
private startKeepAlive(): void {
// Stop any existing interval
this.stopKeepAlive();
// Ping every 30 seconds to keep the connection alive
const PING_INTERVAL = 30000; // 30 seconds
this.pingInterval = setInterval(async () => {
try {
await this.ping();
} catch (error) {
console.error('Keep-alive ping failed:', error);
// If ping fails, try to reconnect
try {
await this.reconnect();
} catch (reconnectError) {
console.error('Reconnection failed:', reconnectError);
}
}
}, PING_INTERVAL);
}
private stopKeepAlive(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
async ping(): Promise<void> {
if (!this.apiUrl) {
throw new Error('Not connected');
}
const now = Date.now();
// Use Echo method for lightweight ping
const response = await this.request([
["Core/echo", { ping: "pong" }, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Core/echo") {
this.lastPingTime = now;
} else {
throw new Error('Ping failed');
}
}
async reconnect(): Promise<void> {
await this.connect();
}
disconnect(): void {
this.stopKeepAlive();
this.closePushNotifications();
this.apiUrl = "";
this.accountId = "";
this.session = null;
this.capabilities = {};
}
private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise<JMAPResponse> {
if (!this.apiUrl) {
throw new Error('Not connected. Call connect() first.');
}
const requestBody = {
using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
methodCalls: methodCalls,
};
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Authorization': this.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
const responseText = await response.text();
if (!response.ok) {
console.error('Request failed:', response.status, responseText);
throw new Error(`Request failed: ${response.status} - ${responseText.substring(0, 200)}`);
}
let data;
try {
data = JSON.parse(responseText);
} catch {
console.error('Failed to parse response:', responseText);
throw new Error('Invalid JSON response from server');
}
return data;
}
async getQuota(): Promise<{ used: number; total: number } | null> {
try {
const response = await this.request([
["Quota/get", {
accountId: this.accountId,
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Quota/get") {
const quotas = (response.methodResponses[0][1].list || []) as JMAPQuota[];
// Find the mail quota if it exists
const mailQuota = quotas.find((q) => q.resourceType === "mail" || q.scope === "mail");
if (mailQuota) {
return {
used: mailQuota.used ?? 0,
total: mailQuota.hardLimit ?? mailQuota.limit ?? 0
};
}
}
return null;
} catch {
return null;
}
}
async getMailboxes(): Promise<Mailbox[]> {
try {
const response = await this.request([
["Mailbox/get", {
accountId: this.accountId,
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
// Map and ensure all required fields are present
const mailboxes = rawMailboxes.map((mb) => {
return {
id: mb.id,
originalId: undefined, // Primary account uses original IDs
name: mb.name,
parentId: mb.parentId || undefined,
role: mb.role || undefined,
sortOrder: mb.sortOrder ?? 0,
totalEmails: mb.totalEmails ?? 0,
unreadEmails: mb.unreadEmails ?? 0,
totalThreads: mb.totalThreads ?? 0,
unreadThreads: mb.unreadThreads ?? 0,
myRights: mb.myRights || {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
},
isSubscribed: mb.isSubscribed ?? true,
// Account info for primary account
accountId: this.accountId,
accountName: this.accounts[this.accountId]?.name || this.username,
isShared: false,
} as Mailbox;
});
return mailboxes;
}
throw new Error('Unexpected response format');
} catch (error) {
console.error('Failed to get mailboxes:', error);
// Return default inbox with all required fields
return [{
id: 'INBOX',
originalId: undefined,
name: 'Inbox',
role: 'inbox',
sortOrder: 0,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
myRights: {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
},
isSubscribed: true,
accountId: this.accountId,
accountName: this.username,
isShared: false,
}] as Mailbox[];
}
}
async getAllMailboxes(): Promise<Mailbox[]> {
try {
const allMailboxes: Mailbox[] = [];
// Get all account IDs
const accountIds = Object.keys(this.accounts);
// If no accounts, fallback to primary only
if (accountIds.length === 0) {
return this.getMailboxes();
}
// Fetch mailboxes for each account
for (const accountId of accountIds) {
const account = this.accounts[accountId];
const isPrimary = accountId === this.accountId;
try {
const response = await this.request([
["Mailbox/get", {
accountId: accountId,
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
// Map mailboxes with account info
const mailboxes = rawMailboxes.map((mb) => {
return {
id: isPrimary ? mb.id : `${accountId}:${mb.id}`, // Namespace shared mailbox IDs
originalId: mb.id, // Keep original ID for JMAP queries
name: mb.name,
parentId: mb.parentId ? (isPrimary ? mb.parentId : `${accountId}:${mb.parentId}`) : undefined,
role: mb.role || undefined,
sortOrder: mb.sortOrder ?? 0,
totalEmails: mb.totalEmails ?? 0,
unreadEmails: mb.unreadEmails ?? 0,
totalThreads: mb.totalThreads ?? 0,
unreadThreads: mb.unreadThreads ?? 0,
myRights: mb.myRights || {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
},
isSubscribed: mb.isSubscribed ?? true,
// Account info
accountId: accountId,
accountName: account?.name || (isPrimary ? this.username : accountId),
isShared: !isPrimary,
} as Mailbox;
});
allMailboxes.push(...mailboxes);
}
} catch (error) {
console.error(`Failed to fetch mailboxes for account ${accountId}:`, error);
// Continue with other accounts even if one fails
}
}
return allMailboxes;
} catch (error) {
console.error("Failed to fetch all mailboxes:", error);
// Fallback to primary account mailboxes
return this.getMailboxes();
}
}
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
try {
// Use provided accountId or fallback to primary account
const targetAccountId = accountId || this.accountId;
// Build filter - only add inMailbox if we have a mailboxId
const filter: { inMailbox?: string } = {};
if (mailboxId && mailboxId !== '') {
filter.inMailbox = mailboxId;
}
const response = await this.request([
["Email/query", {
accountId: targetAccountId,
filter: filter,
sort: [{ property: "receivedAt", isAscending: false }],
limit: limit,
position: position,
}, "0"],
["Email/get", {
accountId: targetAccountId,
"#ids": {
resultOf: "0",
name: "Email/query",
path: "/ids",
},
properties: [
"id",
"threadId",
"mailboxIds",
"keywords",
"size",
"receivedAt",
"from",
"to",
"cc",
"subject",
"preview",
"hasAttachment",
],
}, "1"],
]);
const queryResponse = response.methodResponses?.[0]?.[1];
const getResponse = response.methodResponses?.[1]?.[1];
if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) {
const emails = getResponse.list || [];
// Stalwart doesn't return 'total', so we use a different strategy:
// If we got exactly 'limit' emails, there might be more
// If we got fewer, we've reached the end
const total = queryResponse?.total || 0;
const hasMore = total > 0
? (position + emails.length) < total // Use total if available
: emails.length === limit; // Otherwise, check if we got a full page
// If fetching from a shared account, namespace the mailboxIds to match our store
const isSharedAccount = accountId && accountId !== this.accountId;
if (isSharedAccount) {
emails.forEach((email: Email) => {
if (email.mailboxIds) {
const namespacedMailboxIds: Record<string, boolean> = {};
Object.keys(email.mailboxIds).forEach(mbId => {
namespacedMailboxIds[`${accountId}:${mbId}`] = email.mailboxIds[mbId];
});
email.mailboxIds = namespacedMailboxIds;
}
});
}
return { emails, hasMore, total };
}
return { emails: [], hasMore: false, total: 0 };
} catch (error) {
console.error('Failed to get emails:', error);
return { emails: [], hasMore: false, total: 0 };
}
}
async getEmail(emailId: string, accountId?: string): Promise<Email | null> {
try {
// Use provided accountId or fallback to primary account
const targetAccountId = accountId || this.accountId;
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: [emailId],
properties: [
"id",
"threadId",
"mailboxIds",
"keywords",
"size",
"receivedAt",
"sentAt",
"from",
"to",
"cc",
"bcc",
"replyTo",
"subject",
"preview",
"textBody",
"htmlBody",
"bodyValues",
"hasAttachment",
"attachments",
"messageId",
"inReplyTo",
"references",
"headers",
],
fetchTextBodyValues: true,
fetchHTMLBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 256000,
}, "0"],
]);
if (response.methodResponses?.[0]?.[0] === "Email/get") {
const emails = response.methodResponses[0][1].list || [];
const email = emails[0];
if (email) {
// If fetching from a shared account, namespace the mailboxIds to match our store
const isSharedAccount = accountId && accountId !== this.accountId;
if (isSharedAccount && email.mailboxIds) {
const namespacedMailboxIds: Record<string, boolean> = {};
Object.keys(email.mailboxIds).forEach(mbId => {
namespacedMailboxIds[`${accountId}:${mbId}`] = email.mailboxIds[mbId];
});
email.mailboxIds = namespacedMailboxIds;
}
// Parse headers if available
if (email.headers) {
// Import the parsing functions
const { parseAuthenticationResults, parseSpamScore, parseSpamLLM } = await import('@/lib/email-headers');
// Convert headers array to Record format if needed
let headersRecord: Record<string, string | string[]>;
if (Array.isArray(email.headers)) {
headersRecord = {};
(email.headers as JMAPEmailHeader[]).forEach((header) => {
if (header && header.name && header.value) {
// If header already exists, convert to array or append
if (headersRecord[header.name]) {
if (Array.isArray(headersRecord[header.name])) {
(headersRecord[header.name] as string[]).push(header.value);
} else {
headersRecord[header.name] = [headersRecord[header.name] as string, header.value];
}
} else {
headersRecord[header.name] = header.value;
}
}
});
// Replace array with record for easier access
email.headers = headersRecord;
} else {
headersRecord = email.headers as Record<string, string | string[]>;
}
// Parse Authentication-Results header
const authResultsHeader = headersRecord['Authentication-Results'];
if (authResultsHeader) {
const headerValue = Array.isArray(authResultsHeader) ? authResultsHeader[0] : authResultsHeader;
email.authenticationResults = parseAuthenticationResults(headerValue);
}
// Parse Spam headers
const spamHeaders = ['X-Spam-Status', 'X-Spam-Result', 'X-Rspamd-Score'];
for (const header of spamHeaders) {
if (headersRecord[header]) {
const headerValue = Array.isArray(headersRecord[header]) ? headersRecord[header][0] : headersRecord[header];
const spamResult = parseSpamScore(headerValue as string);
if (spamResult) {
email.spamScore = spamResult.score;
email.spamStatus = spamResult.status;
break;
}
}
}
// Parse X-Spam-LLM header
if (headersRecord['X-Spam-LLM']) {
const llmHeader = Array.isArray(headersRecord['X-Spam-LLM'])
? headersRecord['X-Spam-LLM'][0]
: headersRecord['X-Spam-LLM'];
const llmResult = parseSpamLLM(llmHeader as string);
if (llmResult) {
email.spamLLM = llmResult;
}
}
}
return email;
}
return null;
}
return null;
} catch (error) {
console.error('Failed to get email:', error);
return null;
}
}
async markAsRead(emailId: string, read: boolean = true, accountId?: string): Promise<void> {
// Use provided accountId or fallback to primary account
const targetAccountId = accountId || this.accountId;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
"keywords/$seen": read,
},
},
}, "0"],
]);
}
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> {
if (emailIds.length === 0) return;
const updates: Record<string, { "keywords/$seen": boolean }> = {};
emailIds.forEach(id => {
updates[id] = {
"keywords/$seen": read,
};
});
await this.request([
["Email/set", {
accountId: this.accountId,
update: updates,
}, "0"],
]);
}
async toggleStar(emailId: string, starred: boolean): Promise<void> {
await this.request([
["Email/set", {
accountId: this.accountId,
update: {
[emailId]: {
"keywords/$flagged": starred,
},
},
}, "0"],
]);
}
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void> {
await this.request([
["Email/set", {
accountId: this.accountId,
update: {
[emailId]: {
keywords,
},
},
}, "0"],
]);
}
async deleteEmail(emailId: string): Promise<void> {
await this.request([
["Email/set", {
accountId: this.accountId,
destroy: [emailId],
}, "0"],
]);
}
async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [trashMailboxId]: true },
},
},
}, "0"],
]);
}
async batchDeleteEmails(emailIds: string[]): Promise<void> {
if (emailIds.length === 0) return;
await this.request([
["Email/set", {
accountId: this.accountId,
destroy: emailIds,
}, "0"],
]);
}
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
if (emailIds.length === 0) return;
const updates: Record<string, { mailboxIds: Record<string, boolean> }> = {};
emailIds.forEach(id => {
updates[id] = {
mailboxIds: { [toMailboxId]: true },
};
});
await this.request([
["Email/set", {
accountId: this.accountId,
update: updates,
}, "0"],
]);
}
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
await this.request([
["Email/set", {
accountId: this.accountId,
update: {
[emailId]: {
mailboxIds: { [toMailboxId]: true },
},
},
}, "0"],
]);
}
/**
* Move email to Junk folder
*/
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
const mailboxes = await this.getMailboxes();
const junkMailbox = mailboxes.find(m => {
if (accountId) {
return m.role === 'junk' && m.accountId === accountId;
}
return m.role === 'junk' && !m.isShared;
});
if (!junkMailbox) {
throw new Error('Junk mailbox not found');
}
const mailboxId = accountId && junkMailbox.originalId
? junkMailbox.originalId
: junkMailbox.id;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [mailboxId]: true },
},
},
}, "0"],
]);
}
/**
* Undo spam - move email back from Junk to original mailbox
*/
async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [originalMailboxId]: true },
},
},
}, "0"],
]);
}
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
try {
// Use provided accountId or fallback to primary account
const targetAccountId = accountId || this.accountId;
// Build filter with text search, optionally scoped to a mailbox
const filter: Record<string, unknown> = { text: query };
if (mailboxId) {
filter.inMailbox = mailboxId;
}
const response = await this.request([
["Email/query", {
accountId: targetAccountId,
filter: filter,
sort: [{ property: "receivedAt", isAscending: false }],
limit: limit,
position: position,
}, "0"],
["Email/get", {
accountId: targetAccountId,
"#ids": {
resultOf: "0",
name: "Email/query",
path: "/ids",
},
properties: [
"id",
"threadId",
"mailboxIds",
"keywords",
"size",
"receivedAt",
"from",
"to",
"cc",
"subject",
"preview",
"hasAttachment",
],
}, "1"],
]);
const queryResponse = response.methodResponses?.[0]?.[1];
const emails = response.methodResponses?.[1]?.[1]?.list || [];
// Stalwart doesn't always return 'total', so we use a different strategy:
// If we got exactly 'limit' emails, there might be more
// If we got fewer, we've reached the end
const total = queryResponse?.total || 0;
const hasMore = total > 0
? (position + emails.length) < total // Use total if available
: emails.length === limit; // Otherwise, check if we got a full page
return { emails, hasMore, total };
} catch (error) {
console.error('Search failed:', error);
return { emails: [], hasMore: false, total: 0 };
}
}
// Thread methods for conversation view
async getThread(threadId: string, accountId?: string): Promise<Thread | null> {
try {
const targetAccountId = accountId || this.accountId;
const response = await this.request([
["Thread/get", {
accountId: targetAccountId,
ids: [threadId],
}, "0"],
]);
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
const threads = response.methodResponses[0][1].list || [];
return threads[0] || null;
}
return null;
} catch (error) {
console.error('Failed to get thread:', error);
return null;
}
}
async getThreadEmails(threadId: string, accountId?: string): Promise<Email[]> {
try {
const targetAccountId = accountId || this.accountId;
// First get the thread to find all email IDs
const thread = await this.getThread(threadId, accountId);
if (!thread || !thread.emailIds || thread.emailIds.length === 0) {
return [];
}
// Fetch all emails in the thread
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: thread.emailIds,
properties: [
"id",
"threadId",
"mailboxIds",
"keywords",
"size",
"receivedAt",
"from",
"to",
"cc",
"subject",
"preview",
"hasAttachment",
],
}, "0"],
]);
if (response.methodResponses?.[0]?.[0] === "Email/get") {
const emails = response.methodResponses[0][1].list || [];
// If fetching from a shared account, namespace the mailboxIds
const isSharedAccount = accountId && accountId !== this.accountId;
if (isSharedAccount) {
emails.forEach((email: Email) => {
if (email.mailboxIds) {
const namespacedMailboxIds: Record<string, boolean> = {};
Object.keys(email.mailboxIds).forEach(mbId => {
namespacedMailboxIds[`${accountId}:${mbId}`] = email.mailboxIds[mbId];
});
email.mailboxIds = namespacedMailboxIds;
}
});
}
// Sort by receivedAt descending (newest first)
return emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
}
return [];
} catch (error) {
console.error('Failed to get thread emails:', error);
return [];
}
}
async getIdentities(): Promise<Identity[]> {
try {
const response = await this.request([
["Identity/get", {
accountId: this.accountId,
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (response.methodResponses[0][1].list || []) as Identity[];
return identities;
}
return [];
} catch (error) {
console.error('Failed to get identities:', error);
return [];
}
}
async createIdentity(
name: string,
email: string,
replyTo?: EmailAddress[],
bcc?: EmailAddress[],
textSignature?: string,
htmlSignature?: string
): Promise<Identity> {
const response = await this.request([
["Identity/set", {
accountId: this.accountId,
create: {
"new-identity": {
name,
email,
replyTo,
bcc,
textSignature,
htmlSignature,
}
}
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
const result = response.methodResponses[0][1];
// Check for errors
if (result.notCreated?.["new-identity"]) {
const error = result.notCreated["new-identity"];
if (error.type === "forbidden") {
throw new Error("You are not authorized to send from this email address");
}
throw new Error(error.description || "Failed to create identity");
}
// Return created identity
const createdId = result.created?.["new-identity"]?.id;
if (createdId) {
// Fetch the full identity object
const identities = await this.getIdentities();
const identity = identities.find(i => i.id === createdId);
if (identity) return identity;
}
}
throw new Error("Failed to create identity: Server response was unexpected. Check server logs.");
}
async updateIdentity(
identityId: string,
updates: {
name?: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
}
): Promise<void> {
const response = await this.request([
["Identity/set", {
accountId: this.accountId,
update: {
[identityId]: updates
}
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
const result = response.methodResponses[0][1];
// Check for errors
if (result.notUpdated?.[identityId]) {
const error = result.notUpdated[identityId];
if (error.type === "notFound") {
throw new Error("Identity not found (may have been deleted)");
}
if (error.type === "forbidden") {
throw new Error("You are not authorized to modify this identity");
}
throw new Error(error.description || "Failed to update identity");
}
return;
}
throw new Error("Failed to update identity: Server response was unexpected. Check server logs.");
}
async deleteIdentity(identityId: string): Promise<void> {
const response = await this.request([
["Identity/set", {
accountId: this.accountId,
destroy: [identityId]
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
const result = response.methodResponses[0][1];
// Check for errors
if (result.notDestroyed?.[identityId]) {
const error = result.notDestroyed[identityId];
if (error.type === "forbidden") {
throw new Error("This identity cannot be deleted");
}
if (error.type === "notFound") {
throw new Error("Identity not found (may already be deleted)");
}
throw new Error(error.description || "Failed to delete identity");
}
return;
}
throw new Error("Failed to delete identity: Server response was unexpected. Check server logs.");
}
async createDraft(
to: string[],
subject: string,
body: string,
cc?: string[],
bcc?: string[],
identityId?: string,
fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>
): Promise<string> {
// Find the drafts mailbox
const mailboxes = await this.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
if (!draftsMailbox) {
throw new Error('No drafts mailbox found');
}
const emailId = `draft-${Date.now()}`;
// Build email object with attachments if provided
interface EmailDraft {
from: { email: string }[];
to: { email: string }[];
cc?: { email: string }[];
bcc?: { email: string }[];
subject: string;
keywords: Record<string, boolean>;
mailboxIds: Record<string, boolean>;
bodyValues: Record<string, { value: string }>;
textBody: { partId: string }[];
attachments?: { blobId: string; type: string; name: string; disposition: string }[];
}
const emailData: EmailDraft = {
from: [{ email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
subject: subject,
keywords: { "$draft": true },
mailboxIds: { [draftsMailbox.id]: true },
bodyValues: {
"1": {
value: body,
},
},
textBody: [
{
partId: "1",
},
],
};
// Add attachments if provided
if (attachments && attachments.length > 0) {
emailData.attachments = attachments.map(att => ({
blobId: att.blobId,
type: att.type,
name: att.name,
disposition: "attachment",
}));
}
// If updating an existing draft, destroy it first then create new one
// This is simpler than trying to update individual fields
const methodCalls: JMAPMethodCall[] = [];
if (draftId) {
// Delete old draft
methodCalls.push(["Email/set", {
accountId: this.accountId,
destroy: [draftId],
}, "0"]);
// Create new draft
methodCalls.push(["Email/set", {
accountId: this.accountId,
create: {
[emailId]: emailData
},
}, "1"]);
} else {
// Just create new draft
methodCalls.push(["Email/set", {
accountId: this.accountId,
create: {
[emailId]: emailData
},
}, "0"]);
}
const response = await this.request(methodCalls);
// If we're updating (destroy + create), check the second response
// Otherwise check the first response
const responseIndex = draftId ? 1 : 0;
if (response.methodResponses?.[responseIndex]?.[0] === "Email/set") {
const result = response.methodResponses[responseIndex][1];
// Check for errors
if (result.notCreated || result.notUpdated) {
const errors = result.notCreated || result.notUpdated;
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
console.error('Draft save error:', firstError);
throw new Error(firstError?.description || firstError?.type || 'Failed to save draft');
}
if (result.created?.[emailId]) {
return result.created[emailId].id;
}
}
console.error('Unexpected draft save response:', response);
throw new Error('Failed to save draft');
}
async sendEmail(
to: string[],
subject: string,
body: string,
cc?: string[],
bcc?: string[],
identityId?: string,
fromEmail?: string,
draftId?: string
): Promise<void> {
const emailId = draftId || `draft-${Date.now()}`;
// Find the Sent mailbox
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (!sentMailbox) {
throw new Error('No sent mailbox found');
}
// Use provided identity ID or fetch from server as fallback
let finalIdentityId = identityId;
if (!finalIdentityId) {
const identityResponse = await this.request([
["Identity/get", {
accountId: this.accountId,
}, "0"]
]);
finalIdentityId = this.accountId; // fallback
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
if (identities.length > 0) {
// Use the first identity (or find one matching the fromEmail/username)
const matchingIdentity = identities.find((id) => id.email === (fromEmail || this.username));
finalIdentityId = matchingIdentity?.id || identities[0].id;
}
}
}
const methodCalls: JMAPMethodCall[] = [];
// If we have a draftId, update it and remove draft keyword, move to Sent
// Otherwise, create a new email in Sent
if (draftId) {
methodCalls.push(["Email/set", {
accountId: this.accountId,
update: {
[draftId]: {
"keywords/$draft": false,
"keywords/$seen": true,
mailboxIds: { [sentMailbox.id]: true },
},
},
}, "0"]);
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: {
"1": {
emailId: draftId,
identityId: finalIdentityId,
},
},
}, "1"]);
} else {
methodCalls.push(["Email/set", {
accountId: this.accountId,
create: {
[emailId]: {
from: [{ email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
subject: subject,
keywords: { "$seen": true },
mailboxIds: { [sentMailbox.id]: true },
bodyValues: {
"1": {
value: body,
},
},
textBody: [
{
partId: "1",
},
],
},
},
}, "0"]);
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: {
"1": {
emailId: `#${emailId}`,
identityId: finalIdentityId,
},
},
}, "1"]);
}
const response = await this.request(methodCalls);
// Check for errors in the response
if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) {
if (methodName.endsWith('/error')) {
console.error('JMAP method error:', result);
throw new Error(result.description || `Failed to send email: ${result.type}`);
}
// Check for notCreated/notUpdated
if (result.notCreated || result.notUpdated) {
const errors = result.notCreated || result.notUpdated;
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
console.error('Email send error:', firstError);
throw new Error(firstError?.description || firstError?.type || 'Failed to send email');
}
}
}
}
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
if (!this.session) {
throw new Error('Not connected. Call connect() first.');
}
// Get upload URL from session
const uploadUrl = this.session.uploadUrl;
if (!uploadUrl) {
throw new Error('Upload URL not available');
}
// Replace accountId in the upload URL
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(this.accountId));
console.log('Uploading file to:', finalUploadUrl);
console.log('File info:', { name: file.name, size: file.size, type: file.type });
const response = await fetch(finalUploadUrl, {
method: 'POST',
headers: {
'Authorization': this.authHeader,
'Content-Type': file.type || 'application/octet-stream',
},
body: file, // Send the file directly as binary
});
console.log('Upload response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('Upload failed:', errorText);
throw new Error(`Failed to upload file: ${response.status} - ${errorText}`);
}
const responseText = await response.text();
console.log('Upload response body:', responseText);
let result;
try {
result = JSON.parse(responseText);
console.log('Parsed upload response:', JSON.stringify(result, null, 2));
} catch {
console.error('Failed to parse upload response as JSON:', responseText);
throw new Error('Invalid JSON response from upload');
}
// Try different response formats
// Format 1: Direct response { blobId, type, size }
if (result.blobId) {
console.log('Using direct response format');
return {
blobId: result.blobId,
size: result.size || file.size,
type: result.type || file.type,
};
}
// Format 2: Nested under accountId { accountId: { blobId, type, size } }
const blobInfo = result[this.accountId];
if (blobInfo && blobInfo.blobId) {
console.log('Using accountId-nested response format');
return {
blobId: blobInfo.blobId,
size: blobInfo.size || file.size,
type: blobInfo.type || file.type,
};
}
// If neither format works, show what we got
console.error('Unexpected upload response format:', result);
throw new Error('Invalid upload response: blobId not found');
}
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
if (!this.downloadUrl) {
throw new Error('Download URL not available. Please reconnect.');
}
// The downloadUrl is a URI Template (RFC 6570 level 1) with variables
// like {accountId}, {blobId}, {name}, and {type}
let url = this.downloadUrl;
// Replace template variables with actual values
url = url.replace('{accountId}', encodeURIComponent(this.accountId));
url = url.replace('{blobId}', encodeURIComponent(blobId));
// Replace {name} - use a default if not provided
const fileName = name || 'download';
url = url.replace('{name}', encodeURIComponent(fileName));
// Replace {type} - URL encode it since it may contain slashes (e.g., "application/pdf")
// If type is not provided, use a generic binary type
const mimeType = type || 'application/octet-stream';
url = url.replace('{type}', encodeURIComponent(mimeType));
return url;
}
// Capability checking methods
getCapabilities(): Record<string, unknown> {
return this.capabilities;
}
hasCapability(capability: string): boolean {
return capability in this.capabilities;
}
getMaxSizeUpload(): number {
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxSizeUpload?: number } | undefined;
return coreCapability?.maxSizeUpload || 0;
}
getMaxCallsInRequest(): number {
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxCallsInRequest?: number } | undefined;
return coreCapability?.maxCallsInRequest || 50;
}
getEventSourceUrl(): string | null {
const session = this.session;
if (!session) {
return null;
}
// RFC 8620: eventSourceUrl is at session root level
if (session.eventSourceUrl) {
return session.eventSourceUrl;
}
// Some servers may put it in capabilities
const coreCapability = session.capabilities?.["urn:ietf:params:jmap:core"] as { eventSourceUrl?: string } | undefined;
if (coreCapability?.eventSourceUrl) {
return coreCapability.eventSourceUrl;
}
return null;
}
getAccountId(): string {
return this.accountId;
}
supportsEmailSubmission(): boolean {
return this.hasCapability("urn:ietf:params:jmap:submission");
}
supportsQuota(): boolean {
return this.hasCapability("urn:ietf:params:jmap:quota");
}
supportsVacationResponse(): boolean {
return this.hasCapability("urn:ietf:params:jmap:vacationresponse");
}
supportsContacts(): boolean {
return this.hasCapability("urn:ietf:params:jmap:contacts");
}
getContactsAccountId(): string {
const contactsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:contacts"];
return contactsAccount || this.accountId;
}
private contactUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
}
async getAddressBooks(): Promise<AddressBook[]> {
try {
const accountId = this.getContactsAccountId();
const response = await this.request([
["AddressBook/get", { accountId }, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
return (response.methodResponses[0][1].list || []) as AddressBook[];
}
return [];
} catch (error) {
console.error('Failed to get address books:', error);
return [];
}
}
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
try {
const accountId = this.getContactsAccountId();
const methodCalls: JMAPMethodCall[] = [];
if (addressBookId) {
methodCalls.push(
["ContactCard/query", {
accountId,
filter: { inAddressBook: addressBookId },
limit: 1000,
}, "0"],
["ContactCard/get", {
accountId,
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
}, "1"]
);
} else {
methodCalls.push(
["ContactCard/query", { accountId, limit: 1000 }, "0"],
["ContactCard/get", {
accountId,
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
}, "1"]
);
}
const response = await this.request(methodCalls, this.contactUsing());
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
return (response.methodResponses[1][1].list || []) as ContactCard[];
}
return [];
} catch (error) {
console.error('Failed to get contacts:', error);
return [];
}
}
async getContact(contactId: string): Promise<ContactCard | null> {
try {
const accountId = this.getContactsAccountId();
const response = await this.request([
["ContactCard/get", {
accountId,
ids: [contactId],
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
const list = response.methodResponses[0][1].list || [];
return list[0] || null;
}
return null;
} catch (error) {
console.error('Failed to get contact:', error);
return null;
}
}
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
const accountId = this.getContactsAccountId();
// If no addressBookIds provided, get default address book
let addressBookIds = contact.addressBookIds;
if (!addressBookIds || Object.keys(addressBookIds).length === 0) {
const books = await this.getAddressBooks();
const defaultBook = books.find(b => b.isDefault) || books[0];
if (defaultBook) {
addressBookIds = { [defaultBook.id]: true };
}
}
const response = await this.request([
["ContactCard/set", {
accountId,
create: {
"new-contact": {
...contact,
addressBookIds,
}
}
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
const result = response.methodResponses[0][1];
if (result.notCreated?.["new-contact"]) {
const error = result.notCreated["new-contact"];
throw new Error(error.description || "Failed to create contact");
}
const createdId = result.created?.["new-contact"]?.id;
if (createdId) {
const created = await this.getContact(createdId);
if (created) return created;
}
}
throw new Error("Failed to create contact");
}
async updateContact(contactId: string, updates: Partial<ContactCard>): Promise<void> {
const accountId = this.getContactsAccountId();
const response = await this.request([
["ContactCard/set", {
accountId,
update: {
[contactId]: updates
}
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
const result = response.methodResponses[0][1];
if (result.notUpdated?.[contactId]) {
const error = result.notUpdated[contactId];
throw new Error(error.description || "Failed to update contact");
}
return;
}
throw new Error("Failed to update contact");
}
async deleteContact(contactId: string): Promise<void> {
const accountId = this.getContactsAccountId();
const response = await this.request([
["ContactCard/set", {
accountId,
destroy: [contactId]
}, "0"]
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
const result = response.methodResponses[0][1];
if (result.notDestroyed?.[contactId]) {
const error = result.notDestroyed[contactId];
throw new Error(error.description || "Failed to delete contact");
}
return;
}
throw new Error("Failed to delete contact");
}
async searchContacts(query: string): Promise<ContactCard[]> {
try {
const accountId = this.getContactsAccountId();
const response = await this.request([
["ContactCard/query", {
accountId,
filter: { text: query },
limit: 50,
}, "0"],
["ContactCard/get", {
accountId,
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
}, "1"]
], this.contactUsing());
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
return (response.methodResponses[1][1].list || []) as ContactCard[];
}
return [];
} catch (error) {
console.error('Failed to search contacts:', error);
return [];
}
}
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await fetch(url, {
headers: {
'Authorization': this.authHeader,
},
});
if (!response.ok) {
throw new Error(`Failed to download attachment: ${response.status}`);
}
// Get the blob from the response
const blob = await response.blob();
// Create a temporary URL for the blob
const blobUrl = URL.createObjectURL(blob);
// Create a temporary anchor element and trigger download
const a = document.createElement('a');
a.href = blobUrl;
a.download = name || 'download';
document.body.appendChild(a);
a.click();
// Clean up
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}
// Real-time Updates via Polling (EventSource has auth limitations with Basic Auth)
private pollingInterval: NodeJS.Timeout | null = null;
private pollingStates: { [key: string]: string } = {};
setupPushNotifications(): boolean {
// Use polling instead of EventSource due to Basic Auth limitations
// EventSource can't send Authorization headers, and URL-embedded credentials
// get decoded by browsers, breaking auth for usernames/passwords with special chars
// Initial state fetch
this.fetchCurrentStates();
// Set up polling interval
this.pollingInterval = setInterval(() => {
this.checkForStateChanges();
}, 15000); // Poll every 15 seconds
return true;
}
private async fetchCurrentStates(): Promise<void> {
try {
// Get current states from server using JMAP query
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': this.authHeader,
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
],
}),
});
if (response.ok) {
const data = await response.json();
// Extract states from response
for (const [method, result] of data.methodResponses) {
if (method === 'Mailbox/get' && result.state) {
this.pollingStates['Mailbox'] = result.state;
}
if (method === 'Email/get' && result.state) {
this.pollingStates['Email'] = result.state;
}
}
}
} catch {
// Silently fail - polling will retry
}
}
private async checkForStateChanges(): Promise<void> {
try {
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': this.authHeader,
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
],
}),
});
if (response.ok) {
const data = await response.json();
const changes: { [key: string]: string } = {};
let hasChanges = false;
for (const [method, result] of data.methodResponses) {
if (method === 'Mailbox/get' && result.state) {
if (this.pollingStates['Mailbox'] && this.pollingStates['Mailbox'] !== result.state) {
changes['Mailbox'] = result.state;
hasChanges = true;
}
this.pollingStates['Mailbox'] = result.state;
}
if (method === 'Email/get' && result.state) {
if (this.pollingStates['Email'] && this.pollingStates['Email'] !== result.state) {
changes['Email'] = result.state;
hasChanges = true;
}
this.pollingStates['Email'] = result.state;
}
}
if (hasChanges && this.stateChangeCallback) {
this.stateChangeCallback({
'@type': 'StateChange',
changed: {
[this.accountId]: changes,
},
});
}
}
} catch {
// Silently fail - polling will retry
}
}
closePushNotifications(): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.stateChangeCallback = null;
this.pollingStates = {};
}
onStateChange(callback: (change: StateChange) => void): void {
this.stateChangeCallback = callback;
}
getLastStates(): AccountStates {
return { ...this.lastStates };
}
setLastStates(states: AccountStates): void {
this.lastStates = { ...states };
}
}