diff --git a/apps/mail/app/(auth)/login/login-client.tsx b/apps/mail/app/(auth)/login/login-client.tsx index fd976b124..e50ddf129 100644 --- a/apps/mail/app/(auth)/login/login-client.tsx +++ b/apps/mail/app/(auth)/login/login-client.tsx @@ -4,7 +4,7 @@ import { useEffect, type ReactNode, useState, Suspense } from 'react'; import type { EnvVarInfo } from '@zero/server/auth-providers'; import ErrorMessage from '@/app/(auth)/login/error-message'; import { signIn, useSession } from '@/lib/auth-client'; -import { Google } from '@/components/icons/icons'; +import { Google, Microsoft } from '@/components/icons/icons'; import { Button } from '@/components/ui/button'; import { TriangleAlert } from 'lucide-react'; import { useRouter } from 'next/navigation'; @@ -42,6 +42,9 @@ const getProviderIcon = (providerId: string, className?: string): ReactNode => { case 'google': return ; + case 'microsoft': + return ; + case 'zero': return ( <> diff --git a/apps/mail/app/(routes)/settings/connections/page.tsx b/apps/mail/app/(routes)/settings/connections/page.tsx index 521835e20..b009c0291 100644 --- a/apps/mail/app/(routes)/settings/connections/page.tsx +++ b/apps/mail/app/(routes)/settings/connections/page.tsx @@ -24,7 +24,6 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { useTranslations } from 'next-intl'; import { useState } from 'react'; -import { env } from '@/lib/env'; import Image from 'next/image'; import { toast } from 'sonner'; @@ -80,119 +79,122 @@ export default function ConnectionsPage() { ) : data?.connections?.length ? (
- {data.connections.map((connection) => ( -
-
- {connection.picture ? ( - {connection.id - ) : ( -
- - - -
- )} -
- {connection.name} -
- { - if (window.innerWidth <= 768) { - setOpenTooltip(open ? connection.id : null); - } - }} - > - - { - if (window.innerWidth <= 768) { - setOpenTooltip( - openTooltip === connection.id ? null : connection.id, - ); - } - }} - > - {connection.email} - - - -
{connection.email}
-
-
+ {data.connections.map((connection) => { + const Icon = emailProviders.find( + (p) => p.providerId === connection.providerId, + )?.icon; + return ( +
+
+ {connection.picture ? ( + + ) : ( +
+ {Icon && } +
+ )} +
+ {connection.name} +
+ { + if (window.innerWidth <= 768) { + setOpenTooltip(open ? connection.id : null); + } + }} + > + + { + if (window.innerWidth <= 768) { + setOpenTooltip( + openTooltip === connection.id ? null : connection.id, + ); + } + }} + > + {connection.email} + + + +
{connection.email}
+
+
+
+
+ {data.disconnectedIds?.includes(connection.id) ? ( + <> +
+ + {t('pages.settings.connections.disconnected')} + +
+ + + ) : null} + + + + + + + + {t('pages.settings.connections.disconnectTitle')} + + + {t('pages.settings.connections.disconnectDescription')} + + +
+ + + + + + +
+
+
+
-
- {data.disconnectedIds?.includes(connection.id) ? ( - <> -
- - {t('pages.settings.connections.disconnected')} - -
- - - ) : null} - - - - - - - - {t('pages.settings.connections.disconnectTitle')} - - - {t('pages.settings.connections.disconnectDescription')} - - -
- - - - - - -
-
-
-
-
- ))} + ); + })}
) : null} diff --git a/apps/mail/components/connection/add.tsx b/apps/mail/components/connection/add.tsx index 010382df5..11063d668 100644 --- a/apps/mail/components/connection/add.tsx +++ b/apps/mail/components/connection/add.tsx @@ -15,7 +15,6 @@ import { useTranslations } from 'next-intl'; import { Button } from '../ui/button'; import { motion } from 'motion/react'; import { cn } from '@/lib/utils'; -import { env } from '@/lib/env'; import { useMemo } from 'react'; import { toast } from 'sonner'; @@ -31,11 +30,11 @@ export const AddConnectionDialog = ({ const { connections, attach } = useBilling(); const t = useTranslations(); - const pathname = usePathname(); const canCreateConnection = useMemo(() => { if (!connections?.remaining && !connections?.unlimited) return false; return (connections?.unlimited && !connections?.remaining) || (connections?.remaining ?? 0) > 0; }, [connections]); + const pathname = usePathname(); const handleUpgrade = async () => { if (attach) { @@ -97,33 +96,34 @@ export const AddConnectionDialog = ({ animate={{ opacity: 1 }} transition={{ duration: 0.3 }} > - {emailProviders.map((provider, index) => ( - - - - ))} + + + ); + })} ( ); +export const GmailColor = ({ className }: { className?: string }) => ( + + + + + + + +); + +export const Microsoft = ({ className }: { className?: string }) => ( + + Microsoft + + +); + export const Outlook = ({ className }: { className?: string }) => ( Outlook @@ -25,6 +45,108 @@ export const Outlook = ({ className }: { className?: string }) => ( ); +export const OutlookColor = ({ className }: { className?: string }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + export const Discord = ({ className }: { className?: string }) => ( Discord diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 8548263fb..49151f737 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -35,6 +35,7 @@ import { backgroundQueueAtom } from '@/store/backgroundQueue'; import { handleUnsubscribe } from '@/lib/email-utils.client'; import { useMediaQuery } from '../../hooks/use-media-query'; import { useSearchValue } from '@/hooks/use-search-value'; +import { useConnections } from '@/hooks/use-connections'; import { MailList } from '@/components/mail/mail-list'; import { useHotkeysContext } from 'react-hotkeys-hook'; import { useParams, useRouter } from 'next/navigation'; @@ -319,10 +320,18 @@ export function MailLayout() { const isMobile = useIsMobile(); const router = useRouter(); const { data: session, isPending } = useSession(); + const { data: connections } = useConnections(); const t = useTranslations(); const prevFolderRef = useRef(folder); const { enableScope, disableScope } = useHotkeysContext(); + const activeAccount = useMemo(() => { + if (!session?.activeConnection?.id || !connections?.connections) return null; + return connections.connections.find( + (connection) => connection.id === session.activeConnection?.id, + ); + }, [session?.activeConnection?.id, connections?.connections]); + useEffect(() => { if (prevFolderRef.current !== folder && mail.bulkSelected.length > 0) { clearBulkSelection(); @@ -454,7 +463,7 @@ export function MailLayout() {
- {folder === 'inbox' && ( + {activeAccount?.providerId === 'google' && folder === 'inbox' && ( 0} /> )}
diff --git a/apps/mail/components/ui/nav-user.tsx b/apps/mail/components/ui/nav-user.tsx index 426175edc..ab75157da 100644 --- a/apps/mail/components/ui/nav-user.tsx +++ b/apps/mail/components/ui/nav-user.tsx @@ -51,8 +51,7 @@ import { toast } from 'sonner'; import Link from 'next/link'; export function NavUser() { - const { data: session, refetch } = useSession(); - const router = useRouter(); + const { data: session, refetch: refetchSession } = useSession(); const { data, refetch: refetchConnections } = useConnections(); const [isRendered, setIsRendered] = useState(false); const [showPricing, setShowPricing] = useState(false); @@ -60,9 +59,6 @@ export function NavUser() { const t = useTranslations(); const { state } = useSidebar(); const trpc = useTRPC(); - const { refetch: refetchStats } = useStats(); - const [{ refetch: refetchThreads }] = useThreads(); - const { refetch: refetchLabels } = useLabels(); const { mutateAsync: setDefaultConnection } = useMutation( trpc.connections.setDefault.mutationOptions(), ); @@ -98,20 +94,13 @@ export function NavUser() { useEffect(() => setIsRendered(true), []); - const refetchBrainLabels = useCallback(() => { - queryClient.invalidateQueries({ queryKey: trpc.brain.getLabels.queryKey() }); - }, [queryClient]); - const handleAccountSwitch = (connectionId: string) => async () => { if (connectionId === session?.connectionId) return; await setDefaultConnection({ connectionId }); - refetch(); - refetchConnections(); - refetchThreads(); - refetchLabels(); - refetchStats(); - refetchBrainState(); - refetchBrainLabels(); + await refetchConnections(); + refetchSession(); + // TODO: fix this cache issue, for now this is a quick fix to hard refresh the page + window.location.href = pathname; }; const handleLogout = async () => { diff --git a/apps/mail/lib/constants.ts b/apps/mail/lib/constants.tsx similarity index 72% rename from apps/mail/lib/constants.ts rename to apps/mail/lib/constants.tsx index d261f73a2..d7f6f8d5d 100644 --- a/apps/mail/lib/constants.ts +++ b/apps/mail/lib/constants.tsx @@ -1,3 +1,4 @@ +import { GmailColor, OutlookColor } from '../components/icons/icons'; import { env } from '@/lib/env'; export const I18N_LOCALE_COOKIE_NAME = 'i18n:locale'; @@ -14,15 +15,15 @@ export const CACHE_BURST_KEY = 'cache-burst:v0.0.2'; export const emailProviders = [ { - name: 'Google', - icon: 'M11.99 13.9v-3.72h9.36c.14.63.25 1.22.25 2.05c0 5.71-3.83 9.77-9.6 9.77c-5.52 0-10-4.48-10-10S6.48 2 12 2c2.7 0 4.96.99 6.69 2.61l-2.84 2.76c-.72-.68-1.98-1.48-3.85-1.48c-3.31 0-6.01 2.75-6.01 6.12s2.7 6.12 6.01 6.12c3.83 0 5.24-2.65 5.5-4.22h-5.51z', + name: 'Gmail', + icon: GmailColor, providerId: 'google', }, - // { - // name: 'Microsoft', - // icon: 'M11.99 13.9v-3.72h9.36c.14.63.25 1.22.25 2.05c0 5.71-3.83 9.77-9.6 9.77c-5.52 0-10-4.48-10-10S6.48 2 12 2c2.7 0 4.96.99 6.69 2.61l-2.84 2.76c-.72-.68-1.98-1.48-3.85-1.48c-3.31 0-6.01 2.75-6.01 6.12s2.7 6.12 6.01 6.12c3.83 0 5.24-2.65 5.5-4.22h-5.51z', - // providerId: 'microsoft', - // }, + { + name: 'Outlook', + icon: OutlookColor, + providerId: 'microsoft', + }, ] as const; interface GmailColor { diff --git a/apps/mail/package.json b/apps/mail/package.json index 9fb90da9c..e4edf237e 100644 --- a/apps/mail/package.json +++ b/apps/mail/package.json @@ -86,7 +86,7 @@ "@zero/server": "workspace:*", "ai": "^4.3.9", "autumn-js": "0.0.22", - "better-auth": "1.2.7", + "better-auth": "1.2.8-beta.7", "canvas-confetti": "1.9.3", "cheerio": "1.0.0", "class-variance-authority": "0.7.1", diff --git a/apps/server/package.json b/apps/server/package.json index 79795baaf..5f9492914 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,6 +16,8 @@ "dependencies": { "@ai-sdk/openai": "^1.3.21", "@hono/trpc-server": "^0.3.4", + "@microsoft/microsoft-graph-client": "^3.0.7", + "@microsoft/microsoft-graph-types": "^2.40.0", "@react-email/components": "^0.0.38", "@react-email/render": "^1.1.0", "@trpc/client": "^11.1.2", @@ -32,6 +34,7 @@ "googleapis": "^148.0.0", "he": "^1.2.0", "hono": "^4.7.8", + "hono-party": "^0.0.12", "jsonrepair": "^3.12.0", "mimetext": "^3.0.27", "partyserver": "^0.0.71", diff --git a/apps/server/src/lib/auth-providers.ts b/apps/server/src/lib/auth-providers.ts index cdfee61d8..da6cab93a 100644 --- a/apps/server/src/lib/auth-providers.ts +++ b/apps/server/src/lib/auth-providers.ts @@ -50,25 +50,31 @@ export const authProviders = (env: Record): ProviderConfig[] => }, required: true, }, - // { - // id: 'microsoft', - // name: 'Microsoft', - // requiredEnvVars: ['MICROSOFT_CLIENT_ID', 'MICROSOFT_CLIENT_SECRET'], - // envVarInfo: [ - // { name: 'MICROSOFT_CLIENT_ID', source: 'Microsoft Azure App ID' }, - // { name: 'MICROSOFT_CLIENT_SECRET', source: 'Microsoft Azure App Password' }, - // ], - // config: { - // clientId: env.MICROSOFT_CLIENT_ID!, - // clientSecret: env.MICROSOFT_CLIENT_SECRET!, - // redirectUri: env.MICROSOFT_REDIRECT_URI!, - // scope: ['https://graph.microsoft.com/User.Read', 'offline_access'], - // authority: 'https://login.microsoftonline.com/common', - // responseType: 'code', - // prompt: 'consent', - // loginHint: 'email', - // }, - // }, + { + id: 'microsoft', + name: 'Microsoft', + requiredEnvVars: ['MICROSOFT_CLIENT_ID', 'MICROSOFT_CLIENT_SECRET'], + envVarInfo: [ + { name: 'MICROSOFT_CLIENT_ID', source: 'Microsoft Azure App ID' }, + { name: 'MICROSOFT_CLIENT_SECRET', source: 'Microsoft Azure App Password' }, + ], + config: { + clientId: env.MICROSOFT_CLIENT_ID, + clientSecret: env.MICROSOFT_CLIENT_SECRET, + redirectUri: env.MICROSOFT_REDIRECT_URI, + scope: [ + 'https://graph.microsoft.com/User.Read', + 'https://graph.microsoft.com/Mail.ReadWrite', + 'https://graph.microsoft.com/Mail.Send', + 'offline_access', + ], + authority: 'https://login.microsoftonline.com/common', + responseType: 'code', + prompt: 'consent', + loginHint: 'email', + disableProfilePhoto: true, + }, + }, ]; export function isProviderEnabled(provider: ProviderConfig, env: Record): boolean { diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index 330ee96d6..5ce70f717 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -31,7 +31,12 @@ const connectionHandlerHook = async (account: Account) => { } const driver = createDriver(account.providerId, { - auth: { accessToken: account.accessToken, refreshToken: account.refreshToken, email: '' }, + auth: { + accessToken: account.accessToken, + refreshToken: account.refreshToken, + userId: account.userId, + email: '', + }, }); const userInfo = await driver.getUserInfo().catch(() => { diff --git a/apps/server/src/lib/driver/google.ts b/apps/server/src/lib/driver/google.ts index bed4e1738..9b17a13b4 100644 --- a/apps/server/src/lib/driver/google.ts +++ b/apps/server/src/lib/driver/google.ts @@ -34,7 +34,6 @@ export class GoogleMailManager implements MailManager { this.gmail = google.gmail({ version: 'v1', auth: this.auth }); } - public getScope(): string { return [ 'https://www.googleapis.com/auth/gmail.modify', @@ -42,7 +41,6 @@ export class GoogleMailManager implements MailManager { 'https://www.googleapis.com/auth/userinfo.email', ].join(' '); } - public getAttachment(messageId: string, attachmentId: string) { return this.withErrorHandler( 'getAttachment', @@ -62,7 +60,6 @@ export class GoogleMailManager implements MailManager { { messageId, attachmentId }, ); } - public getEmailAliases() { return this.withErrorHandler('getEmailAliases', async () => { const profile = await this.gmail.users.getProfile({ @@ -95,7 +92,6 @@ export class GoogleMailManager implements MailManager { return aliases; }); } - public markAsRead(threadIds: string[]) { return this.withErrorHandler( 'markAsRead', @@ -105,7 +101,6 @@ export class GoogleMailManager implements MailManager { { threadIds }, ); } - public markAsUnread(threadIds: string[]) { return this.withErrorHandler( 'markAsUnread', @@ -115,7 +110,6 @@ export class GoogleMailManager implements MailManager { { threadIds }, ); } - public getUserInfo() { return this.withErrorHandler( 'getUserInfo', @@ -132,7 +126,6 @@ export class GoogleMailManager implements MailManager { {}, ); } - public getTokens(code: string) { return this.withErrorHandler( 'getTokens', @@ -143,7 +136,6 @@ export class GoogleMailManager implements MailManager { { code }, ); } - public count() { return this.withErrorHandler( 'count', @@ -174,7 +166,6 @@ export class GoogleMailManager implements MailManager { { email: this.config.auth?.email }, ); } - public list(params: { folder: string; query?: string; @@ -209,7 +200,6 @@ export class GoogleMailManager implements MailManager { { folder, q, maxResults, _labelIds, pageToken, email: this.config.auth?.email }, ); } - public get(id: string) { return this.withErrorHandler( 'get', @@ -353,7 +343,6 @@ export class GoogleMailManager implements MailManager { { id, email: this.config.auth?.email }, ); } - public create(data: IOutgoingMessage) { return this.withErrorHandler( 'create', @@ -371,7 +360,6 @@ export class GoogleMailManager implements MailManager { { data, email: this.config.auth?.email }, ); } - public delete(id: string) { return this.withErrorHandler( 'delete', @@ -382,7 +370,6 @@ export class GoogleMailManager implements MailManager { { id }, ); } - public normalizeIds(ids: string[]) { return this.withSyncErrorHandler( 'normalizeIds', @@ -395,7 +382,6 @@ export class GoogleMailManager implements MailManager { { ids }, ); } - public modifyLabels( threadIds: string[], options: { addLabels: string[]; removeLabels: string[] }, @@ -411,7 +397,6 @@ export class GoogleMailManager implements MailManager { { threadIds, options }, ); } - public sendDraft(draftId: string, data: IOutgoingMessage) { return this.withErrorHandler( 'sendDraft', @@ -663,9 +648,6 @@ export class GoogleMailManager implements MailManager { return false; } } - - // =============================================== - private async modifyThreadLabels( threadIds: string[], requestBody: gmail_v1.Schema$ModifyThreadRequest, @@ -990,7 +972,6 @@ export class GoogleMailManager implements MailManager { rawMessage: draft.message, }; } - private async withErrorHandler( operation: string, fn: () => Promise | T, @@ -1037,16 +1018,15 @@ export class GoogleMailManager implements MailManager { } } - private findAttachments(parts: any[]): any[] { - let results: any[] = []; + private findAttachments(parts: gmail_v1.Schema$MessagePart[]): gmail_v1.Schema$MessagePart[] { + let results: gmail_v1.Schema$MessagePart[] = []; for (const part of parts) { if (part.filename && part.filename.length > 0) { const contentDisposition = - part.headers?.find((h: any) => h.name?.toLowerCase() === 'content-disposition')?.value || - ''; + part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value || ''; const isInline = contentDisposition.toLowerCase().includes('inline'); - const hasContentId = part.headers?.some((h: any) => h.name?.toLowerCase() === 'content-id'); + const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id'); if (!isInline || (isInline && !hasContentId)) { results.push(part); diff --git a/apps/server/src/lib/driver/index.ts b/apps/server/src/lib/driver/index.ts index 0d578c073..36fb1a079 100644 --- a/apps/server/src/lib/driver/index.ts +++ b/apps/server/src/lib/driver/index.ts @@ -1,9 +1,10 @@ import type { MailManager, ManagerConfig } from './types'; +import { OutlookMailManager } from './microsoft'; import { GoogleMailManager } from './google'; const supportedProviders = { google: GoogleMailManager, - // microsoft: microsoftDriver, + microsoft: OutlookMailManager, }; export const createDriver = ( diff --git a/apps/server/src/lib/driver/microsoft.ts b/apps/server/src/lib/driver/microsoft.ts index 572691408..5330e972b 100644 --- a/apps/server/src/lib/driver/microsoft.ts +++ b/apps/server/src/lib/driver/microsoft.ts @@ -1,446 +1,1194 @@ -/** - * NEEDS TO BE FIXED, COMMENTED OUT FOR NOW - */ +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'; -// import { IOutgoingMessage, Sender, type ParsedMessage, type InitialThread } from '@/types'; -// import { parseAddressList, parseFrom, wasSentWithTLS } from '@/lib/email-utils'; -// import { fromBinary, fromBase64Url, findHtmlBody } from '@/lib/driver-utils'; -// import { Conversation } from '@microsoft/microsoft-graph-types'; -// import type { Message } from '@microsoft/microsoft-graph-types'; -// import { Client } from '@microsoft/microsoft-graph-client'; -// import { filterSuggestions } from '@/lib/filter'; -// import { cleanSearchValue } from '@/lib/utils'; -// import { IConfig, MailManager } from './types'; -// import { createMimeMessage } from 'mimetext'; -// import * as he from 'he'; +export class OutlookMailManager implements MailManager { + private graphClient: Client; -// export const driver = async (config: IConfig): Promise => { -// const getClient = (accessToken: string) => { -// return Client.initWithMiddleware({ -// authProvider: { -// getAccessToken: async () => accessToken, -// }, -// }); -// }; + constructor(public config: ManagerConfig) { + const getAccessToken = async () => { + const c = getContext(); + 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; + }; -// const getScope = () => -// 'https://graph.microsoft.com/User.Read https://graph.microsoft.com/Mail.ReadWrite https://graph.microsoft.com/Mail.Send offline_access'; + this.graphClient = Client.initWithMiddleware({ + authProvider: { + getAccessToken, + }, + }); + } -// const parseMessage = (message: Message): ParsedMessage => { -// const headers = message.internetMessageHeaders || []; -// const dateHeader = headers.find((h) => h.name?.toLowerCase() === 'date'); -// const receivedOn = (dateHeader?.value || -// message.receivedDateTime || -// new Date().toISOString()) as string; -// const sender = headers.find((h) => h.name?.toLowerCase() === 'from')?.value || 'Failed'; -// const subject = headers.find((h) => h.name?.toLowerCase() === 'subject')?.value || ''; -// const references = headers.find((h) => h.name?.toLowerCase() === 'references')?.value || ''; -// const inReplyTo = headers.find((h) => h.name?.toLowerCase() === 'in-reply-to')?.value || ''; -// const messageId = headers.find((h) => h.name?.toLowerCase() === 'message-id')?.value || ''; -// const listUnsubscribe = headers.find( -// (h) => h.name?.toLowerCase() === 'list-unsubscribe', -// )?.value; -// const listUnsubscribePost = headers.find( -// (h) => h.name?.toLowerCase() === 'list-unsubscribe-post', -// )?.value; -// const replyTo = headers.find((h) => h.name?.toLowerCase() === 'reply-to')?.value; -// const to = headers.find((h) => h.name?.toLowerCase() === 'to')?.value || ''; -// const cc = headers.find((h) => h.name?.toLowerCase() === 'cc')?.value || ''; -// const receivedHeaders = headers -// .filter((h) => h.name?.toLowerCase() === 'received') -// .map((h) => h.value || ''); -// const hasTLSReport = headers.some((h) => h.name?.toLowerCase() === 'tls-report'); + 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(); -// return { -// id: message.id || 'ERROR', -// bcc: [], -// threadId: message.conversationId || '', -// title: message.subject || 'ERROR', -// tls: wasSentWithTLS(receivedHeaders) || !!hasTLSReport, -// tags: message.categories?.map((c) => ({ id: c, name: c })) || [], -// // listUnsubscribe, -// // listUnsubscribePost, -// // replyTo, -// references, -// inReplyTo, -// sender: { -// email: sender, -// name: sender, -// }, -// unread: !message.isRead, -// to: parseAddressList(to), -// cc: cc ? parseAddressList(cc) : null, -// receivedOn, -// subject: subject ? subject.replace(/"/g, '').trim() : '(no subject)', -// messageId, -// body: message.body?.content || '', -// processedHtml: message.body?.content || '', -// blobUrl: '', -// }; -// }; + const attachment = response; -// const parseOutgoing = async ({ -// to, -// subject, -// message, -// attachments, -// headers, -// cc, -// bcc, -// }: IOutgoingMessage) => { -// const msg = createMimeMessage(); -// const fromEmail = config.auth?.email || 'nobody@example.com'; -// msg.setSender({ name: '', addr: fromEmail }); + if (!attachment || !attachment.contentBytes) { + throw new Error('Attachment data not found'); + } -// const uniqueRecipients = new Set(); + const base64 = fromBase64Url(attachment.contentBytes); -// if (!Array.isArray(to) || to.length === 0) { -// throw new Error('Recipient address required'); -// } + 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 toRecipients = to -// .filter((recipient) => { -// if (!recipient || !recipient.email) return false; -// const email = recipient.email.toLowerCase(); -// if (!uniqueRecipients.has(email)) { -// uniqueRecipients.add(email); -// return true; -// } -// return false; -// }) -// .map((recipient) => ({ -// name: recipient.name || '', -// addr: recipient.email, -// })); + const aliases: { email: string; name?: string; primary?: boolean }[] = [ + { email: primaryEmail, primary: true }, + ]; -// if (toRecipients.length === 0) { -// throw new Error('No valid recipients found in To field'); -// } + 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; + } -// msg.setTo(toRecipients); + const batchRequests = messageIds.map((id, index) => ({ + id: `${index}`, + method: 'PATCH', + url: `/me/messages/${id}`, + body: { isRead: isRead }, + headers: { 'Content-Type': 'application/json' }, + })); -// if (Array.isArray(cc) && cc.length > 0) { -// const ccRecipients = cc -// .filter((recipient) => { -// const email = recipient.email.toLowerCase(); -// if (!uniqueRecipients.has(email)) { -// uniqueRecipients.add(email); -// return true; -// } -// return false; -// }) -// .map((recipient) => ({ -// name: recipient.name || '', -// addr: recipient.email, -// })); -// msg.setCc(ccRecipients); -// } + 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(); -// if (Array.isArray(bcc) && bcc.length > 0) { -// const bccRecipients = bcc -// .filter((recipient) => { -// const email = recipient.email.toLowerCase(); -// if (!uniqueRecipients.has(email)) { -// uniqueRecipients.add(email); -// return true; -// } -// return false; -// }) -// .map((recipient) => ({ -// name: recipient.name || '', -// addr: recipient.email, -// })); -// msg.setBcc(bccRecipients); -// } + let photoUrl = ''; + try { + // Requires separate fetching logic + } catch (error: unknown) { + console.warn( + 'Could not fetch user photo:', + error instanceof Error ? error.message : 'Unknown error', + ); + } -// msg.setSubject(subject || ''); -// msg.addMessage({ -// contentType: 'text/html', -// data: message.trim(), -// }); + const info = { + address: user.mail || user.userPrincipalName || '', + name: user.displayName || '', + photo: photoUrl, + }; + console.log({ info }); + return info; + }, + {}, + ); + } + public getTokens(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 (attachments && attachments.length > 0) { -// for (const attachment of attachments) { -// msg.addAttachment({ -// filename: attachment.filename, -// contentType: attachment.contentType, -// data: attachment.content, -// }); -// } -// } + if (!userLabels.value) { + return []; + } -// return msg.asRaw(); -// }; + const folders = await Promise.all( + userLabels.value.map(async (folder: MailFolder) => { + try { + const res = await this.graphClient.api(`/me/mailfolders/${folder.id}`).get(); -// const normalizeSearch = (folder: string, q: string) => { -// if (!q) return ''; -// const searchValue = cleanSearchValue(q); -// return `contains(subject,'${searchValue}') or contains(body,'${searchValue}')`; -// }; + let normalizedLabel = res.displayName || res.id || ''; -// return { -// // @ts-expect-error, not complete yet -// get: async (id: string) => { -// const client = getClient(config.auth?.access_token || ''); -// console.log('get', id); -// const message: Message = await client.api(`/me/messages/${id}`).get(); -// console.log('message', message); + 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'; -// // Get all messages in the conversation using the conversationId -// // const conversationMessages = await client -// // .api('/me/messages') -// // .filter(`conversationId eq '${message.conversationId}'`) -// // .get(); + // Use unreadItemCount only for Inbox, use totalItemCount for all other folders + const count = + res.id?.toLowerCase() === 'inbox' + ? Number(res.unreadItemCount) + : Number(res.totalItemCount); -// // console.log('conversationMessages', conversationMessages); + 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, + }; + } + }), + ); -// // const messages = [null] -// return { -// messages: [ -// { -// decodedBody: message.body?.content, -// processedHtml: message.body?.content, -// title: message.subject, -// blobUrl: message.body?.content, -// to: [], -// receivedOn: message.receivedDateTime -// ? new Date(message.receivedDateTime).toISOString() -// : new Date().toISOString(), -// threadId: message.id, -// id: message.id, -// messageId: message.id, -// subject: message.subject, -// sender: { -// email: message.sender?.emailAddress?.address, -// name: message.sender?.emailAddress?.name || message.sender?.emailAddress?.address, -// }, -// }, -// ], -// latest: { -// to: [], -// receivedOn: message.receivedDateTime -// ? new Date(message.receivedDateTime).toISOString() -// : new Date().toISOString(), -// threadId: message.id, -// id: message.id, -// messageId: message.id, -// subject: message.subject, -// sender: { -// email: message.sender?.emailAddress?.address, -// name: message.sender?.emailAddress?.name || message.sender?.emailAddress?.address, -// }, -// }, -// hasUnread: false, -// totalReplies: 4, -// }; -// }, + 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; -// create: async (data: IOutgoingMessage) => { -// const client = getClient(config.auth?.access_token || ''); -// const rawMessage = await parseOutgoing(data); -// return client.api('/me/sendMail').post({ -// message: { -// subject: data.subject, -// body: { -// contentType: 'HTML', -// content: data.message, -// }, -// toRecipients: data.to.map((r) => ({ emailAddress: { address: r.email } })), -// ccRecipients: data.cc?.map((r) => ({ emailAddress: { address: r.email } })), -// bccRecipients: data.bcc?.map((r) => ({ emailAddress: { address: r.email } })), -// }, -// }); -// }, + let folderId = this.getOutlookFolderId(folder); + if (!folderId) { + folderId = folder; + } -// createDraft: async (data: any) => { -// const client = getClient(config.auth?.access_token || ''); -// return client.api('/me/messages').post({ -// subject: data.subject, -// body: { -// contentType: 'HTML', -// content: data.message, -// }, -// toRecipients: data.to.map((r: any) => ({ emailAddress: { address: r.email } })), -// ccRecipients: data.cc?.map((r: any) => ({ emailAddress: { address: r.email } })), -// bccRecipients: data.bcc?.map((r: any) => ({ emailAddress: { address: r.email } })), -// }); -// }, -// getUserLabels() { -// return new Promise((resolve) => resolve([])); -// }, + let request = this.graphClient.api(`/me/mailFolders/${folderId}/messages`).top(maxResults); -// getDraft: async (id: string) => { -// const client = getClient(config.auth?.access_token || ''); -// const draft = await client.api(`/me/messages/${id}`).get(); -// // return parseMessage(draft); -// return { id: id }; -// }, + // if (q) { + // request = request.search(`"${q}"`); + // } -// listDrafts: async (q?: string, maxResults = 20, pageToken?: string) => { -// const client = getClient(config.auth?.access_token || ''); -// const response = await client -// .api('/me/messages') -// .filter('isDraft eq true') -// .top(1) -// .skip(pageToken ? parseInt(pageToken) : 0) -// .get(); -// return { -// drafts: response.value.map(parseMessage), -// nextPageToken: response['@odata.nextLink'] -// ? (parseInt(pageToken || '0') + maxResults).toString() -// : undefined, -// }; -// }, + request = request.select( + 'id,subject,from,toRecipients,ccRecipients,bccRecipients,sentDateTime,receivedDateTime,isRead,internetMessageId,inferenceClassification,categories,parentFolderId', + ); -// delete: async (id: string) => { -// const client = getClient(config.auth?.access_token || ''); -// return client.api(`/me/messages/${id}`).delete(); -// }, + 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.', + ); + } -// list: async (params: { -// folder: string; -// query?: string; -// maxResults: number; -// labelIds?: string[]; -// pageToken?: string; -// }): Promise<{ threads: InitialThread[] }> => { -// const { folder, query, maxResults = 20, pageToken } = params; -// const client = getClient(config.auth?.access_token || ''); -// // const searchQuery = query ? normalizeSearch(folder, query) : ''; -// const response = await client -// .api('/me/messages') -// // .filter(searchQuery) -// .top(3) -// // .skip(pageToken ? parseInt(pageToken.toString()) : 0) -// .get(); + // request = request.orderby('receivedDateTime desc'); -// // console.log(response); + return this.withErrorHandler( + 'list', + async () => { + const res = await request.get(); -// const threads: InitialThread[] = (response.value as Message[]).map((message) => ({ -// id: message.id ?? '', -// subject: message.subject, -// snippet: message.bodyPreview, -// unread: !message.isRead, -// date: message.receivedDateTime, -// })); + // console.log(JSON.stringify(res, null, 4)); -// const result = { -// threads, -// nextPageToken: response['@odata.nextLink'] -// ? (parseInt(pageToken?.toString() || '0') + maxResults).toString() -// : undefined, -// }; + const messages: Message[] = res.value; + const nextPageLink: string | undefined = res['@odata.nextLink']; -// return result as unknown as { threads: InitialThread[] }; -// }, + // First parse all messages to get basic info + const parsedMessages = await Promise.all( + messages.map((msg) => this.parseOutlookMessage(msg)), + ); -// count: async () => { -// // const client = getClient(config.auth?.access_token || ''); -// // const response = await client.api('/me/messages').get(); -// return []; -// }, + // 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: [], + }; + } + }), + ); -// generateConnectionAuthUrl: (userId: string) => { -// const params = new URLSearchParams({ -// client_id: process.env.MICROSOFT_CLIENT_ID as string, -// redirect_uri: process.env.MICROSOFT_REDIRECT_URI as string, -// response_type: 'code', -// scope: getScope(), -// state: userId, -// }); -// return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${params.toString()}`; -// }, + // 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(); -// getTokens: async (code: string) => { -// const params = new URLSearchParams({ -// client_id: process.env.MICROSOFT_CLIENT_ID as string, -// client_secret: process.env.MICROSOFT_CLIENT_SECRET as string, -// code, -// redirect_uri: process.env.MICROSOFT_REDIRECT_URI as string, -// grant_type: 'authorization_code', -// }); + if (!message) { + throw new Error('Message not found'); + } -// const response = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { -// method: 'POST', -// headers: { -// 'Content-Type': 'application/x-www-form-urlencoded', -// }, -// body: params.toString(), -// }); + const bodyContent = message.body?.content || ''; + const bodyContentType = message.body?.contentType?.toLowerCase() || 'text'; -// if (!response.ok) { -// throw new Error('Failed to get tokens'); -// } + let decodedBody = ''; + if (bodyContentType === 'html') { + decodedBody = he.decode(bodyContent); + } else { + decodedBody = he.decode(bodyContent).replace(/\n/g, '
'); + } -// const data = await response.json(); -// return { -// tokens: { -// access_token: data.access_token, -// refresh_token: data.refresh_token, -// expiry_date: Date.now() + data.expires_in * 1000, -// }, -// }; -// }, -// // @ts-expect-error, fix types -// getUserInfo: async (tokens: IConfig['auth']) => { -// if (!tokens?.access_token) throw new Error('No access token provided'); -// const client = getClient(tokens.access_token); -// const user = await client.api('/me').get(); -// return { -// address: user.mail || user.userPrincipalName, -// name: user.displayName, -// photo: null, -// }; -// }, + const attachmentsData = message.attachments || []; -// getScope, + 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(); -// markAsRead: async (ids: string[]) => { -// const client = getClient(config.auth?.access_token || ''); -// await Promise.all( -// ids.map((id) => -// client.api(`/me/messages/${id}`).patch({ -// isRead: true, -// }), -// ), -// ); -// }, + if (!attachmentContent.contentBytes) { + return null; + } -// markAsUnread: async (ids: string[]) => { -// const client = getClient(config.auth?.access_token || ''); -// await Promise.all( -// ids.map((id) => -// client.api(`/me/messages/${id}`).patch({ -// isRead: false, -// }), -// ), -// ); -// }, + 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 => a !== null)); -// normalizeIds: (ids: string[]) => ({ -// threadIds: ids, -// }), + const parsedData = this.parseOutlookMessage(message); -// modifyLabels: async ( -// ids: string[], -// options: { addLabels: string[]; removeLabels: string[] }, -// ) => { -// const client = getClient(config.auth?.access_token || ''); -// await Promise.all( -// ids.map((id) => -// client.api(`/me/messages/${id}`).patch({ -// categories: options.addLabels, -// }), -// ), -// ); -// }, + const fullEmailData = { + ...parsedData, + body: '', + processedHtml: '', + blobUrl: '', + decodedBody: decodedBody, + attachments, + }; -// getAttachment: async (messageId: string, attachmentId: string) => { -// const client = getClient(config.auth?.access_token || ''); -// const attachment = await client -// .api(`/me/messages/${messageId}/attachments/${attachmentId}`) -// .get(); -// return attachment.contentBytes; -// }, -// }; -// }; + 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