blockEmails[] = $email; if ($domain) $this->blockDomains[] = $domain; } elseif ($type === 'allow') { if ($email) $this->allowEmails[] = $email; if ($domain) $this->allowDomains[] = $domain; } } } /** English + German spam keywords. Each match adds 2 points (capped). */ private const SPAM_PHRASES = [ // English 'viagra', 'cialis', 'pharmacy', 'rolex', 'casino', 'lottery', 'winner', 'congratulations', 'claim your prize', 'act now', 'click here', 'free gift', 'limited time', 'risk free', 'no obligation', 'satisfaction guaranteed', 'money back', 'lose weight', 'work from home', 'make money fast', 'earn extra cash', 'inheritance', 'beneficiary', 'next of kin', 'crypto millionaire', '100% free', '100% satisfied', 'urgent reply', 'wire transfer', 'western union', 'nigerian prince', 'unclaimed funds', 'verify your account', 'suspended account', 'confirm your identity', // German 'sie haben gewonnen', 'gewinnspiel', 'lotto', 'erbschaft', 'sofort gewinn', 'kostenlos', 'gratis', 'nur heute', 'einmalig', 'jetzt zugreifen', 'jetzt bestellen', 'risikofrei', 'geld verdienen', 'schnell reich', 'abnehmen ohne', 'wundermittel', 'klicken sie hier', 'bestätigen sie', 'ihr konto wurde', 'ihr passwort', 'verifizieren sie', 'dringend antworten', ]; /** Suspicious TLDs frequently used by spammers. */ private const SUSPICIOUS_TLDS = [ 'top', 'xyz', 'click', 'link', 'work', 'gq', 'ml', 'tk', 'ga', 'cf', 'loan', 'racing', 'win', 'download', 'review', 'country', 'kim', 'science', 'party', 'bid', 'trade', 'date', 'stream', 'cricket', 'webcam', 'mom', 'rest', ]; /** URL shorteners (high spam correlation in cold email). */ private const SHORT_URL_HOSTS = [ 'bit.ly', 'tinyurl.com', 't.co', 'goo.gl', 'ow.ly', 'is.gd', 'buff.ly', 'tiny.cc', 'cutt.ly', 'shorturl.at', 'rebrand.ly', 'rb.gy', ]; /** * @param array $headers Lowercase-keyed header map (as returned by ImapClient::parseHeaders) * @param string|null $bodyText Optional plaintext body for content checks * @return array{score:int, level:string, reasons:array} */ public function score(array $headers, ?string $bodyText = null): array { $score = 0; $reasons = []; // ── User-defined rules (override the heuristic with strong weight) ── $fromAddrEarly = $this->extractAddress($headers['from'] ?? ''); $fromDomainEarly = strtolower($this->extractDomain($fromAddrEarly)); $fromAddrLow = strtolower($fromAddrEarly); if ($fromAddrLow && in_array($fromAddrLow, $this->allowEmails, true)) { $score -= 15; $reasons[] = 'sender on allowlist (email)'; } elseif ($fromDomainEarly && in_array($fromDomainEarly, $this->allowDomains, true)) { $score -= 12; $reasons[] = 'sender domain on allowlist'; } if ($fromAddrLow && in_array($fromAddrLow, $this->blockEmails, true)) { $score += 15; $reasons[] = 'sender on blocklist (email)'; } elseif ($fromDomainEarly && in_array($fromDomainEarly, $this->blockDomains, true)) { $score += 12; $reasons[] = 'sender domain on blocklist'; } // ── Upstream filter verdict (strongest signal) ────────────── $xSpamFlag = strtolower(trim($headers['x-spam-flag'] ?? '')); if ($xSpamFlag === 'yes') { $score += 10; $reasons[] = 'upstream X-Spam-Flag: YES'; } $xSpamStatus = $headers['x-spam-status'] ?? ''; if (preg_match('/^Yes/i', $xSpamStatus)) { $score += 8; $reasons[] = 'upstream X-Spam-Status: Yes'; } if (preg_match('/score\s*=\s*([\d.]+)/i', $xSpamStatus, $m)) { $sa = (float)$m[1]; if ($sa >= 5) { $score += 5; $reasons[] = "SpamAssassin score $sa"; } elseif ($sa >= 3) { $score += 2; $reasons[] = "SpamAssassin score $sa"; } } $xSpamScore = $headers['x-spam-score'] ?? ''; if (is_numeric(trim($xSpamScore)) && (float)$xSpamScore >= 5) { $score += 4; $reasons[] = "X-Spam-Score $xSpamScore"; } // ── Authentication results (SPF/DKIM/DMARC) ───────────────── $authResults = strtolower($headers['authentication-results'] ?? ''); $receivedSpf = strtolower($headers['received-spf'] ?? ''); if (str_contains($authResults, 'spf=fail') || str_starts_with($receivedSpf, 'fail')) { $score += 4; $reasons[] = 'SPF fail'; } elseif (str_contains($authResults, 'spf=softfail') || str_starts_with($receivedSpf, 'softfail')) { $score += 1; $reasons[] = 'SPF softfail'; } if (str_contains($authResults, 'dkim=fail')) { $score += 3; $reasons[] = 'DKIM fail'; } if (str_contains($authResults, 'dmarc=fail')) { $score += 4; $reasons[] = 'DMARC fail'; } // Missing Message-ID is rare in legitimate mail if (empty($headers['message-id']) && empty($headers['x-spam-flag'])) { $score += 2; $reasons[] = 'missing Message-ID'; } // ── Subject heuristics ────────────────────────────────────── $subject = $headers['subject'] ?? ''; if ($subject !== '') { // ALL CAPS check (only count letters; ignore short subjects) $letters = preg_replace('/[^A-Za-zÄÖÜäöüß]/u', '', $subject); if (strlen($letters) >= 12) { $upper = preg_replace('/[^A-ZÄÖÜ]/u', '', $letters); $ratio = strlen($upper) / max(1, strlen($letters)); if ($ratio > 0.7) { $score += 3; $reasons[] = 'ALL CAPS subject'; } } // Excessive exclamation/question marks $excessPunct = preg_match_all('/[!?]{2,}/', $subject); if ($excessPunct >= 1) { $score += min(3, $excessPunct + 1); $reasons[] = 'excessive punctuation'; } // Currency clusters if (preg_match_all('/[\$€£¥]\s*\d+|\d+\s*[\$€£¥]|EUR\s*\d+|USD\s*\d+/i', $subject, $cm)) { if (count($cm[0]) >= 1) { $score += 2; $reasons[] = 'currency in subject'; } } // Keyword matches (capped at +6) $kwScore = 0; $kwHits = 0; $lowSubject = function_exists('mb_strtolower') ? mb_strtolower($subject) : strtolower($subject); foreach (self::SPAM_PHRASES as $phrase) { if (str_contains($lowSubject, $phrase)) { $kwScore += 2; $kwHits++; if ($kwHits >= 3) break; // cap at 3 distinct hits } } if ($kwScore > 0) { $score += min(6, $kwScore); $reasons[] = "$kwHits spam keyword" . ($kwHits === 1 ? '' : 's'); } // Cyrillic homoglyph attack: Latin recipients getting Cyrillic letters if (preg_match('/[\x{0400}-\x{04FF}]/u', $subject) && preg_match('/[a-zA-Z]/', $subject)) { $score += 4; $reasons[] = 'mixed Latin/Cyrillic (homoglyph)'; } } // ── Sender heuristics ─────────────────────────────────────── $from = $headers['from'] ?? ''; $replyTo = $headers['reply-to'] ?? ''; $fromAddr = $this->extractAddress($from); $replyAddr = $this->extractAddress($replyTo); $fromDomain = $this->extractDomain($fromAddr); $replyDomain = $this->extractDomain($replyAddr); // Reply-To pointing to a different domain than From (classic phishing) if ($replyDomain && $fromDomain && strcasecmp($replyDomain, $fromDomain) !== 0) { $score += 3; $reasons[] = "Reply-To domain mismatch ($replyDomain ≠ $fromDomain)"; } // Suspicious TLD if ($fromDomain) { $tld = strtolower((string)substr(strrchr($fromDomain, '.'), 1)); if (in_array($tld, self::SUSPICIOUS_TLDS, true)) { $score += 3; $reasons[] = "suspicious TLD .$tld"; } // Digit-heavy domain (often throwaways like x4892jk-shop.com) $digitCount = preg_match_all('/\d/', $fromDomain); $domainLen = strlen($fromDomain); if ($domainLen > 8 && $digitCount / $domainLen > 0.3) { $score += 2; $reasons[] = 'digit-heavy sender domain'; } // Hyphen overload if (substr_count($fromDomain, '-') >= 3) { $score += 1; $reasons[] = 'many hyphens in domain'; } } // Display name impersonates an email address that doesn't match the actual address if (preg_match('/^"?([^"<]+)"?\s*<([^>]+)>$/', $from, $fm)) { $displayName = trim($fm[1]); $actualAddr = trim($fm[2]); if (preg_match('/[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $displayName, $dm)) { if (strcasecmp($dm[0], $actualAddr) !== 0) { $score += 4; $reasons[] = 'display name impersonates a different email'; } } } // ── List-Unsubscribe present is a slight signal it's bulk // (not necessarily spam, just a small positive bump) if (!empty($headers['list-unsubscribe'])) { $score += 1; $reasons[] = 'bulk mail (list-unsubscribe)'; } // ── Body heuristics (only if body is provided) ────────────── if ($bodyText) { // Count URLs preg_match_all('#https?://([^\s<>"\']+)#i', $bodyText, $urlMatches); $urls = $urlMatches[1] ?? []; $urlCount = count($urls); if ($urlCount > 8) { $score += min(4, intdiv($urlCount, 4)); $reasons[] = "$urlCount links in body"; } $shortCount = 0; foreach ($urls as $u) { $host = strtolower(parse_url('http://' . $u, PHP_URL_HOST) ?? ''); if ($host && in_array($host, self::SHORT_URL_HOSTS, true)) $shortCount++; } if ($shortCount >= 2) { $score += 2; $reasons[] = "$shortCount short URLs"; } } // ── Determine level ───────────────────────────────────────── if ($score >= 8) { $level = 'spam'; } elseif ($score >= 4) { $level = 'suspicious'; } else { $level = 'clean'; } return ['score' => $score, 'level' => $level, 'reasons' => $reasons]; } private function extractAddress(string $field): string { if (preg_match('/<([^>]+)>/', $field, $m)) return trim($m[1]); if (preg_match('/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $field, $m)) return $m[0]; return ''; } private function extractDomain(string $address): string { $at = strrpos($address, '@'); return $at === false ? '' : strtolower(substr($address, $at + 1)); } }