src/gen_smtp_client.erl

%%% Copyright 2009 Andrew Thompson <andrew@hijacked.us>. All rights reserved.
%%%
%%% Redistribution and use in source and binary forms, with or without
%%% modification, are permitted provided that the following conditions are met:
%%%
%%%   1. Redistributions of source code must retain the above copyright notice,
%%%      this list of conditions and the following disclaimer.
%%%   2. Redistributions in binary form must reproduce the above copyright
%%%      notice, this list of conditions and the following disclaimer in the
%%%      documentation and/or other materials provided with the distribution.
%%%
%%% THIS SOFTWARE IS PROVIDED BY THE FREEBSD PROJECT ``AS IS'' AND ANY EXPRESS OR
%%% IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
%%% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
%%% EVENT SHALL THE FREEBSD PROJECT OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
%%% INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
%%% (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
%%% LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
%%% ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
%%% (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
%%% SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

%% @doc A simple SMTP client used for sending mail - assumes relaying via a
%% smarthost.

-module(gen_smtp_client).

-define(DEFAULT_OPTIONS, [
    % whether to connect on 465 in ssl mode
    {ssl, false},
    % always, never, if_available
    {tls, if_available},
    % used in ssl:connect, http://erlang.org/doc/man/ssl.html
    {tls_options, [{versions, ['tlsv1', 'tlsv1.1', 'tlsv1.2']}]},
    {auth, if_available},
    {hostname, smtp_util:guess_FQDN()},
    % how many retries per smtp host on temporary failure
    {retries, 1},
    {on_transaction_error, quit}
]).

-define(AUTH_PREFERENCE, [
    "CRAM-MD5",
    "LOGIN",
    "PLAIN",
    "XOAUTH2"
]).

-define(TIMEOUT, 1200000).

-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-compile([export_all, nowarn_export_all]).
-else.
-export([send/2, send/3, send_blocking/2, open/1, deliver/2, close/1]).
-endif.

-export_type([
    smtp_client_socket/0,
    email/0,
    email_address/0,
    options/0,
    callback/0,
    smtp_session_error/0,
    host_failure/0,
    failure/0,
    validate_options_error/0
]).

-type email_address() :: string() | binary().
-type email() :: {
    From :: email_address(),
    To :: [email_address(), ...],
    Body :: string() | binary() | fun(() -> string() | binary())
}.

-type options() :: [
    {ssl, boolean()}
    | {tls, always | never | if_available}
    % ssl:option() / ssl:tls_client_option()
    | {tls_options, list()}
    | {sockopts, [gen_tcp:connect_option()]}
    | {port, inet:port_number()}
    | {timeout, timeout()}
    | {relay, inet:ip_address() | inet:hostname()}
    | {no_mx_lookups, boolean()}
    | {auth, always | never | if_available}
    | {hostname, string()}
    | {retries, non_neg_integer()}
    | {username, string()}
    | {password, string()}
    | {trace_fun, fun((Fmt :: string(), Args :: [any()]) -> any())}
    | {on_transaction_error, quit | reset}
].

-type extensions() :: [{binary(), binary()}].

-record(smtp_client_socket, {
    socket :: smtp_socket:socket(),
    host :: string(),
    extensions :: list(),
    options :: list()
}).
-opaque smtp_client_socket() :: #smtp_client_socket{}.

-type callback() :: fun(
    (
        {exit, any()}
        | smtp_session_error()
        | {ok, binary()}
    ) -> any()
).

%% Smth that is thrown from inner SMTP functions

% server's 5xx response
-type permanent_failure_reason() ::
    binary()
    | auth_failed
    | ssl_not_started.
%server's 4xx response
-type temporary_failure_reason() ::
    binary()
    | tls_failed.
-type validate_options_error() ::
    no_relay
    | invalid_port
    | no_credentials.
-type failure() ::
    {temporary_failure, temporary_failure_reason()}
    | {permanent_failure, permanent_failure_reason()}
    | {missing_requirement, auth | tls}
    | {unexpected_response, [binary()]}
    | {network_failure, {error, timeout | inet:posix()}}.
-type smtp_host() :: inet:hostname().
-type host_failure() ::
    {temporary_failure, smtp_host(), temporary_failure_reason()}
    | {permanent_failure, smtp_host(), permanent_failure_reason()}
    | {missing_requirement, smtp_host(), auth | tls}
    | {unexpected_response, smtp_host(), [binary()]}
    | {network_failure, smtp_host(), {error, timeout | inet:posix()}}.
-type smtp_session_error() ::
    {error, no_more_hosts | send, {permanent_failure, smtp_host(), permanent_failure_reason()}}
    | {error, retries_exceeded | send, host_failure()}.

-spec send(Email :: email(), Options :: options()) ->
    {'ok', pid()} | {'error', validate_options_error()}.
%% @doc Send an email in a non-blocking fashion via a spawned_linked process.
%% The process will exit abnormally on a send failure.
send(Email, Options) ->
    send(Email, Options, undefined).

%% @doc Send an email nonblocking and invoke a callback with the result of the send.
%% The callback will receive either `{ok, Receipt}' where Receipt is the SMTP server's receipt
%% identifier,  `{error, Type, Message}' or `{exit, ExitReason}', as the single argument.
-spec send(Email :: email(), Options :: options(), Callback :: callback() | 'undefined') ->
    {'ok', pid()} | {'error', validate_options_error()}.
send(Email, Options, Callback) ->
    NewOptions = lists:ukeymerge(
        1,
        lists:sort(Options),
        lists:sort(?DEFAULT_OPTIONS)
    ),
    case check_options(NewOptions) of
        ok ->
            Pid = spawn_link(
                fun() ->
                    try send_it(Email, NewOptions) of
                        {error, _Type, _Reason} = Error when is_function(Callback, 1) ->
                            Callback(Error);
                        {error, _Type, _Reason} = Error ->
                            exit(Error);
                        Receipt when is_function(Callback, 1) ->
                            Callback({ok, Receipt});
                        _Receipt ->
                            ok
                    catch
                        exit:Reason when is_function(Callback, 1) ->
                            Callback({exit, Reason})
                    end
                end
            ),
            {ok, Pid};
        {error, Reason} ->
            {error, Reason}
    end.

-spec send_blocking(Email :: email(), Options :: options()) ->
    binary()
    | smtp_session_error()
    | {error, validate_options_error()}.
%% @doc Send an email and block waiting for the reply. Returns either a binary that contains
%% the SMTP server's receipt or `{error, Type, Message}' or `{error, Reason}'.
send_blocking(Email, Options) ->
    NewOptions = lists:ukeymerge(
        1,
        lists:sort(Options),
        lists:sort(?DEFAULT_OPTIONS)
    ),
    case check_options(NewOptions) of
        ok ->
            send_it(Email, NewOptions);
        {error, Reason} ->
            {error, Reason}
    end.

-spec open(Options :: options()) ->
    {ok, SocketDescriptor :: smtp_client_socket()}
    | smtp_session_error()
    | {error, bad_option, validate_options_error()}.
%% @doc Open a SMTP client socket with the provided options
%% Once the socket has been opened, you can use it with deliver/2.
open(Options) ->
    NewOptions = lists:ukeymerge(
        1,
        lists:sort(Options),
        lists:sort(?DEFAULT_OPTIONS)
    ),
    case check_options(NewOptions) of
        ok ->
            RelayDomain = proplists:get_value(relay, NewOptions),
            MXRecords =
                case proplists:get_value(no_mx_lookups, NewOptions) of
                    true ->
                        [];
                    _ ->
                        smtp_util:mxlookup(RelayDomain)
                end,
            trace(Options, "MX records for ~s are ~p~n", [RelayDomain, MXRecords]),
            Hosts =
                case MXRecords of
                    [] ->
                        % maybe we're supposed to relay to a host directly
                        [{0, RelayDomain}];
                    _ ->
                        MXRecords
                end,
            try_smtp_sessions(Hosts, NewOptions, []);
        {error, Reason} ->
            {error, bad_option, Reason}
    end.

-spec deliver(Socket :: smtp_client_socket(), Email :: email()) ->
    {'ok', Receipt :: binary()} | {error, FailMsg :: failure()}.
%% @doc Deliver an email on an open smtp client socket.
%% For use with a socket opened with open/1. The socket can be reused as long as the previous call to deliver/2 returned `{ok, Receipt}'.
%% If the previous call to deliver/2 returned `{error, FailMsg}' and the option `{on_transaction_error, reset}' was given in the open/1 call,
%% the socket <em>may</em> still be reused.
deliver(#smtp_client_socket{} = SmtpClientSocket, Email) ->
    #smtp_client_socket{
        socket = Socket,
        extensions = Extensions,
        options = Options
    } = SmtpClientSocket,
    try
        Receipt = try_sending_it(Email, Socket, Extensions, Options),
        {ok, Receipt}
    catch
        throw:FailMsg ->
            {error, FailMsg}
    end.

-spec close(Socket :: smtp_client_socket()) -> ok.
%% @doc Close an open smtp client socket opened with open/1.
close(#smtp_client_socket{socket = Socket}) ->
    quit(Socket).

-spec send_it(Email :: email(), Options :: options()) ->
    binary()
    | smtp_session_error().
send_it(Email, Options) ->
    RelayDomain = to_string(proplists:get_value(relay, Options)),
    MXRecords =
        case proplists:get_value(no_mx_lookups, Options) of
            true ->
                [];
            _ ->
                smtp_util:mxlookup(RelayDomain)
        end,
    trace(Options, "MX records for ~s are ~p~n", [RelayDomain, MXRecords]),
    Hosts =
        case MXRecords of
            [] ->
                % maybe we're supposed to relay to a host directly
                [{0, RelayDomain}];
            _ ->
                MXRecords
        end,
    case try_smtp_sessions(Hosts, Options, []) of
        {error, _, _} = Error ->
            Error;
        {ok, ClientSocket} ->
            #smtp_client_socket{
                socket = Socket,
                host = Host,
                extensions = Extensions,
                options = Options1
            } = ClientSocket,
            try
                try_sending_it(Email, Socket, Extensions, Options1)
            catch
                throw:{FailureType, Message} ->
                    {error, send, {FailureType, Host, Message}}
            after
                quit(Socket)
            end
    end.

-spec try_smtp_sessions(
    Hosts :: [{non_neg_integer(), string()}, ...], Options :: options(), RetryList :: list()
) ->
    {ok, smtp_client_socket()}
    | smtp_session_error().
try_smtp_sessions([{_Distance, Host} | _Tail] = Hosts, Options, RetryList) ->
    try
        {ok, open_smtp_session(Host, Options)}
    catch
        throw:FailMsg ->
            handle_smtp_throw(FailMsg, Hosts, Options, RetryList)
    end.

-spec handle_smtp_throw(failure(), [{non_neg_integer(), smtp_host()}], options(), list()) ->
    {ok, smtp_client_socket()}
    | smtp_session_error().
handle_smtp_throw({permanent_failure, Message}, [{_Distance, Host} | _Tail], _Options, _RetryList) ->
    % permanent failure means no retries, and don't even continue with other hosts
    {error, no_more_hosts, {permanent_failure, Host, Message}};
handle_smtp_throw(
    {temporary_failure, tls_failed}, [{_Distance, Host} | _Tail] = Hosts, Options, RetryList
) ->
    % Could not start the TLS handshake; if tls is optional then try without TLS
    case proplists:get_value(tls, Options) of
        if_available ->
            NoTLSOptions = [{tls, never} | proplists:delete(tls, Options)],
            try open_smtp_session(Host, NoTLSOptions) of
                Res -> {ok, Res}
            catch
                throw:FailMsg ->
                    handle_smtp_throw(FailMsg, Hosts, Options, RetryList)
            end;
        _ ->
            try_next_host({temporary_failure, tls_failed}, Hosts, Options, RetryList)
    end;
handle_smtp_throw(FailMsg, Hosts, Options, RetryList) ->
    try_next_host(FailMsg, Hosts, Options, RetryList).

try_next_host({FailureType, Message}, [{_Distance, Host} | _Tail] = Hosts, Options, RetryList) ->
    Retries = proplists:get_value(retries, Options),
    RetryCount = proplists:get_value(Host, RetryList),
    case fetch_next_host(Retries, RetryCount, Hosts, RetryList, Options) of
        {[], _NewRetryList} ->
            {error, retries_exceeded, {FailureType, Host, Message}};
        {NewHosts, NewRetryList} ->
            try_smtp_sessions(NewHosts, Options, NewRetryList)
    end.

fetch_next_host(Retries, RetryCount, [{_Distance, Host} | Tail], RetryList, Options) when
    is_integer(RetryCount), RetryCount >= Retries
->
    % out of chances
    trace(Options, "retries for ~s exceeded (~p of ~p)~n", [Host, RetryCount, Retries]),
    {Tail, lists:keydelete(Host, 1, RetryList)};
fetch_next_host(Retries, RetryCount, [{Distance, Host} | Tail], RetryList, Options) when
    is_integer(RetryCount)
->
    trace(Options, "scheduling ~s for retry (~p of ~p)~n", [Host, RetryCount, Retries]),
    {Tail ++ [{Distance, Host}], lists:keydelete(Host, 1, RetryList) ++ [{Host, RetryCount + 1}]};
fetch_next_host(0, _RetryCount, [{_Distance, Host} | Tail], RetryList, _Options) ->
    % done retrying completely
    {Tail, lists:keydelete(Host, 1, RetryList)};
fetch_next_host(Retries, _RetryCount, [{Distance, Host} | Tail], RetryList, Options) ->
    % otherwise...
    trace(Options, "scheduling ~s for retry (~p of ~p)~n", [Host, 1, Retries]),
    {Tail ++ [{Distance, Host}], lists:keydelete(Host, 1, RetryList) ++ [{Host, 1}]}.

-spec open_smtp_session(Host :: string(), Options :: options()) -> smtp_client_socket().
open_smtp_session(Host, Options) ->
    {ok, Socket, _Host2, Banner} = connect(Host, Options),
    trace(Options, "connected to ~s; banner was ~s~n", [Host, Banner]),
    {ok, Extensions} = try_EHLO(Socket, Options),
    trace(Options, "Extensions are ~p~n", [Extensions]),
    {Socket2, Extensions2} = try_STARTTLS(Socket, Options, Extensions),
    trace(Options, "Extensions are ~p~n", [Extensions2]),
    Authed = try_AUTH(Socket2, Options, proplists:get_value(<<"AUTH">>, Extensions2)),
    trace(Options, "Authentication status is ~p~n", [Authed]),
    #smtp_client_socket{
        socket = Socket2,
        host = Host,
        extensions = Extensions,
        options = Options
    }.

-spec try_sending_it(
    Email :: email(),
    Socket :: smtp_socket:socket(),
    Extensions :: extensions(),
    Options :: options()
) -> binary().
try_sending_it({From, To, Body}, Socket, Extensions, Options) ->
    try_MAIL_FROM(From, Socket, Extensions, Options),
    try_RCPT_TO(To, Socket, Extensions, Options),
    try_DATA(Body, Socket, Extensions, Options).

-spec try_MAIL_FROM(
    From :: email_address(),
    Socket :: smtp_socket:socket(),
    Extensions :: extensions(),
    Options :: options()
) -> true.
try_MAIL_FROM(From, Socket, Extensions, Options) when is_binary(From) ->
    try_MAIL_FROM(binary_to_list(From), Socket, Extensions, Options);
try_MAIL_FROM("<" ++ _ = From, Socket, _Extensions, Options) ->
    OnTxError = proplists:get_value(on_transaction_error, Options),
    % TODO do we need to bother with SIZE?
    smtp_socket:send(Socket, ["MAIL FROM:", From, "\r\n"]),
    case read_possible_multiline_reply(Socket) of
        {ok, <<"250", _Rest/binary>>} ->
            true;
        {ok, <<"4", _Rest/binary>> = Msg} when OnTxError =:= reset ->
            rset_or_quit(Socket),
            throw({temporary_failure, Msg});
        {ok, <<"4", _Rest/binary>> = Msg} ->
            quit(Socket),
            throw({temporary_failure, Msg});
        {ok, <<"5", _Rest/binary>> = Msg} when OnTxError =:= reset ->
            trace(Options, "Mail FROM rejected: ~p~n", [Msg]),
            ok = rset_or_quit(Socket),
            throw({permanent_failure, Msg});
        {ok, Msg} ->
            trace(Options, "Mail FROM rejected: ~p~n", [Msg]),
            quit(Socket),
            throw({permanent_failure, Msg})
    end;
try_MAIL_FROM(From, Socket, Extension, Options) ->
    % someone was bad and didn't put in the angle brackets
    try_MAIL_FROM("<" ++ From ++ ">", Socket, Extension, Options).

-spec try_RCPT_TO(
    Tos :: [email_address()],
    Socket :: smtp_socket:socket(),
    Extensions :: extensions(),
    Options :: options()
) -> true.
try_RCPT_TO([], _Socket, _Extensions, _Options) ->
    true;
try_RCPT_TO([To | Tail], Socket, Extensions, Options) when is_binary(To) ->
    try_RCPT_TO([binary_to_list(To) | Tail], Socket, Extensions, Options);
try_RCPT_TO(["<" ++ _ = To | Tail], Socket, Extensions, Options) ->
    OnTxError = proplists:get_value(on_transaction_error, Options),
    smtp_socket:send(Socket, ["RCPT TO:", To, "\r\n"]),
    case read_possible_multiline_reply(Socket) of
        {ok, <<"250", _Rest/binary>>} ->
            try_RCPT_TO(Tail, Socket, Extensions, Options);
        {ok, <<"251", _Rest/binary>>} ->
            try_RCPT_TO(Tail, Socket, Extensions, Options);
        {ok, <<"4", _Rest/binary>> = Msg} when OnTxError =:= reset ->
            rset_or_quit(Socket),
            throw({temporary_failure, Msg});
        {ok, <<"4", _Rest/binary>> = Msg} ->
            quit(Socket),
            throw({temporary_failure, Msg});
        {ok, <<"5", _Rest/binary>> = Msg} when OnTxError =:= reset ->
            rset_or_quit(Socket),
            throw({permanent_failure, Msg});
        {ok, Msg} ->
            quit(Socket),
            throw({permanent_failure, Msg})
    end;
try_RCPT_TO([To | Tail], Socket, Extensions, Options) ->
    % someone was bad and didn't put in the angle brackets
    try_RCPT_TO(["<" ++ To ++ ">" | Tail], Socket, Extensions, Options).

-spec try_DATA(
    Body :: binary() | function(),
    Socket :: smtp_socket:socket(),
    Extensions :: extensions(),
    Options :: options()
) -> binary().
try_DATA(Body, Socket, Extensions, Options) when is_function(Body) ->
    try_DATA(Body(), Socket, Extensions, Options);
try_DATA(Body, Socket, _Extensions, Options) ->
    OnTxError = proplists:get_value(on_transaction_error, Options),
    smtp_socket:send(Socket, "DATA\r\n"),
    case read_possible_multiline_reply(Socket) of
        {ok, <<"354", _Rest/binary>>} ->
            %% Escape period at start of line (rfc5321 4.5.2)
            EscapedBody = re:replace(Body, <<"^\\\.">>, <<"..">>, [
                global, multiline, {return, binary}
            ]),
            smtp_socket:send(Socket, [EscapedBody, "\r\n.\r\n"]),
            case read_possible_multiline_reply(Socket) of
                {ok, <<"250 ", Receipt/binary>>} ->
                    Receipt;
                {ok, <<"4", _Rest2/binary>> = Msg} when OnTxError =:= reset ->
                    throw({temporary_failure, Msg});
                {ok, <<"4", _Rest2/binary>> = Msg} ->
                    quit(Socket),
                    throw({temporary_failure, Msg});
                {ok, <<"5", _Rest2/binary>> = Msg} when OnTxError =:= reset ->
                    throw({permanent_failure, Msg});
                {ok, Msg} ->
                    quit(Socket),
                    throw({permanent_failure, Msg})
            end;
        {ok, <<"4", _Rest/binary>> = Msg} when OnTxError =:= reset ->
            rset_or_quit(Socket),
            throw({temporary_failure, Msg});
        {ok, <<"4", _Rest/binary>> = Msg} ->
            quit(Socket),
            throw({temporary_failure, Msg});
        {ok, <<"5", _Rest/binary>> = Msg} when OnTxError =:= reset ->
            rset_or_quit(Socket),
            throw({permanent_failure, Msg});
        {ok, Msg} ->
            quit(Socket),
            throw({permanent_failure, Msg})
    end.

-spec try_AUTH(Socket :: smtp_socket:socket(), Options :: options(), AuthTypes :: [string()]) ->
    boolean().
try_AUTH(Socket, Options, []) ->
    case proplists:get_value(auth, Options) of
        always ->
            quit(Socket),
            erlang:throw({missing_requirement, auth});
        _ ->
            false
    end;
try_AUTH(Socket, Options, undefined) ->
    case proplists:get_value(auth, Options) of
        always ->
            quit(Socket),
            erlang:throw({missing_requirement, auth});
        _ ->
            false
    end;
try_AUTH(Socket, Options, AuthTypes) ->
    case
        proplists:is_defined(username, Options) and
            proplists:is_defined(password, Options) and
            (proplists:get_value(auth, Options) =/= never)
    of
        false ->
            case proplists:get_value(auth, Options) of
                always ->
                    quit(Socket),
                    erlang:throw({missing_requirement, auth});
                _ ->
                    false
            end;
        true ->
            Username = to_binary(proplists:get_value(username, Options)),
            Password = to_binary(proplists:get_value(password, Options)),
            trace(Options, "Auth types: ~p~n", [AuthTypes]),
            Types = re:split(AuthTypes, " ", [{return, list}, trim]),
            case do_AUTH(Socket, Username, Password, Types, Options) of
                false ->
                    case proplists:get_value(auth, Options) of
                        always ->
                            quit(Socket),
                            erlang:throw({permanent_failure, auth_failed});
                        _ ->
                            false
                    end;
                true ->
                    true
            end
    end.

to_string(String) when is_list(String) -> String;
to_string(Binary) when is_binary(Binary) -> binary_to_list(Binary).

to_binary(String) when is_binary(String) -> String;
to_binary(String) when is_list(String) -> list_to_binary(String).

-spec do_AUTH(
    Socket :: smtp_socket:socket(),
    Username :: binary(),
    Password :: binary(),
    Types :: [string()],
    Options :: options()
) -> boolean().
do_AUTH(Socket, Username, Password, Types, Options) ->
    FixedTypes = [string:to_upper(X) || X <- Types],
    trace(Options, "Fixed types: ~p~n", [FixedTypes]),
    AllowedTypes = [X || X <- ?AUTH_PREFERENCE, lists:member(X, FixedTypes)],
    trace(Options, "available authentication types, in order of preference: ~p~n", [AllowedTypes]),
    do_AUTH_each(Socket, Username, Password, AllowedTypes, Options).

-spec do_AUTH_each(
    Socket :: smtp_socket:socket(),
    Username :: binary(),
    Password :: binary(),
    AuthTypes :: [string()],
    Options :: options()
) -> boolean().
do_AUTH_each(_Socket, _Username, _Password, [], _Options) ->
    false;
do_AUTH_each(Socket, Username, Password, ["CRAM-MD5" | Tail], Options) ->
    smtp_socket:send(Socket, "AUTH CRAM-MD5\r\n"),
    case read_possible_multiline_reply(Socket) of
        {ok, <<"334 ", Rest/binary>>} ->
            Seed64 = binstr:strip(binstr:strip(Rest, right, $\n), right, $\r),
            Seed = base64:decode(Seed64),
            Digest = smtp_util:compute_cram_digest(Password, Seed),
            String = base64:encode(list_to_binary([Username, " ", Digest])),
            smtp_socket:send(Socket, [String, "\r\n"]),
            case read_possible_multiline_reply(Socket) of
                {ok, <<"235", _Rest/binary>>} ->
                    trace(Options, "authentication accepted~n", []),
                    true;
                {ok, Msg} ->
                    trace(Options, "authentication rejected: ~s~n", [Msg]),
                    do_AUTH_each(Socket, Username, Password, Tail, Options)
            end;
        {ok, Something} ->
            trace(Options, "got ~s~n", [Something]),
            do_AUTH_each(Socket, Username, Password, Tail, Options)
    end;
do_AUTH_each(Socket, Username, Password, ["XOAUTH2" | Tail], Options) ->
    Str = base64:encode(list_to_binary(["user=", Username, 1, "auth=Bearer ", Password, 1, 1])),
    smtp_socket:send(Socket, ["AUTH XOAUTH2 ", Str, "\r\n"]),
    case read_possible_multiline_reply(Socket) of
        {ok, <<"235", _Rest/binary>>} ->
            true;
        {ok, _Msg} ->
            do_AUTH_each(Socket, Username, Password, Tail, Options)
    end;
do_AUTH_each(Socket, Username, Password, ["LOGIN" | Tail], Options) ->
    smtp_socket:send(Socket, "AUTH LOGIN\r\n"),
    {ok, Prompt} = read_possible_multiline_reply(Socket),
    case is_auth_username_prompt(Prompt) of
        true ->
            %% base64 Username: or username:
            trace(Options, "username prompt~n", []),
            U = base64:encode(Username),
            smtp_socket:send(Socket, [U, "\r\n"]),
            {ok, Prompt2} = read_possible_multiline_reply(Socket),
            case is_auth_password_prompt(Prompt2) of
                true ->
                    %% base64 Password: or password:
                    trace(Options, "password prompt~n", []),
                    P = base64:encode(Password),
                    smtp_socket:send(Socket, [P, "\r\n"]),
                    case read_possible_multiline_reply(Socket) of
                        {ok, <<"235 ", _Rest/binary>>} ->
                            trace(Options, "authentication accepted~n", []),
                            true;
                        {ok, Msg} ->
                            trace(Options, "password rejected: ~s", [Msg]),
                            do_AUTH_each(Socket, Username, Password, Tail, Options)
                    end;
                false ->
                    trace(Options, "username rejected: ~s", [Prompt2]),
                    do_AUTH_each(Socket, Username, Password, Tail, Options)
            end;
        false ->
            trace(Options, "got ~s~n", [Prompt]),
            do_AUTH_each(Socket, Username, Password, Tail, Options)
    end;
do_AUTH_each(Socket, Username, Password, ["PLAIN" | Tail], Options) ->
    AuthString = base64:encode(<<0, Username/binary, 0, Password/binary>>),
    smtp_socket:send(Socket, ["AUTH PLAIN ", AuthString, "\r\n"]),
    case read_possible_multiline_reply(Socket) of
        {ok, <<"235", _Rest/binary>>} ->
            trace(Options, "authentication accepted~n", []),
            true;
        Else ->
            % TODO do we need to bother trying the multi-step PLAIN?
            trace(Options, "authentication rejected ~p~n", [Else]),
            do_AUTH_each(Socket, Username, Password, Tail, Options)
    end;
do_AUTH_each(Socket, Username, Password, [Type | Tail], Options) ->
    trace(Options, "unsupported AUTH type ~s~n", [Type]),
    do_AUTH_each(Socket, Username, Password, Tail, Options).

is_auth_username_prompt(<<"334 VXNlcm5hbWU6\r\n">>) -> true;
is_auth_username_prompt(<<"334 dXNlcm5hbWU6\r\n">>) -> true;
is_auth_username_prompt(<<"334 VXNlcm5hbWU6 ", _/binary>>) -> true;
is_auth_username_prompt(<<"334 dXNlcm5hbWU6 ", _/binary>>) -> true;
is_auth_username_prompt(_) -> false.

is_auth_password_prompt(<<"334 UGFzc3dvcmQ6\r\n">>) -> true;
is_auth_password_prompt(<<"334 cGFzc3dvcmQ6\r\n">>) -> true;
is_auth_password_prompt(<<"334 UGFzc3dvcmQ6 ", _/binary>>) -> true;
is_auth_password_prompt(<<"334 cGFzc3dvcmQ6 ", _/binary>>) -> true;
is_auth_password_prompt(_) -> false.

-spec try_EHLO(Socket :: smtp_socket:socket(), Options :: options()) -> {ok, extensions()}.
try_EHLO(Socket, Options) ->
    ok = smtp_socket:send(Socket, [
        "EHLO ", proplists:get_value(hostname, Options, smtp_util:guess_FQDN()), "\r\n"
    ]),
    case read_possible_multiline_reply(Socket) of
        {ok, <<"500", _Rest/binary>>} ->
            % Unrecognized command, fall back to HELO
            try_HELO(Socket, Options);
        {ok, <<"4", _Rest/binary>> = Msg} ->
            quit(Socket),
            throw({temporary_failure, Msg});
        {ok, Reply} ->
            {ok, parse_extensions(Reply, Options)}
    end.

-spec try_HELO(Socket :: smtp_socket:socket(), Options :: options()) -> {ok, list()}.
try_HELO(Socket, Options) ->
    ok = smtp_socket:send(Socket, [
        "HELO ", proplists:get_value(hostname, Options, smtp_util:guess_FQDN()), "\r\n"
    ]),
    case read_possible_multiline_reply(Socket) of
        {ok, <<"250", _Rest/binary>>} ->
            {ok, []};
        {ok, <<"4", _Rest/binary>> = Msg} ->
            quit(Socket),
            throw({temporary_failure, Msg});
        {ok, Msg} ->
            quit(Socket),
            throw({permanent_failure, Msg})
    end.

% check if we should try to do TLS
-spec try_STARTTLS(
    Socket :: smtp_socket:socket(), Options :: options(), Extensions :: extensions()
) -> {smtp_socket:socket(), extensions()}.
try_STARTTLS(Socket, Options, Extensions) ->
    case {proplists:get_value(tls, Options), proplists:get_value(<<"STARTTLS">>, Extensions)} of
        {Atom, true} when Atom =:= always; Atom =:= if_available ->
            trace(Options, "Starting TLS~n", []),
            case {do_STARTTLS(Socket, Options), Atom} of
                {false, always} ->
                    trace(Options, "TLS failed~n", []),
                    quit(Socket),
                    erlang:throw({temporary_failure, tls_failed});
                {false, if_available} ->
                    trace(Options, "TLS failed~n", []),
                    {Socket, Extensions};
                {{S, E}, _} ->
                    trace(Options, "TLS started~n", []),
                    {S, E}
            end;
        {always, _} ->
            quit(Socket),
            erlang:throw({missing_requirement, tls});
        _ ->
            trace(Options, "TLS not requested ~p~n", [Options]),
            {Socket, Extensions}
    end.

%% attempt to upgrade socket to TLS
-spec do_STARTTLS(Socket :: smtp_socket:socket(), Options :: options()) ->
    {smtp_socket:socket(), extensions()} | false.
do_STARTTLS(Socket, Options) ->
    smtp_socket:send(Socket, "STARTTLS\r\n"),
    case read_possible_multiline_reply(Socket) of
        {ok, <<"220", _Rest/binary>>} ->
            case
                catch smtp_socket:to_ssl_client(
                    Socket, [binary | proplists:get_value(tls_options, Options, [])], 5000
                )
            of
                {ok, NewSocket} ->
                    %NewSocket;
                    {ok, Extensions} = try_EHLO(NewSocket, Options),
                    {NewSocket, Extensions};
                {'EXIT', Reason} ->
                    quit(Socket),
                    error_logger:error_msg("Error in ssl upgrade: ~p.~n", [Reason]),
                    erlang:throw({temporary_failure, tls_failed});
                {error, closed} ->
                    quit(Socket),
                    error_logger:error_msg("Error in ssl upgrade: socket closed.~n"),
                    erlang:throw({temporary_failure, tls_failed});
                {error, ssl_not_started} ->
                    quit(Socket),
                    error_logger:error_msg("SSL not started.~n"),
                    erlang:throw({permanent_failure, ssl_not_started});
                Else ->
                    trace(Options, "~p~n", [Else]),
                    false
            end;
        {ok, <<"4", _Rest/binary>> = Msg} ->
            quit(Socket),
            erlang:throw({temporary_failure, Msg});
        {ok, Msg} ->
            quit(Socket),
            erlang:throw({permanent_failure, Msg})
    end.

%% try connecting to a host
connect(Host, Options) when is_binary(Host) ->
    connect(binary_to_list(Host), Options);
connect(Host, Options) ->
    AddSockOpts =
        case proplists:get_value(sockopts, Options) of
            undefined -> [];
            Other -> Other
        end,
    SockOpts = [binary, {packet, line}, {keepalive, true}, {active, false} | AddSockOpts],
    Proto =
        case proplists:get_value(ssl, Options) of
            true ->
                ssl;
            _ ->
                tcp
        end,
    Port =
        case proplists:get_value(port, Options) of
            undefined when Proto =:= ssl ->
                465;
            OPort when is_integer(OPort) ->
                OPort;
            _ ->
                25
        end,
    Timeout =
        case proplists:get_value(timeout, Options) of
            undefined -> 5000;
            OTimeout -> OTimeout
        end,
    case smtp_socket:connect(Proto, Host, Port, SockOpts, Timeout) of
        {ok, Socket} ->
            case read_possible_multiline_reply(Socket) of
                {ok, <<"220", Banner/binary>>} ->
                    {ok, Socket, Host, Banner};
                {ok, <<"4", _Rest/binary>> = Msg} ->
                    quit(Socket),
                    throw({temporary_failure, Msg});
                {ok, Msg} ->
                    quit(Socket),
                    throw({permanent_failure, Msg})
            end;
        {error, Reason} ->
            throw({network_failure, {error, Reason}})
    end.

%% read a multiline reply (eg. EHLO reply)
-spec read_possible_multiline_reply(Socket :: smtp_socket:socket()) -> {ok, binary()}.
read_possible_multiline_reply(Socket) ->
    case smtp_socket:recv(Socket, 0, ?TIMEOUT) of
        {ok, Packet} ->
            case binstr:substr(Packet, 4, 1) of
                <<"-">> ->
                    Code = binstr:substr(Packet, 1, 3),
                    read_multiline_reply(Socket, Code, [Packet]);
                <<" ">> ->
                    {ok, Packet}
            end;
        Error ->
            throw({network_failure, Error})
    end.

-spec read_multiline_reply(Socket :: smtp_socket:socket(), Code :: binary(), Acc :: [binary()]) ->
    {ok, binary()}.
read_multiline_reply(Socket, Code, Acc) ->
    case smtp_socket:recv(Socket, 0, ?TIMEOUT) of
        {ok, Packet} ->
            case {binstr:substr(Packet, 1, 3), binstr:substr(Packet, 4, 1)} of
                {Code, <<" ">>} ->
                    {ok, list_to_binary(lists:reverse([Packet | Acc]))};
                {Code, <<"-">>} ->
                    read_multiline_reply(Socket, Code, [Packet | Acc]);
                _ ->
                    quit(Socket),
                    throw({unexpected_response, lists:reverse([Packet | Acc])})
            end;
        Error ->
            throw({network_failure, Error})
    end.

rset_or_quit(Socket) ->
    ok = smtp_socket:send(Socket, "RSET\r\n"),
    case read_possible_multiline_reply(Socket) of
        {ok, <<"250", _Rest/binary>>} ->
            ok;
        {ok, _Msg} ->
            quit(Socket)
    end.

quit(Socket) ->
    smtp_socket:send(Socket, "QUIT\r\n"),
    smtp_socket:close(Socket),
    ok.

% TODO - more checking
check_options(Options) ->
    CheckedOptions = [relay, port, auth],
    lists:foldl(
        fun(Option, State) ->
            case State of
                ok ->
                    Value = proplists:get_value(Option, Options),
                    check_option({Option, Value}, Options);
                Other ->
                    Other
            end
        end,
        ok,
        CheckedOptions
    ).

check_option({relay, undefined}, _Options) ->
    {error, no_relay};
check_option({relay, _}, _Options) ->
    ok;
check_option({port, undefined}, _Options) ->
    ok;
check_option({port, Port}, _Options) when is_integer(Port) -> ok;
check_option({port, _}, _Options) ->
    {error, invalid_port};
check_option({auth, always}, Options) ->
    case
        proplists:is_defined(username, Options) and
            proplists:is_defined(password, Options)
    of
        false ->
            {error, no_credentials};
        true ->
            ok
    end;
check_option({auth, _}, _Options) ->
    ok.

-spec parse_extensions(Reply :: binary(), Options :: options()) -> extensions().
parse_extensions(Reply, Options) ->
    [_ | Reply2] = re:split(Reply, "\r\n", [{return, binary}, trim]),
    [
        begin
            Body = binstr:substr(Entry, 5),
            case re:split(Body, " ", [{return, binary}, trim, {parts, 2}]) of
                [Verb, Parameters] ->
                    {binstr:to_upper(Verb), Parameters};
                [Body] ->
                    case binstr:strchr(Body, $=) of
                        0 ->
                            {binstr:to_upper(Body), true};
                        _ ->
                            trace(Options, "discarding option ~p~n", [Body]),
                            []
                    end
            end
        end
     || Entry <- Reply2
    ].

trace(Options, Format, Args) ->
    case proplists:get_value(trace_fun, Options) of
        undefined -> ok;
        F -> F(Format, Args)
    end.

-ifdef(TEST).

session_start_test_() ->
    {foreach, local,
        fun() ->
            {ok, ListenSock} = smtp_socket:listen(tcp, 9876),
            {ListenSock}
        end,
        fun({ListenSock}) ->
            smtp_socket:close(ListenSock)
        end,
        [
            fun({ListenSock}) ->
                {"simple session initiation", fun() ->
                    Options = [{relay, "localhost"}, {port, 9876}, {hostname, "testing"}],
                    {ok, _Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"retry on crashed EHLO twice if requested", fun() ->
                    Options = [
                        {relay, "localhost"}, {port, 9876}, {hostname, "testing"}, {retries, 2}
                    ],
                    {ok, _Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:close(X),
                    {ok, Y} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(Y, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:close(Y),
                    {ok, Z} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(Z, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(Z, 0, 1000)),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"retry on crashed EHLO", fun() ->
                    Options = [{relay, "localhost"}, {port, 9876}, {hostname, "testing"}],
                    {ok, Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    unlink(Pid),
                    Monitor = erlang:monitor(process, Pid),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:close(X),
                    {ok, Y} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(Y, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:close(Y),
                    ?assertEqual({error, timeout}, smtp_socket:accept(ListenSock, 1000)),
                    receive
                        {'DOWN', Monitor, _, _, Error} ->
                            ?assertMatch({error, retries_exceeded, _}, Error)
                    end,
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"abort on 554 greeting", fun() ->
                    Options = [{relay, "localhost"}, {port, 9876}, {hostname, "testing"}],
                    {ok, Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    unlink(Pid),
                    Monitor = erlang:monitor(process, Pid),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "554 get lost, kid\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    receive
                        {'DOWN', Monitor, _, _, Error} ->
                            ?assertMatch({error, no_more_hosts, _}, Error)
                    end,
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"retry on 421 greeting", fun() ->
                    Options = [{relay, "localhost"}, {port, 9876}, {hostname, "testing"}],
                    {ok, _Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "421 can't you see I'm busy?\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    {ok, Y} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(Y, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"retry on messed up EHLO response", fun() ->
                    Options = [{relay, "localhost"}, {port, 9876}, {hostname, "testing"}],
                    {ok, Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    unlink(Pid),
                    Monitor = erlang:monitor(process, Pid),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(
                        X, "250-server.example.com EHLO\r\n250-AUTH LOGIN PLAIN\r\n421 too busy\r\n"
                    ),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(X, 0, 1000)),

                    {ok, Y} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(Y, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(
                        Y, "250-server.example.com EHLO\r\n250-AUTH LOGIN PLAIN\r\n421 too busy\r\n"
                    ),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    receive
                        {'DOWN', Monitor, _, _, Error} ->
                            ?assertMatch({error, retries_exceeded, _}, Error)
                    end,
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"retry with HELO when EHLO not accepted", fun() ->
                    Options = [{relay, "localhost"}, {port, 9876}, {hostname, "testing"}],
                    {ok, _Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 \r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "500 5.3.3 Unrecognized command\r\n"),
                    ?assertMatch({ok, "HELO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 Some banner\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(X, 0, 1000)
                    ),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "354 ok\r\n"),
                    ?assertMatch({ok, "hello world\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"a valid complete transaction without TLS advertised should succeed", fun() ->
                    Options = [{relay, "localhost"}, {port, 9876}, {hostname, "testing"}],
                    {ok, _Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 hostname\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(X, 0, 1000)
                    ),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "354 ok\r\n"),
                    ?assertMatch({ok, "hello world\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"a valid complete transaction exercising period escaping", fun() ->
                    Options = [{relay, "localhost"}, {port, 9876}, {hostname, "testing"}],
                    {ok, _Pid} = send({"test@foo.com", ["foo@bar.com"], ".hello world"}, Options),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 hostname\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(X, 0, 1000)
                    ),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "354 ok\r\n"),
                    ?assertMatch({ok, "..hello world\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"a valid complete transaction with binary arguments should succeed", fun() ->
                    Options = [{relay, "localhost"}, {port, 9876}, {hostname, "testing"}],
                    {ok, _Pid} = send(
                        {<<"test@foo.com">>, [<<"foo@bar.com">>], <<"hello world">>}, Options
                    ),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 hostname\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(X, 0, 1000)
                    ),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "354 ok\r\n"),
                    ?assertMatch({ok, "hello world\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"a valid complete transaction with TLS advertised should succeed", fun() ->
                    Options = [{relay, "localhost"}, {port, 9876}, {hostname, <<"testing">>}],
                    {ok, _Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250-hostname\r\n250 STARTTLS\r\n"),
                    ?assertMatch({ok, "STARTTLS\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    application:ensure_all_started(gen_smtp),
                    smtp_socket:send(X, "220 ok\r\n"),
                    {ok, Y} = smtp_socket:to_ssl_server(
                        X,
                        [
                            {certfile, "test/fixtures/mx1.example.com-server.crt"},
                            {keyfile, "test/fixtures/mx1.example.com-server.key"}
                        ],
                        5000
                    ),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250-hostname\r\n250 STARTTLS\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)
                    ),
                    smtp_socket:send(Y, "250 ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "354 ok\r\n"),
                    ?assertMatch({ok, "hello world\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 ok\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"a valid complete transaction with TLS advertised and binary arguments should succeed", fun() ->
                    Options = [{relay, "localhost"}, {port, 9876}, {hostname, <<"testing">>}],
                    {ok, _Pid} = send(
                        {<<"test@foo.com">>, [<<"foo@bar.com">>], <<"hello world">>}, Options
                    ),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250-hostname\r\n250 STARTTLS\r\n"),
                    ?assertMatch({ok, "STARTTLS\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    application:ensure_all_started(gen_smtp),
                    smtp_socket:send(X, "220 ok\r\n"),
                    {ok, Y} = smtp_socket:to_ssl_server(
                        X,
                        [
                            {certfile, "test/fixtures/mx1.example.com-server.crt"},
                            {keyfile, "test/fixtures/mx1.example.com-server.key"}
                        ],
                        5000
                    ),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250-hostname\r\n250 STARTTLS\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)
                    ),
                    smtp_socket:send(Y, "250 ok\r\n"),
                    ?assertMatch(
                        {ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)
                    ),
                    smtp_socket:send(Y, "250 ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "354 ok\r\n"),
                    ?assertMatch({ok, "hello world\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 ok\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"Transaction with TLS advertised, but broken, should be restarted without TLS, if allowed", fun() ->
                    Options = [
                        {relay, "localhost"},
                        {port, 9876},
                        {hostname, <<"testing">>},
                        {tls, if_available}
                    ],
                    {ok, _Pid} = send(
                        {<<"test@foo.com">>, [<<"foo@bar.com">>], <<"hello world">>}, Options
                    ),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250-hostname\r\n250 STARTTLS\r\n"),
                    ?assertMatch({ok, "STARTTLS\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "220 ok\r\n"),
                    %% Now, send some invalid data instead of TLS handshake and close the socket
                    {ok, [22, V1, V2 | _]} = smtp_socket:recv(X, 0, 1000),
                    smtp_socket:send(X, [22, V1, V2, 0, 0]),
                    smtp_socket:close(X),
                    %% Client would make another attempt to connect, without TLS
                    {ok, Y} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(Y, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250-hostname\r\n250 STARTTLS\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)
                    ),
                    smtp_socket:send(Y, "250 ok\r\n"),
                    ?assertMatch(
                        {ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)
                    ),
                    smtp_socket:send(Y, "250 ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "354 ok\r\n"),
                    ?assertMatch({ok, "hello world\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 ok\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"Send with callback", fun() ->
                    Options = [{relay, "localhost"}, {port, 9876}, {hostname, <<"testing">>}],
                    Self = self(),
                    Ref = make_ref(),
                    Callback = fun(Arg) -> Self ! {callback, Ref, Arg} end,
                    {ok, _Pid1} = send(
                        {<<"test@foo.com">>, [<<"foo@bar.com">>], <<"hello world">>},
                        Options,
                        Callback
                    ),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 hostname\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(X, 0, 1000)
                    ),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "354 ok\r\n"),
                    ?assertMatch({ok, "hello world\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    ?assertMatch(
                        {ok, <<"ok\r\n">>},
                        receive
                            {callback, Ref, CbRet1} -> CbRet1
                        end
                    ),
                    {ok, _Pid2} = send(
                        {<<"test@foo.com">>, [<<"foo@bar.com">>], <<"hello world">>},
                        Options,
                        Callback
                    ),
                    {ok, Y} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(Y, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 hostname\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)
                    ),
                    smtp_socket:send(Y, "599 error\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    ?assertMatch(
                        {error, send, {permanent_failure, _, <<"599 error\r\n">>}},
                        receive
                            {callback, Ref, CbRet2} -> CbRet2
                        end
                    ),
                    ok
                end}
            end,

            fun({ListenSock}) ->
                {"Deliver with RSET on transaction error", fun() ->
                    Self = self(),
                    Pid = spawn_link(fun() ->
                        EMail = {"test@foo.com", ["foo@bar.com"], "hello world"},
                        Options = [
                            {relay, "localhost"},
                            {port, 9876},
                            {hostname, "testing"},
                            {on_transaction_error, reset}
                        ],
                        {ok, X} = open(Options),
                        LoopFn = fun Loop() ->
                            receive
                                {Self, deliver, Exp} ->
                                    ?assertMatch({Exp, _}, deliver(X, EMail)),
                                    Loop();
                                {Self, stop} ->
                                    close(X),
                                    ok
                            end
                        end,
                        LoopFn(),
                        unlink(Self)
                    end),
                    {ok, Y} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(Y, "220 Some Banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 hostname\r\n"),

                    Pid ! {self(), deliver, error},
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)
                    ),
                    smtp_socket:send(Y, "599 Error\r\n"),
                    ?assertMatch({ok, "RSET\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 Ok\r\n"),

                    Pid ! {self(), deliver, error},
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)
                    ),
                    smtp_socket:send(Y, "250 Ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "599 Error\r\n"),
                    ?assertMatch({ok, "RSET\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 Ok\r\n"),

                    Pid ! {self(), deliver, error},
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)
                    ),
                    smtp_socket:send(Y, "250 Ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 Ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "599 Error\r\n"),
                    ?assertMatch({ok, "RSET\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 Ok\r\n"),

                    Pid ! {self(), deliver, error},
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)
                    ),
                    smtp_socket:send(Y, "250 Ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 Ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "354 Continue\r\n"),
                    ?assertMatch({ok, "hello world\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "599 Error\r\n"),

                    Pid ! {self(), deliver, ok},
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)
                    ),
                    smtp_socket:send(Y, "250 Ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 Ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "354 Continue\r\n"),
                    ?assertMatch({ok, "hello world\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:send(Y, "250 Ok\r\n"),

                    Pid ! {self(), stop},
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                    smtp_socket:close(Y),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"Deliver with QUIT on transaction error", fun() ->
                    Self = self(),
                    Pid = spawn_link(fun() ->
                        EMail = {"test@foo.com", ["foo@bar.com"], "hello world"},
                        Options = [
                            {relay, "localhost"},
                            {port, 9876},
                            {hostname, "testing"},
                            {on_transaction_error, quit}
                        ],
                        LoopFn = fun Loop(LastSock) ->
                            receive
                                {Self, deliver, Exp} ->
                                    {ok, X} = open(Options),
                                    ?assertMatch({Exp, _}, deliver(X, EMail)),
                                    Loop(X);
                                {Self, stop} ->
                                    catch close(LastSock),
                                    ok
                            end
                        end,
                        LoopFn(undefined),
                        unlink(Self)
                    end),
                    SessionInitFn = fun() ->
                        {ok, Y} = smtp_socket:accept(ListenSock, 1000),
                        smtp_socket:send(Y, "220 Some Banner\r\n"),
                        ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(Y, 0, 1000)),
                        smtp_socket:send(Y, "250 hostname\r\n"),
                        Y
                    end,

                    Pid ! {self(), deliver, error},
                    Y1 = SessionInitFn(),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y1, 0, 1000)
                    ),
                    smtp_socket:send(Y1, "599 Error\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(Y1, 0, 1000)),
                    smtp_socket:close(Y1),

                    Pid ! {self(), deliver, error},
                    Y2 = SessionInitFn(),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y2, 0, 1000)
                    ),
                    smtp_socket:send(Y2, "250 Ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(Y2, 0, 1000)),
                    smtp_socket:send(Y2, "599 Error\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(Y2, 0, 1000)),
                    smtp_socket:close(Y2),

                    Pid ! {self(), deliver, error},
                    Y3 = SessionInitFn(),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y3, 0, 1000)
                    ),
                    smtp_socket:send(Y3, "250 Ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(Y3, 0, 1000)),
                    smtp_socket:send(Y3, "250 Ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(Y3, 0, 1000)),
                    smtp_socket:send(Y3, "599 Error\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(Y3, 0, 1000)),
                    smtp_socket:close(Y3),

                    Pid ! {self(), deliver, error},
                    Y4 = SessionInitFn(),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y4, 0, 1000)
                    ),
                    smtp_socket:send(Y4, "250 Ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(Y4, 0, 1000)),
                    smtp_socket:send(Y4, "250 Ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(Y4, 0, 1000)),
                    smtp_socket:send(Y4, "354 Continue\r\n"),
                    ?assertMatch({ok, "hello world\r\n"}, smtp_socket:recv(Y4, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(Y4, 0, 1000)),
                    smtp_socket:send(Y4, "599 Error\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(Y4, 0, 1000)),
                    smtp_socket:close(Y4),

                    Pid ! {self(), deliver, ok},
                    Y5 = SessionInitFn(),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(Y5, 0, 1000)
                    ),
                    smtp_socket:send(Y5, "250 Ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(Y5, 0, 1000)),
                    smtp_socket:send(Y5, "250 Ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(Y5, 0, 1000)),
                    smtp_socket:send(Y5, "354 Continue\r\n"),
                    ?assertMatch({ok, "hello world\r\n"}, smtp_socket:recv(Y5, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(Y5, 0, 1000)),
                    smtp_socket:send(Y5, "250 Ok\r\n"),

                    Pid ! {self(), stop},
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(Y5, 0, 1000)),
                    smtp_socket:close(Y5),
                    ok
                end}
            end,

            fun({ListenSock}) ->
                {"AUTH PLAIN should work", fun() ->
                    Options = [
                        {relay, "localhost"},
                        {port, 9876},
                        {hostname, "testing"},
                        {username, "user"},
                        {password, "pass"}
                    ],
                    {ok, _Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250-hostname\r\n250 AUTH PLAIN\r\n"),
                    AuthString = binary_to_list(base64:encode("\0user\0pass")),
                    AuthPacket = "AUTH PLAIN " ++ AuthString ++ "\r\n",
                    ?assertEqual({ok, AuthPacket}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "235 ok\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(X, 0, 1000)
                    ),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"AUTH LOGIN should work", fun() ->
                    Options = [
                        {relay, "localhost"},
                        {port, 9876},
                        {hostname, "testing"},
                        {username, "user"},
                        {password, "pass"}
                    ],
                    {ok, _Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250-hostname\r\n250 AUTH LOGIN\r\n"),
                    ?assertEqual({ok, "AUTH LOGIN\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "334 VXNlcm5hbWU6\r\n"),
                    UserString = binary_to_list(base64:encode("user")),
                    ?assertEqual({ok, UserString ++ "\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "334 UGFzc3dvcmQ6\r\n"),
                    PassString = binary_to_list(base64:encode("pass")),
                    ?assertEqual({ok, PassString ++ "\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "235 ok\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(X, 0, 1000)
                    ),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"AUTH LOGIN should work with lowercase prompts", fun() ->
                    Options = [
                        {relay, "localhost"},
                        {port, 9876},
                        {hostname, "testing"},
                        {username, "user"},
                        {password, "pass"}
                    ],
                    {ok, _Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250-hostname\r\n250 AUTH LOGIN\r\n"),
                    ?assertEqual({ok, "AUTH LOGIN\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "334 dXNlcm5hbWU6\r\n"),
                    UserString = binary_to_list(base64:encode("user")),
                    ?assertEqual({ok, UserString ++ "\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "334 cGFzc3dvcmQ6\r\n"),
                    PassString = binary_to_list(base64:encode("pass")),
                    ?assertEqual({ok, PassString ++ "\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "235 ok\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(X, 0, 1000)
                    ),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"AUTH LOGIN should work with appended methods", fun() ->
                    Options = [
                        {relay, "localhost"},
                        {port, 9876},
                        {hostname, "testing"},
                        {username, "user"},
                        {password, "pass"}
                    ],
                    {ok, _Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250-hostname\r\n250 AUTH LOGIN\r\n"),
                    ?assertEqual({ok, "AUTH LOGIN\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "334 VXNlcm5hbWU6 R6S4yT8pcW5sQjZD3CW61N0 - hssmtp\r\n"),
                    UserString = binary_to_list(base64:encode("user")),
                    ?assertEqual({ok, UserString ++ "\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "334 UGFzc3dvcmQ6 R6S4yT8pcW5sQjZD3CW61N0 - hssmtp\r\n"),
                    PassString = binary_to_list(base64:encode("pass")),
                    ?assertEqual({ok, PassString ++ "\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "235 ok\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(X, 0, 1000)
                    ),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"AUTH CRAM-MD5 should work", fun() ->
                    Options = [
                        {relay, "localhost"},
                        {port, 9876},
                        {hostname, "testing"},
                        {username, "user"},
                        {password, "pass"}
                    ],
                    {ok, _Pid} = send({"test@foo.com", ["foo@bar.com"], "hello world"}, Options),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250-hostname\r\n250 AUTH CRAM-MD5\r\n"),
                    ?assertEqual({ok, "AUTH CRAM-MD5\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    Seed = smtp_util:get_cram_string(smtp_util:guess_FQDN()),
                    DecodedSeed = base64:decode_to_string(Seed),
                    Digest = smtp_util:compute_cram_digest("pass", DecodedSeed),
                    String = binary_to_list(base64:encode(list_to_binary(["user ", Digest]))),
                    smtp_socket:send(X, "334 " ++ Seed ++ "\r\n"),
                    {ok, Packet} = smtp_socket:recv(X, 0, 1000),
                    CramDigest = smtp_util:trim_crlf(Packet),
                    ?assertEqual(String, CramDigest),
                    smtp_socket:send(X, "235 ok\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(X, 0, 1000)
                    ),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"AUTH CRAM-MD5 should work", fun() ->
                    Options = [
                        {relay, <<"localhost">>},
                        {port, 9876},
                        {hostname, <<"testing">>},
                        {username, <<"user">>},
                        {password, <<"pass">>}
                    ],
                    {ok, _Pid} = send(
                        {<<"test@foo.com">>, [<<"foo@bar.com">>, <<"baz@bar.com">>], <<"hello world">>},
                        Options
                    ),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250-hostname\r\n250 AUTH CRAM-MD5\r\n"),
                    ?assertEqual({ok, "AUTH CRAM-MD5\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    Seed = smtp_util:get_cram_string(smtp_util:guess_FQDN()),
                    DecodedSeed = base64:decode_to_string(Seed),
                    Digest = smtp_util:compute_cram_digest("pass", DecodedSeed),
                    String = binary_to_list(base64:encode(list_to_binary(["user ", Digest]))),
                    smtp_socket:send(X, "334 " ++ Seed ++ "\r\n"),
                    {ok, Packet} = smtp_socket:recv(X, 0, 1000),
                    CramDigest = smtp_util:trim_crlf(Packet),
                    ?assertEqual(String, CramDigest),
                    smtp_socket:send(X, "235 ok\r\n"),
                    ?assertMatch(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(X, 0, 1000)
                    ),
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"should bail when AUTH is required but not provided", fun() ->
                    Options = [
                        {relay, <<"localhost">>},
                        {port, 9876},
                        {hostname, <<"testing">>},
                        {auth, always},
                        {username, <<"user">>},
                        {retries, 0},
                        {password, <<"pass">>}
                    ],
                    {ok, Pid} = send(
                        {<<"test@foo.com">>, [<<"foo@bar.com">>, <<"baz@bar.com">>], <<"hello world">>},
                        Options
                    ),
                    unlink(Pid),
                    Monitor = erlang:monitor(process, Pid),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250-hostname\r\n250 8BITMIME\r\n"),
                    ?assertEqual({ok, "QUIT\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    receive
                        {'DOWN', Monitor, _, _, Error} ->
                            ?assertMatch(
                                {error, retries_exceeded, {missing_requirement, _, auth}}, Error
                            )
                    end,
                    ok
                end}
            end,
            fun({ListenSock}) ->
                {"should bail when AUTH is required but of an unsupported type", fun() ->
                    Options = [
                        {relay, <<"localhost">>},
                        {port, 9876},
                        {hostname, <<"testing">>},
                        {auth, always},
                        {username, <<"user">>},
                        {retries, 0},
                        {password, <<"pass">>}
                    ],
                    {ok, Pid} = send(
                        {<<"test@foo.com">>, [<<"foo@bar.com">>, <<"baz@bar.com">>], <<"hello world">>},
                        Options
                    ),
                    unlink(Pid),
                    Monitor = erlang:monitor(process, Pid),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250-hostname\r\n250-AUTH GSSAPI\r\n250 8BITMIME\r\n"),
                    ?assertEqual({ok, "QUIT\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    receive
                        {'DOWN', Monitor, _, _, Error} ->
                            ?assertMatch(
                                {error, no_more_hosts, {permanent_failure, _, auth_failed}}, Error
                            )
                    end,
                    ok
                end}
            end,
            fun({_ListenSock}) ->
                {"Connecting to a SSL socket directly should work", fun() ->
                    application:ensure_all_started(gen_smtp),
                    {ok, ListenSock} = smtp_socket:listen(ssl, 9877, [
                        {certfile, "test/fixtures/mx1.example.com-server.crt"},
                        {keyfile, "test/fixtures/mx1.example.com-server.key"}
                    ]),
                    Options = [
                        {relay, <<"localhost">>},
                        {port, 9877},
                        {hostname, <<"testing">>},
                        {ssl, true}
                    ],
                    {ok, _Pid} = send(
                        {<<"test@foo.com">>, [<<"<foo@bar.com>">>, <<"baz@bar.com">>], <<"hello world">>},
                        Options
                    ),
                    {ok, X} = smtp_socket:accept(ListenSock, 1000),
                    smtp_socket:send(X, "220 Some banner\r\n"),
                    ?assertMatch({ok, "EHLO testing\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250-hostname\r\n250 AUTH CRAM-MD5\r\n"),
                    ?assertEqual(
                        {ok, "MAIL FROM:<test@foo.com>\r\n"}, smtp_socket:recv(X, 0, 1000)
                    ),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<foo@bar.com>\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "RCPT TO:<baz@bar.com>\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "DATA\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "354 ok\r\n"),
                    ?assertMatch({ok, "hello world\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    ?assertMatch({ok, ".\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:send(X, "250 ok\r\n"),
                    ?assertMatch({ok, "QUIT\r\n"}, smtp_socket:recv(X, 0, 1000)),
                    smtp_socket:close(ListenSock),
                    ok
                end}
            end
        ]}.

extension_parse_test_() ->
    [
        {"parse extensions", fun() ->
            Res = parse_extensions(
                <<"250-smtp.example.com\r\n250-PIPELINING\r\n250-SIZE 20971520\r\n250-VRFY\r\n250-ETRN\r\n250-STARTTLS\r\n250-AUTH CRAM-MD5 PLAIN DIGEST-MD5 LOGIN\r\n250-AUTH=CRAM-MD5 PLAIN DIGEST-MD5 LOGIN\r\n250-ENHANCEDSTATUSCODES\r\n250-8BITMIME\r\n250 DSN">>,
                []
            ),
            ?assertEqual(true, proplists:get_value(<<"PIPELINING">>, Res)),
            ?assertEqual(<<"20971520">>, proplists:get_value(<<"SIZE">>, Res)),
            ?assertEqual(true, proplists:get_value(<<"VRFY">>, Res)),
            ?assertEqual(true, proplists:get_value(<<"ETRN">>, Res)),
            ?assertEqual(true, proplists:get_value(<<"STARTTLS">>, Res)),
            ?assertEqual(
                <<"CRAM-MD5 PLAIN DIGEST-MD5 LOGIN">>, proplists:get_value(<<"AUTH">>, Res)
            ),
            ?assertEqual(true, proplists:get_value(<<"ENHANCEDSTATUSCODES">>, Res)),
            ?assertEqual(true, proplists:get_value(<<"8BITMIME">>, Res)),
            ?assertEqual(true, proplists:get_value(<<"DSN">>, Res)),
            ?assertEqual(10, length(Res)),
            ok
        end}
    ].

-endif.