@c9up/rover 0.1.8 → 0.1.10

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 (71) hide show
  1. package/README.md +1 -1
  2. package/dist/BaseMail.d.ts +41 -1
  3. package/dist/BaseMail.d.ts.map +1 -1
  4. package/dist/BaseMail.js +48 -1
  5. package/dist/BaseMail.js.map +1 -1
  6. package/dist/Mail.d.ts +90 -24
  7. package/dist/Mail.d.ts.map +1 -1
  8. package/dist/Mail.js +123 -40
  9. package/dist/Mail.js.map +1 -1
  10. package/dist/MessageBuilder.d.ts +296 -8
  11. package/dist/MessageBuilder.d.ts.map +1 -1
  12. package/dist/MessageBuilder.js +537 -8
  13. package/dist/MessageBuilder.js.map +1 -1
  14. package/dist/config.d.ts +38 -0
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +34 -0
  17. package/dist/config.js.map +1 -1
  18. package/dist/format.d.ts +10 -0
  19. package/dist/format.d.ts.map +1 -1
  20. package/dist/format.js +28 -1
  21. package/dist/format.js.map +1 -1
  22. package/dist/index.d.ts +19 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +16 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/testing/FakeMail.d.ts +33 -0
  27. package/dist/testing/FakeMail.d.ts.map +1 -1
  28. package/dist/testing/FakeMail.js +28 -0
  29. package/dist/testing/FakeMail.js.map +1 -1
  30. package/dist/transports/BrevoTransport.d.ts.map +1 -1
  31. package/dist/transports/BrevoTransport.js +7 -5
  32. package/dist/transports/BrevoTransport.js.map +1 -1
  33. package/dist/transports/MailgunTransport.d.ts.map +1 -1
  34. package/dist/transports/MailgunTransport.js +5 -3
  35. package/dist/transports/MailgunTransport.js.map +1 -1
  36. package/dist/transports/ResendTransport.d.ts.map +1 -1
  37. package/dist/transports/ResendTransport.js +7 -5
  38. package/dist/transports/ResendTransport.js.map +1 -1
  39. package/dist/transports/SendGridTransport.d.ts.map +1 -1
  40. package/dist/transports/SendGridTransport.js +10 -6
  41. package/dist/transports/SendGridTransport.js.map +1 -1
  42. package/dist/transports/SesTransport.d.ts.map +1 -1
  43. package/dist/transports/SesTransport.js +16 -7
  44. package/dist/transports/SesTransport.js.map +1 -1
  45. package/dist/transports/SparkPostTransport.d.ts.map +1 -1
  46. package/dist/transports/SparkPostTransport.js +7 -5
  47. package/dist/transports/SparkPostTransport.js.map +1 -1
  48. package/dist/transports/fetchError.d.ts +10 -0
  49. package/dist/transports/fetchError.d.ts.map +1 -1
  50. package/dist/transports/fetchError.js +30 -0
  51. package/dist/transports/fetchError.js.map +1 -1
  52. package/index.darwin-arm64.node +0 -0
  53. package/index.darwin-x64.node +0 -0
  54. package/index.linux-arm64-gnu.node +0 -0
  55. package/index.linux-x64-gnu.node +0 -0
  56. package/index.win32-x64-msvc.node +0 -0
  57. package/package.json +6 -1
  58. package/src/BaseMail.ts +59 -2
  59. package/src/Mail.ts +194 -65
  60. package/src/MessageBuilder.ts +756 -12
  61. package/src/config.ts +46 -0
  62. package/src/format.ts +33 -1
  63. package/src/index.ts +31 -2
  64. package/src/testing/FakeMail.ts +46 -0
  65. package/src/transports/BrevoTransport.ts +7 -5
  66. package/src/transports/MailgunTransport.ts +5 -3
  67. package/src/transports/ResendTransport.ts +7 -5
  68. package/src/transports/SendGridTransport.ts +10 -6
  69. package/src/transports/SesTransport.ts +16 -7
  70. package/src/transports/SparkPostTransport.ts +17 -11
  71. package/src/transports/fetchError.ts +39 -0
package/src/Mail.ts CHANGED
@@ -4,6 +4,7 @@ import { BaseMail } from "./BaseMail.js";
4
4
  import {
5
5
  type MailAttachment,
6
6
  type MailMessage,
7
+ type MessageBodyTemplates,
7
8
  MessageBuilder,
8
9
  } from "./MessageBuilder.js";
