fix(dev): Vite-API-Proxy, Auth, Stripe-Mails und Backend-Erweiterungen
- Client: API-Basis-URL (joinApiUrl, /v1-Falle), Vite strictPort + Proxy 127.0.0.1, Nicht-JSON-Fehler - Server: /api-404 ohne Wildcard-Bug, SPA-Fallback, Auth-Middleware, Cron, Mailer, Crypto - Routen: OAuth-State, Email/Stripe/Analytics; client/.env.example Made-with: Cursor
This commit is contained in:
@@ -9,11 +9,15 @@ import { validate, rules } from '../middleware/validate.mjs'
|
||||
import { limiters } from '../middleware/rateLimit.mjs'
|
||||
import { respond } from '../utils/response.mjs'
|
||||
import { emailAccounts, emailStats, emailDigests, userPreferences, emailUsage, subscriptions } from '../services/database.mjs'
|
||||
import { config, features } from '../config/index.mjs'
|
||||
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'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.use(requireAuthUnlessEmailWebhook)
|
||||
|
||||
// Lazy load heavy services
|
||||
let gmailServiceClass = null
|
||||
let outlookServiceClass = null
|
||||
@@ -77,13 +81,13 @@ const DEMO_EMAILS = [
|
||||
router.post('/connect',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])],
|
||||
email: [rules.required('email'), rules.email()],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId, provider, email, accessToken, refreshToken, expiresAt, password, imapHost, imapPort, imapSecure } = req.body
|
||||
const userId = req.appwriteUser.id
|
||||
const { provider, email, accessToken, refreshToken, expiresAt, password, imapHost, imapPort, imapSecure } = req.body
|
||||
|
||||
// IMAP: require password (or accessToken as password)
|
||||
if (provider === 'imap') {
|
||||
@@ -125,11 +129,12 @@ router.post('/connect',
|
||||
}
|
||||
|
||||
// Create account
|
||||
const rawImapSecret = provider === 'imap' ? (password || accessToken) : ''
|
||||
const accountData = {
|
||||
userId,
|
||||
provider,
|
||||
email,
|
||||
accessToken: provider === 'imap' ? (password || accessToken) : (accessToken || ''),
|
||||
accessToken: provider === 'imap' ? encryptImapSecret(rawImapSecret) : (accessToken || ''),
|
||||
refreshToken: provider === 'imap' ? '' : (refreshToken || ''),
|
||||
expiresAt: provider === 'imap' ? 0 : (expiresAt || 0),
|
||||
isActive: true,
|
||||
@@ -157,13 +162,8 @@ router.post('/connect',
|
||||
* Connect a demo email account for testing
|
||||
*/
|
||||
router.post('/connect-demo',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body
|
||||
const userId = req.appwriteUser.id
|
||||
const demoEmail = `demo-${userId.slice(0, 8)}@mailflow.demo`
|
||||
|
||||
// Check if demo account already exists
|
||||
@@ -207,11 +207,7 @@ router.post('/connect-demo',
|
||||
* Get user's connected email accounts
|
||||
*/
|
||||
router.get('/accounts', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId is required')
|
||||
}
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
const accounts = await emailAccounts.getByUser(userId)
|
||||
|
||||
@@ -234,11 +230,7 @@ router.get('/accounts', asyncHandler(async (req, res) => {
|
||||
*/
|
||||
router.delete('/accounts/:accountId', asyncHandler(async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { userId } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId is required')
|
||||
}
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
// Verify ownership
|
||||
const account = await emailAccounts.get(accountId)
|
||||
@@ -259,11 +251,7 @@ router.delete('/accounts/:accountId', asyncHandler(async (req, res) => {
|
||||
* Get email sorting statistics
|
||||
*/
|
||||
router.get('/stats', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId is required')
|
||||
}
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
const stats = await emailStats.getByUser(userId)
|
||||
|
||||
@@ -299,19 +287,20 @@ router.post('/sort',
|
||||
limiters.emailSort,
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
accountId: [rules.required('accountId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId, accountId, maxEmails = 500, processAll = true } = req.body
|
||||
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)
|
||||
const subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||
const isFreeTier = subscription?.isFreeTier || false
|
||||
|
||||
// Check free tier limit
|
||||
if (isFreeTier) {
|
||||
const adminUser = isAdmin(req.appwriteUser?.email)
|
||||
|
||||
// Check free tier limit (admins: unlimited)
|
||||
if (isFreeTier && !adminUser) {
|
||||
const usage = await emailUsage.getUsage(userId)
|
||||
const limit = subscription?.emailsLimit || config.freeTier.emailsPerMonth
|
||||
|
||||
@@ -875,7 +864,7 @@ router.post('/sort',
|
||||
port: account.imapPort != null ? account.imapPort : 993,
|
||||
secure: account.imapSecure !== false,
|
||||
user: account.email,
|
||||
password: account.accessToken,
|
||||
password: decryptImapSecret(account.accessToken),
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -1013,8 +1002,8 @@ router.post('/sort',
|
||||
// Update last sync
|
||||
await emailAccounts.updateLastSync(accountId)
|
||||
|
||||
// Update email usage (for free tier tracking)
|
||||
if (isFreeTier) {
|
||||
// Update email usage (for free tier tracking; admins are "business", skip counter)
|
||||
if (isFreeTier && !adminUser) {
|
||||
await emailUsage.increment(userId, sortedCount)
|
||||
}
|
||||
|
||||
@@ -1202,18 +1191,18 @@ router.post('/sort-demo', asyncHandler(async (req, res) => {
|
||||
}))
|
||||
|
||||
/**
|
||||
* POST /api/email/cleanup
|
||||
* Cleanup old MailFlow labels from Gmail
|
||||
* POST /api/email/cleanup/mailflow-labels
|
||||
* Cleanup old MailFlow labels from Gmail (legacy label names)
|
||||
*/
|
||||
router.post('/cleanup',
|
||||
router.post('/cleanup/mailflow-labels',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
accountId: [rules.required('accountId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId, accountId } = req.body
|
||||
const userId = req.appwriteUser.id
|
||||
const { accountId } = req.body
|
||||
|
||||
const account = await emailAccounts.get(accountId)
|
||||
|
||||
@@ -1246,11 +1235,7 @@ router.post('/cleanup',
|
||||
* Get today's sorting digest summary
|
||||
*/
|
||||
router.get('/digest', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId is required')
|
||||
}
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
const digest = await emailDigests.getByUserToday(userId)
|
||||
|
||||
@@ -1285,13 +1270,10 @@ router.get('/digest', asyncHandler(async (req, res) => {
|
||||
* Get digest history for the last N days
|
||||
*/
|
||||
router.get('/digest/history', asyncHandler(async (req, res) => {
|
||||
const { userId, days = 7 } = req.query
|
||||
const userId = req.appwriteUser.id
|
||||
const days = req.query.days ?? 7
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId is required')
|
||||
}
|
||||
|
||||
const digests = await emailDigests.getByUserRecent(userId, parseInt(days))
|
||||
const digests = await emailDigests.getByUserRecent(userId, parseInt(String(days), 10))
|
||||
|
||||
// Calculate totals
|
||||
const totals = {
|
||||
@@ -1333,6 +1315,77 @@ router.get('/categories', asyncHandler(async (req, res) => {
|
||||
respond.success(res, formattedCategories)
|
||||
}))
|
||||
|
||||
/**
|
||||
* GET /api/email/:accountId/cleanup/preview
|
||||
* Dry-run: messages that would be affected by cleanup settings (no mutations).
|
||||
*
|
||||
* curl examples:
|
||||
* curl -s -H "Authorization: Bearer YOUR_JWT" "http://localhost:3000/api/email/ACCOUNT_DOC_ID/cleanup/preview"
|
||||
*/
|
||||
router.get('/:accountId/cleanup/preview', asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
const { accountId } = req.params
|
||||
|
||||
const account = await emailAccounts.get(accountId)
|
||||
if (account.userId !== userId) {
|
||||
throw new AuthorizationError('No permission for this account')
|
||||
}
|
||||
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
const cleanup = prefs?.preferences?.cleanup || userPreferences.getDefaults().cleanup
|
||||
const maxList = cleanup.safety?.maxDeletesPerRun ?? 100
|
||||
|
||||
const messages = []
|
||||
|
||||
if (cleanup.readItems?.enabled) {
|
||||
const readList = await listReadCleanupPreviewMessages(account, cleanup.readItems.gracePeriodDays, maxList)
|
||||
for (const m of readList) {
|
||||
if (messages.length >= maxList) break
|
||||
messages.push({ ...m, reason: 'read' })
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanup.promotions?.enabled && messages.length < maxList) {
|
||||
const promoList = await listPromotionCleanupPreviewMessages(
|
||||
account,
|
||||
cleanup.promotions.deleteAfterDays,
|
||||
cleanup.promotions.matchCategoriesOrLabels || [],
|
||||
maxList - messages.length
|
||||
)
|
||||
for (const m of promoList) {
|
||||
if (messages.length >= maxList) break
|
||||
messages.push({ ...m, reason: 'promotion' })
|
||||
}
|
||||
}
|
||||
|
||||
respond.success(res, { messages, count: messages.length })
|
||||
}))
|
||||
|
||||
/**
|
||||
* GET /api/email/:accountId/cleanup/status
|
||||
*
|
||||
* curl examples:
|
||||
* curl -s -H "Authorization: Bearer YOUR_JWT" "http://localhost:3000/api/email/ACCOUNT_DOC_ID/cleanup/status"
|
||||
*/
|
||||
router.get('/:accountId/cleanup/status', asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
const { accountId } = req.params
|
||||
|
||||
const account = await emailAccounts.get(accountId)
|
||||
if (account.userId !== userId) {
|
||||
throw new AuthorizationError('No permission for this account')
|
||||
}
|
||||
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
const meta = prefs?.preferences?.cleanupMeta || {}
|
||||
|
||||
respond.success(res, {
|
||||
lastRun: meta.lastRun,
|
||||
lastRunCounts: meta.lastRunCounts,
|
||||
lastErrors: meta.lastErrors,
|
||||
})
|
||||
}))
|
||||
|
||||
/**
|
||||
* POST /api/email/webhook/gmail
|
||||
* Gmail push notification webhook
|
||||
@@ -1380,10 +1433,10 @@ router.post('/webhook/outlook', asyncHandler(async (req, res) => {
|
||||
* Can be called manually or by cron job
|
||||
*/
|
||||
router.post('/cleanup', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body // Optional: only process this user, otherwise all users
|
||||
|
||||
log.info('Cleanup job started', { userId: userId || 'all' })
|
||||
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
log.info('Cleanup job started', { userId })
|
||||
|
||||
const results = {
|
||||
usersProcessed: 0,
|
||||
emailsProcessed: {
|
||||
@@ -1394,72 +1447,60 @@ router.post('/cleanup', asyncHandler(async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all users with cleanup enabled
|
||||
let usersToProcess = []
|
||||
|
||||
if (userId) {
|
||||
// Single user mode
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
if (prefs?.preferences?.cleanup?.enabled) {
|
||||
usersToProcess = [{ userId, preferences: prefs.preferences }]
|
||||
}
|
||||
} else {
|
||||
// All users mode - get all user preferences
|
||||
// Note: This is a simplified approach. In production, you might want to add an index
|
||||
// or query optimization for users with cleanup.enabled = true
|
||||
const allPrefs = await emailAccounts.getByUser('*') // This won't work, need different approach
|
||||
// For now, we'll process users individually when they have accounts
|
||||
// TODO: Add efficient query for users with cleanup enabled
|
||||
log.warn('Processing all users not yet implemented efficiently. Use userId parameter for single user cleanup.')
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
if (!prefs?.preferences?.cleanup?.enabled) {
|
||||
return respond.success(res, { ...results, message: 'Cleanup not enabled for this user' })
|
||||
}
|
||||
|
||||
// If userId provided, process that user
|
||||
if (userId) {
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
if (!prefs?.preferences?.cleanup?.enabled) {
|
||||
return respond.success(res, { ...results, message: 'Cleanup not enabled for this user' })
|
||||
}
|
||||
const accounts = await emailAccounts.getByUser(userId)
|
||||
if (!accounts || accounts.length === 0) {
|
||||
return respond.success(res, { ...results, message: 'No email accounts found' })
|
||||
}
|
||||
|
||||
const accounts = await emailAccounts.getByUser(userId)
|
||||
if (!accounts || accounts.length === 0) {
|
||||
return respond.success(res, { ...results, message: 'No email accounts found' })
|
||||
}
|
||||
for (const account of accounts) {
|
||||
if (!account.isActive || !account.accessToken) continue
|
||||
|
||||
for (const account of accounts) {
|
||||
if (!account.isActive || !account.accessToken) continue
|
||||
try {
|
||||
const cleanup = prefs.preferences.cleanup
|
||||
|
||||
try {
|
||||
const cleanup = prefs.preferences.cleanup
|
||||
|
||||
// Read Items Cleanup
|
||||
if (cleanup.readItems?.enabled) {
|
||||
const readItemsCount = await processReadItemsCleanup(
|
||||
account,
|
||||
cleanup.readItems.action,
|
||||
cleanup.readItems.gracePeriodDays
|
||||
)
|
||||
results.emailsProcessed.readItems += readItemsCount
|
||||
}
|
||||
|
||||
// Promotion Cleanup
|
||||
if (cleanup.promotions?.enabled) {
|
||||
const promotionsCount = await processPromotionsCleanup(
|
||||
account,
|
||||
cleanup.promotions.action,
|
||||
cleanup.promotions.deleteAfterDays,
|
||||
cleanup.promotions.matchCategoriesOrLabels || []
|
||||
)
|
||||
results.emailsProcessed.promotions += promotionsCount
|
||||
}
|
||||
|
||||
results.usersProcessed = 1
|
||||
} catch (error) {
|
||||
log.error(`Cleanup failed for account ${account.email}`, { error: error.message })
|
||||
results.errors.push({ userId, accountId: account.id, error: error.message })
|
||||
if (cleanup.readItems?.enabled) {
|
||||
const readItemsCount = await processReadItemsCleanup(
|
||||
account,
|
||||
cleanup.readItems.action,
|
||||
cleanup.readItems.gracePeriodDays
|
||||
)
|
||||
results.emailsProcessed.readItems += readItemsCount
|
||||
}
|
||||
|
||||
if (cleanup.promotions?.enabled) {
|
||||
const promotionsCount = await processPromotionsCleanup(
|
||||
account,
|
||||
cleanup.promotions.action,
|
||||
cleanup.promotions.deleteAfterDays,
|
||||
cleanup.promotions.matchCategoriesOrLabels || []
|
||||
)
|
||||
results.emailsProcessed.promotions += promotionsCount
|
||||
}
|
||||
|
||||
results.usersProcessed = 1
|
||||
} catch (error) {
|
||||
log.error(`Cleanup failed for account ${account.email}`, { error: error.message })
|
||||
results.errors.push({ userId, accountId: account.$id, error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
const lastRun = new Date().toISOString()
|
||||
await userPreferences.upsert(userId, {
|
||||
cleanupMeta: {
|
||||
lastRun,
|
||||
lastRunCounts: {
|
||||
readItems: results.emailsProcessed.readItems,
|
||||
promotions: results.emailsProcessed.promotions,
|
||||
},
|
||||
lastErrors: results.errors.map((e) => e.error),
|
||||
},
|
||||
})
|
||||
|
||||
log.success('Cleanup job completed', results)
|
||||
respond.success(res, results, 'Cleanup completed')
|
||||
} catch (error) {
|
||||
@@ -1607,4 +1648,98 @@ async function processPromotionsCleanup(account, action, deleteAfterDays, matchC
|
||||
return processedCount
|
||||
}
|
||||
|
||||
async function listReadCleanupPreviewMessages(account, gracePeriodDays, cap) {
|
||||
const out = []
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - gracePeriodDays)
|
||||
const before = `${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`
|
||||
|
||||
try {
|
||||
if (account.provider === 'gmail') {
|
||||
const gmail = await getGmailService(account.accessToken, account.refreshToken)
|
||||
const query = `-is:unread before:${before}`
|
||||
const response = await gmail.gmail.users.messages.list({
|
||||
userId: 'me',
|
||||
q: query,
|
||||
maxResults: Math.min(cap, 500),
|
||||
})
|
||||
const ids = (response.data.messages || []).map((m) => m.id).slice(0, cap)
|
||||
const emails = await gmail.batchGetEmails(ids)
|
||||
for (const email of emails) {
|
||||
out.push({
|
||||
id: email.id,
|
||||
subject: email.headers?.subject || '',
|
||||
from: email.headers?.from || '',
|
||||
date: email.headers?.date || email.internalDate || '',
|
||||
})
|
||||
}
|
||||
} else if (account.provider === 'outlook') {
|
||||
const outlook = await getOutlookService(account.accessToken)
|
||||
const filter = `isRead eq true and receivedDateTime lt ${cutoffDate.toISOString()}`
|
||||
const data = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=${Math.min(cap, 500)}`)
|
||||
for (const message of data.value || []) {
|
||||
out.push({
|
||||
id: message.id,
|
||||
subject: message.subject || '',
|
||||
from: message.from?.emailAddress?.address || '',
|
||||
date: message.receivedDateTime || '',
|
||||
})
|
||||
if (out.length >= cap) break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('listReadCleanupPreviewMessages failed', { error: err.message })
|
||||
}
|
||||
return out.slice(0, cap)
|
||||
}
|
||||
|
||||
async function listPromotionCleanupPreviewMessages(account, deleteAfterDays, matchCategories, cap) {
|
||||
const out = []
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - deleteAfterDays)
|
||||
const before = `${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`
|
||||
|
||||
try {
|
||||
if (account.provider === 'gmail' && matchCategories.length > 0) {
|
||||
const gmail = await getGmailService(account.accessToken, account.refreshToken)
|
||||
const labelQueries = matchCategories.map((cat) => `label:MailFlow/${cat}`).join(' OR ')
|
||||
const query = `(${labelQueries}) before:${before}`
|
||||
const response = await gmail.gmail.users.messages.list({
|
||||
userId: 'me',
|
||||
q: query,
|
||||
maxResults: Math.min(cap, 500),
|
||||
})
|
||||
const ids = (response.data.messages || []).map((m) => m.id).slice(0, cap)
|
||||
const emails = await gmail.batchGetEmails(ids)
|
||||
for (const email of emails) {
|
||||
out.push({
|
||||
id: email.id,
|
||||
subject: email.headers?.subject || '',
|
||||
from: email.headers?.from || '',
|
||||
date: email.headers?.date || email.internalDate || '',
|
||||
})
|
||||
}
|
||||
} else if (account.provider === 'outlook' && cap > 0) {
|
||||
const outlook = await getOutlookService(account.accessToken)
|
||||
const filter = `receivedDateTime lt ${cutoffDate.toISOString()}`
|
||||
const data = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=${Math.min(cap, 500)}`)
|
||||
for (const message of data.value || []) {
|
||||
const cats = message.categories || []
|
||||
const match = matchCategories.length === 0 || cats.some((c) => matchCategories.includes(c))
|
||||
if (!match) continue
|
||||
out.push({
|
||||
id: message.id,
|
||||
subject: message.subject || '',
|
||||
from: message.from?.emailAddress?.address || '',
|
||||
date: message.receivedDateTime || '',
|
||||
})
|
||||
if (out.length >= cap) break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('listPromotionCleanupPreviewMessages failed', { error: err.message })
|
||||
}
|
||||
return out.slice(0, cap)
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
Reference in New Issue
Block a user