File size: 6,888 Bytes
fd7800b
5db9737
 
fd7800b
 
 
 
 
 
 
5db9737
 
 
fd7800b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5db9737
 
 
 
 
fd7800b
 
 
5db9737
 
 
 
 
 
fd7800b
5db9737
 
 
 
 
fd7800b
 
 
 
5db9737
fd7800b
5db9737
fd7800b
 
 
 
 
 
5db9737
fd7800b
 
 
 
 
 
 
 
 
 
 
 
 
 
5db9737
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd7800b
5db9737
 
fd7800b
5db9737
fd7800b
5db9737
fd7800b
5db9737
 
fd7800b
 
 
5db9737
 
 
 
cd07add
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5db9737
 
fd7800b
5db9737
 
 
 
 
 
 
 
 
 
fd7800b
 
 
 
5db9737
 
fd7800b
 
 
 
5db9737
fd7800b
 
 
5db9737
 
 
fd7800b
 
 
 
 
 
5db9737
 
fd7800b
 
 
5db9737
fd7800b
 
 
 
 
 
 
 
5db9737
 
 
fd7800b
5db9737
 
 
 
 
fd7800b
5db9737
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import re
from typing import Optional
from ..models import (
    SyntaxResult,
    DNSResult,
    DisposableResult,
    CatchAllResult,
    SMTPResult,
    ScoringResult,
    RiskLevel,
)


def _is_explicit_mailbox_rejection(smtp: SMTPResult) -> bool:
    if smtp.smtp_code != 550 or not smtp.smtp_message:
        return False

    message = smtp.smtp_message.casefold()
    enhanced_status = re.search(r"(?<![\d.])5\.1\.1(?![\d.])", message)
    mailbox_rejection_phrases = (
        "user unknown",
        "no such user",
        "mailbox does not exist",
        "mailbox not found",
        "recipient does not exist",
        "recipient not found",
        "account does not exist",
    )
    return bool(enhanced_status) or any(
        phrase in message for phrase in mailbox_rejection_phrases
    )


