@drawbridge/drawbridge-utils 0.0.163 → 0.0.165

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.
@@ -5808,6 +5808,12 @@ var publicConnectionKeys = Object.freeze([
5808
5808
  // excerpt, guide, and any vendor redirect copy.
5809
5809
  "content",
5810
5810
  "createdAt",
5811
+ // THE MERCHANT'S OWN SWITCH. `enabled : false` is a connection the merchant
5812
+ // paused — kept, configured, and not run — as distinct from a vendor the
5813
+ // platform switched off (which the api overlays as an error). Absent means
5814
+ // on; the dashboard renders Paused from it and the sync gates refuse a paused
5815
+ // connection the way they refuse a missing one.
5816
+ "enabled",
5811
5817
  // The connection DOCUMENT's own errors array — scope-drift entries written by
5812
5818
  // drawbridge-sync. NOT the manifest's error copy, which is content.errors:
5813
5819
  // the document is spread OVER the resolved manifest downstream, so the two
@@ -7500,6 +7500,12 @@ const publicConnectionKeys = Object.freeze([
7500
7500
  // excerpt, guide, and any vendor redirect copy.
7501
7501
  'content',
7502
7502
  'createdAt',
7503
+ // THE MERCHANT'S OWN SWITCH. `enabled : false` is a connection the merchant
7504
+ // paused — kept, configured, and not run — as distinct from a vendor the
7505
+ // platform switched off (which the api overlays as an error). Absent means
7506
+ // on; the dashboard renders Paused from it and the sync gates refuse a paused
7507
+ // connection the way they refuse a missing one.
7508
+ 'enabled',
7503
7509
  // The connection DOCUMENT's own errors array — scope-drift entries written by
7504
7510
  // drawbridge-sync. NOT the manifest's error copy, which is content.errors:
7505
7511
  // the document is spread OVER the resolved manifest downstream, so the two
@@ -7500,6 +7500,12 @@ const publicConnectionKeys = Object.freeze([
7500
7500
  // excerpt, guide, and any vendor redirect copy.
7501
7501
  'content',
7502
7502
  'createdAt',
7503
+ // THE MERCHANT'S OWN SWITCH. `enabled : false` is a connection the merchant
7504
+ // paused — kept, configured, and not run — as distinct from a vendor the
7505
+ // platform switched off (which the api overlays as an error). Absent means
7506
+ // on; the dashboard renders Paused from it and the sync gates refuse a paused
7507
+ // connection the way they refuse a missing one.
7508
+ 'enabled',
7503
7509
  // The connection DOCUMENT's own errors array — scope-drift entries written by
7504
7510
  // drawbridge-sync. NOT the manifest's error copy, which is content.errors:
7505
7511
  // the document is spread OVER the resolved manifest downstream, so the two
@@ -5732,6 +5732,12 @@ var publicConnectionKeys = Object.freeze([
5732
5732
  // excerpt, guide, and any vendor redirect copy.
5733
5733
  "content",
5734
5734
  "createdAt",
5735
+ // THE MERCHANT'S OWN SWITCH. `enabled : false` is a connection the merchant
5736
+ // paused — kept, configured, and not run — as distinct from a vendor the
5737
+ // platform switched off (which the api overlays as an error). Absent means
5738
+ // on; the dashboard renders Paused from it and the sync gates refuse a paused
5739
+ // connection the way they refuse a missing one.
5740
+ "enabled",
5735
5741
  // The connection DOCUMENT's own errors array — scope-drift entries written by
5736
5742
  // drawbridge-sync. NOT the manifest's error copy, which is content.errors:
5737
5743
  // the document is spread OVER the resolved manifest downstream, so the two
@@ -19,6 +19,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // lib/notification.js
20
20
  var notification_exports = {};
21
21
  __export(notification_exports, {
22
+ POINTS: () => POINTS,
23
+ notificationRules: () => notificationRules,
22
24
  notificationWarnings: () => notificationWarnings
23
25
  });
24
26
  module.exports = __toCommonJS(notification_exports);
@@ -49,41 +51,96 @@ var phrasesIn = (value) => {
49
51
  return PHRASES.filter((phrase) => lower.includes(phrase));
50
52
  };
51
53
  var list = (values) => values.map((value) => "\u201C" + value + "\u201D").join(", ");
52
- var subjectWarnings = ({ brand, subject }) => {
54
+ var POINTS = {
55
+ currency: 1,
56
+ emoji: 0.5,
57
+ length: 0.5,
58
+ phrase: 1,
59
+ punctuation: 1,
60
+ shouting: 1.5
61
+ };
62
+ var subjectRules = ({ subject }) => {
53
63
  const value = String(subject || "").trim();
54
64
  if (!value) return [];
55
65
  const found = phrasesIn(value);
56
66
  const emojis = emojiCount(value);
57
67
  return [
58
- value.length > SUBJECT_DISPLAY_LIMIT && "Most email apps cut the subject around " + SUBJECT_DISPLAY_LIMIT + " characters \u2014 yours is " + value.length + ".",
59
- shouting(value) && "Mostly capitals reads as shouting, and filters treat it that way too.",
60
- /[!?]{2,}/.test(value) && "Repeated exclamation or question marks are a common spam signal.",
61
- emojis > 1 && "More than one emoji in a subject is a common spam signal.",
62
- /[$£€]\s?\d/.test(value) && "A currency amount in the subject is a common spam signal.",
63
- found.length > 0 && "Reads like a scam to filters and to people: " + list(found) + ".",
64
- brand && !value.toLowerCase().includes(String(brand).toLowerCase()) && "Nothing here says the mail is from " + brand + " \u2014 subjects that name the sender get opened more."
65
- ].filter(Boolean);
68
+ value.length > SUBJECT_DISPLAY_LIMIT && {
69
+ message: "Most email apps cut the subject around " + SUBJECT_DISPLAY_LIMIT + " characters \u2014 yours is " + value.length + ".",
70
+ points: POINTS.length,
71
+ rule: "length"
72
+ },
73
+ shouting(value) && {
74
+ message: "Mostly capitals reads as shouting, and filters treat it that way too.",
75
+ points: POINTS.shouting,
76
+ rule: "shouting"
77
+ },
78
+ /[!?]{2,}/.test(value) && {
79
+ message: "Repeated exclamation or question marks are a common spam signal.",
80
+ points: POINTS.punctuation,
81
+ rule: "punctuation"
82
+ },
83
+ emojis > 1 && {
84
+ message: "More than one emoji in a subject is a common spam signal.",
85
+ points: POINTS.emoji,
86
+ rule: "emoji"
87
+ },
88
+ /[$£€]\s?\d/.test(value) && {
89
+ message: "A currency amount in the subject is a common spam signal.",
90
+ points: POINTS.currency,
91
+ rule: "currency"
92
+ },
93
+ found.length > 0 && {
94
+ message: "Reads like a scam to filters and to people: " + list(found) + ".",
95
+ points: POINTS.phrase * found.length,
96
+ rule: "phrase"
97
+ }
98
+ ].filter(Boolean).map((rule) => ({ ...rule, field: "subject" }));
66
99
  };
67
- var messageWarnings = ({ message }) => {
100
+ var messageRules = ({ message }) => {
68
101
  const value = String(message || "").trim();
69
102
  if (!value) return [];
70
103
  const found = phrasesIn(value);
71
104
  return [
72
- value.length < MESSAGE_SHORT_LIMIT && "Very short messages read as a fragment, and bulk senders send exactly this shape.",
73
- shouting(value) && "Mostly capitals reads as shouting, and filters treat it that way too.",
74
- /[!?]{2,}/.test(value) && "Repeated exclamation or question marks are a common spam signal.",
75
- found.length > 0 && "Reads like a scam to filters and to people: " + list(found) + "."
76
- ].filter(Boolean);
105
+ value.length < MESSAGE_SHORT_LIMIT && {
106
+ message: "Very short messages read as a fragment, and bulk senders send exactly this shape.",
107
+ points: POINTS.length,
108
+ rule: "length"
109
+ },
110
+ shouting(value) && {
111
+ message: "Mostly capitals reads as shouting, and filters treat it that way too.",
112
+ points: POINTS.shouting,
113
+ rule: "shouting"
114
+ },
115
+ /[!?]{2,}/.test(value) && {
116
+ message: "Repeated exclamation or question marks are a common spam signal.",
117
+ points: POINTS.punctuation,
118
+ rule: "punctuation"
119
+ },
120
+ found.length > 0 && {
121
+ message: "Reads like a scam to filters and to people: " + list(found) + ".",
122
+ points: POINTS.phrase * found.length,
123
+ rule: "phrase"
124
+ }
125
+ ].filter(Boolean).map((rule) => ({ ...rule, field: "message" }));
77
126
  };
78
- var notificationWarnings = ({
79
- brand,
127
+ var notificationRules = ({
80
128
  message,
81
129
  subject
82
- } = {}) => ({
83
- message: messageWarnings({ message }),
84
- subject: subjectWarnings({ brand, subject })
85
- });
130
+ } = {}) => [
131
+ ...subjectRules({ subject }),
132
+ ...messageRules({ message })
133
+ ];
134
+ var notificationWarnings = (input) => {
135
+ const rules = notificationRules(input);
136
+ return {
137
+ message: rules.filter((rule) => rule.field === "message").map((rule) => rule.message),
138
+ subject: rules.filter((rule) => rule.field === "subject").map((rule) => rule.message)
139
+ };
140
+ };
86
141
  // Annotate the CommonJS export names for ESM import in node:
87
142
  0 && (module.exports = {
143
+ POINTS,
144
+ notificationRules,
88
145
  notificationWarnings
89
146
  });
@@ -75,11 +75,41 @@ const phrasesIn = ( value ) => {
75
75
 
76
76
  const list = ( values ) => values.map( ( value ) => '“' + value + '”' ).join( ', ' );
77
77
 
78
- // `brand` is the organization's own name. A subject that never mentions who is
79
- // writing is the single most recognisable trait of the mail people delete
80
- // unread — and the one a merchant can fix in five seconds once it is pointed
81
- // out. Skipped entirely when no brand is supplied rather than guessed at.
82
- const subjectWarnings = ({ brand, subject }) => {
78
+ // WHAT EACH RULE IS WORTH, on SpamAssassin's scale its own rules run 0.1 to
79
+ // about 2.0 and five is the line most installs call spam at.
80
+ //
81
+ // THESE CARRY THE NUMBER. Measured 2026-09-10 through Postmark's SpamCheck:
82
+ // stock SpamAssassin scored "Congratulations!!!" at ZERO its rules are tuned
83
+ // for 2005-era pharma and lottery mail and header forgery, and a two-line
84
+ // giveaway email trips none of them. So for the copy this product sends, the
85
+ // filter's opinion is close to noise and these rules are the actual signal.
86
+ // A flat half-point each (the first version) left "Congratulations!!!" reading
87
+ // as fine, which is the opposite of what a merchant needs to hear.
88
+ //
89
+ // Weighted by how reliably each one gets mail binned, and by how likely it is
90
+ // to be an accident. Shouting and scam phrasing are never accidents. Repeated
91
+ // punctuation and a currency amount are strong. Emoji and length are habits
92
+ // worth a nudge.
93
+ //
94
+ // NO BRAND RULE. There was one — "nothing here says the mail is from Acme" —
95
+ // and it was asking the merchant to do by hand what the send already does:
96
+ // email goes out with the organization's title as the From name, and SMS puts
97
+ // it on the first line of the body (twilio.sms). Advice to repeat what the
98
+ // envelope carries is noise, and a static default could never satisfy it.
99
+ //
100
+ // PHRASES COUNT PER MATCH. "Congratulations, click here, limited time" is
101
+ // three of them and reads like three of them; one sentence lists them all,
102
+ // the points say how many. A product table, and Darren's to move.
103
+ const POINTS = {
104
+ currency : 1,
105
+ emoji : 0.5,
106
+ length : 0.5,
107
+ phrase : 1,
108
+ punctuation : 1,
109
+ shouting : 1.5
110
+ };
111
+
112
+ const subjectRules = ({ subject }) => {
83
113
 
84
114
  const value = String( subject || '' ).trim();
85
115
 
@@ -89,25 +119,41 @@ const subjectWarnings = ({ brand, subject }) => {
89
119
  const emojis = emojiCount( value );
90
120
 
91
121
  return [
92
- value.length > SUBJECT_DISPLAY_LIMIT
93
- && 'Most email apps cut the subject around ' + SUBJECT_DISPLAY_LIMIT + ' characters — yours is ' + value.length + '.',
94
- shouting( value )
95
- && 'Mostly capitals reads as shouting, and filters treat it that way too.',
96
- /[!?]{2,}/.test( value )
97
- && 'Repeated exclamation or question marks are a common spam signal.',
98
- emojis > 1
99
- && 'More than one emoji in a subject is a common spam signal.',
100
- /[$£€]\s?\d/.test( value )
101
- && 'A currency amount in the subject is a common spam signal.',
102
- found.length > 0
103
- && 'Reads like a scam to filters and to people: ' + list( found ) + '.',
104
- brand && ! value.toLowerCase().includes( String( brand ).toLowerCase() )
105
- && 'Nothing here says the mail is from ' + brand + ' — subjects that name the sender get opened more.'
106
- ].filter( Boolean );
122
+ value.length > SUBJECT_DISPLAY_LIMIT && {
123
+ message : 'Most email apps cut the subject around ' + SUBJECT_DISPLAY_LIMIT + ' characters — yours is ' + value.length + '.',
124
+ points : POINTS.length,
125
+ rule : 'length'
126
+ },
127
+ shouting( value ) && {
128
+ message : 'Mostly capitals reads as shouting, and filters treat it that way too.',
129
+ points : POINTS.shouting,
130
+ rule : 'shouting'
131
+ },
132
+ /[!?]{2,}/.test( value ) && {
133
+ message : 'Repeated exclamation or question marks are a common spam signal.',
134
+ points : POINTS.punctuation,
135
+ rule : 'punctuation'
136
+ },
137
+ emojis > 1 && {
138
+ message : 'More than one emoji in a subject is a common spam signal.',
139
+ points : POINTS.emoji,
140
+ rule : 'emoji'
141
+ },
142
+ /[$£€]\s?\d/.test( value ) && {
143
+ message : 'A currency amount in the subject is a common spam signal.',
144
+ points : POINTS.currency,
145
+ rule : 'currency'
146
+ },
147
+ found.length > 0 && {
148
+ message : 'Reads like a scam to filters and to people: ' + list( found ) + '.',
149
+ points : POINTS.phrase * found.length,
150
+ rule : 'phrase'
151
+ }
152
+ ].filter( Boolean ).map( ( rule ) => ({ ...rule, field : 'subject' }) );
107
153
 
108
154
  };
109
155
 
110
- const messageWarnings = ({ message }) => {
156
+ const messageRules = ({ message }) => {
111
157
 
112
158
  const value = String( message || '' ).trim();
113
159
 
@@ -116,20 +162,38 @@ const messageWarnings = ({ message }) => {
116
162
  const found = phrasesIn( value );
117
163
 
118
164
  return [
119
- value.length < MESSAGE_SHORT_LIMIT
120
- && 'Very short messages read as a fragment, and bulk senders send exactly this shape.',
121
- shouting( value )
122
- && 'Mostly capitals reads as shouting, and filters treat it that way too.',
123
- /[!?]{2,}/.test( value )
124
- && 'Repeated exclamation or question marks are a common spam signal.',
125
- found.length > 0
126
- && 'Reads like a scam to filters and to people: ' + list( found ) + '.'
127
- ].filter( Boolean );
165
+ value.length < MESSAGE_SHORT_LIMIT && {
166
+ message : 'Very short messages read as a fragment, and bulk senders send exactly this shape.',
167
+ points : POINTS.length,
168
+ rule : 'length'
169
+ },
170
+ shouting( value ) && {
171
+ message : 'Mostly capitals reads as shouting, and filters treat it that way too.',
172
+ points : POINTS.shouting,
173
+ rule : 'shouting'
174
+ },
175
+ /[!?]{2,}/.test( value ) && {
176
+ message : 'Repeated exclamation or question marks are a common spam signal.',
177
+ points : POINTS.punctuation,
178
+ rule : 'punctuation'
179
+ },
180
+ found.length > 0 && {
181
+ message : 'Reads like a scam to filters and to people: ' + list( found ) + '.',
182
+ points : POINTS.phrase * found.length,
183
+ rule : 'phrase'
184
+ }
185
+ ].filter( Boolean ).map( ( rule ) => ({ ...rule, field : 'message' }) );
128
186
 
129
187
  };
130
188
 
131
- // ONE ENTRY POINT, answering per field so each warning renders against the
132
- // input it is about rather than as a pile at the bottom of the form.
189
+ // THE RULES, WITH THEIR WEIGHT: `[ { field, message, points, rule } ]`, subject
190
+ // first. This is what the api's scorer folds into the spam score beside
191
+ // SpamAssassin's own rules, so the sentence a merchant reads and the points
192
+ // it cost are one object.
193
+ //
194
+ // The same rule tripped in both fields comes back twice, once per field, each
195
+ // with its points. Whether that is one habit or two is the scorer's call to
196
+ // make; here the facts are just reported.
133
197
  //
134
198
  // NO CHANNEL ARGUMENT, deliberately, and it is worth saying why since one was
135
199
  // tried twice.
@@ -150,13 +214,25 @@ const messageWarnings = ({ message }) => {
150
214
  // wording, and the segment mechanics where one emoji drops the limit from 160
151
215
  // characters to 70. Those belong with the SMS connection work rather than here,
152
216
  // and their absence is a reason to write them, not to stay quiet meanwhile.
153
- const notificationWarnings = ({
154
- brand,
217
+ const notificationRules = ({
155
218
  message,
156
219
  subject
157
- } = {}) => ({
158
- message : messageWarnings({ message }),
159
- subject : subjectWarnings({ brand, subject })
160
- });
220
+ } = {}) => [
221
+ ...subjectRules({ subject }),
222
+ ...messageRules({ message })
223
+ ];
224
+
225
+ // The sentences alone, per field — what the rules looked like before they had
226
+ // points. Kept for anything that only wants the words.
227
+ const notificationWarnings = ( input ) => {
228
+
229
+ const rules = notificationRules( input );
230
+
231
+ return {
232
+ message : rules.filter( ( rule ) => rule.field === 'message' ).map( ( rule ) => rule.message ),
233
+ subject : rules.filter( ( rule ) => rule.field === 'subject' ).map( ( rule ) => rule.message )
234
+ };
235
+
236
+ };
161
237
 
162
- export { notificationWarnings };
238
+ export { POINTS, notificationRules, notificationWarnings };
@@ -75,11 +75,41 @@ const phrasesIn = ( value ) => {
75
75
 
76
76
  const list = ( values ) => values.map( ( value ) => '“' + value + '”' ).join( ', ' );
77
77
 
78
- // `brand` is the organization's own name. A subject that never mentions who is
79
- // writing is the single most recognisable trait of the mail people delete
80
- // unread — and the one a merchant can fix in five seconds once it is pointed
81
- // out. Skipped entirely when no brand is supplied rather than guessed at.
82
- const subjectWarnings = ({ brand, subject }) => {
78
+ // WHAT EACH RULE IS WORTH, on SpamAssassin's scale its own rules run 0.1 to
79
+ // about 2.0 and five is the line most installs call spam at.
80
+ //
81
+ // THESE CARRY THE NUMBER. Measured 2026-09-10 through Postmark's SpamCheck:
82
+ // stock SpamAssassin scored "Congratulations!!!" at ZERO its rules are tuned
83
+ // for 2005-era pharma and lottery mail and header forgery, and a two-line
84
+ // giveaway email trips none of them. So for the copy this product sends, the
85
+ // filter's opinion is close to noise and these rules are the actual signal.
86
+ // A flat half-point each (the first version) left "Congratulations!!!" reading
87
+ // as fine, which is the opposite of what a merchant needs to hear.
88
+ //
89
+ // Weighted by how reliably each one gets mail binned, and by how likely it is
90
+ // to be an accident. Shouting and scam phrasing are never accidents. Repeated
91
+ // punctuation and a currency amount are strong. Emoji and length are habits
92
+ // worth a nudge.
93
+ //
94
+ // NO BRAND RULE. There was one — "nothing here says the mail is from Acme" —
95
+ // and it was asking the merchant to do by hand what the send already does:
96
+ // email goes out with the organization's title as the From name, and SMS puts
97
+ // it on the first line of the body (twilio.sms). Advice to repeat what the
98
+ // envelope carries is noise, and a static default could never satisfy it.
99
+ //
100
+ // PHRASES COUNT PER MATCH. "Congratulations, click here, limited time" is
101
+ // three of them and reads like three of them; one sentence lists them all,
102
+ // the points say how many. A product table, and Darren's to move.
103
+ const POINTS = {
104
+ currency : 1,
105
+ emoji : 0.5,
106
+ length : 0.5,
107
+ phrase : 1,
108
+ punctuation : 1,
109
+ shouting : 1.5
110
+ };
111
+
112
+ const subjectRules = ({ subject }) => {
83
113
 
84
114
  const value = String( subject || '' ).trim();
85
115
 
@@ -89,25 +119,41 @@ const subjectWarnings = ({ brand, subject }) => {
89
119
  const emojis = emojiCount( value );
90
120
 
91
121
  return [
92
- value.length > SUBJECT_DISPLAY_LIMIT
93
- && 'Most email apps cut the subject around ' + SUBJECT_DISPLAY_LIMIT + ' characters — yours is ' + value.length + '.',
94
- shouting( value )
95
- && 'Mostly capitals reads as shouting, and filters treat it that way too.',
96
- /[!?]{2,}/.test( value )
97
- && 'Repeated exclamation or question marks are a common spam signal.',
98
- emojis > 1
99
- && 'More than one emoji in a subject is a common spam signal.',
100
- /[$£€]\s?\d/.test( value )
101
- && 'A currency amount in the subject is a common spam signal.',
102
- found.length > 0
103
- && 'Reads like a scam to filters and to people: ' + list( found ) + '.',
104
- brand && ! value.toLowerCase().includes( String( brand ).toLowerCase() )
105
- && 'Nothing here says the mail is from ' + brand + ' — subjects that name the sender get opened more.'
106
- ].filter( Boolean );
122
+ value.length > SUBJECT_DISPLAY_LIMIT && {
123
+ message : 'Most email apps cut the subject around ' + SUBJECT_DISPLAY_LIMIT + ' characters — yours is ' + value.length + '.',
124
+ points : POINTS.length,
125
+ rule : 'length'
126
+ },
127
+ shouting( value ) && {
128
+ message : 'Mostly capitals reads as shouting, and filters treat it that way too.',
129
+ points : POINTS.shouting,
130
+ rule : 'shouting'
131
+ },
132
+ /[!?]{2,}/.test( value ) && {
133
+ message : 'Repeated exclamation or question marks are a common spam signal.',
134
+ points : POINTS.punctuation,
135
+ rule : 'punctuation'
136
+ },
137
+ emojis > 1 && {
138
+ message : 'More than one emoji in a subject is a common spam signal.',
139
+ points : POINTS.emoji,
140
+ rule : 'emoji'
141
+ },
142
+ /[$£€]\s?\d/.test( value ) && {
143
+ message : 'A currency amount in the subject is a common spam signal.',
144
+ points : POINTS.currency,
145
+ rule : 'currency'
146
+ },
147
+ found.length > 0 && {
148
+ message : 'Reads like a scam to filters and to people: ' + list( found ) + '.',
149
+ points : POINTS.phrase * found.length,
150
+ rule : 'phrase'
151
+ }
152
+ ].filter( Boolean ).map( ( rule ) => ({ ...rule, field : 'subject' }) );
107
153
 
108
154
  };
109
155
 
110
- const messageWarnings = ({ message }) => {
156
+ const messageRules = ({ message }) => {
111
157
 
112
158
  const value = String( message || '' ).trim();
113
159
 
@@ -116,20 +162,38 @@ const messageWarnings = ({ message }) => {
116
162
  const found = phrasesIn( value );
117
163
 
118
164
  return [
119
- value.length < MESSAGE_SHORT_LIMIT
120
- && 'Very short messages read as a fragment, and bulk senders send exactly this shape.',
121
- shouting( value )
122
- && 'Mostly capitals reads as shouting, and filters treat it that way too.',
123
- /[!?]{2,}/.test( value )
124
- && 'Repeated exclamation or question marks are a common spam signal.',
125
- found.length > 0
126
- && 'Reads like a scam to filters and to people: ' + list( found ) + '.'
127
- ].filter( Boolean );
165
+ value.length < MESSAGE_SHORT_LIMIT && {
166
+ message : 'Very short messages read as a fragment, and bulk senders send exactly this shape.',
167
+ points : POINTS.length,
168
+ rule : 'length'
169
+ },
170
+ shouting( value ) && {
171
+ message : 'Mostly capitals reads as shouting, and filters treat it that way too.',
172
+ points : POINTS.shouting,
173
+ rule : 'shouting'
174
+ },
175
+ /[!?]{2,}/.test( value ) && {
176
+ message : 'Repeated exclamation or question marks are a common spam signal.',
177
+ points : POINTS.punctuation,
178
+ rule : 'punctuation'
179
+ },
180
+ found.length > 0 && {
181
+ message : 'Reads like a scam to filters and to people: ' + list( found ) + '.',
182
+ points : POINTS.phrase * found.length,
183
+ rule : 'phrase'
184
+ }
185
+ ].filter( Boolean ).map( ( rule ) => ({ ...rule, field : 'message' }) );
128
186
 
129
187
  };
130
188
 
131
- // ONE ENTRY POINT, answering per field so each warning renders against the
132
- // input it is about rather than as a pile at the bottom of the form.
189
+ // THE RULES, WITH THEIR WEIGHT: `[ { field, message, points, rule } ]`, subject
190
+ // first. This is what the api's scorer folds into the spam score beside
191
+ // SpamAssassin's own rules, so the sentence a merchant reads and the points
192
+ // it cost are one object.
193
+ //
194
+ // The same rule tripped in both fields comes back twice, once per field, each
195
+ // with its points. Whether that is one habit or two is the scorer's call to
196
+ // make; here the facts are just reported.
133
197
  //
134
198
  // NO CHANNEL ARGUMENT, deliberately, and it is worth saying why since one was
135
199
  // tried twice.
@@ -150,13 +214,25 @@ const messageWarnings = ({ message }) => {
150
214
  // wording, and the segment mechanics where one emoji drops the limit from 160
151
215
  // characters to 70. Those belong with the SMS connection work rather than here,
152
216
  // and their absence is a reason to write them, not to stay quiet meanwhile.
153
- const notificationWarnings = ({
154
- brand,
217
+ const notificationRules = ({
155
218
  message,
156
219
  subject
157
- } = {}) => ({
158
- message : messageWarnings({ message }),
159
- subject : subjectWarnings({ brand, subject })
160
- });
220
+ } = {}) => [
221
+ ...subjectRules({ subject }),
222
+ ...messageRules({ message })
223
+ ];
224
+
225
+ // The sentences alone, per field — what the rules looked like before they had
226
+ // points. Kept for anything that only wants the words.
227
+ const notificationWarnings = ( input ) => {
228
+
229
+ const rules = notificationRules( input );
230
+
231
+ return {
232
+ message : rules.filter( ( rule ) => rule.field === 'message' ).map( ( rule ) => rule.message ),
233
+ subject : rules.filter( ( rule ) => rule.field === 'subject' ).map( ( rule ) => rule.message )
234
+ };
235
+
236
+ };
161
237
 
162
- export { notificationWarnings };
238
+ export { POINTS, notificationRules, notificationWarnings };
@@ -26,40 +26,95 @@ var phrasesIn = (value) => {
26
26
  return PHRASES.filter((phrase) => lower.includes(phrase));
27
27
  };
28
28
  var list = (values) => values.map((value) => "\u201C" + value + "\u201D").join(", ");
29
- var subjectWarnings = ({ brand, subject }) => {
29
+ var POINTS = {
30
+ currency: 1,
31
+ emoji: 0.5,
32
+ length: 0.5,
33
+ phrase: 1,
34
+ punctuation: 1,
35
+ shouting: 1.5
36
+ };
37
+ var subjectRules = ({ subject }) => {
30
38
  const value = String(subject || "").trim();
31
39
  if (!value) return [];
32
40
  const found = phrasesIn(value);
33
41
  const emojis = emojiCount(value);
34
42
  return [
35
- value.length > SUBJECT_DISPLAY_LIMIT && "Most email apps cut the subject around " + SUBJECT_DISPLAY_LIMIT + " characters \u2014 yours is " + value.length + ".",
36
- shouting(value) && "Mostly capitals reads as shouting, and filters treat it that way too.",
37
- /[!?]{2,}/.test(value) && "Repeated exclamation or question marks are a common spam signal.",
38
- emojis > 1 && "More than one emoji in a subject is a common spam signal.",
39
- /[$£€]\s?\d/.test(value) && "A currency amount in the subject is a common spam signal.",
40
- found.length > 0 && "Reads like a scam to filters and to people: " + list(found) + ".",
41
- brand && !value.toLowerCase().includes(String(brand).toLowerCase()) && "Nothing here says the mail is from " + brand + " \u2014 subjects that name the sender get opened more."
42
- ].filter(Boolean);
43
+ value.length > SUBJECT_DISPLAY_LIMIT && {
44
+ message: "Most email apps cut the subject around " + SUBJECT_DISPLAY_LIMIT + " characters \u2014 yours is " + value.length + ".",
45
+ points: POINTS.length,
46
+ rule: "length"
47
+ },
48
+ shouting(value) && {
49
+ message: "Mostly capitals reads as shouting, and filters treat it that way too.",
50
+ points: POINTS.shouting,
51
+ rule: "shouting"
52
+ },
53
+ /[!?]{2,}/.test(value) && {
54
+ message: "Repeated exclamation or question marks are a common spam signal.",
55
+ points: POINTS.punctuation,
56
+ rule: "punctuation"
57
+ },
58
+ emojis > 1 && {
59
+ message: "More than one emoji in a subject is a common spam signal.",
60
+ points: POINTS.emoji,
61
+ rule: "emoji"
62
+ },
63
+ /[$£€]\s?\d/.test(value) && {
64
+ message: "A currency amount in the subject is a common spam signal.",
65
+ points: POINTS.currency,
66
+ rule: "currency"
67
+ },
68
+ found.length > 0 && {
69
+ message: "Reads like a scam to filters and to people: " + list(found) + ".",
70
+ points: POINTS.phrase * found.length,
71
+ rule: "phrase"
72
+ }
73
+ ].filter(Boolean).map((rule) => ({ ...rule, field: "subject" }));
43
74
  };
44
- var messageWarnings = ({ message }) => {
75
+ var messageRules = ({ message }) => {
45
76
  const value = String(message || "").trim();
46
77
  if (!value) return [];
47
78
  const found = phrasesIn(value);
48
79
  return [
49
- value.length < MESSAGE_SHORT_LIMIT && "Very short messages read as a fragment, and bulk senders send exactly this shape.",
50
- shouting(value) && "Mostly capitals reads as shouting, and filters treat it that way too.",
51
- /[!?]{2,}/.test(value) && "Repeated exclamation or question marks are a common spam signal.",
52
- found.length > 0 && "Reads like a scam to filters and to people: " + list(found) + "."
53
- ].filter(Boolean);
80
+ value.length < MESSAGE_SHORT_LIMIT && {
81
+ message: "Very short messages read as a fragment, and bulk senders send exactly this shape.",
82
+ points: POINTS.length,
83
+ rule: "length"
84
+ },
85
+ shouting(value) && {
86
+ message: "Mostly capitals reads as shouting, and filters treat it that way too.",
87
+ points: POINTS.shouting,
88
+ rule: "shouting"
89
+ },
90
+ /[!?]{2,}/.test(value) && {
91
+ message: "Repeated exclamation or question marks are a common spam signal.",
92
+ points: POINTS.punctuation,
93
+ rule: "punctuation"
94
+ },
95
+ found.length > 0 && {
96
+ message: "Reads like a scam to filters and to people: " + list(found) + ".",
97
+ points: POINTS.phrase * found.length,
98
+ rule: "phrase"
99
+ }
100
+ ].filter(Boolean).map((rule) => ({ ...rule, field: "message" }));
54
101
  };
55
- var notificationWarnings = ({
56
- brand,
102
+ var notificationRules = ({
57
103
  message,
58
104
  subject
59
- } = {}) => ({
60
- message: messageWarnings({ message }),
61
- subject: subjectWarnings({ brand, subject })
62
- });
105
+ } = {}) => [
106
+ ...subjectRules({ subject }),
107
+ ...messageRules({ message })
108
+ ];
109
+ var notificationWarnings = (input) => {
110
+ const rules = notificationRules(input);
111
+ return {
112
+ message: rules.filter((rule) => rule.field === "message").map((rule) => rule.message),
113
+ subject: rules.filter((rule) => rule.field === "subject").map((rule) => rule.message)
114
+ };
115
+ };
63
116
  export {
117
+ POINTS,
118
+ notificationRules,
64
119
  notificationWarnings
65
120
  };
@@ -5653,6 +5653,12 @@ var publicConnectionKeys = Object.freeze([
5653
5653
  // excerpt, guide, and any vendor redirect copy.
5654
5654
  "content",
5655
5655
  "createdAt",
5656
+ // THE MERCHANT'S OWN SWITCH. `enabled : false` is a connection the merchant
5657
+ // paused — kept, configured, and not run — as distinct from a vendor the
5658
+ // platform switched off (which the api overlays as an error). Absent means
5659
+ // on; the dashboard renders Paused from it and the sync gates refuse a paused
5660
+ // connection the way they refuse a missing one.
5661
+ "enabled",
5656
5662
  // The connection DOCUMENT's own errors array — scope-drift entries written by
5657
5663
  // drawbridge-sync. NOT the manifest's error copy, which is content.errors:
5658
5664
  // the document is spread OVER the resolved manifest downstream, so the two
package/dist/providers.js CHANGED
@@ -5604,6 +5604,12 @@ var publicConnectionKeys = Object.freeze([
5604
5604
  // excerpt, guide, and any vendor redirect copy.
5605
5605
  "content",
5606
5606
  "createdAt",
5607
+ // THE MERCHANT'S OWN SWITCH. `enabled : false` is a connection the merchant
5608
+ // paused — kept, configured, and not run — as distinct from a vendor the
5609
+ // platform switched off (which the api overlays as an error). Absent means
5610
+ // on; the dashboard renders Paused from it and the sync gates refuse a paused
5611
+ // connection the way they refuse a missing one.
5612
+ "enabled",
5607
5613
  // The connection DOCUMENT's own errors array — scope-drift entries written by
5608
5614
  // drawbridge-sync. NOT the manifest's error copy, which is content.errors:
5609
5615
  // the document is spread OVER the resolved manifest downstream, so the two
package/dist/sendgrid.cjs CHANGED
@@ -19,6 +19,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // lib/sendgrid.js
20
20
  var sendgrid_exports = {};
21
21
  __export(sendgrid_exports, {
22
+ dnsRows: () => dnsRows,
23
+ domain: () => domain,
22
24
  sendWithRetry: () => sendWithRetry,
23
25
  sendgrid: () => sendgrid,
24
26
  sendgridRequest: () => sendgridRequest
@@ -111,7 +113,48 @@ var sendgridRequest = ({ apiKey, body, method, path, query, request: request2 =
111
113
  url: SENDGRID_BASE + path
112
114
  });
113
115
  };
116
+ var dnsRows = (dns) => Object.values(dns || {}).filter((row) => (row == null ? void 0 : row.host) && (row == null ? void 0 : row.type)).map((row) => ({
117
+ data: String(row.data ?? ""),
118
+ host: String(row.host),
119
+ type: String(row.type),
120
+ valid: typeof row.valid === "boolean" ? row.valid : null
121
+ }));
122
+ var domain = {
123
+ // automatic_security is what makes this CNAME-based: the provider holds the
124
+ // keys and the merchant publishes three CNAMEs. Without it they paste DKIM
125
+ // keys into TXT records by hand.
126
+ register: async ({ apiKey, domain: name, via = sendgridRequest }) => {
127
+ const created = await via({
128
+ apiKey,
129
+ body: {
130
+ automatic_security: true,
131
+ domain: name
132
+ },
133
+ method: "POST",
134
+ path: "/v3/whitelabel/domains"
135
+ });
136
+ return {
137
+ records: dnsRows(created == null ? void 0 : created.dns),
138
+ reference: (created == null ? void 0 : created.id) == null ? null : String(created.id)
139
+ };
140
+ },
141
+ // Asks SendGrid to look the records up now. `records` carries per-record
142
+ // validity so a UI can say WHICH record has not landed rather than only that
143
+ // something has not; `valid` is the whole domain's verdict.
144
+ validate: async ({ apiKey, reference, via = sendgridRequest }) => {
145
+ const result = await via({
146
+ apiKey,
147
+ method: "POST",
148
+ path: "/v3/whitelabel/domains/" + encodeURIComponent(reference) + "/validate"
149
+ });
150
+ return {
151
+ records: dnsRows(result == null ? void 0 : result.validation_results),
152
+ valid: Boolean(result == null ? void 0 : result.valid)
153
+ };
154
+ }
155
+ };
114
156
  var sendgrid = {
157
+ domain,
115
158
  // `headers` (optional) carries the List-Unsubscribe pair on lead-facing
116
159
  // commercial sends; omitted, the request body is byte-identical to the
117
160
  // pre-opt-out-floor shape so system mail is untouched.
@@ -177,6 +220,8 @@ var sendgrid = {
177
220
  };
178
221
  // Annotate the CommonJS export names for ESM import in node:
179
222
  0 && (module.exports = {
223
+ dnsRows,
224
+ domain,
180
225
  sendWithRetry,
181
226
  sendgrid,
182
227
  sendgridRequest
@@ -109,8 +109,79 @@ const sendgridRequest = ({ apiKey, body, method, path, query, request: request$1
109
109
 
110
110
  };
111
111
 
112
+ // SendGrid returns the records to publish as an OBJECT keyed by their role
113
+ // (mail_cname, dkim1, dkim2), not a list. The keys are provider vocabulary the
114
+ // merchant never sees and the set differs by security mode, so they are
115
+ // flattened to the rows the identity schema stores — the UI then renders
116
+ // whatever came back instead of naming roles it would have to keep in step
117
+ // with.
118
+ const dnsRows = ( dns ) => Object.values( dns || {} )
119
+ .filter( ( row ) => row?.host && row?.type )
120
+ .map( ( row ) => ({
121
+ data : String( row.data ?? '' ),
122
+ host : String( row.host ),
123
+ type : String( row.type ),
124
+ valid : typeof row.valid === 'boolean' ? row.valid : null
125
+ }) );
126
+
127
+ // DOMAIN AUTHENTICATION, the two calls behind a custom sending domain. They
128
+ // lived inline in sync's identity worker; the api needed the second one too, so
129
+ // a merchant pressing Check now gets an answer in the request rather than a
130
+ // timestamp that a change stream turns into a job that answers a socket later.
131
+ // One copy, both callers, same shape back.
132
+ //
133
+ // `via` is the SendGrid-level call, injectable: sync's identity worker stubs
134
+ // exactly that boundary (its tests assert on the `path`), and tsup's exports
135
+ // are read-only so a test cannot reassign one.
136
+ const domain = {
137
+
138
+ // automatic_security is what makes this CNAME-based: the provider holds the
139
+ // keys and the merchant publishes three CNAMEs. Without it they paste DKIM
140
+ // keys into TXT records by hand.
141
+ register : async ({ apiKey, domain : name, via = sendgridRequest }) => {
142
+
143
+ const created = await via({
144
+ apiKey,
145
+ body : {
146
+ automatic_security : true,
147
+ domain : name
148
+ },
149
+ method : 'POST',
150
+ path : '/v3/whitelabel/domains'
151
+ });
152
+
153
+ return {
154
+ records : dnsRows( created?.dns ),
155
+ reference : created?.id == null ? null : String( created.id )
156
+ };
157
+
158
+ },
159
+
160
+ // Asks SendGrid to look the records up now. `records` carries per-record
161
+ // validity so a UI can say WHICH record has not landed rather than only that
162
+ // something has not; `valid` is the whole domain's verdict.
163
+ validate : async ({ apiKey, reference, via = sendgridRequest }) => {
164
+
165
+ const result = await via({
166
+ apiKey,
167
+ method : 'POST',
168
+ path : '/v3/whitelabel/domains/' + encodeURIComponent( reference ) + '/validate'
169
+ });
170
+
171
+ return {
172
+ records : dnsRows( result?.validation_results ),
173
+ valid : Boolean( result?.valid )
174
+ };
175
+
176
+ }
177
+
178
+ };
179
+
112
180
  const sendgrid = {
113
181
 
182
+ domain,
183
+
184
+
114
185
  // `headers` (optional) carries the List-Unsubscribe pair on lead-facing
115
186
  // commercial sends; omitted, the request body is byte-identical to the
116
187
  // pre-opt-out-floor shape so system mail is untouched.
@@ -210,4 +281,4 @@ const sendgrid = {
210
281
  // domain-authentication endpoints (/v3/whitelabel/domains) through the same
211
282
  // authenticated client the mail send uses, rather than minting a second one.
212
283
 
213
- export { sendWithRetry, sendgrid, sendgridRequest };
284
+ export { dnsRows, domain, sendWithRetry, sendgrid, sendgridRequest };
@@ -109,8 +109,79 @@ const sendgridRequest = ({ apiKey, body, method, path, query, request: request$1
109
109
 
110
110
  };
111
111
 
112
+ // SendGrid returns the records to publish as an OBJECT keyed by their role
113
+ // (mail_cname, dkim1, dkim2), not a list. The keys are provider vocabulary the
114
+ // merchant never sees and the set differs by security mode, so they are
115
+ // flattened to the rows the identity schema stores — the UI then renders
116
+ // whatever came back instead of naming roles it would have to keep in step
117
+ // with.
118
+ const dnsRows = ( dns ) => Object.values( dns || {} )
119
+ .filter( ( row ) => row?.host && row?.type )
120
+ .map( ( row ) => ({
121
+ data : String( row.data ?? '' ),
122
+ host : String( row.host ),
123
+ type : String( row.type ),
124
+ valid : typeof row.valid === 'boolean' ? row.valid : null
125
+ }) );
126
+
127
+ // DOMAIN AUTHENTICATION, the two calls behind a custom sending domain. They
128
+ // lived inline in sync's identity worker; the api needed the second one too, so
129
+ // a merchant pressing Check now gets an answer in the request rather than a
130
+ // timestamp that a change stream turns into a job that answers a socket later.
131
+ // One copy, both callers, same shape back.
132
+ //
133
+ // `via` is the SendGrid-level call, injectable: sync's identity worker stubs
134
+ // exactly that boundary (its tests assert on the `path`), and tsup's exports
135
+ // are read-only so a test cannot reassign one.
136
+ const domain = {
137
+
138
+ // automatic_security is what makes this CNAME-based: the provider holds the
139
+ // keys and the merchant publishes three CNAMEs. Without it they paste DKIM
140
+ // keys into TXT records by hand.
141
+ register : async ({ apiKey, domain : name, via = sendgridRequest }) => {
142
+
143
+ const created = await via({
144
+ apiKey,
145
+ body : {
146
+ automatic_security : true,
147
+ domain : name
148
+ },
149
+ method : 'POST',
150
+ path : '/v3/whitelabel/domains'
151
+ });
152
+
153
+ return {
154
+ records : dnsRows( created?.dns ),
155
+ reference : created?.id == null ? null : String( created.id )
156
+ };
157
+
158
+ },
159
+
160
+ // Asks SendGrid to look the records up now. `records` carries per-record
161
+ // validity so a UI can say WHICH record has not landed rather than only that
162
+ // something has not; `valid` is the whole domain's verdict.
163
+ validate : async ({ apiKey, reference, via = sendgridRequest }) => {
164
+
165
+ const result = await via({
166
+ apiKey,
167
+ method : 'POST',
168
+ path : '/v3/whitelabel/domains/' + encodeURIComponent( reference ) + '/validate'
169
+ });
170
+
171
+ return {
172
+ records : dnsRows( result?.validation_results ),
173
+ valid : Boolean( result?.valid )
174
+ };
175
+
176
+ }
177
+
178
+ };
179
+
112
180
  const sendgrid = {
113
181
 
182
+ domain,
183
+
184
+
114
185
  // `headers` (optional) carries the List-Unsubscribe pair on lead-facing
115
186
  // commercial sends; omitted, the request body is byte-identical to the
116
187
  // pre-opt-out-floor shape so system mail is untouched.
@@ -210,4 +281,4 @@ const sendgrid = {
210
281
  // domain-authentication endpoints (/v3/whitelabel/domains) through the same
211
282
  // authenticated client the mail send uses, rather than minting a second one.
212
283
 
213
- export { sendWithRetry, sendgrid, sendgridRequest };
284
+ export { dnsRows, domain, sendWithRetry, sendgrid, sendgridRequest };
package/dist/sendgrid.js CHANGED
@@ -84,7 +84,48 @@ var sendgridRequest = ({ apiKey, body, method, path, query, request: request2 =
84
84
  url: SENDGRID_BASE + path
85
85
  });
86
86
  };
87
+ var dnsRows = (dns) => Object.values(dns || {}).filter((row) => (row == null ? void 0 : row.host) && (row == null ? void 0 : row.type)).map((row) => ({
88
+ data: String(row.data ?? ""),
89
+ host: String(row.host),
90
+ type: String(row.type),
91
+ valid: typeof row.valid === "boolean" ? row.valid : null
92
+ }));
93
+ var domain = {
94
+ // automatic_security is what makes this CNAME-based: the provider holds the
95
+ // keys and the merchant publishes three CNAMEs. Without it they paste DKIM
96
+ // keys into TXT records by hand.
97
+ register: async ({ apiKey, domain: name, via = sendgridRequest }) => {
98
+ const created = await via({
99
+ apiKey,
100
+ body: {
101
+ automatic_security: true,
102
+ domain: name
103
+ },
104
+ method: "POST",
105
+ path: "/v3/whitelabel/domains"
106
+ });
107
+ return {
108
+ records: dnsRows(created == null ? void 0 : created.dns),
109
+ reference: (created == null ? void 0 : created.id) == null ? null : String(created.id)
110
+ };
111
+ },
112
+ // Asks SendGrid to look the records up now. `records` carries per-record
113
+ // validity so a UI can say WHICH record has not landed rather than only that
114
+ // something has not; `valid` is the whole domain's verdict.
115
+ validate: async ({ apiKey, reference, via = sendgridRequest }) => {
116
+ const result = await via({
117
+ apiKey,
118
+ method: "POST",
119
+ path: "/v3/whitelabel/domains/" + encodeURIComponent(reference) + "/validate"
120
+ });
121
+ return {
122
+ records: dnsRows(result == null ? void 0 : result.validation_results),
123
+ valid: Boolean(result == null ? void 0 : result.valid)
124
+ };
125
+ }
126
+ };
87
127
  var sendgrid = {
128
+ domain,
88
129
  // `headers` (optional) carries the List-Unsubscribe pair on lead-facing
89
130
  // commercial sends; omitted, the request body is byte-identical to the
90
131
  // pre-opt-out-floor shape so system mail is untouched.
@@ -149,6 +190,8 @@ var sendgrid = {
149
190
  }
150
191
  };
151
192
  export {
193
+ dnsRows,
194
+ domain,
152
195
  sendWithRetry,
153
196
  sendgrid,
154
197
  sendgridRequest
package/dist/twilio.cjs CHANGED
@@ -71,6 +71,12 @@ var twilio = {
71
71
  sms: async ({
72
72
  accountSid,
73
73
  authToken,
74
+ // WHO IS TEXTING. A platform number carries no sender name, so the body is
75
+ // the only place a recipient learns who this is from — and a text from a
76
+ // bare number with no name is the exact shape of smishing, which carriers
77
+ // filter on and people delete. Lead-facing sends pass the organization's
78
+ // title; OTC/auth sends pass nothing and keep their exact copy.
79
+ brand,
74
80
  from,
75
81
  link,
76
82
  message,
@@ -83,7 +89,9 @@ var twilio = {
83
89
  }) => {
84
90
  const missing = Object.entries({ accountSid, authToken, from }).filter(([, value]) => !value).map(([name]) => name);
85
91
  if (missing.length) throw new Error("Twilio credentials missing \u2014 " + missing.join(", ") + " (the drawbridge provider's smsSid, smsToken and smsFrom)");
92
+ const named = brand && String(title || "").trim().toLowerCase().startsWith(String(brand).trim().toLowerCase());
86
93
  const body = [
94
+ ...brand && !named ? [brand] : [],
87
95
  title,
88
96
  message,
89
97
  ...prize ? [prize.title + " (" + prize.value + ")"] : [],
package/dist/twilio.d.cts CHANGED
@@ -15,6 +15,12 @@ const twilio = {
15
15
  sms : async ({
16
16
  accountSid,
17
17
  authToken,
18
+ // WHO IS TEXTING. A platform number carries no sender name, so the body is
19
+ // the only place a recipient learns who this is from — and a text from a
20
+ // bare number with no name is the exact shape of smishing, which carriers
21
+ // filter on and people delete. Lead-facing sends pass the organization's
22
+ // title; OTC/auth sends pass nothing and keep their exact copy.
23
+ brand,
18
24
  from,
19
25
  link,
20
26
  message,
@@ -38,12 +44,23 @@ const twilio = {
38
44
 
39
45
  if( missing.length ) throw new Error( 'Twilio credentials missing — ' + missing.join( ', ' ) + ' (the drawbridge provider\'s smsSid, smsToken and smsFrom)' );
40
46
 
41
- // Newline-separated, brand/title first, and no "Your prize is" /
47
+ // Newline-separated, BRAND FIRST, then title, and no "Your prize is" /
42
48
  // "Click here to verify your prize" phrasing — textbook carrier-filter
43
49
  // bait that got these messages flagged. The opt-out line is appended
44
50
  // on lead-facing sends (carrier compliance); OTC/auth sends pass
45
51
  // nothing new and keep their exact copy.
52
+ //
53
+ // The brand line is what the toll-free verification sample below
54
+ // promised Twilio we send — "Cedar & Sage / Thanks for entering… / Reply
55
+ // STOP to opt out" — and for a while it was the one line the real sends
56
+ // left out. It was a merchant's job to type it into the subject, and a
57
+ // warning nagged them when they did not; now it is put here, once, and
58
+ // the warning is gone. Skipped when the title already opens with it, so
59
+ // a merchant who wrote "Cedar & Sage: your prize" is not branded twice.
60
+ const named = brand && String( title || '' ).trim().toLowerCase().startsWith( String( brand ).trim().toLowerCase() );
61
+
46
62
  const body = [
63
+ ...( brand && ! named ? [ brand ] : [] ),
47
64
  title,
48
65
  message,
49
66
  ...( prize ? [ prize.title + ' (' + prize.value + ')' ] : [] ),
package/dist/twilio.d.ts CHANGED
@@ -15,6 +15,12 @@ const twilio = {
15
15
  sms : async ({
16
16
  accountSid,
17
17
  authToken,
18
+ // WHO IS TEXTING. A platform number carries no sender name, so the body is
19
+ // the only place a recipient learns who this is from — and a text from a
20
+ // bare number with no name is the exact shape of smishing, which carriers
21
+ // filter on and people delete. Lead-facing sends pass the organization's
22
+ // title; OTC/auth sends pass nothing and keep their exact copy.
23
+ brand,
18
24
  from,
19
25
  link,
20
26
  message,
@@ -38,12 +44,23 @@ const twilio = {
38
44
 
39
45
  if( missing.length ) throw new Error( 'Twilio credentials missing — ' + missing.join( ', ' ) + ' (the drawbridge provider\'s smsSid, smsToken and smsFrom)' );
40
46
 
41
- // Newline-separated, brand/title first, and no "Your prize is" /
47
+ // Newline-separated, BRAND FIRST, then title, and no "Your prize is" /
42
48
  // "Click here to verify your prize" phrasing — textbook carrier-filter
43
49
  // bait that got these messages flagged. The opt-out line is appended
44
50
  // on lead-facing sends (carrier compliance); OTC/auth sends pass
45
51
  // nothing new and keep their exact copy.
52
+ //
53
+ // The brand line is what the toll-free verification sample below
54
+ // promised Twilio we send — "Cedar & Sage / Thanks for entering… / Reply
55
+ // STOP to opt out" — and for a while it was the one line the real sends
56
+ // left out. It was a merchant's job to type it into the subject, and a
57
+ // warning nagged them when they did not; now it is put here, once, and
58
+ // the warning is gone. Skipped when the title already opens with it, so
59
+ // a merchant who wrote "Cedar & Sage: your prize" is not branded twice.
60
+ const named = brand && String( title || '' ).trim().toLowerCase().startsWith( String( brand ).trim().toLowerCase() );
61
+
46
62
  const body = [
63
+ ...( brand && ! named ? [ brand ] : [] ),
47
64
  title,
48
65
  message,
49
66
  ...( prize ? [ prize.title + ' (' + prize.value + ')' ] : [] ),
package/dist/twilio.js CHANGED
@@ -46,6 +46,12 @@ var twilio = {
46
46
  sms: async ({
47
47
  accountSid,
48
48
  authToken,
49
+ // WHO IS TEXTING. A platform number carries no sender name, so the body is
50
+ // the only place a recipient learns who this is from — and a text from a
51
+ // bare number with no name is the exact shape of smishing, which carriers
52
+ // filter on and people delete. Lead-facing sends pass the organization's
53
+ // title; OTC/auth sends pass nothing and keep their exact copy.
54
+ brand,
49
55
  from,
50
56
  link,
51
57
  message,
@@ -58,7 +64,9 @@ var twilio = {
58
64
  }) => {
59
65
  const missing = Object.entries({ accountSid, authToken, from }).filter(([, value]) => !value).map(([name]) => name);
60
66
  if (missing.length) throw new Error("Twilio credentials missing \u2014 " + missing.join(", ") + " (the drawbridge provider's smsSid, smsToken and smsFrom)");
67
+ const named = brand && String(title || "").trim().toLowerCase().startsWith(String(brand).trim().toLowerCase());
61
68
  const body = [
69
+ ...brand && !named ? [brand] : [],
62
70
  title,
63
71
  message,
64
72
  ...prize ? [prize.title + " (" + prize.value + ")"] : [],
package/package.json CHANGED
@@ -226,5 +226,5 @@
226
226
  "prepublishOnly": ". \"$HOME/.nvm/nvm.sh\" && nvm use && tsup && node --test"
227
227
  },
228
228
  "types": "dist/index.d.ts",
229
- "version": "0.0.163"
229
+ "version": "0.0.165"
230
230
  }