import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import internal/protocol.{Done, ProtocolConfig, Receive, Send, Upgrade} import sendr.{ type Field, type SendrError, BackendError, Bcc, Cc, From, InvalidAttachment, InvalidBody, InvalidMailbox, NoBody, NoRecipients, ReplyTo, RequiredContentIdMissing, RequiredFieldMissing, RequiredFilenameMissing, Subject, To, } import sendr/message.{type Message} import sendr/message/attachment.{InlinedAttachment} import sendr/message/mailbox.{type Mailbox} /// Errors that can occur when sending an email via SMTP. pub type SmtpError { ConnectionError(ConnectionError) ProtocolError(protocol.ProtocolError) } /// Errors related to the network connection to the SMTP server. /// /// - `Closed`: The connection was closed by the server. /// - `Timeout`: The operation timed out. /// - `PosixError`: A POSIX system error occurred. /// - `SslNotStarted`: The SSL/TLS application was not started. /// - `TlsAlert`: A TLS alert was received from the server. pub type ConnectionError { /// The connection was closed. Closed /// The operation timed out. Timeout // /// The Erlang VM can't allocate more resources for network operations. // SystemLimit /// A POSIX error. PosixError(PosixError) /// The SSL application has not been started. It probably failed to start. SslNotStarted /// A TLS alert. TlsAlert(TlsAlert, String) // /// A generic TCP error with a description. // TcpError(String) } /// TLS alert descriptions as defined in the [Erlang ssl module documentation][1]. /// /// [1]: https://www.erlang.org/doc/apps/ssl/ssl.html#t:tls_alert/0 pub type TlsAlert { CloseNotify UnexpectedMessage BadRecordMac RecordOverflow HandshakeFailure BadCertificate UnsupportedCertificate CertificateRevoked CertificateExpired CertificateUnknown IllegalParameter UnknownCa AccessDenied DecodeError DecryptError ExportRestriction ProtocolVersion InsufficientSecurity InternalError InappropriateFallback UserCanceled NoRenegotiation UnsupportedExtension CertificateUnobtainable UnrecognizedName BadCertificateStatusResponse BadCertificateHashValue UnknownPskIdentity NoApplicationProtocol } /// POSIX error codes. /// /// See the [Erlang inet documentation](https://www.erlang.org/doc/apps/kernel/inet.html#module-posix-error-codes) /// for descriptions of each error code. pub type PosixError { Eaddrinuse Eaddrnotavail Eafnosupport Ealready Econnaborted Econnrefused Econnreset Edestaddrreq Ehostdown Ehostunreach Einprogress Eisconn Emsgsize Enetdown Enetreset Enetunreach Enopkg Enoprotoopt Enotconn Enotty Enotsock Eproto Eprotonosupport Eprototype Esocktnosupport Etimedout Ewouldblock Exbadport Exbadseq Nxdomain Eacces Eagain Ebadf Ebadmsg Ebusy Edeadlk Edeadlock Edquot Eexist Efault Efbig Eftype Eintr Einval Eio Eisdir Eloop Emfile Emlink Emultihop Enametoolong Enfile Enobufs Enodev Enolck Enolink Enoent Enomem Enospc Enosr Enostr Enosys Enotblk Enotdir Enotsup Enxio Eopnotsupp Eoverflow Eperm Epipe Erange Erofs Eshutdown Espipe Esrch Estale Etxtbsy Exdev } /// SMTP authentication credentials (username and password). pub type Credentials { Credentials(username: String, password: String) } /// The TLS mode for the SMTP connection. /// /// - `ImplicitTls`: Connect with TLS immediately (default port 465). /// - `StartTls`: Upgrade to TLS after connecting (default port 587). pub type TlsMode { ImplicitTls StartTls } /// TLS configuration for the SMTP connection. pub type Tls { Tls(mode: TlsMode, allow_invalid_certs: Bool) } /// Configuration for connecting to an SMTP server. /// /// - `hostname`: The SMTP server hostname. /// - `port`: The SMTP server port. /// - `tls`: The TLS configuration. /// - `timeout_ms`: Timeout in milliseconds for network operations. /// - `helo_host`: Custom hostname for the EHLO/HELO command, if none given /// the hostname is guessed. If guessing fails localhost is used. /// - `credentials`: Optional authentication credentials. pub type SmtpConfig { SmtpConfig( hostname: String, port: Int, tls: Tls, timeout_ms: Int, helo_host: Option(String), credentials: Option(Credentials), ) } type ConnectOptions { ConnectOptions(hostname: String, port: Int, tls: Tls, timeout_ms: Int) } type Socket type Connection { TcpConnection(socket: Socket, timeout_ms: Int) SslConnection(socket: Socket, timeout_ms: Int) } /// Create a default `SmtpConfig` for the given hostname and port. /// /// Automatically sets TLS mode based on the port: /// - Port 465: `ImplicitTls` with `allow_invalid_certs: False` /// - Other ports: `StartTls` with `allow_invalid_certs: True` /// Default timeout is 10 seconds. /// /// - `hostname`: The SMTP server hostname. /// - `port`: The SMTP server port. /// /// Returns a new `SmtpConfig`. pub fn config(connect_to hostname: String, on port: Int) -> SmtpConfig { let tls = case port { 465 -> Tls(ImplicitTls, False) _ -> Tls(StartTls, True) } SmtpConfig( hostname:, port:, tls:, timeout_ms: 10_000, credentials: None, helo_host: None, ) } /// Set the SMTP server port on the config. pub fn port(config config: SmtpConfig, set port: Int) -> SmtpConfig { SmtpConfig(..config, port:) } /// Set the TLS mode on the config. pub fn tls_mode( config config: SmtpConfig, set tls_mode: TlsMode, ) -> SmtpConfig { SmtpConfig(..config, tls: Tls(tls_mode, config.tls.allow_invalid_certs)) } /// Set whether to allow invalid TLS certificates. pub fn allow_invalid_certs( config config: SmtpConfig, set allow_invalid_certs: Bool, ) -> SmtpConfig { SmtpConfig(..config, tls: Tls(config.tls.mode, allow_invalid_certs)) } /// Set the SMTP authentication credentials on the config. pub fn credentials( config config: SmtpConfig, username username: String, password password: String, ) -> SmtpConfig { SmtpConfig(..config, credentials: Some(Credentials(username, password))) } /// Set a custom hostname for the EHLO/HELO command. pub fn helo_host( config config: SmtpConfig, set helo_host: String, ) -> SmtpConfig { SmtpConfig(..config, helo_host: Some(helo_host)) } /// Deliver an email message via SMTP. /// /// Validates the message fields upfront, then connects to the SMTP server, /// performs the SMTP protocol session, and disconnects. /// /// - `message`: The `sendr/message.Message` to deliver. /// - `config`: The `SmtpConfig` for the connection. /// /// Returns `Ok(Nil)` on success, or `Error(SendrError(SmtpError))` if /// validation or delivery fails. pub fn deliver( email message: Message, with config: SmtpConfig, ) -> Result(Nil, SendrError(SmtpError)) { use _ <- result.try(validate_from(message)) use _ <- result.try(validate_reply_to(message)) use _ <- result.try(validate_recipients(message)) use _ <- result.try(validate_subject(message)) use _ <- result.try(validate_attachments(message)) use _ <- result.try(validate_body(message)) do_deliver(message, config) |> result.map_error(fn(e) { BackendError(e) }) } fn validate_from(message: Message) -> Result(Nil, SendrError(error)) { case message.from { None -> Error(RequiredFieldMissing(From)) Some(mailbox) -> validate_mailbox(mailbox, From) } } fn validate_reply_to(message: Message) -> Result(Nil, SendrError(error)) { case message.reply_to { None | Some([]) -> Ok(Nil) Some(mailboxes) -> list.try_each(mailboxes, validate_mailbox(_, ReplyTo)) |> result.replace(Nil) } } fn validate_recipients(message: Message) -> Result(Nil, SendrError(error)) { let recipients = [message.to, message.cc, message.bcc] |> option.values() |> list.flatten() let validate = fn(mailboxes, field) { mailboxes |> option.unwrap([]) |> list.try_each(validate_mailbox(_, field)) } case recipients { [] -> Error(NoRecipients) _ -> validate(message.bcc, Bcc) |> result.or(validate(message.cc, Cc)) |> result.or(validate(message.to, To)) } } fn validate_mailbox( mailbox: Mailbox, field: Field, ) -> Result(Nil, SendrError(error)) { case string.split_once(mailbox.address, "@") { Ok(#(local, domain)) if local != "" && domain != "" -> Ok(Nil) _ -> Error(InvalidMailbox(field, mailbox)) } } fn validate_subject(message: Message) -> Result(Nil, SendrError(error)) { case message.subject { None | Some("") -> Error(RequiredFieldMissing(Subject)) _ -> Ok(Nil) } } fn validate_attachments(message: Message) -> Result(Nil, SendrError(error)) { list.try_each(message.attachments, fn(attachment) { case attachment.filename, attachment.filename_utf8 { "", "" -> Error(InvalidAttachment(RequiredFilenameMissing, attachment)) _, _ -> case attachment { InlinedAttachment(content_id: "", ..) -> Error(InvalidAttachment(RequiredContentIdMissing, attachment)) _ -> Ok(Nil) } } }) } fn validate_body(message: Message) -> Result(Nil, SendrError(error)) { case message.body.text, message.body.html { None, None -> Error(InvalidBody(NoBody)) _, _ -> Ok(Nil) } } fn do_deliver(message: Message, config: SmtpConfig) -> Result(Nil, SmtpError) { let connection_options = ConnectOptions(config.hostname, config.port, config.tls, config.timeout_ms) let protocol_config = ProtocolConfig( helo_host: option.lazy_unwrap(config.helo_host, fqdn), credentials: option.map(config.credentials, fn(credentials) { #(credentials.username, credentials.password) }), ) use connection <- result.try(connect(connection_options)) let result = smtp_session( connection, connection_options, protocol.start_session(message, protocol_config), ) disconnect(connection) result } fn fqdn() -> String { get_hostname() |> get_host_by_name() |> result.unwrap("localhost") } fn smtp_session( connection: Connection, options: ConnectOptions, action: Result(protocol.Action, protocol.ProtocolError), ) -> Result(Nil, SmtpError) { case action { Ok(Receive(callback)) -> connection |> receive() |> result.try(handle_response(_, callback)) |> result.try(fn(r) { smtp_session(connection, options, Ok(r)) }) Ok(Send(command, callback)) -> connection |> send(command) |> result.map(fn(_) { callback() }) |> result.try(smtp_session(connection, options, _)) Ok(Upgrade(callback)) -> connection |> upgrade(options) |> result.try(smtp_session(_, options, callback())) Ok(Done) -> Ok(Nil) Error(error) -> Error(ProtocolError(error)) } } fn handle_response( response: String, callback: fn(String) -> Result(b, protocol.ProtocolError), ) -> Result(b, SmtpError) { response |> callback() |> result.map_error(ProtocolError) } fn connect(options: ConnectOptions) -> Result(Connection, SmtpError) { case options.tls.mode { ImplicitTls -> { ssl_connect( options.hostname, options.port, options.tls.allow_invalid_certs, options.timeout_ms, ) |> result.map(SslConnection(_, options.timeout_ms)) } StartTls -> tcp_connect(options.hostname, options.port, options.timeout_ms) |> result.map(TcpConnection(_, options.timeout_ms)) } |> result.map_error(ConnectionError) } fn disconnect(connection: Connection) -> Nil { case connection { TcpConnection(socket:, ..) -> tcp_close(socket) SslConnection(socket:, ..) -> ssl_close(socket) } } fn receive(connection: Connection) -> Result(String, SmtpError) { case connection { TcpConnection(socket:, timeout_ms:) -> tcp_receive(socket, timeout_ms) SslConnection(socket:, timeout_ms:) -> ssl_receive(socket, timeout_ms) } |> result.map_error(ConnectionError) } fn send(connection: Connection, command: String) -> Result(Nil, SmtpError) { case connection { TcpConnection(socket:, ..) -> tcp_send(socket, <>) SslConnection(socket:, ..) -> ssl_send(socket, <>) } |> result.map_error(ConnectionError) } fn upgrade( connection: Connection, options: ConnectOptions, ) -> Result(Connection, SmtpError) { case connection { TcpConnection(socket:, timeout_ms:) -> tcp_upgrade_to_tls( socket, options.hostname, options.tls.allow_invalid_certs, options.timeout_ms, ) |> result.map(SslConnection(_, timeout_ms)) |> result.map_error(ConnectionError) SslConnection(..) -> Ok(connection) } } @external(erlang, "sendr_smtp_ffi", "get_hostname") fn get_hostname() -> String @external(erlang, "sendr_smtp_ffi", "get_host_by_name") fn get_host_by_name(hostname: String) -> Result(String, PosixError) @external(erlang, "sendr_smtp_ffi", "upgrade") fn tcp_upgrade_to_tls( socket: Socket, host: String, allow_invalid_certs: Bool, timeout: Int, ) -> Result(Socket, ConnectionError) @external(erlang, "sendr_smtp_ffi", "tcp_connect") fn tcp_connect( host: String, port: Int, timeout: Int, ) -> Result(Socket, ConnectionError) @external(erlang, "sendr_smtp_ffi", "tcp_close") fn tcp_close(socket: Socket) -> Nil @external(erlang, "sendr_smtp_ffi", "tcp_receive") fn tcp_receive(socket: Socket, timeout: Int) -> Result(String, ConnectionError) @external(erlang, "sendr_smtp_ffi", "tcp_send") fn tcp_send(socket: Socket, data: BitArray) -> Result(Nil, ConnectionError) @external(erlang, "sendr_smtp_ffi", "ssl_connect") fn ssl_connect( host: String, port: Int, allow_invalid_certs: Bool, timeout: Int, ) -> Result(Socket, ConnectionError) @external(erlang, "sendr_smtp_ffi", "ssl_close") fn ssl_close(socket: Socket) -> Nil @external(erlang, "sendr_smtp_ffi", "ssl_receive") fn ssl_receive(socket: Socket, timeout: Int) -> Result(String, ConnectionError) @external(erlang, "sendr_smtp_ffi", "ssl_send") fn ssl_send(socket: Socket, data: BitArray) -> Result(Nil, ConnectionError)