def calculate_score(

    syntax: SyntaxResult,

    dns: Optional[DNSResult],

    disposable: Optional[DisposableResult],

    catch_all: Optional[CatchAllResult],

    smtp: Optional[SMTPResult],

    *,

    mailbox_verification_skipped: bool = False,

) -> ScoringResult:
    """

    Layer 7: Comprehensive deliverability scoring.

    Score: 0-100 (higher = better deliverability)

    """
    score = 100
    risk_cap = 100
    breakdown = {}
    warnings = []

    # ---- Syntax (hard fail → score 0) --------------------------------
    if not syntax.passed:
        breakdown["SYNTAX_INVALID"] = -100
        warnings.append("Email syntax is invalid")
        return _scoring_result(0, breakdown, warnings)
    breakdown["syntax"] = 0

    # ---- Domain/MX -------------------------------------------------------
    if dns:
        if dns.error_code in {"timeout", "resolver_error"}:
            breakdown["DNS_TRANSIENT_FAILURE"] = -50
            warnings.append("DNS lookup was inconclusive")
            return _scoring_result(50, breakdown, warnings)
        elif not dns.domain_exists:
            breakdown["DNS_MX_MISSING"] = -100
            warnings.append("Domain does not exist")
            return _scoring_result(0, breakdown, warnings)
        elif dns.error_code == "nxdomain" or (
            not dns.has_mx_records and not dns.a_fallback
        ):
            breakdown["DNS_MX_MISSING"] = -100
            warnings.append("Domain has no valid mail route")
            return _scoring_result(0, breakdown, warnings)
        elif dns.a_fallback:
            risk_cap = min(risk_cap, 74)
            breakdown["mx_records"] = -45
            score -= 45
            warnings.append(
                "No MX records — using A/AAAA fallback; mailbox result is limited"
            )
        else:
            breakdown["domain"] = 0
            breakdown["mx_records"] = 0

        # Authentication bonuses/penalties
        if dns.has_spf:
            breakdown["spf"] = 3
            score += 3
        else:
            warnings.append("No SPF record — increases spam risk")

        if dns.has_dmarc:
            breakdown["dmarc"] = 3
            score += 3
        else:
            warnings.append("No DMARC record configured")

        if dns.has_dkim:
            breakdown["dkim"] = 2
            score += 2

    # ---- Disposable ---------------------------------------------------
    if disposable:
        if disposable.is_disposable:
            breakdown["DISPOSABLE_DOMAIN"] = -100
            warnings.append("Disposable/temporary email domain detected")
            return _scoring_result(0, breakdown, warnings)
        elif disposable.is_role_based:
            risk_cap = min(risk_cap, 74)
            breakdown["role_based"] = -10
            score -= 10
            warnings.append(
                "Role-based email (admin@, info@, etc.) — low engagement risk"
            )
        elif disposable.is_free_provider:
            breakdown["free_provider"] = -5
            score -= 5

    # ---- ML advisory (DistilBERT, unknown domains only) ---------------
    # NEVER a hard verdict: a statistical false positive must not destroy a
    # legitimate lead. Penalty is small and reversible by promotion gate.
    ml_label = getattr(disposable, "ml_label", None)
    if ml_label == "DISPOSABLE" and not disposable.is_disposable:
        from ..config import settings  # local import: scoring stays pure

        penalty = max(1, int(getattr(settings, "ml_score_penalty", 5)))
        score -= penalty
        breakdown["ML_DISPOSABLE_SUSPECT"] = -penalty
        confidence = getattr(disposable, "ml_confidence", 0.0) or 0.0
        warnings.append(
            f"ML model flagged domain as disposable-like ({confidence:.2f})"
        )

    # ---- Catch-All (-20) ---------------------------------------------
    if catch_all and catch_all.is_catch_all:
        risk_cap = min(risk_cap, 74)
        breakdown["catch_all"] = -20
        score -= 20
        warnings.append("Catch-all domain — cannot verify specific mailbox")

    # ---- SMTP Verification -------------------------------------------
    if smtp:
        if smtp.verified is True:
            breakdown["smtp"] = 10
            score += 10
        elif smtp.verified is False:
            if _is_explicit_mailbox_rejection(smtp):
                breakdown["SMTP_MAILBOX_NOT_FOUND"] = -100
                warnings.append("SMTP explicitly reported the mailbox as not found")
                return _scoring_result(0, breakdown, warnings)
            breakdown["smtp"] = -30
            score -= 30
            warnings.append(
                f"SMTP rejected the recipient (code {smtp.smtp_code}); "
                "provider policy may make the result inconclusive"
            )
        else:
            risk_cap = min(risk_cap, 70)
            breakdown["SMTP_CHECK_INCONCLUSIVE"] = -30
            score -= 30
            if smtp.error:
                warnings.append(f"SMTP check inconclusive: {smtp.error}")

    if mailbox_verification_skipped:
        risk_cap = min(risk_cap, 70)
        breakdown["FAST_MODE_SKIPPED_MAILBOX_CHECK"] = -30
        score -= 30
        warnings.append("Mailbox verification was not performed")

    # Clamp score between 0 and 100
    score = max(0, min(100, score))
    if score > risk_cap:
        breakdown["RISK_CONTEXT_CAP"] = risk_cap - score
        score = risk_cap

    return _scoring_result(score, breakdown, warnings)


def _scoring_result(

    score: int,

    breakdown: dict[str, int],

    warnings: list[str],

) -> ScoringResult:
    # Determine risk level
    if score >= 75:
        risk = RiskLevel.LOW
    elif score >= 40:
        risk = RiskLevel.MEDIUM
    else:
        risk = RiskLevel.HIGH

    return ScoringResult(
        score=score, risk_level=risk, breakdown=breakdown, warnings=warnings
    )