@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/connections/index.cjs +105 -8
- package/dist/connections/index.d.cts +104 -4
- package/dist/connections/index.d.ts +104 -4
- package/dist/connections/index.js +105 -8
- package/dist/html.cjs +5 -2
- package/dist/html.d.cts +20 -1
- package/dist/html.d.ts +20 -1
- package/dist/html.js +3 -1
- package/dist/index.cjs +34 -1
- package/dist/index.d.cts +34 -1
- package/dist/index.d.ts +34 -1
- package/dist/index.js +34 -1
- package/dist/notification.cjs +84 -20
- package/dist/notification.d.cts +117 -34
- package/dist/notification.d.ts +117 -34
- package/dist/notification.js +82 -20
- package/dist/providers.cjs +137 -17
- package/dist/providers.d.cts +104 -12
- package/dist/providers.d.ts +104 -12
- package/dist/providers.js +133 -16
- package/package.json +1 -1
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: {
|
package/dist/notification.cjs
CHANGED
|
@@ -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
|
|
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 &&
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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 &&
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
133
|
+
var notificationRules = ({
|
|
79
134
|
brand,
|
|
80
135
|
message,
|
|
81
136
|
subject
|
|
82
|
-
} = {}) =>
|
|
83
|
-
|
|
84
|
-
|
|
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
|
});
|
package/dist/notification.d.cts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
//
|
|
132
|
-
//
|
|
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
|
|
223
|
+
const notificationRules = ({
|
|
154
224
|
brand,
|
|
155
225
|
message,
|
|
156
226
|
subject
|
|
157
|
-
} = {}) =>
|
|
158
|
-
|
|
159
|
-
|
|
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 };
|
package/dist/notification.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
//
|
|
132
|
-
//
|
|
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
|
|
223
|
+
const notificationRules = ({
|
|
154
224
|
brand,
|
|
155
225
|
message,
|
|
156
226
|
subject
|
|
157
|
-
} = {}) =>
|
|
158
|
-
|
|
159
|
-
|
|
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 };
|