@c9up/rover 0.1.9 → 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 (45) hide show
  1. package/dist/BaseMail.d.ts +11 -0
  2. package/dist/BaseMail.d.ts.map +1 -1
  3. package/dist/BaseMail.js +18 -1
  4. package/dist/BaseMail.js.map +1 -1
  5. package/dist/Mail.d.ts +58 -23
  6. package/dist/Mail.d.ts.map +1 -1
  7. package/dist/Mail.js +88 -39
  8. package/dist/Mail.js.map +1 -1
  9. package/dist/MessageBuilder.d.ts +107 -9
  10. package/dist/MessageBuilder.d.ts.map +1 -1
  11. package/dist/MessageBuilder.js +151 -16
  12. package/dist/MessageBuilder.js.map +1 -1
  13. package/dist/index.d.ts +3 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/transports/BrevoTransport.d.ts.map +1 -1
  18. package/dist/transports/BrevoTransport.js +3 -2
  19. package/dist/transports/BrevoTransport.js.map +1 -1
  20. package/dist/transports/MailgunTransport.d.ts.map +1 -1
  21. package/dist/transports/MailgunTransport.js +3 -2
  22. package/dist/transports/MailgunTransport.js.map +1 -1
  23. package/dist/transports/ResendTransport.d.ts.map +1 -1
  24. package/dist/transports/ResendTransport.js +3 -2
  25. package/dist/transports/ResendTransport.js.map +1 -1
  26. package/dist/transports/SendGridTransport.d.ts.map +1 -1
  27. package/dist/transports/SendGridTransport.js +8 -5
  28. package/dist/transports/SendGridTransport.js.map +1 -1
  29. package/dist/transports/SesTransport.js +9 -2
  30. package/dist/transports/SesTransport.js.map +1 -1
  31. package/dist/transports/SparkPostTransport.d.ts.map +1 -1
  32. package/dist/transports/SparkPostTransport.js +3 -2
  33. package/dist/transports/SparkPostTransport.js.map +1 -1
  34. package/index.win32-x64-msvc.node +0 -0
  35. package/package.json +1 -1
  36. package/src/BaseMail.ts +19 -1
  37. package/src/Mail.ts +139 -63
  38. package/src/MessageBuilder.ts +211 -17
  39. package/src/index.ts +4 -1
  40. package/src/transports/BrevoTransport.ts +3 -2
  41. package/src/transports/MailgunTransport.ts +3 -2
  42. package/src/transports/ResendTransport.ts +3 -2
  43. package/src/transports/SendGridTransport.ts +8 -5
  44. package/src/transports/SesTransport.ts +9 -2
  45. package/src/transports/SparkPostTransport.ts +3 -2
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 {
@@ -75,24 +76,40 @@ export interface EmitterLike {
75
76
  }
76
77
 
77
78
  /**
78
- * Emitted (`mail:sending`) right before the transport `send` runs — no
79
- * `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.
80
87
  */
81
- 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;
82
97
  to: string[];
83
98
  cc: string[];
84
99
  bcc: string[];
100
+ /** rover's original name for {@link MailEventBase.mailerName}. */
85
101
  transportName: string;
86
102
  timestamp: number;
87
103
  }
88
104
 
89
- 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 {
90
112
  messageId: string;
91
- to: string[];
92
- cc: string[];
93
- bcc: string[];
94
- transportName: string;
95
- timestamp: number;
96
113
  }
97
114
 
98
115
  /**
@@ -100,22 +117,13 @@ export interface MailSentEvent {
100
117
  * only present on `mail:queued` (once the job has been accepted by the queue /
101
118
  * in-memory messenger).
102
119
  */
