emailpadu / app.py
Pikeuli's picture
Update app.py
d63f324 verified
Raw
History Blame Contribute Delete
11.1 kB
"""
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 "<p>(No HTML Content)</p>"
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 <a>
if html_body:
anchors = re.findall(r'<a\b[^>]*href\s*=\s*["\'](https?://[^"\']+)["\'][^>]*>(.*?)</a>', 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/<doc_id>", 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)