@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.
- package/dist/BaseMail.d.ts +11 -0
- package/dist/BaseMail.d.ts.map +1 -1
- package/dist/BaseMail.js +18 -1
- package/dist/BaseMail.js.map +1 -1
- package/dist/Mail.d.ts +58 -23
- package/dist/Mail.d.ts.map +1 -1
- package/dist/Mail.js +88 -39
- package/dist/Mail.js.map +1 -1
- package/dist/MessageBuilder.d.ts +107 -9
- package/dist/MessageBuilder.d.ts.map +1 -1
- package/dist/MessageBuilder.js +151 -16
- package/dist/MessageBuilder.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/transports/BrevoTransport.d.ts.map +1 -1
- package/dist/transports/BrevoTransport.js +3 -2
- package/dist/transports/BrevoTransport.js.map +1 -1
- package/dist/transports/MailgunTransport.d.ts.map +1 -1
- package/dist/transports/MailgunTransport.js +3 -2
- package/dist/transports/MailgunTransport.js.map +1 -1
- package/dist/transports/ResendTransport.d.ts.map +1 -1
- package/dist/transports/ResendTransport.js +3 -2
- package/dist/transports/ResendTransport.js.map +1 -1
- package/dist/transports/SendGridTransport.d.ts.map +1 -1
- package/dist/transports/SendGridTransport.js +8 -5
- package/dist/transports/SendGridTransport.js.map +1 -1
- package/dist/transports/SesTransport.js +9 -2
- package/dist/transports/SesTransport.js.map +1 -1
- package/dist/transports/SparkPostTransport.d.ts.map +1 -1
- package/dist/transports/SparkPostTransport.js +3 -2
- package/dist/transports/SparkPostTransport.js.map +1 -1
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/src/BaseMail.ts +19 -1
- package/src/Mail.ts +139 -63
- package/src/MessageBuilder.ts +211 -17
- package/src/index.ts +4 -1
- package/src/transports/BrevoTransport.ts +3 -2
- package/src/transports/MailgunTransport.ts +3 -2
- package/src/transports/ResendTransport.ts +3 -2
- package/src/transports/SendGridTransport.ts +8 -5
- package/src/transports/SesTransport.ts +9 -2
- 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
|
-
*
|
|
79
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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.#
|
|
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.#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/MessageBuilder.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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`
|
|
305
|
-
*
|
|
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(
|
|
308
|
-
|
|
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.
|
|
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 {
|