""" Emailpadu Server V2 - 2026 Edition Dikemaskini untuk menyokong Hugging Face Hub v1.0+ dan nh3 v0.3+ Gunakan sekurang-kurangnya Python 3.12+ """ import os import logging import re from uuid import uuid4 from datetime import datetime, timezone from email.utils import parseaddr from html import unescape from concurrent.futures import ThreadPoolExecutor import requests import nh3 # Pustaka sanitasi HTML standard 2026 (menggantikan bleach) from flask import Flask, request, jsonify, Response from huggingface_hub import HfApi, hf_hub_download # ───────────────────────────────────────── # SETUP & LOGGING # ───────────────────────────────────────── app = Flask(__name__) logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s' ) logger = logging.getLogger(__name__) # Executor untuk tugasan latar (Background Tasks) executor = ThreadPoolExecutor(max_workers=5) # Environment Variables DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL") HF_TOKEN = os.getenv("HF_TOKEN") DATASET_ID = os.getenv("DATASET_ID") WEBHOOK_USER = os.getenv("WEBHOOK_USER") WEBHOOK_PASS = os.getenv("WEBHOOK_PASS") # API HF v1.0+ kini dikuasakan oleh `httpx` untuk operasi pantas api = HfApi(token=HF_TOKEN) # ───────────────────────────────────────── # HELPERS (SANITIZATION & EXTRACTION) # ───────────────────────────────────────── def truncate(value: str | None, max_len: int) -> str: value = str(value or "") return value if len(value) <= max_len else value[:max_len - 1] + "…" def clean_possible_link(link: str | None) -> str | None: if not link: return None link = unescape(str(link).strip()) link = link.replace("=\r\n", "").replace("=\n", "").replace("=3D", "=") return link.strip(' \t\r\n<>"\'.,()[]') def sanitize_email_html(html_content: str | None) -> str: """Guna pustaka nh3 (v0.3.x ke atas) untuk hapus tag XSS. 20x ganda lebih pantas.""" if not html_content: return "

(No HTML Content)

