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.
- package/README.md +6 -4
- package/dist/accounts/defaultGoogleOAuth.d.ts +4 -0
- package/dist/accounts/defaultGoogleOAuth.d.ts.map +1 -0
- package/dist/accounts/defaultGoogleOAuth.js +7 -0
- package/dist/accounts/defaultGoogleOAuth.js.map +1 -0
- package/dist/accounts/gmailConnection.js +2 -2
- package/dist/accounts/gmailConnection.js.map +1 -1
- package/dist/accounts/googleAuth.d.ts +16 -9
- package/dist/accounts/googleAuth.d.ts.map +1 -1
- package/dist/accounts/googleAuth.js +52 -20
- package/dist/accounts/googleAuth.js.map +1 -1
- package/dist/accounts/microsoftAuth.d.ts +5 -2
- package/dist/accounts/microsoftAuth.d.ts.map +1 -1
- package/dist/accounts/microsoftAuth.js +25 -22
- package/dist/accounts/microsoftAuth.js.map +1 -1
- package/dist/accounts/registry.d.ts +11 -9
- package/dist/accounts/registry.d.ts.map +1 -1
- package/dist/accounts/registry.js +169 -98
- package/dist/accounts/registry.js.map +1 -1
- package/dist/auth.d.ts +106 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +455 -0
- package/dist/auth.js.map +1 -0
- package/dist/authorization.d.ts +9 -0
- package/dist/authorization.d.ts.map +1 -0
- package/dist/authorization.js +23 -0
- package/dist/authorization.js.map +1 -0
- package/dist/cli.d.ts +15 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1096 -403
- package/dist/cli.js.map +1 -1
- package/dist/cliInstances.d.ts +51 -0
- package/dist/cliInstances.d.ts.map +1 -0
- package/dist/cliInstances.js +237 -0
- package/dist/cliInstances.js.map +1 -0
- package/dist/cliMail.d.ts +9 -0
- package/dist/cliMail.d.ts.map +1 -0
- package/dist/cliMail.js +450 -0
- package/dist/cliMail.js.map +1 -0
- package/dist/config.d.ts +95 -118
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +351 -286
- package/dist/config.js.map +1 -1
- package/dist/configMigration.d.ts +8 -0
- package/dist/configMigration.d.ts.map +1 -0
- package/dist/configMigration.js +234 -0
- package/dist/configMigration.js.map +1 -0
- package/dist/context.d.ts +8 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +29 -8
- package/dist/context.js.map +1 -1
- package/dist/deploymentConfig.d.ts +190 -0
- package/dist/deploymentConfig.d.ts.map +1 -0
- package/dist/deploymentConfig.js +539 -0
- package/dist/deploymentConfig.js.map +1 -0
- package/dist/http/admin.d.ts +75 -3
- package/dist/http/admin.d.ts.map +1 -1
- package/dist/http/admin.js +347 -79
- package/dist/http/admin.js.map +1 -1
- package/dist/http/app.d.ts +4 -1
- package/dist/http/app.d.ts.map +1 -1
- package/dist/http/app.js +119 -63
- package/dist/http/app.js.map +1 -1
- package/dist/http/identity.d.ts +114 -0
- package/dist/http/identity.d.ts.map +1 -0
- package/dist/http/identity.js +936 -0
- package/dist/http/identity.js.map +1 -0
- package/dist/http/operationRoutes.d.ts +8 -0
- package/dist/http/operationRoutes.d.ts.map +1 -0
- package/dist/http/operationRoutes.js +13 -0
- package/dist/http/operationRoutes.js.map +1 -0
- package/dist/http/rest.d.ts +16 -4
- package/dist/http/rest.d.ts.map +1 -1
- package/dist/http/rest.js +213 -71
- package/dist/http/rest.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/instanceConfig.d.ts +36 -0
- package/dist/instanceConfig.d.ts.map +1 -0
- package/dist/instanceConfig.js +157 -0
- package/dist/instanceConfig.js.map +1 -0
- package/dist/licensing/activation.d.ts +2 -1
- package/dist/licensing/activation.d.ts.map +1 -1
- package/dist/licensing/activation.js +2 -3
- package/dist/licensing/activation.js.map +1 -1
- package/dist/licensing/entitlements.d.ts.map +1 -1
- package/dist/licensing/entitlements.js +2 -2
- package/dist/licensing/entitlements.js.map +1 -1
- package/dist/licensing/refresher.d.ts +6 -1
- package/dist/licensing/refresher.d.ts.map +1 -1
- package/dist/licensing/refresher.js +18 -7
- package/dist/licensing/refresher.js.map +1 -1
- package/dist/logging.d.ts +87 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +612 -0
- package/dist/logging.js.map +1 -0
- package/dist/mcp/buildServer.d.ts +3 -1
- package/dist/mcp/buildServer.d.ts.map +1 -1
- package/dist/mcp/buildServer.js +27 -24
- package/dist/mcp/buildServer.js.map +1 -1
- package/dist/permissions.d.ts +1 -1
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +10 -4
- package/dist/permissions.js.map +1 -1
- package/dist/scheduler/sendScheduler.d.ts +4 -3
- package/dist/scheduler/sendScheduler.d.ts.map +1 -1
- package/dist/scheduler/sendScheduler.js +15 -3
- package/dist/scheduler/sendScheduler.js.map +1 -1
- package/dist/service/emailService.d.ts +10 -17
- package/dist/service/emailService.d.ts.map +1 -1
- package/dist/service/emailService.js +28 -38
- package/dist/service/emailService.js.map +1 -1
- package/dist/storage/adminAudit.d.ts +15 -2
- package/dist/storage/adminAudit.d.ts.map +1 -1
- package/dist/storage/adminAudit.js +22 -27
- package/dist/storage/adminAudit.js.map +1 -1
- package/dist/storage/apiKeys.d.ts +8 -8
- package/dist/storage/apiKeys.d.ts.map +1 -1
- package/dist/storage/apiKeys.js +16 -11
- package/dist/storage/apiKeys.js.map +1 -1
- package/dist/storage/db.d.ts +596 -31
- package/dist/storage/db.d.ts.map +1 -1
- package/dist/storage/db.js +383 -45
- package/dist/storage/db.js.map +1 -1
- package/dist/storage/fileLock.d.ts +7 -0
- package/dist/storage/fileLock.d.ts.map +1 -0
- package/dist/storage/fileLock.js +183 -0
- package/dist/storage/fileLock.js.map +1 -0
- package/dist/storage/gmailConnectionGrants.d.ts +3 -4
- package/dist/storage/gmailConnectionGrants.d.ts.map +1 -1
- package/dist/storage/gmailConnectionGrants.js +6 -6
- package/dist/storage/gmailConnectionGrants.js.map +1 -1
- package/dist/storage/members.d.ts +11 -0
- package/dist/storage/members.d.ts.map +1 -1
- package/dist/storage/members.js +70 -6
- package/dist/storage/members.js.map +1 -1
- package/dist/telemetry.d.ts +17 -2
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +34 -19
- package/dist/telemetry.js.map +1 -1
- package/dist/updateNotifier.d.ts +18 -0
- package/dist/updateNotifier.d.ts.map +1 -0
- package/dist/updateNotifier.js +128 -0
- package/dist/updateNotifier.js.map +1 -0
- 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 {
|
|
5
|
+
import { authenticateBearer, isBootstrapComplete } from '../auth.js';
|
|
6
6
|
import { completeIdempotencyKey, reserveIdempotencyKey } from '../storage/restIdempotency.js';
|
|
7
|
-
import {
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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) => !
|
|
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.
|
|
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
|
-
|
|
520
|
+
captureOperation(deps.telemetry, {
|
|
521
|
+
productSurface: 'rest',
|
|
490
522
|
operation: options.operation,
|
|
491
523
|
outcome,
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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 (!
|
|
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
|
-
|
|
577
|
+
captureOperation(deps.telemetry, {
|
|
578
|
+
productSurface: 'rest',
|
|
543
579
|
operation: 'downloadAttachment',
|
|
544
580
|
outcome,
|
|
545
|
-
|
|
546
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
});
|