|
|
@@ -9,15 +9,18 @@ import time
|
|
|
import traceback
|
|
|
from datetime import datetime, timezone
|
|
|
from email.header import decode_header
|
|
|
-from email.utils import parseaddr, parsedate_to_datetime
|
|
|
+from email.utils import getaddresses, parseaddr, parsedate_to_datetime
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
from urllib.error import HTTPError, URLError
|
|
|
from urllib.parse import urlencode
|
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
|
|
|
-HOST = "127.0.0.1"
|
|
|
-PORT = 17373
|
|
|
+HOST = os.environ.get("HOTMAIL_HELPER_HOST", "127.0.0.1").strip() or "127.0.0.1"
|
|
|
+try:
|
|
|
+ PORT = int(os.environ.get("HOTMAIL_HELPER_PORT", "17373") or 17373)
|
|
|
+except Exception:
|
|
|
+ PORT = 17373
|
|
|
LIVE_TOKEN_URL = "https://login.live.com/oauth20_token.srf"
|
|
|
ENTRA_COMMON_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
|
|
|
ENTRA_CONSUMERS_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
|
|
|
@@ -65,6 +68,7 @@ FETCH_LIMIT_DEFAULT = 5
|
|
|
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
|
ACCOUNT_LOG_PATH = os.path.join(BASE_DIR, "data", "account-run-history.txt")
|
|
|
ACCOUNT_RECORDS_SNAPSHOT_PATH = os.path.join(BASE_DIR, "data", "account-run-history.json")
|
|
|
+A4SKY_IMAP_CONFIG_PATH = os.path.join(BASE_DIR, "data", "a4sky-imap.local.json")
|
|
|
ACCOUNT_RECORDS_LOCK = threading.Lock()
|
|
|
|
|
|
|
|
|
@@ -328,12 +332,51 @@ def refresh_access_token(client_id, refresh_token, strategy_names=None):
|
|
|
raise RuntimeError(f"Token refresh failed on all endpoints: {details}")
|
|
|
|
|
|
|
|
|
+def load_local_imap_config():
|
|
|
+ if not os.path.exists(A4SKY_IMAP_CONFIG_PATH):
|
|
|
+ return {}
|
|
|
+ try:
|
|
|
+ with open(A4SKY_IMAP_CONFIG_PATH, "r", encoding="utf-8") as handle:
|
|
|
+ payload = json.load(handle)
|
|
|
+ return payload if isinstance(payload, dict) else {}
|
|
|
+ except Exception as exc:
|
|
|
+ raise RuntimeError(f"Invalid local IMAP config: {exc}") from exc
|
|
|
+
|
|
|
+
|
|
|
+def resolve_basic_imap_settings(payload):
|
|
|
+ local_config = load_local_imap_config()
|
|
|
+ host = str(payload.get("host") or local_config.get("host") or "").strip()
|
|
|
+ username = str(payload.get("username") or local_config.get("username") or "").strip()
|
|
|
+ password = str(payload.get("password") or local_config.get("password") or "").strip()
|
|
|
+ port_raw = payload.get("port") if payload.get("port") is not None else local_config.get("port")
|
|
|
+ try:
|
|
|
+ port = int(port_raw or 993)
|
|
|
+ except Exception as exc:
|
|
|
+ raise RuntimeError(f"Invalid IMAP port: {exc}") from exc
|
|
|
+
|
|
|
+ if not host or not username or not password:
|
|
|
+ raise RuntimeError("Missing IMAP host/username/password. Please fill data/a4sky-imap.local.json or pass credentials explicitly.")
|
|
|
+
|
|
|
+ return {
|
|
|
+ "host": host,
|
|
|
+ "port": max(1, port),
|
|
|
+ "username": username,
|
|
|
+ "password": password,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def open_basic_imap_mailbox(host, port, username, password):
|
|
|
+ client = imaplib.IMAP4_SSL(host, port)
|
|
|
+ client.login(username, password)
|
|
|
+ return client
|
|
|
+
|
|
|
+
|
|
|
def build_xoauth2(email_addr, access_token):
|
|
|
return f"user={email_addr}\x01auth=Bearer {access_token}\x01\x01".encode("utf-8")
|
|
|
|
|
|
|
|
|
def open_mailbox(email_addr, access_token):
|
|
|
- client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, timeout=REQUEST_TIMEOUT_SECONDS)
|
|
|
+ client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
|
|
|
client.authenticate("XOAUTH2", lambda _: build_xoauth2(email_addr, access_token))
|
|
|
return client
|
|
|
|
|
|
@@ -427,6 +470,25 @@ def normalize_message(message_id, raw_bytes, mailbox):
|
|
|
subject = decode_mime_header(parsed.get("Subject", ""))
|
|
|
body = extract_text_part(parsed)
|
|
|
timestamp_ms = to_timestamp_ms(parsed.get("Date"))
|
|
|
+
|
|
|
+ recipient_headers = []
|
|
|
+ for header_name in ["To", "Delivered-To", "Envelope-To", "X-Original-To", "Cc"]:
|
|
|
+ recipient_headers.extend(parsed.get_all(header_name, []))
|
|
|
+
|
|
|
+ recipient_items = []
|
|
|
+ recipient_addresses = []
|
|
|
+ for recipient_name, recipient_addr in getaddresses(recipient_headers):
|
|
|
+ normalized_addr = str(recipient_addr or "").strip().lower()
|
|
|
+ if not normalized_addr:
|
|
|
+ continue
|
|
|
+ recipient_addresses.append(normalized_addr)
|
|
|
+ recipient_items.append({
|
|
|
+ "emailAddress": {
|
|
|
+ "address": normalized_addr,
|
|
|
+ "name": str(recipient_name or "").strip(),
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
return {
|
|
|
"id": str(message_id),
|
|
|
"mailbox": mailbox,
|
|
|
@@ -437,6 +499,8 @@ def normalize_message(message_id, raw_bytes, mailbox):
|
|
|
"name": sender_name.strip(),
|
|
|
}
|
|
|
},
|
|
|
+ "toRecipients": recipient_items,
|
|
|
+ "recipientAddresses": recipient_addresses,
|
|
|
"bodyPreview": body[:500],
|
|
|
"receivedDateTime": to_iso_string(timestamp_ms),
|
|
|
"receivedTimestamp": timestamp_ms,
|
|
|
@@ -488,6 +552,70 @@ def fetch_messages_for_mailboxes(email_addr, access_token, mailboxes, top):
|
|
|
return {"mailboxResults": mailbox_results, "messages": all_messages}
|
|
|
|
|
|
|
|
|
+def fetch_basic_imap_messages(host, port, username, password, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
|
|
|
+ client = None
|
|
|
+ logical_mailbox = normalize_mailbox_label(mailbox)
|
|
|
+ try:
|
|
|
+ client = open_basic_imap_mailbox(host, port, username, password)
|
|
|
+ select_mailbox(client, mailbox)
|
|
|
+ status, data = client.search(None, "ALL")
|
|
|
+ if status != "OK" or not data or not data[0]:
|
|
|
+ return {"mailbox": logical_mailbox, "messages": [], "count": 0}
|
|
|
+
|
|
|
+ message_ids = data[0].split()
|
|
|
+ selected_ids = list(reversed(message_ids[-max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)):]))
|
|
|
+ messages = []
|
|
|
+ for message_id in selected_ids:
|
|
|
+ fetch_status, fetch_data = client.fetch(message_id, "(RFC822)")
|
|
|
+ if fetch_status != "OK" or not fetch_data:
|
|
|
+ continue
|
|
|
+ raw_bytes = b""
|
|
|
+ for item in fetch_data:
|
|
|
+ if isinstance(item, tuple) and len(item) >= 2:
|
|
|
+ raw_bytes = item[1]
|
|
|
+ break
|
|
|
+ if not raw_bytes:
|
|
|
+ continue
|
|
|
+ messages.append(normalize_message(message_id.decode("utf-8", errors="ignore"), raw_bytes, logical_mailbox))
|
|
|
+ return {"mailbox": logical_mailbox, "messages": messages, "count": len(messages)}
|
|
|
+ finally:
|
|
|
+ if client is not None:
|
|
|
+ try:
|
|
|
+ client.logout()
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+def fetch_basic_imap_messages_for_mailboxes(host, port, username, password, mailboxes, top):
|
|
|
+ mailbox_results = []
|
|
|
+ all_messages = []
|
|
|
+ for mailbox in mailboxes or ["INBOX"]:
|
|
|
+ result = fetch_basic_imap_messages(host, port, username, password, mailbox=mailbox, top=top)
|
|
|
+ mailbox_results.append(result)
|
|
|
+ all_messages.extend(result["messages"])
|
|
|
+ all_messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
|
|
|
+ return {"mailboxResults": mailbox_results, "messages": all_messages}
|
|
|
+
|
|
|
+
|
|
|
+def collect_basic_imap_messages(payload, mailboxes, top):
|
|
|
+ settings = resolve_basic_imap_settings(payload)
|
|
|
+ result = fetch_basic_imap_messages_for_mailboxes(
|
|
|
+ settings["host"],
|
|
|
+ settings["port"],
|
|
|
+ settings["username"],
|
|
|
+ settings["password"],
|
|
|
+ mailboxes,
|
|
|
+ top,
|
|
|
+ )
|
|
|
+ result["transport"] = "imap-basic"
|
|
|
+ result["settings"] = {
|
|
|
+ "host": settings["host"],
|
|
|
+ "port": settings["port"],
|
|
|
+ "username": settings["username"],
|
|
|
+ }
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
def normalize_graph_message(message, mailbox):
|
|
|
sender = message.get("from", {}) or {}
|
|
|
email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
|
|
|
@@ -664,11 +792,13 @@ def extract_code(text):
|
|
|
return ""
|
|
|
|
|
|
|
|
|
-def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp):
|
|
|
+def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp, target_email=""):
|
|
|
sender_keywords = [str(item).strip().lower() for item in sender_filters or [] if str(item).strip()]
|
|
|
subject_keywords = [str(item).strip().lower() for item in subject_filters or [] if str(item).strip()]
|
|
|
excluded = {str(item).strip() for item in exclude_codes or [] if str(item).strip()}
|
|
|
|
|
|
+ normalized_target_email = str(target_email or "").strip().lower()
|
|
|
+
|
|
|
def match_message(message, apply_time_filter):
|
|
|
timestamp = int(message.get("receivedTimestamp") or 0)
|
|
|
if apply_time_filter and filter_after_timestamp and timestamp and timestamp < int(filter_after_timestamp):
|
|
|
@@ -677,7 +807,16 @@ def select_latest_code(messages, sender_filters, subject_filters, exclude_codes,
|
|
|
sender = str(message.get("from", {}).get("emailAddress", {}).get("address", "")).lower()
|
|
|
subject = str(message.get("subject", ""))
|
|
|
preview = str(message.get("bodyPreview", ""))
|
|
|
- combined = " ".join([sender, subject.lower(), preview.lower()])
|
|
|
+ recipient_addresses = [
|
|
|
+ str(item or "").strip().lower()
|
|
|
+ for item in message.get("recipientAddresses", [])
|
|
|
+ if str(item or "").strip()
|
|
|
+ ]
|
|
|
+ recipient_text = " ".join(recipient_addresses)
|
|
|
+ combined = " ".join([sender, subject.lower(), preview.lower(), recipient_text])
|
|
|
+ if normalized_target_email and recipient_addresses and normalized_target_email not in recipient_addresses:
|
|
|
+ return None
|
|
|
+
|
|
|
code = extract_code(" ".join([subject, preview, sender]))
|
|
|
if not code or code in excluded:
|
|
|
return None
|
|
|
@@ -740,15 +879,46 @@ class HotmailHelperHandler(BaseHTTPRequestHandler):
|
|
|
})
|
|
|
return
|
|
|
|
|
|
+ top = max(1, min(int(payload.get("top") or FETCH_LIMIT_DEFAULT), 30))
|
|
|
+ mailboxes = payload.get("mailboxes") if isinstance(payload.get("mailboxes"), list) else [payload.get("mailbox") or "INBOX"]
|
|
|
+
|
|
|
+ if self.path == "/imap-messages":
|
|
|
+ result = collect_basic_imap_messages(payload, mailboxes, top)
|
|
|
+ json_response(self, 200, {
|
|
|
+ "ok": True,
|
|
|
+ "messages": result["messages"],
|
|
|
+ "mailboxResults": result["mailboxResults"],
|
|
|
+ "transport": result.get("transport") or "",
|
|
|
+ "settings": result.get("settings") or {},
|
|
|
+ })
|
|
|
+ return
|
|
|
+
|
|
|
+ if self.path == "/imap-code":
|
|
|
+ result = collect_basic_imap_messages(payload, mailboxes, top)
|
|
|
+ selected = select_latest_code(
|
|
|
+ result["messages"],
|
|
|
+ payload.get("senderFilters") or [],
|
|
|
+ payload.get("subjectFilters") or [],
|
|
|
+ payload.get("excludeCodes") or [],
|
|
|
+ int(payload.get("filterAfterTimestamp") or 0),
|
|
|
+ payload.get("targetEmail") or payload.get("email") or "",
|
|
|
+ )
|
|
|
+ json_response(self, 200, {
|
|
|
+ "ok": True,
|
|
|
+ "code": selected["code"],
|
|
|
+ "message": selected["message"],
|
|
|
+ "usedTimeFallback": selected["usedTimeFallback"],
|
|
|
+ "transport": result.get("transport") or "",
|
|
|
+ "settings": result.get("settings") or {},
|
|
|
+ })
|
|
|
+ return
|
|
|
+
|
|
|
email_addr = str(payload.get("email") or "").strip()
|
|
|
client_id = str(payload.get("clientId") or "").strip()
|
|
|
refresh_token = str(payload.get("refreshToken") or "").strip()
|
|
|
if not email_addr or not client_id or not refresh_token:
|
|
|
raise RuntimeError("Missing email/clientId/refreshToken")
|
|
|
|
|
|
- top = max(1, min(int(payload.get("top") or FETCH_LIMIT_DEFAULT), 30))
|
|
|
- mailboxes = payload.get("mailboxes") if isinstance(payload.get("mailboxes"), list) else [payload.get("mailbox") or "INBOX"]
|
|
|
-
|
|
|
if self.path == "/messages":
|
|
|
result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
|
|
|
json_response(self, 200, {
|
|
|
@@ -769,6 +939,7 @@ class HotmailHelperHandler(BaseHTTPRequestHandler):
|
|
|
payload.get("subjectFilters") or [],
|
|
|
payload.get("excludeCodes") or [],
|
|
|
int(payload.get("filterAfterTimestamp") or 0),
|
|
|
+ payload.get("targetEmail") or "",
|
|
|
)
|
|
|
json_response(self, 200, {
|
|
|
"ok": True,
|