Try
dfssdfsfdsf
This commit is contained in:
@@ -13,6 +13,9 @@ import { config, features, isAdmin } from '../config/index.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
import { requireAuthUnlessEmailWebhook } from '../middleware/auth.mjs'
|
||||
import { encryptImapSecret, decryptImapSecret } from '../utils/crypto.mjs'
|
||||
import { isAppwriteCollectionMissing } from '../utils/appwriteErrors.mjs'
|
||||
import { AI_BATCH_CHUNK_SIZE, AI_BATCH_CHUNK_DELAY_MS } from '../services/ai-sorter.mjs'
|
||||
import { CATEGORY_FOLDER_KEYWORDS, findBestFolder, findPersonFolder } from '../services/imap.mjs'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -47,6 +50,49 @@ async function getAISorter() {
|
||||
return aiSorterInstance
|
||||
}
|
||||
|
||||
/** Reject after `ms` so the IMAP sort handler cannot hang indefinitely. */
|
||||
function imapSortRaceWithTimeout(promise, ms, message) {
|
||||
let timer
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new ValidationError(message)), ms)
|
||||
})
|
||||
return Promise.race([
|
||||
promise.finally(() => clearTimeout(timer)),
|
||||
timeout,
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* IMAP credentials: new accounts store JSON in accessToken; legacy uses encrypted password only.
|
||||
*/
|
||||
export function parseImapAccountAccess(account) {
|
||||
const fallbackHost = account.imapHost || 'imap.porkbun.com'
|
||||
const fallbackPort = account.imapPort != null ? Number(account.imapPort) : 993
|
||||
const fallbackSecure = account.imapSecure !== false
|
||||
if (!account.accessToken) {
|
||||
return { host: fallbackHost, port: fallbackPort, secure: fallbackSecure, password: '' }
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(account.accessToken)
|
||||
if (parsed && typeof parsed === 'object' && typeof parsed.password === 'string') {
|
||||
return {
|
||||
host: parsed.host ?? fallbackHost,
|
||||
port: parsed.port != null ? Number(parsed.port) : fallbackPort,
|
||||
secure: parsed.secure !== false,
|
||||
password: decryptImapSecret(parsed.password),
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// legacy: entire accessToken is encrypted secret
|
||||
}
|
||||
return {
|
||||
host: fallbackHost,
|
||||
port: fallbackPort,
|
||||
secure: fallbackSecure,
|
||||
password: decryptImapSecret(account.accessToken),
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DEMO DATA - Realistic Test Emails
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -128,23 +174,26 @@ router.post('/connect',
|
||||
}
|
||||
}
|
||||
|
||||
// Create account
|
||||
// Create account (IMAP: encode host/port/secure inside accessToken JSON — no Appwrite attrs)
|
||||
const rawImapSecret = provider === 'imap' ? (password || accessToken) : ''
|
||||
const accountData = {
|
||||
userId,
|
||||
provider,
|
||||
email,
|
||||
accessToken: provider === 'imap' ? encryptImapSecret(rawImapSecret) : (accessToken || ''),
|
||||
accessToken:
|
||||
provider === 'imap'
|
||||
? JSON.stringify({
|
||||
password: encryptImapSecret(rawImapSecret),
|
||||
host: imapHost || 'imap.porkbun.com',
|
||||
port: imapPort != null ? Number(imapPort) : 993,
|
||||
secure: imapSecure !== false,
|
||||
})
|
||||
: (accessToken || ''),
|
||||
refreshToken: provider === 'imap' ? '' : (refreshToken || ''),
|
||||
expiresAt: provider === 'imap' ? 0 : (expiresAt || 0),
|
||||
isActive: true,
|
||||
lastSync: null,
|
||||
}
|
||||
if (provider === 'imap') {
|
||||
if (imapHost != null) accountData.imapHost = String(imapHost)
|
||||
if (imapPort != null) accountData.imapPort = Number(imapPort)
|
||||
if (imapSecure !== undefined) accountData.imapSecure = Boolean(imapSecure)
|
||||
}
|
||||
const account = await emailAccounts.create(accountData)
|
||||
|
||||
log.success(`Email account connected: ${email} (${provider})`)
|
||||
@@ -280,7 +329,7 @@ router.get('/stats', asyncHandler(async (req, res) => {
|
||||
* Trigger email sorting for an account
|
||||
*
|
||||
* Options:
|
||||
* - maxEmails: Maximum emails to process (default: 500, max: 2000)
|
||||
* - maxEmails: Maximum emails to process (default: 500; IMAP default/cap 50, max 2000 non-IMAP)
|
||||
* - processAll: If true, process entire inbox with pagination
|
||||
*/
|
||||
router.post('/sort',
|
||||
@@ -292,13 +341,33 @@ router.post('/sort',
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
const { accountId, maxEmails = 500, processAll = true } = req.body
|
||||
|
||||
// Check subscription status and free tier limits
|
||||
const subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||
const { accountId, processAll = true } = req.body
|
||||
|
||||
console.log('[SORT] Step 1: Auth OK, userId:', userId)
|
||||
|
||||
const subscriptionFreeTierDefaults = () => ({
|
||||
plan: 'free',
|
||||
status: 'active',
|
||||
isFreeTier: true,
|
||||
emailsUsedThisMonth: 0,
|
||||
emailsLimit: config.freeTier.emailsPerMonth,
|
||||
})
|
||||
|
||||
// Check subscription status and free tier limits (missing collections → treat as free tier)
|
||||
let subscription
|
||||
try {
|
||||
subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||
} catch {
|
||||
subscription = null
|
||||
}
|
||||
if (!subscription) {
|
||||
subscription = subscriptionFreeTierDefaults()
|
||||
}
|
||||
const isFreeTier = subscription?.isFreeTier || false
|
||||
const adminUser = isAdmin(req.appwriteUser?.email)
|
||||
|
||||
console.log('[SORT] Step 3: Subscription:', subscription?.plan, 'isFreeTier:', subscription?.isFreeTier)
|
||||
|
||||
// Check free tier limit (admins: unlimited)
|
||||
if (isFreeTier && !adminUser) {
|
||||
const usage = await emailUsage.getUsage(userId)
|
||||
@@ -314,22 +383,40 @@ router.post('/sort',
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is first run (no stats exist)
|
||||
const existingStats = await emailStats.getByUser(userId)
|
||||
const isFirstRun = !existingStats || existingStats.totalSorted === 0
|
||||
|
||||
// For first run, limit to 50 emails for speed
|
||||
const effectiveMax = isFirstRun
|
||||
? Math.min(maxEmails, 50)
|
||||
: Math.min(maxEmails, 2000) // Cap at 2000 emails
|
||||
|
||||
// Get account
|
||||
const account = await emailAccounts.get(accountId)
|
||||
if (!account) {
|
||||
throw new NotFoundError('Email account')
|
||||
}
|
||||
|
||||
if (account.userId !== userId) {
|
||||
throw new AuthorizationError('No permission for this account')
|
||||
}
|
||||
|
||||
let maxEmails = req.body.maxEmails
|
||||
if (maxEmails == null) {
|
||||
maxEmails = 500
|
||||
} else {
|
||||
maxEmails = Number(maxEmails)
|
||||
if (!Number.isFinite(maxEmails) || maxEmails < 0) {
|
||||
maxEmails = 500
|
||||
}
|
||||
}
|
||||
if (account.provider === 'imap') {
|
||||
maxEmails = Math.min(maxEmails, 500)
|
||||
}
|
||||
|
||||
const effectiveMax =
|
||||
account.provider === 'imap'
|
||||
? Math.min(maxEmails, 500)
|
||||
: isFirstRun
|
||||
? Math.min(maxEmails, 50)
|
||||
: Math.min(maxEmails, 2000)
|
||||
|
||||
console.log('[SORT] Step 2: Account fetched:', account?.$id ?? 'NULL', 'provider:', account?.provider)
|
||||
|
||||
// Get user preferences
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
const preferences = prefs?.preferences || {}
|
||||
@@ -337,6 +424,7 @@ router.post('/sort',
|
||||
// Get AI sorter
|
||||
const sorter = await getAISorter()
|
||||
let sortedCount = 0
|
||||
let timedOut = false
|
||||
const results = { byCategory: {} }
|
||||
let emailSamples = [] // For suggested rules generation
|
||||
|
||||
@@ -351,11 +439,15 @@ router.post('/sort',
|
||||
const shuffled = [...DEMO_EMAILS].sort(() => Math.random() - 0.5)
|
||||
const emailsToSort = shuffled.slice(0, emailCount)
|
||||
|
||||
console.log('[SORT] Step 4: Emails fetched (demo):', emailsToSort?.length ?? 0)
|
||||
|
||||
// Check if AI is available
|
||||
if (features.ai()) {
|
||||
// Real AI sorting with demo data
|
||||
const classified = await sorter.batchCategorize(emailsToSort, preferences)
|
||||
|
||||
console.log('[SORT] Step 5: Categorized (demo AI):', classified?.length ?? 0)
|
||||
|
||||
for (const { email, classification } of classified) {
|
||||
const category = classification.category
|
||||
sortedCount++
|
||||
@@ -427,6 +519,8 @@ router.post('/sort',
|
||||
|
||||
log.success(`Rule-based sorting completed: ${sortedCount} demo emails`)
|
||||
}
|
||||
|
||||
console.log('[SORT] Step 5: Categorized (demo final sortedCount):', sortedCount)
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// GMAIL - Real Gmail sorting with native categories
|
||||
@@ -548,6 +642,8 @@ router.post('/sort',
|
||||
// Get full email details
|
||||
const emails = await gmail.batchGetEmails(messages.map(m => m.id))
|
||||
|
||||
console.log('[SORT] Step 4: Emails fetched (gmail batch):', emails?.length ?? 0)
|
||||
|
||||
// Process each email: check company labels first, then AI categorization
|
||||
const processedEmails = []
|
||||
|
||||
@@ -693,6 +789,8 @@ router.post('/sort',
|
||||
}
|
||||
} while (pageToken && processAll)
|
||||
|
||||
console.log('[SORT] Step 5: Categorized (gmail sortedCount):', sortedCount)
|
||||
|
||||
log.success(`Gmail sorting completed: ${sortedCount} emails processed`)
|
||||
} catch (err) {
|
||||
log.error('Gmail sorting failed', { error: err.message })
|
||||
@@ -728,6 +826,8 @@ router.post('/sort',
|
||||
|
||||
if (!messages?.length) break
|
||||
|
||||
console.log('[SORT] Step 4: Emails fetched (outlook batch):', messages?.length ?? 0)
|
||||
|
||||
// Process each email: check company labels first, then AI categorization
|
||||
const enabledCategories = sorter.getEnabledCategories(preferences)
|
||||
const processedEmails = []
|
||||
@@ -785,6 +885,8 @@ router.post('/sort',
|
||||
})
|
||||
}
|
||||
|
||||
console.log('[SORT] Step 5: Categorized (outlook batch processed):', processedEmails.length)
|
||||
|
||||
// Apply categories and actions
|
||||
for (const { email, category, companyLabel } of processedEmails) {
|
||||
const action = sorter.getCategoryAction(category, preferences)
|
||||
@@ -838,6 +940,8 @@ router.post('/sort',
|
||||
}
|
||||
} while (skipToken && processAll)
|
||||
|
||||
console.log('[SORT] Step 5: Categorized (outlook sortedCount):', sortedCount)
|
||||
|
||||
log.success(`Outlook sorting completed: ${sortedCount} emails processed`)
|
||||
} catch (err) {
|
||||
log.error('Outlook sorting failed', { error: err.message })
|
||||
@@ -858,147 +962,272 @@ router.post('/sort',
|
||||
|
||||
log.info(`IMAP sorting started for ${account.email}`)
|
||||
|
||||
const { ImapService, getFolderNameForCategory } = await import('../services/imap.mjs')
|
||||
const { ImapService } = await import('../services/imap.mjs')
|
||||
const imapCfg = parseImapAccountAccess(account)
|
||||
const imap = new ImapService({
|
||||
host: account.imapHost || 'imap.porkbun.com',
|
||||
port: account.imapPort != null ? account.imapPort : 993,
|
||||
secure: account.imapSecure !== false,
|
||||
host: imapCfg.host,
|
||||
port: imapCfg.port,
|
||||
secure: imapCfg.secure,
|
||||
user: account.email,
|
||||
password: decryptImapSecret(account.accessToken),
|
||||
password: imapCfg.password,
|
||||
})
|
||||
|
||||
try {
|
||||
await imap.connect()
|
||||
|
||||
const enabledCategories = sorter.getEnabledCategories(preferences)
|
||||
// Name labels (workers): create Team subfolders for IMAP/Nextcloud
|
||||
const nameLabelMap = {}
|
||||
if (preferences.nameLabels?.length) {
|
||||
for (const nl of preferences.nameLabels) {
|
||||
if (!nl.enabled) continue
|
||||
const folderName = `Team/${nl.name}`
|
||||
try {
|
||||
await imapSortRaceWithTimeout(
|
||||
(async () => {
|
||||
try {
|
||||
await imap.ensureFolder(folderName)
|
||||
nameLabelMap[nl.id || nl.name] = folderName
|
||||
if (nl.name !== (nl.id || nl.name)) nameLabelMap[nl.name] = folderName
|
||||
await imap.connect()
|
||||
|
||||
const enabledCategories = sorter.getEnabledCategories(preferences)
|
||||
|
||||
const existingFolders = await imap.listAllFolders()
|
||||
console.log('[SORT-IMAP] All available folders:', existingFolders)
|
||||
const folderPathSet = new Set(existingFolders)
|
||||
console.log(`[SORT-IMAP] Folders discovered: ${existingFolders.length}`)
|
||||
|
||||
const folderMap = {}
|
||||
for (const cat of Object.keys(CATEGORY_FOLDER_KEYWORDS)) {
|
||||
folderMap[cat] = findBestFolder(cat, existingFolders)
|
||||
}
|
||||
console.log('[SORT-IMAP] Folder map:', JSON.stringify(folderMap))
|
||||
|
||||
const fetchCap = Math.min(500, effectiveMax)
|
||||
const { messages } = await imap.listEmails(fetchCap, null)
|
||||
if (!messages?.length) {
|
||||
console.log('[SORT-IMAP] No messages in INBOX to process')
|
||||
log.success('IMAP sorting completed: 0 emails processed')
|
||||
return
|
||||
}
|
||||
|
||||
const emails = await imap.batchGetEmails(messages.map((m) => m.id))
|
||||
console.log('[SORT-IMAP] Emails fetched (batch):', emails?.length ?? 0)
|
||||
|
||||
const processedEmails = []
|
||||
|
||||
for (let chunkStart = 0; chunkStart < emails.length; chunkStart += AI_BATCH_CHUNK_SIZE) {
|
||||
const chunk = emails.slice(chunkStart, chunkStart + AI_BATCH_CHUNK_SIZE)
|
||||
for (const email of chunk) {
|
||||
const emailData = {
|
||||
from: email.headers?.from || '',
|
||||
subject: email.headers?.subject || '',
|
||||
snippet: email.snippet || '',
|
||||
}
|
||||
|
||||
let category = null
|
||||
let companyLabel = null
|
||||
let assignedTo = null
|
||||
let skipAI = false
|
||||
|
||||
if (preferences.companyLabels?.length) {
|
||||
for (const companyLabelConfig of preferences.companyLabels) {
|
||||
if (!companyLabelConfig.enabled) continue
|
||||
if (sorter.matchesCompanyLabel(emailData, companyLabelConfig)) {
|
||||
category = companyLabelConfig.category || 'promotions'
|
||||
companyLabel = companyLabelConfig.name
|
||||
skipAI = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipAI && preferences.autoDetectCompanies) {
|
||||
const detected = sorter.detectCompany(emailData)
|
||||
if (detected) {
|
||||
category = 'promotions'
|
||||
companyLabel = detected.label
|
||||
skipAI = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipAI) {
|
||||
const classification = await sorter.categorize(emailData, preferences)
|
||||
category = classification.category
|
||||
assignedTo = classification.assignedTo || null
|
||||
if (!enabledCategories.includes(category)) category = 'review'
|
||||
}
|
||||
|
||||
processedEmails.push({ email, category, companyLabel, assignedTo })
|
||||
|
||||
if (isFirstRun && emailSamples.length < 50) {
|
||||
emailSamples.push({
|
||||
from: emailData.from,
|
||||
subject: emailData.subject,
|
||||
snippet: emailData.snippet,
|
||||
category,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (chunkStart + AI_BATCH_CHUNK_SIZE < emails.length) {
|
||||
await new Promise((r) => setTimeout(r, AI_BATCH_CHUNK_DELAY_MS))
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SORT-IMAP] Categorized:', processedEmails.length)
|
||||
|
||||
const MOVE_OUT_COMPLETELY = ['newsletters', 'promotions', 'social']
|
||||
|
||||
let movedCount = 0
|
||||
let copiedCount = 0
|
||||
let sortDecisionLogCount = 0
|
||||
|
||||
for (const { email, category, companyLabel, assignedTo } of processedEmails) {
|
||||
const resolvedCategory = companyLabel
|
||||
? preferences.companyLabels?.find((c) => c.name === companyLabel)?.category || 'promotions'
|
||||
: category
|
||||
|
||||
const emailDataForPerson = {
|
||||
from: email.headers?.from || '',
|
||||
subject: email.headers?.subject || '',
|
||||
}
|
||||
|
||||
const personFolder = findPersonFolder(emailDataForPerson, existingFolders)
|
||||
const categoryFolder = folderMap[resolvedCategory] || null
|
||||
|
||||
const action = sorter.getCategoryAction
|
||||
? sorter.getCategoryAction(resolvedCategory, preferences)
|
||||
: 'inbox'
|
||||
|
||||
const didEarlyArchiveRead =
|
||||
MOVE_OUT_COMPLETELY.includes(resolvedCategory) &&
|
||||
categoryFolder &&
|
||||
action === 'archive_read'
|
||||
|
||||
if (didEarlyArchiveRead) {
|
||||
try {
|
||||
await imap.markAsRead(email.id)
|
||||
} catch {
|
||||
// ignore — message may already be moved in edge cases
|
||||
}
|
||||
}
|
||||
|
||||
if (personFolder) {
|
||||
try {
|
||||
const copied = await imap.copyToFolder(email.id, personFolder, folderPathSet)
|
||||
if (copied) {
|
||||
copiedCount++
|
||||
console.log(
|
||||
`[SORT-IMAP] Email ${email.id} → copied to person folder "${personFolder}"`
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (categoryFolder && categoryFolder !== personFolder) {
|
||||
if (MOVE_OUT_COMPLETELY.includes(resolvedCategory)) {
|
||||
try {
|
||||
const moved = await imap.moveMessageToExistingPath(
|
||||
email.id,
|
||||
categoryFolder,
|
||||
folderPathSet
|
||||
)
|
||||
if (moved) {
|
||||
movedCount++
|
||||
console.log(`[SORT-IMAP] Email ${email.id} → MOVED to "${categoryFolder}"`)
|
||||
} else {
|
||||
const copied = await imap.copyToFolder(
|
||||
email.id,
|
||||
categoryFolder,
|
||||
folderPathSet
|
||||
)
|
||||
if (copied) {
|
||||
copiedCount++
|
||||
console.log(
|
||||
`[SORT-IMAP] Email ${email.id} → copied (move failed) to "${categoryFolder}"`
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const copied = await imap.copyToFolder(
|
||||
email.id,
|
||||
categoryFolder,
|
||||
folderPathSet
|
||||
)
|
||||
if (copied) {
|
||||
copiedCount++
|
||||
console.log(
|
||||
`[SORT-IMAP] Email ${email.id} → copied to "${categoryFolder}"`
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!MOVE_OUT_COMPLETELY.includes(resolvedCategory) || personFolder) {
|
||||
try {
|
||||
await imap.addMailFlowCategoryKeyword(
|
||||
email.id,
|
||||
resolvedCategory,
|
||||
assignedTo || null
|
||||
)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'archive_read' && !didEarlyArchiveRead) {
|
||||
await imap.markAsRead(email.id)
|
||||
}
|
||||
} catch {
|
||||
// ignore — e.g. message already moved out of INBOX
|
||||
}
|
||||
|
||||
sortedCount++
|
||||
results.byCategory[resolvedCategory] = (results.byCategory[resolvedCategory] || 0) + 1
|
||||
|
||||
if (sortDecisionLogCount < 20) {
|
||||
sortDecisionLogCount++
|
||||
console.log(
|
||||
`[SORT-IMAP] Decision ${sortDecisionLogCount}/20: uid=${email.id} category=${resolvedCategory} ` +
|
||||
`personFolder=${personFolder || '—'} categoryFolder=${categoryFolder || '—'} ` +
|
||||
`moveOut=${MOVE_OUT_COMPLETELY.includes(resolvedCategory)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SORT-IMAP] Complete: ${movedCount} moved, ${copiedCount} copied`
|
||||
)
|
||||
|
||||
log.success(`IMAP sorting completed: ${sortedCount} emails processed`)
|
||||
} catch (err) {
|
||||
log.warn(`IMAP name label folder failed: ${nl.name}`, { error: err.message })
|
||||
log.error('IMAP sorting failed', { error: err.message })
|
||||
throw new ValidationError(`IMAP error: ${err.message}. Check credentials or reconnect.`)
|
||||
}
|
||||
})(),
|
||||
300_000,
|
||||
'IMAP sort timed out after 300 seconds. Check host, port, or network.'
|
||||
)
|
||||
} catch (err) {
|
||||
if (String(err?.message || '').includes('timed out')) {
|
||||
timedOut = true
|
||||
log.warn(
|
||||
`[SORT-IMAP] Timed out after 300s — saving partial results (${sortedCount} sorted so far)`
|
||||
)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
let pageToken = null
|
||||
let totalProcessed = 0
|
||||
const batchSize = 100
|
||||
|
||||
do {
|
||||
const { messages, nextPageToken } = await imap.listEmails(batchSize, pageToken)
|
||||
pageToken = nextPageToken
|
||||
|
||||
if (!messages?.length) break
|
||||
|
||||
const emails = await imap.batchGetEmails(messages.map((m) => m.id))
|
||||
const processedEmails = []
|
||||
|
||||
for (const email of emails) {
|
||||
const emailData = {
|
||||
from: email.headers?.from || '',
|
||||
subject: email.headers?.subject || '',
|
||||
snippet: email.snippet || '',
|
||||
}
|
||||
|
||||
let category = null
|
||||
let companyLabel = null
|
||||
let assignedTo = null
|
||||
let skipAI = false
|
||||
|
||||
if (preferences.companyLabels?.length) {
|
||||
for (const companyLabelConfig of preferences.companyLabels) {
|
||||
if (!companyLabelConfig.enabled) continue
|
||||
if (sorter.matchesCompanyLabel(emailData, companyLabelConfig)) {
|
||||
category = companyLabelConfig.category || 'promotions'
|
||||
companyLabel = companyLabelConfig.name
|
||||
skipAI = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipAI && preferences.autoDetectCompanies) {
|
||||
const detected = sorter.detectCompany(emailData)
|
||||
if (detected) {
|
||||
category = 'promotions'
|
||||
companyLabel = detected.label
|
||||
skipAI = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipAI) {
|
||||
const classification = await sorter.categorize(emailData, preferences)
|
||||
category = classification.category
|
||||
assignedTo = classification.assignedTo || null
|
||||
if (!enabledCategories.includes(category)) category = 'review'
|
||||
}
|
||||
|
||||
processedEmails.push({ email, category, companyLabel, assignedTo })
|
||||
|
||||
if (isFirstRun && emailSamples.length < 50) {
|
||||
emailSamples.push({
|
||||
from: emailData.from,
|
||||
subject: emailData.subject,
|
||||
snippet: emailData.snippet,
|
||||
category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const actionMap = sorter.getCategoryAction ? (cat) => sorter.getCategoryAction(cat, preferences) : () => 'inbox'
|
||||
|
||||
for (const { email, category, companyLabel, assignedTo } of processedEmails) {
|
||||
try {
|
||||
const action = actionMap(category)
|
||||
// If AI assigned to a worker, move to Team/<Name> folder; else use category folder
|
||||
const folderName = (assignedTo && nameLabelMap[assignedTo])
|
||||
? nameLabelMap[assignedTo]
|
||||
: getFolderNameForCategory(companyLabel ? (preferences.companyLabels?.find((c) => c.name === companyLabel)?.category || 'promotions') : category)
|
||||
|
||||
await imap.moveToFolder(email.id, folderName)
|
||||
|
||||
if (action === 'archive_read') {
|
||||
try {
|
||||
await imap.markAsRead(email.id)
|
||||
} catch {
|
||||
// already moved; mark as read optional
|
||||
}
|
||||
}
|
||||
|
||||
sortedCount++
|
||||
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
||||
} catch (err) {
|
||||
log.warn(`IMAP sort failed: ${email.id}`, { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
totalProcessed += emails.length
|
||||
log.info(`IMAP processed ${totalProcessed} emails so far...`)
|
||||
|
||||
if (totalProcessed >= effectiveMax) break
|
||||
if (pageToken) await new Promise((r) => setTimeout(r, 200))
|
||||
} while (pageToken && processAll)
|
||||
|
||||
await imap.close()
|
||||
log.success(`IMAP sorting completed: ${sortedCount} emails processed`)
|
||||
} catch (err) {
|
||||
} finally {
|
||||
try {
|
||||
await imap.close()
|
||||
} catch {
|
||||
// ignore
|
||||
// ignore — dead connection must not block HTTP response
|
||||
}
|
||||
log.error('IMAP sorting failed', { error: err.message })
|
||||
throw new ValidationError(`IMAP error: ${err.message}. Check credentials or reconnect.`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SORT] Step 6: Saving results (sync, stats, usage, digest)...')
|
||||
|
||||
// Update last sync
|
||||
await emailAccounts.updateLastSync(accountId)
|
||||
|
||||
@@ -1075,6 +1304,8 @@ router.post('/sort',
|
||||
log.warn('Digest update failed', { error: err.message })
|
||||
}
|
||||
|
||||
console.log('[SORT] Step 6: Results saved, sortedCount:', sortedCount)
|
||||
|
||||
log.success(`Sorting completed: ${sortedCount} emails for ${account.email}`)
|
||||
|
||||
// Generate suggested rules for first run
|
||||
@@ -1102,10 +1333,122 @@ router.post('/sort',
|
||||
suggestions,
|
||||
provider: account.provider,
|
||||
isDemo: account.provider === 'demo',
|
||||
timedOut: timedOut || undefined,
|
||||
message: timedOut
|
||||
? `Sorted ${sortedCount} emails (sort timed out, will continue next run)`
|
||||
: undefined,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/email/recover/:accountId
|
||||
* Move messages from all non-INBOX folders back to INBOX (IMAP only).
|
||||
*/
|
||||
router.post(
|
||||
'/recover/:accountId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
const { accountId } = req.params
|
||||
|
||||
const account = await emailAccounts.get(accountId)
|
||||
if (!account) throw new NotFoundError('Email account')
|
||||
if (account.userId !== userId) throw new AuthorizationError('No permission for this account')
|
||||
|
||||
if (account.provider !== 'imap') {
|
||||
return respond.success(res, {
|
||||
recovered: 0,
|
||||
folders: [],
|
||||
message: 'Only available for IMAP accounts',
|
||||
})
|
||||
}
|
||||
|
||||
const { ImapService } = await import('../services/imap.mjs')
|
||||
const imapCfg = parseImapAccountAccess(account)
|
||||
const imap = new ImapService({
|
||||
host: imapCfg.host,
|
||||
port: imapCfg.port,
|
||||
secure: imapCfg.secure,
|
||||
user: account.email,
|
||||
password: imapCfg.password,
|
||||
})
|
||||
|
||||
log.info(`Email recovery started for ${account.email}`)
|
||||
|
||||
const result = await imap.recoverAllToInbox()
|
||||
|
||||
log.success(`Recovery complete: ${result.recovered} emails returned to INBOX`)
|
||||
respond.success(res, {
|
||||
recovered: result.recovered,
|
||||
folders: result.folders,
|
||||
message:
|
||||
result.recovered > 0
|
||||
? `${result.recovered} emails recovered back to inbox`
|
||||
: 'No emails found outside inbox',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/email/re-sort/:accountId
|
||||
* IMAP: move messages from sort-related folders (Junk, Archive, MailFlow/*, …) back to INBOX and strip $MailFlow-* keywords.
|
||||
*/
|
||||
router.post(
|
||||
'/re-sort/:accountId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
const { accountId } = req.params
|
||||
|
||||
const account = await emailAccounts.get(accountId)
|
||||
if (!account) throw new NotFoundError('Email account')
|
||||
if (account.userId !== userId) throw new AuthorizationError('No permission for this account')
|
||||
|
||||
if (account.provider !== 'imap') {
|
||||
return respond.success(res, {
|
||||
recovered: 0,
|
||||
folders: [],
|
||||
mailFlowKeywordsStripped: 0,
|
||||
message: 'Only available for IMAP accounts',
|
||||
})
|
||||
}
|
||||
|
||||
const { ImapService } = await import('../services/imap.mjs')
|
||||
const imapCfg = parseImapAccountAccess(account)
|
||||
const imap = new ImapService({
|
||||
host: imapCfg.host,
|
||||
port: imapCfg.port,
|
||||
secure: imapCfg.secure,
|
||||
user: account.email,
|
||||
password: imapCfg.password,
|
||||
})
|
||||
|
||||
log.info(`IMAP re-sort prep started for ${account.email}`)
|
||||
|
||||
try {
|
||||
await imap.connect()
|
||||
const result = await imap.reSortRecoverAndStripKeywords()
|
||||
log.success(
|
||||
`Re-sort prep: ${result.recovered} to INBOX, MailFlow keywords stripped on ${result.mailFlowKeywordsStripped} message(s)`
|
||||
)
|
||||
respond.success(res, {
|
||||
recovered: result.recovered,
|
||||
folders: result.folders,
|
||||
mailFlowKeywordsStripped: result.mailFlowKeywordsStripped,
|
||||
message:
|
||||
result.recovered > 0 || result.mailFlowKeywordsStripped > 0
|
||||
? `Moved ${result.recovered} message(s) to INBOX; stripped MailFlow tags from ${result.mailFlowKeywordsStripped} INBOX message(s). Run sort again.`
|
||||
: 'Nothing to reset — INBOX already clean of sort folders / keywords.',
|
||||
})
|
||||
} finally {
|
||||
try {
|
||||
await imap.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/email/sort-demo
|
||||
* Quick demo sorting without account (for testing)
|
||||
@@ -1233,16 +1576,13 @@ router.post('/cleanup/mailflow-labels',
|
||||
/**
|
||||
* GET /api/email/digest
|
||||
* Get today's sorting digest summary
|
||||
* (Also registered on app in index.mjs before router mount.)
|
||||
*/
|
||||
router.get('/digest', asyncHandler(async (req, res) => {
|
||||
export async function handleGetDigest(req, res) {
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
const digest = await emailDigests.getByUserToday(userId)
|
||||
|
||||
if (!digest) {
|
||||
// Return empty digest for new users
|
||||
return respond.success(res, {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
const emptyDigest = () =>
|
||||
respond.success(res, {
|
||||
date: new Date().toISOString(),
|
||||
totalSorted: 0,
|
||||
inboxCleared: 0,
|
||||
timeSavedMinutes: 0,
|
||||
@@ -1251,19 +1591,33 @@ router.get('/digest', asyncHandler(async (req, res) => {
|
||||
suggestions: [],
|
||||
hasData: false,
|
||||
})
|
||||
}
|
||||
|
||||
respond.success(res, {
|
||||
date: digest.date,
|
||||
totalSorted: digest.totalSorted,
|
||||
inboxCleared: digest.inboxCleared,
|
||||
timeSavedMinutes: digest.timeSavedMinutes,
|
||||
stats: digest.stats,
|
||||
highlights: digest.highlights,
|
||||
suggestions: digest.suggestions,
|
||||
hasData: true,
|
||||
})
|
||||
}))
|
||||
try {
|
||||
const digest = await emailDigests.getByUserToday(userId)
|
||||
|
||||
if (!digest) {
|
||||
return emptyDigest()
|
||||
}
|
||||
|
||||
return respond.success(res, {
|
||||
date: digest.date,
|
||||
totalSorted: digest.totalSorted,
|
||||
inboxCleared: digest.inboxCleared,
|
||||
timeSavedMinutes: digest.timeSavedMinutes,
|
||||
stats: digest.stats,
|
||||
highlights: digest.highlights,
|
||||
suggestions: digest.suggestions,
|
||||
hasData: true,
|
||||
})
|
||||
} catch (err) {
|
||||
if (isAppwriteCollectionMissing(err)) {
|
||||
return emptyDigest()
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/digest', asyncHandler(handleGetDigest))
|
||||
|
||||
/**
|
||||
* GET /api/email/digest/history
|
||||
|
||||
Reference in New Issue
Block a user