mirror of
https://github.com/Mail-0/Zero.git
synced 2026-07-01 08:16:28 +00:00
1195 lines
38 KiB
TypeScript
1195 lines
38 KiB
TypeScript
import {
|
|
deleteActiveConnection,
|
|
FatalErrors,
|
|
fromBase64Url,
|
|
sanitizeContext,
|
|
StandardizedError,
|
|
} from './utils';
|
|
import type {
|
|
OutlookCategory as Category,
|
|
MailFolder,
|
|
Message,
|
|
User,
|
|
} from '@microsoft/microsoft-graph-types';
|
|
import type { IOutgoingMessage, Label, ParsedMessage } from '../../types';
|
|
import { sanitizeTipTapHtml } from '../sanitize-tip-tap-html';
|
|
import { Client } from '@microsoft/microsoft-graph-client';
|
|
import type { MailManager, ManagerConfig } from './types';
|
|
import { getContext } from 'hono/context-storage';
|
|
import type { CreateDraftData } from '../schemas';
|
|
import type { HonoContext } from '../../ctx';
|
|
import * as he from 'he';
|
|
|
|
export class OutlookMailManager implements MailManager {
|
|
private graphClient: Client;
|
|
|
|
constructor(public config: ManagerConfig) {
|
|
const getAccessToken = async () => {
|
|
const c = getContext<HonoContext>();
|
|
const data = await c.var.auth.api.getAccessToken({
|
|
body: {
|
|
providerId: 'microsoft',
|
|
userId: config.auth.userId,
|
|
// accountId: config.auth.accountId,
|
|
},
|
|
headers: c.req.raw.headers,
|
|
});
|
|
if (!data.accessToken) throw new Error('Failed to get access token');
|
|
return data.accessToken;
|
|
};
|
|
|
|
this.graphClient = Client.initWithMiddleware({
|
|
authProvider: {
|
|
getAccessToken,
|
|
},
|
|
});
|
|
}
|
|
|
|
public getScope(): string {
|
|
return [
|
|
'https://graph.microsoft.com/User.Read',
|
|
'https://graph.microsoft.com/Mail.ReadWrite',
|
|
'https://graph.microsoft.com/Mail.Send',
|
|
'offline_access',
|
|
].join(' ');
|
|
}
|
|
public getAttachment(messageId: string, attachmentId: string) {
|
|
return this.withErrorHandler(
|
|
'getAttachment',
|
|
async () => {
|
|
const response = await this.graphClient
|
|
.api(`/me/messages/${messageId}/attachments/${attachmentId}`)
|
|
.get();
|
|
|
|
const attachment = response;
|
|
|
|
if (!attachment || !attachment.contentBytes) {
|
|
throw new Error('Attachment data not found');
|
|
}
|
|
|
|
const base64 = fromBase64Url(attachment.contentBytes);
|
|
|
|
return base64;
|
|
},
|
|
{ messageId, attachmentId },
|
|
);
|
|
}
|
|
public getEmailAliases() {
|
|
return this.withErrorHandler('getEmailAliases', async () => {
|
|
const user: User = await this.graphClient.api('/me').select('mail,userPrincipalName').get();
|
|
const primaryEmail = user.mail || user.userPrincipalName || '';
|
|
|
|
const aliases: { email: string; name?: string; primary?: boolean }[] = [
|
|
{ email: primaryEmail, primary: true },
|
|
];
|
|
|
|
return aliases;
|
|
});
|
|
}
|
|
public markAsRead(messageIds: string[]) {
|
|
return this.withErrorHandler(
|
|
'markAsRead',
|
|
async () => {
|
|
await this.modifyMessageReadStatus(messageIds, true);
|
|
},
|
|
{ messageIds },
|
|
);
|
|
}
|
|
public markAsUnread(messageIds: string[]) {
|
|
return this.withErrorHandler(
|
|
'markAsUnread',
|
|
async () => {
|
|
await this.modifyMessageReadStatus(messageIds, false);
|
|
},
|
|
{ messageIds },
|
|
);
|
|
}
|
|
private async modifyMessageReadStatus(messageIds: string[], isRead: boolean) {
|
|
if (messageIds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const batchRequests = messageIds.map((id, index) => ({
|
|
id: `${index}`,
|
|
method: 'PATCH',
|
|
url: `/me/messages/${id}`,
|
|
body: { isRead: isRead },
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}));
|
|
|
|
try {
|
|
await this.graphClient.api('/$batch').post({ requests: batchRequests });
|
|
} catch (error) {
|
|
console.error('Error during batch update of message read status:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
public getUserInfo() {
|
|
return this.withErrorHandler(
|
|
'getUserInfo',
|
|
async () => {
|
|
const user: User = await this.graphClient
|
|
.api('/me')
|
|
.select('id,displayName,userPrincipalName,mail')
|
|
.get();
|
|
|
|
let photoUrl = '';
|
|
try {
|
|
// Requires separate fetching logic
|
|
} catch (error: unknown) {
|
|
console.warn(
|
|
'Could not fetch user photo:',
|
|
error instanceof Error ? error.message : 'Unknown error',
|
|
);
|
|
}
|
|
|
|
const info = {
|
|
address: user.mail || user.userPrincipalName || '',
|
|
name: user.displayName || '',
|
|
photo: photoUrl,
|
|
};
|
|
console.log({ info });
|
|
return info;
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
public getTokens<T>(code: string) {
|
|
return this.withErrorHandler(
|
|
'getTokens',
|
|
async () => {
|
|
const tokens = {
|
|
accessToken: this.config.auth?.accessToken,
|
|
refreshToken: this.config.auth?.refreshToken,
|
|
};
|
|
return { tokens } as T;
|
|
},
|
|
{ code },
|
|
);
|
|
}
|
|
public count() {
|
|
return this.withErrorHandler(
|
|
'count',
|
|
async () => {
|
|
const userLabels = await this.graphClient.api('/me/mailfolders').get();
|
|
|
|
if (!userLabels.value) {
|
|
return [];
|
|
}
|
|
|
|
const folders = await Promise.all(
|
|
userLabels.value.map(async (folder: MailFolder) => {
|
|
try {
|
|
const res = await this.graphClient.api(`/me/mailfolders/${folder.id}`).get();
|
|
|
|
let normalizedLabel = res.displayName || res.id || '';
|
|
|
|
if (res.displayName === 'Inbox' || res.id?.toLowerCase() === 'inbox')
|
|
normalizedLabel = 'Inbox';
|
|
else if (res.displayName === 'Sent Items' || res.id?.toLowerCase() === 'sentitems')
|
|
normalizedLabel = 'Sent';
|
|
else if (res.displayName === 'Drafts' || res.id?.toLowerCase() === 'drafts')
|
|
normalizedLabel = 'Drafts';
|
|
else if (
|
|
res.displayName === 'Deleted Items' ||
|
|
res.id?.toLowerCase() === 'deleteditems'
|
|
)
|
|
normalizedLabel = 'Bin';
|
|
else if (res.displayName === 'Archive' || res.id?.toLowerCase() === 'archive')
|
|
normalizedLabel = 'Archive';
|
|
else if (res.displayName === 'Junk Email' || res.id?.toLowerCase() === 'junkemail')
|
|
normalizedLabel = 'Spam';
|
|
|
|
// Use unreadItemCount only for Inbox, use totalItemCount for all other folders
|
|
const count =
|
|
res.id?.toLowerCase() === 'inbox'
|
|
? Number(res.unreadItemCount)
|
|
: Number(res.totalItemCount);
|
|
|
|
return {
|
|
label: normalizedLabel,
|
|
count: count ?? undefined,
|
|
};
|
|
} catch (error) {
|
|
console.error(`Error getting counts for folder ${folder.id}:`, error);
|
|
return {
|
|
label: folder.displayName || folder.id || '',
|
|
count: undefined,
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
return folders;
|
|
},
|
|
{ email: this.config.auth?.email },
|
|
);
|
|
}
|
|
public list(params: {
|
|
folder: string;
|
|
query?: string;
|
|
maxResults?: number;
|
|
labelIds?: string[];
|
|
pageToken?: string;
|
|
}) {
|
|
const { folder, query: q, maxResults = 100, pageToken } = params;
|
|
|
|
let folderId = this.getOutlookFolderId(folder);
|
|
if (!folderId) {
|
|
folderId = folder;
|
|
}
|
|
|
|
let request = this.graphClient.api(`/me/mailFolders/${folderId}/messages`).top(maxResults);
|
|
|
|
// if (q) {
|
|
// request = request.search(`"${q}"`);
|
|
// }
|
|
|
|
request = request.select(
|
|
'id,subject,from,toRecipients,ccRecipients,bccRecipients,sentDateTime,receivedDateTime,isRead,internetMessageId,inferenceClassification,categories,parentFolderId',
|
|
);
|
|
|
|
if (maxResults > 0) {
|
|
request = request.top(maxResults);
|
|
}
|
|
if (pageToken) {
|
|
console.warn(
|
|
'Outlook pagination typically uses @odata.nextLink (full URL). pageToken needs to be handled accordingly.',
|
|
);
|
|
}
|
|
|
|
// request = request.orderby('receivedDateTime desc');
|
|
|
|
return this.withErrorHandler(
|
|
'list',
|
|
async () => {
|
|
const res = await request.get();
|
|
|
|
// console.log(JSON.stringify(res, null, 4));
|
|
|
|
const messages: Message[] = res.value;
|
|
const nextPageLink: string | undefined = res['@odata.nextLink'];
|
|
|
|
// First parse all messages to get basic info
|
|
const parsedMessages = await Promise.all(
|
|
messages.map((msg) => this.parseOutlookMessage(msg)),
|
|
);
|
|
|
|
// Then fetch full content for each message
|
|
const fullMessages = await Promise.all(
|
|
messages.map(async (msg, index) => {
|
|
try {
|
|
// Get the full message content using the get method
|
|
const fullMessage = await this.get(msg.id || '');
|
|
return {
|
|
...parsedMessages[index],
|
|
...fullMessage.latest,
|
|
decodedBody: fullMessage.latest.decodedBody || '',
|
|
};
|
|
} catch (error) {
|
|
console.error(`Failed to fetch full message for ${msg.id}:`, error);
|
|
// If get fails, fall back to basic message info
|
|
return {
|
|
...parsedMessages[index],
|
|
body: '',
|
|
processedHtml: '',
|
|
blobUrl: '',
|
|
decodedBody: '',
|
|
attachments: [],
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
// Format response according to interface requirements
|
|
return {
|
|
threads: messages.map((msg, index) => ({
|
|
id: msg.id || msg.internetMessageId || '',
|
|
$raw: {
|
|
...msg,
|
|
...fullMessages[index],
|
|
},
|
|
})),
|
|
nextPageToken: nextPageLink || null,
|
|
};
|
|
},
|
|
{
|
|
folder,
|
|
q,
|
|
maxResults,
|
|
_labelIds: params.labelIds,
|
|
pageToken,
|
|
email: this.config.auth?.email,
|
|
},
|
|
);
|
|
}
|
|
private getOutlookFolderId(folderName: string): string | undefined {
|
|
switch (folderName.toLowerCase()) {
|
|
case 'inbox':
|
|
return 'inbox';
|
|
case 'sent':
|
|
return 'sentitems';
|
|
case 'drafts':
|
|
return 'drafts';
|
|
case 'bin':
|
|
case 'trash':
|
|
return 'deleteditems';
|
|
case 'archive':
|
|
return 'archive';
|
|
case 'junk':
|
|
case 'spam':
|
|
return 'junkemail';
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
public get(id: string) {
|
|
return this.withErrorHandler(
|
|
'get',
|
|
async () => {
|
|
const message: Message = await this.graphClient
|
|
.api(`/me/messages/${id}`)
|
|
.select(
|
|
'id,subject,body,from,toRecipients,ccRecipients,bccRecipients,sentDateTime,receivedDateTime,isRead,internetMessageId,inferenceClassification,categories,attachments',
|
|
)
|
|
.get();
|
|
|
|
if (!message) {
|
|
throw new Error('Message not found');
|
|
}
|
|
|
|
const bodyContent = message.body?.content || '';
|
|
const bodyContentType = message.body?.contentType?.toLowerCase() || 'text';
|
|
|
|
let decodedBody = '';
|
|
if (bodyContentType === 'html') {
|
|
decodedBody = he.decode(bodyContent);
|
|
} else {
|
|
decodedBody = he.decode(bodyContent).replace(/\n/g, '<br>');
|
|
}
|
|
|
|
const attachmentsData = message.attachments || [];
|
|
|
|
const attachments = await Promise.all(
|
|
attachmentsData.map(async (att) => {
|
|
if (!att.id || !att.name || att.size === undefined || att.contentType === undefined) {
|
|
return null;
|
|
}
|
|
const attachmentContent = await this.graphClient
|
|
.api(`/me/messages/${message.id}/attachments/${att.id}`)
|
|
.get();
|
|
|
|
if (!attachmentContent.contentBytes) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
filename: att.name,
|
|
mimeType: att.contentType ?? 'application/octet-stream',
|
|
size: att.size,
|
|
attachmentId: att.id,
|
|
headers: [],
|
|
body: attachmentContent.contentBytes,
|
|
};
|
|
}),
|
|
).then((attachments) => attachments.filter((a): a is NonNullable<typeof a> => a !== null));
|
|
|
|
const parsedData = this.parseOutlookMessage(message);
|
|
|
|
const fullEmailData = {
|
|
...parsedData,
|
|
body: '',
|
|
processedHtml: '',
|
|
blobUrl: '',
|
|
decodedBody: decodedBody,
|
|
attachments,
|
|
};
|
|
|
|
return {
|
|
labels: parsedData.tags,
|
|
messages: [fullEmailData],
|
|
latest: fullEmailData,
|
|
hasUnread: parsedData.unread,
|
|
totalReplies: 1,
|
|
};
|
|
},
|
|
{ id, email: this.config.auth?.email },
|
|
);
|
|
}
|
|
public create(data: IOutgoingMessage) {
|
|
return this.withErrorHandler(
|
|
'create',
|
|
async () => {
|
|
const messagePayload = await this.parseOutgoingOutlook(data);
|
|
|
|
const res = await this.graphClient.api('/me/sendMail').post({
|
|
message: messagePayload,
|
|
saveToSentItems: true,
|
|
});
|
|
|
|
return res;
|
|
},
|
|
{ data, email: this.config.auth?.email },
|
|
);
|
|
}
|
|
public delete(id: string) {
|
|
return this.withErrorHandler(
|
|
'delete',
|
|
async () => {
|
|
await this.graphClient.api(`/me/messages/${id}`).delete();
|
|
},
|
|
{ id },
|
|
);
|
|
}
|
|
public normalizeIds(ids: string[]) {
|
|
return this.withSyncErrorHandler(
|
|
'normalizeIds',
|
|
() => {
|
|
const messageIds: string[] = ids.map((id) =>
|
|
id.startsWith('thread:') ? id.substring(7) : id,
|
|
);
|
|
return { threadIds: messageIds }; // Renamed from threadIds to messageIds conceptually
|
|
},
|
|
{ ids },
|
|
);
|
|
}
|
|
public modifyLabels(
|
|
messageIds: string[],
|
|
options: { addLabels: string[]; removeLabels: string[] },
|
|
) {
|
|
return this.withErrorHandler(
|
|
'modifyLabels',
|
|
async () => {
|
|
await this.modifyMessageLabelsOrFolders(
|
|
messageIds,
|
|
options.addLabels,
|
|
options.removeLabels,
|
|
);
|
|
},
|
|
{ messageIds, options },
|
|
);
|
|
}
|
|
private async modifyMessageLabelsOrFolders(
|
|
messageIds: string[],
|
|
addItems: string[],
|
|
removeItems: string[],
|
|
) {
|
|
if (messageIds.length === 0) {
|
|
return;
|
|
}
|
|
const batchRequests = messageIds.map((id, index) => {
|
|
const patchBody = {};
|
|
|
|
if (addItems.length > 0 || removeItems.length > 0) {
|
|
console.warn(
|
|
`Modifying categories (${addItems.join(',')}, ${removeItems.join(',')}) on message ${id} is not fully implemented.`,
|
|
);
|
|
}
|
|
|
|
if (!addItems[0]) {
|
|
console.warn('No addItems');
|
|
return;
|
|
}
|
|
|
|
let moveToFolderId: string | undefined;
|
|
if (addItems.length > 0 && this.getOutlookFolderId(addItems[0])) {
|
|
moveToFolderId = this.getOutlookFolderId(addItems[0]) || addItems[0];
|
|
console.warn(
|
|
`Attempting to move message ${id} to folder ${moveToFolderId}. This is a move operation, not adding a label.`,
|
|
);
|
|
return {
|
|
id: `${index}`,
|
|
method: 'POST',
|
|
url: `/me/messages/${id}/move`,
|
|
body: { destinationId: moveToFolderId },
|
|
headers: { 'Content-Type': 'application/json' },
|
|
};
|
|
}
|
|
return {
|
|
id: `${index}`,
|
|
method: 'PATCH',
|
|
url: `/me/messages/${id}`,
|
|
body: patchBody,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
};
|
|
});
|
|
|
|
const validBatchRequests = batchRequests
|
|
.filter((req) => typeof req !== 'undefined')
|
|
.filter((req) => Object.keys(req.body).length > 0 || req.method === 'POST');
|
|
|
|
if (validBatchRequests.length === 0) {
|
|
console.warn('No valid batch requests generated for modifyMessageLabelsOrFolders.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.graphClient.api('/$batch').post({ requests: validBatchRequests });
|
|
} catch (error) {
|
|
console.error('Error during batch modification of messages:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
public sendDraft(draftId: string, data: IOutgoingMessage) {
|
|
return this.withErrorHandler(
|
|
'sendDraft',
|
|
async () => {
|
|
await this.graphClient.api(`/me/drafts/${draftId}/send`).post({});
|
|
},
|
|
{ draftId, data },
|
|
);
|
|
}
|
|
public getDraft(draftId: string) {
|
|
return this.withErrorHandler(
|
|
'getDraft',
|
|
async () => {
|
|
const draftMessage: Message = await this.graphClient
|
|
.api(`/me/messages/${draftId}`) // Drafts are messages in the drafts folder
|
|
.select('id,subject,body,from,toRecipients,ccRecipients,bccRecipients')
|
|
.get();
|
|
|
|
if (!draftMessage) {
|
|
throw new Error('Draft not found');
|
|
}
|
|
|
|
const parsedDraft = this.parseOutlookDraft(draftMessage);
|
|
if (!parsedDraft) {
|
|
throw new Error('Failed to parse draft');
|
|
}
|
|
|
|
return parsedDraft;
|
|
},
|
|
{ draftId },
|
|
);
|
|
}
|
|
public listDrafts(params: { q?: string; maxResults?: number; pageToken?: string }) {
|
|
const { q, maxResults = 20, pageToken } = params;
|
|
return this.withErrorHandler(
|
|
'listDrafts',
|
|
async () => {
|
|
let request = this.graphClient.api('/me/mailfolders/drafts/messages');
|
|
|
|
// if (q) {
|
|
// request = request.search(`"${q}"`);
|
|
// }
|
|
|
|
request = request.select(
|
|
'id,subject,from,toRecipients,ccRecipients,bccRecipients,sentDateTime,receivedDateTime,isRead,internetMessageId',
|
|
);
|
|
// request = request.orderby('receivedDateTime desc');
|
|
request = request.top(maxResults);
|
|
|
|
if (pageToken) {
|
|
console.warn(
|
|
'Outlook pagination typically uses @odata.nextLink (full URL). pageToken needs to be handled accordingly.',
|
|
);
|
|
}
|
|
|
|
const res = await request.get();
|
|
|
|
const draftMessages: Message[] = res.value;
|
|
const nextPageLink: string | undefined = res['@odata.nextLink'];
|
|
|
|
const drafts = await Promise.all(
|
|
draftMessages.map(async (message) => {
|
|
if (!message.id) return null;
|
|
try {
|
|
const parsed = this.parseOutlookMessage(message);
|
|
return {
|
|
...parsed,
|
|
id: message.id,
|
|
threadId: message.conversationId || message.id,
|
|
receivedOn: message.receivedDateTime || new Date().toISOString(),
|
|
};
|
|
} catch (error) {
|
|
console.error('Error parsing draft message:', error);
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
|
|
const sortedDrafts = drafts
|
|
.filter((draft) => draft !== null)
|
|
.sort((a, b) => {
|
|
const dateA = new Date(a?.receivedOn || new Date()).getTime();
|
|
const dateB = new Date(b?.receivedOn || new Date()).getTime();
|
|
return dateB - dateA;
|
|
});
|
|
|
|
return {
|
|
threads: sortedDrafts.map((draft) => ({
|
|
id: draft.id,
|
|
$raw: draft,
|
|
})),
|
|
nextPageToken: nextPageLink || null,
|
|
};
|
|
},
|
|
{ q, maxResults, pageToken },
|
|
);
|
|
}
|
|
public createDraft(data: CreateDraftData) {
|
|
return this.withErrorHandler(
|
|
'createDraft',
|
|
async () => {
|
|
const message = await sanitizeTipTapHtml(data.message);
|
|
|
|
const toRecipients = Array.isArray(data.to) ? data.to : data.to.split(', ');
|
|
|
|
const outlookMessage: Message = {
|
|
subject: data.subject,
|
|
body: {
|
|
contentType: 'html',
|
|
content: message || '',
|
|
},
|
|
toRecipients: toRecipients.map((recipient) => ({
|
|
emailAddress: {
|
|
address: typeof recipient === 'string' ? recipient : recipient.email,
|
|
name: typeof recipient === 'string' ? undefined : recipient.name || undefined,
|
|
},
|
|
})),
|
|
};
|
|
|
|
if (data.cc) {
|
|
const ccRecipients = Array.isArray(data.cc) ? data.cc : data.cc.split(', ');
|
|
outlookMessage.ccRecipients = ccRecipients.map((recipient) => ({
|
|
emailAddress: {
|
|
address: typeof recipient === 'string' ? recipient : recipient.email,
|
|
name: typeof recipient === 'string' ? undefined : recipient.name || undefined,
|
|
},
|
|
}));
|
|
}
|
|
|
|
if (data.bcc) {
|
|
const bccRecipients = Array.isArray(data.bcc) ? data.bcc : data.bcc.split(', ');
|
|
outlookMessage.bccRecipients = bccRecipients.map((recipient) => ({
|
|
emailAddress: {
|
|
address: typeof recipient === 'string' ? recipient : recipient.email,
|
|
name: typeof recipient === 'string' ? undefined : recipient.name || undefined,
|
|
},
|
|
}));
|
|
}
|
|
|
|
if (data.attachments && data.attachments.length > 0) {
|
|
outlookMessage.attachments = await Promise.all(
|
|
data.attachments.map(async (file) => {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const buffer = Buffer.from(arrayBuffer);
|
|
const base64Content = buffer.toString('base64');
|
|
|
|
return {
|
|
'@odata.type': '#microsoft.graph.fileAttachment',
|
|
name: file.name,
|
|
contentType: file.type || 'application/octet-stream',
|
|
contentBytes: base64Content,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
let res;
|
|
|
|
if (data.id) {
|
|
try {
|
|
res = await this.graphClient
|
|
.api(`/me/mailfolders/drafts/messages/${data.id}`)
|
|
.patch(outlookMessage);
|
|
} catch (error) {
|
|
console.warn(`Failed to update draft ${data.id}, creating a new one`, error);
|
|
try {
|
|
await this.graphClient.api(`/me/mailfolders/drafts/messages/${data.id}`).delete();
|
|
} catch (deleteError) {
|
|
console.error(`Failed to delete draft ${data.id}`, deleteError);
|
|
}
|
|
|
|
res = await this.graphClient
|
|
.api('/me/mailfolders/drafts/messages')
|
|
.post(outlookMessage);
|
|
}
|
|
} else {
|
|
res = await this.graphClient.api('/me/mailfolders/drafts/messages').post(outlookMessage);
|
|
}
|
|
|
|
return res;
|
|
},
|
|
{ data },
|
|
);
|
|
}
|
|
public async getUserLabels() {
|
|
console.warn(
|
|
'getUserLabels maps to Outlook Categories and Mail Folders, which have different APIs.',
|
|
);
|
|
|
|
try {
|
|
const categories: Category[] = (
|
|
await this.graphClient.api('/me/outlook/masterCategories').get()
|
|
).value;
|
|
const folders: MailFolder[] = (await this.graphClient.api('/me/mailfolders').get()).value;
|
|
|
|
const mappedCategories: Label[] = categories.map((cat: Category) => ({
|
|
id: cat.id || cat.displayName || '',
|
|
name: cat.displayName || '',
|
|
type: 'category', // Indicate these are categories
|
|
color: {
|
|
backgroundColor: cat.color || '', // Graph category color is a string enum, not hex
|
|
textColor: '', // Outlook categories don't have separate text color in API
|
|
},
|
|
}));
|
|
|
|
const mappedFolders: Label[] = folders.map((folder) => ({
|
|
id: folder.id || '',
|
|
name: folder.displayName || '',
|
|
type: 'user', // Differentiate system vs user folders
|
|
color: {
|
|
backgroundColor: '', // Outlook folders don't have colors via API
|
|
textColor: '',
|
|
},
|
|
}));
|
|
|
|
return [...mappedCategories, ...mappedFolders];
|
|
} catch (error) {
|
|
console.error('Error fetching Outlook categories or folders:', error);
|
|
return [];
|
|
}
|
|
}
|
|
public async getLabel(labelId: string): Promise<Label> {
|
|
console.warn('getLabel needs to differentiate between Category ID and Mail Folder ID.');
|
|
|
|
try {
|
|
// Try fetching as a Mail Folder first
|
|
const folder: MailFolder = await this.graphClient.api(`/me/mailfolders/${labelId}`).get();
|
|
return {
|
|
id: folder.id || '',
|
|
name: folder.displayName || '',
|
|
type: 'user',
|
|
color: { backgroundColor: '', textColor: '' },
|
|
};
|
|
} catch (folderError) {
|
|
try {
|
|
const category: Category = await this.graphClient
|
|
.api(`/me/outlook/masterCategories/${labelId}`)
|
|
.get();
|
|
return {
|
|
id: category.id || category.displayName || '',
|
|
name: category.displayName || '',
|
|
type: 'category',
|
|
color: { backgroundColor: category.color || '', textColor: '' },
|
|
};
|
|
} catch (categoryError) {
|
|
console.error(
|
|
`Label or folder with id ${labelId} not found as Folder or Category:`,
|
|
folderError,
|
|
categoryError,
|
|
);
|
|
throw new Error(`Label or folder with id ${labelId} not found`);
|
|
}
|
|
}
|
|
}
|
|
public async createLabel(label: {
|
|
name: string;
|
|
color?: { backgroundColor: string; textColor: string };
|
|
}) {
|
|
console.warn(
|
|
'createLabel defaults to creating a Mail Folder. Creating a Category uses a different API.',
|
|
);
|
|
|
|
try {
|
|
const newFolder: MailFolder = await this.graphClient.api('/me/mailfolders').post({
|
|
displayName: label.name,
|
|
// parentFolderId: 'inbox', // Optional: Create under a specific parent folder
|
|
});
|
|
console.log('Mail Folder created:', newFolder);
|
|
|
|
// create a Category:
|
|
// const newCategory: Category = await this.graphClient.api('/me/outlook/masterCategories').post({
|
|
// displayName: label.name,
|
|
// color: 'presetColorEnum' // Graph category color is a string enum
|
|
// });
|
|
// console.log('Category created:', newCategory);
|
|
} catch (error) {
|
|
console.error('Error creating Outlook Mail Folder:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
public async updateLabel(id: string, label: Label) {
|
|
console.warn('updateLabel needs to differentiate between Category and Mail Folder updates.');
|
|
|
|
try {
|
|
await this.graphClient.api(`/me/mailfolders/${id}`).patch({
|
|
displayName: label.name,
|
|
// Folder colors are not updateable via Graph API
|
|
});
|
|
console.log(`Mail Folder ${id} updated.`);
|
|
} catch (folderError) {
|
|
try {
|
|
await this.graphClient.api(`/me/outlook/masterCategories/${id}`).patch({
|
|
displayName: label.name,
|
|
// color: label.color?.backgroundColor, // Requires mapping hex to Graph color enum
|
|
});
|
|
console.log(`Category ${id} updated.`);
|
|
} catch (categoryError) {
|
|
console.error(
|
|
`Could not update label or folder with id ${id} as Folder or Category:`,
|
|
folderError,
|
|
categoryError,
|
|
);
|
|
throw new Error(`Could not update label or folder with id ${id}`);
|
|
}
|
|
}
|
|
}
|
|
public async deleteLabel(id: string) {
|
|
await this.graphClient.api(`/me/mailfolders/${id}`).delete();
|
|
}
|
|
public async revokeRefreshToken(refreshToken: string) {
|
|
if (!refreshToken) {
|
|
return false;
|
|
}
|
|
try {
|
|
console.warn(
|
|
'Revoking Microsoft refresh tokens requires MSAL or specific Azure AD endpoints, not a direct Graph API call. This method is a placeholder.',
|
|
);
|
|
return false;
|
|
} catch (error: unknown) {
|
|
console.error(
|
|
'Failed to revoke Microsoft token:',
|
|
error instanceof Error ? error.message : 'Unknown error',
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
private async modifyThreadLabels(
|
|
threadIds: string[],
|
|
requestBody: unknown, // Gmail-specific type, replace with relevant Outlook logic
|
|
) {
|
|
// This method is Gmail-specific (modifying thread labels).
|
|
// The equivalent in Outlook is modifying messages (read status, categories)
|
|
// or moving messages between folders.
|
|
// The logic from modifyMessageReadStatus and modifyMessageLabelsOrFolders is more relevant.
|
|
console.warn(
|
|
'modifyThreadLabels is a Gmail-specific concept. Use modifyMessageReadStatus or modifyMessageLabelsOrFolders.',
|
|
);
|
|
// Placeholder
|
|
return Promise.resolve();
|
|
}
|
|
private normalizeSearch(folder: string, q: string) {
|
|
// This normalization logic is based on Gmail's search syntax and folder mapping.
|
|
// For Outlook/Graph, you need to translate to OData $filter or $search syntax
|
|
// and map folder names to Outlook folder IDs.
|
|
console.warn(
|
|
'normalizeSearch is based on Gmail syntax. Needs translation to OData $filter or $search.',
|
|
);
|
|
|
|
let outlookQuery = q;
|
|
let folderId: string | undefined;
|
|
|
|
switch (folder.toLowerCase()) {
|
|
case 'inbox':
|
|
folderId = 'inbox';
|
|
break;
|
|
case 'bin':
|
|
case 'trash':
|
|
folderId = 'deleteditems';
|
|
break;
|
|
case 'archive':
|
|
folderId = 'archive';
|
|
break;
|
|
case 'sent':
|
|
folderId = 'sentitems';
|
|
break;
|
|
case 'drafts':
|
|
folderId = 'drafts';
|
|
break;
|
|
default:
|
|
folderId = folder;
|
|
break;
|
|
}
|
|
|
|
// This is a very basic translation. A real implementation needs to parse Gmail queries
|
|
// and build complex OData filter strings.
|
|
if (q) {
|
|
// Simple keyword search example
|
|
outlookQuery = `"${q}"`;
|
|
}
|
|
|
|
return { folder: folderId, q: outlookQuery };
|
|
}
|
|
private parseOutlookMessage({
|
|
id,
|
|
conversationId, // Use conversationId as threadId equivalent
|
|
subject,
|
|
bodyPreview, // Snippet equivalent
|
|
isRead,
|
|
from,
|
|
toRecipients,
|
|
ccRecipients,
|
|
bccRecipients,
|
|
sentDateTime,
|
|
receivedDateTime,
|
|
internetMessageId,
|
|
inferenceClassification, // Might indicate if junk
|
|
categories, // Outlook categories map to tags
|
|
parentFolderId, // Can indicate folder (e.g. 'deleteditems')
|
|
// headers, // Array of Header objects (name, value), doesn't exist in Outlook
|
|
}: Message): Omit<
|
|
ParsedMessage,
|
|
'body' | 'processedHtml' | 'blobUrl' | 'totalReplies' | 'attachments'
|
|
> {
|
|
const receivedOn = receivedDateTime || new Date().toISOString();
|
|
const sender = from?.emailAddress
|
|
? {
|
|
name: from.emailAddress.name || '',
|
|
email: from.emailAddress.address || '',
|
|
}
|
|
: { name: 'Unknown', email: 'unknown@example.com' };
|
|
|
|
const to =
|
|
toRecipients?.map((rec) => ({
|
|
name: rec.emailAddress?.name || '',
|
|
email: rec.emailAddress?.address || '',
|
|
})) || [];
|
|
|
|
const cc =
|
|
ccRecipients?.map((rec) => ({
|
|
name: rec.emailAddress?.name || '',
|
|
email: rec.emailAddress?.address || '',
|
|
})) || null;
|
|
|
|
const bcc =
|
|
bccRecipients?.map((rec) => ({
|
|
name: rec.emailAddress?.name || '',
|
|
email: rec.emailAddress?.address || '',
|
|
})) || [];
|
|
|
|
const tags: Label[] =
|
|
(categories || []).map((cat) => ({
|
|
id: cat,
|
|
name: cat,
|
|
type: 'category',
|
|
color: {
|
|
backgroundColor: '',
|
|
textColor: '',
|
|
},
|
|
})) || [];
|
|
|
|
let references: string | undefined;
|
|
let inReplyTo: string | undefined;
|
|
let listUnsubscribe: string | undefined;
|
|
let listUnsubscribePost: string | undefined;
|
|
let replyTo: string | undefined;
|
|
|
|
// TODO: use headers if available
|
|
// if (headers) {
|
|
// const referencesHeader = headers.find((h) => h.name?.toLowerCase() === 'references');
|
|
// if (referencesHeader) references = referencesHeader.value || undefined;
|
|
|
|
// const inReplyToHeader = headers.find((h) => h.name?.toLowerCase() === 'in-reply-to');
|
|
// if (inReplyToHeader) inReplyTo = inReplyToHeader.value || undefined;
|
|
|
|
// const listUnsubscribeHeader = headers.find(
|
|
// (h) => h.name?.toLowerCase() === 'list-unsubscribe',
|
|
// );
|
|
// if (listUnsubscribeHeader) listUnsubscribe = listUnsubscribeHeader.value || undefined;
|
|
|
|
// const listUnsubscribePostHeader = headers.find(
|
|
// (h) => h.name?.toLowerCase() === 'list-unsubscribe-post',
|
|
// );
|
|
// if (listUnsubscribePostHeader)
|
|
// listUnsubscribePost = listUnsubscribePostHeader.value || undefined;
|
|
|
|
// const replyToHeader = headers.find((h) => h.name?.toLowerCase() === 'reply-to');
|
|
// if (replyToHeader) replyTo = replyToHeader.value || undefined;
|
|
// }
|
|
|
|
// TLS status is difficult to determine reliably from typical Graph message properties.
|
|
// You'd need to examine "Received" headers if available and parse them, similar to the Gmail logic.
|
|
// The `wasSentWithTLS` utility would need to be adapted or rewritten for Outlook header formats.
|
|
const tls = false; // Placeholder - needs proper header parsing
|
|
|
|
return {
|
|
id: id || 'ERROR',
|
|
bcc,
|
|
threadId: conversationId || id || '',
|
|
title: bodyPreview ? he.decode(bodyPreview).trim() : 'ERROR',
|
|
tls: tls,
|
|
tags: tags,
|
|
listUnsubscribe,
|
|
listUnsubscribePost,
|
|
replyTo,
|
|
references,
|
|
inReplyTo,
|
|
sender,
|
|
unread: !isRead,
|
|
to,
|
|
cc,
|
|
receivedOn: receivedOn.toString(),
|
|
subject: subject ? he.decode(subject).trim() : '(no subject)',
|
|
messageId: internetMessageId || id || 'ERROR',
|
|
};
|
|
}
|
|
private async parseOutgoingOutlook({
|
|
to,
|
|
subject,
|
|
message,
|
|
attachments,
|
|
headers,
|
|
cc,
|
|
bcc,
|
|
fromEmail, // In Outlook, this is usually determined by the authenticated user unless using "send on behalf of" or "send as"
|
|
}: IOutgoingMessage): Promise<Message> {
|
|
// Outlook Graph API expects a Message object structure for sending/creating drafts
|
|
console.log(to);
|
|
const outlookMessage: Message = {
|
|
subject: subject,
|
|
body: {
|
|
contentType: 'html', // Or 'text'
|
|
content: await sanitizeTipTapHtml(message.trim()),
|
|
},
|
|
toRecipients:
|
|
to?.map((rec) => ({
|
|
emailAddress: {
|
|
name: rec.name || '',
|
|
address: rec.email,
|
|
},
|
|
})) || [],
|
|
ccRecipients:
|
|
cc?.map((rec) => ({
|
|
emailAddress: {
|
|
name: rec.name || '',
|
|
address: rec.email,
|
|
},
|
|
})) || undefined,
|
|
bccRecipients:
|
|
bcc?.map((rec) => ({
|
|
emailAddress: {
|
|
name: rec.name || '',
|
|
address: rec.email,
|
|
},
|
|
})) || undefined,
|
|
// from, sender properties are often handled automatically by Graph based on auth
|
|
// or require specific permissions (Send as, Send on behalf of) and different payload structure.
|
|
// If fromEmail is provided and requires Send As/On Behalf permissions:
|
|
// from: { emailAddress: { name: 'Sender Name', address: fromEmail } } // Requires permission
|
|
};
|
|
|
|
if (headers) {
|
|
// Graph API doesn't have a direct 'headers' property for sending
|
|
// Custom headers are usually not added this way.
|
|
// Some headers like Reply-To can be set as properties, but not general headers.
|
|
console.warn(
|
|
'Custom headers from IOutgoingMessage are not directly applied when sending via Microsoft Graph API.',
|
|
);
|
|
// If you need to set specific headers like In-Reply-To or References for threading replies:
|
|
// outlookMessage.internetMessageHeaders = Object.entries(headers).map(([name, value]) => ({ name, value: value?.toString() }));
|
|
// Note: internetMessageHeaders might be read-only or limited for sending.
|
|
// Setting properties like InReplyTo, References on the message object itself is the standard way if supported.
|
|
// outlookMessage.inReplyTo = headers.inReplyTo as string | undefined; // Example if supported
|
|
// outlookMessage.references = headers.references as string | undefined; // Example if supported
|
|
}
|
|
|
|
if (attachments && attachments.length > 0) {
|
|
outlookMessage.attachments = await Promise.all(
|
|
attachments.map(async (file) => {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const buffer = Buffer.from(arrayBuffer);
|
|
const base64Content = buffer.toString('base64');
|
|
|
|
// Graph API expects a FileAttachment object or ItemAttachment object
|
|
// Assuming FileAttachment for typical file uploads
|
|
return {
|
|
'@odata.type': '#microsoft.graph.fileAttachment',
|
|
name: file.name,
|
|
contentType: file.type || 'application/octet-stream',
|
|
contentBytes: base64Content, // Base64 content here
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
return outlookMessage;
|
|
}
|
|
private parseOutlookDraft(draftMessage: Message) {
|
|
if (!draftMessage) return null;
|
|
|
|
const to =
|
|
draftMessage.toRecipients?.map((rec) => rec.emailAddress?.address || '').filter(Boolean) ||
|
|
[];
|
|
const subject = draftMessage.subject;
|
|
|
|
let content = '';
|
|
if (draftMessage.body?.content) {
|
|
content = draftMessage.body.content;
|
|
if (draftMessage.body.contentType?.toLowerCase() === 'text') {
|
|
content = content.replace(/\n/g, '<br>'); // Basic text to HTML
|
|
}
|
|
}
|
|
|
|
const cc =
|
|
draftMessage.ccRecipients?.map((rec) => rec.emailAddress?.address || '').filter(Boolean) ||
|
|
[];
|
|
const bcc =
|
|
draftMessage.bccRecipients?.map((rec) => rec.emailAddress?.address || '').filter(Boolean) ||
|
|
[];
|
|
|
|
return {
|
|
id: draftMessage.id || '',
|
|
to,
|
|
cc,
|
|
bcc,
|
|
subject: subject ? he.decode(subject).trim() : '',
|
|
content,
|
|
rawMessage: draftMessage, // Include raw Graph message
|
|
};
|
|
}
|
|
private async withErrorHandler<T>(
|
|
operation: string,
|
|
fn: () => Promise<T> | T,
|
|
context?: Record<string, unknown>,
|
|
): Promise<T> {
|
|
try {
|
|
return await Promise.resolve(fn());
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (error: any) {
|
|
// Adapt error checking for Microsoft Graph errors
|
|
const isFatal =
|
|
FatalErrors.includes(error.message) ||
|
|
(error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 429); // Consider 4xx errors other than 429 as potentially fatal depending on the error
|
|
console.error(
|
|
`[${isFatal ? 'FATAL_ERROR' : 'ERROR'}] [Outlook Driver] Operation: ${operation}`,
|
|
{
|
|
error: error.message,
|
|
code: error.code, // Graph errors might have error.code
|
|
statusCode: error.statusCode, // Graph errors have status codes
|
|
context: sanitizeContext(context),
|
|
stack: error.stack,
|
|
isFatal,
|
|
},
|
|
);
|
|
if (isFatal) await deleteActiveConnection();
|
|
throw new StandardizedError(error, operation, context);
|
|
}
|
|
}
|
|
private withSyncErrorHandler<T>(
|
|
operation: string,
|
|
fn: () => T,
|
|
context?: Record<string, unknown>,
|
|
): T {
|
|
try {
|
|
return fn();
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} catch (error: any) {
|
|
const isFatal =
|
|
FatalErrors.includes(error.message) ||
|
|
(error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 429);
|
|
console.error(`[Outlook Driver Error] Operation: ${operation}`, {
|
|
error: error.message,
|
|
code: error.code,
|
|
statusCode: error.statusCode,
|
|
context: sanitizeContext(context),
|
|
stack: error.stack,
|
|
isFatal,
|
|
});
|
|
if (isFatal) void deleteActiveConnection();
|
|
throw new StandardizedError(error, operation, context);
|
|
}
|
|
}
|
|
}
|