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..22e1982 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 $@ \ 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 new file mode 100644 index 0000000..4092822 --- /dev/null +++ b/src/smtp_client.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +import asyncio +import base64 +from email.utils import formatdate +import logging +import re +import argparse +import json +import sys + +# Configuración básica de logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +# 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 isinstance(email, str) and bool(EMAIL_REGEX.match(email)) + +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: + """ + Función asíncrona que simula el envío de un correo. + + Retorna: + tuple: (email_sent: bool, error_type: int) + error_type: 0 => éxito, 1 => error en remitente, 2 => error en destinatario + """ + # Validar dirección remitente + if not sender_address or not validate_email_address(sender_address): + return False, 1 + + # Validar cada destinatario + for recipient in recipient_addresses: + if not recipient or not validate_email_address(recipient): + return False, 2 + + # 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: "Unknown server error.", + 1: "Invalid sender address", + 2: "Invalid recipient address", + 3: "SMTP error." + } + status_codes = { + 0: 550, # Error genérico + 1: 501, + 2: 550, + 3: 503 + } + + # Configuración de argumentos de línea de comandos + parser = argparse.ArgumentParser( + description="Cliente SMTP simulado con validación de entradas", + add_help=False + ) + 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") + else: + custom_headers = {} + except Exception as e: + print(json.dumps({"status_code": 400, "message": f"Error en encabezados: {str(e)}"})) + 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)}"})) + sys.exit(1) + + # 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 la función asíncrona que simula el envío + result, error_type = asyncio.run( + send_email( + args.from_mail, + args.password, + recipients, + email_subject, + email_body, + custom_headers, + args.host, + args.port + ) + ) + + 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() 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..0830ee7 --- /dev/null +++ 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 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 = []