@novacraft-engineering/mailbox 0.4.18 → 0.4.20
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/lib/mailbox.ts +33 -10
- package/package.json +1 -1
package/lib/mailbox.ts
CHANGED
|
@@ -573,6 +573,17 @@ const listFrom = (raw: string | undefined, fallback: Iterable<string>): Set<stri
|
|
|
573
573
|
// Read per call, so a deployment can change any of them without a release.
|
|
574
574
|
const freeProviders = () => listFrom(process.env.MAIL_FREE_PROVIDERS, FREE_MAIL_DEFAULT)
|
|
575
575
|
const throwawayTlds = () => listFrom(process.env.MAIL_THROWAWAY_TLDS, THROWAWAY_TLDS_DEFAULT)
|
|
576
|
+
|
|
577
|
+
// Bulk senders put their own bounce domain in From and the real correspondent in Reply-To.
|
|
578
|
+
// That is how the campaign gets replies, not an attempt to redirect them somewhere unexpected.
|
|
579
|
+
const BULK_SENDERS_DEFAULT = new Set([
|
|
580
|
+
'mailchimpapp.com', 'mcsv.net', 'rsgsv.net', 'mailchimp.com',
|
|
581
|
+
'sendgrid.net', 'sendgrid.com', 'sparkpostmail.com', 'amazonses.com',
|
|
582
|
+
'mailgun.org', 'mandrillapp.com', 'postmarkapp.com', 'sendinblue.com',
|
|
583
|
+
'brevo.com', 'constantcontact.com', 'cmail19.com', 'createsend.com',
|
|
584
|
+
'hubspotemail.net', 'mailerlite.com', 'klaviyomail.com', 'salesforce.com',
|
|
585
|
+
])
|
|
586
|
+
const bulkSenders = () => listFrom(process.env.MAIL_BULK_SENDERS, BULK_SENDERS_DEFAULT)
|
|
576
587
|
const scamPhrases = () => [...listFrom(process.env.MAIL_SCAM_PHRASES, SCAM_PHRASES_DEFAULT)]
|
|
577
588
|
|
|
578
589
|
/** Weight at which a message stops being labelled and is held out of the inbox instead. */
|
|
@@ -593,7 +604,15 @@ export type RiskJudgement = { risk: Risk; reasons: string[]; score: number; quar
|
|
|
593
604
|
export function judgeMessage(signals: RiskSignals, standing: SenderStanding): RiskJudgement {
|
|
594
605
|
const reasons: string[] = []
|
|
595
606
|
let score = 0
|
|
596
|
-
|
|
607
|
+
// A finding is "telling" when it is hard to trip by accident. Failing an authentication
|
|
608
|
+
// check or shouting in the subject line is neither: ordinary mail does both. Holding a
|
|
609
|
+
// message back takes at least one finding of the first kind, however the weights add up.
|
|
610
|
+
let telling = 0
|
|
611
|
+
const add = (weight: number, why: string, isTelling = false) => {
|
|
612
|
+
score += weight
|
|
613
|
+
if (isTelling) telling += 1
|
|
614
|
+
reasons.push(why)
|
|
615
|
+
}
|
|
597
616
|
|
|
598
617
|
if (/^fail$/i.test((signals.virus ?? '').trim())) {
|
|
599
618
|
return { risk: 'virus', reasons: ['A virus scan failed on this message'], score: 100, quarantine: true }
|
|
@@ -604,12 +623,12 @@ export function judgeMessage(signals: RiskSignals, standing: SenderStanding): Ri
|
|
|
604
623
|
const trusted = standing.replied > 0 && standing.markedSpam === 0
|
|
605
624
|
if (standing.markedSpam > 0) {
|
|
606
625
|
add(4 + Math.min(standing.markedSpam, 4),
|
|
607
|
-
`You marked ${standing.markedSpam} earlier message${standing.markedSpam === 1 ? '' : 's'} from this sender as spam
|
|
626
|
+
`You marked ${standing.markedSpam} earlier message${standing.markedSpam === 1 ? '' : 's'} from this sender as spam`, true)
|
|
608
627
|
} else if (standing.trashed >= 3 && standing.replied === 0) {
|
|
609
|
-
add(3, `You have deleted ${standing.trashed} messages from this sender without ever replying
|
|
628
|
+
add(3, `You have deleted ${standing.trashed} messages from this sender without ever replying`, true)
|
|
610
629
|
}
|
|
611
630
|
|
|
612
|
-
if (/^fail$/i.test((signals.spam ?? '').trim())) add(4, 'The provider\u2019s spam filter flagged this message')
|
|
631
|
+
if (/^fail$/i.test((signals.spam ?? '').trim())) add(4, 'The provider\u2019s spam filter flagged this message', true)
|
|
613
632
|
|
|
614
633
|
const authenticated = /^pass$/i.test((signals.dmarc ?? '').trim())
|
|
615
634
|
// Heavy, but not enough on its own to hide a message: mail forwarded through a list
|
|
@@ -627,15 +646,19 @@ export function judgeMessage(signals: RiskSignals, standing: SenderStanding): Ri
|
|
|
627
646
|
.filter(entry => entry && entry !== fromDomain)
|
|
628
647
|
const free = freeProviders()
|
|
629
648
|
const freeReply = replyDomains.find(entry => free.has(entry))
|
|
630
|
-
|
|
631
|
-
|
|
649
|
+
const bulk = bulkSenders().has(fromDomain)
|
|
650
|
+
if (bulk) {
|
|
651
|
+
// Nothing to say: a campaign's replies are meant to land somewhere other than the
|
|
652
|
+
// sending platform, and treating that as misdirection buries ordinary bulk mail.
|
|
653
|
+
} else if (freeReply && fromDomain && !free.has(fromDomain)) {
|
|
654
|
+
add(4, `Replies to this message go to ${freeReply}, not to ${fromDomain}`, true)
|
|
632
655
|
} else if (replyDomains.length) {
|
|
633
656
|
add(1, `Replies go to ${replyDomains[0]} rather than ${fromDomain || 'the sender'}`)
|
|
634
657
|
}
|
|
635
658
|
|
|
636
659
|
const tld = fromDomain.split('.').pop() ?? ''
|
|
637
|
-
if (throwawayTlds().has(tld)) add(2, `The sender\u2019s domain ends in .${tld}, which is cheap to register and often disposable
|
|
638
|
-
if (/^\d{4,}$/.test(fromDomain.split('.')[0] ?? '')) add(2, 'The sender\u2019s domain name is just a string of digits')
|
|
660
|
+
if (throwawayTlds().has(tld)) add(2, `The sender\u2019s domain ends in .${tld}, which is cheap to register and often disposable`, true)
|
|
661
|
+
if (/^\d{4,}$/.test(fromDomain.split('.')[0] ?? '')) add(2, 'The sender\u2019s domain name is just a string of digits', true)
|
|
639
662
|
|
|
640
663
|
const subject = (signals.subject ?? '').trim()
|
|
641
664
|
const letters = subject.replace(/[^A-Za-z]/g, '')
|
|
@@ -643,7 +666,7 @@ export function judgeMessage(signals: RiskSignals, standing: SenderStanding): Ri
|
|
|
643
666
|
|
|
644
667
|
const body = (signals.text ?? '').toLowerCase()
|
|
645
668
|
const hits = scamPhrases().filter(phrase => body.includes(phrase))
|
|
646
|
-
if (hits.length >= 2) add(3, `The wording follows a known advance-fee approach (${hits.slice(0, 3).join(', ')})
|
|
669
|
+
if (hits.length >= 2) add(3, `The wording follows a known advance-fee approach (${hits.slice(0, 3).join(', ')})`, true)
|
|
647
670
|
else if (hits.length === 1) add(1, `Wording associated with advance-fee mail (${hits[0]})`)
|
|
648
671
|
|
|
649
672
|
// Never heard from before is not suspicious by itself — everyone writes once for the
|
|
@@ -660,7 +683,7 @@ export function judgeMessage(signals: RiskSignals, standing: SenderStanding): Ri
|
|
|
660
683
|
// time teaches the reader to ignore the warning.
|
|
661
684
|
if (score < flagAt()) return { risk: 'clean', reasons: [], score, quarantine: false }
|
|
662
685
|
|
|
663
|
-
const quarantine = score >= limit
|
|
686
|
+
const quarantine = score >= limit && telling > 0
|
|
664
687
|
return { risk: quarantine ? 'spam' : 'suspicious', reasons, score, quarantine }
|
|
665
688
|
}
|
|
666
689
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/mailbox",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.20",
|
|
4
4
|
"description": "A shared webmail app: one codebase, one deployment per mailbox. Threads, a rich composer with signatures, attachments on S3-compatible storage, sharing links, full-text search, web push and PWA install.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"webmail",
|