fluxmail 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +6 -4
  2. package/dist/accounts/defaultGoogleOAuth.d.ts +4 -0
  3. package/dist/accounts/defaultGoogleOAuth.d.ts.map +1 -0
  4. package/dist/accounts/defaultGoogleOAuth.js +7 -0
  5. package/dist/accounts/defaultGoogleOAuth.js.map +1 -0
  6. package/dist/accounts/gmailConnection.js +2 -2
  7. package/dist/accounts/gmailConnection.js.map +1 -1
  8. package/dist/accounts/googleAuth.d.ts +16 -9
  9. package/dist/accounts/googleAuth.d.ts.map +1 -1
  10. package/dist/accounts/googleAuth.js +52 -20
  11. package/dist/accounts/googleAuth.js.map +1 -1
  12. package/dist/accounts/microsoftAuth.d.ts +5 -2
  13. package/dist/accounts/microsoftAuth.d.ts.map +1 -1
  14. package/dist/accounts/microsoftAuth.js +25 -22
  15. package/dist/accounts/microsoftAuth.js.map +1 -1
  16. package/dist/accounts/registry.d.ts +11 -9
  17. package/dist/accounts/registry.d.ts.map +1 -1
  18. package/dist/accounts/registry.js +169 -98
  19. package/dist/accounts/registry.js.map +1 -1
  20. package/dist/auth.d.ts +106 -0
  21. package/dist/auth.d.ts.map +1 -0
  22. package/dist/auth.js +455 -0
  23. package/dist/auth.js.map +1 -0
  24. package/dist/authorization.d.ts +9 -0
  25. package/dist/authorization.d.ts.map +1 -0
  26. package/dist/authorization.js +23 -0
  27. package/dist/authorization.js.map +1 -0
  28. package/dist/cli.d.ts +15 -1
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +1096 -403
  31. package/dist/cli.js.map +1 -1
  32. package/dist/cliInstances.d.ts +51 -0
  33. package/dist/cliInstances.d.ts.map +1 -0
  34. package/dist/cliInstances.js +237 -0
  35. package/dist/cliInstances.js.map +1 -0
  36. package/dist/cliMail.d.ts +9 -0
  37. package/dist/cliMail.d.ts.map +1 -0
  38. package/dist/cliMail.js +450 -0
  39. package/dist/cliMail.js.map +1 -0
  40. package/dist/config.d.ts +95 -118
  41. package/dist/config.d.ts.map +1 -1
  42. package/dist/config.js +351 -286
  43. package/dist/config.js.map +1 -1
  44. package/dist/configMigration.d.ts +8 -0
  45. package/dist/configMigration.d.ts.map +1 -0
  46. package/dist/configMigration.js +234 -0
  47. package/dist/configMigration.js.map +1 -0
  48. package/dist/context.d.ts +8 -2
  49. package/dist/context.d.ts.map +1 -1
  50. package/dist/context.js +29 -8
  51. package/dist/context.js.map +1 -1
  52. package/dist/deploymentConfig.d.ts +190 -0
  53. package/dist/deploymentConfig.d.ts.map +1 -0
  54. package/dist/deploymentConfig.js +539 -0
  55. package/dist/deploymentConfig.js.map +1 -0
  56. package/dist/http/admin.d.ts +75 -3
  57. package/dist/http/admin.d.ts.map +1 -1
  58. package/dist/http/admin.js +347 -79
  59. package/dist/http/admin.js.map +1 -1
  60. package/dist/http/app.d.ts +4 -1
  61. package/dist/http/app.d.ts.map +1 -1
  62. package/dist/http/app.js +119 -63
  63. package/dist/http/app.js.map +1 -1
  64. package/dist/http/identity.d.ts +114 -0
  65. package/dist/http/identity.d.ts.map +1 -0
  66. package/dist/http/identity.js +936 -0
  67. package/dist/http/identity.js.map +1 -0
  68. package/dist/http/operationRoutes.d.ts +8 -0
  69. package/dist/http/operationRoutes.d.ts.map +1 -0
  70. package/dist/http/operationRoutes.js +13 -0
  71. package/dist/http/operationRoutes.js.map +1 -0
  72. package/dist/http/rest.d.ts +16 -4
  73. package/dist/http/rest.d.ts.map +1 -1
  74. package/dist/http/rest.js +213 -71
  75. package/dist/http/rest.js.map +1 -1
  76. package/dist/index.d.ts +5 -3
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +5 -3
  79. package/dist/index.js.map +1 -1
  80. package/dist/instanceConfig.d.ts +36 -0
  81. package/dist/instanceConfig.d.ts.map +1 -0
  82. package/dist/instanceConfig.js +157 -0
  83. package/dist/instanceConfig.js.map +1 -0
  84. package/dist/licensing/activation.d.ts +2 -1
  85. package/dist/licensing/activation.d.ts.map +1 -1
  86. package/dist/licensing/activation.js +2 -3
  87. package/dist/licensing/activation.js.map +1 -1
  88. package/dist/licensing/entitlements.d.ts.map +1 -1
  89. package/dist/licensing/entitlements.js +2 -2
  90. package/dist/licensing/entitlements.js.map +1 -1
  91. package/dist/licensing/refresher.d.ts +6 -1
  92. package/dist/licensing/refresher.d.ts.map +1 -1
  93. package/dist/licensing/refresher.js +18 -7
  94. package/dist/licensing/refresher.js.map +1 -1
  95. package/dist/logging.d.ts +87 -0
  96. package/dist/logging.d.ts.map +1 -0
  97. package/dist/logging.js +612 -0
  98. package/dist/logging.js.map +1 -0
  99. package/dist/mcp/buildServer.d.ts +3 -1
  100. package/dist/mcp/buildServer.d.ts.map +1 -1
  101. package/dist/mcp/buildServer.js +27 -24
  102. package/dist/mcp/buildServer.js.map +1 -1
  103. package/dist/permissions.d.ts +1 -1
  104. package/dist/permissions.d.ts.map +1 -1
  105. package/dist/permissions.js +10 -4
  106. package/dist/permissions.js.map +1 -1
  107. package/dist/scheduler/sendScheduler.d.ts +4 -3
  108. package/dist/scheduler/sendScheduler.d.ts.map +1 -1
  109. package/dist/scheduler/sendScheduler.js +15 -3
  110. package/dist/scheduler/sendScheduler.js.map +1 -1
  111. package/dist/service/emailService.d.ts +10 -17
  112. package/dist/service/emailService.d.ts.map +1 -1
  113. package/dist/service/emailService.js +28 -38
  114. package/dist/service/emailService.js.map +1 -1
  115. package/dist/storage/adminAudit.d.ts +15 -2
  116. package/dist/storage/adminAudit.d.ts.map +1 -1
  117. package/dist/storage/adminAudit.js +22 -27
  118. package/dist/storage/adminAudit.js.map +1 -1
  119. package/dist/storage/apiKeys.d.ts +8 -8
  120. package/dist/storage/apiKeys.d.ts.map +1 -1
  121. package/dist/storage/apiKeys.js +16 -11
  122. package/dist/storage/apiKeys.js.map +1 -1
  123. package/dist/storage/db.d.ts +596 -31
  124. package/dist/storage/db.d.ts.map +1 -1
  125. package/dist/storage/db.js +383 -45
  126. package/dist/storage/db.js.map +1 -1
  127. package/dist/storage/fileLock.d.ts +7 -0
  128. package/dist/storage/fileLock.d.ts.map +1 -0
  129. package/dist/storage/fileLock.js +183 -0
  130. package/dist/storage/fileLock.js.map +1 -0
  131. package/dist/storage/gmailConnectionGrants.d.ts +3 -4
  132. package/dist/storage/gmailConnectionGrants.d.ts.map +1 -1
  133. package/dist/storage/gmailConnectionGrants.js +6 -6
  134. package/dist/storage/gmailConnectionGrants.js.map +1 -1
  135. package/dist/storage/members.d.ts +11 -0
  136. package/dist/storage/members.d.ts.map +1 -1
  137. package/dist/storage/members.js +70 -6
  138. package/dist/storage/members.js.map +1 -1
  139. package/dist/telemetry.d.ts +17 -2
  140. package/dist/telemetry.d.ts.map +1 -1
  141. package/dist/telemetry.js +34 -19
  142. package/dist/telemetry.js.map +1 -1
  143. package/dist/updateNotifier.d.ts +18 -0
  144. package/dist/updateNotifier.d.ts.map +1 -0
  145. package/dist/updateNotifier.js +128 -0
  146. package/dist/updateNotifier.js.map +1 -0
  147. package/package.json +38 -5
