Skip to main content

src/sendr_smtp.erl

-module(sendr_smtp).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/sendr_smtp.gleam").
-export([config/2, port/2, tls_mode/2, allow_invalid_certs/2, credentials/3, helo_host/2, deliver/2]).
-export_type([smtp_error/0, connection_error/0, tls_alert/0, posix_error/0, credentials/0, tls_mode/0, tls/0, smtp_config/0, connect_options/0, socket/0, connection/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

-type smtp_error() :: {connection_error, connection_error()} |
    {protocol_error, internal@protocol:protocol_error()}.

-type connection_error() :: closed |
    timeout |
    {posix_error, posix_error()} |
    ssl_not_started |
    {tls_alert, tls_alert(), binary()}.

-type tls_alert() :: close_notify |
    unexpected_message |
    bad_record_mac |
    record_overflow |
    handshake_failure |
    bad_certificate |
    unsupported_certificate |
    certificate_revoked |
    certificate_expired |
    certificate_unknown |
    illegal_parameter |
    unknown_ca |
    access_denied |
    decode_error |
    decrypt_error |
    export_restriction |
    protocol_version |
    insufficient_security |
    internal_error |
    inappropriate_fallback |
    user_canceled |
    no_renegotiation |
    unsupported_extension |
    certificate_unobtainable |
    unrecognized_name |
    bad_certificate_status_response |
    bad_certificate_hash_value |
    unknown_psk_identity |
    no_application_protocol.

-type posix_error() :: 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.

-type credentials() :: {credentials, binary(), binary()}.

-type tls_mode() :: implicit_tls | start_tls.

-type tls() :: {tls, tls_mode(), boolean()}.

-type smtp_config() :: {smtp_config,
        binary(),
        integer(),
        tls(),
        integer(),
        gleam@option:option(binary()),
        gleam@option:option(credentials())}.

-type connect_options() :: {connect_options,
        binary(),
        integer(),
        tls(),
        integer()}.

-type socket() :: any().

-type connection() :: {tcp_connection, socket(), integer()} |
    {ssl_connection, socket(), integer()}.

-file("src/sendr_smtp.gleam", 227).
?DOC(
    " Create a default `SmtpConfig` for the given hostname and port.\n"
    "\n"
    " Automatically sets TLS mode based on the port:\n"
    " - Port 465: `ImplicitTls` with `allow_invalid_certs: False`\n"
    " - Other ports: `StartTls` with `allow_invalid_certs: True`\n"
    " Default timeout is 10 seconds.\n"
    "\n"
    " - `hostname`: The SMTP server hostname.\n"
    " - `port`: The SMTP server port.\n"
    "\n"
    " Returns a new `SmtpConfig`.\n"
).
-spec config(binary(), integer()) -> smtp_config().
config(Hostname, Port) ->
    Tls = case Port of
        465 ->
            {tls, implicit_tls, false};

        _ ->
            {tls, start_tls, true}
    end,
    {smtp_config, Hostname, Port, Tls, 10000, none, none}.

-file("src/sendr_smtp.gleam", 244).
?DOC(" Set the SMTP server port on the config.\n").
-spec port(smtp_config(), integer()) -> smtp_config().
port(Config, Port) ->
    {smtp_config,
        erlang:element(2, Config),
        Port,
        erlang:element(4, Config),
        erlang:element(5, Config),
        erlang:element(6, Config),
        erlang:element(7, Config)}.

-file("src/sendr_smtp.gleam", 249).
?DOC(" Set the TLS mode on the config.\n").
-spec tls_mode(smtp_config(), tls_mode()) -> smtp_config().
tls_mode(Config, Tls_mode) ->
    {smtp_config,
        erlang:element(2, Config),
        erlang:element(3, Config),
        {tls, Tls_mode, erlang:element(3, erlang:element(4, Config))},
        erlang:element(5, Config),
        erlang:element(6, Config),
        erlang:element(7, Config)}.

-file("src/sendr_smtp.gleam", 257).
?DOC(" Set whether to allow invalid TLS certificates.\n").
-spec allow_invalid_certs(smtp_config(), boolean()) -> smtp_config().
allow_invalid_certs(Config, Allow_invalid_certs) ->
    {smtp_config,
        erlang:element(2, Config),
        erlang:element(3, Config),
        {tls, erlang:element(2, erlang:element(4, Config)), Allow_invalid_certs},
        erlang:element(5, Config),
        erlang:element(6, Config),
        erlang:element(7, Config)}.

-file("src/sendr_smtp.gleam", 265).
?DOC(" Set the SMTP authentication credentials on the config.\n").
-spec credentials(smtp_config(), binary(), binary()) -> smtp_config().
credentials(Config, Username, Password) ->
    {smtp_config,
        erlang:element(2, Config),
        erlang:element(3, Config),
        erlang:element(4, Config),
        erlang:element(5, Config),
        erlang:element(6, Config),
        {some, {credentials, Username, Password}}}.

-file("src/sendr_smtp.gleam", 274).
?DOC(" Set a custom hostname for the EHLO/HELO command.\n").
-spec helo_host(smtp_config(), binary()) -> smtp_config().
helo_host(Config, Helo_host) ->
    {smtp_config,
        erlang:element(2, Config),
        erlang:element(3, Config),
        erlang:element(4, Config),
        erlang:element(5, Config),
        {some, Helo_host},
        erlang:element(7, Config)}.

-file("src/sendr_smtp.gleam", 461).
-spec disconnect(connection()) -> nil.
disconnect(Connection) ->
    case Connection of
        {tcp_connection, Socket, _} ->
            sendr_smtp_ffi:tcp_close(Socket);

        {ssl_connection, Socket@1, _} ->
            sendr_smtp_ffi:ssl_close(Socket@1)
    end.

-file("src/sendr_smtp.gleam", 484).
-spec upgrade(connection(), connect_options()) -> {ok, connection()} |
    {error, smtp_error()}.
upgrade(Connection, Options) ->
    case Connection of
        {tcp_connection, Socket, Timeout_ms} ->
            _pipe = sendr_smtp_ffi:upgrade(
                Socket,
                erlang:element(2, Options),
                erlang:element(3, erlang:element(4, Options)),
                erlang:element(5, Options)
            ),
            _pipe@1 = gleam@result:map(
                _pipe,
                fun(_capture) -> {ssl_connection, _capture, Timeout_ms} end
            ),
            gleam@result:map_error(
                _pipe@1,
                fun(Field@0) -> {connection_error, Field@0} end
            );

        {ssl_connection, _, _} ->
            {ok, Connection}
    end.

-file("src/sendr_smtp.gleam", 476).
-spec send(connection(), binary()) -> {ok, nil} | {error, smtp_error()}.
send(Connection, Command) ->
    _pipe = case Connection of
        {tcp_connection, Socket, _} ->
            sendr_smtp_ffi:tcp_send(Socket, <<Command/binary>>);

        {ssl_connection, Socket@1, _} ->
            sendr_smtp_ffi:ssl_send(Socket@1, <<Command/binary>>)
    end,
    gleam@result:map_error(
        _pipe,
        fun(Field@0) -> {connection_error, Field@0} end
    ).

-file("src/sendr_smtp.gleam", 434).
-spec handle_response(
    binary(),
    fun((binary()) -> {ok, IDM} | {error, internal@protocol:protocol_error()})
) -> {ok, IDM} | {error, smtp_error()}.
handle_response(Response, Callback) ->
    _pipe = Response,
    _pipe@1 = Callback(_pipe),
    gleam@result:map_error(
        _pipe@1,
        fun(Field@0) -> {protocol_error, Field@0} end
    ).

-file("src/sendr_smtp.gleam", 468).
-spec 'receive'(connection()) -> {ok, binary()} | {error, smtp_error()}.
'receive'(Connection) ->
    _pipe = case Connection of
        {tcp_connection, Socket, Timeout_ms} ->
            sendr_smtp_ffi:tcp_receive(Socket, Timeout_ms);

        {ssl_connection, Socket@1, Timeout_ms@1} ->
            sendr_smtp_ffi:ssl_receive(Socket@1, Timeout_ms@1)
    end,
    gleam@result:map_error(
        _pipe,
        fun(Field@0) -> {connection_error, Field@0} end
    ).

-file("src/sendr_smtp.gleam", 409).
-spec smtp_session(
    connection(),
    connect_options(),
    {ok, internal@protocol:action()} |
        {error, internal@protocol:protocol_error()}
) -> {ok, nil} | {error, smtp_error()}.
smtp_session(Connection, Options, Action) ->
    case Action of
        {ok, {'receive', Callback}} ->
            _pipe = Connection,
            _pipe@1 = 'receive'(_pipe),
            _pipe@2 = gleam@result:'try'(
                _pipe@1,
                fun(_capture) -> handle_response(_capture, Callback) end
            ),
            gleam@result:'try'(
                _pipe@2,
                fun(R) -> smtp_session(Connection, Options, {ok, R}) end
            );

        {ok, {send, Command, Callback@1}} ->
            _pipe@3 = Connection,
            _pipe@4 = send(_pipe@3, Command),
            _pipe@5 = gleam@result:map(_pipe@4, fun(_) -> Callback@1() end),
            gleam@result:'try'(
                _pipe@5,
                fun(_capture@1) ->
                    smtp_session(Connection, Options, _capture@1)
                end
            );

        {ok, {upgrade, Callback@2}} ->
            _pipe@6 = Connection,
            _pipe@7 = upgrade(_pipe@6, Options),
            gleam@result:'try'(
                _pipe@7,
                fun(_capture@2) ->
                    smtp_session(_capture@2, Options, Callback@2())
                end
            );

        {ok, done} ->
            {ok, nil};

        {error, Error} ->
            {error, {protocol_error, Error}}
    end.

-file("src/sendr_smtp.gleam", 443).
-spec connect(connect_options()) -> {ok, connection()} | {error, smtp_error()}.
connect(Options) ->
    _pipe@2 = case erlang:element(2, erlang:element(4, Options)) of
        implicit_tls ->
            _pipe = sendr_smtp_ffi:ssl_connect(
                erlang:element(2, Options),
                erlang:element(3, Options),
                erlang:element(3, erlang:element(4, Options)),
                erlang:element(5, Options)
            ),
            gleam@result:map(
                _pipe,
                fun(_capture) ->
                    {ssl_connection, _capture, erlang:element(5, Options)}
                end
            );

        start_tls ->
            _pipe@1 = sendr_smtp_ffi:tcp_connect(
                erlang:element(2, Options),
                erlang:element(3, Options),
                erlang:element(5, Options)
            ),
            gleam@result:map(
                _pipe@1,
                fun(_capture@1) ->
                    {tcp_connection, _capture@1, erlang:element(5, Options)}
                end
            )
    end,
    gleam@result:map_error(
        _pipe@2,
        fun(Field@0) -> {connection_error, Field@0} end
    ).

-file("src/sendr_smtp.gleam", 403).
-spec fqdn() -> binary().
fqdn() ->
    _pipe = sendr_smtp_ffi:get_hostname(),
    _pipe@1 = sendr_smtp_ffi:get_host_by_name(_pipe),
    gleam@result:unwrap(_pipe@1, <<"localhost"/utf8>>).

-file("src/sendr_smtp.gleam", 381).
-spec do_deliver(sendr@message:message(), smtp_config()) -> {ok, nil} |
    {error, smtp_error()}.
do_deliver(Message, Config) ->
    Connection_options = {connect_options,
        erlang:element(2, Config),
        erlang:element(3, Config),
        erlang:element(4, Config),
        erlang:element(5, Config)},
    Protocol_config = {protocol_config,
        gleam@option:lazy_unwrap(erlang:element(6, Config), fun fqdn/0),
        gleam@option:map(
            erlang:element(7, Config),
            fun(Credentials) ->
                {erlang:element(2, Credentials), erlang:element(3, Credentials)}
            end
        )},
    gleam@result:'try'(
        connect(Connection_options),
        fun(Connection) ->
            Result = smtp_session(
                Connection,
                Connection_options,
                internal@protocol:start_session(Message, Protocol_config)
            ),
            disconnect(Connection),
            Result
        end
    ).

-file("src/sendr_smtp.gleam", 374).
-spec validate_body(sendr@message:message()) -> {ok, nil} |
    {error, sendr:sendr_error(any())}.
validate_body(Message) ->
    case {erlang:element(2, erlang:element(9, Message)),
        erlang:element(3, erlang:element(9, Message))} of
        {none, none} ->
            {error, {invalid_body, no_body}};

        {_, _} ->
            {ok, nil}
    end.

-file("src/sendr_smtp.gleam", 360).
-spec validate_attachments(sendr@message:message()) -> {ok, nil} |
    {error, sendr:sendr_error(any())}.
validate_attachments(Message) ->
    gleam@list:try_each(
        erlang:element(8, Message),
        fun(Attachment) ->
            case {erlang:element(3, Attachment), erlang:element(4, Attachment)} of
                {<<""/utf8>>, <<""/utf8>>} ->
                    {error,
                        {invalid_attachment,
                            required_filename_missing,
                            Attachment}};

                {_, _} ->
                    case Attachment of
                        {inlined_attachment, _, _, _, _, <<""/utf8>>} ->
                            {error,
                                {invalid_attachment,
                                    required_content_id_missing,
                                    Attachment}};

                        _ ->
                            {ok, nil}
                    end
            end
        end
    ).

-file("src/sendr_smtp.gleam", 353).
-spec validate_subject(sendr@message:message()) -> {ok, nil} |
    {error, sendr:sendr_error(any())}.
validate_subject(Message) ->
    case erlang:element(7, Message) of
        none ->
            {error, {required_field_missing, subject}};

        {some, <<""/utf8>>} ->
            {error, {required_field_missing, subject}};

        _ ->
            {ok, nil}
    end.

-file("src/sendr_smtp.gleam", 343).
-spec validate_mailbox(sendr@message@mailbox:mailbox(), sendr:field()) -> {ok,
        nil} |
    {error, sendr:sendr_error(any())}.
validate_mailbox(Mailbox, Field) ->
    case gleam@string:split_once(erlang:element(3, Mailbox), <<"@"/utf8>>) of
        {ok, {Local, Domain}} when (Local =/= <<""/utf8>>) andalso (Domain =/= <<""/utf8>>) ->
            {ok, nil};

        _ ->
            {error, {invalid_mailbox, Field, Mailbox}}
    end.

-file("src/sendr_smtp.gleam", 322).
-spec validate_recipients(sendr@message:message()) -> {ok, nil} |
    {error, sendr:sendr_error(any())}.
validate_recipients(Message) ->
    Recipients = begin
        _pipe = [erlang:element(4, Message),
            erlang:element(5, Message),
            erlang:element(6, Message)],
        _pipe@1 = gleam@option:values(_pipe),
        lists:append(_pipe@1)
    end,
    Validate = fun(Mailboxes, Field) -> _pipe@2 = Mailboxes,
        _pipe@3 = gleam@option:unwrap(_pipe@2, []),
        gleam@list:try_each(
            _pipe@3,
            fun(_capture) -> validate_mailbox(_capture, Field) end
        ) end,
    case Recipients of
        [] ->
            {error, no_recipients};

        _ ->
            _pipe@4 = Validate(erlang:element(6, Message), bcc),
            _pipe@5 = gleam@result:'or'(
                _pipe@4,
                Validate(erlang:element(5, Message), cc)
            ),
            gleam@result:'or'(_pipe@5, Validate(erlang:element(4, Message), to))
    end.

-file("src/sendr_smtp.gleam", 313).
-spec validate_reply_to(sendr@message:message()) -> {ok, nil} |
    {error, sendr:sendr_error(any())}.
validate_reply_to(Message) ->
    case erlang:element(3, Message) of
        none ->
            {ok, nil};

        {some, []} ->
            {ok, nil};

        {some, Mailboxes} ->
            _pipe = gleam@list:try_each(
                Mailboxes,
                fun(_capture) -> validate_mailbox(_capture, reply_to) end
            ),
            gleam@result:replace(_pipe, nil)
    end.

-file("src/sendr_smtp.gleam", 306).
-spec validate_from(sendr@message:message()) -> {ok, nil} |
    {error, sendr:sendr_error(any())}.
validate_from(Message) ->
    case erlang:element(2, Message) of
        none ->
            {error, {required_field_missing, from}};

        {some, Mailbox} ->
            validate_mailbox(Mailbox, from)
    end.

-file("src/sendr_smtp.gleam", 291).
?DOC(
    " Deliver an email message via SMTP.\n"
    "\n"
    " Validates the message fields upfront, then connects to the SMTP server,\n"
    " performs the SMTP protocol session, and disconnects.\n"
    "\n"
    " - `message`: The `sendr/message.Message` to deliver.\n"
    " - `config`: The `SmtpConfig` for the connection.\n"
    "\n"
    " Returns `Ok(Nil)` on success, or `Error(SendrError(SmtpError))` if\n"
    " validation or delivery fails.\n"
).
-spec deliver(sendr@message:message(), smtp_config()) -> {ok, nil} |
    {error, sendr:sendr_error(smtp_error())}.
deliver(Message, Config) ->
    gleam@result:'try'(
        validate_from(Message),
        fun(_) ->
            gleam@result:'try'(
                validate_reply_to(Message),
                fun(_) ->
                    gleam@result:'try'(
                        validate_recipients(Message),
                        fun(_) ->
                            gleam@result:'try'(
                                validate_subject(Message),
                                fun(_) ->
                                    gleam@result:'try'(
                                        validate_attachments(Message),
                                        fun(_) ->
                                            gleam@result:'try'(
                                                validate_body(Message),
                                                fun(_) ->
                                                    _pipe = do_deliver(
                                                        Message,
                                                        Config
                                                    ),
                                                    gleam@result:map_error(
                                                        _pipe,
                                                        fun(E) ->
                                                            {backend_error, E}
                                                        end
                                                    )
                                                end
                                            )
                                        end
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).