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.
Files changed (101) hide show
  1. package/README.md +1 -1
  2. package/dist/accounts/googleAuth.d.ts +4 -2
  3. package/dist/accounts/googleAuth.d.ts.map +1 -1
  4. package/dist/accounts/googleAuth.js +20 -85
  5. package/dist/accounts/googleAuth.js.map +1 -1
  6. package/dist/accounts/microsoftAuth.d.ts +2 -1
  7. package/dist/accounts/microsoftAuth.d.ts.map +1 -1
  8. package/dist/accounts/microsoftAuth.js +16 -80
  9. package/dist/accounts/microsoftAuth.js.map +1 -1
  10. package/dist/accounts/oauthCallback.d.ts +52 -0
  11. package/dist/accounts/oauthCallback.d.ts.map +1 -0
  12. package/dist/accounts/oauthCallback.js +237 -0
  13. package/dist/accounts/oauthCallback.js.map +1 -0
  14. package/dist/accounts/registry.d.ts.map +1 -1
  15. package/dist/accounts/registry.js +12 -5
  16. package/dist/accounts/registry.js.map +1 -1
  17. package/dist/accounts/telemetry.d.ts +6 -0
  18. package/dist/accounts/telemetry.d.ts.map +1 -1
  19. package/dist/accounts/telemetry.js +19 -0
  20. package/dist/accounts/telemetry.js.map +1 -1
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +114 -36
  23. package/dist/cli.js.map +1 -1
  24. package/dist/cliInstances.d.ts +5 -2
  25. package/dist/cliInstances.d.ts.map +1 -1
  26. package/dist/cliInstances.js +74 -22
  27. package/dist/cliInstances.js.map +1 -1
  28. package/dist/cliMail.d.ts +2 -0
  29. package/dist/cliMail.d.ts.map +1 -1
  30. package/dist/cliMail.js +113 -19
  31. package/dist/cliMail.js.map +1 -1
  32. package/dist/cliOutput.d.ts +9 -0
  33. package/dist/cliOutput.d.ts.map +1 -0
  34. package/dist/cliOutput.js +76 -0
  35. package/dist/cliOutput.js.map +1 -0
  36. package/dist/context.js +1 -1
  37. package/dist/context.js.map +1 -1
  38. package/dist/http/admin.d.ts.map +1 -1
  39. package/dist/http/admin.js +2 -0
  40. package/dist/http/admin.js.map +1 -1
  41. package/dist/http/app.d.ts.map +1 -1
  42. package/dist/http/app.js +4 -0
  43. package/dist/http/app.js.map +1 -1
  44. package/dist/http/identity.d.ts.map +1 -1
  45. package/dist/http/identity.js +2 -0
  46. package/dist/http/identity.js.map +1 -1
  47. package/dist/http/rest.d.ts.map +1 -1
  48. package/dist/http/rest.js +270 -48
  49. package/dist/http/rest.js.map +1 -1
  50. package/dist/licensing/entitlements.d.ts +23 -5
  51. package/dist/licensing/entitlements.d.ts.map +1 -1
  52. package/dist/licensing/entitlements.js +101 -22
  53. package/dist/licensing/entitlements.js.map +1 -1
  54. package/dist/licensing/lease.d.ts +1 -1
  55. package/dist/licensing/lease.d.ts.map +1 -1
  56. package/dist/licensing/refresher.d.ts.map +1 -1
  57. package/dist/licensing/refresher.js +5 -2
  58. package/dist/licensing/refresher.js.map +1 -1
  59. package/dist/mcp/buildServer.d.ts.map +1 -1
  60. package/dist/mcp/buildServer.js +236 -78
  61. package/dist/mcp/buildServer.js.map +1 -1
  62. package/dist/mcp/outputSchemas.d.ts +2923 -0
  63. package/dist/mcp/outputSchemas.d.ts.map +1 -0
  64. package/dist/mcp/outputSchemas.js +108 -0
  65. package/dist/mcp/outputSchemas.js.map +1 -0
  66. package/dist/scheduler/sendScheduler.d.ts +2 -0
  67. package/dist/scheduler/sendScheduler.d.ts.map +1 -1
  68. package/dist/scheduler/sendScheduler.js +13 -3
  69. package/dist/scheduler/sendScheduler.js.map +1 -1
  70. package/dist/service/deliveryCoordinator.d.ts +45 -0
  71. package/dist/service/deliveryCoordinator.d.ts.map +1 -0
  72. package/dist/service/deliveryCoordinator.js +296 -0
  73. package/dist/service/deliveryCoordinator.js.map +1 -0
  74. package/dist/service/emailService.d.ts +48 -5
  75. package/dist/service/emailService.d.ts.map +1 -1
  76. package/dist/service/emailService.js +258 -37
  77. package/dist/service/emailService.js.map +1 -1
  78. package/dist/service/publicErrors.d.ts +12 -0
  79. package/dist/service/publicErrors.d.ts.map +1 -0
  80. package/dist/service/publicErrors.js +41 -0
  81. package/dist/service/publicErrors.js.map +1 -0
  82. package/dist/service/searchCursor.d.ts +2 -0
  83. package/dist/service/searchCursor.d.ts.map +1 -1
  84. package/dist/service/searchCursor.js +6 -2
  85. package/dist/service/searchCursor.js.map +1 -1
  86. package/dist/storage/db.d.ts +252 -2
  87. package/dist/storage/db.d.ts.map +1 -1
  88. package/dist/storage/db.js +57 -1
  89. package/dist/storage/db.js.map +1 -1
  90. package/dist/storage/members.d.ts.map +1 -1
  91. package/dist/storage/members.js +3 -1
  92. package/dist/storage/members.js.map +1 -1
  93. package/dist/storage/restIdempotency.d.ts +6 -0
  94. package/dist/storage/restIdempotency.d.ts.map +1 -1
  95. package/dist/storage/restIdempotency.js +8 -0
  96. package/dist/storage/restIdempotency.js.map +1 -1
  97. package/dist/storage/scheduledSends.d.ts +2 -1
  98. package/dist/storage/scheduledSends.d.ts.map +1 -1
  99. package/dist/storage/scheduledSends.js +6 -0
  100. package/dist/storage/scheduledSends.js.map +1 -1
  101. 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.object({ code: z.string(), message: z.string(), data: z.record(z.unknown()).optional() }).strict(),
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: MessageBodySchema,
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.string().optional(),
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.array(messageId).min(1),
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
- error: { code: err.code, message: err.message, ...(err.data ? { data: err.data } : {}) },
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: { code: 'internal', message: 'The request could not be completed.' } },
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: { 'content-type': 'application/json; charset=UTF-8', 'cache-control': 'no-store' },
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, failure.retryAfter ? { 'retry-after': failure.retryAfter } : {});
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 EmailError('invalid_request', 'Attachment is too large to return through the REST API.', {
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 EmailError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
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 EmailError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
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 EmailError('invalid_request', 'Use the dedicated archive or trash action for this folder.');
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 EmailError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
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 EmailError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
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: { content: { 'application/json': { schema: dataEnvelope(SendResultSchema) } }, description: 'Message sent' },
1568
+ 200: {
1569
+ content: { 'application/json': { schema: dataEnvelope(DeliveryOperationSchema) } },
1570
+ description: 'Delivery operation',
1571
+ },
1392
1572
  202: {
1393
- content: { 'application/json': { schema: dataEnvelope(ScheduledSendSchema) } },
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
- idempotent: true,
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
- data: sendAt
1422
- ? await c.get('restService').scheduleSend(accountId, request, sendAt)
1423
- : await c.get('restService').send(accountId, request),
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: { content: { 'application/json': { schema: dataEnvelope(SendResultSchema) } }, description: 'Forward sent' },
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
- idempotent: true,
1716
+ legacyReplay: true,
1505
1717
  accountId,
1506
1718
  telemetry: { include_attachments: input.includeAttachments !== false },
1507
- }, async () => ({
1508
- data: await c.get('restService').forward(accountId, { messageId, ...input }),
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(z.object({ modified: z.number().int(), action: z.enum(modifyActionNames) }).strict()),
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 { data: { modified: input.messageIds.length, action: input.action } };
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 EmailError('invalid_request', 'Malformed JSON body.')
1800
+ ? new ClientInputError('invalid_request', 'Malformed JSON body.')
1582
1801
  : err;
1583
- logFailure(deps.logger, 'rest.unhandled_error', malformedJson, { productSurface: 'rest' });
1584
- const failure = apiFailure(malformedJson);
1585
- return jsonResponse(failure.payload, failure.status);
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
  }