package/dist/http/rest.js CHANGED
@@ -2,12 +2,15 @@ import { createHash } from 'node:crypto';
2
2
  import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
3
3
  import { HTTPException } from 'hono/http-exception';
4
4
  import { EmailError, isEmailError } from '@fluxmail/core';
5
- import { authenticateApiKey } from '../storage/apiKeys.js';
5
+ import { authenticateBearer, isBootstrapComplete } from '../auth.js';
6
6
  import { completeIdempotencyKey, reserveIdempotencyKey } from '../storage/restIdempotency.js';
7
- import { FULL_PERMISSION_POLICY, hasCapability } from '../permissions.js';
7
+ import { allowsCapability } from '../authorization.js';
8
+ import { captureOperation } from '../telemetry.js';
8
9
  import { VERSION } from '../version.js';
9
- import { administrationUsesHttps, registerAdminRoutes, requestBodyExceedsLimit } from './admin.js';
10
+ import { administrationUsesHttps, adminOperationId, registerAdminRoutes, requestBodyExceedsLimit } from './admin.js';
10
11
  import { recordAdminAuditEvent } from '../storage/adminAudit.js';
12
+ import { identityOperationId, registerIdentityRoutes } from './identity.js';
13
+ import { logCodedFailure, logFailure } from '../logging.js';
11
14
  const id = z.string().trim().min(1);
