2025-05-19 12:33:37 +02:00
|
|
|
import errno
|
2025-05-16 23:02:56 +02:00
|
|
|
import socket
|
|
|
|
import ssl
|
|
|
|
from binascii import b2a_base64
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
|
|
|
|
|
|
class MicromailClient:
|
2025-05-19 12:34:55 +02:00
|
|
|
def __init__(self, host, port=0, ssl=False, starttls=False):
|
2025-05-16 23:02:56 +02:00
|
|
|
self.host = host
|
|
|
|
|
|
|
|
if ssl and starttls:
|
2025-05-19 12:33:37 +02:00
|
|
|
raise Error("Cannot use both SSL and STARTTLS at the same time.")
|
2025-05-16 23:02:56 +02:00
|
|
|
|
|
|
|
self.ssl = ssl
|
|
|
|
self.starttls = starttls
|
|
|
|
|
2025-05-19 12:34:55 +02:00
|
|
|
if port == 0:
|
|
|
|
if ssl:
|
|
|
|
port = 465
|
|
|
|
elif starttls:
|
|
|
|
port = 587
|
|
|
|
else:
|
|
|
|
port = 25
|
|
|
|
self.port = port
|
|
|
|
|
2025-05-16 23:02:56 +02:00
|
|
|
self.socket = None
|
|
|
|
self.username = None
|
|
|
|
|
|
|
|
def connect(self):
|
2025-05-19 12:33:37 +02:00
|
|
|
con_errors = []
|
|
|
|
try:
|
|
|
|
ai = socket.getaddrinfo(
|
|
|
|
self.host, self.port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP
|
|
|
|
)
|
|
|
|
except OSError as e:
|
|
|
|
ai = []
|
|
|
|
con_errors.append(e)
|
|
|
|
|
|
|
|
for addr_info in ai:
|
2025-05-16 23:02:56 +02:00
|
|
|
af, socktype, proto, _, sa = addr_info
|
|
|
|
try:
|
|
|
|
self.socket = socket.socket(af, socktype, proto)
|
2025-05-19 12:33:37 +02:00
|
|
|
except OSError as e:
|
|
|
|
con_errors.append(e)
|
2025-05-16 23:02:56 +02:00
|
|
|
self.socket = None
|
|
|
|
continue
|
|
|
|
|
|
|
|
try:
|
|
|
|
self.socket.connect(sa)
|
2025-05-19 12:33:37 +02:00
|
|
|
except OSError as e:
|
|
|
|
con_errors.append(e)
|
2025-05-16 23:02:56 +02:00
|
|
|
self.socket.close()
|
|
|
|
self.socket = None
|
|
|
|
continue
|
|
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
if self.socket is None:
|
2025-05-19 12:33:37 +02:00
|
|
|
err = ", ".join([errno.errorcode.get(e.errno, str(e)) for e in con_errors])
|
|
|
|
raise Error(
|
|
|
|
f"Could not connect to server: {err}. Are you connected to the network?"
|
|
|
|
)
|
|
|
|
print("Connected to server.")
|
2025-05-16 23:02:56 +02:00
|
|
|
|
|
|
|
if self.ssl:
|
|
|
|
self.socket = ssl.wrap_socket(self.socket)
|
|
|
|
|
2025-05-19 12:33:37 +02:00
|
|
|
self.read_res(code=220)
|
2025-05-16 23:02:56 +02:00
|
|
|
self.features = self.ehlo()
|
|
|
|
|
2025-05-17 21:35:50 +02:00
|
|
|
if self.starttls and b"STARTTLS" in self.features:
|
|
|
|
self.start_starttls()
|
2025-05-16 23:02:56 +02:00
|
|
|
|
|
|
|
def login(self, username, password):
|
|
|
|
self.username = username
|
|
|
|
|
|
|
|
auth_methods = None
|
|
|
|
for feature in self.features:
|
|
|
|
if feature.startswith(b"AUTH"):
|
|
|
|
auth_methods = feature[4:].upper().split()
|
|
|
|
break
|
2025-05-19 12:33:37 +02:00
|
|
|
|
2025-05-16 23:02:56 +02:00
|
|
|
if auth_methods is None:
|
2025-05-19 12:33:37 +02:00
|
|
|
raise Error(
|
|
|
|
f"No authentication methods available. Server features: {self.features}"
|
|
|
|
)
|
2025-05-16 23:02:56 +02:00
|
|
|
|
|
|
|
if b"PLAIN" in auth_methods:
|
|
|
|
encoded_auth = b2a_base64(f"\0{username}\0{password}", newline=False)
|
2025-05-19 12:33:37 +02:00
|
|
|
self.send_command(b"AUTH PLAIN ", encoded_auth, code=235)
|
2025-05-16 23:02:56 +02:00
|
|
|
elif b"LOGIN" in auth_methods:
|
|
|
|
encoded_username = b2a_base64(username, newline=False)
|
2025-05-19 12:33:37 +02:00
|
|
|
self.send_command(b"AUTH LOGIN ", encoded_username, code=334)
|
|
|
|
|
2025-05-16 23:02:56 +02:00
|
|
|
encoded_password = b2a_base64(password, newline=False)
|
2025-05-19 12:33:37 +02:00
|
|
|
self.send_command(encoded_password, code=235)
|
2025-05-16 23:02:56 +02:00
|
|
|
else:
|
2025-05-19 12:33:37 +02:00
|
|
|
raise Error(
|
|
|
|
f"Unsupported authentication methods: {', '.join(auth_methods)}. Micromail only supports PLAIN and LOGIN."
|
|
|
|
)
|
2025-05-16 23:02:56 +02:00
|
|
|
|
2025-05-19 12:33:37 +02:00
|
|
|
print(f"Authenticated as {username}.")
|
2025-05-16 23:02:56 +02:00
|
|
|
|
|
|
|
def write(self, data):
|
|
|
|
if self.socket is None:
|
2025-05-19 12:33:37 +02:00
|
|
|
raise Error("No socket to write to.")
|
2025-05-16 23:02:56 +02:00
|
|
|
|
|
|
|
n = self.socket.write(data)
|
|
|
|
if n != len(data):
|
|
|
|
print(f"Failure writing data <{data}>: not all bytes written")
|
|
|
|
|
2025-05-19 12:33:37 +02:00
|
|
|
def send_command(self, *cmd, code=[]):
|
|
|
|
cmd = b" ".join(cmd)
|
|
|
|
self.write(cmd + b"\r\n")
|
|
|
|
return self.read_res(cmd, code=code)
|
2025-05-16 23:02:56 +02:00
|
|
|
|
2025-05-19 12:33:37 +02:00
|
|
|
def read_res(self, cmd="", code=[]):
|
2025-05-16 23:02:56 +02:00
|
|
|
response = []
|
|
|
|
next = True
|
|
|
|
while next:
|
2025-05-19 12:33:37 +02:00
|
|
|
r_code = int(self.socket.read(3))
|
2025-05-16 23:02:56 +02:00
|
|
|
next = self.socket.read(1) == b"-"
|
|
|
|
response.append(self.socket.readline().strip())
|
|
|
|
|
2025-05-19 12:33:37 +02:00
|
|
|
if isinstance(code, int):
|
|
|
|
code = [code]
|
|
|
|
if code != [] and r_code not in code:
|
|
|
|
raise SMTPError(cmd, r_code, b" ".join(response).decode())
|
|
|
|
return r_code, response
|
2025-05-16 23:02:56 +02:00
|
|
|
|
|
|
|
def ehlo(self):
|
2025-05-19 12:33:37 +02:00
|
|
|
_, res = self.send_command(b"EHLO _", code=250)
|
2025-05-16 23:02:56 +02:00
|
|
|
return res
|
|
|
|
|
2025-05-17 21:35:50 +02:00
|
|
|
def start_starttls(self):
|
2025-05-19 12:33:37 +02:00
|
|
|
self.send_command(b"STARTTLS", code=220)
|
2025-05-16 23:02:56 +02:00
|
|
|
self.socket = ssl.wrap_socket(self.socket)
|
|
|
|
|
|
|
|
def new_message(self, to, sender=None):
|
2025-05-19 12:33:37 +02:00
|
|
|
self.send_command(b"RSET", code=250)
|
2025-05-16 23:02:56 +02:00
|
|
|
|
|
|
|
if sender is None:
|
|
|
|
sender = self.username
|
|
|
|
if isinstance(to, str):
|
|
|
|
to = [to]
|
|
|
|
|
|
|
|
self.sender = sender
|
|
|
|
self.to = to
|
|
|
|
|
2025-05-19 12:33:37 +02:00
|
|
|
self.send_command(f"MAIL FROM:<{sender}>".encode(), code=250)
|
2025-05-16 23:02:56 +02:00
|
|
|
|
|
|
|
for recipient in to:
|
2025-05-19 12:33:37 +02:00
|
|
|
self.send_command(f"RCPT TO:<{recipient}>".encode(), code=[250, 251])
|
2025-05-16 23:02:56 +02:00
|
|
|
|
2025-05-19 12:33:37 +02:00
|
|
|
self.send_command(b"DATA", code=354)
|
2025-05-16 23:02:56 +02:00
|
|
|
|
|
|
|
def headers(self, headers={}):
|
|
|
|
date = headers.get("date", datetime.now(tz=timezone(timedelta())))
|
|
|
|
if isinstance(date, datetime):
|
|
|
|
date = format_date(date)
|
|
|
|
self.write(f"Date: {date}\r\n".encode())
|
|
|
|
|
|
|
|
sender = headers.get("from", self.sender)
|
|
|
|
self.write(f"From: {sender}\r\n".encode())
|
|
|
|
|
|
|
|
if "subject" in headers:
|
|
|
|
self.write(f"Subject: {headers['subject']}\r\n".encode())
|
|
|
|
|
|
|
|
to = headers.get("to", self.to)
|
2025-05-16 23:21:09 +02:00
|
|
|
if isinstance(to, str):
|
|
|
|
to = [to]
|
2025-05-16 23:02:56 +02:00
|
|
|
self.write(f"To: {', '.join(to)}\r\n".encode())
|
|
|
|
|
2025-05-17 20:10:14 +02:00
|
|
|
self.write("\r\n".encode())
|
|
|
|
|
|
|
|
def write_line(self, content):
|
2025-05-16 23:02:56 +02:00
|
|
|
if isinstance(content, str):
|
|
|
|
content = content.encode()
|
2025-05-17 20:10:14 +02:00
|
|
|
if content.startswith(b"."):
|
|
|
|
content = b"." + content
|
2025-05-16 23:02:56 +02:00
|
|
|
|
2025-05-17 20:10:14 +02:00
|
|
|
self.write(content + b"\r\n")
|
2025-05-16 23:02:56 +02:00
|
|
|
|
|
|
|
def send(self):
|
2025-05-19 12:33:37 +02:00
|
|
|
self.send_command(b"\r\n.", code=250)
|
2025-05-16 23:02:56 +02:00
|
|
|
print("Message sent successfully")
|
|
|
|
|
|
|
|
def quit(self):
|
2025-05-19 12:33:37 +02:00
|
|
|
self.send_command(b"QUIT", code=221)
|
2025-05-16 23:02:56 +02:00
|
|
|
print("Disconnected from server")
|
2025-05-17 20:46:38 +02:00
|
|
|
self.socket.close()
|
2025-05-16 23:02:56 +02:00
|
|
|
|
|
|
|
|
2025-05-19 12:33:37 +02:00
|
|
|
class Error(Exception):
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
class SMTPError(Error):
|
|
|
|
def __init__(self, cmd, code, message):
|
|
|
|
super().__init__(f"{code} {message}")
|
|
|
|
|
|
|
|
self.cmd = cmd
|
|
|
|
self.code = code
|
|
|
|
|
|
|
|
|
2025-05-16 23:02:56 +02:00
|
|
|
MONTHS = [
|
|
|
|
"Jan",
|
|
|
|
"Feb",
|
|
|
|
"Mar",
|
|
|
|
"Apr",
|
|
|
|
"May",
|
|
|
|
"Jun",
|
|
|
|
"Jul",
|
|
|
|
"Aug",
|
|
|
|
"Sep",
|
|
|
|
"Oct",
|
|
|
|
"Nov",
|
|
|
|
"Dec",
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
def format_date(date):
|
|
|
|
date_str = f"{date.day} {MONTHS[date.month]} {str(date.year)[-2:]}"
|
|
|
|
time_str = f"{date.hour:02}:{date.minute:02}:{date.second:02} UT"
|
|
|
|
return f"{date_str} {time_str}"
|