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.
Files changed (91) hide show
  1. package/README.md +1 -1
  2. package/dist/accounts/googleAuth.d.ts.map +1 -1
  3. package/dist/accounts/googleAuth.js +2 -1
  4. package/dist/accounts/googleAuth.js.map +1 -1
  5. package/dist/accounts/registry.d.ts.map +1 -1
  6. package/dist/accounts/registry.js +12 -5
  7. package/dist/accounts/registry.js.map +1 -1
  8. package/dist/accounts/telemetry.d.ts +3 -0
  9. package/dist/accounts/telemetry.d.ts.map +1 -1
  10. package/dist/accounts/telemetry.js +18 -0
  11. package/dist/accounts/telemetry.js.map +1 -1
  12. package/dist/cli.d.ts.map +1 -1
  13. package/dist/cli.js +83 -24
  14. package/dist/cli.js.map +1 -1
  15. package/dist/cliInstances.d.ts +5 -2
  16. package/dist/cliInstances.d.ts.map +1 -1
  17. package/dist/cliInstances.js +74 -22
  18. package/dist/cliInstances.js.map +1 -1
  19. package/dist/cliMail.d.ts +2 -0
  20. package/dist/cliMail.d.ts.map +1 -1
  21. package/dist/cliMail.js +106 -18
  22. package/dist/cliMail.js.map +1 -1
  23. package/dist/cliOutput.d.ts +9 -0
  24. package/dist/cliOutput.d.ts.map +1 -0
  25. package/dist/cliOutput.js +76 -0
  26. package/dist/cliOutput.js.map +1 -0
  27. package/dist/context.js +1 -1
  28. package/dist/context.js.map +1 -1
  29. package/dist/http/admin.d.ts.map +1 -1
  30. package/dist/http/admin.js +2 -0
  31. package/dist/http/admin.js.map +1 -1
  32. package/dist/http/app.d.ts.map +1 -1
  33. package/dist/http/app.js +4 -0
  34. package/dist/http/app.js.map +1 -1
  35. package/dist/http/identity.d.ts.map +1 -1
  36. package/dist/http/identity.js +2 -0
  37. package/dist/http/identity.js.map +1 -1
  38. package/dist/http/rest.d.ts.map +1 -1
  39. package/dist/http/rest.js +250 -48
  40. package/dist/http/rest.js.map +1 -1
  41. package/dist/licensing/entitlements.d.ts +23 -5
  42. package/dist/licensing/entitlements.d.ts.map +1 -1
  43. package/dist/licensing/entitlements.js +101 -22
  44. package/dist/licensing/entitlements.js.map +1 -1
  45. package/dist/licensing/lease.d.ts +1 -1
  46. package/dist/licensing/lease.d.ts.map +1 -1
  47. package/dist/licensing/refresher.d.ts.map +1 -1
  48. package/dist/licensing/refresher.js +5 -2
  49. package/dist/licensing/refresher.js.map +1 -1
  50. package/dist/mcp/buildServer.d.ts.map +1 -1
  51. package/dist/mcp/buildServer.js +230 -78
  52. package/dist/mcp/buildServer.js.map +1 -1
  53. package/dist/mcp/outputSchemas.d.ts +2923 -0
  54. package/dist/mcp/outputSchemas.d.ts.map +1 -0
  55. package/dist/mcp/outputSchemas.js +108 -0
  56. package/dist/mcp/outputSchemas.js.map +1 -0
  57. package/dist/scheduler/sendScheduler.d.ts +2 -0
  58. package/dist/scheduler/sendScheduler.d.ts.map +1 -1
  59. package/dist/scheduler/sendScheduler.js +13 -3
  60. package/dist/scheduler/sendScheduler.js.map +1 -1
  61. package/dist/service/deliveryCoordinator.d.ts +45 -0
  62. package/dist/service/deliveryCoordinator.d.ts.map +1 -0
  63. package/dist/service/deliveryCoordinator.js +296 -0
  64. package/dist/service/deliveryCoordinator.js.map +1 -0
  65. package/dist/service/emailService.d.ts +47 -5
  66. package/dist/service/emailService.d.ts.map +1 -1
  67. package/dist/service/emailService.js +245 -39
  68. package/dist/service/emailService.js.map +1 -1
  69. package/dist/service/publicErrors.d.ts +12 -0
  70. package/dist/service/publicErrors.d.ts.map +1 -0
  71. package/dist/service/publicErrors.js +41 -0
  72. package/dist/service/publicErrors.js.map +1 -0
  73. package/dist/service/searchCursor.d.ts.map +1 -1
  74. package/dist/service/searchCursor.js +2 -1
  75. package/dist/service/searchCursor.js.map +1 -1
  76. package/dist/storage/db.d.ts +252 -2
  77. package/dist/storage/db.d.ts.map +1 -1
  78. package/dist/storage/db.js +57 -1
  79. package/dist/storage/db.js.map +1 -1
  80. package/dist/storage/members.d.ts.map +1 -1
  81. package/dist/storage/members.js +3 -1
  82. package/dist/storage/members.js.map +1 -1
  83. package/dist/storage/restIdempotency.d.ts +6 -0
  84. package/dist/storage/restIdempotency.d.ts.map +1 -1
  85. package/dist/storage/restIdempotency.js +8 -0
  86. package/dist/storage/restIdempotency.js.map +1 -1
  87. package/dist/storage/scheduledSends.d.ts +2 -1
  88. package/dist/storage/scheduledSends.d.ts.map +1 -1
  89. package/dist/storage/scheduledSends.js +6 -0
  90. package/dist/storage/scheduledSends.js.map +1 -1
  91. 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.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(),
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: MessageBodySchema,
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.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.'),
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.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.'),
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
- error: { code: err.code, message: err.message, ...(err.data ? { data: err.data } : {}) },
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: { code: 'internal', message: 'The request could not be completed.' } },
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: { '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
+ },
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, 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
+ });
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 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.', {
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 EmailError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
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 EmailError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
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 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.');
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 EmailError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
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 EmailError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
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: { content: { 'application/json': { schema: dataEnvelope(SendResultSchema) } }, description: 'Message sent' },
1568
+ 200: {
1569
+ content: { 'application/json': { schema: dataEnvelope(DeliveryOperationSchema) } },
1570
+ description: 'Delivery operation',
1571
+ },
1412
1572
  202: {
1413
- content: { 'application/json': { schema: dataEnvelope(ScheduledSendSchema) } },
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
- idempotent: true,
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
- data: sendAt
1442
- ? await c.get('restService').scheduleSend(accountId, request, sendAt)
1443
- : 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),
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: { content: { 'application/json': { schema: dataEnvelope(SendResultSchema) } }, description: 'Forward sent' },
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
- idempotent: true,
1716
+ legacyReplay: true,
1525
1717
  accountId,
1526
1718
  telemetry: { include_attachments: input.includeAttachments !== false },
1527
- }, async () => ({
1528
- data: await c.get('restService').forward(accountId, { messageId, ...input }),
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(z.object({ modified: z.number().int(), action: z.enum(modifyActionNames) }).strict()),
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 { 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
+ };
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 EmailError('invalid_request', 'Malformed JSON body.')
1800
+ ? new ClientInputError('invalid_request', 'Malformed JSON body.')
1602
1801
  : err;
1603
- logFailure(deps.logger, 'rest.unhandled_error', malformedJson, { productSurface: 'rest' });
1604
- const failure = apiFailure(malformedJson);
1605
- 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 });
1606
1808
  });
1607
1809
  return app;
1608
1810
  }