Kraft102 Claude Opus 4.5 commited on
Commit
247104f
·
1 Parent(s): fdb57f3

feat: add CloudMailin webhook for cloud email receiving

Browse files

- POST /api/email/webhook/cloudmailin receives emails via HTTPS
- Stores emails in memory cache (max 50)
- Emits real-time events on new email
- Removes mock data fallback completely
- Updates status endpoint to show CloudMailin status

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Files changed (1) hide show
  1. apps/backend/src/routes/email.ts +118 -47
apps/backend/src/routes/email.ts CHANGED
@@ -2,9 +2,10 @@
2
  * Email API - Cloud-compatible email fetching
3
  *
4
  * Provides REST endpoints for email retrieval using:
5
- * 1. Microsoft Graph API (recommended for cloud - uses HTTPS)
6
- * 2. IMAP with App Passwords (Outlook/Gmail - may be blocked in cloud)
7
- * 3. Mock data for demo purposes
 
8
  */
9
 
10
  import { Router, Request, Response } from 'express';
@@ -16,11 +17,15 @@ import { simpleParser } from 'mailparser';
16
  const router = Router();
17
  const log = logger.child({ module: 'email-api' });
18
 
19
- // In-memory cache
20
  let emailCache: any[] = [];
21
  let lastFetchTime: Date | null = null;
22
  let lastError: string | null = null;
23
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
 
 
 
 
24
 
25
  interface EmailMessage {
26
  id: string;
@@ -266,50 +271,22 @@ async function fetchEmails(forceRefresh = false): Promise<{
266
  }
267
  }
268
 
269
- // Return mock data as fallback
270
- log.info('Using mock email data');
271
- const mockEmails: EmailMessage[] = [
272
- {
273
- id: 'mock-1',
274
- subject: 'Quarterly Report Ready',
275
- sender: 'analytics@company.dk',
276
- content: 'The Q4 analytics report is ready for review...',
277
- timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
278
- metadata: { isRead: false, importance: 'high', provider: 'mock' },
279
- },
280
- {
281
- id: 'mock-2',
282
- subject: 'Meeting Reminder: Strategy Session',
283
- sender: 'calendar@company.dk',
284
- content: 'Reminder: Strategy session tomorrow at 10:00...',
285
- timestamp: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(),
286
- metadata: { isRead: false, importance: 'normal', provider: 'mock' },
287
- },
288
- {
289
- id: 'mock-3',
290
- subject: 'Security Alert: New Login',
291
- sender: 'security@company.dk',
292
- content: 'A new login was detected from Copenhagen...',
293
- timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(),
294
- metadata: { isRead: true, importance: 'high', provider: 'mock' },
295
- },
296
- {
297
- id: 'mock-4',
298
- subject: 'Weekly Newsletter',
299
- sender: 'news@industry.com',
300
- content: 'This week in technology: AI advances...',
301
- timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
302
- metadata: { isRead: true, importance: 'normal', provider: 'mock' },
303
- },
304
- ];
305
-
306
- emailCache = mockEmails;
307
- lastFetchTime = new Date();
308
 
 
 
309
  return {
310
- emails: mockEmails,
311
- source: 'mock',
312
- unreadCount: mockEmails.filter(e => !e.metadata?.isRead).length,
313
  };
314
  }
315
 
