@drawbridge/drawbridge-utils 0.0.112 → 0.0.114

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.
@@ -1,11 +1,11 @@
1
1
  // lib/connections/oauth.js
2
2
  import { createHash, randomBytes } from "crypto";
3
- var credentials = ({ clientId, clientSecret, descriptor }) => (descriptor == null ? void 0 : descriptor.clientAuth) === "basic" ? {
4
- headers: { authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64") },
5
- body: {}
3
+ var credentials = ({ basic, clientId, clientSecret }) => basic ? {
4
+ body: {},
5
+ headers: { authorization: "Basic " + Buffer.from(clientId + ":" + clientSecret).toString("base64") }
6
6
  } : {
7
- headers: {},
8
- body: { client_id: clientId, client_secret: clientSecret }
7
+ body: { client_id: clientId, client_secret: clientSecret },
8
+ headers: {}
9
9
  };
10
10
  var pkcePair = () => {
11
11
  const verifier = randomBytes(32).toString("base64url");
@@ -16,10 +16,11 @@ var pkcePair = () => {
16
16
  };
17
17
  };
18
18
  var consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) => {
19
+ var _a;
19
20
  if (!clientId) throw new Error("This deployment has no OAuth client configured, so there is nothing to consent through");
20
- if (!(descriptor == null ? void 0 : descriptor.authorize)) throw new Error("This connection declares no authorize url");
21
+ if (!((_a = descriptor == null ? void 0 : descriptor.urls) == null ? void 0 : _a.authorize)) throw new Error("This connection declares no authorize url");
21
22
  if (descriptor.pkce && !challenge) throw new Error("This connection requires PKCE, so a code challenge is not optional");
22
- return descriptor.authorize + "?" + new URLSearchParams({
23
+ return descriptor.urls.authorize + "?" + new URLSearchParams({
23
24
  // The descriptor's own params go FIRST, so a vendor quirk cannot quietly
24
25
  // overwrite one of the fields below that every consent carries.
25
26
  ...descriptor.params || {},
@@ -34,17 +35,33 @@ var consentUrl = ({ challenge, clientId, descriptor, redirect, state } = {}) =>
34
35
  state
35
36
  });
36
37
  };
37
- var exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fetch, redirect, verifier } = {}) => {
38
- if (!(descriptor == null ? void 0 : descriptor.token)) throw new Error("This connection declares no token url");
39
- if (descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
40
- const client = credentials({ clientId, clientSecret, descriptor });
41
- const response = await fetcher(descriptor.token, {
38
+ var authToken = async ({
39
+ basic,
40
+ clientId,
41
+ clientSecret,
42
+ code,
43
+ descriptor,
44
+ fetcher = fetch,
45
+ redirect,
46
+ refreshToken,
47
+ verifier
48
+ } = {}) => {
49
+ var _a;
50
+ if (!clientId || !clientSecret) throw new Error("This deployment has no OAuth client configured, so no token can be minted");
51
+ if (!((_a = descriptor == null ? void 0 : descriptor.urls) == null ? void 0 : _a.token)) throw new Error("This connection declares no token url");
52
+ const renewing = !code;
53
+ if (renewing && !refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
54
+ if (!renewing && descriptor.pkce && !verifier) throw new Error("This connection requires PKCE, so the code verifier is not optional");
55
+ const client = credentials({ basic, clientId, clientSecret });
56
+ const response = await fetcher(descriptor.urls.token, {
42
57
  body: new URLSearchParams({
43
58
  ...client.body,
44
- code: decodeURIComponent(String(code || "").trim()),
45
- grant_type: "authorization_code",
46
- redirect_uri: redirect,
47
- ...descriptor.pkce && { code_verifier: verifier }
59
+ ...renewing ? { grant_type: "refresh_token", refresh_token: refreshToken } : {
60
+ code: decodeURIComponent(String(code || "").trim()),
61
+ grant_type: "authorization_code",
62
+ redirect_uri: redirect,
63
+ ...descriptor.pkce && { code_verifier: verifier }
64
+ }
48
65
  }),
49
66
  headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
50
67
  method: "POST",
@@ -52,45 +69,28 @@ var exchange = async ({ clientId, clientSecret, code, descriptor, fetcher = fetc
52
69
  });
53
70
  const body = await response.json().catch(() => ({}));
54
71
  if (!response.ok) {
55
- throw new Error("The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : ""));
72
+ throw new Error(
73
+ renewing ? "The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection" : "The vendor refused the exchange (" + response.status + ")" + ((body == null ? void 0 : body.error) ? ": " + body.error : "")
74
+ );
56
75
  }
57
- if (!body.access_token) throw new Error("The vendor returned no access token");
76
+ if (!renewing && !body.access_token) throw new Error("The vendor returned no access token");
58
77
  return {
59
78
  accessToken: body.access_token,
60
79
  expiresIn: body.expires_in || null,
80
+ // A REFRESH TOKEN IS NOT UNIVERSAL. Google returns one only with
81
+ // access_type=offline; Mailchimp's tokens do not expire and it returns none
82
+ // at all, so its absence cannot be an error here.
83
+ //
84
+ // On a RENEWAL the same null matters for the opposite reason: a vendor that
85
+ // rotates returns a new one, and dropping it silently invalidates the stored
86
+ // grant on the NEXT refresh — a failure a day late and nowhere near its
87
+ // cause. tokenSettings() keeps the existing one when this is null.
61
88
  refreshToken: body.refresh_token || null,
62
89
  scope: body.scope || null
63
90
  };
64
91
  };
65
- var refresh = async ({ clientId, clientSecret, descriptor, fetcher = fetch, refreshToken } = {}) => {
66
- if (!clientId || !clientSecret) throw new Error("This deployment has no OAuth client configured, so no token can be minted");
67
- if (!refreshToken) throw new Error("Nothing has been consented to yet, so there is no refresh token to spend");
68
- if (!(descriptor == null ? void 0 : descriptor.token)) throw new Error("This connection declares no token url");
69
- const client = credentials({ clientId, clientSecret, descriptor });
70
- const response = await fetcher(descriptor.token, {
71
- body: new URLSearchParams({
72
- ...client.body,
73
- grant_type: "refresh_token",
74
- refresh_token: refreshToken
75
- }),
76
- headers: { "content-type": "application/x-www-form-urlencoded", ...client.headers },
77
- method: "POST",
78
- signal: AbortSignal.timeout(15e3)
79
- });
80
- if (!response.ok) throw new Error("The vendor refused the refresh token (" + response.status + ") \u2014 reconnect the connection");
81
- const body = await response.json();
82
- return {
83
- accessToken: body.access_token,
84
- expiresIn: body.expires_in || null,
85
- // A vendor that rotates its refresh token returns a new one, and dropping
86
- // it silently invalidates the stored grant on the NEXT refresh rather
87
- // than this one — a failure a day late and nowhere near its cause.
88
- refreshToken: body.refresh_token || null
89
- };
90
- };
91
92
  export {
93
+ authToken,
92
94
  consentUrl,
93
- exchange,
94
- pkcePair,
95
- refresh
95
+ pkcePair
96
96
  };
@@ -1,7 +1,7 @@
1
1
  import dns from 'dns';
2
2
  import * as http from 'node:http';
3
3
  import * as https from 'node:https';
4
- import { isBlockedIP, axios } from './axios.cjs';
4
+ import { axios, isBlockedIP } from './axios.cjs';
5
5
  import 'axios';
6
6
  import 'net';
7
7
 
@@ -1,7 +1,7 @@
1
1
  import dns from 'dns';
2
2
  import * as http from 'node:http';
3
3
  import * as https from 'node:https';
4
- import { isBlockedIP, axios } from './axios.js';
4
+ import { axios, isBlockedIP } from './axios.js';
5
5
  import 'axios';
6
6
  import 'net';
7
7
 
@@ -0,0 +1,168 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // lib/sendgrid.js
20
+ var sendgrid_exports = {};
21
+ __export(sendgrid_exports, {
22
+ sendWithRetry: () => sendWithRetry,
23
+ sendgrid: () => sendgrid,
24
+ sendgridRequest: () => sendgridRequest
25
+ });
26
+ module.exports = __toCommonJS(sendgrid_exports);
27
+
28
+ // lib/http.js
29
+ var DEFAULT_TIMEOUT_MS = 15e3;
30
+ var request = async ({
31
+ body,
32
+ headers = {},
33
+ method = "GET",
34
+ query,
35
+ timeout = DEFAULT_TIMEOUT_MS,
36
+ type = "json",
37
+ url
38
+ }) => {
39
+ const fullUrl = new URL(url);
40
+ if (query) {
41
+ Object.entries(query).forEach(([k, v]) => fullUrl.searchParams.set(k, v));
42
+ }
43
+ ;
44
+ const isForm = type === "form";
45
+ const response = await fetch(fullUrl.toString(), {
46
+ method,
47
+ headers: {
48
+ "Content-Type": isForm ? "application/x-www-form-urlencoded" : "application/json",
49
+ ...headers
50
+ },
51
+ signal: AbortSignal.timeout(timeout),
52
+ ...body !== void 0 && {
53
+ body: isForm ? new URLSearchParams(body).toString() : JSON.stringify(body)
54
+ }
55
+ });
56
+ if (!response.ok) {
57
+ const text2 = await response.text().catch(() => "");
58
+ const error = new Error(text2 || response.statusText);
59
+ error.status = response.status;
60
+ throw error;
61
+ }
62
+ ;
63
+ const text = await response.text();
64
+ try {
65
+ return text ? JSON.parse(text) : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ };
70
+
71
+ // lib/sendgrid.js
72
+ var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
73
+ var logger = (0, import_drawbridge_telemetry.createLogger)();
74
+ var SENDGRID_BASE = "https://api.sendgrid.com";
75
+ var RETRYABLE_STATUSES = [429, 500, 502, 503];
76
+ var RETRY_DELAYS_MS = [1e3, 4e3];
77
+ var sendWithRetry = async (send, { delays = RETRY_DELAYS_MS } = {}) => {
78
+ for (let attempt = 0; ; attempt++) {
79
+ try {
80
+ return await send();
81
+ } catch (error) {
82
+ const status = Number(error == null ? void 0 : error.status);
83
+ const retryable = RETRYABLE_STATUSES.includes(status);
84
+ if (!retryable || attempt >= delays.length) {
85
+ if (!status) {
86
+ logger.warn("email.send.ambiguous-failure", {
87
+ name: error == null ? void 0 : error.name,
88
+ message: error == null ? void 0 : error.message
89
+ });
90
+ }
91
+ throw error;
92
+ }
93
+ logger.info("email.send.retry", {
94
+ attempt: attempt + 1,
95
+ status,
96
+ delayMs: delays[attempt]
97
+ });
98
+ await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
99
+ }
100
+ }
101
+ };
102
+ var sendgridRequest = ({ apiKey, body, method, path, query, request: request2 = request }) => {
103
+ const key = apiKey || process.env.SENDGRID_API_KEY;
104
+ return request2({
105
+ body,
106
+ headers: {
107
+ "Authorization": "Bearer " + key
108
+ },
109
+ method,
110
+ query,
111
+ url: SENDGRID_BASE + path
112
+ });
113
+ };
114
+ var sendgrid = {
115
+ // `headers` (optional) carries the List-Unsubscribe pair on lead-facing
116
+ // commercial sends; omitted, the request body is byte-identical to the
117
+ // pre-opt-out-floor shape so system mail is untouched.
118
+ send: async ({ apiKey, from, headers, html, request: request2, subject, text, to }) => {
119
+ var _a, _b;
120
+ try {
121
+ const sender = (from == null ? void 0 : from.email) ? from : {
122
+ email: process.env.SENDGRID_FROM_ADDRESS,
123
+ name: "Drawbridge"
124
+ };
125
+ await sendWithRetry(() => sendgridRequest({
126
+ apiKey,
127
+ ...request2 && { request: request2 },
128
+ method: "POST",
129
+ path: "/v3/mail/send",
130
+ body: {
131
+ content: [
132
+ {
133
+ type: "text/plain",
134
+ value: text
135
+ },
136
+ {
137
+ type: "text/html",
138
+ value: html
139
+ }
140
+ ],
141
+ from: sender,
142
+ ...headers && { headers },
143
+ personalizations: [
144
+ {
145
+ to: [
146
+ { email: to }
147
+ ]
148
+ }
149
+ ],
150
+ subject
151
+ }
152
+ }));
153
+ } catch (error) {
154
+ try {
155
+ const parsed = JSON.parse(error.message);
156
+ if ((_b = (_a = parsed == null ? void 0 : parsed.errors) == null ? void 0 : _a[0]) == null ? void 0 : _b.message) error.message = parsed.errors[0].message;
157
+ } catch {
158
+ }
159
+ throw error;
160
+ }
161
+ }
162
+ };
163
+ // Annotate the CommonJS export names for ESM import in node:
164
+ 0 && (module.exports = {
165
+ sendWithRetry,
166
+ sendgrid,
167
+ sendgridRequest
168
+ });
@@ -0,0 +1,185 @@
1
+ import { request } from './http.cjs';
2
+ import { createLogger } from '@drawbridge/drawbridge-telemetry';
3
+
4
+ // SENDGRID TRANSPORT. Vendor HTTP and nothing else — no controller, no queue,
5
+ // no database.
6
+ //
7
+ // HERE rather than in drawbridge-sync because every other vendor's transport is
8
+ // here: Klaviyo's api(), Shopify's signature verification, Mailchimp's fetch.
9
+ // SendGrid was the one exception, and Drawbridge is a connection now like any
10
+ // other — the private `drawbridge` manifest declares email.send, email.notify
11
+ // and email.digest, so its transport belongs beside its manifest.
12
+ //
13
+ // The API key still falls back to process.env, which is a carry-over rather than
14
+ // a preference: a published package reading a deployment's environment is the
15
+ // thing `client : { id : 'KLAVIYO_OAUTH_CLIENT_ID' }` exists to avoid. Callers
16
+ // that pass a key explicitly never touch it.
17
+
18
+
19
+ const logger = createLogger();
20
+
21
+ const SENDGRID_BASE = 'https://api.sendgrid.com';
22
+
23
+ // --- provider-send retry -----------------------------------------------------
24
+ //
25
+ // Notification jobs are non-retryable BY DESIGN (non-idempotent $incs on the
26
+ // user-notification path), so a provider hiccup used to mean a silently
27
+ // dropped email. This retry wraps ONLY the provider POST, and only fires on
28
+ // DEFINITE pre-delivery failures — an HTTP status proving the provider
29
+ // REJECTED the request (rate limit / server error), where a retry can never
30
+ // double-send. Ambiguous outcomes (timeout, abort, connection reset — no
31
+ // status, because the request died in transit) are NOT retried: the provider
32
+ // may already have accepted the mail, and retrying risks a duplicate email in
33
+ // a real person's inbox. Those are logged and rethrown (plan guard-rail 12).
34
+ const RETRYABLE_STATUSES = [ 429, 500, 502, 503 ];
35
+
36
+ // 1s then 4s — long enough for a rate-limit window to roll over, short enough
37
+ // to keep the send inside the notification job's runtime budget.
38
+ const RETRY_DELAYS_MS = [ 1000, 4000 ];
39
+
40
+ const sendWithRetry = async ( send, { delays = RETRY_DELAYS_MS } = {} ) => {
41
+
42
+ for( let attempt = 0; ; attempt++ ){
43
+
44
+ try {
45
+
46
+ return await send();
47
+
48
+ } catch ( error ) {
49
+
50
+ const status = Number( error?.status );
51
+ const retryable = RETRYABLE_STATUSES.includes( status );
52
+
53
+ if( ! retryable || attempt >= delays.length ){
54
+
55
+ if( ! status ){
56
+
57
+ // No HTTP status = timeout / abort / reset — the ambiguous
58
+ // "maybe the provider accepted it" case. Surface it in logs
59
+ // so possible-but-unconfirmed sends are searchable.
60
+ logger.warn( 'email.send.ambiguous-failure', {
61
+ name : error?.name,
62
+ message : error?.message
63
+ });
64
+
65
+ }
66
+
67
+ throw error;
68
+
69
+ }
70
+
71
+ logger.info( 'email.send.retry', {
72
+ attempt : attempt + 1,
73
+ status,
74
+ delayMs : delays[ attempt ]
75
+ });
76
+
77
+ await new Promise( ( resolve ) => setTimeout( resolve, delays[ attempt ] ) );
78
+
79
+ }
80
+
81
+ }
82
+
83
+ };
84
+
85
+ // `request` is INJECTABLE, and that is not decoration. tsup emits this module's
86
+ // exports as getter-only, non-configurable properties, so the stub-by-mutation
87
+ // trick that worked when this lived in drawbridge-sync silently no-ops here —
88
+ // the assignment appears to succeed and the real HTTP call goes out. Injection
89
+ // is how every other vendor call in this package is tested (`fetcher` on each
90
+ // manifest hook), and it is the only thing that works across the boundary.
91
+ const sendgridRequest = ({ apiKey, body, method, path, query, request: request$1 = request }) => {
92
+
93
+ const key = apiKey || process.env.SENDGRID_API_KEY;
94
+
95
+ return request$1({
96
+ body,
97
+ headers : {
98
+ 'Authorization' : 'Bearer ' + key
99
+ },
100
+ method,
101
+ query,
102
+ url : SENDGRID_BASE + path
103
+ });
104
+
105
+ };
106
+
107
+ const sendgrid = {
108
+
109
+ // `headers` (optional) carries the List-Unsubscribe pair on lead-facing
110
+ // commercial sends; omitted, the request body is byte-identical to the
111
+ // pre-opt-out-floor shape so system mail is untouched.
112
+ send : async ({ apiKey, from, headers, html, request, subject, text, to }) => {
113
+
114
+ try {
115
+
116
+ const sender = ( from?.email ?
117
+ from
118
+ :
119
+ {
120
+ email : process.env.SENDGRID_FROM_ADDRESS,
121
+ name : 'Drawbridge'
122
+ }
123
+ );
124
+
125
+ await sendWithRetry( () => sendgridRequest({
126
+ apiKey,
127
+ ...( request && { request }),
128
+ method : 'POST',
129
+ path : '/v3/mail/send',
130
+ body : {
131
+ content : [
132
+ {
133
+ type : 'text/plain',
134
+ value : text
135
+ },
136
+ {
137
+ type : 'text/html',
138
+ value : html
139
+ }
140
+ ],
141
+ from : sender,
142
+ ...( headers && { headers } ),
143
+ personalizations : [
144
+ {
145
+ to : [
146
+ { email : to }
147
+ ]
148
+ }
149
+ ],
150
+ subject
151
+ }
152
+ }) );
153
+
154
+ } catch ( error ) {
155
+
156
+ // SendGrid rejections carry a JSON body; surface its first message.
157
+ // Non-JSON failures (timeouts, resets) pass through untouched so the
158
+ // parse can't mask the real error, and error.status survives for the
159
+ // caller.
160
+ try {
161
+
162
+ const parsed = JSON.parse( error.message );
163
+
164
+ if( parsed?.errors?.[ 0 ]?.message ) error.message = parsed.errors[ 0 ].message;
165
+
166
+ } catch {}
167
+
168
+ throw error;
169
+
170
+ }
171
+
172
+ }
173
+
174
+ };
175
+
176
+ // The Mandrill sender that used to live here is gone. Drawbridge sends every
177
+ // lead-facing email from its own SendGrid account now, so there was no caller
178
+ // left — and the merchant `mailchimp` connection is becoming an audience sync,
179
+ // which speaks to the Marketing API rather than Mandrill's transactional one.
180
+
181
+ // sendgridRequest is exported so the sending-domain slice can call SendGrid's
182
+ // domain-authentication endpoints (/v3/whitelabel/domains) through the same
183
+ // authenticated client the mail send uses, rather than minting a second one.
184
+
185
+ export { sendWithRetry, sendgrid, sendgridRequest };
@@ -0,0 +1,185 @@
1
+ import { request } from './http.js';
2
+ import { createLogger } from '@drawbridge/drawbridge-telemetry';
3
+
4
+ // SENDGRID TRANSPORT. Vendor HTTP and nothing else — no controller, no queue,
5
+ // no database.
6
+ //
7
+ // HERE rather than in drawbridge-sync because every other vendor's transport is
8
+ // here: Klaviyo's api(), Shopify's signature verification, Mailchimp's fetch.
9
+ // SendGrid was the one exception, and Drawbridge is a connection now like any
10
+ // other — the private `drawbridge` manifest declares email.send, email.notify
11
+ // and email.digest, so its transport belongs beside its manifest.
12
+ //
13
+ // The API key still falls back to process.env, which is a carry-over rather than
14
+ // a preference: a published package reading a deployment's environment is the
15
+ // thing `client : { id : 'KLAVIYO_OAUTH_CLIENT_ID' }` exists to avoid. Callers
16
+ // that pass a key explicitly never touch it.
17
+
18
+
19
+ const logger = createLogger();
20
+
21
+ const SENDGRID_BASE = 'https://api.sendgrid.com';
22
+
23
+ // --- provider-send retry -----------------------------------------------------
24
+ //
25
+ // Notification jobs are non-retryable BY DESIGN (non-idempotent $incs on the
26
+ // user-notification path), so a provider hiccup used to mean a silently
27
+ // dropped email. This retry wraps ONLY the provider POST, and only fires on
28
+ // DEFINITE pre-delivery failures — an HTTP status proving the provider
29
+ // REJECTED the request (rate limit / server error), where a retry can never
30
+ // double-send. Ambiguous outcomes (timeout, abort, connection reset — no
31
+ // status, because the request died in transit) are NOT retried: the provider
32
+ // may already have accepted the mail, and retrying risks a duplicate email in
33
+ // a real person's inbox. Those are logged and rethrown (plan guard-rail 12).
34
+ const RETRYABLE_STATUSES = [ 429, 500, 502, 503 ];
35
+
36
+ // 1s then 4s — long enough for a rate-limit window to roll over, short enough
37
+ // to keep the send inside the notification job's runtime budget.
38
+ const RETRY_DELAYS_MS = [ 1000, 4000 ];
39
+
40
+ const sendWithRetry = async ( send, { delays = RETRY_DELAYS_MS } = {} ) => {
41
+
42
+ for( let attempt = 0; ; attempt++ ){
43
+
44
+ try {
45
+
46
+ return await send();
47
+
48
+ } catch ( error ) {
49
+
50
+ const status = Number( error?.status );
51
+ const retryable = RETRYABLE_STATUSES.includes( status );
52
+
53
+ if( ! retryable || attempt >= delays.length ){
54
+
55
+ if( ! status ){
56
+
57
+ // No HTTP status = timeout / abort / reset — the ambiguous
58
+ // "maybe the provider accepted it" case. Surface it in logs
59
+ // so possible-but-unconfirmed sends are searchable.
60
+ logger.warn( 'email.send.ambiguous-failure', {
61
+ name : error?.name,
62
+ message : error?.message
63
+ });
64
+
65
+ }
66
+
67
+ throw error;
68
+
69
+ }
70
+
71
+ logger.info( 'email.send.retry', {
72
+ attempt : attempt + 1,
73
+ status,
74
+ delayMs : delays[ attempt ]
75
+ });
76
+
77
+ await new Promise( ( resolve ) => setTimeout( resolve, delays[ attempt ] ) );
78
+
79
+ }
80
+
81
+ }
82
+
83
+ };
84
+
85
+ // `request` is INJECTABLE, and that is not decoration. tsup emits this module's
86
+ // exports as getter-only, non-configurable properties, so the stub-by-mutation
87
+ // trick that worked when this lived in drawbridge-sync silently no-ops here —
88
+ // the assignment appears to succeed and the real HTTP call goes out. Injection
89
+ // is how every other vendor call in this package is tested (`fetcher` on each
90
+ // manifest hook), and it is the only thing that works across the boundary.
91
+ const sendgridRequest = ({ apiKey, body, method, path, query, request: request$1 = request }) => {
92
+
93
+ const key = apiKey || process.env.SENDGRID_API_KEY;
94
+
95
+ return request$1({
96
+ body,
97
+ headers : {
98
+ 'Authorization' : 'Bearer ' + key
99
+ },
100
+ method,
101
+ query,
102
+ url : SENDGRID_BASE + path
103
+ });
104
+
105
+ };
106
+
107
+ const sendgrid = {
108
+
109
+ // `headers` (optional) carries the List-Unsubscribe pair on lead-facing
110
+ // commercial sends; omitted, the request body is byte-identical to the
111
+ // pre-opt-out-floor shape so system mail is untouched.
112
+ send : async ({ apiKey, from, headers, html, request, subject, text, to }) => {
113
+
114
+ try {
115
+
116
+ const sender = ( from?.email ?
117
+ from
118
+ :
119
+ {
120
+ email : process.env.SENDGRID_FROM_ADDRESS,
121
+ name : 'Drawbridge'
122
+ }
123
+ );
124
+
125
+ await sendWithRetry( () => sendgridRequest({
126
+ apiKey,
127
+ ...( request && { request }),
128
+ method : 'POST',
129
+ path : '/v3/mail/send',
130
+ body : {
131
+ content : [
132
+ {
133
+ type : 'text/plain',
134
+ value : text
135
+ },
136
+ {
137
+ type : 'text/html',
138
+ value : html
139
+ }
140
+ ],
141
+ from : sender,
142
+ ...( headers && { headers } ),
143
+ personalizations : [
144
+ {
145
+ to : [
146
+ { email : to }
147
+ ]
148
+ }
149
+ ],
150
+ subject
151
+ }
152
+ }) );
153
+
154
+ } catch ( error ) {
155
+
156
+ // SendGrid rejections carry a JSON body; surface its first message.
157
+ // Non-JSON failures (timeouts, resets) pass through untouched so the
158
+ // parse can't mask the real error, and error.status survives for the
159
+ // caller.
160
+ try {
161
+
162
+ const parsed = JSON.parse( error.message );
163
+
164
+ if( parsed?.errors?.[ 0 ]?.message ) error.message = parsed.errors[ 0 ].message;
165
+
166
+ } catch {}
167
+
168
+ throw error;
169
+
170
+ }
171
+
172
+ }
173
+
174
+ };
175
+
176
+ // The Mandrill sender that used to live here is gone. Drawbridge sends every
177
+ // lead-facing email from its own SendGrid account now, so there was no caller
178
+ // left — and the merchant `mailchimp` connection is becoming an audience sync,
179
+ // which speaks to the Marketing API rather than Mandrill's transactional one.
180
+
181
+ // sendgridRequest is exported so the sending-domain slice can call SendGrid's
182
+ // domain-authentication endpoints (/v3/whitelabel/domains) through the same
183
+ // authenticated client the mail send uses, rather than minting a second one.
184
+
185
+ export { sendWithRetry, sendgrid, sendgridRequest };