Try
dfssdfsfdsf
This commit is contained in:
@@ -7,6 +7,132 @@ import { Mistral } from '@mistralai/mistralai'
|
||||
import { config } from '../config/index.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
function is503Error(error) {
|
||||
const status = error?.status ?? error?.statusCode ?? error?.response?.status
|
||||
if (status === 503) return true
|
||||
const msg = String(error?.message || '').toLowerCase()
|
||||
return msg.includes('503') || msg.includes('service unavailable')
|
||||
}
|
||||
|
||||
function isRetryableError(err) {
|
||||
if (is503Error(err)) return true
|
||||
const status = err?.status ?? err?.statusCode ?? err?.response?.status
|
||||
if (status === 429) return true
|
||||
const msg = (err?.message || '').toLowerCase()
|
||||
return (
|
||||
msg.includes('429') ||
|
||||
msg.includes('rate limit') ||
|
||||
msg.includes('too many requests')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule-based fallback when Mistral is unavailable or rate-limited.
|
||||
* @param {{ from?: string, subject?: string, snippet?: string }} emailData
|
||||
*/
|
||||
export function ruleBasedCategory(emailData) {
|
||||
const from = (emailData.from || '').toLowerCase()
|
||||
const subject = (emailData.subject || '').toLowerCase()
|
||||
const snippet = (emailData.snippet || '').toLowerCase()
|
||||
|
||||
// NEWSLETTERS — mass emails, unsubscribe links
|
||||
if (
|
||||
from.includes('noreply') ||
|
||||
from.includes('no-reply') ||
|
||||
from.includes('newsletter') ||
|
||||
from.includes('marketing') ||
|
||||
subject.includes('newsletter') ||
|
||||
subject.includes('unsubscribe') ||
|
||||
subject.includes('abbestellen')
|
||||
) {
|
||||
return 'newsletters'
|
||||
}
|
||||
|
||||
// PROMOTIONS — sales, offers, discounts
|
||||
if (
|
||||
subject.includes('sale') ||
|
||||
subject.includes('offer') ||
|
||||
subject.includes('deal') ||
|
||||
subject.includes('discount') ||
|
||||
subject.includes('% off') ||
|
||||
subject.includes('angebot') ||
|
||||
subject.includes('rabatt') ||
|
||||
from.includes('promo') ||
|
||||
from.includes('deals') ||
|
||||
from.includes('offers')
|
||||
) {
|
||||
return 'promotions'
|
||||
}
|
||||
|
||||
// INVOICES — billing documents
|
||||
if (
|
||||
subject.includes('invoice') ||
|
||||
subject.includes('rechnung') ||
|
||||
subject.includes('payment') ||
|
||||
subject.includes('zahlung') ||
|
||||
subject.includes('bill ') ||
|
||||
subject.includes('receipt') ||
|
||||
subject.includes('quittung')
|
||||
) {
|
||||
return 'invoices'
|
||||
}
|
||||
|
||||
// SECURITY — ONLY real security alerts (very specific)
|
||||
if (
|
||||
(subject.includes('security alert') ||
|
||||
subject.includes('sign-in') ||
|
||||
subject.includes('new login') ||
|
||||
subject.includes('suspicious') ||
|
||||
subject.includes('verify your') ||
|
||||
subject.includes('2fa') ||
|
||||
subject.includes('two-factor') ||
|
||||
subject.includes('password reset') ||
|
||||
(subject.includes('passwort') && subject.includes('zurücksetzen'))) &&
|
||||
(from.includes('security') ||
|
||||
from.includes('noreply') ||
|
||||
from.includes('accounts') ||
|
||||
from.includes('alerts'))
|
||||
) {
|
||||
return 'security'
|
||||
}
|
||||
|
||||
// CALENDAR — meetings and events
|
||||
if (
|
||||
subject.includes('meeting') ||
|
||||
subject.includes('invitation') ||
|
||||
subject.includes('calendar') ||
|
||||
subject.includes('appointment') ||
|
||||
subject.includes('termin') ||
|
||||
subject.includes('einladung') ||
|
||||
subject.endsWith('.ics')
|
||||
) {
|
||||
return 'calendar'
|
||||
}
|
||||
|
||||
// VIP — personal direct emails (not noreply, short subject)
|
||||
if (
|
||||
!from.includes('noreply') &&
|
||||
!from.includes('no-reply') &&
|
||||
!from.includes('newsletter') &&
|
||||
!from.includes('info@') &&
|
||||
subject.length < 60 &&
|
||||
subject.length > 3
|
||||
) {
|
||||
return 'vip'
|
||||
}
|
||||
|
||||
// DEFAULT — review (not security!)
|
||||
return 'review'
|
||||
}
|
||||
|
||||
/** Pace Mistral calls (IMAP sort uses these in email.mjs) */
|
||||
export const AI_BATCH_CHUNK_SIZE = 5
|
||||
export const AI_BATCH_CHUNK_DELAY_MS = 2000
|
||||
|
||||
/**
|
||||
* Email categories with metadata
|
||||
* Uses Gmail categories where available
|
||||
@@ -67,7 +193,8 @@ const CATEGORIES = {
|
||||
},
|
||||
security: {
|
||||
name: 'Security',
|
||||
description: 'Security codes and notifications',
|
||||
description:
|
||||
'ONLY real account-security mail: login alerts (new sign-in, suspicious activity), password reset/change, 2FA/MFA codes, device verification. NOT marketing, shipping alerts, price drops, social notifications, or generic “notification” subjects.',
|
||||
color: '#f44336',
|
||||
gmailCategory: null,
|
||||
action: 'inbox', // Keep in inbox (important!)
|
||||
@@ -396,7 +523,12 @@ export class AISorterService {
|
||||
*/
|
||||
async categorize(email, preferences = {}) {
|
||||
if (!this.enabled) {
|
||||
return { category: 'review', confidence: 0, reason: 'AI not configured' }
|
||||
return {
|
||||
category: ruleBasedCategory(email),
|
||||
confidence: 0,
|
||||
reason: 'AI not configured',
|
||||
assignedTo: null,
|
||||
}
|
||||
}
|
||||
|
||||
const { from, subject, snippet } = email
|
||||
@@ -409,6 +541,13 @@ export class AISorterService {
|
||||
AVAILABLE CATEGORIES:
|
||||
${Object.entries(CATEGORIES).map(([key, cat]) => `- ${key}: ${cat.name} - ${cat.description}`).join('\n')}
|
||||
|
||||
CLASSIFICATION RULES (important):
|
||||
- security: Use ONLY for genuine account safety: password reset/change, 2FA/MFA codes, new device login, suspicious sign-in warnings from the service itself. Do NOT use security for marketing, newsletters, order/shipping "alerts", price alerts, social network notifications, or anything that merely says "alert" or "notification".
|
||||
- social: Social networks, likes, follows, mentions, friend requests, activity digests.
|
||||
- newsletters: Recurring digests, blogs, Substack, product updates that are not personal.
|
||||
- promotions: Sales, discounts, ads, deals.
|
||||
- review: When unsure or mixed — prefer review over guessing security.
|
||||
|
||||
${preferenceContext}
|
||||
|
||||
EMAIL:
|
||||
@@ -422,36 +561,78 @@ If the email is clearly FOR a specific worker (e.g. "für Max", "an Anna", subje
|
||||
|
||||
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 parseAndValidate = (content) => {
|
||||
const result = JSON.parse(content)
|
||||
|
||||
// Validate category
|
||||
if (!CATEGORIES[result.category]) {
|
||||
result.category = 'review'
|
||||
}
|
||||
|
||||
// Validate assignedTo against name labels (id or name)
|
||||
if (result.assignedTo && preferences.nameLabels?.length) {
|
||||
const match = preferences.nameLabels.find(
|
||||
l => l.enabled && (l.id === result.assignedTo || l.name === result.assignedTo)
|
||||
(l) => l.enabled && (l.id === result.assignedTo || l.name === result.assignedTo)
|
||||
)
|
||||
if (!match) result.assignedTo = null
|
||||
else result.assignedTo = match.id || match.name
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
log.error('AI categorization failed', { error: error.message })
|
||||
return { category: 'review', confidence: 0, reason: 'Categorization error' }
|
||||
}
|
||||
|
||||
let attempt = 0
|
||||
let used503Backoff = false
|
||||
while (true) {
|
||||
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
|
||||
return parseAndValidate(content)
|
||||
} catch (error) {
|
||||
if (!isRetryableError(error)) {
|
||||
log.error('AI categorization failed', { error: error.message })
|
||||
return {
|
||||
category: ruleBasedCategory(email),
|
||||
confidence: 0,
|
||||
reason: 'Categorization error',
|
||||
assignedTo: null,
|
||||
}
|
||||
}
|
||||
if (is503Error(error)) {
|
||||
if (!used503Backoff) {
|
||||
used503Backoff = true
|
||||
log.warn('Mistral 503 (service unavailable), retry in 5s', { attempt: attempt + 1 })
|
||||
await sleep(5000)
|
||||
continue
|
||||
}
|
||||
log.warn('Mistral 503 after retry, using rule-based fallback')
|
||||
return {
|
||||
category: ruleBasedCategory(email),
|
||||
confidence: 0,
|
||||
reason: '503 — rule-based fallback',
|
||||
assignedTo: null,
|
||||
}
|
||||
}
|
||||
if (attempt >= 2) {
|
||||
log.warn('Mistral rate limit after retries, using rule-based fallback')
|
||||
return {
|
||||
category: ruleBasedCategory(email),
|
||||
confidence: 0,
|
||||
reason: 'Rate limit — rule-based fallback',
|
||||
assignedTo: null,
|
||||
}
|
||||
}
|
||||
if (attempt === 0) {
|
||||
log.warn('Mistral rate limit (429), retry in 2s', { attempt: attempt + 1 })
|
||||
await sleep(2000)
|
||||
} else {
|
||||
log.warn('Mistral rate limit (429), retry in 5s', { attempt: attempt + 1 })
|
||||
await sleep(5000)
|
||||
}
|
||||
attempt++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,9 +641,14 @@ Respond ONLY with the JSON object.`
|
||||
*/
|
||||
async batchCategorize(emails, preferences = {}) {
|
||||
if (!this.enabled || emails.length === 0) {
|
||||
return emails.map(e => ({
|
||||
return emails.map((e) => ({
|
||||
email: e,
|
||||
classification: { category: 'review', confidence: 0, reason: 'AI not available' },
|
||||
classification: {
|
||||
category: ruleBasedCategory(e),
|
||||
confidence: 0,
|
||||
reason: 'AI not available',
|
||||
assignedTo: null,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -486,7 +672,9 @@ Respond ONLY with the JSON object.`
|
||||
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(' | ')}
|
||||
${Object.entries(CATEGORIES).map(([key, cat]) => `${key}: ${cat.name} — ${cat.description}`).join('\n')}
|
||||
|
||||
RULES: Use "security" ONLY for real account safety (password/2FA/login alerts). NOT for marketing alerts, shipping updates, or social notifications — use promotions, newsletters, social, or review instead.
|
||||
|
||||
${preferenceContext}
|
||||
|
||||
@@ -499,7 +687,7 @@ If an email is clearly FOR a specific worker, set assignedTo to that worker's id
|
||||
|
||||
Respond ONLY with the JSON array.`
|
||||
|
||||
try {
|
||||
const runBatchRequest = async () => {
|
||||
const response = await this.client.chat.complete({
|
||||
model: this.model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
@@ -511,7 +699,6 @@ Respond ONLY with the JSON array.`
|
||||
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
|
||||
@@ -519,17 +706,16 @@ Respond ONLY with the JSON array.`
|
||||
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 result = parsed.find((r) => r.index === i)
|
||||
const category = result?.category && CATEGORIES[result.category] ? result.category : 'review'
|
||||
let assignedTo = result?.assignedTo || null
|
||||
if (assignedTo && preferences.nameLabels?.length) {
|
||||
const match = preferences.nameLabels.find(
|
||||
l => l.enabled && (l.id === assignedTo || l.name === assignedTo)
|
||||
(l) => l.enabled && (l.id === assignedTo || l.name === assignedTo)
|
||||
)
|
||||
assignedTo = match ? (match.id || match.name) : null
|
||||
}
|
||||
@@ -538,10 +724,68 @@ Respond ONLY with the JSON array.`
|
||||
classification: { category, confidence: 0.8, reason: 'Batch', assignedTo },
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
log.error('Batch categorization failed', { error: error.message })
|
||||
return this._fallbackBatch(emails, preferences)
|
||||
}
|
||||
|
||||
let attempt = 0
|
||||
let used503Backoff = false
|
||||
while (true) {
|
||||
try {
|
||||
return await runBatchRequest()
|
||||
} catch (error) {
|
||||
if (!isRetryableError(error)) {
|
||||
log.error('Batch categorization failed', { error: error.message })
|
||||
return this._fallbackBatch(emails, preferences)
|
||||
}
|
||||
if (is503Error(error)) {
|
||||
if (!used503Backoff) {
|
||||
used503Backoff = true
|
||||
log.warn('Mistral batch 503 (service unavailable), retry in 5s', { attempt: attempt + 1 })
|
||||
await sleep(5000)
|
||||
continue
|
||||
}
|
||||
log.warn('Mistral batch 503 after retry, rule-based per email')
|
||||
return emails.map((email) => ({
|
||||
email,
|
||||
classification: {
|
||||
category: ruleBasedCategory(email),
|
||||
confidence: 0,
|
||||
reason: '503 — rule-based fallback',
|
||||
assignedTo: null,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (attempt >= 2) {
|
||||
log.warn('Mistral batch rate limit after retries, rule-based per email')
|
||||
return emails.map((email) => ({
|
||||
email,
|
||||
classification: {
|
||||
category: ruleBasedCategory(email),
|
||||
confidence: 0,
|
||||
reason: 'Rate limit — rule-based fallback',
|
||||
assignedTo: null,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (attempt === 0) {
|
||||
log.warn('Mistral batch rate limit (429), retry in 2s', { attempt: attempt + 1 })
|
||||
await sleep(2000)
|
||||
} else {
|
||||
log.warn('Mistral batch rate limit (429), retry in 5s', { attempt: attempt + 1 })
|
||||
await sleep(5000)
|
||||
}
|
||||
attempt++
|
||||
}
|
||||
}
|
||||
|
||||
return emails.map((email) => ({
|
||||
email,
|
||||
classification: {
|
||||
category: ruleBasedCategory(email),
|
||||
confidence: 0,
|
||||
reason: 'Rate limit — rule-based fallback',
|
||||
assignedTo: null,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Client, Databases, Query, ID } from 'node-appwrite'
|
||||
import { config, isAdmin } from '../config/index.mjs'
|
||||
import { NotFoundError } from '../middleware/errorHandler.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
|
||||
// Initialize Appwrite client
|
||||
const client = new Client()
|
||||
@@ -15,6 +16,19 @@ const client = new Client()
|
||||
|
||||
const databases = new Databases(client)
|
||||
const DB_ID = config.appwrite.databaseId
|
||||
const DATABASE_ID = DB_ID
|
||||
|
||||
/**
|
||||
* Appwrite: database/collection missing (schema not provisioned yet)
|
||||
*/
|
||||
function isCollectionNotFound(err) {
|
||||
if (!err) return false
|
||||
const msg = typeof err.message === 'string' ? err.message : ''
|
||||
return (
|
||||
err.type === 'collection_not_found' ||
|
||||
msg.includes('Collection with the requested ID')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection names
|
||||
@@ -44,7 +58,12 @@ export const db = {
|
||||
* Create a document
|
||||
*/
|
||||
async create(collection, data, id = ID.unique()) {
|
||||
return await databases.createDocument(DB_ID, collection, id, data)
|
||||
try {
|
||||
return await databases.createDocument(DB_ID, collection, id, data)
|
||||
} catch (err) {
|
||||
if (isCollectionNotFound(err)) return null
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -54,6 +73,9 @@ export const db = {
|
||||
try {
|
||||
return await databases.getDocument(DB_ID, collection, id)
|
||||
} catch (error) {
|
||||
if (isCollectionNotFound(error)) {
|
||||
return null
|
||||
}
|
||||
if (error.code === 404) {
|
||||
throw new NotFoundError(collection)
|
||||
}
|
||||
@@ -65,22 +87,43 @@ export const db = {
|
||||
* Update a document
|
||||
*/
|
||||
async update(collection, id, data) {
|
||||
return await databases.updateDocument(DB_ID, collection, id, data)
|
||||
try {
|
||||
return await databases.updateDocument(DB_ID, collection, id, data)
|
||||
} catch (err) {
|
||||
if (isCollectionNotFound(err)) return null
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a document
|
||||
*/
|
||||
async delete(collection, id) {
|
||||
return await databases.deleteDocument(DB_ID, collection, id)
|
||||
try {
|
||||
return await databases.deleteDocument(DB_ID, collection, id)
|
||||
} catch (err) {
|
||||
if (isCollectionNotFound(err)) return null
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List documents with optional queries
|
||||
*/
|
||||
async list(collection, queries = []) {
|
||||
const response = await databases.listDocuments(DB_ID, collection, queries)
|
||||
return response.documents
|
||||
async list(collectionId, queries = []) {
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
collectionId,
|
||||
queries
|
||||
)
|
||||
return response.documents
|
||||
} catch (err) {
|
||||
if (isCollectionNotFound(err)) {
|
||||
return []
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -98,7 +141,8 @@ export const db = {
|
||||
try {
|
||||
await databases.getDocument(DB_ID, collection, id)
|
||||
return true
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (isCollectionNotFound(err)) return false
|
||||
return false
|
||||
}
|
||||
},
|
||||
@@ -107,11 +151,16 @@ export const db = {
|
||||
* Count documents
|
||||
*/
|
||||
async count(collection, queries = []) {
|
||||
const response = await databases.listDocuments(DB_ID, collection, [
|
||||
...queries,
|
||||
Query.limit(1),
|
||||
])
|
||||
return response.total
|
||||
try {
|
||||
const response = await databases.listDocuments(DB_ID, collection, [
|
||||
...queries,
|
||||
Query.limit(1),
|
||||
])
|
||||
return response.total
|
||||
} catch (err) {
|
||||
if (isCollectionNotFound(err)) return 0
|
||||
throw err
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -208,21 +257,28 @@ export const emailStats = {
|
||||
const stats = await this.getByUser(userId)
|
||||
|
||||
if (stats) {
|
||||
return db.update(Collections.EMAIL_STATS, stats.$id, {
|
||||
const updated = await 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: '{}',
|
||||
})
|
||||
if (updated == null && process.env.NODE_ENV === 'development') {
|
||||
log.warn('emailStats.increment: update skipped (missing collection or document)', { userId })
|
||||
}
|
||||
return updated
|
||||
}
|
||||
const created = await this.create(userId, {
|
||||
totalSorted: counts.total || 0,
|
||||
todaySorted: counts.today || 0,
|
||||
weekSorted: counts.week || 0,
|
||||
timeSavedMinutes: counts.timeSaved || 0,
|
||||
categoriesJson: '{}',
|
||||
})
|
||||
if (created == null && process.env.NODE_ENV === 'development') {
|
||||
log.warn('emailStats.increment: create skipped (missing collection)', { userId })
|
||||
}
|
||||
return created
|
||||
},
|
||||
|
||||
async updateCategories(userId, categories) {
|
||||
@@ -276,18 +332,26 @@ export const emailUsage = {
|
||||
const existing = await this.getCurrentMonth(userId)
|
||||
|
||||
if (existing) {
|
||||
return db.update(Collections.EMAIL_USAGE, existing.$id, {
|
||||
const updated = await db.update(Collections.EMAIL_USAGE, existing.$id, {
|
||||
emailsProcessed: (existing.emailsProcessed || 0) + count,
|
||||
lastReset: new Date().toISOString(),
|
||||
})
|
||||
if (updated == null && process.env.NODE_ENV === 'development') {
|
||||
log.warn('emailUsage.increment: update skipped (missing collection or document)', { userId })
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
return db.create(Collections.EMAIL_USAGE, {
|
||||
const created = await db.create(Collections.EMAIL_USAGE, {
|
||||
userId,
|
||||
month,
|
||||
emailsProcessed: count,
|
||||
lastReset: new Date().toISOString(),
|
||||
})
|
||||
if (created == null && process.env.NODE_ENV === 'development') {
|
||||
log.warn('emailUsage.increment: create skipped (missing collection)', { userId })
|
||||
}
|
||||
return created
|
||||
},
|
||||
|
||||
async getUsage(userId) {
|
||||
@@ -578,6 +642,26 @@ export const onboardingState = {
|
||||
last_updated: new Date().toISOString(),
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset onboarding to initial state (does not delete the document).
|
||||
*/
|
||||
async resetToInitial(userId) {
|
||||
const existing = await db.findOne(Collections.ONBOARDING_STATE, [
|
||||
Query.equal('userId', userId),
|
||||
])
|
||||
const data = {
|
||||
onboarding_step: 'not_started',
|
||||
completed_steps_json: JSON.stringify([]),
|
||||
skipped_at: null,
|
||||
first_value_seen_at: null,
|
||||
last_updated: new Date().toISOString(),
|
||||
}
|
||||
if (existing) {
|
||||
return db.update(Collections.ONBOARDING_STATE, existing.$id, data)
|
||||
}
|
||||
return db.create(Collections.ONBOARDING_STATE, { userId, ...data })
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -609,12 +693,17 @@ export const referrals = {
|
||||
attempts++
|
||||
}
|
||||
|
||||
return db.create(Collections.REFERRALS, {
|
||||
const created = await db.create(Collections.REFERRALS, {
|
||||
userId,
|
||||
referralCode: uniqueCode,
|
||||
referralCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
// Collection missing → return null safely
|
||||
if (!created) return null
|
||||
|
||||
return created
|
||||
},
|
||||
|
||||
async getByCode(code) {
|
||||
@@ -746,6 +835,25 @@ export const emailDigests = {
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all documents in a collection where attribute `userId` matches (paginated batches of 100).
|
||||
*/
|
||||
export async function deleteAllDocumentsForUser(collection, userId) {
|
||||
let deleted = 0
|
||||
for (;;) {
|
||||
const batch = await db.list(collection, [
|
||||
Query.equal('userId', userId),
|
||||
Query.limit(100),
|
||||
])
|
||||
if (!batch.length) break
|
||||
for (const doc of batch) {
|
||||
await db.delete(collection, doc.$id)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
export { Query }
|
||||
|
||||
export default {
|
||||
|
||||
@@ -9,6 +9,33 @@ import { log } from '../middleware/logger.mjs'
|
||||
const INBOX = 'INBOX'
|
||||
const FOLDER_PREFIX = 'MailFlow'
|
||||
|
||||
/** Timeouts (ms) — avoid hanging on bad host/firewall */
|
||||
const IMAP_CONNECT_TIMEOUT_MS = 10_000
|
||||
const IMAP_OPERATION_TIMEOUT_MS = 10_000
|
||||
|
||||
/** Per-command cap so dead connections fail fast (sort try/catch + race can complete). */
|
||||
const IMAP_OP_TIMEOUT_MS = 5000
|
||||
|
||||
async function withOpTimeout(promise, label = 'IMAP op') {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`${label} timed out`)), IMAP_OP_TIMEOUT_MS)
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
function rejectAfter(ms, message) {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(message)), ms)
|
||||
})
|
||||
}
|
||||
|
||||
async function withTimeout(promise, ms, label) {
|
||||
const msg = `${label} timed out after ${ms / 1000} seconds`
|
||||
return Promise.race([promise, rejectAfter(ms, msg)])
|
||||
}
|
||||
|
||||
/** Map category key to IMAP folder name */
|
||||
export function getFolderNameForCategory(category) {
|
||||
const map = {
|
||||
@@ -26,6 +53,212 @@ export function getFolderNameForCategory(category) {
|
||||
return map[category] || 'Review'
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder last segments that must NEVER be used as sort targets (Trash, Sent, Drafts, Deleted only).
|
||||
* Junk, Spam, Archive, Favorites are valid targets.
|
||||
*/
|
||||
const FORBIDDEN_LAST_SEGMENTS = new Set(['trash', 'sent', 'drafts', 'deleted'])
|
||||
|
||||
/** Full paths that are never valid move targets (case-insensitive). */
|
||||
const FORBIDDEN_FULL_PATHS = new Set([
|
||||
'trash',
|
||||
'sent',
|
||||
'drafts',
|
||||
'deleted',
|
||||
'inbox.trash',
|
||||
'inbox.sent',
|
||||
'inbox.drafts',
|
||||
'inbox.deleted',
|
||||
])
|
||||
|
||||
/**
|
||||
* Keywords per AI category for matching existing mailbox paths (case-insensitive).
|
||||
* First match wins (keyword order, then folder list order).
|
||||
*/
|
||||
export const CATEGORY_FOLDER_KEYWORDS = {
|
||||
vip: ['favorites', 'vip', 'important', 'priority', 'wichtig'],
|
||||
customers: ['clients', 'customers', 'kunden', 'client'],
|
||||
invoices: ['invoices', 'invoice', 'rechnungen', 'rechnung', 'billing'],
|
||||
newsletters: ['junk', 'newsletters', 'newsletter', 'subscriptions'],
|
||||
promotions: ['junk', 'promotions', 'promotion', 'marketing', 'spam'],
|
||||
social: ['social', 'notifications', 'team'],
|
||||
security: ['security', 'alerts', 'sicherheit'],
|
||||
calendar: ['calendar', 'meetings'],
|
||||
review: ['archive', 'review', 'later'],
|
||||
}
|
||||
|
||||
function lastMailboxSegment(path) {
|
||||
if (!path || typeof path !== 'string') return ''
|
||||
const parts = path.split(/[/\\]/).filter(Boolean)
|
||||
const leaf = parts.length ? parts[parts.length - 1] : path
|
||||
const dotted = leaf.split('.')
|
||||
return dotted[dotted.length - 1].toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* True if this path must not be used as a sort move destination.
|
||||
*/
|
||||
export function isForbiddenMoveTarget(folderPath) {
|
||||
if (!folderPath || typeof folderPath !== 'string') return true
|
||||
const norm = folderPath.trim()
|
||||
if (!norm) return true
|
||||
if (norm.toUpperCase() === 'INBOX') return true
|
||||
const fullLower = norm.toLowerCase().replace(/\\/g, '/')
|
||||
if (FORBIDDEN_FULL_PATHS.has(fullLower)) return true
|
||||
const last = lastMailboxSegment(norm)
|
||||
return FORBIDDEN_LAST_SEGMENTS.has(last)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best existing folder for a category, or null (keep in INBOX).
|
||||
* Prefers exact last-segment match (e.g. path "Junk" or "*.Junk") over substring (avoids wrong "junk" hits).
|
||||
* @param {string} category
|
||||
* @param {string[]} existingFolders - paths from LIST
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function findBestFolder(category, existingFolders) {
|
||||
if (!existingFolders?.length) return null
|
||||
const keywords = CATEGORY_FOLDER_KEYWORDS[category]
|
||||
if (!keywords?.length) return null
|
||||
|
||||
for (const keyword of keywords) {
|
||||
const kw = keyword.toLowerCase()
|
||||
for (const folderPath of existingFolders) {
|
||||
if (!folderPath || isForbiddenMoveTarget(folderPath)) continue
|
||||
if (lastMailboxSegment(folderPath) === kw) return folderPath
|
||||
}
|
||||
}
|
||||
|
||||
for (const keyword of keywords) {
|
||||
const kw = keyword.toLowerCase()
|
||||
for (const folderPath of existingFolders) {
|
||||
if (!folderPath || isForbiddenMoveTarget(folderPath)) continue
|
||||
const pathLower = folderPath.toLowerCase()
|
||||
if (pathLower.includes(kw)) return folderPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** System mailboxes we never pull from during "re-sort" recovery (keep Sent/Drafts/Trash untouched). */
|
||||
const RESORT_SKIP_LAST_SEGMENTS = new Set([
|
||||
'sent',
|
||||
'drafts',
|
||||
'trash',
|
||||
'deleted',
|
||||
'templates',
|
||||
'outbox',
|
||||
'inbox',
|
||||
])
|
||||
|
||||
/**
|
||||
* Folders whose messages we move back to INBOX before a full re-sort (sort destinations + MailFlow/EmailSorter trees).
|
||||
* @param {string} folderPath
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isReSortRecoveryFolder(folderPath) {
|
||||
if (!folderPath || typeof folderPath !== 'string') return false
|
||||
const norm = folderPath.trim()
|
||||
if (!norm || norm.toUpperCase() === 'INBOX') return false
|
||||
const last = lastMailboxSegment(norm)
|
||||
if (RESORT_SKIP_LAST_SEGMENTS.has(last)) return false
|
||||
|
||||
const low = norm.toLowerCase().replace(/\\/g, '/')
|
||||
if (low.includes('mailflow') || low.includes('emailsorter')) return true
|
||||
|
||||
const sortLeaves = new Set([
|
||||
'junk',
|
||||
'spam',
|
||||
'archive',
|
||||
'newsletters',
|
||||
'newsletter',
|
||||
'promotions',
|
||||
'promotion',
|
||||
'social',
|
||||
'review',
|
||||
'security',
|
||||
'calendar',
|
||||
'invoices',
|
||||
'invoice',
|
||||
'clients',
|
||||
'customers',
|
||||
'client',
|
||||
'vip',
|
||||
'favorites',
|
||||
'important',
|
||||
'subscriptions',
|
||||
'marketing',
|
||||
'team',
|
||||
'meetings',
|
||||
'later',
|
||||
'billing',
|
||||
'rechnungen',
|
||||
'rechnung',
|
||||
])
|
||||
return sortLeaves.has(last)
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a person/team folder by local name appearing in From or Subject.
|
||||
* @param {{ from?: string, subject?: string }} emailData
|
||||
* @param {string[]} existingFolders
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function findPersonFolder(emailData, existingFolders) {
|
||||
const from = (emailData.from || '').toLowerCase()
|
||||
const subject = (emailData.subject || '').toLowerCase()
|
||||
|
||||
for (const folder of existingFolders) {
|
||||
const folderName = folder.split('.').pop() || folder
|
||||
if (folderName.length < 3) continue
|
||||
|
||||
const skip = [
|
||||
'inbox',
|
||||
'sent',
|
||||
'trash',
|
||||
'junk',
|
||||
'spam',
|
||||
'drafts',
|
||||
'archive',
|
||||
'deleted',
|
||||
'favorites',
|
||||
'emailsorter',
|
||||
'vip',
|
||||
'clients',
|
||||
'invoices',
|
||||
'newsletters',
|
||||
'promotions',
|
||||
'security',
|
||||
'calendar',
|
||||
'review',
|
||||
]
|
||||
if (skip.includes(folderName.toLowerCase())) continue
|
||||
|
||||
const name = folderName.toLowerCase()
|
||||
if (from.includes(name) || subject.includes(name)) {
|
||||
return folder
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** IMAP keywords ($MailFlow-*) — reliable on hosts that block custom folders */
|
||||
export function getMailFlowKeywordForCategory(category) {
|
||||
const map = {
|
||||
vip: '$MailFlow-VIP',
|
||||
customers: '$MailFlow-Clients',
|
||||
invoices: '$MailFlow-Invoices',
|
||||
newsletters: '$MailFlow-Newsletters',
|
||||
promotions: '$MailFlow-Promotions',
|
||||
social: '$MailFlow-Social',
|
||||
security: '$MailFlow-Security',
|
||||
calendar: '$MailFlow-Calendar',
|
||||
review: '$MailFlow-Review',
|
||||
archive: '$MailFlow-Archive',
|
||||
}
|
||||
return map[category] || '$MailFlow-Review'
|
||||
}
|
||||
|
||||
/**
|
||||
* IMAP Service – same conceptual interface as GmailService/OutlookService
|
||||
*/
|
||||
@@ -40,18 +273,47 @@ export class ImapService {
|
||||
*/
|
||||
constructor(opts) {
|
||||
const { host, port = 993, secure = true, user, password } = opts
|
||||
this.client = new ImapFlow({
|
||||
host: host || 'imap.porkbun.com',
|
||||
port: port || 993,
|
||||
this.host = host || 'imap.porkbun.com'
|
||||
this.port = port || 993
|
||||
this._imapFlowOptions = {
|
||||
host: this.host,
|
||||
port: this.port,
|
||||
secure: secure !== false,
|
||||
auth: { user, pass: password },
|
||||
logger: false,
|
||||
})
|
||||
}
|
||||
this.client = new ImapFlow({ ...this._imapFlowOptions })
|
||||
this.lock = null
|
||||
}
|
||||
|
||||
async ensureConnected() {
|
||||
try {
|
||||
if (!this.client.usable) throw new Error('not usable')
|
||||
await withOpTimeout(this.client.noop(), 'noop')
|
||||
} catch {
|
||||
try {
|
||||
if (this.lock) await this.lock.release().catch(() => {})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.lock = null
|
||||
try {
|
||||
await this.client.logout()
|
||||
} catch {
|
||||
try {
|
||||
this.client.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this.client = new ImapFlow({ ...this._imapFlowOptions })
|
||||
await this.connect()
|
||||
}
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await this.client.connect()
|
||||
console.log('[IMAP] Connecting to:', this.host, this.port)
|
||||
await withTimeout(this.client.connect(), IMAP_CONNECT_TIMEOUT_MS, 'IMAP connect')
|
||||
}
|
||||
|
||||
async close() {
|
||||
@@ -69,6 +331,10 @@ export class ImapService {
|
||||
* @param {string|null} _pageToken - reserved for future pagination
|
||||
*/
|
||||
async listEmails(maxResults = 50, _pageToken = null) {
|
||||
return withTimeout(this._listEmailsImpl(maxResults, _pageToken), IMAP_OPERATION_TIMEOUT_MS, 'IMAP listEmails')
|
||||
}
|
||||
|
||||
async _listEmailsImpl(maxResults = 50, _pageToken = null) {
|
||||
let lock = null
|
||||
try {
|
||||
lock = await this.client.getMailboxLock(INBOX)
|
||||
@@ -123,6 +389,10 @@ export class ImapService {
|
||||
*/
|
||||
async batchGetEmails(messageIds) {
|
||||
if (!messageIds.length) return []
|
||||
return withTimeout(this._batchGetEmailsImpl(messageIds), IMAP_OPERATION_TIMEOUT_MS, 'IMAP batchGetEmails')
|
||||
}
|
||||
|
||||
async _batchGetEmailsImpl(messageIds) {
|
||||
let lock = null
|
||||
try {
|
||||
lock = await this.client.getMailboxLock(INBOX)
|
||||
@@ -142,13 +412,26 @@ export class ImapService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all mailbox paths on the server (for sort: map categories to existing folders only).
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async listAllFolders() {
|
||||
await this.ensureConnected()
|
||||
const allFolders = await withOpTimeout(this.client.list(), 'list')
|
||||
return (allFolders || [])
|
||||
.map((f) => f.path || f.name)
|
||||
.filter((p) => typeof p === 'string' && p.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure folder exists (create if not). Use subfolder under MailFlow to avoid clutter.
|
||||
* NOT USED IN SORT — automatic sort must not create folders; use existing paths only.
|
||||
*/
|
||||
async ensureFolder(folderName) {
|
||||
const path = `${FOLDER_PREFIX}/${folderName}`
|
||||
try {
|
||||
await this.client.mailboxCreate(path)
|
||||
await withOpTimeout(this.client.mailboxCreate(path), 'mailboxCreate')
|
||||
log.info(`IMAP folder created: ${path}`)
|
||||
} catch (err) {
|
||||
if (err.code !== 'ALREADYEXISTS' && !/already exists/i.test(err.message)) {
|
||||
@@ -159,16 +442,274 @@ export class ImapService {
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT USED IN SORT — sort uses `copyToFolder` (no folder creation; originals stay in INBOX).
|
||||
* Move message (by UID) from INBOX to folder name (under MailFlow/)
|
||||
*/
|
||||
async moveToFolder(messageId, folderName) {
|
||||
await this.ensureConnected()
|
||||
const path = `${FOLDER_PREFIX}/${folderName}`
|
||||
await this.ensureFolder(folderName)
|
||||
const runMove = async () => {
|
||||
let lock = null
|
||||
try {
|
||||
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||
this.lock = lock
|
||||
await withOpTimeout(
|
||||
this.client.messageMove(String(messageId), path, { uid: true }),
|
||||
'messageMove'
|
||||
)
|
||||
} finally {
|
||||
if (lock) {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.ensureFolder(folderName)
|
||||
await runMove()
|
||||
} catch (err) {
|
||||
log.warn('IMAP moveToFolder first attempt failed', { error: err.message })
|
||||
await this.ensureConnected()
|
||||
await this.ensureFolder(folderName)
|
||||
await runMove()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add MailFlow category keyword (and optional team tag) on INBOX message — no folder required.
|
||||
* Never throws; returns false if tagging failed (sort can still count the email as categorized).
|
||||
* @param {string | null} [assignedTo]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async addMailFlowCategoryKeyword(messageId, category, assignedTo = null) {
|
||||
const flags = [getMailFlowKeywordForCategory(category)]
|
||||
if (assignedTo) {
|
||||
const safe = String(assignedTo).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50)
|
||||
flags.push(`$MailFlow-Team-${safe}`)
|
||||
}
|
||||
try {
|
||||
await this.ensureConnected()
|
||||
let lock = null
|
||||
try {
|
||||
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||
this.lock = lock
|
||||
await withOpTimeout(
|
||||
this.client.messageFlagsAdd(String(messageId), flags, { uid: true }),
|
||||
'messageFlagsAdd'
|
||||
)
|
||||
} finally {
|
||||
if (lock) {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
const msg = err?.message || String(err)
|
||||
log.warn('IMAP addMailFlowCategoryKeyword failed', { messageId, error: msg })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move UID from INBOX to an existing mailbox path only. Does not create folders.
|
||||
* IMAP sort (POST /sort) uses `copyToFolder` instead — originals stay in INBOX.
|
||||
* Returns true on success; false on validation failure or IMAP error (never throws).
|
||||
* @param {string} messageId - UID string
|
||||
* @param {string} destPath - exact path from LIST
|
||||
* @param {Set<string>} existingFolderSet - paths that exist on server
|
||||
*/
|
||||
async moveMessageToExistingPath(messageId, destPath, existingFolderSet) {
|
||||
const uid = typeof messageId === 'string' ? Number(messageId) : Number(messageId)
|
||||
if (messageId == null || Number.isNaN(uid) || uid < 1) {
|
||||
log.warn('IMAP moveMessageToExistingPath: invalid UID', { messageId })
|
||||
return false
|
||||
}
|
||||
if (!destPath || !existingFolderSet || !(existingFolderSet instanceof Set)) {
|
||||
log.warn('IMAP moveMessageToExistingPath: bad dest or set', { destPath })
|
||||
return false
|
||||
}
|
||||
if (!existingFolderSet.has(destPath)) {
|
||||
log.warn('IMAP moveMessageToExistingPath: path not in existing set', { destPath })
|
||||
return false
|
||||
}
|
||||
if (isForbiddenMoveTarget(destPath)) {
|
||||
log.warn('IMAP moveMessageToExistingPath: forbidden destination', { destPath })
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureConnected()
|
||||
let lock = null
|
||||
try {
|
||||
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||
this.lock = lock
|
||||
await withOpTimeout(
|
||||
this.client.messageMove(String(uid), destPath, { uid: true }),
|
||||
'messageMove'
|
||||
)
|
||||
const uidRange = `${uid}:${uid}`
|
||||
const stillInInbox = await withOpTimeout(
|
||||
this.client.search({ uid: uidRange }, { uid: true }),
|
||||
'search'
|
||||
)
|
||||
if (stillInInbox?.length) {
|
||||
log.warn('IMAP moveMessageToExistingPath: UID still in INBOX after MOVE (path may be wrong)', {
|
||||
messageId,
|
||||
destPath,
|
||||
uid,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} finally {
|
||||
if (lock) {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('IMAP moveMessageToExistingPath failed', { messageId, destPath, error: err?.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy UID from INBOX to an existing mailbox path (original stays in INBOX).
|
||||
* Returns true on success; false on validation failure or IMAP error (never throws).
|
||||
* @param {string} messageId - UID string
|
||||
* @param {string} destPath - exact path from LIST
|
||||
* @param {Set<string>} existingFolderSet - paths that exist on server
|
||||
*/
|
||||
async copyToFolder(messageId, destPath, existingFolderSet) {
|
||||
const uid = typeof messageId === 'string' ? Number(messageId) : Number(messageId)
|
||||
if (messageId == null || Number.isNaN(uid) || uid < 1) {
|
||||
log.warn('IMAP copyToFolder: invalid UID', { messageId })
|
||||
return false
|
||||
}
|
||||
if (!destPath || !existingFolderSet || !(existingFolderSet instanceof Set)) {
|
||||
log.warn('IMAP copyToFolder: bad dest or set', { destPath })
|
||||
return false
|
||||
}
|
||||
if (!existingFolderSet.has(destPath)) {
|
||||
log.warn('IMAP copyToFolder: path not in existing set', { destPath })
|
||||
return false
|
||||
}
|
||||
if (isForbiddenMoveTarget(destPath)) {
|
||||
log.warn('IMAP copyToFolder: forbidden destination', { destPath })
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureConnected()
|
||||
let lock = null
|
||||
try {
|
||||
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||
this.lock = lock
|
||||
await withOpTimeout(
|
||||
this.client.messageCopy(String(uid), destPath, { uid: true }),
|
||||
'messageCopy'
|
||||
)
|
||||
return true
|
||||
} finally {
|
||||
if (lock) {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('IMAP copyToFolder failed', { messageId, destPath, error: err?.message })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove $MailFlow-sorted keyword from all messages in INBOX (UID SEARCH + STORE).
|
||||
* @returns {Promise<number>} count of UIDs processed (same as messages touched)
|
||||
*/
|
||||
async removeAllSortedFlags() {
|
||||
await this.connect()
|
||||
let lock = null
|
||||
try {
|
||||
lock = await this.client.getMailboxLock(INBOX)
|
||||
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||
this.lock = lock
|
||||
await this.client.messageMove(String(messageId), path, { uid: true })
|
||||
const allUids = await withOpTimeout(
|
||||
this.client.search({ all: true }, { uid: true }),
|
||||
'search'
|
||||
)
|
||||
const list = (allUids || [])
|
||||
.map((u) => (typeof u === 'bigint' ? Number(u) : u))
|
||||
.filter((n) => n != null && !Number.isNaN(Number(n)))
|
||||
if (list.length > 0) {
|
||||
await withOpTimeout(
|
||||
this.client.messageFlagsRemove(list, ['$MailFlow-sorted'], { uid: true }),
|
||||
'messageFlagsRemove'
|
||||
)
|
||||
}
|
||||
log.info(`Removed $MailFlow-sorted from ${list.length} emails`)
|
||||
return list.length
|
||||
} catch (err) {
|
||||
log.warn('IMAP removeAllSortedFlags failed', { error: err?.message })
|
||||
return 0
|
||||
} finally {
|
||||
if (lock) {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
try {
|
||||
await this.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT USED IN SORT — sort never moves to Trash/Archive for cleanup.
|
||||
* Move to Trash or Archive (first that works) — standard mailboxes on most hosts.
|
||||
*/
|
||||
async moveToArchiveOrTrash(messageId) {
|
||||
const candidates = ['Trash', 'Archive', 'Deleted', 'INBOX.Trash']
|
||||
let lastErr = null
|
||||
for (const dest of candidates) {
|
||||
try {
|
||||
await this.ensureConnected()
|
||||
let lock = null
|
||||
try {
|
||||
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||
this.lock = lock
|
||||
await withOpTimeout(
|
||||
this.client.messageMove(String(messageId), dest, { uid: true }),
|
||||
'messageMove'
|
||||
)
|
||||
return
|
||||
} finally {
|
||||
if (lock) {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
log.warn(`IMAP moveToArchiveOrTrash: ${dest} failed`, { error: err.message })
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error('IMAP move to Trash/Archive failed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark message as read (\\Seen)
|
||||
*/
|
||||
async markAsRead(messageId) {
|
||||
await this.ensureConnected()
|
||||
let lock = null
|
||||
try {
|
||||
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||
this.lock = lock
|
||||
await withOpTimeout(
|
||||
this.client.messageFlagsAdd(String(messageId), ['\\Seen'], { uid: true }),
|
||||
'messageFlagsAdd'
|
||||
)
|
||||
} finally {
|
||||
if (lock) {
|
||||
lock.release()
|
||||
@@ -178,14 +719,48 @@ export class ImapService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark message as read (\\Seen)
|
||||
* Remove every $MailFlow-* keyword from all messages in INBOX (incl. $MailFlow-sorted and $MailFlow-Team-*).
|
||||
* Does not close the connection.
|
||||
* @returns {Promise<number>} number of messages that had at least one MailFlow keyword removed
|
||||
*/
|
||||
async markAsRead(messageId) {
|
||||
async stripAllMailFlowKeywordsInInbox() {
|
||||
await this.ensureConnected()
|
||||
let lock = null
|
||||
let touched = 0
|
||||
try {
|
||||
lock = await this.client.getMailboxLock(INBOX)
|
||||
lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock')
|
||||
this.lock = lock
|
||||
await this.client.messageFlagsAdd(String(messageId), ['\\Seen'], { uid: true })
|
||||
const allUids = await withOpTimeout(
|
||||
this.client.search({ all: true }, { uid: true }),
|
||||
'search'
|
||||
)
|
||||
const uidList = (allUids || [])
|
||||
.map((u) => (typeof u === 'bigint' ? Number(u) : u))
|
||||
.filter((n) => n != null && !Number.isNaN(Number(n)))
|
||||
if (!uidList.length) return 0
|
||||
|
||||
const messages = await withOpTimeout(
|
||||
this.client.fetchAll(uidList, { flags: true, uid: true }),
|
||||
'fetchAll'
|
||||
)
|
||||
for (const msg of messages || []) {
|
||||
const flagSet = msg.flags
|
||||
if (!flagSet || !flagSet.size) continue
|
||||
const toRemove = [...flagSet].filter(
|
||||
(f) => typeof f === 'string' && f.startsWith('$MailFlow')
|
||||
)
|
||||
if (!toRemove.length) continue
|
||||
await withOpTimeout(
|
||||
this.client.messageFlagsRemove(String(msg.uid), toRemove, { uid: true }),
|
||||
'messageFlagsRemove'
|
||||
)
|
||||
touched++
|
||||
}
|
||||
log.info(`Stripped MailFlow keywords from ${touched} INBOX message(s)`)
|
||||
return touched
|
||||
} catch (err) {
|
||||
log.warn('IMAP stripAllMailFlowKeywordsInInbox failed', { error: err?.message })
|
||||
return touched
|
||||
} finally {
|
||||
if (lock) {
|
||||
lock.release()
|
||||
@@ -193,4 +768,114 @@ export class ImapService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move messages from sort-related folders back to INBOX, then strip $MailFlow-* keywords in INBOX.
|
||||
* Safer than recoverAllToInbox (does not touch Sent/Drafts/Trash/Deleted).
|
||||
* Caller should connect first; does not call logout/close.
|
||||
* @returns {Promise<{ recovered: number, folders: Array<{ folder: string, count: number }>, mailFlowKeywordsStripped: number }>}
|
||||
*/
|
||||
async reSortRecoverAndStripKeywords() {
|
||||
await this.ensureConnected()
|
||||
let recovered = 0
|
||||
const folders = []
|
||||
try {
|
||||
const listed = await withOpTimeout(this.client.list(), 'list')
|
||||
for (const folder of listed || []) {
|
||||
const name = folder.path || folder.name
|
||||
if (!name || !isReSortRecoveryFolder(name)) continue
|
||||
if (folder.flags?.has?.('\\Noselect') || folder.flags?.has?.('\\NonExistent')) continue
|
||||
|
||||
try {
|
||||
const lock = await withOpTimeout(this.client.getMailboxLock(name), 'getMailboxLock')
|
||||
this.lock = lock
|
||||
try {
|
||||
const uids = await withOpTimeout(
|
||||
this.client.search({ all: true }, { uid: true }),
|
||||
'search'
|
||||
)
|
||||
if (!uids?.length) continue
|
||||
|
||||
const uidList = uids
|
||||
.map((u) => (typeof u === 'bigint' ? Number(u) : u))
|
||||
.filter((n) => n != null && !Number.isNaN(Number(n)))
|
||||
if (!uidList.length) continue
|
||||
|
||||
folders.push({ folder: name, count: uidList.length })
|
||||
await withOpTimeout(
|
||||
this.client.messageMove(uidList, INBOX, { uid: true }),
|
||||
'messageMove'
|
||||
)
|
||||
recovered += uidList.length
|
||||
log.info(`Re-sort recovery: ${uidList.length} message(s) from "${name}" → INBOX`)
|
||||
} finally {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Re-sort: could not empty folder "${name}"`, { error: err?.message })
|
||||
}
|
||||
}
|
||||
|
||||
const mailFlowKeywordsStripped = await this.stripAllMailFlowKeywordsInInbox()
|
||||
return { recovered, folders, mailFlowKeywordsStripped }
|
||||
} catch (err) {
|
||||
log.warn('IMAP reSortRecoverAndStripKeywords failed', { error: err?.message })
|
||||
return { recovered, folders, mailFlowKeywordsStripped: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move all messages from every mailbox except INBOX into INBOX (nuclear recovery).
|
||||
* WARNING: Also affects Sent, Drafts, Trash, Junk, etc. — only use to recover mail misplaced by buggy moves.
|
||||
*/
|
||||
async recoverAllToInbox() {
|
||||
await this.connect()
|
||||
const checkedFolders = []
|
||||
let recovered = 0
|
||||
try {
|
||||
const allFolders = await withOpTimeout(this.client.list(), 'list')
|
||||
for (const folder of allFolders) {
|
||||
const name = folder.path || folder.name
|
||||
if (!name || name.toUpperCase() === 'INBOX') continue
|
||||
|
||||
try {
|
||||
const lock = await withOpTimeout(this.client.getMailboxLock(name), 'getMailboxLock')
|
||||
this.lock = lock
|
||||
try {
|
||||
const uids = await withOpTimeout(
|
||||
this.client.search({ all: true }, { uid: true }),
|
||||
'search'
|
||||
)
|
||||
if (!uids || !uids.length) continue
|
||||
|
||||
const uidList = uids
|
||||
.map((u) => (typeof u === 'bigint' ? Number(u) : u))
|
||||
.filter((n) => n != null && !Number.isNaN(Number(n)))
|
||||
if (!uidList.length) continue
|
||||
|
||||
checkedFolders.push({ folder: name, count: uidList.length })
|
||||
await withOpTimeout(
|
||||
this.client.messageMove(uidList, INBOX, { uid: true }),
|
||||
'messageMove'
|
||||
)
|
||||
recovered += uidList.length
|
||||
log.info(`Recovered ${uidList.length} emails from "${name}" → INBOX`)
|
||||
} finally {
|
||||
lock.release()
|
||||
this.lock = null
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Could not process folder "${name}"`, { error: err.message })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await this.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return { recovered, folders: checkedFolders }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user