dfssdfsfdsf
This commit is contained in:
2026-04-09 21:00:04 +02:00
parent 983b67e6fc
commit 89bc86b615
27 changed files with 2921 additions and 408 deletions

View File

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