fluxmail 0.9.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 +4 -2
- package/dist/accounts/googleAuth.d.ts.map +1 -1
- package/dist/accounts/googleAuth.js +20 -85
- package/dist/accounts/googleAuth.js.map +1 -1
- package/dist/accounts/microsoftAuth.d.ts +2 -1
- package/dist/accounts/microsoftAuth.d.ts.map +1 -1
- package/dist/accounts/microsoftAuth.js +16 -80
- package/dist/accounts/microsoftAuth.js.map +1 -1
- package/dist/accounts/oauthCallback.d.ts +52 -0
- package/dist/accounts/oauthCallback.d.ts.map +1 -0
- package/dist/accounts/oauthCallback.js +237 -0
- package/dist/accounts/oauthCallback.js.map +1 -0
- 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 +6 -0
- package/dist/accounts/telemetry.d.ts.map +1 -1
- package/dist/accounts/telemetry.js +19 -0
- package/dist/accounts/telemetry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +114 -36
- 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 +113 -19
- 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 +270 -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 +236 -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 +48 -5
- package/dist/service/emailService.d.ts.map +1 -1
- package/dist/service/emailService.js +258 -37
- 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 +2 -0
- package/dist/service/searchCursor.d.ts.map +1 -1
- package/dist/service/searchCursor.js +6 -2
- 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),
|
|
@@ -125,6 +136,13 @@ const MessageSchema = z
|
|
|
125
136
|
subject: z.string(),
|
|
126
137
|
date: z.string(),
|
|
127
138
|
snippet: z.string().optional(),
|
|
139
|
+
searchContext: z
|
|
140
|
+
.union([
|
|
141
|
+
z.object({ status: z.literal('matched'), excerpt: z.string() }).strict(),
|
|
142
|
+
z.object({ status: z.enum(['no_literal_match', 'scan_limit', 'unavailable']) }).strict(),
|
|
143
|
+
])
|
|
144
|
+
.optional()
|
|
145
|
+
.describe('Optional body excerpt status for a requested portable text search.'),
|
|
128
146
|
body: MessageBodySchema.optional(),
|
|
129
147
|
attachments: z.array(AttachmentMetaSchema).optional(),
|
|
130
148
|
flags: z.object({ read: z.boolean(), starred: z.boolean(), draft: z.boolean() }).strict(),
|
|
@@ -195,6 +213,7 @@ const AccountSchema = z
|
|
|
195
213
|
})
|
|
196
214
|
.strict(),
|
|
197
215
|
snippets: z.boolean(),
|
|
216
|
+
searchContext: z.boolean().optional().describe('Supports optional match-centered body excerpts.'),
|
|
198
217
|
})
|
|
199
218
|
.strict(),
|
|
200
219
|
ownerMemberId: z.string(),
|
|
@@ -206,10 +225,11 @@ const AccountSchema = z
|
|
|
206
225
|
const ScheduledSendSchema = z
|
|
207
226
|
.object({
|
|
208
227
|
scheduleId: z.string(),
|
|
228
|
+
operationId: z.string().optional(),
|
|
209
229
|
accountId: z.string(),
|
|
210
230
|
draftId: z.string(),
|
|
211
231
|
sendAt: z.string(),
|
|
212
|
-
status: z.enum(['pending', 'sending', 'sent', 'failed', 'canceled']),
|
|
232
|
+
status: z.enum(['pending', 'sending', 'sent', 'failed', 'uncertain', 'canceled']),
|
|
213
233
|
attempts: z.number().int().nonnegative(),
|
|
214
234
|
subject: z.string().optional(),
|
|
215
235
|
to: z.string().optional(),
|
|
@@ -223,9 +243,50 @@ const SendResultSchema = z
|
|
|
223
243
|
.object({ id: z.string(), threadId: z.string(), warnings: z.array(z.string()).optional() })
|
|
224
244
|
.strict()
|
|
225
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();
|
|
226
280
|
const ErrorSchema = z
|
|
227
281
|
.object({
|
|
228
|
-
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(),
|
|
229
290
|
})
|
|
230
291
|
.strict()
|
|
231
292
|
.openapi('RestError');
|
|
@@ -311,7 +372,7 @@ const draftShape = {
|
|
|
311
372
|
cc: z.array(EmailAddressSchema).optional(),
|
|
312
373
|
bcc: z.array(EmailAddressSchema).optional(),
|
|
313
374
|
subject: z.string().optional(),
|
|
314
|
-
body:
|
|
375
|
+
body: MessageInputBodySchema,
|
|
315
376
|
replyToMessageId: messageId.optional(),
|
|
316
377
|
replyAll: z.boolean().optional().describe('Requires replyToMessageId when true.'),
|
|
317
378
|
attachments: z.array(AttachmentInputSchema).optional(),
|
|
@@ -333,7 +394,10 @@ const ForwardRequestSchema = z
|
|
|
333
394
|
from: z.string().email().optional(),
|
|
334
395
|
to: z.array(EmailAddressSchema).min(1),
|
|
335
396
|
cc: z.array(EmailAddressSchema).optional(),
|
|
336
|
-
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.'),
|
|
337
401
|
includeAttachments: z
|
|
338
402
|
.boolean()
|
|
339
403
|
.optional()
|
|
@@ -356,7 +420,10 @@ const modifyActionNames = [
|
|
|
356
420
|
];
|
|
357
421
|
const ModifyRequestSchema = z
|
|
358
422
|
.object({
|
|
359
|
-
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.'),
|
|
360
427
|
action: z.enum(modifyActionNames),
|
|
361
428
|
folder: z
|
|
362
429
|
.string()
|
|
@@ -407,6 +474,10 @@ const messageQuerySchema = z
|
|
|
407
474
|
.optional(),
|
|
408
475
|
pageToken: z.string().min(1).optional(),
|
|
409
476
|
includeSnippet: z.enum(['true', 'false']).optional(),
|
|
477
|
+
includeSearchContext: z
|
|
478
|
+
.enum(['true', 'false'])
|
|
479
|
+
.optional()
|
|
480
|
+
.describe('Include a match-centered body excerpt. Requires a portable text query.'),
|
|
410
481
|
})
|
|
411
482
|
.strict();
|
|
412
483
|
const portableFolder = z.enum(['inbox', 'sent', 'drafts', 'archive', 'spam', 'trash', 'all']);
|
|
@@ -429,6 +500,10 @@ const BatchSearchRequestSchema = z
|
|
|
429
500
|
before: isoDate.optional(),
|
|
430
501
|
pageSize: z.number().int().min(1).max(100).optional(),
|
|
431
502
|
includeSnippet: z.boolean().optional(),
|
|
503
|
+
includeSearchContext: z
|
|
504
|
+
.boolean()
|
|
505
|
+
.optional()
|
|
506
|
+
.describe('Include a match-centered body excerpt. Requires a portable text query.'),
|
|
432
507
|
})
|
|
433
508
|
.strict()
|
|
434
509
|
.openapi('BatchSearchRequest');
|
|
@@ -459,11 +534,12 @@ class RestFailure extends Error {
|
|
|
459
534
|
this.retryAfter = retryAfter;
|
|
460
535
|
}
|
|
461
536
|
}
|
|
462
|
-
function logRestFailure(deps, operation, error, startedAt) {
|
|
537
|
+
function logRestFailure(deps, operation, error, startedAt, requestId) {
|
|
463
538
|
const context = {
|
|
464
539
|
productSurface: 'rest',
|
|
465
540
|
operation,
|
|
466
541
|
durationMs: performance.now() - startedAt,
|
|
542
|
+
...(requestId ? { details: { request_id: requestId } } : {}),
|
|
467
543
|
};
|
|
468
544
|
if (error instanceof RestFailure) {
|
|
469
545
|
logCodedFailure(deps.logger, 'rest.operation_failed', error.code, error.message, context);
|
|
@@ -472,17 +548,18 @@ function logRestFailure(deps, operation, error, startedAt) {
|
|
|
472
548
|
logFailure(deps.logger, 'rest.operation_failed', error, context);
|
|
473
549
|
}
|
|
474
550
|
}
|
|
475
|
-
function apiFailure(err) {
|
|
551
|
+
function apiFailure(err, requestId = randomUUID()) {
|
|
476
552
|
if (err instanceof RestFailure) {
|
|
477
553
|
return {
|
|
478
554
|
status: err.status,
|
|
479
|
-
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 } : {}) } },
|
|
480
556
|
...(err.retryAfter ? { retryAfter: err.retryAfter } : {}),
|
|
481
557
|
};
|
|
482
558
|
}
|
|
483
559
|
if (isEmailError(err)) {
|
|
484
560
|
const status = {
|
|
485
561
|
invalid_request: 400,
|
|
562
|
+
idempotency_conflict: 409,
|
|
486
563
|
permission_denied: 403,
|
|
487
564
|
entitlement_exceeded: 403,
|
|
488
565
|
not_found: 404,
|
|
@@ -491,16 +568,19 @@ function apiFailure(err) {
|
|
|
491
568
|
rate_limited: 429,
|
|
492
569
|
provider_unavailable: 503,
|
|
493
570
|
};
|
|
571
|
+
const safe = publicError(err, requestId);
|
|
572
|
+
const retryAfterMs = safe.data?.retryAfterMs;
|
|
494
573
|
return {
|
|
495
574
|
status: status[err.code] ?? 500,
|
|
496
|
-
payload: {
|
|
497
|
-
|
|
498
|
-
|
|
575
|
+
payload: { error: safe },
|
|
576
|
+
...(typeof retryAfterMs === 'number' && Number.isFinite(retryAfterMs) && retryAfterMs >= 0
|
|
577
|
+
? { retryAfter: String(Math.max(1, Math.ceil(retryAfterMs / 1000))) }
|
|
578
|
+
: {}),
|
|
499
579
|
};
|
|
500
580
|
}
|
|
501
581
|
return {
|
|
502
582
|
status: 500,
|
|
503
|
-
payload: { error:
|
|
583
|
+
payload: { error: publicError(err, requestId) },
|
|
504
584
|
};
|
|
505
585
|
}
|
|
506
586
|
function jsonResponse(body, status, headers = {}) {
|
|
@@ -543,6 +623,7 @@ function safeAttachmentFilename(filename) {
|
|
|
543
623
|
async function runJson(c, deps, options, fn) {
|
|
544
624
|
const finishActivity = deps.telemetry?.beginActivity?.();
|
|
545
625
|
const startedAt = performance.now();
|
|
626
|
+
const requestId = randomUUID();
|
|
546
627
|
let outcome = 'error';
|
|
547
628
|
let errorCode;
|
|
548
629
|
let idempotencyStatus;
|
|
@@ -554,6 +635,32 @@ async function runJson(c, deps, options, fn) {
|
|
|
554
635
|
throw new EmailError('permission_denied', `This API key does not allow: ${missing.join(', ')}.`);
|
|
555
636
|
}
|
|
556
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
|
+
}
|
|
557
664
|
if (options.idempotent) {
|
|
558
665
|
const idempotencyKey = c.req.header('idempotency-key');
|
|
559
666
|
if (!idempotencyKey || !/^[\x21-\x7e]{1,255}$/.test(idempotencyKey)) {
|
|
@@ -590,6 +697,7 @@ async function runJson(c, deps, options, fn) {
|
|
|
590
697
|
'content-type': 'application/json; charset=UTF-8',
|
|
591
698
|
'cache-control': 'no-store',
|
|
592
699
|
'idempotency-replayed': 'true',
|
|
700
|
+
'x-request-id': requestId,
|
|
593
701
|
},
|
|
594
702
|
});
|
|
595
703
|
}
|
|
@@ -613,12 +721,16 @@ async function runJson(c, deps, options, fn) {
|
|
|
613
721
|
errorCode = 'account_failure';
|
|
614
722
|
return new Response(body, {
|
|
615
723
|
status,
|
|
616
|
-
headers: {
|
|
724
|
+
headers: {
|
|
725
|
+
'content-type': 'application/json; charset=UTF-8',
|
|
726
|
+
'cache-control': 'no-store',
|
|
727
|
+
'x-request-id': requestId,
|
|
728
|
+
},
|
|
617
729
|
});
|
|
618
730
|
}
|
|
619
731
|
catch (err) {
|
|
620
|
-
logRestFailure(deps, options.operation, err, startedAt);
|
|
621
|
-
const failure = apiFailure(err);
|
|
732
|
+
logRestFailure(deps, options.operation, err, startedAt, requestId);
|
|
733
|
+
const failure = apiFailure(err, requestId);
|
|
622
734
|
const body = JSON.stringify(failure.payload);
|
|
623
735
|
if (reservation) {
|
|
624
736
|
completeIdempotencyKey(deps.db, {
|
|
@@ -633,16 +745,20 @@ async function runJson(c, deps, options, fn) {
|
|
|
633
745
|
headers: {
|
|
634
746
|
'content-type': 'application/json; charset=UTF-8',
|
|
635
747
|
'cache-control': 'no-store',
|
|
748
|
+
'x-request-id': requestId,
|
|
636
749
|
...(failure.retryAfter ? { 'retry-after': failure.retryAfter } : {}),
|
|
637
750
|
},
|
|
638
751
|
});
|
|
639
752
|
}
|
|
640
753
|
}
|
|
641
754
|
catch (err) {
|
|
642
|
-
logRestFailure(deps, options.operation, err, startedAt);
|
|
643
|
-
const failure = apiFailure(err);
|
|
755
|
+
logRestFailure(deps, options.operation, err, startedAt, requestId);
|
|
756
|
+
const failure = apiFailure(err, requestId);
|
|
644
757
|
errorCode = failure.payload.error.code;
|
|
645
|
-
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
|
+
});
|
|
646
762
|
}
|
|
647
763
|
finally {
|
|
648
764
|
captureOperation(deps.telemetry, {
|
|
@@ -662,6 +778,7 @@ async function runJson(c, deps, options, fn) {
|
|
|
662
778
|
async function runAttachment(c, deps, accountId, messageId, attachmentId) {
|
|
663
779
|
const finishActivity = deps.telemetry?.beginActivity?.();
|
|
664
780
|
const startedAt = performance.now();
|
|
781
|
+
const requestId = randomUUID();
|
|
665
782
|
let outcome = 'error';
|
|
666
783
|
let errorCode;
|
|
667
784
|
try {
|
|
@@ -673,7 +790,7 @@ async function runAttachment(c, deps, accountId, messageId, attachmentId) {
|
|
|
673
790
|
const warning = service.enforceQuota();
|
|
674
791
|
const { meta, content } = await service.getAttachment(accountId, messageId, attachmentId, deps.config.maxAttachmentBytes);
|
|
675
792
|
if (content.length > deps.config.maxAttachmentBytes) {
|
|
676
|
-
throw new
|
|
793
|
+
throw new ClientInputError('invalid_request', 'Attachment is too large to return through the REST API.', {
|
|
677
794
|
sizeBytes: content.length,
|
|
678
795
|
maxBytes: deps.config.maxAttachmentBytes,
|
|
679
796
|
});
|
|
@@ -691,15 +808,16 @@ async function runAttachment(c, deps, accountId, messageId, attachmentId) {
|
|
|
691
808
|
'content-length': String(content.length),
|
|
692
809
|
'content-disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeContentDispositionFilename(safeName)}`,
|
|
693
810
|
'cache-control': 'no-store',
|
|
811
|
+
'x-request-id': requestId,
|
|
694
812
|
...(warning ? { 'fluxmail-warning': warning.replace(/[\r\n]/g, ' ') } : {}),
|
|
695
813
|
},
|
|
696
814
|
});
|
|
697
815
|
}
|
|
698
816
|
catch (err) {
|
|
699
|
-
logRestFailure(deps, 'downloadAttachment', err, startedAt);
|
|
700
|
-
const failure = apiFailure(err);
|
|
817
|
+
logRestFailure(deps, 'downloadAttachment', err, startedAt, requestId);
|
|
818
|
+
const failure = apiFailure(err, requestId);
|
|
701
819
|
errorCode = failure.payload.error.code;
|
|
702
|
-
return jsonResponse(failure.payload, failure.status);
|
|
820
|
+
return jsonResponse(failure.payload, failure.status, { 'x-request-id': requestId });
|
|
703
821
|
}
|
|
704
822
|
finally {
|
|
705
823
|
captureOperation(deps.telemetry, {
|
|
@@ -741,13 +859,13 @@ function toEmailQuery(input) {
|
|
|
741
859
|
const parsed = parseEmailSearch(input.query);
|
|
742
860
|
diagnostics = parsed.diagnostics;
|
|
743
861
|
if (!parsed.valid) {
|
|
744
|
-
throw new
|
|
862
|
+
throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
|
|
745
863
|
diagnostics: parsed.diagnostics,
|
|
746
864
|
});
|
|
747
865
|
}
|
|
748
866
|
const merged = mergeEmailQueries(parsed.query, structured);
|
|
749
867
|
if (!merged.success) {
|
|
750
|
-
throw new
|
|
868
|
+
throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
|
|
751
869
|
diagnostics: merged.diagnostics,
|
|
752
870
|
});
|
|
753
871
|
}
|
|
@@ -760,6 +878,9 @@ function toEmailQuery(input) {
|
|
|
760
878
|
...(input.pageSize ? { pageSize: Number(input.pageSize) } : {}),
|
|
761
879
|
...(input.pageToken ? { pageToken: input.pageToken } : {}),
|
|
762
880
|
...(input.includeSnippet !== undefined ? { includeSnippet: input.includeSnippet === 'true' } : {}),
|
|
881
|
+
...(input.includeSearchContext !== undefined
|
|
882
|
+
? { includeSearchContext: input.includeSearchContext === 'true' }
|
|
883
|
+
: {}),
|
|
763
884
|
},
|
|
764
885
|
};
|
|
765
886
|
}
|
|
@@ -786,7 +907,7 @@ function modifyAction(input) {
|
|
|
786
907
|
if (input.action === 'move') {
|
|
787
908
|
const folder = input.folder;
|
|
788
909
|
if (PROTECTED_MOVE_DESTINATIONS.has(folder.trim().toLowerCase())) {
|
|
789
|
-
throw new
|
|
910
|
+
throw new ClientInputError('invalid_request', 'Use the dedicated archive or trash action for this folder.');
|
|
790
911
|
}
|
|
791
912
|
return { move: folder };
|
|
792
913
|
}
|
|
@@ -999,6 +1120,12 @@ export function createRestApi(deps) {
|
|
|
999
1120
|
return jsonResponse({ error: { code: 'unauthorized', message: 'Pass a member session or API key as a Bearer token.' } }, 401, { 'www-authenticate': 'Bearer' });
|
|
1000
1121
|
}
|
|
1001
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
|
+
}
|
|
1002
1129
|
if (administrative && (c.req.method === 'POST' || c.req.method === 'PATCH' || c.req.method === 'PUT')) {
|
|
1003
1130
|
const mediaType = c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
1004
1131
|
if (mediaType !== 'application/json') {
|
|
@@ -1217,7 +1344,7 @@ export function createRestApi(deps) {
|
|
|
1217
1344
|
const input = c.req.valid('json');
|
|
1218
1345
|
const parsed = parseEmailSearch(input.query);
|
|
1219
1346
|
if (!parsed.valid) {
|
|
1220
|
-
throw new
|
|
1347
|
+
throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
|
|
1221
1348
|
diagnostics: parsed.diagnostics,
|
|
1222
1349
|
});
|
|
1223
1350
|
}
|
|
@@ -1232,7 +1359,7 @@ export function createRestApi(deps) {
|
|
|
1232
1359
|
}
|
|
1233
1360
|
const merged = mergeEmailQueries(parsed.query, structured);
|
|
1234
1361
|
if (!merged.success) {
|
|
1235
|
-
throw new
|
|
1362
|
+
throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
|
|
1236
1363
|
diagnostics: merged.diagnostics,
|
|
1237
1364
|
});
|
|
1238
1365
|
}
|
|
@@ -1241,6 +1368,7 @@ export function createRestApi(deps) {
|
|
|
1241
1368
|
query: merged.query,
|
|
1242
1369
|
...(input.pageSize !== undefined ? { pageSize: input.pageSize } : {}),
|
|
1243
1370
|
...(input.includeSnippet !== undefined ? { includeSnippet: input.includeSnippet } : {}),
|
|
1371
|
+
...(input.includeSearchContext !== undefined ? { includeSearchContext: input.includeSearchContext } : {}),
|
|
1244
1372
|
});
|
|
1245
1373
|
return {
|
|
1246
1374
|
data: result.groups.map((group) => {
|
|
@@ -1304,6 +1432,24 @@ export function createRestApi(deps) {
|
|
|
1304
1432
|
data: await c.get('restService').getThread(accountId, threadId),
|
|
1305
1433
|
}));
|
|
1306
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
|
+
});
|
|
1307
1453
|
const createDraftRoute = createRoute({
|
|
1308
1454
|
method: 'post',
|
|
1309
1455
|
path: '/api/v1/accounts/{accountId}/drafts',
|
|
@@ -1375,6 +1521,37 @@ export function createRestApi(deps) {
|
|
|
1375
1521
|
return { data: { deleted: draftId } };
|
|
1376
1522
|
});
|
|
1377
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
|
+
});
|
|
1378
1555
|
const sendRoute = createRoute({
|
|
1379
1556
|
method: 'post',
|
|
1380
1557
|
path: '/api/v1/accounts/{accountId}/send',
|
|
@@ -1388,9 +1565,12 @@ export function createRestApi(deps) {
|
|
|
1388
1565
|
},
|
|
1389
1566
|
...protectedRoute,
|
|
1390
1567
|
responses: {
|
|
1391
|
-
200: {
|
|
1568
|
+
200: {
|
|
1569
|
+
content: { 'application/json': { schema: dataEnvelope(DeliveryOperationSchema) } },
|
|
1570
|
+
description: 'Delivery operation',
|
|
1571
|
+
},
|
|
1392
1572
|
202: {
|
|
1393
|
-
content: { 'application/json': { schema: dataEnvelope(
|
|
1573
|
+
content: { 'application/json': { schema: dataEnvelope(DeliveryOperationSchema) } },
|
|
1394
1574
|
description: 'Message scheduled',
|
|
1395
1575
|
},
|
|
1396
1576
|
...errorResponses,
|
|
@@ -1410,17 +1590,46 @@ export function createRestApi(deps) {
|
|
|
1410
1590
|
capabilities,
|
|
1411
1591
|
successStatus: sendAt ? 202 : 200,
|
|
1412
1592
|
request: { accountId, input },
|
|
1413
|
-
|
|
1593
|
+
legacyReplay: true,
|
|
1414
1594
|
accountId,
|
|
1415
1595
|
telemetry: {
|
|
1416
1596
|
mode: 'draftId' in input ? 'draft' : input.replyToMessageId ? 'reply' : 'direct',
|
|
1417
1597
|
scheduled: sendAt !== undefined,
|
|
1418
1598
|
reply_all: 'replyAll' in input && input.replyAll === true,
|
|
1419
1599
|
},
|
|
1420
|
-
}, async () =>
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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),
|
|
1424
1633
|
}));
|
|
1425
1634
|
});
|
|
1426
1635
|
const listScheduledRoute = createRoute({
|
|
@@ -1490,7 +1699,10 @@ export function createRestApi(deps) {
|
|
|
1490
1699
|
},
|
|
1491
1700
|
...protectedRoute,
|
|
1492
1701
|
responses: {
|
|
1493
|
-
200: {
|
|
1702
|
+
200: {
|
|
1703
|
+
content: { 'application/json': { schema: dataEnvelope(DeliveryOperationSchema) } },
|
|
1704
|
+
description: 'Forward delivery operation',
|
|
1705
|
+
},
|
|
1494
1706
|
...errorResponses,
|
|
1495
1707
|
},
|
|
1496
1708
|
});
|
|
@@ -1501,12 +1713,15 @@ export function createRestApi(deps) {
|
|
|
1501
1713
|
operation: 'forwardMessage',
|
|
1502
1714
|
capabilities: ['mail.send', 'mail.read'],
|
|
1503
1715
|
request: { accountId, messageId, input },
|
|
1504
|
-
|
|
1716
|
+
legacyReplay: true,
|
|
1505
1717
|
accountId,
|
|
1506
1718
|
telemetry: { include_attachments: input.includeAttachments !== false },
|
|
1507
|
-
}, async () =>
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
+
});
|
|
1510
1725
|
});
|
|
1511
1726
|
const modifyRoute = createRoute({
|
|
1512
1727
|
method: 'post',
|
|
@@ -1523,7 +1738,7 @@ export function createRestApi(deps) {
|
|
|
1523
1738
|
200: {
|
|
1524
1739
|
content: {
|
|
1525
1740
|
'application/json': {
|
|
1526
|
-
schema: dataEnvelope(
|
|
1741
|
+
schema: dataEnvelope(ModifyResultSchema),
|
|
1527
1742
|
},
|
|
1528
1743
|
},
|
|
1529
1744
|
description: 'Messages modified',
|
|
@@ -1540,8 +1755,11 @@ export function createRestApi(deps) {
|
|
|
1540
1755
|
capabilities: [capability],
|
|
1541
1756
|
telemetry: { action: input.action },
|
|
1542
1757
|
}, async () => {
|
|
1543
|
-
await c.get('restService').modify(accountId, input.messageIds, modifyAction(input));
|
|
1544
|
-
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
|
+
};
|
|
1545
1763
|
});
|
|
1546
1764
|
});
|
|
1547
1765
|
const attachmentRoute = createRoute({
|
|
@@ -1577,12 +1795,16 @@ export function createRestApi(deps) {
|
|
|
1577
1795
|
app.doc31('/api/v1/openapi.json', REST_OPENAPI_DOCUMENT);
|
|
1578
1796
|
app.all('/api/v1/*', () => jsonResponse({ error: { code: 'not_found', message: 'No REST route matches this request.' } }, 404));
|
|
1579
1797
|
app.onError((err) => {
|
|
1798
|
+
const requestId = randomUUID();
|
|
1580
1799
|
const malformedJson = err instanceof SyntaxError || (err instanceof HTTPException && err.status === 400)
|
|
1581
|
-
? new
|
|
1800
|
+
? new ClientInputError('invalid_request', 'Malformed JSON body.')
|
|
1582
1801
|
: err;
|
|
1583
|
-
logFailure(deps.logger, 'rest.unhandled_error', malformedJson, {
|
|
1584
|
-
|
|
1585
|
-
|
|
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 });
|
|
1586
1808
|
});
|
|
1587
1809
|
return app;
|
|
1588
1810
|
}
|