@drawbridge/drawbridge-utils 0.0.164 → 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.
@@ -52,7 +52,6 @@ var phrasesIn = (value) => {
52
52
  };
53
53
  var list = (values) => values.map((value) => "\u201C" + value + "\u201D").join(", ");
54
54
  var POINTS = {
55
- brand: 0,
56
55
  currency: 1,
57
56
  emoji: 0.5,
58
57
  length: 0.5,
@@ -60,7 +59,7 @@ var POINTS = {
60
59
  punctuation: 1,
61
60
  shouting: 1.5
62
61
  };
63
- var subjectRules = ({ brand, subject }) => {
62
+ var subjectRules = ({ subject }) => {
64
63
  const value = String(subject || "").trim();
65
64
  if (!value) return [];
66
65
  const found = phrasesIn(value);
@@ -95,11 +94,6 @@ var subjectRules = ({ brand, subject }) => {
95
94
  message: "Reads like a scam to filters and to people: " + list(found) + ".",
96
95
  points: POINTS.phrase * found.length,
97
96
  rule: "phrase"
98
- },
99
- brand && !value.toLowerCase().includes(String(brand).toLowerCase()) && {
100
- message: "Nothing here says the mail is from " + brand + " \u2014 subjects that name the sender get opened more.",
101
- points: POINTS.brand,
102
- rule: "brand"
103
97
  }
104
98
  ].filter(Boolean).map((rule) => ({ ...rule, field: "subject" }));
105
99
  };
@@ -131,11 +125,10 @@ var messageRules = ({ message }) => {
131
125
  ].filter(Boolean).map((rule) => ({ ...rule, field: "message" }));
132
126
  };
133
127
  var notificationRules = ({
134
- brand,
135
128
  message,
136
129
  subject
137
130
  } = {}) => [
138
- ...subjectRules({ brand, subject }),
131
+ ...subjectRules({ subject }),
139
132
  ...messageRules({ message })
140
133
  ];
