@drawbridge/drawbridge-utils 0.0.158 → 0.0.160
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 +29 -17
- package/dist/connections/index.d.cts +23 -10
- package/dist/connections/index.d.ts +23 -10
- package/dist/connections/index.js +27 -15
- package/dist/index.cjs +30 -4
- package/dist/index.d.cts +19 -6
- package/dist/index.d.ts +19 -6
- package/dist/index.js +30 -4
- package/dist/nanoid.cjs +11 -1
- package/dist/nanoid.d.cts +41 -1
- package/dist/nanoid.d.ts +41 -1
- package/dist/nanoid.js +11 -1
- package/dist/notification.cjs +93 -0
- package/dist/notification.d.cts +155 -0
- package/dist/notification.d.ts +155 -0
- package/dist/notification.js +69 -0
- package/dist/plans.cjs +4 -2
- package/dist/plans.d.cts +2 -1
- package/dist/plans.d.ts +2 -1
- package/dist/plans.js +4 -2
- package/dist/pricing.cjs +4 -2
- package/dist/pricing.d.cts +2 -1
- package/dist/pricing.d.ts +2 -1
- package/dist/pricing.js +4 -2
- package/dist/providers.cjs +31 -19
- package/dist/providers.d.cts +2 -1
- package/dist/providers.d.ts +2 -1
- package/dist/providers.js +29 -17
- package/package.json +6 -1
package/dist/nanoid.d.cts
CHANGED
|
@@ -11,7 +11,47 @@ import { customAlphabet } from 'nanoid';
|
|
|
11
11
|
// New code should prefer this subpath, which also exposes `retry`:
|
|
12
12
|
// const { nanoid, retry } = require( '@drawbridge/drawbridge-utils/nanoid' );
|
|
13
13
|
|
|
14
|
-
const
|
|
14
|
+
const generate = customAlphabet( '0123456789abcdefghijklmnopqrstuvwxyz', 8 );
|
|
15
|
+
|
|
16
|
+
// SpamAssassin's FROM_LOCAL_NOVOWEL scores any From address whose LOCAL PART
|
|
17
|
+
// carries seven consecutive letters from this class:
|
|
18
|
+
//
|
|
19
|
+
// header FROM_LOCAL_NOVOWEL From =~ /[bcdfgjklmnpqrstvwxz]{7}\S*\@/i
|
|
20
|
+
//
|
|
21
|
+
// organization.shortId becomes exactly that: lead mail sends from
|
|
22
|
+
// org+<shortId>@<sending domain> (lib/email.js subAddress). Nineteen of the
|
|
23
|
+
// thirty-six characters in the alphabet above are in the class, so a run of
|
|
24
|
+
// seven inside eight characters is not rare — measured at 1.67% of generated
|
|
25
|
+
// ids, which is roughly one organization in sixty penalised on every lead email
|
|
26
|
+
// it will ever send, decided by nothing but the draw.
|
|
27
|
+
//
|
|
28
|
+
// Rejecting those costs a re-roll on 1.7% of calls and a slice of the keyspace
|
|
29
|
+
// far too small to matter against 36⁸. Applied to EVERY nanoid rather than only
|
|
30
|
+
// the organization's: the rest become slugs in urls where the rule is
|
|
31
|
+
// irrelevant and the cost is nil, and doing it here means no future caller can
|
|
32
|
+
// put an id in an address and quietly reintroduce this.
|
|
33
|
+
//
|
|
34
|
+
// Existing ids are untouched — this only shapes new ones.
|
|
35
|
+
const NOVOWEL = /[bcdfgjklmnpqrstvwxz]{7}/i;
|
|
36
|
+
|
|
37
|
+
// Bounded rather than looping until clean. At a 1.67% rejection rate the odds
|
|
38
|
+
// of ten consecutive rejections are about 1 in 10^18, so the cap is never
|
|
39
|
+
// reached — but a generator that CANNOT hang is worth more than one that is
|
|
40
|
+
// merely very unlikely to, and returning a slightly penalised id beats spinning.
|
|
41
|
+
const ATTEMPTS = 10;
|
|
42
|
+
|
|
43
|
+
const nanoid = () => {
|
|
44
|
+
|
|
45
|
+
let value = generate();
|
|
46
|
+
|
|
47
|
+
for( let attempt = 1; attempt < ATTEMPTS && NOVOWEL.test( value ); attempt++ ){
|
|
48
|
+
|
|
49
|
+
value = generate();
|
|
50
|
+
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
|
|
54
|
+
};
|
|
15
55
|
|
|
16
56
|
// Retry an async operation when Mongo rejects an insert because of a
|
|
17
57
|
// duplicate-key error on a specific field. The operation receives a fresh
|
package/dist/nanoid.d.ts
CHANGED
|
@@ -11,7 +11,47 @@ import { customAlphabet } from 'nanoid';
|
|
|
11
11
|
// New code should prefer this subpath, which also exposes `retry`:
|
|
12
12
|
// const { nanoid, retry } = require( '@drawbridge/drawbridge-utils/nanoid' );
|
|
13
13
|
|
|
14
|
-
const
|
|
14
|
+
const generate = customAlphabet( '0123456789abcdefghijklmnopqrstuvwxyz', 8 );
|
|
15
|
+
|
|
16
|
+
// SpamAssassin's FROM_LOCAL_NOVOWEL scores any From address whose LOCAL PART
|
|
17
|
+
// carries seven consecutive letters from this class:
|
|
18
|
+
//
|
|
19
|
+
// header FROM_LOCAL_NOVOWEL From =~ /[bcdfgjklmnpqrstvwxz]{7}\S*\@/i
|
|
20
|
+
//
|
|
21
|
+
// organization.shortId becomes exactly that: lead mail sends from
|
|
22
|
+
// org+<shortId>@<sending domain> (lib/email.js subAddress). Nineteen of the
|
|
23
|
+
// thirty-six characters in the alphabet above are in the class, so a run of
|
|
24
|
+
// seven inside eight characters is not rare — measured at 1.67% of generated
|
|
25
|
+
// ids, which is roughly one organization in sixty penalised on every lead email
|
|
26
|
+
// it will ever send, decided by nothing but the draw.
|
|
27
|
+
//
|
|
28
|
+
// Rejecting those costs a re-roll on 1.7% of calls and a slice of the keyspace
|
|
29
|
+
// far too small to matter against 36⁸. Applied to EVERY nanoid rather than only
|
|
30
|
+
// the organization's: the rest become slugs in urls where the rule is
|
|
31
|
+
// irrelevant and the cost is nil, and doing it here means no future caller can
|
|
32
|
+
// put an id in an address and quietly reintroduce this.
|
|
33
|
+
//
|
|
34
|
+
// Existing ids are untouched — this only shapes new ones.
|
|
35
|
+
const NOVOWEL = /[bcdfgjklmnpqrstvwxz]{7}/i;
|
|
36
|
+
|
|
37
|
+
// Bounded rather than looping until clean. At a 1.67% rejection rate the odds
|
|
38
|
+
// of ten consecutive rejections are about 1 in 10^18, so the cap is never
|
|
39
|
+
// reached — but a generator that CANNOT hang is worth more than one that is
|
|
40
|
+
// merely very unlikely to, and returning a slightly penalised id beats spinning.
|
|
41
|
+
const ATTEMPTS = 10;
|
|
42
|
+
|
|
43
|
+
const nanoid = () => {
|
|
44
|
+
|
|
45
|
+
let value = generate();
|
|
46
|
+
|
|
47
|
+
for( let attempt = 1; attempt < ATTEMPTS && NOVOWEL.test( value ); attempt++ ){
|
|
48
|
+
|
|
49
|
+
value = generate();
|
|
50
|
+
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
|
|
54
|
+
};
|
|
15
55
|
|
|
16
56
|
// Retry an async operation when Mongo rejects an insert because of a
|
|
17
57
|
// duplicate-key error on a specific field. The operation receives a fresh
|
package/dist/nanoid.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
// lib/nanoid.js
|
|
2
2
|
import { customAlphabet } from "nanoid";
|
|
3
|
-
var
|
|
3
|
+
var generate = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 8);
|
|
4
|
+
var NOVOWEL = /[bcdfgjklmnpqrstvwxz]{7}/i;
|
|
5
|
+
var ATTEMPTS = 10;
|
|
6
|
+
var nanoid = () => {
|
|
7
|
+
let value = generate();
|
|
8
|
+
for (let attempt = 1; attempt < ATTEMPTS && NOVOWEL.test(value); attempt++) {
|
|
9
|
+
value = generate();
|
|
10
|
+
}
|
|
11
|
+
;
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
4
14
|
var DEFAULT_MAX_RETRIES = 5;
|
|
5
15
|
var retry = async ({
|
|
6
16
|
field,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
8
|
+
};
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
18
|
+
|
|
19
|
+
// lib/notification.js
|
|
20
|
+
var notification_exports = {};
|
|
21
|
+
__export(notification_exports, {
|
|
22
|
+
notificationWarnings: () => notificationWarnings
|
|
23
|
+
});
|
|
24
|
+
module.exports = __toCommonJS(notification_exports);
|
|
25
|
+
var PHRASES = [
|
|
26
|
+
"act now",
|
|
27
|
+
"apply now",
|
|
28
|
+
"click here",
|
|
29
|
+
"congratulations",
|
|
30
|
+
"limited time",
|
|
31
|
+
"no obligation",
|
|
32
|
+
"once in a lifetime",
|
|
33
|
+
"risk free",
|
|
34
|
+
"this is not a scam",
|
|
35
|
+
"urgent",
|
|
36
|
+
"you have been selected"
|
|
37
|
+
];
|
|
38
|
+
var SUBJECT_DISPLAY_LIMIT = 60;
|
|
39
|
+
var MESSAGE_SHORT_LIMIT = 20;
|
|
40
|
+
var emojiCount = (value) => (value.match(new RegExp("\\p{Extended_Pictographic}", "gu")) || []).length;
|
|
41
|
+
var shouting = (value) => {
|
|
42
|
+
const letters = value.replace(/[^A-Za-z]/g, "");
|
|
43
|
+
if (letters.length < 4) return false;
|
|
44
|
+
const upper = letters.replace(/[^A-Z]/g, "");
|
|
45
|
+
return upper.length / letters.length > 0.7;
|
|
46
|
+
};
|
|
47
|
+
var phrasesIn = (value) => {
|
|
48
|
+
const lower = value.toLowerCase();
|
|
49
|
+
return PHRASES.filter((phrase) => lower.includes(phrase));
|
|
50
|
+
};
|
|
51
|
+
var list = (values) => values.map((value) => "\u201C" + value + "\u201D").join(", ");
|
|
52
|
+
var subjectWarnings = ({ brand, subject }) => {
|
|
53
|
+
const value = String(subject || "").trim();
|
|
54
|
+
if (!value) return [];
|
|
55
|
+
const found = phrasesIn(value);
|
|
56
|
+
const emojis = emojiCount(value);
|
|
57
|
+
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);
|
|
66
|
+
};
|
|
67
|
+
var messageWarnings = ({ message }) => {
|
|
68
|
+
const value = String(message || "").trim();
|
|
69
|
+
if (!value) return [];
|
|
70
|
+
const found = phrasesIn(value);
|
|
71
|
+
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);
|
|
77
|
+
};
|
|
78
|
+
var notificationWarnings = ({
|
|
79
|
+
brand,
|
|
80
|
+
channel = "email",
|
|
81
|
+
message,
|
|
82
|
+
subject
|
|
83
|
+
} = {}) => {
|
|
84
|
+
if (channel !== "email") return { message: [], subject: [] };
|
|
85
|
+
return {
|
|
86
|
+
message: messageWarnings({ message }),
|
|
87
|
+
subject: subjectWarnings({ brand, subject })
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
91
|
+
0 && (module.exports = {
|
|
92
|
+
notificationWarnings
|
|
93
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Warnings about the copy a merchant writes into a notification — the subject
|
|
2
|
+
// and message pair behind every lead-facing send.
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS IS NOT: a deliverability prediction. Gmail accepts mail and then
|
|
5
|
+
// decides where to file it using reputation and engagement signals no local
|
|
6
|
+
// check can see — a send can score perfectly here and still land in Promotions.
|
|
7
|
+
// Anyone reading a clean result as "this will reach the inbox" has been misled,
|
|
8
|
+
// so nothing here returns a score or a pass. It returns sentences a person can
|
|
9
|
+
// act on, or nothing at all.
|
|
10
|
+
//
|
|
11
|
+
// WHAT IT IS FOR: the own-goals. A merchant swapping one weak default for a
|
|
12
|
+
// worse one, in ALL CAPS, with three exclamation marks. That matters more here
|
|
13
|
+
// than on most platforms because lead mail sends from a SHARED domain
|
|
14
|
+
// (org+<shortId>@send.…), so one merchant's copy is spent out of every other
|
|
15
|
+
// tenant's reputation. A merchant on their own verified domain is spending only
|
|
16
|
+
// their own.
|
|
17
|
+
//
|
|
18
|
+
// Every rule below is a WARNING, never an error. False positives are certain —
|
|
19
|
+
// a giveaway platform's mail legitimately says "you won" — and blocking a
|
|
20
|
+
// merchant's own words over a heuristic is worse than the heuristic is good.
|
|
21
|
+
|
|
22
|
+
// Deliberately short, and deliberately NOT the usual spam-word list. This
|
|
23
|
+
// product's mail is about prizes: "winner", "free" and "claim" are the domain's
|
|
24
|
+
// own vocabulary and flagging them would fire on every campaign, which teaches
|
|
25
|
+
// people to ignore the warnings entirely. What is left is the scam-SHAPED
|
|
26
|
+
// phrasing — vague, urgent, and naming no brand — which is exactly what a
|
|
27
|
+
// merchant reaches for when they have nothing better to say.
|
|
28
|
+
const PHRASES = [
|
|
29
|
+
'act now',
|
|
30
|
+
'apply now',
|
|
31
|
+
'click here',
|
|
32
|
+
'congratulations',
|
|
33
|
+
'limited time',
|
|
34
|
+
'no obligation',
|
|
35
|
+
'once in a lifetime',
|
|
36
|
+
'risk free',
|
|
37
|
+
'this is not a scam',
|
|
38
|
+
'urgent',
|
|
39
|
+
'you have been selected'
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Most clients cut the subject around here, and a mobile list cuts it sooner.
|
|
43
|
+
// Not a spam rule at all — a legibility one — but it belongs in the same place
|
|
44
|
+
// because it is the same question a merchant is asking when they type.
|
|
45
|
+
const SUBJECT_DISPLAY_LIMIT = 60;
|
|
46
|
+
|
|
47
|
+
// Under this, a body reads as a fragment rather than a message. Short bodies
|
|
48
|
+
// with a link are also the shape bulk senders use, so it is worth a nudge.
|
|
49
|
+
const MESSAGE_SHORT_LIMIT = 20;
|
|
50
|
+
|
|
51
|
+
const emojiCount = ( value ) => ( value.match( /\p{Extended_Pictographic}/gu ) || [] ).length;
|
|
52
|
+
|
|
53
|
+
// Shouting is measured over LETTERS, not characters: "WIN A $500 GIFT CARD" is
|
|
54
|
+
// mostly non-letters, and counting those would let a genuinely shouted subject
|
|
55
|
+
// slip under any sensible ratio.
|
|
56
|
+
const shouting = ( value ) => {
|
|
57
|
+
|
|
58
|
+
const letters = value.replace( /[^A-Za-z]/g, '' );
|
|
59
|
+
|
|
60
|
+
if( letters.length < 4 ) return false;
|
|
61
|
+
|
|
62
|
+
const upper = letters.replace( /[^A-Z]/g, '' );
|
|
63
|
+
|
|
64
|
+
return ( upper.length / letters.length ) > 0.7;
|
|
65
|
+
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const phrasesIn = ( value ) => {
|
|
69
|
+
|
|
70
|
+
const lower = value.toLowerCase();
|
|
71
|
+
|
|
72
|
+
return PHRASES.filter( ( phrase ) => lower.includes( phrase ) );
|
|
73
|
+
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const list = ( values ) => values.map( ( value ) => '“' + value + '”' ).join( ', ' );
|
|
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 }) => {
|
|
83
|
+
|
|
84
|
+
const value = String( subject || '' ).trim();
|
|
85
|
+
|
|
86
|
+
if( ! value ) return [];
|
|
87
|
+
|
|
88
|
+
const found = phrasesIn( value );
|
|
89
|
+
const emojis = emojiCount( value );
|
|
90
|
+
|
|
91
|
+
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 );
|
|
107
|
+
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const messageWarnings = ({ message }) => {
|
|
111
|
+
|
|
112
|
+
const value = String( message || '' ).trim();
|
|
113
|
+
|
|
114
|
+
if( ! value ) return [];
|
|
115
|
+
|
|
116
|
+
const found = phrasesIn( value );
|
|
117
|
+
|
|
118
|
+
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 );
|
|
128
|
+
|
|
129
|
+
};
|
|
130
|
+
|
|
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.
|
|
133
|
+
//
|
|
134
|
+
// `channel` is accepted and only 'email' is implemented. SMS is a genuinely
|
|
135
|
+
// different problem — carriers filter on SHAFT categories, link shorteners and
|
|
136
|
+
// opt-out wording, none of which these rules look at — and returning email
|
|
137
|
+
// advice for an SMS would be confidently wrong. It returns nothing instead,
|
|
138
|
+
// until someone writes the carrier rules properly.
|
|
139
|
+
const notificationWarnings = ({
|
|
140
|
+
brand,
|
|
141
|
+
channel = 'email',
|
|
142
|
+
message,
|
|
143
|
+
subject
|
|
144
|
+
} = {}) => {
|
|
145
|
+
|
|
146
|
+
if( channel !== 'email' ) return { message : [], subject : [] };
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
message : messageWarnings({ message }),
|
|
150
|
+
subject : subjectWarnings({ brand, subject })
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export { notificationWarnings };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Warnings about the copy a merchant writes into a notification — the subject
|
|
2
|
+
// and message pair behind every lead-facing send.
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS IS NOT: a deliverability prediction. Gmail accepts mail and then
|
|
5
|
+
// decides where to file it using reputation and engagement signals no local
|
|
6
|
+
// check can see — a send can score perfectly here and still land in Promotions.
|
|
7
|
+
// Anyone reading a clean result as "this will reach the inbox" has been misled,
|
|
8
|
+
// so nothing here returns a score or a pass. It returns sentences a person can
|
|
9
|
+
// act on, or nothing at all.
|
|
10
|
+
//
|
|
11
|
+
// WHAT IT IS FOR: the own-goals. A merchant swapping one weak default for a
|
|
12
|
+
// worse one, in ALL CAPS, with three exclamation marks. That matters more here
|
|
13
|
+
// than on most platforms because lead mail sends from a SHARED domain
|
|
14
|
+
// (org+<shortId>@send.…), so one merchant's copy is spent out of every other
|
|
15
|
+
// tenant's reputation. A merchant on their own verified domain is spending only
|
|
16
|
+
// their own.
|
|
17
|
+
//
|
|
18
|
+
// Every rule below is a WARNING, never an error. False positives are certain —
|
|
19
|
+
// a giveaway platform's mail legitimately says "you won" — and blocking a
|
|
20
|
+
// merchant's own words over a heuristic is worse than the heuristic is good.
|
|
21
|
+
|
|
22
|
+
// Deliberately short, and deliberately NOT the usual spam-word list. This
|
|
23
|
+
// product's mail is about prizes: "winner", "free" and "claim" are the domain's
|
|
24
|
+
// own vocabulary and flagging them would fire on every campaign, which teaches
|
|
25
|
+
// people to ignore the warnings entirely. What is left is the scam-SHAPED
|
|
26
|
+
// phrasing — vague, urgent, and naming no brand — which is exactly what a
|
|
27
|
+
// merchant reaches for when they have nothing better to say.
|
|
28
|
+
const PHRASES = [
|
|
29
|
+
'act now',
|
|
30
|
+
'apply now',
|
|
31
|
+
'click here',
|
|
32
|
+
'congratulations',
|
|
33
|
+
'limited time',
|
|
34
|
+
'no obligation',
|
|
35
|
+
'once in a lifetime',
|
|
36
|
+
'risk free',
|
|
37
|
+
'this is not a scam',
|
|
38
|
+
'urgent',
|
|
39
|
+
'you have been selected'
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Most clients cut the subject around here, and a mobile list cuts it sooner.
|
|
43
|
+
// Not a spam rule at all — a legibility one — but it belongs in the same place
|
|
44
|
+
// because it is the same question a merchant is asking when they type.
|
|
45
|
+
const SUBJECT_DISPLAY_LIMIT = 60;
|
|
46
|
+
|
|
47
|
+
// Under this, a body reads as a fragment rather than a message. Short bodies
|
|
48
|
+
// with a link are also the shape bulk senders use, so it is worth a nudge.
|
|
49
|
+
const MESSAGE_SHORT_LIMIT = 20;
|
|
50
|
+
|
|
51
|
+
const emojiCount = ( value ) => ( value.match( /\p{Extended_Pictographic}/gu ) || [] ).length;
|
|
52
|
+
|
|
53
|
+
// Shouting is measured over LETTERS, not characters: "WIN A $500 GIFT CARD" is
|
|
54
|
+
// mostly non-letters, and counting those would let a genuinely shouted subject
|
|
55
|
+
// slip under any sensible ratio.
|
|
56
|
+
const shouting = ( value ) => {
|
|
57
|
+
|
|
58
|
+
const letters = value.replace( /[^A-Za-z]/g, '' );
|
|
59
|
+
|
|
60
|
+
if( letters.length < 4 ) return false;
|
|
61
|
+
|
|
62
|
+
const upper = letters.replace( /[^A-Z]/g, '' );
|
|
63
|
+
|
|
64
|
+
return ( upper.length / letters.length ) > 0.7;
|
|
65
|
+
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const phrasesIn = ( value ) => {
|
|
69
|
+
|
|
70
|
+
const lower = value.toLowerCase();
|
|
71
|
+
|
|
72
|
+
return PHRASES.filter( ( phrase ) => lower.includes( phrase ) );
|
|
73
|
+
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const list = ( values ) => values.map( ( value ) => '“' + value + '”' ).join( ', ' );
|
|
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 }) => {
|
|
83
|
+
|
|
84
|
+
const value = String( subject || '' ).trim();
|
|
85
|
+
|
|
86
|
+
if( ! value ) return [];
|
|
87
|
+
|
|
88
|
+
const found = phrasesIn( value );
|
|
89
|
+
const emojis = emojiCount( value );
|
|
90
|
+
|
|
91
|
+
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 );
|
|
107
|
+
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const messageWarnings = ({ message }) => {
|
|
111
|
+
|
|
112
|
+
const value = String( message || '' ).trim();
|
|
113
|
+
|
|
114
|
+
if( ! value ) return [];
|
|
115
|
+
|
|
116
|
+
const found = phrasesIn( value );
|
|
117
|
+
|
|
118
|
+
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 );
|
|
128
|
+
|
|
129
|
+
};
|
|
130
|
+
|
|
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.
|
|
133
|
+
//
|
|
134
|
+
// `channel` is accepted and only 'email' is implemented. SMS is a genuinely
|
|
135
|
+
// different problem — carriers filter on SHAFT categories, link shorteners and
|
|
136
|
+
// opt-out wording, none of which these rules look at — and returning email
|
|
137
|
+
// advice for an SMS would be confidently wrong. It returns nothing instead,
|
|
138
|
+
// until someone writes the carrier rules properly.
|
|
139
|
+
const notificationWarnings = ({
|
|
140
|
+
brand,
|
|
141
|
+
channel = 'email',
|
|
142
|
+
message,
|
|
143
|
+
subject
|
|
144
|
+
} = {}) => {
|
|
145
|
+
|
|
146
|
+
if( channel !== 'email' ) return { message : [], subject : [] };
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
message : messageWarnings({ message }),
|
|
150
|
+
subject : subjectWarnings({ brand, subject })
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export { notificationWarnings };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// lib/notification.js
|
|
2
|
+
var PHRASES = [
|
|
3
|
+
"act now",
|
|
4
|
+
"apply now",
|
|
5
|
+
"click here",
|
|
6
|
+
"congratulations",
|
|
7
|
+
"limited time",
|
|
8
|
+
"no obligation",
|
|
9
|
+
"once in a lifetime",
|
|
10
|
+
"risk free",
|
|
11
|
+
"this is not a scam",
|
|
12
|
+
"urgent",
|
|
13
|
+
"you have been selected"
|
|
14
|
+
];
|
|
15
|
+
var SUBJECT_DISPLAY_LIMIT = 60;
|
|
16
|
+
var MESSAGE_SHORT_LIMIT = 20;
|
|
17
|
+
var emojiCount = (value) => (value.match(new RegExp("\\p{Extended_Pictographic}", "gu")) || []).length;
|
|
18
|
+
var shouting = (value) => {
|
|
19
|
+
const letters = value.replace(/[^A-Za-z]/g, "");
|
|
20
|
+
if (letters.length < 4) return false;
|
|
21
|
+
const upper = letters.replace(/[^A-Z]/g, "");
|
|
22
|
+
return upper.length / letters.length > 0.7;
|
|
23
|
+
};
|
|
24
|
+
var phrasesIn = (value) => {
|
|
25
|
+
const lower = value.toLowerCase();
|
|
26
|
+
return PHRASES.filter((phrase) => lower.includes(phrase));
|
|
27
|
+
};
|
|
28
|
+
var list = (values) => values.map((value) => "\u201C" + value + "\u201D").join(", ");
|
|
29
|
+
var subjectWarnings = ({ brand, subject }) => {
|
|
30
|
+
const value = String(subject || "").trim();
|
|
31
|
+
if (!value) return [];
|
|
32
|
+
const found = phrasesIn(value);
|
|
33
|
+
const emojis = emojiCount(value);
|
|
34
|
+
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
|
+
};
|
|
44
|
+
var messageWarnings = ({ message }) => {
|
|
45
|
+
const value = String(message || "").trim();
|
|
46
|
+
if (!value) return [];
|
|
47
|
+
const found = phrasesIn(value);
|
|
48
|
+
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);
|
|
54
|
+
};
|
|
55
|
+
var notificationWarnings = ({
|
|
56
|
+
brand,
|
|
57
|
+
channel = "email",
|
|
58
|
+
message,
|
|
59
|
+
subject
|
|
60
|
+
} = {}) => {
|
|
61
|
+
if (channel !== "email") return { message: [], subject: [] };
|
|
62
|
+
return {
|
|
63
|
+
message: messageWarnings({ message }),
|
|
64
|
+
subject: subjectWarnings({ brand, subject })
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
export {
|
|
68
|
+
notificationWarnings
|
|
69
|
+
};
|
package/dist/plans.cjs
CHANGED
|
@@ -199,7 +199,6 @@ var organization = {
|
|
|
199
199
|
|
|
200
200
|
// index.js
|
|
201
201
|
var import_currency_codes = require("currency-codes");
|
|
202
|
-
var import_nanoid = require("nanoid");
|
|
203
202
|
|
|
204
203
|
// lib/color.js
|
|
205
204
|
var import_tinycolor2 = __toESM(require("tinycolor2"), 1);
|
|
@@ -262,8 +261,11 @@ var style = {
|
|
|
262
261
|
}
|
|
263
262
|
};
|
|
264
263
|
|
|
264
|
+
// lib/nanoid.js
|
|
265
|
+
var import_nanoid = require("nanoid");
|
|
266
|
+
var generate = (0, import_nanoid.customAlphabet)("0123456789abcdefghijklmnopqrstuvwxyz", 8);
|
|
267
|
+
|
|
265
268
|
// index.js
|
|
266
|
-
var nanoid = (0, import_nanoid.customAlphabet)("0123456789abcdefghijklmnopqrstuvwxyz", 8);
|
|
267
269
|
var infinite = 1e300;
|
|
268
270
|
var megabyte = 1024 * 1024;
|
|
269
271
|
var gigabyte = megabyte * 1024;
|
package/dist/plans.d.cts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { organization, page, connection, fields, field } from './features.cjs';
|
|
2
2
|
import { infinite, gigabyte } from './index.cjs';
|
|
3
3
|
import 'currency-codes';
|
|
4
|
-
import 'nanoid';
|
|
5
4
|
import './color.cjs';
|
|
6
5
|
import 'tinycolor2';
|
|
6
|
+
import './nanoid.cjs';
|
|
7
|
+
import 'nanoid';
|
|
7
8
|
import './usage.cjs';
|
|
8
9
|
|
|
9
10
|
const featuresFor = ( array = [] ) => Object.values({
|
package/dist/plans.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { organization, page, connection, fields, field } from './features.js';
|
|
2
2
|
import { infinite, gigabyte } from './index.js';
|
|
3
3
|
import 'currency-codes';
|
|
4
|
-
import 'nanoid';
|
|
5
4
|
import './color.js';
|
|
6
5
|
import 'tinycolor2';
|
|
6
|
+
import './nanoid.js';
|
|
7
|
+
import 'nanoid';
|
|
7
8
|
import './usage.js';
|
|
8
9
|
|
|
9
10
|
const featuresFor = ( array = [] ) => Object.values({
|
package/dist/plans.js
CHANGED
|
@@ -160,7 +160,6 @@ var organization = {
|
|
|
160
160
|
|
|
161
161
|
// index.js
|
|
162
162
|
import { code, data } from "currency-codes";
|
|
163
|
-
import { customAlphabet } from "nanoid";
|
|
164
163
|
|
|
165
164
|
// lib/color.js
|
|
166
165
|
import tinycolor from "tinycolor2";
|
|
@@ -223,8 +222,11 @@ var style = {
|
|
|
223
222
|
}
|
|
224
223
|
};
|
|
225
224
|
|
|
225
|
+
// lib/nanoid.js
|
|
226
|
+
import { customAlphabet } from "nanoid";
|
|
227
|
+
var generate = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 8);
|
|
228
|
+
|
|
226
229
|
// index.js
|
|
227
|
-
var nanoid = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 8);
|
|
228
230
|
var infinite = 1e300;
|
|
229
231
|
var megabyte = 1024 * 1024;
|
|
230
232
|
var gigabyte = megabyte * 1024;
|