Email Sorter Beta
Ich habe soweit automatisiert the Emails sortieren aber ich muss noch schauen was es fur bugs es gibt wenn die app online ist deswegen wurde ich mit diesen Commit die website veroffentlichen obwohjl es sein konnte das es noch nicht fertig ist und verkaufs bereit
This commit is contained in:
360
server/services/ai-sorter.mjs
Normal file
360
server/services/ai-sorter.mjs
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* AI Email Sorter Service
|
||||
* Uses Mistral AI for intelligent email categorization
|
||||
*/
|
||||
|
||||
import { Mistral } from '@mistralai/mistralai'
|
||||
import { config } from '../config/index.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
|
||||
/**
|
||||
* Email categories with metadata
|
||||
* Uses Gmail categories where available
|
||||
*
|
||||
* Actions:
|
||||
* - 'star': Keep in inbox + add star (VIP)
|
||||
* - 'inbox': Keep in inbox
|
||||
* - 'archive_read': Archive + mark as read (cleans inbox)
|
||||
*/
|
||||
const CATEGORIES = {
|
||||
vip: {
|
||||
name: 'Important',
|
||||
description: 'Important emails from known contacts',
|
||||
color: '#ff0000',
|
||||
gmailCategory: null,
|
||||
action: 'star', // Keep in inbox + star
|
||||
priority: 1,
|
||||
},
|
||||
customers: {
|
||||
name: 'Clients',
|
||||
description: 'Emails from clients and projects',
|
||||
color: '#4285f4',
|
||||
gmailCategory: null,
|
||||
action: 'inbox', // Keep in inbox
|
||||
priority: 2,
|
||||
},
|
||||
invoices: {
|
||||
name: 'Invoices',
|
||||
description: 'Invoices, receipts and financial documents',
|
||||
color: '#0f9d58',
|
||||
gmailCategory: null,
|
||||
action: 'inbox', // Keep in inbox
|
||||
priority: 3,
|
||||
},
|
||||
newsletters: {
|
||||
name: 'Newsletter',
|
||||
description: 'Regular newsletters and updates',
|
||||
color: '#9c27b0',
|
||||
gmailCategory: 'CATEGORY_UPDATES',
|
||||
action: 'archive_read', // Archive + mark as read
|
||||
priority: 4,
|
||||
},
|
||||
promotions: {
|
||||
name: 'Promotions',
|
||||
description: 'Marketing emails and promotions',
|
||||
color: '#ff9800',
|
||||
gmailCategory: 'CATEGORY_PROMOTIONS',
|
||||
action: 'archive_read', // Archive + mark as read
|
||||
priority: 5,
|
||||
},
|
||||
social: {
|
||||
name: 'Social',
|
||||
description: 'Social media and platform notifications',
|
||||
color: '#00bcd4',
|
||||
gmailCategory: 'CATEGORY_SOCIAL',
|
||||
action: 'archive_read', // Archive + mark as read
|
||||
priority: 6,
|
||||
},
|
||||
security: {
|
||||
name: 'Security',
|
||||
description: 'Security codes and notifications',
|
||||
color: '#f44336',
|
||||
gmailCategory: null,
|
||||
action: 'inbox', // Keep in inbox (important!)
|
||||
priority: 1,
|
||||
},
|
||||
calendar: {
|
||||
name: 'Calendar',
|
||||
description: 'Calendar invites and events',
|
||||
color: '#673ab7',
|
||||
gmailCategory: null,
|
||||
action: 'inbox', // Keep in inbox
|
||||
priority: 3,
|
||||
},
|
||||
review: {
|
||||
name: 'Review',
|
||||
description: 'Emails that need manual review',
|
||||
color: '#607d8b',
|
||||
gmailCategory: null,
|
||||
action: 'inbox', // Keep in inbox for review
|
||||
priority: 10,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Sorter Service Class
|
||||
*/
|
||||
export class AISorterService {
|
||||
constructor() {
|
||||
this.client = null
|
||||
this.model = 'mistral-small-latest'
|
||||
this.enabled = Boolean(config.mistral.apiKey)
|
||||
|
||||
if (this.enabled) {
|
||||
this.client = new Mistral({ apiKey: config.mistral.apiKey })
|
||||
log.info('AI Sorter Service initialized with Mistral AI')
|
||||
} else {
|
||||
log.warn('AI Sorter Service disabled - no MISTRAL_API_KEY')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories
|
||||
*/
|
||||
getCategories() {
|
||||
return CATEGORIES
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category names as array
|
||||
*/
|
||||
getCategoryNames() {
|
||||
return Object.values(CATEGORIES).map(c => c.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get label name for a category key
|
||||
*/
|
||||
getLabelName(key) {
|
||||
return CATEGORIES[key]?.name || CATEGORIES.review.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gmail category ID if available
|
||||
*/
|
||||
getGmailCategory(key) {
|
||||
return CATEGORIES[key]?.gmailCategory || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action for category
|
||||
*/
|
||||
getCategoryAction(key) {
|
||||
return CATEGORIES[key]?.action || 'inbox'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for category
|
||||
*/
|
||||
getCategoryColor(key) {
|
||||
return CATEGORIES[key]?.color || '#607d8b'
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize a single email
|
||||
*/
|
||||
async categorize(email, preferences = {}) {
|
||||
if (!this.enabled) {
|
||||
return { category: 'review', confidence: 0, reason: 'AI not configured' }
|
||||
}
|
||||
|
||||
const { from, subject, snippet } = email
|
||||
|
||||
// Build context from preferences
|
||||
const preferenceContext = this._buildPreferenceContext(preferences)
|
||||
|
||||
const prompt = `You are an intelligent email sorting assistant. Analyze the following email and categorize it.
|
||||
|
||||
AVAILABLE CATEGORIES:
|
||||
${Object.entries(CATEGORIES).map(([key, cat]) => `- ${key}: ${cat.name} - ${cat.description}`).join('\n')}
|
||||
|
||||
${preferenceContext}
|
||||
|
||||
EMAIL:
|
||||
From: ${from}
|
||||
Subject: ${subject}
|
||||
Preview: ${snippet?.substring(0, 500) || 'No preview'}
|
||||
|
||||
RESPONSE FORMAT (JSON ONLY):
|
||||
{"category": "category_key", "confidence": 0.0-1.0, "reason": "brief explanation"}
|
||||
|
||||
Respond ONLY with the JSON object.`
|
||||
|
||||
try {
|
||||
const response = await this.client.chat.complete({
|
||||
model: this.model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.1,
|
||||
maxTokens: 150,
|
||||
responseFormat: { type: 'json_object' },
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
const result = JSON.parse(content)
|
||||
|
||||
// Validate category
|
||||
if (!CATEGORIES[result.category]) {
|
||||
result.category = 'review'
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
log.error('AI categorization failed', { error: error.message })
|
||||
return { category: 'review', confidence: 0, reason: 'Categorization error' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch categorize multiple emails
|
||||
*/
|
||||
async batchCategorize(emails, preferences = {}) {
|
||||
if (!this.enabled || emails.length === 0) {
|
||||
return emails.map(e => ({
|
||||
email: e,
|
||||
classification: { category: 'review', confidence: 0, reason: 'AI not available' },
|
||||
}))
|
||||
}
|
||||
|
||||
// For small batches, process individually
|
||||
if (emails.length <= 5) {
|
||||
const results = []
|
||||
for (const email of emails) {
|
||||
const classification = await this.categorize(email, preferences)
|
||||
results.push({ email, classification })
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// For larger batches, use batch request
|
||||
const preferenceContext = this._buildPreferenceContext(preferences)
|
||||
|
||||
const emailList = emails.map((e, i) =>
|
||||
`[${i}] From: ${e.from} | Subject: ${e.subject} | Preview: ${e.snippet?.substring(0, 200) || '-'}`
|
||||
).join('\n')
|
||||
|
||||
const prompt = `You are an email sorting assistant. Categorize the following ${emails.length} emails.
|
||||
|
||||
CATEGORIES:
|
||||
${Object.entries(CATEGORIES).map(([key, cat]) => `${key}: ${cat.name}`).join(' | ')}
|
||||
|
||||
${preferenceContext}
|
||||
|
||||
EMAILS:
|
||||
${emailList}
|
||||
|
||||
RESPONSE FORMAT (JSON ARRAY ONLY):
|
||||
[{"index": 0, "category": "key"}, {"index": 1, "category": "key"}, ...]
|
||||
|
||||
Respond ONLY with the JSON array.`
|
||||
|
||||
try {
|
||||
const response = await this.client.chat.complete({
|
||||
model: this.model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.1,
|
||||
maxTokens: emails.length * 50,
|
||||
responseFormat: { type: 'json_object' },
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
let parsed
|
||||
|
||||
// Handle both array and object responses
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
if (parsed.results) parsed = parsed.results
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('Not an array')
|
||||
}
|
||||
} catch {
|
||||
// Fallback to individual processing
|
||||
return this._fallbackBatch(emails, preferences)
|
||||
}
|
||||
|
||||
return emails.map((email, i) => {
|
||||
const result = parsed.find(r => r.index === i)
|
||||
const category = result?.category && CATEGORIES[result.category] ? result.category : 'review'
|
||||
return {
|
||||
email,
|
||||
classification: { category, confidence: 0.8, reason: 'Batch' },
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('Batch categorization failed', { error: error.message })
|
||||
return this._fallbackBatch(emails, preferences)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to individual categorization
|
||||
*/
|
||||
async _fallbackBatch(emails, preferences) {
|
||||
const results = []
|
||||
for (const email of emails) {
|
||||
const classification = await this.categorize(email, preferences)
|
||||
results.push({ email, classification })
|
||||
// Rate limiting pause
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Build preference context for prompt
|
||||
*/
|
||||
_buildPreferenceContext(preferences) {
|
||||
const parts = []
|
||||
|
||||
if (preferences.vipSenders?.length) {
|
||||
parts.push(`VIP Senders (always categorize as "vip"): ${preferences.vipSenders.join(', ')}`)
|
||||
}
|
||||
|
||||
if (preferences.blockedSenders?.length) {
|
||||
parts.push(`Blocked Senders (categorize as "promotions"): ${preferences.blockedSenders.join(', ')}`)
|
||||
}
|
||||
|
||||
if (preferences.customRules?.length) {
|
||||
parts.push(`Custom Rules:\n${preferences.customRules.map(r => `- ${r.condition}: ${r.category}`).join('\n')}`)
|
||||
}
|
||||
|
||||
if (preferences.priorityTopics?.length) {
|
||||
parts.push(`Priority Topics (higher priority): ${preferences.priorityTopics.join(', ')}`)
|
||||
}
|
||||
|
||||
return parts.length > 0 ? `USER PREFERENCES:\n${parts.join('\n')}\n` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn from user corrections
|
||||
*/
|
||||
async learnFromCorrection(email, originalCategory, correctedCategory, userId) {
|
||||
log.info('Learning correction received', {
|
||||
from: email.from,
|
||||
original: originalCategory,
|
||||
corrected: correctedCategory,
|
||||
userId,
|
||||
})
|
||||
|
||||
// In production, this would:
|
||||
// 1. Store correction in database
|
||||
// 2. Update user preferences
|
||||
// 3. Potentially fine-tune the model
|
||||
|
||||
return { learned: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category statistics
|
||||
*/
|
||||
getCategoryStats(classifications) {
|
||||
const stats = {}
|
||||
for (const { classification } of classifications) {
|
||||
const cat = classification.category
|
||||
stats[cat] = (stats[cat] || 0) + 1
|
||||
}
|
||||
return stats
|
||||
}
|
||||
}
|
||||
|
||||
// Default export
|
||||
export default AISorterService
|
||||
428
server/services/database.mjs
Normal file
428
server/services/database.mjs
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Database Service
|
||||
* Centralized Appwrite database operations
|
||||
*/
|
||||
|
||||
import { Client, Databases, Query, ID } from 'node-appwrite'
|
||||
import { config } from '../config/index.mjs'
|
||||
import { NotFoundError } from '../middleware/errorHandler.mjs'
|
||||
|
||||
// Initialize Appwrite client
|
||||
const client = new Client()
|
||||
.setEndpoint(config.appwrite.endpoint)
|
||||
.setProject(config.appwrite.projectId)
|
||||
.setKey(config.appwrite.apiKey)
|
||||
|
||||
const databases = new Databases(client)
|
||||
const DB_ID = config.appwrite.databaseId
|
||||
|
||||
/**
|
||||
* Collection names
|
||||
*/
|
||||
export const Collections = {
|
||||
PRODUCTS: 'products',
|
||||
QUESTIONS: 'questions',
|
||||
SUBMISSIONS: 'submissions',
|
||||
ANSWERS: 'answers',
|
||||
ORDERS: 'orders',
|
||||
EMAIL_ACCOUNTS: 'email_accounts',
|
||||
EMAIL_STATS: 'email_stats',
|
||||
EMAIL_DIGESTS: 'email_digests',
|
||||
SUBSCRIPTIONS: 'subscriptions',
|
||||
USER_PREFERENCES: 'user_preferences',
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic database operations
|
||||
*/
|
||||
export const db = {
|
||||
/**
|
||||
* Create a document
|
||||
*/
|
||||
async create(collection, data, id = ID.unique()) {
|
||||
return await databases.createDocument(DB_ID, collection, id, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a document by ID
|
||||
*/
|
||||
async get(collection, id) {
|
||||
try {
|
||||
return await databases.getDocument(DB_ID, collection, id)
|
||||
} catch (error) {
|
||||
if (error.code === 404) {
|
||||
throw new NotFoundError(collection)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a document
|
||||
*/
|
||||
async update(collection, id, data) {
|
||||
return await databases.updateDocument(DB_ID, collection, id, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a document
|
||||
*/
|
||||
async delete(collection, id) {
|
||||
return await databases.deleteDocument(DB_ID, collection, id)
|
||||
},
|
||||
|
||||
/**
|
||||
* List documents with optional queries
|
||||
*/
|
||||
async list(collection, queries = []) {
|
||||
const response = await databases.listDocuments(DB_ID, collection, queries)
|
||||
return response.documents
|
||||
},
|
||||
|
||||
/**
|
||||
* Find one document matching queries
|
||||
*/
|
||||
async findOne(collection, queries) {
|
||||
const docs = await this.list(collection, [...queries, Query.limit(1)])
|
||||
return docs[0] || null
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if document exists
|
||||
*/
|
||||
async exists(collection, id) {
|
||||
try {
|
||||
await databases.getDocument(DB_ID, collection, id)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Count documents
|
||||
*/
|
||||
async count(collection, queries = []) {
|
||||
const response = await databases.listDocuments(DB_ID, collection, [
|
||||
...queries,
|
||||
Query.limit(1),
|
||||
])
|
||||
return response.total
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Product operations
|
||||
*/
|
||||
export const products = {
|
||||
async getBySlug(slug) {
|
||||
return db.findOne(Collections.PRODUCTS, [
|
||||
Query.equal('slug', slug),
|
||||
Query.equal('isActive', true),
|
||||
])
|
||||
},
|
||||
|
||||
async getActive() {
|
||||
return db.list(Collections.PRODUCTS, [Query.equal('isActive', true)])
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Questions operations
|
||||
*/
|
||||
export const questions = {
|
||||
async getByProduct(productId) {
|
||||
return db.list(Collections.QUESTIONS, [
|
||||
Query.equal('productId', productId),
|
||||
Query.equal('isActive', true),
|
||||
Query.orderAsc('step'),
|
||||
Query.orderAsc('order'),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Submissions operations
|
||||
*/
|
||||
export const submissions = {
|
||||
async create(data) {
|
||||
return db.create(Collections.SUBMISSIONS, data)
|
||||
},
|
||||
|
||||
async updateStatus(id, status) {
|
||||
return db.update(Collections.SUBMISSIONS, id, { status })
|
||||
},
|
||||
|
||||
async getByUser(userId) {
|
||||
return db.list(Collections.SUBMISSIONS, [Query.equal('userId', userId)])
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Email accounts operations
|
||||
*/
|
||||
export const emailAccounts = {
|
||||
async create(data) {
|
||||
return db.create(Collections.EMAIL_ACCOUNTS, data)
|
||||
},
|
||||
|
||||
async getByUser(userId) {
|
||||
return db.list(Collections.EMAIL_ACCOUNTS, [
|
||||
Query.equal('userId', userId),
|
||||
Query.equal('isActive', true),
|
||||
])
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
return db.get(Collections.EMAIL_ACCOUNTS, id)
|
||||
},
|
||||
|
||||
async updateLastSync(id) {
|
||||
return db.update(Collections.EMAIL_ACCOUNTS, id, {
|
||||
lastSync: new Date().toISOString(),
|
||||
})
|
||||
},
|
||||
|
||||
async deactivate(id) {
|
||||
return db.update(Collections.EMAIL_ACCOUNTS, id, { isActive: false })
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Email stats operations
|
||||
*/
|
||||
export const emailStats = {
|
||||
async getByUser(userId) {
|
||||
return db.findOne(Collections.EMAIL_STATS, [Query.equal('userId', userId)])
|
||||
},
|
||||
|
||||
async create(userId, data) {
|
||||
return db.create(Collections.EMAIL_STATS, { userId, ...data })
|
||||
},
|
||||
|
||||
async increment(userId, counts) {
|
||||
const stats = await this.getByUser(userId)
|
||||
|
||||
if (stats) {
|
||||
return db.update(Collections.EMAIL_STATS, stats.$id, {
|
||||
totalSorted: (stats.totalSorted || 0) + (counts.total || 0),
|
||||
todaySorted: (stats.todaySorted || 0) + (counts.today || 0),
|
||||
weekSorted: (stats.weekSorted || 0) + (counts.week || 0),
|
||||
timeSavedMinutes: (stats.timeSavedMinutes || 0) + (counts.timeSaved || 0),
|
||||
})
|
||||
} else {
|
||||
return this.create(userId, {
|
||||
totalSorted: counts.total || 0,
|
||||
todaySorted: counts.today || 0,
|
||||
weekSorted: counts.week || 0,
|
||||
timeSavedMinutes: counts.timeSaved || 0,
|
||||
categoriesJson: '{}',
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async updateCategories(userId, categories) {
|
||||
const stats = await this.getByUser(userId)
|
||||
if (stats) {
|
||||
return db.update(Collections.EMAIL_STATS, stats.$id, {
|
||||
categoriesJson: JSON.stringify(categories),
|
||||
})
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
async resetDaily() {
|
||||
// Reset daily counters - would be called by a cron job
|
||||
const allStats = await db.list(Collections.EMAIL_STATS, [])
|
||||
for (const stat of allStats) {
|
||||
await db.update(Collections.EMAIL_STATS, stat.$id, { todaySorted: 0 })
|
||||
}
|
||||
},
|
||||
|
||||
async resetWeekly() {
|
||||
// Reset weekly counters - would be called by a cron job
|
||||
const allStats = await db.list(Collections.EMAIL_STATS, [])
|
||||
for (const stat of allStats) {
|
||||
await db.update(Collections.EMAIL_STATS, stat.$id, {
|
||||
weekSorted: 0,
|
||||
categoriesJson: '{}',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscriptions operations
|
||||
*/
|
||||
export const subscriptions = {
|
||||
async getByUser(userId) {
|
||||
return db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
|
||||
},
|
||||
|
||||
async getByStripeId(stripeSubscriptionId) {
|
||||
return db.findOne(Collections.SUBSCRIPTIONS, [
|
||||
Query.equal('stripeSubscriptionId', stripeSubscriptionId),
|
||||
])
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
return db.create(Collections.SUBSCRIPTIONS, data)
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
return db.update(Collections.SUBSCRIPTIONS, id, data)
|
||||
},
|
||||
|
||||
async upsertByUser(userId, data) {
|
||||
const existing = await this.getByUser(userId)
|
||||
if (existing) {
|
||||
return this.update(existing.$id, data)
|
||||
}
|
||||
return this.create({ userId, ...data })
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* User preferences operations
|
||||
*/
|
||||
export const userPreferences = {
|
||||
async getByUser(userId) {
|
||||
const pref = await db.findOne(Collections.USER_PREFERENCES, [
|
||||
Query.equal('userId', userId),
|
||||
])
|
||||
if (pref?.preferencesJson) {
|
||||
return { ...pref, preferences: JSON.parse(pref.preferencesJson) }
|
||||
}
|
||||
return pref
|
||||
},
|
||||
|
||||
async upsert(userId, preferences) {
|
||||
const existing = await db.findOne(Collections.USER_PREFERENCES, [
|
||||
Query.equal('userId', userId),
|
||||
])
|
||||
|
||||
const data = { preferencesJson: JSON.stringify(preferences) }
|
||||
|
||||
if (existing) {
|
||||
return db.update(Collections.USER_PREFERENCES, existing.$id, data)
|
||||
}
|
||||
return db.create(Collections.USER_PREFERENCES, { userId, ...data })
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders operations
|
||||
*/
|
||||
export const orders = {
|
||||
async create(submissionId, orderData) {
|
||||
return db.create(Collections.ORDERS, {
|
||||
submissionId,
|
||||
orderDataJson: JSON.stringify(orderData),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Email digests operations
|
||||
*/
|
||||
export const emailDigests = {
|
||||
async create(userId, digestData) {
|
||||
const date = new Date().toISOString().split('T')[0] // YYYY-MM-DD
|
||||
return db.create(Collections.EMAIL_DIGESTS, {
|
||||
userId,
|
||||
date,
|
||||
statsJson: JSON.stringify(digestData.stats || {}),
|
||||
highlightsJson: JSON.stringify(digestData.highlights || []),
|
||||
suggestionsJson: JSON.stringify(digestData.suggestions || []),
|
||||
totalSorted: digestData.totalSorted || 0,
|
||||
inboxCleared: digestData.inboxCleared || 0,
|
||||
timeSavedMinutes: digestData.timeSavedMinutes || 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
},
|
||||
|
||||
async getByUserToday(userId) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const digest = await db.findOne(Collections.EMAIL_DIGESTS, [
|
||||
Query.equal('userId', userId),
|
||||
Query.equal('date', today),
|
||||
])
|
||||
|
||||
if (digest) {
|
||||
return {
|
||||
...digest,
|
||||
stats: JSON.parse(digest.statsJson || '{}'),
|
||||
highlights: JSON.parse(digest.highlightsJson || '[]'),
|
||||
suggestions: JSON.parse(digest.suggestionsJson || '[]'),
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
async getByUserRecent(userId, days = 7) {
|
||||
const startDate = new Date()
|
||||
startDate.setDate(startDate.getDate() - days)
|
||||
|
||||
const digests = await db.list(Collections.EMAIL_DIGESTS, [
|
||||
Query.equal('userId', userId),
|
||||
Query.greaterThanEqual('date', startDate.toISOString().split('T')[0]),
|
||||
Query.orderDesc('date'),
|
||||
])
|
||||
|
||||
return digests.map(d => ({
|
||||
...d,
|
||||
stats: JSON.parse(d.statsJson || '{}'),
|
||||
highlights: JSON.parse(d.highlightsJson || '[]'),
|
||||
suggestions: JSON.parse(d.suggestionsJson || '[]'),
|
||||
}))
|
||||
},
|
||||
|
||||
async updateToday(userId, updates) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const existing = await db.findOne(Collections.EMAIL_DIGESTS, [
|
||||
Query.equal('userId', userId),
|
||||
Query.equal('date', today),
|
||||
])
|
||||
|
||||
if (existing) {
|
||||
const updateData = {}
|
||||
if (updates.stats) updateData.statsJson = JSON.stringify(updates.stats)
|
||||
if (updates.highlights) updateData.highlightsJson = JSON.stringify(updates.highlights)
|
||||
if (updates.suggestions) updateData.suggestionsJson = JSON.stringify(updates.suggestions)
|
||||
if (updates.totalSorted !== undefined) {
|
||||
updateData.totalSorted = (existing.totalSorted || 0) + updates.totalSorted
|
||||
}
|
||||
if (updates.inboxCleared !== undefined) {
|
||||
updateData.inboxCleared = (existing.inboxCleared || 0) + updates.inboxCleared
|
||||
}
|
||||
if (updates.timeSavedMinutes !== undefined) {
|
||||
updateData.timeSavedMinutes = (existing.timeSavedMinutes || 0) + updates.timeSavedMinutes
|
||||
}
|
||||
return db.update(Collections.EMAIL_DIGESTS, existing.$id, updateData)
|
||||
}
|
||||
|
||||
// Create new digest for today
|
||||
return this.create(userId, {
|
||||
stats: updates.stats || {},
|
||||
highlights: updates.highlights || [],
|
||||
suggestions: updates.suggestions || [],
|
||||
totalSorted: updates.totalSorted || 0,
|
||||
inboxCleared: updates.inboxCleared || 0,
|
||||
timeSavedMinutes: updates.timeSavedMinutes || 0,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
db,
|
||||
products,
|
||||
questions,
|
||||
submissions,
|
||||
emailAccounts,
|
||||
emailStats,
|
||||
emailDigests,
|
||||
subscriptions,
|
||||
userPreferences,
|
||||
orders,
|
||||
Collections,
|
||||
}
|
||||
357
server/services/gmail.mjs
Normal file
357
server/services/gmail.mjs
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Gmail Service
|
||||
* Handles Gmail API operations
|
||||
*/
|
||||
|
||||
import { google } from 'googleapis'
|
||||
import { OAuth2Client } from 'google-auth-library'
|
||||
import { config } from '../config/index.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
|
||||
/**
|
||||
* Gmail Service Class
|
||||
*/
|
||||
export class GmailService {
|
||||
constructor(accessToken, refreshToken = null) {
|
||||
this.auth = new OAuth2Client(
|
||||
config.google.clientId,
|
||||
config.google.clientSecret,
|
||||
config.google.redirectUri
|
||||
)
|
||||
|
||||
this.auth.setCredentials({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
|
||||
this.gmail = google.gmail({ version: 'v1', auth: this.auth })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's email address
|
||||
*/
|
||||
async getProfile() {
|
||||
const { data } = await this.gmail.users.getProfile({ userId: 'me' })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* List emails from inbox
|
||||
* @param {number} maxResults - Maximum number of emails to fetch
|
||||
* @param {string} pageToken - Pagination token
|
||||
* @param {string} query - Gmail search query
|
||||
*/
|
||||
async listEmails(maxResults = 50, pageToken = null, query = 'in:inbox is:unread') {
|
||||
const params = {
|
||||
userId: 'me',
|
||||
maxResults,
|
||||
q: query,
|
||||
}
|
||||
|
||||
if (pageToken) {
|
||||
params.pageToken = pageToken
|
||||
}
|
||||
|
||||
const { data } = await this.gmail.users.messages.list(params)
|
||||
|
||||
return {
|
||||
messages: data.messages || [],
|
||||
nextPageToken: data.nextPageToken,
|
||||
resultSizeEstimate: data.resultSizeEstimate,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full email details
|
||||
* @param {string} messageId - Message ID
|
||||
*/
|
||||
async getEmail(messageId) {
|
||||
const { data } = await this.gmail.users.messages.get({
|
||||
userId: 'me',
|
||||
id: messageId,
|
||||
format: 'full',
|
||||
})
|
||||
|
||||
// Parse headers
|
||||
const headers = {}
|
||||
data.payload?.headers?.forEach(h => {
|
||||
headers[h.name.toLowerCase()] = h.value
|
||||
})
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
threadId: data.threadId,
|
||||
snippet: data.snippet,
|
||||
labelIds: data.labelIds || [],
|
||||
headers,
|
||||
internalDate: data.internalDate,
|
||||
sizeEstimate: data.sizeEstimate,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get multiple emails
|
||||
* @param {string[]} messageIds - Array of message IDs
|
||||
*/
|
||||
async batchGetEmails(messageIds) {
|
||||
// Gmail API supports batch requests, but we'll do simple parallel for now
|
||||
const emails = await Promise.all(
|
||||
messageIds.map(id => this.getEmail(id).catch(e => {
|
||||
log.warn(`E-Mail abrufen fehlgeschlagen: ${id}`, { error: e.message })
|
||||
return null
|
||||
}))
|
||||
)
|
||||
|
||||
return emails.filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or get a label
|
||||
* @param {string} name - Label name (e.g., "EmailSorter/VIP")
|
||||
* @param {string} color - Optional label color (must be from Gmail's palette)
|
||||
*/
|
||||
async createLabel(name, color = null) {
|
||||
// Gmail's allowed label colors (background colors)
|
||||
const GMAIL_COLORS = {
|
||||
red: { backgroundColor: '#fb4c2f', textColor: '#ffffff' },
|
||||
orange: { backgroundColor: '#ffad47', textColor: '#000000' },
|
||||
yellow: { backgroundColor: '#fad165', textColor: '#000000' },
|
||||
green: { backgroundColor: '#16a766', textColor: '#ffffff' },
|
||||
teal: { backgroundColor: '#43d692', textColor: '#000000' },
|
||||
blue: { backgroundColor: '#4a86e8', textColor: '#ffffff' },
|
||||
purple: { backgroundColor: '#a479e2', textColor: '#ffffff' },
|
||||
pink: { backgroundColor: '#f691b3', textColor: '#000000' },
|
||||
gray: { backgroundColor: '#666666', textColor: '#ffffff' },
|
||||
}
|
||||
|
||||
// Map our colors to Gmail colors
|
||||
const colorMap = {
|
||||
'#ff0000': GMAIL_COLORS.red, // VIP
|
||||
'#4285f4': GMAIL_COLORS.blue, // Kunden
|
||||
'#0f9d58': GMAIL_COLORS.green, // Rechnungen
|
||||
'#9c27b0': GMAIL_COLORS.purple, // Newsletter
|
||||
'#ff9800': GMAIL_COLORS.orange, // Werbung
|
||||
'#00bcd4': GMAIL_COLORS.teal, // Social
|
||||
'#f44336': GMAIL_COLORS.red, // Security
|
||||
'#673ab7': GMAIL_COLORS.purple, // Kalender
|
||||
'#607d8b': GMAIL_COLORS.gray, // Review
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if label exists
|
||||
const { data } = await this.gmail.users.labels.list({ userId: 'me' })
|
||||
const existing = data.labels?.find(l => l.name === name)
|
||||
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
// Create new label
|
||||
const labelData = {
|
||||
name,
|
||||
labelListVisibility: 'labelShow',
|
||||
messageListVisibility: 'show',
|
||||
}
|
||||
|
||||
// Use mapped Gmail color if available
|
||||
if (color && colorMap[color]) {
|
||||
labelData.color = colorMap[color]
|
||||
} else if (color) {
|
||||
// Default to blue if color not in map
|
||||
labelData.color = GMAIL_COLORS.blue
|
||||
}
|
||||
|
||||
const { data: created } = await this.gmail.users.labels.create({
|
||||
userId: 'me',
|
||||
requestBody: labelData,
|
||||
})
|
||||
|
||||
log.success(`Gmail Label erstellt: ${name}`)
|
||||
return created
|
||||
} catch (error) {
|
||||
log.error(`Label erstellen fehlgeschlagen: ${name}`, { error: error.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add labels to a message
|
||||
* @param {string} messageId - Message ID
|
||||
* @param {string[]} labelIds - Label IDs to add
|
||||
*/
|
||||
async addLabels(messageId, labelIds) {
|
||||
await this.gmail.users.messages.modify({
|
||||
userId: 'me',
|
||||
id: messageId,
|
||||
requestBody: {
|
||||
addLabelIds: labelIds,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove labels from a message
|
||||
* @param {string} messageId - Message ID
|
||||
* @param {string[]} labelIds - Label IDs to remove
|
||||
*/
|
||||
async removeLabels(messageId, labelIds) {
|
||||
await this.gmail.users.messages.modify({
|
||||
userId: 'me',
|
||||
id: messageId,
|
||||
requestBody: {
|
||||
removeLabelIds: labelIds,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a message (remove from INBOX)
|
||||
* @param {string} messageId - Message ID
|
||||
*/
|
||||
async archiveEmail(messageId) {
|
||||
await this.removeLabels(messageId, ['INBOX'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Move message to trash
|
||||
* @param {string} messageId - Message ID
|
||||
*/
|
||||
async trashEmail(messageId) {
|
||||
await this.gmail.users.messages.trash({
|
||||
userId: 'me',
|
||||
id: messageId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark message as read
|
||||
* @param {string} messageId - Message ID
|
||||
*/
|
||||
async markAsRead(messageId) {
|
||||
await this.removeLabels(messageId, ['UNREAD'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark message as unread
|
||||
* @param {string} messageId - Message ID
|
||||
*/
|
||||
async markAsUnread(messageId) {
|
||||
await this.addLabels(messageId, ['UNREAD'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Star a message
|
||||
* @param {string} messageId - Message ID
|
||||
*/
|
||||
async starEmail(messageId) {
|
||||
await this.addLabels(messageId, ['STARRED'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all labels
|
||||
*/
|
||||
async getLabels() {
|
||||
const { data } = await this.gmail.users.labels.list({ userId: 'me' })
|
||||
return data.labels || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a label by ID
|
||||
*/
|
||||
async deleteLabel(labelId) {
|
||||
try {
|
||||
await this.gmail.users.labels.delete({
|
||||
userId: 'me',
|
||||
id: labelId,
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
log.warn(`Label löschen fehlgeschlagen: ${labelId}`, { error: error.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old EmailSorter labels
|
||||
* Removes all labels starting with "EmailSorter/" and old German labels
|
||||
*/
|
||||
async cleanupOldLabels() {
|
||||
// Old labels to remove (German and old format)
|
||||
const OLD_LABELS = [
|
||||
// Old "EmailSorter/" prefix labels
|
||||
'EmailSorter/',
|
||||
// Old German labels
|
||||
'Wichtig', 'Kunden', 'Rechnungen', 'Sicherheit', 'Termine', 'Prüfen', 'Werbung',
|
||||
'VIP / Wichtig', 'Kunden / Projekte', 'Rechnungen / Belege',
|
||||
'Werbung / Promotions', 'Social / Benachrichtigungen', 'Security / 2FA',
|
||||
'Kalender / Events', 'Review / Unklar',
|
||||
]
|
||||
|
||||
try {
|
||||
const labels = await this.getLabels()
|
||||
|
||||
// Filter labels that match old patterns
|
||||
const labelsToDelete = labels.filter(l => {
|
||||
if (!l.name) return false
|
||||
// Check for EmailSorter/ prefix
|
||||
if (l.name.startsWith('EmailSorter/')) return true
|
||||
// Check for exact matches with old labels
|
||||
if (OLD_LABELS.includes(l.name)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
let deleted = 0
|
||||
for (const label of labelsToDelete) {
|
||||
if (await this.deleteLabel(label.id)) {
|
||||
log.info(`Old label deleted: ${label.name}`)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
|
||||
return deleted
|
||||
} catch (error) {
|
||||
log.error('Cleanup failed', { error: error.message })
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Gmail push notifications
|
||||
* @param {string} webhookUrl - Webhook URL for notifications
|
||||
*/
|
||||
async setupWatch(webhookUrl) {
|
||||
const { data } = await this.gmail.users.watch({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
labelIds: ['INBOX'],
|
||||
topicName: webhookUrl, // Should be a Cloud Pub/Sub topic
|
||||
labelFilterAction: 'include',
|
||||
},
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching for push notifications
|
||||
*/
|
||||
async stopWatch() {
|
||||
await this.gmail.users.stop({ userId: 'me' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history changes since a historyId
|
||||
* @param {string} startHistoryId - History ID to start from
|
||||
*/
|
||||
async getHistory(startHistoryId) {
|
||||
const { data } = await this.gmail.users.history.list({
|
||||
userId: 'me',
|
||||
startHistoryId,
|
||||
historyTypes: ['messageAdded', 'labelAdded', 'labelRemoved'],
|
||||
})
|
||||
|
||||
return data.history || []
|
||||
}
|
||||
}
|
||||
|
||||
export default GmailService
|
||||
335
server/services/outlook.mjs
Normal file
335
server/services/outlook.mjs
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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
|
||||
Reference in New Issue
Block a user