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 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,8 @@
1
+ """
2
+ analyzer.py | Email analysis modules (re-exports for backwards compatibility).
3
+ """
4
+
5
+ from .fast import fast_analyze
6
+ from .pro import pro_analyze
7
+
8
+ __all__ = ["fast_analyze", "pro_analyze"]
@@ -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")