From bb078a37e778d45af54aceae0ca1fe326c535477 Mon Sep 17 00:00:00 2001 From: Jimmy Galland Date: Thu, 26 Feb 2026 05:40:29 +0000 Subject: [PATCH] Update mail2sms/main.py --- mail2sms/main.py | 555 ++++++++++++++++++++++------------------------- 1 file changed, 256 insertions(+), 299 deletions(-) diff --git a/mail2sms/main.py b/mail2sms/main.py index 0d1ba29..bd7f207 100644 --- a/mail2sms/main.py +++ b/mail2sms/main.py @@ -1,300 +1,257 @@ -import os -import time -import json -import re -import socket -import requests -import smtplib -from email.message import EmailMessage -from imapclient import IMAPClient -from requests.auth import HTTPBasicAuth -from mail2sms.text_utils import html_to_sms_text - -# ============================================================ -# CONFIGURATION -# ============================================================ - -IMAP_SERVER = os.getenv("IMAP_SERVER") -IMAP_PORT = int(os.getenv("IMAP_PORT", 993)) -EMAIL_ACCOUNT = os.getenv("EMAIL_ACCOUNT") -EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD") - -SMTP_SERVER = os.getenv("SMTP_SERVER") -SMTP_PORT = int(os.getenv("SMTP_PORT", 587)) - -API_URL = os.getenv("API_URL") - -API_USERNAME = os.getenv("API_USERNAME") -API_PASSWORD = os.getenv("API_PASSWORD") - -DEFAULT_DEVICE_ID = os.getenv("DEFAULT_DEVICE_ID") -DEFAULT_SIM_NUMBER = os.getenv("DEFAULT_SIM_NUMBER") - -IDLE_TIMEOUT = 60 * 25 # 25 minutes (safe before typical 30 min timeout) -TEST_MODE = os.getenv("TEST_MODE", "false").lower() == "true" - -# ============================================================ -# LOGGING -# ============================================================ - -def log(message): - print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}", flush=True) - - - -def html_to_sms_text(raw_html): - """ - Convert HTML content to clean SMS-compatible plain text. - - Removes HTML tags - - Decodes HTML entities - - Normalizes unicode - - Removes unsupported SMS characters - """ - - if not raw_html: - return "" - - # Parse HTML - soup = BeautifulSoup(raw_html, "html.parser") - - # Get text only - text = soup.get_text(separator=" ") - - # Decode HTML entities (  & etc.) - text = html.unescape(text) - - # Normalize unicode (remove weird accents/combined chars) - text = unicodedata.normalize("NFKD", text) - - # Remove non GSM basic characters - gsm_basic = ( - "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ" - + ' !"#¤%&\'()*+,-./' - + "0123456789:;<=>?" - + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - + "ÄÖÑܧ¿" - + "abcdefghijklmnopqrstuvwxyz" - + "äöñüà" - ) - - cleaned = "".join(c for c in text if c in gsm_basic) - - # Remove multiple spaces - cleaned = re.sub(r"\s+", " ", cleaned).strip() - - return cleaned - -# ============================================================ -# SUBJECT PARAMETER EXTRACTION -# ============================================================ - -def extract_parameters(subject): - """ - Extract parameters from email subject. - Returns: to, device_id, sim_number, api_login, api_password - """ - if not subject: - return None, None, None, None, None - - # Regex case-insensitive - def get_param(name): - match = re.search(rf"{name}\s*=\s*([^; ]+)", subject, re.IGNORECASE) - return match.group(1) if match else None - - to = get_param("to") - device_id = get_param("deviceId") - sim_number = get_param("simNumber") - api_login = get_param("login") - api_password = get_param("password") - - return to, device_id, sim_number, api_login, api_password - -# ============================================================ -# SMS SEND -# ============================================================ - -def send_sms(payload, username, password): - """ - Send SMS using the API. - In TEST_MODE, it returns a fake response. - If username/password are provided, they override the defaults from env. - """ - if TEST_MODE: - log("TEST_MODE enabled - Fake SMS send") - class FakeResponse: - status_code = 202 - text = json.dumps({"id": "fake-id", "state": "Sent"}) - return FakeResponse() - - return requests.post( - API_URL, - auth=HTTPBasicAuth(username, password), - headers={"Content-Type": "application/json"}, - data=json.dumps(payload), - timeout=15 - ) -# ============================================================ -# STATUS EMAIL -# ============================================================ - -def send_status_email(to_address, phone, status_code, response_text): - if TEST_MODE: - log("TEST_MODE enabled - Fake email send") - return - msg = EmailMessage() - msg["From"] = EMAIL_ACCOUNT - msg["To"] = to_address - msg["Subject"] = f"SMS Status for {phone}" - msg.set_content( - f"Destination: {phone}\n" - f"HTTP Code: {status_code}\n\n" - f"API Response:\n{response_text}" - ) - try: - with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=10) as server: - server.starttls() - server.login(EMAIL_ACCOUNT, EMAIL_PASSWORD) - server.send_message(msg) - log(f"Status email sent to {to_address}") - except Exception as e: - log(f"SMTP error: {e}") - -# ============================================================ -# PROCESS EMAILS READING -# ============================================================ - -def process_unseen(server): - messages = server.search(['UNSEEN']) - if not messages: - return - log(f"{len(messages)} new message(s) detected") - fetched = server.fetch(messages, ['RFC822']) - for uid, message_data in fetched.items(): - try: - import email - msg = email.message_from_bytes(message_data[b'RFC822']) - sender = email.utils.parseaddr(msg["From"])[1] - subject = msg["Subject"] - phone_number, subject_device_id, subject_sim_number, api_login, api_password = extract_parameters(subject) - - if not phone_number: - log("No phone number found in subject") - server.add_flags(uid, ['\\Seen']) - continue - device_id = subject_device_id or DEFAULT_DEVICE_ID - sim_number = subject_sim_number or DEFAULT_SIM_NUMBER - username = api_login or API_USERNAME - if not username: - log("No API Username found") - server.add_flags(uid, ['\\Seen']) - continue - password = api_password or API_PASSWORD - if not password: - log("No API Password found") - server.add_flags(uid, ['\\Seen']) - continue - - sms_text = "" - - if msg.is_multipart(): - for part in msg.walk(): - content_type = part.get_content_type() - payload = part.get_payload(decode=True) - - if not payload: - continue - - decoded = payload.decode(errors="ignore") - - if content_type == "text/plain": - sms_text = decoded.strip() - break - - elif content_type == "text/html" and not sms_text: - sms_text = html_to_sms_text(decoded) - - else: - payload = msg.get_payload(decode=True) - if payload: - decoded = payload.decode(errors="ignore") - if msg.get_content_type() == "text/html": - sms_text = html_to_sms_text(decoded) - else: - sms_text = decoded.strip() - - payload = { - "textMessage": {"text": sms_text}, - "phoneNumbers": [phone_number] - } - if device_id: - payload["deviceId"] = device_id - if sim_number: - payload["simNumber"] = int(sim_number) - log(f"Sending SMS to {phone_number} using deviceId={device_id} simNumber={sim_number}") - response = send_sms(payload, username, password) - log(f"HTTP {response.status_code}") - send_status_email(sender, phone_number, response.status_code, response.text) - if 200 <= response.status_code <= 204: - server.delete_messages(uid) - log("Email deleted (success)") - else: - server.add_flags(uid, ['\\Seen']) - log("Email kept (non-success response)") - except Exception as e: - log(f"Processing error: {e}") - server.add_flags(uid, ['\\Seen']) - server.expunge() - -# ============================================================ -# HANDLE IMAP CONNECTION -# ============================================================ - -def connect(): - while True: - try: - log("Connecting to IMAP server...") - server = IMAPClient(IMAP_SERVER, port=IMAP_PORT, ssl=True, timeout=20) - server.login(EMAIL_ACCOUNT, EMAIL_PASSWORD) - server.select_folder("INBOX") - log("IMAP connected successfully") - return server - except (socket.error, Exception) as e: - log(f"IMAP connection failed: {e}") - time.sleep(10) - -# ============================================================ -# IDLE LOOP -# ============================================================ - -def idle_loop(): - server = connect() - while True: - try: - log("Entering IDLE mode") - server.idle() - responses = server.idle_check(timeout=IDLE_TIMEOUT) - server.idle_done() - if responses: - log("New mail event received") - process_unseen(server) - else: - log("Idle timeout reached (keep-alive)") - except (socket.error, Exception) as e: - log(f"Connection lost: {e}") - try: - server.logout() - except: - pass - time.sleep(5) - server = connect() - -# ============================================================ -# ENTRY POINT -# ============================================================ - -if __name__ == "__main__": - log("Mail2SMS service started") - if TEST_MODE: - log("Running in TEST_MODE - no IMAP connection") - else: +import os +import time +import json +import re +import socket +import requests +import smtplib +from email.message import EmailMessage +from imapclient import IMAPClient +from requests.auth import HTTPBasicAuth +from mail2sms.text_utils import html_to_sms_text + +# ============================================================ +# CONFIGURATION +# ============================================================ + +IMAP_SERVER = os.getenv("IMAP_SERVER") +IMAP_PORT = int(os.getenv("IMAP_PORT", 993)) +EMAIL_ACCOUNT = os.getenv("EMAIL_ACCOUNT") +EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD") + +SMTP_SERVER = os.getenv("SMTP_SERVER") +SMTP_PORT = int(os.getenv("SMTP_PORT", 587)) + +API_URL = os.getenv("API_URL") + +API_USERNAME = os.getenv("API_USERNAME") +API_PASSWORD = os.getenv("API_PASSWORD") + +DEFAULT_DEVICE_ID = os.getenv("DEFAULT_DEVICE_ID") +DEFAULT_SIM_NUMBER = os.getenv("DEFAULT_SIM_NUMBER") + +IDLE_TIMEOUT = 60 * 25 # 25 minutes (safe before typical 30 min timeout) +TEST_MODE = os.getenv("TEST_MODE", "false").lower() == "true" + +# ============================================================ +# LOGGING +# ============================================================ + +def log(message): + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}", flush=True) + + +# ============================================================ +# SUBJECT PARAMETER EXTRACTION +# ============================================================ + +def extract_parameters(subject): + """ + Extract parameters from email subject. + Returns: to, device_id, sim_number, api_login, api_password + """ + if not subject: + return None, None, None, None, None + + # Regex case-insensitive + def get_param(name): + match = re.search(rf"{name}\s*=\s*([^; ]+)", subject, re.IGNORECASE) + return match.group(1) if match else None + + to = get_param("to") + device_id = get_param("deviceId") + sim_number = get_param("simNumber") + api_login = get_param("login") + api_password = get_param("password") + + return to, device_id, sim_number, api_login, api_password + +# ============================================================ +# SMS SEND +# ============================================================ + +def send_sms(payload, username, password): + """ + Send SMS using the API. + In TEST_MODE, it returns a fake response. + If username/password are provided, they override the defaults from env. + """ + if TEST_MODE: + log("TEST_MODE enabled - Fake SMS send") + class FakeResponse: + status_code = 202 + text = json.dumps({"id": "fake-id", "state": "Sent"}) + return FakeResponse() + + return requests.post( + API_URL, + auth=HTTPBasicAuth(username, password), + headers={"Content-Type": "application/json"}, + data=json.dumps(payload), + timeout=15 + ) +# ============================================================ +# STATUS EMAIL +# ============================================================ + +def send_status_email(to_address, phone, status_code, response_text): + if TEST_MODE: + log("TEST_MODE enabled - Fake email send") + return + msg = EmailMessage() + msg["From"] = EMAIL_ACCOUNT + msg["To"] = to_address + msg["Subject"] = f"SMS Status for {phone}" + msg.set_content( + f"Destination: {phone}\n" + f"HTTP Code: {status_code}\n\n" + f"API Response:\n{response_text}" + ) + try: + with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=10) as server: + server.starttls() + server.login(EMAIL_ACCOUNT, EMAIL_PASSWORD) + server.send_message(msg) + log(f"Status email sent to {to_address}") + except Exception as e: + log(f"SMTP error: {e}") + +# ============================================================ +# PROCESS EMAILS READING +# ============================================================ + +def process_unseen(server): + messages = server.search(['UNSEEN']) + if not messages: + return + log(f"{len(messages)} new message(s) detected") + fetched = server.fetch(messages, ['RFC822']) + for uid, message_data in fetched.items(): + try: + import email + msg = email.message_from_bytes(message_data[b'RFC822']) + sender = email.utils.parseaddr(msg["From"])[1] + subject = msg["Subject"] + phone_number, subject_device_id, subject_sim_number, api_login, api_password = extract_parameters(subject) + + if not phone_number: + log("No phone number found in subject") + server.add_flags(uid, ['\\Seen']) + continue + device_id = subject_device_id or DEFAULT_DEVICE_ID + sim_number = subject_sim_number or DEFAULT_SIM_NUMBER + username = api_login or API_USERNAME + if not username: + log("No API Username found") + server.add_flags(uid, ['\\Seen']) + continue + password = api_password or API_PASSWORD + if not password: + log("No API Password found") + server.add_flags(uid, ['\\Seen']) + continue + + sms_text = "" + + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + payload = part.get_payload(decode=True) + + if not payload: + continue + + decoded = payload.decode(errors="ignore") + + if content_type == "text/plain": + sms_text = decoded.strip() + break + + elif content_type == "text/html" and not sms_text: + sms_text = html_to_sms_text(decoded) + + else: + payload = msg.get_payload(decode=True) + if payload: + decoded = payload.decode(errors="ignore") + if msg.get_content_type() == "text/html": + sms_text = html_to_sms_text(decoded) + else: + sms_text = decoded.strip() + + payload = { + "textMessage": {"text": sms_text}, + "phoneNumbers": [phone_number] + } + if device_id: + payload["deviceId"] = device_id + if sim_number: + payload["simNumber"] = int(sim_number) + log(f"Sending SMS to {phone_number} using deviceId={device_id} simNumber={sim_number}") + response = send_sms(payload, username, password) + log(f"HTTP {response.status_code}") + send_status_email(sender, phone_number, response.status_code, response.text) + if 200 <= response.status_code <= 204: + server.delete_messages(uid) + log("Email deleted (success)") + else: + server.add_flags(uid, ['\\Seen']) + log("Email kept (non-success response)") + except Exception as e: + log(f"Processing error: {e}") + server.add_flags(uid, ['\\Seen']) + server.expunge() + +# ============================================================ +# HANDLE IMAP CONNECTION +# ============================================================ + +def connect(): + while True: + try: + log("Connecting to IMAP server...") + server = IMAPClient(IMAP_SERVER, port=IMAP_PORT, ssl=True, timeout=20) + server.login(EMAIL_ACCOUNT, EMAIL_PASSWORD) + server.select_folder("INBOX") + log("IMAP connected successfully") + return server + except (socket.error, Exception) as e: + log(f"IMAP connection failed: {e}") + time.sleep(10) + +# ============================================================ +# IDLE LOOP +# ============================================================ + +def idle_loop(): + server = connect() + while True: + try: + log("Entering IDLE mode") + server.idle() + responses = server.idle_check(timeout=IDLE_TIMEOUT) + server.idle_done() + if responses: + log("New mail event received") + process_unseen(server) + else: + log("Idle timeout reached (keep-alive)") + except (socket.error, Exception) as e: + log(f"Connection lost: {e}") + try: + server.logout() + except: + pass + time.sleep(5) + server = connect() + +# ============================================================ +# ENTRY POINT +# ============================================================ + +if __name__ == "__main__": + log("Mail2SMS service started") + if TEST_MODE: + log("Running in TEST_MODE - no IMAP connection") + else: idle_loop() \ No newline at end of file