103
- export interface MailQueueEvent {
104
- to: string[];
105
- cc: string[];
106
- bcc: string[];
107
- transportName: string;
120
+ export interface MailQueueEvent extends MailEventBase {
108
121
  queue: string;
109
122
  jobId?: string;
110
- timestamp: number;
111
123
  }
112
124
 
113
- export interface MailFailedEvent {
125
+ export interface MailFailedEvent extends MailEventBase {
114
126
  messageId: string;
115
- to: string[];
116
- cc: string[];
117
- bcc: string[];
118
- transportName: string;
119
127
  error: {
120
128
  code: string;
121
129
  message: string;
@@ -123,7 +131,6 @@ export interface MailFailedEvent {
123
131
  upstreamStatusRaw?: string;
124
132
  attempts: number;
125
133
  };
126
- timestamp: number;
127
134
  }
128
135
 
129
136
  export interface MailConfig {
@@ -250,6 +257,10 @@ export class SmtpTransport implements MailTransport {
250
257
  subject: message.subject,
251
258
  html: message.html,
252
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,
253
264
  priority: message.priority,
254
265
  messageId: message.messageId,
255
266
  inReplyTo: message.inReplyTo,
@@ -428,11 +439,53 @@ export class Mail {
428
439
  }
429
440
 
430
441
  if (options?.queue) {
431
- this.#queue = options.queue;
432
- this.#queue.register(this.#queueName, new MailJobHandler(this));
442
+ this.setMessenger(options.queue);
433
443
  }
434
444
  }
435
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
+
436
489
  /** Send an email using the fluent message builder. */
437
490
  async send(
438
491
  callback: (message: MessageBuilder) => void,
@@ -449,15 +502,9 @@ export class Mail {
449
502
  // BaseMail, for constructor-based assertions) instead of touching a
450
503
  // transport. Still fire the send lifecycle so event wiring stays testable.
451
504
  if (this.#fake !== null) {
452
- const message = await this.#buildMessage(arg);
505
+ const { message, views } = await this.#buildMessageWithViews(arg);
453
506
  this.#fake.trackSent(message, arg instanceof BaseMail ? arg : undefined);
454
- const base = {
455
- to: message.to.slice(),
456
- cc: message.cc.slice(),
457
- bcc: message.bcc.slice(),
458
- transportName,
459
- timestamp: Date.now(),
460
- };
507
+ const base = this.#eventBase(message, views, transportName);
461
508
  this.#fireSending(base);
462
509
  this.#fireSent({ ...base, messageId: randomBytes(16).toString("hex") });
463
510
  return;
@@ -467,8 +514,8 @@ export class Mail {
467
514
  throw new Error(`Mail transport '${transportName}' not configured`);
468
515
  }
469
516
 
470
- const message = await this.#buildMessage(arg);
471
- await this.dispatchMessage(message, transportName);
517
+ const { message, views } = await this.#buildMessageWithViews(arg);
518
+ await this.dispatchMessage(message, transportName, undefined, views);
472
519
  }
473
520
 
474
521
  /**
@@ -481,24 +528,34 @@ export class Mail {
481
528
  arg: ((message: MessageBuilder) => void) | BaseMail,
482
529
  options?: { transport?: string; queue?: string },
483
530
  ): Promise<string> {
484
- 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;
485
549
  const queueName = options?.queue ?? this.#queueName;
486
550
  const transportName = options?.transport ?? this.#defaultTransport;
487
551
  const base: MailQueueEvent = {
488
- to: message.to.slice(),
489
- cc: message.cc.slice(),
490
- bcc: message.bcc.slice(),
491
- transportName,
552
+ ...this.#eventBase(message, views, transportName),
492
553
  queue: queueName,
493
- timestamp: Date.now(),
494
554
  };
495
555
 
496
556
  // Fake mode: capture into the queued bucket, don't dispatch.
497
557
  if (this.#fake !== null) {
498
- this.#fake.trackQueued(
499
- message,
500
- arg instanceof BaseMail ? arg : undefined,
501
- );
558
+ this.#fake.trackQueued(message, built.source);
502
559
  const jobId = `fake_${randomBytes(12).toString("hex")}`;
503
560
  this.#fireQueueing(base);
504
561
  this.#fireQueued({ ...base, jobId });
@@ -530,6 +587,11 @@ export class Mail {
530
587
  message: MailMessage,
531
588
  transportName?: string,
532
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 = {},
533
595
  ): Promise<void> {
534
596
  // Defense-in-depth: queue payloads bypass `#buildMessage`, so a
535
597
  // malformed message deserialised from storage would otherwise reach
@@ -550,13 +612,7 @@ export class Mail {
550
612
 
551
613
  // Fire once before the first attempt — `mail:sending` signals intent, not
552
614
  // per-retry, matching @adonisjs/mail.
553
- this.#fireSending({
554
- to: message.to.slice(),
555
- cc: message.cc.slice(),
556
- bcc: message.bcc.slice(),
557
- transportName: name,
558
- timestamp: Date.now(),
559
- });
615
+ this.#fireSending(this.#eventBase(message, views, name));
560
616
 
561
617
  for (let attempt = 1; attempt <= retry.maxAttempts; attempt += 1) {
562
618
  let sendResult: MailSendOutcome;
@@ -568,13 +624,9 @@ export class Mail {
568
624
  if (!retryable || attempt === retry.maxAttempts) {
569
625
  const annotated = this.#withAttempts(err, attempt);
570
626
  this.#fireFailed({
627
+ ...this.#eventBase(message, views, name),
571
628
  messageId: generatedId,
572
- to: message.to.slice(),
573
- cc: message.cc.slice(),
574
- bcc: message.bcc.slice(),
575
- transportName: name,
576
629
  error: errorDescriptor(annotated, attempt),
577
- timestamp: Date.now(),
578
630
  });
579
631
  throw annotated;
580
632
  }
@@ -596,12 +648,8 @@ export class Mail {
596
648
  const providerId =
597
649
  providerIdRaw && providerIdRaw.length > 0 ? providerIdRaw : undefined;
598
650
  this.#fireSent({
651
+ ...this.#eventBase(message, views, name),
599
652
  messageId: providerId ?? generatedId,
600
- to: message.to.slice(),
601
- cc: message.cc.slice(),
602
- bcc: message.bcc.slice(),
603
- transportName: name,
604
- timestamp: Date.now(),
605
653
  });
606
654
  return;
607
655
  }
@@ -674,21 +722,49 @@ export class Mail {
674
722
  }
675
723
  }
676
724
 
677
- 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(
678
730
  arg: ((message: MessageBuilder) => void) | BaseMail,
679
- ): Promise<MailMessage> {
731
+ ): Promise<{ message: MailMessage; views: MessageBodyTemplates }> {
680
732
  let result: MailMessage;
733
+ let views: MessageBodyTemplates;
681
734
  if (arg instanceof BaseMail) {
682
735
  const built = await arg.build(this.#viewsRoot);
683
736
  result = built.from ? built : { ...built, from: this.#defaultFrom };
737
+ views = arg.message.views;
684
738
  } else {
685
739
  const builder = new MessageBuilder();
686
740
  builder.from(this.#defaultFrom);
687
741
  arg(builder);
688
742
  result = await builder.build(this.#viewsRoot);
743
+ views = builder.views;
689
744
  }
690
745
  validateMailMessage(result);
691
- 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
+ };
692
768
  }
693
769
 
694
770
  /**
@@ -5,6 +5,33 @@ import { formatAddress } from "./format.js";
5
5
  import { RoverError } from "./RoverError.js";
6
6
  import { renderFile as renderTemplateFile } from "./templating/SimpleTemplate.js";
7
7
 
8
+ /**
9
+ * A header nodemailer must pass through untouched (`{ prepared: true }`).
10
+ *
11
+ * Normal headers get re-encoded — folded, MIME-encoded when non-ASCII. A value
12
+ * that is already exactly what must go on the wire (a signature, a
13
+ * pre-encoded id) has to say so, or the encoding corrupts it.
14
+ */
15
+ export interface PreparedHeader {
16
+ prepared: true;
17
+ value: string;
18
+ }
19
+
20
+ /**
21
+ * A header value as a plain string, for the HTTP-API transports.
22
+ *
23
+ * "Prepared" is a nodemailer notion — it tells its MIME encoder to leave the
24
+ * value alone. A provider REST API takes a JSON string and does no MIME
25
+ * encoding, so the flag has nothing to say there; sending the wrapper object
26
+ * would put `[object Object]` on the wire.
27
+ */
28
+ export function headerValue(
29
+ value: string | string[] | PreparedHeader,
30
+ ): string | string[] {
31
+ if (Array.isArray(value) || typeof value === "string") return value;
32
+ return value.value;
33
+ }
34
+
8
35
  export interface MailMessage {
9
36
  from: string;
10
37
  to: string[];
@@ -14,8 +41,13 @@ export interface MailMessage {
14
41
  subject: string;
15
42
  html?: string;
16
43
  text?: string;
44
+ /**
45
+ * The Apple Watch body (nodemailer `watchHtml`). A stripped-down HTML part
46
+ * a watch renders instead of the full one.
47
+ */
48
+ watchHtml?: string;
17
49
  attachments: MailAttachment[];
18
- headers: Record<string, string | string[]>;
50
+ headers: Record<string, string | string[] | PreparedHeader>;
19
51
  /** Email priority hint (nodemailer `priority`). */
20
52
  priority?: "low" | "normal" | "high";
21
53
  /** Custom `Message-ID` header (threading / idempotency). */
@@ -115,8 +147,19 @@ export interface RecipientObject {
115
147
  export type Recipient = string | RecipientObject;
116
148
 
117
149
  /** Whether `list` holds `address`, or anything at all when it is omitted. */
118
- function contains(list: readonly string[], address?: string): boolean {
150
+ function contains(
151
+ list: readonly string[],
152
+ address?: string,
153
+ name?: string,
154
+ ): boolean {
119
155
  if (address === undefined) return list.length > 0;
156
+ // With a name, both halves must match — the entry was stored through
157
+ // `formatAddress`, so rebuilding it is the exact comparison (AdonisJS
158
+ // checks address AND name the same way).
159
+ if (name !== undefined) {
160
+ const formatted = formatAddress(address, name);
161
+ return list.some((entry) => entry === formatted);
162
+ }
120
163
  // Addresses are stored formatted (`"Name" <a@b.c>`), so an assertion on the
121
164
  // bare address has to match inside the display form too.
122
165
  return list.some(
@@ -208,6 +251,16 @@ function expect(passed: boolean, expectation: string, actual: unknown): void {
208
251
  );
209
252
  }
210
253
 
254
+ /**
255
+ * Which templates a message was rendered from — AdonisJS
256
+ * `MessageBodyTemplates`, carried on every mail lifecycle event.
257
+ */
258
+ export interface MessageBodyTemplates {
259
+ html?: { template: string; data: Record<string, unknown> };
260
+ text?: { template: string; data: Record<string, unknown> };
261
+ watch?: { template: string; data: Record<string, unknown> };
262
+ }
263
+
211
264
  export class MessageBuilder {
212
265
  #msg: MailMessage = {
213
266
  from: "",
@@ -219,6 +272,15 @@ export class MessageBuilder {
219
272
  headers: {},
220
273
  };
221
274
  #pendingView: { path: string; data: Record<string, unknown> } | null = null;
275
+ /**
276
+ * The templates {@link build} actually rendered. Recorded because `build()`
277
+ * clears the pending views once they are rendered, and the lifecycle events
278
+ * carry them (AdonisJS `views`, the third field of every mail event) — an
279
+ * app logging a send wants to know which template produced it.
280
+ */
281
+ #renderedViews: MessageBodyTemplates = {};
282
+ #pendingWatchView: { path: string; data: Record<string, unknown> } | null =
283
+ null;
222
284
  #pendingTextView: { path: string; data: Record<string, unknown> } | null =
223
285
  null;
224
286
 
@@ -265,20 +327,24 @@ export class MessageBuilder {
265
327
  // `has*` answers, `assert*` throws. Both exist because a test reads better
266
328
  // as an assertion and a conditional reads better as a question.
267
329
 
268
- hasTo(address?: string): boolean {
269
- return contains(this.#msg.to, address);
330
+ hasTo(address?: string, name?: string): boolean {
331
+ return contains(this.#msg.to, address, name);
270
332
  }
271
- hasCc(address?: string): boolean {
272
- return contains(this.#msg.cc, address);
333
+ hasCc(address?: string, name?: string): boolean {
334
+ return contains(this.#msg.cc, address, name);
273
335
  }
274
- hasBcc(address?: string): boolean {
275
- return contains(this.#msg.bcc, address);
336
+ hasBcc(address?: string, name?: string): boolean {
337
+ return contains(this.#msg.bcc, address, name);
276
338
  }
277
- hasFrom(address?: string): boolean {
278
- return contains(this.#msg.from ? [this.#msg.from] : [], address);
339
+ hasFrom(address?: string, name?: string): boolean {
340
+ return contains(this.#msg.from ? [this.#msg.from] : [], address, name);
279
341
  }
280
- hasReplyTo(address?: string): boolean {
281
- return contains(this.#msg.replyTo ? [this.#msg.replyTo] : [], address);
342
+ hasReplyTo(address?: string, name?: string): boolean {
343
+ return contains(
344
+ this.#msg.replyTo ? [this.#msg.replyTo] : [],
345
+ address,
346
+ name,
347
+ );
282
348
  }
283
349
  hasSubject(subject?: string): boolean {
284
350
  if (subject === undefined) return this.#msg.subject !== "";
@@ -301,11 +367,42 @@ export class MessageBuilder {
301
367
  }
302
368
 
303
369
  /**
304
- * Whether `address` is a recipient in ANY field (AdonisJS `hasRecipient`).
305
- * Without one, whether the message has a recipient at all.
370
+ * Whether `address` appears in ONE named field (AdonisJS `hasRecipient`).
371
+ *
372
+ * The field comes first, as upstream: `hasRecipient('to', 'a@b.c')`. It used
373
+ * to take the address alone and search every field, so a migrated
374
+ * `hasRecipient('to', addr)` asked whether `'to'` was a recipient and quietly
375
+ * answered false — the worst possible outcome inside a test assertion.
376
+ * {@link hasAnyRecipient} is the any-field question under a name that says so.
306
377
  */
307
- hasRecipient(address?: string): boolean {
308
- return this.hasTo(address) || this.hasCc(address) || this.hasBcc(address);
378
+ hasRecipient(
379
+ property: "to" | "cc" | "bcc" | "replyTo",
380
+ address: string,
381
+ name?: string,
382
+ ): boolean {
383
+ switch (property) {
384
+ case "to":
385
+ return this.hasTo(address, name);
386
+ case "cc":
387
+ return this.hasCc(address, name);
388
+ case "bcc":
389
+ return this.hasBcc(address, name);
390
+ case "replyTo":
391
+ return this.hasReplyTo(address, name);
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Whether `address` is a recipient in any of `to` / `cc` / `bcc`. Without
397
+ * one, whether the message has a recipient at all. Ream's own, since
398
+ * "does this reach them" is the question an assertion usually asks.
399
+ */
400
+ hasAnyRecipient(address?: string, name?: string): boolean {
401
+ return (
402
+ this.hasTo(address, name) ||
403
+ this.hasCc(address, name) ||
404
+ this.hasBcc(address, name)
405
+ );
309
406
  }
310
407
 
311
408
  /**
@@ -374,7 +471,7 @@ export class MessageBuilder {
374
471
 
375
472
  /** `address` is a recipient in some field (AdonisJS `assertRecipient`). */
376
473
  assertRecipient(address: string): void {
377
- expect(this.hasRecipient(address), `to reach "${address}"`, {
474
+ expect(this.hasAnyRecipient(address), `to reach "${address}"`, {
378
475
  to: this.#msg.to,
379
476
  cc: this.#msg.cc,
380
477
  bcc: this.#msg.bcc,
@@ -410,6 +507,22 @@ export class MessageBuilder {
410
507
  );
411
508
  }
412
509
 
510
+ /**
511
+ * The Apple Watch body contains `substring` (AdonisJS
512
+ * `assertWatchIncludes`).
513
+ *
514
+ * Takes a RegExp as well as a string, as upstream does — a rendered body
515
+ * rarely matches a fixed substring exactly.
516
+ */
517
+ assertWatchIncludes(substring: string | RegExp): void {
518
+ const body = this.#msg.watchHtml ?? "";
519
+ const hit =
520
+ typeof substring === "string"
521
+ ? body.includes(substring)
522
+ : substring.test(body);
523
+ expect(hit, `watch body to include "${String(substring)}"`, body);
524
+ }
525
+
413
526
  from(address: string, name?: string): this {
414
527
  this.#msg.from = formatAddress(address, name);
415
528
  return this;
@@ -449,6 +562,35 @@ export class MessageBuilder {
449
562
  this.#msg.html = content;
450
563
  return this;
451
564
  }
565
+
566
+ /**
567
+ * The Apple Watch body (AdonisJS `watch`).
568
+ *
569
+ * NAMED DEVIATION — this writes nodemailer's `watchHtml`. AdonisJS writes a
570
+ * bare `watch` field, which nodemailer's mail composer never reads
571
+ * (lib/mail-composer/index.js only looks at `watchHtml`), so upstream's
572
+ * watch body never reaches the wire. {@link watchHtml} is the same method
573
+ * under the field's own name.
574
+ */
575
+ watch(content: string): this {
576
+ return this.watchHtml(content);
577
+ }
578
+
579
+ watchHtml(content: string): this {
580
+ this.#msg.watchHtml = content;
581
+ return this;
582
+ }
583
+
584
+ /**
585
+ * Render a template as the Apple Watch body (AdonisJS `watchView`).
586
+ *
587
+ * The counterpart of {@link htmlView} and {@link textView}: the render
588
+ * happens lazily at `build()` time so the fluent chain stays synchronous.
589
+ */
590
+ watchView(viewPath: string, data?: Record<string, unknown>): this {
591
+ this.#pendingWatchView = { path: viewPath, data: data ?? {} };
592
+ return this;
593
+ }
452
594
  text(content: string): this {
453
595
  this.#msg.text = content;
454
596
  return this;
@@ -565,6 +707,17 @@ export class MessageBuilder {
565
707
  return this;
566
708
  }
567
709
 
710
+ /**
711
+ * A header nodemailer passes through untouched (AdonisJS `preparedHeader`).
712
+ *
713
+ * Use it when the value IS what must appear on the wire and re-encoding
714
+ * would corrupt it — a signature, an already-encoded message id.
715
+ */
716
+ preparedHeader(key: string, value: string): this {
717
+ this.#msg.headers[key] = { prepared: true, value };
718
+ return this;
719
+ }
720
+
568
721
  /**
569
722
  * Body transfer encoding (AdonisJS `encoding`) — `7bit`, `base64`,
570
723
  * `quoted-printable`… SMTP only: the provider HTTP APIs encode the payload
@@ -674,6 +827,26 @@ export class MessageBuilder {
674
827
  return this;
675
828
  }
676
829
 
830
+ /** The templates {@link build} rendered, for the lifecycle events. */
831
+ get views(): MessageBodyTemplates {
832
+ return { ...this.#renderedViews };
833
+ }
834
+
835
+ /** AdonisJS' name for {@link views}. */
836
+ get contentViews(): MessageBodyTemplates {
837
+ return this.views;
838
+ }
839
+
840
+ /**
841
+ * The message as a plain object (AdonisJS `nodeMailerMessage`).
842
+ *
843
+ * A live reference, as upstream's is — this is the object the transports
844
+ * read, not a snapshot. {@link build} is what finalises it.
845
+ */
846
+ get nodeMailerMessage(): MailMessage {
847
+ return this.#msg;
848
+ }
849
+
677
850
  /**
678
851
  * Finalise the message. `viewsRoot`, when provided by the owning `Mail`,
679
852
  * scopes template resolution to that instance's configured root instead of
@@ -682,6 +855,10 @@ export class MessageBuilder {
682
855
  */
683
856
  async build(viewsRoot?: string): Promise<MailMessage> {
684
857
  if (this.#pendingView !== null) {
858
+ this.#renderedViews.html = {
859
+ template: this.#pendingView.path,
860
+ data: this.#pendingView.data,
861
+ };
685
862
  this.#msg.html = await renderTemplateFile(
686
863
  this.#pendingView.path,
687
864
  this.#pendingView.data,
@@ -690,7 +867,24 @@ export class MessageBuilder {
690
867
  );
691
868
  this.#pendingView = null;
692
869
  }
870
+ if (this.#pendingWatchView !== null) {
871
+ this.#renderedViews.watch = {
872
+ template: this.#pendingWatchView.path,
873
+ data: this.#pendingWatchView.data,
874
+ };
875
+ this.#msg.watchHtml = await renderTemplateFile(
876
+ this.#pendingWatchView.path,
877
+ this.#pendingWatchView.data,
878
+ undefined,
879
+ viewsRoot,
880
+ );
881
+ this.#pendingWatchView = null;
882
+ }
693
883
  if (this.#pendingTextView !== null) {
884
+ this.#renderedViews.text = {
885
+ template: this.#pendingTextView.path,
886
+ data: this.#pendingTextView.data,
887
+ };
694
888
  this.#msg.text = await renderTemplateFile(
695
889
  this.#pendingTextView.path,
696
890
  this.#pendingTextView.data,
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export type {
15
15
  EmitterLike,
16
16
  MailAttachment,
17
17
  MailConfig,
18
+ MailEventBase,
18
19
  MailFailedEvent,
19
20
  MailHooks,
20
21
  MailMessage,
@@ -25,6 +26,7 @@ export type {
25
26
  MailSentEvent,
26
27
  MailTransport,
27
28
  MailTransportFactory,
29
+ MessageBodyTemplates,
28
30
  } from "./Mail.js";
29
31
  export {
30
32
  LogTransport,
@@ -41,10 +43,11 @@ export type {
41
43
  CalendarEventOptions,
42
44
  ListHeader,
43
45
  MailEnvelope,
46
+ PreparedHeader,
44
47
  Recipient,
45
48
  RecipientObject,
46
49
  } from "./MessageBuilder.js";
47
- export { attachmentsFor } from "./MessageBuilder.js";
50
+ export { attachmentsFor, headerValue } from "./MessageBuilder.js";
48
51
  export { RoverError } from "./RoverError.js";
49
52
  export { default as RoverProvider } from "./RoverProvider.js";
50
53
  export {