141
134
  var notificationWarnings = (input) => {
@@ -89,15 +89,18 @@ const list = ( values ) => values.map( ( value ) => '“' + value + '”' ).join
89
89
  // Weighted by how reliably each one gets mail binned, and by how likely it is
90
90
  // to be an accident. Shouting and scam phrasing are never accidents. Repeated
91
91
  // punctuation and a currency amount are strong. Emoji and length are habits
92
- // worth a nudge. The brand line is advice about context rather than the
93
- // words — and the default copy every campaign ships with cannot name a brand
94
- // it does not know so it is listed at zero and never marks a default down.
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.
95
99
  //
96
100
  // PHRASES COUNT PER MATCH. "Congratulations, click here, limited time" is
97
101
  // three of them and reads like three of them; one sentence lists them all,
98
102
  // the points say how many. A product table, and Darren's to move.
99
103
  const POINTS = {
100
- brand : 0,
101
104
  currency : 1,
102
105
  emoji : 0.5,
103
106
  length : 0.5,
@@ -106,11 +109,7 @@ const POINTS = {
106
109
  shouting : 1.5
107
110
  };
108
111
 
109
- // `brand` is the organization's own name. A subject that never mentions who is
110
- // writing is the single most recognisable trait of the mail people delete
111
- // unread — and the one a merchant can fix in five seconds once it is pointed
112
- // out. Skipped entirely when no brand is supplied rather than guessed at.
113
- const subjectRules = ({ brand, subject }) => {
112
+ const subjectRules = ({ subject }) => {
114
113
 
115
114
  const value = String( subject || '' ).trim();
116
115
 
@@ -149,11 +148,6 @@ const subjectRules = ({ brand, subject }) => {
149
148
  message : 'Reads like a scam to filters and to people: ' + list( found ) + '.',
150
149
  points : POINTS.phrase * found.length,
151
150
  rule : 'phrase'
152
- },
153
- brand && ! value.toLowerCase().includes( String( brand ).toLowerCase() ) && {
154
- message : 'Nothing here says the mail is from ' + brand + ' — subjects that name the sender get opened more.',
155
- points : POINTS.brand,
156
- rule : 'brand'
157
151
  }
158
152
  ].filter( Boolean ).map( ( rule ) => ({ ...rule, field : 'subject' }) );
159
153
 
@@ -221,11 +215,10 @@ const messageRules = ({ message }) => {
221
215
  // characters to 70. Those belong with the SMS connection work rather than here,
222
216
  // and their absence is a reason to write them, not to stay quiet meanwhile.
223
217
  const notificationRules = ({
224
- brand,
225
218
  message,
226
219
  subject
227
220
  } = {}) => [
228
- ...subjectRules({ brand, subject }),
221
+ ...subjectRules({ subject }),
229
222
  ...messageRules({ message })
230
223
  ];
231
224
 
@@ -89,15 +89,18 @@ const list = ( values ) => values.map( ( value ) => '“' + value + '”' ).join
89
89
  // Weighted by how reliably each one gets mail binned, and by how likely it is
90
90
  // to be an accident. Shouting and scam phrasing are never accidents. Repeated
91
91
  // punctuation and a currency amount are strong. Emoji and length are habits
92
- // worth a nudge. The brand line is advice about context rather than the
93
- // words — and the default copy every campaign ships with cannot name a brand
94
- // it does not know so it is listed at zero and never marks a default down.
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.
95
99
  //
96
100
  // PHRASES COUNT PER MATCH. "Congratulations, click here, limited time" is
97
101
  // three of them and reads like three of them; one sentence lists them all,
98
102
  // the points say how many. A product table, and Darren's to move.
99
103
  const POINTS = {
100
- brand : 0,
101
104
  currency : 1,
102
105
  emoji : 0.5,
103
106
  length : 0.5,
@@ -106,11 +109,7 @@ const POINTS = {
106
109
  shouting : 1.5
107
110
  };
108
111
 
109
- // `brand` is the organization's own name. A subject that never mentions who is
110
- // writing is the single most recognisable trait of the mail people delete
111
- // unread — and the one a merchant can fix in five seconds once it is pointed
112
- // out. Skipped entirely when no brand is supplied rather than guessed at.
113
- const subjectRules = ({ brand, subject }) => {
112
+ const subjectRules = ({ subject }) => {
114
113
 
115
114
  const value = String( subject || '' ).trim();
116
115
 
@@ -149,11 +148,6 @@ const subjectRules = ({ brand, subject }) => {
149
148
  message : 'Reads like a scam to filters and to people: ' + list( found ) + '.',
150
149
  points : POINTS.phrase * found.length,
151
150
  rule : 'phrase'
152
- },
153
- brand && ! value.toLowerCase().includes( String( brand ).toLowerCase() ) && {
154
- message : 'Nothing here says the mail is from ' + brand + ' — subjects that name the sender get opened more.',
155
- points : POINTS.brand,
156
- rule : 'brand'
157
151
  }
158
152
  ].filter( Boolean ).map( ( rule ) => ({ ...rule, field : 'subject' }) );
159
153
 
@@ -221,11 +215,10 @@ const messageRules = ({ message }) => {
221
215
  // characters to 70. Those belong with the SMS connection work rather than here,
222
216
  // and their absence is a reason to write them, not to stay quiet meanwhile.
223
217
  const notificationRules = ({
224
- brand,
225
218
  message,
226
219
  subject
227
220
  } = {}) => [
228
- ...subjectRules({ brand, subject }),
221
+ ...subjectRules({ subject }),
229
222
  ...messageRules({ message })
230
223
  ];
231
224
 
@@ -27,7 +27,6 @@ var phrasesIn = (value) => {
27
27
  };
28
28
  var list = (values) => values.map((value) => "\u201C" + value + "\u201D").join(", ");
29
29
  var POINTS = {
30
- brand: 0,
31
30
  currency: 1,
32
31
  emoji: 0.5,
33
32
  length: 0.5,
@@ -35,7 +34,7 @@ var POINTS = {
35
34
  punctuation: 1,
36
35
  shouting: 1.5
37
36
  };
38
- var subjectRules = ({ brand, subject }) => {
37
+ var subjectRules = ({ subject }) => {
39
38
  const value = String(subject || "").trim();
40
39
  if (!value) return [];
41
40
  const found = phrasesIn(value);
@@ -70,11 +69,6 @@ var subjectRules = ({ brand, subject }) => {
70
69
  message: "Reads like a scam to filters and to people: " + list(found) + ".",
71
70
  points: POINTS.phrase * found.length,
72
71
  rule: "phrase"
73
- },
74
- brand && !value.toLowerCase().includes(String(brand).toLowerCase()) && {
75
- message: "Nothing here says the mail is from " + brand + " \u2014 subjects that name the sender get opened more.",
76
- points: POINTS.brand,
77
- rule: "brand"
78
72
  }
79
73
  ].filter(Boolean).map((rule) => ({ ...rule, field: "subject" }));
80
74
  };
@@ -106,11 +100,10 @@ var messageRules = ({ message }) => {
106
100
  ].filter(Boolean).map((rule) => ({ ...rule, field: "message" }));
107
101
  };
108
102
  var notificationRules = ({
109
- brand,
110
103
  message,
111
104
  subject
112
105
  } = {}) => [
113
- ...subjectRules({ brand, subject }),
106
+ ...subjectRules({ subject }),
114
107
  ...messageRules({ message })
115
108
  ];
116
109
  var notificationWarnings = (input) => {
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.164"
229
+ "version": "0.0.165"
230
230
  }