@c9up/rover 0.1.5 → 0.1.7

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 (70) hide show
  1. package/README.md +3 -1
  2. package/dist/BaseMail.d.ts +2 -1
  3. package/dist/BaseMail.d.ts.map +1 -1
  4. package/dist/BaseMail.js +9 -4
  5. package/dist/BaseMail.js.map +1 -1
  6. package/dist/Mail.d.ts +65 -11
  7. package/dist/Mail.d.ts.map +1 -1
  8. package/dist/Mail.js +142 -49
  9. package/dist/Mail.js.map +1 -1
  10. package/dist/MessageBuilder.d.ts +44 -7
  11. package/dist/MessageBuilder.d.ts.map +1 -1
  12. package/dist/MessageBuilder.js +57 -10
  13. package/dist/MessageBuilder.js.map +1 -1
  14. package/dist/format.d.ts +12 -0
  15. package/dist/format.d.ts.map +1 -0
  16. package/dist/format.js +14 -0
  17. package/dist/format.js.map +1 -0
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/queue/MailJob.d.ts.map +1 -1
  23. package/dist/queue/MailJob.js.map +1 -1
  24. package/dist/queue/MemoryMailMessenger.d.ts +15 -0
  25. package/dist/queue/MemoryMailMessenger.d.ts.map +1 -0
  26. package/dist/queue/MemoryMailMessenger.js +42 -0
  27. package/dist/queue/MemoryMailMessenger.js.map +1 -0
  28. package/dist/testing/FakeMail.d.ts +30 -3
  29. package/dist/testing/FakeMail.d.ts.map +1 -1
  30. package/dist/testing/FakeMail.js +106 -28
  31. package/dist/testing/FakeMail.js.map +1 -1
  32. package/dist/transports/BrevoTransport.d.ts +14 -0
  33. package/dist/transports/BrevoTransport.d.ts.map +1 -0
  34. package/dist/transports/BrevoTransport.js +134 -0
  35. package/dist/transports/BrevoTransport.js.map +1 -0
  36. package/dist/transports/MailgunTransport.d.ts.map +1 -1
  37. package/dist/transports/MailgunTransport.js +3 -1
  38. package/dist/transports/MailgunTransport.js.map +1 -1
  39. package/dist/transports/ResendTransport.d.ts.map +1 -1
  40. package/dist/transports/ResendTransport.js +4 -2
  41. package/dist/transports/ResendTransport.js.map +1 -1
  42. package/dist/transports/SendGridTransport.d.ts.map +1 -1
  43. package/dist/transports/SendGridTransport.js +2 -2
  44. package/dist/transports/SendGridTransport.js.map +1 -1
  45. package/dist/transports/SesTransport.js +1 -1
  46. package/dist/transports/SesTransport.js.map +1 -1
  47. package/dist/transports/SparkPostTransport.d.ts +14 -0
  48. package/dist/transports/SparkPostTransport.d.ts.map +1 -0
  49. package/dist/transports/SparkPostTransport.js +144 -0
  50. package/dist/transports/SparkPostTransport.js.map +1 -0
  51. package/index.darwin-arm64.node +0 -0
  52. package/index.darwin-x64.node +0 -0
  53. package/index.linux-arm64-gnu.node +0 -0
  54. package/index.linux-x64-gnu.node +0 -0
  55. package/index.win32-x64-msvc.node +0 -0
  56. package/package.json +10 -1
  57. package/src/BaseMail.ts +17 -5
  58. package/src/Mail.ts +205 -62
  59. package/src/MessageBuilder.ts +103 -12
  60. package/src/format.ts +14 -0
  61. package/src/index.ts +5 -1
  62. package/src/queue/MailJob.ts +1 -1
  63. package/src/queue/MemoryMailMessenger.ts +45 -0
  64. package/src/testing/FakeMail.ts +232 -31
  65. package/src/transports/BrevoTransport.ts +174 -0
  66. package/src/transports/MailgunTransport.ts +3 -1
  67. package/src/transports/ResendTransport.ts +4 -2
  68. package/src/transports/SendGridTransport.ts +2 -2
  69. package/src/transports/SesTransport.ts +3 -1
  70. package/src/transports/SparkPostTransport.ts +182 -0