12
15
  const accountId = id.openapi({ example: 'acct_123' });
13
16
  const messageId = id.openapi({ example: 'msg_123' });
@@ -72,6 +75,21 @@ const FolderSchema = z
72
75
  })
73
76
  .strict()
74
77
  .openapi('Folder');
78
+ const LabelSchema = z
79
+ .object({
80
+ id: z.string(),
81
+ name: z.string(),
82
+ color: z
83
+ .object({
84
+ background: z.string().optional(),
85
+ text: z.string().optional(),
86
+ preset: z.string().optional(),
87
+ })
88
+ .strict()
89
+ .optional(),
90
+ })
91
+ .strict()
92
+ .openapi('Label');
75
93
  const MessageSchema = z
76
94
  .object({
77
95
  id: z.string(),
@@ -114,10 +132,9 @@ const AccountSchema = z
114
132
  snippets: z.boolean(),
115
133
  })
116
134
  .strict(),
117
- ownerId: z.string().optional(),
118
- sharingMode: z.enum(['private', 'all', 'selected']),
119
- sharedMemberIds: z.array(z.string()),
120
- memberId: z.string().optional(),
135
+ ownerMemberId: z.string(),
136
+ sharedWithAll: z.boolean(),
137
+ grantedMemberIds: z.array(z.string()),
121
138
  })
122
139
  .strict()
123
140
  .openapi('Account');
