SMTP con STARTTLS via curl

- Reimplementación de smtp.zig usando curl subprocess
- Curl maneja TLS/STARTTLS transparentemente
- Configuración SMTP añadida a services.conf (Mailbox.org)
- Más simple y fiable que implementación nativa TLS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 01:18:43 +01:00
parent f31ce95afe
commit 4a9d0e62e0
2 changed files with 110 additions and 161 deletions

View file

@ -12,3 +12,7 @@ tcp,Forgejo (SSH),git.reugenio.com,2222
# Telegram # Telegram
telegram,8158165444:AAFxUjLChsuusgFD5B1gt2svt8NflvAm1M8,1481345275 telegram,8158165444:AAFxUjLChsuusgFD5B1gt2svt8NflvAm1M8,1481345275
# Email SMTP (Mailbox.org con STARTTLS)
email_smtp,smtp.mailbox.org,587,reugenio@mailbox.org,#Szy(fFaA3DIY_2-iC8lHYQS1R3u(ur2kbfah(1q,reugenio@mailbox.org
email,reugenio@mailbox.org

View file

@ -1,16 +1,16 @@
//! Cliente SMTP para envío de emails //! Cliente SMTP para envío de emails
//! //!
//! Implementación del protocolo SMTP (RFC 5321) con soporte para //! Implementación usando curl para SMTP con STARTTLS.
//! autenticación AUTH LOGIN y STARTTLS. //! Curl maneja TLS de forma transparente, lo que simplifica mucho el código.
//! //!
//! Ejemplo de uso: //! Ejemplo de uso:
//! ```zig //! ```zig
//! try smtp.sendEmail(allocator, .{ //! try smtp.sendEmail(allocator, .{
//! .host = "smtp.gmail.com", //! .host = "smtp.mailbox.org",
//! .port = 587, //! .port = 587,
//! .username = "usuario@gmail.com", //! .username = "usuario@mailbox.org",
//! .password = "app_password", //! .password = "app_password",
//! .from = "usuario@gmail.com", //! .from = "usuario@mailbox.org",
//! .to = &[_][]const u8{"destino@ejemplo.com"}, //! .to = &[_][]const u8{"destino@ejemplo.com"},
//! .subject = "Alerta", //! .subject = "Alerta",
//! .body = "Servicio caído", //! .body = "Servicio caído",
@ -41,6 +41,12 @@ pub const SmtpError = error{
NetworkError, NetworkError,
/// Buffer demasiado pequeño. /// Buffer demasiado pequeño.
BufferTooSmall, BufferTooSmall,
/// Error iniciando TLS.
TlsInitFailed,
/// El servidor no soporta STARTTLS.
StarttlsNotSupported,
/// Curl no disponible o falló.
CurlFailed,
}; };
/// Opciones para enviar un email. /// Opciones para enviar un email.
@ -61,201 +67,140 @@ pub const EmailOptions = struct {
subject: []const u8, subject: []const u8,
/// Cuerpo del email (texto plano). /// Cuerpo del email (texto plano).
body: []const u8, body: []const u8,
/// Usar STARTTLS (requerido para puerto 587).
use_starttls: bool = true,
}; };
/// Envía un email usando SMTP. /// Envía un email usando curl con SMTP/STARTTLS.
/// ///
/// Soporta autenticación AUTH LOGIN. Para servidores que requieren /// Usa curl para manejar la conexión SMTP con TLS de forma transparente.
/// TLS (como Gmail en puerto 587), se necesita STARTTLS que aún /// Funciona con servidores como Gmail (smtp.gmail.com:587) o
/// no está implementado - usar puerto 465 (SMTPS) como alternativa. /// Mailbox (smtp.mailbox.org:587).
pub fn sendEmail(allocator: std.mem.Allocator, options: EmailOptions) SmtpError!void { pub fn sendEmail(allocator: std.mem.Allocator, options: EmailOptions) SmtpError!void {
// Conectar al servidor // Construir URL SMTP
const stream = std.net.tcpConnectToHost(allocator, options.host, options.port) catch { var url_buf: [256]u8 = undefined;
return SmtpError.ConnectionFailed; const url = std.fmt.bufPrint(&url_buf, "smtp://{s}:{d}", .{ options.host, options.port }) catch {
};
defer stream.close();
var read_buffer: [1024]u8 = undefined;
// Recibir saludo (220)
try expectResponse(stream, &read_buffer, "220");
// EHLO
try sendCommand(stream, "EHLO localhost\r\n");
try expectResponse(stream, &read_buffer, "250");
// Autenticación si hay credenciales
if (options.username.len > 0 and options.password.len > 0) {
try sendCommand(stream, "AUTH LOGIN\r\n");
try expectResponse(stream, &read_buffer, "334");
// Usuario en Base64
var user_b64: [256]u8 = undefined;
const user_encoded = base64Encode(options.username, &user_b64) catch {
return SmtpError.BufferTooSmall; return SmtpError.BufferTooSmall;
}; };
try sendLine(stream, user_encoded);
try expectResponse(stream, &read_buffer, "334");
// Password en Base64 // Construir credenciales user:pass
var pass_b64: [256]u8 = undefined; var creds_buf: [256]u8 = undefined;
const pass_encoded = base64Encode(options.password, &pass_b64) catch { const creds = std.fmt.bufPrint(&creds_buf, "{s}:{s}", .{ options.username, options.password }) catch {
return SmtpError.BufferTooSmall; return SmtpError.BufferTooSmall;
}; };
try sendLine(stream, pass_encoded);
const auth_response = try readResponse(stream, &read_buffer); // Construir mail-from
if (!std.mem.startsWith(u8, auth_response, "235")) { var from_buf: [256]u8 = undefined;
return SmtpError.AuthenticationFailed; const mail_from = std.fmt.bufPrint(&from_buf, "<{s}>", .{options.from}) catch {
}
}
// MAIL FROM
var from_cmd: [512]u8 = undefined;
const from_len = std.fmt.bufPrint(&from_cmd, "MAIL FROM:<{s}>\r\n", .{options.from}) catch {
return SmtpError.BufferTooSmall; return SmtpError.BufferTooSmall;
}; };
try sendCommand(stream, from_len);
const from_response = try readResponse(stream, &read_buffer); // Construir mail-rcpt (primer destinatario)
if (!std.mem.startsWith(u8, from_response, "250")) { var rcpt_buf: [256]u8 = undefined;
return SmtpError.SenderRejected; const mail_rcpt = std.fmt.bufPrint(&rcpt_buf, "<{s}>", .{options.to[0]}) catch {
}
// RCPT TO (para cada destinatario)
for (options.to) |recipient| {
var rcpt_cmd: [512]u8 = undefined;
const rcpt_len = std.fmt.bufPrint(&rcpt_cmd, "RCPT TO:<{s}>\r\n", .{recipient}) catch {
return SmtpError.BufferTooSmall; return SmtpError.BufferTooSmall;
}; };
try sendCommand(stream, rcpt_len);
const rcpt_response = try readResponse(stream, &read_buffer); // Construir mensaje completo
if (!std.mem.startsWith(u8, rcpt_response, "250")) { var msg_buf: [4096]u8 = undefined;
return SmtpError.RecipientRejected; const message = std.fmt.bufPrint(&msg_buf,
}
}
// DATA
try sendCommand(stream, "DATA\r\n");
try expectResponse(stream, &read_buffer, "354");
// Headers + Body
var msg_buffer: [4096]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buffer,
\\From: {s} \\From: {s}
\\To: {s} \\To: {s}
\\Subject: {s} \\Subject: {s}
\\Content-Type: text/plain; charset=UTF-8 \\Content-Type: text/plain; charset=UTF-8
\\ \\
\\{s} \\{s}
\\.
\\
, .{ , .{
options.from, options.from,
options.to[0], // Primer destinatario en header options.to[0],
options.subject, options.subject,
options.body, options.body,
}) catch { }) catch {
return SmtpError.BufferTooSmall; return SmtpError.BufferTooSmall;
}; };
// Convertir \n a \r\n para SMTP // Construir argumentos de curl
try sendSmtpData(stream, msg); var args = std.array_list.Managed([]const u8).init(allocator);
defer args.deinit();
const data_response = try readResponse(stream, &read_buffer); args.append("curl") catch return SmtpError.CurlFailed;
if (!std.mem.startsWith(u8, data_response, "250")) { args.append("-s") catch return SmtpError.CurlFailed; // Silent
return SmtpError.MessageRejected; args.append("--url") catch return SmtpError.CurlFailed;
args.append(url) catch return SmtpError.CurlFailed;
// STARTTLS
if (options.use_starttls) {
args.append("--ssl-reqd") catch return SmtpError.CurlFailed;
} }
// QUIT // Autenticación
try sendCommand(stream, "QUIT\r\n"); if (options.username.len > 0) {
// No esperamos respuesta de QUIT, solo cerramos args.append("-u") catch return SmtpError.CurlFailed;
} args.append(creds) catch return SmtpError.CurlFailed;
/// Lee una respuesta del servidor SMTP.
fn readResponse(stream: std.net.Stream, buffer: []u8) SmtpError![]const u8 {
const bytes_read = stream.read(buffer) catch {
return SmtpError.NetworkError;
};
if (bytes_read == 0) {
return SmtpError.ConnectionClosed;
} }
return buffer[0..bytes_read]; // Remitente y destinatario
} args.append("--mail-from") catch return SmtpError.CurlFailed;
args.append(mail_from) catch return SmtpError.CurlFailed;
args.append("--mail-rcpt") catch return SmtpError.CurlFailed;
args.append(mail_rcpt) catch return SmtpError.CurlFailed;
/// Espera una respuesta que empiece con el código dado. // Mensaje via stdin
fn expectResponse(stream: std.net.Stream, buffer: []u8, expected_code: []const u8) SmtpError!void { args.append("-T") catch return SmtpError.CurlFailed;
const response = try readResponse(stream, buffer); args.append("-") catch return SmtpError.CurlFailed; // Read from stdin
if (!std.mem.startsWith(u8, response, expected_code)) { // Ejecutar curl
return SmtpError.UnexpectedResponse; var child = std.process.Child.init(args.items, allocator);
child.stdin_behavior = .Pipe;
child.stdout_behavior = .Pipe;
child.stderr_behavior = .Pipe;
child.spawn() catch return SmtpError.CurlFailed;
// Enviar mensaje por stdin
if (child.stdin) |stdin| {
stdin.writeAll(message) catch return SmtpError.CurlFailed;
stdin.close();
child.stdin = null;
} }
}
/// Envía un comando al servidor. // Esperar a que termine
fn sendCommand(stream: std.net.Stream, command: []const u8) SmtpError!void { const result = child.wait() catch return SmtpError.CurlFailed;
_ = stream.write(command) catch {
return SmtpError.NetworkError;
};
}
/// Envía una línea con CRLF al final. // Limpiar stdout/stderr
fn sendLine(stream: std.net.Stream, line: []const u8) SmtpError!void { if (child.stdout) |stdout| {
_ = stream.write(line) catch { stdout.close();
return SmtpError.NetworkError; }
}; if (child.stderr) |stderr| {
_ = stream.write("\r\n") catch { stderr.close();
return SmtpError.NetworkError; }
};
}
/// Envía datos SMTP convirtiendo \n a \r\n. // Verificar resultado
fn sendSmtpData(stream: std.net.Stream, data: []const u8) SmtpError!void { if (result.Exited != 0) {
var i: usize = 0; // Códigos de error comunes de curl para SMTP:
while (i < data.len) { // 67 = login denied (AuthenticationFailed)
const start = i; // 7 = couldn't connect (ConnectionFailed)
// Buscar siguiente \n // 56 = recv error (NetworkError)
while (i < data.len and data[i] != '\n') : (i += 1) {} return switch (result.Exited) {
67 => SmtpError.AuthenticationFailed,
// Enviar hasta aquí 7 => SmtpError.ConnectionFailed,
if (i > start) { else => SmtpError.CurlFailed,
_ = stream.write(data[start..i]) catch {
return SmtpError.NetworkError;
}; };
} }
// Si encontramos \n, enviar \r\n
if (i < data.len and data[i] == '\n') {
_ = stream.write("\r\n") catch {
return SmtpError.NetworkError;
};
i += 1;
}
}
}
/// Codifica datos en Base64.
fn base64Encode(input: []const u8, output: []u8) ![]const u8 {
const encoder = std.base64.standard.Encoder;
const encoded_len = encoder.calcSize(input.len);
if (encoded_len > output.len) {
return error.BufferTooSmall;
}
const result = encoder.encode(output, input);
return result;
} }
// ============================================================================ // ============================================================================
// Tests // Tests
// ============================================================================ // ============================================================================
test "base64 encode" { test "smtp module compiles" {
var buffer: [64]u8 = undefined; // Test básico para verificar que el módulo compila
const result = try base64Encode("test", &buffer); const opts = EmailOptions{
try std.testing.expectEqualStrings("dGVzdA==", result); .host = "smtp.example.com",
.from = "test@example.com",
.to = &[_][]const u8{"dest@example.com"},
.subject = "Test",
.body = "Test body",
};
_ = opts;
} }