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:
2026-04-03 00:23:01 +02:00
parent 61008b63bb
commit ecae89a79d
33 changed files with 1663 additions and 550 deletions

View File

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