@drawbridge/drawbridge-utils 0.0.162 → 0.0.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -85,7 +85,40 @@ var constantsData = {
85
85
  notifications : {
86
86
  draw : {
87
87
  subject : 'Your prize is ready to claim',
88
- message : 'You were selected in our giveaway. Follow the link to confirm your details and claim your prize.'
88
+ message : 'You were selected in our giveaway. Follow the link to confirm your details and claim your prize.',
89
+ // THE DEFAULT COPY'S SPAM SCORE, SHIPPED WITH THE COPY.
90
+ //
91
+ // A campaign is seeded from this object, so a score written here is a
92
+ // score every new campaign has from the moment it exists — which is
93
+ // what lets the dashboard's card open with an answer and ask the
94
+ // checker only when a merchant changes the words. Without it the copy
95
+ // arrived unmeasured and the panel had to go and find out, spending a
96
+ // request on a free service to be told what is measured right here.
97
+ //
98
+ // Verbatim what the checker returned for these two strings on
99
+ // 2026-09-10, so a campaign created today and one moved by
100
+ // backfillDrawNotifications carry the same object rather than two
101
+ // shapes of the same fact.
102
+ //
103
+ // The 0.5 is OURS, not the copy's: SpamAssassin charges the sending
104
+ // domain for its missing vowels on every message it will ever score.
105
+ // That is why `subtotal` — the merchant's own share — is what the
106
+ // dashboard reads and what it scores out of five.
107
+ score : {
108
+ rules : [
109
+ { description : 'From: domain has series of non-vowel letters', score : 0.5 },
110
+ { description : 'Informational: message was not relayed via SMTP', score : 0 },
111
+ { description : 'Informational: message has no Received headers', score : 0 }
112
+ ],
113
+ subtotal : 0,
114
+ threshold : 5,
115
+ total : 0.5
116
+ },
117
+ // The plain rendering that rides alongside `message`. Identical to it
118
+ // here because the default carries no markup, and stored rather than
119
+ // derived because the draw notification leaves as email OR as SMS and
120
+ // twilio.sms drops its body in verbatim.
121
+ text : 'You were selected in our giveaway. Follow the link to confirm your details and claim your prize.'
89
122
  }
90
123
  },
91
124
  settings : {
package/dist/index.js CHANGED
@@ -105,7 +105,40 @@ var constants_default = {
105
105
  notifications: {
106
106
  draw: {
107
107
  subject: "Your prize is ready to claim",
108
- message: "You were selected in our giveaway. Follow the link to confirm your details and claim your prize."
108
+ message: "You were selected in our giveaway. Follow the link to confirm your details and claim your prize.",
109
+ // THE DEFAULT COPY'S SPAM SCORE, SHIPPED WITH THE COPY.
110
+ //
111
+ // A campaign is seeded from this object, so a score written here is a
112
+ // score every new campaign has from the moment it exists — which is
113
+ // what lets the dashboard's card open with an answer and ask the
114
+ // checker only when a merchant changes the words. Without it the copy
115
+ // arrived unmeasured and the panel had to go and find out, spending a
116
+ // request on a free service to be told what is measured right here.
117
+ //
118
+ // Verbatim what the checker returned for these two strings on
119
+ // 2026-09-10, so a campaign created today and one moved by
120
+ // backfillDrawNotifications carry the same object rather than two
121
+ // shapes of the same fact.
122
+ //
123
+ // The 0.5 is OURS, not the copy's: SpamAssassin charges the sending
124
+ // domain for its missing vowels on every message it will ever score.
125
+ // That is why `subtotal` — the merchant's own share — is what the
126
+ // dashboard reads and what it scores out of five.
127
+ score: {
128
+ rules: [
129
+ { description: "From: domain has series of non-vowel letters", score: 0.5 },
130
+ { description: "Informational: message was not relayed via SMTP", score: 0 },
131
+ { description: "Informational: message has no Received headers", score: 0 }
132
+ ],
133
+ subtotal: 0,
134
+ threshold: 5,
135
+ total: 0.5
136
+ },
137
+ // The plain rendering that rides alongside `message`. Identical to it
138
+ // here because the default carries no markup, and stored rather than
139
+ // derived because the draw notification leaves as email OR as SMS and
140
+ // twilio.sms drops its body in verbatim.
141
+ text: "You were selected in our giveaway. Follow the link to confirm your details and claim your prize."
109
142
  }
110
143
  },
111
144
  settings: {
@@ -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,103 @@ 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
+ brand: 0,
56
+ currency: 1,
57
+ emoji: 0.5,
58
+ length: 0.5,
59
+ phrase: 1,
60
+ punctuation: 1,
61
+ shouting: 1.5
62
+ };
63
+ var subjectRules = ({ brand, subject }) => {
53
64
  const value = String(subject || "").trim();
54
65
  if (!value) return [];
55
66
  const found = phrasesIn(value);
56
67
  const emojis = emojiCount(value);
57
68
  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);
69
+ value.length > SUBJECT_DISPLAY_LIMIT && {
70
+ message: "Most email apps cut the subject around " + SUBJECT_DISPLAY_LIMIT + " characters \u2014 yours is " + value.length + ".",
71
+ points: POINTS.length,
72
+ rule: "length"
73
+ },
74
+ shouting(value) && {
75
+ message: "Mostly capitals reads as shouting, and filters treat it that way too.",
76
+ points: POINTS.shouting,
77
+ rule: "shouting"
78
+ },
79
+ /[!?]{2,}/.test(value) && {
80
+ message: "Repeated exclamation or question marks are a common spam signal.",
81
+ points: POINTS.punctuation,
82
+ rule: "punctuation"
83
+ },
84
+ emojis > 1 && {
85
+ message: "More than one emoji in a subject is a common spam signal.",
86
+ points: POINTS.emoji,
87
+ rule: "emoji"
88
+ },
89
+ /[$£€]\s?\d/.test(value) && {
90
+ message: "A currency amount in the subject is a common spam signal.",
91
+ points: POINTS.currency,
92
+ rule: "currency"
93
+ },
94
+ found.length > 0 && {
95
+ message: "Reads like a scam to filters and to people: " + list(found) + ".",
96
+ points: POINTS.phrase * found.length,
97
+ 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
+ }
104
+ ].filter(Boolean).map((rule) => ({ ...rule, field: "subject" }));
66
105
  };
67
- var messageWarnings = ({ message }) => {
106
+ var messageRules = ({ message }) => {
68
107
  const value = String(message || "").trim();
69
108
  if (!value) return [];
70
109
  const found = phrasesIn(value);
71
110
  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);
111
+ value.length < MESSAGE_SHORT_LIMIT && {
112
+ message: "Very short messages read as a fragment, and bulk senders send exactly this shape.",
113
+ points: POINTS.length,
114
+ rule: "length"
115
+ },
116
+ shouting(value) && {
117
+ message: "Mostly capitals reads as shouting, and filters treat it that way too.",
118
+ points: POINTS.shouting,
119
+ rule: "shouting"
120
+ },
121
+ /[!?]{2,}/.test(value) && {
122
+ message: "Repeated exclamation or question marks are a common spam signal.",
123
+ points: POINTS.punctuation,
124
+ rule: "punctuation"
125
+ },
126
+ found.length > 0 && {
127
+ message: "Reads like a scam to filters and to people: " + list(found) + ".",
128
+ points: POINTS.phrase * found.length,
129
+ rule: "phrase"
130
+ }
131
+ ].filter(Boolean).map((rule) => ({ ...rule, field: "message" }));
77
132
  };
