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

@@ -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(', ')}`)

View File

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

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

View File

@@ -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')
}

View File

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