@c9up/rover 0.1.6 → 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.
- package/README.md +3 -1
- package/dist/BaseMail.d.ts +2 -1
- package/dist/BaseMail.d.ts.map +1 -1
- package/dist/BaseMail.js +9 -4
- package/dist/BaseMail.js.map +1 -1
- package/dist/Mail.d.ts +65 -11
- package/dist/Mail.d.ts.map +1 -1
- package/dist/Mail.js +142 -49
- package/dist/Mail.js.map +1 -1
- package/dist/MessageBuilder.d.ts +44 -7
- package/dist/MessageBuilder.d.ts.map +1 -1
- package/dist/MessageBuilder.js +57 -10
- package/dist/MessageBuilder.js.map +1 -1
- package/dist/format.d.ts +12 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +14 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/queue/MailJob.d.ts.map +1 -1
- package/dist/queue/MailJob.js.map +1 -1
- package/dist/queue/MemoryMailMessenger.d.ts +15 -0
- package/dist/queue/MemoryMailMessenger.d.ts.map +1 -0
- package/dist/queue/MemoryMailMessenger.js +42 -0
- package/dist/queue/MemoryMailMessenger.js.map +1 -0
- package/dist/testing/FakeMail.d.ts +30 -3
- package/dist/testing/FakeMail.d.ts.map +1 -1
- package/dist/testing/FakeMail.js +106 -28
- package/dist/testing/FakeMail.js.map +1 -1
- package/dist/transports/BrevoTransport.d.ts +14 -0
- package/dist/transports/BrevoTransport.d.ts.map +1 -0
- package/dist/transports/BrevoTransport.js +134 -0
- package/dist/transports/BrevoTransport.js.map +1 -0
- package/dist/transports/MailgunTransport.d.ts.map +1 -1
- package/dist/transports/MailgunTransport.js +3 -1
- package/dist/transports/MailgunTransport.js.map +1 -1
- package/dist/transports/ResendTransport.d.ts.map +1 -1
- package/dist/transports/ResendTransport.js +4 -2
- package/dist/transports/ResendTransport.js.map +1 -1
- package/dist/transports/SendGridTransport.d.ts.map +1 -1
- package/dist/transports/SendGridTransport.js +2 -2
- package/dist/transports/SendGridTransport.js.map +1 -1
- package/dist/transports/SesTransport.js +1 -1
- package/dist/transports/SesTransport.js.map +1 -1
- package/dist/transports/SparkPostTransport.d.ts +14 -0
- package/dist/transports/SparkPostTransport.d.ts.map +1 -0
- package/dist/transports/SparkPostTransport.js +144 -0
- package/dist/transports/SparkPostTransport.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +10 -1
- package/src/BaseMail.ts +17 -5
- package/src/Mail.ts +205 -62
- package/src/MessageBuilder.ts +103 -12
- package/src/format.ts +14 -0
- package/src/index.ts +5 -1
- package/src/queue/MailJob.ts +1 -1
- package/src/queue/MemoryMailMessenger.ts +45 -0
- package/src/testing/FakeMail.ts +232 -31
- package/src/transports/BrevoTransport.ts +174 -0
- package/src/transports/MailgunTransport.ts +3 -1
- package/src/transports/ResendTransport.ts +4 -2
- package/src/transports/SendGridTransport.ts +2 -2
- package/src/transports/SesTransport.ts +3 -1
- package/src/transports/SparkPostTransport.ts +182 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import {
|
|
3
|
+
type MailMessage,
|
|
4
|
+
type MailSendOutcome,
|
|
5
|
+
type MailTransport,
|
|
6
|
+
registerTransport,
|
|
7
|
+
} from "../Mail.js";
|
|
8
|
+
import { RoverError } from "../RoverError.js";
|
|
9
|
+
import { wrapFetchNetworkError } from "./fetchError.js";
|
|
10
|
+
|
|
11
|
+
const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
|
|
12
|
+
const normalizeConfig = (v: string): string => stripCrlf(v).trim();
|
|
13
|
+
const MAX_PROVIDER_MESSAGE = 16 * 1024;
|
|
14
|
+
const capMessage = (s: string): string =>
|
|
15
|
+
s.length <= MAX_PROVIDER_MESSAGE
|
|
16
|
+
? s
|
|
17
|
+
: `${s.slice(0, MAX_PROVIDER_MESSAGE)}...[truncated]`;
|
|
18
|
+
/** Redact Basic/Bearer tokens if the upstream echoes our own request headers. */
|
|
19
|
+
const redactSecrets = (s: string): string =>
|
|
20
|
+
s
|
|
21
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]")
|
|
22
|
+
.replace(/Basic\s+[A-Za-z0-9+/=]+/g, "Basic [REDACTED]");
|
|
23
|
+
|
|
24
|
+
interface SparkPostRecipient {
|
|
25
|
+
address: { email: string; name?: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SparkPostBody {
|
|
29
|
+
content: {
|
|
30
|
+
from: string;
|
|
31
|
+
subject: string;
|
|
32
|
+
html?: string;
|
|
33
|
+
text?: string;
|
|
34
|
+
reply_to?: string;
|
|
35
|
+
headers?: Record<string, string>;
|
|
36
|
+
attachments?: Array<{ name: string; type: string; data: string }>;
|
|
37
|
+
};
|
|
38
|
+
recipients: SparkPostRecipient[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* SparkPost transactional email transport. Talks to the `v1/transmissions`
|
|
43
|
+
* REST API over `fetch`, mirroring the `@adonisjs/mail` SparkPost transport.
|
|
44
|
+
* Same conventions as the other fetch-based transports (Resend / Brevo):
|
|
45
|
+
* CRLF stripping at the wire boundary, secret redaction, `retry-after`
|
|
46
|
+
* surfacing.
|
|
47
|
+
*/
|
|
48
|
+
export class SparkPostTransport implements MailTransport {
|
|
49
|
+
#apiKey: string;
|
|
50
|
+
#baseUrl: string;
|
|
51
|
+
|
|
52
|
+
constructor(config: Record<string, unknown>) {
|
|
53
|
+
const apiKey =
|
|
54
|
+
typeof config.apiKey === "string"
|
|
55
|
+
? normalizeConfig(config.apiKey)
|
|
56
|
+
: typeof config.key === "string"
|
|
57
|
+
? normalizeConfig(config.key)
|
|
58
|
+
: "";
|
|
59
|
+
if (!apiKey) {
|
|
60
|
+
throw new RoverError(
|
|
61
|
+
"MAIL_PROVIDER_CONFIG",
|
|
62
|
+
"SparkPost transport requires apiKey",
|
|
63
|
+
{ hint: "Set { apiKey } in your mail config." },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
this.#apiKey = apiKey;
|
|
67
|
+
this.#baseUrl =
|
|
68
|
+
typeof config.baseUrl === "string"
|
|
69
|
+
? normalizeConfig(config.baseUrl).replace(/\/+$/, "")
|
|
70
|
+
: "https://api.sparkpost.com";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async send(message: MailMessage): Promise<MailSendOutcome> {
|
|
74
|
+
if (
|
|
75
|
+
message.to.length === 0 &&
|
|
76
|
+
message.cc.length === 0 &&
|
|
77
|
+
message.bcc.length === 0
|
|
78
|
+
) {
|
|
79
|
+
throw new RoverError(
|
|
80
|
+
"MAIL_PROVIDER_CONFIG",
|
|
81
|
+
"Mail message has no recipients",
|
|
82
|
+
{ hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
// SparkPost delivers to every address in `recipients`; cc/bcc are folded in
|
|
86
|
+
// so they receive the message, and cc is surfaced via a `CC` header for
|
|
87
|
+
// display (bcc stays hidden by design).
|
|
88
|
+
const recipients: SparkPostRecipient[] = [
|
|
89
|
+
...message.to,
|
|
90
|
+
...message.cc,
|
|
91
|
+
...message.bcc,
|
|
92
|
+
].map((addr) => ({ address: parseAddress(addr) }));
|
|
93
|
+
|
|
94
|
+
const body: SparkPostBody = {
|
|
95
|
+
content: {
|
|
96
|
+
from: stripCrlf(message.from),
|
|
97
|
+
subject: stripCrlf(message.subject),
|
|
98
|
+
},
|
|
99
|
+
recipients,
|
|
100
|
+
};
|
|
101
|
+
if (message.html) body.content.html = message.html;
|
|
102
|
+
if (message.text) body.content.text = message.text;
|
|
103
|
+
if (message.replyTo) body.content.reply_to = stripCrlf(message.replyTo);
|
|
104
|
+
const headers: Record<string, string> = {};
|
|
105
|
+
for (const [k, v] of Object.entries(message.headers)) {
|
|
106
|
+
headers[stripCrlf(k)] = Array.isArray(v)
|
|
107
|
+
? v.map(stripCrlf).join(", ")
|
|
108
|
+
: stripCrlf(v);
|
|
109
|
+
}
|
|
110
|
+
if (message.cc.length) headers.CC = message.cc.map(stripCrlf).join(", ");
|
|
111
|
+
if (Object.keys(headers).length > 0) body.content.headers = headers;
|
|
112
|
+
if (message.attachments.length > 0) {
|
|
113
|
+
body.content.attachments = message.attachments.map((att) => ({
|
|
114
|
+
name: stripCrlf(att.filename),
|
|
115
|
+
type: att.contentType
|
|
116
|
+
? stripCrlf(att.contentType)
|
|
117
|
+
: "application/octet-stream",
|
|
118
|
+
data: Buffer.from(att.content as Buffer | string).toString("base64"),
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let res: Response;
|
|
123
|
+
try {
|
|
124
|
+
res = await fetch(`${this.#baseUrl}/api/v1/transmissions`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: {
|
|
127
|
+
Authorization: this.#apiKey,
|
|
128
|
+
"Content-Type": "application/json",
|
|
129
|
+
},
|
|
130
|
+
body: JSON.stringify(body),
|
|
131
|
+
});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
throw wrapFetchNetworkError("sparkpost", err);
|
|
134
|
+
}
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
const providerMessage = redactSecrets(capMessage(await res.text()));
|
|
137
|
+
const retryAfter = res.headers.get("retry-after") ?? undefined;
|
|
138
|
+
const ctx: Record<string, string> = {
|
|
139
|
+
provider: "sparkpost",
|
|
140
|
+
upstreamStatus: String(res.status),
|
|
141
|
+
providerMessage,
|
|
142
|
+
};
|
|
143
|
+
if (retryAfter) ctx.retryAfter = retryAfter;
|
|
144
|
+
throw new RoverError(
|
|
145
|
+
"MAIL_PROVIDER_ERROR",
|
|
146
|
+
`SparkPost returned ${res.status}`,
|
|
147
|
+
{
|
|
148
|
+
hint: "Inspect `context.upstreamStatus` to decide retry eligibility. `context.retryAfter` (when set) carries the provider's backoff hint in seconds.",
|
|
149
|
+
context: ctx,
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
// Success: SparkPost returns `{ results: { id: "<id>", ... } }`.
|
|
154
|
+
try {
|
|
155
|
+
const parsed = (await res.json()) as { results?: { id?: string } };
|
|
156
|
+
const id = parsed.results?.id;
|
|
157
|
+
if (typeof id === "string" && id.length > 0) {
|
|
158
|
+
return { providerId: id };
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// Empty / non-JSON body — fall back to generated id.
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Split a possibly-formatted address (`"Name" <addr>` / `Name <addr>` / bare
|
|
169
|
+
* `addr`) into SparkPost's `{ email, name? }` address shape.
|
|
170
|
+
*/
|
|
171
|
+
function parseAddress(input: string): { email: string; name?: string } {
|
|
172
|
+
const s = stripCrlf(input).trim();
|
|
173
|
+
const match = s.match(/^(.*)<([^>]+)>\s*$/);
|
|
174
|
+
if (match) {
|
|
175
|
+
const email = match[2].trim();
|
|
176
|
+
const name = match[1].trim().replace(/^"|"$/g, "").trim();
|
|
177
|
+
return name ? { email, name } : { email };
|
|
178
|
+
}
|
|
179
|
+
return { email: s };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
registerTransport("sparkpost", (config) => new SparkPostTransport(config));
|