@@ -352,23 +329,27 @@ router.get('/inbox', async (req: Request, res: Response) => {
352
  * Get email service status
353
  */
354
  router.get('/status', (_req: Request, res: Response) => {
 
355
  const hasGraph = !!process.env.MS_GRAPH_ACCESS_TOKEN;
356
  const hasOutlook = !!(process.env.OUTLOOK_EMAIL && process.env.OUTLOOK_APP_PASSWORD);
357
  const hasGmail = !!(process.env.GMAIL_EMAIL && process.env.GMAIL_APP_PASSWORD);
358
 
359
  res.json({
360
- configured: hasGraph || hasOutlook || hasGmail,
361
  providers: {
 
362
  microsoftGraph: hasGraph,
363
  outlook: hasOutlook,
364
  gmail: hasGmail,
365
  },
 
366
  cacheStatus: {
367
  count: emailCache.length,
368
  lastFetch: lastFetchTime?.toISOString() || null,
369
  ttlMs: CACHE_TTL_MS,
370
  },
371
  debug: {
 
372
  graphToken: hasGraph ? 'configured' : null,
373
  outlookEmail: process.env.OUTLOOK_EMAIL ? `${process.env.OUTLOOK_EMAIL.substring(0, 3)}***` : null,
374
  lastError: lastError,
@@ -397,4 +378,94 @@ router.post('/refresh', async (_req: Request, res: Response) => {
397
  }
398
  });
399
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  export default router;
 
2
  * Email API - Cloud-compatible email fetching
3
  *
4
  * Provides REST endpoints for email retrieval using:
5
+ * 1. CloudMailin webhook (recommended for cloud - receives emails via HTTPS POST)
6
+ * 2. Microsoft Graph API (uses HTTPS)
7
+ * 3. IMAP with App Passwords (Outlook/Gmail - may be blocked in cloud)
8
+ * 4. Mock data for demo purposes
9
  */
10
 
11
  import { Router, Request, Response } from 'express';
 
17
  const router = Router();
18
  const log = logger.child({ module: 'email-api' });
19
 
20
+ // In-memory cache for CloudMailin webhook emails
21
  let emailCache: any[] = [];
22
  let lastFetchTime: Date | null = null;
23
  let lastError: string | null = null;
24
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
25
+ const MAX_CACHED_EMAILS = 50; // Keep last 50 emails
26
+
27
+ // CloudMailin webhook secret for validation
28
+ const CLOUDMAILIN_SECRET = process.env.CLOUDMAILIN_SECRET || '';
29
 
30
  interface EmailMessage {
31
  id: string;
 
271
  }
272
  }
273
 
274
+ // Return CloudMailin emails from cache if available
275
+ if (emailCache.length > 0) {
276
+ log.info(`Returning ${emailCache.length} emails from CloudMailin cache`);
277
+ return {
278
+ emails: emailCache,
279
+ source: 'cloudmailin-cache',
280
+ unreadCount: emailCache.filter(e => !e.metadata?.isRead).length,
281
+ };
282
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
284
+ // No emails available - CloudMailin webhook not yet received any
285
+ log.info('No emails in cache. Waiting for CloudMailin webhook.');
286
  return {
287
+ emails: [],
288
+ source: 'none',
289
+ unreadCount: 0,
290
  };
291
  }
292
 
 
329
  * Get email service status
330
  */
331
  router.get('/status', (_req: Request, res: Response) => {
332
+ const hasCloudMailin = emailCache.some(e => e.metadata?.provider === 'cloudmailin');
333
  const hasGraph = !!process.env.MS_GRAPH_ACCESS_TOKEN;
334
  const hasOutlook = !!(process.env.OUTLOOK_EMAIL && process.env.OUTLOOK_APP_PASSWORD);
335
  const hasGmail = !!(process.env.GMAIL_EMAIL && process.env.GMAIL_APP_PASSWORD);
336
 
337
  res.json({
338
+ configured: hasCloudMailin || hasGraph || hasOutlook || hasGmail,
339
  providers: {
340
+ cloudmailin: hasCloudMailin,
341
  microsoftGraph: hasGraph,
342
  outlook: hasOutlook,
343
  gmail: hasGmail,
344
  },
345
+ webhookUrl: '/api/email/webhook/cloudmailin',
346
  cacheStatus: {
347
  count: emailCache.length,
348
  lastFetch: lastFetchTime?.toISOString() || null,
349
  ttlMs: CACHE_TTL_MS,
350
  },
351
  debug: {
352
+ cloudmailinEmails: emailCache.filter(e => e.metadata?.provider === 'cloudmailin').length,
353
  graphToken: hasGraph ? 'configured' : null,
354
  outlookEmail: process.env.OUTLOOK_EMAIL ? `${process.env.OUTLOOK_EMAIL.substring(0, 3)}***` : null,
355
  lastError: lastError,
 
378
  }
379
  });
380
 
381
+ /**
382
+ * POST /api/email/webhook/cloudmailin
383
+ * CloudMailin webhook - receives emails via HTTPS POST
384
+ * Set this URL in CloudMailin dashboard as your webhook target
385
+ */
386
+ router.post('/webhook/cloudmailin', (req: Request, res: Response) => {
387
+ try {
388
+ // Optional: Validate webhook secret via Basic Auth or header
389
+ const authHeader = req.headers.authorization;
390
+ if (CLOUDMAILIN_SECRET && authHeader) {
391
+ const expectedAuth = `Basic ${Buffer.from(`:${CLOUDMAILIN_SECRET}`).toString('base64')}`;
392
+ if (authHeader !== expectedAuth) {
393
+ log.warn('CloudMailin webhook: Invalid authorization');
394
+ return res.status(401).json({ error: 'Unauthorized' });
395
+ }
396
+ }
397
+
398
+ const payload = req.body;
399
+ log.info('CloudMailin webhook received email', {
400
+ subject: payload.headers?.subject,
401
+ from: payload.envelope?.from
402
+ });
403
+
404
+ // Parse CloudMailin JSON normalized format
405
+ const email: EmailMessage = {
406
+ id: `cloudmailin-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
407
+ subject: payload.headers?.subject || '(No Subject)',
408
+ sender: payload.headers?.from || payload.envelope?.from || 'Unknown',
409
+ content: payload.plain || payload.html?.replace(/<[^>]*>/g, '').substring(0, 500) || '',
410
+ timestamp: payload.headers?.date ? new Date(payload.headers.date).toISOString() : new Date().toISOString(),
411
+ metadata: {
412
+ isRead: false,
413
+ importance: 'normal',
414
+ provider: 'cloudmailin',
415
+ },
416
+ };
417
+
418
+ // Add to cache (newest first)
419
+ emailCache.unshift(email);
420
+
421
+ // Limit cache size
422
+ if (emailCache.length > MAX_CACHED_EMAILS) {
423
+ emailCache = emailCache.slice(0, MAX_CACHED_EMAILS);
424
+ }
425
+
426
+ lastFetchTime = new Date();
427
+ lastError = null;
428
+
429
+ // Emit event for real-time updates
430
+ eventBus.emit('email:new', {
431
+ type: 'email:new',
432
+ timestamp: new Date().toISOString(),
433
+ source: 'cloudmailin',
434
+ payload: {
435
+ id: email.id,
436
+ subject: email.subject,
437
+ sender: email.sender,
438
+ count: emailCache.length
439
+ },
440
+ });
441
+
442
+ log.info(`CloudMailin: Email stored. Cache now has ${emailCache.length} emails`);
443
+
444
+ // CloudMailin expects 2xx response
445
+ res.status(200).json({
446
+ success: true,
447
+ message: 'Email received',
448
+ id: email.id
449
+ });
450
+ } catch (error: any) {
451
+ log.error('CloudMailin webhook error:', error);
452
+ res.status(500).json({
453
+ success: false,
454
+ error: error.message || 'Failed to process email',
455
+ });
456
+ }
457
+ });
458
+
459
+ /**
460
+ * DELETE /api/email/cache
461
+ * Clear email cache (for testing)
462
+ */
463
+ router.delete('/cache', (_req: Request, res: Response) => {
464
+ const count = emailCache.length;
465
+ emailCache = [];
466
+ lastFetchTime = null;
467
+ log.info(`Email cache cleared. Removed ${count} emails`);
468
+ res.json({ success: true, message: `Cleared ${count} emails from cache` });
469
+ });
470
+
471
  export default router;