mailshift 1.0.0__py3-none-any.whl
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.
- mailshift/__init__.py +1 -0
- mailshift/config/__init__.py +1 -0
- mailshift/config/config.py +279 -0
- mailshift/core/__init__.py +1 -0
- mailshift/core/analyzers/__init__.py +1 -0
- mailshift/core/analyzers/base.py +8 -0
- mailshift/core/analyzers/fast.py +123 -0
- mailshift/core/analyzers/pro.py +529 -0
- mailshift/core/engine.py +842 -0
- mailshift/core/session.py +268 -0
- mailshift/db/__init__.py +1 -0
- mailshift/db/database.py +199 -0
- mailshift/main.py +902 -0
- mailshift/models/__init__.py +1 -0
- mailshift/models/models.py +47 -0
- mailshift/ui/__init__.py +1 -0
- mailshift/ui/cli.py +919 -0
- mailshift/ui/styles.py +96 -0
- mailshift/utils/__init__.py +1 -0
- mailshift/utils/hardware.py +572 -0
- mailshift/utils/history.py +358 -0
- mailshift/utils/logger.py +78 -0
- mailshift/utils/paths.py +8 -0
- mailshift/utils/power_user_settings.py +85 -0
- mailshift/utils/unsubscribe.py +119 -0
- mailshift/utils/updater.py +136 -0
- mailshift/utils/worker_profile_store.py +284 -0
- mailshift-1.0.0.dist-info/METADATA +258 -0
- mailshift-1.0.0.dist-info/RECORD +32 -0
- mailshift-1.0.0.dist-info/WHEEL +4 -0
- mailshift-1.0.0.dist-info/entry_points.txt +2 -0
- mailshift-1.0.0.dist-info/licenses/LICENSE +674 -0
mailshift/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""
|
|
2
|
+
config.py | Pydantic-based configuration models for MailShift.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
|
13
|
+
|
|
14
|
+
from ..utils.paths import get_path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Provider(str, Enum):
|
|
18
|
+
GMAIL = "gmail"
|
|
19
|
+
PROTON = "proton"
|
|
20
|
+
CUSTOM = "custom"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Mode(str, Enum):
|
|
24
|
+
FAST = "fast"
|
|
25
|
+
PRO = "pro"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IMAPConfig(BaseModel):
|
|
29
|
+
"""IMAP connection parameters."""
|
|
30
|
+
host: str
|
|
31
|
+
port: int = 993
|
|
32
|
+
use_ssl: bool = True
|
|
33
|
+
username: str
|
|
34
|
+
password: SecretStr
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(frozen=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
PROVIDER_DEFAULTS: dict[Provider, dict] = {
|
|
40
|
+
Provider.GMAIL: {"host": "imap.gmail.com", "port": 993, "use_ssl": True},
|
|
41
|
+
Provider.PROTON: {"host": "127.0.0.1", "port": 1143, "use_ssl": False},
|
|
42
|
+
Provider.CUSTOM: {"host": "", "port": 993, "use_ssl": True},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
DEFAULT_SYSTEM_PROMPT = """
|
|
46
|
+
MailShift için e-posta sınıflandırması yap.
|
|
47
|
+
|
|
48
|
+
Yalnızca geçerli JSON döndür:
|
|
49
|
+
{"decision":"SIL|TUT","reason":"kısa"}
|
|
50
|
+
|
|
51
|
+
Karar kuralları:
|
|
52
|
+
- SIL: Pazarlama/satış, kampanya/indirim, sepet hatırlatma, bülten/digest, sadakat-puan/VIP, gamification, genel kurumsal duyurular.
|
|
53
|
+
- TUT: Kişisel yazışma, fatura/dekont, banka/ödeme, şifre sıfırlama/OTP, kargo/teslimat, resmi vergi-hukuki bildirim, abonelik iptali/ücretli yenileme uyarısı.
|
|
54
|
+
|
|
55
|
+
Çakışma kuralı:
|
|
56
|
+
- Mesaj hem pazarlama hem operasyonel görünüyorsa güvenlik/ödeme/fatura içeriği varsa TUT, yoksa SIL.
|
|
57
|
+
|
|
58
|
+
Yanıt kuralları:
|
|
59
|
+
- Decision sadece SIL veya TUT olmalı.
|
|
60
|
+
- Reason 2-5 kelime olmalı.
|
|
61
|
+
- JSON dışında hiçbir metin yazma.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Heuristic keyword lists & Pattern Management
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
class KeywordManager:
|
|
69
|
+
"""Manages dynamic loading, updating, and compiling of heuristic keywords."""
|
|
70
|
+
|
|
71
|
+
def __init__(self):
|
|
72
|
+
self.whitelist: List[str] = []
|
|
73
|
+
self.blacklist_dict: Dict[str, List[str]] = {}
|
|
74
|
+
|
|
75
|
+
# Compiled patterns
|
|
76
|
+
self.whitelist_pattern: Optional[re.Pattern] = None
|
|
77
|
+
self.junk_pattern: Optional[re.Pattern] = None
|
|
78
|
+
|
|
79
|
+
# Fast lookup maps
|
|
80
|
+
self.blacklist_category_map: Dict[str, str] = {}
|
|
81
|
+
self.junk_keywords_flat: List[str] = []
|
|
82
|
+
|
|
83
|
+
self.reload()
|
|
84
|
+
|
|
85
|
+
def _load_json(self, filename: str, default: Any) -> Any:
|
|
86
|
+
path = get_path(filename)
|
|
87
|
+
if not path.exists():
|
|
88
|
+
return default
|
|
89
|
+
with open(path, encoding="utf-8") as f:
|
|
90
|
+
try:
|
|
91
|
+
return json.load(f)
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
return default
|
|
94
|
+
|
|
95
|
+
def _save_json(self, filename: str, data: Any) -> None:
|
|
96
|
+
path = get_path(filename)
|
|
97
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
98
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
99
|
+
|
|
100
|
+
def _infer_category(self, keyword: str) -> str:
|
|
101
|
+
key = keyword.lower()
|
|
102
|
+
if any(t in key for t in ("newsletter", "bülten", "bulten", "digest", "weekly", "daily", "substack", "mailchimp")):
|
|
103
|
+
return "newsletter"
|
|
104
|
+
if any(t in key for t in ("unsubscribe", "abonelik", "aboneligi", "aboneliği", "list-unsubscribe", "opt out", "opt-out", "üyelik", "uyelik", "subscription", "listeden çık", "listeden cik", "preferences", "tercih")):
|
|
105
|
+
return "subscription"
|
|
106
|
+
if any(t in key for t in ("discount", "indirim", "kampanya", "campaign", "offer", "fırsat", "firsat", "sale", "coupon", "kupon", "free shipping", "ücretsiz kargo", "black friday", "deal", "promo", "promotion", "flash sale", "special offer", "sepet", "cart")):
|
|
107
|
+
return "promotion"
|
|
108
|
+
return "uncategorized"
|
|
109
|
+
|
|
110
|
+
def reload(self) -> None:
|
|
111
|
+
"""Reloads keywords from disk and recompiles regex patterns."""
|
|
112
|
+
self.whitelist = self._load_json("whitelist.json", [])
|
|
113
|
+
|
|
114
|
+
raw_blacklist = self._load_json("blacklist.json", {"uncategorized": []})
|
|
115
|
+
|
|
116
|
+
# Otomatik Format Göçü (List -> Dict)
|
|
117
|
+
if isinstance(raw_blacklist, list):
|
|
118
|
+
self.blacklist_dict = {}
|
|
119
|
+
for item in raw_blacklist:
|
|
120
|
+
if isinstance(item, str):
|
|
121
|
+
cat = self._infer_category(item)
|
|
122
|
+
self.blacklist_dict.setdefault(cat, []).append(item)
|
|
123
|
+
self._save_json("blacklist.json", self.blacklist_dict)
|
|
124
|
+
else:
|
|
125
|
+
self.blacklist_dict = raw_blacklist
|
|
126
|
+
|
|
127
|
+
# Yassılaştırma (Flatten) ve Haritalama (Mapping)
|
|
128
|
+
self.junk_keywords_flat = []
|
|
129
|
+
self.blacklist_category_map = {}
|
|
130
|
+
|
|
131
|
+
for cat, items in self.blacklist_dict.items():
|
|
132
|
+
if not isinstance(items, list): continue
|
|
133
|
+
for word in items:
|
|
134
|
+
if isinstance(word, str):
|
|
135
|
+
clean_word = word.strip().lower()
|
|
136
|
+
self.junk_keywords_flat.append(clean_word)
|
|
137
|
+
self.blacklist_category_map[clean_word] = cat.strip().lower()
|
|
138
|
+
|
|
139
|
+
# Regex Derleme
|
|
140
|
+
wl_escaped = [re.escape(k.lower()) for k in self.whitelist]
|
|
141
|
+
junk_escaped = [re.escape(k) for k in self.junk_keywords_flat]
|
|
142
|
+
|
|
143
|
+
self.whitelist_pattern = re.compile('|'.join(wl_escaped), re.IGNORECASE) if wl_escaped else None
|
|
144
|
+
self.junk_pattern = re.compile('|'.join(junk_escaped), re.IGNORECASE) if junk_escaped else None
|
|
145
|
+
|
|
146
|
+
# --- Public API for Keywords ---
|
|
147
|
+
|
|
148
|
+
def add_whitelist(self, word: str) -> bool:
|
|
149
|
+
if word not in self.whitelist:
|
|
150
|
+
self.whitelist.append(word)
|
|
151
|
+
self._save_json("whitelist.json", self.whitelist)
|
|
152
|
+
self.reload()
|
|
153
|
+
return True
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
def remove_whitelist(self, word: str) -> bool:
|
|
157
|
+
if word in self.whitelist:
|
|
158
|
+
self.whitelist.remove(word)
|
|
159
|
+
self._save_json("whitelist.json", self.whitelist)
|
|
160
|
+
self.reload()
|
|
161
|
+
return True
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
def add_blacklist(self, word: str) -> bool:
|
|
165
|
+
normalized = word.strip().lower()
|
|
166
|
+
if normalized in self.blacklist_category_map:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
target_category = self._infer_category(normalized)
|
|
170
|
+
self.blacklist_dict.setdefault(target_category, []).append(word)
|
|
171
|
+
self._save_json("blacklist.json", self.blacklist_dict)
|
|
172
|
+
self.reload()
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
def remove_blacklist(self, word: str) -> bool:
|
|
176
|
+
target = word.strip().lower()
|
|
177
|
+
removed = False
|
|
178
|
+
|
|
179
|
+
for category, items in self.blacklist_dict.items():
|
|
180
|
+
if not isinstance(items, list): continue
|
|
181
|
+
original_len = len(items)
|
|
182
|
+
self.blacklist_dict[category] = [k for k in items if not (isinstance(k, str) and k.strip().lower() == target)]
|
|
183
|
+
if len(self.blacklist_dict[category]) < original_len:
|
|
184
|
+
removed = True
|
|
185
|
+
|
|
186
|
+
if removed:
|
|
187
|
+
self._save_json("blacklist.json", self.blacklist_dict)
|
|
188
|
+
self.reload()
|
|
189
|
+
return removed
|
|
190
|
+
|
|
191
|
+
def get_category_for_match(self, matched_token: str) -> str:
|
|
192
|
+
key = (matched_token or "").strip().lower()
|
|
193
|
+
return self.blacklist_category_map.get(key, "uncategorized")
|
|
194
|
+
|
|
195
|
+
# Modül düzeyinde tekil instance (Singleton) oluştur
|
|
196
|
+
keyword_manager = KeywordManager()
|
|
197
|
+
|
|
198
|
+
# Geriye dönük uyumluluk için aracı fonksiyonlar (Diğer dosyalar bozulmasın diye)
|
|
199
|
+
def add_to_whitelist(word: str) -> bool: return keyword_manager.add_whitelist(word)
|
|
200
|
+
def remove_from_whitelist(word: str) -> bool: return keyword_manager.remove_whitelist(word)
|
|
201
|
+
def add_to_blacklist(word: str) -> bool: return keyword_manager.add_blacklist(word)
|
|
202
|
+
def remove_from_blacklist(word: str) -> bool: return keyword_manager.remove_blacklist(word)
|
|
203
|
+
def list_keywords() -> tuple[list[str], list[str]]: return keyword_manager.whitelist, keyword_manager.junk_keywords_flat
|
|
204
|
+
def get_blacklist_category_for_match(matched_token: str) -> str: return keyword_manager.get_category_for_match(matched_token)
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# LLM & App Configurations
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
class OllamaConfig(BaseModel):
|
|
211
|
+
"""Settings for the local Ollama LLM endpoint."""
|
|
212
|
+
base_url: str = "http://localhost:11434"
|
|
213
|
+
model: str = "qwen3.5:0.8B"
|
|
214
|
+
timeout: int = 60
|
|
215
|
+
max_body_chars: int = 250
|
|
216
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT
|
|
217
|
+
use_think: bool = False
|
|
218
|
+
|
|
219
|
+
model_config = ConfigDict(frozen=True)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class LMStudioConfig(BaseModel):
|
|
223
|
+
"""Settings for the LM Studio OpenAI-compatible endpoint."""
|
|
224
|
+
base_url: str = "http://localhost:1234"
|
|
225
|
+
model: str = "gemma-4-26b-a4b"
|
|
226
|
+
timeout: int = 60
|
|
227
|
+
max_body_chars: int = 250
|
|
228
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT
|
|
229
|
+
use_think: bool = False
|
|
230
|
+
|
|
231
|
+
model_config = ConfigDict(frozen=True)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class RateLimitConfig(BaseModel):
|
|
235
|
+
"""Rate limiting and retry settings for IMAP operations."""
|
|
236
|
+
fetch_chunk_size: int = 100 # UIDs per IMAP fetch request
|
|
237
|
+
delete_chunk_size: int = 100 # UIDs per IMAP store/copy request
|
|
238
|
+
chunk_delay: float = 0.1 # 100 ms between chunks by default
|
|
239
|
+
max_retries: int = 3 # Number of retry attempts per chunk
|
|
240
|
+
retry_backoff: float = 2.0 # Exponential back-off multiplier (s, s*2, s*4 …)
|
|
241
|
+
connect_timeout: int = 30 # Connection timeout (seconds) | applied to the underlying socket
|
|
242
|
+
db_batch_size: int = 500 # Database batch-commit size
|
|
243
|
+
|
|
244
|
+
model_config = ConfigDict(frozen=True)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class AppConfig(BaseModel):
|
|
248
|
+
"""Top-level application configuration."""
|
|
249
|
+
provider: Provider
|
|
250
|
+
mode: Mode
|
|
251
|
+
imap: IMAPConfig
|
|
252
|
+
ollama: OllamaConfig = Field(default_factory=OllamaConfig)
|
|
253
|
+
lm_studio: LMStudioConfig = Field(default_factory=LMStudioConfig)
|
|
254
|
+
llm_backend: str = "ollama" # "ollama" or "lm_studio"
|
|
255
|
+
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
|
|
256
|
+
dry_run: bool = True
|
|
257
|
+
scan_limit: Optional[int] = None
|
|
258
|
+
since: Optional[str] = None
|
|
259
|
+
before: Optional[str] = None
|
|
260
|
+
max_workers: Optional[int] = None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def build_imap_config(
|
|
264
|
+
provider: Provider,
|
|
265
|
+
username: str,
|
|
266
|
+
password: str,
|
|
267
|
+
host: Optional[str] = None,
|
|
268
|
+
port: Optional[int] = None,
|
|
269
|
+
use_ssl: Optional[bool] = None,
|
|
270
|
+
) -> IMAPConfig:
|
|
271
|
+
"""Construct an IMAPConfig using provider defaults plus overrides."""
|
|
272
|
+
defaults = PROVIDER_DEFAULTS[provider].copy()
|
|
273
|
+
if host is not None:
|
|
274
|
+
defaults["host"] = host
|
|
275
|
+
if port is not None:
|
|
276
|
+
defaults["port"] = port
|
|
277
|
+
if use_ssl is not None:
|
|
278
|
+
defaults["use_ssl"] = use_ssl
|
|
279
|
+
return IMAPConfig(username=username, password=password, **defaults)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fast_analyzer.py | Fast heuristic-based email analysis.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
# Statik patternler yerine Singleton KeywordManager'ı çağırıyoruz.
|
|
10
|
+
# Böylece çalışma zamanında eklenen kelimeler anında taranmaya başlar.
|
|
11
|
+
from ...config.config import keyword_manager
|
|
12
|
+
from ...models.models import MailMeta, ScanResult
|
|
13
|
+
|
|
14
|
+
# Backwards-compatible module-level pattern names used by tests
|
|
15
|
+
# Tests patch `mailshift.core.analyzers.fast.WHITELIST_PATTERN` / `JUNK_PATTERN`.
|
|
16
|
+
WHITELIST_PATTERN = None
|
|
17
|
+
JUNK_PATTERN = None
|
|
18
|
+
|
|
19
|
+
# re.IGNORECASE flag'i kaldırıldı (metin zaten normalize ediliyor).
|
|
20
|
+
# Yakalama gerektirmeyen (Non-capturing) gruplar (?:...) kullanılarak regex motorunun bellek tahsisi azaltıldı.
|
|
21
|
+
_ALWAYS_KEEP_PATTERN = re.compile(
|
|
22
|
+
r"""
|
|
23
|
+
(?:
|
|
24
|
+
# Premium lifecycle notices (expiry/end of trial)
|
|
25
|
+
\bpremium\b.{0,80}(?:bitiyor|bitecek|ending|expires?|expiring|sona\s+eriyor|deneme\s+s[üu]reniz\s+bitiyor)
|
|
26
|
+
|
|
|
27
|
+
# Verification / OTP codes
|
|
28
|
+
\b(?:otp|verification\s+code|verify\s+code|do[ğg]rulama\s+kodu|onay\s+kodu)\b
|
|
29
|
+
|
|
|
30
|
+
# Google Drive / cloud storage / service quota fullness notices
|
|
31
|
+
(?:google\s*drive|gdrive|onedrive|icloud|drive|nextdns)\b.{0,120}(?:dol[uı]|dolmak\s+[üu]zere|storage|depolama|quota|space\s+(?:is\s+)?(?:almost\s+)?full|kota|limit|exceeded)
|
|
32
|
+
|
|
|
33
|
+
# Phishing heuristics (force TUT to avoid SILing real mails by mistake)
|
|
34
|
+
\b(?:urgent|account\s+suspended|verify\s+your\s+identity|tebrikler.{0,30}kazand[ıi]n[ıi]z|çekiliş|şifre(?:nizi)?\s+sıfırlayın|kart\s+bilgileri(?:nizi)?\s+güncelleyin)\b
|
|
35
|
+
|
|
|
36
|
+
# Award/Gift only if it looks like a winning notice (phishing risk)
|
|
37
|
+
\b(?:ödül|hediye).{0,20}(?:kazand[ıi]n[ıi]z|hesab[ıi]n[ıi]za\s+tan[ıi]mland[ıi])\b
|
|
38
|
+
)
|
|
39
|
+
""",
|
|
40
|
+
re.VERBOSE,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _normalize(text: str | None) -> str:
|
|
45
|
+
"""Lowercase with explicit Turkish dotted-İ and dotless-I mapping."""
|
|
46
|
+
if not text:
|
|
47
|
+
return ""
|
|
48
|
+
# "I" harfinin "i" yerine "ı" olmasına ve "İ" harfinin "i" olmasına dikkat ediyoruz.
|
|
49
|
+
return text.replace("İ", "i").replace("I", "ı").lower()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_fast_category(reason: str) -> str:
|
|
53
|
+
"""Extracts the category from a reason string formatted as 'heuristic:category:token'"""
|
|
54
|
+
# Non-heuristic reasons don't have a category (return empty string)
|
|
55
|
+
if not reason.startswith("heuristic:"):
|
|
56
|
+
return ""
|
|
57
|
+
# Expecting format: heuristic:category:token
|
|
58
|
+
parts = reason.split(":", 2)
|
|
59
|
+
if len(parts) == 3:
|
|
60
|
+
return parts[1]
|
|
61
|
+
# No category provided
|
|
62
|
+
return "uncategorized"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fast_analyze(meta: MailMeta) -> ScanResult:
|
|
66
|
+
"""
|
|
67
|
+
Tier-1 heuristic analysis using pre-compiled regex patterns.
|
|
68
|
+
Optimized for short-circuit evaluation and safe attachment handling.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
# 1. Eklenti (Attachment) Koruması (En hızlı çıkış noktası)
|
|
72
|
+
if meta.has_attachment:
|
|
73
|
+
return ScanResult(mail=meta, decision="TUT", reason="has_attachment")
|
|
74
|
+
|
|
75
|
+
# 2. Öncelikli Normalizasyon (Header Seviyesi)
|
|
76
|
+
# Bellek tahsisini geciktirmek için önce sadece başlıkları işliyoruz.
|
|
77
|
+
subject_text = _normalize(meta.subject)
|
|
78
|
+
sender_text = _normalize(meta.sender)
|
|
79
|
+
header_text = f"{subject_text} {sender_text}"
|
|
80
|
+
|
|
81
|
+
# Referansı yerel değişkene alıyoruz ki lookup hızı artsın
|
|
82
|
+
# Prefer module-level patched patterns (tests) and fall back to keyword_manager.
|
|
83
|
+
wl_pattern = globals().get("WHITELIST_PATTERN")
|
|
84
|
+
if wl_pattern is None:
|
|
85
|
+
wl_pattern = keyword_manager.whitelist_pattern
|
|
86
|
+
junk_pattern = globals().get("JUNK_PATTERN")
|
|
87
|
+
if junk_pattern is None:
|
|
88
|
+
junk_pattern = keyword_manager.junk_pattern
|
|
89
|
+
|
|
90
|
+
# 3. Whitelist Kontrolü (Önce sadece Header'da)
|
|
91
|
+
if wl_pattern:
|
|
92
|
+
match = wl_pattern.search(header_text)
|
|
93
|
+
if match:
|
|
94
|
+
return ScanResult(mail=meta, decision="TUT", reason=f"whitelist:{match.group()}")
|
|
95
|
+
|
|
96
|
+
# 4. Body Normalizasyonu (Header'dan whitelist geçilemezse çalışır)
|
|
97
|
+
body_text = _normalize(meta.body_preview)
|
|
98
|
+
|
|
99
|
+
# Whitelist fallback (Body Kontrolü)
|
|
100
|
+
if wl_pattern:
|
|
101
|
+
match = wl_pattern.search(body_text)
|
|
102
|
+
if match:
|
|
103
|
+
return ScanResult(mail=meta, decision="TUT", reason=f"whitelist:{match.group()}")
|
|
104
|
+
|
|
105
|
+
full_text = f"{header_text} {body_text}"
|
|
106
|
+
|
|
107
|
+
# 5. Phishing ve Safe-Guard Kontrolü
|
|
108
|
+
safe_match = _ALWAYS_KEEP_PATTERN.search(full_text)
|
|
109
|
+
if safe_match:
|
|
110
|
+
return ScanResult(mail=meta, decision="TUT", reason=f"safe-guard:{safe_match.group()}")
|
|
111
|
+
|
|
112
|
+
# 6. Junk / Spam Kontrolü (Gönderici Hariç - Yalnızca Konu ve İçerik)
|
|
113
|
+
if junk_pattern:
|
|
114
|
+
content_text = f"{subject_text} {body_text}"
|
|
115
|
+
match = junk_pattern.search(content_text)
|
|
116
|
+
if match:
|
|
117
|
+
matched_token = match.group()
|
|
118
|
+
# Dinamik kategori haritasından token'ın kategorisini çekiyoruz
|
|
119
|
+
category = keyword_manager.get_category_for_match(matched_token)
|
|
120
|
+
return ScanResult(mail=meta, decision="SIL", reason=f"heuristic:{category}:{matched_token}")
|
|
121
|
+
|
|
122
|
+
# 7. Varsayılan (Eşleşme Yok)
|
|
123
|
+
return ScanResult(mail=meta, decision="TUT", reason="no match")
|