diff --git a/README.markdown b/README.markdown index d79a70df..909355ac 100644 --- a/README.markdown +++ b/README.markdown @@ -195,6 +195,12 @@ Session options are: * `{tls_options, [ssl:server_option()]}` - options to pass to `ssl:handshake/3` (OTP-21+) / `ssl:ssl_accept/3` when `STARTTLS` command is sent by the client. Only needed if `STARTTLS` extension is enabled +* `{protocol, smtp | lmtp}` - when `lmtp` is passed, the control flow of the + [Local Mail Tranfer Protocol](https://tools.ietf.org/html/rfc2033) is applied. + LMTP is derived from SMTP with just a few variations and is used by standard + [Mail Transfer Agents (MTA)](https://en.wikipedia.org/wiki/Message_transfer_agent), like Postfix, Exim and OpenSMTPD to + send incoming email to local mail-handling applications that usually don't have a delivery queue. + The default value of this option is `smtp`. * `{callbackoptions, any()}` - value will be passed as 4th argument to callback module's `init/4` You can connect and test this using the `gen_smtp_client` via something like: @@ -207,6 +213,12 @@ gen_smtp_client:send( If you want to listen on IPv6, you can use the `{family, inet6}` and `{address, "::"}` options to enable listening on IPv6. +Please notice that when using the LMTP protocol, the `handle_EHLO` callback will be used +to handle the `LHLO` command as defined in [RFC2033](https://tools.ietf.org/html/rfc2033), +due to their similarities. Although not used, the implementation of `handle_HELO` is still +mandatory for the general `gen_smtp_server_session` behaviour (you can simply +return a 500 error, e.g. `{error, "500 LMTP server, not SMTP"}`). + ## Dependency on iconv gen_smtp relies on iconv for text encoding and decoding when parsing is activated. diff --git a/src/gen_smtp_server.erl b/src/gen_smtp_server.erl index 6e5d1eaf..0e185430 100644 --- a/src/gen_smtp_server.erl +++ b/src/gen_smtp_server.erl @@ -26,6 +26,8 @@ -define(PORT, 2525). +-include_lib("hut/include/hut.hrl"). + %% External API -export([ start/3, start/2, start/1, @@ -47,16 +49,24 @@ CallbackModule :: module(), Options :: options()) -> {'ok', pid()} | {'error', any()}. start(ServerName, CallbackModule, Options) when is_list(Options) -> - {ok, Transport, TransportOpts, ProtocolOpts} - = convert_options(CallbackModule, Options), - ranch:start_listener( - ServerName, Transport, TransportOpts, gen_smtp_server_session, ProtocolOpts). + case convert_options(CallbackModule, Options) of + {ok, Transport, TransportOpts, ProtocolOpts} -> + ranch:start_listener( + ServerName, Transport, TransportOpts, gen_smtp_server_session, ProtocolOpts); + {error, Reason} -> {error, Reason} + end. child_spec(ServerName, CallbackModule, Options) -> - {ok, Transport, TransportOpts, ProtocolOpts} - = convert_options(CallbackModule, Options), - ranch:child_spec( - ServerName, Transport, TransportOpts, gen_smtp_server_session, ProtocolOpts). + case convert_options(CallbackModule, Options) of + {ok, Transport, TransportOpts, ProtocolOpts} -> + ranch:child_spec( + ServerName, Transport, TransportOpts, gen_smtp_server_session, ProtocolOpts); + {error, Reason} -> + % `supervisor:child_spec' is not compatible with ok/error tuples. + % This error is likely to occur when starting the application, + % so the user can sort out the configuration parameters and try again. + erlang:error(Reason) + end. convert_options(CallbackModule, Options) -> Transport = case proplists:get_value(protocol, Options, tcp) of @@ -68,18 +78,26 @@ convert_options(CallbackModule, Options) -> Port = proplists:get_value(port, Options, ?PORT), Hostname = proplists:get_value(domain, Options, smtp_util:guess_FQDN()), ProtocolOpts = proplists:get_value(sessionoptions, Options, []), - ProtocolOpts1 = {CallbackModule, [{hostname, Hostname} | ProtocolOpts]}, - RanchOpts = proplists:get_value(ranch_opts, Options, #{}), - SocketOpts = maps:get(socket_opts, RanchOpts, []), - TransportOpts = RanchOpts#{ - socket_opts => - [{port, Port}, - {ip, Address}, - {keepalive, true}, - %% binary, {active, false}, {reuseaddr, true} - ranch defaults - Family - | SocketOpts]}, - {ok, Transport, TransportOpts, ProtocolOpts1}. + EmailTransferProtocol = proplists:get_value(protocol, ProtocolOpts, smtp), + case {EmailTransferProtocol, Port} of + {lmtp, 25} -> + ?log(error, "LMTP is different from SMTP, it MUST NOT be used on the TCP port 25"), + % Error defined in section 5 of https://tools.ietf.org/html/rfc2033 + {error, invalid_lmtp_port}; + _ -> + ProtocolOpts1 = {CallbackModule, [{hostname, Hostname} | ProtocolOpts]}, + RanchOpts = proplists:get_value(ranch_opts, Options, #{}), + SocketOpts = maps:get(socket_opts, RanchOpts, []), + TransportOpts = RanchOpts#{ + socket_opts => + [{port, Port}, + {ip, Address}, + {keepalive, true}, + %% binary, {active, false}, {reuseaddr, true} - ranch defaults + Family + | SocketOpts]}, + {ok, Transport, TransportOpts, ProtocolOpts1} + end. %% @doc Start the listener with callback module `Module' with options `Options' linked to no process. diff --git a/src/gen_smtp_server_session.erl b/src/gen_smtp_server_session.erl index f03edeb5..bf90f489 100644 --- a/src/gen_smtp_server_session.erl +++ b/src/gen_smtp_server_session.erl @@ -45,7 +45,7 @@ %% gen_server callbacks -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). --export_type([options/0, error_class/0]). +-export_type([options/0, error_class/0, protocol_message/0]). -include_lib("hut/include/hut.hrl"). @@ -73,6 +73,7 @@ readmessage = false :: boolean(), tls = false :: boolean(), callbackstate :: any(), + protocol = smtp :: 'smtp' | 'lmtp', options = [] :: [tuple()] } ). @@ -93,6 +94,7 @@ | {keyfile, file:name_all()} % deprecated, see tls_options | {allow_bare_newlines, false | ignore | fix | strip} | {hostname, inet:hostname()} + | {protocol, smtp | lmtp} | {tls_options, [tls_opt()]}]. -type(state() :: any()). @@ -107,6 +109,8 @@ | setopts_error | data_receive_error. +-type protocol_message() :: string() | iodata(). + -callback init(Hostname :: inet:hostname(), _SessionCount, Peername :: inet:ip_address(), Opts :: any()) -> {ok, Banner :: iodata(), CallbackState :: state()} | {stop, Reason :: any(), Message :: iodata()} | ignore. @@ -128,7 +132,8 @@ -callback handle_RCPT_extension(Extension :: binary(), State :: state()) -> {ok, state()} | error. -callback handle_DATA(From :: binary(), To :: [binary(),...], Data :: binary(), State :: state()) -> - {ok, string(), state()} | {error, string(), state()}. + {ok | error, protocol_message(), state()} | {multiple, [{ok | error, protocol_message()}], state()}. +% the 'multiple' reply is only available for LMTP -callback handle_RSET(State :: state()) -> state(). -callback handle_VRFY(Address :: binary(), State :: state()) -> {ok, string(), state()} | {error, string(), state()}. @@ -168,6 +173,7 @@ ranch_init({Ref, Transport, {Callback, Opts}}) -> %% @private -spec init(Args :: list()) -> {'ok', #state{}, ?TIMEOUT} | {'stop', any()} | 'ignore'. init([Ref, Transport, Socket, Module, Options]) -> + Protocol = proplists:get_value(protocol, Options, smtp), PeerName = case Transport:peername(Socket) of {ok, {IPaddr, _Port}} -> IPaddr; {error, _} -> error @@ -189,6 +195,7 @@ init([Ref, Transport, Socket, Module, Options]) -> transport = Transport, module = Module, ranch_ref = Ref, + protocol = Protocol, options = Options, callbackstate = CallbackState}, ?TIMEOUT}; {stop, Reason, Message} -> @@ -236,24 +243,16 @@ handle_info({receive_data, Body, Rest}, end, setopts(State, [{packet, line}]), %% Unescape periods at start of line (rfc5321 4.5.2) - UnescapedBody = re:replace(Body, <<"^\\\.">>, <<>>, [global, multiline, {return, binary}]), - Envelope = Env#envelope{data = UnescapedBody}, - case MaxSize =:= infinity orelse byte_size(Envelope#envelope.data) =< MaxSize of + Data = re:replace(Body, <<"^\\\.">>, <<>>, [global, multiline, {return, binary}]), + #envelope{from = From, to = To} = Env, + case MaxSize =:= infinity orelse byte_size(Data) =< MaxSize of true -> - case Module:handle_DATA(Envelope#envelope.from, Envelope#envelope.to, Envelope#envelope.data, OldCallbackState) of - {ok, Reference, CallbackState} -> - send(State, io_lib:format("250 queued as ~s\r\n", [Reference])), - setopts(State, [{active, once}]), - {noreply, State#state{readmessage = false, - envelope = #envelope{}, - callbackstate = CallbackState}, ?TIMEOUT}; - {error, Message, CallbackState} -> - send(State, [Message, "\r\n"]), - setopts(State, [{active, once}]), - {noreply, State#state{readmessage = false, - envelope = #envelope{}, - callbackstate = CallbackState}, ?TIMEOUT} - end; + {ResponseType, Value, CallbackState} = Module:handle_DATA(From, To, Data, OldCallbackState), + report_recipient(ResponseType, Value, State), + setopts(State, [{active, once}]), + {noreply, State#state{readmessage = false, + envelope = #envelope{}, + callbackstate = CallbackState}, ?TIMEOUT}; false -> send(State, "552 Message too large\r\n"), setopts(State, [{active, once}]), @@ -349,8 +348,16 @@ parse_request(Packet) -> handle_request({<<>>, _Any}, State) -> send(State, "500 Error: bad syntax\r\n"), {ok, State}; -handle_request({<<"HELO">>, <<>>}, State) -> - send(State, "501 Syntax: HELO hostname\r\n"), +handle_request({Command, <<>>}, State) + when Command == <<"HELO">>; Command == <<"EHLO">>; Command == <<"LHLO">> -> + send(State, ["501 Syntax: ", Command, " hostname\r\n"]), + {ok, State}; +handle_request({<<"LHLO">>, _Any}, #state{protocol = smtp} = State) -> + send(State, "500 Error: SMTP should send HELO or EHLO instead of LHLO\r\n"), + {ok, State}; +handle_request({Msg, _Any}, #state{protocol = lmtp} = State) + when Msg == <<"HELO">>; Msg == <<"EHLO">> -> + send(State, "500 Error: LMTP should replace HELO and EHLO with LHLO\r\n"), {ok, State}; handle_request({<<"HELO">>, Hostname}, #state{options = Options, module = Module, callbackstate = OldCallbackState} = State) -> @@ -369,11 +376,9 @@ handle_request({<<"HELO">>, Hostname}, send(State, [Message, "\r\n"]), {ok, State#state{callbackstate = CallbackState}} end; -handle_request({<<"EHLO">>, <<>>}, State) -> - send(State, "501 Syntax: EHLO hostname\r\n"), - {ok, State}; -handle_request({<<"EHLO">>, Hostname}, - #state{options = Options, module = Module, callbackstate = OldCallbackState, tls = Tls} = State) -> +handle_request({Msg, Hostname}, + #state{options = Options, module = Module, callbackstate = OldCallbackState, tls = Tls} = State) + when Msg == <<"EHLO">>; Msg == <<"LHLO">> -> case Module:handle_EHLO(Hostname, ?BUILTIN_EXTENSIONS, OldCallbackState) of {ok, [], CallbackState} -> Data = ["250 ", hostname(Options), "\r\n"], @@ -416,8 +421,8 @@ handle_request({<<"EHLO">>, Hostname}, {ok, State#state{callbackstate = CallbackState}} end; -handle_request({<<"AUTH">> = C, _Args}, #state{envelope = undefined} = State) -> - send(State, "503 Error: send EHLO first\r\n"), +handle_request({<<"AUTH">> = C, _Args}, #state{envelope = undefined, protocol = Protocol} = State) -> + send(State, ["503 Error: send ", lhlo_if_lmtp(Protocol, "EHLO"), " first\r\n"]), State1 = handle_error(out_of_order, C, State), {ok, State1}; handle_request({<<"AUTH">>, Args}, #state{extensions = Extensions, envelope = Envelope, options = Options} = State) -> @@ -512,8 +517,8 @@ handle_request({Password64, <<>>}, #state{waitingauth = 'login', envelope = #env Password = base64:decode(Password64), try_auth('login', Username, Password, State); -handle_request({<<"MAIL">> = C, _Args}, #state{envelope = undefined} = State) -> - send(State, "503 Error: send HELO/EHLO first\r\n"), +handle_request({<<"MAIL">> = C, _Args}, #state{envelope = undefined, protocol = Protocol} = State) -> + send(State, ["503 Error: send ", lhlo_if_lmtp(Protocol, "HELO/EHLO"), " first\r\n"]), State1 = handle_error(out_of_order, C, State), {ok, State1}; handle_request({<<"MAIL">>, Args}, @@ -631,8 +636,8 @@ handle_request({<<"RCPT">>, Args}, #state{envelope = Envelope, module = Module, send(State, "501 Syntax: RCPT TO:
\r\n"), {ok, State} end; -handle_request({<<"DATA">> = C, <<>>}, #state{envelope = undefined} = State) -> - send(State, "503 Error: send HELO/EHLO first\r\n"), +handle_request({<<"DATA">> = C, <<>>}, #state{envelope = undefined, protocol = Protocol} = State) -> + send(State, ["503 Error: send ", lhlo_if_lmtp(Protocol, "HELO/EHLO"), " first\r\n"]), State1 = handle_error(out_of_order, C, State), {ok, State1}; handle_request({<<"DATA">> = C, <<>>}, #state{envelope = Envelope} = State) -> @@ -993,6 +998,29 @@ setopts(#state{transport = Transport, socket = Sock} = St, Opts) -> hostname(Opts) -> proplists:get_value(hostname, Opts, smtp_util:guess_FQDN()). +%% @hidden +lhlo_if_lmtp(Protocol, Fallback) -> + case Protocol == lmtp of + true -> "LHLO"; + false -> Fallback + end. + +%% @hidden +-spec report_recipient(ResponseType :: 'ok' | 'error' | 'multiple', + Value :: string() | [{'ok' | 'error', string()}], + State :: #state{}) -> any(). +report_recipient(ok, Reference, State) -> + send(State, ["250 ", Reference, "\r\n"]); +report_recipient(error, Message, State) -> + send(State, [Message, "\r\n"]); +report_recipient(multiple, _Any, #state{protocol = smtp} = State) -> + Msg = "SMTP should report a single delivery status for all the recipients", + throw({stop, {handle_DATA_error, Msg}, State}); +report_recipient(multiple, [], _State) -> ok; +report_recipient(multiple, [{ResponseType, Value} | Rest], State) -> + report_recipient(ResponseType, Value, State), + report_recipient(multiple, Rest, State). + -ifdef(TEST). parse_encoded_address_test_() -> [ @@ -1068,6 +1096,7 @@ parse_request_test_() -> fun() -> ?assertEqual({<<"HELO">>, <<>>}, parse_request(<<"HELO\r\n">>)), ?assertEqual({<<"EHLO">>, <<"hell.af.mil">>}, parse_request(<<"EHLO hell.af.mil\r\n">>)), + ?assertEqual({<<"LHLO">>, <<"hell.af.mil">>}, parse_request(<<"LHLO hell.af.mil\r\n">>)), ?assertEqual({<<"MAIL">>, <<"FROM:God@heaven.af.mil">>}, parse_request(<<"MAIL FROM:God@heaven.af.mil">>)) end }, @@ -1138,6 +1167,18 @@ smtp_session_test_() -> end } end, + fun({CSock, _Pid}) -> + {"An error in response to an LHLO sent by SMTP", + fun() -> + smtp_socket:active_once(CSock), + receive {tcp, CSock, Packet} -> smtp_socket:active_once(CSock) end, + ?assertMatch("220 localhost"++_Stuff, Packet), + smtp_socket:send(CSock, "LHLO somehost.com\r\n"), + receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, + ?assertMatch("500 Error: SMTP should send HELO or EHLO instead of LHLO\r\n", Packet2) + end + } + end, fun({CSock, _Pid}) -> {"A rejected HELO", fun() -> @@ -1416,6 +1457,134 @@ smtp_session_test_() -> ] }. +lmtp_session_test_() -> + {foreach, + local, + fun() -> + application:ensure_all_started(gen_smtp), + {ok, Pid} = gen_smtp_server:start( + smtp_server_example, + [{sessionoptions, + [{protocol, lmtp}, + {callbackoptions, + [{protocol, lmtp}, + {size, infinity}]} + ]}, + {domain, "localhost"}, + {port, 9876}]), + {ok, CSock} = smtp_socket:connect(tcp, "localhost", 9876), + {CSock, Pid} + end, + fun({CSock, _Pid}) -> + gen_smtp_server:stop(gen_smtp_server), + smtp_socket:close(CSock), + timer:sleep(10) + end, + [fun({CSock, _Pid}) -> + {"An error in response to a HELO/EHLO sent by LMTP", + fun() -> + smtp_socket:active_once(CSock), + receive {tcp, CSock, Packet} -> smtp_socket:active_once(CSock) end, + ?assertMatch("220 localhost"++_Stuff, Packet), + smtp_socket:send(CSock, "HELO somehost.com\r\n"), + receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, + ?assertMatch("500 Error: LMTP should replace HELO and EHLO with LHLO\r\n", Packet2), + smtp_socket:send(CSock, "EHLO somehost.com\r\n"), + receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, + ?assertMatch("500 Error: LMTP should replace HELO and EHLO with LHLO\r\n", Packet3) + end + } + end, + fun({CSock, _Pid}) -> + {"LHLO response", + fun() -> + smtp_socket:active_once(CSock), + receive {tcp, CSock, Packet} -> smtp_socket:active_once(CSock) end, + ?assertMatch("220 localhost"++_Stuff, Packet), + smtp_socket:send(CSock, "LHLO somehost.com\r\n"), + receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, + ?assertMatch("250-localhost\r\n", Packet2), + Foo = fun(F) -> + receive + {tcp, CSock, "250-"++_Packet3} -> + smtp_socket:active_once(CSock), + F(F); + {tcp, CSock, "250 "++_Packet3} -> + smtp_socket:active_once(CSock), + ok; + {tcp, CSock, _R} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(ok, Foo(Foo)) + end + } + end, + fun({CSock, _Pid}) -> + {"DATA with multiple RCPT TO", + fun() -> + smtp_socket:active_once(CSock), + receive {tcp, CSock, Packet} -> smtp_socket:active_once(CSock) end, + ?assertMatch("220 localhost"++_Stuff, Packet), + smtp_socket:send(CSock, "LHLO somehost.com\r\n"), + receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, + ?assertMatch("250-localhost\r\n", Packet2), + Foo = fun(F, Acc) -> + receive + {tcp, CSock, "250-SIZE"++_ = Data} -> + {error, ["received: ", Data]}; + {tcp, CSock, "250-"++_} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 PIPELINING"++_} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, Data} -> + smtp_socket:active_once(CSock), + {error, ["received: ", Data]} + end + end, + ?assertEqual(true, Foo(Foo, false)), + + smtp_socket:send(CSock, "MAIL FROM: