/** * Outlook Service * Handles Microsoft Graph API operations for Outlook mail */ import { log } from '../middleware/logger.mjs' const GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0' /** * Outlook Service Class */ export class OutlookService { constructor(accessToken) { this.accessToken = accessToken this.headers = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', } } /** * Make Graph API request * @private */ async _request(endpoint, options = {}) { const url = endpoint.startsWith('http') ? endpoint : `${GRAPH_API_BASE}${endpoint}` const response = await fetch(url, { ...options, headers: { ...this.headers, ...options.headers, }, }) if (!response.ok) { const error = await response.json().catch(() => ({})) throw new Error(error.error?.message || `Graph API Error: ${response.status}`) } // Handle 204 No Content if (response.status === 204) { return null } return response.json() } /** * Get user profile */ async getProfile() { return this._request('/me') } /** * List emails from inbox * @param {number} top - Number of emails to fetch * @param {string} skip - Skip token for pagination * @param {string} filter - OData filter */ async listEmails(top = 50, skip = null, filter = null) { let endpoint = `/me/mailFolders/inbox/messages?$top=${top}&$select=id,subject,from,bodyPreview,receivedDateTime,isRead,categories` if (filter) { endpoint += `&$filter=${encodeURIComponent(filter)}` } if (skip) { endpoint += `&$skip=${skip}` } const data = await this._request(endpoint) return { messages: data.value || [], nextLink: data['@odata.nextLink'], count: data['@odata.count'], } } /** * Get full email details * @param {string} messageId - Message ID */ async getEmail(messageId) { return this._request(`/me/messages/${messageId}?$select=id,subject,from,body,bodyPreview,receivedDateTime,isRead,categories,importance,flag`) } /** * Batch get multiple emails * @param {string[]} messageIds - Array of message IDs */ async batchGetEmails(messageIds) { // Use Graph batch API for efficiency const batchRequest = { requests: messageIds.map((id, index) => ({ id: String(index), method: 'GET', url: `/me/messages/${id}?$select=id,subject,from,bodyPreview,receivedDateTime,categories`, })), } const response = await this._request('/$batch', { method: 'POST', body: JSON.stringify(batchRequest), }) return response.responses .filter(r => r.status === 200) .map(r => r.body) } /** * Update message properties * @param {string} messageId - Message ID * @param {object} updates - Properties to update */ async updateMessage(messageId, updates) { return this._request(`/me/messages/${messageId}`, { method: 'PATCH', body: JSON.stringify(updates), }) } /** * Add categories to a message * @param {string} messageId - Message ID * @param {string[]} categories - Categories to add */ async addCategories(messageId, categories) { const email = await this.getEmail(messageId) const existingCategories = email.categories || [] const newCategories = [...new Set([...existingCategories, ...categories])] return this.updateMessage(messageId, { categories: newCategories }) } /** * Remove categories from a message * @param {string} messageId - Message ID * @param {string[]} categories - Categories to remove */ async removeCategories(messageId, categories) { const email = await this.getEmail(messageId) const existingCategories = email.categories || [] const newCategories = existingCategories.filter(c => !categories.includes(c)) return this.updateMessage(messageId, { categories: newCategories }) } /** * Archive a message (move to archive folder) * @param {string} messageId - Message ID */ async archiveEmail(messageId) { // First, try to get or create archive folder let archiveFolder try { archiveFolder = await this._request('/me/mailFolders/archive') } catch { // Archive folder might not exist, try to find it const folders = await this._request('/me/mailFolders?$filter=displayName eq \'Archive\'') if (folders.value?.length) { archiveFolder = folders.value[0] } } if (archiveFolder) { return this._request(`/me/messages/${messageId}/move`, { method: 'POST', body: JSON.stringify({ destinationId: archiveFolder.id }), }) } // Fallback: just mark as read return this.markAsRead(messageId) } /** * Move message to deleted items * @param {string} messageId - Message ID */ async deleteEmail(messageId) { return this._request(`/me/messages/${messageId}/move`, { method: 'POST', body: JSON.stringify({ destinationId: 'deleteditems' }), }) } /** * Move message to a folder * @param {string} messageId - Message ID * @param {string} folderId - Destination folder ID */ async moveEmail(messageId, folderId) { return this._request(`/me/messages/${messageId}/move`, { method: 'POST', body: JSON.stringify({ destinationId: folderId }), }) } /** * Mark message as read * @param {string} messageId - Message ID */ async markAsRead(messageId) { return this.updateMessage(messageId, { isRead: true }) } /** * Mark message as unread * @param {string} messageId - Message ID */ async markAsUnread(messageId) { return this.updateMessage(messageId, { isRead: false }) } /** * Flag a message * @param {string} messageId - Message ID * @param {string} flagStatus - 'flagged' | 'complete' | 'notFlagged' */ async flagEmail(messageId, flagStatus = 'flagged') { return this.updateMessage(messageId, { flag: { flagStatus }, }) } /** * Set message importance * @param {string} messageId - Message ID * @param {string} importance - 'low' | 'normal' | 'high' */ async setImportance(messageId, importance) { return this.updateMessage(messageId, { importance }) } /** * Get all mail folders */ async getFolders() { const data = await this._request('/me/mailFolders') return data.value || [] } /** * Create a mail folder * @param {string} displayName - Folder name * @param {string} parentFolderId - Parent folder ID (optional) */ async createFolder(displayName, parentFolderId = null) { const endpoint = parentFolderId ? `/me/mailFolders/${parentFolderId}/childFolders` : '/me/mailFolders' return this._request(endpoint, { method: 'POST', body: JSON.stringify({ displayName }), }) } /** * Get available categories */ async getCategories() { const data = await this._request('/me/outlook/masterCategories') return data.value || [] } /** * Create a category * @param {string} displayName - Category name * @param {string} color - Color preset (e.g., 'preset0' to 'preset24') */ async createCategory(displayName, color = 'preset0') { try { return await this._request('/me/outlook/masterCategories', { method: 'POST', body: JSON.stringify({ displayName, color }), }) } catch (error) { // Category might already exist log.warn(`Kategorie erstellen fehlgeschlagen: ${displayName}`, { error: error.message }) return null } } /** * Create subscription for webhook notifications * @param {string} webhookUrl - Notification URL * @param {number} expirationMinutes - Subscription expiration (max 4230 minutes / ~3 days) */ async createSubscription(webhookUrl, expirationMinutes = 4230) { const expirationDateTime = new Date(Date.now() + expirationMinutes * 60000).toISOString() return this._request('/subscriptions', { method: 'POST', body: JSON.stringify({ changeType: 'created,updated', notificationUrl: webhookUrl, resource: 'me/mailFolders(\'inbox\')/messages', expirationDateTime, clientState: 'email-sorter-webhook', }), }) } /** * Renew subscription * @param {string} subscriptionId - Subscription ID * @param {number} expirationMinutes - New expiration time */ async renewSubscription(subscriptionId, expirationMinutes = 4230) { const expirationDateTime = new Date(Date.now() + expirationMinutes * 60000).toISOString() return this._request(`/subscriptions/${subscriptionId}`, { method: 'PATCH', body: JSON.stringify({ expirationDateTime }), }) } /** * Delete subscription * @param {string} subscriptionId - Subscription ID */ async deleteSubscription(subscriptionId) { return this._request(`/subscriptions/${subscriptionId}`, { method: 'DELETE', }) } } export default OutlookService