9
10
  import {
@@ -59,6 +60,11 @@ export type MailSendOutcome = MailSendResult | undefined;
59
60
 
60
61
  export interface MailTransport {
61
62
  send(message: MailMessage): Promise<MailSendOutcome>;
63
+ /**
64
+ * Release what this transport holds open — an SMTP connection pool, mostly.
65
+ * Optional: an HTTP-API transport has nothing to close.
66
+ */
67
+ close?(): Promise<void>;
62
68
  }
63
69
 
64
70
  /**
@@ -70,24 +76,40 @@ export interface EmitterLike {
70
76
  }
71
77
 
72
78
  /**
73
- * Emitted (`mail:sending`) right before the transport `send` runs — no
74
- * `messageId` yet, since the provider hasn't accepted the message.
79
+ * What every mail lifecycle event carries, matching `@adonisjs/mail`:
80
+ * the mailer that handled it, the message itself, and the templates it was
81
+ * rendered from. A listener migrating over reads `message.to` / `views.html`.
82
+ *
83
+ * `transportName` and the flattened recipient lists are rover's own and stay:
84
+ * the transport name is the same string as `mailerName` under the name rover
85
+ * used first, and a listener that only wants the addresses should not have to
86
+ * reach into the message for them.
75
87
  */
76
- export interface MailSendingEvent {
88
+ export type { MessageBodyTemplates };
89
+
90
+ export interface MailEventBase {
91
+ /** AdonisJS name for the mailer that handled the message. */
92
+ mailerName: string;
93
+ /** The built message. */
94
+ message: MailMessage;
95
+ /** Templates the message was rendered from, empty when it carried none. */
96
+ views: MessageBodyTemplates;
77
97
  to: string[];
78
98
  cc: string[];
79
99
  bcc: string[];
100
+ /** rover's original name for {@link MailEventBase.mailerName}. */
80
101
  transportName: string;
81
102
  timestamp: number;
82
103
  }
83
104
 
84
- export interface MailSentEvent {
105
+ /**
106
+ * Emitted (`mail:sending`) right before the transport `send` runs — no
107
+ * `messageId` yet, since the provider hasn't accepted the message.
108
+ */
109
+ export interface MailSendingEvent extends MailEventBase {}
110
+
111
+ export interface MailSentEvent extends MailEventBase {
85
112
  messageId: string;
86
- to: string[];
87
- cc: string[];
88
- bcc: string[];
89
- transportName: string;
90
- timestamp: number;
91
113
  }
92
114
 
93
115
  /**
@@ -95,22 +117,13 @@ export interface MailSentEvent {
95
117
  * only present on `mail:queued` (once the job has been accepted by the queue /
96
118
  * in-memory messenger).
97
119
  */
98
- export interface MailQueueEvent {
99
- to: string[];
100
- cc: string[];
101
- bcc: string[];
102
- transportName: string;
120
+ export interface MailQueueEvent extends MailEventBase {
103
121
  queue: string;
104
122
  jobId?: string;
105
- timestamp: number;
106
123
  }
107
124
 
108
- export interface MailFailedEvent {
125
+ export interface MailFailedEvent extends MailEventBase {
109
126
  messageId: string;
110
- to: string[];
111
- cc: string[];
112
- bcc: string[];
113
- transportName: string;
114
127
  error: {
115
128
  code: string;
116
129
  message: string;
@@ -118,13 +131,21 @@ export interface MailFailedEvent {
118
131
  upstreamStatusRaw?: string;
119
132
  attempts: number;
120
133
  };
121
- timestamp: number;
122
134
  }
123
135
 
124
136
  export interface MailConfig {
125
137
  default: string;
126
138
  from: string;
127
- transports: Record<
139
+ transports?: Record<
140
+ string,
141
+ { transport: string; retry?: RetryConfig; [key: string]: unknown }
142
+ >;
143
+ /**
144
+ * AdonisJS spelling of `transports`. Both are accepted and mean the same
145
+ * thing, so a migrated `config/mail.ts` runs with its imports rewritten and
146
+ * nothing else.
147
+ */
148
+ mailers?: Record<
128
149
  string,
129
150
  { transport: string; retry?: RetryConfig; [key: string]: unknown }
130
151
  >;
@@ -236,6 +257,10 @@ export class SmtpTransport implements MailTransport {
236
257
  subject: message.subject,
237
258
  html: message.html,
238
259
  text: message.text,
260
+ // nodemailer reads `watchHtml`; there is no bare `watch` field in
261
+ // its mail composer, so writing one — as AdonisJS does — never
262
+ // reaches the wire.
263
+ watchHtml: message.watchHtml,
239
264
  priority: message.priority,
240
265
  messageId: message.messageId,
241
266
  inReplyTo: message.inReplyTo,
@@ -243,12 +268,20 @@ export class SmtpTransport implements MailTransport {
243
268
  headers: Object.keys(message.headers).length
244
269
  ? message.headers
245
270
  : undefined,
271
+ encoding: message.encoding,
272
+ // `list` is NOT passed: `build()` already rendered the `List-*`
273
+ // headers into `headers`, so handing nodemailer the structured form
274
+ // too would emit each of them twice.
275
+ icalEvent: message.icalEvent,
246
276
  attachments: message.attachments.length
247
277
  ? message.attachments.map((att) => ({
248
278
  filename: att.filename,
249
279
  content: att.content,
250
280
  contentType: att.contentType,
251
281
  cid: att.cid,
282
+ contentDisposition: att.contentDisposition,
283
+ encoding: att.encoding,
284
+ headers: att.headers,
252
285
  }))
253
286
  : undefined,
254
287
  });
@@ -258,6 +291,14 @@ export class SmtpTransport implements MailTransport {
258
291
  throw wrapSmtpError(err);
259
292
  }
260
293
  }
294
+
295
+ /**
296
+ * Drain nodemailer's connection pool. Idempotent — nodemailer tolerates a
297
+ * second close, and a shutdown path may run twice.
298
+ */
299
+ async close(): Promise<void> {
300
+ this.#transporter.close();
301
+ }
261
302
  }
262
303
 
263
304
  /**
@@ -377,7 +418,10 @@ export class Mail {
377
418
  setViewsRoot(config.viewsRoot);
378
419
  }
379
420
 
380
- for (const [name, transportConfig] of Object.entries(config.transports)) {
421
+ // `mailers` (AdonisJS) and `transports` (rover) are the same map under two
422
+ // names; a config that sets both gets both, last name wins per key.
423
+ const declared = { ...config.transports, ...config.mailers };
424
+ for (const [name, transportConfig] of Object.entries(declared)) {
381
425
  const factory = transportFactories[transportConfig.transport];
382
426
  if (!factory) {
383
427
  throw new RoverError(
@@ -395,11 +439,53 @@ export class Mail {
395
439
  }
396
440
 
397
441
  if (options?.queue) {
398
- this.#queue = options.queue;
399
- this.#queue.register(this.#queueName, new MailJobHandler(this));
442
+ this.setMessenger(options.queue);
400
443
  }
401
444
  }
402
445
 
446
+ /**
447
+ * Send a message that is already built (AdonisJS `sendCompiled`).
448
+ *
449
+ * What a queue worker calls: the message was composed and serialised
450
+ * elsewhere, so there is nothing left to render. An alias of
451
+ * {@link dispatchMessage}, which is the name ream used first.
452
+ */
453
+ async sendCompiled(message: MailMessage, transport?: string): Promise<void> {
454
+ await this.dispatchMessage(message, transport);
455
+ }
456
+
457
+ /**
458
+ * Queue a message that is already built (AdonisJS `sendLaterCompiled`).
459
+ *
460
+ * Returns the job id, like {@link sendLater}.
461
+ */
462
+ async sendLaterCompiled(
463
+ message: MailMessage,
464
+ options?: { transport?: string; queue?: string },
465
+ ): Promise<string> {
466
+ // No views: a compiled message was rendered elsewhere, and the templates
467
+ // that produced it did not travel with it.
468
+ return this.#enqueue({ message, views: {} }, options);
469
+ }
470
+
471
+ /**
472
+ * Hand `sendLater()` a queue after construction (AdonisJS `setMessenger`).
473
+ *
474
+ * The constructor takes one too, but a queue is often resolved later than
475
+ * the mailer — a provider that boots after this one, a test that swaps it.
476
+ * Without this the only way in was the constructor, so an app migrating
477
+ * from `mail.setMessenger(queue)` stopped at a TypeError.
478
+ *
479
+ * "Messenger" is upstream's word for what rover's config calls `queue`;
480
+ * they are the same thing, and the method keeps the upstream name so a
481
+ * migrated call site resolves.
482
+ */
483
+ setMessenger(messenger: BayQueueLike): this {
484
+ this.#queue = messenger;
485
+ this.#queue.register(this.#queueName, new MailJobHandler(this));
486
+ return this;
487
+ }
488
+
403
489
  /** Send an email using the fluent message builder. */
404
490
  async send(
405
491
  callback: (message: MessageBuilder) => void,
@@ -416,15 +502,9 @@ export class Mail {
416
502
  // BaseMail, for constructor-based assertions) instead of touching a
417
503
  // transport. Still fire the send lifecycle so event wiring stays testable.
418
504
  if (this.#fake !== null) {
419
- const message = await this.#buildMessage(arg);
505
+ const { message, views } = await this.#buildMessageWithViews(arg);
420
506
  this.#fake.trackSent(message, arg instanceof BaseMail ? arg : undefined);
421
- const base = {
422
- to: message.to.slice(),
423
- cc: message.cc.slice(),
424
- bcc: message.bcc.slice(),
425
- transportName,
426
- timestamp: Date.now(),
427
- };
507
+ const base = this.#eventBase(message, views, transportName);
428
508
  this.#fireSending(base);
429
509
  this.#fireSent({ ...base, messageId: randomBytes(16).toString("hex") });
430
510
  return;
@@ -434,8 +514,8 @@ export class Mail {
434
514
  throw new Error(`Mail transport '${transportName}' not configured`);
435
515
  }
436
516
 
437
- const message = await this.#buildMessage(arg);
438
- await this.dispatchMessage(message, transportName);
517
+ const { message, views } = await this.#buildMessageWithViews(arg);
518
+ await this.dispatchMessage(message, transportName, undefined, views);
439
519
  }
440
520
 
441
521
  /**
@@ -448,24 +528,34 @@ export class Mail {
448
528
  arg: ((message: MessageBuilder) => void) | BaseMail,
449
529
  options?: { transport?: string; queue?: string },
450
530
  ): Promise<string> {
451
- const message = await this.#buildMessage(arg);
531
+ const built = await this.#buildMessageWithViews(arg);
532
+ return this.#enqueue(
533
+ { ...built, source: arg instanceof BaseMail ? arg : undefined },
534
+ options,
535
+ );
536
+ }
537
+
538
+ /** The queueing half, shared with {@link sendLaterCompiled}. */
539
+ async #enqueue(
540
+ built: {
541
+ message: MailMessage;
542
+ views: MessageBodyTemplates;
543
+ /** The BaseMail it came from, for `assertQueued(WelcomeMail)`. */
544
+ source?: BaseMail;
545
+ },
546
+ options?: { transport?: string; queue?: string },
547
+ ): Promise<string> {
548
+ const { message, views } = built;
452
549
  const queueName = options?.queue ?? this.#queueName;
453
550
  const transportName = options?.transport ?? this.#defaultTransport;
454
551
  const base: MailQueueEvent = {
455
- to: message.to.slice(),
456
- cc: message.cc.slice(),
457
- bcc: message.bcc.slice(),
458
- transportName,
552
+ ...this.#eventBase(message, views, transportName),
459
553
  queue: queueName,
460
- timestamp: Date.now(),
461
554
  };
462
555
 
463
556
  // Fake mode: capture into the queued bucket, don't dispatch.
464
557
  if (this.#fake !== null) {
465
- this.#fake.trackQueued(
466
- message,
467
- arg instanceof BaseMail ? arg : undefined,
468
- );
558
+ this.#fake.trackQueued(message, built.source);
469
559
  const jobId = `fake_${randomBytes(12).toString("hex")}`;
470
560
  this.#fireQueueing(base);
471
561
  this.#fireQueued({ ...base, jobId });
@@ -497,6 +587,11 @@ export class Mail {
497
587
  message: MailMessage,
498
588
  transportName?: string,
499
589
  overrideRetry?: RetryConfig,
590
+ // The templates the message came from, when the caller still knows them.
591
+ // A message revived from a queue payload does not: the rendered bodies
592
+ // were serialised, the templates that produced them were not. Empty is
593
+ // the honest answer there rather than a guess.
594
+ views: MessageBodyTemplates = {},
500
595
  ): Promise<void> {
501
596
  // Defense-in-depth: queue payloads bypass `#buildMessage`, so a
502
597
  // malformed message deserialised from storage would otherwise reach
@@ -517,13 +612,7 @@ export class Mail {
517
612
 
518
613
  // Fire once before the first attempt — `mail:sending` signals intent, not
519
614
  // per-retry, matching @adonisjs/mail.
520
- this.#fireSending({
521
- to: message.to.slice(),
522
- cc: message.cc.slice(),
523
- bcc: message.bcc.slice(),
524
- transportName: name,
525
- timestamp: Date.now(),
526
- });
615
+ this.#fireSending(this.#eventBase(message, views, name));
527
616
 
528
617
  for (let attempt = 1; attempt <= retry.maxAttempts; attempt += 1) {
529
618
  let sendResult: MailSendOutcome;
@@ -535,13 +624,9 @@ export class Mail {
535
624
  if (!retryable || attempt === retry.maxAttempts) {
536
625
  const annotated = this.#withAttempts(err, attempt);
537
626
  this.#fireFailed({
627
+ ...this.#eventBase(message, views, name),
538
628
  messageId: generatedId,
539
- to: message.to.slice(),
540
- cc: message.cc.slice(),
541
- bcc: message.bcc.slice(),
542
- transportName: name,
543
629
  error: errorDescriptor(annotated, attempt),
544
- timestamp: Date.now(),
545
630
  });
546
631
  throw annotated;
547
632
  }
@@ -563,12 +648,8 @@ export class Mail {
563
648
  const providerId =
564
649
  providerIdRaw && providerIdRaw.length > 0 ? providerIdRaw : undefined;
565
650
  this.#fireSent({
651
+ ...this.#eventBase(message, views, name),
566
652
  messageId: providerId ?? generatedId,
567
- to: message.to.slice(),
568
- cc: message.cc.slice(),
569
- bcc: message.bcc.slice(),
570
- transportName: name,
571
- timestamp: Date.now(),
572
653
  });
573
654
  return;
574
655
  }
@@ -641,21 +722,49 @@ export class Mail {
641
722
  }
642
723
  }
643
724
 
644
- async #buildMessage(
725
+ /**
726
+ * Build the message AND report which templates it was rendered from, since
727
+ * every lifecycle event carries both (AdonisJS `message` + `views`).
728
+ */
729
+ async #buildMessageWithViews(
645
730
  arg: ((message: MessageBuilder) => void) | BaseMail,
646
- ): Promise<MailMessage> {
731
+ ): Promise<{ message: MailMessage; views: MessageBodyTemplates }> {
647
732
  let result: MailMessage;
733
+ let views: MessageBodyTemplates;
648
734
  if (arg instanceof BaseMail) {
649
735
  const built = await arg.build(this.#viewsRoot);
650
736
  result = built.from ? built : { ...built, from: this.#defaultFrom };
737
+ views = arg.message.views;
651
738
  } else {
652
739
  const builder = new MessageBuilder();
653
740
  builder.from(this.#defaultFrom);
654
741
  arg(builder);
655
742
  result = await builder.build(this.#viewsRoot);
743
+ views = builder.views;
656
744
  }
657
745
  validateMailMessage(result);
658
- return result;
746
+ return { message: result, views };
747
+ }
748
+
749
+ /**
750
+ * The fields every lifecycle event shares. One place, so `mail:sending` and
751
+ * `mail:sent` cannot describe the same message differently.
752
+ */
753
+ #eventBase(
754
+ message: MailMessage,
755
+ views: MessageBodyTemplates,
756
+ mailerName: string,
757
+ ): MailEventBase {
758
+ return {
759
+ mailerName,
760
+ message,
761
+ views,
762
+ to: message.to.slice(),
763
+ cc: message.cc.slice(),
764
+ bcc: message.bcc.slice(),
765
+ transportName: mailerName,
766
+ timestamp: Date.now(),
767
+ };
659
768
  }
660
769
 
661
770
  /**
@@ -681,6 +790,26 @@ export class Mail {
681
790
  return t;
682
791
  }
683
792
 
793
+ /**
794
+ * Close one transport's open connections (Adonis `close`).
795
+ *
796
+ * An SMTP pool keeps sockets alive between sends; a process that exits
797
+ * without closing them leaves the server holding connections until it times
798
+ * them out. Unknown or already-closed names are a no-op — shutdown is not
799
+ * the place to throw.
800
+ */
801
+ async close(name?: string): Promise<void> {
802
+ const transportName = name ?? this.#defaultTransport;
803
+ await this.#transports.get(transportName)?.close?.();
804
+ }
805
+
806
+ /** Close every built transport (Adonis `closeAll`). What a shutdown hook calls. */
807
+ async closeAll(): Promise<void> {
808
+ await Promise.all(
809
+ [...this.#transports.values()].map((transport) => transport.close?.()),
810
+ );
811
+ }
812
+
684
813
  /**
685
814
  * Enter fake mode. Every subsequent `send()` / `sendLater()` — including via
686
815
  * `use(name)` — is captured by the returned `FakeMail` instead of hitting a