@drawbridge/drawbridge-utils 0.0.154 → 0.0.156

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.
@@ -334,6 +334,10 @@ const WRITE_OPERATIONS = Object.freeze([ 'create', 'delete', 'update' ]);
334
334
  // payload the verified inbound body
335
335
  // scope the granted scope string being judged
336
336
  // secret the credential VALUE the caller resolved for verification
337
+ // segments the non-system segments this contact is in, as { id, title },
338
+ // resolved by the shell — null when the run carried no contact
339
+ // document, which means nobody looked, where [] means they are
340
+ // in none. A hook handed null leaves the vendor's copy alone
337
341
  // settings the connection's decrypted settings
338
342
  // step the step document
339
343
  // suppressed the opt-out floor's answer for this lead, computed by the shell
@@ -383,7 +387,7 @@ const HOOK_PROPS = Object.freeze([
383
387
  'channel', 'clientId', 'clientSecret', 'connection', 'contact', 'context',
384
388
  'cursor', 'declaration', 'doc', 'email', 'event', 'headers', 'id', 'lead', 'limit',
385
389
  'manifest', 'payload',
386
- 'scope', 'search', 'secret', 'settings', 'sort', 'step', 'suppressed',
390
+ 'scope', 'search', 'secret', 'segments', 'settings', 'sort', 'step', 'suppressed',
387
391
  'token', 'tokens', 'workflow'
388
392
  ]);
389
393
 
