import { FastmailAuth } from './auth.js';
export interface JmapSession {
apiUrl: string;
accountId: string;
capabilities: Record<string, any>;
downloadUrl?: string;
uploadUrl?: string;
}
export interface JmapRequest {
using: string[];
methodCalls: [string, any, string][];
}
export interface JmapResponse {
methodResponses: Array<[string, any, string]>;
sessionState: string;
}
export class JmapClient {
private auth: FastmailAuth;
private session: JmapSession | null = null;
constructor(auth: FastmailAuth) {
this.auth = auth;
}
async getSession(): Promise<JmapSession> {
if (this.session) {
return this.session;
}
const response = await fetch(this.auth.getSessionUrl(), {
method: 'GET',
headers: this.auth.getAuthHeaders()
});
if (!response.ok) {
throw new Error(`Failed to get session: ${response.statusText}`);
}
const sessionData = await response.json() as any;
this.session = {
apiUrl: sessionData.apiUrl,
accountId: Object.keys(sessionData.accounts)[0],
capabilities: sessionData.capabilities,
downloadUrl: sessionData.downloadUrl,
uploadUrl: sessionData.uploadUrl
};
return this.session;
}
async getUserEmail(): Promise<string> {
try {
const identity = await this.getDefaultIdentity();
return identity?.email || 'user@example.com';
} catch (error) {
// Fallback if Identity/get is not available
return 'user@example.com';
}
}
async makeRequest(request: JmapRequest): Promise<JmapResponse> {
const session = await this.getSession();
const response = await fetch(session.apiUrl, {
method: 'POST',
headers: this.auth.getAuthHeaders(),
body: JSON.stringify(request)
});
if (!response.ok) {
throw new Error(`JMAP request failed: ${response.statusText}`);
}
return await response.json() as JmapResponse;
}
async getMailboxes(): Promise<any[]> {
const session = await this.getSession();
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Mailbox/get', { accountId: session.accountId }, 'mailboxes']
]
};
const response = await this.makeRequest(request);
return response.methodResponses[0][1].list;
}
async getEmails(mailboxId?: string, limit: number = 20): Promise<any[]> {
const session = await this.getSession();
const filter = mailboxId ? { inMailbox: mailboxId } : {};
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/query', {
accountId: session.accountId,
filter,
sort: [{ property: 'receivedAt', isAscending: false }],
limit
}, 'query'],
['Email/get', {
accountId: session.accountId,
'#ids': { resultOf: 'query', name: 'Email/query', path: '/ids' },
properties: ['id', 'subject', 'from', 'to', 'receivedAt', 'preview', 'hasAttachment']
}, 'emails']
]
};
const response = await this.makeRequest(request);
return response.methodResponses[1][1].list;
}
async getEmailById(id: string): Promise<any> {
const session = await this.getSession();
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/get', {
accountId: session.accountId,
ids: [id],
properties: ['id', 'subject', 'from', 'to', 'cc', 'bcc', 'receivedAt', 'textBody', 'htmlBody', 'attachments', 'bodyValues'],
bodyProperties: ['partId', 'blobId', 'type', 'size'],
fetchTextBodyValues: true,
fetchHTMLBodyValues: true,
}, 'email']
]
};
const response = await this.makeRequest(request);
const result = response.methodResponses[0][1];
if (result.notFound && result.notFound.includes(id)) {
throw new Error(`Email with ID '${id}' not found`);
}
const email = result.list[0];
if (!email) {
throw new Error(`Email with ID '${id}' not found or not accessible`);
}
return email;
}
async getIdentities(): Promise<any[]> {
const session = await this.getSession();
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission'],
methodCalls: [
['Identity/get', {
accountId: session.accountId
}, 'identities']
]
};
const response = await this.makeRequest(request);
return response.methodResponses[0][1].list;
}
async getDefaultIdentity(): Promise<any> {
const identities = await this.getIdentities();
// Find the default identity (usually the one that can't be deleted)
return identities.find((id: any) => id.mayDelete === false) || identities[0];
}
async sendEmail(email: {
to: string[];
cc?: string[];
bcc?: string[];
subject: string;
textBody?: string;
htmlBody?: string;
from?: string;
mailboxId?: string;
}): Promise<string> {
const session = await this.getSession();
// Get all identities to validate from address
const identities = await this.getIdentities();
if (!identities || identities.length === 0) {
throw new Error('No sending identities found');
}
// Determine which identity to use
let selectedIdentity;
if (email.from) {
// Validate that the from address matches an available identity
selectedIdentity = identities.find(id =>
id.email.toLowerCase() === email.from?.toLowerCase()
);
if (!selectedIdentity) {
throw new Error('From address is not verified for sending. Choose one of your verified identities.');
}
} else {
// Use default identity
selectedIdentity = identities.find(id => id.mayDelete === false) || identities[0];
}
const fromEmail = selectedIdentity.email;
// Get the mailbox IDs we need
const mailboxes = await this.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts') || mailboxes.find(mb => mb.name.toLowerCase().includes('draft'));
const sentMailbox = mailboxes.find(mb => mb.role === 'sent') || mailboxes.find(mb => mb.name.toLowerCase().includes('sent'));
if (!draftsMailbox) {
throw new Error('Could not find Drafts mailbox to save email');
}
if (!sentMailbox) {
throw new Error('Could not find Sent mailbox to move email after sending');
}
// Use provided mailboxId or default to drafts for initial creation
const initialMailboxId = email.mailboxId || draftsMailbox.id;
// Ensure we have at least one body type
if (!email.textBody && !email.htmlBody) {
throw new Error('Either textBody or htmlBody must be provided');
}
const initialMailboxIds: Record<string, boolean> = {};
initialMailboxIds[initialMailboxId] = true;
const sentMailboxIds: Record<string, boolean> = {};
sentMailboxIds[sentMailbox.id] = true;
const emailObject = {
mailboxIds: initialMailboxIds,
keywords: { $draft: true },
from: [{ email: fromEmail }],
to: email.to.map(addr => ({ email: addr })),
cc: email.cc?.map(addr => ({ email: addr })) || [],
bcc: email.bcc?.map(addr => ({ email: addr })) || [],
subject: email.subject,
textBody: email.textBody ? [{ partId: 'text', type: 'text/plain' }] : undefined,
htmlBody: email.htmlBody ? [{ partId: 'html', type: 'text/html' }] : undefined,
bodyValues: {
...(email.textBody && { text: { value: email.textBody } }),
...(email.htmlBody && { html: { value: email.htmlBody } })
}
};
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission'],
methodCalls: [
['Email/set', {
accountId: session.accountId,
create: { draft: emailObject }
}, 'createEmail'],
['EmailSubmission/set', {
accountId: session.accountId,
create: {
submission: {
emailId: '#draft',
identityId: selectedIdentity.id,
envelope: {
mailFrom: { email: fromEmail },
rcptTo: email.to.map(addr => ({ email: addr }))
}
}
},
onSuccessUpdateEmail: {
'#submission': {
mailboxIds: sentMailboxIds,
keywords: { $seen: true }
}
}
}, 'submitEmail']
]
};
const response = await this.makeRequest(request);
// Check if email creation was successful
const emailResult = response.methodResponses[0][1];
if (emailResult.notCreated && emailResult.notCreated.draft) {
throw new Error('Failed to create email. Please check inputs and try again.');
}
// Check if email submission was successful
const submissionResult = response.methodResponses[1][1];
if (submissionResult.notCreated && submissionResult.notCreated.submission) {
throw new Error('Failed to submit email. Please try again later.');
}
return submissionResult.created?.submission?.id || 'unknown';
}
async getRecentEmails(limit: number = 10, mailboxName: string = 'inbox'): Promise<any[]> {
const session = await this.getSession();
// Find the specified mailbox (default to inbox)
const mailboxes = await this.getMailboxes();
const targetMailbox = mailboxes.find(mb =>
mb.role === mailboxName.toLowerCase() ||
mb.name.toLowerCase().includes(mailboxName.toLowerCase())
);
if (!targetMailbox) {
throw new Error(`Could not find mailbox: ${mailboxName}`);
}
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/query', {
accountId: session.accountId,
filter: { inMailbox: targetMailbox.id },
sort: [{ property: 'receivedAt', isAscending: false }],
limit: Math.min(limit, 50)
}, 'query'],
['Email/get', {
accountId: session.accountId,
'#ids': { resultOf: 'query', name: 'Email/query', path: '/ids' },
properties: ['id', 'subject', 'from', 'to', 'receivedAt', 'preview', 'hasAttachment', 'keywords']
}, 'emails']
]
};
const response = await this.makeRequest(request);
return response.methodResponses[1][1].list;
}
async markEmailRead(emailId: string, read: boolean = true): Promise<void> {
const session = await this.getSession();
const keywords = read ? { $seen: true } : {};
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/set', {
accountId: session.accountId,
update: {
[emailId]: {
keywords
}
}
}, 'updateEmail']
]
};
const response = await this.makeRequest(request);
const result = response.methodResponses[0][1];
if (result.notUpdated && result.notUpdated[emailId]) {
throw new Error(`Failed to mark email as ${read ? 'read' : 'unread'}.`);
}
}
async deleteEmail(emailId: string): Promise<void> {
const session = await this.getSession();
// Find the trash mailbox
const mailboxes = await this.getMailboxes();
const trashMailbox = mailboxes.find(mb => mb.role === 'trash') || mailboxes.find(mb => mb.name.toLowerCase().includes('trash'));
if (!trashMailbox) {
throw new Error('Could not find Trash mailbox');
}
const trashMailboxIds: Record<string, boolean> = {};
trashMailboxIds[trashMailbox.id] = true;
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/set', {
accountId: session.accountId,
update: {
[emailId]: {
mailboxIds: trashMailboxIds
}
}
}, 'moveToTrash']
]
};
const response = await this.makeRequest(request);
const result = response.methodResponses[0][1];
if (result.notUpdated && result.notUpdated[emailId]) {
throw new Error('Failed to delete email.');
}
}
async moveEmail(emailId: string, targetMailboxId: string): Promise<void> {
const session = await this.getSession();
const targetMailboxIds: Record<string, boolean> = {};
targetMailboxIds[targetMailboxId] = true;
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/set', {
accountId: session.accountId,
update: {
[emailId]: {
mailboxIds: targetMailboxIds
}
}
}, 'moveEmail']
]
};
const response = await this.makeRequest(request);
const result = response.methodResponses[0][1];
if (result.notUpdated && result.notUpdated[emailId]) {
throw new Error('Failed to move email.');
}
}
async getEmailAttachments(emailId: string): Promise<any[]> {
const session = await this.getSession();
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/get', {
accountId: session.accountId,
ids: [emailId],
properties: ['attachments']
}, 'getAttachments']
]
};
const response = await this.makeRequest(request);
const email = response.methodResponses[0][1].list[0];
return email?.attachments || [];
}
async downloadAttachment(emailId: string, attachmentId: string): Promise<string> {
const session = await this.getSession();
// Get the email with full attachment details
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/get', {
accountId: session.accountId,
ids: [emailId],
properties: ['attachments', 'bodyValues'],
bodyProperties: ['partId', 'blobId', 'size', 'name', 'type']
}, 'getEmail']
]
};
const response = await this.makeRequest(request);
const email = response.methodResponses[0][1].list[0];
if (!email) {
throw new Error('Email not found');
}
// Find attachment by partId or by index
let attachment = email.attachments?.find((att: any) =>
att.partId === attachmentId || att.blobId === attachmentId
);
// If not found, try by array index
if (!attachment && !isNaN(parseInt(attachmentId))) {
const index = parseInt(attachmentId);
attachment = email.attachments?.[index];
}
if (!attachment) {
throw new Error('Attachment not found.');
}
// Get the download URL from session
const downloadUrl = session.downloadUrl;
if (!downloadUrl) {
throw new Error('Download capability not available in session');
}
// Build download URL
const url = downloadUrl
.replace('{accountId}', session.accountId)
.replace('{blobId}', attachment.blobId)
.replace('{type}', encodeURIComponent(attachment.type || 'application/octet-stream'))
.replace('{name}', encodeURIComponent(attachment.name || 'attachment'));
return url;
}
async advancedSearch(filters: {
query?: string;
from?: string;
to?: string;
subject?: string;
hasAttachment?: boolean;
isUnread?: boolean;
mailboxId?: string;
after?: string;
before?: string;
limit?: number;
}): Promise<any[]> {
const session = await this.getSession();
// Build JMAP filter object
const filter: any = {};
if (filters.query) filter.text = filters.query;
if (filters.from) filter.from = filters.from;
if (filters.to) filter.to = filters.to;
if (filters.subject) filter.subject = filters.subject;
if (filters.hasAttachment !== undefined) filter.hasAttachment = filters.hasAttachment;
if (filters.isUnread !== undefined) filter.hasKeyword = filters.isUnread ? undefined : '$seen';
if (filters.mailboxId) filter.inMailbox = filters.mailboxId;
if (filters.after) filter.after = filters.after;
if (filters.before) filter.before = filters.before;
// If unread filter is specifically true, we need to check for absence of $seen
if (filters.isUnread === true) {
filter.notKeyword = '$seen';
delete filter.hasKeyword;
}
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/query', {
accountId: session.accountId,
filter,
sort: [{ property: 'receivedAt', isAscending: false }],
limit: Math.min(filters.limit || 50, 100)
}, 'query'],
['Email/get', {
accountId: session.accountId,
'#ids': { resultOf: 'query', name: 'Email/query', path: '/ids' },
properties: ['id', 'subject', 'from', 'to', 'cc', 'receivedAt', 'preview', 'hasAttachment', 'keywords', 'threadId']
}, 'emails']
]
};
const response = await this.makeRequest(request);
return response.methodResponses[1][1].list;
}
async getThread(threadId: string): Promise<any[]> {
const session = await this.getSession();
// First, check if threadId is actually an email ID and resolve the thread
let actualThreadId = threadId;
// Try to get the email first to see if we need to resolve thread ID
try {
const emailRequest: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/get', {
accountId: session.accountId,
ids: [threadId],
properties: ['threadId']
}, 'checkEmail']
]
};
const emailResponse = await this.makeRequest(emailRequest);
const email = emailResponse.methodResponses[0][1].list[0];
if (email && email.threadId) {
actualThreadId = email.threadId;
}
} catch (error) {
// If email lookup fails, assume threadId is correct
}
// Use Thread/get with the resolved thread ID
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Thread/get', {
accountId: session.accountId,
ids: [actualThreadId]
}, 'getThread'],
['Email/get', {
accountId: session.accountId,
'#ids': { resultOf: 'getThread', name: 'Thread/get', path: '/list/*/emailIds' },
properties: ['id', 'subject', 'from', 'to', 'cc', 'receivedAt', 'preview', 'hasAttachment', 'keywords', 'threadId']
}, 'emails']
]
};
const response = await this.makeRequest(request);
const threadResult = response.methodResponses[0][1];
// Check if thread was found
if (threadResult.notFound && threadResult.notFound.includes(actualThreadId)) {
throw new Error(`Thread with ID '${actualThreadId}' not found`);
}
return response.methodResponses[1][1].list;
}
async getMailboxStats(mailboxId?: string): Promise<any> {
const session = await this.getSession();
if (mailboxId) {
// Get stats for specific mailbox
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Mailbox/get', {
accountId: session.accountId,
ids: [mailboxId],
properties: ['id', 'name', 'role', 'totalEmails', 'unreadEmails', 'totalThreads', 'unreadThreads']
}, 'mailbox']
]
};
const response = await this.makeRequest(request);
return response.methodResponses[0][1].list[0];
} else {
// Get stats for all mailboxes
const mailboxes = await this.getMailboxes();
return mailboxes.map(mb => ({
id: mb.id,
name: mb.name,
role: mb.role,
totalEmails: mb.totalEmails || 0,
unreadEmails: mb.unreadEmails || 0,
totalThreads: mb.totalThreads || 0,
unreadThreads: mb.unreadThreads || 0
}));
}
}
async getAccountSummary(): Promise<any> {
const session = await this.getSession();
const mailboxes = await this.getMailboxes();
const identities = await this.getIdentities();
// Calculate totals
const totals = mailboxes.reduce((acc, mb) => ({
totalEmails: acc.totalEmails + (mb.totalEmails || 0),
unreadEmails: acc.unreadEmails + (mb.unreadEmails || 0),
totalThreads: acc.totalThreads + (mb.totalThreads || 0),
unreadThreads: acc.unreadThreads + (mb.unreadThreads || 0)
}), { totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 });
return {
accountId: session.accountId,
mailboxCount: mailboxes.length,
identityCount: identities.length,
...totals,
mailboxes: mailboxes.map(mb => ({
id: mb.id,
name: mb.name,
role: mb.role,
totalEmails: mb.totalEmails || 0,
unreadEmails: mb.unreadEmails || 0
}))
};
}
async bulkMarkRead(emailIds: string[], read: boolean = true): Promise<void> {
const session = await this.getSession();
const keywords = read ? { $seen: true } : {};
const updates: Record<string, any> = {};
emailIds.forEach(id => {
updates[id] = { keywords };
});
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/set', {
accountId: session.accountId,
update: updates
}, 'bulkUpdate']
]
};
const response = await this.makeRequest(request);
const result = response.methodResponses[0][1];
if (result.notUpdated && Object.keys(result.notUpdated).length > 0) {
throw new Error('Failed to update some emails.');
}
}
async bulkMove(emailIds: string[], targetMailboxId: string): Promise<void> {
const session = await this.getSession();
const targetMailboxIds: Record<string, boolean> = {};
targetMailboxIds[targetMailboxId] = true;
const updates: Record<string, any> = {};
emailIds.forEach(id => {
updates[id] = { mailboxIds: targetMailboxIds };
});
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/set', {
accountId: session.accountId,
update: updates
}, 'bulkMove']
]
};
const response = await this.makeRequest(request);
const result = response.methodResponses[0][1];
if (result.notUpdated && Object.keys(result.notUpdated).length > 0) {
throw new Error('Failed to move some emails.');
}
}
async bulkDelete(emailIds: string[]): Promise<void> {
const session = await this.getSession();
// Find the trash mailbox
const mailboxes = await this.getMailboxes();
const trashMailbox = mailboxes.find(mb => mb.role === 'trash') || mailboxes.find(mb => mb.name.toLowerCase().includes('trash'));
if (!trashMailbox) {
throw new Error('Could not find Trash mailbox');
}
const trashMailboxIds: Record<string, boolean> = {};
trashMailboxIds[trashMailbox.id] = true;
const updates: Record<string, any> = {};
emailIds.forEach(id => {
updates[id] = { mailboxIds: trashMailboxIds };
});
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
['Email/set', {
accountId: session.accountId,
update: updates
}, 'bulkDelete']
]
};
const response = await this.makeRequest(request);
const result = response.methodResponses[0][1];
if (result.notUpdated && Object.keys(result.notUpdated).length > 0) {
throw new Error('Failed to delete some emails.');
}
}
}