package/src/BaseMail.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type MailMessage, MessageBuilder } from "./MessageBuilder.js";
2
2
 
3
- export type MailAddress = string | { address: string; name: string };
3
+ export type MailAddress = string | { address: string; name?: string };
4
4
 
5
5
  /**
6
6
  * Abstract base for class-based mail messages (Adonis parity).
@@ -20,6 +20,7 @@ export abstract class BaseMail {
20
20
  protected readonly message: MessageBuilder = new MessageBuilder();
21
21
 
22
22
  from?: MailAddress;
23
+ replyTo?: MailAddress;
23
24
  subject?: string;
24
25
 
25
26
  constructor() {
@@ -34,7 +35,14 @@ export abstract class BaseMail {
34
35
 
35
36
  async build(viewsRoot?: string): Promise<MailMessage> {
36
37
  if (this.from !== undefined) {
37
- this.message.from(formatAddress(this.from));
38
+ applyAddress(this.from, (address, name) =>
39
+ this.message.from(address, name),
40
+ );
41
+ }
42
+ if (this.replyTo !== undefined) {
43
+ applyAddress(this.replyTo, (address, name) =>
44
+ this.message.replyTo(address, name),
45
+ );
38
46
  }
39
47
  if (this.subject !== undefined) {
40
48
  this.message.subject(this.subject);
@@ -46,9 +54,13 @@ export abstract class BaseMail {
46
54
  }
47
55
  }
48
56
 
49
- function formatAddress(addr: MailAddress): string {
57
+ function applyAddress(
58
+ addr: MailAddress,
59
+ set: (address: string, name?: string) => void,
60
+ ): void {
50
61
  if (typeof addr === "string") {
51
- return addr;
62
+ set(addr);
63
+ return;
52
64
  }
53
- return `"${addr.name}" <${addr.address}>`;
65
+ set(addr.address, addr.name);
54
66
  }
package/src/Mail.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  MAIL_JOB_NAME,
12
12
  MailJobHandler,
13
13
  } from "./queue/MailJob.js";
14
+ import { MemoryMailMessenger } from "./queue/MemoryMailMessenger.js";
14
15
  import { RoverError } from "./RoverError.js";
15
16
  import {
16
17
  computeBackoffMs,
@@ -68,6 +69,18 @@ export interface EmitterLike {
68
69
  emit(event: string, data: unknown): void;
69
70
  }
70
71
 
72
+ /**
73
+ * Emitted (`mail:sending`) right before the transport `send` runs — no
74
+ * `messageId` yet, since the provider hasn't accepted the message.
75
+ */
76
+ export interface MailSendingEvent {
77
+ to: string[];
78
+ cc: string[];
79
+ bcc: string[];
80
+ transportName: string;
81
+ timestamp: number;
82
+ }
83
+
71
84
  export interface MailSentEvent {
72
85
  messageId: string;
73
86
  to: string[];
@@ -77,6 +90,21 @@ export interface MailSentEvent {
77
90
  timestamp: number;
78
91
  }
79
92
 
93
+ /**
94
+ * Emitted for the queue lifecycle (`mail:queueing` / `mail:queued`). `jobId` is
95
+ * only present on `mail:queued` (once the job has been accepted by the queue /
96
+ * in-memory messenger).
97
+ */
98
+ export interface MailQueueEvent {
99
+ to: string[];
100
+ cc: string[];
101
+ bcc: string[];
102
+ transportName: string;
103
+ queue: string;
104
+ jobId?: string;
105
+ timestamp: number;
106
+ }
107
+
80
108
  export interface MailFailedEvent {
81
109
  messageId: string;
82
110
  to: string[];
@@ -110,12 +138,16 @@ export interface MailConfig {
110
138
 
111
139
  /**
112
140
  * Event hooks invoked by the internal dispatch loop. Default implementations
113
- * are no-ops; when an event-bus `EmitterLike` is wired, the hooks emit
114
- * `mail.sent` / `mail.failed`. Tests inject spies.
141
+ * are no-ops; when an event-bus `EmitterLike` is wired, the hooks emit the
142
+ * `mail:*` events (colon-namespaced, `@adonisjs/mail` parity). Tests inject
143
+ * spies.
115
144
  */
116
145
  export interface MailHooks {
146
+ onSending?(event: MailSendingEvent): void;
117
147
  onSent?(event: MailSentEvent): void;
118
148
  onFailed?(event: MailFailedEvent): void;
149
+ onQueueing?(event: MailQueueEvent): void;
150
+ onQueued?(event: MailQueueEvent): void;
119
151
  }
120
152
 
121
153
  /**
@@ -204,6 +236,10 @@ export class SmtpTransport implements MailTransport {
204
236
  subject: message.subject,
205
237
  html: message.html,
206
238
  text: message.text,
239
+ priority: message.priority,
240
+ messageId: message.messageId,
241
+ inReplyTo: message.inReplyTo,
242
+ references: message.references,
207
243
  headers: Object.keys(message.headers).length
208
244
  ? message.headers
209
245
  : undefined,
@@ -212,6 +248,7 @@ export class SmtpTransport implements MailTransport {
212
248
  filename: att.filename,
213
249
  content: att.content,
214
250
  contentType: att.contentType,
251
+ cid: att.cid,
215
252
  }))
216
253
  : undefined,
217
254
  });
@@ -297,11 +334,14 @@ export function registerTransport(
297
334
  */
298
335
  export class Mail {
299
336
  #transports: Map<string, MailTransport> = new Map();
337
+ #mailers: Map<string, Mailer> = new Map();
300
338
  #defaultTransport: string;
301
339
  #defaultFrom: string;
302
- #fakeSnapshot: { transportName: string; original: MailTransport } | null =
303
- null;
340
+ /** Active `FakeMail`, when `fake()` mode is on. Manager-level (captures both `send` and `sendLater`), not a transport swap. */
341
+ #fake: FakeMail | null = null;
304
342
  #queue: BayQueueLike | null = null;
343
+ /** Default in-memory messenger for `sendLater()` when no Bay queue is wired (Adonis MemoryQueueMessenger parity). */
344
+ #memoryMessenger: MemoryMailMessenger;
305
345
  #queueName: string;
306
346
  #queueMaxAttempts: number;
307
347
  #globalRetry: RetryConfig | undefined;
@@ -328,6 +368,7 @@ export class Mail {
328
368
  this.#globalRetry = config.retry;
329
369
  this.#hooks = options?.hooks ?? {};
330
370
  this.#emitter = options?.emitter ?? null;
371
+ this.#memoryMessenger = new MemoryMailMessenger(this, this.#emitter);
331
372
  this.#viewsRoot = config.viewsRoot;
332
373
  // Keep mutating the process-wide global too: standalone MessageBuilder
333
374
  // usage (not routed through this Mail) still reads it. The per-instance
@@ -371,6 +412,23 @@ export class Mail {
371
412
  transport?: string,
372
413
  ): Promise<void> {
373
414
  const transportName = transport ?? this.#defaultTransport;
415
+ // Fake mode is manager-level: capture the built message (and the source
416
+ // BaseMail, for constructor-based assertions) instead of touching a
417
+ // transport. Still fire the send lifecycle so event wiring stays testable.
418
+ if (this.#fake !== null) {
419
+ const message = await this.#buildMessage(arg);
420
+ 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
+ };
428
+ this.#fireSending(base);
429
+ this.#fireSent({ ...base, messageId: randomBytes(16).toString("hex") });
430
+ return;
431
+ }
374
432
  // Validate transport up-front before running any callback / prepare() side effects.
375
433
  if (!this.#transports.has(transportName)) {
376
434
  throw new Error(`Mail transport '${transportName}' not configured`);
@@ -381,30 +439,50 @@ export class Mail {
381
439
  }
382
440
 
383
441
  /**
384
- * Enqueue a send onto the Bay queue. Returns the job id. Throws
385
- * `MAIL_QUEUE_REQUIRED` if no `QueueManager` was wired through the
386
- * constructor options.
442
+ * Enqueue a send. Returns the job id. When a `@c9up/bay` `QueueManager` was
443
+ * wired it enqueues there; otherwise it falls back to the default in-memory
444
+ * messenger (immediate microtask dispatch), matching `@adonisjs/mail`'s
445
+ * `MemoryQueueMessenger` — `sendLater()` never throws for a missing queue.
387
446
  */
388
447
  async sendLater(
389
448
  arg: ((message: MessageBuilder) => void) | BaseMail,
390
449
  options?: { transport?: string; queue?: string },
391
450
  ): Promise<string> {
392
- if (this.#queue === null) {
393
- throw new RoverError(
394
- "MAIL_QUEUE_REQUIRED",
395
- "mail.sendLater() requires @c9up/bay QueueManager",
396
- {
397
- hint: "Register @c9up/bay and pass the QueueManager to Mail via RoverProvider, or use mail.send() for synchronous delivery.",
398
- },
399
- );
400
- }
401
451
  const message = await this.#buildMessage(arg);
402
452
  const queueName = options?.queue ?? this.#queueName;
403
- return this.#queue.dispatch(
404
- queueName,
405
- { message, transport: options?.transport },
406
- { maxAttempts: this.#queueMaxAttempts },
407
- );
453
+ const transportName = options?.transport ?? this.#defaultTransport;
454
+ const base: MailQueueEvent = {
455
+ to: message.to.slice(),
456
+ cc: message.cc.slice(),
457
+ bcc: message.bcc.slice(),
458
+ transportName,
459
+ queue: queueName,
460
+ timestamp: Date.now(),
461
+ };
462
+
463
+ // Fake mode: capture into the queued bucket, don't dispatch.
464
+ if (this.#fake !== null) {
465
+ this.#fake.trackQueued(
466
+ message,
467
+ arg instanceof BaseMail ? arg : undefined,
468
+ );
469
+ const jobId = `fake_${randomBytes(12).toString("hex")}`;
470
+ this.#fireQueueing(base);
471
+ this.#fireQueued({ ...base, jobId });
472
+ return jobId;
473
+ }
474
+
475
+ this.#fireQueueing(base);
476
+ const jobId =
477
+ this.#queue !== null
478
+ ? await this.#queue.dispatch(
479
+ queueName,
480
+ { message, transport: options?.transport },
481
+ { maxAttempts: this.#queueMaxAttempts },
482
+ )
483
+ : this.#memoryMessenger.queue(message, options?.transport);
484
+ this.#fireQueued({ ...base, jobId });
485
+ return jobId;
408
486
  }
409
487
 
410
488
  /**
@@ -437,6 +515,16 @@ export class Mail {
437
515
  const generatedId = randomBytes(16).toString("hex");
438
516
  let lastError: unknown;
439
517
 
518
+ // Fire once before the first attempt — `mail:sending` signals intent, not
519
+ // 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
+ });
527
+
440
528
  for (let attempt = 1; attempt <= retry.maxAttempts; attempt += 1) {
441
529
  let sendResult: MailSendOutcome;
442
530
  try {
@@ -507,36 +595,46 @@ export class Mail {
507
595
  return clone;
508
596
  }
509
597
 
598
+ #fireSending(event: MailSendingEvent): void {
599
+ this.#fire("mail:sending", event, this.#hooks.onSending);
600
+ }
601
+
510
602
  #fireSent(event: MailSentEvent): void {
511
- // Hooks are in user-land and may throw; their failure must not poison
512
- // delivery outcome. Emitter errors are already defensively swallowed.
513
- try {
514
- this.#hooks.onSent?.(event);
515
- } catch (err) {
516
- process.stderr.write(
517
- `[rover] onSent hook threw: ${err instanceof Error ? err.message : String(err)}\n`,
518
- );
519
- }
520
- if (this.#emitter) {
521
- try {
522
- this.#emitter.emit("mail.sent", event);
523
- } catch {
524
- // Event bus failure ≠ mail delivery failure — swallow.
525
- }
526
- }
603
+ this.#fire("mail:sent", event, this.#hooks.onSent);
527
604
  }
528
605
 
529
606
  #fireFailed(event: MailFailedEvent): void {
607
+ this.#fire("mail:failed", event, this.#hooks.onFailed);
608
+ }
609
+
610
+ #fireQueueing(event: MailQueueEvent): void {
611
+ this.#fire("mail:queueing", event, this.#hooks.onQueueing);
612
+ }
613
+
614
+ #fireQueued(event: MailQueueEvent): void {
615
+ this.#fire("mail:queued", event, this.#hooks.onQueued);
616
+ }
617
+
618
+ /**
619
+ * Fan a lifecycle event out to the (optional, user-land, may-throw) hook and
620
+ * the (optional) event bus. Hook and bus failures are isolated: neither can
621
+ * poison the delivery outcome.
622
+ */
623
+ #fire<T>(
624
+ name: string,
625
+ event: T,
626
+ hook: ((event: T) => void) | undefined,
627
+ ): void {
530
628
  try {
531
- this.#hooks.onFailed?.(event);
629
+ hook?.(event);
532
630
  } catch (err) {
533
631
  process.stderr.write(
534
- `[rover] onFailed hook threw: ${err instanceof Error ? err.message : String(err)}\n`,
632
+ `[rover] ${name} hook threw: ${err instanceof Error ? err.message : String(err)}\n`,
535
633
  );
536
634
  }
537
635
  if (this.#emitter) {
538
636
  try {
539
- this.#emitter.emit("mail.failed", event);
637
+ this.#emitter.emit(name, event);
540
638
  } catch {
541
639
  // Event bus failure ≠ mail delivery failure — swallow.
542
640
  }
@@ -560,41 +658,86 @@ export class Mail {
560
658
  return result;
561
659
  }
562
660
 
563
- /** Get a specific transport. */
564
- use(name: string): MailTransport {
661
+ /**
662
+ * Get a `Mailer` bound to a named transport, so `mail.use('mailgun').send(cb)`
663
+ * routes through that transport (Adonis parity). Mailers are cached per name.
664
+ */
665
+ use(name: string): Mailer {
666
+ if (!this.#transports.has(name)) {
667
+ throw new Error(`Mail transport '${name}' not configured`);
668
+ }
669
+ let mailer = this.#mailers.get(name);
670
+ if (mailer === undefined) {
671
+ mailer = new Mailer(this, name);
672
+ this.#mailers.set(name, mailer);
673
+ }
674
+ return mailer;
675
+ }
676
+
677
+ /** @internal Resolve the raw transport instance behind a mailer name. */
678
+ transportFor(name: string): MailTransport {
565
679
  const t = this.#transports.get(name);
566
680
  if (!t) throw new Error(`Mail transport '${name}' not configured`);
567
681
  return t;
568
682
  }
569
683
 
570
684
  /**
571
- * Swap the default transport with a `FakeMail` that captures every send.
572
- * Call `restore()` to re-install the original. Throws if a fake is already
573
- * active — nested fakes always indicate a forgotten `restore()`.
685
+ * Enter fake mode. Every subsequent `send()` / `sendLater()` — including via
686
+ * `use(name)` — is captured by the returned `FakeMail` instead of hitting a
687
+ * transport or the queue. Call `restore()` to exit. Throws if already faking
688
+ * — nested fakes always indicate a forgotten `restore()`.
574
689
  */
575
690
  fake(): FakeMail {
576
- if (this.#fakeSnapshot !== null) {
691
+ if (this.#fake !== null) {
577
692
  throw new Error("Mail.fake() already active — call restore() first");
578
693
  }
579
- const transportName = this.#defaultTransport;
580
- const original = this.#transports.get(transportName);
581
- if (!original) {
582
- throw new Error(
583
- `Cannot fake default transport '${transportName}' — not configured`,
584
- );
585
- }
586
- const fake = new FakeMail();
587
- this.#fakeSnapshot = { transportName, original };
588
- this.#transports.set(transportName, fake);
589
- return fake;
694
+ this.#fake = new FakeMail();
695
+ return this.#fake;
590
696
  }
591
697
 
592
- /** Undo the swap installed by `fake()`. No-op if no fake is active. */
698
+ /** Exit fake mode. No-op if not currently faking. */
593
699
  restore(): void {
594
- if (this.#fakeSnapshot === null) return;
595
- const { transportName, original } = this.#fakeSnapshot;
596
- this.#transports.set(transportName, original);
597
- this.#fakeSnapshot = null;
700
+ this.#fake = null;
701
+ }
702
+ }
703
+
704
+ /**
705
+ * A `Mailer` binds a named transport to the `send` / `sendLater` API so
706
+ * `mail.use('mailgun').send(cb)` routes through that transport (Adonis parity).
707
+ * It delegates back to the owning `Mail`, so fake mode and lifecycle events are
708
+ * honoured uniformly.
709
+ */
710
+ export class Mailer {
711
+ #mail: Mail;
712
+ #name: string;
713
+
714
+ constructor(mail: Mail, name: string) {
715
+ this.#mail = mail;
716
+ this.#name = name;
717
+ }
718
+
719
+ /** The transport name this mailer is bound to. */
720
+ get name(): string {
721
+ return this.#name;
722
+ }
723
+
724
+ /** The underlying transport instance. */
725
+ get transport(): MailTransport {
726
+ return this.#mail.transportFor(this.#name);
727
+ }
728
+
729
+ send(arg: ((message: MessageBuilder) => void) | BaseMail): Promise<void> {
730
+ // Narrow so the overloaded `Mail.send` resolves without a union cast.
731
+ return arg instanceof BaseMail
732
+ ? this.#mail.send(arg, this.#name)
733
+ : this.#mail.send(arg, this.#name);
734
+ }
735
+
736
+ sendLater(
737
+ arg: ((message: MessageBuilder) => void) | BaseMail,
738
+ options?: { queue?: string },
739
+ ): Promise<string> {
740
+ return this.#mail.sendLater(arg, { ...options, transport: this.#name });
598
741
  }
599
742
  }
600
743
 
@@ -1,3 +1,4 @@
1
+ import { formatAddress } from "./format.js";
1
2
  import { renderFile as renderTemplateFile } from "./templating/SimpleTemplate.js";
2
3
 
3
4
  export interface MailMessage {
@@ -10,15 +11,36 @@ export interface MailMessage {
10
11
  html?: string;
11
12
  text?: string;
12
13
  attachments: MailAttachment[];
13
- headers: Record<string, string>;
14
+ headers: Record<string, string | string[]>;
15
+ /** Email priority hint (nodemailer `priority`). */
16
+ priority?: "low" | "normal" | "high";
17
+ /** Custom `Message-ID` header (threading / idempotency). */
18
+ messageId?: string;
19
+ /** `In-Reply-To` header — the message id this email replies to. */
20
+ inReplyTo?: string;
21
+ /** `References` header — the thread's message ids. */
22
+ references?: string[];
14
23
  }
15
24
 
16
25
  export interface MailAttachment {
17
26
  filename: string;
18
27
  content: Buffer | string;
19
28
  contentType?: string;
29
+ /** Content-ID for inline (CID) embedding — set via `embedData()`. */
30
+ cid?: string;
20
31
  }
21
32
 
33
+ /**
34
+ * A recipient in object form (Adonis parity). `name` is optional and, when
35
+ * present, produces the `"Name" <address>` display form.
36
+ */
37
+ export interface RecipientObject {
38
+ address: string;
39
+ name?: string;
40
+ }
41
+
42
+ export type Recipient = string | RecipientObject;
43
+
22
44
  export class MessageBuilder {
23
45
  #msg: MailMessage = {
24
46
  from: "",
@@ -31,26 +53,37 @@ export class MessageBuilder {
31
53
  };
32
54
  #pendingView: { path: string; data: Record<string, unknown> } | null = null;
33
55
 
34
- from(address: string): this {
35
- this.#msg.from = address;
56
+ from(address: string, name?: string): this {
57
+ this.#msg.from = formatAddress(address, name);
36
58
  return this;
37
59
  }
38
- to(address: string): this {
39
- this.#msg.to.push(address);
60
+
61
+ to(address: string, name?: string): this;
62
+ to(addresses: Recipient[]): this;
63
+ to(address: string | Recipient[], name?: string): this {
64
+ addRecipients(this.#msg.to, address, name);
40
65
  return this;
41
66
  }
42
- cc(address: string): this {
43
- this.#msg.cc.push(address);
67
+
68
+ cc(address: string, name?: string): this;
69
+ cc(addresses: Recipient[]): this;
70
+ cc(address: string | Recipient[], name?: string): this {
71
+ addRecipients(this.#msg.cc, address, name);
44
72
  return this;
45
73
  }
46
- bcc(address: string): this {
47
- this.#msg.bcc.push(address);
74
+
75
+ bcc(address: string, name?: string): this;
76
+ bcc(addresses: Recipient[]): this;
77
+ bcc(address: string | Recipient[], name?: string): this {
78
+ addRecipients(this.#msg.bcc, address, name);
48
79
  return this;
49
80
  }
50
- replyTo(address: string): this {
51
- this.#msg.replyTo = address;
81
+
82
+ replyTo(address: string, name?: string): this {
83
+ this.#msg.replyTo = formatAddress(address, name);
52
84
  return this;
53
85
  }
86
+
54
87
  subject(text: string): this {
55
88
  this.#msg.subject = text;
56
89
  return this;
@@ -64,6 +97,30 @@ export class MessageBuilder {
64
97
  return this;
65
98
  }
66
99
 
100
+ /** Email priority (`low` | `normal` | `high`). */
101
+ priority(priority: "low" | "normal" | "high"): this {
102
+ this.#msg.priority = priority;
103
+ return this;
104
+ }
105
+
106
+ /** Set a custom `Message-ID` header. */
107
+ messageId(messageId: string): this {
108
+ this.#msg.messageId = messageId;
109
+ return this;
110
+ }
111
+
112
+ /** Set the `In-Reply-To` header for threading replies. */
113
+ inReplyTo(messageId: string): this {
114
+ this.#msg.inReplyTo = messageId;
115
+ return this;
116
+ }
117
+
118
+ /** Set the `References` header (thread message ids). */
119
+ references(messageIds: string[]): this {
120
+ this.#msg.references = messageIds.slice();
121
+ return this;
122
+ }
123
+
67
124
  attach(
68
125
  filename: string,
69
126
  content: Buffer | string,
@@ -73,7 +130,18 @@ export class MessageBuilder {
73
130
  return this;
74
131
  }
75
132
 
76
- header(key: string, value: string): this {
133
+ /**
134
+ * Embed inline content referenced by a Content-ID. Use `cid:<cid>` inside the
135
+ * HTML body to reference it. Content-based (rover is agnostic / no-fs): the
136
+ * path-based `embed(file, cid)` form from `@adonisjs/mail` is a deliberate
137
+ * divergence — pass the bytes directly instead.
138
+ */
139
+ embedData(content: Buffer | string, cid: string, contentType?: string): this {
140
+ this.#msg.attachments.push({ filename: cid, content, contentType, cid });
141
+ return this;
142
+ }
143
+
144
+ header(key: string, value: string | string[]): this {
77
145
  this.#msg.headers[key] = value;
78
146
  return this;
79
147
  }
@@ -107,3 +175,26 @@ export class MessageBuilder {
107
175
  return this.#msg;
108
176
  }
109
177
  }
178
+
179
+ /**
180
+ * Append one recipient, an array of recipients, or a `(address, name)` pair to
181
+ * a recipient list, formatting each into the `"Name" <address>` display form
182
+ * when a name is present.
183
+ */
184
+ function addRecipients(
185
+ list: string[],
186
+ address: string | Recipient[],
187
+ name?: string,
188
+ ): void {
189
+ if (Array.isArray(address)) {
190
+ for (const entry of address) {
191
+ list.push(
192
+ typeof entry === "string"
193
+ ? entry
194
+ : formatAddress(entry.address, entry.name),
195
+ );
196
+ }
197
+ return;
198
+ }
199
+ list.push(formatAddress(address, name));
200
+ }
package/src/format.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared address formatting helper. Kept dependency-free and in its own module
3
+ * so both `BaseMail` and `MessageBuilder` can import it without re-introducing
4
+ * the BaseMail ↔ MessageBuilder value cycle.
5
+ */
6
+
7
+ /**
8
+ * Format a recipient address with an optional display name into the
9
+ * `"Name" <address>` form (RFC 5322 quoted display name). A bare address —
10
+ * or an empty/whitespace-only name — is returned unchanged.
11
+ */
12
+ export function formatAddress(address: string, name?: string): string {
13
+ return name !== undefined && name !== "" ? `"${name}" <${address}>` : address;
14
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  export type { MailAddress } from "./BaseMail.js";
2
2
  export { BaseMail } from "./BaseMail.js";
3
3
  export { defineConfig } from "./config.js";
4
- export { RoverError } from "./RoverError.js";
5
4
  export type {
6
5
  EmitterLike,
7
6
  MailAttachment,
@@ -9,6 +8,8 @@ export type {
9
8
  MailFailedEvent,
10
9
  MailHooks,
11
10
  MailMessage,
11
+ MailQueueEvent,
12
+ MailSendingEvent,
12
13
  MailSendOutcome,
13
14
  MailSendResult,
14
15
  MailSentEvent,
@@ -18,10 +19,13 @@ export type {
18
19
  export {
19
20
  LogTransport,
20
21
  Mail,
22
+ Mailer,
21
23
  MessageBuilder,
22
24
  registerTransport,
23
25
  SmtpTransport,
24
26
  } from "./Mail.js";
27
+ export type { Recipient, RecipientObject } from "./MessageBuilder.js";
28
+ export { RoverError } from "./RoverError.js";
25
29
  export { default as RoverProvider } from "./RoverProvider.js";
26
30
  export {
27
31
  DEFAULT_RETRY_CONFIG,
@@ -1,6 +1,6 @@
1
1
  import { Buffer } from "node:buffer";
2
- import { RoverError } from "../RoverError.js";
3
2
  import type { MailAttachment, MailMessage } from "../Mail.js";
3
+ import { RoverError } from "../RoverError.js";
4
4
 
5
5
  export const MAIL_JOB_NAME = "mail.send";
6
6