@drawbridge/drawbridge-utils 0.0.157 → 0.0.158

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/providers.js CHANGED
@@ -791,7 +791,53 @@ var attentive_default2 = {
791
791
  };
792
792
 
793
793
  // lib/connections/providers/drawbridge.js
794
- import { createHmac, timingSafeEqual } from "crypto";
794
+ import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
795
+
796
+ // lib/connections/inbound.js
797
+ import { createHmac, createVerify, timingSafeEqual } from "crypto";
798
+ var verifySignature = ({ body, descriptor, headers, secret }) => {
799
+ if (!secret) {
800
+ throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
801
+ }
802
+ const provided = headers[descriptor.headers.signature];
803
+ if (!provided) {
804
+ throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
805
+ }
806
+ const digest = createHmac(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
807
+ const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
808
+ const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
809
+ if (digestBuffer.length !== providedBuffer.length || !timingSafeEqual(digestBuffer, providedBuffer)) {
810
+ throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
811
+ }
812
+ return JSON.parse(body.toString());
813
+ };
814
+ var REPLAY_TOLERANCE_SECONDS = 5 * 60;
815
+ var asPem = (key) => [
816
+ "-----BEGIN PUBLIC KEY-----",
817
+ ...key.match(/.{1,64}/g) || [],
818
+ "-----END PUBLIC KEY-----"
819
+ ].join("\n");
820
+ var verifyEcdsa = ({ body, descriptor, headers, secret }) => {
821
+ if (!secret) {
822
+ throw Object.assign(new Error("Missing webhook key: " + descriptor.signature.secret), { status: 500 });
823
+ }
824
+ const provided = headers[descriptor.headers.signature];
825
+ const timestamp = headers[descriptor.headers.timestamp];
826
+ if (!provided || !timestamp) {
827
+ throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
828
+ }
829
+ const age = Math.abs(Math.floor(Date.now() / 1e3) - Number(timestamp));
830
+ if (!(age <= REPLAY_TOLERANCE_SECONDS)) {
831
+ throw Object.assign(new Error("Stale webhook signature"), { status: 401 });
832
+ }
833
+ const payload = Buffer.concat([Buffer.from(String(timestamp), "utf8"), body]);
834
+ const verified = createVerify("sha256").update(payload).verify(asPem(secret), Buffer.from(provided, "base64"));
835
+ if (!verified) {
836
+ throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
837
+ }
838
+ return JSON.parse(body.toString());
839
+ };
840
+ var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
795
841
 
796
842
  // lib/http.js
797
843
  var DEFAULT_TIMEOUT_MS = 15e3;
@@ -1682,6 +1728,144 @@ var MINIMUM_ACTION_CENTS = Math.round(
1682
1728
  // lib/connections/providers/drawbridge.js
1683
1729
  var STOP_KEYWORDS = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"];
1684
1730
  var START_KEYWORDS = ["START", "UNSTOP", "YES"];
1731
+ var sendgridInbound = {
1732
+ headers: {
1733
+ signature: "x-twilio-email-event-webhook-signature",
1734
+ timestamp: "x-twilio-email-event-webhook-timestamp"
1735
+ },
1736
+ signature: {
1737
+ secret: "SENDGRID_EVENT_WEBHOOK_KEY"
1738
+ }
1739
+ };
1740
+ var REFUSALS = /* @__PURE__ */ new Set(["blocked", "bounce", "deferred", "dropped", "spamreport"]);
1741
+ var DELIVERY = {
1742
+ queued: 10,
1743
+ sent: 20,
1744
+ deferred: 30,
1745
+ blocked: 40,
1746
+ delivered: 50,
1747
+ bounced: 60,
1748
+ complained: 70
1749
+ };
1750
+ var fromSendgrid = (event) => {
1751
+ if ((event == null ? void 0 : event.event) === "bounce") return event.type === "blocked" ? "blocked" : "bounced";
1752
+ return {
1753
+ blocked: "blocked",
1754
+ deferred: "deferred",
1755
+ delivered: "delivered",
1756
+ dropped: "bounced",
1757
+ processed: "queued",
1758
+ spamreport: "complained"
1759
+ }[event == null ? void 0 : event.event] || null;
1760
+ };
1761
+ var fromTwilio = (status) => ({
1762
+ delivered: "delivered",
1763
+ failed: "bounced",
1764
+ queued: "queued",
1765
+ sending: "sent",
1766
+ sent: "sent",
1767
+ undelivered: "bounced"
1768
+ })[String(status || "").toLowerCase()] || null;
1769
+ var deliveryWrite = ({ at, code: code2, notification, permanent, provider, reason, status }) => {
1770
+ const rank = DELIVERY[status];
1771
+ if (!notification || !rank) return null;
1772
+ return {
1773
+ collection: "notification",
1774
+ data: {
1775
+ $set: {
1776
+ "meta.delivery": {
1777
+ at: at || /* @__PURE__ */ new Date(),
1778
+ ...code2 !== void 0 && code2 !== null && { code: String(code2) },
1779
+ ...permanent !== void 0 && { permanent },
1780
+ provider,
1781
+ rank,
1782
+ ...reason && { reason: String(reason) },
1783
+ status
1784
+ }
1785
+ }
1786
+ },
1787
+ operation: "update",
1788
+ query: {
1789
+ id: notification,
1790
+ // NEVER an upsert. A callback naming a notification we do not have is a
1791
+ // vendor talking about someone else's message, not a document to create.
1792
+ $or: [
1793
+ { "meta.delivery.rank": { $exists: false } },
1794
+ { "meta.delivery.rank": { $lt: rank } }
1795
+ ]
1796
+ }
1797
+ };
1798
+ };
1799
+ var summariseEvents = (events) => {
1800
+ const list = Array.isArray(events) ? events : [];
1801
+ if (!list.length) return { message: "SendGrid delivered an empty event batch.", skipped: true };
1802
+ const counts = {};
1803
+ for (const { event } of list) {
1804
+ const name = event || "unknown";
1805
+ counts[name] = (counts[name] || 0) + 1;
1806
+ }
1807
+ const tally = Object.entries(counts).map(([name, count]) => count + " " + name).join(", ");
1808
+ const refused = list.filter(({ event }) => REFUSALS.has(event)).slice(0, 5).map(({ email, reason, response, status }) => email + " \u2014 " + (reason || response || status || "no reason given"));
1809
+ const writes = list.map((event) => {
1810
+ var _a;
1811
+ return deliveryWrite({
1812
+ at: event.timestamp ? new Date(event.timestamp * 1e3) : void 0,
1813
+ code: event.status,
1814
+ // Read BOTH shapes. SendGrid echoes custom_args as siblings of `email`
1815
+ // and `event`, which is why it warns about colliding with reserved
1816
+ // names — but this is the one thing in the design that cannot be proved
1817
+ // until a real event arrives, and reading the nested form too costs a
1818
+ // token and removes the only silent failure left here.
1819
+ notification: event.notification || ((_a = event.custom_args) == null ? void 0 : _a.notification),
1820
+ // Only a bounce carries the distinction, and only a real one is
1821
+ // permanent — `blocked` is this attempt refused, not this address dead.
1822
+ ...event.event === "bounce" && { permanent: event.type !== "blocked" },
1823
+ provider: { slug: "sendgrid", id: event.sg_message_id || null },
1824
+ reason: event.reason || event.response,
1825
+ status: fromSendgrid(event)
1826
+ });
1827
+ }).filter(Boolean);
1828
+ return {
1829
+ message: [list.length + " SendGrid event(s): " + tally, ...refused].join(" | "),
1830
+ ...writes.length ? { writes } : { skipped: true }
1831
+ };
1832
+ };
1833
+ var statusCallback = (data2) => {
1834
+ const status = fromTwilio((data2 == null ? void 0 : data2.MessageStatus) || (data2 == null ? void 0 : data2.SmsStatus));
1835
+ if (!status) return { message: "Twilio sent a status this does not map: " + ((data2 == null ? void 0 : data2.MessageStatus) || "none"), skipped: true };
1836
+ const rank = DELIVERY[status];
1837
+ const write = {
1838
+ collection: "notification",
1839
+ data: {
1840
+ $set: {
1841
+ "meta.delivery": {
1842
+ at: /* @__PURE__ */ new Date(),
1843
+ ...(data2 == null ? void 0 : data2.ErrorCode) && { code: String(data2.ErrorCode) },
1844
+ // `failed` never left Twilio and `undelivered` was refused by the
1845
+ // carrier. Both are dead ends for this handset, so neither is
1846
+ // reported as a temporary condition.
1847
+ ...status === "bounced" && { permanent: true },
1848
+ provider: { slug: "twilio", id: (data2 == null ? void 0 : data2.MessageSid) || null },
1849
+ rank,
1850
+ ...(data2 == null ? void 0 : data2.ErrorMessage) && { reason: String(data2.ErrorMessage) },
1851
+ status
1852
+ }
1853
+ }
1854
+ },
1855
+ operation: "update",
1856
+ query: {
1857
+ "meta.delivery.provider.id": data2 == null ? void 0 : data2.MessageSid,
1858
+ $or: [
1859
+ { "meta.delivery.rank": { $exists: false } },
1860
+ { "meta.delivery.rank": { $lt: rank } }
1861
+ ]
1862
+ }
1863
+ };
1864
+ return {
1865
+ message: "Twilio " + status + " for " + ((data2 == null ? void 0 : data2.MessageSid) || "an unnamed message") + ((data2 == null ? void 0 : data2.ErrorCode) ? " (" + data2.ErrorCode + ")" : ""),
1866
+ ...(data2 == null ? void 0 : data2.MessageSid) ? { writes: [write] } : { skipped: true }
1867
+ };
1868
+ };
1685
1869
  var interpolate = (template, data2) => {
1686
1870
  if (!template) return template;
1687
1871
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (data2 == null ? void 0 : data2[key]) != null ? String(data2[key]) : "{{" + key + "}}");
@@ -1925,9 +2109,16 @@ var drawbridge_default2 = {
1925
2109
  // other vendor, and the bespoke webhooks route + the handler that lived in
1926
2110
  // sync's buffer table are gone.
1927
2111
  inbound: {
1928
- // EVERY POST ON THIS CHANNEL IS AN INBOUND MESSAGE. Twilio sends no
1929
- // topic header the registered url IS the topic.
1930
- event: () => "message.inbound",
2112
+ // THE REGISTERED URL IS THE TOPIC. Neither vendor sends a topic header,
2113
+ // so the channel it arrived on names the event — `sms` is one inbound
2114
+ // message, `email` is a batch of SendGrid delivery events.
2115
+ //
2116
+ // Defaulted because the drain calls `event()` with nothing when it only
2117
+ // wants the SMS name.
2118
+ event: ({ channel } = {}) => ({
2119
+ email: "email.events",
2120
+ "sms-status": "sms.status"
2121
+ })[channel] || "message.inbound",
1931
2122
  // STOP AND START, AS DESCRIBED WRITES. A withdrawn number is withdrawn
1932
2123
  // for everyone — organization : null is the platform floor canSend()
1933
2124
  // reads on every send path. $setOnInsert + upsert so webhook replays
@@ -1935,6 +2126,8 @@ var drawbridge_default2 = {
1935
2126
  // organization } index instead of erroring.
1936
2127
  process: ({ context }) => {
1937
2128
  var _a, _b;
2129
+ if ((context == null ? void 0 : context.topic) === "email.events") return summariseEvents(context == null ? void 0 : context.data);
2130
+ if ((context == null ? void 0 : context.topic) === "sms.status") return statusCallback(context == null ? void 0 : context.data);
1938
2131
  const keyword = String(((_a = context == null ? void 0 : context.data) == null ? void 0 : _a.Body) || "").trim().toUpperCase();
1939
2132
  const address = toE164((_b = context == null ? void 0 : context.data) == null ? void 0 : _b.From);
1940
2133
  if (!address) return { message: "Inbound SMS carried no usable sender number.", skipped: true };
@@ -1980,9 +2173,14 @@ var drawbridge_default2 = {
1980
2173
  }
1981
2174
  return { message: "A real reply, not a keyword \u2014 nothing to record.", skipped: true };
1982
2175
  },
1983
- receive: ({ payload }) => ({
2176
+ // A SendGrid batch carries many sg_message_ids and no single identity, so
2177
+ // it names no provider id. That means no dedupe key for a redelivery —
2178
+ // acceptable because SendGrid only retries a non-2xx, and the cost of a
2179
+ // duplicate here is a second buffer row rather than a repeated side
2180
+ // effect. Revisit if this ever writes suppressions.
2181
+ receive: ({ channel, payload }) => ({
1984
2182
  data: payload,
1985
- provider: { id: (payload == null ? void 0 : payload.MessageSid) || null }
2183
+ provider: { id: channel === "email" ? null : (payload == null ? void 0 : payload.MessageSid) || null }
1986
2184
  }),
1987
2185
  // TWILIO'S SCHEME, from docs.twilio.com/usage/security: take the full
1988
2186
  // registered url, sort the POST parameters alphabetically (Unix-style,
@@ -1991,13 +2189,14 @@ var drawbridge_default2 = {
1991
2189
  // X-Twilio-Signature. It signs the URL rather than the raw body, which
1992
2190
  // is exactly why the shared body-HMAC verifier cannot cover it and this
1993
2191
  // hook exists.
1994
- verify: ({ body, headers, secret, url }) => {
2192
+ verify: ({ body, channel, headers, secret, url }) => {
2193
+ if (channel === "email") return verifyEcdsa({ body, descriptor: sendgridInbound, headers, secret });
1995
2194
  if (!secret) throw Object.assign(new Error("Missing webhook secret: TWILIO_AUTH_TOKEN"), { status: 500 });
1996
2195
  const params = new URLSearchParams(String(body || ""));
1997
2196
  const signed = url + [...params.keys()].sort().map((key) => key + params.get(key)).join("");
1998
- const expected = createHmac("sha1", secret).update(signed).digest("base64");
2197
+ const expected = createHmac2("sha1", secret).update(signed).digest("base64");
1999
2198
  const provided = String((headers == null ? void 0 : headers["x-twilio-signature"]) || "");
2000
- const matches = expected.length === provided.length && timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
2199
+ const matches = expected.length === provided.length && timingSafeEqual2(Buffer.from(expected), Buffer.from(provided));
2001
2200
  if (!matches) throw Object.assign(new Error("Invalid Twilio signature"), { status: 401 });
2002
2201
  return Object.fromEntries(params);
2003
2202
  }
@@ -2181,15 +2380,30 @@ var drawbridge_default2 = {
2181
2380
  },
2182
2381
  icon: drawbridge_default,
2183
2382
  // WHERE TWILIO PUTS THINGS on an inbound request, and WHICH credential
2184
- // verifies it. `secret` names the drawbridge provider's smsToken the route
2185
- // resolves the name to the stored value and hands it to verify.
2383
+ // verifies each channel. The route resolves the NAME to the stored value and
2384
+ // hands it to verify; this manifest never reads a store.
2385
+ //
2386
+ // `signature.channels` because this is the one manifest that receives from two
2387
+ // different vendors — Twilio on `sms`, SendGrid on `email` — which need
2388
+ // different credentials and share no scheme. A manifest whose channels all
2389
+ // verify the same way keeps declaring `signature` flat, and the route falls
2390
+ // back to it; Shopify's two channels do exactly that.
2186
2391
  inbound: {
2187
2392
  headers: {
2188
2393
  id: "i-twilio-idempotency-token",
2189
2394
  signature: "x-twilio-signature"
2190
2395
  },
2191
2396
  signature: {
2192
- secret: "TWILIO_AUTH_TOKEN"
2397
+ channels: {
2398
+ email: sendgridInbound.signature,
2399
+ sms: { secret: "TWILIO_AUTH_TOKEN" },
2400
+ // Outbound delivery receipts, which Twilio signs exactly as it signs
2401
+ // an inbound message — same scheme, same credential, different url.
2402
+ // Separate channel because the payload is a MessageStatus rather than
2403
+ // a message, and conflating them would put a STOP keyword check on a
2404
+ // delivery receipt.
2405
+ "sms-status": { secret: "TWILIO_AUTH_TOKEN" }
2406
+ }
2193
2407
  }
2194
2408
  },
2195
2409
  // PRIVATE: never in the catalog, always available to the builder.
@@ -2221,6 +2435,13 @@ var drawbridge_default2 = {
2221
2435
  // Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
2222
2436
  // either. Unset, it degrades to the account sender rather than
2223
2437
  // refusing to start.
2438
+ // NOT redacted, because it is a PUBLIC key. Marking it secret would
2439
+ // be theatre, and it would stop an operator reading back the value
2440
+ // they need to compare against the SendGrid console.
2441
+ //
2442
+ // Optional: absent, the event webhook answers 500 on verify rather
2443
+ // than accepting unverified deliveries, and nothing else notices.
2444
+ { input: "text", key: "eventKey", credential: "SENDGRID_EVENT_WEBHOOK_KEY", label: "Event webhook key", message: "SendGrid, Settings, Mail Settings, Event Webhook \u2014 the verification key shown once signature verification is enabled. Public, not a secret." },
2224
2445
  { input: "email", key: "leadSender", credential: "SENDGRID_SEND_FROM_ADDRESS", label: "Lead sender", message: "The default for lead-facing mail when a merchant has not verified their own domain." }
2225
2446
  ],
2226
2447
  icon: sendgrid_default,
@@ -3335,26 +3556,6 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
3335
3556
  <path d="M300.325 382.771L368 365.96C368 365.96 338.904 169.185 338.689 167.892C338.473 166.599 337.396 165.737 336.318 165.737C335.24 165.737 316.274 165.306 316.274 165.306C316.274 165.306 304.636 154.099 300.325 149.788V382.771Z" fill="white"/>
3336
3557
  </svg>`;
3337
3558
 
3338
- // lib/connections/inbound.js
3339
- import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
3340
- var verifySignature = ({ body, descriptor, headers, secret }) => {
3341
- if (!secret) {
3342
- throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
3343
- }
3344
- const provided = headers[descriptor.headers.signature];
3345
- if (!provided) {
3346
- throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
3347
- }
3348
- const digest = createHmac2(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
3349
- const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
3350
- const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
3351
- if (digestBuffer.length !== providedBuffer.length || !timingSafeEqual2(digestBuffer, providedBuffer)) {
3352
- throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
3353
- }
3354
- return JSON.parse(body.toString());
3355
- };
3356
- var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
3357
-
3358
3559
  // lib/email.js
3359
3560
  var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
3360
3561
  var toCanonicalEmail = (value) => {
package/dist/sendgrid.cjs CHANGED
@@ -115,11 +115,27 @@ var sendgrid = {
115
115
  // `headers` (optional) carries the List-Unsubscribe pair on lead-facing
116
116
  // commercial sends; omitted, the request body is byte-identical to the
117
117
  // pre-opt-out-floor shape so system mail is untouched.
118
- send: async ({ apiKey, from, headers, html, request: request2, subject, text, to }) => {
118
+ // `args` (optional) rides out as SendGrid's custom_args and comes BACK on every
119
+ // Event Webhook event for the message. That is what makes a delivery event
120
+ // answerable: an event carries the recipient and the receiving server's
121
+ // reason, but nothing about who we sent as or why, so without these a bounce
122
+ // cannot be told from a lead send or a member one.
123
+ //
124
+ // Cheaper than the alternative, which was storing SendGrid's message id on the
125
+ // notification and joining on it — a schema-gated field, and schema-gated
126
+ // fields have to ship api-first and booted. This needs no collection to change.
127
+ //
128
+ // String values only, 10,000 bytes total (SendGrid's limit). One caveat worth
129
+ // knowing when reading events: a bounce delivered asynchronously against the
130
+ // Return-Path does not carry them.
131
+ send: async ({ apiKey, args, from, headers, html, request: request2, subject, text, to }) => {
119
132
  var _a, _b;
120
133
  try {
121
134
  if (!(from == null ? void 0 : from.email)) throw new Error("SendGrid sender missing \u2014 pass from.email (the drawbridge provider's accountSender)");
122
135
  const sender = { name: "Drawbridge", ...from };
136
+ const custom = Object.fromEntries(
137
+ Object.entries(args || {}).filter(([, value]) => value !== void 0 && value !== null && value !== "").map(([key, value]) => [key, String(value)])
138
+ );
123
139
  await sendWithRetry(() => sendgridRequest({
124
140
  apiKey,
125
141
  ...request2 && { request: request2 },
@@ -137,6 +153,7 @@ var sendgrid = {
137
153
  }
138
154
  ],
139
155
  from: sender,
156
+ ...Object.keys(custom).length && { custom_args: custom },
140
157
  ...headers && { headers },
141
158
  personalizations: [
142
159
  {
@@ -114,7 +114,20 @@ const sendgrid = {
114
114
  // `headers` (optional) carries the List-Unsubscribe pair on lead-facing
115
115
  // commercial sends; omitted, the request body is byte-identical to the
116
116
  // pre-opt-out-floor shape so system mail is untouched.
117
- send : async ({ apiKey, from, headers, html, request, subject, text, to }) => {
117
+ // `args` (optional) rides out as SendGrid's custom_args and comes BACK on every
118
+ // Event Webhook event for the message. That is what makes a delivery event
119
+ // answerable: an event carries the recipient and the receiving server's
120
+ // reason, but nothing about who we sent as or why, so without these a bounce
121
+ // cannot be told from a lead send or a member one.
122
+ //
123
+ // Cheaper than the alternative, which was storing SendGrid's message id on the
124
+ // notification and joining on it — a schema-gated field, and schema-gated
125
+ // fields have to ship api-first and booted. This needs no collection to change.
126
+ //
127
+ // String values only, 10,000 bytes total (SendGrid's limit). One caveat worth
128
+ // knowing when reading events: a bounce delivered asynchronously against the
129
+ // Return-Path does not carry them.
130
+ send : async ({ apiKey, args, from, headers, html, request, subject, text, to }) => {
118
131
 
119
132
  try {
120
133
 
@@ -127,6 +140,15 @@ const sendgrid = {
127
140
 
128
141
  const sender = { name : 'Drawbridge', ...from };
129
142
 
143
+ // Coerced and pruned here rather than at every call site. SendGrid
144
+ // rejects a non-string value outright, and an absent one would come back
145
+ // as the literal "undefined" on every event it tagged.
146
+ const custom = Object.fromEntries(
147
+ Object.entries( args || {} )
148
+ .filter( ( [ , value ] ) => value !== undefined && value !== null && value !== '' )
149
+ .map( ( [ key, value ] ) => [ key, String( value ) ] )
150
+ );
151
+
130
152
  await sendWithRetry( () => sendgridRequest({
131
153
  apiKey,
132
154
  ...( request && { request }),
@@ -144,6 +166,7 @@ const sendgrid = {
144
166
  }
145
167
  ],
146
168
  from : sender,
169
+ ...( Object.keys( custom ).length && { custom_args : custom } ),
147
170
  ...( headers && { headers } ),
148
171
  personalizations : [
149
172
  {
@@ -114,7 +114,20 @@ const sendgrid = {
114
114
  // `headers` (optional) carries the List-Unsubscribe pair on lead-facing
115
115
  // commercial sends; omitted, the request body is byte-identical to the
116
116
  // pre-opt-out-floor shape so system mail is untouched.
117
- send : async ({ apiKey, from, headers, html, request, subject, text, to }) => {
117
+ // `args` (optional) rides out as SendGrid's custom_args and comes BACK on every
118
+ // Event Webhook event for the message. That is what makes a delivery event
119
+ // answerable: an event carries the recipient and the receiving server's
120
+ // reason, but nothing about who we sent as or why, so without these a bounce
121
+ // cannot be told from a lead send or a member one.
122
+ //
123
+ // Cheaper than the alternative, which was storing SendGrid's message id on the
124
+ // notification and joining on it — a schema-gated field, and schema-gated
125
+ // fields have to ship api-first and booted. This needs no collection to change.
126
+ //
127
+ // String values only, 10,000 bytes total (SendGrid's limit). One caveat worth
128
+ // knowing when reading events: a bounce delivered asynchronously against the
129
+ // Return-Path does not carry them.
130
+ send : async ({ apiKey, args, from, headers, html, request, subject, text, to }) => {
118
131
 
119
132
  try {
120
133
 
@@ -127,6 +140,15 @@ const sendgrid = {
127
140
 
128
141
  const sender = { name : 'Drawbridge', ...from };
129
142
 
143
+ // Coerced and pruned here rather than at every call site. SendGrid
144
+ // rejects a non-string value outright, and an absent one would come back
145
+ // as the literal "undefined" on every event it tagged.
146
+ const custom = Object.fromEntries(
147
+ Object.entries( args || {} )
148
+ .filter( ( [ , value ] ) => value !== undefined && value !== null && value !== '' )
149
+ .map( ( [ key, value ] ) => [ key, String( value ) ] )
150
+ );
151
+
130
152
  await sendWithRetry( () => sendgridRequest({
131
153
  apiKey,
132
154
  ...( request && { request }),
@@ -144,6 +166,7 @@ const sendgrid = {
144
166
  }
145
167
  ],
146
168
  from : sender,
169
+ ...( Object.keys( custom ).length && { custom_args : custom } ),
147
170
  ...( headers && { headers } ),
148
171
  personalizations : [
149
172
  {
package/dist/sendgrid.js CHANGED
@@ -88,11 +88,27 @@ var sendgrid = {
88
88
  // `headers` (optional) carries the List-Unsubscribe pair on lead-facing
89
89
  // commercial sends; omitted, the request body is byte-identical to the
90
90
  // pre-opt-out-floor shape so system mail is untouched.
91
- send: async ({ apiKey, from, headers, html, request: request2, subject, text, to }) => {
91
+ // `args` (optional) rides out as SendGrid's custom_args and comes BACK on every
92
+ // Event Webhook event for the message. That is what makes a delivery event
93
+ // answerable: an event carries the recipient and the receiving server's
94
+ // reason, but nothing about who we sent as or why, so without these a bounce
95
+ // cannot be told from a lead send or a member one.
96
+ //
97
+ // Cheaper than the alternative, which was storing SendGrid's message id on the
98
+ // notification and joining on it — a schema-gated field, and schema-gated
99
+ // fields have to ship api-first and booted. This needs no collection to change.
100
+ //
101
+ // String values only, 10,000 bytes total (SendGrid's limit). One caveat worth
102
+ // knowing when reading events: a bounce delivered asynchronously against the
103
+ // Return-Path does not carry them.
104
+ send: async ({ apiKey, args, from, headers, html, request: request2, subject, text, to }) => {
92
105
  var _a, _b;
93
106
  try {
94
107
  if (!(from == null ? void 0 : from.email)) throw new Error("SendGrid sender missing \u2014 pass from.email (the drawbridge provider's accountSender)");
95
108
  const sender = { name: "Drawbridge", ...from };
109
+ const custom = Object.fromEntries(
110
+ Object.entries(args || {}).filter(([, value]) => value !== void 0 && value !== null && value !== "").map(([key, value]) => [key, String(value)])
111
+ );
96
112
  await sendWithRetry(() => sendgridRequest({
97
113
  apiKey,
98
114
  ...request2 && { request: request2 },
@@ -110,6 +126,7 @@ var sendgrid = {
110
126
  }
111
127
  ],
112
128
  from: sender,
129
+ ...Object.keys(custom).length && { custom_args: custom },
113
130
  ...headers && { headers },
114
131
  personalizations: [
115
132
  {
package/package.json CHANGED
@@ -216,5 +216,5 @@
216
216
  "prepublishOnly": ". \"$HOME/.nvm/nvm.sh\" && nvm use && tsup && node --test"
217
217
  },
218
218
  "types": "dist/index.d.ts",
219
- "version": "0.0.157"
219
+ "version": "0.0.158"
220
220
  }