@@ -173,6 +190,14 @@ const errorResponses = {
173
190
  const protectedRoute = {
174
191
  security: [{ bearerAuth: [] }],
175
192
  };
193
+ export const REST_OPENAPI_DOCUMENT = {
194
+ openapi: '3.1.0',
195
+ info: {
196
+ title: 'Fluxmail REST API',
197
+ version: VERSION,
198
+ description: 'Read, draft, send, schedule, and organize email through connected Fluxmail accounts.',
199
+ },
200
+ };
176
201
  const idempotencyHeaders = z.object({
177
202
  'Idempotency-Key': z
178
203
  .string()
@@ -241,7 +266,7 @@ const ModifyRequestSchema = z
241
266
  .array(z.string().min(1))
242
267
  .max(100)
243
268
  .optional()
244
- .describe('Required when action is addLabels or removeLabels. Change system labels with dedicated actions.'),
269
+ .describe('Required when action is addLabels or removeLabels. Change Gmail system labels with dedicated actions.'),
245
270
  })
246
271
  .strict()
247
272
  .superRefine((value, ctx) => {
@@ -295,7 +320,6 @@ const MODIFY_CAPABILITIES = {
295
320
  removeLabels: 'mail.organize',
296
321
  };
297
322
  const PROTECTED_MOVE_DESTINATIONS = new Set(['archive', 'trash']);
298
- const SYSTEM_LABELS = new Set(['inbox', 'sent', 'draft', 'drafts', 'trash', 'spam', 'starred', 'unread', 'important']);
299
323
  class RestFailure extends Error {
300
324
  code;
301
325
  status;
@@ -309,6 +333,19 @@ class RestFailure extends Error {
309
333
  this.retryAfter = retryAfter;
310
334
  }
311
335
  }
336
+ function logRestFailure(deps, operation, error, startedAt) {
337
+ const context = {
338
+ productSurface: 'rest',
339
+ operation,
340
+ durationMs: performance.now() - startedAt,
341
+ };
342
+ if (error instanceof RestFailure) {
343
+ logCodedFailure(deps.logger, 'rest.operation_failed', error.code, error.message, context);
344
+ }
345
+ else {
346
+ logFailure(deps.logger, 'rest.operation_failed', error, context);
347
+ }
348
+ }
312
349
  function apiFailure(err) {
313
350
  if (err instanceof RestFailure) {
314
351
  return {
@@ -377,14 +414,6 @@ function safeAttachmentFilename(filename) {
377
414
  }
378
415
  return safe || 'attachment';
379
416
  }
380
- function captureRestTelemetry(telemetry, properties) {
381
- try {
382
- telemetry?.capture('rest api called', { product_surface: 'rest', ...properties });
383
- }
384
- catch {
385
- // Telemetry must not affect API responses.
386
- }
387
- }
388
417
  async function runJson(c, deps, options, fn) {
389
418
  const finishActivity = deps.telemetry?.beginActivity?.();
390
419
  const startedAt = performance.now();
@@ -394,7 +423,7 @@ async function runJson(c, deps, options, fn) {
394
423
  let reservation;
395
424
  try {
396
425
  const auth = c.get('restAuth');
397
- const missing = options.capabilities.filter((capability) => !hasCapability(auth.permissions, capability));
426
+ const missing = options.capabilities.filter((capability) => !allowsCapability(auth, capability));
398
427
  if (missing.length) {
399
428
  throw new EmailError('permission_denied', `This API key does not allow: ${missing.join(', ')}.`);
400
429
  }
@@ -407,7 +436,7 @@ async function runJson(c, deps, options, fn) {
407
436
  if (options.accountId !== undefined)
408
437
  service.assertAccountAccess(options.accountId);
409
438
  const hash = requestHash(options.operation, options.request);
410
- const principalId = auth.keyId;
439
+ const principalId = auth.principalId;
411
440
  const result = reserveIdempotencyKey(deps.db, {
412
441
  principalId,
413
442
  idempotencyKey,
@@ -460,6 +489,7 @@ async function runJson(c, deps, options, fn) {
460
489
  });
461
490
  }
462
491
  catch (err) {
492
+ logRestFailure(deps, options.operation, err, startedAt);
463
493
  const failure = apiFailure(err);
464
494
  const body = JSON.stringify(failure.payload);
465
495
  if (reservation) {
@@ -481,18 +511,22 @@ async function runJson(c, deps, options, fn) {
481
511
  }
482
512
  }
483
513
  catch (err) {
514
+ logRestFailure(deps, options.operation, err, startedAt);
484
515
  const failure = apiFailure(err);
485
516
  errorCode = failure.payload.error.code;
486
517
  return jsonResponse(failure.payload, failure.status, failure.retryAfter ? { 'retry-after': failure.retryAfter } : {});
487
518
  }
488
519
  finally {
489
- captureRestTelemetry(deps.telemetry, {
520
+ captureOperation(deps.telemetry, {
521
+ productSurface: 'rest',
490
522
  operation: options.operation,
491
523
  outcome,
492
- duration_ms: Math.round(performance.now() - startedAt),
493
- ...(errorCode ? { error_code: errorCode } : {}),
494
- ...(idempotencyStatus ? { idempotency_status: idempotencyStatus } : {}),
495
- ...options.telemetry,
524
+ durationMs: performance.now() - startedAt,
525
+ errorCode,
526
+ properties: {
527
+ ...(idempotencyStatus ? { idempotency_status: idempotencyStatus } : {}),
528
+ ...options.telemetry,
529
+ },
496
530
  });
497
531
  finishActivity?.();
498
532
  }
@@ -504,7 +538,7 @@ async function runAttachment(c, deps, accountId, messageId, attachmentId) {
504
538
  let errorCode;
505
539
  try {
506
540
  const auth = c.get('restAuth');
507
- if (!hasCapability(auth.permissions, 'mail.read')) {
541
+ if (!allowsCapability(auth, 'mail.read')) {
508
542
  throw new EmailError('permission_denied', 'This API key does not allow: mail.read.');
509
543
  }
510
544
  const service = c.get('restService');
@@ -534,16 +568,18 @@ async function runAttachment(c, deps, accountId, messageId, attachmentId) {
534
568
  });
535
569
  }
536
570
  catch (err) {
571
+ logRestFailure(deps, 'downloadAttachment', err, startedAt);
537
572
  const failure = apiFailure(err);
538
573
  errorCode = failure.payload.error.code;
539
574
  return jsonResponse(failure.payload, failure.status);
540
575
  }
541
576
  finally {
542
- captureRestTelemetry(deps.telemetry, {
577
+ captureOperation(deps.telemetry, {
578
+ productSurface: 'rest',
543
579
  operation: 'downloadAttachment',
544
580
  outcome,
545
- duration_ms: Math.round(performance.now() - startedAt),
546
- ...(errorCode ? { error_code: errorCode } : {}),
581
+ durationMs: performance.now() - startedAt,
582
+ errorCode,
547
583
  });
548
584
  finishActivity?.();
549
585
  }
@@ -592,6 +628,9 @@ function adminAuditPath(path) {
592
628
  if (/^\/api\/v1\/admin\/accounts\/[^/]+\/imap\/folders$/.test(path)) {
593
629
  return '/api/v1/admin/accounts/:id/imap/folders';
594
630
  }
631
+ if (/^\/api\/v1\/admin\/oauth-apps\/(google|outlook)$/.test(path)) {
632
+ return '/api/v1/admin/oauth-apps/:provider';
633
+ }
595
634
  return '/api/v1/admin/*';
596
635
  }
597
636
  function modifyAction(input) {
@@ -604,18 +643,21 @@ function modifyAction(input) {
604
643
  }
605
644
  if (input.action === 'addLabels' || input.action === 'removeLabels') {
606
645
  const labels = input.labels;
607
- if (labels.some((label) => SYSTEM_LABELS.has(label.trim().toLowerCase()))) {
608
- throw new EmailError('invalid_request', 'System labels must be changed with their dedicated message action.');
609
- }
610
646
  return input.action === 'addLabels' ? { addLabels: labels } : { removeLabels: labels };
611
647
  }
612
648
  return input.action;
613
649
  }
614
650
  export function createRestApi(deps) {
615
651
  const app = new OpenAPIHono({
616
- defaultHook(result, _c) {
652
+ defaultHook(result, c) {
617
653
  if (result.success)
618
654
  return;
655
+ logCodedFailure(deps.logger, 'rest.operation_failed', 'invalid_request', 'Request validation failed', {
656
+ productSurface: 'rest',
657
+ operation: identityOperationId(c.req.method, c.req.path) ??
658
+ adminOperationId(c.req.method, c.req.path) ??
659
+ 'request_validation',
660
+ });
619
661
  return jsonResponse({
620
662
  error: {
621
663
  code: 'invalid_request',
@@ -627,15 +669,47 @@ export function createRestApi(deps) {
627
669
  }, 400);
628
670
  },
629
671
  });
672
+ app.use('/api/v1/*', async (c, next) => {
673
+ const telemetryOperation = identityOperationId(c.req.method, c.req.path);
674
+ if (!telemetryOperation)
675
+ return next();
676
+ const finishActivity = deps.telemetry?.beginActivity?.();
677
+ const startedAt = performance.now();
678
+ let errorCode;
679
+ try {
680
+ await next();
681
+ if (c.res.status >= 400) {
682
+ try {
683
+ const payload = (await c.res.clone().json());
684
+ if (typeof payload.error?.code === 'string')
685
+ errorCode = payload.error.code;
686
+ }
687
+ catch {
688
+ errorCode = 'internal';
689
+ }
690
+ }
691
+ }
692
+ catch (error) {
693
+ errorCode = isEmailError(error) ? error.code : 'internal';
694
+ throw error;
695
+ }
696
+ finally {
697
+ captureOperation(deps.telemetry, {
698
+ productSurface: 'rest',
699
+ operation: telemetryOperation,
700
+ outcome: c.res.status < 400 && !errorCode ? 'success' : 'error',
701
+ durationMs: performance.now() - startedAt,
702
+ errorCode,
703
+ });
704
+ finishActivity?.();
705
+ }
706
+ });
630
707
  app.use('/api/v1/admin/*', async (c, next) => {
708
+ const telemetryOperation = adminOperationId(c.req.method, c.req.path);
709
+ const identityOperation = identityOperationId(c.req.method, c.req.path);
710
+ const finishActivity = telemetryOperation ? deps.telemetry?.beginActivity?.() : undefined;
711
+ const startedAt = performance.now();
631
712
  await next();
632
- if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(c.req.method))
633
- return;
634
- const auth = c.get('restAuth');
635
- if (!auth)
636
- return;
637
- const resource = c.req.path.match(/\/(api-keys|accounts)\/((?:key|acct)_[^/]+)/);
638
- const operation = `${c.req.method.toLowerCase()} ${adminAuditPath(c.req.path)}`;
639
713
  let errorCode;
640
714
  if (c.res.status >= 400) {
641
715
  try {
@@ -647,11 +721,29 @@ export function createRestApi(deps) {
647
721
  errorCode = 'internal';
648
722
  }
649
723
  }
724
+ if (telemetryOperation) {
725
+ captureOperation(deps.telemetry, {
726
+ productSurface: 'rest',
727
+ operation: telemetryOperation,
728
+ outcome: c.res.status < 400 ? 'success' : 'error',
729
+ durationMs: performance.now() - startedAt,
730
+ errorCode,
731
+ });
732
+ finishActivity?.();
733
+ }
734
+ if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(c.req.method) || (identityOperation && c.res.status < 400))
735
+ return;
736
+ const auth = c.get('restAuth');
737
+ if (!auth)
738
+ return;
739
+ const resource = c.req.path.match(/\/(api-keys|accounts)\/((?:key|acct)_[^/]+)/);
740
+ const operation = `${c.req.method.toLowerCase()} ${adminAuditPath(c.req.path)}`;
650
741
  try {
651
742
  recordAdminAuditEvent(deps.db, {
652
743
  operation,
653
744
  outcome: c.res.status < 400 ? 'success' : 'error',
654
- actorKeyId: auth.keyId,
745
+ actorKeyId: auth.kind === 'api_key' ? auth.keyId : undefined,
746
+ actorSessionId: auth.kind === 'session' ? auth.sessionId : undefined,
655
747
  actorMemberId: auth.memberId,
656
748
  ...(resource
657
749
  ? { resourceType: resource[1] === 'api-keys' ? 'api_key' : 'account', resourceId: resource[2] }
@@ -664,7 +756,31 @@ export function createRestApi(deps) {
664
756
  }
665
757
  });
666
758
  app.use('/api/v1/*', async (c, next) => {
667
- if (c.req.path === '/api/v1' || c.req.path === '/api/v1/openapi.json')
759
+ c.set('restLogger', deps.logger);
760
+ if (c.req.path.startsWith('/api/v1/auth/') ||
761
+ c.req.path === '/api/v1/me' ||
762
+ c.req.path.startsWith('/api/v1/me/') ||
763
+ c.req.path === '/api/v1/accounts/connections') {
764
+ c.header('cache-control', 'no-store');
765
+ c.header('x-content-type-options', 'nosniff');
766
+ c.header('referrer-policy', 'no-referrer');
767
+ }
768
+ const publicPaths = new Set([
769
+ '/api/v1',
770
+ '/api/v1/openapi.json',
771
+ '/api/v1/auth/login',
772
+ '/api/v1/auth/enroll',
773
+ '/api/v1/auth/password-reset',
774
+ ]);
775
+ const publicAuthRequest = c.req.method === 'POST' &&
776
+ (c.req.path === '/api/v1/auth/login' ||
777
+ c.req.path === '/api/v1/auth/enroll' ||
778
+ c.req.path === '/api/v1/auth/password-reset');
779
+ if (publicAuthRequest && (await requestBodyExceedsLimit(c.req.raw))) {
780
+ logCodedFailure(deps.logger, 'rest.request_rejected', 'request_too_large', 'Authentication request body exceeded the size limit', { productSurface: 'rest', operation: identityOperationId(c.req.method, c.req.path) ?? 'request' });
781
+ return jsonResponse({ error: { code: 'request_too_large', message: 'Authentication request bodies are limited to 64 KiB.' } }, 413);
782
+ }
783
+ if (publicPaths.has(c.req.path))
668
784
  return next();
669
785
  const administrative = c.req.path.startsWith('/api/v1/admin/');
670
786
  if (administrative) {
@@ -674,46 +790,44 @@ export function createRestApi(deps) {
674
790
  if (!administrationUsesHttps(c.req.raw, {
675
791
  remoteAddress: c.env?.incoming?.socket.remoteAddress,
676
792
  encrypted: Boolean(c.env?.incoming?.socket?.encrypted),
677
- })) {
793
+ }, deps.config.trustProxy)) {
794
+ logCodedFailure(deps.logger, 'rest.request_rejected', 'https_required', 'Administrative request did not use HTTPS', { productSurface: 'rest', operation: adminOperationId(c.req.method, c.req.path) ?? 'admin_request' });
678
795
  return jsonResponse({ error: { code: 'https_required', message: 'Administrative routes require HTTPS outside loopback.' } }, 400);
679
796
  }
680
797
  }
681
- let auth;
682
- if (deps.config.authMode === 'none' && !administrative) {
683
- auth = {
684
- keyId: 'auth:none',
685
- memberId: null,
686
- role: null,
687
- permissions: FULL_PERMISSION_POLICY,
688
- accountIds: null,
689
- };
690
- }
691
- else {
692
- const authorization = c.req.header('authorization');
693
- const bearer = authorization?.match(/^Bearer\s+([^\s]+)$/i)?.[1];
694
- auth = bearer ? authenticateApiKey(deps.db, bearer) : null;
798
+ if (!isBootstrapComplete(deps.db)) {
799
+ logCodedFailure(deps.logger, 'rest.request_rejected', 'setup_required', 'Fluxmail setup is incomplete', {
800
+ productSurface: 'rest',
801
+ operation: identityOperationId(c.req.method, c.req.path) ?? adminOperationId(c.req.method, c.req.path) ?? 'request',
802
+ });
803
+ return jsonResponse({ error: { code: 'setup_required', message: 'Run "fluxmail setup" on the server before using Fluxmail.' } }, 503);
695
804
  }
805
+ const authorization = c.req.header('authorization');
806
+ const bearer = authorization?.match(/^Bearer\s+([^\s]+)$/i)?.[1];
807
+ const auth = bearer ? authenticateBearer(deps.db, bearer) : null;
696
808
  if (!auth) {
697
- return jsonResponse({ error: { code: 'unauthorized', message: 'Pass an API key as a Bearer token.' } }, 401, {
698
- 'www-authenticate': 'Bearer',
809
+ logCodedFailure(deps.logger, 'rest.request_rejected', 'unauthorized', 'REST request was not authorized', {
810
+ productSurface: 'rest',
811
+ operation: identityOperationId(c.req.method, c.req.path) ?? adminOperationId(c.req.method, c.req.path) ?? 'request',
699
812
  });
813
+ return jsonResponse({ error: { code: 'unauthorized', message: 'Pass a member session or API key as a Bearer token.' } }, 401, { 'www-authenticate': 'Bearer' });
700
814
  }
701
815
  c.set('restAuth', auth);
702
816
  if (administrative && (c.req.method === 'POST' || c.req.method === 'PATCH' || c.req.method === 'PUT')) {
703
817
  const mediaType = c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
704
818
  if (mediaType !== 'application/json') {
819
+ logCodedFailure(deps.logger, 'rest.request_rejected', 'unsupported_media_type', 'Administrative request did not use application/json', { productSurface: 'rest', operation: adminOperationId(c.req.method, c.req.path) ?? 'admin_request' });
705
820
  return jsonResponse({ error: { code: 'unsupported_media_type', message: 'Content-Type must be application/json.' } }, 415);
706
821
  }
707
822
  if (await requestBodyExceedsLimit(c.req.raw)) {
823
+ logCodedFailure(deps.logger, 'rest.request_rejected', 'request_too_large', 'Administrative request body exceeded the size limit', { productSurface: 'rest', operation: adminOperationId(c.req.method, c.req.path) ?? 'admin_request' });
708
824
  return jsonResponse({ error: { code: 'request_too_large', message: 'Administrative request bodies are limited to 64 KiB.' } }, 413);
709
825
  }
710
826
  }
711
- const scope = deps.config.authMode === 'none'
712
- ? { memberId: null }
713
- : { memberId: auth.memberId, role: auth.role, accountIds: auth.accountIds };
714
- c.set('restService', deps.service.withScope(scope));
827
+ c.set('restService', deps.service.withPrincipal(auth));
715
828
  return next();
716
829
  });
830
+ registerIdentityRoutes(app, deps);
717
831
  registerAdminRoutes(app, deps);
718
832
  const discoveryRoute = createRoute({
719
833
  method: 'get',
@@ -732,7 +846,17 @@ export function createRestApi(deps) {
732
846
  },
733
847
  },
734
848
  });
735
- app.openapi(discoveryRoute, (c) => c.json({ data: { name: 'fluxmail', version: VERSION, openapi: '/api/v1/openapi.json' } }));
849
+ app.openapi(discoveryRoute, (c) => {
850
+ const startedAt = performance.now();
851
+ const response = c.json({ data: { name: 'fluxmail', version: VERSION, openapi: '/api/v1/openapi.json' } }, 200);
852
+ captureOperation(deps.telemetry, {
853
+ productSurface: 'rest',
854
+ operation: 'getApiInfo',
855
+ outcome: 'success',
856
+ durationMs: performance.now() - startedAt,
857
+ });
858
+ return response;
859
+ });
736
860
  const statusRoute = createRoute({
737
861
  method: 'get',
738
862
  path: '/api/v1/status',
@@ -785,6 +909,25 @@ export function createRestApi(deps) {
785
909
  data: await c.get('restService').listFolders(accountId),
786
910
  }));
787
911
  });
912
+ const labelsRoute = createRoute({
913
+ method: 'get',
914
+ path: '/api/v1/accounts/{accountId}/labels',
915
+ operationId: 'listLabels',
916
+ summary: 'List labels',
917
+ description: 'List Gmail user labels or Outlook categories in an email account.',
918
+ request: { params: accountParams },
919
+ ...protectedRoute,
920
+ responses: {
921
+ 200: { content: { 'application/json': { schema: dataEnvelope(z.array(LabelSchema)) } }, description: 'Labels' },
922
+ ...errorResponses,
923
+ },
924
+ });
925
+ app.openapi(labelsRoute, (c) => {
926
+ const { accountId } = c.req.valid('param');
927
+ return runJson(c, deps, { operation: 'listLabels', capabilities: ['mail.read'] }, async () => ({
928
+ data: await c.get('restService').listLabels(accountId),
929
+ }));
930
+ });
788
931
  const listMessagesRoute = createRoute({
789
932
  method: 'get',
790
933
  path: '/api/v1/accounts/{accountId}/messages',
@@ -1107,21 +1250,20 @@ export function createRestApi(deps) {
1107
1250
  app.openAPIRegistry.registerComponent('securitySchemes', 'bearerAuth', {
1108
1251
  type: 'http',
1109
1252
  scheme: 'bearer',
1110
- bearerFormat: 'Fluxmail API key',
1253
+ bearerFormat: 'Fluxmail member session or API key',
1111
1254
  });
1112
- app.doc31('/api/v1/openapi.json', {
1113
- openapi: '3.1.0',
1114
- info: {
1115
- title: 'Fluxmail REST API',
1116
- version: VERSION,
1117
- description: 'Read, draft, send, schedule, and organize email through connected Fluxmail accounts.',
1118
- },
1255
+ app.openAPIRegistry.registerComponent('securitySchemes', 'memberSessionAuth', {
1256
+ type: 'http',
1257
+ scheme: 'bearer',
1258
+ bearerFormat: 'Fluxmail member session',
1119
1259
  });
1260
+ app.doc31('/api/v1/openapi.json', REST_OPENAPI_DOCUMENT);
1120
1261
  app.all('/api/v1/*', () => jsonResponse({ error: { code: 'not_found', message: 'No REST route matches this request.' } }, 404));
1121
1262
  app.onError((err) => {
1122
1263
  const malformedJson = err instanceof SyntaxError || (err instanceof HTTPException && err.status === 400)
1123
1264
  ? new EmailError('invalid_request', 'Malformed JSON body.')
1124
1265
  : err;
1266
+ logFailure(deps.logger, 'rest.unhandled_error', malformedJson, { productSurface: 'rest' });
1125
1267
  const failure = apiFailure(malformedJson);
1126
1268
  return jsonResponse(failure.payload, failure.status);
1127
1269
  });