" return nh3.clean(str(html_content)) def html_to_text(html: str | None) -> str: """Tukar HTML ke plain text untuk description Discord menggunakan enjin nh3.""" if not html: return "" text = nh3.clean(str(html), tags=set()) # Buang semua tag HTML text = unescape(text) text = re.sub(r"[ \t]+", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def extract_sender(data: dict) -> str: """Ekstrak maklumat penghantar dari JSON CloudMailin.""" headers = data.get("headers") or {} envelope = data.get("envelope") or {} from_header = ( headers.get("From") or headers.get("from") or envelope.get("from") or data.get("from") ) if not from_header: return "Unknown Sender" if isinstance(from_header, list): from_header = from_header[0] name, addr = parseaddr(str(from_header)) if addr: return f"{name} <{addr}>" if name else addr return str(from_header) def extract_important_link(plain_body: str = "", html_body: str = "", reply_plain: str = "") -> str | None: """Ekstrak pautan pengesahan (verify) daripada kandungan emel.""" action_words = {"verify", "confirm", "activate", "reset", "password", "auth", "magic"} bad_words = {"unsubscribe", "privacy", "help", "redditinc", "twitter", "facebook"} candidates: list[tuple[int, str]] =[] def add_candidate(url: str, score: int = 0): clean = clean_possible_link(url) if not clean: return lower = clean.lower() if any(bad in lower for bad in bad_words): score -= 30 if any(word in lower for word in action_words): score += 50 candidates.append((score, clean)) # Carian di dalam teks biasa for source in[reply_plain, plain_body]: if not source: continue found = re.findall(r'https?://[^\s<>"\']+', str(source)) for url in found: add_candidate(url, 10) # Carian di dalam tag HTML if html_body: anchors = re.findall(r']*href\s*=\s*["\'](https?://[^"\']+)["\'][^>]*>(.*?)', str(html_body), re.I|re.S) for href, inner in anchors: anchor_text = html_to_text(inner).lower() score = 20 if any(word in anchor_text for word in action_words): score += 100 add_candidate(href, score) if not candidates: return None candidates.sort(key=lambda x: x[0], reverse=True) return candidates[0][1] if candidates[0][0] >= 0 else None # ───────────────────────────────────────── # CORE LOGIC (BACKGROUND TASKS) # ───────────────────────────────────────── def process_email_background(data: dict, host_url: str): """ Fungsi pemprosesan berat yang kini berjalan secara stabil menggunakan ThreadPoolExecutor. """ try: headers = data.get("headers") if isinstance(data.get("headers"), dict) else {} subject = str(headers.get("subject") or "No Subject") sender = extract_sender(data) plain_body = str(data.get("plain") or "") html_body = str(data.get("html") or "") reply_plain = str(data.get("reply_plain") or "") body_clean = (plain_body or reply_plain or html_to_text(html_body) or "Kosong").split("On ")[0].strip() important_link = extract_important_link(plain_body, html_body, reply_plain) # 1. Simpan HTML ke HF Dataset (v1.0 API) & Jana Preview URL html_preview_url = None if html_body and HF_TOKEN and DATASET_ID: html_id = uuid4().hex[:16] try: safe_html = sanitize_email_html(html_body) api.upload_file( path_or_fileobj=safe_html.encode("utf-8"), path_in_repo=f"html/{html_id}.html", repo_id=DATASET_ID, repo_type="dataset", ) html_preview_url = f"{host_url.rstrip('/')}/html/{html_id}" except Exception as e: logger.error(f"HF HTML Upload Fail: {e}") # 2. Simpan Log Teks ke HF (v1.0 API) if HF_TOKEN and DATASET_ID: ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") filename = f"logs/email_{ts}_{uuid4().hex[:5]}.txt" log_content = f"From: {sender}\nSubject: {subject}\nLink: {important_link}\n\nPreview: {html_preview_url}\n\n---BODY---\n{body_clean}" try: api.upload_file( path_or_fileobj=log_content.encode("utf-8"), path_in_repo=filename, repo_id=DATASET_ID, repo_type="dataset", ) except Exception as e: logger.error(f"HF Log Upload Fail: {e}") # 3. Hantar Notifikasi ke Discord (Webhooks 2026 Layout) if DISCORD_WEBHOOK_URL: embed = { "title": f"📩 {truncate(subject, 250)}", "description": truncate(body_clean, 1700), "color": 3447003, "timestamp": datetime.now(timezone.utc).isoformat(), "fields":[{"name": "📧 Dari", "value": truncate(sender, 500)}] } content_parts = [] if important_link: embed["url"] = important_link embed["fields"].append({"name": "🔗 Link Penting", "value": important_link}) content_parts.append(f"🔗 <{important_link}>") if html_preview_url: embed["fields"].append({"name": "🖼️ Preview Email", "value": html_preview_url}) content_parts.append(f"🖼️ <{html_preview_url}>") payload = { "embeds": [embed], # Anda boleh tambah "flags": 4096 di sini untuk ciri 'Silent Message' baharu Discord # "flags": 4096 } if content_parts: payload["content"] = "\n".join(content_parts) # Timeout di-set pada 10 untuk menghalang sambungan HTTP yang tergantung requests.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10) logger.info(f"Selesai memproses e-mel daripada {sender}") except Exception: logger.exception("Ralat kritikal dalam tugasan latar") # ───────────────────────────────────────── # ROUTES # ───────────────────────────────────────── @app.route("/", methods=["GET"]) def home(): return "Emailpadu Server V2 (2026) is Running ✅", 200 @app.route("/health", methods=["GET"]) def health(): return jsonify(status="OK", timestamp=datetime.now(timezone.utc).isoformat()), 200 @app.route("/html/", methods=["GET"]) def view_html(doc_id: str): """Paparkan HTML yang telah dibersihkan secara selamat (XSS safe).""" if not re.fullmatch(r"[a-f0-9]{16}", doc_id): return "Invalid ID", 400 try: file_path = hf_hub_download( repo_id=DATASET_ID, repo_type="dataset", filename=f"html/{doc_id}.html", token=HF_TOKEN ) with open(file_path, "r", encoding="utf-8") as f: content = f.read() return Response(content, mimetype="text/html") except Exception as e: logger.warning(f"File not found: {e}") return "Email not found", 404 @app.route("/webhook", methods=["POST"]) def handle_webhook(): # 1. Auth Check (Standard Werkzeug 3.x+) auth = request.authorization if WEBHOOK_USER and WEBHOOK_PASS: if not auth or auth.username != WEBHOOK_USER or auth.password != WEBHOOK_PASS: return jsonify(error="Unauthorized"), 401 # 2. Parse Data data = request.get_json(silent=True) if not data: return jsonify(error="Invalid JSON"), 400 # 3. Masukkan Tugasan Dalam Pool Moden executor.submit(process_email_background, data, request.host_url) # 4. Beri Maklum Balas Pantas (202 Accepted) return jsonify(status="Accepted", message="Tugasan dimasukkan ke Executor"), 202 # ───────────────────────────────────────── # RUN (Gunicorn will use 'app') # ───────────────────────────────────────── if __name__ == "__main__": app.run(host="0.0.0.0", port=7860)