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:
@@ -76,6 +76,9 @@ export const config = {
|
||||
autoSchedule: false, // manual only
|
||||
},
|
||||
|
||||
/** Highest product tier (admin comped plan, PLANS key in stripe.mjs). Optional env: TOP_SUBSCRIPTION_PLAN */
|
||||
topSubscriptionPlan: (process.env.TOP_SUBSCRIPTION_PLAN || 'business').trim().toLowerCase(),
|
||||
|
||||
// Admin: comma-separated list of emails with admin rights (e.g. support)
|
||||
adminEmails: (process.env.ADMIN_EMAILS || '')
|
||||
.split(',')
|
||||
@@ -87,6 +90,9 @@ export const config = {
|
||||
webhookSecret: process.env.GITEA_WEBHOOK_SECRET || '',
|
||||
webhookAuthToken: process.env.GITEA_WEBHOOK_AUTH_TOKEN || process.env.GITEA_WEBHOOK_SECRET || '',
|
||||
},
|
||||
|
||||
/** HMAC secret for Gmail/Outlook OAuth state (recommended in production) */
|
||||
oauthStateSecret: process.env.OAUTH_STATE_SECRET || '',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
7
server/emails/payment-failed.txt
Normal file
7
server/emails/payment-failed.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Hello,
|
||||
|
||||
We could not process your latest MailFlow payment (invoice {{invoiceId}}).
|
||||
|
||||
Please update your payment method in the billing portal to keep your subscription active.
|
||||
|
||||
— MailFlow
|
||||
7
server/emails/subscription-ended.txt
Normal file
7
server/emails/subscription-ended.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Hello,
|
||||
|
||||
Your MailFlow subscription has ended on {{endedDate}}.
|
||||
|
||||
You can resubscribe anytime from your account settings.
|
||||
|
||||
— MailFlow
|
||||
9
server/emails/subscription-updated.txt
Normal file
9
server/emails/subscription-updated.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
Hello,
|
||||
|
||||
Your MailFlow subscription was updated.
|
||||
|
||||
Plan: {{plan}}
|
||||
Status: {{status}}
|
||||
{{periodEndLine}}
|
||||
|
||||
— MailFlow
|
||||
140
server/index.mjs
140
server/index.mjs
@@ -11,10 +11,11 @@ import { dirname, join } from 'path'
|
||||
|
||||
// Config & Middleware
|
||||
import { config, validateConfig } from './config/index.mjs'
|
||||
import { errorHandler, asyncHandler, NotFoundError, ValidationError, AuthorizationError } from './middleware/errorHandler.mjs'
|
||||
import { errorHandler, asyncHandler, AppError, NotFoundError, ValidationError, AuthorizationError } from './middleware/errorHandler.mjs'
|
||||
import { respond } from './utils/response.mjs'
|
||||
import { logger, log } from './middleware/logger.mjs'
|
||||
import { limiters } from './middleware/rateLimit.mjs'
|
||||
import { requireAuth } from './middleware/auth.mjs'
|
||||
|
||||
// Routes
|
||||
import oauthRoutes from './routes/oauth.mjs'
|
||||
@@ -23,6 +24,7 @@ import stripeRoutes from './routes/stripe.mjs'
|
||||
import apiRoutes from './routes/api.mjs'
|
||||
import analyticsRoutes from './routes/analytics.mjs'
|
||||
import webhookRoutes from './routes/webhook.mjs'
|
||||
import { startCounterJobs } from './jobs/reset-counters.mjs'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
@@ -93,21 +95,16 @@ import { userPreferences } from './services/database.mjs'
|
||||
import { isAdmin } from './config/index.mjs'
|
||||
|
||||
/**
|
||||
* GET /api/me?email=xxx
|
||||
* Returns current user context (e.g. isAdmin) for the given email.
|
||||
* GET /api/me
|
||||
* Returns current user context (JWT). isAdmin from verified email.
|
||||
*/
|
||||
app.get('/api/me', asyncHandler(async (req, res) => {
|
||||
const { email } = req.query
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw new ValidationError('email is required')
|
||||
}
|
||||
respond.success(res, { isAdmin: isAdmin(email) })
|
||||
app.get('/api/me', requireAuth, asyncHandler(async (req, res) => {
|
||||
respond.success(res, { isAdmin: isAdmin(req.appwriteUser.email) })
|
||||
}))
|
||||
|
||||
app.get('/api/preferences', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
if (!userId) throw new ValidationError('userId ist erforderlich')
|
||||
|
||||
app.get('/api/preferences', requireAuth, asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
respond.success(res, prefs?.preferences || {
|
||||
vipSenders: [],
|
||||
@@ -117,22 +114,40 @@ app.get('/api/preferences', asyncHandler(async (req, res) => {
|
||||
})
|
||||
}))
|
||||
|
||||
app.post('/api/preferences', asyncHandler(async (req, res) => {
|
||||
const { userId, ...preferences } = req.body
|
||||
if (!userId) throw new ValidationError('userId ist erforderlich')
|
||||
|
||||
app.post('/api/preferences', requireAuth, asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
const { ...preferences } = req.body
|
||||
|
||||
await userPreferences.upsert(userId, preferences)
|
||||
respond.success(res, null, 'Einstellungen gespeichert')
|
||||
}))
|
||||
|
||||
/**
|
||||
* PATCH /api/preferences/profile
|
||||
* { displayName?, timezone?, notificationPrefs? }
|
||||
*/
|
||||
app.patch('/api/preferences/profile', requireAuth, asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
const { displayName, timezone, notificationPrefs } = req.body
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
const current = prefs?.preferences?.profile || userPreferences.getDefaults().profile
|
||||
const profile = {
|
||||
...current,
|
||||
...(displayName !== undefined && { displayName }),
|
||||
...(timezone !== undefined && { timezone }),
|
||||
...(notificationPrefs !== undefined && { notificationPrefs }),
|
||||
}
|
||||
await userPreferences.upsert(userId, { profile })
|
||||
respond.success(res, { profile }, 'Profile saved')
|
||||
}))
|
||||
|
||||
/**
|
||||
* GET /api/preferences/ai-control
|
||||
* Get AI Control settings
|
||||
*/
|
||||
app.get('/api/preferences/ai-control', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
if (!userId) throw new ValidationError('userId is required')
|
||||
|
||||
app.get('/api/preferences/ai-control', requireAuth, asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
const preferences = prefs?.preferences || userPreferences.getDefaults()
|
||||
|
||||
@@ -148,10 +163,10 @@ app.get('/api/preferences/ai-control', asyncHandler(async (req, res) => {
|
||||
* POST /api/preferences/ai-control
|
||||
* Save AI Control settings
|
||||
*/
|
||||
app.post('/api/preferences/ai-control', asyncHandler(async (req, res) => {
|
||||
const { userId, enabledCategories, categoryActions, autoDetectCompanies, cleanup } = req.body
|
||||
if (!userId) throw new ValidationError('userId is required')
|
||||
|
||||
app.post('/api/preferences/ai-control', requireAuth, asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
const { enabledCategories, categoryActions, autoDetectCompanies, cleanup } = req.body
|
||||
|
||||
const updates = {}
|
||||
if (enabledCategories !== undefined) updates.enabledCategories = enabledCategories
|
||||
if (categoryActions !== undefined) updates.categoryActions = categoryActions
|
||||
@@ -166,10 +181,9 @@ app.post('/api/preferences/ai-control', asyncHandler(async (req, res) => {
|
||||
* GET /api/preferences/company-labels
|
||||
* Get company labels
|
||||
*/
|
||||
app.get('/api/preferences/company-labels', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
if (!userId) throw new ValidationError('userId is required')
|
||||
|
||||
app.get('/api/preferences/company-labels', requireAuth, asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
const preferences = prefs?.preferences || userPreferences.getDefaults()
|
||||
|
||||
@@ -180,9 +194,9 @@ app.get('/api/preferences/company-labels', asyncHandler(async (req, res) => {
|
||||
* POST /api/preferences/company-labels
|
||||
* Save/Update company label
|
||||
*/
|
||||
app.post('/api/preferences/company-labels', asyncHandler(async (req, res) => {
|
||||
const { userId, companyLabel } = req.body
|
||||
if (!userId) throw new ValidationError('userId is required')
|
||||
app.post('/api/preferences/company-labels', requireAuth, asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
const { companyLabel } = req.body
|
||||
if (!companyLabel) throw new ValidationError('companyLabel is required')
|
||||
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
@@ -211,10 +225,9 @@ app.post('/api/preferences/company-labels', asyncHandler(async (req, res) => {
|
||||
* DELETE /api/preferences/company-labels/:id
|
||||
* Delete company label
|
||||
*/
|
||||
app.delete('/api/preferences/company-labels/:id', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
app.delete('/api/preferences/company-labels/:id', requireAuth, asyncHandler(async (req, res) => {
|
||||
const userId = req.appwriteUser.id
|
||||
const { id } = req.params
|
||||
if (!userId) throw new ValidationError('userId is required')
|
||||
if (!id) throw new ValidationError('label id is required')
|
||||
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
@@ -230,12 +243,10 @@ app.delete('/api/preferences/company-labels/:id', asyncHandler(async (req, res)
|
||||
* GET /api/preferences/name-labels
|
||||
* Get name labels (worker labels). Admin only.
|
||||
*/
|
||||
app.get('/api/preferences/name-labels', asyncHandler(async (req, res) => {
|
||||
const { userId, email } = req.query
|
||||
if (!userId) throw new ValidationError('userId is required')
|
||||
if (!email || typeof email !== 'string') throw new ValidationError('email is required')
|
||||
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels')
|
||||
app.get('/api/preferences/name-labels', requireAuth, asyncHandler(async (req, res) => {
|
||||
if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels')
|
||||
|
||||
const userId = req.appwriteUser.id
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
const preferences = prefs?.preferences || userPreferences.getDefaults()
|
||||
respond.success(res, preferences.nameLabels || [])
|
||||
@@ -245,11 +256,11 @@ app.get('/api/preferences/name-labels', asyncHandler(async (req, res) => {
|
||||
* POST /api/preferences/name-labels
|
||||
* Save/Update name label (worker). Admin only.
|
||||
*/
|
||||
app.post('/api/preferences/name-labels', asyncHandler(async (req, res) => {
|
||||
const { userId, email, nameLabel } = req.body
|
||||
if (!userId) throw new ValidationError('userId is required')
|
||||
if (!email || typeof email !== 'string') throw new ValidationError('email is required')
|
||||
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels')
|
||||
app.post('/api/preferences/name-labels', requireAuth, asyncHandler(async (req, res) => {
|
||||
if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels')
|
||||
|
||||
const userId = req.appwriteUser.id
|
||||
const { nameLabel } = req.body
|
||||
if (!nameLabel) throw new ValidationError('nameLabel is required')
|
||||
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
@@ -274,12 +285,11 @@ app.post('/api/preferences/name-labels', asyncHandler(async (req, res) => {
|
||||
* DELETE /api/preferences/name-labels/:id
|
||||
* Delete name label. Admin only.
|
||||
*/
|
||||
app.delete('/api/preferences/name-labels/:id', asyncHandler(async (req, res) => {
|
||||
const { userId, email } = req.query
|
||||
app.delete('/api/preferences/name-labels/:id', requireAuth, asyncHandler(async (req, res) => {
|
||||
if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels')
|
||||
|
||||
const userId = req.appwriteUser.id
|
||||
const { id } = req.params
|
||||
if (!userId) throw new ValidationError('userId is required')
|
||||
if (!email || typeof email !== 'string') throw new ValidationError('email is required')
|
||||
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels')
|
||||
if (!id) throw new ValidationError('label id is required')
|
||||
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
@@ -292,14 +302,33 @@ app.delete('/api/preferences/name-labels/:id', asyncHandler(async (req, res) =>
|
||||
// Legacy Stripe webhook endpoint
|
||||
app.use('/stripe', stripeRoutes)
|
||||
|
||||
// 404 handler for API routes
|
||||
app.use('/api/*', (req, res, next) => {
|
||||
// Unmatched /api → JSON 404 (Express 4 treats '/api/*' as a literal path, not a wildcard)
|
||||
app.use((req, res, next) => {
|
||||
const pathOnly = req.originalUrl.split('?')[0]
|
||||
if (!pathOnly.startsWith('/api')) {
|
||||
return next()
|
||||
}
|
||||
next(new NotFoundError('Endpoint'))
|
||||
})
|
||||
|
||||
// SPA fallback for non-API routes
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(join(__dirname, '..', 'public', 'index.html'))
|
||||
// SPA fallback: never send index.html for /api (avoids 404/HTML when public/index.html is missing)
|
||||
app.get('*', (req, res, next) => {
|
||||
const pathOnly = req.originalUrl.split('?')[0]
|
||||
if (pathOnly.startsWith('/api')) {
|
||||
return next(new NotFoundError('Endpoint'))
|
||||
}
|
||||
const indexPath = join(__dirname, '..', 'public', 'index.html')
|
||||
res.sendFile(indexPath, (err) => {
|
||||
if (err) {
|
||||
next(
|
||||
new AppError(
|
||||
'public/index.html fehlt. In Entwicklung: Frontend über Vite (z. B. http://localhost:5173) starten; für Produktion: Client-Build nach public/ legen.',
|
||||
404,
|
||||
'NOT_FOUND'
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Global error handler (must be last)
|
||||
@@ -346,6 +375,7 @@ server = app.listen(config.port, () => {
|
||||
console.log(` 🌐 API: http://localhost:${config.port}/api`)
|
||||
console.log(` 💚 Health: http://localhost:${config.port}/api/health`)
|
||||
console.log('')
|
||||
startCounterJobs()
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
39
server/jobs/reset-counters.mjs
Normal file
39
server/jobs/reset-counters.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Scheduled counter resets (UTC).
|
||||
*/
|
||||
|
||||
import cron from 'node-cron'
|
||||
import { emailStats } from '../services/database.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
|
||||
export function startCounterJobs() {
|
||||
cron.schedule(
|
||||
'0 0 * * *',
|
||||
async () => {
|
||||
const t = new Date().toISOString()
|
||||
try {
|
||||
const n = await emailStats.resetDaily()
|
||||
log.info(`[cron] resetDaily at ${t} — updated ${n} email_stats documents`)
|
||||
} catch (e) {
|
||||
log.error('[cron] resetDaily failed', { error: e.message })
|
||||
}
|
||||
},
|
||||
{ timezone: 'UTC' }
|
||||
)
|
||||
|
||||
cron.schedule(
|
||||
'0 0 1 * *',
|
||||
async () => {
|
||||
const t = new Date().toISOString()
|
||||
try {
|
||||
const n = await emailStats.resetWeekly()
|
||||
log.info(`[cron] resetWeekly at ${t} — updated ${n} email_stats documents`)
|
||||
} catch (e) {
|
||||
log.error('[cron] resetWeekly failed', { error: e.message })
|
||||
}
|
||||
},
|
||||
{ timezone: 'UTC' }
|
||||
)
|
||||
|
||||
log.success('Counter cron jobs scheduled (daily 00:00 UTC, monthly week reset 1st 00:00 UTC)')
|
||||
}
|
||||
58
server/middleware/auth.mjs
Normal file
58
server/middleware/auth.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Appwrite JWT verification for user-scoped API routes.
|
||||
*/
|
||||
|
||||
import { Client, Account } from 'node-appwrite'
|
||||
import { config } from '../config/index.mjs'
|
||||
import { AuthenticationError } from './errorHandler.mjs'
|
||||
|
||||
/**
|
||||
* Verify Authorization: Bearer <jwt> and attach Appwrite user to req.appwriteUser
|
||||
*/
|
||||
export function requireAuth(req, res, next) {
|
||||
;(async () => {
|
||||
try {
|
||||
const header = req.headers.authorization || ''
|
||||
const m = /^Bearer\s+(.+)$/i.exec(header)
|
||||
if (!m?.[1]) {
|
||||
throw new AuthenticationError('Authorization Bearer token required')
|
||||
}
|
||||
const jwt = m[1].trim()
|
||||
const client = new Client()
|
||||
.setEndpoint(config.appwrite.endpoint)
|
||||
.setProject(config.appwrite.projectId)
|
||||
.setJWT(jwt)
|
||||
|
||||
const account = new Account(client)
|
||||
const user = await account.get()
|
||||
|
||||
if (!user || !user.$id) {
|
||||
throw new AuthenticationError('Ungültige Appwrite-Sitzung')
|
||||
}
|
||||
|
||||
req.appwriteUser = {
|
||||
id: user.$id,
|
||||
email: user.email || '',
|
||||
name: user.name || '',
|
||||
}
|
||||
next()
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
next(err)
|
||||
return
|
||||
}
|
||||
next(new AuthenticationError(err.message || 'Invalid or expired session'))
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip auth for email provider inbound webhooks only.
|
||||
*/
|
||||
export function requireAuthUnlessEmailWebhook(req, res, next) {
|
||||
const p = req.path || ''
|
||||
if (p === '/webhook/gmail' || p === '/webhook/outlook') {
|
||||
return next()
|
||||
}
|
||||
return requireAuth(req, res, next)
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
* Catches all errors and returns consistent JSON responses
|
||||
*/
|
||||
|
||||
import { AppwriteException } from 'node-appwrite'
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
|
||||
super(message)
|
||||
@@ -56,11 +58,28 @@ export function errorHandler(err, req, res, next) {
|
||||
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
|
||||
})
|
||||
|
||||
// Default error values
|
||||
let statusCode = err.statusCode || 500
|
||||
let code = err.code || 'INTERNAL_ERROR'
|
||||
// Default error values (AppwriteException uses numeric err.code — do not reuse as JSON "code" string)
|
||||
let statusCode =
|
||||
typeof err.statusCode === 'number' ? err.statusCode : undefined
|
||||
let code = typeof err.code === 'string' && err.code ? err.code : 'INTERNAL_ERROR'
|
||||
let message = err.message || 'Ein Fehler ist aufgetreten'
|
||||
|
||||
if (
|
||||
err instanceof AppwriteException &&
|
||||
typeof err.code === 'number' &&
|
||||
err.code >= 400 &&
|
||||
err.code < 600
|
||||
) {
|
||||
statusCode = err.code
|
||||
code = err.type || 'APPWRITE_ERROR'
|
||||
message = err.message || message
|
||||
err.isOperational = true
|
||||
}
|
||||
|
||||
if (statusCode === undefined) {
|
||||
statusCode = 500
|
||||
}
|
||||
|
||||
// Handle specific error types
|
||||
if (err.name === 'ValidationError') {
|
||||
statusCode = 400
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { RateLimitError } from './errorHandler.mjs'
|
||||
import { isAdmin } from '../config/index.mjs'
|
||||
|
||||
// In-memory store for rate limiting (use Redis in production)
|
||||
const requestCounts = new Map()
|
||||
@@ -25,6 +26,7 @@ setInterval(() => {
|
||||
* @param {number} options.max - Max requests per window
|
||||
* @param {string} options.message - Error message
|
||||
* @param {Function} options.keyGenerator - Function to generate unique key
|
||||
* @param {Function} options.skip - If (req) => true, do not count this request
|
||||
*/
|
||||
export function rateLimit(options = {}) {
|
||||
const {
|
||||
@@ -32,9 +34,14 @@ export function rateLimit(options = {}) {
|
||||
max = 100,
|
||||
message = 'Zu viele Anfragen. Bitte versuche es später erneut.',
|
||||
keyGenerator = (req) => req.ip,
|
||||
skip = () => false,
|
||||
} = options
|
||||
|
||||
return (req, res, next) => {
|
||||
if (skip(req)) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const key = keyGenerator(req)
|
||||
const now = Date.now()
|
||||
|
||||
@@ -80,11 +87,12 @@ export const limiters = {
|
||||
message: 'Zu viele Anmeldeversuche. Bitte warte 15 Minuten.',
|
||||
}),
|
||||
|
||||
// Limit for email sorting (expensive operation)
|
||||
// Limit for email sorting (expensive operation); ADMIN_EMAILS (isAdmin) bypass
|
||||
emailSort: rateLimit({
|
||||
windowMs: 60000,
|
||||
max: 30, // Erhöht für Entwicklung
|
||||
message: 'E-Mail-Sortierung ist limitiert. Bitte warte eine Minute.',
|
||||
skip: (req) => isAdmin(req.appwriteUser?.email),
|
||||
}),
|
||||
|
||||
// Limit for AI operations
|
||||
|
||||
32
server/package-lock.json
generated
32
server/package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "email-sorter-server",
|
||||
"name": "mailflow-server",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "email-sorter-server",
|
||||
"name": "mailflow-server",
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -18,7 +18,9 @@
|
||||
"googleapis": "^144.0.0",
|
||||
"imapflow": "^1.2.8",
|
||||
"node-appwrite": "^14.1.0",
|
||||
"stripe": "^17.4.0"
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
"stripe": "^17.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0"
|
||||
@@ -1119,6 +1121,15 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow/node_modules/nodemailer": {
|
||||
"version": "7.0.13",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -1448,6 +1459,15 @@
|
||||
"node-fetch-native-with-agent": "1.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
@@ -1497,9 +1517,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.13",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
||||
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
||||
@@ -41,7 +41,9 @@
|
||||
"googleapis": "^144.0.0",
|
||||
"imapflow": "^1.2.8",
|
||||
"node-appwrite": "^14.1.0",
|
||||
"stripe": "^17.4.0"
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
"stripe": "^17.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Client, Databases, Query, ID } from 'node-appwrite'
|
||||
import { config } from '../config/index.mjs'
|
||||
import { config, isAdmin } from '../config/index.mjs'
|
||||
import { NotFoundError } from '../middleware/errorHandler.mjs'
|
||||
|
||||
// Initialize Appwrite client
|
||||
@@ -236,22 +236,26 @@ export const emailStats = {
|
||||
},
|
||||
|
||||
async resetDaily() {
|
||||
// Reset daily counters - would be called by a cron job
|
||||
const allStats = await db.list(Collections.EMAIL_STATS, [])
|
||||
let n = 0
|
||||
for (const stat of allStats) {
|
||||
await db.update(Collections.EMAIL_STATS, stat.$id, { todaySorted: 0 })
|
||||
n++
|
||||
}
|
||||
return n
|
||||
},
|
||||
|
||||
async resetWeekly() {
|
||||
// Reset weekly counters - would be called by a cron job
|
||||
const allStats = await db.list(Collections.EMAIL_STATS, [])
|
||||
let n = 0
|
||||
for (const stat of allStats) {
|
||||
await db.update(Collections.EMAIL_STATS, stat.$id, {
|
||||
await db.update(Collections.EMAIL_STATS, stat.$id, {
|
||||
weekSorted: 0,
|
||||
categoriesJson: '{}',
|
||||
})
|
||||
n++
|
||||
}
|
||||
return n
|
||||
},
|
||||
}
|
||||
|
||||
@@ -299,42 +303,60 @@ export const emailUsage = {
|
||||
* Subscriptions operations
|
||||
*/
|
||||
export const subscriptions = {
|
||||
async getByUser(userId) {
|
||||
/**
|
||||
* @param {string} userId
|
||||
* @param {string|null} [viewerEmail] - if set and isAdmin(email), effective plan is business (highest tier)
|
||||
*/
|
||||
async getByUser(userId, viewerEmail = null) {
|
||||
const subscription = await db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
|
||||
|
||||
|
||||
let result
|
||||
|
||||
// If no subscription, user is on free tier
|
||||
if (!subscription) {
|
||||
const usage = await emailUsage.getUsage(userId)
|
||||
return {
|
||||
result = {
|
||||
plan: 'free',
|
||||
status: 'active',
|
||||
isFreeTier: true,
|
||||
emailsUsedThisMonth: usage.emailsProcessed,
|
||||
emailsLimit: 500, // From config
|
||||
}
|
||||
} else {
|
||||
// Check if subscription is active
|
||||
const isActive = subscription.status === 'active'
|
||||
const isFreeTier = !isActive || subscription.plan === 'free'
|
||||
|
||||
// Get usage for free tier users
|
||||
let emailsUsedThisMonth = 0
|
||||
let emailsLimit = -1 // Unlimited for paid
|
||||
|
||||
if (isFreeTier) {
|
||||
const usage = await emailUsage.getUsage(userId)
|
||||
emailsUsedThisMonth = usage.emailsProcessed
|
||||
emailsLimit = 500 // From config
|
||||
}
|
||||
|
||||
result = {
|
||||
...subscription,
|
||||
plan: subscription.plan || 'free',
|
||||
isFreeTier,
|
||||
emailsUsedThisMonth,
|
||||
emailsLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if subscription is active
|
||||
const isActive = subscription.status === 'active'
|
||||
const isFreeTier = !isActive || subscription.plan === 'free'
|
||||
|
||||
// Get usage for free tier users
|
||||
let emailsUsedThisMonth = 0
|
||||
let emailsLimit = -1 // Unlimited for paid
|
||||
|
||||
if (isFreeTier) {
|
||||
const usage = await emailUsage.getUsage(userId)
|
||||
emailsUsedThisMonth = usage.emailsProcessed
|
||||
emailsLimit = 500 // From config
|
||||
if (viewerEmail && isAdmin(viewerEmail)) {
|
||||
return {
|
||||
...result,
|
||||
plan: config.topSubscriptionPlan,
|
||||
status: 'active',
|
||||
isFreeTier: false,
|
||||
emailsLimit: -1,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...subscription,
|
||||
plan: subscription.plan || 'free',
|
||||
isFreeTier,
|
||||
emailsUsedThisMonth,
|
||||
emailsLimit,
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
async getByStripeId(stripeSubscriptionId) {
|
||||
@@ -352,8 +374,8 @@ export const subscriptions = {
|
||||
},
|
||||
|
||||
async upsertByUser(userId, data) {
|
||||
const existing = await this.getByUser(userId)
|
||||
if (existing) {
|
||||
const existing = await db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
|
||||
if (existing?.$id) {
|
||||
return this.update(existing.$id, data)
|
||||
}
|
||||
return this.create({ userId, ...data })
|
||||
@@ -377,6 +399,12 @@ export const userPreferences = {
|
||||
autoDetectCompanies: true,
|
||||
version: 1,
|
||||
categoryAdvanced: {},
|
||||
profile: {
|
||||
displayName: '',
|
||||
timezone: '',
|
||||
notificationPrefs: {},
|
||||
},
|
||||
cleanupMeta: {},
|
||||
cleanup: {
|
||||
enabled: false,
|
||||
readItems: {
|
||||
@@ -413,6 +441,9 @@ export const userPreferences = {
|
||||
companyLabels: preferences.companyLabels || defaults.companyLabels,
|
||||
nameLabels: preferences.nameLabels || defaults.nameLabels,
|
||||
autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : defaults.autoDetectCompanies,
|
||||
profile: preferences.profile != null ? { ...defaults.profile, ...preferences.profile } : defaults.profile,
|
||||
cleanupMeta:
|
||||
preferences.cleanupMeta !== undefined ? preferences.cleanupMeta : defaults.cleanupMeta,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
74
server/utils/crypto.mjs
Normal file
74
server/utils/crypto.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* AES-256-GCM for IMAP passwords. ENCRYPTION_KEY = 64 hex chars (32 bytes).
|
||||
* Legacy: if decrypt fails or key missing, value treated as plaintext.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto'
|
||||
|
||||
const ALGO = 'aes-256-gcm'
|
||||
const IV_LEN = 16
|
||||
const AUTH_TAG_LEN = 16
|
||||
|
||||
function getKeyBuffer() {
|
||||
const hex = process.env.ENCRYPTION_KEY || ''
|
||||
if (hex.length !== 64) {
|
||||
throw new Error('ENCRYPTION_KEY must be 64 hex characters (32 bytes). Generate: openssl rand -hex 32')
|
||||
}
|
||||
return Buffer.from(hex, 'hex')
|
||||
}
|
||||
|
||||
export function encrypt(text) {
|
||||
if (text == null || text === '') return ''
|
||||
const key = getKeyBuffer()
|
||||
const iv = crypto.randomBytes(IV_LEN)
|
||||
const cipher = crypto.createCipheriv(ALGO, key, iv, { authTagLength: AUTH_TAG_LEN })
|
||||
const enc = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()])
|
||||
const authTag = cipher.getAuthTag()
|
||||
const combined = Buffer.concat([iv, authTag, enc])
|
||||
return combined.toString('base64url')
|
||||
}
|
||||
|
||||
export function decrypt(encoded) {
|
||||
if (!encoded) return ''
|
||||
const buf = Buffer.from(String(encoded), 'base64url')
|
||||
if (buf.length < IV_LEN + AUTH_TAG_LEN + 1) {
|
||||
throw new Error('invalid ciphertext')
|
||||
}
|
||||
const key = getKeyBuffer()
|
||||
const iv = buf.subarray(0, IV_LEN)
|
||||
const authTag = buf.subarray(IV_LEN, IV_LEN + AUTH_TAG_LEN)
|
||||
const data = buf.subarray(IV_LEN + AUTH_TAG_LEN)
|
||||
const decipher = crypto.createDecipheriv(ALGO, key, iv, { authTagLength: AUTH_TAG_LEN })
|
||||
decipher.setAuthTag(authTag)
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
|
||||
}
|
||||
|
||||
/** Encrypt IMAP password when ENCRYPTION_KEY is set; otherwise store plaintext. */
|
||||
export function encryptImapSecret(plain) {
|
||||
if (plain == null || plain === '') return ''
|
||||
if (!process.env.ENCRYPTION_KEY) return String(plain)
|
||||
try {
|
||||
return encrypt(plain)
|
||||
} catch (e) {
|
||||
logWarnOnce('encryptImapSecret', e.message)
|
||||
return String(plain)
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt IMAP secret; on failure return as plaintext (legacy). */
|
||||
export function decryptImapSecret(stored) {
|
||||
if (stored == null || stored === '') return ''
|
||||
if (!process.env.ENCRYPTION_KEY) return String(stored)
|
||||
try {
|
||||
return decrypt(stored)
|
||||
} catch {
|
||||
return String(stored)
|
||||
}
|
||||
}
|
||||
|
||||
let warnedEncrypt = false
|
||||
function logWarnOnce(tag, msg) {
|
||||
if (warnedEncrypt) return
|
||||
warnedEncrypt = true
|
||||
console.warn(`[crypto] ${tag}: ${msg}`)
|
||||
}
|
||||
68
server/utils/mailer.mjs
Normal file
68
server/utils/mailer.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Plain SMTP mailer (nodemailer). Optional: if SMTP not configured, send is a no-op.
|
||||
*/
|
||||
|
||||
import nodemailer from 'nodemailer'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
let transporter = null
|
||||
|
||||
function getTransporter() {
|
||||
const host = process.env.SMTP_HOST
|
||||
const user = process.env.SMTP_USER
|
||||
const pass = process.env.SMTP_PASS
|
||||
if (!host || !user || !pass) {
|
||||
return null
|
||||
}
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: { user, pass },
|
||||
})
|
||||
}
|
||||
return transporter
|
||||
}
|
||||
|
||||
export function renderTemplate(text, vars) {
|
||||
let out = text
|
||||
for (const [k, v] of Object.entries(vars || {})) {
|
||||
out = out.split(`{{${k}}}`).join(v != null ? String(v) : '')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function loadEmailTemplate(name) {
|
||||
const path = join(__dirname, '..', 'emails', `${name}.txt`)
|
||||
return readFileSync(path, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Send plain-text email. Returns false if SMTP not configured or send failed (logged).
|
||||
*/
|
||||
export async function sendPlainEmail({ to, subject, text }) {
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER
|
||||
if (!to || !subject || !text) {
|
||||
log.warn('sendPlainEmail: missing to/subject/text')
|
||||
return false
|
||||
}
|
||||
const tx = getTransporter()
|
||||
if (!tx) {
|
||||
log.warn('SMTP not configured (SMTP_HOST/SMTP_USER/SMTP_PASS); email skipped')
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await tx.sendMail({ from, to, subject, text })
|
||||
log.info(`Email sent to ${to}: ${subject}`)
|
||||
return true
|
||||
} catch (e) {
|
||||
log.error('sendPlainEmail failed', { error: e.message, to })
|
||||
return false
|
||||
}
|
||||
}
|
||||
44
server/utils/oauth-state.mjs
Normal file
44
server/utils/oauth-state.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Signed OAuth state (userId) to prevent tampering when OAUTH_STATE_SECRET is set.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto'
|
||||
import { config } from '../config/index.mjs'
|
||||
|
||||
export function buildOAuthState(userId) {
|
||||
const secret = config.oauthStateSecret
|
||||
if (!secret) {
|
||||
return JSON.stringify({ userId })
|
||||
}
|
||||
const body = JSON.stringify({ userId, exp: Date.now() + 15 * 60 * 1000 })
|
||||
const sig = crypto.createHmac('sha256', secret).update(body).digest('hex')
|
||||
return Buffer.from(JSON.stringify({ b: body, s: sig })).toString('base64url')
|
||||
}
|
||||
|
||||
export function parseOAuthState(state) {
|
||||
if (!state || typeof state !== 'string') {
|
||||
throw new Error('invalid_state')
|
||||
}
|
||||
const trimmed = state.trim()
|
||||
const secret = config.oauthStateSecret
|
||||
|
||||
if (trimmed.startsWith('{')) {
|
||||
const legacy = JSON.parse(trimmed)
|
||||
if (!legacy.userId) throw new Error('invalid_state')
|
||||
if (secret) {
|
||||
throw new Error('unsigned_state_rejected')
|
||||
}
|
||||
return { userId: legacy.userId }
|
||||
}
|
||||
|
||||
const raw = Buffer.from(trimmed, 'base64url').toString('utf8')
|
||||
const outer = JSON.parse(raw)
|
||||
if (!outer.b || !outer.s) throw new Error('invalid_state')
|
||||
if (!secret) throw new Error('signed_state_requires_secret')
|
||||
const expected = crypto.createHmac('sha256', secret).update(outer.b).digest('hex')
|
||||
if (outer.s !== expected) throw new Error('invalid_state_signature')
|
||||
const payload = JSON.parse(outer.b)
|
||||
if (payload.exp != null && payload.exp < Date.now()) throw new Error('state_expired')
|
||||
if (!payload.userId) throw new Error('invalid_state')
|
||||
return { userId: payload.userId }
|
||||
}
|
||||
Reference in New Issue
Block a user