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:
2026-01-22 19:32:12 +01:00
parent 95349af50b
commit abf761db07
596 changed files with 56405 additions and 51231 deletions

View 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

View 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
View 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
View 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