78
- var notificationWarnings = ({
133
+ var notificationRules = ({
79
134
  brand,
80
135
  message,
81
136
  subject
82
- } = {}) => ({
83
- message: messageWarnings({ message }),
84
- subject: subjectWarnings({ brand, subject })
85
- });
137
+ } = {}) => [
138
+ ...subjectRules({ brand, subject }),
139
+ ...messageRules({ message })
140
+ ];
141
+ var notificationWarnings = (input) => {
142
+ const rules = notificationRules(input);
143
+ return {
144
+ message: rules.filter((rule) => rule.field === "message").map((rule) => rule.message),
145
+ subject: rules.filter((rule) => rule.field === "subject").map((rule) => rule.message)
146
+ };
147
+ };
86
148
  // Annotate the CommonJS export names for ESM import in node:
87
149
  0 && (module.exports = {
150
+ POINTS,
151
+ notificationRules,
88
152
  notificationWarnings
89
153
  });
@@ -75,11 +75,42 @@ const phrasesIn = ( value ) => {
75
75
 
76
76
  const list = ( values ) => values.map( ( value ) => '“' + value + '”' ).join( ', ' );
77
77
 
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. 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.
95
+ //
96
+ // PHRASES COUNT PER MATCH. "Congratulations, click here, limited time" is
97
+ // three of them and reads like three of them; one sentence lists them all,
98
+ // the points say how many. A product table, and Darren's to move.
99
+ const POINTS = {
100
+ brand : 0,
101
+ currency : 1,
102
+ emoji : 0.5,
103
+ length : 0.5,
104
+ phrase : 1,
105
+ punctuation : 1,
106
+ shouting : 1.5
107
+ };
108
+
78
109
  // `brand` is the organization's own name. A subject that never mentions who is
79
110
  // writing is the single most recognisable trait of the mail people delete
80
111
  // unread — and the one a merchant can fix in five seconds once it is pointed
81
112
  // out. Skipped entirely when no brand is supplied rather than guessed at.
82
- const subjectWarnings = ({ brand, subject }) => {
113
+ const subjectRules = ({ brand, subject }) => {
83
114
 
84
115
  const value = String( subject || '' ).trim();
85
116
 
@@ -89,25 +120,46 @@ const subjectWarnings = ({ brand, subject }) => {
89
120
  const emojis = emojiCount( value );
90
121
 
91
122
  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 );
123
+ value.length > SUBJECT_DISPLAY_LIMIT && {
124
+ message : 'Most email apps cut the subject around ' + SUBJECT_DISPLAY_LIMIT + ' characters — yours is ' + value.length + '.',
125
+ points : POINTS.length,
126
+ rule : 'length'
127
+ },
128
+ shouting( value ) && {
129
+ message : 'Mostly capitals reads as shouting, and filters treat it that way too.',
130
+ points : POINTS.shouting,
131
+ rule : 'shouting'
132
+ },
133
+ /[!?]{2,}/.test( value ) && {
134
+ message : 'Repeated exclamation or question marks are a common spam signal.',
135
+ points : POINTS.punctuation,
136
+ rule : 'punctuation'
137
+ },
138
+ emojis > 1 && {
139
+ message : 'More than one emoji in a subject is a common spam signal.',
140
+ points : POINTS.emoji,
141
+ rule : 'emoji'
142
+ },
143
+ /[$£€]\s?\d/.test( value ) && {
144
+ message : 'A currency amount in the subject is a common spam signal.',
145
+ points : POINTS.currency,
146
+ rule : 'currency'
147
+ },
148
+ found.length > 0 && {
149
+ message : 'Reads like a scam to filters and to people: ' + list( found ) + '.',
150
+ points : POINTS.phrase * found.length,
151
+ 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
+ }
158
+ ].filter( Boolean ).map( ( rule ) => ({ ...rule, field : 'subject' }) );
107
159
 
108
160
  };
109
161
 
110
- const messageWarnings = ({ message }) => {
162
+ const messageRules = ({ message }) => {
111
163
 
112
164
  const value = String( message || '' ).trim();
113
165
 
@@ -116,20 +168,38 @@ const messageWarnings = ({ message }) => {
116
168
  const found = phrasesIn( value );
117
169
 
118
170
  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 );
171
+ value.length < MESSAGE_SHORT_LIMIT && {
172
+ message : 'Very short messages read as a fragment, and bulk senders send exactly this shape.',
173
+ points : POINTS.length,
174
+ rule : 'length'
175
+ },
176
+ shouting( value ) && {
177
+ message : 'Mostly capitals reads as shouting, and filters treat it that way too.',
178
+ points : POINTS.shouting,
179
+ rule : 'shouting'
180
+ },
181
+ /[!?]{2,}/.test( value ) && {
182
+ message : 'Repeated exclamation or question marks are a common spam signal.',
183
+ points : POINTS.punctuation,
184
+ rule : 'punctuation'
185
+ },
186
+ found.length > 0 && {
187
+ message : 'Reads like a scam to filters and to people: ' + list( found ) + '.',
188
+ points : POINTS.phrase * found.length,
189
+ rule : 'phrase'
190
+ }
191
+ ].filter( Boolean ).map( ( rule ) => ({ ...rule, field : 'message' }) );
128
192
 
129
193
  };
130
194
 
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.
195
+ // THE RULES, WITH THEIR WEIGHT: `[ { field, message, points, rule } ]`, subject
196
+ // first. This is what the api's scorer folds into the spam score beside
197
+ // SpamAssassin's own rules, so the sentence a merchant reads and the points
198
+ // it cost are one object.
199
+ //
200
+ // The same rule tripped in both fields comes back twice, once per field, each
201
+ // with its points. Whether that is one habit or two is the scorer's call to
202
+ // make; here the facts are just reported.
133
203
  //
134
204
  // NO CHANNEL ARGUMENT, deliberately, and it is worth saying why since one was
135
205
  // tried twice.
@@ -150,13 +220,26 @@ const messageWarnings = ({ message }) => {
150
220
  // wording, and the segment mechanics where one emoji drops the limit from 160
151
221
  // characters to 70. Those belong with the SMS connection work rather than here,
152
222
  // and their absence is a reason to write them, not to stay quiet meanwhile.
153
- const notificationWarnings = ({
223
+ const notificationRules = ({
154
224
  brand,
155
225
  message,
156
226
  subject
157
- } = {}) => ({
158
- message : messageWarnings({ message }),
159
- subject : subjectWarnings({ brand, subject })
160
- });
227
+ } = {}) => [
228
+ ...subjectRules({ brand, subject }),
229
+ ...messageRules({ message })
230
+ ];
231
+
232
+ // The sentences alone, per field — what the rules looked like before they had
233
+ // points. Kept for anything that only wants the words.
234
+ const notificationWarnings = ( input ) => {
235
+
236
+ const rules = notificationRules( input );
237
+
238
+ return {
239
+ message : rules.filter( ( rule ) => rule.field === 'message' ).map( ( rule ) => rule.message ),
240
+ subject : rules.filter( ( rule ) => rule.field === 'subject' ).map( ( rule ) => rule.message )
241
+ };
242
+
243
+ };
161
244
 
162
- export { notificationWarnings };
245
+ export { POINTS, notificationRules, notificationWarnings };
@@ -75,11 +75,42 @@ const phrasesIn = ( value ) => {
75
75
 
76
76
  const list = ( values ) => values.map( ( value ) => '“' + value + '”' ).join( ', ' );
77
77
 
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. 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.
95
+ //
96
+ // PHRASES COUNT PER MATCH. "Congratulations, click here, limited time" is
97
+ // three of them and reads like three of them; one sentence lists them all,
98
+ // the points say how many. A product table, and Darren's to move.
99
+ const POINTS = {
100
+ brand : 0,
101
+ currency : 1,
102
+ emoji : 0.5,
103
+ length : 0.5,
104
+ phrase : 1,
105
+ punctuation : 1,
106
+ shouting : 1.5
107
+ };
108
+
78
109
  // `brand` is the organization's own name. A subject that never mentions who is
79
110
  // writing is the single most recognisable trait of the mail people delete
80
111
  // unread — and the one a merchant can fix in five seconds once it is pointed
81
112
  // out. Skipped entirely when no brand is supplied rather than guessed at.
82
- const subjectWarnings = ({ brand, subject }) => {
113
+ const subjectRules = ({ brand, subject }) => {
83
114
 
84
115
  const value = String( subject || '' ).trim();
85
116
 
@@ -89,25 +120,46 @@ const subjectWarnings = ({ brand, subject }) => {
89
120
  const emojis = emojiCount( value );
90
121
 
91
122
  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 );
123
+ value.length > SUBJECT_DISPLAY_LIMIT && {
124
+ message : 'Most email apps cut the subject around ' + SUBJECT_DISPLAY_LIMIT + ' characters — yours is ' + value.length + '.',
125
+ points : POINTS.length,
126
+ rule : 'length'
127
+ },
128
+ shouting( value ) && {
129
+ message : 'Mostly capitals reads as shouting, and filters treat it that way too.',
130
+ points : POINTS.shouting,
131
+ rule : 'shouting'
132
+ },
133
+ /[!?]{2,}/.test( value ) && {
134
+ message : 'Repeated exclamation or question marks are a common spam signal.',
135
+ points : POINTS.punctuation,
136
+ rule : 'punctuation'
137
+ },
138
+ emojis > 1 && {
139
+ message : 'More than one emoji in a subject is a common spam signal.',
140
+ points : POINTS.emoji,
141
+ rule : 'emoji'
142
+ },
143
+ /[$£€]\s?\d/.test( value ) && {
144
+ message : 'A currency amount in the subject is a common spam signal.',
145
+ points : POINTS.currency,
146
+ rule : 'currency'
147
+ },
148
+ found.length > 0 && {
149
+ message : 'Reads like a scam to filters and to people: ' + list( found ) + '.',
150
+ points : POINTS.phrase * found.length,
151
+ 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
+ }
158
+ ].filter( Boolean ).map( ( rule ) => ({ ...rule, field : 'subject' }) );
107
159
 
108
160
  };
109
161
 
110
- const messageWarnings = ({ message }) => {
162
+ const messageRules = ({ message }) => {
111
163
 
112
164
  const value = String( message || '' ).trim();
113
165
 
@@ -116,20 +168,38 @@ const messageWarnings = ({ message }) => {
116
168
  const found = phrasesIn( value );
117
169
 
118
170
  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 );
171
+ value.length < MESSAGE_SHORT_LIMIT && {
172
+ message : 'Very short messages read as a fragment, and bulk senders send exactly this shape.',
173
+ points : POINTS.length,
174
+ rule : 'length'
175
+ },
176
+ shouting( value ) && {
177
+ message : 'Mostly capitals reads as shouting, and filters treat it that way too.',
178
+ points : POINTS.shouting,
179
+ rule : 'shouting'
180
+ },
181
+ /[!?]{2,}/.test( value ) && {
182
+ message : 'Repeated exclamation or question marks are a common spam signal.',
183
+ points : POINTS.punctuation,
184
+ rule : 'punctuation'
185
+ },
186
+ found.length > 0 && {
187
+ message : 'Reads like a scam to filters and to people: ' + list( found ) + '.',
188
+ points : POINTS.phrase * found.length,
189
+ rule : 'phrase'
190
+ }
191
+ ].filter( Boolean ).map( ( rule ) => ({ ...rule, field : 'message' }) );
128
192
 
129
193
  };
130
194
 
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.
195
+ // THE RULES, WITH THEIR WEIGHT: `[ { field, message, points, rule } ]`, subject
196
+ // first. This is what the api's scorer folds into the spam score beside
197
+ // SpamAssassin's own rules, so the sentence a merchant reads and the points
198
+ // it cost are one object.
199
+ //
200
+ // The same rule tripped in both fields comes back twice, once per field, each
201
+ // with its points. Whether that is one habit or two is the scorer's call to
202
+ // make; here the facts are just reported.
133
203
  //
134
204
  // NO CHANNEL ARGUMENT, deliberately, and it is worth saying why since one was
135
205
  // tried twice.
@@ -150,13 +220,26 @@ const messageWarnings = ({ message }) => {
150
220
  // wording, and the segment mechanics where one emoji drops the limit from 160
151
221
  // characters to 70. Those belong with the SMS connection work rather than here,
152
222
  // and their absence is a reason to write them, not to stay quiet meanwhile.
153
- const notificationWarnings = ({
223
+ const notificationRules = ({
154
224
  brand,
155
225
  message,
156
226
  subject
157
- } = {}) => ({
158
- message : messageWarnings({ message }),
159
- subject : subjectWarnings({ brand, subject })
160
- });
227
+ } = {}) => [
228
+ ...subjectRules({ brand, subject }),
229
+ ...messageRules({ message })
230
+ ];
231
+
232
+ // The sentences alone, per field — what the rules looked like before they had
233
+ // points. Kept for anything that only wants the words.
234
+ const notificationWarnings = ( input ) => {
235
+
236
+ const rules = notificationRules( input );
237
+
238
+ return {
239
+ message : rules.filter( ( rule ) => rule.field === 'message' ).map( ( rule ) => rule.message ),
240
+ subject : rules.filter( ( rule ) => rule.field === 'subject' ).map( ( rule ) => rule.message )
241
+ };
242
+
243
+ };
161
244
 
162
- export { notificationWarnings };
245
+ export { POINTS, notificationRules, notificationWarnings };