fluxmail 0.10.0 → 0.11.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 +1 -1
- package/dist/accounts/googleAuth.d.ts.map +1 -1
- package/dist/accounts/googleAuth.js +2 -1
- package/dist/accounts/googleAuth.js.map +1 -1
- package/dist/accounts/registry.d.ts.map +1 -1
- package/dist/accounts/registry.js +12 -5
- package/dist/accounts/registry.js.map +1 -1
- package/dist/accounts/telemetry.d.ts +3 -0
- package/dist/accounts/telemetry.d.ts.map +1 -1
- package/dist/accounts/telemetry.js +18 -0
- package/dist/accounts/telemetry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +83 -24
- package/dist/cli.js.map +1 -1
- package/dist/cliInstances.d.ts +5 -2
- package/dist/cliInstances.d.ts.map +1 -1
- package/dist/cliInstances.js +74 -22
- package/dist/cliInstances.js.map +1 -1
- package/dist/cliMail.d.ts +2 -0
- package/dist/cliMail.d.ts.map +1 -1
- package/dist/cliMail.js +106 -18
- package/dist/cliMail.js.map +1 -1
- package/dist/cliOutput.d.ts +9 -0
- package/dist/cliOutput.d.ts.map +1 -0
- package/dist/cliOutput.js +76 -0
- package/dist/cliOutput.js.map +1 -0
- package/dist/context.js +1 -1
- package/dist/context.js.map +1 -1
- package/dist/http/admin.d.ts.map +1 -1
- package/dist/http/admin.js +2 -0
- package/dist/http/admin.js.map +1 -1
- package/dist/http/app.d.ts.map +1 -1
- package/dist/http/app.js +4 -0
- package/dist/http/app.js.map +1 -1
- package/dist/http/identity.d.ts.map +1 -1
- package/dist/http/identity.js +2 -0
- package/dist/http/identity.js.map +1 -1
- package/dist/http/rest.d.ts.map +1 -1
- package/dist/http/rest.js +250 -48
- package/dist/http/rest.js.map +1 -1
- package/dist/licensing/entitlements.d.ts +23 -5
- package/dist/licensing/entitlements.d.ts.map +1 -1
- package/dist/licensing/entitlements.js +101 -22
- package/dist/licensing/entitlements.js.map +1 -1
- package/dist/licensing/lease.d.ts +1 -1
- package/dist/licensing/lease.d.ts.map +1 -1
- package/dist/licensing/refresher.d.ts.map +1 -1
- package/dist/licensing/refresher.js +5 -2
- package/dist/licensing/refresher.js.map +1 -1
- package/dist/mcp/buildServer.d.ts.map +1 -1
- package/dist/mcp/buildServer.js +230 -78
- package/dist/mcp/buildServer.js.map +1 -1
- package/dist/mcp/outputSchemas.d.ts +2923 -0
- package/dist/mcp/outputSchemas.d.ts.map +1 -0
- package/dist/mcp/outputSchemas.js +108 -0
- package/dist/mcp/outputSchemas.js.map +1 -0
- package/dist/scheduler/sendScheduler.d.ts +2 -0
- package/dist/scheduler/sendScheduler.d.ts.map +1 -1
- package/dist/scheduler/sendScheduler.js +13 -3
- package/dist/scheduler/sendScheduler.js.map +1 -1
- package/dist/service/deliveryCoordinator.d.ts +45 -0
- package/dist/service/deliveryCoordinator.d.ts.map +1 -0
- package/dist/service/deliveryCoordinator.js +296 -0
- package/dist/service/deliveryCoordinator.js.map +1 -0
- package/dist/service/emailService.d.ts +47 -5
- package/dist/service/emailService.d.ts.map +1 -1
- package/dist/service/emailService.js +245 -39
- package/dist/service/emailService.js.map +1 -1
- package/dist/service/publicErrors.d.ts +12 -0
- package/dist/service/publicErrors.d.ts.map +1 -0
- package/dist/service/publicErrors.js +41 -0
- package/dist/service/publicErrors.js.map +1 -0
- package/dist/service/searchCursor.d.ts.map +1 -1
- package/dist/service/searchCursor.js +2 -1
- package/dist/service/searchCursor.js.map +1 -1
- package/dist/storage/db.d.ts +252 -2
- package/dist/storage/db.d.ts.map +1 -1
- package/dist/storage/db.js +57 -1
- package/dist/storage/db.js.map +1 -1
- package/dist/storage/members.d.ts.map +1 -1
- package/dist/storage/members.js +3 -1
- package/dist/storage/members.js.map +1 -1
- package/dist/storage/restIdempotency.d.ts +6 -0
- package/dist/storage/restIdempotency.d.ts.map +1 -1
- package/dist/storage/restIdempotency.js +8 -0
- package/dist/storage/restIdempotency.js.map +1 -1
- package/dist/storage/scheduledSends.d.ts +2 -1
- package/dist/storage/scheduledSends.d.ts.map +1 -1
- package/dist/storage/scheduledSends.js +6 -0
- package/dist/storage/scheduledSends.js.map +1 -1
- package/package.json +5 -5
package/dist/http/rest.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } 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, mergeEmailQueries, parseEmailSearch, } from '@fluxmail/core';
|
|
5
5
|
import { authenticateBearer, isBootstrapComplete } from '../auth.js';
|
|
6
|
-
import { completeIdempotencyKey, reserveIdempotencyKey } from '../storage/restIdempotency.js';
|
|
6
|
+
import { completeIdempotencyKey, lookupIdempotencyKey, reserveIdempotencyKey } from '../storage/restIdempotency.js';
|
|
7
7
|
import { allowsCapability } from '../authorization.js';
|
|
8
8
|
import { captureOperation } from '../telemetry.js';
|
|
9
9
|
import { VERSION } from '../version.js';
|
|
@@ -12,6 +12,7 @@ import { recordAdminAuditEvent } from '../storage/adminAudit.js';
|
|
|
12
12
|
import { identityOperationId, registerIdentityRoutes } from './identity.js';
|
|
13
13
|
import { operationProperties } from './operationTelemetry.js';
|
|
14
14
|
import { logCodedFailure, logFailure } from '../logging.js';
|
|
15
|
+
import { ClientInputError, publicError } from '../service/publicErrors.js';
|
|
15
16
|
const id = z.string().trim().min(1);
|
|
16
17
|
const accountId = id.openapi({ example: 'acct_123' });
|
|
17
18
|
const messageId = id.openapi({ example: 'msg_123' });
|
|
@@ -45,6 +46,16 @@ const MessageBodySchema = z
|
|
|
45
46
|
.object({ text: z.string().optional(), html: z.string().optional() })
|
|
46
47
|
.strict()
|
|
47
48
|
.openapi('MessageBody');
|
|
49
|
+
const MessageInputBodySchema = z
|
|
50
|
+
.object({
|
|
51
|
+
text: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe('Plain-text body. Line breaks appear in the sent email. Keep each prose paragraph on one continuous line and separate paragraphs with blank lines.'),
|
|
55
|
+
html: z.string().optional(),
|
|
56
|
+
})
|
|
57
|
+
.strict()
|
|
58
|
+
.openapi('MessageInputBody');
|
|
48
59
|
const AttachmentInputSchema = z
|
|
49
60
|
.object({
|
|
50
61
|
filename: z.string().min(1),
|
|
@@ -214,10 +225,11 @@ const AccountSchema = z
|
|
|
214
225
|
const ScheduledSendSchema = z
|
|
215
226
|
.object({
|
|
216
227
|
scheduleId: z.string(),
|
|
228
|
+
operationId: z.string().optional(),
|
|
217
229
|
accountId: z.string(),
|
|
218
230
|
draftId: z.string(),
|
|
219
231
|
sendAt: z.string(),
|
|
220
|
-
status: z.enum(['pending', 'sending', 'sent', 'failed', 'canceled']),
|
|
232
|
+
status: z.enum(['pending', 'sending', 'sent', 'failed', 'uncertain', 'canceled']),
|
|
221
233
|
attempts: z.number().int().nonnegative(),
|
|
222
234
|
subject: z.string().optional(),
|
|
223
235
|
to: z.string().optional(),
|
|
@@ -231,9 +243,50 @@ const SendResultSchema = z
|
|
|
231
243
|
.object({ id: z.string(), threadId: z.string(), warnings: z.array(z.string()).optional() })
|
|
232
244
|
.strict()
|
|
233
245
|
.openapi('SendResult');
|
|
246
|
+
const DeliveryOperationSchema = z
|
|
247
|
+
.object({
|
|
248
|
+
operationId: z.string(),
|
|
249
|
+
accountId: z.string(),
|
|
250
|
+
kind: z.enum(['send', 'forward', 'scheduled']),
|
|
251
|
+
status: z.enum(['queued', 'sending', 'succeeded', 'failed', 'uncertain']),
|
|
252
|
+
result: SendResultSchema.optional(),
|
|
253
|
+
error: z.object({ code: z.string() }).strict().optional(),
|
|
254
|
+
scheduleId: z.string().optional(),
|
|
255
|
+
})
|
|
256
|
+
.strict()
|
|
257
|
+
.openapi('DeliveryOperation');
|
|
258
|
+
const SendPreviewSchema = z
|
|
259
|
+
.object({
|
|
260
|
+
accountId: z.string(),
|
|
261
|
+
from: z.string(),
|
|
262
|
+
to: z.array(EmailAddressSchema),
|
|
263
|
+
cc: z.array(EmailAddressSchema),
|
|
264
|
+
bcc: z.array(EmailAddressSchema),
|
|
265
|
+
subject: z.string(),
|
|
266
|
+
attachments: z.array(z.object({ filename: z.string(), mimeType: z.string(), sizeBytes: z.number().int() }).strict()),
|
|
267
|
+
bodyTextChars: z.number().int(),
|
|
268
|
+
bodyHtmlChars: z.number().int(),
|
|
269
|
+
})
|
|
270
|
+
.strict()
|
|
271
|
+
.openapi('SendPreview');
|
|
272
|
+
const ModifyResultSchema = z
|
|
273
|
+
.object({
|
|
274
|
+
action: z.string(),
|
|
275
|
+
succeededIds: z.array(z.string()),
|
|
276
|
+
failed: z.array(z.object({ messageId: z.string(), code: z.string() }).strict()),
|
|
277
|
+
uncertainIds: z.array(z.string()),
|
|
278
|
+
})
|
|
279
|
+
.strict();
|
|
234
280
|
const ErrorSchema = z
|
|
235
281
|
.object({
|
|
236
|
-
error: z
|
|
282
|
+
error: z
|
|
283
|
+
.object({
|
|
284
|
+
code: z.string(),
|
|
285
|
+
message: z.string(),
|
|
286
|
+
requestId: z.string().optional(),
|
|
287
|
+
data: z.record(z.unknown()).optional(),
|
|
288
|
+
})
|
|
289
|
+
.strict(),
|
|
237
290
|
})
|
|
238
291
|
.strict()
|
|
239
292
|
.openapi('RestError');
|
|
@@ -319,7 +372,7 @@ const draftShape = {
|
|
|
319
372
|
cc: z.array(EmailAddressSchema).optional(),
|
|
320
373
|
bcc: z.array(EmailAddressSchema).optional(),
|
|
321
374
|
subject: z.string().optional(),
|
|
322
|
-
body:
|
|
375
|
+
body: MessageInputBodySchema,
|
|
323
376
|
replyToMessageId: messageId.optional(),
|
|
324
377
|
replyAll: z.boolean().optional().describe('Requires replyToMessageId when true.'),
|
|
325
378
|
attachments: z.array(AttachmentInputSchema).optional(),
|
|
@@ -341,7 +394,10 @@ const ForwardRequestSchema = z
|
|
|
341
394
|
from: z.string().email().optional(),
|
|
342
395
|
to: z.array(EmailAddressSchema).min(1),
|
|
343
396
|
cc: z.array(EmailAddressSchema).optional(),
|
|
344
|
-
comment: z
|
|
397
|
+
comment: z
|
|
398
|
+
.string()
|
|
399
|
+
.optional()
|
|
400
|
+
.describe('Comment above the forwarded message. Keep prose paragraphs on one continuous line and separate paragraphs with blank lines.'),
|
|
345
401
|
includeAttachments: z
|
|
346
402
|
.boolean()
|
|
347
403
|
.optional()
|
|
@@ -364,7 +420,10 @@ const modifyActionNames = [
|
|
|
364
420
|
];
|
|
365
421
|
const ModifyRequestSchema = z
|
|
366
422
|
.object({
|
|
367
|
-
messageIds: z
|
|
423
|
+
messageIds: z
|
|
424
|
+
.array(messageId)
|
|
425
|
+
.min(1)
|
|
426
|
+
.refine((ids) => new Set(ids).size <= 100, 'At most 100 distinct message IDs are allowed.'),
|
|
368
427
|
action: z.enum(modifyActionNames),
|
|
369
428
|
folder: z
|
|
370
429
|
.string()
|
|
@@ -475,11 +534,12 @@ class RestFailure extends Error {
|
|
|
475
534
|
this.retryAfter = retryAfter;
|
|
476
535
|
}
|
|
477
536
|
}
|
|
478
|
-
function logRestFailure(deps, operation, error, startedAt) {
|
|
537
|
+
function logRestFailure(deps, operation, error, startedAt, requestId) {
|
|
479
538
|
const context = {
|
|
480
539
|
productSurface: 'rest',
|
|
481
540
|
operation,
|
|
482
541
|
durationMs: performance.now() - startedAt,
|
|
542
|
+
...(requestId ? { details: { request_id: requestId } } : {}),
|
|
483
543
|
};
|
|
484
544
|
if (error instanceof RestFailure) {
|
|
485
545
|
logCodedFailure(deps.logger, 'rest.operation_failed', error.code, error.message, context);
|
|
@@ -488,17 +548,18 @@ function logRestFailure(deps, operation, error, startedAt) {
|
|
|
488
548
|
logFailure(deps.logger, 'rest.operation_failed', error, context);
|
|
489
549
|
}
|
|
490
550
|
}
|
|
491
|
-
function apiFailure(err) {
|
|
551
|
+
function apiFailure(err, requestId = randomUUID()) {
|
|
492
552
|
if (err instanceof RestFailure) {
|
|
493
553
|
return {
|
|
494
554
|
status: err.status,
|
|
495
|
-
payload: { error: { code: err.code, message: err.message, ...(err.data ? { data: err.data } : {}) } },
|
|
555
|
+
payload: { error: { code: err.code, message: err.message, requestId, ...(err.data ? { data: err.data } : {}) } },
|
|
496
556
|
...(err.retryAfter ? { retryAfter: err.retryAfter } : {}),
|
|
497
557
|
};
|
|
498
558
|
}
|
|
499
559
|
if (isEmailError(err)) {
|
|
500
560
|
const status = {
|
|
501
561
|
invalid_request: 400,
|
|
562
|
+
idempotency_conflict: 409,
|
|
502
563
|
permission_denied: 403,
|
|
503
564
|
entitlement_exceeded: 403,
|
|
504
565
|
not_found: 404,
|
|
@@ -507,16 +568,19 @@ function apiFailure(err) {
|
|
|
507
568
|
rate_limited: 429,
|
|
508
569
|
provider_unavailable: 503,
|
|
509
570
|
};
|
|
571
|
+
const safe = publicError(err, requestId);
|
|
572
|
+
const retryAfterMs = safe.data?.retryAfterMs;
|
|
510
573
|
return {
|
|
511
574
|
status: status[err.code] ?? 500,
|
|
512
|
-
payload: {
|
|
513
|
-
|
|
514
|
-
|
|
575
|
+
payload: { error: safe },
|
|
576
|
+
...(typeof retryAfterMs === 'number' && Number.isFinite(retryAfterMs) && retryAfterMs >= 0
|
|
577
|
+
? { retryAfter: String(Math.max(1, Math.ceil(retryAfterMs / 1000))) }
|
|
578
|
+
: {}),
|
|
515
579
|
};
|
|
516
580
|
}
|
|
517
581
|
return {
|
|
518
582
|
status: 500,
|
|
519
|
-
payload: { error:
|
|
583
|
+
payload: { error: publicError(err, requestId) },
|
|
520
584
|
};
|
|
521
585
|
}
|
|
522
586
|
function jsonResponse(body, status, headers = {}) {
|
|
@@ -559,6 +623,7 @@ function safeAttachmentFilename(filename) {
|
|
|
559
623
|
async function runJson(c, deps, options, fn) {
|
|
560
624
|
const finishActivity = deps.telemetry?.beginActivity?.();
|
|
561
625
|
const startedAt = performance.now();
|
|
626
|
+
const requestId = randomUUID();
|
|
562
627
|
let outcome = 'error';
|
|
563
628
|
let errorCode;
|
|
564
629
|
let idempotencyStatus;
|
|
@@ -570,6 +635,32 @@ async function runJson(c, deps, options, fn) {
|
|
|
570
635
|
throw new EmailError('permission_denied', `This API key does not allow: ${missing.join(', ')}.`);
|
|
571
636
|
}
|
|
572
637
|
const service = c.get('restService');
|
|
638
|
+
if (options.legacyReplay) {
|
|
639
|
+
const key = c.req.header('idempotency-key');
|
|
640
|
+
if (!key || !/^[\x21-\x7e]{1,255}$/.test(key)) {
|
|
641
|
+
throw new RestFailure('invalid_request', 'Idempotency-Key is required.', 400);
|
|
642
|
+
}
|
|
643
|
+
if (options.accountId !== undefined)
|
|
644
|
+
service.assertAccountAccess(options.accountId);
|
|
645
|
+
const legacy = lookupIdempotencyKey(deps.db, auth.principalId, key);
|
|
646
|
+
if (legacy) {
|
|
647
|
+
if (legacy.requestHash !== requestHash(options.operation, options.request)) {
|
|
648
|
+
throw new RestFailure('idempotency_conflict', 'This Idempotency-Key was already used for a different request.', 409);
|
|
649
|
+
}
|
|
650
|
+
if (legacy.state !== 'completed' || legacy.responseStatus === null || legacy.responseBody === null) {
|
|
651
|
+
throw new RestFailure('idempotency_in_progress', 'The earlier delivery outcome is uncertain.', 409);
|
|
652
|
+
}
|
|
653
|
+
return new Response(legacy.responseBody, {
|
|
654
|
+
status: legacy.responseStatus,
|
|
655
|
+
headers: {
|
|
656
|
+
'content-type': 'application/json; charset=UTF-8',
|
|
657
|
+
'cache-control': 'no-store',
|
|
658
|
+
'idempotency-replayed': 'true',
|
|
659
|
+
'x-request-id': requestId,
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
573
664
|
if (options.idempotent) {
|
|
574
665
|
const idempotencyKey = c.req.header('idempotency-key');
|
|
575
666
|
if (!idempotencyKey || !/^[\x21-\x7e]{1,255}$/.test(idempotencyKey)) {
|
|
@@ -606,6 +697,7 @@ async function runJson(c, deps, options, fn) {
|
|
|
606
697
|
'content-type': 'application/json; charset=UTF-8',
|
|
607
698
|
'cache-control': 'no-store',
|
|
608
699
|
'idempotency-replayed': 'true',
|
|
700
|
+
'x-request-id': requestId,
|
|
609
701
|
},
|
|
610
702
|
});
|
|
611
703
|
}
|
|
@@ -629,12 +721,16 @@ async function runJson(c, deps, options, fn) {
|
|
|
629
721
|
errorCode = 'account_failure';
|
|
630
722
|
return new Response(body, {
|
|
631
723
|
status,
|
|
632
|
-
headers: {
|
|
724
|
+
headers: {
|
|
725
|
+
'content-type': 'application/json; charset=UTF-8',
|
|
726
|
+
'cache-control': 'no-store',
|
|
727
|
+
'x-request-id': requestId,
|
|
728
|
+
},
|
|
633
729
|
});
|
|
634
730
|
}
|
|
635
731
|
catch (err) {
|
|
636
|
-
logRestFailure(deps, options.operation, err, startedAt);
|
|
637
|
-
const failure = apiFailure(err);
|
|
732
|
+
logRestFailure(deps, options.operation, err, startedAt, requestId);
|
|
733
|
+
const failure = apiFailure(err, requestId);
|
|
638
734
|
const body = JSON.stringify(failure.payload);
|
|
639
735
|
if (reservation) {
|
|
640
736
|
completeIdempotencyKey(deps.db, {
|
|
@@ -649,16 +745,20 @@ async function runJson(c, deps, options, fn) {
|
|
|
649
745
|
headers: {
|
|
650
746
|
'content-type': 'application/json; charset=UTF-8',
|
|
651
747
|
'cache-control': 'no-store',
|
|
748
|
+
'x-request-id': requestId,
|
|
652
749
|
...(failure.retryAfter ? { 'retry-after': failure.retryAfter } : {}),
|
|
653
750
|
},
|
|
654
751
|
});
|
|
655
752
|
}
|
|
656
753
|
}
|
|
657
754
|
catch (err) {
|
|
658
|
-
logRestFailure(deps, options.operation, err, startedAt);
|
|
659
|
-
const failure = apiFailure(err);
|
|
755
|
+
logRestFailure(deps, options.operation, err, startedAt, requestId);
|
|
756
|
+
const failure = apiFailure(err, requestId);
|
|
660
757
|
errorCode = failure.payload.error.code;
|
|
661
|
-
return jsonResponse(failure.payload, failure.status,
|
|
758
|
+
return jsonResponse(failure.payload, failure.status, {
|
|
759
|
+
'x-request-id': requestId,
|
|
760
|
+
...(failure.retryAfter ? { 'retry-after': failure.retryAfter } : {}),
|
|
761
|
+
});
|
|
662
762
|
}
|
|
663
763
|
finally {
|
|
664
764
|
captureOperation(deps.telemetry, {
|
|
@@ -678,6 +778,7 @@ async function runJson(c, deps, options, fn) {
|
|
|
678
778
|
async function runAttachment(c, deps, accountId, messageId, attachmentId) {
|
|
679
779
|
const finishActivity = deps.telemetry?.beginActivity?.();
|
|
680
780
|
const startedAt = performance.now();
|
|
781
|
+
const requestId = randomUUID();
|
|
681
782
|
let outcome = 'error';
|
|
682
783
|
let errorCode;
|
|
683
784
|
try {
|
|
@@ -689,7 +790,7 @@ async function runAttachment(c, deps, accountId, messageId, attachmentId) {
|
|
|
689
790
|
const warning = service.enforceQuota();
|
|
690
791
|
const { meta, content } = await service.getAttachment(accountId, messageId, attachmentId, deps.config.maxAttachmentBytes);
|
|
691
792
|
if (content.length > deps.config.maxAttachmentBytes) {
|
|
692
|
-
throw new
|
|
793
|
+
throw new ClientInputError('invalid_request', 'Attachment is too large to return through the REST API.', {
|
|
693
794
|
sizeBytes: content.length,
|
|
694
795
|
maxBytes: deps.config.maxAttachmentBytes,
|
|
695
796
|
});
|
|
@@ -707,15 +808,16 @@ async function runAttachment(c, deps, accountId, messageId, attachmentId) {
|
|
|
707
808
|
'content-length': String(content.length),
|
|
708
809
|
'content-disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeContentDispositionFilename(safeName)}`,
|
|
709
810
|
'cache-control': 'no-store',
|
|
811
|
+
'x-request-id': requestId,
|
|
710
812
|
...(warning ? { 'fluxmail-warning': warning.replace(/[\r\n]/g, ' ') } : {}),
|
|
711
813
|
},
|
|
712
814
|
});
|
|
713
815
|
}
|
|
714
816
|
catch (err) {
|
|
715
|
-
logRestFailure(deps, 'downloadAttachment', err, startedAt);
|
|
716
|
-
const failure = apiFailure(err);
|
|
817
|
+
logRestFailure(deps, 'downloadAttachment', err, startedAt, requestId);
|
|
818
|
+
const failure = apiFailure(err, requestId);
|
|
717
819
|
errorCode = failure.payload.error.code;
|
|
718
|
-
return jsonResponse(failure.payload, failure.status);
|
|
820
|
+
return jsonResponse(failure.payload, failure.status, { 'x-request-id': requestId });
|
|
719
821
|
}
|
|
720
822
|
finally {
|
|
721
823
|
captureOperation(deps.telemetry, {
|
|
@@ -757,13 +859,13 @@ function toEmailQuery(input) {
|
|
|
757
859
|
const parsed = parseEmailSearch(input.query);
|
|
758
860
|
diagnostics = parsed.diagnostics;
|
|
759
861
|
if (!parsed.valid) {
|
|
760
|
-
throw new
|
|
862
|
+
throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
|
|
761
863
|
diagnostics: parsed.diagnostics,
|
|
762
864
|
});
|
|
763
865
|
}
|
|
764
866
|
const merged = mergeEmailQueries(parsed.query, structured);
|
|
765
867
|
if (!merged.success) {
|
|
766
|
-
throw new
|
|
868
|
+
throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
|
|
767
869
|
diagnostics: merged.diagnostics,
|
|
768
870
|
});
|
|
769
871
|
}
|
|
@@ -805,7 +907,7 @@ function modifyAction(input) {
|
|
|
805
907
|
if (input.action === 'move') {
|
|
806
908
|
const folder = input.folder;
|
|
807
909
|
if (PROTECTED_MOVE_DESTINATIONS.has(folder.trim().toLowerCase())) {
|
|
808
|
-
throw new
|
|
910
|
+
throw new ClientInputError('invalid_request', 'Use the dedicated archive or trash action for this folder.');
|
|
809
911
|
}
|
|
810
912
|
return { move: folder };
|
|
811
913
|
}
|
|
@@ -1018,6 +1120,12 @@ export function createRestApi(deps) {
|
|
|
1018
1120
|
return jsonResponse({ error: { code: 'unauthorized', message: 'Pass a member session or API key as a Bearer token.' } }, 401, { 'www-authenticate': 'Bearer' });
|
|
1019
1121
|
}
|
|
1020
1122
|
c.set('restAuth', auth);
|
|
1123
|
+
if (!administrative && ['POST', 'PATCH', 'PUT'].includes(c.req.method)) {
|
|
1124
|
+
const limit = Math.ceil((deps.config.maxAttachmentBytes * 4) / 3) + 2 * 1024 * 1024;
|
|
1125
|
+
if (await requestBodyExceedsLimit(c.req.raw, limit)) {
|
|
1126
|
+
return jsonResponse({ error: { code: 'request_too_large', message: 'Mail request body exceeds the size limit.' } }, 413);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1021
1129
|
if (administrative && (c.req.method === 'POST' || c.req.method === 'PATCH' || c.req.method === 'PUT')) {
|
|
1022
1130
|
const mediaType = c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
1023
1131
|
if (mediaType !== 'application/json') {
|
|
@@ -1236,7 +1344,7 @@ export function createRestApi(deps) {
|
|
|
1236
1344
|
const input = c.req.valid('json');
|
|
1237
1345
|
const parsed = parseEmailSearch(input.query);
|
|
1238
1346
|
if (!parsed.valid) {
|
|
1239
|
-
throw new
|
|
1347
|
+
throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
|
|
1240
1348
|
diagnostics: parsed.diagnostics,
|
|
1241
1349
|
});
|
|
1242
1350
|
}
|
|
@@ -1251,7 +1359,7 @@ export function createRestApi(deps) {
|
|
|
1251
1359
|
}
|
|
1252
1360
|
const merged = mergeEmailQueries(parsed.query, structured);
|
|
1253
1361
|
if (!merged.success) {
|
|
1254
|
-
throw new
|
|
1362
|
+
throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
|
|
1255
1363
|
diagnostics: merged.diagnostics,
|
|
1256
1364
|
});
|
|
1257
1365
|
}
|
|
@@ -1324,6 +1432,24 @@ export function createRestApi(deps) {
|
|
|
1324
1432
|
data: await c.get('restService').getThread(accountId, threadId),
|
|
1325
1433
|
}));
|
|
1326
1434
|
});
|
|
1435
|
+
const getDraftRoute = createRoute({
|
|
1436
|
+
method: 'get',
|
|
1437
|
+
path: '/api/v1/accounts/{accountId}/drafts/{draftId}',
|
|
1438
|
+
operationId: 'getDraft',
|
|
1439
|
+
summary: 'Get a draft',
|
|
1440
|
+
request: { params: draftParams },
|
|
1441
|
+
...protectedRoute,
|
|
1442
|
+
responses: {
|
|
1443
|
+
200: { content: { 'application/json': { schema: dataEnvelope(MessageSchema) } }, description: 'Draft' },
|
|
1444
|
+
...errorResponses,
|
|
1445
|
+
},
|
|
1446
|
+
});
|
|
1447
|
+
app.openapi(getDraftRoute, (c) => {
|
|
1448
|
+
const { accountId, draftId } = c.req.valid('param');
|
|
1449
|
+
return runJson(c, deps, { operation: 'getDraft', capabilities: ['mail.drafts'] }, async () => ({
|
|
1450
|
+
data: await c.get('restService').getDraft(accountId, draftId),
|
|
1451
|
+
}));
|
|
1452
|
+
});
|
|
1327
1453
|
const createDraftRoute = createRoute({
|
|
1328
1454
|
method: 'post',
|
|
1329
1455
|
path: '/api/v1/accounts/{accountId}/drafts',
|
|
@@ -1395,6 +1521,37 @@ export function createRestApi(deps) {
|
|
|
1395
1521
|
return { data: { deleted: draftId } };
|
|
1396
1522
|
});
|
|
1397
1523
|
});
|
|
1524
|
+
const previewSendRoute = createRoute({
|
|
1525
|
+
method: 'post',
|
|
1526
|
+
path: '/api/v1/accounts/{accountId}/send/preview',
|
|
1527
|
+
operationId: 'previewSend',
|
|
1528
|
+
summary: 'Preview a send',
|
|
1529
|
+
request: {
|
|
1530
|
+
params: accountParams,
|
|
1531
|
+
body: { required: true, content: { 'application/json': { schema: SendRequestSchema } } },
|
|
1532
|
+
},
|
|
1533
|
+
...protectedRoute,
|
|
1534
|
+
responses: {
|
|
1535
|
+
200: {
|
|
1536
|
+
content: { 'application/json': { schema: dataEnvelope(SendPreviewSchema) } },
|
|
1537
|
+
description: 'Resolved send details',
|
|
1538
|
+
},
|
|
1539
|
+
...errorResponses,
|
|
1540
|
+
},
|
|
1541
|
+
});
|
|
1542
|
+
app.openapi(previewSendRoute, (c) => {
|
|
1543
|
+
const { accountId } = c.req.valid('param');
|
|
1544
|
+
const input = c.req.valid('json');
|
|
1545
|
+
const capabilities = ['mail.send'];
|
|
1546
|
+
if ('draftId' in input)
|
|
1547
|
+
capabilities.push('mail.drafts');
|
|
1548
|
+
if ('replyToMessageId' in input && input.replyToMessageId)
|
|
1549
|
+
capabilities.push('mail.read');
|
|
1550
|
+
const request = 'draftId' in input ? { draftId: input.draftId } : toSendInput(input);
|
|
1551
|
+
return runJson(c, deps, { operation: 'previewSend', capabilities }, async () => ({
|
|
1552
|
+
data: await c.get('restService').previewSend(accountId, request),
|
|
1553
|
+
}));
|
|
1554
|
+
});
|
|
1398
1555
|
const sendRoute = createRoute({
|
|
1399
1556
|
method: 'post',
|
|
1400
1557
|
path: '/api/v1/accounts/{accountId}/send',
|
|
@@ -1408,9 +1565,12 @@ export function createRestApi(deps) {
|
|
|
1408
1565
|
},
|
|
1409
1566
|
...protectedRoute,
|
|
1410
1567
|
responses: {
|
|
1411
|
-
200: {
|
|
1568
|
+
200: {
|
|
1569
|
+
content: { 'application/json': { schema: dataEnvelope(DeliveryOperationSchema) } },
|
|
1570
|
+
description: 'Delivery operation',
|
|
1571
|
+
},
|
|
1412
1572
|
202: {
|
|
1413
|
-
content: { 'application/json': { schema: dataEnvelope(
|
|
1573
|
+
content: { 'application/json': { schema: dataEnvelope(DeliveryOperationSchema) } },
|
|
1414
1574
|
description: 'Message scheduled',
|
|
1415
1575
|
},
|
|
1416
1576
|
...errorResponses,
|
|
@@ -1430,17 +1590,46 @@ export function createRestApi(deps) {
|
|
|
1430
1590
|
capabilities,
|
|
1431
1591
|
successStatus: sendAt ? 202 : 200,
|
|
1432
1592
|
request: { accountId, input },
|
|
1433
|
-
|
|
1593
|
+
legacyReplay: true,
|
|
1434
1594
|
accountId,
|
|
1435
1595
|
telemetry: {
|
|
1436
1596
|
mode: 'draftId' in input ? 'draft' : input.replyToMessageId ? 'reply' : 'direct',
|
|
1437
1597
|
scheduled: sendAt !== undefined,
|
|
1438
1598
|
reply_all: 'replyAll' in input && input.replyAll === true,
|
|
1439
1599
|
},
|
|
1440
|
-
}, async () =>
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1600
|
+
}, async () => {
|
|
1601
|
+
const service = c.get('restService');
|
|
1602
|
+
const key = c.req.header('idempotency-key');
|
|
1603
|
+
if (sendAt) {
|
|
1604
|
+
const operation = await service.scheduleDelivery(accountId, request, sendAt, key);
|
|
1605
|
+
return {
|
|
1606
|
+
data: operation,
|
|
1607
|
+
operationFailed: operation.status === 'failed' || operation.status === 'uncertain',
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
const operation = await service.deliver(accountId, request, key);
|
|
1611
|
+
return { data: operation, operationFailed: operation.status === 'failed' || operation.status === 'uncertain' };
|
|
1612
|
+
});
|
|
1613
|
+
});
|
|
1614
|
+
const deliveryStatusRoute = createRoute({
|
|
1615
|
+
method: 'get',
|
|
1616
|
+
path: '/api/v1/accounts/{accountId}/delivery-operations/{operationId}',
|
|
1617
|
+
operationId: 'getDeliveryOperation',
|
|
1618
|
+
summary: 'Get a delivery outcome',
|
|
1619
|
+
request: { params: z.object({ accountId, operationId: id }).strict() },
|
|
1620
|
+
...protectedRoute,
|
|
1621
|
+
responses: {
|
|
1622
|
+
200: {
|
|
1623
|
+
content: { 'application/json': { schema: dataEnvelope(DeliveryOperationSchema) } },
|
|
1624
|
+
description: 'Delivery outcome',
|
|
1625
|
+
},
|
|
1626
|
+
...errorResponses,
|
|
1627
|
+
},
|
|
1628
|
+
});
|
|
1629
|
+
app.openapi(deliveryStatusRoute, (c) => {
|
|
1630
|
+
const { accountId, operationId } = c.req.valid('param');
|
|
1631
|
+
return runJson(c, deps, { operation: 'getDeliveryOperation', capabilities: ['mail.send'], quota: false }, () => ({
|
|
1632
|
+
data: c.get('restService').getDelivery(accountId, operationId),
|
|
1444
1633
|
}));
|
|
1445
1634
|
});
|
|
1446
1635
|
const listScheduledRoute = createRoute({
|
|
@@ -1510,7 +1699,10 @@ export function createRestApi(deps) {
|
|
|
1510
1699
|
},
|
|
1511
1700
|
...protectedRoute,
|
|
1512
1701
|
responses: {
|
|
1513
|
-
200: {
|
|
1702
|
+
200: {
|
|
1703
|
+
content: { 'application/json': { schema: dataEnvelope(DeliveryOperationSchema) } },
|
|
1704
|
+
description: 'Forward delivery operation',
|
|
1705
|
+
},
|
|
1514
1706
|
...errorResponses,
|
|
1515
1707
|
},
|
|
1516
1708
|
});
|
|
@@ -1521,12 +1713,15 @@ export function createRestApi(deps) {
|
|
|
1521
1713
|
operation: 'forwardMessage',
|
|
1522
1714
|
capabilities: ['mail.send', 'mail.read'],
|
|
1523
1715
|
request: { accountId, messageId, input },
|
|
1524
|
-
|
|
1716
|
+
legacyReplay: true,
|
|
1525
1717
|
accountId,
|
|
1526
1718
|
telemetry: { include_attachments: input.includeAttachments !== false },
|
|
1527
|
-
}, async () =>
|
|
1528
|
-
|
|
1529
|
-
|
|
1719
|
+
}, async () => {
|
|
1720
|
+
const operation = await c
|
|
1721
|
+
.get('restService')
|
|
1722
|
+
.deliverForward(accountId, { messageId, ...input }, c.req.header('idempotency-key'));
|
|
1723
|
+
return { data: operation, operationFailed: operation.status === 'failed' || operation.status === 'uncertain' };
|
|
1724
|
+
});
|
|
1530
1725
|
});
|
|
1531
1726
|
const modifyRoute = createRoute({
|
|
1532
1727
|
method: 'post',
|
|
@@ -1543,7 +1738,7 @@ export function createRestApi(deps) {
|
|
|
1543
1738
|
200: {
|
|
1544
1739
|
content: {
|
|
1545
1740
|
'application/json': {
|
|
1546
|
-
schema: dataEnvelope(
|
|
1741
|
+
schema: dataEnvelope(ModifyResultSchema),
|
|
1547
1742
|
},
|
|
1548
1743
|
},
|
|
1549
1744
|
description: 'Messages modified',
|
|
@@ -1560,8 +1755,11 @@ export function createRestApi(deps) {
|
|
|
1560
1755
|
capabilities: [capability],
|
|
1561
1756
|
telemetry: { action: input.action },
|
|
1562
1757
|
}, async () => {
|
|
1563
|
-
await c.get('restService').modify(accountId, input.messageIds, modifyAction(input));
|
|
1564
|
-
return {
|
|
1758
|
+
const result = await c.get('restService').modify(accountId, input.messageIds, modifyAction(input));
|
|
1759
|
+
return {
|
|
1760
|
+
data: { ...result, action: input.action },
|
|
1761
|
+
operationFailed: result.failed.length > 0 || result.uncertainIds.length > 0,
|
|
1762
|
+
};
|
|
1565
1763
|
});
|
|
1566
1764
|
});
|
|
1567
1765
|
const attachmentRoute = createRoute({
|
|
@@ -1597,12 +1795,16 @@ export function createRestApi(deps) {
|
|
|
1597
1795
|
app.doc31('/api/v1/openapi.json', REST_OPENAPI_DOCUMENT);
|
|
1598
1796
|
app.all('/api/v1/*', () => jsonResponse({ error: { code: 'not_found', message: 'No REST route matches this request.' } }, 404));
|
|
1599
1797
|
app.onError((err) => {
|
|
1798
|
+
const requestId = randomUUID();
|
|
1600
1799
|
const malformedJson = err instanceof SyntaxError || (err instanceof HTTPException && err.status === 400)
|
|
1601
|
-
? new
|
|
1800
|
+
? new ClientInputError('invalid_request', 'Malformed JSON body.')
|
|
1602
1801
|
: err;
|
|
1603
|
-
logFailure(deps.logger, 'rest.unhandled_error', malformedJson, {
|
|
1604
|
-
|
|
1605
|
-
|
|
1802
|
+
logFailure(deps.logger, 'rest.unhandled_error', malformedJson, {
|
|
1803
|
+
productSurface: 'rest',
|
|
1804
|
+
details: { request_id: requestId },
|
|
1805
|
+
});
|
|
1806
|
+
const failure = apiFailure(malformedJson, requestId);
|
|
1807
|
+
return jsonResponse(failure.payload, failure.status, { 'x-request-id': requestId });
|
|
1606
1808
|
});
|
|
1607
1809
|
return app;
|
|
1608
1810
|
}
|