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:
@@ -8,9 +8,12 @@ import { asyncHandler, ValidationError } from '../middleware/errorHandler.mjs'
|
||||
import { respond } from '../utils/response.mjs'
|
||||
import { db, Collections } from '../services/database.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
import { requireAuth } from '../middleware/auth.mjs'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.use(requireAuth)
|
||||
|
||||
// Whitelist of allowed event types
|
||||
const ALLOWED_EVENT_TYPES = [
|
||||
'page_view',
|
||||
@@ -79,7 +82,6 @@ function stripPII(metadata) {
|
||||
router.post('/track', asyncHandler(async (req, res) => {
|
||||
const {
|
||||
type,
|
||||
userId,
|
||||
tracking,
|
||||
metadata,
|
||||
timestamp,
|
||||
@@ -88,6 +90,8 @@ router.post('/track', asyncHandler(async (req, res) => {
|
||||
sessionId,
|
||||
} = req.body
|
||||
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
// Validate event type
|
||||
if (!type || !ALLOWED_EVENT_TYPES.includes(type)) {
|
||||
throw new ValidationError(`Invalid event type. Allowed: ${ALLOWED_EVENT_TYPES.join(', ')}`)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { products, questions, submissions, orders, onboardingState, emailAccount
|
||||
import Stripe from 'stripe'
|
||||
import { config } from '../config/index.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
import { requireAuth } from '../middleware/auth.mjs'
|
||||
|
||||
const router = express.Router()
|
||||
const stripe = new Stripe(config.stripe.secretKey)
|
||||
@@ -177,13 +178,9 @@ router.get('/config', (req, res) => {
|
||||
* Get current onboarding state
|
||||
*/
|
||||
router.get('/onboarding/status',
|
||||
validate({
|
||||
query: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
const userId = req.appwriteUser.id
|
||||
const state = await onboardingState.getByUser(userId)
|
||||
respond.success(res, state)
|
||||
})
|
||||
@@ -194,15 +191,16 @@ router.get('/onboarding/status',
|
||||
* Update onboarding step progress
|
||||
*/
|
||||
router.post('/onboarding/step',
|
||||
requireAuth,
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
step: [rules.required('step')],
|
||||
completedSteps: [rules.isArray('completedSteps')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId, step, completedSteps = [] } = req.body
|
||||
const userId = req.appwriteUser.id
|
||||
const { step, completedSteps = [] } = req.body
|
||||
await onboardingState.updateStep(userId, step, completedSteps)
|
||||
respond.success(res, { step, completedSteps })
|
||||
})
|
||||
@@ -213,13 +211,9 @@ router.post('/onboarding/step',
|
||||
* Skip onboarding
|
||||
*/
|
||||
router.post('/onboarding/skip',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body
|
||||
const userId = req.appwriteUser.id
|
||||
await onboardingState.skip(userId)
|
||||
respond.success(res, { skipped: true })
|
||||
})
|
||||
@@ -230,13 +224,9 @@ router.post('/onboarding/skip',
|
||||
* Resume onboarding
|
||||
*/
|
||||
router.post('/onboarding/resume',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body
|
||||
const userId = req.appwriteUser.id
|
||||
await onboardingState.resume(userId)
|
||||
const state = await onboardingState.getByUser(userId)
|
||||
respond.success(res, state)
|
||||
@@ -248,13 +238,9 @@ router.post('/onboarding/resume',
|
||||
* Delete all user data and account
|
||||
*/
|
||||
router.delete('/account/delete',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
log.info(`Account deletion requested for user ${userId}`)
|
||||
|
||||
@@ -301,7 +287,7 @@ router.delete('/account/delete',
|
||||
}
|
||||
|
||||
// Delete subscription
|
||||
const subscription = await subscriptions.getByUser(userId)
|
||||
const subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||
if (subscription && subscription.$id) {
|
||||
try {
|
||||
await db.delete(Collections.SUBSCRIPTIONS, subscription.$id)
|
||||
@@ -344,13 +330,9 @@ router.delete('/account/delete',
|
||||
* Get or create referral code for user
|
||||
*/
|
||||
router.get('/referrals/code',
|
||||
validate({
|
||||
query: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
const userId = req.appwriteUser.id
|
||||
const referral = await referrals.getOrCreateCode(userId)
|
||||
respond.success(res, {
|
||||
referralCode: referral.referralCode,
|
||||
@@ -364,14 +346,15 @@ router.get('/referrals/code',
|
||||
* Track a referral (when new user signs up with referral code)
|
||||
*/
|
||||
router.post('/referrals/track',
|
||||
requireAuth,
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
referralCode: [rules.required('referralCode')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId, referralCode } = req.body
|
||||
const userId = req.appwriteUser.id
|
||||
const { referralCode } = req.body
|
||||
|
||||
// Find referrer by code
|
||||
const referrer = await referrals.getByCode(referralCode)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,14 +6,26 @@
|
||||
import express from 'express'
|
||||
import { OAuth2Client } from 'google-auth-library'
|
||||
import { ConfidentialClientApplication } from '@azure/msal-node'
|
||||
import { asyncHandler, ValidationError, AppError } from '../middleware/errorHandler.mjs'
|
||||
import { asyncHandler, ValidationError, AppError, AuthorizationError } from '../middleware/errorHandler.mjs'
|
||||
import { respond } from '../utils/response.mjs'
|
||||
import { emailAccounts } from '../services/database.mjs'
|
||||
import { config, features } from '../config/index.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
import { requireAuth } from '../middleware/auth.mjs'
|
||||
import { buildOAuthState, parseOAuthState } from '../utils/oauth-state.mjs'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function requireAuthUnlessOAuthPublic(req, res, next) {
|
||||
const p = req.path || ''
|
||||
if (['/gmail/callback', '/outlook/callback', '/status'].includes(p)) {
|
||||
return next()
|
||||
}
|
||||
return requireAuth(req, res, next)
|
||||
}
|
||||
|
||||
router.use(requireAuthUnlessOAuthPublic)
|
||||
|
||||
// Google OAuth client (lazy initialization)
|
||||
let googleClient = null
|
||||
|
||||
@@ -71,12 +83,6 @@ const OUTLOOK_SCOPES = [
|
||||
* Initiate Gmail OAuth flow
|
||||
*/
|
||||
router.get('/gmail/connect', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId ist erforderlich')
|
||||
}
|
||||
|
||||
if (!features.gmail()) {
|
||||
throw new AppError('Gmail OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED')
|
||||
}
|
||||
@@ -86,7 +92,7 @@ router.get('/gmail/connect', asyncHandler(async (req, res) => {
|
||||
access_type: 'offline',
|
||||
scope: GMAIL_SCOPES,
|
||||
prompt: 'consent',
|
||||
state: JSON.stringify({ userId }),
|
||||
state: buildOAuthState(req.appwriteUser.id),
|
||||
include_granted_scopes: true,
|
||||
})
|
||||
|
||||
@@ -118,10 +124,10 @@ router.get('/gmail/callback', asyncHandler(async (req, res) => {
|
||||
|
||||
let userId
|
||||
try {
|
||||
const stateData = JSON.parse(state)
|
||||
const stateData = parseOAuthState(state)
|
||||
userId = stateData.userId
|
||||
} catch (e) {
|
||||
log.error('Gmail OAuth: State konnte nicht geparst werden', { state })
|
||||
log.error('Gmail OAuth: State konnte nicht geparst werden', { state, error: e.message })
|
||||
return res.redirect(`${config.frontendUrl}/settings?error=invalid_state`)
|
||||
}
|
||||
|
||||
@@ -214,6 +220,10 @@ router.post('/gmail/refresh', asyncHandler(async (req, res) => {
|
||||
|
||||
const account = await emailAccounts.get(accountId)
|
||||
|
||||
if (account.userId !== req.appwriteUser.id) {
|
||||
throw new AuthorizationError('No permission for this account')
|
||||
}
|
||||
|
||||
if (account.provider !== 'gmail') {
|
||||
throw new ValidationError('Kein Gmail-Konto')
|
||||
}
|
||||
@@ -249,12 +259,6 @@ router.post('/gmail/refresh', asyncHandler(async (req, res) => {
|
||||
* Initiate Outlook OAuth flow
|
||||
*/
|
||||
router.get('/outlook/connect', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId ist erforderlich')
|
||||
}
|
||||
|
||||
if (!features.outlook()) {
|
||||
throw new AppError('Outlook OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED')
|
||||
}
|
||||
@@ -263,7 +267,7 @@ router.get('/outlook/connect', asyncHandler(async (req, res) => {
|
||||
const authUrl = await client.getAuthCodeUrl({
|
||||
scopes: OUTLOOK_SCOPES,
|
||||
redirectUri: config.microsoft.redirectUri,
|
||||
state: JSON.stringify({ userId }),
|
||||
state: buildOAuthState(req.appwriteUser.id),
|
||||
prompt: 'select_account',
|
||||
})
|
||||
|
||||
@@ -286,7 +290,14 @@ router.get('/outlook/callback', asyncHandler(async (req, res) => {
|
||||
throw new ValidationError('Code und State sind erforderlich')
|
||||
}
|
||||
|
||||
const { userId } = JSON.parse(state)
|
||||
let userId
|
||||
try {
|
||||
userId = parseOAuthState(state).userId
|
||||
} catch (e) {
|
||||
log.error('Outlook OAuth: invalid state', { error: e.message })
|
||||
return respond.redirect(res, `${config.frontendUrl}/settings?error=invalid_state`)
|
||||
}
|
||||
|
||||
const client = getMsalClient()
|
||||
|
||||
// Exchange code for tokens
|
||||
@@ -334,6 +345,10 @@ router.post('/outlook/refresh', asyncHandler(async (req, res) => {
|
||||
|
||||
const account = await emailAccounts.get(accountId)
|
||||
|
||||
if (account.userId !== req.appwriteUser.id) {
|
||||
throw new AuthorizationError('No permission for this account')
|
||||
}
|
||||
|
||||
if (account.provider !== 'outlook') {
|
||||
throw new ValidationError('Kein Outlook-Konto')
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import express from 'express'
|
||||
import Stripe from 'stripe'
|
||||
import { Client, Users } from 'node-appwrite'
|
||||
import { asyncHandler, ValidationError, NotFoundError } from '../middleware/errorHandler.mjs'
|
||||
import { validate, rules } from '../middleware/validate.mjs'
|
||||
import { limiters } from '../middleware/rateLimit.mjs'
|
||||
@@ -12,13 +13,55 @@ import { respond } from '../utils/response.mjs'
|
||||
import { subscriptions, submissions } from '../services/database.mjs'
|
||||
import { config } from '../config/index.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
import { requireAuth } from '../middleware/auth.mjs'
|
||||
import { loadEmailTemplate, renderTemplate, sendPlainEmail } from '../utils/mailer.mjs'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
async function resolveUserEmail(userId, stripeCustomerId) {
|
||||
if (userId) {
|
||||
try {
|
||||
const c = new Client()
|
||||
.setEndpoint(config.appwrite.endpoint)
|
||||
.setProject(config.appwrite.projectId)
|
||||
.setKey(config.appwrite.apiKey)
|
||||
const u = await new Users(c).get(userId)
|
||||
if (u.email) return u.email
|
||||
} catch (e) {
|
||||
log.warn('Appwrite Users.get failed', { userId, error: e.message })
|
||||
}
|
||||
}
|
||||
if (stripeCustomerId) {
|
||||
try {
|
||||
const cust = await stripe.customers.retrieve(String(stripeCustomerId))
|
||||
if (cust && !cust.deleted && cust.email) return cust.email
|
||||
} catch (e) {
|
||||
log.warn('Stripe customer retrieve failed', { error: e.message })
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
const stripe = new Stripe(config.stripe.secretKey)
|
||||
|
||||
function requireAuthUnlessStripeWebhook(req, res, next) {
|
||||
if (req.path === '/webhook' && req.method === 'POST') {
|
||||
return next()
|
||||
}
|
||||
return requireAuth(req, res, next)
|
||||
}
|
||||
|
||||
router.use(requireAuthUnlessStripeWebhook)
|
||||
|
||||
/**
|
||||
* Plan configuration
|
||||
*/
|
||||
const PLAN_DISPLAY_NAMES = {
|
||||
basic: 'Basic',
|
||||
pro: 'Pro',
|
||||
business: 'Business',
|
||||
free: 'Free',
|
||||
}
|
||||
|
||||
const PLANS = {
|
||||
basic: {
|
||||
name: 'Basic',
|
||||
@@ -63,12 +106,12 @@ router.post('/checkout',
|
||||
limiters.auth,
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
plan: [rules.required('plan'), rules.isIn('plan', ['basic', 'pro', 'business'])],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId, plan, email } = req.body
|
||||
const userId = req.appwriteUser.id
|
||||
const { plan, email } = req.body
|
||||
|
||||
const planConfig = PLANS[plan]
|
||||
if (!planConfig) {
|
||||
@@ -76,7 +119,7 @@ router.post('/checkout',
|
||||
}
|
||||
|
||||
// Check for existing subscription
|
||||
const existing = await subscriptions.getByUser(userId)
|
||||
const existing = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||
let customerId = existing?.stripeCustomerId
|
||||
|
||||
// Create checkout session
|
||||
@@ -124,31 +167,26 @@ router.post('/checkout',
|
||||
* Get user's subscription status
|
||||
*/
|
||||
router.get('/status', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId ist erforderlich')
|
||||
}
|
||||
|
||||
const sub = await subscriptions.getByUser(userId)
|
||||
|
||||
if (!sub) {
|
||||
// No subscription - return trial info
|
||||
return respond.success(res, {
|
||||
status: 'trial',
|
||||
plan: 'pro',
|
||||
features: PLANS.pro.features,
|
||||
trialEndsAt: null, // Would calculate from user creation date
|
||||
cancelAtPeriodEnd: false,
|
||||
})
|
||||
}
|
||||
const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||
const topKey = config.topSubscriptionPlan
|
||||
const plan = sub.plan || topKey
|
||||
const features =
|
||||
PLANS[plan]?.features ||
|
||||
PLANS[topKey]?.features ||
|
||||
PLANS.business.features
|
||||
|
||||
respond.success(res, {
|
||||
status: sub.status,
|
||||
plan: sub.plan,
|
||||
features: PLANS[sub.plan]?.features || PLANS.basic.features,
|
||||
status: sub.status || 'active',
|
||||
plan,
|
||||
planDisplayName: PLAN_DISPLAY_NAMES[plan] || PLAN_DISPLAY_NAMES[topKey] || 'Business',
|
||||
isFreeTier: Boolean(sub.isFreeTier),
|
||||
emailsUsedThisMonth: sub.emailsUsedThisMonth ?? 0,
|
||||
emailsLimit: sub.emailsLimit ?? -1,
|
||||
features,
|
||||
currentPeriodEnd: sub.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: sub.cancelAtPeriodEnd || false,
|
||||
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -157,15 +195,10 @@ router.get('/status', asyncHandler(async (req, res) => {
|
||||
* Create Stripe Customer Portal session
|
||||
*/
|
||||
router.post('/portal',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
const sub = await subscriptions.getByUser(userId)
|
||||
const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||
|
||||
if (!sub?.stripeCustomerId) {
|
||||
throw new NotFoundError('Subscription')
|
||||
@@ -185,15 +218,10 @@ router.post('/portal',
|
||||
* Cancel subscription at period end
|
||||
*/
|
||||
router.post('/cancel',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
const sub = await subscriptions.getByUser(userId)
|
||||
const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||
|
||||
if (!sub?.stripeSubscriptionId) {
|
||||
throw new NotFoundError('Subscription')
|
||||
@@ -216,15 +244,10 @@ router.post('/cancel',
|
||||
* Reactivate cancelled subscription
|
||||
*/
|
||||
router.post('/reactivate',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
const sub = await subscriptions.getByUser(userId)
|
||||
const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
||||
|
||||
if (!sub?.stripeSubscriptionId) {
|
||||
throw new NotFoundError('Subscription')
|
||||
@@ -304,6 +327,29 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(
|
||||
})
|
||||
|
||||
log.info(`Subscription aktualisiert: ${subscription.id}`)
|
||||
|
||||
try {
|
||||
const to = await resolveUserEmail(sub.userId, subscription.customer)
|
||||
if (to) {
|
||||
const plan = subscription.metadata?.plan || sub.plan || 'current'
|
||||
const periodEnd = subscription.current_period_end
|
||||
? new Date(subscription.current_period_end * 1000).toISOString()
|
||||
: ''
|
||||
const tpl = loadEmailTemplate('subscription-updated')
|
||||
const text = renderTemplate(tpl, {
|
||||
plan: String(plan),
|
||||
status: String(subscription.status || ''),
|
||||
periodEndLine: periodEnd ? `Current period ends: ${periodEnd}` : '',
|
||||
})
|
||||
await sendPlainEmail({
|
||||
to,
|
||||
subject: 'MailFlow — Subscription updated',
|
||||
text,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('subscription.updated email skipped', { error: e.message })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -318,6 +364,23 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(
|
||||
})
|
||||
|
||||
log.info(`Subscription gelöscht: ${subscription.id}`)
|
||||
|
||||
try {
|
||||
const to = await resolveUserEmail(sub.userId, subscription.customer)
|
||||
if (to) {
|
||||
const tpl = loadEmailTemplate('subscription-ended')
|
||||
const text = renderTemplate(tpl, {
|
||||
endedDate: new Date().toISOString(),
|
||||
})
|
||||
await sendPlainEmail({
|
||||
to,
|
||||
subject: 'MailFlow — Your subscription has ended',
|
||||
text,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('subscription.deleted email skipped', { error: e.message })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -327,7 +390,27 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(
|
||||
log.warn(`Zahlung fehlgeschlagen: ${invoice.id}`, {
|
||||
customer: invoice.customer,
|
||||
})
|
||||
// TODO: Send notification email
|
||||
try {
|
||||
let metaUserId
|
||||
if (invoice.subscription) {
|
||||
const subStripe = await stripe.subscriptions.retrieve(invoice.subscription)
|
||||
metaUserId = subStripe.metadata?.userId
|
||||
}
|
||||
const to = await resolveUserEmail(metaUserId, invoice.customer)
|
||||
if (to) {
|
||||
const tpl = loadEmailTemplate('payment-failed')
|
||||
const text = renderTemplate(tpl, {
|
||||
invoiceId: String(invoice.id || ''),
|
||||
})
|
||||
await sendPlainEmail({
|
||||
to,
|
||||
subject: 'MailFlow — Payment failed, please update billing',
|
||||
text,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('invoice.payment_failed email skipped', { error: e.message })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user