@@ -1516,10 +1520,15 @@ const contacts = {
1516
1520
  remove : async ( { email, id, token }, { fetcher } = {} ) => {
1517
1521
 
1518
1522
  // NO TOKEN IS A NO-OP HERE, unlike sendgrid and twilio, and the
1519
- // difference is what the credential is declared to be: hubspotToken
1520
- // is the one provider field that is not `required`, because this is
1521
- // internal CRM tooling no merchant sees. A deployment with no portal
1522
- // is a supported state, not a missing credential.
1523
+ // difference is what the credential is FOR: this is internal CRM
1524
+ // tooling no merchant sees, so a deployment with no portal is a
1525
+ // supported state rather than a missing credential.
1526
+ //
1527
+ // The manifest does mark hubspotToken `required`, and that is not a
1528
+ // contradiction — the flag feeds `isLive` and nothing else, so it
1529
+ // decides whether the admin card reads live. Not live is the honest
1530
+ // badge when nothing will sync. It gates no behaviour, and
1531
+ // saveProviderSettings never consults it, so the row still writes.
1523
1532
  if( ! token ) return;
1524
1533
 
1525
1534
  const contact = id || await lookup({ email, fetcher, token });
@@ -2834,10 +2843,15 @@ var klaviyo = {
2834
2843
 
2835
2844
  // HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
2836
2845
  // rendered — four useful instructions no component displayed.
2846
+ // NAMES THE LIST STEP, because `status` below gates on it: a grant with no
2847
+ // list chosen sits at Pending, and this told the merchant it would show
2848
+ // Active. The one required action was the one action the guide omitted, so
2849
+ // anybody following it landed on a connection that looked broken.
2837
2850
  guide : [
2838
2851
  'Press Connect. Drawbridge sends you to Klaviyo to approve access.',
2839
2852
  'Sign in to Klaviyo if you are not already, and choose the account to connect.',
2840
- 'Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.',
2853
+ 'Approve the permissions Klaviyo lists. You come back here to pick the list your contacts should sync into.',
2854
+ 'The connection shows Pending until you pick a list, then Active.',
2841
2855
  'You can revoke access at any time from Klaviyo, under Integrations.'
2842
2856
  ]
2843
2857
 
@@ -2987,7 +3001,7 @@ var klaviyo = {
2987
3001
  // different thing from deleting the profile.
2988
3002
  remove : false,
2989
3003
 
2990
- sync : async ( { contact, lead, settings, suppressed, token }, { fetcher } = {} ) => {
3004
+ sync : async ( { context, lead, segments, settings, suppressed, token }, { fetcher } = {} ) => {
2991
3005
 
2992
3006
  const list = settings?.list;
2993
3007
 
@@ -3005,9 +3019,32 @@ var klaviyo = {
3005
3019
  // contact knows money: order attribution is person-level and merges
3006
3020
  // across every address a human used, so revenue and orders cannot
3007
3021
  // exist on a single-address record.
3008
- const totals = contact?.totals || {};
3009
-
3010
- const profile = await api$1( '/profiles/', {
3022
+ //
3023
+ // READ OFF `context`, NOT the shell's `contact` prop. That prop is the
3024
+ // ADDRESS a string so `contact?.totals` was undefined on every run
3025
+ // this hook has ever made, and all five properties below shipped as 0
3026
+ // no matter what the person had entered, drawn or spent. Only a
3027
+ // segment.contact.add run carries the document (drawbridge-sync
3028
+ // lib/segment.js); lead.insert names a brand-new entrant who has no
3029
+ // history to send anyway.
3030
+ const person = context?.contact || null;
3031
+ const totals = person?.totals || {};
3032
+
3033
+ // UPSERT, NOT CREATE. /profiles/ is the plain create and answers a
3034
+ // conflict for a profile Klaviyo already holds — which is most of them,
3035
+ // because the whole point of the segment trigger is syncing people who
3036
+ // have been around long enough to earn a ranking. profile-import is
3037
+ // Klaviyo's documented create-or-update: 201 when it made one, 200 when
3038
+ // it updated one, and no conflict either way
3039
+ // (developers.klaviyo.com/en/reference/create_or_update_profile,
3040
+ // fetched 2026-09-09).
3041
+ //
3042
+ // IT MERGES, and that is why the blocks below are conditional rather
3043
+ // than defaulted. "Not including a field in your request will leave it
3044
+ // unchanged" — so omitting the totals on a lead.insert preserves
3045
+ // whatever the last segment run published, where sending zeros would
3046
+ // erase it. A property we cannot compute is one we must not write.
3047
+ const profile = await api$1( '/profile-import', {
3011
3048
  fetcher,
3012
3049
  method : 'POST',
3013
3050
  payload : {
@@ -3016,16 +3053,36 @@ var klaviyo = {
3016
3053
  email,
3017
3054
  ...( lead?.name && { first_name : String( lead.name ).trim().split( /\s+/ )[ 0 ] }),
3018
3055
  properties : {
3019
- drawbridge_campaigns : ( contact?.campaigns || [] ).length,
3020
- drawbridge_draws : totals.draws || 0,
3021
- drawbridge_entries : totals.entries || 0,
3022
- drawbridge_orders : totals.orders || 0,
3023
- // Campaign-attributed, NOT lifetime. A merchant running
3024
- // Shopify already has lifetime revenue in Klaviyo through
3025
- // Klaviyo's own integration; what only we can say is how
3026
- // much a campaign drove. Named so the two cannot be
3027
- // mistaken for one another in a segment builder.
3028
- drawbridge_revenue : totals.gross || 0
3056
+ ...( person && {
3057
+ drawbridge_campaigns : ( person.campaigns || [] ).length,
3058
+ drawbridge_draws : totals.draws || 0,
3059
+ drawbridge_entries : totals.entries || 0,
3060
+ drawbridge_orders : totals.orders || 0,
3061
+ // Campaign-attributed, NOT lifetime. A merchant running
3062
+ // Shopify already has lifetime revenue in Klaviyo through
3063
+ // Klaviyo's own integration; what only we can say is how
3064
+ // much a campaign drove. Named so the two cannot be
3065
+ // mistaken for one another in a segment builder.
3066
+ drawbridge_revenue : totals.gross || 0
3067
+ }),
3068
+ // THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
3069
+ // merchant builds Klaviyo segments on top of. Klaviyo owns no
3070
+ // writable membership — its segments are computed from rules,
3071
+ // so a profile cannot be put in one — and this is the nearest
3072
+ // honest equivalent.
3073
+ //
3074
+ // WHOLESALE, WHICH IS WHAT MAKES IT SELF-HEALING. Drawbridge
3075
+ // segments are dynamic and nothing dispatches a leave event —
3076
+ // only segment.contact.add exists — so a property that was
3077
+ // only ever appended to would outlive the membership that
3078
+ // earned it. Rewriting the whole array each run means every
3079
+ // sync corrects whatever the last one got wrong, with no
3080
+ // removal path to build.
3081
+ //
3082
+ // `segments` is null when the run carried no contact document,
3083
+ // meaning nobody looked — different from [], which means they
3084
+ // are in none. Null omits the key and merge leaves it alone.
3085
+ ...( segments && { drawbridge_segments : segments.map( ( entry ) => entry.title ).filter( Boolean ) })
3029
3086
  }
3030
3087
  },
3031
3088
  type : 'profile'
@@ -3427,7 +3484,10 @@ const api = async ( path, { dc, fetcher = fetch, method = 'GET', payload, token
3427
3484
 
3428
3485
  }
3429
3486
 
3430
- return response.json();
3487
+ // 204 ON A TAG WRITE — nothing to parse, and asking an empty body for json
3488
+ // throws, which would report a change Mailchimp accepted as a failed step.
3489
+ // Klaviyo's file carries the same guard for the same reason.
3490
+ return response.status === 204 ? null : response.json();
3431
3491
 
3432
3492
  };
3433
3493
 
@@ -3591,7 +3651,7 @@ var mailchimp = {
3591
3651
  // PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
3592
3652
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3593
3653
  // Marketing API reference for the list-members resource.
3594
- sync : async ( { lead, settings, suppressed, token }, { fetcher } = {} ) => {
3654
+ sync : async ( { connection, lead, segments, settings, suppressed, token }, { fetcher, read } = {} ) => {
3595
3655
 
3596
3656
  const audience = settings?.audience;
3597
3657
 
@@ -3648,6 +3708,75 @@ var mailchimp = {
3648
3708
  token
3649
3709
  });
3650
3710
 
3711
+ // THE DRAWBRIDGE SEGMENTS THIS PERSON IS IN, written as Mailchimp tags.
3712
+ //
3713
+ // TAGS, NOT MERGE FIELDS. A merge field has to exist on the audience
3714
+ // before anything can be written to it, and an unregistered one is
3715
+ // refused along with the whole member request — so carrying data that
3716
+ // way means registering fields first, which is lifecycle.register's job
3717
+ // and is not built. A tag needs no setup: "If a tag that does not exist
3718
+ // is passed in and set as 'active', a new tag will be created"
3719
+ // (mailchimp.com/developer/marketing/api/list-member-tags/add-or-remove-member-tags,
3720
+ // fetched 2026-09-09). Nothing for the merchant to prepare, so nothing
3721
+ // to explain in the guide.
3722
+ //
3723
+ // ACTIVE AND INACTIVE IN ONE CALL, which is what keeps this correct
3724
+ // over time. Drawbridge segments are dynamic, and nothing dispatches a
3725
+ // leave event — only segment.contact.add exists — so a tag that was
3726
+ // only ever added would outlive the membership behind it and the
3727
+ // merchant would target a condition that had stopped being true.
3728
+ // Sending the whole list every time, each tag active or inactive
3729
+ // against current membership, means each sync corrects the last.
3730
+ //
3731
+ // WRITTEN FOR A SUPPRESSED PERSON TOO. A tag is a label, not a
3732
+ // permission: the `status : 'unsubscribed'` above is what stops mail
3733
+ // reaching them, and letting their labels rot would leave the merchant
3734
+ // a wrong answer about who belongs to what.
3735
+ //
3736
+ // PREFIXED, so a Drawbridge tag cannot collide with one the merchant
3737
+ // keeps by hand, and so the inactive pass can only clear tags this
3738
+ // integration wrote.
3739
+ if( segments && read ){
3740
+
3741
+ const owned = await read.aggregate({
3742
+ collection : 'segment',
3743
+ pipeline : [
3744
+ {
3745
+ $match : {
3746
+ organization : connection?.organization,
3747
+ system : { $ne : true }
3748
+ }
3749
+ },
3750
+ {
3751
+ $project : { _id : 0, title : 1 }
3752
+ }
3753
+ ]
3754
+ });
3755
+
3756
+ const joined = new Set( segments.map( ( entry ) => entry.title ) );
3757
+
3758
+ const tags = ( owned || [] )
3759
+ .map( ( entry ) => entry.title )
3760
+ .filter( Boolean )
3761
+ .map( ( title ) => ({
3762
+ name : 'Drawbridge: ' + title,
3763
+ status : joined.has( title ) ? 'active' : 'inactive'
3764
+ }) );
3765
+
3766
+ if( tags.length > 0 ){
3767
+
3768
+ await api( '/lists/' + audience + '/members/' + hash + '/tags', {
3769
+ dc : settings?.dc,
3770
+ fetcher,
3771
+ method : 'POST',
3772
+ payload : { tags },
3773
+ token
3774
+ });
3775
+
3776
+ }
3777
+
3778
+ }
3779
+
3651
3780
  return {
3652
3781
  // Merged into `context` for later steps in this run.
3653
3782
  context : { mailchimpMemberId : member?.id || hash },
@@ -5765,9 +5894,14 @@ var webhook = {
5765
5894
  'Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it.'
5766
5895
  ],
5767
5896
  excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
5897
+ // SAYS WHERE THE URL GOES. Connecting only mints a secret, so a merchant
5898
+ // following this had nowhere to put the endpoint they came to configure —
5899
+ // the destination lives on the step (see steps.webhook.send.settings), and
5900
+ // the guide never said so.
5768
5901
  guide : [
5769
5902
  'Press Connect. Drawbridge generates a signing secret and shows it here.',
5770
5903
  'Copy the secret into your own endpoint.',
5904
+ 'Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.',
5771
5905
  'On each request, compute HMAC-SHA256 of the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload.'
5772
5906
  ]
5773
5907
  },
@@ -199,6 +199,7 @@ var HOOK_PROPS = Object.freeze([
199
199
  "scope",
200
200
  "search",
201
201
  "secret",
202
+ "segments",
202
203
  "settings",
203
204
  "sort",
204
205
  "step",
@@ -2592,10 +2593,15 @@ var klaviyo_default2 = {
2592
2593
  excerpt: "Sync the contacts your campaigns collect into a Klaviyo list.",
2593
2594
  // HOW TO CONNECT, in the merchant's words. Was `setup`, which nothing
2594
2595
  // rendered — four useful instructions no component displayed.
2596
+ // NAMES THE LIST STEP, because `status` below gates on it: a grant with no
2597
+ // list chosen sits at Pending, and this told the merchant it would show
2598
+ // Active. The one required action was the one action the guide omitted, so
2599
+ // anybody following it landed on a connection that looked broken.
2595
2600
  guide: [
2596
2601
  "Press Connect. Drawbridge sends you to Klaviyo to approve access.",
2597
2602
  "Sign in to Klaviyo if you are not already, and choose the account to connect.",
2598
- "Approve the permissions Klaviyo lists. You are returned here and the connection shows Active.",
2603
+ "Approve the permissions Klaviyo lists. You come back here to pick the list your contacts should sync into.",
2604
+ "The connection shows Pending until you pick a list, then Active.",
2599
2605
  "You can revoke access at any time from Klaviyo, under Integrations."
2600
2606
  ]
2601
2607
  },
@@ -2717,14 +2723,15 @@ var klaviyo_default2 = {
2717
2723
  // Not yet. Suppression syncs an opt-out as unsubscribed, which is a
2718
2724
  // different thing from deleting the profile.
2719
2725
  remove: false,
2720
- sync: async ({ contact, lead, settings, suppressed, token }, { fetcher } = {}) => {
2726
+ sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
2721
2727
  var _a, _b, _c;
2722
2728
  const list = settings == null ? void 0 : settings.list;
2723
2729
  if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
2724
2730
  const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
2725
2731
  if (!email) return { message: "That lead has no email address to sync.", skipped: true };
2726
- const totals = (contact == null ? void 0 : contact.totals) || {};
2727
- const profile = await api2("/profiles/", {
2732
+ const person = (context == null ? void 0 : context.contact) || null;
2733
+ const totals = (person == null ? void 0 : person.totals) || {};
2734
+ const profile = await api2("/profile-import", {
2728
2735
  fetcher,
2729
2736
  method: "POST",
2730
2737
  payload: {
@@ -2733,16 +2740,36 @@ var klaviyo_default2 = {
2733
2740
  email,
2734
2741
  ...(lead == null ? void 0 : lead.name) && { first_name: String(lead.name).trim().split(/\s+/)[0] },
2735
2742
  properties: {
2736
- drawbridge_campaigns: ((contact == null ? void 0 : contact.campaigns) || []).length,
2737
- drawbridge_draws: totals.draws || 0,
2738
- drawbridge_entries: totals.entries || 0,
2739
- drawbridge_orders: totals.orders || 0,
2740
- // Campaign-attributed, NOT lifetime. A merchant running
2741
- // Shopify already has lifetime revenue in Klaviyo through
2742
- // Klaviyo's own integration; what only we can say is how
2743
- // much a campaign drove. Named so the two cannot be
2744
- // mistaken for one another in a segment builder.
2745
- drawbridge_revenue: totals.gross || 0
2743
+ ...person && {
2744
+ drawbridge_campaigns: (person.campaigns || []).length,
2745
+ drawbridge_draws: totals.draws || 0,
2746
+ drawbridge_entries: totals.entries || 0,
2747
+ drawbridge_orders: totals.orders || 0,
2748
+ // Campaign-attributed, NOT lifetime. A merchant running
2749
+ // Shopify already has lifetime revenue in Klaviyo through
2750
+ // Klaviyo's own integration; what only we can say is how
2751
+ // much a campaign drove. Named so the two cannot be
2752
+ // mistaken for one another in a segment builder.
2753
+ drawbridge_revenue: totals.gross || 0
2754
+ },
2755
+ // THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
2756
+ // merchant builds Klaviyo segments on top of. Klaviyo owns no
2757
+ // writable membership — its segments are computed from rules,
2758
+ // so a profile cannot be put in one — and this is the nearest
2759
+ // honest equivalent.
2760
+ //
2761
+ // WHOLESALE, WHICH IS WHAT MAKES IT SELF-HEALING. Drawbridge
2762
+ // segments are dynamic and nothing dispatches a leave event —
2763
+ // only segment.contact.add exists — so a property that was
2764
+ // only ever appended to would outlive the membership that
2765
+ // earned it. Rewriting the whole array each run means every
2766
+ // sync corrects whatever the last one got wrong, with no
2767
+ // removal path to build.
2768
+ //
2769
+ // `segments` is null when the run carried no contact document,
2770
+ // meaning nobody looked — different from [], which means they
2771
+ // are in none. Null omits the key and merge leaves it alone.
2772
+ ...segments && { drawbridge_segments: segments.map((entry) => entry.title).filter(Boolean) }
2746
2773
  }
2747
2774
  },
2748
2775
  type: "profile"
@@ -3034,7 +3061,7 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3034
3061
  { status: response.status }
3035
3062
  );
3036
3063
  }
3037
- return response.json();
3064
+ return response.status === 204 ? null : response.json();
3038
3065
  };
3039
3066
  var subscriberHash = (email) => createHash2("md5").update(String(email).trim().toLowerCase()).digest("hex");
3040
3067
  var mailchimp_default2 = {
@@ -3168,7 +3195,7 @@ var mailchimp_default2 = {
3168
3195
  // PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
3169
3196
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3170
3197
  // Marketing API reference for the list-members resource.
3171
- sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
3198
+ sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
3172
3199
  var _a, _b;
3173
3200
  const audience = settings == null ? void 0 : settings.audience;
3174
3201
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
@@ -3194,6 +3221,36 @@ var mailchimp_default2 = {
3194
3221
  },
3195
3222
  token
3196
3223
  });
3224
+ if (segments && read) {
3225
+ const owned = await read.aggregate({
3226
+ collection: "segment",
3227
+ pipeline: [
3228
+ {
3229
+ $match: {
3230
+ organization: connection2 == null ? void 0 : connection2.organization,
3231
+ system: { $ne: true }
3232
+ }
3233
+ },
3234
+ {
3235
+ $project: { _id: 0, title: 1 }
3236
+ }
3237
+ ]
3238
+ });
3239
+ const joined = new Set(segments.map((entry) => entry.title));
3240
+ const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
3241
+ name: "Drawbridge: " + title,
3242
+ status: joined.has(title) ? "active" : "inactive"
3243
+ }));
3244
+ if (tags.length > 0) {
3245
+ await api3("/lists/" + audience + "/members/" + hash + "/tags", {
3246
+ dc: settings == null ? void 0 : settings.dc,
3247
+ fetcher,
3248
+ method: "POST",
3249
+ payload: { tags },
3250
+ token
3251
+ });
3252
+ }
3253
+ }
3197
3254
  return {
3198
3255
  // Merged into `context` for later steps in this run.
3199
3256
  context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
@@ -4862,9 +4919,14 @@ var webhook_default = {
4862
4919
  "Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it."
4863
4920
  ],
4864
4921
  excerpt: "Sign outgoing webhook payloads with an HMAC secret to verify authenticity.",
4922
+ // SAYS WHERE THE URL GOES. Connecting only mints a secret, so a merchant
4923
+ // following this had nowhere to put the endpoint they came to configure —
4924
+ // the destination lives on the step (see steps.webhook.send.settings), and
4925
+ // the guide never said so.
4865
4926
  guide: [
4866
4927
  "Press Connect. Drawbridge generates a signing secret and shows it here.",
4867
4928
  "Copy the secret into your own endpoint.",
4929
+ "Add a Send webhook step to a workflow and put your endpoint URL on it. The URL belongs to the step rather than the connection, so one connection can serve several endpoints.",
4868
4930
  "On each request, compute HMAC-SHA256 of the raw body using the secret and compare it against the X-Drawbridge-Signature header before acting on the payload."
4869
4931
  ]
4870
4932
  },
package/dist/email.cjs CHANGED
@@ -19,6 +19,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // lib/email.js
20
20
  var email_exports = {};
21
21
  __export(email_exports, {
22
+ subAddress: () => subAddress,
22
23
  toCanonicalEmail: () => toCanonicalEmail
23
24
  });
24
25
  module.exports = __toCommonJS(email_exports);
@@ -36,7 +37,20 @@ var toCanonicalEmail = (value) => {
36
37
  if (!local) return null;
37
38
  return local + "@" + domain;
38
39
  };
40
+ var LOCAL_PART_LIMIT = 64;
41
+ var subAddress = (email, tag) => {
42
+ if (!email || typeof email !== "string") return email;
43
+ if (!tag || typeof tag !== "string") return email;
44
+ if (!/^[A-Za-z0-9]+$/.test(tag)) return email;
45
+ const at = email.lastIndexOf("@");
46
+ if (at < 1 || at === email.length - 1) return email;
47
+ const local = email.slice(0, at);
48
+ if (local.includes("+")) return email;
49
+ if (local.length + 1 + tag.length > LOCAL_PART_LIMIT) return email;
50
+ return local + "+" + tag + "@" + email.slice(at + 1);
51
+ };
39
52
  // Annotate the CommonJS export names for ESM import in node:
40
53
  0 && (module.exports = {
54
+ subAddress,
41
55
  toCanonicalEmail
42
56
  });
package/dist/email.d.cts CHANGED
@@ -33,4 +33,56 @@ const toCanonicalEmail = ( value ) => {
33
33
 
34
34
  };
35
35
 
36
- export { toCanonicalEmail };
36
+ // RFC 5322 caps the local part at 64 octets. Everything here is ASCII, so
37
+ // length is octets.
38
+ const LOCAL_PART_LIMIT = 64;
39
+
40
+ // The inverse of toCanonicalEmail: put a tag INTO the local part rather than
41
+ // strip one out. `subAddress( 'org@send.drwbrdg.com', '507f…' )` gives
42
+ // `org+507f…@send.drwbrdg.com` — RFC 5233 sub-addressing, which Google delivers
43
+ // to the untagged mailbox natively.
44
+ //
45
+ // This is how the platform's lead-facing sender carries the ORGANIZATION.
46
+ // Shopify does the same thing with `store+<shopId>@shopifyemail.com`: one
47
+ // mailbox, one MX record, one authenticated domain, and the tenant rides in the
48
+ // address instead of in infrastructure. There is no per-organization address to
49
+ // store, so there is nothing to migrate or backfill.
50
+ //
51
+ // It is derived at send time, never persisted. Worth settling BEFORE the first
52
+ // send rather than after: the From line is frozen into every message already
53
+ // delivered, so a shared address cannot be split apart retroactively.
54
+ //
55
+ // Returns the address UNCHANGED whenever it cannot be tagged safely — a flat
56
+ // from-address is a working email, a malformed one is a send the provider
57
+ // rejects outright. Callers rely on that: the sender resolvers fall through to
58
+ // their own defaults on a falsy return, and never on a broken string.
59
+ const subAddress = ( email, tag ) => {
60
+
61
+ if( ! email || typeof email !== 'string' ) return email;
62
+ if( ! tag || typeof tag !== 'string' ) return email;
63
+
64
+ // Organization ids are 24-char ObjectId hex, and the short-id generator is
65
+ // base36 — both alphanumeric, both safely atext. Checked rather than assumed
66
+ // because the api schema types `id` as a bare string with no pattern, and
67
+ // `formats.documents.insert` spreads caller data OVER the generated id, so a
68
+ // hand-written id would win.
69
+ if( ! /^[A-Za-z0-9]+$/.test( tag ) ) return email;
70
+
71
+ const at = email.lastIndexOf( '@' );
72
+
73
+ if( at < 1 || at === email.length - 1 ) return email;
74
+
75
+ const local = email.slice( 0, at );
76
+
77
+ // Already tagged. Re-tagging would nest (`org+a+b@`), and the first tag is
78
+ // the one a receiving mailbox would route on — so the second would be a
79
+ // silent lie about which tenant the mail belongs to.
80
+ if( local.includes( '+' ) ) return email;
81
+
82
+ if( local.length + 1 + tag.length > LOCAL_PART_LIMIT ) return email;
83
+
84
+ return local + '+' + tag + '@' + email.slice( at + 1 );
85
+
86
+ };
87
+
88
+ export { subAddress, toCanonicalEmail };
package/dist/email.d.ts CHANGED
@@ -33,4 +33,56 @@ const toCanonicalEmail = ( value ) => {
33
33
 
34
34
  };
35
35
 
36
- export { toCanonicalEmail };
36
+ // RFC 5322 caps the local part at 64 octets. Everything here is ASCII, so
37
+ // length is octets.
38
+ const LOCAL_PART_LIMIT = 64;
39
+
40
+ // The inverse of toCanonicalEmail: put a tag INTO the local part rather than
41
+ // strip one out. `subAddress( 'org@send.drwbrdg.com', '507f…' )` gives
42
+ // `org+507f…@send.drwbrdg.com` — RFC 5233 sub-addressing, which Google delivers
43
+ // to the untagged mailbox natively.
44
+ //
45
+ // This is how the platform's lead-facing sender carries the ORGANIZATION.
46
+ // Shopify does the same thing with `store+<shopId>@shopifyemail.com`: one
47
+ // mailbox, one MX record, one authenticated domain, and the tenant rides in the
48
+ // address instead of in infrastructure. There is no per-organization address to
49
+ // store, so there is nothing to migrate or backfill.
50
+ //
51
+ // It is derived at send time, never persisted. Worth settling BEFORE the first
52
+ // send rather than after: the From line is frozen into every message already
53
+ // delivered, so a shared address cannot be split apart retroactively.
54
+ //
55
+ // Returns the address UNCHANGED whenever it cannot be tagged safely — a flat
56
+ // from-address is a working email, a malformed one is a send the provider
57
+ // rejects outright. Callers rely on that: the sender resolvers fall through to
58
+ // their own defaults on a falsy return, and never on a broken string.
59
+ const subAddress = ( email, tag ) => {
60
+
61
+ if( ! email || typeof email !== 'string' ) return email;
62
+ if( ! tag || typeof tag !== 'string' ) return email;
63
+
64
+ // Organization ids are 24-char ObjectId hex, and the short-id generator is
65
+ // base36 — both alphanumeric, both safely atext. Checked rather than assumed
66
+ // because the api schema types `id` as a bare string with no pattern, and
67
+ // `formats.documents.insert` spreads caller data OVER the generated id, so a
68
+ // hand-written id would win.
69
+ if( ! /^[A-Za-z0-9]+$/.test( tag ) ) return email;
70
+
71
+ const at = email.lastIndexOf( '@' );
72
+
73
+ if( at < 1 || at === email.length - 1 ) return email;
74
+
75
+ const local = email.slice( 0, at );
76
+
77
+ // Already tagged. Re-tagging would nest (`org+a+b@`), and the first tag is
78
+ // the one a receiving mailbox would route on — so the second would be a
79
+ // silent lie about which tenant the mail belongs to.
80
+ if( local.includes( '+' ) ) return email;
81
+
82
+ if( local.length + 1 + tag.length > LOCAL_PART_LIMIT ) return email;
83
+
84
+ return local + '+' + tag + '@' + email.slice( at + 1 );
85
+
86
+ };
87
+
88
+ export { subAddress, toCanonicalEmail };
package/dist/email.js CHANGED
@@ -13,6 +13,19 @@ var toCanonicalEmail = (value) => {
13
13
  if (!local) return null;
14
14
  return local + "@" + domain;
15
15
  };
16
+ var LOCAL_PART_LIMIT = 64;
17
+ var subAddress = (email, tag) => {
18
+ if (!email || typeof email !== "string") return email;
19
+ if (!tag || typeof tag !== "string") return email;
20
+ if (!/^[A-Za-z0-9]+$/.test(tag)) return email;
21
+ const at = email.lastIndexOf("@");
22
+ if (at < 1 || at === email.length - 1) return email;
23
+ const local = email.slice(0, at);
24
+ if (local.includes("+")) return email;
25
+ if (local.length + 1 + tag.length > LOCAL_PART_LIMIT) return email;
26
+ return local + "+" + tag + "@" + email.slice(at + 1);
27
+ };
16
28
  export {
29
+ subAddress,
17
30
  toCanonicalEmail
18
31
  };