diff --git a/apps/mail/lib/constants.tsx b/apps/mail/lib/constants.tsx index 36c23497bd..688079536b 100644 --- a/apps/mail/lib/constants.tsx +++ b/apps/mail/lib/constants.tsx @@ -1,4 +1,4 @@ -import { GmailColor, } from '../components/icons/icons'; +import { GmailColor, OutlookColor } from '../components/icons/icons'; export const I18N_LOCALE_COOKIE_NAME = 'i18n:locale'; export const SIDEBAR_COOKIE_NAME = 'sidebar:state'; @@ -18,11 +18,11 @@ export const emailProviders = [ icon: GmailColor, providerId: 'google', }, - // { - // name: 'Outlook', - // icon: OutlookColor, - // providerId: 'microsoft', - // }, + { + name: 'Outlook', + icon: OutlookColor, + providerId: 'microsoft', + }, ] as const; interface GmailColor { diff --git a/apps/server/src/lib/auth-providers.ts b/apps/server/src/lib/auth-providers.ts index 84db3d2d30..438aba9306 100644 --- a/apps/server/src/lib/auth-providers.ts +++ b/apps/server/src/lib/auth-providers.ts @@ -49,32 +49,32 @@ 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', - // '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, - // }, - // required: false, - // }, + { + 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, + }, + required: false, + }, ]; 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 ca918f6e12..f5f0f1e14a 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -107,6 +107,7 @@ const connectionHandlerHook = async (account: Account) => { const userInfo = await driver.getUserInfo().catch(async () => { if (account.accessToken) { + console.log('[connectionHandlerHook] revoking token', account.accessToken); await driver.revokeToken(account.accessToken); await resetConnection(account.id); } @@ -114,6 +115,7 @@ const connectionHandlerHook = async (account: Account) => { }); if (!userInfo?.address) { + console.log('[connectionHandlerHook] no user info address', userInfo); try { await Promise.allSettled( [account.accessToken, account.refreshToken] @@ -136,6 +138,10 @@ const connectionHandlerHook = async (account: Account) => { expiresAt: new Date(Date.now() + (account.accessTokenExpiresAt?.getTime() || 3600000)), }; + console.log('[connectionHandlerHook] creating connection', userInfo); + + console.log('[connectionHandlerHook] updatingInfo', updatingInfo); + const db = await getZeroDB(account.userId); const [result] = await db.createConnection( account.providerId as EProviders, diff --git a/apps/server/src/lib/brain.ts b/apps/server/src/lib/brain.ts index 5f46e1925b..8850e160e1 100644 --- a/apps/server/src/lib/brain.ts +++ b/apps/server/src/lib/brain.ts @@ -1,7 +1,7 @@ import { ReSummarizeThread, SummarizeMessage, SummarizeThread } from './brain.fallback.prompts'; import { getSubscriptionFactory } from './factories/subscription-factory.registry'; import { AiChatPrompt, StyledEmailAssistantSystemPrompt } from './prompts'; -import { resetConnection } from './server-utils'; +// import { resetConnection } from './server-utils'; import { EPrompts, EProviders } from '../types'; import { getPromptName } from '../pipelines'; import { env } from '../env'; @@ -12,7 +12,7 @@ export const enableBrainFunction = async (connection: { id: string; providerId: await subscriptionFactory.subscribe({ body: { connectionId: connection.id } }); } catch (error) { console.error(`Failed to enable brain function: ${error}`); - await resetConnection(connection.id); + // await resetConnection(connection.id); } }; diff --git a/apps/server/src/lib/driver/microsoft.ts b/apps/server/src/lib/driver/microsoft.ts index 9c14f7dfcd..338ecfd56c 100644 --- a/apps/server/src/lib/driver/microsoft.ts +++ b/apps/server/src/lib/driver/microsoft.ts @@ -12,30 +12,44 @@ import type { User, } from '@microsoft/microsoft-graph-types'; import type { IOutgoingMessage, Label, ParsedMessage } from '../../types'; +import type { MailManager, ManagerConfig, ParsedDraft } 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 { env } from '../../env'; import * as he from 'he'; export class OutlookMailManager implements MailManager { private graphClient: Client; + private accessToken: string; + private refreshToken: string; + private tokenExpiry: number = 0; constructor(public config: ManagerConfig) { + this.accessToken = config.auth.accessToken; + this.refreshToken = config.auth.refreshToken; + 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; + // Check if token is still valid (with 5 minute buffer) + const now = Math.floor(Date.now() / 1000); + if (this.accessToken && this.tokenExpiry > now + 300) { + return this.accessToken; + } + + // Refresh the token + try { + const tokenData = await this.refreshAccessToken(); + this.accessToken = tokenData.accessToken; + if (tokenData.refreshToken) { + this.refreshToken = tokenData.refreshToken; + } + // Set expiry to 50 minutes from now (tokens usually last 60 minutes) + this.tokenExpiry = now + 3000; + return this.accessToken; + } catch (error) { + console.error('Failed to refresh Microsoft access token:', error); + throw new Error('Failed to refresh access token'); + } }; this.graphClient = Client.initWithMiddleware({ @@ -45,6 +59,49 @@ export class OutlookMailManager implements MailManager { }); } + public listHistory(historyId: string): Promise<{ history: T[]; historyId: string }> { + return this.withErrorHandler( + 'listHistory', + async () => { + // added this as Microsoft Graph API doesn't have a direct equivalent to Gmail's history API + // this is a placeholder implementation that returns empty results + console.warn('[listHistory] listHistory is not implemented for Microsoft Graph API'); + return { history: [] as T[], historyId }; + }, + { historyId }, + ); + } + + private async refreshAccessToken(): Promise<{ accessToken: string; refreshToken?: string }> { + const response = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: env.MICROSOFT_CLIENT_ID, + client_secret: env.MICROSOFT_CLIENT_SECRET, + refresh_token: this.refreshToken, + grant_type: 'refresh_token', + scope: this.getScope(), + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to refresh token: ${error}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token?: string; + }; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, // Microsoft sometimes returns a new refresh token + }; + } + public getScope(): string { return [ 'https://graph.microsoft.com/User.Read', @@ -74,6 +131,49 @@ export class OutlookMailManager implements MailManager { { messageId, attachmentId }, ); } + public getMessageAttachments(messageId: string) { + return this.withErrorHandler( + 'getMessageAttachments', + async () => { + const message: Message = await this.graphClient + .api(`/me/messages/${messageId}`) + .select('id,attachments') + .get(); + + if (!message || !message.attachments) { + return []; + } + + const attachments = await Promise.all( + message.attachments.map(async (att) => { + if (!att.id || !att.name || att.size === 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, + }; + }), + ); + + return attachments.filter((a): a is NonNullable => a !== null); + }, + { messageId }, + ); + } public getEmailAliases() { return this.withErrorHandler('getEmailAliases', async () => { const user: User = await this.graphClient.api('/me').select('mail,userPrincipalName').get(); @@ -86,22 +186,22 @@ export class OutlookMailManager implements MailManager { return aliases; }); } - public markAsRead(messageIds: string[]) { + public markAsRead(threadIds: string[]) { return this.withErrorHandler( 'markAsRead', async () => { - await this.modifyMessageReadStatus(messageIds, true); + await this.modifyMessageReadStatus(threadIds, true); }, - { messageIds }, + { threadIds }, ); } - public markAsUnread(messageIds: string[]) { + public markAsUnread(threadIds: string[]) { return this.withErrorHandler( 'markAsUnread', async () => { - await this.modifyMessageReadStatus(messageIds, false); + await this.modifyMessageReadStatus(threadIds, false); }, - { messageIds }, + { threadIds }, ); } private async modifyMessageReadStatus(messageIds: string[], isRead: boolean) { @@ -133,7 +233,7 @@ export class OutlookMailManager implements MailManager { .select('id,displayName,userPrincipalName,mail') .get(); - let photoUrl = ''; + const photoUrl = ''; try { // Requires separate fetching logic } catch (error: unknown) { @@ -428,7 +528,7 @@ export class OutlookMailManager implements MailManager { saveToSentItems: true, }); - return res; + return res || {}; }, { data, email: this.config.auth?.email }, ); @@ -456,8 +556,12 @@ export class OutlookMailManager implements MailManager { } public modifyLabels( messageIds: string[], - options: { addLabels: string[]; removeLabels: string[] }, + addOrOptions: { addLabels: string[]; removeLabels: string[] } | string[], + maybeRemove?: string[], ) { + const options = Array.isArray(addOrOptions) + ? { addLabels: addOrOptions as string[], removeLabels: maybeRemove ?? [] } + : addOrOptions; return this.withErrorHandler( 'modifyLabels', async () => { @@ -467,7 +571,7 @@ export class OutlookMailManager implements MailManager { options.removeLabels, ); }, - { messageIds, options }, + { messageIds, addOrOptions, maybeRemove }, ); } private async modifyMessageLabelsOrFolders( @@ -698,15 +802,11 @@ export class OutlookMailManager implements MailManager { if (data.attachments && data.attachments.length > 0) { const regularAttachments = 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, + contentBytes: file.base64, }; }), ); @@ -1182,15 +1282,11 @@ export class OutlookMailManager implements MailManager { if (attachments?.length > 0) { const regularAttachments = await Promise.all( 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, + contentBytes: file.base64, }; }), ); @@ -1203,7 +1299,7 @@ export class OutlookMailManager implements MailManager { return outlookMessage; } - private parseOutlookDraft(draftMessage: Message) { + private parseOutlookDraft(draftMessage: Message): ParsedDraft | null { if (!draftMessage) return null; const to = @@ -1228,12 +1324,14 @@ export class OutlookMailManager implements MailManager { return { id: draftMessage.id || '', - to, - cc, - bcc, - subject: subject ? he.decode(subject).trim() : '', - content, - rawMessage: draftMessage, // Include raw Graph message + to: to.length > 0 ? to : undefined, + cc: cc.length > 0 ? cc : undefined, + bcc: bcc.length > 0 ? bcc : undefined, + subject: subject ? he.decode(subject).trim() : undefined, + content: content || undefined, + rawMessage: { + internalDate: draftMessage.receivedDateTime || undefined, + }, }; } private async withErrorHandler( @@ -1288,7 +1386,4 @@ export class OutlookMailManager implements MailManager { throw new StandardizedError(error, operation, context); } } - listHistory(historyId: string): Promise<{ history: T[]; historyId: string }> { - return Promise.resolve({ history: [], historyId }); - } } diff --git a/apps/server/src/lib/server-utils.ts b/apps/server/src/lib/server-utils.ts index b5ee8d7b1e..83da7575f7 100644 --- a/apps/server/src/lib/server-utils.ts +++ b/apps/server/src/lib/server-utils.ts @@ -501,7 +501,7 @@ const getCounts = async (connectionId: string): Promise => { export const sendDoState = async (connectionId: string) => { try { const agent = await getZeroSocketAgent(connectionId); - + const cached = await agent.getCachedDoState(); if (cached) { console.log(`[sendDoState] Using cached data for connection ${connectionId}`); @@ -522,9 +522,9 @@ export const sendDoState = async (connectionId: string) => { getCounts(connectionId), ]); const shards = await listShards(registry); - + await agent.setCachedDoState(size, counts, shards.length); - + return agent.broadcastChatMessage({ type: OutgoingMessageType.Do_State, isSyncing: false, @@ -575,7 +575,7 @@ export const getActiveConnection = async () => { export const connectionToDriver = (activeConnection: typeof connection.$inferSelect) => { if (!activeConnection.accessToken || !activeConnection.refreshToken) { - throw new Error(`Invalid connection ${JSON.stringify(activeConnection?.id)}`); + throw new Error(`[connectionToDriver] Invalid connection ${JSON.stringify(activeConnection)}`); } return createDriver(activeConnection.providerId, { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index a9bb9f51d7..edf0d33249 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -152,6 +152,10 @@ export class DbRpcDO extends RpcTarget { updatingInfo: { expiresAt: Date; scope: string; + accessToken: string; + refreshToken: string; + name: string; + picture: string; }, ): Promise<{ id: string }[]> { return await this.mainDo.createConnection(providerId, email, this.userId, updatingInfo); @@ -417,9 +421,17 @@ class ZeroDB extends DurableObject { updatingInfo: { expiresAt: Date; scope: string; + accessToken: string; + refreshToken: string; + name: string; + picture: string; }, ): Promise<{ id: string }[]> { - return await this.db + console.log('[createConnection] creating connection', { + updatingInfo, + }); + + const [result] = await this.db .insert(connection) .values({ ...updatingInfo, @@ -437,7 +449,11 @@ class ZeroDB extends DurableObject { updatedAt: new Date(), }, }) - .returning({ id: connection.id }); + .returning(); + + console.log('[createConnection] result', result); + + return [{ id: result.id }]; } /** @@ -857,9 +873,9 @@ export default class Entry extends WorkerEntrypoint { await Promise.all( batch.messages.map(async (msg: any) => { const connectionId = msg.body.connectionId; - const providerId = msg.body.providerId; + // const providerId = msg.body.providerId; try { - await enableBrainFunction({ id: connectionId, providerId }); + // await enableBrainFunction({ id: connectionId, providerId }); } catch (error) { console.error( `Failed to enable brain function for connection ${connectionId}:`, diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index 3ad1d1b74c..7bd58e2f8b 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -703,13 +703,33 @@ export class ZeroDriver extends DurableObject { if (this.name === 'general') return; if (!this.driver) { const { db, conn } = createDb(this.env.HYPERDRIVE.connectionString); + + console.log(`[setupAuth] Looking for connection: ${this.name}`); + console.log( + `[setupAuth] Using database: ${this.env.HYPERDRIVE.connectionString?.substring(0, 50)}...`, + ); + const _connection = await db.query.connection.findFirst({ where: eq(connection.id, this.name), }); - if (_connection) { - this.driver = connectionToDriver(_connection); - this.connection = _connection; + + console.log(`[setupAuth] Connection: ${JSON.stringify(_connection)}`); + + if (!_connection) { + // Try to debug what connections exist + const allConnections = await db.query.connection.findMany({ + columns: { id: true, email: true, providerId: true }, + limit: 10, + }); + console.error( + `[setupAuth] Connection "${this.name}" not found. Available connections:`, + allConnections, + ); + throw new Error(`[setupAuth] Invalid connection "${this.name}" `); } + + this.driver = connectionToDriver(_connection); + this.connection = _connection; this.ctx.waitUntil(conn.end()); } if (!this.agent) this.agent = await getZeroSocketAgent(this.name);