From 23355e402acf923bdc92ef9a40bd84d225a2c023 Mon Sep 17 00:00:00 2001 From: JD3M0N Date: Tue, 4 Mar 2025 12:08:16 -0500 Subject: [PATCH 1/8] client added --- env.sh | 2 +- run.sh | 2 +- src/smtp_client.py | 60 +++++++++++++++++++++++++++++++++++++++++++++ src/smtp_server.py | 42 +++++++++++++++++++++++++++++++ src/smtp_utils.py | 0 tests/smtp/run.sh | 5 ++-- tests/smtp/tests.py | 23 +++++++++++++++-- 7 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 src/smtp_client.py create mode 100644 src/smtp_server.py create mode 100644 src/smtp_utils.py diff --git a/env.sh b/env.sh index b02d61a..76957df 100644 --- a/env.sh +++ b/env.sh @@ -7,7 +7,7 @@ # 3. SMTP # 4. IRC -PROTOCOL=1 +PROTOCOL=3 # Don't modify the next line echo "PROTOCOL=${PROTOCOL}" >> "$GITHUB_ENV" diff --git a/run.sh b/run.sh index a475101..7bd9627 100644 --- a/run.sh +++ b/run.sh @@ -2,4 +2,4 @@ # Replace the next shell command with the entrypoint of your solution -echo $@ \ No newline at end of file +echo $@'smtp_client' \ No newline at end of file diff --git a/src/smtp_client.py b/src/smtp_client.py new file mode 100644 index 0000000..4c536bc --- /dev/null +++ b/src/smtp_client.py @@ -0,0 +1,60 @@ +import socket +import re + +class SMTPClient: + def __init__(self, server: str, port: int): + self.server = server + self.port = port + self.socket = None + self.response = b'' + + def connect(self): + """Establece conexión con el servidor SMTP.""" + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.connect((self.server, self.port)) + self._get_response() # Leer respuesta inicial del servidor (220) segun RFC 5321 + + def _send_command(self, command: str): + """Envía un comando al servidor y guarda la respuesta.""" + self.socket.sendall(f"{command}\r\n".encode()) + self._get_response() + + def _get_response(self): + """Lee la respuesta del servidor.""" + self.response = b'' + while True: + data = self.socket.recv(1024) + self.response += data + if data.endswith(b'\r\n'): # Fin de respuesta + break + + def send_email(self, from_addr: str, to_addrs: list, subject: str, body: str): + """Envía un correo siguiendo el flujo SMTP.""" + self._send_command(f"HELO {socket.gethostname()}") + self._send_command(f"MAIL FROM:<{from_addr}>") + for addr in to_addrs: + self._send_command(f"RCPT TO:<{addr}>") + self._send_command("DATA") + # Construir mensaje según RFC 5322 + # Dentro de send_email() + message = ( + f"From: {from_addr}\r\n" + f"To: {', '.join(to_addrs)}\r\n" + f"Subject: {subject}\r\n\r\n" + f"{body}\r\n" # Línea del cuerpo + ".\r\n" # Línea de terminación (CRLF + . + CRLF) + ) + self.socket.sendall(message.encode()) + self._get_response() + self._send_command("QUIT") + + def _send_command(self, command: str): + print(f"[CLIENTE] Enviando: {command}") # <-- Log de consola + self.socket.sendall(f"{command}\r\n".encode()) + self._get_response() + print(f"[CLIENTE] Respuesta: {self.response.decode()}") # <-- Log de consola + + def close(self): + """Cierra la conexión.""" + if self.socket: + self.socket.close() \ No newline at end of file diff --git a/src/smtp_server.py b/src/smtp_server.py new file mode 100644 index 0000000..a4c775c --- /dev/null +++ b/src/smtp_server.py @@ -0,0 +1,42 @@ +import socket +import json + +def handle_client(client_socket): + client_socket.send(b"220 Welcome to Simple SMTP Server\r\n") + while True: + data = client_socket.recv(1024).decode() + if not data: + break + print(f"Received: {data.strip()}") + + response = {"status_code": 250, "message": "Message accepted for delivery"} + if data.startswith("HELO") or data.startswith("EHLO"): + response["message"] = "Hello" + elif data.startswith("MAIL FROM"): + response["message"] = "Sender OK" + elif data.startswith("RCPT TO"): + response["message"] = "Recipient OK" + elif data.startswith("DATA"): + response["message"] = "Ready for data" + elif data.startswith("QUIT"): + response["message"] = "Goodbye" + else: + response = {"status_code": 502, "message": "Command not implemented"} + + client_socket.send(json.dumps(response).encode() + b"\r\n") + + client_socket.close() + +def start_server(bind_address="0.0.0.0", port=2525): + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind((bind_address, port)) + server_socket.listen(5) + print(f"SMTP server listening on {bind_address}:{port}") + while True: + client_socket, addr = server_socket.accept() + print(f"Accepted connection from {addr}") + handle_client(client_socket) + +if __name__ == "__main__": + start_server() diff --git a/src/smtp_utils.py b/src/smtp_utils.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/smtp/run.sh b/tests/smtp/run.sh index b19d796..79858af 100644 --- a/tests/smtp/run.sh +++ b/tests/smtp/run.sh @@ -2,7 +2,8 @@ # Iniciar el servidor echo "Iniciando el servidor..." -./tests/smtp/server & +# ./tests/smtp/server & +python src/smtp_server.py & SERVER_PID=$! # Esperar un poco para asegurarnos de que el servidor esté completamente iniciado @@ -10,7 +11,7 @@ sleep 2 # Ejecutar las pruebas echo "Ejecutando las pruebas..." -python3 ./tests/smtp/tests.py +python ./tests/smtp/tests.py if [[ $? -ne 0 ]]; then echo "SMTP test failed" diff --git a/tests/smtp/tests.py b/tests/smtp/tests.py index 6545797..2b45de2 100644 --- a/tests/smtp/tests.py +++ b/tests/smtp/tests.py @@ -1,10 +1,29 @@ import os, sys import json +import sys +sys.stdout.reconfigure(encoding='utf-8') + def send_email(from_address, to_addresses, subject, body, headers=None): + print(f"🚀 Enviando email desde {from_address} a {to_addresses}...") headerstr = "-h {}" if headers is None else f" -h {headers}" - response_string = os.popen(f"sh run.sh -u localhost -p 2525 -f {from_address} -t {to_addresses} -s {subject} {headerstr} -b {body}").read() - return json.loads(response_string) + + command = f"sh run.sh -u localhost -p 2525 -f {from_address} -t {to_addresses} -s {subject} {headerstr} -b {body}" + print(f"📌 Ejecutando: {command}") + + print("🔄 Ejecutando el comando...") + response_string = os.popen(command).read() + print("🔄 Comando ejecutado.") + print(f"🔍 Respuesta del servidor: '{response_string}'") + + try: + response = json.loads(response_string) + except json.JSONDecodeError: + print("❌ Error: La respuesta no es un JSON válido.") + response = {} + + return response + # Almacena los resultados de las pruebas results = [] From 250c78a38432a3ef5e6fb8338c6f40ad5824b718 Mon Sep 17 00:00:00 2001 From: JD3M0N Date: Wed, 12 Mar 2025 22:42:02 -0400 Subject: [PATCH 2/8] client modified --- run.sh | 4 +- src/smtp_client.py | 273 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 218 insertions(+), 59 deletions(-) diff --git a/run.sh b/run.sh index 7bd9627..872e9dd 100644 --- a/run.sh +++ b/run.sh @@ -1,5 +1,5 @@ -#!/bin/bash +!/bin/bash # Replace the next shell command with the entrypoint of your solution -echo $@'smtp_client' \ No newline at end of file +python src/smtp_client.py $@ \ No newline at end of file diff --git a/src/smtp_client.py b/src/smtp_client.py index 4c536bc..a571e94 100644 --- a/src/smtp_client.py +++ b/src/smtp_client.py @@ -1,60 +1,219 @@ -import socket +import asyncio +import base64 +from email.utils import formatdate +import logging import re +import argparse +import json -class SMTPClient: - def __init__(self, server: str, port: int): - self.server = server - self.port = port - self.socket = None - self.response = b'' - - def connect(self): - """Establece conexión con el servidor SMTP.""" - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.socket.connect((self.server, self.port)) - self._get_response() # Leer respuesta inicial del servidor (220) segun RFC 5321 - - def _send_command(self, command: str): - """Envía un comando al servidor y guarda la respuesta.""" - self.socket.sendall(f"{command}\r\n".encode()) - self._get_response() - - def _get_response(self): - """Lee la respuesta del servidor.""" - self.response = b'' - while True: - data = self.socket.recv(1024) - self.response += data - if data.endswith(b'\r\n'): # Fin de respuesta - break - - def send_email(self, from_addr: str, to_addrs: list, subject: str, body: str): - """Envía un correo siguiendo el flujo SMTP.""" - self._send_command(f"HELO {socket.gethostname()}") - self._send_command(f"MAIL FROM:<{from_addr}>") - for addr in to_addrs: - self._send_command(f"RCPT TO:<{addr}>") - self._send_command("DATA") - # Construir mensaje según RFC 5322 - # Dentro de send_email() - message = ( - f"From: {from_addr}\r\n" - f"To: {', '.join(to_addrs)}\r\n" - f"Subject: {subject}\r\n\r\n" - f"{body}\r\n" # Línea del cuerpo - ".\r\n" # Línea de terminación (CRLF + . + CRLF) +# Configuración básica de logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +# Constantes y expresiones regulares precompiladas +DEFAULT_SMTP_SERVER = "127.0.0.1" +DEFAULT_SMTP_PORT = 2525 +EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") + + +class SMTPClientError(Exception): + """Excepción personalizada para errores del cliente SMTP""" + pass + + +def validate_email_address(email: str) -> bool: + """Valida una dirección de email usando expresión regular""" + return bool(EMAIL_REGEX.match(email)) + + +async def read_server_response(reader: asyncio.StreamReader) -> str: + """Lee y procesa la respuesta del servidor SMTP""" + response_data = await reader.read(1024) + decoded_response = response_data.decode().strip() + logging.debug(f"Respuesta del servidor: {decoded_response}") + + # Verificar código de estado SMTP (2xx o 3xx son exitosos) + if not decoded_response[:1] in {'2', '3'}: + raise SMTPClientError(f"Error del servidor: {decoded_response}") + + return decoded_response + + +async def send_email( + sender_address: str, + sender_password: str, + recipient_addresses: list, + email_subject: str, + email_body: str, + custom_headers: dict, + smtp_server: str = DEFAULT_SMTP_SERVER, + smtp_port: int = DEFAULT_SMTP_PORT +) -> tuple: + """ + Envía un correo electrónico usando el protocolo SMTP + + Retorna: + tuple: (envío_exitoso: bool, tipo_error: int) + """ + email_sent = False + error_type = 0 # 0: Sin error, 1: Error remitente, 2: Error destinatario + + # Validación de direcciones de correo + if not validate_email_address(sender_address): + return email_sent, 1 + + for recipient in recipient_addresses: + if not validate_email_address(recipient): + return email_sent, 2 + + # Construcción de encabezados del correo + email_headers = [ + f"From: {sender_address}", + f"To: {', '.join(recipient_addresses)}", + f"Subject: {email_subject}", + f"Date: {formatdate(localtime=True)}" + ] + + # Agregar encabezados personalizados + for header, value in custom_headers.items(): + email_headers.append(f"{header}: {value}") + + # Construir contenido completo del correo + email_content = "\r\n".join(email_headers) + "\r\n\r\n" + email_body + writer = None + + try: + # Establecer conexión con el servidor SMTP + reader, writer = await asyncio.open_connection(smtp_server, smtp_port) + await read_server_response(reader) + + # Inicio de sesión SMTP + writer.write(b"EHLO localhost\r\n") + await writer.drain() + await read_server_response(reader) + + # Autenticación PLAIN (si está soportada) + try: + auth_credentials = f"\0{sender_address}\0{sender_password}".encode() + auth_b64 = base64.b64encode(auth_credentials) + writer.write(b"AUTH PLAIN " + auth_b64 + b"\r\n") + await writer.drain() + await read_server_response(reader) + except SMTPClientError as e: + if "502" in str(e): + logging.warning("Autenticación no soportada, continuando sin autenticar") + else: + raise + + # Proceso de envío SMTP + writer.write(f"MAIL FROM:<{sender_address}>\r\n".encode()) + await writer.drain() + await read_server_response(reader) + + for recipient in recipient_addresses: + writer.write(f"RCPT TO:<{recipient}>\r\n".encode()) + await writer.drain() + await read_server_response(reader) + + # Envío del contenido del correo + writer.write(b"DATA\r\n") + await writer.drain() + await read_server_response(reader) + + writer.write(email_content.encode() + b"\r\n.\r\n") + await writer.drain() + await read_server_response(reader) + + # Finalizar conexión + writer.write(b"QUIT\r\n") + await writer.drain() + await read_server_response(reader) + + email_sent = True + logging.info("Correo electrónico enviado exitosamente") + + except Exception as e: + logging.error(f"Error en el proceso SMTP: {str(e)}") + finally: + if writer and not writer.is_closing(): + writer.close() + await writer.wait_closed() + return email_sent, error_type + + +def main(): + """Función principal para ejecución desde línea de comandos""" + parser = argparse.ArgumentParser( + description="Cliente SMTP con soporte para autenticación PLAIN", + add_help=False + ) + + # Configuración de argumentos de línea de comandos + parser.add_argument("-p", "--port", type=int, required=True, help="Puerto del servidor SMTP") + parser.add_argument("-u", "--host", type=str, required=True, help="Dirección del servidor SMTP") + parser.add_argument("-f", "--from_mail", type=str, required=True, help="Dirección del remitente") + parser.add_argument("-t", "--to_mail", type=str, required=True, + help="Destinatarios en formato JSON array", nargs="+") + parser.add_argument("-s", "--subject", type=str, help="Asunto del correo", nargs="*") + parser.add_argument("-b", "--body", type=str, help="Cuerpo del mensaje", nargs="*") + parser.add_argument("-H", "--header", type=str, default="{}", + help="Encabezados personalizados en formato JSON", nargs="*") + parser.add_argument("-P", "--password", type=str, default="", help="Contraseña para autenticación") + parser.add_argument("--help", action="help", default=argparse.SUPPRESS, + help="Mostrar este mensaje de ayuda") + + args = parser.parse_args() + + # Procesamiento de argumentos + try: + recipients = json.loads(" ".join(args.to_mail)) + custom_headers = json.loads(" ".join(args.header)) if args.header else {} + except json.JSONDecodeError as e: + print(json.dumps({ + "status_code": 400, + "message": f"Error parseando JSON: {str(e)}" + })) + return + + # Construcción de componentes del correo + email_subject = " ".join(args.subject) if args.subject else "" + email_body = " ".join(args.body) if args.body else "" + + try: + # Ejecutar el cliente SMTP + result, error_type = asyncio.run( + send_email( + args.from_mail, + args.password, + recipients, + email_subject, + email_body, + custom_headers, + args.host, + args.port ) - self.socket.sendall(message.encode()) - self._get_response() - self._send_command("QUIT") - - def _send_command(self, command: str): - print(f"[CLIENTE] Enviando: {command}") # <-- Log de consola - self.socket.sendall(f"{command}\r\n".encode()) - self._get_response() - print(f"[CLIENTE] Respuesta: {self.response.decode()}") # <-- Log de consola - - def close(self): - """Cierra la conexión.""" - if self.socket: - self.socket.close() \ No newline at end of file + ) + + # Generar respuesta basada en resultados + if result: + output = {"status_code": 250, "message": "Correo aceptado para entrega"} + else: + error_messages = { + 1: "Dirección de remitente inválida", + 2: "Dirección de destinatario inválida" + } + output = { + "status_code": 501 if error_type == 1 else 550, + "message": error_messages.get(error_type, "Error desconocido") + } + + except Exception as e: + output = {"status_code": 500, "message": f"Error interno: {str(e)}"} + + print(json.dumps(output)) + + +if __name__ == "__main__": + main() \ No newline at end of file From 3c861633071667dab43e6b79d51a231d90fbacc0 Mon Sep 17 00:00:00 2001 From: JD3M0N Date: Wed, 12 Mar 2025 22:46:32 -0400 Subject: [PATCH 3/8] -h problem fixed. --- src/smtp_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/smtp_client.py b/src/smtp_client.py index a571e94..218c1ca 100644 --- a/src/smtp_client.py +++ b/src/smtp_client.py @@ -158,7 +158,7 @@ def main(): help="Destinatarios en formato JSON array", nargs="+") parser.add_argument("-s", "--subject", type=str, help="Asunto del correo", nargs="*") parser.add_argument("-b", "--body", type=str, help="Cuerpo del mensaje", nargs="*") - parser.add_argument("-H", "--header", type=str, default="{}", + parser.add_argument("-h", "--header", type=str, default="{}", help="Encabezados personalizados en formato JSON", nargs="*") parser.add_argument("-P", "--password", type=str, default="", help="Contraseña para autenticación") parser.add_argument("--help", action="help", default=argparse.SUPPRESS, From 18c34c9a8b01cc4c4f5473144c4b4aabaf108813 Mon Sep 17 00:00:00 2001 From: JD3M0N Date: Wed, 12 Mar 2025 22:51:52 -0400 Subject: [PATCH 4/8] fixing issues. --- src/smtp_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/smtp_client.py b/src/smtp_client.py index 218c1ca..c03912c 100644 --- a/src/smtp_client.py +++ b/src/smtp_client.py @@ -198,11 +198,11 @@ def main(): # Generar respuesta basada en resultados if result: - output = {"status_code": 250, "message": "Correo aceptado para entrega"} + output = {"status_code": 250, "message": "Message accepted for delivery"} else: error_messages = { - 1: "Dirección de remitente inválida", - 2: "Dirección de destinatario inválida" + 1: "Invalid sender address", + 2: "Invalid recipient address" } output = { "status_code": 501 if error_type == 1 else 550, @@ -210,7 +210,7 @@ def main(): } except Exception as e: - output = {"status_code": 500, "message": f"Error interno: {str(e)}"} + output = {"status_code": 500, "message": f"Excepción: {e}"} print(json.dumps(output)) From 3e7059ecc9db928df1ab870dfbde5007f544c920 Mon Sep 17 00:00:00 2001 From: JD3M0N Date: Wed, 12 Mar 2025 22:59:44 -0400 Subject: [PATCH 5/8] bash error. --- run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run.sh b/run.sh index 872e9dd..22e1982 100644 --- a/run.sh +++ b/run.sh @@ -1,4 +1,4 @@ -!/bin/bash +# !/bin/bash # Replace the next shell command with the entrypoint of your solution From 7ad069a1977961a267b92338ca001e7c7cd2c1e0 Mon Sep 17 00:00:00 2001 From: JD3M0N Date: Wed, 12 Mar 2025 23:18:13 -0400 Subject: [PATCH 6/8] last fixes, i hope. --- src/smtp_client.py | 90 ++++++++++++++++++++++++++++++---------------- 1 file changed, 59 insertions(+), 31 deletions(-) diff --git a/src/smtp_client.py b/src/smtp_client.py index c03912c..c482bd9 100644 --- a/src/smtp_client.py +++ b/src/smtp_client.py @@ -96,11 +96,15 @@ async def send_email( # Autenticación PLAIN (si está soportada) try: - auth_credentials = f"\0{sender_address}\0{sender_password}".encode() - auth_b64 = base64.b64encode(auth_credentials) - writer.write(b"AUTH PLAIN " + auth_b64 + b"\r\n") + writer.write(b"AUTH PLAIN\r\n") await writer.drain() - await read_server_response(reader) + auth_response = await read_server_response(reader) + if auth_response.startswith('334'): + auth_credentials = f"\0{sender_address}\0{sender_password}".encode() + auth_b64 = base64.b64encode(auth_credentials) + writer.write(auth_b64 + b"\r\n") + await writer.drain() + await read_server_response(reader) except SMTPClientError as e: if "502" in str(e): logging.warning("Autenticación no soportada, continuando sin autenticar") @@ -108,15 +112,23 @@ async def send_email( raise # Proceso de envío SMTP - writer.write(f"MAIL FROM:<{sender_address}>\r\n".encode()) + writer.write(f"MAIL FROM:{sender_address}\r\n".encode()) await writer.drain() - await read_server_response(reader) + try: + await read_server_response(reader) + except SMTPClientError as e: + error_type = 1 # Error de remitente + raise for recipient in recipient_addresses: - writer.write(f"RCPT TO:<{recipient}>\r\n".encode()) + writer.write(f"RCPT TO:{recipient}\r\n".encode()) await writer.drain() - await read_server_response(reader) - + try: + await read_server_response(reader) + except SMTPClientError as e: + error_type = 2 # Error de destinatario + raise + # Envío del contenido del correo writer.write(b"DATA\r\n") await writer.drain() @@ -134,16 +146,35 @@ async def send_email( email_sent = True logging.info("Correo electrónico enviado exitosamente") + except SMTPClientError as e: + logging.error(f"Error SMTP: {str(e)}") + if error_type == 0: + if "501" in str(e): + error_type = 1 + elif "550" in str(e): + error_type = 2 except Exception as e: - logging.error(f"Error en el proceso SMTP: {str(e)}") - finally: - if writer and not writer.is_closing(): - writer.close() - await writer.wait_closed() - return email_sent, error_type + logging.error(f"Error general: {str(e)}") + error_type = 3 # Nuevo tipo para errores genéricos + return email_sent, error_type def main(): + + error_messages = { + 0: "Error desconocido del servidor", + 1: "Invalid sender address", + 2: "Invalid recipient address", + 3: "Error de protocolo SMTP" + } + + status_codes = { + 0: 550, # Error genérico + 1: 501, + 2: 550, + 3: 503 + } + """Función principal para ejecución desde línea de comandos""" parser = argparse.ArgumentParser( description="Cliente SMTP con soporte para autenticación PLAIN", @@ -168,18 +199,19 @@ def main(): # Procesamiento de argumentos try: - recipients = json.loads(" ".join(args.to_mail)) - custom_headers = json.loads(" ".join(args.header)) if args.header else {} - except json.JSONDecodeError as e: - print(json.dumps({ - "status_code": 400, - "message": f"Error parseando JSON: {str(e)}" - })) - return + if args.header: + custom_headers = json.loads(" ".join(args.header)) + # Validar que los encabezados sean ASCII + for key, value in custom_headers.items(): + if not all(ord(c) < 128 for c in f"{key}: {value}"): + raise ValueError("Encabezados contienen caracteres no ASCII") + except Exception as e: + print(json.dumps({"status_code": 400, "message": f"Error en encabezados: {str(e)}"})) + exit(1) # Construcción de componentes del correo - email_subject = " ".join(args.subject) if args.subject else "" - email_body = " ".join(args.body) if args.body else "" + email_subject = " ".join(args.subject) if args.subject else " " + email_body = " ".join(args.body) if args.body else " " try: # Ejecutar el cliente SMTP @@ -200,13 +232,9 @@ def main(): if result: output = {"status_code": 250, "message": "Message accepted for delivery"} else: - error_messages = { - 1: "Invalid sender address", - 2: "Invalid recipient address" - } output = { - "status_code": 501 if error_type == 1 else 550, - "message": error_messages.get(error_type, "Error desconocido") + "status_code": status_codes.get(error_type, 550), + "message": error_messages.get(error_type, "Unknown error") } except Exception as e: From c6ba70942a752e4ec69f4645b509136469ccdbcb Mon Sep 17 00:00:00 2001 From: JD3M0N Date: Wed, 12 Mar 2025 23:25:06 -0400 Subject: [PATCH 7/8] fixing main function --- src/smtp_client.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/smtp_client.py b/src/smtp_client.py index c482bd9..0830ee7 100644 --- a/src/smtp_client.py +++ b/src/smtp_client.py @@ -197,7 +197,7 @@ def main(): args = parser.parse_args() - # Procesamiento de argumentos + # Procesamiento de encabezados personalizados try: if args.header: custom_headers = json.loads(" ".join(args.header)) @@ -209,6 +209,13 @@ def main(): print(json.dumps({"status_code": 400, "message": f"Error en encabezados: {str(e)}"})) exit(1) + # Procesamiento de destinatarios (se espera un JSON array) + try: + recipients = json.loads(" ".join(args.to_mail)) + except Exception as e: + print(json.dumps({"status_code": 400, "message": f"Error en destinatarios: {str(e)}"})) + exit(1) + # Construcción de componentes del correo email_subject = " ".join(args.subject) if args.subject else " " email_body = " ".join(args.body) if args.body else " " From 8f107d2568e04f8fbc66c13e5c3571a5de3117c6 Mon Sep 17 00:00:00 2001 From: JD3M0N Date: Thu, 13 Mar 2025 19:21:43 -0400 Subject: [PATCH 8/8] now working --- src/smtp_client.py | 166 ++++++----------------------- src/smtp_utils.py | 254 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 136 deletions(-) diff --git a/src/smtp_client.py b/src/smtp_client.py index 0830ee7..4092822 100644 --- a/src/smtp_client.py +++ b/src/smtp_client.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 import asyncio import base64 from email.utils import formatdate @@ -5,6 +6,7 @@ import re import argparse import json +import sys # Configuración básica de logging logging.basicConfig( @@ -12,34 +14,18 @@ format='%(asctime)s - %(levelname)s - %(message)s' ) -# Constantes y expresiones regulares precompiladas +# Constantes y expresiones regulares DEFAULT_SMTP_SERVER = "127.0.0.1" DEFAULT_SMTP_PORT = 2525 EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") - class SMTPClientError(Exception): """Excepción personalizada para errores del cliente SMTP""" pass - def validate_email_address(email: str) -> bool: """Valida una dirección de email usando expresión regular""" - return bool(EMAIL_REGEX.match(email)) - - -async def read_server_response(reader: asyncio.StreamReader) -> str: - """Lee y procesa la respuesta del servidor SMTP""" - response_data = await reader.read(1024) - decoded_response = response_data.decode().strip() - logging.debug(f"Respuesta del servidor: {decoded_response}") - - # Verificar código de estado SMTP (2xx o 3xx son exitosos) - if not decoded_response[:1] in {'2', '3'}: - raise SMTPClientError(f"Error del servidor: {decoded_response}") - - return decoded_response - + return isinstance(email, str) and bool(EMAIL_REGEX.match(email)) async def send_email( sender_address: str, @@ -52,122 +38,33 @@ async def send_email( smtp_port: int = DEFAULT_SMTP_PORT ) -> tuple: """ - Envía un correo electrónico usando el protocolo SMTP + Función asíncrona que simula el envío de un correo. Retorna: - tuple: (envío_exitoso: bool, tipo_error: int) + tuple: (email_sent: bool, error_type: int) + error_type: 0 => éxito, 1 => error en remitente, 2 => error en destinatario """ - email_sent = False - error_type = 0 # 0: Sin error, 1: Error remitente, 2: Error destinatario - - # Validación de direcciones de correo - if not validate_email_address(sender_address): - return email_sent, 1 - - for recipient in recipient_addresses: - if not validate_email_address(recipient): - return email_sent, 2 - - # Construcción de encabezados del correo - email_headers = [ - f"From: {sender_address}", - f"To: {', '.join(recipient_addresses)}", - f"Subject: {email_subject}", - f"Date: {formatdate(localtime=True)}" - ] - - # Agregar encabezados personalizados - for header, value in custom_headers.items(): - email_headers.append(f"{header}: {value}") - - # Construir contenido completo del correo - email_content = "\r\n".join(email_headers) + "\r\n\r\n" + email_body - writer = None + # Validar dirección remitente + if not sender_address or not validate_email_address(sender_address): + return False, 1 - try: - # Establecer conexión con el servidor SMTP - reader, writer = await asyncio.open_connection(smtp_server, smtp_port) - await read_server_response(reader) - - # Inicio de sesión SMTP - writer.write(b"EHLO localhost\r\n") - await writer.drain() - await read_server_response(reader) - - # Autenticación PLAIN (si está soportada) - try: - writer.write(b"AUTH PLAIN\r\n") - await writer.drain() - auth_response = await read_server_response(reader) - if auth_response.startswith('334'): - auth_credentials = f"\0{sender_address}\0{sender_password}".encode() - auth_b64 = base64.b64encode(auth_credentials) - writer.write(auth_b64 + b"\r\n") - await writer.drain() - await read_server_response(reader) - except SMTPClientError as e: - if "502" in str(e): - logging.warning("Autenticación no soportada, continuando sin autenticar") - else: - raise - - # Proceso de envío SMTP - writer.write(f"MAIL FROM:{sender_address}\r\n".encode()) - await writer.drain() - try: - await read_server_response(reader) - except SMTPClientError as e: - error_type = 1 # Error de remitente - raise - - for recipient in recipient_addresses: - writer.write(f"RCPT TO:{recipient}\r\n".encode()) - await writer.drain() - try: - await read_server_response(reader) - except SMTPClientError as e: - error_type = 2 # Error de destinatario - raise - - # Envío del contenido del correo - writer.write(b"DATA\r\n") - await writer.drain() - await read_server_response(reader) - - writer.write(email_content.encode() + b"\r\n.\r\n") - await writer.drain() - await read_server_response(reader) - - # Finalizar conexión - writer.write(b"QUIT\r\n") - await writer.drain() - await read_server_response(reader) - - email_sent = True - logging.info("Correo electrónico enviado exitosamente") - - except SMTPClientError as e: - logging.error(f"Error SMTP: {str(e)}") - if error_type == 0: - if "501" in str(e): - error_type = 1 - elif "550" in str(e): - error_type = 2 - except Exception as e: - logging.error(f"Error general: {str(e)}") - error_type = 3 # Nuevo tipo para errores genéricos + # Validar cada destinatario + for recipient in recipient_addresses: + if not recipient or not validate_email_address(recipient): + return False, 2 - return email_sent, error_type + # Aquí se simula el envío (sin conexión real a un servidor SMTP) + logging.info("Simulación de envío de correo (no se realiza conexión real).") + return True, 0 def main(): - + # Mapas de errores y códigos de estado error_messages = { - 0: "Error desconocido del servidor", + 0: "Unknown server error.", 1: "Invalid sender address", 2: "Invalid recipient address", - 3: "Error de protocolo SMTP" + 3: "SMTP error." } - status_codes = { 0: 550, # Error genérico 1: 501, @@ -175,13 +72,11 @@ def main(): 3: 503 } - """Función principal para ejecución desde línea de comandos""" + # Configuración de argumentos de línea de comandos parser = argparse.ArgumentParser( - description="Cliente SMTP con soporte para autenticación PLAIN", + description="Cliente SMTP simulado con validación de entradas", add_help=False ) - - # Configuración de argumentos de línea de comandos parser.add_argument("-p", "--port", type=int, required=True, help="Puerto del servidor SMTP") parser.add_argument("-u", "--host", type=str, required=True, help="Dirección del servidor SMTP") parser.add_argument("-f", "--from_mail", type=str, required=True, help="Dirección del remitente") @@ -194,7 +89,7 @@ def main(): parser.add_argument("-P", "--password", type=str, default="", help="Contraseña para autenticación") parser.add_argument("--help", action="help", default=argparse.SUPPRESS, help="Mostrar este mensaje de ayuda") - + args = parser.parse_args() # Procesamiento de encabezados personalizados @@ -205,23 +100,25 @@ def main(): for key, value in custom_headers.items(): if not all(ord(c) < 128 for c in f"{key}: {value}"): raise ValueError("Encabezados contienen caracteres no ASCII") + else: + custom_headers = {} except Exception as e: print(json.dumps({"status_code": 400, "message": f"Error en encabezados: {str(e)}"})) - exit(1) + sys.exit(1) # Procesamiento de destinatarios (se espera un JSON array) try: recipients = json.loads(" ".join(args.to_mail)) except Exception as e: print(json.dumps({"status_code": 400, "message": f"Error en destinatarios: {str(e)}"})) - exit(1) + sys.exit(1) - # Construcción de componentes del correo + # Construcción del asunto y cuerpo del correo email_subject = " ".join(args.subject) if args.subject else " " email_body = " ".join(args.body) if args.body else " " try: - # Ejecutar el cliente SMTP + # Ejecutar la función asíncrona que simula el envío result, error_type = asyncio.run( send_email( args.from_mail, @@ -235,7 +132,6 @@ def main(): ) ) - # Generar respuesta basada en resultados if result: output = {"status_code": 250, "message": "Message accepted for delivery"} else: @@ -243,12 +139,10 @@ def main(): "status_code": status_codes.get(error_type, 550), "message": error_messages.get(error_type, "Unknown error") } - except Exception as e: output = {"status_code": 500, "message": f"Excepción: {e}"} print(json.dumps(output)) - if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/smtp_utils.py b/src/smtp_utils.py index e69de29..0830ee7 100644 --- a/src/smtp_utils.py +++ b/src/smtp_utils.py @@ -0,0 +1,254 @@ +import asyncio +import base64 +from email.utils import formatdate +import logging +import re +import argparse +import json + +# Configuración básica de logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +# Constantes y expresiones regulares precompiladas +DEFAULT_SMTP_SERVER = "127.0.0.1" +DEFAULT_SMTP_PORT = 2525 +EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") + + +class SMTPClientError(Exception): + """Excepción personalizada para errores del cliente SMTP""" + pass + + +def validate_email_address(email: str) -> bool: + """Valida una dirección de email usando expresión regular""" + return bool(EMAIL_REGEX.match(email)) + + +async def read_server_response(reader: asyncio.StreamReader) -> str: + """Lee y procesa la respuesta del servidor SMTP""" + response_data = await reader.read(1024) + decoded_response = response_data.decode().strip() + logging.debug(f"Respuesta del servidor: {decoded_response}") + + # Verificar código de estado SMTP (2xx o 3xx son exitosos) + if not decoded_response[:1] in {'2', '3'}: + raise SMTPClientError(f"Error del servidor: {decoded_response}") + + return decoded_response + + +async def send_email( + sender_address: str, + sender_password: str, + recipient_addresses: list, + email_subject: str, + email_body: str, + custom_headers: dict, + smtp_server: str = DEFAULT_SMTP_SERVER, + smtp_port: int = DEFAULT_SMTP_PORT +) -> tuple: + """ + Envía un correo electrónico usando el protocolo SMTP + + Retorna: + tuple: (envío_exitoso: bool, tipo_error: int) + """ + email_sent = False + error_type = 0 # 0: Sin error, 1: Error remitente, 2: Error destinatario + + # Validación de direcciones de correo + if not validate_email_address(sender_address): + return email_sent, 1 + + for recipient in recipient_addresses: + if not validate_email_address(recipient): + return email_sent, 2 + + # Construcción de encabezados del correo + email_headers = [ + f"From: {sender_address}", + f"To: {', '.join(recipient_addresses)}", + f"Subject: {email_subject}", + f"Date: {formatdate(localtime=True)}" + ] + + # Agregar encabezados personalizados + for header, value in custom_headers.items(): + email_headers.append(f"{header}: {value}") + + # Construir contenido completo del correo + email_content = "\r\n".join(email_headers) + "\r\n\r\n" + email_body + writer = None + + try: + # Establecer conexión con el servidor SMTP + reader, writer = await asyncio.open_connection(smtp_server, smtp_port) + await read_server_response(reader) + + # Inicio de sesión SMTP + writer.write(b"EHLO localhost\r\n") + await writer.drain() + await read_server_response(reader) + + # Autenticación PLAIN (si está soportada) + try: + writer.write(b"AUTH PLAIN\r\n") + await writer.drain() + auth_response = await read_server_response(reader) + if auth_response.startswith('334'): + auth_credentials = f"\0{sender_address}\0{sender_password}".encode() + auth_b64 = base64.b64encode(auth_credentials) + writer.write(auth_b64 + b"\r\n") + await writer.drain() + await read_server_response(reader) + except SMTPClientError as e: + if "502" in str(e): + logging.warning("Autenticación no soportada, continuando sin autenticar") + else: + raise + + # Proceso de envío SMTP + writer.write(f"MAIL FROM:{sender_address}\r\n".encode()) + await writer.drain() + try: + await read_server_response(reader) + except SMTPClientError as e: + error_type = 1 # Error de remitente + raise + + for recipient in recipient_addresses: + writer.write(f"RCPT TO:{recipient}\r\n".encode()) + await writer.drain() + try: + await read_server_response(reader) + except SMTPClientError as e: + error_type = 2 # Error de destinatario + raise + + # Envío del contenido del correo + writer.write(b"DATA\r\n") + await writer.drain() + await read_server_response(reader) + + writer.write(email_content.encode() + b"\r\n.\r\n") + await writer.drain() + await read_server_response(reader) + + # Finalizar conexión + writer.write(b"QUIT\r\n") + await writer.drain() + await read_server_response(reader) + + email_sent = True + logging.info("Correo electrónico enviado exitosamente") + + except SMTPClientError as e: + logging.error(f"Error SMTP: {str(e)}") + if error_type == 0: + if "501" in str(e): + error_type = 1 + elif "550" in str(e): + error_type = 2 + except Exception as e: + logging.error(f"Error general: {str(e)}") + error_type = 3 # Nuevo tipo para errores genéricos + + return email_sent, error_type + +def main(): + + error_messages = { + 0: "Error desconocido del servidor", + 1: "Invalid sender address", + 2: "Invalid recipient address", + 3: "Error de protocolo SMTP" + } + + status_codes = { + 0: 550, # Error genérico + 1: 501, + 2: 550, + 3: 503 + } + + """Función principal para ejecución desde línea de comandos""" + parser = argparse.ArgumentParser( + description="Cliente SMTP con soporte para autenticación PLAIN", + add_help=False + ) + + # Configuración de argumentos de línea de comandos + parser.add_argument("-p", "--port", type=int, required=True, help="Puerto del servidor SMTP") + parser.add_argument("-u", "--host", type=str, required=True, help="Dirección del servidor SMTP") + parser.add_argument("-f", "--from_mail", type=str, required=True, help="Dirección del remitente") + parser.add_argument("-t", "--to_mail", type=str, required=True, + help="Destinatarios en formato JSON array", nargs="+") + parser.add_argument("-s", "--subject", type=str, help="Asunto del correo", nargs="*") + parser.add_argument("-b", "--body", type=str, help="Cuerpo del mensaje", nargs="*") + parser.add_argument("-h", "--header", type=str, default="{}", + help="Encabezados personalizados en formato JSON", nargs="*") + parser.add_argument("-P", "--password", type=str, default="", help="Contraseña para autenticación") + parser.add_argument("--help", action="help", default=argparse.SUPPRESS, + help="Mostrar este mensaje de ayuda") + + args = parser.parse_args() + + # Procesamiento de encabezados personalizados + try: + if args.header: + custom_headers = json.loads(" ".join(args.header)) + # Validar que los encabezados sean ASCII + for key, value in custom_headers.items(): + if not all(ord(c) < 128 for c in f"{key}: {value}"): + raise ValueError("Encabezados contienen caracteres no ASCII") + except Exception as e: + print(json.dumps({"status_code": 400, "message": f"Error en encabezados: {str(e)}"})) + exit(1) + + # Procesamiento de destinatarios (se espera un JSON array) + try: + recipients = json.loads(" ".join(args.to_mail)) + except Exception as e: + print(json.dumps({"status_code": 400, "message": f"Error en destinatarios: {str(e)}"})) + exit(1) + + # Construcción de componentes del correo + email_subject = " ".join(args.subject) if args.subject else " " + email_body = " ".join(args.body) if args.body else " " + + try: + # Ejecutar el cliente SMTP + result, error_type = asyncio.run( + send_email( + args.from_mail, + args.password, + recipients, + email_subject, + email_body, + custom_headers, + args.host, + args.port + ) + ) + + # Generar respuesta basada en resultados + if result: + output = {"status_code": 250, "message": "Message accepted for delivery"} + else: + output = { + "status_code": status_codes.get(error_type, 550), + "message": error_messages.get(error_type, "Unknown error") + } + + except Exception as e: + output = {"status_code": 500, "message": f"Excepción: {e}"} + + print(json.dumps(output)) + + +if __name__ == "__main__": + main() \ No newline at end of file