File size: 11,083 Bytes
d63f324
 
 
 
 
 
1d047ce
26944da
065c62e
26944da
 
a866cec
b999f01
d63f324
26944da
1d047ce
d63f324
67e6d6c
 
 
 
ed2d335
67e6d6c
1d047ce
 
ed2d335
 
 
 
26944da
 
d63f324
 
 
ed2d335
1d047ce
67e6d6c
 
 
 
26944da
d63f324
26944da
 
a866cec
ed2d335
a866cec
 
d63f324
26944da
 
 
d63f324
ed2d335
67e6d6c
 
 
 
d63f324
 
ed2d335
 
 
67e6d6c
d63f324
 
ed2d335
d63f324
67e6d6c
 
 
 
 
d63f324
 
 
 
ed2d335
 
 
 
 
 
 
 
 
 
b999f01
ed2d335
 
065c62e
ed2d335
 
 
 
065c62e
d63f324
 
 
 
ed2d335
d63f324
9532261
d63f324
9532261
ed2d335
9532261
ed2d335
 
 
b999f01
d63f324
 
ed2d335
 
 
9532261
d63f324
ed2d335
 
 
 
9532261
ed2d335
 
9532261
ed2d335
 
 
065c62e
ed2d335
 
 
065c62e
d63f324
 
 
 
065c62e
ed2d335
 
 
 
 
 
 
 
 
 
d63f324
ed2d335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d63f324
ed2d335
 
 
 
 
 
 
 
 
 
 
 
 
 
d63f324
ed2d335
 
 
 
 
 
d63f324
ed2d335
 
 
 
 
 
 
 
 
 
 
 
d63f324
 
 
 
 
 
 
ed2d335
d63f324
ed2d335
 
d63f324
26944da
ed2d335
d63f324
26944da
a866cec
 
 
 
 
 
ed2d335
a866cec
 
ed2d335
 
a866cec
67e6d6c
d63f324
 
 
 
 
67e6d6c
 
 
 
 
 
 
ed2d335
 
 
d63f324
 
ed2d335
67e6d6c
26944da
ed2d335
d63f324
ed2d335
 
 
 
26944da
ed2d335
26944da
d63f324
 
26944da
d63f324
 
26944da
d63f324
 
0ffddf8
a866cec
ed2d335
a866cec
065c62e
1d047ce
26944da
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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
"""
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)