From 3967bcbd349b2bf0c390f68c68bd1d79eb5ad1fc Mon Sep 17 00:00:00 2001 From: Sergey Prokhorov Date: Thu, 27 Jan 2022 00:18:22 +0100 Subject: [PATCH 1/2] Introduce code formatter --- .editorconfig | 2 +- rebar.config | 121 +- src/binstr.erl | 249 +- src/gen_smtp.app.src | 17 +- src/gen_smtp_client.erl | 3051 ++++++++------- src/gen_smtp_server.erl | 140 +- src/gen_smtp_server_session.erl | 6240 +++++++++++++++++-------------- src/mimemail.erl | 5257 ++++++++++++++------------ src/smtp_server_example.erl | 363 +- src/smtp_socket.erl | 1152 +++--- src/smtp_util.erl | 311 +- test/gen_smtp_server_test.erl | 21 +- test/gen_smtp_util_test.erl | 213 +- test/prop_mimemail.erl | 612 +-- test/prop_rfc5322.erl | 329 +- 15 files changed, 9944 insertions(+), 8134 deletions(-) diff --git a/.editorconfig b/.editorconfig index ea1a1a20..f7f1a6da 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,7 @@ root = true [*] charset = utf-8 -indent_style = tab +indent_style = space indent_size = 4 end_of_line = lf insert_final_newline = true diff --git a/rebar.config b/rebar.config index 7fdb3d66..9e7382b6 100644 --- a/rebar.config +++ b/rebar.config @@ -1,68 +1,75 @@ %% -*- mode: erlang; -*- {require_min_otp_vsn, "21"}. -{erl_opts, - [fail_on_warning, - debug_info, - warn_unused_vars, - warn_unused_import, - warn_exported_vars]}. +{erl_opts, [ + fail_on_warning, + debug_info, + warn_unused_vars, + warn_unused_import, + warn_exported_vars +]}. -{xref_checks, - [undefined_function_calls, - undefined_functions, - locals_not_used, - %% exports_not_used, - deprecated_function_calls, - deprecated_functions - ]}. +{xref_checks, [ + undefined_function_calls, + undefined_functions, + locals_not_used, + %% exports_not_used, + deprecated_function_calls, + deprecated_functions +]}. {project_plugins, [ + erlfmt, rebar3_proper ]}. +{erlfmt, [ + write, + {print_width, 120}, + {files, [ + "{src,include,test}/*.{hrl,erl}", + "src/*.app.src", + "rebar.config" + ]}, + {exclude_files, [ + "src/smtp_rfc5322_parse.erl", + "src/smtp_rfc5322_scan.erl", + "src/smtp_rfc822_parse.erl" + ]} +]}. -{xref_ignores, - [ - {smtp_rfc822_parse, return_error, 2} - ]}. +{xref_ignores, [ + {smtp_rfc822_parse, return_error, 2} +]}. -{deps, [{ranch, ">= 1.8.0"}, - {hut, "1.3.0"}]}. +{deps, [ + {ranch, ">= 1.8.0"}, + {hut, "1.3.0"} +]}. -{profiles, - [ - {dialyzer, - [ - {deps, - [ - {eiconv, "1.0.0"} - ] - }, - {dialyzer, - [ - {plt_extra_apps, - [ - eiconv, - ssl, - hut - ] - }, - {warnings, - [error_handling, - unknown - ]} - ]} - ]}, - {ranch_v2, - [{deps, [{ranch, "2.1.0"}]}]}, - {test, - [ - {cover_enabled, true}, - {cover_print_enabled, true}, - {deps, - [ - {eiconv, "1.0.0"}, - {proper, "1.3.0"} - ]} - ]} - ]}. +{profiles, [ + {dialyzer, [ + {deps, [ + {eiconv, "1.0.0"} + ]}, + {dialyzer, [ + {plt_extra_apps, [ + eiconv, + ssl, + hut + ]}, + {warnings, [ + error_handling, + unknown + ]} + ]} + ]}, + {ranch_v2, [{deps, [{ranch, "2.1.0"}]}]}, + {test, [ + {cover_enabled, true}, + {cover_print_enabled, true}, + {deps, [ + {eiconv, "1.0.0"}, + {proper, "1.3.0"} + ]} + ]} +]}. diff --git a/src/binstr.erl b/src/binstr.erl index 073028a9..46451617 100644 --- a/src/binstr.erl +++ b/src/binstr.erl @@ -25,24 +25,24 @@ -module(binstr). -export([ - strchr/2, - strrchr/2, - strpos/2, - strrpos/2, - substr/2, - substr/3, - split/3, - split/2, - chomp/1, - strip/1, - strip/2, - strip/3, - to_lower/1, - to_upper/1, - all/2, - reverse/1, - reverse_str_to_bin/1, - join/2 + strchr/2, + strrchr/2, + strpos/2, + strrpos/2, + substr/2, + substr/3, + split/3, + split/2, + chomp/1, + strip/1, + strip/2, + strip/3, + to_lower/1, + to_upper/1, + all/2, + reverse/1, + reverse_str_to_bin/1, + join/2 ]). -spec strchr(Bin :: binary(), C :: char()) -> non_neg_integer(). @@ -56,199 +56,194 @@ strchr(Bin, C) when is_binary(Bin) -> -spec strrchr(Bin :: binary(), C :: char()) -> non_neg_integer(). strrchr(Bin, C) -> - strrchr(Bin, C, byte_size(Bin)). + strrchr(Bin, C, byte_size(Bin)). strrchr(Bin, C, I) -> - case Bin of - <<_X:I/binary, C, _Rest/binary>> -> - I+1; - _ when I =< 1 -> - 0; - _ -> - strrchr(Bin, C, I-1) - end. - + case Bin of + <<_X:I/binary, C, _Rest/binary>> -> + I + 1; + _ when I =< 1 -> + 0; + _ -> + strrchr(Bin, C, I - 1) + end. -spec strpos(Bin :: binary(), C :: binary() | list()) -> non_neg_integer(). strpos(Bin, C) when is_binary(Bin), is_list(C) -> - strpos(Bin, list_to_binary(C)); + strpos(Bin, list_to_binary(C)); strpos(Bin, C) when is_binary(Bin) -> - case binary:match(Bin, C) of - {Index, _Length} -> - Index+1; - nomatch -> - 0 + case binary:match(Bin, C) of + {Index, _Length} -> + Index + 1; + nomatch -> + 0 end. -spec strrpos(Bin :: binary(), C :: binary() | list()) -> non_neg_integer(). strrpos(Bin, C) -> - strrpos(Bin, C, byte_size(Bin), byte_size(C)). + strrpos(Bin, C, byte_size(Bin), byte_size(C)). strrpos(Bin, C, I, S) -> - case Bin of - <<_X:I/binary, C:S/binary, _Rest/binary>> -> - I+1; - _ when I =< 1 -> - 0; - _ -> - strrpos(Bin, C, I-1, S) - end. - + case Bin of + <<_X:I/binary, C:S/binary, _Rest/binary>> -> + I + 1; + _ when I =< 1 -> + 0; + _ -> + strrpos(Bin, C, I - 1, S) + end. -spec substr(Bin :: binary(), Start :: pos_integer() | neg_integer()) -> binary(). substr(<<>>, _) -> - <<>>; + <<>>; substr(Bin, Start) when Start > 0 -> - {_, B2} = split_binary(Bin, Start-1), - B2; + {_, B2} = split_binary(Bin, Start - 1), + B2; substr(Bin, Start) when Start < 0 -> - Size = byte_size(Bin), - {_, B2} = split_binary(Bin, Size+Start), - B2. - + Size = byte_size(Bin), + {_, B2} = split_binary(Bin, Size + Start), + B2. --spec substr(Bin :: binary(), Start :: pos_integer() | neg_integer(), Length :: pos_integer()) -> binary(). +-spec substr(Bin :: binary(), Start :: pos_integer() | neg_integer(), Length :: pos_integer()) -> + binary(). substr(<<>>, _, _) -> - <<>>; + <<>>; substr(Bin, Start, Length) when Start > 0 -> - {_, B2} = split_binary(Bin, Start-1), - {B3, _} = split_binary(B2, Length), - B3; + {_, B2} = split_binary(Bin, Start - 1), + {B3, _} = split_binary(B2, Length), + B3; substr(Bin, Start, Length) when Start < 0 -> - Size = byte_size(Bin), - {_, B2} = split_binary(Bin, Size+Start), - {B3, _} = split_binary(B2, Length), - B3. - + Size = byte_size(Bin), + {_, B2} = split_binary(Bin, Size + Start), + {B3, _} = split_binary(B2, Length), + B3. -spec split(Bin :: binary(), Separator :: binary(), SplitCount :: pos_integer()) -> [binary()]. split(Bin, Separator, SplitCount) -> - split_(Bin, Separator, SplitCount, []). + split_(Bin, Separator, SplitCount, []). split_(<<>>, _Separator, _SplitCount, Acc) -> - lists:reverse(Acc); + lists:reverse(Acc); split_(Bin, <<>>, 1, Acc) -> - lists:reverse([Bin | Acc]); + lists:reverse([Bin | Acc]); split_(Bin, _Separator, 1, Acc) -> - lists:reverse([Bin | Acc]); + lists:reverse([Bin | Acc]); split_(Bin, <<>>, SplitCount, Acc) -> - split_(substr(Bin, 2), <<>>, SplitCount - 1, [substr(Bin, 1, 1) | Acc]); + split_(substr(Bin, 2), <<>>, SplitCount - 1, [substr(Bin, 1, 1) | Acc]); split_(Bin, Separator, SplitCount, Acc) -> - case strpos(Bin, Separator) of - 0 -> - lists:reverse([Bin | Acc]); - Index -> - Head = substr(Bin, 1, Index - 1), - Tailpresplit = substr(Bin, Index + byte_size(Separator)), - split_(Tailpresplit, Separator, SplitCount - 1, [Head | Acc]) - end. - + case strpos(Bin, Separator) of + 0 -> + lists:reverse([Bin | Acc]); + Index -> + Head = substr(Bin, 1, Index - 1), + Tailpresplit = substr(Bin, Index + byte_size(Separator)), + split_(Tailpresplit, Separator, SplitCount - 1, [Head | Acc]) + end. -spec split(Bin :: binary(), Separator :: binary()) -> [binary()]. split(Bin, Separator) -> - case binary:split(Bin, Separator, [global]) of - Result -> - case lists:last(Result) of - <<>> -> - lists:sublist(Result, length(Result) - 1); - _ -> - Result - end + case binary:split(Bin, Separator, [global]) of + Result -> + case lists:last(Result) of + <<>> -> + lists:sublist(Result, length(Result) - 1); + _ -> + Result + end end. -spec chomp(Bin :: binary()) -> binary(). chomp(Bin) -> - L = byte_size(Bin), - case [binary:at(Bin, L-2), binary:at(Bin, L-1)] of - "\r\n" -> - binary:part(Bin, 0, L-2); - [_, X] when X == $\r; X == $\n -> - binary:part(Bin, 0, L-1); - _ -> - Bin + L = byte_size(Bin), + case [binary:at(Bin, L - 2), binary:at(Bin, L - 1)] of + "\r\n" -> + binary:part(Bin, 0, L - 2); + [_, X] when X == $\r; X == $\n -> + binary:part(Bin, 0, L - 1); + _ -> + Bin end. -spec strip(Bin :: binary()) -> binary(). strip(Bin) -> - strip(Bin, both, $\s). + strip(Bin, both, $\s). -spec strip(Bin :: binary(), Dir :: 'left' | 'right' | 'both') -> binary(). strip(Bin, Dir) -> - strip(Bin, Dir, $\s). + strip(Bin, Dir, $\s). -spec strip(Bin :: binary(), Dir :: 'left' | 'right' | 'both', C :: non_neg_integer()) -> binary(). strip(<<>>, _, _) -> - <<>>; + <<>>; strip(Bin, both, C) -> - strip(strip(Bin, left, C), right, C); + strip(strip(Bin, left, C), right, C); strip(<> = Bin, left, C) -> - strip(substr(Bin, 2), left, C); + strip(substr(Bin, 2), left, C); strip(Bin, left, _C) -> - Bin; + Bin; strip(Bin, right, C) -> - L = byte_size(Bin), - case binary:at(Bin, L-1) of - C -> - strip(binary:part(Bin, 0, L-1), right, C); - _ -> - Bin + L = byte_size(Bin), + case binary:at(Bin, L - 1) of + C -> + strip(binary:part(Bin, 0, L - 1), right, C); + _ -> + Bin end. -spec to_lower(Bin :: binary()) -> binary(). to_lower(Bin) -> - to_lower(Bin, <<>>). + to_lower(Bin, <<>>). to_lower(<<>>, Acc) -> - Acc; + Acc; to_lower(<>, Acc) when H >= $A, H =< $Z -> - H2 = H + 32, - to_lower(T, <>); + H2 = H + 32, + to_lower(T, <>); to_lower(<>, Acc) -> - to_lower(T, <>). - + to_lower(T, <>). -spec to_upper(Bin :: binary()) -> binary(). to_upper(Bin) -> - to_upper(Bin, <<>>). + to_upper(Bin, <<>>). to_upper(<<>>, Acc) -> - Acc; + Acc; to_upper(<>, Acc) when H >= $a, H =< $z -> - H2 = H - 32, - to_upper(T, <>); + H2 = H - 32, + to_upper(T, <>); to_upper(<>, Acc) -> - to_upper(T, <>). + to_upper(T, <>). -spec all(Fun :: function(), Binary :: binary()) -> boolean(). all(_Fun, <<>>) -> - true; + true; all(Fun, Binary) -> - Res = << <> || <> <= Binary, Fun(X) >>, - Binary == Res. + Res = <<<> || <> <= Binary, Fun(X)>>, + Binary == Res. %all(Fun, <>) -> % Fun(H) =:= true andalso all(Fun, Tail). %% this is a cool hack to very quickly reverse a binary -spec reverse(Bin :: binary()) -> binary(). reverse(Bin) -> - Size = byte_size(Bin)*8, - <> = Bin, - <>. + Size = byte_size(Bin) * 8, + <> = Bin, + <>. %% reverse a string into a binary - can be faster than lists:reverse on large %% lists, even if you run binary_to_string on the result. For smaller strings %% it's probably slower (but still not that bad). -spec reverse_str_to_bin(String :: string()) -> binary(). reverse_str_to_bin(String) -> - reverse(list_to_binary(String)). + reverse(list_to_binary(String)). --spec join(Binaries :: [binary()|list()], Glue :: binary() | list()) -> binary(). +-spec join(Binaries :: [binary() | list()], Glue :: binary() | list()) -> binary(). join(Binaries, Glue) -> - join(Binaries, Glue, []). + join(Binaries, Glue, []). join([H], _Glue, Acc) -> - list_to_binary(lists:reverse([H | Acc])); -join([H|T], Glue, Acc) -> - join(T, Glue, [Glue, H | Acc]); + list_to_binary(lists:reverse([H | Acc])); +join([H | T], Glue, Acc) -> + join(T, Glue, [Glue, H | Acc]); join([], _Glue, _Acc) -> - <<"">>. + <<"">>. diff --git a/src/gen_smtp.app.src b/src/gen_smtp.app.src index af67a8e2..eb17ad13 100644 --- a/src/gen_smtp.app.src +++ b/src/gen_smtp.app.src @@ -1,8 +1,9 @@ -{application,gen_smtp, - [{description,"The extensible Erlang SMTP client and server library."}, - {vsn,"1.1.1"}, - {applications,[kernel,stdlib,crypto,asn1,public_key,ssl,ranch]}, - {registered,[]}, - {licenses,["BSD-2-Clause"]}, - {links,[{"GitHub","https://github.com/gen-smtp/gen_smtp"}]}, - {exclude_files,["src/smtp_rfc822_parse.erl"]}]}. +{application, gen_smtp, [ + {description, "The extensible Erlang SMTP client and server library."}, + {vsn, "1.1.1"}, + {applications, [kernel, stdlib, crypto, asn1, public_key, ssl, ranch]}, + {registered, []}, + {licenses, ["BSD-2-Clause"]}, + {links, [{"GitHub", "https://github.com/gen-smtp/gen_smtp"}]}, + {exclude_files, ["src/smtp_rfc822_parse.erl"]} +]}. diff --git a/src/gen_smtp_client.erl b/src/gen_smtp_client.erl index 369c25de..7a9791d8 100644 --- a/src/gen_smtp_client.erl +++ b/src/gen_smtp_client.erl @@ -26,21 +26,25 @@ -module(gen_smtp_client). -define(DEFAULT_OPTIONS, [ - {ssl, false}, % whether to connect on 465 in ssl mode - {tls, if_available}, % always, never, if_available - {tls_options, [{versions, ['tlsv1', 'tlsv1.1', 'tlsv1.2']}]}, % used in ssl:connect, http://erlang.org/doc/man/ssl.html - {auth, if_available}, - {hostname, smtp_util:guess_FQDN()}, - {retries, 1}, % how many retries per smtp host on temporary failure - {on_transaction_error, quit} - ]). + % 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" - ]). + "CRAM-MD5", + "LOGIN", + "PLAIN", + "XOAUTH2" +]). -define(TIMEOUT, 1200000). @@ -51,549 +55,624 @@ -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]). +-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} | - {tls_options, list()} | % ssl:option() / ssl:tls_client_option() - {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 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() - }). + 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() ). +-type callback() :: fun( + ( + {exit, any()} + | smtp_session_error() + | {ok, binary()} + ) -> any() +). %% Smth that is thrown from inner SMTP functions --type permanent_failure_reason() :: binary() | % server's 5xx response - auth_failed | - ssl_not_started. --type temporary_failure_reason() :: binary() | %server's 4xx response - 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()}}. + +% 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()}}. + {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()}. + {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()}. +-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). + 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()}. +-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. + 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()}. + 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. + 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()}. + {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 - [] -> - [{0, RelayDomain}]; % maybe we're supposed to relay to a host directly - _ -> - 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()}. + 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 may 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. + #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). +close(#smtp_client_socket{socket = Socket}) -> + quit(Socket). --spec send_it(Email :: email(), Options :: options()) -> binary() | - smtp_session_error(). +-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 - [] -> - [{0, RelayDomain}]; % maybe we're supposed to relay to a host directly - _ -> - 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(). + 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. + 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(). + {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; + % 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(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}]}; + 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)}; + % 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}]}. - + % 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(). + {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), + 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(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; + 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. + % 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; + 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([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; + 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(). + % 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); 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(). + 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; + 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; + 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; + 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_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(). +-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(). + 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; + 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; + 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; + 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), + 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), + %% 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; + %% 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; + 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). - + 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; @@ -607,969 +686,1151 @@ 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. + 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. + 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()}. +-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. + 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. +-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. + 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(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. + 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()}. + 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. + 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. + 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. + 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; + 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({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. + 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]. + [_ | 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. + 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:\r\n"}, smtp_socket:recv(X, 0, 1000)), - smtp_socket:send(X, "250 ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(X, 0, 1000)), - smtp_socket:send(X, "250 ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(X, 0, 1000)), - smtp_socket:send(X, "250 ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(X, 0, 1000)), - smtp_socket:send(X, "250 ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000)), - smtp_socket:send(Y, "250 ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000)), - smtp_socket:send(Y, "250 ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000)), - smtp_socket:send(Y, "250 ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(X, 0, 1000)), - smtp_socket:send(X, "250 ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\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:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000)), - smtp_socket:send(Y, "250 Ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000)), - smtp_socket:send(Y, "250 Ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000)), - smtp_socket:send(Y, "250 Ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000)), - smtp_socket:send(Y, "250 Ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\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:\r\n"}, smtp_socket:recv(Y2, 0, 1000)), - smtp_socket:send(Y2, "250 Ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y3, 0, 1000)), - smtp_socket:send(Y3, "250 Ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y4, 0, 1000)), - smtp_socket:send(Y4, "250 Ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y5, 0, 1000)), - smtp_socket:send(Y5, "250 Ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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:\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:\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:\r\n"}, smtp_socket:recv(X, 0, 1000)), - ok - end - } - end, + {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:\r\n"}, smtp_socket:recv(X, 0, 1000) + ), + smtp_socket:send(X, "250 ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(X, 0, 1000) + ), + smtp_socket:send(X, "250 ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(X, 0, 1000) + ), + smtp_socket:send(X, "250 ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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}) -> - {"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:\r\n"}, smtp_socket:recv(X, 0, 1000)), - ok + {"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:\r\n"}, smtp_socket:recv(X, 0, 1000) + ), + smtp_socket:send(X, "250 ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000) + ), + smtp_socket:send(Y, "250 ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000) + ), + smtp_socket:send(Y, "250 ok\r\n"), + ?assertMatch( + {ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000) + ), + smtp_socket:send(Y, "250 ok\r\n"), + ?assertMatch( + {ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(X, 0, 1000) + ), + smtp_socket:send(X, "250 ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\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:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000) + ), + smtp_socket:send(Y, "250 Ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000) + ), + smtp_socket:send(Y, "250 Ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000) + ), + smtp_socket:send(Y, "250 Ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y, 0, 1000) + ), + smtp_socket:send(Y, "250 Ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\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:\r\n"}, smtp_socket:recv(Y2, 0, 1000) + ), + smtp_socket:send(Y2, "250 Ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y3, 0, 1000) + ), + smtp_socket:send(Y3, "250 Ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y4, 0, 1000) + ), + smtp_socket:send(Y4, "250 Ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\r\n"}, smtp_socket:recv(Y5, 0, 1000) + ), + smtp_socket:send(Y5, "250 Ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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:\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:\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:\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:\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:\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:\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}) -> - {"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:\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:\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">>, [<<"">>, <<"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:\r\n"}, smtp_socket:recv(X, 0, 1000)), - smtp_socket:send(X, "250 ok\r\n"), - ?assertMatch({ok, "RCPT TO:\r\n"}, smtp_socket:recv(X, 0, 1000)), - smtp_socket:send(X, "250 ok\r\n"), - ?assertMatch({ok, "RCPT TO:\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 - - ] - }. + 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">>, [<<"">>, <<"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:\r\n"}, smtp_socket:recv(X, 0, 1000) + ), + smtp_socket:send(X, "250 ok\r\n"), + ?assertMatch({ok, "RCPT TO:\r\n"}, smtp_socket:recv(X, 0, 1000)), + smtp_socket:send(X, "250 ok\r\n"), + ?assertMatch({ok, "RCPT TO:\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 - } - ]. + [ + {"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. diff --git a/src/gen_smtp_server.erl b/src/gen_smtp_server.erl index 0e185430..5b6a88f2 100644 --- a/src/gen_smtp_server.erl +++ b/src/gen_smtp_server.erl @@ -30,92 +30,106 @@ %% External API -export([ - start/3, start/2, start/1, - stop/1, child_spec/3, sessions/1]). + start/3, start/2, start/1, + stop/1, + child_spec/3, + sessions/1 +]). -export_type([options/0]). -type server_name() :: any(). -type options() :: - [{domain, string()} - | {address, inet:ip4_address()} - | {family, inet | inet6} - | {port, inet:port_number()} - | {protocol, 'tcp' | 'ssl'} - | {ranch_opts, ranch:opts()} - | {sessionoptions, gen_smtp_server_session:options()}]. + [ + {domain, string()} + | {address, inet:ip4_address()} + | {family, inet | inet6} + | {port, inet:port_number()} + | {protocol, 'tcp' | 'ssl'} + | {ranch_opts, ranch:opts()} + | {sessionoptions, gen_smtp_server_session:options()} + ]. %% @doc Start the listener as a registered process with callback module `Module' with options `Options' linked to no process. --spec start(ServerName :: server_name(), - CallbackModule :: module(), - Options :: options()) -> {'ok', pid()} | {'error', any()}. +-spec start( + ServerName :: server_name(), + CallbackModule :: module(), + Options :: options() +) -> {'ok', pid()} | {'error', any()}. start(ServerName, CallbackModule, Options) when is_list(Options) -> - 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. + 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) -> - 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. + 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 - tcp -> ranch_tcp; - ssl -> ranch_ssl - end, - Family = proplists:get_value(family, Options, inet), - Address = proplists:get_value(address, Options, {0, 0, 0, 0}), - Port = proplists:get_value(port, Options, ?PORT), - Hostname = proplists:get_value(domain, Options, smtp_util:guess_FQDN()), - ProtocolOpts = proplists:get_value(sessionoptions, Options, []), - 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. - + Transport = + case proplists:get_value(protocol, Options, tcp) of + tcp -> ranch_tcp; + ssl -> ranch_ssl + end, + Family = proplists:get_value(family, Options, inet), + Address = proplists:get_value(address, Options, {0, 0, 0, 0}), + Port = proplists:get_value(port, Options, ?PORT), + Hostname = proplists:get_value(domain, Options, smtp_util:guess_FQDN()), + ProtocolOpts = proplists:get_value(sessionoptions, Options, []), + 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. --spec start(CallbackModule :: module(), Options :: options()) -> {'ok', pid()} | 'ignore' | {'error', any()}. +-spec start(CallbackModule :: module(), Options :: options()) -> + {'ok', pid()} | 'ignore' | {'error', any()}. start(CallbackModule, Options) when is_list(Options) -> - start(?MODULE, CallbackModule, Options). + start(?MODULE, CallbackModule, Options). %% @doc Start the listener with callback module `Module' with default options linked to no process. -spec start(CallbackModule :: atom()) -> {'ok', pid()} | 'ignore' | {'error', any()}. start(CallbackModule) -> - start(CallbackModule, []). + start(CallbackModule, []). %% @doc Stop the listener pid() `Pid' with reason `normal'. -spec stop(Name :: server_name()) -> 'ok'. stop(Name) -> - ranch:stop_listener(Name). + ranch:stop_listener(Name). %% @doc Return the list of active SMTP session pids. -spec sessions(Name :: server_name()) -> [pid()]. sessions(Name) -> - ranch:procs(Name, connections). + ranch:procs(Name, connections). diff --git a/src/gen_smtp_server_session.erl b/src/gen_smtp_server_session.erl index 6f662ae2..2ed70bd8 100644 --- a/src/gen_smtp_server_session.erl +++ b/src/gen_smtp_server_session.erl @@ -34,50 +34,60 @@ -include_lib("eunit/include/eunit.hrl"). -endif. --define(DEFAULT_MAXSIZE, 10485760). %10mb --define(BUILTIN_EXTENSIONS, [{"SIZE", integer_to_list(?DEFAULT_MAXSIZE)}, {"8BITMIME", true}, {"PIPELINING", true}, {"SMTPUTF8", true}]). --define(TIMEOUT, 180000). % 3 minutes +%10mb +-define(DEFAULT_MAXSIZE, 10485760). +-define(BUILTIN_EXTENSIONS, [ + {"SIZE", integer_to_list(?DEFAULT_MAXSIZE)}, + {"8BITMIME", true}, + {"PIPELINING", true}, + {"SMTPUTF8", true} +]). +% 3 minutes +-define(TIMEOUT, 180000). %% External API -export([start_link/3, start_link/4]). -export([ranch_init/1]). %% gen_server callbacks --export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, - code_change/3]). +-export([ + init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2, + code_change/3 +]). -export_type([options/0, error_class/0, protocol_message/0]). -include_lib("hut/include/hut.hrl"). --record(envelope, - { - from :: binary() | 'undefined', - to = [] :: [binary()], - data = <<>> :: binary(), - expectedsize = 0 :: pos_integer() | 0, - auth = {<<>>, <<>>} :: {binary(), binary()}, % {"username", "password"} - flags = [] :: [smtputf8 | '8bitmime' | '7bit'] - } -). - --record(state, - { - socket = erlang:error({undefined, socket}) :: port() | tuple(), - module = erlang:error({undefined, module}) :: atom(), - transport :: module(), - ranch_ref :: ranch:ref(), - envelope = undefined :: 'undefined' | #envelope{}, - extensions = [] :: [{string(), string()}], - maxsize = ?DEFAULT_MAXSIZE :: pos_integer() | 'infinity', - waitingauth = false :: 'false' | 'plain' | 'login' | 'cram-md5', - authdata :: 'undefined' | binary(), - readmessage = false :: boolean(), - tls = false :: boolean(), - callbackstate :: any(), - protocol = smtp :: 'smtp' | 'lmtp', - options = [] :: [tuple()] - } -). +-record(envelope, { + from :: binary() | 'undefined', + to = [] :: [binary()], + data = <<>> :: binary(), + expectedsize = 0 :: pos_integer() | 0, + % {"username", "password"} + auth = {<<>>, <<>>} :: {binary(), binary()}, + flags = [] :: [smtputf8 | '8bitmime' | '7bit'] +}). + +-record(state, { + socket = erlang:error({undefined, socket}) :: port() | tuple(), + module = erlang:error({undefined, module}) :: atom(), + transport :: module(), + ranch_ref :: ranch:ref(), + envelope = undefined :: 'undefined' | #envelope{}, + extensions = [] :: [{string(), string()}], + maxsize = ?DEFAULT_MAXSIZE :: pos_integer() | 'infinity', + waitingauth = false :: 'false' | 'plain' | 'login' | 'cram-md5', + authdata :: 'undefined' | binary(), + readmessage = false :: boolean(), + tls = false :: boolean(), + callbackstate :: any(), + protocol = smtp :: 'smtp' | 'lmtp', + options = [] :: [tuple()] +}). %% OTP-19: ssl:ssl_option() %% OTP-20: ssl:ssl_option() @@ -90,39 +100,56 @@ -type tls_opt() :: ssl:ssl_option(). -endif. --type options() :: [ {callbackoptions, any()} - | {certfile, file:name_all()} % deprecated, see tls_options - | {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()). --type(error_message() :: {error, string(), state()}). --type error_class() :: tcp_closed | tcp_error - | ssl_closed | ssl_error - | data_rejected - | timeout - | out_of_order - | ssl_handshake_error - | send_error - | setopts_error - | data_receive_error. +-type options() :: [ + {callbackoptions, any()} + % deprecated, see tls_options + | {certfile, file:name_all()} + % deprecated, see tls_options + | {keyfile, file:name_all()} + | {allow_bare_newlines, false | ignore | fix | strip} + | {hostname, inet:hostname()} + | {protocol, smtp | lmtp} + | {tls_options, [tls_opt()]} +]. + +-type state() :: any(). +-type error_message() :: {error, string(), state()}. +-type error_class() :: + tcp_closed + | tcp_error + | ssl_closed + | ssl_error + | data_rejected + | timeout + | out_of_order + | ssl_handshake_error + | send_error + | 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. --callback code_change(OldVsn :: any(), State :: state(), Extra :: any()) -> {ok, state()}. +-callback init( + Hostname :: inet:hostname(), + _SessionCount, + Peername :: inet:ip_address(), + Opts :: any() +) -> + {ok, Banner :: iodata(), CallbackState :: state()} + | {stop, Reason :: any(), Message :: iodata()} + | ignore. +-callback code_change(OldVsn :: any(), State :: state(), Extra :: any()) -> {ok, state()}. -callback handle_HELO(Hostname :: binary(), State :: state()) -> {ok, pos_integer() | 'infinity', state()} | {ok, state()} | error_message(). -callback handle_EHLO(Hostname :: binary(), Extensions :: list(), State :: state()) -> {ok, list(), state()} | error_message(). -callback handle_STARTTLS(state()) -> state(). --callback handle_AUTH(AuthType :: login | plain | 'cram-md5', Username :: binary(), - Credential :: binary() | {binary(), binary()}, State :: state()) -> +-callback handle_AUTH( + AuthType :: login | plain | 'cram-md5', + Username :: binary(), + Credential :: binary() | {binary(), binary()}, + State :: state() +) -> {ok, state()} | any(). -callback handle_MAIL(From :: binary(), State :: state()) -> {ok, state()} | {error, string(), state()}. @@ -132,2903 +159,3518 @@ {ok, state()} | {error, string(), state()}. -callback handle_RCPT_extension(Extension :: binary(), State :: state()) -> {ok, state()} | error. --callback handle_DATA(From :: binary(), To :: [binary(),...], Data :: binary(), State :: state()) -> - {ok | error, protocol_message(), state()} | {multiple, [{ok | error, protocol_message()}], state()}. +-callback handle_DATA(From :: binary(), To :: [binary(), ...], Data :: binary(), State :: 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()}. -callback handle_other(Verb :: binary(), Args :: binary(), state()) -> - {string() | noreply, state()}. + {string() | noreply, state()}. -callback handle_info(Info :: term(), State :: state()) -> - {noreply, NewState :: state()} | - {noreply, NewState :: state(), timeout() | hibernate} | - {stop, Reason :: term(), NewState :: term()}. --callback handle_error(error_class(), any(), state()) -> {ok, state()} | {stop, Reason :: any(), state()}. + {noreply, NewState :: state()} + | {noreply, NewState :: state(), timeout() | hibernate} + | {stop, Reason :: term(), NewState :: term()}. +-callback handle_error(error_class(), any(), state()) -> + {ok, state()} | {stop, Reason :: any(), state()}. -callback terminate(Reason :: any(), state()) -> {ok, Reason :: any(), state()}. -optional_callbacks([handle_info/2, handle_AUTH/4, handle_error/3]). %% @doc Start a SMTP session linked to the calling process. %% @see start/3 --spec start_link(Ref :: ranch:ref(), Transport :: module(), - {Callback :: module(), Options :: options()}) -> - {'ok', pid()}. +-spec start_link( + Ref :: ranch:ref(), + Transport :: module(), + {Callback :: module(), Options :: options()} +) -> + {'ok', pid()}. start_link(Ref, Transport, Options) -> - {ok, proc_lib:spawn_link(?MODULE, ranch_init, [{Ref, Transport, Options}])}. + {ok, proc_lib:spawn_link(?MODULE, ranch_init, [{Ref, Transport, Options}])}. start_link(Ref, _Sock, Transport, Options) -> - start_link(Ref, Transport, Options). + start_link(Ref, Transport, Options). ranch_init({Ref, Transport, {Callback, Opts}}) -> - {ok, Socket} = ranch:handshake(Ref), - case init([Ref, Transport, Socket, Callback, Opts]) of - {ok, State, Timeout} -> - gen_server:enter_loop(?MODULE, [], State, Timeout); - {stop, Reason} -> - exit(Reason); - ignore -> - ok - end. + {ok, Socket} = ranch:handshake(Ref), + case init([Ref, Transport, Socket, Callback, Opts]) of + {ok, State, Timeout} -> + gen_server:enter_loop(?MODULE, [], State, Timeout); + {stop, Reason} -> + exit(Reason); + ignore -> + ok + end. %% @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 - end, - case PeerName =/= error - andalso Module:init(hostname(Options), - proplists:get_value(sessioncount, Options, 0), %FIXME - PeerName, - proplists:get_value(callbackoptions, Options, [])) - of - false -> - Transport:close(Socket), - ignore; - {ok, Banner, CallbackState} -> - Transport:send(Socket, ["220 ", Banner, "\r\n"]), - ok = Transport:setopts(Socket, [{active, once}, - {packet, line}, - binary]), - {ok, #state{socket = Socket, - transport = Transport, - module = Module, - ranch_ref = Ref, - protocol = Protocol, - options = Options, - callbackstate = CallbackState}, ?TIMEOUT}; - {stop, Reason, Message} -> - Transport:send(Socket, [Message, "\r\n"]), - Transport:close(Socket), - {stop, Reason}; - ignore -> - Transport:close(Socket), - ignore - end. + PeerName = + case Transport:peername(Socket) of + {ok, {IPaddr, _Port}} -> IPaddr; + {error, _} -> error + end, + case + PeerName =/= error andalso + Module:init( + hostname(Options), + %FIXME + proplists:get_value(sessioncount, Options, 0), + PeerName, + proplists:get_value(callbackoptions, Options, []) + ) + of + false -> + Transport:close(Socket), + ignore; + {ok, Banner, CallbackState} -> + Transport:send(Socket, ["220 ", Banner, "\r\n"]), + ok = Transport:setopts(Socket, [ + {active, once}, + {packet, line}, + binary + ]), + {ok, + #state{ + socket = Socket, + transport = Transport, + module = Module, + ranch_ref = Ref, + protocol = Protocol, + options = Options, + callbackstate = CallbackState + }, + ?TIMEOUT}; + {stop, Reason, Message} -> + Transport:send(Socket, [Message, "\r\n"]), + Transport:close(Socket), + {stop, Reason}; + ignore -> + Transport:close(Socket), + ignore + end. %% @hidden handle_call(stop, _From, State) -> - {stop, normal, ok, State}; - + {stop, normal, ok, State}; handle_call(Request, _From, State) -> - {reply, {unknown_call, Request}, State}. + {reply, {unknown_call, Request}, State}. %% @hidden handle_cast(_Msg, State) -> - {noreply, State}. + {noreply, State}. %% @hidden --spec handle_info(Message :: any(), State :: #state{}) -> {'noreply', #state{}} | {'stop', any(), #state{}}. +-spec handle_info(Message :: any(), State :: #state{}) -> + {'noreply', #state{}} | {'stop', any(), #state{}}. handle_info({receive_data, {error, size_exceeded}}, #state{readmessage = true} = State) -> - send(State, "552 Message too large\r\n"), - setopts(State, [{active, once}]), - State1 = handle_error(data_rejected, size_exceeded, State), - {noreply, State1#state{readmessage = false, envelope = #envelope{}}, ?TIMEOUT}; + send(State, "552 Message too large\r\n"), + setopts(State, [{active, once}]), + State1 = handle_error(data_rejected, size_exceeded, State), + {noreply, State1#state{readmessage = false, envelope = #envelope{}}, ?TIMEOUT}; handle_info({receive_data, {error, bare_newline}}, #state{readmessage = true} = State) -> - send(State, "451 Bare newline detected\r\n"), - setopts(State, [{active, once}]), - State1 = handle_error(data_rejected, bare_neline, State), - {noreply, State1#state{readmessage = false, envelope = #envelope{}}, ?TIMEOUT}; + send(State, "451 Bare newline detected\r\n"), + setopts(State, [{active, once}]), + State1 = handle_error(data_rejected, bare_neline, State), + {noreply, State1#state{readmessage = false, envelope = #envelope{}}, ?TIMEOUT}; handle_info({receive_data, {error, Other}}, #state{readmessage = true} = State) -> - State1 = handle_error(data_receive_error, Other, State), - {stop, {error_receiving_data, Other}, State1}; -handle_info({receive_data, Body, Rest}, - #state{socket = Socket, transport = Transport, readmessage = true, envelope = Env, module=Module, - callbackstate = OldCallbackState, maxsize=MaxSize} = State) -> - % send the remainder of the data... - case Rest of - <<>> -> ok; % no remaining data - _ -> self() ! {Transport:name(), Socket, Rest} - end, - setopts(State, [{packet, line}]), - %% Unescape periods at start of line (rfc5321 4.5.2) - Data = re:replace(Body, <<"^\\\.">>, <<>>, [global, multiline, {return, binary}]), - #envelope{from = From, to = To} = Env, - case MaxSize =:= infinity orelse byte_size(Data) =< MaxSize of - true -> - {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}]), - % might not even be able to get here anymore... - {noreply, State#state{readmessage = false, envelope = #envelope{}}, ?TIMEOUT} - end; -handle_info({SocketType, Socket, Packet}, #state{socket = Socket, transport = Transport, waitingauth = false} = State) - when SocketType =:= tcp; SocketType =:= ssl -> - case handle_request(parse_request(Packet), State) of - {ok, #state{options = Options, readmessage = true, maxsize = MaxSize} = NewState} -> - Session = self(), - Size = 0, - setopts(NewState, [{packet, raw}]), - %% TODO: change to receive asynchronously in the same process - spawn_opt(fun() -> - receive_data([], Transport, Socket, 0, Size, MaxSize, Session, Options) - end, - [link, {fullsweep_after, 0}]), - {noreply, NewState, ?TIMEOUT}; - {ok, NewState} -> - setopts(NewState, [{active, once}]), - {noreply, NewState, ?TIMEOUT}; - {stop, Reason, NewState} -> - {stop, Reason, NewState} - end; -handle_info({SocketType, Socket, Packet}, #state{socket = Socket} = State) - when SocketType =:= tcp; SocketType =:= ssl -> - %% We are in SASL state RFC-4954 - Request = binstr:strip(binstr:strip(binstr:strip(binstr:strip(Packet, right, $\n), right, $\r), right, $\s), left, $\s), - ?log(debug, "Got SASL request ~p", [Request]), - {ok, NewState} = handle_sasl(base64:decode(Request), State), - setopts(NewState, [{active, once}]), - {noreply, NewState, ?TIMEOUT}; -handle_info({Kind, _Socket}, State) when Kind == tcp_closed; - Kind == ssl_closed -> - State1 = handle_error(Kind, [], State), - {stop, normal, State1}; -handle_info({Kind, _Socket, Reason}, State) when Kind == ssl_error; - Kind == tcp_error -> - State1 = handle_error(Kind, Reason, State), - {stop, normal, State1}; + State1 = handle_error(data_receive_error, Other, State), + {stop, {error_receiving_data, Other}, State1}; +handle_info( + {receive_data, Body, Rest}, + #state{ + socket = Socket, + transport = Transport, + readmessage = true, + envelope = Env, + module = Module, + callbackstate = OldCallbackState, + maxsize = MaxSize + } = State +) -> + % send the remainder of the data... + case Rest of + % no remaining data + <<>> -> ok; + _ -> self() ! {Transport:name(), Socket, Rest} + end, + setopts(State, [{packet, line}]), + %% Unescape periods at start of line (rfc5321 4.5.2) + Data = re:replace(Body, <<"^\\\.">>, <<>>, [global, multiline, {return, binary}]), + #envelope{from = From, to = To} = Env, + case MaxSize =:= infinity orelse byte_size(Data) =< MaxSize of + true -> + {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}]), + % might not even be able to get here anymore... + {noreply, State#state{readmessage = false, envelope = #envelope{}}, ?TIMEOUT} + end; +handle_info( + {SocketType, Socket, Packet}, + #state{socket = Socket, transport = Transport, waitingauth = false} = State +) when + SocketType =:= tcp; SocketType =:= ssl +-> + case handle_request(parse_request(Packet), State) of + {ok, #state{options = Options, readmessage = true, maxsize = MaxSize} = NewState} -> + Session = self(), + Size = 0, + setopts(NewState, [{packet, raw}]), + %% TODO: change to receive asynchronously in the same process + spawn_opt( + fun() -> + receive_data([], Transport, Socket, 0, Size, MaxSize, Session, Options) + end, + [link, {fullsweep_after, 0}] + ), + {noreply, NewState, ?TIMEOUT}; + {ok, NewState} -> + setopts(NewState, [{active, once}]), + {noreply, NewState, ?TIMEOUT}; + {stop, Reason, NewState} -> + {stop, Reason, NewState} + end; +handle_info({SocketType, Socket, Packet}, #state{socket = Socket} = State) when + SocketType =:= tcp; SocketType =:= ssl +-> + %% We are in SASL state RFC-4954 + Request = binstr:strip( + binstr:strip(binstr:strip(binstr:strip(Packet, right, $\n), right, $\r), right, $\s), + left, + $\s + ), + ?log(debug, "Got SASL request ~p", [Request]), + {ok, NewState} = handle_sasl(base64:decode(Request), State), + setopts(NewState, [{active, once}]), + {noreply, NewState, ?TIMEOUT}; +handle_info({Kind, _Socket}, State) when + Kind == tcp_closed; + Kind == ssl_closed +-> + State1 = handle_error(Kind, [], State), + {stop, normal, State1}; +handle_info({Kind, _Socket, Reason}, State) when + Kind == ssl_error; + Kind == tcp_error +-> + State1 = handle_error(Kind, Reason, State), + {stop, normal, State1}; handle_info(timeout, #state{socket = Socket, transport = Transport} = State) -> - send(State, "421 Error: timeout exceeded\r\n"), - Transport:close(Socket), - State1 = handle_error(timeout, [], State), - {stop, normal, State1}; -handle_info(Info, #state{module=Module, callbackstate = OldCallbackState} = State) -> - case erlang:function_exported(Module, handle_info, 2) of - true -> - case Module:handle_info(Info, OldCallbackState) of - {noreply, NewCallbackState} -> - {noreply, State#state{callbackstate = NewCallbackState}}; - {noreply, NewCallbackState, Action} -> - {noreply, State#state{callbackstate = NewCallbackState}, Action}; - {stop, Reason, NewCallbackState} -> - {stop, Reason, State#state{callbackstate = NewCallbackState}} - end; - false -> - ?log(debug, "Ignored message ~p", [Info]), - {noreply, State, ?TIMEOUT} - end. + send(State, "421 Error: timeout exceeded\r\n"), + Transport:close(Socket), + State1 = handle_error(timeout, [], State), + {stop, normal, State1}; +handle_info(Info, #state{module = Module, callbackstate = OldCallbackState} = State) -> + case erlang:function_exported(Module, handle_info, 2) of + true -> + case Module:handle_info(Info, OldCallbackState) of + {noreply, NewCallbackState} -> + {noreply, State#state{callbackstate = NewCallbackState}}; + {noreply, NewCallbackState, Action} -> + {noreply, State#state{callbackstate = NewCallbackState}, Action}; + {stop, Reason, NewCallbackState} -> + {stop, Reason, State#state{callbackstate = NewCallbackState}} + end; + false -> + ?log(debug, "Ignored message ~p", [Info]), + {noreply, State, ?TIMEOUT} + end. %% @hidden -spec terminate(Reason :: any(), State :: #state{}) -> 'ok'. -terminate(Reason, #state{socket = Socket, transport = Transport, module = Module, - callbackstate = CallbackState}) -> - ok = Transport:close(Socket), - Module:terminate(Reason, CallbackState). +terminate(Reason, #state{ + socket = Socket, + transport = Transport, + module = Module, + callbackstate = CallbackState +}) -> + ok = Transport:close(Socket), + Module:terminate(Reason, CallbackState). %% @hidden --spec code_change(OldVsn :: any(), State :: #state{}, Extra :: any()) -> {'ok', #state{}}. +-spec code_change(OldVsn :: any(), State :: #state{}, Extra :: any()) -> {'ok', #state{}}. code_change(OldVsn, #state{module = Module, callbackstate = CallbackState} = State, Extra) -> - % TODO - this should probably be the callback module's version or its checksum - CallbackState = - case catch Module:code_change(OldVsn, CallbackState, Extra) of - {ok, NewCallbackState} -> NewCallbackState; - _ -> CallbackState - end, - {ok, State#state{callbackstate = CallbackState}}. + % TODO - this should probably be the callback module's version or its checksum + CallbackState = + case catch Module:code_change(OldVsn, CallbackState, Extra) of + {ok, NewCallbackState} -> NewCallbackState; + _ -> CallbackState + end, + {ok, State#state{callbackstate = CallbackState}}. -spec parse_request(Packet :: binary()) -> {binary(), binary()}. parse_request(Packet) -> - Request = binstr:strip(binstr:strip(binstr:strip(binstr:strip(Packet, right, $\n), right, $\r), right, $\s), left, $\s), - case binstr:strchr(Request, $\s) of - 0 -> - ?log(debug, "got a ~s request~n", [Request]), - {binstr:to_upper(Request), <<>>}; - Index -> - Verb = binstr:substr(Request, 1, Index - 1), - Parameters = binstr:strip(binstr:substr(Request, Index + 1), left, $\s), - ?log(debug, "got a ~s request with parameters ~s~n", [Verb, Parameters]), - {binstr:to_upper(Verb), Parameters} - end. - --spec handle_request({Verb :: binary(), Args :: binary()}, State :: #state{}) -> {'ok', #state{}} | {'stop', any(), #state{}}. + Request = binstr:strip( + binstr:strip(binstr:strip(binstr:strip(Packet, right, $\n), right, $\r), right, $\s), + left, + $\s + ), + case binstr:strchr(Request, $\s) of + 0 -> + ?log(debug, "got a ~s request~n", [Request]), + {binstr:to_upper(Request), <<>>}; + Index -> + Verb = binstr:substr(Request, 1, Index - 1), + Parameters = binstr:strip(binstr:substr(Request, Index + 1), left, $\s), + ?log(debug, "got a ~s request with parameters ~s~n", [Verb, Parameters]), + {binstr:to_upper(Verb), Parameters} + end. + +-spec handle_request({Verb :: binary(), Args :: binary()}, State :: #state{}) -> + {'ok', #state{}} | {'stop', any(), #state{}}. handle_request({<<>>, _Any}, State) -> - send(State, "500 Error: bad syntax\r\n"), - {ok, State}; -handle_request({Command, <<>>}, State) - when Command == <<"HELO">>; Command == <<"EHLO">>; Command == <<"LHLO">> -> - send(State, ["501 Syntax: ", Command, " hostname\r\n"]), - {ok, State}; + send(State, "500 Error: bad syntax\r\n"), + {ok, State}; +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) -> - case Module:handle_HELO(Hostname, OldCallbackState) of - {ok, MaxSize, CallbackState} when MaxSize =:= infinity; is_integer(MaxSize) -> - Data = ["250 ", hostname(Options), "\r\n"], - send(State, Data), - {ok, State#state{maxsize = MaxSize, - envelope = #envelope{}, - callbackstate = CallbackState}}; - {ok, CallbackState} -> - Data = ["250 ", hostname(Options), "\r\n"], - send(State, Data), - {ok, State#state{envelope = #envelope{}, callbackstate = CallbackState}}; - {error, Message, CallbackState} -> - send(State, [Message, "\r\n"]), - {ok, State#state{callbackstate = CallbackState}} - end; -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"], - send(State, Data), - {ok, State#state{extensions = [], callbackstate = CallbackState}}; - {ok, Extensions, CallbackState} -> - ExtensionsUpper = lists:map( fun({X, Y}) -> {string:to_upper(X), Y} end, Extensions), - {Extensions1, MaxSize} = case lists:keyfind("SIZE", 1, ExtensionsUpper) of - {"SIZE", "0"} -> - {lists:keydelete("SIZE", 1, ExtensionsUpper), infinity}; - {"SIZE", MaxSizeString} when is_list(MaxSizeString) -> - {ExtensionsUpper, list_to_integer(MaxSizeString)}; - false -> - {ExtensionsUpper, State#state.maxsize} - end, - Extensions2 = case Tls of - true -> - lists:delete({"STARTTLS", true}, Extensions1); - false -> - Extensions1 - end, - Response = (fun - F([{E, true}]) -> ["250 ", E, "\r\n"]; - F([{E, V}]) -> ["250 ", E, " ", V, "\r\n"]; - F([Line]) -> ["250 ", Line, "\r\n"]; - F([{E, true}|More]) -> ["250-", E, "\r\n" | F(More)]; - F([{E, V}|More]) -> ["250-", E, " ", V, "\r\n" | F(More)]; - F([Line|More]) -> ["250-", Line, "\r\n" | F(More)] - end)([hostname(Options)|Extensions2]), - %?debugFmt("Respponse ~p~n", [lists:reverse(Response)]), - send(State, Response), - {ok, State#state{extensions = Extensions2, maxsize = MaxSize, envelope = #envelope{}, callbackstate = CallbackState}}; - {error, Message, CallbackState} -> - send(State, [Message, "\r\n"]), - {ok, State#state{callbackstate = CallbackState}} - end; - + 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 +) -> + case Module:handle_HELO(Hostname, OldCallbackState) of + {ok, MaxSize, CallbackState} when MaxSize =:= infinity; is_integer(MaxSize) -> + Data = ["250 ", hostname(Options), "\r\n"], + send(State, Data), + {ok, State#state{ + maxsize = MaxSize, + envelope = #envelope{}, + callbackstate = CallbackState + }}; + {ok, CallbackState} -> + Data = ["250 ", hostname(Options), "\r\n"], + send(State, Data), + {ok, State#state{envelope = #envelope{}, callbackstate = CallbackState}}; + {error, Message, CallbackState} -> + send(State, [Message, "\r\n"]), + {ok, State#state{callbackstate = CallbackState}} + end; +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"], + send(State, Data), + {ok, State#state{extensions = [], callbackstate = CallbackState}}; + {ok, Extensions, CallbackState} -> + ExtensionsUpper = lists:map(fun({X, Y}) -> {string:to_upper(X), Y} end, Extensions), + {Extensions1, MaxSize} = + case lists:keyfind("SIZE", 1, ExtensionsUpper) of + {"SIZE", "0"} -> + {lists:keydelete("SIZE", 1, ExtensionsUpper), infinity}; + {"SIZE", MaxSizeString} when is_list(MaxSizeString) -> + {ExtensionsUpper, list_to_integer(MaxSizeString)}; + false -> + {ExtensionsUpper, State#state.maxsize} + end, + Extensions2 = + case Tls of + true -> + lists:delete({"STARTTLS", true}, Extensions1); + false -> + Extensions1 + end, + Response = (fun + F([{E, true}]) -> ["250 ", E, "\r\n"]; + F([{E, V}]) -> ["250 ", E, " ", V, "\r\n"]; + F([Line]) -> ["250 ", Line, "\r\n"]; + F([{E, true} | More]) -> ["250-", E, "\r\n" | F(More)]; + F([{E, V} | More]) -> ["250-", E, " ", V, "\r\n" | F(More)]; + F([Line | More]) -> ["250-", Line, "\r\n" | F(More)] + end)( + [hostname(Options) | Extensions2] + ), + %?debugFmt("Respponse ~p~n", [lists:reverse(Response)]), + send(State, Response), + {ok, State#state{ + extensions = Extensions2, + maxsize = MaxSize, + envelope = #envelope{}, + callbackstate = CallbackState + }}; + {error, Message, CallbackState} -> + send(State, [Message, "\r\n"]), + {ok, State#state{callbackstate = CallbackState}} + end; 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) -> - case binstr:strchr(Args, $\s) of - 0 -> - AuthType = Args, - Parameters = false; - Index -> - AuthType = binstr:substr(Args, 1, Index - 1), - Parameters = binstr:strip(binstr:substr(Args, Index + 1), left, $\s) - end, - - case has_extension(Extensions, "AUTH") of - false -> - send(State, "502 Error: AUTH not implemented\r\n"), - {ok, State}; - {true, AvailableTypes} -> - case lists:member(string:to_upper(binary_to_list(AuthType)), - string:tokens(AvailableTypes, " ")) of - false -> - send(State, "504 Unrecognized authentication type\r\n"), - {ok, State}; - true -> - case binstr:to_upper(AuthType) of - <<"LOGIN">> -> - % smtp_socket:send(Socket, "334 " ++ base64:encode_to_string("Username:")), - send(State, "334 VXNlcm5hbWU6\r\n"), - {ok, State#state{waitingauth = 'login', envelope = Envelope#envelope{auth = {<<>>, <<>>}}}}; - <<"PLAIN">> when Parameters =/= false -> - % TODO - duplicated below in handle_request waitingauth PLAIN - case binstr:split(base64:decode(Parameters), <<0>>) of - [_Identity, Username, Password] -> - try_auth('plain', Username, Password, State); - [Username, Password] -> - try_auth('plain', Username, Password, State); - _ -> - % TODO error - {ok, State} - end; - <<"PLAIN">> -> - send(State, "334\r\n"), - {ok, State#state{waitingauth = 'plain', envelope = Envelope#envelope{auth = {<<>>, <<>>}}}}; - <<"CRAM-MD5">> -> - crypto:start(), % ensure crypto is started, we're gonna need it - String = smtp_util:get_cram_string(hostname(Options)), - send(State, ["334 ", String, "\r\n"]), - {ok, State#state{waitingauth = 'cram-md5', authdata=base64:decode(String), envelope = Envelope#envelope{auth = {<<>>, <<>>}}}} - %"DIGEST-MD5" -> % TODO finish this? (see rfc 2831) - %crypto:start(), % ensure crypto is started, we're gonna need it - %Nonce = get_digest_nonce(), - %Response = io_lib:format("nonce=\"~s\",realm=\"~s\",qop=\"auth\",algorithm=md5-sess,charset=utf-8", Nonce, State#state.hostname), - %smtp_socket:send(Socket, "334 "++Response++"\r\n"), - %{ok, State#state{waitingauth = "DIGEST-MD5", authdata=base64:decode_to_string(Nonce), envelope = Envelope#envelope{auth = {[], []}}}} - end - end - end; - + 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 +) -> + case binstr:strchr(Args, $\s) of + 0 -> + AuthType = Args, + Parameters = false; + Index -> + AuthType = binstr:substr(Args, 1, Index - 1), + Parameters = binstr:strip(binstr:substr(Args, Index + 1), left, $\s) + end, + + case has_extension(Extensions, "AUTH") of + false -> + send(State, "502 Error: AUTH not implemented\r\n"), + {ok, State}; + {true, AvailableTypes} -> + case + lists:member( + string:to_upper(binary_to_list(AuthType)), + string:tokens(AvailableTypes, " ") + ) + of + false -> + send(State, "504 Unrecognized authentication type\r\n"), + {ok, State}; + true -> + case binstr:to_upper(AuthType) of + <<"LOGIN">> -> + % smtp_socket:send(Socket, "334 " ++ base64:encode_to_string("Username:")), + send(State, "334 VXNlcm5hbWU6\r\n"), + {ok, State#state{ + waitingauth = 'login', + envelope = Envelope#envelope{auth = {<<>>, <<>>}} + }}; + <<"PLAIN">> when Parameters =/= false -> + % TODO - duplicated below in handle_request waitingauth PLAIN + case binstr:split(base64:decode(Parameters), <<0>>) of + [_Identity, Username, Password] -> + try_auth('plain', Username, Password, State); + [Username, Password] -> + try_auth('plain', Username, Password, State); + _ -> + % TODO error + {ok, State} + end; + <<"PLAIN">> -> + send(State, "334\r\n"), + {ok, State#state{ + waitingauth = 'plain', + envelope = Envelope#envelope{auth = {<<>>, <<>>}} + }}; + <<"CRAM-MD5">> -> + % ensure crypto is started, we're gonna need it + crypto:start(), + String = smtp_util:get_cram_string(hostname(Options)), + send(State, ["334 ", String, "\r\n"]), + {ok, State#state{ + waitingauth = 'cram-md5', + authdata = base64:decode(String), + envelope = Envelope#envelope{auth = {<<>>, <<>>}} + }} + %"DIGEST-MD5" -> % TODO finish this? (see rfc 2831) + %crypto:start(), % ensure crypto is started, we're gonna need it + %Nonce = get_digest_nonce(), + %Response = io_lib:format("nonce=\"~s\",realm=\"~s\",qop=\"auth\",algorithm=md5-sess,charset=utf-8", Nonce, State#state.hostname), + %smtp_socket:send(Socket, "334 "++Response++"\r\n"), + %{ok, State#state{waitingauth = "DIGEST-MD5", authdata=base64:decode_to_string(Nonce), envelope = Envelope#envelope{auth = {[], []}}}} + end + end + end; 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}, - #state{ - module = Module, envelope = Envelope0, - callbackstate = OldCallbackState, extensions = Extensions, - maxsize=MaxSize} = State) -> - case Envelope0#envelope.from of - undefined -> - case binstr:strpos(binstr:to_upper(Args), <<"FROM:">>) of - 1 -> - Address = binstr:strip(binstr:substr(Args, 6), left, $\s), - case parse_encoded_address(Address, has_extension(Extensions, "SMTPUTF8") =/= false) of - error -> - send(State, "501 Bad sender address syntax\r\n"), - {ok, State}; - {ParsedAddress, <<>>} -> - ?log(debug, "From address ~s (parsed as ~s)~n", [Address, ParsedAddress]), - case Module:handle_MAIL(ParsedAddress, OldCallbackState) of - {ok, CallbackState} -> - send(State, "250 sender Ok\r\n"), - {ok, State#state{envelope = Envelope0#envelope{from = ParsedAddress}, callbackstate = CallbackState}}; - {error, Message, CallbackState} -> - send(State, [Message, "\r\n"]), - {ok, State#state{callbackstate = CallbackState}} - end; - {ParsedAddress, ExtraInfo} -> - ?log(debug, "From address ~s (parsed as ~s) with extra info ~s~n", [Address, ParsedAddress, ExtraInfo]), - Options = [binstr:to_upper(X) || X <- binstr:split(ExtraInfo, <<" ">>)], - ?log(debug, "options are ~p~n", [Options]), - F = fun(_, {error, Message}) -> - {error, Message}; - (<<"SIZE=", Size/binary>>, #state{envelope = Envelope} = InnerState) when MaxSize =:= 'infinity' -> - InnerState#state{envelope = Envelope#envelope{expectedsize = binary_to_integer(Size)}}; - (<<"SIZE=", Size/binary>>, #state{envelope = Envelope} = InnerState) -> - case binary_to_integer(Size) > MaxSize of - true -> - {error, ["552 Estimated message length ", Size, " exceeds limit of ", integer_to_binary(MaxSize), "\r\n"]}; - false -> - InnerState#state{envelope = Envelope#envelope{expectedsize = binary_to_integer(Size)}} - end; - (<<"BODY=", BodyType/binary>>, #state{envelope = #envelope{flags = Flags} = Envelope} = InnerState) -> - case has_extension(Extensions, "8BITMIME") of - {true, _} -> - Flag = maps:get(BodyType, #{<<"8BITMIME">> => '8bitmime', - <<"7BIT">> => '7bit'}), - InnerState#state{envelope = Envelope#envelope{flags = [Flag | Flags]}}; - false -> - {error, "555 Unsupported option BODY\r\n"} - end; - (<<"SMTPUTF8">>, #state{envelope = #envelope{flags = Flags} = Envelope} = InnerState) -> - case has_extension(Extensions, "SMTPUTF8") of - {true, _} -> - InnerState#state{envelope = Envelope#envelope{flags = ['smtputf8' | Flags]}}; - false -> - {error, "555 Unsupported option SMTPUTF8\r\n"} - end; - (X, InnerState) -> - case Module:handle_MAIL_extension(X, OldCallbackState) of - {ok, CallbackState} -> - InnerState#state{callbackstate = CallbackState}; - error -> - {error, ["555 Unsupported option: ", ExtraInfo, "\r\n"]} - end - end, - case lists:foldl(F, State, Options) of - {error, Message} -> - ?log(debug, "error: ~s~n", [Message]), - send(State, Message), - {ok, State}; - #state{envelope = Envelope} = NewState -> - ?log(debug, "OK~n"), - case Module:handle_MAIL(ParsedAddress, State#state.callbackstate) of - {ok, CallbackState} -> - send(State, "250 sender Ok\r\n"), - {ok, State#state{envelope = Envelope#envelope{from = ParsedAddress}, callbackstate = CallbackState}}; - {error, Message, CallbackState} -> - send(State, [Message, "\r\n"]), - {ok, NewState#state{callbackstate = CallbackState}} - end - end - end; - _Else -> - send(State, "501 Syntax: MAIL FROM:
\r\n"), - {ok, State} - end; - _Other -> - send(State, "503 Error: Nested MAIL command\r\n"), - {ok, State} - end; + 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}, + #state{ + module = Module, + envelope = Envelope0, + callbackstate = OldCallbackState, + extensions = Extensions, + maxsize = MaxSize + } = State +) -> + case Envelope0#envelope.from of + undefined -> + case binstr:strpos(binstr:to_upper(Args), <<"FROM:">>) of + 1 -> + Address = binstr:strip(binstr:substr(Args, 6), left, $\s), + case + parse_encoded_address( + Address, has_extension(Extensions, "SMTPUTF8") =/= false + ) + of + error -> + send(State, "501 Bad sender address syntax\r\n"), + {ok, State}; + {ParsedAddress, <<>>} -> + ?log(debug, "From address ~s (parsed as ~s)~n", [Address, ParsedAddress]), + case Module:handle_MAIL(ParsedAddress, OldCallbackState) of + {ok, CallbackState} -> + send(State, "250 sender Ok\r\n"), + {ok, State#state{ + envelope = Envelope0#envelope{from = ParsedAddress}, + callbackstate = CallbackState + }}; + {error, Message, CallbackState} -> + send(State, [Message, "\r\n"]), + {ok, State#state{callbackstate = CallbackState}} + end; + {ParsedAddress, ExtraInfo} -> + ?log(debug, "From address ~s (parsed as ~s) with extra info ~s~n", [ + Address, ParsedAddress, ExtraInfo + ]), + Options = [binstr:to_upper(X) || X <- binstr:split(ExtraInfo, <<" ">>)], + ?log(debug, "options are ~p~n", [Options]), + F = fun + (_, {error, Message}) -> + {error, Message}; + ( + <<"SIZE=", Size/binary>>, + #state{envelope = Envelope} = InnerState + ) when MaxSize =:= 'infinity' -> + InnerState#state{ + envelope = Envelope#envelope{ + expectedsize = binary_to_integer(Size) + } + }; + ( + <<"SIZE=", Size/binary>>, + #state{envelope = Envelope} = InnerState + ) -> + case binary_to_integer(Size) > MaxSize of + true -> + {error, [ + "552 Estimated message length ", + Size, + " exceeds limit of ", + integer_to_binary(MaxSize), + "\r\n" + ]}; + false -> + InnerState#state{ + envelope = Envelope#envelope{ + expectedsize = binary_to_integer(Size) + } + } + end; + ( + <<"BODY=", BodyType/binary>>, + #state{envelope = #envelope{flags = Flags} = Envelope} = + InnerState + ) -> + case has_extension(Extensions, "8BITMIME") of + {true, _} -> + Flag = maps:get(BodyType, #{ + <<"8BITMIME">> => '8bitmime', + <<"7BIT">> => '7bit' + }), + InnerState#state{ + envelope = Envelope#envelope{flags = [Flag | Flags]} + }; + false -> + {error, "555 Unsupported option BODY\r\n"} + end; + ( + <<"SMTPUTF8">>, + #state{envelope = #envelope{flags = Flags} = Envelope} = + InnerState + ) -> + case has_extension(Extensions, "SMTPUTF8") of + {true, _} -> + InnerState#state{ + envelope = Envelope#envelope{ + flags = ['smtputf8' | Flags] + } + }; + false -> + {error, "555 Unsupported option SMTPUTF8\r\n"} + end; + (X, InnerState) -> + case Module:handle_MAIL_extension(X, OldCallbackState) of + {ok, CallbackState} -> + InnerState#state{callbackstate = CallbackState}; + error -> + {error, ["555 Unsupported option: ", ExtraInfo, "\r\n"]} + end + end, + case lists:foldl(F, State, Options) of + {error, Message} -> + ?log(debug, "error: ~s~n", [Message]), + send(State, Message), + {ok, State}; + #state{envelope = Envelope} = NewState -> + ?log(debug, "OK~n"), + case Module:handle_MAIL(ParsedAddress, State#state.callbackstate) of + {ok, CallbackState} -> + send(State, "250 sender Ok\r\n"), + {ok, State#state{ + envelope = Envelope#envelope{from = ParsedAddress}, + callbackstate = CallbackState + }}; + {error, Message, CallbackState} -> + send(State, [Message, "\r\n"]), + {ok, NewState#state{callbackstate = CallbackState}} + end + end + end; + _Else -> + send(State, "501 Syntax: MAIL FROM:
\r\n"), + {ok, State} + end; + _Other -> + send(State, "503 Error: Nested MAIL command\r\n"), + {ok, State} + end; handle_request({<<"RCPT">> = C, _Args}, #state{envelope = undefined} = State) -> - send(State, "503 Error: need MAIL command\r\n"), - State1 = handle_error(out_of_order, C, State), - {ok, State1}; -handle_request({<<"RCPT">>, Args}, #state{envelope = Envelope, module = Module, callbackstate = OldCallbackState, extensions = Extensions} = State) -> - case binstr:strpos(binstr:to_upper(Args), <<"TO:">>) of - 1 -> - Address = binstr:strip(binstr:substr(Args, 4), left, $\s), - case parse_encoded_address(Address, has_extension(Extensions, "SMTPUTF8") =/= false) of - error -> - send(State, "501 Bad recipient address syntax\r\n"), - {ok, State}; - {<<>>, _} -> - % empty rcpt to addresses aren't cool - send(State, "501 Bad recipient address syntax\r\n"), - {ok, State}; - {ParsedAddress, <<>>} -> - ?log(debug, "To address ~s (parsed as ~s)~n", [Address, ParsedAddress]), - case Module:handle_RCPT(ParsedAddress, OldCallbackState) of - {ok, CallbackState} -> - send(State, "250 recipient Ok\r\n"), - {ok, State#state{envelope = Envelope#envelope{to = Envelope#envelope.to ++ [ParsedAddress]}, callbackstate = CallbackState}}; - {error, Message, CallbackState} -> - send(State, [Message, "\r\n"]), - {ok, State#state{callbackstate = CallbackState}} - end; - {ParsedAddress, ExtraInfo} -> - % TODO - are there even any RCPT extensions? - ?log(debug, "To address ~s (parsed as ~s) with extra info ~s~n", [Address, ParsedAddress, ExtraInfo]), - send(State, ["555 Unsupported option: ", ExtraInfo, "\r\n"]), - {ok, State} - end; - _Else -> - send(State, "501 Syntax: RCPT TO:
\r\n"), - {ok, State} - end; + send(State, "503 Error: need MAIL command\r\n"), + State1 = handle_error(out_of_order, C, State), + {ok, State1}; +handle_request( + {<<"RCPT">>, Args}, + #state{ + envelope = Envelope, + module = Module, + callbackstate = OldCallbackState, + extensions = Extensions + } = State +) -> + case binstr:strpos(binstr:to_upper(Args), <<"TO:">>) of + 1 -> + Address = binstr:strip(binstr:substr(Args, 4), left, $\s), + case parse_encoded_address(Address, has_extension(Extensions, "SMTPUTF8") =/= false) of + error -> + send(State, "501 Bad recipient address syntax\r\n"), + {ok, State}; + {<<>>, _} -> + % empty rcpt to addresses aren't cool + send(State, "501 Bad recipient address syntax\r\n"), + {ok, State}; + {ParsedAddress, <<>>} -> + ?log(debug, "To address ~s (parsed as ~s)~n", [Address, ParsedAddress]), + case Module:handle_RCPT(ParsedAddress, OldCallbackState) of + {ok, CallbackState} -> + send(State, "250 recipient Ok\r\n"), + {ok, State#state{ + envelope = Envelope#envelope{ + to = Envelope#envelope.to ++ [ParsedAddress] + }, + callbackstate = CallbackState + }}; + {error, Message, CallbackState} -> + send(State, [Message, "\r\n"]), + {ok, State#state{callbackstate = CallbackState}} + end; + {ParsedAddress, ExtraInfo} -> + % TODO - are there even any RCPT extensions? + ?log(debug, "To address ~s (parsed as ~s) with extra info ~s~n", [ + Address, ParsedAddress, ExtraInfo + ]), + send(State, ["555 Unsupported option: ", ExtraInfo, "\r\n"]), + {ok, State} + end; + _Else -> + send(State, "501 Syntax: RCPT TO:
\r\n"), + {ok, State} + end; 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}; + 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) -> - case {Envelope#envelope.from, Envelope#envelope.to} of - {undefined, _} -> - send(State, "503 Error: need MAIL command\r\n"), - State1 = handle_error(out_of_order, C, State), - {ok, State1}; - {_, []} -> - send(State, "503 Error: need RCPT command\r\n"), - State1 = handle_error(out_of_order, C, State), - {ok, State1}; - _Else -> - send(State, "354 enter mail, end with line containing only '.'\r\n"), - ?log(debug, "switching to data read mode~n", []), - - {ok, State#state{readmessage = true}} - end; -handle_request({<<"RSET">>, _Any}, #state{envelope = Envelope, module = Module, callbackstate = OldCallbackState} = State) -> - send(State, "250 Ok\r\n"), - % if the client sends a RSET before a HELO/EHLO don't give them a valid envelope - NewEnvelope = case Envelope of - undefined -> undefined; - _Something -> #envelope{} - end, - {ok, State#state{envelope = NewEnvelope, callbackstate = Module:handle_RSET(OldCallbackState)}}; + case {Envelope#envelope.from, Envelope#envelope.to} of + {undefined, _} -> + send(State, "503 Error: need MAIL command\r\n"), + State1 = handle_error(out_of_order, C, State), + {ok, State1}; + {_, []} -> + send(State, "503 Error: need RCPT command\r\n"), + State1 = handle_error(out_of_order, C, State), + {ok, State1}; + _Else -> + send(State, "354 enter mail, end with line containing only '.'\r\n"), + ?log(debug, "switching to data read mode~n", []), + + {ok, State#state{readmessage = true}} + end; +handle_request( + {<<"RSET">>, _Any}, + #state{envelope = Envelope, module = Module, callbackstate = OldCallbackState} = State +) -> + send(State, "250 Ok\r\n"), + % if the client sends a RSET before a HELO/EHLO don't give them a valid envelope + NewEnvelope = + case Envelope of + undefined -> undefined; + _Something -> #envelope{} + end, + {ok, State#state{envelope = NewEnvelope, callbackstate = Module:handle_RSET(OldCallbackState)}}; handle_request({<<"NOOP">>, _Any}, State) -> - send(State, "250 Ok\r\n"), - {ok, State}; + send(State, "250 Ok\r\n"), + {ok, State}; handle_request({<<"QUIT">>, _Any}, State) -> - send(State, "221 Bye\r\n"), - {stop, normal, State}; -handle_request({<<"VRFY">>, Address}, #state{module= Module, callbackstate = OldCallbackState, extensions = Extensions} = State) -> - case parse_encoded_address(Address, has_extension(Extensions, "SMTPUTF8") =/= false) of - {ParsedAddress, <<>>} -> - case Module:handle_VRFY(ParsedAddress, OldCallbackState) of - {ok, Reply, CallbackState} -> - send(State, ["250 ", Reply, "\r\n"]), - {ok, State#state{callbackstate = CallbackState}}; - {error, Message, CallbackState} -> - send(State, [Message, "\r\n"]), - {ok, State#state{callbackstate = CallbackState}} - end; - _Other -> - send(State, "501 Syntax: VRFY username/address\r\n"), - {ok, State} - end; -handle_request({<<"STARTTLS">>, <<>>}, #state{socket = Socket, module = Module, tls=false, - extensions = Extensions, callbackstate = OldCallbackState, - options = Options} = State) -> - case has_extension(Extensions, "STARTTLS") of - {true, _} -> - send(State, "220 OK\r\n"), - TlsOpts0 = proplists:get_value(tls_options, Options, []), - TlsOpts1 = case proplists:get_value(certfile, Options) of - undefined -> - TlsOpts0; - CertFile -> - [{certfile, CertFile} | TlsOpts0] - end, - TlsOpts2 = case proplists:get_value(keyfile, Options) of - undefined -> - TlsOpts1; - KeyFile -> - [{keyfile, KeyFile} | TlsOpts1] - end, - %% Assert that socket is in passive state - {ok, [{active, false}]} = inet:getopts(Socket, [active]), - case ranch_ssl:handshake(Socket, [{packet, line}, {mode, list}, {ssl_imp, new} | TlsOpts2], 5000) of %XXX: see smtp_socket:?SSL_LISTEN_OPTIONS - {ok, NewSocket} -> - ?log(debug, "SSL negotiation successful~n"), - ranch_ssl:setopts(NewSocket, [{packet, line}, binary]), - {ok, State#state{socket = NewSocket, transport = ranch_ssl, envelope=undefined, - authdata=undefined, waitingauth=false, readmessage=false, - tls=true, callbackstate = Module:handle_STARTTLS(OldCallbackState)}}; - {error, Reason} -> - ?log(info, "SSL handshake failed : ~p~n", [Reason]), - send(State, "454 TLS negotiation failed\r\n"), - State1 = handle_error(ssl_handshake_error, Reason, State), - {ok, State1} - end; - false -> - send(State, "500 Command unrecognized\r\n"), - {ok, State} - end; + send(State, "221 Bye\r\n"), + {stop, normal, State}; +handle_request( + {<<"VRFY">>, Address}, + #state{module = Module, callbackstate = OldCallbackState, extensions = Extensions} = State +) -> + case parse_encoded_address(Address, has_extension(Extensions, "SMTPUTF8") =/= false) of + {ParsedAddress, <<>>} -> + case Module:handle_VRFY(ParsedAddress, OldCallbackState) of + {ok, Reply, CallbackState} -> + send(State, ["250 ", Reply, "\r\n"]), + {ok, State#state{callbackstate = CallbackState}}; + {error, Message, CallbackState} -> + send(State, [Message, "\r\n"]), + {ok, State#state{callbackstate = CallbackState}} + end; + _Other -> + send(State, "501 Syntax: VRFY username/address\r\n"), + {ok, State} + end; +handle_request( + {<<"STARTTLS">>, <<>>}, + #state{ + socket = Socket, + module = Module, + tls = false, + extensions = Extensions, + callbackstate = OldCallbackState, + options = Options + } = State +) -> + case has_extension(Extensions, "STARTTLS") of + {true, _} -> + send(State, "220 OK\r\n"), + TlsOpts0 = proplists:get_value(tls_options, Options, []), + TlsOpts1 = + case proplists:get_value(certfile, Options) of + undefined -> + TlsOpts0; + CertFile -> + [{certfile, CertFile} | TlsOpts0] + end, + TlsOpts2 = + case proplists:get_value(keyfile, Options) of + undefined -> + TlsOpts1; + KeyFile -> + [{keyfile, KeyFile} | TlsOpts1] + end, + %% Assert that socket is in passive state + {ok, [{active, false}]} = inet:getopts(Socket, [active]), + %XXX: see smtp_socket:?SSL_LISTEN_OPTIONS + case + ranch_ssl:handshake( + Socket, [{packet, line}, {mode, list}, {ssl_imp, new} | TlsOpts2], 5000 + ) + of + {ok, NewSocket} -> + ?log(debug, "SSL negotiation successful~n"), + ranch_ssl:setopts(NewSocket, [{packet, line}, binary]), + {ok, State#state{ + socket = NewSocket, + transport = ranch_ssl, + envelope = undefined, + authdata = undefined, + waitingauth = false, + readmessage = false, + tls = true, + callbackstate = Module:handle_STARTTLS(OldCallbackState) + }}; + {error, Reason} -> + ?log(info, "SSL handshake failed : ~p~n", [Reason]), + send(State, "454 TLS negotiation failed\r\n"), + State1 = handle_error(ssl_handshake_error, Reason, State), + {ok, State1} + end; + false -> + send(State, "500 Command unrecognized\r\n"), + {ok, State} + end; handle_request({<<"STARTTLS">> = C, <<>>}, State) -> - send(State, "500 TLS already negotiated\r\n"), - State1 = handle_error(out_of_order, C, State), - {ok, State1}; + send(State, "500 TLS already negotiated\r\n"), + State1 = handle_error(out_of_order, C, State), + {ok, State1}; handle_request({<<"STARTTLS">>, _Args}, State) -> - send(State, "501 Syntax error (no parameters allowed)\r\n"), - {ok, State}; + send(State, "501 Syntax error (no parameters allowed)\r\n"), + {ok, State}; handle_request({Verb, Args}, #state{module = Module, callbackstate = OldCallbackState} = State) -> CallbackState = case Module:handle_other(Verb, Args, OldCallbackState) of - {noreply, CState1} -> CState1; + {noreply, CState1} -> + CState1; {Message, CState1} -> send(State, [Message, "\r\n"]), CState1 end, - {ok, State#state{callbackstate = CallbackState}}. + {ok, State#state{callbackstate = CallbackState}}. %% @doc handle SASL client response to `334' challenge - RFC-4954 % the client sends a response to auth-cram-md5 -handle_sasl(UserDigest, #state{waitingauth = 'cram-md5', envelope = #envelope{auth = {<<>>, <<>>}}, authdata = AuthData} = State) -> - case binstr:split(UserDigest, <<" ">>) of - [Username, Digest] -> - try_auth('cram-md5', Username, {Digest, AuthData}, State#state{authdata=undefined}); - _ -> - % TODO error - {ok, State#state{waitingauth=false, authdata=undefined}} - end; - +handle_sasl( + UserDigest, + #state{waitingauth = 'cram-md5', envelope = #envelope{auth = {<<>>, <<>>}}, authdata = AuthData} = + State +) -> + case binstr:split(UserDigest, <<" ">>) of + [Username, Digest] -> + try_auth('cram-md5', Username, {Digest, AuthData}, State#state{authdata = undefined}); + _ -> + % TODO error + {ok, State#state{waitingauth = false, authdata = undefined}} + end; % the client sends a \0username\0password response to auth-plain -handle_sasl(UserPass, #state{waitingauth = 'plain', envelope = #envelope{auth = {<<>>,<<>>}}} = State) -> - case binstr:split(UserPass, <<0>>) of - [_Identity, Username, Password] -> - try_auth('plain', Username, Password, State); - [Username, Password] -> - try_auth('plain', Username, Password, State); - _ -> - % TODO error - {ok, State#state{waitingauth=false}} - end; - +handle_sasl( + UserPass, #state{waitingauth = 'plain', envelope = #envelope{auth = {<<>>, <<>>}}} = State +) -> + case binstr:split(UserPass, <<0>>) of + [_Identity, Username, Password] -> + try_auth('plain', Username, Password, State); + [Username, Password] -> + try_auth('plain', Username, Password, State); + _ -> + % TODO error + {ok, State#state{waitingauth = false}} + end; % the client sends a username response to auth-login -handle_sasl(Username, #state{waitingauth = 'login', envelope = #envelope{auth = {<<>>,<<>>}}} = State) -> - Envelope = State#state.envelope, - % smtp_socket:send(Socket, "334 " ++ base64:encode_to_string("Password:")), - send(State, "334 UGFzc3dvcmQ6\r\n"), - % store the provided username in envelope.auth - NewState = State#state{envelope = Envelope#envelope{auth = {Username, <<>>}}}, - {ok, NewState}; - +handle_sasl( + Username, #state{waitingauth = 'login', envelope = #envelope{auth = {<<>>, <<>>}}} = State +) -> + Envelope = State#state.envelope, + % smtp_socket:send(Socket, "334 " ++ base64:encode_to_string("Password:")), + send(State, "334 UGFzc3dvcmQ6\r\n"), + % store the provided username in envelope.auth + NewState = State#state{envelope = Envelope#envelope{auth = {Username, <<>>}}}, + {ok, NewState}; % the client sends a password response to auth-login -handle_sasl(Password, #state{waitingauth = 'login', envelope = #envelope{auth = {Username,<<>>}}} = State) -> - try_auth('login', Username, Password, State). +handle_sasl( + Password, #state{waitingauth = 'login', envelope = #envelope{auth = {Username, <<>>}}} = State +) -> + try_auth('login', Username, Password, State). -spec handle_error(error_class(), any(), #state{}) -> #state{}. -handle_error(Kind, Details, #state{module=Module, callbackstate = OldCallbackState} = State) -> - case erlang:function_exported(Module, handle_error, 3) of - true -> - case Module:handle_error(Kind, Details, OldCallbackState) of - {ok, CallbackState} -> - State#state{callbackstate = CallbackState}; - {stop, Reason, CallbackState} -> - throw({stop, Reason, State#state{callbackstate = CallbackState}}) - end; - false -> - State - end. +handle_error(Kind, Details, #state{module = Module, callbackstate = OldCallbackState} = State) -> + case erlang:function_exported(Module, handle_error, 3) of + true -> + case Module:handle_error(Kind, Details, OldCallbackState) of + {ok, CallbackState} -> + State#state{callbackstate = CallbackState}; + {stop, Reason, CallbackState} -> + throw({stop, Reason, State#state{callbackstate = CallbackState}}) + end; + false -> + State + end. %% pa = parse address %% ab = angular brackets --record(pa, - {quotes = false, - ab = true, - utf8 = false}). +-record(pa, { + quotes = false, + ab = true, + utf8 = false +}). %% https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2 --spec parse_encoded_address(Address :: binary(), Utf8 :: boolean()) -> {binary(), binary()} | 'error'. +-spec parse_encoded_address(Address :: binary(), Utf8 :: boolean()) -> + {binary(), binary()} | 'error'. parse_encoded_address(<<>>, _) -> - error; % empty + % empty + error; parse_encoded_address(<<"<@", Address/binary>>, Utf8) -> - %% A-d-l (source route) - should be ignored - case binstr:strchr(Address, $:) of - 0 -> - error; % invalid address - Index -> - parse_encoded_address(binstr:substr(Address, Index + 1), [], #pa{quotes=false, ab=true, utf8 = Utf8}) - end; + %% A-d-l (source route) - should be ignored + case binstr:strchr(Address, $:) of + 0 -> + % invalid address + error; + Index -> + parse_encoded_address(binstr:substr(Address, Index + 1), [], #pa{ + quotes = false, ab = true, utf8 = Utf8 + }) + end; parse_encoded_address(<<"<", Address/binary>>, Utf8) -> - parse_encoded_address(Address, [], #pa{quotes=false, ab=true, utf8=Utf8}); + parse_encoded_address(Address, [], #pa{quotes = false, ab = true, utf8 = Utf8}); parse_encoded_address(<<" ", Address/binary>>, Utf8) -> - parse_encoded_address(Address, Utf8); + parse_encoded_address(Address, Utf8); parse_encoded_address(Address, Utf8) -> - parse_encoded_address(Address, [], #pa{quotes=false, ab=false, utf8=Utf8}). + parse_encoded_address(Address, [], #pa{quotes = false, ab = false, utf8 = Utf8}). --spec parse_encoded_address(Address :: binary(), Acc :: list(), Flags :: #pa{}) -> {binary(), binary()} | 'error'. +-spec parse_encoded_address(Address :: binary(), Acc :: list(), Flags :: #pa{}) -> + {binary(), binary()} | 'error'. parse_encoded_address(<<>>, Acc, #pa{ab = false}) -> - {unicode:characters_to_binary(lists:reverse(Acc)), <<>>}; + {unicode:characters_to_binary(lists:reverse(Acc)), <<>>}; parse_encoded_address(<<>>, _Acc, #pa{ab = true}) -> - error; % began with angle brackets but didn't end with them + % began with angle brackets but didn't end with them + error; parse_encoded_address(_, Acc, _) when length(Acc) > 320 -> - error; % too long + % too long + error; parse_encoded_address(<<"\\", H, Tail/binary>>, Acc, Flags) -> - parse_encoded_address(Tail, [H | Acc], Flags); + parse_encoded_address(Tail, [H | Acc], Flags); parse_encoded_address(<<"\"", Tail/binary>>, Acc, #pa{quotes = false} = F) -> - parse_encoded_address(Tail, Acc, F#pa{quotes = true}); + parse_encoded_address(Tail, Acc, F#pa{quotes = true}); parse_encoded_address(<<"\"", Tail/binary>>, Acc, #pa{quotes = true} = F) -> - parse_encoded_address(Tail, Acc, F#pa{quotes = false}); + parse_encoded_address(Tail, Acc, F#pa{quotes = false}); parse_encoded_address(<<">", Tail/binary>>, Acc, #pa{quotes = false, ab = true}) -> - {unicode:characters_to_binary(lists:reverse(Acc)), binstr:strip(Tail, left, $\s)}; + {unicode:characters_to_binary(lists:reverse(Acc)), binstr:strip(Tail, left, $\s)}; parse_encoded_address(<<">", _Tail/binary>>, _Acc, #pa{quotes = false, ab = false}) -> - error; % ended with angle brackets but didn't begin with them + % ended with angle brackets but didn't begin with them + error; parse_encoded_address(<<" ", Tail/binary>>, Acc, #pa{quotes = false, ab = false}) -> - {unicode:characters_to_binary(lists:reverse(Acc)), binstr:strip(Tail, left, $\s)}; + {unicode:characters_to_binary(lists:reverse(Acc)), binstr:strip(Tail, left, $\s)}; parse_encoded_address(<<" ", _Tail/binary>>, _Acc, #pa{quotes = false, ab = true}) -> - error; % began with angle brackets but didn't end with them + % began with angle brackets but didn't end with them + error; parse_encoded_address(<>, Acc, #pa{utf8 = true} = F) when H > 127 -> - %% https://datatracker.ietf.org/doc/html/rfc6531#section-3.3 - parse_encoded_address(Tail, [H | Acc], F); % UTF-8 above 7bit (when allowed) + %% https://datatracker.ietf.org/doc/html/rfc6531#section-3.3 + + % UTF-8 above 7bit (when allowed) + parse_encoded_address(Tail, [H | Acc], F); parse_encoded_address(<>, Acc, #pa{quotes = false} = F) when H >= $0, H =< $9 -> - parse_encoded_address(Tail, [H | Acc], F); % digits + % digits + parse_encoded_address(Tail, [H | Acc], F); parse_encoded_address(<>, Acc, #pa{quotes = false} = F) when H >= $@, H =< $Z -> - parse_encoded_address(Tail, [H | Acc], F); % @ symbol and uppercase letters + % @ symbol and uppercase letters + parse_encoded_address(Tail, [H | Acc], F); parse_encoded_address(<>, Acc, #pa{quotes = false} = F) when H >= $a, H =< $z -> - parse_encoded_address(Tail, [H | Acc], F); % lowercase letters -parse_encoded_address(<>, Acc, #pa{quotes = false} = F) when H =:= $-; H =:= $.; H =:= $_ -> - parse_encoded_address(Tail, [H | Acc], F); % dash, dot, underscore + % lowercase letters + parse_encoded_address(Tail, [H | Acc], F); +parse_encoded_address(<>, Acc, #pa{quotes = false} = F) when + H =:= $-; H =:= $.; H =:= $_ +-> + % dash, dot, underscore + parse_encoded_address(Tail, [H | Acc], F); % Allowed characters in the local name: ! # $ % & ' * + - / = ? ^ _ ` . { | } ~ -parse_encoded_address(<>, Acc, #pa{quotes = false} = F) when H =:= $+; - H =:= $!; H =:= $#; H =:= $$; H =:= $%; H =:= $&; H =:= $'; H =:= $*; H =:= $=; - H =:= $/; H =:= $?; H =:= $^; H =:= $`; H =:= ${; H =:= $|; H =:= $}; H =:= $~ -> - parse_encoded_address(Tail, [H | Acc], F); % other characters +parse_encoded_address(<>, Acc, #pa{quotes = false} = F) when + H =:= $+; + H =:= $!; + H =:= $#; + H =:= $$; + H =:= $%; + H =:= $&; + H =:= $'; + H =:= $*; + H =:= $=; + H =:= $/; + H =:= $?; + H =:= $^; + H =:= $`; + H =:= ${; + H =:= $|; + H =:= $}; + H =:= $~ +-> + % other characters + parse_encoded_address(Tail, [H | Acc], F); parse_encoded_address(_, _Acc, #pa{quotes = false}) -> - error; + error; parse_encoded_address(<>, Acc, #pa{quotes = true} = F) -> - parse_encoded_address(Tail, [H | Acc], F). + parse_encoded_address(Tail, [H | Acc], F). --spec has_extension(Extensions :: [{string(), string()}], Extension :: string()) -> {'true', string()} | 'false'. +-spec has_extension(Extensions :: [{string(), string()}], Extension :: string()) -> + {'true', string()} | 'false'. has_extension(Extensions, Ext) -> - ?log(debug, "extensions ~p~n", [Extensions]), - case proplists:get_value(Ext, Extensions) of - undefined -> - false; - Value -> - {true, Value} - end. - - --spec try_auth(AuthType :: 'login' | 'plain' | 'cram-md5', Username :: binary(), Credential :: binary() | {binary(), binary()}, State :: #state{}) -> {'ok', #state{}}. -try_auth(AuthType, Username, Credential, #state{module = Module, envelope = Envelope, callbackstate = OldCallbackState} = State) -> - % clear out waiting auth - NewState = State#state{waitingauth = false, envelope = Envelope#envelope{auth = {<<>>, <<>>}}}, - case erlang:function_exported(Module, handle_AUTH, 4) of - true -> - case Module:handle_AUTH(AuthType, Username, Credential, OldCallbackState) of - {ok, CallbackState} -> - send(State, "235 Authentication successful.\r\n"), - {ok, NewState#state{callbackstate = CallbackState, - envelope = Envelope#envelope{auth = {Username, Credential}}}}; - _Other -> - send(State, "535 Authentication failed.\r\n"), - {ok, NewState} - end; - false -> - ?log(warning, "Please define handle_AUTH/4 in your server module or remove AUTH from your module extensions~n"), - send(State, "535 authentication failed (#5.7.1)\r\n"), - {ok, NewState} - end. + ?log(debug, "extensions ~p~n", [Extensions]), + case proplists:get_value(Ext, Extensions) of + undefined -> + false; + Value -> + {true, Value} + end. + +-spec try_auth( + AuthType :: 'login' | 'plain' | 'cram-md5', + Username :: binary(), + Credential :: binary() | {binary(), binary()}, + State :: #state{} +) -> {'ok', #state{}}. +try_auth( + AuthType, + Username, + Credential, + #state{module = Module, envelope = Envelope, callbackstate = OldCallbackState} = State +) -> + % clear out waiting auth + NewState = State#state{waitingauth = false, envelope = Envelope#envelope{auth = {<<>>, <<>>}}}, + case erlang:function_exported(Module, handle_AUTH, 4) of + true -> + case Module:handle_AUTH(AuthType, Username, Credential, OldCallbackState) of + {ok, CallbackState} -> + send(State, "235 Authentication successful.\r\n"), + {ok, NewState#state{ + callbackstate = CallbackState, + envelope = Envelope#envelope{auth = {Username, Credential}} + }}; + _Other -> + send(State, "535 Authentication failed.\r\n"), + {ok, NewState} + end; + false -> + ?log( + warning, + "Please define handle_AUTH/4 in your server module or remove AUTH from your module extensions~n" + ), + send(State, "535 authentication failed (#5.7.1)\r\n"), + {ok, NewState} + end. %get_digest_nonce() -> - %A = [io_lib:format("~2.16.0b", [X]) || <> <= erlang:md5(integer_to_list(rand:uniform(4294967295)))], - %B = [io_lib:format("~2.16.0b", [X]) || <> <= erlang:md5(integer_to_list(rand:uniform(4294967295)))], - %binary_to_list(base64:encode(lists:flatten(A ++ B))). - +%A = [io_lib:format("~2.16.0b", [X]) || <> <= erlang:md5(integer_to_list(rand:uniform(4294967295)))], +%B = [io_lib:format("~2.16.0b", [X]) || <> <= erlang:md5(integer_to_list(rand:uniform(4294967295)))], +%binary_to_list(base64:encode(lists:flatten(A ++ B))). %% @doc a tight loop to receive the message body -receive_data(_Acc, _Transport, _Socket, _, Size, MaxSize, Session, _Options) when MaxSize =/= 'infinity', Size > MaxSize -> - ?log(info, "SMTP message body size ~B exceeded maximum allowed ~B~n", [Size, MaxSize]), - Session ! {receive_data, {error, size_exceeded}}; +receive_data(_Acc, _Transport, _Socket, _, Size, MaxSize, Session, _Options) when + MaxSize =/= 'infinity', Size > MaxSize +-> + ?log(info, "SMTP message body size ~B exceeded maximum allowed ~B~n", [Size, MaxSize]), + Session ! {receive_data, {error, size_exceeded}}; receive_data(Acc, Transport, Socket, RecvSize, Size, MaxSize, Session, Options) -> - case Transport:recv(Socket, RecvSize, 1000) of - {ok, Packet} when Acc =:= [] -> - case check_bare_crlf(Packet, <<>>, proplists:get_value(allow_bare_newlines, Options, false), 0) of - error -> - Session ! {receive_data, {error, bare_newline}}; - FixedPacket -> - case binstr:strpos(FixedPacket, <<"\r\n.\r\n">>) of - 0 -> - ?log(debug, "received ~B bytes; size is now ~p~n", [RecvSize, Size + size(Packet)]), - ?log(debug, "memory usage: ~p~n", [erlang:process_info(self(), memory)]), - receive_data([FixedPacket | Acc], Transport, Socket, RecvSize, Size + byte_size(FixedPacket), MaxSize, Session, Options); - Index -> - String = binstr:substr(FixedPacket, 1, Index - 1), - Rest = binstr:substr(FixedPacket, Index+5), - ?log(debug, "memory usage before flattening: ~p~n", [erlang:process_info(self(), memory)]), - Result = list_to_binary(lists:reverse([String | Acc])), - ?log(debug, "memory usage after flattening: ~p~n", [erlang:process_info(self(), memory)]), - Session ! {receive_data, Result, Rest} - end - end; - {ok, Packet} -> - [Last | _] = Acc, - case check_bare_crlf(Packet, Last, proplists:get_value(allow_bare_newlines, Options, false), 0) of - error -> - Session ! {receive_data, {error, bare_newline}}; - FixedPacket -> - case binstr:strpos(FixedPacket, <<"\r\n.\r\n">>) of - 0 -> - ?log(debug, "received ~B bytes; size is now ~p~n", [RecvSize, Size + size(Packet)]), - ?log(debug, "memory usage: ~p~n", [erlang:process_info(self(), memory)]), - receive_data([FixedPacket | Acc], Transport, Socket, RecvSize, Size + byte_size(FixedPacket), MaxSize, Session, Options); - Index -> - String = binstr:substr(FixedPacket, 1, Index - 1), - Rest = binstr:substr(FixedPacket, Index+5), - ?log(debug, "memory usage before flattening: ~p~n", [erlang:process_info(self(), memory)]), - Result = list_to_binary(lists:reverse([String | Acc])), - ?log(debug, "memory usage after flattening: ~p~n", [erlang:process_info(self(), memory)]), - Session ! {receive_data, Result, Rest} - end - end; - {error, timeout} when RecvSize =:= 0, length(Acc) > 1 -> - % check that we didn't accidentally receive a \r\n.\r\n split across 2 receives - [A, B | Acc2] = Acc, - Packet = list_to_binary([B, A]), - case binstr:strpos(Packet, <<"\r\n.\r\n">>) of - 0 -> - % uh-oh - ?log(debug, "no data on socket, and no DATA terminator, retrying ~p~n", [Session]), - % eventually we'll either get data or a different error, just keep retrying - receive_data(Acc, Transport, Socket, 0, Size, MaxSize, Session, Options); - Index -> - String = binstr:substr(Packet, 1, Index - 1), - Rest = binstr:substr(Packet, Index+5), - ?log(debug, "memory usage before flattening: ~p~n", [erlang:process_info(self(), memory)]), - Result = list_to_binary(lists:reverse([String | Acc2])), - ?log(debug, "memory usage after flattening: ~p~n", [erlang:process_info(self(), memory)]), - Session ! {receive_data, Result, Rest} - end; - {error, timeout} -> - receive_data(Acc, Transport, Socket, 0, Size, MaxSize, Session, Options); - {error, Reason} -> - ?log(warning, "SMTP receive error: ~p", [Reason]), - Session ! {receive_data, {error, Reason}} - end. + case Transport:recv(Socket, RecvSize, 1000) of + {ok, Packet} when Acc =:= [] -> + case + check_bare_crlf( + Packet, <<>>, proplists:get_value(allow_bare_newlines, Options, false), 0 + ) + of + error -> + Session ! {receive_data, {error, bare_newline}}; + FixedPacket -> + case binstr:strpos(FixedPacket, <<"\r\n.\r\n">>) of + 0 -> + ?log(debug, "received ~B bytes; size is now ~p~n", [ + RecvSize, Size + size(Packet) + ]), + ?log(debug, "memory usage: ~p~n", [erlang:process_info(self(), memory)]), + receive_data( + [FixedPacket | Acc], + Transport, + Socket, + RecvSize, + Size + byte_size(FixedPacket), + MaxSize, + Session, + Options + ); + Index -> + String = binstr:substr(FixedPacket, 1, Index - 1), + Rest = binstr:substr(FixedPacket, Index + 5), + ?log(debug, "memory usage before flattening: ~p~n", [ + erlang:process_info(self(), memory) + ]), + Result = list_to_binary(lists:reverse([String | Acc])), + ?log(debug, "memory usage after flattening: ~p~n", [ + erlang:process_info(self(), memory) + ]), + Session ! {receive_data, Result, Rest} + end + end; + {ok, Packet} -> + [Last | _] = Acc, + case + check_bare_crlf( + Packet, Last, proplists:get_value(allow_bare_newlines, Options, false), 0 + ) + of + error -> + Session ! {receive_data, {error, bare_newline}}; + FixedPacket -> + case binstr:strpos(FixedPacket, <<"\r\n.\r\n">>) of + 0 -> + ?log(debug, "received ~B bytes; size is now ~p~n", [ + RecvSize, Size + size(Packet) + ]), + ?log(debug, "memory usage: ~p~n", [erlang:process_info(self(), memory)]), + receive_data( + [FixedPacket | Acc], + Transport, + Socket, + RecvSize, + Size + byte_size(FixedPacket), + MaxSize, + Session, + Options + ); + Index -> + String = binstr:substr(FixedPacket, 1, Index - 1), + Rest = binstr:substr(FixedPacket, Index + 5), + ?log(debug, "memory usage before flattening: ~p~n", [ + erlang:process_info(self(), memory) + ]), + Result = list_to_binary(lists:reverse([String | Acc])), + ?log(debug, "memory usage after flattening: ~p~n", [ + erlang:process_info(self(), memory) + ]), + Session ! {receive_data, Result, Rest} + end + end; + {error, timeout} when RecvSize =:= 0, length(Acc) > 1 -> + % check that we didn't accidentally receive a \r\n.\r\n split across 2 receives + [A, B | Acc2] = Acc, + Packet = list_to_binary([B, A]), + case binstr:strpos(Packet, <<"\r\n.\r\n">>) of + 0 -> + % uh-oh + ?log(debug, "no data on socket, and no DATA terminator, retrying ~p~n", [ + Session + ]), + % eventually we'll either get data or a different error, just keep retrying + receive_data(Acc, Transport, Socket, 0, Size, MaxSize, Session, Options); + Index -> + String = binstr:substr(Packet, 1, Index - 1), + Rest = binstr:substr(Packet, Index + 5), + ?log(debug, "memory usage before flattening: ~p~n", [ + erlang:process_info(self(), memory) + ]), + Result = list_to_binary(lists:reverse([String | Acc2])), + ?log(debug, "memory usage after flattening: ~p~n", [ + erlang:process_info(self(), memory) + ]), + Session ! {receive_data, Result, Rest} + end; + {error, timeout} -> + receive_data(Acc, Transport, Socket, 0, Size, MaxSize, Session, Options); + {error, Reason} -> + ?log(warning, "SMTP receive error: ~p", [Reason]), + Session ! {receive_data, {error, Reason}} + end. check_for_bare_crlf(Bin, Offset) -> - case {re:run(Bin, "(? true; - {_, match} -> true; - _ -> false - end. + case + { + re:run(Bin, "(? true; + {_, match} -> true; + _ -> false + end. fix_bare_crlf(Bin, Offset) -> - Options = [{offset, Offset}, {return, binary}, global], - re:replace(re:replace(Bin, "(? - Options = [{offset, Offset}, {return, binary}, global], - re:replace(re:replace(Bin, "(? - Binary; + Binary; check_bare_crlf(<<$\n, _Rest/binary>> = Bin, Prev, Op, 0 = _Offset) when byte_size(Prev) > 0 -> - % check if last character of previous was a CR - Lastchar = binstr:substr(Prev, -1), - case Lastchar of - <<"\r">> -> - % okay, check again for the rest - check_bare_crlf(Bin, <<>>, Op, 1); - _ when Op == false -> % not fixing or ignoring them - error; - _ -> - % no dice - check_bare_crlf(Bin, <<>>, Op, 0) - end; + % check if last character of previous was a CR + Lastchar = binstr:substr(Prev, -1), + case Lastchar of + <<"\r">> -> + % okay, check again for the rest + check_bare_crlf(Bin, <<>>, Op, 1); + % not fixing or ignoring them + _ when Op == false -> + error; + _ -> + % no dice + check_bare_crlf(Bin, <<>>, Op, 0) + end; check_bare_crlf(Binary, _Prev, Op, Offset) -> - Last = binstr:substr(Binary, -1), - % is the last character a CR? - case Last of - <<"\r">> -> - % okay, the last character is a CR, we have to assume the next packet contains the corresponding LF - NewBin = binstr:substr(Binary, 1, byte_size(Binary) -1), - case check_for_bare_crlf(NewBin, Offset) of - true when Op == fix -> - list_to_binary([fix_bare_crlf(NewBin, Offset), "\r"]); - true when Op == strip -> - list_to_binary([strip_bare_crlf(NewBin, Offset), "\r"]); - true -> - error; - false -> - Binary - end; - _ -> - case check_for_bare_crlf(Binary, Offset) of - true when Op == fix -> - fix_bare_crlf(Binary, Offset); - true when Op == strip -> - strip_bare_crlf(Binary, Offset); - true -> - error; - false -> - Binary - end - end. + Last = binstr:substr(Binary, -1), + % is the last character a CR? + case Last of + <<"\r">> -> + % okay, the last character is a CR, we have to assume the next packet contains the corresponding LF + NewBin = binstr:substr(Binary, 1, byte_size(Binary) - 1), + case check_for_bare_crlf(NewBin, Offset) of + true when Op == fix -> + list_to_binary([fix_bare_crlf(NewBin, Offset), "\r"]); + true when Op == strip -> + list_to_binary([strip_bare_crlf(NewBin, Offset), "\r"]); + true -> + error; + false -> + Binary + end; + _ -> + case check_for_bare_crlf(Binary, Offset) of + true when Op == fix -> + fix_bare_crlf(Binary, Offset); + true when Op == strip -> + strip_bare_crlf(Binary, Offset); + true -> + error; + false -> + Binary + end + end. send(#state{transport = Transport, socket = Sock} = St, Data) -> case Transport:send(Sock, Data) of - ok -> ok; - {error, Err} -> - St1 = handle_error(send_error, Err, St), - throw({stop, {send_error, Err}, St1}) - end. + ok -> + ok; + {error, Err} -> + St1 = handle_error(send_error, Err, St), + throw({stop, {send_error, Err}, St1}) + end. setopts(#state{transport = Transport, socket = Sock} = St, Opts) -> case Transport:setopts(Sock, Opts) of - ok -> ok; - {error, Err} -> - St1 = handle_error(setopts_error, Err, St), - throw({stop, {setopts_error, Err}, St1}) - end. + ok -> + ok; + {error, Err} -> + St1 = handle_error(setopts_error, Err, St), + throw({stop, {setopts_error, Err}, St1}) + end. 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. + 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(). +-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"]); + send(State, ["250 ", Reference, "\r\n"]); report_recipient(error, Message, State) -> - send(State, [Message, "\r\n"]); + 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; + 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). + report_recipient(ResponseType, Value, State), + report_recipient(multiple, Rest, State). -ifdef(TEST). parse_encoded_address_test_() -> - [ - {"Valid addresses should parse", - fun() -> - ?assertEqual({<<"God@heaven.af.mil">>, <<>>}, parse_encoded_address(<<"">>, false)), - ?assertEqual({<<"God@heaven.af.mil">>, <<>>}, parse_encoded_address(<<"<\\God@heaven.af.mil>">>, false)), - ?assertEqual({<<"God@heaven.af.mil">>, <<>>}, parse_encoded_address(<<"<\"God\"@heaven.af.mil>">>, false)), - ?assertEqual({<<"God@heaven.af.mil">>, <<>>}, parse_encoded_address(<<"<@gateway.af.mil,@uucp.local:\"\\G\\o\\d\"@heaven.af.mil>">>, false)), - ?assertEqual({<<"God2@heaven.af.mil">>, <<>>}, parse_encoded_address(<<"">>, false)), - ?assertEqual({<<"God+extension@heaven.af.mil">>, <<>>}, parse_encoded_address(<<"">>, false)), - ?assertEqual({<<"God~*$@heaven.af.mil">>, <<>>}, parse_encoded_address(<<"">>, false)), - ?assertEqual({<<"God~!#$%^&*()_+123@heaven.af.mil">>, <<>>}, parse_encoded_address(<<"<\"God~!#$%^&*()_+123\"@heaven.af.mil>">>, false)) - end - }, - {"Addresses that are sorta valid should parse", - fun() -> - ?assertEqual({<<"God@heaven.af.mil">>, <<>>}, parse_encoded_address(<<"God@heaven.af.mil">>, false)), - ?assertEqual({<<"God@heaven.af.mil">>, <<>>}, parse_encoded_address(<<"God@heaven.af.mil ">>, false)), - ?assertEqual({<<"God@heaven.af.mil">>, <<>>}, parse_encoded_address(<<" God@heaven.af.mil ">>, false)), - ?assertEqual({<<"God@heaven.af.mil">>, <<>>}, parse_encoded_address(<<" ">>, false)) - end - }, - {"Addresses with UTF8 characters should parse only when allowed", - fun() -> - %% https://www.iana.org/domains/reserved - ?assertEqual({<<"испытание@пример.испытание"/utf8>>, <<>>}, - parse_encoded_address(<<"<испытание@пример.испытание>"/utf8>>, true)), - ?assertEqual({<<"測試@例子.測試"/utf8>>, <<>>}, - parse_encoded_address(<<"<測試@例子.測試>"/utf8>>, true)), - ?assertEqual({<<"испытание@пример.испытание"/utf8>>, <<"SIZE=100">>}, - parse_encoded_address(<<"<испытание@пример.испытание> SIZE=100"/utf8>>, true)), - ?assertEqual({<<"test@пример.испытание"/utf8>>, <<>>}, - parse_encoded_address(<<""/utf8>>, true)), - ?assertEqual({<<"испытание!#¤½§´`<>@пример.испытание"/utf8>>, <<>>}, - parse_encoded_address(<<"<\"испытание!#¤½§´`<>\"@пример.испытание>"/utf8>>, true)), - ?assertEqual(error, parse_encoded_address(<<"<испытание@пример.испытание>"/utf8>>, false)) - end - }, - {"Addresses containing unescaped <> that aren't at start/end should fail", - fun() -> - ?assertEqual(error, parse_encoded_address(<<"<<">>, false)), - ?assertEqual(error, parse_encoded_address(<<"">>, false)) - end - }, - {"Address that begins with < but doesn't end with a > should fail", - fun() -> - ?assertEqual(error, parse_encoded_address(<<">, false)), - ?assertEqual(error, parse_encoded_address(<<">, false)) - end - }, - {"Address that begins without < but ends with a > should fail", - fun() -> - ?assertEqual(error, parse_encoded_address(<<"God@heaven.af.mil>">>, false)) - end - }, - {"Address longer than 320 characters should fail", - fun() -> - MegaAddress = list_to_binary(lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122) ++ "@" ++ lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122)), - ?assertEqual(error, parse_encoded_address(MegaAddress, false)) - end - }, - {"Address with an invalid route should fail", - fun() -> - ?assertEqual(error, parse_encoded_address(<<"<@gateway.af.mil God@heaven.af.mil>">>, false)) - end - }, - {"Empty addresses should parse OK", - fun() -> - ?assertEqual({<<>>, <<>>}, parse_encoded_address(<<"<>">>, false)), - ?assertEqual({<<>>, <<>>}, parse_encoded_address(<<" <> ">>, false)) - end - }, - {"Completely empty addresses are an error", - fun() -> - ?assertEqual(error, parse_encoded_address(<<"">>, false)), - ?assertEqual(error, parse_encoded_address(<<" ">>, false)) - end - }, - {"addresses with trailing parameters should return the trailing parameters", - fun() -> - ?assertEqual({<<"God@heaven.af.mil">>, <<"SIZE=100 BODY=8BITMIME">>}, parse_encoded_address(<<" SIZE=100 BODY=8BITMIME">>, false)) - end - } - ]. + [ + {"Valid addresses should parse", fun() -> + ?assertEqual( + {<<"God@heaven.af.mil">>, <<>>}, + parse_encoded_address(<<"">>, false) + ), + ?assertEqual( + {<<"God@heaven.af.mil">>, <<>>}, + parse_encoded_address(<<"<\\God@heaven.af.mil>">>, false) + ), + ?assertEqual( + {<<"God@heaven.af.mil">>, <<>>}, + parse_encoded_address(<<"<\"God\"@heaven.af.mil>">>, false) + ), + ?assertEqual( + {<<"God@heaven.af.mil">>, <<>>}, + parse_encoded_address( + <<"<@gateway.af.mil,@uucp.local:\"\\G\\o\\d\"@heaven.af.mil>">>, false + ) + ), + ?assertEqual( + {<<"God2@heaven.af.mil">>, <<>>}, + parse_encoded_address(<<"">>, false) + ), + ?assertEqual( + {<<"God+extension@heaven.af.mil">>, <<>>}, + parse_encoded_address(<<"">>, false) + ), + ?assertEqual( + {<<"God~*$@heaven.af.mil">>, <<>>}, + parse_encoded_address(<<"">>, false) + ), + ?assertEqual( + {<<"God~!#$%^&*()_+123@heaven.af.mil">>, <<>>}, + parse_encoded_address(<<"<\"God~!#$%^&*()_+123\"@heaven.af.mil>">>, false) + ) + end}, + {"Addresses that are sorta valid should parse", fun() -> + ?assertEqual( + {<<"God@heaven.af.mil">>, <<>>}, + parse_encoded_address(<<"God@heaven.af.mil">>, false) + ), + ?assertEqual( + {<<"God@heaven.af.mil">>, <<>>}, + parse_encoded_address(<<"God@heaven.af.mil ">>, false) + ), + ?assertEqual( + {<<"God@heaven.af.mil">>, <<>>}, + parse_encoded_address(<<" God@heaven.af.mil ">>, false) + ), + ?assertEqual( + {<<"God@heaven.af.mil">>, <<>>}, + parse_encoded_address(<<" ">>, false) + ) + end}, + {"Addresses with UTF8 characters should parse only when allowed", fun() -> + %% https://www.iana.org/domains/reserved + ?assertEqual( + {<<"испытание@пример.испытание"/utf8>>, <<>>}, + parse_encoded_address(<<"<испытание@пример.испытание>"/utf8>>, true) + ), + ?assertEqual( + {<<"測試@例子.測試"/utf8>>, <<>>}, + parse_encoded_address(<<"<測試@例子.測試>"/utf8>>, true) + ), + ?assertEqual( + {<<"испытание@пример.испытание"/utf8>>, <<"SIZE=100">>}, + parse_encoded_address(<<"<испытание@пример.испытание> SIZE=100"/utf8>>, true) + ), + ?assertEqual( + {<<"test@пример.испытание"/utf8>>, <<>>}, + parse_encoded_address(<<""/utf8>>, true) + ), + ?assertEqual( + {<<"испытание!#¤½§´`<>@пример.испытание"/utf8>>, <<>>}, + parse_encoded_address(<<"<\"испытание!#¤½§´`<>\"@пример.испытание>"/utf8>>, true) + ), + ?assertEqual( + error, parse_encoded_address(<<"<испытание@пример.испытание>"/utf8>>, false) + ) + end}, + {"Addresses containing unescaped <> that aren't at start/end should fail", fun() -> + ?assertEqual(error, parse_encoded_address(<<"<<">>, false)), + ?assertEqual(error, parse_encoded_address(<<"">>, false)) + end}, + {"Address that begins with < but doesn't end with a > should fail", fun() -> + ?assertEqual(error, parse_encoded_address(<<">, false)), + ?assertEqual(error, parse_encoded_address(<<">, false)) + end}, + {"Address that begins without < but ends with a > should fail", fun() -> + ?assertEqual(error, parse_encoded_address(<<"God@heaven.af.mil>">>, false)) + end}, + {"Address longer than 320 characters should fail", fun() -> + MegaAddress = list_to_binary( + lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122) ++ + lists:seq(97, 122) ++ lists:seq(97, 122) ++ "@" ++ lists:seq(97, 122) ++ + lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122) ++ + lists:seq(97, 122) ++ lists:seq(97, 122) ++ lists:seq(97, 122) + ), + ?assertEqual(error, parse_encoded_address(MegaAddress, false)) + end}, + {"Address with an invalid route should fail", fun() -> + ?assertEqual( + error, parse_encoded_address(<<"<@gateway.af.mil God@heaven.af.mil>">>, false) + ) + end}, + {"Empty addresses should parse OK", fun() -> + ?assertEqual({<<>>, <<>>}, parse_encoded_address(<<"<>">>, false)), + ?assertEqual({<<>>, <<>>}, parse_encoded_address(<<" <> ">>, false)) + end}, + {"Completely empty addresses are an error", fun() -> + ?assertEqual(error, parse_encoded_address(<<"">>, false)), + ?assertEqual(error, parse_encoded_address(<<" ">>, false)) + end}, + {"addresses with trailing parameters should return the trailing parameters", fun() -> + ?assertEqual( + {<<"God@heaven.af.mil">>, <<"SIZE=100 BODY=8BITMIME">>}, + parse_encoded_address(<<" SIZE=100 BODY=8BITMIME">>, false) + ) + end} + ]. parse_request_test_() -> - [ - {"Parsing normal SMTP requests", - 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 - }, - {"Verbs should be uppercased", - fun() -> - ?assertEqual({<<"HELO">>, <<"hell.af.mil">>}, parse_request(<<"helo hell.af.mil">>)), - ?assertEqual({<<"RSET">>, <<>>}, parse_request(<<"rset\r\n">>)) - end - }, - {"Leading and trailing spaces are removed", - fun() -> - ?assertEqual({<<"HELO">>, <<"hell.af.mil">>}, parse_request(<<" helo hell.af.mil ">>)) - end - }, - {"Blank lines are blank", - fun() -> - ?assertEqual({<<>>, <<>>}, parse_request(<<"">>)) - end - } - ]. + [ + {"Parsing normal SMTP requests", 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}, + {"Verbs should be uppercased", fun() -> + ?assertEqual({<<"HELO">>, <<"hell.af.mil">>}, parse_request(<<"helo hell.af.mil">>)), + ?assertEqual({<<"RSET">>, <<>>}, parse_request(<<"rset\r\n">>)) + end}, + {"Leading and trailing spaces are removed", fun() -> + ?assertEqual( + {<<"HELO">>, <<"hell.af.mil">>}, parse_request(<<" helo hell.af.mil ">>) + ) + end}, + {"Blank lines are blank", fun() -> + ?assertEqual({<<>>, <<>>}, parse_request(<<"">>)) + end} + ]. smtp_session_test_() -> - {foreach, - local, - fun() -> - application:ensure_all_started(gen_smtp), - {ok, Pid} = gen_smtp_server:start( - smtp_server_example, - [{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}) -> - {"A new connection should get a banner", - fun() -> - smtp_socket:active_once(CSock), - receive {tcp, CSock, Packet} -> ok end, - ?assertMatch("220 localhost"++_Stuff, Packet) - end - } - end, - fun({CSock, _Pid}) -> - {"A correct response to HELO", - 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("250 localhost\r\n", Packet2) - end - } - end, - fun({CSock, _Pid}) -> - {"An error in response to an invalid HELO", - 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\r\n"), - receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, - ?assertMatch("501 Syntax: HELO hostname\r\n", Packet2) - 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() -> - 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 invalid\r\n"), - receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, - ?assertMatch("554 invalid hostname\r\n", Packet2) - end - } - end, - fun({CSock, _Pid}) -> - {"A rejected EHLO", - 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, "EHLO invalid\r\n"), - receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, - ?assertMatch("554 invalid hostname\r\n", Packet2) - end - } - end, - fun({CSock, _Pid}) -> - {"EHLO 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, "EHLO 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}) -> - {"Unsupported AUTH PLAIN", - 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, "EHLO 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)), - smtp_socket:send(CSock, "AUTH PLAIN\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("502 Error: AUTH not implemented\r\n", Packet4) - end - } - end, - fun({CSock, _Pid}) -> - {"Sending DATA", - 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("250 localhost\r\n", Packet2), - smtp_socket:send(CSock, "MAIL FROM:\r\n"), - receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 "++_, Packet3), - smtp_socket:send(CSock, "RCPT TO:\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 "++_, Packet4), - smtp_socket:send(CSock, "DATA\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("354 "++_, Packet5), - smtp_socket:send(CSock, "Subject: tls message\r\n"), - smtp_socket:send(CSock, "To: \r\n"), - smtp_socket:send(CSock, "From: \r\n"), - smtp_socket:send(CSock, "\r\n"), - smtp_socket:send(CSock, "message body"), - smtp_socket:send(CSock, "\r\n.\r\n"), - receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 queued as"++_, Packet6) - end - } - end, - fun({CSock, _Pid}) -> - {"Sending with spaced MAIL FROM / 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, "HELO somehost.com\r\n"), - receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 localhost\r\n", Packet2), - smtp_socket:send(CSock, "MAIL FROM: \r\n"), - receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 "++_, Packet3), - smtp_socket:send(CSock, "RCPT TO: \r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 "++_, Packet4), - smtp_socket:send(CSock, "DATA\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("354 "++_, Packet5), - smtp_socket:send(CSock, "Subject: tls message\r\n"), - smtp_socket:send(CSock, "To: \r\n"), - smtp_socket:send(CSock, "From: \r\n"), - smtp_socket:send(CSock, "\r\n"), - smtp_socket:send(CSock, "message body"), - smtp_socket:send(CSock, "\r\n.\r\n"), - receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 queued as"++_, Packet6) - end - } - end, - fun({CSock, _Pid}) -> - {"Sending with UTF8 addresses and body", - 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, "EHLO somehost.com\r\n"), - receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250-localhost\r\n", Packet2), - receive {tcp, CSock, Packet31} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250-SIZE"++_, Packet31), - receive {tcp, CSock, Packet32} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250-8BITMIME"++_, Packet32), - receive {tcp, CSock, Packet33} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250-PIPELINING"++_, Packet33), - receive {tcp, CSock, Packet34} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 SMTPUTF8"++_, Packet34), - smtp_socket:send(CSock, <<"MAIL FROM: <испытание@пример.испытание> SMTPUTF8\r\n"/utf8>>), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 sender Ok"++_, Packet4), - smtp_socket:send(CSock, <<"RCPT TO: <測試@例子.測試>\r\n"/utf8>>), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 recipient Ok"++_, Packet5), - smtp_socket:send(CSock, "DATA\r\n"), - receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, - ?assertMatch("354 "++_, Packet6), - smtp_socket:send(CSock, <<"Subject: Я помню чудное мгновенье\r\n"/utf8>>), - smtp_socket:send(CSock, <<"To: <測試@例子.測試>\r\n"/utf8>>), - smtp_socket:send(CSock, <<"From: <испытание@пример.испытание>\r\n"/utf8>>), - smtp_socket:send(CSock, "\r\n"), - smtp_socket:send(CSock, <<"Передо мной явилась ты"/utf8>>), - smtp_socket:send(CSock, "\r\n.\r\n"), - receive {tcp, CSock, Packet7} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 queued as"++_, Packet7) - end - } - end, -% fun({CSock, _Pid}) -> -% {"Sending DATA with a bare newline", -% 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("250 localhost\r\n", Packet2), -% smtp_socket:send(CSock, "MAIL FROM:\r\n"), -% receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, -% ?assertMatch("250 "++_, Packet3), -% smtp_socket:send(CSock, "RCPT TO: \r\n"), -% receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, -% ?assertMatch("250 "++_, Packet4), -% smtp_socket:send(CSock, "DATA\r\n"), -% receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, -% ?assertMatch("354 "++_, Packet5), -% smtp_socket:send(CSock, "Subject: tls message\r\n"), -% smtp_socket:send(CSock, "To: \r\n"), -% smtp_socket:send(CSock, "From: \r\n"), -% smtp_socket:send(CSock, "\r\n"), -% smtp_socket:send(CSock, "this\r\n"), -% smtp_socket:send(CSock, "body\r\n"), -% smtp_socket:send(CSock, "has\r\n"), -% smtp_socket:send(CSock, "a\r\n"), -% smtp_socket:send(CSock, "bare\n"), -% smtp_socket:send(CSock, "newline\r\n"), -% smtp_socket:send(CSock, "\r\n.\r\n"), -% receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, -% ?assertMatch("451 "++_, Packet6), -% end -% } -% end, - %fun({CSock, _Pid}) -> -% {"Sending DATA with a bare CR", -% 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("250 localhost\r\n", Packet2), -% smtp_socket:send(CSock, "MAIL FROM:\r\n"), -% receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, -% ?assertMatch("250 "++_, Packet3), -% smtp_socket:send(CSock, "RCPT TO: \r\n"), -% receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, -% ?assertMatch("250 "++_, Packet4), -% smtp_socket:send(CSock, "DATA\r\n"), -% receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, -% ?assertMatch("354 "++_, Packet5), -% smtp_socket:send(CSock, "Subject: tls message\r\n"), -% smtp_socket:send(CSock, "To: \r\n"), -% smtp_socket:send(CSock, "From: \r\n"), -% smtp_socket:send(CSock, "\r\n"), -% smtp_socket:send(CSock, "this\r\n"), -% smtp_socket:send(CSock, "\rbody\r\n"), -% smtp_socket:send(CSock, "has\r\n"), -% smtp_socket:send(CSock, "a\r\n"), -% smtp_socket:send(CSock, "bare\r"), -% smtp_socket:send(CSock, "CR\r\n"), -% smtp_socket:send(CSock, "\r\n.\r\n"), -% receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, -% ?assertMatch("451 "++_, Packet6), -% end -% } -% end, - -% fun({CSock, _Pid}) -> -% {"Sending DATA with a bare newline in the headers", -% 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("250 localhost\r\n", Packet2), -% smtp_socket:send(CSock, "MAIL FROM:\r\n"), -% receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, -% ?assertMatch("250 "++_, Packet3), -% smtp_socket:send(CSock, "RCPT TO: \r\n"), -% receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, -% ?assertMatch("250 "++_, Packet4), -% smtp_socket:send(CSock, "DATA\r\n"), -% receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, -% ?assertMatch("354 "++_, Packet5), -% smtp_socket:send(CSock, "Subject: tls message\r\n"), -% smtp_socket:send(CSock, "To: \n"), -% smtp_socket:send(CSock, "From: \r\n"), -% smtp_socket:send(CSock, "\r\n"), -% smtp_socket:send(CSock, "this\r\n"), -% smtp_socket:send(CSock, "body\r\n"), -% smtp_socket:send(CSock, "has\r\n"), -% smtp_socket:send(CSock, "no\r\n"), -% smtp_socket:send(CSock, "bare\r\n"), -% smtp_socket:send(CSock, "newlines\r\n"), -% smtp_socket:send(CSock, "\r\n.\r\n"), -% receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, -% ?assertMatch("451 "++_, Packet6), -% end -% } -% end, - fun({CSock, _Pid}) -> - {"Sending DATA with bare newline on first line of body", - 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("250 localhost\r\n", Packet2), - smtp_socket:send(CSock, "MAIL FROM:\r\n"), - receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 "++_, Packet3), - smtp_socket:send(CSock, "RCPT TO:\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 "++_, Packet4), - smtp_socket:send(CSock, "DATA\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("354 "++_, Packet5), - smtp_socket:send(CSock, "Subject: tls message\r\n"), - smtp_socket:send(CSock, "To: \n"), - smtp_socket:send(CSock, "From: \r\n"), - smtp_socket:send(CSock, "\r\n"), - smtp_socket:send(CSock, "this\n"), - smtp_socket:send(CSock, "body\r\n"), - smtp_socket:send(CSock, "has\r\n"), - smtp_socket:send(CSock, "no\r\n"), - smtp_socket:send(CSock, "bare\r\n"), - smtp_socket:send(CSock, "newlines\r\n"), - smtp_socket:send(CSock, "\r\n.\r\n"), - receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, - ?assertMatch("451 "++_, Packet6) - end - } - end - - ] - }. + {foreach, local, + fun() -> + application:ensure_all_started(gen_smtp), + {ok, Pid} = gen_smtp_server:start( + smtp_server_example, + [ + {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}) -> + {"A new connection should get a banner", fun() -> + smtp_socket:active_once(CSock), + receive + {tcp, CSock, Packet} -> ok + end, + ?assertMatch("220 localhost" ++ _Stuff, Packet) + end} + end, + fun({CSock, _Pid}) -> + {"A correct response to HELO", 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("250 localhost\r\n", Packet2) + end} + end, + fun({CSock, _Pid}) -> + {"An error in response to an invalid HELO", 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\r\n"), + receive + {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("501 Syntax: HELO hostname\r\n", Packet2) + 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() -> + 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 invalid\r\n"), + receive + {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("554 invalid hostname\r\n", Packet2) + end} + end, + fun({CSock, _Pid}) -> + {"A rejected EHLO", 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, "EHLO invalid\r\n"), + receive + {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("554 invalid hostname\r\n", Packet2) + end} + end, + fun({CSock, _Pid}) -> + {"EHLO 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, "EHLO 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}) -> + {"Unsupported AUTH PLAIN", 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, "EHLO 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)), + smtp_socket:send(CSock, "AUTH PLAIN\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("502 Error: AUTH not implemented\r\n", Packet4) + end} + end, + fun({CSock, _Pid}) -> + {"Sending DATA", 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("250 localhost\r\n", Packet2), + smtp_socket:send(CSock, "MAIL FROM:\r\n"), + receive + {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet3), + smtp_socket:send(CSock, "RCPT TO:\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet4), + smtp_socket:send(CSock, "DATA\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("354 " ++ _, Packet5), + smtp_socket:send(CSock, "Subject: tls message\r\n"), + smtp_socket:send(CSock, "To: \r\n"), + smtp_socket:send(CSock, "From: \r\n"), + smtp_socket:send(CSock, "\r\n"), + smtp_socket:send(CSock, "message body"), + smtp_socket:send(CSock, "\r\n.\r\n"), + receive + {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 queued as" ++ _, Packet6) + end} + end, + fun({CSock, _Pid}) -> + {"Sending with spaced MAIL FROM / 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, "HELO somehost.com\r\n"), + receive + {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 localhost\r\n", Packet2), + smtp_socket:send(CSock, "MAIL FROM: \r\n"), + receive + {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet3), + smtp_socket:send(CSock, "RCPT TO: \r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet4), + smtp_socket:send(CSock, "DATA\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("354 " ++ _, Packet5), + smtp_socket:send(CSock, "Subject: tls message\r\n"), + smtp_socket:send(CSock, "To: \r\n"), + smtp_socket:send(CSock, "From: \r\n"), + smtp_socket:send(CSock, "\r\n"), + smtp_socket:send(CSock, "message body"), + smtp_socket:send(CSock, "\r\n.\r\n"), + receive + {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 queued as" ++ _, Packet6) + end} + end, + fun({CSock, _Pid}) -> + {"Sending with UTF8 addresses and body", 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, "EHLO somehost.com\r\n"), + receive + {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250-localhost\r\n", Packet2), + receive + {tcp, CSock, Packet31} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250-SIZE" ++ _, Packet31), + receive + {tcp, CSock, Packet32} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250-8BITMIME" ++ _, Packet32), + receive + {tcp, CSock, Packet33} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250-PIPELINING" ++ _, Packet33), + receive + {tcp, CSock, Packet34} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 SMTPUTF8" ++ _, Packet34), + smtp_socket:send( + CSock, <<"MAIL FROM: <испытание@пример.испытание> SMTPUTF8\r\n"/utf8>> + ), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 sender Ok" ++ _, Packet4), + smtp_socket:send(CSock, <<"RCPT TO: <測試@例子.測試>\r\n"/utf8>>), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 recipient Ok" ++ _, Packet5), + smtp_socket:send(CSock, "DATA\r\n"), + receive + {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("354 " ++ _, Packet6), + smtp_socket:send(CSock, <<"Subject: Я помню чудное мгновенье\r\n"/utf8>>), + smtp_socket:send(CSock, <<"To: <測試@例子.測試>\r\n"/utf8>>), + smtp_socket:send(CSock, <<"From: <испытание@пример.испытание>\r\n"/utf8>>), + smtp_socket:send(CSock, "\r\n"), + smtp_socket:send(CSock, <<"Передо мной явилась ты"/utf8>>), + smtp_socket:send(CSock, "\r\n.\r\n"), + receive + {tcp, CSock, Packet7} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 queued as" ++ _, Packet7) + end} + end, + % fun({CSock, _Pid}) -> + % {"Sending DATA with a bare newline", + % 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("250 localhost\r\n", Packet2), + % smtp_socket:send(CSock, "MAIL FROM:\r\n"), + % receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, + % ?assertMatch("250 "++_, Packet3), + % smtp_socket:send(CSock, "RCPT TO: \r\n"), + % receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, + % ?assertMatch("250 "++_, Packet4), + % smtp_socket:send(CSock, "DATA\r\n"), + % receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, + % ?assertMatch("354 "++_, Packet5), + % smtp_socket:send(CSock, "Subject: tls message\r\n"), + % smtp_socket:send(CSock, "To: \r\n"), + % smtp_socket:send(CSock, "From: \r\n"), + % smtp_socket:send(CSock, "\r\n"), + % smtp_socket:send(CSock, "this\r\n"), + % smtp_socket:send(CSock, "body\r\n"), + % smtp_socket:send(CSock, "has\r\n"), + % smtp_socket:send(CSock, "a\r\n"), + % smtp_socket:send(CSock, "bare\n"), + % smtp_socket:send(CSock, "newline\r\n"), + % smtp_socket:send(CSock, "\r\n.\r\n"), + % receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, + % ?assertMatch("451 "++_, Packet6), + % end + % } + % end, + %fun({CSock, _Pid}) -> + % {"Sending DATA with a bare CR", + % 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("250 localhost\r\n", Packet2), + % smtp_socket:send(CSock, "MAIL FROM:\r\n"), + % receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, + % ?assertMatch("250 "++_, Packet3), + % smtp_socket:send(CSock, "RCPT TO: \r\n"), + % receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, + % ?assertMatch("250 "++_, Packet4), + % smtp_socket:send(CSock, "DATA\r\n"), + % receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, + % ?assertMatch("354 "++_, Packet5), + % smtp_socket:send(CSock, "Subject: tls message\r\n"), + % smtp_socket:send(CSock, "To: \r\n"), + % smtp_socket:send(CSock, "From: \r\n"), + % smtp_socket:send(CSock, "\r\n"), + % smtp_socket:send(CSock, "this\r\n"), + % smtp_socket:send(CSock, "\rbody\r\n"), + % smtp_socket:send(CSock, "has\r\n"), + % smtp_socket:send(CSock, "a\r\n"), + % smtp_socket:send(CSock, "bare\r"), + % smtp_socket:send(CSock, "CR\r\n"), + % smtp_socket:send(CSock, "\r\n.\r\n"), + % receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, + % ?assertMatch("451 "++_, Packet6), + % end + % } + % end, + + % fun({CSock, _Pid}) -> + % {"Sending DATA with a bare newline in the headers", + % 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("250 localhost\r\n", Packet2), + % smtp_socket:send(CSock, "MAIL FROM:\r\n"), + % receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, + % ?assertMatch("250 "++_, Packet3), + % smtp_socket:send(CSock, "RCPT TO: \r\n"), + % receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, + % ?assertMatch("250 "++_, Packet4), + % smtp_socket:send(CSock, "DATA\r\n"), + % receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, + % ?assertMatch("354 "++_, Packet5), + % smtp_socket:send(CSock, "Subject: tls message\r\n"), + % smtp_socket:send(CSock, "To: \n"), + % smtp_socket:send(CSock, "From: \r\n"), + % smtp_socket:send(CSock, "\r\n"), + % smtp_socket:send(CSock, "this\r\n"), + % smtp_socket:send(CSock, "body\r\n"), + % smtp_socket:send(CSock, "has\r\n"), + % smtp_socket:send(CSock, "no\r\n"), + % smtp_socket:send(CSock, "bare\r\n"), + % smtp_socket:send(CSock, "newlines\r\n"), + % smtp_socket:send(CSock, "\r\n.\r\n"), + % receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, + % ?assertMatch("451 "++_, Packet6), + % end + % } + % end, + fun({CSock, _Pid}) -> + {"Sending DATA with bare newline on first line of body", 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("250 localhost\r\n", Packet2), + smtp_socket:send(CSock, "MAIL FROM:\r\n"), + receive + {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet3), + smtp_socket:send(CSock, "RCPT TO:\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet4), + smtp_socket:send(CSock, "DATA\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("354 " ++ _, Packet5), + smtp_socket:send(CSock, "Subject: tls message\r\n"), + smtp_socket:send(CSock, "To: \n"), + smtp_socket:send(CSock, "From: \r\n"), + smtp_socket:send(CSock, "\r\n"), + smtp_socket:send(CSock, "this\n"), + smtp_socket:send(CSock, "body\r\n"), + smtp_socket:send(CSock, "has\r\n"), + smtp_socket:send(CSock, "no\r\n"), + smtp_socket:send(CSock, "bare\r\n"), + smtp_socket:send(CSock, "newlines\r\n"), + smtp_socket:send(CSock, "\r\n.\r\n"), + receive + {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("451 " ++ _, Packet6) + end} + end + ]}. 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, "250 SMTPUTF8"++_} -> - 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:\r\n"), - receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 " ++ _, Packet3), - smtp_socket:send(CSock, "RCPT TO:\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 " ++ _, Packet4), - smtp_socket:send(CSock, "RCPT TO:\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 " ++ _, Packet5), - smtp_socket:send(CSock, "RCPT TO:\r\n"), - receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 " ++ _, Packet6), - - smtp_socket:send(CSock, "DATA\r\n"), - receive {tcp, CSock, Packet7} -> smtp_socket:active_once(CSock) end, - ?assertMatch("354 " ++ _, Packet7), - - smtp_socket:send(CSock, "Subject: tls message\r\n"), - smtp_socket:send(CSock, "To: \r\n"), - smtp_socket:send(CSock, "From: \r\n"), - smtp_socket:send(CSock, "\r\n"), - smtp_socket:send(CSock, "message body"), - smtp_socket:send(CSock, "\r\n.\r\n"), - % We sent 3 RCPT TO, so we should have 3 delivery reports - AssertDelivery = fun(_) -> - receive {tcp, CSock, Packet8} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 "++_, Packet8) - end, - lists:foreach(AssertDelivery, [1, 2, 3]), - smtp_socket:send(CSock, "QUIT\r\n"), - receive {tcp, CSock, Packet9} -> smtp_socket:active_once(CSock) end, - ?assertMatch("221 " ++ _, Packet9) - end - } - end - ] - }. + {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, "250 SMTPUTF8" ++ _} -> + 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:\r\n"), + receive + {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet3), + smtp_socket:send(CSock, "RCPT TO:\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet4), + smtp_socket:send(CSock, "RCPT TO:\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet5), + smtp_socket:send(CSock, "RCPT TO:\r\n"), + receive + {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet6), + + smtp_socket:send(CSock, "DATA\r\n"), + receive + {tcp, CSock, Packet7} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("354 " ++ _, Packet7), + + smtp_socket:send(CSock, "Subject: tls message\r\n"), + smtp_socket:send(CSock, "To: \r\n"), + smtp_socket:send(CSock, "From: \r\n"), + smtp_socket:send(CSock, "\r\n"), + smtp_socket:send(CSock, "message body"), + smtp_socket:send(CSock, "\r\n.\r\n"), + % We sent 3 RCPT TO, so we should have 3 delivery reports + AssertDelivery = fun(_) -> + receive + {tcp, CSock, Packet8} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet8) + end, + lists:foreach(AssertDelivery, [1, 2, 3]), + smtp_socket:send(CSock, "QUIT\r\n"), + receive + {tcp, CSock, Packet9} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("221 " ++ _, Packet9) + end} + end + ]}. smtp_session_auth_test_() -> - {foreach, - local, - fun() -> - application:ensure_all_started(gen_smtp), - {ok, Pid} = gen_smtp_server:start( - smtp_server_example, - [{sessionoptions, - [{callbackoptions, [{auth, true}]}]}, - {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}) -> - {"EHLO response includes AUTH", - 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, "EHLO 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-AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)) - end - } - end, - fun({CSock, _Pid}) -> - {"AUTH before EHLO is error", - 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, "AUTH CRAZY\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("503 "++_, Packet4) - end - } - end, - fun({CSock, _Pid}) -> - {"Unknown authentication type", - 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, "EHLO 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-AUTH"++_} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 AUTH"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "AUTH CRAZY\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("504 Unrecognized authentication type\r\n", Packet4) - end - } - end, - - fun({CSock, _Pid}) -> - {"A successful AUTH PLAIN", - 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, "EHLO 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-AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "AUTH PLAIN\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("334\r\n", Packet4), - String = binary_to_list(base64:encode("\0username\0PaSSw0rd")), - smtp_socket:send(CSock, String++"\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("235 Authentication successful.\r\n", Packet5) - end - } - end, - fun({CSock, _Pid}) -> - {"A successful AUTH PLAIN with an identity", - 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, "EHLO 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-AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "AUTH PLAIN\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("334\r\n", Packet4), - String = binary_to_list(base64:encode("username\0username\0PaSSw0rd")), - smtp_socket:send(CSock, String++"\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("235 Authentication successful.\r\n", Packet5) - end - } - end, - fun({CSock, _Pid}) -> - {"A successful immediate AUTH PLAIN", - 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, "EHLO 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-AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - String = binary_to_list(base64:encode("\0username\0PaSSw0rd")), - smtp_socket:send(CSock, "AUTH PLAIN "++String++"\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("235 Authentication successful.\r\n", Packet5) - end - } - end, - fun({CSock, _Pid}) -> - {"A successful immediate AUTH PLAIN with an identity", - 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, "EHLO somehost.com\r\n"), - receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250-localhost\r\n", Packet2), - ?assertMatch("250-localhost\r\n", Packet2), - Foo = fun(F, Acc) -> - receive - {tcp, CSock, "250-AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _R} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - String = binary_to_list(base64:encode("username\0username\0PaSSw0rd")), - smtp_socket:send(CSock, "AUTH PLAIN "++String++"\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("235 Authentication successful.\r\n", Packet5) - end - } - end, - fun({CSock, _Pid}) -> - {"An unsuccessful immediate AUTH PLAIN", - 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, "EHLO somehost.com\r\n"), - receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250-localhost\r\n", Packet2), - ?assertMatch("250-localhost\r\n", Packet2), - Foo = fun(F, Acc) -> - receive - {tcp, CSock, "250-AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - String = binary_to_list(base64:encode("username\0username\0PaSSw0rd2")), - smtp_socket:send(CSock, "AUTH PLAIN "++String++"\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("535 Authentication failed.\r\n", Packet5) - end - } - end, - fun({CSock, _Pid}) -> - {"An unsuccessful AUTH PLAIN", - 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, "EHLO somehost.com\r\n"), - receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250-localhost\r\n", Packet2), - ?assertMatch("250-localhost\r\n", Packet2), - Foo = fun(F, Acc) -> - receive - {tcp, CSock, "250-AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "AUTH PLAIN\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("334\r\n", Packet4), - String = binary_to_list(base64:encode("\0username\0NotThePassword")), - smtp_socket:send(CSock, String++"\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("535 Authentication failed.\r\n", Packet5) - end - } - end, - fun({CSock, _Pid}) -> - {"A successful AUTH LOGIN", - 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, "EHLO 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-AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "AUTH LOGIN\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("334 VXNlcm5hbWU6\r\n", Packet4), - String = binary_to_list(base64:encode("username")), - smtp_socket:send(CSock, String++"\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("334 UGFzc3dvcmQ6\r\n", Packet5), - PString = binary_to_list(base64:encode("PaSSw0rd")), - smtp_socket:send(CSock, PString++"\r\n"), - receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, - ?assertMatch("235 Authentication successful.\r\n", Packet6) - end - } - end, - fun({CSock, _Pid}) -> - {"An unsuccessful AUTH LOGIN", - 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, "EHLO 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-AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "AUTH LOGIN\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("334 VXNlcm5hbWU6\r\n", Packet4), - String = binary_to_list(base64:encode("username2")), - smtp_socket:send(CSock, String++"\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("334 UGFzc3dvcmQ6\r\n", Packet5), - PString = binary_to_list(base64:encode("PaSSw0rd")), - smtp_socket:send(CSock, PString++"\r\n"), - receive {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) end, - ?assertMatch("535 Authentication failed.\r\n", Packet6) - end - } - end, - fun({CSock, _Pid}) -> - {"A successful AUTH CRAM-MD5", - 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, "EHLO 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-AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "AUTH CRAM-MD5\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("334 "++_, Packet4), - - ["334", Seed64] = string:tokens(smtp_util:trim_crlf(Packet4), " "), - Seed = base64:decode_to_string(Seed64), - Digest = smtp_util:compute_cram_digest("PaSSw0rd", Seed), - String = binary_to_list(base64:encode(list_to_binary(["username ", Digest]))), - smtp_socket:send(CSock, String++"\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("235 Authentication successful.\r\n", Packet5) - end - } - end, - fun({CSock, _Pid}) -> - {"An unsuccessful AUTH CRAM-MD5", - 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, "EHLO 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-AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 AUTH"++_Packet3} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "AUTH CRAM-MD5\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("334 "++_, Packet4), - - ["334", Seed64] = string:tokens(smtp_util:trim_crlf(Packet4), " "), - Seed = base64:decode_to_string(Seed64), - Digest = smtp_util:compute_cram_digest("Passw0rd", Seed), - String = binary_to_list(base64:encode(list_to_binary(["username ", Digest]))), - smtp_socket:send(CSock, String++"\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("535 Authentication failed.\r\n", Packet5) - end - } - end - ] - }. + {foreach, local, + fun() -> + application:ensure_all_started(gen_smtp), + {ok, Pid} = gen_smtp_server:start( + smtp_server_example, + [ + {sessionoptions, [{callbackoptions, [{auth, true}]}]}, + {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}) -> + {"EHLO response includes AUTH", 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, "EHLO 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-AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)) + end} + end, + fun({CSock, _Pid}) -> + {"AUTH before EHLO is error", 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, "AUTH CRAZY\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("503 " ++ _, Packet4) + end} + end, + fun({CSock, _Pid}) -> + {"Unknown authentication type", 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, "EHLO 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-AUTH" ++ _} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 AUTH" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "AUTH CRAZY\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("504 Unrecognized authentication type\r\n", Packet4) + end} + end, + + fun({CSock, _Pid}) -> + {"A successful AUTH PLAIN", 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, "EHLO 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-AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "AUTH PLAIN\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("334\r\n", Packet4), + String = binary_to_list(base64:encode("\0username\0PaSSw0rd")), + smtp_socket:send(CSock, String ++ "\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("235 Authentication successful.\r\n", Packet5) + end} + end, + fun({CSock, _Pid}) -> + {"A successful AUTH PLAIN with an identity", 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, "EHLO 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-AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "AUTH PLAIN\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("334\r\n", Packet4), + String = binary_to_list(base64:encode("username\0username\0PaSSw0rd")), + smtp_socket:send(CSock, String ++ "\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("235 Authentication successful.\r\n", Packet5) + end} + end, + fun({CSock, _Pid}) -> + {"A successful immediate AUTH PLAIN", 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, "EHLO 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-AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + String = binary_to_list(base64:encode("\0username\0PaSSw0rd")), + smtp_socket:send(CSock, "AUTH PLAIN " ++ String ++ "\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("235 Authentication successful.\r\n", Packet5) + end} + end, + fun({CSock, _Pid}) -> + {"A successful immediate AUTH PLAIN with an identity", 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, "EHLO somehost.com\r\n"), + receive + {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250-localhost\r\n", Packet2), + ?assertMatch("250-localhost\r\n", Packet2), + Foo = fun(F, Acc) -> + receive + {tcp, CSock, "250-AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _R} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + String = binary_to_list(base64:encode("username\0username\0PaSSw0rd")), + smtp_socket:send(CSock, "AUTH PLAIN " ++ String ++ "\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("235 Authentication successful.\r\n", Packet5) + end} + end, + fun({CSock, _Pid}) -> + {"An unsuccessful immediate AUTH PLAIN", 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, "EHLO somehost.com\r\n"), + receive + {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250-localhost\r\n", Packet2), + ?assertMatch("250-localhost\r\n", Packet2), + Foo = fun(F, Acc) -> + receive + {tcp, CSock, "250-AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + String = binary_to_list(base64:encode("username\0username\0PaSSw0rd2")), + smtp_socket:send(CSock, "AUTH PLAIN " ++ String ++ "\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("535 Authentication failed.\r\n", Packet5) + end} + end, + fun({CSock, _Pid}) -> + {"An unsuccessful AUTH PLAIN", 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, "EHLO somehost.com\r\n"), + receive + {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250-localhost\r\n", Packet2), + ?assertMatch("250-localhost\r\n", Packet2), + Foo = fun(F, Acc) -> + receive + {tcp, CSock, "250-AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "AUTH PLAIN\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("334\r\n", Packet4), + String = binary_to_list(base64:encode("\0username\0NotThePassword")), + smtp_socket:send(CSock, String ++ "\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("535 Authentication failed.\r\n", Packet5) + end} + end, + fun({CSock, _Pid}) -> + {"A successful AUTH LOGIN", 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, "EHLO 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-AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "AUTH LOGIN\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("334 VXNlcm5hbWU6\r\n", Packet4), + String = binary_to_list(base64:encode("username")), + smtp_socket:send(CSock, String ++ "\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("334 UGFzc3dvcmQ6\r\n", Packet5), + PString = binary_to_list(base64:encode("PaSSw0rd")), + smtp_socket:send(CSock, PString ++ "\r\n"), + receive + {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("235 Authentication successful.\r\n", Packet6) + end} + end, + fun({CSock, _Pid}) -> + {"An unsuccessful AUTH LOGIN", 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, "EHLO 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-AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "AUTH LOGIN\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("334 VXNlcm5hbWU6\r\n", Packet4), + String = binary_to_list(base64:encode("username2")), + smtp_socket:send(CSock, String ++ "\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("334 UGFzc3dvcmQ6\r\n", Packet5), + PString = binary_to_list(base64:encode("PaSSw0rd")), + smtp_socket:send(CSock, PString ++ "\r\n"), + receive + {tcp, CSock, Packet6} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("535 Authentication failed.\r\n", Packet6) + end} + end, + fun({CSock, _Pid}) -> + {"A successful AUTH CRAM-MD5", 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, "EHLO 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-AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "AUTH CRAM-MD5\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("334 " ++ _, Packet4), + + ["334", Seed64] = string:tokens(smtp_util:trim_crlf(Packet4), " "), + Seed = base64:decode_to_string(Seed64), + Digest = smtp_util:compute_cram_digest("PaSSw0rd", Seed), + String = binary_to_list(base64:encode(list_to_binary(["username ", Digest]))), + smtp_socket:send(CSock, String ++ "\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("235 Authentication successful.\r\n", Packet5) + end} + end, + fun({CSock, _Pid}) -> + {"An unsuccessful AUTH CRAM-MD5", 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, "EHLO 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-AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 AUTH" ++ _Packet3} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "AUTH CRAM-MD5\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("334 " ++ _, Packet4), + + ["334", Seed64] = string:tokens(smtp_util:trim_crlf(Packet4), " "), + Seed = base64:decode_to_string(Seed64), + Digest = smtp_util:compute_cram_digest("Passw0rd", Seed), + String = binary_to_list(base64:encode(list_to_binary(["username ", Digest]))), + smtp_socket:send(CSock, String ++ "\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("535 Authentication failed.\r\n", Packet5) + end} + end + ]}. smtp_session_tls_test_() -> - {foreach, - local, - fun() -> - application:ensure_all_started(gen_smtp), - {ok, Pid} = gen_smtp_server:start( - smtp_server_example, - [{sessionoptions, - [{tls_options, - [{keyfile, "test/fixtures/mx1.example.com-server.key"}, - {certfile, "test/fixtures/mx1.example.com-server.crt"}]}, - {callbackoptions, [{auth, true}]}]}, - {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}) -> - {"EHLO response includes STARTTLS", - 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, "EHLO 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-STARTTLS"++_} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 STARTTLS"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)) - end - } - end, - fun({CSock, _Pid}) -> - {"STARTTLS does a SSL handshake", - 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, "EHLO 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-STARTTLS"++_} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 STARTTLS"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "STARTTLS\r\n"), - receive {tcp, CSock, Packet4} -> ok end, - ?assertMatch("220 "++_, Packet4), - Result = smtp_socket:to_ssl_client(CSock), - ?assertMatch({ok, _Socket}, Result), - {ok, _Socket} = Result - %smtp_socket:active_once(Socket), - %ssl:send(Socket, "EHLO somehost.com\r\n"), - %receive {ssl, Socket, Packet5} -> smtp_socket:active_once(Socket) end, - %?assertEqual("Foo", Packet5), - end - } - end, - fun({CSock, _Pid}) -> - {"After STARTTLS, EHLO doesn't report STARTTLS", - 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, "EHLO 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-STARTTLS"++_} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 STARTTLS"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "STARTTLS\r\n"), - receive {tcp, CSock, Packet4} -> ok end, - ?assertMatch("220 "++_, Packet4), - Result = smtp_socket:to_ssl_client(CSock), - ?assertMatch({ok, _Socket}, Result), - {ok, Socket} = Result, - smtp_socket:active_once(Socket), - smtp_socket:send(Socket, "EHLO somehost.com\r\n"), - receive {ssl, Socket, Packet5} -> smtp_socket:active_once(Socket) end, - ?assertMatch("250-localhost\r\n", Packet5), - Bar = fun(F, Acc) -> - receive - {ssl, Socket, "250-STARTTLS"++_} -> - smtp_socket:active_once(Socket), - F(F, true); - {ssl, Socket, "250-"++_} -> - smtp_socket:active_once(Socket), - F(F, Acc); - {ssl, Socket, "250 STARTTLS"++_} -> - smtp_socket:active_once(Socket), - true; - {ssl, Socket, "250 "++_} -> - smtp_socket:active_once(Socket), - Acc; - {ssl, Socket, _} -> - smtp_socket:active_once(Socket), - error - end - end, - ?assertEqual(false, Bar(Bar, false)) - end - } - end, - fun({CSock, _Pid}) -> - {"After STARTTLS, re-negotiating STARTTLS is an error", - 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, "EHLO 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-STARTTLS"++_} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 STARTTLS"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "STARTTLS\r\n"), - receive {tcp, CSock, Packet4} -> ok end, - ?assertMatch("220 "++_, Packet4), - Result = smtp_socket:to_ssl_client(CSock), - ?assertMatch({ok, _Socket}, Result), - {ok, Socket} = Result, - smtp_socket:active_once(Socket), - smtp_socket:send(Socket, "EHLO somehost.com\r\n"), - receive {ssl, Socket, Packet5} -> smtp_socket:active_once(Socket) end, - ?assertMatch("250-localhost\r\n", Packet5), - Bar = fun(F, Acc) -> - receive - {ssl, Socket, "250-STARTTLS"++_} -> - smtp_socket:active_once(Socket), - F(F, true); - {ssl, Socket, "250-"++_} -> - smtp_socket:active_once(Socket), - F(F, Acc); - {ssl, Socket, "250 STARTTLS"++_} -> - smtp_socket:active_once(Socket), - true; - {ssl, Socket, "250 "++_} -> - smtp_socket:active_once(Socket), - Acc; - {ssl, Socket, _} -> - smtp_socket:active_once(Socket), - error - end - end, - ?assertEqual(false, Bar(Bar, false)), - smtp_socket:send(Socket, "STARTTLS\r\n"), - receive {ssl, Socket, Packet6} -> smtp_socket:active_once(Socket) end, - ?assertMatch("500 "++_, Packet6) - end - } - end, - fun({CSock, _Pid}) -> - {"STARTTLS can't take any parameters", - 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, "EHLO 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-STARTTLS"++_} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 STARTTLS"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "STARTTLS foo\r\n"), - receive {tcp, CSock, Packet4} -> ok end, - ?assertMatch("501 "++_, Packet4) - end - } - end, - fun({CSock, _Pid}) -> - {"Negotiating STARTTLS twice is an error", - fun() -> - smtp_socket:active_once(CSock), - receive {tcp, CSock, _Packet} -> smtp_socket:active_once(CSock) end, - smtp_socket:send(CSock, "EHLO somehost.com\r\n"), - receive {tcp, CSock, _Packet2} -> smtp_socket:active_once(CSock) end, - ReadExtensions = fun(F, Acc) -> - receive - {tcp, CSock, "250-STARTTLS"++_} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 STARTTLS"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, ReadExtensions(ReadExtensions, false)), - smtp_socket:send(CSock, "STARTTLS\r\n"), - receive {tcp, CSock, _} -> ok end, - {ok, Socket} = smtp_socket:to_ssl_client(CSock), - smtp_socket:active_once(Socket), - smtp_socket:send(Socket, "EHLO somehost.com\r\n"), - receive {ssl, Socket, PacketN} -> smtp_socket:active_once(Socket) end, - ?assertMatch("250-localhost\r\n", PacketN), - Bar = fun(F, Acc) -> - receive - {ssl, Socket, "250-STARTTLS"++_} -> - smtp_socket:active_once(Socket), - F(F, true); - {ssl, Socket, "250-"++_} -> - smtp_socket:active_once(Socket), - F(F, Acc); - {ssl, Socket, "250 STARTTLS"++_} -> - smtp_socket:active_once(Socket), - true; - {ssl, Socket, "250 "++_} -> - smtp_socket:active_once(Socket), - Acc; - {tcp, Socket, _} -> - smtp_socket:active_once(Socket), - error - end - end, - ?assertEqual(false, Bar(Bar, false)), - smtp_socket:send(Socket, "STARTTLS\r\n"), - receive {ssl, Socket, Packet6} -> smtp_socket:active_once(Socket) end, - ?assertMatch("500 "++_, Packet6) - end - } - end, - fun({CSock, _Pid}) -> - {"STARTTLS can't take any parameters", - 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, "EHLO 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-STARTTLS"++_} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 STARTTLS"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "STARTTLS foo\r\n"), - receive {tcp, CSock, Packet4} -> ok end, - ?assertMatch("501 "++_, Packet4) - end - } - end, - fun({CSock, _Pid}) -> - {"After STARTTLS, message is received by server", - fun() -> - smtp_socket:active_once(CSock), - receive {tcp, CSock, _Packet} -> smtp_socket:active_once(CSock) end, - smtp_socket:send(CSock, "EHLO somehost.com\r\n"), - receive {tcp, CSock, _Packet2} -> smtp_socket:active_once(CSock) end, - ReadExtensions = fun(F, Acc) -> - receive - {tcp, CSock, "250-STARTTLS"++_} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 STARTTLS"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, ReadExtensions(ReadExtensions, false)), - smtp_socket:send(CSock, "STARTTLS\r\n"), - receive {tcp, CSock, _} -> ok end, - {ok, Socket} = smtp_socket:to_ssl_client(CSock), - smtp_socket:active_once(Socket), - smtp_socket:send(Socket, "EHLO somehost.com\r\n"), - ReadSSLExtensions = fun(F, Acc) -> - receive - {ssl, Socket, "250-"++_Rest} -> - smtp_socket:active_once(Socket), - F(F, Acc); - {ssl, Socket, "250 "++_} -> - smtp_socket:active_once(Socket), - true; - {ssl, Socket, _R} -> - smtp_socket:active_once(Socket), - error - end - end, - ?assertEqual(true, ReadSSLExtensions(ReadSSLExtensions, false)), - smtp_socket:send(Socket, "MAIL FROM:\r\n"), - receive {ssl, Socket, Packet4} -> smtp_socket:active_once(Socket) end, - ?assertMatch("250 "++_, Packet4), - smtp_socket:send(Socket, "RCPT TO:\r\n"), - receive {ssl, Socket, Packet5} -> smtp_socket:active_once(Socket) end, - ?assertMatch("250 "++_, Packet5), - smtp_socket:send(Socket, "DATA\r\n"), - receive {ssl, Socket, Packet6} -> smtp_socket:active_once(Socket) end, - ?assertMatch("354 "++_, Packet6), - smtp_socket:send(Socket, "Subject: tls message\r\n"), - smtp_socket:send(Socket, "To: \r\n"), - smtp_socket:send(Socket, "From: \r\n"), - smtp_socket:send(Socket, "\r\n"), - smtp_socket:send(Socket, "message body"), - smtp_socket:send(Socket, "\r\n.\r\n"), - receive {ssl, Socket, Packet7} -> smtp_socket:active_once(Socket) end, - ?assertMatch("250 "++_, Packet7) - end - } - end - ] - }. + {foreach, local, + fun() -> + application:ensure_all_started(gen_smtp), + {ok, Pid} = gen_smtp_server:start( + smtp_server_example, + [ + {sessionoptions, [ + {tls_options, [ + {keyfile, "test/fixtures/mx1.example.com-server.key"}, + {certfile, "test/fixtures/mx1.example.com-server.crt"} + ]}, + {callbackoptions, [{auth, true}]} + ]}, + {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}) -> + {"EHLO response includes STARTTLS", 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, "EHLO 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-STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)) + end} + end, + fun({CSock, _Pid}) -> + {"STARTTLS does a SSL handshake", 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, "EHLO 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-STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "STARTTLS\r\n"), + receive + {tcp, CSock, Packet4} -> ok + end, + ?assertMatch("220 " ++ _, Packet4), + Result = smtp_socket:to_ssl_client(CSock), + ?assertMatch({ok, _Socket}, Result), + {ok, _Socket} = Result + %smtp_socket:active_once(Socket), + %ssl:send(Socket, "EHLO somehost.com\r\n"), + %receive {ssl, Socket, Packet5} -> smtp_socket:active_once(Socket) end, + %?assertEqual("Foo", Packet5), + end} + end, + fun({CSock, _Pid}) -> + {"After STARTTLS, EHLO doesn't report STARTTLS", 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, "EHLO 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-STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "STARTTLS\r\n"), + receive + {tcp, CSock, Packet4} -> ok + end, + ?assertMatch("220 " ++ _, Packet4), + Result = smtp_socket:to_ssl_client(CSock), + ?assertMatch({ok, _Socket}, Result), + {ok, Socket} = Result, + smtp_socket:active_once(Socket), + smtp_socket:send(Socket, "EHLO somehost.com\r\n"), + receive + {ssl, Socket, Packet5} -> smtp_socket:active_once(Socket) + end, + ?assertMatch("250-localhost\r\n", Packet5), + Bar = fun(F, Acc) -> + receive + {ssl, Socket, "250-STARTTLS" ++ _} -> + smtp_socket:active_once(Socket), + F(F, true); + {ssl, Socket, "250-" ++ _} -> + smtp_socket:active_once(Socket), + F(F, Acc); + {ssl, Socket, "250 STARTTLS" ++ _} -> + smtp_socket:active_once(Socket), + true; + {ssl, Socket, "250 " ++ _} -> + smtp_socket:active_once(Socket), + Acc; + {ssl, Socket, _} -> + smtp_socket:active_once(Socket), + error + end + end, + ?assertEqual(false, Bar(Bar, false)) + end} + end, + fun({CSock, _Pid}) -> + {"After STARTTLS, re-negotiating STARTTLS is an error", 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, "EHLO 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-STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "STARTTLS\r\n"), + receive + {tcp, CSock, Packet4} -> ok + end, + ?assertMatch("220 " ++ _, Packet4), + Result = smtp_socket:to_ssl_client(CSock), + ?assertMatch({ok, _Socket}, Result), + {ok, Socket} = Result, + smtp_socket:active_once(Socket), + smtp_socket:send(Socket, "EHLO somehost.com\r\n"), + receive + {ssl, Socket, Packet5} -> smtp_socket:active_once(Socket) + end, + ?assertMatch("250-localhost\r\n", Packet5), + Bar = fun(F, Acc) -> + receive + {ssl, Socket, "250-STARTTLS" ++ _} -> + smtp_socket:active_once(Socket), + F(F, true); + {ssl, Socket, "250-" ++ _} -> + smtp_socket:active_once(Socket), + F(F, Acc); + {ssl, Socket, "250 STARTTLS" ++ _} -> + smtp_socket:active_once(Socket), + true; + {ssl, Socket, "250 " ++ _} -> + smtp_socket:active_once(Socket), + Acc; + {ssl, Socket, _} -> + smtp_socket:active_once(Socket), + error + end + end, + ?assertEqual(false, Bar(Bar, false)), + smtp_socket:send(Socket, "STARTTLS\r\n"), + receive + {ssl, Socket, Packet6} -> smtp_socket:active_once(Socket) + end, + ?assertMatch("500 " ++ _, Packet6) + end} + end, + fun({CSock, _Pid}) -> + {"STARTTLS can't take any parameters", 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, "EHLO 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-STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "STARTTLS foo\r\n"), + receive + {tcp, CSock, Packet4} -> ok + end, + ?assertMatch("501 " ++ _, Packet4) + end} + end, + fun({CSock, _Pid}) -> + {"Negotiating STARTTLS twice is an error", fun() -> + smtp_socket:active_once(CSock), + receive + {tcp, CSock, _Packet} -> smtp_socket:active_once(CSock) + end, + smtp_socket:send(CSock, "EHLO somehost.com\r\n"), + receive + {tcp, CSock, _Packet2} -> smtp_socket:active_once(CSock) + end, + ReadExtensions = fun(F, Acc) -> + receive + {tcp, CSock, "250-STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, ReadExtensions(ReadExtensions, false)), + smtp_socket:send(CSock, "STARTTLS\r\n"), + receive + {tcp, CSock, _} -> ok + end, + {ok, Socket} = smtp_socket:to_ssl_client(CSock), + smtp_socket:active_once(Socket), + smtp_socket:send(Socket, "EHLO somehost.com\r\n"), + receive + {ssl, Socket, PacketN} -> smtp_socket:active_once(Socket) + end, + ?assertMatch("250-localhost\r\n", PacketN), + Bar = fun(F, Acc) -> + receive + {ssl, Socket, "250-STARTTLS" ++ _} -> + smtp_socket:active_once(Socket), + F(F, true); + {ssl, Socket, "250-" ++ _} -> + smtp_socket:active_once(Socket), + F(F, Acc); + {ssl, Socket, "250 STARTTLS" ++ _} -> + smtp_socket:active_once(Socket), + true; + {ssl, Socket, "250 " ++ _} -> + smtp_socket:active_once(Socket), + Acc; + {tcp, Socket, _} -> + smtp_socket:active_once(Socket), + error + end + end, + ?assertEqual(false, Bar(Bar, false)), + smtp_socket:send(Socket, "STARTTLS\r\n"), + receive + {ssl, Socket, Packet6} -> smtp_socket:active_once(Socket) + end, + ?assertMatch("500 " ++ _, Packet6) + end} + end, + fun({CSock, _Pid}) -> + {"STARTTLS can't take any parameters", 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, "EHLO 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-STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "STARTTLS foo\r\n"), + receive + {tcp, CSock, Packet4} -> ok + end, + ?assertMatch("501 " ++ _, Packet4) + end} + end, + fun({CSock, _Pid}) -> + {"After STARTTLS, message is received by server", fun() -> + smtp_socket:active_once(CSock), + receive + {tcp, CSock, _Packet} -> smtp_socket:active_once(CSock) + end, + smtp_socket:send(CSock, "EHLO somehost.com\r\n"), + receive + {tcp, CSock, _Packet2} -> smtp_socket:active_once(CSock) + end, + ReadExtensions = fun(F, Acc) -> + receive + {tcp, CSock, "250-STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, ReadExtensions(ReadExtensions, false)), + smtp_socket:send(CSock, "STARTTLS\r\n"), + receive + {tcp, CSock, _} -> ok + end, + {ok, Socket} = smtp_socket:to_ssl_client(CSock), + smtp_socket:active_once(Socket), + smtp_socket:send(Socket, "EHLO somehost.com\r\n"), + ReadSSLExtensions = fun(F, Acc) -> + receive + {ssl, Socket, "250-" ++ _Rest} -> + smtp_socket:active_once(Socket), + F(F, Acc); + {ssl, Socket, "250 " ++ _} -> + smtp_socket:active_once(Socket), + true; + {ssl, Socket, _R} -> + smtp_socket:active_once(Socket), + error + end + end, + ?assertEqual(true, ReadSSLExtensions(ReadSSLExtensions, false)), + smtp_socket:send(Socket, "MAIL FROM:\r\n"), + receive + {ssl, Socket, Packet4} -> smtp_socket:active_once(Socket) + end, + ?assertMatch("250 " ++ _, Packet4), + smtp_socket:send(Socket, "RCPT TO:\r\n"), + receive + {ssl, Socket, Packet5} -> smtp_socket:active_once(Socket) + end, + ?assertMatch("250 " ++ _, Packet5), + smtp_socket:send(Socket, "DATA\r\n"), + receive + {ssl, Socket, Packet6} -> smtp_socket:active_once(Socket) + end, + ?assertMatch("354 " ++ _, Packet6), + smtp_socket:send(Socket, "Subject: tls message\r\n"), + smtp_socket:send(Socket, "To: \r\n"), + smtp_socket:send(Socket, "From: \r\n"), + smtp_socket:send(Socket, "\r\n"), + smtp_socket:send(Socket, "message body"), + smtp_socket:send(Socket, "\r\n.\r\n"), + receive + {ssl, Socket, Packet7} -> smtp_socket:active_once(Socket) + end, + ?assertMatch("250 " ++ _, Packet7) + end} + end + ]}. smtp_session_tls_sni_test_() -> - {foreach, - local, - fun() -> - SniHosts = - [ - {"mx1.example.com", - [{keyfile, "test/fixtures/mx1.example.com-server.key"}, - {certfile, "test/fixtures/mx1.example.com-server.crt"}, - {cacertfile, "test/fixtures/root.crt"}]}, - {"mx2.example.com", - [{keyfile, "test/fixtures/mx2.example.com-server.key"}, - {certfile, "test/fixtures/mx2.example.com-server.crt"}, - {cacertfile, "test/fixtures/root.crt"}]} - ], - application:ensure_all_started(gen_smtp), - {ok, _} = gen_smtp_server:start( - smtp_server_example, - [{sessionoptions, - [{tls_options, [{sni_hosts, SniHosts}]}, - {callbackoptions, [{auth, true}]}]}, - {domain, "localhost"}, - {port, 9876}]), - [Host || {Host, _} <- SniHosts] - end, - fun(_Hosts) -> - gen_smtp_server:stop(gen_smtp_server) - end, - [fun strict_sni/1] - }. + {foreach, local, + fun() -> + SniHosts = + [ + {"mx1.example.com", [ + {keyfile, "test/fixtures/mx1.example.com-server.key"}, + {certfile, "test/fixtures/mx1.example.com-server.crt"}, + {cacertfile, "test/fixtures/root.crt"} + ]}, + {"mx2.example.com", [ + {keyfile, "test/fixtures/mx2.example.com-server.key"}, + {certfile, "test/fixtures/mx2.example.com-server.crt"}, + {cacertfile, "test/fixtures/root.crt"} + ]} + ], + application:ensure_all_started(gen_smtp), + {ok, _} = gen_smtp_server:start( + smtp_server_example, + [ + {sessionoptions, [ + {tls_options, [{sni_hosts, SniHosts}]}, + {callbackoptions, [{auth, true}]} + ]}, + {domain, "localhost"}, + {port, 9876} + ] + ), + [Host || {Host, _} <- SniHosts] + end, + fun(_Hosts) -> + gen_smtp_server:stop(gen_smtp_server) + end, + [fun strict_sni/1]}. strict_sni(Hosts) -> - {"Do strict validation based on SNI", - fun() -> - [begin - {ok, CSock} = smtp_socket:connect(tcp, "localhost", 9876), - smtp_socket:active_once(CSock), - receive {tcp, CSock, Packet} -> smtp_socket:active_once(CSock) end, - ?assertMatch("220 localhost"++_Stuff, Packet), - smtp_socket:send(CSock, "EHLO somehost.com\r\n"), - receive {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250-localhost\r\n", Packet2), - Foo = fun Foo(Acc) -> - receive - {tcp, CSock, "250-STARTTLS"++_} -> - smtp_socket:active_once(CSock), - Foo(true); - {tcp, CSock, "250-"++_Packet3} -> - smtp_socket:active_once(CSock), - Foo(Acc); - {tcp, CSock, "250 STARTTLS"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 "++_Packet3} -> - smtp_socket:active_once(CSock), - Acc; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(false)), - smtp_socket:send(CSock, "STARTTLS\r\n"), - receive {tcp, CSock, Packet4} -> ok end, - ?assertMatch("220 "++_, Packet4), - {ok, TlsSocket} = ssl:connect( - CSock, - [{server_name_indication, Host}, - {verify, verify_peer}, - {cacertfile, "test/fixtures/root.crt"}]), - %% Make sure server selects certificate based on SNI - {ok, Cert} = ssl:peercert(TlsSocket), - verify_cert_hostname(Cert, Host), - smtp_socket:active_once(TlsSocket), - smtp_socket:send(TlsSocket, "EHLO somehost.com\r\n"), - receive {ssl, TlsSocket, Packet5} -> smtp_socket:active_once(TlsSocket) end, - ?assertMatch("250-localhost\r\n", Packet5), - ssl:close(TlsSocket) - end || Host <- Hosts] - end - }. + {"Do strict validation based on SNI", fun() -> + [ + begin + {ok, CSock} = smtp_socket:connect(tcp, "localhost", 9876), + smtp_socket:active_once(CSock), + receive + {tcp, CSock, Packet} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("220 localhost" ++ _Stuff, Packet), + smtp_socket:send(CSock, "EHLO somehost.com\r\n"), + receive + {tcp, CSock, Packet2} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250-localhost\r\n", Packet2), + Foo = fun Foo(Acc) -> + receive + {tcp, CSock, "250-STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + Foo(true); + {tcp, CSock, "250-" ++ _Packet3} -> + smtp_socket:active_once(CSock), + Foo(Acc); + {tcp, CSock, "250 STARTTLS" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 " ++ _Packet3} -> + smtp_socket:active_once(CSock), + Acc; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(false)), + smtp_socket:send(CSock, "STARTTLS\r\n"), + receive + {tcp, CSock, Packet4} -> ok + end, + ?assertMatch("220 " ++ _, Packet4), + {ok, TlsSocket} = ssl:connect( + CSock, + [ + {server_name_indication, Host}, + {verify, verify_peer}, + {cacertfile, "test/fixtures/root.crt"} + ] + ), + %% Make sure server selects certificate based on SNI + {ok, Cert} = ssl:peercert(TlsSocket), + verify_cert_hostname(Cert, Host), + smtp_socket:active_once(TlsSocket), + smtp_socket:send(TlsSocket, "EHLO somehost.com\r\n"), + receive + {ssl, TlsSocket, Packet5} -> smtp_socket:active_once(TlsSocket) + end, + ?assertMatch("250-localhost\r\n", Packet5), + ssl:close(TlsSocket) + end + || Host <- Hosts + ] + end}. verify_cert_hostname(BinCert, Host) -> - DecCert = public_key:pkix_decode_cert(BinCert, otp), - ?assert(public_key:pkix_verify_hostname(DecCert, [{dns_id, Host}])). + DecCert = public_key:pkix_decode_cert(BinCert, otp), + ?assert(public_key:pkix_verify_hostname(DecCert, [{dns_id, Host}])). stray_newline_test_() -> - [ - {"Error out by default", - fun() -> - ?assertEqual(<<"foo">>, check_bare_crlf(<<"foo">>, <<>>, false, 0)), - ?assertEqual(error, check_bare_crlf(<<"foo\n">>, <<>>, false, 0)), - ?assertEqual(error, check_bare_crlf(<<"fo\ro\n">>, <<>>, false, 0)), - ?assertEqual(error, check_bare_crlf(<<"fo\ro\n\r">>, <<>>, false, 0)), - ?assertEqual(<<"foo\r\n">>, check_bare_crlf(<<"foo\r\n">>, <<>>, false, 0)), - ?assertEqual(<<"foo\r">>, check_bare_crlf(<<"foo\r">>, <<>>, false, 0)) - end - }, - {"Fixing them should work", - fun() -> - ?assertEqual(<<"foo">>, check_bare_crlf(<<"foo">>, <<>>, fix, 0)), - ?assertEqual(<<"foo\r\n">>, check_bare_crlf(<<"foo\n">>, <<>>, fix, 0)), - ?assertEqual(<<"fo\r\no\r\n">>, check_bare_crlf(<<"fo\ro\n">>, <<>>, fix, 0)), - ?assertEqual(<<"fo\r\no\r\n\r">>, check_bare_crlf(<<"fo\ro\n\r">>, <<>>, fix, 0)), - ?assertEqual(<<"foo\r\n">>, check_bare_crlf(<<"foo\r\n">>, <<>>, fix, 0)) - end - }, - {"Stripping them should work", - fun() -> - ?assertEqual(<<"foo">>, check_bare_crlf(<<"foo">>, <<>>, strip, 0)), - ?assertEqual(<<"foo">>, check_bare_crlf(<<"fo\ro\n">>, <<>>, strip, 0)), - ?assertEqual(<<"foo\r">>, check_bare_crlf(<<"fo\ro\n\r">>, <<>>, strip, 0)), - ?assertEqual(<<"foo\r\n">>, check_bare_crlf(<<"foo\r\n">>, <<>>, strip, 0)) - end - }, - {"Ignoring them should work", - fun() -> - ?assertEqual(<<"foo">>, check_bare_crlf(<<"foo">>, <<>>, ignore, 0)), - ?assertEqual(<<"fo\ro\n">>, check_bare_crlf(<<"fo\ro\n">>, <<>>, ignore, 0)), - ?assertEqual(<<"fo\ro\n\r">>, check_bare_crlf(<<"fo\ro\n\r">>, <<>>, ignore, 0)), - ?assertEqual(<<"foo\r\n">>, check_bare_crlf(<<"foo\r\n">>, <<>>, ignore, 0)) - end - }, - {"Leading bare LFs should check the previous line", - fun() -> - ?assertEqual(<<"\nfoo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r">>, false, 0)), - ?assertEqual(<<"\r\nfoo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r\n">>, fix, 0)), - ?assertEqual(<<"\nfoo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r">>, fix, 0)), - ?assertEqual(<<"foo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r\n">>, strip, 0)), - ?assertEqual(<<"\nfoo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r">>, strip, 0)), - ?assertEqual(<<"\nfoo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r\n">>, ignore, 0)), - ?assertEqual(error, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r\n">>, false, 0)), - ?assertEqual(<<"\nfoo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r">>, false, 0)) - end - } - ]. - + [ + {"Error out by default", fun() -> + ?assertEqual(<<"foo">>, check_bare_crlf(<<"foo">>, <<>>, false, 0)), + ?assertEqual(error, check_bare_crlf(<<"foo\n">>, <<>>, false, 0)), + ?assertEqual(error, check_bare_crlf(<<"fo\ro\n">>, <<>>, false, 0)), + ?assertEqual(error, check_bare_crlf(<<"fo\ro\n\r">>, <<>>, false, 0)), + ?assertEqual(<<"foo\r\n">>, check_bare_crlf(<<"foo\r\n">>, <<>>, false, 0)), + ?assertEqual(<<"foo\r">>, check_bare_crlf(<<"foo\r">>, <<>>, false, 0)) + end}, + {"Fixing them should work", fun() -> + ?assertEqual(<<"foo">>, check_bare_crlf(<<"foo">>, <<>>, fix, 0)), + ?assertEqual(<<"foo\r\n">>, check_bare_crlf(<<"foo\n">>, <<>>, fix, 0)), + ?assertEqual(<<"fo\r\no\r\n">>, check_bare_crlf(<<"fo\ro\n">>, <<>>, fix, 0)), + ?assertEqual(<<"fo\r\no\r\n\r">>, check_bare_crlf(<<"fo\ro\n\r">>, <<>>, fix, 0)), + ?assertEqual(<<"foo\r\n">>, check_bare_crlf(<<"foo\r\n">>, <<>>, fix, 0)) + end}, + {"Stripping them should work", fun() -> + ?assertEqual(<<"foo">>, check_bare_crlf(<<"foo">>, <<>>, strip, 0)), + ?assertEqual(<<"foo">>, check_bare_crlf(<<"fo\ro\n">>, <<>>, strip, 0)), + ?assertEqual(<<"foo\r">>, check_bare_crlf(<<"fo\ro\n\r">>, <<>>, strip, 0)), + ?assertEqual(<<"foo\r\n">>, check_bare_crlf(<<"foo\r\n">>, <<>>, strip, 0)) + end}, + {"Ignoring them should work", fun() -> + ?assertEqual(<<"foo">>, check_bare_crlf(<<"foo">>, <<>>, ignore, 0)), + ?assertEqual(<<"fo\ro\n">>, check_bare_crlf(<<"fo\ro\n">>, <<>>, ignore, 0)), + ?assertEqual(<<"fo\ro\n\r">>, check_bare_crlf(<<"fo\ro\n\r">>, <<>>, ignore, 0)), + ?assertEqual(<<"foo\r\n">>, check_bare_crlf(<<"foo\r\n">>, <<>>, ignore, 0)) + end}, + {"Leading bare LFs should check the previous line", fun() -> + ?assertEqual(<<"\nfoo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r">>, false, 0)), + ?assertEqual( + <<"\r\nfoo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r\n">>, fix, 0) + ), + ?assertEqual(<<"\nfoo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r">>, fix, 0)), + ?assertEqual(<<"foo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r\n">>, strip, 0)), + ?assertEqual(<<"\nfoo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r">>, strip, 0)), + ?assertEqual( + <<"\nfoo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r\n">>, ignore, 0) + ), + ?assertEqual(error, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r\n">>, false, 0)), + ?assertEqual(<<"\nfoo\r\n">>, check_bare_crlf(<<"\nfoo\r\n">>, <<"bar\r">>, false, 0)) + end} + ]. smtp_session_maxsize_test_() -> - {foreach, - local, - fun() -> - application:ensure_all_started(gen_smtp), - {ok, Pid} = gen_smtp_server:start( - smtp_server_example, - [{sessionoptions, - [{callbackoptions, [{size, 100}]}]}, - {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}) -> - {"Message with ok size", - 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, "EHLO 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 100\r\n"} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-SIZE"++_} -> - error; - {tcp, CSock, "250-"++_} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 PIPELINING"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 SMTPUTF8"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "MAIL FROM:\r\n"), - receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 " ++ _, Packet3), - smtp_socket:send(CSock, "RCPT TO:\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 " ++ _, Packet4), - smtp_socket:send(CSock, "DATA\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("354 " ++ _, Packet5), - smtp_socket:send(CSock, "Subject: tls message\r\n"), - smtp_socket:send(CSock, "To: \r\n"), - smtp_socket:send(CSock, "From: \r\n"), - smtp_socket:send(CSock, "\r\n"), - smtp_socket:send(CSock, "message body"), - smtp_socket:send(CSock, "\r\n.\r\n"), - receive {tcp, CSock, Packet7} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 "++_, Packet7) - end - } - end, - fun({CSock, _Pid}) -> - {"Message with too large size", - 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, "EHLO 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 100\r\n"} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-SIZE"++_} -> - error; - {tcp, CSock, "250-"++_} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 PIPELINING"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 SMTPUTF8"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "MAIL FROM:\r\n"), - receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 " ++ _, Packet3), - smtp_socket:send(CSock, "RCPT TO:\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 " ++ _, Packet4), - smtp_socket:send(CSock, "DATA\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("354 " ++ _, Packet5), - smtp_socket:send(CSock, "Subject: tls message\r\n"), - smtp_socket:send(CSock, "To: \r\n"), - smtp_socket:send(CSock, "From: \r\n"), - smtp_socket:send(CSock, "\r\n"), - smtp_socket:send(CSock, "message body message body message body message body message body"), - smtp_socket:send(CSock, "message body message body message body message body message body"), - smtp_socket:send(CSock, "\r\n.\r\n"), - receive {tcp, CSock, Packet7} -> smtp_socket:active_once(CSock) end, - ?assertMatch("552 "++_, Packet7) - end - } - end, - fun({CSock, _Pid}) -> - {"Message with ok size in FROM extension", - 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, "EHLO 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 100\r\n"} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-SIZE"++_} -> - error; - {tcp, CSock, "250-"++_} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 PIPELINING"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 SMTPUTF8"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "MAIL FROM: SIZE=100\r\n"), - receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 " ++ _, Packet3) - end - } - end, - fun({CSock, _Pid}) -> - {"Message with not ok size in FROM extension", - 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, "EHLO 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 100\r\n"} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-SIZE"++_} -> - error; - {tcp, CSock, "250-"++_} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 PIPELINING"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 SMTPUTF8"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "MAIL FROM: SIZE=101\r\n"), - receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, - ?assertMatch("552 " ++ _, Packet3) - end - } - end ] - }. + {foreach, local, + fun() -> + application:ensure_all_started(gen_smtp), + {ok, Pid} = gen_smtp_server:start( + smtp_server_example, + [ + {sessionoptions, [{callbackoptions, [{size, 100}]}]}, + {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}) -> + {"Message with ok size", 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, "EHLO 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 100\r\n"} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-SIZE" ++ _} -> + error; + {tcp, CSock, "250-" ++ _} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 PIPELINING" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 SMTPUTF8" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "MAIL FROM:\r\n"), + receive + {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet3), + smtp_socket:send(CSock, "RCPT TO:\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet4), + smtp_socket:send(CSock, "DATA\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("354 " ++ _, Packet5), + smtp_socket:send(CSock, "Subject: tls message\r\n"), + smtp_socket:send(CSock, "To: \r\n"), + smtp_socket:send(CSock, "From: \r\n"), + smtp_socket:send(CSock, "\r\n"), + smtp_socket:send(CSock, "message body"), + smtp_socket:send(CSock, "\r\n.\r\n"), + receive + {tcp, CSock, Packet7} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet7) + end} + end, + fun({CSock, _Pid}) -> + {"Message with too large size", 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, "EHLO 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 100\r\n"} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-SIZE" ++ _} -> + error; + {tcp, CSock, "250-" ++ _} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 PIPELINING" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 SMTPUTF8" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "MAIL FROM:\r\n"), + receive + {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet3), + smtp_socket:send(CSock, "RCPT TO:\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet4), + smtp_socket:send(CSock, "DATA\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("354 " ++ _, Packet5), + smtp_socket:send(CSock, "Subject: tls message\r\n"), + smtp_socket:send(CSock, "To: \r\n"), + smtp_socket:send(CSock, "From: \r\n"), + smtp_socket:send(CSock, "\r\n"), + smtp_socket:send( + CSock, "message body message body message body message body message body" + ), + smtp_socket:send( + CSock, "message body message body message body message body message body" + ), + smtp_socket:send(CSock, "\r\n.\r\n"), + receive + {tcp, CSock, Packet7} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("552 " ++ _, Packet7) + end} + end, + fun({CSock, _Pid}) -> + {"Message with ok size in FROM extension", 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, "EHLO 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 100\r\n"} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-SIZE" ++ _} -> + error; + {tcp, CSock, "250-" ++ _} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 PIPELINING" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 SMTPUTF8" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "MAIL FROM: SIZE=100\r\n"), + receive + {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet3) + end} + end, + fun({CSock, _Pid}) -> + {"Message with not ok size in FROM extension", 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, "EHLO 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 100\r\n"} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-SIZE" ++ _} -> + error; + {tcp, CSock, "250-" ++ _} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 PIPELINING" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 SMTPUTF8" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "MAIL FROM: SIZE=101\r\n"), + receive + {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("552 " ++ _, Packet3) + end} + end + ]}. smtp_session_nomaxsize_test_() -> - {foreach, - local, - fun() -> - application:ensure_all_started(gen_smtp), - {ok, Pid} = gen_smtp_server:start( - smtp_server_example, - [{sessionoptions, - [{callbackoptions, [{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}) -> - {"Message with no max size", - 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, "EHLO 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; - {tcp, CSock, "250-"++_} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 PIPELINING"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 SMTPUTF8"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, _Data} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "MAIL FROM:\r\n"), - receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 " ++ _, Packet3), - smtp_socket:send(CSock, "RCPT TO:\r\n"), - receive {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 " ++ _, Packet4), - smtp_socket:send(CSock, "DATA\r\n"), - receive {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) end, - ?assertMatch("354 " ++ _, Packet5), - smtp_socket:send(CSock, "Subject: tls message\r\n"), - smtp_socket:send(CSock, "To: \r\n"), - smtp_socket:send(CSock, "From: \r\n"), - smtp_socket:send(CSock, "\r\n"), - smtp_socket:send(CSock, "message body"), - smtp_socket:send(CSock, "\r\n.\r\n"), - receive {tcp, CSock, Packet7} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 "++_, Packet7) - end - } - end, - fun({CSock, _Pid}) -> - {"Message with ok huge size in FROM extension", - 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, "EHLO 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 100\r\n"} -> - smtp_socket:active_once(CSock), - F(F, true); - {tcp, CSock, "250-SIZE"++_} -> - error; - {tcp, CSock, "250-"++_} -> - smtp_socket:active_once(CSock), - F(F, Acc); - {tcp, CSock, "250 PIPELINING"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, "250 SMTPUTF8"++_} -> - smtp_socket:active_once(CSock), - true; - {tcp, CSock, _} -> - smtp_socket:active_once(CSock), - error - end - end, - ?assertEqual(true, Foo(Foo, false)), - smtp_socket:send(CSock, "MAIL FROM: SIZE=100000000\r\n"), - receive {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) end, - ?assertMatch("250 " ++ _, Packet3) - end - } - end - ] - }. + {foreach, local, + fun() -> + application:ensure_all_started(gen_smtp), + {ok, Pid} = gen_smtp_server:start( + smtp_server_example, + [ + {sessionoptions, [{callbackoptions, [{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}) -> + {"Message with no max size", 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, "EHLO 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; + {tcp, CSock, "250-" ++ _} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 PIPELINING" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 SMTPUTF8" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, _Data} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "MAIL FROM:\r\n"), + receive + {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet3), + smtp_socket:send(CSock, "RCPT TO:\r\n"), + receive + {tcp, CSock, Packet4} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet4), + smtp_socket:send(CSock, "DATA\r\n"), + receive + {tcp, CSock, Packet5} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("354 " ++ _, Packet5), + smtp_socket:send(CSock, "Subject: tls message\r\n"), + smtp_socket:send(CSock, "To: \r\n"), + smtp_socket:send(CSock, "From: \r\n"), + smtp_socket:send(CSock, "\r\n"), + smtp_socket:send(CSock, "message body"), + smtp_socket:send(CSock, "\r\n.\r\n"), + receive + {tcp, CSock, Packet7} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet7) + end} + end, + fun({CSock, _Pid}) -> + {"Message with ok huge size in FROM extension", 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, "EHLO 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 100\r\n"} -> + smtp_socket:active_once(CSock), + F(F, true); + {tcp, CSock, "250-SIZE" ++ _} -> + error; + {tcp, CSock, "250-" ++ _} -> + smtp_socket:active_once(CSock), + F(F, Acc); + {tcp, CSock, "250 PIPELINING" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, "250 SMTPUTF8" ++ _} -> + smtp_socket:active_once(CSock), + true; + {tcp, CSock, _} -> + smtp_socket:active_once(CSock), + error + end + end, + ?assertEqual(true, Foo(Foo, false)), + smtp_socket:send(CSock, "MAIL FROM: SIZE=100000000\r\n"), + receive + {tcp, CSock, Packet3} -> smtp_socket:active_once(CSock) + end, + ?assertMatch("250 " ++ _, Packet3) + end} + end + ]}. -endif. diff --git a/src/mimemail.erl b/src/mimemail.erl index 41a56f8e..cdd8d7fa 100644 --- a/src/mimemail.erl +++ b/src/mimemail.erl @@ -62,721 +62,845 @@ -export([encode/1, encode/2, decode/2, decode/1, get_header_value/2, get_header_value/3, parse_headers/1]). -export([encode_quoted_printable/1, decode_quoted_printable/1]). --export_type([mimetuple/0, - mime_type/0, - mime_subtype/0, - headers/0, - parameters/0, - options/0, - dkim_options/0]). +-export_type([ + mimetuple/0, + mime_type/0, + mime_subtype/0, + headers/0, + parameters/0, + options/0, + dkim_options/0 +]). -include_lib("hut/include/hut.hrl"). -define(DEFAULT_MIME_VERSION, <<"1.0">>). -define(DEFAULT_OPTIONS, [ - {encoding, get_default_encoding()}, % default encoding is utf-8 if we can find the iconv module - {decode_attachments, true}, % should we decode any base64/quoted printable attachments? - {allow_missing_version, true}, % should we assume default mime version - {default_mime_version, ?DEFAULT_MIME_VERSION} % default mime version - ]). - --type mime_type() :: binary(). % `<<"text">>' --type mime_subtype() :: binary(). % `<<"plain">>' --type headers() :: [{binary(), binary()}]. % `[{<<"Content-Type">>, <<"text/plain">>}]' + % default encoding is utf-8 if we can find the iconv module + {encoding, get_default_encoding()}, + % should we decode any base64/quoted printable attachments? + {decode_attachments, true}, + % should we assume default mime version + {allow_missing_version, true}, + % default mime version + {default_mime_version, ?DEFAULT_MIME_VERSION} +]). + +% `<<"text">>' +-type mime_type() :: binary(). +% `<<"plain">>' +-type mime_subtype() :: binary(). +% `[{<<"Content-Type">>, <<"text/plain">>}]' +-type headers() :: [{binary(), binary()}]. -type parameters() :: - #{%% <<"7bit">> | <<"base64">> | <<"quoted-printable">> etc - transfer_encoding => binary(), - %% [{<<"charset">>, <<"utf-8">>} | {<<"boundary">>, binary()} | {<<"name">>, binary()} etc...] - content_type_params => [{binary(), binary()}], - %% <<"inline">> | <<"attachment">> etc... - disposition => binary(), - %% [{<<"filename">>, binary()}, ] - disposition_params => [{binary(), binary()}]}. + %% <<"7bit">> | <<"base64">> | <<"quoted-printable">> etc + #{ + transfer_encoding => binary(), + %% [{<<"charset">>, <<"utf-8">>} | {<<"boundary">>, binary()} | {<<"name">>, binary()} etc...] + content_type_params => [{binary(), binary()}], + %% <<"inline">> | <<"attachment">> etc... + disposition => binary(), + %% [{<<"filename">>, binary()}, ] + disposition_params => [{binary(), binary()}] + }. -type mimetuple() :: { - mime_type(), - mime_subtype(), - headers(), - parameters(), - Body :: binary() | mimetuple() | [mimetuple()] - }. - --type dkim_priv_key() :: {pem_plain, binary()} | - {pem_encrypted, Key::binary(), Passwd::string()}. --type dkim_options() :: [{h, [binary()]} | - {d, binary()} | - {s, binary()} | - {t, now | calendar:datetime()} | - {x, calendar:datetime()} | - {c, {simple | relaxed, simple | relaxed}} | - {a, 'rsa-sha256' | 'ed25519-sha256'} | - {private_key, dkim_priv_key()}]. --type options() :: [{encoding, binary()} | - {decode_attachment, boolean()} | - {dkim, dkim_options()} | - {allow_missing_version, boolean()} | - {default_mime_version, binary()}]. + mime_type(), + mime_subtype(), + headers(), + parameters(), + Body :: binary() | mimetuple() | [mimetuple()] +}. + +-type dkim_priv_key() :: + {pem_plain, binary()} + | {pem_encrypted, Key :: binary(), Passwd :: string()}. +-type dkim_options() :: [ + {h, [binary()]} + | {d, binary()} + | {s, binary()} + | {t, now | calendar:datetime()} + | {x, calendar:datetime()} + | {c, {simple | relaxed, simple | relaxed}} + | {a, 'rsa-sha256' | 'ed25519-sha256'} + | {private_key, dkim_priv_key()} +]. +-type options() :: [ + {encoding, binary()} + | {decode_attachment, boolean()} + | {dkim, dkim_options()} + | {allow_missing_version, boolean()} + | {default_mime_version, binary()} +]. -spec decode(Email :: binary()) -> mimetuple(). %% @doc Decode a MIME email from a binary. decode(All) -> - {Headers, Body} = parse_headers(All), - decode(Headers, Body, ?DEFAULT_OPTIONS). + {Headers, Body} = parse_headers(All), + decode(Headers, Body, ?DEFAULT_OPTIONS). -spec decode(Email :: binary(), Options :: options()) -> mimetuple(). %% @doc Decode with custom options decode(All, Options) when is_binary(All), is_list(Options) -> - {Headers, Body} = parse_headers(All), - decode(Headers, Body, Options). + {Headers, Body} = parse_headers(All), + decode(Headers, Body, Options). decode(OrigHeaders, Body, Options) -> - ?log(debug, "headers: ~p~n", [OrigHeaders]), - Encoding = proplists:get_value(encoding, Options, none), - %FixedHeaders = fix_headers(Headers), - Headers = decode_headers(OrigHeaders, [], Encoding), - case parse_with_comments(get_header_value(<<"MIME-Version">>, Headers)) of - undefined -> - AllowMissingVersion = proplists:get_value(allow_missing_version, Options, false), - case parse_content_type(get_header_value(<<"Content-Type">>, Headers)) of - {<<"multipart">>, _SubType, _Parameters} when AllowMissingVersion -> - MimeVersion = proplists:get_value(default_mime_version, Options, ?DEFAULT_MIME_VERSION), - decode_component(Headers, Body, MimeVersion, Options); - {<<"multipart">>, _SubType, _Parameters} -> - erlang:error(non_mime_multipart); - {Type, SubType, CTParameters} -> - NewBody = decode_body(get_header_value(<<"Content-Transfer-Encoding">>, Headers), - Body, proplists:get_value(<<"charset">>, CTParameters), Encoding), - {Disposition, DispositionParams} = - case parse_content_disposition(get_header_value(<<"Content-Disposition">>, Headers)) of - undefined -> - {<<"inline">>, []}; - Disp -> - Disp - end, - Parameters = #{content_type_params => CTParameters, - disposition => Disposition, - disposition_params => DispositionParams}, - {Type, SubType, Headers, Parameters, NewBody}; - undefined -> - Parameters = #{content_type_params => [{<<"charset">>, <<"us-ascii">>}], - disposition => <<"inline">>, - disposition_params => []}, - {<<"text">>, <<"plain">>, Headers, Parameters, decode_body(get_header_value(<<"Content-Transfer-Encoding">>, Headers), Body)} - end; - Other -> - decode_component(Headers, Body, Other, Options) - end. + ?log(debug, "headers: ~p~n", [OrigHeaders]), + Encoding = proplists:get_value(encoding, Options, none), + %FixedHeaders = fix_headers(Headers), + Headers = decode_headers(OrigHeaders, [], Encoding), + case parse_with_comments(get_header_value(<<"MIME-Version">>, Headers)) of + undefined -> + AllowMissingVersion = proplists:get_value(allow_missing_version, Options, false), + case parse_content_type(get_header_value(<<"Content-Type">>, Headers)) of + {<<"multipart">>, _SubType, _Parameters} when AllowMissingVersion -> + MimeVersion = proplists:get_value(default_mime_version, Options, ?DEFAULT_MIME_VERSION), + decode_component(Headers, Body, MimeVersion, Options); + {<<"multipart">>, _SubType, _Parameters} -> + erlang:error(non_mime_multipart); + {Type, SubType, CTParameters} -> + NewBody = decode_body( + get_header_value(<<"Content-Transfer-Encoding">>, Headers), + Body, + proplists:get_value(<<"charset">>, CTParameters), + Encoding + ), + {Disposition, DispositionParams} = + case parse_content_disposition(get_header_value(<<"Content-Disposition">>, Headers)) of + undefined -> + {<<"inline">>, []}; + Disp -> + Disp + end, + Parameters = #{ + content_type_params => CTParameters, + disposition => Disposition, + disposition_params => DispositionParams + }, + {Type, SubType, Headers, Parameters, NewBody}; + undefined -> + Parameters = #{ + content_type_params => [{<<"charset">>, <<"us-ascii">>}], + disposition => <<"inline">>, + disposition_params => [] + }, + {<<"text">>, <<"plain">>, Headers, Parameters, + decode_body(get_header_value(<<"Content-Transfer-Encoding">>, Headers), Body)} + end; + Other -> + decode_component(Headers, Body, Other, Options) + end. -spec encode(MimeMail :: mimetuple()) -> binary(). encode(MimeMail) -> - encode(MimeMail, []). + encode(MimeMail, []). %% @doc Encode a MIME tuple to a binary. encode({Type, Subtype, Headers, ContentTypeParams, Parts}, Options) -> - {FixedParams, FixedHeaders} = ensure_content_headers(Type, Subtype, ContentTypeParams, Headers, Parts, true), - CheckedHeaders = check_headers(FixedHeaders), - EncodedBody = binstr:join( - encode_component(Type, Subtype, CheckedHeaders, FixedParams, Parts), - "\r\n"), - EncodedHeaders = encode_headers(CheckedHeaders), - SignedHeaders = case proplists:get_value(dkim, Options) of - undefined -> EncodedHeaders; - DKIM -> dkim_sign_email(EncodedHeaders, EncodedBody, DKIM) - end, - list_to_binary([binstr:join(SignedHeaders, "\r\n"), - "\r\n\r\n", - EncodedBody]); + {FixedParams, FixedHeaders} = ensure_content_headers(Type, Subtype, ContentTypeParams, Headers, Parts, true), + CheckedHeaders = check_headers(FixedHeaders), + EncodedBody = binstr:join( + encode_component(Type, Subtype, CheckedHeaders, FixedParams, Parts), + "\r\n" + ), + EncodedHeaders = encode_headers(CheckedHeaders), + SignedHeaders = + case proplists:get_value(dkim, Options) of + undefined -> EncodedHeaders; + DKIM -> dkim_sign_email(EncodedHeaders, EncodedBody, DKIM) + end, + list_to_binary([ + binstr:join(SignedHeaders, "\r\n"), + "\r\n\r\n", + EncodedBody + ]); encode(_, _) -> - ?log(debug, "Not a mime-decoded DATA~n"), - erlang:error(non_mime). - + ?log(debug, "Not a mime-decoded DATA~n"), + erlang:error(non_mime). decode_headers(Headers, _, none) -> - Headers; + Headers; decode_headers([], Acc, _Charset) -> - lists:reverse(Acc); + lists:reverse(Acc); decode_headers([{Key, Value} | Headers], Acc, Charset) -> - decode_headers(Headers, [{Key, decode_header(Value, Charset)} | Acc], Charset). + decode_headers(Headers, [{Key, decode_header(Value, Charset)} | Acc], Charset). decode_header(Value, Charset) -> - RTokens = tokenize_header(Value, []), - Tokens = lists:reverse(RTokens), - Decoded = try decode_header_tokens_strict(Tokens, Charset) - catch Type:Reason:Stacktrace -> - case decode_header_tokens_permissive(Tokens, Charset, []) of - {ok, Dec} -> Dec; - error -> - % re-throw original error - erlang:raise(Type, Reason, Stacktrace) - end - end, - iolist_to_binary(Decoded). - --type hdr_token() :: binary() | {Encoding::binary(), Data::binary()}. + RTokens = tokenize_header(Value, []), + Tokens = lists:reverse(RTokens), + Decoded = + try + decode_header_tokens_strict(Tokens, Charset) + catch + Type:Reason:Stacktrace -> + case decode_header_tokens_permissive(Tokens, Charset, []) of + {ok, Dec} -> + Dec; + error -> + % re-throw original error + erlang:raise(Type, Reason, Stacktrace) + end + end, + iolist_to_binary(Decoded). + +-type hdr_token() :: binary() | {Encoding :: binary(), Data :: binary()}. -spec tokenize_header(binary(), [hdr_token()]) -> [hdr_token()]. tokenize_header(<<>>, Acc) -> Acc; tokenize_header(Value, Acc) -> - %% maybe replace "?([^\s]+)\\?" with "?([^\s]*)\\?"? - %% see msg lvuvmm593b8s7pqqfhu7cdtqd4g4najh - %% Subject: =?utf-8?Q??= - %% =?utf-8?Q?=D0=9F=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B4=D0=B8=D1=82=D0=B5=20?= - %% =?utf-8?Q?=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8E=20?= - %% =?utf-8?Q?=D0=B2=20Moy-Rebenok.ru?= - - case re:run(Value, "=\\?([-A-Za-z0-9_]+)\\?([qQbB])\\?([^\s]+)\\?=", [ungreedy]) of - nomatch -> - [Value | Acc]; - {match,[{AllStart, AllLen},{EncodingStart, EncodingLen},{TypeStart, _},{DataStart, DataLen}]} -> - %% RFC 2047 #2 (encoded-word) - Encoding = binstr:substr(Value, EncodingStart+1, EncodingLen), - Type = binstr:to_lower(binstr:substr(Value, TypeStart+1, 1)), - Data = binstr:substr(Value, DataStart+1, DataLen), - - EncodedData = - case Type of - <<"q">> -> - %% RFC 2047 #5. (3) - decode_quoted_printable(binary:replace(Data, <<"_">>, <<"=20">>, [global])); - <<"b">> -> - decode_base64(binary:replace(Data, <<"_">>, <<" ">>, [global])) - end, - - - Offset = case re:run(binstr:substr(Value, AllStart + AllLen + 1), "^([\s\t\n\r]+)=\\?[-A-Za-z0-9_]+\\?[^\s]\\?[^\s]+\\?=", [ungreedy]) of - nomatch -> - % no 2047 block immediately following - 1; - {match,[{_, _},{_, WhiteSpaceLen}]} -> - 1+ WhiteSpaceLen - end, - - NewAcc = case binstr:substr(Value, 1, AllStart) of - <<>> -> [{fix_encoding(Encoding), EncodedData} | Acc]; - Other -> [{fix_encoding(Encoding), EncodedData}, Other | Acc] - end, - tokenize_header(binstr:substr(Value, AllStart + AllLen + Offset), NewAcc) - end. - + %% maybe replace "?([^\s]+)\\?" with "?([^\s]*)\\?"? + %% see msg lvuvmm593b8s7pqqfhu7cdtqd4g4najh + %% Subject: =?utf-8?Q??= + %% =?utf-8?Q?=D0=9F=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B4=D0=B8=D1=82=D0=B5=20?= + %% =?utf-8?Q?=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8E=20?= + %% =?utf-8?Q?=D0=B2=20Moy-Rebenok.ru?= + + case re:run(Value, "=\\?([-A-Za-z0-9_]+)\\?([qQbB])\\?([^\s]+)\\?=", [ungreedy]) of + nomatch -> + [Value | Acc]; + {match, [{AllStart, AllLen}, {EncodingStart, EncodingLen}, {TypeStart, _}, {DataStart, DataLen}]} -> + %% RFC 2047 #2 (encoded-word) + Encoding = binstr:substr(Value, EncodingStart + 1, EncodingLen), + Type = binstr:to_lower(binstr:substr(Value, TypeStart + 1, 1)), + Data = binstr:substr(Value, DataStart + 1, DataLen), + + EncodedData = + case Type of + <<"q">> -> + %% RFC 2047 #5. (3) + decode_quoted_printable(binary:replace(Data, <<"_">>, <<"=20">>, [global])); + <<"b">> -> + decode_base64(binary:replace(Data, <<"_">>, <<" ">>, [global])) + end, + + Offset = + case + re:run( + binstr:substr(Value, AllStart + AllLen + 1), + "^([\s\t\n\r]+)=\\?[-A-Za-z0-9_]+\\?[^\s]\\?[^\s]+\\?=", + [ungreedy] + ) + of + nomatch -> + % no 2047 block immediately following + 1; + {match, [{_, _}, {_, WhiteSpaceLen}]} -> + 1 + WhiteSpaceLen + end, + + NewAcc = + case binstr:substr(Value, 1, AllStart) of + <<>> -> [{fix_encoding(Encoding), EncodedData} | Acc]; + Other -> [{fix_encoding(Encoding), EncodedData}, Other | Acc] + end, + tokenize_header(binstr:substr(Value, AllStart + AllLen + Offset), NewAcc) + end. decode_header_tokens_strict([], _) -> - []; + []; decode_header_tokens_strict([{Encoding, Data} | Tokens], Charset) -> - {ok, S} = convert(Charset, Encoding, Data), - [S | decode_header_tokens_strict(Tokens, Charset)]; + {ok, S} = convert(Charset, Encoding, Data), + [S | decode_header_tokens_strict(Tokens, Charset)]; decode_header_tokens_strict([Data | Tokens], Charset) -> - [Data | decode_header_tokens_strict(Tokens, Charset)]. + [Data | decode_header_tokens_strict(Tokens, Charset)]. %% this decoder can handle folded not-by-RFC UTF headers, when somebody split %% multibyte string not by characters, but by bytes. It first join folded %% string and only then decode it with iconv. decode_header_tokens_permissive([], _, [Result]) when is_binary(Result) -> - {ok, Result}; + {ok, Result}; decode_header_tokens_permissive([], _, Stack) -> - case lists:all(fun erlang:is_binary/1, Stack) of - true -> {ok, lists:reverse(Stack)}; - false -> error - end; + case lists:all(fun erlang:is_binary/1, Stack) of + true -> {ok, lists:reverse(Stack)}; + false -> error + end; decode_header_tokens_permissive([{Enc, Data} | Tokens], Charset, [{Enc, PrevData} | Stack]) -> - NewData = iolist_to_binary([PrevData, Data]), - {ok, S} = convert(Charset, Enc, NewData), - decode_header_tokens_permissive(Tokens, Charset, [S | Stack]); -decode_header_tokens_permissive([NextToken | _] = Tokens, Charset, [{_, _} | Stack]) - when is_binary(NextToken) orelse is_tuple(NextToken) -> - %% practically very rare case "=?utf-8?Q?BROKEN?=\r\n\t=?windows-1251?Q?maybe-broken?=" - %% or "=?utf-8?Q?BROKEN?= raw-ascii-string" - %% drop broken value from stack - decode_header_tokens_permissive(Tokens, Charset, Stack); + NewData = iolist_to_binary([PrevData, Data]), + {ok, S} = convert(Charset, Enc, NewData), + decode_header_tokens_permissive(Tokens, Charset, [S | Stack]); +decode_header_tokens_permissive([NextToken | _] = Tokens, Charset, [{_, _} | Stack]) when + is_binary(NextToken) orelse is_tuple(NextToken) +-> + %% practically very rare case "=?utf-8?Q?BROKEN?=\r\n\t=?windows-1251?Q?maybe-broken?=" + %% or "=?utf-8?Q?BROKEN?= raw-ascii-string" + %% drop broken value from stack + decode_header_tokens_permissive(Tokens, Charset, Stack); decode_header_tokens_permissive([Data | Tokens], Charset, Stack) -> - decode_header_tokens_permissive(Tokens, Charset, [Data | Stack]). - + decode_header_tokens_permissive(Tokens, Charset, [Data | Stack]). %% x-binaryenc is not a real encoding and is not used for text, so let it pass through convert(_To, <<"x-binaryenc">>, Data) -> {ok, Data}; convert(To, From, Data) -> - Result = iconv:convert(From, To, Data), - {ok, Result}. - + Result = iconv:convert(From, To, Data), + {ok, Result}. decode_component(Headers, Body, MimeVsn = <<"1.0", _/binary>>, Options) -> - case parse_content_disposition(get_header_value(<<"Content-Disposition">>, Headers)) of - {Disposition, DispositionParams} -> - ok; - _ -> % defaults - Disposition = <<"inline">>, - DispositionParams = [] - end, - - case parse_content_type(get_header_value(<<"Content-Type">>, Headers)) of - {<<"multipart">>, SubType, Parameters} -> - case proplists:get_value(<<"boundary">>, Parameters) of - undefined -> - erlang:error(no_boundary); - Boundary -> - ?log(debug, "this is a multipart email of type: ~s and boundary ~s~n", [SubType, Boundary]), - Parameters2 = #{content_type_params => Parameters, - disposition => Disposition, - disposition_params => DispositionParams}, - {<<"multipart">>, SubType, Headers, Parameters2, split_body_by_boundary(Body, list_to_binary(["--", Boundary]), MimeVsn, Options)} - end; - {<<"message">>, <<"rfc822">>, Parameters} -> - {NewHeaders, NewBody} = parse_headers(Body), - Parameters2 = #{content_type_params => Parameters, - disposition => Disposition, - disposition_params => DispositionParams}, - {<<"message">>, <<"rfc822">>, Headers, Parameters2, decode(NewHeaders, NewBody, Options)}; - {Type, SubType, Parameters} -> - ?log(debug, "body is ~s/~s~n", [Type, SubType]), - Parameters2 = #{content_type_params => Parameters, - disposition => Disposition, - disposition_params => DispositionParams}, - {Type, SubType, Headers, Parameters2, decode_body(get_header_value(<<"Content-Transfer-Encoding">>, Headers), Body, proplists:get_value(<<"charset">>, Parameters), proplists:get_value(encoding, Options, none))}; - undefined -> % defaults - Type = <<"text">>, - SubType = <<"plain">>, - Parameters = #{content_type_params => [{<<"charset">>, <<"us-ascii">>}], - disposition => Disposition, - disposition_params => DispositionParams}, - {Type, SubType, Headers, Parameters, decode_body(get_header_value(<<"Content-Transfer-Encoding">>, Headers), Body)} - end; + case parse_content_disposition(get_header_value(<<"Content-Disposition">>, Headers)) of + {Disposition, DispositionParams} -> + ok; + % defaults + _ -> + Disposition = <<"inline">>, + DispositionParams = [] + end, + + case parse_content_type(get_header_value(<<"Content-Type">>, Headers)) of + {<<"multipart">>, SubType, Parameters} -> + case proplists:get_value(<<"boundary">>, Parameters) of + undefined -> + erlang:error(no_boundary); + Boundary -> + ?log(debug, "this is a multipart email of type: ~s and boundary ~s~n", [SubType, Boundary]), + Parameters2 = #{ + content_type_params => Parameters, + disposition => Disposition, + disposition_params => DispositionParams + }, + {<<"multipart">>, SubType, Headers, Parameters2, + split_body_by_boundary(Body, list_to_binary(["--", Boundary]), MimeVsn, Options)} + end; + {<<"message">>, <<"rfc822">>, Parameters} -> + {NewHeaders, NewBody} = parse_headers(Body), + Parameters2 = #{ + content_type_params => Parameters, + disposition => Disposition, + disposition_params => DispositionParams + }, + {<<"message">>, <<"rfc822">>, Headers, Parameters2, decode(NewHeaders, NewBody, Options)}; + {Type, SubType, Parameters} -> + ?log(debug, "body is ~s/~s~n", [Type, SubType]), + Parameters2 = #{ + content_type_params => Parameters, + disposition => Disposition, + disposition_params => DispositionParams + }, + {Type, SubType, Headers, Parameters2, + decode_body( + get_header_value(<<"Content-Transfer-Encoding">>, Headers), + Body, + proplists:get_value(<<"charset">>, Parameters), + proplists:get_value(encoding, Options, none) + )}; + % defaults + undefined -> + Type = <<"text">>, + SubType = <<"plain">>, + Parameters = #{ + content_type_params => [{<<"charset">>, <<"us-ascii">>}], + disposition => Disposition, + disposition_params => DispositionParams + }, + {Type, SubType, Headers, Parameters, + decode_body(get_header_value(<<"Content-Transfer-Encoding">>, Headers), Body)} + end; decode_component(_Headers, _Body, Other, _Options) -> - erlang:error({mime_version, Other}). + erlang:error({mime_version, Other}). -spec get_header_value(Needle :: binary(), Headers :: [{binary(), binary()}], Default :: any()) -> binary() | any(). %% @doc Do a case-insensitive header lookup to return that header's value, or the specified default. get_header_value(Needle, Headers, Default) -> - ?log(debug, "Headers: ~p~n", [Headers]), - NeedleLower = binstr:to_lower(Needle), - F = - fun({Header, _Value}) -> - binstr:to_lower(Header) =:= NeedleLower - end, - case lists:search(F, Headers) of - % TODO if there's duplicate headers, should we use the first or the last? - {value, {_Header, Value}} -> - Value; - false -> - Default - end. + ?log(debug, "Headers: ~p~n", [Headers]), + NeedleLower = binstr:to_lower(Needle), + F = + fun({Header, _Value}) -> + binstr:to_lower(Header) =:= NeedleLower + end, + case lists:search(F, Headers) of + % TODO if there's duplicate headers, should we use the first or the last? + {value, {_Header, Value}} -> + Value; + false -> + Default + end. -spec get_header_value(Needle :: binary(), Headers :: [{binary(), binary()}]) -> binary() | 'undefined'. %% @doc Do a case-insensitive header lookup to return the header's value, or `undefined'. get_header_value(Needle, Headers) -> - get_header_value(Needle, Headers, undefined). + get_header_value(Needle, Headers, undefined). --spec parse_with_comments(Value :: binary()) -> binary() | no_return(); - (Value :: atom()) -> atom(). +-spec parse_with_comments + (Value :: binary()) -> binary() | no_return(); + (Value :: atom()) -> atom(). parse_with_comments(Value) when is_binary(Value) -> - parse_with_comments(Value, [], 0, false); + parse_with_comments(Value, [], 0, false); parse_with_comments(Value) -> - Value. + Value. --spec parse_with_comments(Value :: binary(), Acc :: list(), Depth :: non_neg_integer(), Quotes :: boolean()) -> binary() | no_return(). +-spec parse_with_comments(Value :: binary(), Acc :: list(), Depth :: non_neg_integer(), Quotes :: boolean()) -> + binary() | no_return(). parse_with_comments(<<>>, _Acc, _Depth, Quotes) when Quotes -> - erlang:error(unterminated_quotes); + erlang:error(unterminated_quotes); parse_with_comments(<<>>, _Acc, Depth, _Quotes) when Depth > 0 -> - erlang:error(unterminated_comment); + erlang:error(unterminated_comment); parse_with_comments(<<>>, Acc, _Depth, _Quotes) -> - binstr:strip(list_to_binary(lists:reverse(Acc))); + binstr:strip(list_to_binary(lists:reverse(Acc))); parse_with_comments(<<$\\, H, Tail/binary>>, Acc, Depth, Quotes) when Depth > 0, H > 32, H < 127 -> - parse_with_comments(Tail, Acc, Depth, Quotes); + parse_with_comments(Tail, Acc, Depth, Quotes); parse_with_comments(<<$\\, Tail/binary>>, Acc, Depth, Quotes) when Depth > 0 -> - parse_with_comments(Tail, Acc, Depth, Quotes); + parse_with_comments(Tail, Acc, Depth, Quotes); parse_with_comments(<<$\\, H, Tail/binary>>, Acc, Depth, Quotes) when H > 32, H < 127 -> - parse_with_comments(Tail, [H | Acc], Depth, Quotes); + parse_with_comments(Tail, [H | Acc], Depth, Quotes); parse_with_comments(<<$\\, Tail/binary>>, Acc, Depth, Quotes) -> - parse_with_comments(Tail, [$\\ | Acc], Depth, Quotes); + parse_with_comments(Tail, [$\\ | Acc], Depth, Quotes); parse_with_comments(<<$(, Tail/binary>>, Acc, Depth, Quotes) when not Quotes -> - parse_with_comments(Tail, Acc, Depth + 1, Quotes); + parse_with_comments(Tail, Acc, Depth + 1, Quotes); parse_with_comments(<<$), Tail/binary>>, Acc, Depth, Quotes) when Depth > 0, not Quotes -> - parse_with_comments(Tail, Acc, Depth - 1, Quotes); + parse_with_comments(Tail, Acc, Depth - 1, Quotes); parse_with_comments(<<_, Tail/binary>>, Acc, Depth, Quotes) when Depth > 0 -> - parse_with_comments(Tail, Acc, Depth, Quotes); -parse_with_comments(<<$", T/binary>>, Acc, Depth, true) -> %" - parse_with_comments(T, Acc, Depth, false); -parse_with_comments(<<$", T/binary>>, Acc, Depth, false) -> %" - parse_with_comments(T, Acc, Depth, true); + parse_with_comments(Tail, Acc, Depth, Quotes); +%" +parse_with_comments(<<$", T/binary>>, Acc, Depth, true) -> + parse_with_comments(T, Acc, Depth, false); +%" +parse_with_comments(<<$", T/binary>>, Acc, Depth, false) -> + parse_with_comments(T, Acc, Depth, true); parse_with_comments(<>, Acc, Depth, Quotes) -> - parse_with_comments(Tail, [H | Acc], Depth, Quotes). + parse_with_comments(Tail, [H | Acc], Depth, Quotes). --spec parse_content_type(Value :: 'undefined') -> 'undefined'; - (Value :: binary()) -> {binary(), binary(), [{binary(), binary()}]}. +-spec parse_content_type + (Value :: 'undefined') -> 'undefined'; + (Value :: binary()) -> {binary(), binary(), [{binary(), binary()}]}. parse_content_type(undefined) -> - undefined; + undefined; parse_content_type(String) -> - try parse_content_disposition(String) of - {RawType, Parameters} -> - case binstr:strchr(RawType, $/) of - Index when Index < 2 -> - throw(bad_content_type); - Index -> - Type = binstr:substr(RawType, 1, Index - 1), - SubType = binstr:substr(RawType, Index + 1), - {binstr:to_lower(Type), binstr:to_lower(SubType), Parameters} - end - catch - bad_disposition -> - throw(bad_content_type) - end. - --spec parse_content_disposition(Value :: 'undefined') -> 'undefined'; - (String :: binary()) -> {binary(), [{binary(), binary()}]}. + try parse_content_disposition(String) of + {RawType, Parameters} -> + case binstr:strchr(RawType, $/) of + Index when Index < 2 -> + throw(bad_content_type); + Index -> + Type = binstr:substr(RawType, 1, Index - 1), + SubType = binstr:substr(RawType, Index + 1), + {binstr:to_lower(Type), binstr:to_lower(SubType), Parameters} + end + catch + bad_disposition -> + throw(bad_content_type) + end. + +-spec parse_content_disposition + (Value :: 'undefined') -> 'undefined'; + (String :: binary()) -> {binary(), [{binary(), binary()}]}. parse_content_disposition(undefined) -> - undefined; + undefined; parse_content_disposition(String) -> - [Disposition | Parameters] = binstr:split(parse_with_comments(String), <<";">>), - F = - fun(X) -> - Y = binstr:strip(binstr:strip(X), both, $\t), - case binstr:strchr(Y, $=) of - Index when Index < 2 -> - throw(bad_disposition); - Index -> - Key = binstr:substr(Y, 1, Index - 1), - Value = binstr:substr(Y, Index + 1), - {binstr:to_lower(Key), Value} - end - end, - Params = lists:map(F, Parameters), - {binstr:to_lower(Disposition), Params}. + [Disposition | Parameters] = binstr:split(parse_with_comments(String), <<";">>), + F = + fun(X) -> + Y = binstr:strip(binstr:strip(X), both, $\t), + case binstr:strchr(Y, $=) of + Index when Index < 2 -> + throw(bad_disposition); + Index -> + Key = binstr:substr(Y, 1, Index - 1), + Value = binstr:substr(Y, Index + 1), + {binstr:to_lower(Key), Value} + end + end, + Params = lists:map(F, Parameters), + {binstr:to_lower(Disposition), Params}. split_body_by_boundary(Body, Boundary, MimeVsn, Options) -> - % find the indices of the first and last boundary - case [binstr:strpos(Body, Boundary), binstr:strpos(Body, list_to_binary([Boundary, "--"]))] of - [0, _] -> - erlang:error(missing_boundary); - [_, 0] -> - erlang:error(missing_last_boundary); - [Start, End] -> - NewBody = binstr:substr(Body, Start + byte_size(Boundary), End - Start), - % from now on, we can be sure that each boundary is preceded by a CRLF - Parts = split_body_by_boundary_(NewBody, list_to_binary(["\r\n", Boundary]), [], Options), - [decode_component(Headers, Body2, MimeVsn, Options) || {Headers, Body2} <- [V || {_, Body3} = V <- Parts, byte_size(Body3) =/= 0]] - end. + % find the indices of the first and last boundary + case [binstr:strpos(Body, Boundary), binstr:strpos(Body, list_to_binary([Boundary, "--"]))] of + [0, _] -> + erlang:error(missing_boundary); + [_, 0] -> + erlang:error(missing_last_boundary); + [Start, End] -> + NewBody = binstr:substr(Body, Start + byte_size(Boundary), End - Start), + % from now on, we can be sure that each boundary is preceded by a CRLF + Parts = split_body_by_boundary_(NewBody, list_to_binary(["\r\n", Boundary]), [], Options), + [ + decode_component(Headers, Body2, MimeVsn, Options) + || {Headers, Body2} <- [V || {_, Body3} = V <- Parts, byte_size(Body3) =/= 0] + ] + end. split_body_by_boundary_(<<>>, _Boundary, Acc, _Options) -> - lists:reverse(Acc); + lists:reverse(Acc); split_body_by_boundary_(Body, Boundary, Acc, Options) -> - % trim the incomplete first line - TrimmedBody = binstr:substr(Body, binstr:strpos(Body, "\r\n") + 2), - case binstr:strpos(TrimmedBody, Boundary) of - 0 -> - lists:reverse([{[], TrimmedBody} | Acc]); - Index -> - {ParsedHdrs, BodyRest} = parse_headers(binstr:substr(TrimmedBody, 1, Index - 1)), - DecodedHdrs = decode_headers(ParsedHdrs, [], proplists:get_value(encoding, Options, none)), - split_body_by_boundary_(binstr:substr(TrimmedBody, Index + byte_size(Boundary)), Boundary, - [{DecodedHdrs, BodyRest} | Acc], Options) - end. + % trim the incomplete first line + TrimmedBody = binstr:substr(Body, binstr:strpos(Body, "\r\n") + 2), + case binstr:strpos(TrimmedBody, Boundary) of + 0 -> + lists:reverse([{[], TrimmedBody} | Acc]); + Index -> + {ParsedHdrs, BodyRest} = parse_headers(binstr:substr(TrimmedBody, 1, Index - 1)), + DecodedHdrs = decode_headers(ParsedHdrs, [], proplists:get_value(encoding, Options, none)), + split_body_by_boundary_( + binstr:substr(TrimmedBody, Index + byte_size(Boundary)), + Boundary, + [{DecodedHdrs, BodyRest} | Acc], + Options + ) + end. -spec parse_headers(Body :: binary()) -> {[{binary(), binary()}], binary()}. %% @doc Parse the headers off of a message and return a list of headers and the trailing body. parse_headers(Body) -> - case binstr:strpos(Body, "\r\n") of - 0 -> - {[], Body}; - 1 -> - {[], binstr:substr(Body, 3)}; - Index -> - parse_headers(binstr:substr(Body, Index+2), binstr:substr(Body, 1, Index - 1), []) - end. - + case binstr:strpos(Body, "\r\n") of + 0 -> + {[], Body}; + 1 -> + {[], binstr:substr(Body, 3)}; + Index -> + parse_headers(binstr:substr(Body, Index + 2), binstr:substr(Body, 1, Index - 1), []) + end. parse_headers(Body, <>, []) when H =:= $\s; H =:= $\t -> - % folded headers - {[], list_to_binary([H, Tail, "\r\n", Body])}; + % folded headers + {[], list_to_binary([H, Tail, "\r\n", Body])}; parse_headers(Body, <>, Headers) when H =:= $\s; H =:= $\t -> - % folded headers - [{FieldName, OldFieldValue} | OtherHeaders] = Headers, - FieldValue = list_to_binary([OldFieldValue, T]), - ?log(debug, "~p = ~p~n", [FieldName, FieldValue]), - case binstr:strpos(Body, "\r\n") of - 0 -> - {lists:reverse([{FieldName, FieldValue} | OtherHeaders]), Body}; - 1 -> - {lists:reverse([{FieldName, FieldValue} | OtherHeaders]), binstr:substr(Body, 3)}; - Index2 -> - parse_headers(binstr:substr(Body, Index2 + 2), binstr:substr(Body, 1, Index2 - 1), [{FieldName, FieldValue} | OtherHeaders]) - end; + % folded headers + [{FieldName, OldFieldValue} | OtherHeaders] = Headers, + FieldValue = list_to_binary([OldFieldValue, T]), + ?log(debug, "~p = ~p~n", [FieldName, FieldValue]), + case binstr:strpos(Body, "\r\n") of + 0 -> + {lists:reverse([{FieldName, FieldValue} | OtherHeaders]), Body}; + 1 -> + {lists:reverse([{FieldName, FieldValue} | OtherHeaders]), binstr:substr(Body, 3)}; + Index2 -> + parse_headers(binstr:substr(Body, Index2 + 2), binstr:substr(Body, 1, Index2 - 1), [ + {FieldName, FieldValue} | OtherHeaders + ]) + end; parse_headers(Body, Line, Headers) -> - ?log(debug, "line: ~p", [Line]), - case binstr:strchr(Line, $:) of - 0 -> - {lists:reverse(Headers), list_to_binary([Line, "\r\n", Body])}; - Index -> - FieldName = binstr:substr(Line, 1, Index - 1), - F = fun(X) -> X > 32 andalso X < 127 end, - case binstr:all(F, FieldName) of - true -> - F2 = fun(X) -> (X > 31 andalso X < 127) orelse X == 9 end, - FValue = binstr:strip(binstr:substr(Line, Index+1)), - FieldValue = case binstr:all(F2, FValue) of - true -> - FValue; - _ -> - % I couldn't figure out how to use a pure binary comprehension here :( - list_to_binary([ filter_non_ascii(C) || <> <= FValue]) - end, - case binstr:strpos(Body, "\r\n") of - 0 -> - {lists:reverse([{FieldName, FieldValue} | Headers]), Body}; - 1 -> - {lists:reverse([{FieldName, FieldValue} | Headers]), binstr:substr(Body, 3)}; - Index2 -> - parse_headers(binstr:substr(Body, Index2 + 2), binstr:substr(Body, 1, Index2 - 1), [{FieldName, FieldValue} | Headers]) - end; - false -> - {lists:reverse(Headers), list_to_binary([Line, "\r\n", Body])} - end - end. + ?log(debug, "line: ~p", [Line]), + case binstr:strchr(Line, $:) of + 0 -> + {lists:reverse(Headers), list_to_binary([Line, "\r\n", Body])}; + Index -> + FieldName = binstr:substr(Line, 1, Index - 1), + F = fun(X) -> X > 32 andalso X < 127 end, + case binstr:all(F, FieldName) of + true -> + F2 = fun(X) -> (X > 31 andalso X < 127) orelse X == 9 end, + FValue = binstr:strip(binstr:substr(Line, Index + 1)), + FieldValue = + case binstr:all(F2, FValue) of + true -> + FValue; + _ -> + % I couldn't figure out how to use a pure binary comprehension here :( + list_to_binary([filter_non_ascii(C) || <> <= FValue]) + end, + case binstr:strpos(Body, "\r\n") of + 0 -> + {lists:reverse([{FieldName, FieldValue} | Headers]), Body}; + 1 -> + {lists:reverse([{FieldName, FieldValue} | Headers]), binstr:substr(Body, 3)}; + Index2 -> + parse_headers(binstr:substr(Body, Index2 + 2), binstr:substr(Body, 1, Index2 - 1), [ + {FieldName, FieldValue} | Headers + ]) + end; + false -> + {lists:reverse(Headers), list_to_binary([Line, "\r\n", Body])} + end + end. filter_non_ascii(C) when (C > 31 andalso C < 127); C == 9 -> - <>; + <>; filter_non_ascii(_C) -> - <<"?">>. + <<"?">>. decode_body(Type, Body, _InEncoding, none) -> - decode_body(Type, << <> || <> <= Body, X < 128 >>); + decode_body(Type, <<<> || <> <= Body, X < 128>>); decode_body(Type, Body, undefined, _OutEncoding) -> - decode_body(Type, << <> || <> <= Body, X < 128 >>); + decode_body(Type, <<<> || <> <= Body, X < 128>>); decode_body(Type, Body, <<"x-binaryenc">>, _OutEncoding) -> - % Not IANA and does not represent text, so we pass it through - decode_body(Type, Body); + % Not IANA and does not represent text, so we pass it through + decode_body(Type, Body); decode_body(Type, Body, InEncoding, OutEncoding) -> - NewBody = decode_body(Type, Body), - InEncodingFixed = fix_encoding(InEncoding), - {ok, ConvertedBody} = convert(OutEncoding, InEncodingFixed, NewBody), - ConvertedBody. + NewBody = decode_body(Type, Body), + InEncodingFixed = fix_encoding(InEncoding), + {ok, ConvertedBody} = convert(OutEncoding, InEncodingFixed, NewBody), + ConvertedBody. -spec decode_body(Type :: binary() | 'undefined', Body :: binary()) -> binary(). decode_body(undefined, Body) -> - Body; + Body; decode_body(Type, Body) -> - case binstr:to_lower(Type) of - <<"quoted-printable">> -> - decode_quoted_printable(Body); - <<"base64">> -> - decode_base64(Body); - _Other -> - Body - end. + case binstr:to_lower(Type) of + <<"quoted-printable">> -> + decode_quoted_printable(Body); + <<"base64">> -> + decode_base64(Body); + _Other -> + Body + end. decode_base64(Body) -> - base64:mime_decode(Body). + base64:mime_decode(Body). decode_quoted_printable(Body) -> - case binstr:strpos(Body, "\r\n") of - 0 -> - decode_quoted_printable(Body, <<>>, []); - Index -> - decode_quoted_printable(binstr:substr(Body, 1, Index +1), binstr:substr(Body, Index + 2), []) - end. + case binstr:strpos(Body, "\r\n") of + 0 -> + decode_quoted_printable(Body, <<>>, []); + Index -> + decode_quoted_printable(binstr:substr(Body, 1, Index + 1), binstr:substr(Body, Index + 2), []) + end. decode_quoted_printable(<<>>, <<>>, Acc) -> - list_to_binary(lists:reverse(Acc)); + list_to_binary(lists:reverse(Acc)); decode_quoted_printable(Line, Rest, Acc) -> - case binstr:strpos(Rest, "\r\n") of - 0 -> - decode_quoted_printable(Rest, <<>>, [decode_quoted_printable_line(Line, []) | Acc]); - Index -> - ?log(debug, "next line ~p~nnext rest ~p~n", [binstr:substr(Rest, 1, Index +1), binstr:substr(Rest, Index + 2)]), - decode_quoted_printable(binstr:substr(Rest, 1, Index +1), binstr:substr(Rest, Index + 2), - [decode_quoted_printable_line(Line, []) | Acc]) - end. + case binstr:strpos(Rest, "\r\n") of + 0 -> + decode_quoted_printable(Rest, <<>>, [decode_quoted_printable_line(Line, []) | Acc]); + Index -> + ?log(debug, "next line ~p~nnext rest ~p~n", [ + binstr:substr(Rest, 1, Index + 1), binstr:substr(Rest, Index + 2) + ]), + decode_quoted_printable( + binstr:substr(Rest, 1, Index + 1), + binstr:substr(Rest, Index + 2), + [decode_quoted_printable_line(Line, []) | Acc] + ) + end. decode_quoted_printable_line(<<>>, Acc) -> - lists:reverse(Acc); + lists:reverse(Acc); decode_quoted_printable_line(<<$\r, $\n>>, Acc) -> - lists:reverse(["\r\n" | Acc]); + lists:reverse(["\r\n" | Acc]); decode_quoted_printable_line(<<$=, C, T/binary>>, Acc) when C =:= $\s; C =:= $\t -> - case binstr:all(fun(X) -> X =:= $\s orelse X =:= $\t end, T) of - true -> - lists:reverse(Acc); - false -> - throw(badchar) - end; + case binstr:all(fun(X) -> X =:= $\s orelse X =:= $\t end, T) of + true -> + lists:reverse(Acc); + false -> + throw(badchar) + end; decode_quoted_printable_line(<<$=, $\r, $\n>>, Acc) -> - lists:reverse(Acc); + lists:reverse(Acc); decode_quoted_printable_line(<<$=, A:2/binary, T/binary>>, Acc) -> - %<> = A, - case binstr:all(fun(C) -> (C >= $0 andalso C =< $9) orelse (C >= $A andalso C =< $F) orelse (C >= $a andalso C =< $f) end, A) of - true -> - {ok, [C | []], []} = io_lib:fread("~16u", binary_to_list(A)), - decode_quoted_printable_line(T, [C | Acc]); - false -> - throw(badchar) - end; + %<> = A, + case + binstr:all( + fun(C) -> (C >= $0 andalso C =< $9) orelse (C >= $A andalso C =< $F) orelse (C >= $a andalso C =< $f) end, A + ) + of + true -> + {ok, [C | []], []} = io_lib:fread("~16u", binary_to_list(A)), + decode_quoted_printable_line(T, [C | Acc]); + false -> + throw(badchar) + end; decode_quoted_printable_line(<<$=>>, Acc) -> - % soft newline - lists:reverse(Acc); + % soft newline + lists:reverse(Acc); decode_quoted_printable_line(<>, Acc) when H >= $!, H =< $< -> - decode_quoted_printable_line(T, [H | Acc]); + decode_quoted_printable_line(T, [H | Acc]); decode_quoted_printable_line(<>, Acc) when H >= $>, H =< $~ -> - decode_quoted_printable_line(T, [H | Acc]); + decode_quoted_printable_line(T, [H | Acc]); decode_quoted_printable_line(<>, Acc) when H =:= $\s; H =:= $\t -> - % if the rest of the line is whitespace, truncate it - case binstr:all(fun(X) -> X =:= $\s orelse X =:= $\t end, T) of - true -> - lists:reverse(Acc); - false -> - decode_quoted_printable_line(T, [H | Acc]) - end; + % if the rest of the line is whitespace, truncate it + case binstr:all(fun(X) -> X =:= $\s orelse X =:= $\t end, T) of + true -> + lists:reverse(Acc); + false -> + decode_quoted_printable_line(T, [H | Acc]) + end; decode_quoted_printable_line(<>, Acc) -> - decode_quoted_printable_line(T, [H| Acc]). + decode_quoted_printable_line(T, [H | Acc]). check_headers(Headers) -> - Checked = [<<"MIME-Version">>, <<"Date">>, <<"From">>, <<"Message-ID">>, <<"References">>, <<"Subject">>], - check_headers(Checked, lists:reverse(Headers)). + Checked = [<<"MIME-Version">>, <<"Date">>, <<"From">>, <<"Message-ID">>, <<"References">>, <<"Subject">>], + check_headers(Checked, lists:reverse(Headers)). check_headers([], Headers) -> - lists:reverse(Headers); + lists:reverse(Headers); check_headers([Header | Tail], Headers) -> - case get_header_value(Header, Headers) of - undefined when Header == <<"MIME-Version">> -> - check_headers(Tail, [{<<"MIME-Version">>, <<"1.0">>} | Headers]); - undefined when Header == <<"Date">> -> - check_headers(Tail, [{<<"Date">>, list_to_binary(smtp_util:rfc5322_timestamp())} | Headers]); - undefined when Header == <<"From">> -> - erlang:error(missing_from); - undefined when Header == <<"Message-ID">> -> - check_headers(Tail, [{<<"Message-ID">>, list_to_binary(smtp_util:generate_message_id())} | Headers]); - undefined when Header == <<"References">> -> - case get_header_value(<<"In-Reply-To">>, Headers) of - undefined -> - check_headers(Tail, Headers); % ok, whatever - ReplyID -> - check_headers(Tail, [{<<"References">>, ReplyID} | Headers]) - end; - References when Header == <<"References">> -> - % check if the in-reply-to header, if present, is in references - case get_header_value(<<"In-Reply-To">>, Headers) of - undefined -> - check_headers(Tail, Headers); % ok, whatever - ReplyID -> - case binstr:strpos(binstr:to_lower(References), binstr:to_lower(ReplyID)) of - 0 -> - % okay, tack on the reply-to to the end of References - check_headers(Tail, [{<<"References">>, list_to_binary([References, " ", ReplyID])} | proplists:delete(<<"References">>, Headers)]); - _Index -> - check_headers(Tail, Headers) % nothing to do - end - end; - _ -> - check_headers(Tail, Headers) - end. + case get_header_value(Header, Headers) of + undefined when Header == <<"MIME-Version">> -> + check_headers(Tail, [{<<"MIME-Version">>, <<"1.0">>} | Headers]); + undefined when Header == <<"Date">> -> + check_headers(Tail, [{<<"Date">>, list_to_binary(smtp_util:rfc5322_timestamp())} | Headers]); + undefined when Header == <<"From">> -> + erlang:error(missing_from); + undefined when Header == <<"Message-ID">> -> + check_headers(Tail, [{<<"Message-ID">>, list_to_binary(smtp_util:generate_message_id())} | Headers]); + undefined when Header == <<"References">> -> + case get_header_value(<<"In-Reply-To">>, Headers) of + undefined -> + % ok, whatever + check_headers(Tail, Headers); + ReplyID -> + check_headers(Tail, [{<<"References">>, ReplyID} | Headers]) + end; + References when Header == <<"References">> -> + % check if the in-reply-to header, if present, is in references + case get_header_value(<<"In-Reply-To">>, Headers) of + undefined -> + % ok, whatever + check_headers(Tail, Headers); + ReplyID -> + case binstr:strpos(binstr:to_lower(References), binstr:to_lower(ReplyID)) of + 0 -> + % okay, tack on the reply-to to the end of References + check_headers(Tail, [ + {<<"References">>, list_to_binary([References, " ", ReplyID])} + | proplists:delete(<<"References">>, Headers) + ]); + _Index -> + % nothing to do + check_headers(Tail, Headers) + end + end; + _ -> + check_headers(Tail, Headers) + end. ensure_content_headers(Type, SubType, Parameters, Headers, Body, Toplevel) -> - CheckHeaders = [<<"Content-Type">>, <<"Content-Disposition">>, <<"Content-Transfer-Encoding">>], - CheckHeadersValues = [{Name, get_header_value(Name, Headers)} || Name <- CheckHeaders], - ensure_content_headers(CheckHeadersValues, Type, SubType, Parameters, lists:reverse(Headers), Body, Toplevel). + CheckHeaders = [<<"Content-Type">>, <<"Content-Disposition">>, <<"Content-Transfer-Encoding">>], + CheckHeadersValues = [{Name, get_header_value(Name, Headers)} || Name <- CheckHeaders], + ensure_content_headers(CheckHeadersValues, Type, SubType, Parameters, lists:reverse(Headers), Body, Toplevel). ensure_content_headers([], _, _, Parameters, Headers, _, _) -> - {Parameters, lists:reverse(Headers)}; -ensure_content_headers([{<<"Content-Type">>, undefined} | Tail], Type, SubType, Parameters, Headers, Body, Toplevel) - when (Type == <<"text">> andalso SubType =/= <<"plain">>) orelse Type =/= <<"text">> -> - %% no content-type header, and its not text/plain - CT = io_lib:format("~s/~s", [Type, SubType]), - CTP = case Type of - <<"multipart">> -> - Boundary = case proplists:get_value(<<"boundary">>, maps:get(content_type_params, Parameters, [])) of - undefined -> - list_to_binary(smtp_util:generate_message_boundary()); - B -> - B - end, - [{<<"boundary">>, Boundary} | proplists:delete(<<"boundary">>, maps:get(content_type_params, Parameters, []))]; - <<"text">> -> - Charset = case proplists:get_value(<<"charset">>, maps:get(content_type_params, Parameters, [])) of - undefined -> - guess_charset(Body); - C -> - C - end, - [{<<"charset">>, Charset} | proplists:delete(<<"charset">>, maps:get(content_type_params, Parameters, []))]; - _ -> - maps:get(content_type_params, Parameters, []) - end, - - %%CTP = proplists:get_value(<<"content-type-params">>, Parameters, [guess_charset(Body)]), - CTH = binstr:join([CT | encode_parameters(CTP)], ";"), - NewParameters = Parameters#{content_type_params => CTP}, - ensure_content_headers(Tail, Type, SubType, NewParameters, [{<<"Content-Type">>, CTH} | Headers], Body, Toplevel); -ensure_content_headers([{<<"Content-Type">>, undefined} | Tail], <<"text">> = Type, <<"plain">> = SubType, Parameters, Headers, Body, Toplevel) -> - %% no content-type header and its text/plain - Charset = case proplists:get_value(<<"charset">>, maps:get(content_type_params, Parameters, [])) of - undefined -> - guess_charset(Body); - C -> - binstr:to_lower(C) - end, - case Charset of - <<"us-ascii">> -> - % the default - ensure_content_headers(Tail, Type, SubType, Parameters, Headers, Body, Toplevel); - _ -> - CTP = [{<<"charset">>, Charset} | proplists:delete(<<"charset">>, maps:get(content_type_params, Parameters, []))], - CTH = binstr:join([<<"text/plain">> | encode_parameters(CTP)], ";"), - NewParameters = Parameters#{content_type_params => CTP}, - ensure_content_headers(Tail, Type, SubType, NewParameters, [{<<"Content-Type">>, CTH} | Headers], Body, Toplevel) - end; -ensure_content_headers([{<<"Content-Transfer-Encoding">>, undefined} | Tail], Type, SubType, Parameters, Headers, Body, Toplevel) - when Type =/= <<"multipart">> -> - Enc = case maps:get(transfer_encoding, Parameters, undefined) of - undefined -> - guess_best_encoding(Body); - Value -> - Value - end, - case Enc of - <<"7bit">> -> - ensure_content_headers(Tail, Type, SubType, Parameters, Headers, Body, Toplevel); - _ -> - ensure_content_headers(Tail, Type, SubType, Parameters, [{<<"Content-Transfer-Encoding">>, Enc} | Headers], Body, Toplevel) - end; -ensure_content_headers([{<<"Content-Disposition">>, undefined} | Tail], Type, SubType, Parameters, Headers, Body, false = Toplevel) -> - CD = maps:get(disposition, Parameters, <<"inline">>), - CDP = maps:get(disposition_params, Parameters, []), - CDH = binstr:join([CD | encode_parameters(CDP)], ";"), - ensure_content_headers(Tail, Type, SubType, Parameters, [{<<"Content-Disposition">>, CDH} | Headers], Body, Toplevel); + {Parameters, lists:reverse(Headers)}; +ensure_content_headers( + [{<<"Content-Type">>, undefined} | Tail], Type, SubType, Parameters, Headers, Body, Toplevel +) when + (Type == <<"text">> andalso SubType =/= <<"plain">>) orelse Type =/= <<"text">> +-> + %% no content-type header, and its not text/plain + CT = io_lib:format("~s/~s", [Type, SubType]), + CTP = + case Type of + <<"multipart">> -> + Boundary = + case proplists:get_value(<<"boundary">>, maps:get(content_type_params, Parameters, [])) of + undefined -> + list_to_binary(smtp_util:generate_message_boundary()); + B -> + B + end, + [ + {<<"boundary">>, Boundary} + | proplists:delete(<<"boundary">>, maps:get(content_type_params, Parameters, [])) + ]; + <<"text">> -> + Charset = + case proplists:get_value(<<"charset">>, maps:get(content_type_params, Parameters, [])) of + undefined -> + guess_charset(Body); + C -> + C + end, + [ + {<<"charset">>, Charset} + | proplists:delete(<<"charset">>, maps:get(content_type_params, Parameters, [])) + ]; + _ -> + maps:get(content_type_params, Parameters, []) + end, + + %%CTP = proplists:get_value(<<"content-type-params">>, Parameters, [guess_charset(Body)]), + CTH = binstr:join([CT | encode_parameters(CTP)], ";"), + NewParameters = Parameters#{content_type_params => CTP}, + ensure_content_headers(Tail, Type, SubType, NewParameters, [{<<"Content-Type">>, CTH} | Headers], Body, Toplevel); +ensure_content_headers( + [{<<"Content-Type">>, undefined} | Tail], + <<"text">> = Type, + <<"plain">> = SubType, + Parameters, + Headers, + Body, + Toplevel +) -> + %% no content-type header and its text/plain + Charset = + case proplists:get_value(<<"charset">>, maps:get(content_type_params, Parameters, [])) of + undefined -> + guess_charset(Body); + C -> + binstr:to_lower(C) + end, + case Charset of + <<"us-ascii">> -> + % the default + ensure_content_headers(Tail, Type, SubType, Parameters, Headers, Body, Toplevel); + _ -> + CTP = [ + {<<"charset">>, Charset} + | proplists:delete(<<"charset">>, maps:get(content_type_params, Parameters, [])) + ], + CTH = binstr:join([<<"text/plain">> | encode_parameters(CTP)], ";"), + NewParameters = Parameters#{content_type_params => CTP}, + ensure_content_headers( + Tail, Type, SubType, NewParameters, [{<<"Content-Type">>, CTH} | Headers], Body, Toplevel + ) + end; +ensure_content_headers( + [{<<"Content-Transfer-Encoding">>, undefined} | Tail], Type, SubType, Parameters, Headers, Body, Toplevel +) when + Type =/= <<"multipart">> +-> + Enc = + case maps:get(transfer_encoding, Parameters, undefined) of + undefined -> + guess_best_encoding(Body); + Value -> + Value + end, + case Enc of + <<"7bit">> -> + ensure_content_headers(Tail, Type, SubType, Parameters, Headers, Body, Toplevel); + _ -> + ensure_content_headers( + Tail, Type, SubType, Parameters, [{<<"Content-Transfer-Encoding">>, Enc} | Headers], Body, Toplevel + ) + end; +ensure_content_headers( + [{<<"Content-Disposition">>, undefined} | Tail], Type, SubType, Parameters, Headers, Body, false = Toplevel +) -> + CD = maps:get(disposition, Parameters, <<"inline">>), + CDP = maps:get(disposition_params, Parameters, []), + CDH = binstr:join([CD | encode_parameters(CDP)], ";"), + ensure_content_headers( + Tail, Type, SubType, Parameters, [{<<"Content-Disposition">>, CDH} | Headers], Body, Toplevel + ); ensure_content_headers([_ | Tail], Type, SubType, Parameters, Headers, Body, Toplevel) -> - ensure_content_headers(Tail, Type, SubType, Parameters, Headers, Body, Toplevel). + ensure_content_headers(Tail, Type, SubType, Parameters, Headers, Body, Toplevel). guess_charset(Body) -> - case binstr:all(fun(X) -> X < 128 end, Body) of - true -> <<"us-ascii">>; - false -> <<"utf-8">> - end. + case binstr:all(fun(X) -> X < 128 end, Body) of + true -> <<"us-ascii">>; + false -> <<"utf-8">> + end. guess_best_encoding(Body) -> - case valid_7bit(Body) of - true -> - <<"7bit">>; - false -> - choose_transformation(Body) - end. + case valid_7bit(Body) of + true -> + <<"7bit">>; + false -> + choose_transformation(Body) + end. choose_transformation(<>) -> - %% Optimization - only analyze 1st 200 bytes - choose_transformation(Chunk); + %% Optimization - only analyze 1st 200 bytes + choose_transformation(Chunk); choose_transformation(Body) -> - {Readable, Encoded} = partition_count_bytes( - fun (C) -> - C >= 16#20 andalso C =< 16#7E orelse C =:= $\r orelse C =:= $\n - end, - Body - ), - - %based on the % of printable characters, choose an encoding - if - Readable >= 4 * Encoded -> % same as 100 * Readable / (Readable + Encoded) >= 80, but avoiding division - %% >80% printable characters - <<"quoted-printable">>; - true -> - %% =<80% printable characters - <<"base64">> - end. + {Readable, Encoded} = partition_count_bytes( + fun(C) -> + C >= 16#20 andalso C =< 16#7E orelse C =:= $\r orelse C =:= $\n + end, + Body + ), + + %based on the % of printable characters, choose an encoding + if + % same as 100 * Readable / (Readable + Encoded) >= 80, but avoiding division + Readable >= 4 * Encoded -> + %% >80% printable characters + <<"quoted-printable">>; + true -> + %% =<80% printable characters + <<"base64">> + end. %% https://tools.ietf.org/html/rfc2045#section-2.7: %% * ASCII codes from 1 to 127 @@ -784,248 +908,299 @@ choose_transformation(Body) -> %% * No lines over 998 chars %% %% Unfortunately, any string that ends with `\n` matches the regexp, so, we need some pre-checks -valid_7bit(<<"\n">>) -> false; -valid_7bit(<<"\r">>) -> false; -valid_7bit(<<>>) -> true; -valid_7bit(<<_>>) -> true; +valid_7bit(<<"\n">>) -> + false; +valid_7bit(<<"\r">>) -> + false; +valid_7bit(<<>>) -> + true; +valid_7bit(<<_>>) -> + true; valid_7bit(Body) -> - Size = byte_size(Body), - case binary:at(Body, Size - 1) =:= $\n andalso binary:at(Body, Size - 2) =/= $\r of - true -> - %% last element is \n, but the one before the last is not \r - false; - false -> - %% So: (all except `\r` and `\n` in 1-127 range) OR (`\r\n`) - case re:run(Body, "^([\x01-\x09\x0b-\x0c\x0e-\x7f]|(\r\n))*$", [{capture, none}]) of - match -> not has_lines_over_998(Body); - nomatch -> false - end - end. + Size = byte_size(Body), + case binary:at(Body, Size - 1) =:= $\n andalso binary:at(Body, Size - 2) =/= $\r of + true -> + %% last element is \n, but the one before the last is not \r + false; + false -> + %% So: (all except `\r` and `\n` in 1-127 range) OR (`\r\n`) + case re:run(Body, "^([\x01-\x09\x0b-\x0c\x0e-\x7f]|(\r\n))*$", [{capture, none}]) of + match -> not has_lines_over_998(Body); + nomatch -> false + end + end. %% @doc If `Body' has at least one line (ending with `\r\n') that is longer than 998 chars has_lines_over_998(Body) -> - Pattern = binary:compile_pattern(<<"\r\n">>), - has_lines_over_998(Body, binary:match(Body, Pattern), 0, Pattern). + Pattern = binary:compile_pattern(<<"\r\n">>), + has_lines_over_998(Body, binary:match(Body, Pattern), 0, Pattern). has_lines_over_998(Bin, nomatch, Offset, _) -> - %% Last line is over 998? - (byte_size(Bin) - Offset) >= 998; + %% Last line is over 998? + (byte_size(Bin) - Offset) >= 998; has_lines_over_998(_Bin, {FoundAt, 2}, Offset, _Patern) when (FoundAt - Offset) >= 998 -> - true; + true; has_lines_over_998(Bin, {FoundAt, 2}, _, Pattern) -> - NewOffset = FoundAt + 2, - Len = byte_size(Bin) - NewOffset, - has_lines_over_998( - Bin, binary:match(Bin, Pattern, [{scope, {NewOffset, Len}}]), NewOffset, Pattern). + NewOffset = FoundAt + 2, + Len = byte_size(Bin) - NewOffset, + has_lines_over_998( + Bin, binary:match(Bin, Pattern, [{scope, {NewOffset, Len}}]), NewOffset, Pattern + ). encode_parameters([[]]) -> - []; + []; encode_parameters(Parameters) -> - [encode_parameter(Parameter) || Parameter <- Parameters]. + [encode_parameter(Parameter) || Parameter <- Parameters]. encode_parameter({X, Y}) -> - YEnc = rfc2047_utf8_encode(Y, byte_size(X) + 3, <<"\t">>), - case escape_tspecial(YEnc, false, <<>>) of - {true, Special} -> [X, $=, $", Special, $"]; - false -> [X, $=, YEnc] - end. + YEnc = rfc2047_utf8_encode(Y, byte_size(X) + 3, <<"\t">>), + case escape_tspecial(YEnc, false, <<>>) of + {true, Special} -> [X, $=, $", Special, $"]; + false -> [X, $=, YEnc] + end. % See also: http://www.ietf.org/rfc/rfc2045.txt section 5.1 escape_tspecial(<<>>, false, _Acc) -> - false; + false; escape_tspecial(<<>>, IsSpecial, Acc) -> - {IsSpecial, Acc}; + {IsSpecial, Acc}; escape_tspecial(<>, _IsSpecial, Acc) when C =:= $" -> - escape_tspecial(Rest, true, <>); + escape_tspecial(Rest, true, <>); escape_tspecial(<>, _IsSpecial, Acc) when C =:= $\\ -> - escape_tspecial(Rest, true, <>); -escape_tspecial(<>, _IsSpecial, Acc) - when C =:= $(; C =:= $); C =:= $<; C =:= $>; C =:= $@; - C =:= $,; C =:= $;; C =:= $:; C =:= $/; C =:= $[; - C =:= $]; C =:= $?; C =:= $=; C =:= $\s -> - escape_tspecial(Rest, true, <>); + escape_tspecial(Rest, true, <>); +escape_tspecial(<>, _IsSpecial, Acc) when + C =:= $(; + C =:= $); + C =:= $<; + C =:= $>; + C =:= $@; + C =:= $,; + C =:= $;; + C =:= $:; + C =:= $/; + C =:= $[; + C =:= $]; + C =:= $?; + C =:= $=; + C =:= $\s +-> + escape_tspecial(Rest, true, <>); escape_tspecial(<>, IsSpecial, Acc) -> - escape_tspecial(Rest, IsSpecial, <>). + escape_tspecial(Rest, IsSpecial, <>). encode_headers([]) -> - []; -encode_headers([{Key, Value}|T] = _Headers) -> - EncodedHeader = maybe_encode_folded_header(Key, list_to_binary([Key,": ",encode_header_value(Key, Value)])), - [EncodedHeader | encode_headers(T)]. - -maybe_encode_folded_header(H, Hdr) when H =:= <<"To">>; H =:= <<"Cc">>; H =:= <<"Bcc">>; - H =:= <<"Reply-To">>; H =:= <<"From">> -> - Hdr; + []; +encode_headers([{Key, Value} | T] = _Headers) -> + EncodedHeader = maybe_encode_folded_header(Key, list_to_binary([Key, ": ", encode_header_value(Key, Value)])), + [EncodedHeader | encode_headers(T)]. + +maybe_encode_folded_header(H, Hdr) when + H =:= <<"To">>; + H =:= <<"Cc">>; + H =:= <<"Bcc">>; + H =:= <<"Reply-To">>; + H =:= <<"From">> +-> + Hdr; maybe_encode_folded_header(_H, Hdr) -> - encode_folded_header(Hdr, <<>>). + encode_folded_header(Hdr, <<>>). encode_folded_header(Rest, Acc) -> - case binstr:split(Rest, <<$;>>, 2) of - [_] -> - <>; - [Before, After] -> - NewPart = case After of - <<$\t,_Rest/binary>> -> - <>; - _ -> - <> - end, + case binstr:split(Rest, <<$;>>, 2) of + [_] -> + <>; + [Before, After] -> + NewPart = + case After of + <<$\t, _Rest/binary>> -> + <>; + _ -> + <> + end, encode_folded_header(After, <>) - end. - -encode_header_value(H, Value) when H =:= <<"To">>; H =:= <<"Cc">>; H =:= <<"Bcc">>; - H =:= <<"Reply-To">>; H =:= <<"From">> -> - {ok, Addresses} = smtp_util:parse_rfc5322_addresses(Value), - {Names, Emails} = lists:unzip(Addresses), - NewNames = lists:map( - fun(undefined) -> undefined; - (Name) -> - %% `Name' contains codepoints, but we need bytes - rfc2047_utf8_encode(unicode:characters_to_binary(Name)) - end, Names), - smtp_util:combine_rfc822_addresses(lists:zip(NewNames, Emails)); + end. + +encode_header_value(H, Value) when + H =:= <<"To">>; + H =:= <<"Cc">>; + H =:= <<"Bcc">>; + H =:= <<"Reply-To">>; + H =:= <<"From">> +-> + {ok, Addresses} = smtp_util:parse_rfc5322_addresses(Value), + {Names, Emails} = lists:unzip(Addresses), + NewNames = lists:map( + fun + (undefined) -> + undefined; + (Name) -> + %% `Name' contains codepoints, but we need bytes + rfc2047_utf8_encode(unicode:characters_to_binary(Name)) + end, + Names + ), + smtp_util:combine_rfc822_addresses(lists:zip(NewNames, Emails)); encode_header_value(H, Value) when H =:= <<"Content-Type">>; H =:= <<"Content-Disposition">> -> - Value; % Parameters are already encoded. + % Parameters are already encoded. + Value; encode_header_value(_, Value) -> - rfc2047_utf8_encode(Value). + rfc2047_utf8_encode(Value). encode_component(_Type, _SubType, _Headers, Params, Body) when is_list(Body) -> % is this a multipart component? - Boundary = proplists:get_value(<<"boundary">>, maps:get(content_type_params, Params)), - [<<>>] ++ % blank line before start of component - lists:flatmap( - fun(Part) -> - [list_to_binary([<<"--">>, Boundary])] ++ % start with the boundary - encode_component_part(Part) - end, - Body - ) ++ [list_to_binary([<<"--">>, Boundary, <<"--">>])] % final boundary (with /--$/) - ++ [<<>>]; % blank line at the end of the multipart component + Boundary = proplists:get_value(<<"boundary">>, maps:get(content_type_params, Params)), + % blank line before start of component + [<<>>] ++ + lists:flatmap( + fun(Part) -> + % start with the boundary + [list_to_binary([<<"--">>, Boundary])] ++ + encode_component_part(Part) + end, + Body + % final boundary (with /--$/) + ) ++ [list_to_binary([<<"--">>, Boundary, <<"--">>])] ++ + % blank line at the end of the multipart component + [<<>>]; encode_component(_Type, _SubType, Headers, _Params, Body) -> % or an inline component? - %encode_component_part({Type, SubType, Headers, Params, Body}) - encode_body( - get_header_value(<<"Content-Transfer-Encoding">>, Headers), - [Body] - ). + %encode_component_part({Type, SubType, Headers, Params, Body}) + encode_body( + get_header_value(<<"Content-Transfer-Encoding">>, Headers), + [Body] + ). encode_component_part({<<"multipart">>, SubType, Headers, PartParams, Body}) -> - {FixedParams, FixedHeaders} = ensure_content_headers(<<"multipart">>, SubType, PartParams, Headers, Body, false), - encode_headers(FixedHeaders) ++ - encode_component(<<"multipart">>, SubType, FixedHeaders, FixedParams, Body); + {FixedParams, FixedHeaders} = ensure_content_headers(<<"multipart">>, SubType, PartParams, Headers, Body, false), + encode_headers(FixedHeaders) ++ + encode_component(<<"multipart">>, SubType, FixedHeaders, FixedParams, Body); encode_component_part({Type, SubType, Headers, PartParams, Body}) -> - PartData = case Body of - {_,_,_,_,_} -> encode_component_part(Body); - String -> [String] - end, - {_FixedParams, FixedHeaders} = ensure_content_headers(Type, SubType, PartParams, Headers, Body, false), - encode_headers(FixedHeaders) ++ [<<>>] ++ - encode_body( - get_header_value(<<"Content-Transfer-Encoding">>, FixedHeaders), - PartData - ); + PartData = + case Body of + {_, _, _, _, _} -> encode_component_part(Body); + String -> [String] + end, + {_FixedParams, FixedHeaders} = ensure_content_headers(Type, SubType, PartParams, Headers, Body, false), + encode_headers(FixedHeaders) ++ [<<>>] ++ + encode_body( + get_header_value(<<"Content-Transfer-Encoding">>, FixedHeaders), + PartData + ); encode_component_part(Part) -> - ?log(debug, "encode_component_part couldn't match Part to: ~p~n", [Part]), - []. + ?log(debug, "encode_component_part couldn't match Part to: ~p~n", [Part]), + []. encode_body(undefined, Body) -> - Body; + Body; encode_body(Type, Body) -> - case binstr:to_lower(Type) of - <<"quoted-printable">> -> - [InnerBody] = Body, - encode_quoted_printable(InnerBody); - <<"base64">> -> - [InnerBody] = Body, - wrap_to_76(base64:encode(InnerBody)); - _ -> Body - end. + case binstr:to_lower(Type) of + <<"quoted-printable">> -> + [InnerBody] = Body, + encode_quoted_printable(InnerBody); + <<"base64">> -> + [InnerBody] = Body, + wrap_to_76(base64:encode(InnerBody)); + _ -> + Body + end. wrap_to_76(String) -> - [wrap_to_76(String, [])]. + [wrap_to_76(String, [])]. wrap_to_76(<<>>, Acc) -> - list_to_binary(lists:reverse(Acc)); + list_to_binary(lists:reverse(Acc)); wrap_to_76(<>, Acc) -> - wrap_to_76(Tail, [<<"\r\n">>, Head | Acc]); + wrap_to_76(Tail, [<<"\r\n">>, Head | Acc]); wrap_to_76(Head, Acc) -> - list_to_binary(lists:reverse([<<"\r\n">>, Head | Acc])). + list_to_binary(lists:reverse([<<"\r\n">>, Head | Acc])). encode_quoted_printable(Body) -> - [encode_quoted_printable(Body, <<>>, 0, false, <<>>, 0)]. + [encode_quoted_printable(Body, <<>>, 0, false, <<>>, 0)]. % End of body (this should only happen if the body was empty to begin with) encode_quoted_printable(<<>>, Acc, _LineLen, _HasWSP, WordAcc, _WordLen) -> - <>; + <>; % CRLF encode_quoted_printable(<<$\r, $\n, More/binary>>, Acc, _LineLen, _HasWSP, WordAcc, _WordLen) -> - encode_quoted_printable(More, <>, 0, false, <<>>, 0); + encode_quoted_printable(More, <>, 0, false, <<>>, 0); % WSP in last position encode_quoted_printable(<>, Acc, LineLen, _HasWSP, WordAcc, WordLen) when C =:= $\s; C =:= $\t -> - Enc = encode_quoted_printable_char(C, true), - case LineLen + WordLen + 3 > 76 of - true -> - % line would become too long -> soft-break before WSP - <>; - false -> - % character fits on current line - <> - end; + Enc = encode_quoted_printable_char(C, true), + case LineLen + WordLen + 3 > 76 of + true -> + % line would become too long -> soft-break before WSP + <>; + false -> + % character fits on current line + <> + end; % WSP before CRLF -encode_quoted_printable(<>, Acc, LineLen, _HasWSP, WordAcc, WordLen) when C =:= $\s; C =:= $\t -> - Enc = encode_quoted_printable_char(C, true), - case LineLen + WordLen + 3 > 76 of - true -> - % line would become too long -> soft-break before WSP - encode_quoted_printable(More, <>, 0, false, <<>>, 0); - false -> - % character fits on current line - encode_quoted_printable(More, <>, 0, false, <<>>, 0) - end; +encode_quoted_printable(<>, Acc, LineLen, _HasWSP, WordAcc, WordLen) when + C =:= $\s; C =:= $\t +-> + Enc = encode_quoted_printable_char(C, true), + case LineLen + WordLen + 3 > 76 of + true -> + % line would become too long -> soft-break before WSP + encode_quoted_printable( + More, <>, 0, false, <<>>, 0 + ); + false -> + % character fits on current line + encode_quoted_printable(More, <>, 0, false, <<>>, 0) + end; % Character elsewhere encode_quoted_printable(<>, Acc, LineLen, HasWSP, WordAcc, WordLen) -> - Enc = encode_quoted_printable_char(C, false), - EncLen = byte_size(Enc), - case LineLen + WordLen + EncLen > 75 of % mind the 75 here, we need the 76th place for the soft linebreak - true when C =:= $\s; C =:= $\t -> - % line would become too long, current char is WSP -> soft-break here (remember we have a WSP) - encode_quoted_printable(More, <>, EncLen, true, <<>>, 0); - true when HasWSP, WordLen + EncLen =< 75 -> - % line would become too long, we have an earlier WSP and word plus encoded character will fit on a new line -> soft-break at earlier WSP - encode_quoted_printable(More, <>, WordLen + EncLen, false, <<>>, 0); - true -> - % line would become too long, we have no earlier WSP or word plus encoded character will not fit on a new line -> soft break here - encode_quoted_printable(More, <>, EncLen, false, <<>>, 0); - false when C =:= $\s; C =:= $\t -> - % WSP character fits on line -> move word and WSP to Acc (remember we have a WSP) - encode_quoted_printable(More, <>, LineLen+WordLen+EncLen, true, <<>>, 0); - false -> - % non-WSP character fits on line -> add character to word - encode_quoted_printable(More, Acc, LineLen, HasWSP, <>, WordLen+EncLen) - end. + Enc = encode_quoted_printable_char(C, false), + EncLen = byte_size(Enc), + % mind the 75 here, we need the 76th place for the soft linebreak + case LineLen + WordLen + EncLen > 75 of + true when C =:= $\s; C =:= $\t -> + % line would become too long, current char is WSP -> soft-break here (remember we have a WSP) + encode_quoted_printable( + More, <>, EncLen, true, <<>>, 0 + ); + true when HasWSP, WordLen + EncLen =< 75 -> + % line would become too long, we have an earlier WSP and word plus encoded character will fit on a new line -> soft-break at earlier WSP + encode_quoted_printable( + More, <>, WordLen + EncLen, false, <<>>, 0 + ); + true -> + % line would become too long, we have no earlier WSP or word plus encoded character will not fit on a new line -> soft break here + encode_quoted_printable( + More, <>, EncLen, false, <<>>, 0 + ); + false when C =:= $\s; C =:= $\t -> + % WSP character fits on line -> move word and WSP to Acc (remember we have a WSP) + encode_quoted_printable( + More, <>, LineLen + WordLen + EncLen, true, <<>>, 0 + ); + false -> + % non-WSP character fits on line -> add character to word + encode_quoted_printable(More, Acc, LineLen, HasWSP, <>, WordLen + EncLen) + end. encode_quoted_printable_char(C, true) -> - <<$=, (hex(C div 16#10)), (hex(C rem 16#10))>>; + <<$=, (hex(C div 16#10)), (hex(C rem 16#10))>>; encode_quoted_printable_char($\s, false) -> - <<$\s>>; + <<$\s>>; encode_quoted_printable_char($\t, false) -> - <<$\t>>; + <<$\t>>; encode_quoted_printable_char($=, _Force) -> - <<$=, $3, $D>>; + <<$=, $3, $D>>; encode_quoted_printable_char(C, _Force) when C =< 16#20; C >= 16#7F -> - encode_quoted_printable_char(C, true); + encode_quoted_printable_char(C, true); encode_quoted_printable_char(C, false) -> - <>. + <>. get_default_encoding() -> - <<"utf-8//IGNORE">>. + <<"utf-8//IGNORE">>. % convert some common invalid character names into the correct ones fix_encoding(Encoding) when Encoding == <<"utf8">>; Encoding == <<"UTF8">> -> - <<"UTF-8">>; + <<"UTF-8">>; fix_encoding(Encoding) -> - Encoding. - + Encoding. % Characters allowed to appear unencoded (RFC 2047 Sections 4.2 and 5): % * lowercase ASCII letters @@ -1038,123 +1213,130 @@ fix_encoding(Encoding) -> % * "/" % SPACE is not really an allowed letter, but since it encodes to "_" % and thereby a single byte, we list it as allowed here --define(is_rfc2047_q_allowed(C), (C=:=$\s orelse (C>=$a andalso C=<$z) orelse (C>=$A andalso C=<$Z) - orelse (C>=$0 andalso C=<$9) orelse C=:=$! orelse C=:=$* orelse C=:=$+ - orelse C=:=$- orelse C=:=$/)). +-define(is_rfc2047_q_allowed(C), + (C =:= $\s orelse (C >= $a andalso C =< $z) orelse (C >= $A andalso C =< $Z) orelse + (C >= $0 andalso C =< $9) orelse C =:= $! orelse C =:= $* orelse C =:= $+ orelse + C =:= $- orelse C =:= $/) +). %% @doc Encode a binary or list according to RFC 2047. Input is %% assumed to be in UTF-8 encoding bytes; not codepoints. rfc2047_utf8_encode(Value) -> - rfc2047_utf8_encode(Value, 0, <<" ">>). + rfc2047_utf8_encode(Value, 0, <<" ">>). rfc2047_utf8_encode(Value, PrefixLen, LineIndent) when is_binary(Value) -> - case is_ascii_printable(Value) of - true -> - % don't encode if all characters are printable ASCII - Value; - false -> - {Readable, Encoded}=partition_count_bytes(fun(C) -> ?is_rfc2047_q_allowed(C) end, Value), - Enc = if - Readable >= Encoded -> - % most characters would be readable in Q-Encoding, - % so we use it - q; - true -> - % most characters would have to be encoded in Q-Encoding, - % so we use B-Encoding instead - b - end, - rfc2047_utf8_encode(Enc, Value, <<>>, PrefixLen, LineIndent) - end; + case is_ascii_printable(Value) of + true -> + % don't encode if all characters are printable ASCII + Value; + false -> + {Readable, Encoded} = partition_count_bytes(fun(C) -> ?is_rfc2047_q_allowed(C) end, Value), + Enc = + if + Readable >= Encoded -> + % most characters would be readable in Q-Encoding, + % so we use it + q; + true -> + % most characters would have to be encoded in Q-Encoding, + % so we use B-Encoding instead + b + end, + rfc2047_utf8_encode(Enc, Value, <<>>, PrefixLen, LineIndent) + end; rfc2047_utf8_encode(Value, PrefixLen, LineIndent) -> - rfc2047_utf8_encode(list_to_binary(Value), PrefixLen, LineIndent). + rfc2047_utf8_encode(list_to_binary(Value), PrefixLen, LineIndent). rfc2047_utf8_encode(_Enc, <<>>, Acc, _PrefixLen, _LineIndent) -> - Acc; + Acc; rfc2047_utf8_encode(b, More, Acc, PrefixLen, LineIndent) -> - % B-Encoding - % An encoded word must not be longer than 75 bytes, - % including the leading "=?", charset name, "?B?" and - % the trailing "?=". Since the charset name is fixed to - % "UTF-8", 63 remain for encoded text. Using Base64, - % a maximum of 45 raw bytes can be encoded in 63 bytes. - rfc2047_utf8_encode(b, More, Acc, <<>>, byte_size(LineIndent), LineIndent, 46-PrefixLen); + % B-Encoding + % An encoded word must not be longer than 75 bytes, + % including the leading "=?", charset name, "?B?" and + % the trailing "?=". Since the charset name is fixed to + % "UTF-8", 63 remain for encoded text. Using Base64, + % a maximum of 45 raw bytes can be encoded in 63 bytes. + rfc2047_utf8_encode(b, More, Acc, <<>>, byte_size(LineIndent), LineIndent, 46 - PrefixLen); rfc2047_utf8_encode(q, More, Acc, PrefixLen, LineIndent) -> - % Q-Encoding - % An encoded word must not be longer than 75 bytes, - % including the leading "=?", charset name, "=?UTF-8?Q?" and - % the trailing "?=". Since the charset name is fixed to - % "UTF-8", 63 remain for encoded text. Using Quoted-Printable, - % between 21 and 63 raw bytes can be encoded in 63 bytes. - rfc2047_utf8_encode(q, More, Acc, <<>>, byte_size(LineIndent), LineIndent, 63-PrefixLen). + % Q-Encoding + % An encoded word must not be longer than 75 bytes, + % including the leading "=?", charset name, "=?UTF-8?Q?" and + % the trailing "?=". Since the charset name is fixed to + % "UTF-8", 63 remain for encoded text. Using Quoted-Printable, + % between 21 and 63 raw bytes can be encoded in 63 bytes. + rfc2047_utf8_encode(q, More, Acc, <<>>, byte_size(LineIndent), LineIndent, 63 - PrefixLen). rfc2047_utf8_encode(Enc, <<>>, Acc, WordAcc, _PrefixLen, LineIndent, _Left) -> - rfc2047_append_word(Acc, WordAcc, Enc, LineIndent); + rfc2047_append_word(Acc, WordAcc, Enc, LineIndent); rfc2047_utf8_encode(Enc, All = <>, Acc, WordAcc, PrefixLen, LineIndent, Left) -> - % convert codepoint back to UTF-8 encoded bytes - Bytes = <>, - Size = byte_size(Bytes), - Reqd = case Enc of - q when not ?is_rfc2047_q_allowed(C) -> - 3*Size; - q -> - Size; - b -> - Size - end, - case Left >= Reqd of - true -> - rfc2047_utf8_encode(Enc, More, Acc, <>, PrefixLen, LineIndent, Left-Reqd); - false -> - rfc2047_utf8_encode(Enc, All, rfc2047_append_word(Acc, WordAcc, Enc, LineIndent), PrefixLen, LineIndent) - end. + % convert codepoint back to UTF-8 encoded bytes + Bytes = <>, + Size = byte_size(Bytes), + Reqd = + case Enc of + q when not ?is_rfc2047_q_allowed(C) -> + 3 * Size; + q -> + Size; + b -> + Size + end, + case Left >= Reqd of + true -> + rfc2047_utf8_encode(Enc, More, Acc, <>, PrefixLen, LineIndent, Left - Reqd); + false -> + rfc2047_utf8_encode(Enc, All, rfc2047_append_word(Acc, WordAcc, Enc, LineIndent), PrefixLen, LineIndent) + end. rfc2047_append_word(Acc, <<>>, _Enc, _LineIndent) -> - % empty word - Acc; + % empty word + Acc; rfc2047_append_word(<<>>, Word, Enc, _LineIndent) -> - % first word in Acc - rfc2047_encode_word(Word, Enc); + % first word in Acc + rfc2047_encode_word(Word, Enc); rfc2047_append_word(Acc, Word, Enc, LineIndent) -> - % subsequent word in Acc - <>. + % subsequent word in Acc + <>. rfc2047_encode_word(Word, q) -> - <<"=?UTF-8?Q?", (rfc2047_q_encode(Word))/binary, "?=">>; + <<"=?UTF-8?Q?", (rfc2047_q_encode(Word))/binary, "?=">>; rfc2047_encode_word(Word, b) -> - <<"=?UTF-8?B?", (base64:encode(Word))/binary, "?=">>. + <<"=?UTF-8?B?", (base64:encode(Word))/binary, "?=">>. rfc2047_q_encode(<<>>) -> - <<>>; + <<>>; rfc2047_q_encode(<<$\s, More/binary>>) -> - % SPACE -> _ - <<$_, (rfc2047_q_encode(More))/binary>>; + % SPACE -> _ + <<$_, (rfc2047_q_encode(More))/binary>>; rfc2047_q_encode(<>) when ?is_rfc2047_q_allowed(C) -> - % character which needs no encoding - <>; + % character which needs no encoding + <>; rfc2047_q_encode(<>) -> - % characters which need encoding -> =XY - <<$=, (hex(N1)), (hex(N2)), (rfc2047_q_encode(More))/binary>>. + % characters which need encoding -> =XY + <<$=, (hex(N1)), (hex(N2)), (rfc2047_q_encode(More))/binary>>. -is_ascii_printable(<<>>) -> 'true'; +is_ascii_printable(<<>>) -> + 'true'; is_ascii_printable(<>) when H >= 32 andalso H =< 126 -> is_ascii_printable(T); -is_ascii_printable(_) -> 'false'. +is_ascii_printable(_) -> + 'false'. hex(N) when N >= 10 -> N + $A - 10; hex(N) -> N + $0. partition_count_bytes(Fun, Bin) -> - partition_count_bytes(Fun, Bin, {0, 0}). + partition_count_bytes(Fun, Bin, {0, 0}). partition_count_bytes(_Fun, <<>>, PartitionCounts) -> - PartitionCounts; + PartitionCounts; partition_count_bytes(Fun, <>, {Trues, Falses}) -> - NewPartitionCounts = case Fun(C) of - true -> {Trues+1, Falses}; - false -> {Trues, Falses+1} - end, - partition_count_bytes(Fun, More, NewPartitionCounts). + NewPartitionCounts = + case Fun(C) of + true -> {Trues + 1, Falses}; + false -> {Trues, Falses + 1} + end, + partition_count_bytes(Fun, More, NewPartitionCounts). %% @doc DKIM sign an email %% DKIM sign functions @@ -1180,171 +1362,188 @@ partition_count_bytes(Fun, <>, {Trues, Falses}) -> %% 3rd paramerter is password to decrypt the key. -spec dkim_sign_email([binary()], binary(), dkim_options()) -> [binary()]. dkim_sign_email(Headers, Body, Opts) -> - HeadersToSign = proplists:get_value(h, Opts, [<<"from">>, <<"to">>, <<"subject">>, <<"date">>]), - SDID = proplists:get_value(d, Opts), - Selector = proplists:get_value(s, Opts), - %% BodyLength = proplists:get_value(l, Opts), - OptionalTags = lists:foldl(fun(Key, Acc) -> - case proplists:get_value(Key, Opts) of - undefined -> Acc; - Value -> [{Key, Value} | Acc] - end - end, [], [t, x]), - {HdrsCanT, BodyCanT} = Can = proplists:get_value(c, Opts, {relaxed, simple}), - Algorithm = proplists:get_value(a, Opts, 'rsa-sha256'), - PrivateKey = proplists:get_value(private_key, Opts), - - %% hash body - CanBody = dkim_canonicalize_body(Body, BodyCanT), - BodyHash = dkim_hash_body(CanBody), - Tags = [%% {b, <<>>}, - {v, 1}, {a, Algorithm}, {bh, BodyHash}, {c, Can}, - {d, SDID}, {h, HeadersToSign}, {s, Selector} | OptionalTags], - %% hash headers - Headers1 = dkim_filter_headers(Headers, HeadersToSign), - CanHeaders = dkim_canonicalize_headers(Headers1, HdrsCanT), - [DkimHeaderNoB] = dkim_canonicalize_headers([dkim_make_header([{b, undefined} | Tags])], HdrsCanT), - DataHash = dkim_hash_data(CanHeaders, DkimHeaderNoB), - %% sign - Signature = dkim_sign(DataHash, Algorithm, PrivateKey), - DkimHeader = dkim_make_header([{b, Signature} | Tags]), - [DkimHeader | Headers]. + HeadersToSign = proplists:get_value(h, Opts, [<<"from">>, <<"to">>, <<"subject">>, <<"date">>]), + SDID = proplists:get_value(d, Opts), + Selector = proplists:get_value(s, Opts), + %% BodyLength = proplists:get_value(l, Opts), + OptionalTags = lists:foldl( + fun(Key, Acc) -> + case proplists:get_value(Key, Opts) of + undefined -> Acc; + Value -> [{Key, Value} | Acc] + end + end, + [], + [t, x] + ), + {HdrsCanT, BodyCanT} = Can = proplists:get_value(c, Opts, {relaxed, simple}), + Algorithm = proplists:get_value(a, Opts, 'rsa-sha256'), + PrivateKey = proplists:get_value(private_key, Opts), + + %% hash body + CanBody = dkim_canonicalize_body(Body, BodyCanT), + BodyHash = dkim_hash_body(CanBody), + %% {b, <<>>}, + Tags = [ + {v, 1}, + {a, Algorithm}, + {bh, BodyHash}, + {c, Can}, + {d, SDID}, + {h, HeadersToSign}, + {s, Selector} + | OptionalTags + ], + %% hash headers + Headers1 = dkim_filter_headers(Headers, HeadersToSign), + CanHeaders = dkim_canonicalize_headers(Headers1, HdrsCanT), + [DkimHeaderNoB] = dkim_canonicalize_headers([dkim_make_header([{b, undefined} | Tags])], HdrsCanT), + DataHash = dkim_hash_data(CanHeaders, DkimHeaderNoB), + %% sign + Signature = dkim_sign(DataHash, Algorithm, PrivateKey), + DkimHeader = dkim_make_header([{b, Signature} | Tags]), + [DkimHeader | Headers]. dkim_filter_headers(Headers, HeadersToSign) -> - KeyedHeaders = [begin - [Name, _] = binary:split(Hdr, <<":">>), - {binstr:strip(binstr:to_lower(Name)), Hdr} - end || Hdr <- Headers], - WithUndef = [get_header_value(binstr:to_lower(Name), KeyedHeaders) || Name <- HeadersToSign], - [Hdr || Hdr <- WithUndef, Hdr =/= undefined]. + KeyedHeaders = [ + begin + [Name, _] = binary:split(Hdr, <<":">>), + {binstr:strip(binstr:to_lower(Name)), Hdr} + end + || Hdr <- Headers + ], + WithUndef = [get_header_value(binstr:to_lower(Name), KeyedHeaders) || Name <- HeadersToSign], + [Hdr || Hdr <- WithUndef, Hdr =/= undefined]. dkim_canonicalize_headers(Headers, simple) -> - Headers; + Headers; dkim_canonicalize_headers(Headers, relaxed) -> - dkim_canonic_hdrs_relaxed(Headers). + dkim_canonic_hdrs_relaxed(Headers). dkim_canonic_hdrs_relaxed([Hdr | Rest]) -> - [Name, Value] = binary:split(Hdr, <<":">>), - LowStripName = binstr:to_lower(binstr:strip(Name)), - - UnfoldedHdrValue = binary:replace(Value, <<"\r\n">>, <<>>, [global]), - SingleWSValue = re:replace(UnfoldedHdrValue, "[\t ]+", " ", [global, {return, binary}]), - StrippedWithName = <>, - [StrippedWithName | dkim_canonic_hdrs_relaxed(Rest)]; -dkim_canonic_hdrs_relaxed([]) -> []. + [Name, Value] = binary:split(Hdr, <<":">>), + LowStripName = binstr:to_lower(binstr:strip(Name)), + UnfoldedHdrValue = binary:replace(Value, <<"\r\n">>, <<>>, [global]), + SingleWSValue = re:replace(UnfoldedHdrValue, "[\t ]+", " ", [global, {return, binary}]), + StrippedWithName = <>, + [StrippedWithName | dkim_canonic_hdrs_relaxed(Rest)]; +dkim_canonic_hdrs_relaxed([]) -> + []. dkim_canonicalize_body(<<>>, simple) -> - <<"\r\n">>; + <<"\r\n">>; dkim_canonicalize_body(Body, simple) -> - re:replace(Body, "(\r\n)*$", "\r\n", [{return, binary}]); + re:replace(Body, "(\r\n)*$", "\r\n", [{return, binary}]); dkim_canonicalize_body(_Body, relaxed) -> - throw({not_supported, dkim_body_relaxed}). + throw({not_supported, dkim_body_relaxed}). dkim_hash_body(CanonicBody) -> - crypto:hash(sha256, CanonicBody). - %% crypto:sha256(CanonicBody). + crypto:hash(sha256, CanonicBody). +%% crypto:sha256(CanonicBody). %% RFC 5.5 & 3.7 dkim_hash_data(CanonicHeaders, DkimHeader) -> - JoinedHeaders = << <> || Hdr <- CanonicHeaders>>, - crypto:hash(sha256, <>). + JoinedHeaders = <<<> || Hdr <- CanonicHeaders>>, + crypto:hash(sha256, <>). %% TODO: Remove once we require Erlang/OTP 24.1+ %% Related Erlang/OTP bug: https://github.com/erlang/otp/pull/5157 ed25519_supported() -> - {ok, PublicKeyAppVersionString} = application:get_key(public_key, vsn), - PublicKeyAppVersionList = - lists:map(fun erlang:list_to_integer/1, string:tokens(PublicKeyAppVersionString, ".")), - PublicKeyAppVersionList >= [1, 11, 2]. + {ok, PublicKeyAppVersionString} = application:get_key(public_key, vsn), + PublicKeyAppVersionList = + lists:map(fun erlang:list_to_integer/1, string:tokens(PublicKeyAppVersionString, ".")), + PublicKeyAppVersionList >= [1, 11, 2]. dkim_get_algorithm_digest(Algorithm) -> - case Algorithm of - 'rsa-sha256' -> - sha256; - 'ed25519-sha256' -> - case ed25519_supported() of - true -> - none; - false -> - throw("DKIM with Ed25519 requires Erlang/OTP 24.1+") - end - end. + case Algorithm of + 'rsa-sha256' -> + sha256; + 'ed25519-sha256' -> + case ed25519_supported() of + true -> + none; + false -> + throw("DKIM with Ed25519 requires Erlang/OTP 24.1+") + end + end. dkim_sign(DataHash, Algorithm, {pem_plain, PrivBin}) -> - [PrivEntry] = public_key:pem_decode(PrivBin), - Digest = dkim_get_algorithm_digest(Algorithm), - Key = public_key:pem_entry_decode(PrivEntry), - public_key:sign({digest, DataHash}, Digest, Key); + [PrivEntry] = public_key:pem_decode(PrivBin), + Digest = dkim_get_algorithm_digest(Algorithm), + Key = public_key:pem_entry_decode(PrivEntry), + public_key:sign({digest, DataHash}, Digest, Key); dkim_sign(DataHash, Algorithm, {pem_encrypted, EncPrivBin, Passwd}) -> - [EncPrivEntry] = public_key:pem_decode(EncPrivBin), - Digest = dkim_get_algorithm_digest(Algorithm), - Key = public_key:pem_entry_decode(EncPrivEntry, Passwd), - public_key:sign({digest, DataHash}, Digest, Key). + [EncPrivEntry] = public_key:pem_decode(EncPrivBin), + Digest = dkim_get_algorithm_digest(Algorithm), + Key = public_key:pem_entry_decode(EncPrivEntry, Passwd), + public_key:sign({digest, DataHash}, Digest, Key). dkim_make_header(Tags) -> - RevTags = lists:reverse(Tags), %so {b, ...} became last tag - EncodedTags = binstr:join([dkim_encode_tag(K, V) || {K, V} <- RevTags], <<"; ">>), - binstr:join(encode_headers([{<<"DKIM-Signature">>, EncodedTags}]), <<"\r\n">>). + %so {b, ...} became last tag + RevTags = lists:reverse(Tags), + EncodedTags = binstr:join([dkim_encode_tag(K, V) || {K, V} <- RevTags], <<"; ">>), + binstr:join(encode_headers([{<<"DKIM-Signature">>, EncodedTags}]), <<"\r\n">>). %% RFC #3.5 dkim_encode_tag(v, 1) -> - %% version - <<"v=1">>; + %% version + <<"v=1">>; dkim_encode_tag(a, Algorithm) -> - %% algorithm - <<"a=", (atom_to_binary(Algorithm, utf8))/binary>>; + %% algorithm + <<"a=", (atom_to_binary(Algorithm, utf8))/binary>>; dkim_encode_tag(b, undefined) -> - %% signature (when hashing with no digest) - <<"b=">>; + %% signature (when hashing with no digest) + <<"b=">>; dkim_encode_tag(b, V) -> - %% signature - B64Sign = base64:encode(V), - <<"b=", B64Sign/binary>>; + %% signature + B64Sign = base64:encode(V), + <<"b=", B64Sign/binary>>; dkim_encode_tag(bh, V) -> - %% body hash - B64Sign = base64:encode(V), - <<"bh=", B64Sign/binary>>; -dkim_encode_tag(c, {Hdrs, simple}) -> % 'relaxed' for body not supported yet - %% canonicalization type - <<"c=", (atom_to_binary(Hdrs, utf8))/binary, "/simple">>; + %% body hash + B64Sign = base64:encode(V), + <<"bh=", B64Sign/binary>>; +% 'relaxed' for body not supported yet +dkim_encode_tag(c, {Hdrs, simple}) -> + %% canonicalization type + <<"c=", (atom_to_binary(Hdrs, utf8))/binary, "/simple">>; dkim_encode_tag(d, Domain) -> - %% SDID (domain) - <<"d=", Domain/binary>>; + %% SDID (domain) + <<"d=", Domain/binary>>; dkim_encode_tag(h, Hdrs) -> - %% headers fields (case-insensitive, ":" separated) - Joined = binstr:join([binstr:to_lower(H) || H <- Hdrs], <<":">>), - <<"h=", Joined/binary>>; + %% headers fields (case-insensitive, ":" separated) + Joined = binstr:join([binstr:to_lower(H) || H <- Hdrs], <<":">>), + <<"h=", Joined/binary>>; dkim_encode_tag(i, V) -> - %% AUID - QPValue = dkim_qp_tag_value(V), - <<"i=", QPValue/binary>>; + %% AUID + QPValue = dkim_qp_tag_value(V), + <<"i=", QPValue/binary>>; dkim_encode_tag(l, IntVal) -> - %% body length count - BinVal = list_to_binary(integer_to_list(IntVal)), - <<"l=", (BinVal)/binary>>; + %% body length count + BinVal = list_to_binary(integer_to_list(IntVal)), + <<"l=", (BinVal)/binary>>; dkim_encode_tag(q, [<<"dns/txt">>]) -> - %% query methods (':' separated) - <<"q=dns/txt">>; + %% query methods (':' separated) + <<"q=dns/txt">>; dkim_encode_tag(s, Selector) -> - %% selector - <<"s=", Selector/binary>>; + %% selector + <<"s=", Selector/binary>>; dkim_encode_tag(t, now) -> - dkim_encode_tag(t, calendar:universal_time()); + dkim_encode_tag(t, calendar:universal_time()); dkim_encode_tag(t, DateTime) -> - %% timestamp - BinTs = datetime_to_bin_timestamp(DateTime), - <<"t=", BinTs/binary>>; + %% timestamp + BinTs = datetime_to_bin_timestamp(DateTime), + <<"t=", BinTs/binary>>; dkim_encode_tag(x, DateTime) -> - %% signature expiration - BinTs = datetime_to_bin_timestamp(DateTime), - <<"x=", BinTs/binary>>; + %% signature expiration + BinTs = datetime_to_bin_timestamp(DateTime), + <<"x=", BinTs/binary>>; %% dkim_encode_tag(z, Hdrs) -> %% %% copied header fields %% Joined = dkim_qp_tag_value(binstr:join([(H) || H <- Hdrs], <<"|">>)), %% <<"z=", Joined/binary>>; dkim_encode_tag(K, V) when is_binary(K), is_binary(V) -> - <>. + <>. dkim_qp_tag_value(Value) -> %% XXX: this not fully satisfy #2.11 @@ -1352,1467 +1551,1807 @@ dkim_qp_tag_value(Value) -> binary:replace(QPValue, <<";">>, <<"=3B">>). datetime_to_bin_timestamp(DateTime) -> - EpochStart = 62167219200, % calendar:datetime_to_gregorian_seconds({{1970,1,1}, {0,0,0}}) + % calendar:datetime_to_gregorian_seconds({{1970,1,1}, {0,0,0}}) + EpochStart = 62167219200, UnixTimestamp = calendar:datetime_to_gregorian_seconds(DateTime) - EpochStart, list_to_binary(integer_to_list(UnixTimestamp)). %% /DKIM - -ifdef(TEST). parse_with_comments_test_() -> - [ - {"bleh", - fun() -> - ?assertEqual(<<"1.0">>, parse_with_comments(<<"1.0">>)), - ?assertEqual(<<"1.0">>, parse_with_comments(<<"1.0 (produced by MetaSend Vx.x)">>)), - ?assertEqual(<<"1.0">>, parse_with_comments(<<"(produced by MetaSend Vx.x) 1.0">>)), - ?assertEqual(<<"1.0">>, parse_with_comments(<<"1.(produced by MetaSend Vx.x)0">>)) - end - }, - {"comments that parse as empty", - fun() -> - ?assertEqual(<<>>, parse_with_comments(<<"(comment (nested (deeply)) (and (oh no!) again))">>)), - ?assertEqual(<<>>, parse_with_comments(<<"(\\)\\\\)">>)), - ?assertEqual(<<>>, parse_with_comments(<<"(by way of Whatever ) (generated by Eudora)">>)) - end - }, - {"some more", - fun() -> - ?assertEqual(<<":sysmail@ group. org, Muhammed. Ali @Vegas.WBA">>, parse_with_comments(<<"\":sysmail\"@ group. org, Muhammed.(the greatest) Ali @(the)Vegas.WBA">>)), - ?assertEqual(<<"Pete ">>, parse_with_comments(<<"Pete(A wonderful \\) chap) ">>)) - end - }, - {"non list values", - fun() -> - ?assertEqual(undefined, parse_with_comments(undefined)), - ?assertEqual(17, parse_with_comments(17)) - end - }, - {"Parens within quotes ignored", - fun() -> - ?assertEqual(<<"Height (from xkcd).eml">>, parse_with_comments(<<"\"Height (from xkcd).eml\"">>)), - ?assertEqual(<<"Height (from xkcd).eml">>, parse_with_comments(<<"\"Height \(from xkcd\).eml\"">>)) - end - }, - {"Escaped quotes are handled correctly", - fun() -> - ?assertEqual(<<"Hello \"world\"">>, parse_with_comments(<<"Hello \\\"world\\\"">>)), - ?assertEqual(<<", Giant; \"Big\" Box ">>, parse_with_comments(<<", \"Giant; \\\"Big\\\" Box\" ">>)) - end - }, - {"backslash not part of a quoted pair", - fun() -> - ?assertEqual(<<"AC \\ DC">>, parse_with_comments(<<"AC \\ DC">>)), - ?assertEqual(<<"AC DC">>, parse_with_comments(<<"AC ( \\ ) DC">>)) - end - }, - {"Unterminated quotes or comments", - fun() -> - ?assertError(unterminated_quotes, parse_with_comments(<<"\"Hello there ">>)), - ?assertError(unterminated_quotes, parse_with_comments(<<"\"Hello there \\\"">>)), - ?assertError(unterminated_comment, parse_with_comments(<<"(Hello there ">>)), - ?assertError(unterminated_comment, parse_with_comments(<<"(Hello there \\\)">>)) - end - } - ]. + [ + {"bleh", fun() -> + ?assertEqual(<<"1.0">>, parse_with_comments(<<"1.0">>)), + ?assertEqual(<<"1.0">>, parse_with_comments(<<"1.0 (produced by MetaSend Vx.x)">>)), + ?assertEqual(<<"1.0">>, parse_with_comments(<<"(produced by MetaSend Vx.x) 1.0">>)), + ?assertEqual(<<"1.0">>, parse_with_comments(<<"1.(produced by MetaSend Vx.x)0">>)) + end}, + {"comments that parse as empty", fun() -> + ?assertEqual(<<>>, parse_with_comments(<<"(comment (nested (deeply)) (and (oh no!) again))">>)), + ?assertEqual(<<>>, parse_with_comments(<<"(\\)\\\\)">>)), + ?assertEqual(<<>>, parse_with_comments(<<"(by way of Whatever ) (generated by Eudora)">>)) + end}, + {"some more", fun() -> + ?assertEqual( + <<":sysmail@ group. org, Muhammed. Ali @Vegas.WBA">>, + parse_with_comments(<<"\":sysmail\"@ group. org, Muhammed.(the greatest) Ali @(the)Vegas.WBA">>) + ), + ?assertEqual( + <<"Pete ">>, + parse_with_comments(<<"Pete(A wonderful \\) chap) ">>) + ) + end}, + {"non list values", fun() -> + ?assertEqual(undefined, parse_with_comments(undefined)), + ?assertEqual(17, parse_with_comments(17)) + end}, + {"Parens within quotes ignored", fun() -> + ?assertEqual(<<"Height (from xkcd).eml">>, parse_with_comments(<<"\"Height (from xkcd).eml\"">>)), + ?assertEqual(<<"Height (from xkcd).eml">>, parse_with_comments(<<"\"Height \(from xkcd\).eml\"">>)) + end}, + {"Escaped quotes are handled correctly", fun() -> + ?assertEqual(<<"Hello \"world\"">>, parse_with_comments(<<"Hello \\\"world\\\"">>)), + ?assertEqual( + <<", Giant; \"Big\" Box ">>, + parse_with_comments(<<", \"Giant; \\\"Big\\\" Box\" ">>) + ) + end}, + {"backslash not part of a quoted pair", fun() -> + ?assertEqual(<<"AC \\ DC">>, parse_with_comments(<<"AC \\ DC">>)), + ?assertEqual(<<"AC DC">>, parse_with_comments(<<"AC ( \\ ) DC">>)) + end}, + {"Unterminated quotes or comments", fun() -> + ?assertError(unterminated_quotes, parse_with_comments(<<"\"Hello there ">>)), + ?assertError(unterminated_quotes, parse_with_comments(<<"\"Hello there \\\"">>)), + ?assertError(unterminated_comment, parse_with_comments(<<"(Hello there ">>)), + ?assertError(unterminated_comment, parse_with_comments(<<"(Hello there \\\)">>)) + end} + ]. parse_content_type_test_() -> - [ - {"parsing content types", - fun() -> - ?assertEqual({<<"text">>, <<"plain">>, [{<<"charset">>, <<"us-ascii">>}]}, parse_content_type(<<"text/plain; charset=us-ascii (Plain text)">>)), - ?assertEqual({<<"text">>, <<"plain">>, [{<<"charset">>, <<"us-ascii">>}]}, parse_content_type(<<"text/plain; charset=\"us-ascii\"">>)), - ?assertEqual({<<"text">>, <<"plain">>, [{<<"charset">>, <<"us-ascii">>}]}, parse_content_type(<<"Text/Plain; Charset=\"us-ascii\"">>)), - ?assertEqual({<<"multipart">>, <<"mixed">>, [{<<"boundary">>, <<"----_=_NextPart_001_01C9DCAE.1F2CB390">>}]}, - parse_content_type(<<"multipart/mixed; boundary=\"----_=_NextPart_001_01C9DCAE.1F2CB390\"">>)) - end - }, - {"parsing content type with a tab in it", - fun() -> - ?assertEqual({<<"text">>, <<"plain">>, [{<<"charset">>, <<"us-ascii">>}]}, parse_content_type(<<"text/plain;\tcharset=us-ascii">>)), - ?assertEqual({<<"text">>, <<"plain">>, [{<<"charset">>, <<"us-ascii">>}, {<<"foo">>, <<"bar">>}]}, parse_content_type(<<"text/plain;\tcharset=us-ascii;\tfoo=bar">>)) - end - }, - {"invalid content types", - fun() -> - ?assertThrow(bad_content_type, parse_content_type(<<"text\\plain; charset=us-ascii">>)), - ?assertThrow(bad_content_type, parse_content_type(<<"text/plain; charset us-ascii">>)) - end - } - ]. + [ + {"parsing content types", fun() -> + ?assertEqual( + {<<"text">>, <<"plain">>, [{<<"charset">>, <<"us-ascii">>}]}, + parse_content_type(<<"text/plain; charset=us-ascii (Plain text)">>) + ), + ?assertEqual( + {<<"text">>, <<"plain">>, [{<<"charset">>, <<"us-ascii">>}]}, + parse_content_type(<<"text/plain; charset=\"us-ascii\"">>) + ), + ?assertEqual( + {<<"text">>, <<"plain">>, [{<<"charset">>, <<"us-ascii">>}]}, + parse_content_type(<<"Text/Plain; Charset=\"us-ascii\"">>) + ), + ?assertEqual( + {<<"multipart">>, <<"mixed">>, [{<<"boundary">>, <<"----_=_NextPart_001_01C9DCAE.1F2CB390">>}]}, + parse_content_type(<<"multipart/mixed; boundary=\"----_=_NextPart_001_01C9DCAE.1F2CB390\"">>) + ) + end}, + {"parsing content type with a tab in it", fun() -> + ?assertEqual( + {<<"text">>, <<"plain">>, [{<<"charset">>, <<"us-ascii">>}]}, + parse_content_type(<<"text/plain;\tcharset=us-ascii">>) + ), + ?assertEqual( + {<<"text">>, <<"plain">>, [{<<"charset">>, <<"us-ascii">>}, {<<"foo">>, <<"bar">>}]}, + parse_content_type(<<"text/plain;\tcharset=us-ascii;\tfoo=bar">>) + ) + end}, + {"invalid content types", fun() -> + ?assertThrow(bad_content_type, parse_content_type(<<"text\\plain; charset=us-ascii">>)), + ?assertThrow(bad_content_type, parse_content_type(<<"text/plain; charset us-ascii">>)) + end} + ]. parse_content_disposition_test_() -> - [ - {"parsing valid dispositions", - fun() -> - ?assertEqual({<<"inline">>, []}, parse_content_disposition(<<"inline">>)), - ?assertEqual({<<"inline">>, []}, parse_content_disposition(<<"inline;">>)), - ?assertEqual({<<"attachment">>, [{<<"filename">>, <<"genome.jpeg">>}, {<<"modification-date">>, <<"Wed, 12 Feb 1997 16:29:51 -0500">>}]}, parse_content_disposition(<<"attachment; filename=genome.jpeg;modification-date=\"Wed, 12 Feb 1997 16:29:51 -0500\";">>)), - ?assertEqual({<<"text/plain">>, [{<<"charset">>, <<"us-ascii">>}]}, parse_content_disposition(<<"text/plain; charset=us-ascii (Plain text)">>)) - end - }, - {"invalid dispositions", - fun() -> - ?assertThrow(bad_disposition, parse_content_disposition(<<"inline; =bar">>)), - ?assertThrow(bad_disposition, parse_content_disposition(<<"inline; bar">>)) - end - } - ]. + [ + {"parsing valid dispositions", fun() -> + ?assertEqual({<<"inline">>, []}, parse_content_disposition(<<"inline">>)), + ?assertEqual({<<"inline">>, []}, parse_content_disposition(<<"inline;">>)), + ?assertEqual( + {<<"attachment">>, [ + {<<"filename">>, <<"genome.jpeg">>}, + {<<"modification-date">>, <<"Wed, 12 Feb 1997 16:29:51 -0500">>} + ]}, + parse_content_disposition( + <<"attachment; filename=genome.jpeg;modification-date=\"Wed, 12 Feb 1997 16:29:51 -0500\";">> + ) + ), + ?assertEqual( + {<<"text/plain">>, [{<<"charset">>, <<"us-ascii">>}]}, + parse_content_disposition(<<"text/plain; charset=us-ascii (Plain text)">>) + ) + end}, + {"invalid dispositions", fun() -> + ?assertThrow(bad_disposition, parse_content_disposition(<<"inline; =bar">>)), + ?assertThrow(bad_disposition, parse_content_disposition(<<"inline; bar">>)) + end} + ]. various_parsing_test_() -> - [ - {"split_body_by_boundary test", - fun() -> - ?assertEqual([{[], <<"foo bar baz">>}], split_body_by_boundary_(<<"stuff\r\nfoo bar baz">>, <<"--bleh">>, [], [])), - ?assertEqual([{[], <<"foo\r\n">>}, {[], <<>>}, {[], <<>>}, {[], <<"bar baz">>}], split_body_by_boundary_(<<"stuff\r\nfoo\r\n--bleh\r\n--bleh\r\n--bleh-- stuff\r\nbar baz">>, <<"--bleh">>, [], [])), - %?assertEqual([{[], []}, {[], []}, {[], "bar baz"}], split_body_by_boundary_("\r\n--bleh\r\n--bleh\r\n", "--bleh", [], [])), - %?assertMatch([{"text", "plain", [], _,"foo\r\n"}], split_body_by_boundary("stuff\r\nfoo\r\n--bleh\r\n--bleh\r\n--bleh-- stuff\r\nbar baz", "--bleh", "1.0", [])) - ?assertEqual({[], <<"foo: bar\r\n">>}, parse_headers(<<"\r\nfoo: bar\r\n">>)), - ?assertEqual({[{<<"foo">>, <<"barbaz">>}], <<>>}, parse_headers(<<"foo: bar\r\n baz\r\n">>)), - ?assertEqual({[], <<" foo bar baz\r\nbam">>}, parse_headers(<<"\sfoo bar baz\r\nbam">>)), - ok - end - }, - {"Headers with non-ASCII characters", - fun() -> - ?assertEqual({[{<<"foo">>, <<"bar ?? baz">>}], <<>>}, parse_headers(<<"foo: bar ø baz\r\n"/utf8>>)), - ?assertEqual({[], <<"bär: bar baz\r\n"/utf8>>}, parse_headers(<<"bär: bar baz\r\n"/utf8>>)) - end - }, - {"Headers with tab characters", - fun() -> - ?assertEqual({[{<<"foo">>, <<"bar baz">>}], <<>>}, parse_headers(<<"foo: bar baz\r\n">>)) - end - } - - ]. - --define(IMAGE_MD5, <<110,130,37,247,39,149,224,61,114,198,227,138,113,4,198,60>>). + [ + {"split_body_by_boundary test", fun() -> + ?assertEqual( + [{[], <<"foo bar baz">>}], split_body_by_boundary_(<<"stuff\r\nfoo bar baz">>, <<"--bleh">>, [], []) + ), + ?assertEqual( + [{[], <<"foo\r\n">>}, {[], <<>>}, {[], <<>>}, {[], <<"bar baz">>}], + split_body_by_boundary_( + <<"stuff\r\nfoo\r\n--bleh\r\n--bleh\r\n--bleh-- stuff\r\nbar baz">>, <<"--bleh">>, [], [] + ) + ), + %?assertEqual([{[], []}, {[], []}, {[], "bar baz"}], split_body_by_boundary_("\r\n--bleh\r\n--bleh\r\n", "--bleh", [], [])), + %?assertMatch([{"text", "plain", [], _,"foo\r\n"}], split_body_by_boundary("stuff\r\nfoo\r\n--bleh\r\n--bleh\r\n--bleh-- stuff\r\nbar baz", "--bleh", "1.0", [])) + ?assertEqual({[], <<"foo: bar\r\n">>}, parse_headers(<<"\r\nfoo: bar\r\n">>)), + ?assertEqual({[{<<"foo">>, <<"barbaz">>}], <<>>}, parse_headers(<<"foo: bar\r\n baz\r\n">>)), + ?assertEqual({[], <<" foo bar baz\r\nbam">>}, parse_headers(<<"\sfoo bar baz\r\nbam">>)), + ok + end}, + {"Headers with non-ASCII characters", fun() -> + ?assertEqual({[{<<"foo">>, <<"bar ?? baz">>}], <<>>}, parse_headers(<<"foo: bar ø baz\r\n"/utf8>>)), + ?assertEqual({[], <<"bär: bar baz\r\n"/utf8>>}, parse_headers(<<"bär: bar baz\r\n"/utf8>>)) + end}, + {"Headers with tab characters", fun() -> + ?assertEqual({[{<<"foo">>, <<"bar baz">>}], <<>>}, parse_headers(<<"foo: bar baz\r\n">>)) + end} + ]. + +-define(IMAGE_MD5, <<110, 130, 37, 247, 39, 149, 224, 61, 114, 198, 227, 138, 113, 4, 198, 60>>). parse_example_mails_test_() -> - Getmail = fun(File) -> - {ok, Email} = file:read_file(string:concat("test/fixtures/", File)), - %Email = binary_to_list(Bin), - decode(Email) - end, - [ - {"parse a plain text email", - fun() -> - Decoded = Getmail("Plain-text-only.eml"), - ?assertEqual(5, tuple_size(Decoded)), - {Type, SubType, _Headers, _Properties, Body} = Decoded, - ?assertEqual({<<"text">>, <<"plain">>}, {Type, SubType}), - ?assertEqual(<<"This message contains only plain text.\r\n">>, Body) - end - }, - {"parse a Python smtplib plain text email", - fun() -> - Decoded = Getmail("python-smtp-lib.eml"), - ?assertEqual(5, tuple_size(Decoded)), - {Type, SubType, _Headers, _Properties, Body} = Decoded, - ?assertEqual({<<"text">>, <<"plain">>}, {Type, SubType}), - ?assertEqual(<<"Hello world Python.\r\n">>, Body) - end - }, - {"parse a plain text email with no content type", - fun() -> - Decoded = Getmail("Plain-text-only-no-content-type.eml"), - ?assertEqual(5, tuple_size(Decoded)), - {Type, SubType, _Headers, _Properties, Body} = Decoded, - ?assertEqual({<<"text">>, <<"plain">>}, {Type, SubType}), - ?assertEqual(<<"This message contains only plain text.\r\n">>, Body) - end - }, - {"parse a plain text email with no MIME header", - fun() -> - {Type, SubType, _Headers, _Properties, Body} = - Getmail("Plain-text-only-no-MIME.eml"), - ?assertEqual({<<"text">>, <<"plain">>}, {Type, SubType}), - ?assertEqual(<<"This message contains only plain text.\r\n">>, Body) - end - }, - {"parse an email that says it is multipart but contains no boundaries", - fun() -> - ?assertError(missing_boundary, Getmail("Plain-text-only-with-boundary-header.eml")) - end - }, - {"parse a multipart email with no MIME header", - fun() -> - % We now insert a default Mime for missing Mime headers - % ?assertError(non_mime_multipart, Getmail("rich-text-no-MIME.eml")) - ?assertMatch({<<"multipart">>,<<"alternative">>, _, _, [{<<"text">>,<<"plain">>, _, _, _}, {<<"text">>,<<"html">>, _, _, _}]}, Getmail("rich-text-no-MIME.eml")) - end - }, - {"rich text", - fun() -> - %% pardon my naming here. apparently 'rich text' in mac mail - %% means 'html'. - Decoded = Getmail("rich-text.eml"), - ?assertEqual(5, tuple_size(Decoded)), - {Type, SubType, _Headers, _Properties, Body} = Decoded, - ?assertEqual({<<"multipart">>, <<"alternative">>}, {Type, SubType}), - ?assertEqual(2, length(Body)), - [Plain, Html] = Body, - ?assertEqual({5, 5}, {tuple_size(Plain), tuple_size(Html)}), - ?assertMatch({<<"text">>, <<"plain">>, _, _, <<"This message contains rich text.">>}, Plain), - ?assertMatch({<<"text">>, <<"html">>, _, _, <<"This message contains rich text.">>}, Html) - end - }, - {"rich text no boundary", - fun() -> - ?assertError(no_boundary, Getmail("rich-text-no-boundary.eml")) - end - }, - {"rich text missing first boundary", - fun() -> - % TODO - should we handle this more elegantly? - Decoded = Getmail("rich-text-missing-first-boundary.eml"), - ?assertEqual(5, tuple_size(Decoded)), - {Type, SubType, _Headers, _Properties, Body} = Decoded, - ?assertEqual({<<"multipart">>, <<"alternative">>}, {Type, SubType}), - ?assertEqual(1, length(Body)), - [Html] = Body, - ?assertEqual(5, tuple_size(Html)), - ?assertMatch({<<"text">>, <<"html">>, _, _, <<"This message contains rich text.">>}, Html) - end - }, - {"rich text missing last boundary", - fun() -> - ?assertError(missing_last_boundary, Getmail("rich-text-missing-last-boundary.eml")) - end - }, - {"rich text wrong last boundary", - fun() -> - ?assertError(missing_last_boundary, Getmail("rich-text-broken-last-boundary.eml")) - end - }, - {"rich text missing text content type", - fun() -> - %% pardon my naming here. apparently 'rich text' in mac mail - %% means 'html'. - Decoded = Getmail("rich-text-no-text-contenttype.eml"), - ?assertEqual(5, tuple_size(Decoded)), - {Type, SubType, _Headers, _Properties, Body} = Decoded, - ?assertEqual({<<"multipart">>, <<"alternative">>}, {Type, SubType}), - ?assertEqual(2, length(Body)), - [Plain, Html] = Body, - ?assertEqual({5, 5}, {tuple_size(Plain), tuple_size(Html)}), - ?assertMatch({<<"text">>, <<"plain">>, _, _, <<"This message contains rich text.">>}, Plain), - ?assertMatch({<<"text">>, <<"html">>, _, _, <<"This message contains rich text.">>}, Html) - end - }, - {"text attachment only", - fun() -> - Decoded = Getmail("text-attachment-only.eml"), - ?assertEqual(5, tuple_size(Decoded)), - {Type, SubType, _Headers, _Properties, Body} = Decoded, - ?assertEqual({<<"multipart">>, <<"mixed">>}, {Type, SubType}), - ?assertEqual(1, length(Body)), - Rich = <<"{\\rtf1\\ansi\\ansicpg1252\\cocoartf949\\cocoasubrtf460\r\n{\\fonttbl\\f0\\fswiss\\fcharset0 Helvetica;}\r\n{\\colortbl;\\red255\\green255\\blue255;}\r\n\\margl1440\\margr1440\\vieww9000\\viewh8400\\viewkind0\r\n\\pard\\tx720\\tx1440\\tx2160\\tx2880\\tx3600\\tx4320\\tx5040\\tx5760\\tx6480\\tx7200\\tx7920\\tx8640\\ql\\qnatural\\pardirnatural\r\n\r\n\\f0\\fs24 \\cf0 This is a basic rtf file.}">>, - ?assertMatch([{<<"text">>, <<"rtf">>, _, _, Rich}], Body) - end - }, - {"image attachment only", - fun() -> - Decoded = Getmail("image-attachment-only.eml"), - ?assertEqual(5, tuple_size(Decoded)), - {Type, SubType, _Headers, _Properties, Body} = Decoded, - ?assertEqual({<<"multipart">>, <<"mixed">>}, {Type, SubType}), - ?assertEqual(1, length(Body)), - ?assertMatch([{<<"image">>, <<"jpeg">>, _, _, _}], Body), - [H | _] = Body, - [{<<"image">>, <<"jpeg">>, _, Parameters, _Image}] = Body, - ?assertEqual(?IMAGE_MD5, erlang:md5(element(5, H))), - ?assertEqual(<<"inline">>, maps:get(disposition, Parameters)), - ?assertEqual(<<"chili-pepper.jpg">>, proplists:get_value(<<"filename">>, maps:get(disposition_params, Parameters))), - ?assertEqual(<<"chili-pepper.jpg">>, proplists:get_value(<<"name">>, maps:get(content_type_params, Parameters))) - end - }, - {"message attachment only", - fun() -> - Decoded = Getmail("message-as-attachment.eml"), - ?assertMatch({<<"multipart">>, <<"mixed">>, _, _, _}, Decoded), - [Body] = element(5, Decoded), - ?assertMatch({<<"message">>, <<"rfc822">>, _, _, _}, Body), - Subbody = element(5, Body), - ?assertMatch({<<"text">>, <<"plain">>, _, _, _}, Subbody), - ?assertEqual(<<"This message contains only plain text.\r\n">>, element(5, Subbody)) - end - }, - {"message, image, and rtf attachments.", - fun() -> - Decoded = Getmail("message-image-text-attachments.eml"), - ?assertMatch({<<"multipart">>, <<"mixed">>, _, _, _}, Decoded), - ?assertEqual(3, length(element(5, Decoded))), - [Message, Rtf, Image] = element(5, Decoded), - ?assertMatch({<<"message">>, <<"rfc822">>, _, _, _}, Message), - Submessage = element(5, Message), - ?assertMatch({<<"text">>, <<"plain">>, _, _, <<"This message contains only plain text.\r\n">>}, Submessage), - - ?assertMatch({<<"text">>, <<"rtf">>, _, _, _}, Rtf), - ?assertEqual(<<"{\\rtf1\\ansi\\ansicpg1252\\cocoartf949\\cocoasubrtf460\r\n{\\fonttbl\\f0\\fswiss\\fcharset0 Helvetica;}\r\n{\\colortbl;\\red255\\green255\\blue255;}\r\n\\margl1440\\margr1440\\vieww9000\\viewh8400\\viewkind0\r\n\\pard\\tx720\\tx1440\\tx2160\\tx2880\\tx3600\\tx4320\\tx5040\\tx5760\\tx6480\\tx7200\\tx7920\\tx8640\\ql\\qnatural\\pardirnatural\r\n\r\n\\f0\\fs24 \\cf0 This is a basic rtf file.}">>, element(5, Rtf)), - - ?assertMatch({<<"image">>, <<"jpeg">>, _, _, _}, Image), - ?assertEqual(?IMAGE_MD5, erlang:md5(element(5, Image))) - end - }, - {"alternative text/html with calendar attachment.", - fun() -> - Decoded = Getmail("message-text-html-attachment.eml"), - ?assertMatch({<<"multipart">>, <<"mixed">>, _, _, [ - {<<"multipart">>, <<"alternative">>, _, _, [ - {<<"text">>, <<"plain">>, _, _, _}, - {<<"text">>, <<"html">>, _, _, _}]}, - {<<"text">>, <<"calendar">>, _, _, _}]}, Decoded) - end - }, - {"Outlook 2007 with leading tabs in quoted-printable.", - fun() -> - Decoded = Getmail("outlook-2007.eml"), - ?assertMatch({<<"multipart">>, <<"alternative">>, _, _, _}, Decoded) - end - }, - {"The gamut", - fun() -> - % multipart/alternative - % text/plain - % multipart/mixed - % text/html - % message/rf822 - % multipart/mixed - % message/rfc822 - % text/plain - % text/html - % message/rtc822 - % text/plain - % text/html - % image/jpeg - % text/html - % text/rtf - % text/html - Decoded = Getmail("the-gamut.eml"), - ?assertMatch({<<"multipart">>, <<"alternative">>, _, _, _}, Decoded), - ?assertEqual(2, length(element(5, Decoded))), - [Toptext, Topmultipart] = element(5, Decoded), - ?assertMatch({<<"text">>, <<"plain">>, _, _, _}, Toptext), - ?assertEqual(<<"This is rich text.\r\n\r\nThe list is html.\r\n\r\nAttchments:\r\nan email containing an attachment of an email.\r\nan email of only plain text.\r\nan image\r\nan rtf file.\r\n">>, element(5, Toptext)), - ?assertEqual(9, length(element(5, Topmultipart))), - [Html, Messagewithin, Brhtml, _Message, Brhtml, Image, Brhtml, Rtf, Brhtml] = element(5, Topmultipart), - ?assertMatch({<<"text">>, <<"html">>, _, _, _}, Html), - ?assertEqual(<<"This is rich text.

The list is html.

Attchments:
  • an email containing an attachment of an email.
  • an email of only plain text.
  • an image
  • an rtf file.
">>, element(5, Html)), - - ?assertMatch({<<"message">>, <<"rfc822">>, _, _, _}, Messagewithin), - %?assertEqual(1, length(element(5, Messagewithin))), - ?assertMatch({<<"multipart">>, <<"mixed">>, _, _, [{<<"message">>, <<"rfc822">>, _, _, {<<"text">>, <<"plain">>, _, _, <<"This message contains only plain text.\r\n">>}}]}, element(5, Messagewithin)), - - ?assertMatch({<<"image">>, <<"jpeg">>, _, _, _}, Image), - ?assertEqual(?IMAGE_MD5, erlang:md5(element(5, Image))), - - ?assertMatch({<<"text">>, <<"rtf">>, _, _, _}, Rtf), - ?assertEqual(<<"{\\rtf1\\ansi\\ansicpg1252\\cocoartf949\\cocoasubrtf460\r\n{\\fonttbl\\f0\\fswiss\\fcharset0 Helvetica;}\r\n{\\colortbl;\\red255\\green255\\blue255;}\r\n\\margl1440\\margr1440\\vieww9000\\viewh8400\\viewkind0\r\n\\pard\\tx720\\tx1440\\tx2160\\tx2880\\tx3600\\tx4320\\tx5040\\tx5760\\tx6480\\tx7200\\tx7920\\tx8640\\ql\\qnatural\\pardirnatural\r\n\r\n\\f0\\fs24 \\cf0 This is a basic rtf file.}">>, element(5, Rtf)) - - end - }, - {"Plain text and 2 identical attachments", - fun() -> - Decoded = Getmail("plain-text-and-two-identical-attachments.eml"), - ?assertMatch({<<"multipart">>, <<"mixed">>, _, _, _}, Decoded), - ?assertEqual(3, length(element(5, Decoded))), - [Plain, Attach1, Attach2] = element(5, Decoded), - ?assertEqual(Attach1, Attach2), - ?assertMatch({<<"text">>, <<"plain">>, _, _, _}, Plain), - ?assertEqual(<<"This message contains only plain text.\r\n">>, element(5, Plain)) - end - }, - {"no \\r\\n before first boundary", - fun() -> - {ok, Bin} = file:read_file("test/fixtures/html.eml"), - Decoded = decode(Bin), - ?assertEqual(2, length(element(5, Decoded))) - end - }, - {"permissive malformed folded multibyte header decoder", - fun() -> - {_, _, Headers, _, Body} = Getmail("malformed-folded-multibyte-header.eml"), - ?assertEqual(<<"Hello world\n">>, Body), - Subject = <<78,79,68,51,50,32,83,109,97,114,116,32,83,101,99,117, 114,105,116,121,32,45,32,208,177,208,181,209,129,208, - 191,208,187,208,176,209,130,208,189,208,176,209,143,32, 208,187,208,184,209,134,208,181,208,189,208,183,208,184,209,143>>, - ?assertEqual(Subject, proplists:get_value(<<"Subject">>, Headers)) - end - }, - {"decode headers of multipart messages", - fun() -> - {<<"multipart">>, _, _, _, [Inline, Attachment]} = Getmail("utf-attachment-name.eml"), - {<<"text">>, _, _, _, InlineBody} = Inline, - {<<"text">>, _, _, ContentHeaders, _AttachmentBody} = Attachment, - ContentTypeName = proplists:get_value( - <<"name">>, maps:get( - content_type_params, ContentHeaders)), - DispositionName = proplists:get_value( - <<"filename">>, maps:get( - disposition_params, ContentHeaders)), - - ?assertEqual(<<"Hello\r\n">>, InlineBody), - ?assert(ContentTypeName == DispositionName), - % Take the filename as a literal, to prevent character set issues with Erlang - % In utf-8 the filename is:"тестовый файл.txt" - Filename = <<209,130,208,181,209,129,209,130,208,190,208,178,209,139,208,185,32,209,132,208,176,208,185,208,187,46,116,120,116>>, - ?assertEqual(Filename, ContentTypeName), - ?assertEqual(Filename, DispositionName) - end - }, - {"testcase1", - fun() -> - Multipart = <<"multipart">>, - Alternative = <<"alternative">>, - Related = <<"related">>, - Mixed = <<"mixed">>, - Text = <<"text">>, - Html = <<"html">>, - Plain = <<"plain">>, - Message = <<"message">>, - Ref822 = <<"rfc822">>, - Image = <<"image">>, - Jpeg = <<"jpeg">>, - %Imagemd5 = <<69,175,198,78,52,72,6,233,147,22,50,137,128,180,169,50>>, - Imagemd5 = <<179,151,42,139,78,14,182,78,24,160,123,221,217,14,141,5>>, - Decoded = Getmail("testcase1"), - ?assertMatch({Multipart, Mixed, _, _, [_, _]}, Decoded), - [Multi1, Message1] = element(5, Decoded), - ?assertMatch({Multipart, Alternative, _, _, [_, _]}, Multi1), - [Plain1, Html1] = element(5, Multi1), - ?assertMatch({Text, Plain, _, _, _}, Plain1), - ?assertMatch({Text, Html, _, _, _}, Html1), - ?assertMatch({Message, Ref822, _, _, _}, Message1), - Multi2 = element(5, Message1), - ?assertMatch({Multipart, Alternative, _, _, [_, _]}, Multi2), - [Plain2, Related1] = element(5, Multi2), - ?assertMatch({Text, Plain, _, _, _}, Plain2), - ?assertMatch({Multipart, Related, _, _, [_, _]}, Related1), - [Html2, Image1] = element(5, Related1), - ?assertMatch({Text, Html, _, _, _}, Html2), - ?assertMatch({Image, Jpeg, _, _, _}, Image1), - Resimage = erlang:md5(element(5, Image1)), - ?assertEqual(Imagemd5, Resimage) - end - }, - {"testcase2", - fun() -> - Multipart = <<"multipart">>, - Alternative = <<"alternative">>, - Mixed = <<"mixed">>, - Text = <<"text">>, - Html = <<"html">>, - Plain = <<"plain">>, - Message = <<"message">>, - Ref822 = <<"rfc822">>, - Application = <<"application">>, - Octetstream = <<"octet-stream">>, - Decoded = Getmail("testcase2"), - ?assertMatch({Multipart, Mixed, _, _, [_, _, _]}, Decoded), - [Plain1, Stream1, Message1] = element(5, Decoded), - ?assertMatch({Text, Plain, _, _, _}, Plain1), - ?assertMatch({Application, Octetstream, _, _, _}, Stream1), - ?assertMatch({Message, Ref822, _, _, _}, Message1), - Multi1 = element(5, Message1), - ?assertMatch({Multipart, Alternative, _, _, [_, _]}, Multi1), - [Plain2, Html1] = element(5, Multi1), - ?assertMatch({Text, Plain, _, _, _}, Plain2), - ?assertMatch({Text, Html, _, _, _}, Html1) - end - } - ]. + Getmail = fun(File) -> + {ok, Email} = file:read_file(string:concat("test/fixtures/", File)), + %Email = binary_to_list(Bin), + decode(Email) + end, + [ + {"parse a plain text email", fun() -> + Decoded = Getmail("Plain-text-only.eml"), + ?assertEqual(5, tuple_size(Decoded)), + {Type, SubType, _Headers, _Properties, Body} = Decoded, + ?assertEqual({<<"text">>, <<"plain">>}, {Type, SubType}), + ?assertEqual(<<"This message contains only plain text.\r\n">>, Body) + end}, + {"parse a Python smtplib plain text email", fun() -> + Decoded = Getmail("python-smtp-lib.eml"), + ?assertEqual(5, tuple_size(Decoded)), + {Type, SubType, _Headers, _Properties, Body} = Decoded, + ?assertEqual({<<"text">>, <<"plain">>}, {Type, SubType}), + ?assertEqual(<<"Hello world Python.\r\n">>, Body) + end}, + {"parse a plain text email with no content type", fun() -> + Decoded = Getmail("Plain-text-only-no-content-type.eml"), + ?assertEqual(5, tuple_size(Decoded)), + {Type, SubType, _Headers, _Properties, Body} = Decoded, + ?assertEqual({<<"text">>, <<"plain">>}, {Type, SubType}), + ?assertEqual(<<"This message contains only plain text.\r\n">>, Body) + end}, + {"parse a plain text email with no MIME header", fun() -> + {Type, SubType, _Headers, _Properties, Body} = + Getmail("Plain-text-only-no-MIME.eml"), + ?assertEqual({<<"text">>, <<"plain">>}, {Type, SubType}), + ?assertEqual(<<"This message contains only plain text.\r\n">>, Body) + end}, + {"parse an email that says it is multipart but contains no boundaries", fun() -> + ?assertError(missing_boundary, Getmail("Plain-text-only-with-boundary-header.eml")) + end}, + {"parse a multipart email with no MIME header", fun() -> + % We now insert a default Mime for missing Mime headers + % ?assertError(non_mime_multipart, Getmail("rich-text-no-MIME.eml")) + ?assertMatch( + {<<"multipart">>, <<"alternative">>, _, _, [ + {<<"text">>, <<"plain">>, _, _, _}, {<<"text">>, <<"html">>, _, _, _} + ]}, + Getmail("rich-text-no-MIME.eml") + ) + end}, + {"rich text", fun() -> + %% pardon my naming here. apparently 'rich text' in mac mail + %% means 'html'. + Decoded = Getmail("rich-text.eml"), + ?assertEqual(5, tuple_size(Decoded)), + {Type, SubType, _Headers, _Properties, Body} = Decoded, + ?assertEqual({<<"multipart">>, <<"alternative">>}, {Type, SubType}), + ?assertEqual(2, length(Body)), + [Plain, Html] = Body, + ?assertEqual({5, 5}, {tuple_size(Plain), tuple_size(Html)}), + ?assertMatch({<<"text">>, <<"plain">>, _, _, <<"This message contains rich text.">>}, Plain), + ?assertMatch( + {<<"text">>, <<"html">>, _, _, + <<"This message contains rich text.">>}, + Html + ) + end}, + {"rich text no boundary", fun() -> + ?assertError(no_boundary, Getmail("rich-text-no-boundary.eml")) + end}, + {"rich text missing first boundary", fun() -> + % TODO - should we handle this more elegantly? + Decoded = Getmail("rich-text-missing-first-boundary.eml"), + ?assertEqual(5, tuple_size(Decoded)), + {Type, SubType, _Headers, _Properties, Body} = Decoded, + ?assertEqual({<<"multipart">>, <<"alternative">>}, {Type, SubType}), + ?assertEqual(1, length(Body)), + [Html] = Body, + ?assertEqual(5, tuple_size(Html)), + ?assertMatch( + {<<"text">>, <<"html">>, _, _, + <<"This message contains rich text.">>}, + Html + ) + end}, + {"rich text missing last boundary", fun() -> + ?assertError(missing_last_boundary, Getmail("rich-text-missing-last-boundary.eml")) + end}, + {"rich text wrong last boundary", fun() -> + ?assertError(missing_last_boundary, Getmail("rich-text-broken-last-boundary.eml")) + end}, + {"rich text missing text content type", fun() -> + %% pardon my naming here. apparently 'rich text' in mac mail + %% means 'html'. + Decoded = Getmail("rich-text-no-text-contenttype.eml"), + ?assertEqual(5, tuple_size(Decoded)), + {Type, SubType, _Headers, _Properties, Body} = Decoded, + ?assertEqual({<<"multipart">>, <<"alternative">>}, {Type, SubType}), + ?assertEqual(2, length(Body)), + [Plain, Html] = Body, + ?assertEqual({5, 5}, {tuple_size(Plain), tuple_size(Html)}), + ?assertMatch({<<"text">>, <<"plain">>, _, _, <<"This message contains rich text.">>}, Plain), + ?assertMatch( + {<<"text">>, <<"html">>, _, _, + <<"This message contains rich text.">>}, + Html + ) + end}, + {"text attachment only", fun() -> + Decoded = Getmail("text-attachment-only.eml"), + ?assertEqual(5, tuple_size(Decoded)), + {Type, SubType, _Headers, _Properties, Body} = Decoded, + ?assertEqual({<<"multipart">>, <<"mixed">>}, {Type, SubType}), + ?assertEqual(1, length(Body)), + Rich = + <<"{\\rtf1\\ansi\\ansicpg1252\\cocoartf949\\cocoasubrtf460\r\n{\\fonttbl\\f0\\fswiss\\fcharset0 Helvetica;}\r\n{\\colortbl;\\red255\\green255\\blue255;}\r\n\\margl1440\\margr1440\\vieww9000\\viewh8400\\viewkind0\r\n\\pard\\tx720\\tx1440\\tx2160\\tx2880\\tx3600\\tx4320\\tx5040\\tx5760\\tx6480\\tx7200\\tx7920\\tx8640\\ql\\qnatural\\pardirnatural\r\n\r\n\\f0\\fs24 \\cf0 This is a basic rtf file.}">>, + ?assertMatch([{<<"text">>, <<"rtf">>, _, _, Rich}], Body) + end}, + {"image attachment only", fun() -> + Decoded = Getmail("image-attachment-only.eml"), + ?assertEqual(5, tuple_size(Decoded)), + {Type, SubType, _Headers, _Properties, Body} = Decoded, + ?assertEqual({<<"multipart">>, <<"mixed">>}, {Type, SubType}), + ?assertEqual(1, length(Body)), + ?assertMatch([{<<"image">>, <<"jpeg">>, _, _, _}], Body), + [H | _] = Body, + [{<<"image">>, <<"jpeg">>, _, Parameters, _Image}] = Body, + ?assertEqual(?IMAGE_MD5, erlang:md5(element(5, H))), + ?assertEqual(<<"inline">>, maps:get(disposition, Parameters)), + ?assertEqual( + <<"chili-pepper.jpg">>, proplists:get_value(<<"filename">>, maps:get(disposition_params, Parameters)) + ), + ?assertEqual( + <<"chili-pepper.jpg">>, proplists:get_value(<<"name">>, maps:get(content_type_params, Parameters)) + ) + end}, + {"message attachment only", fun() -> + Decoded = Getmail("message-as-attachment.eml"), + ?assertMatch({<<"multipart">>, <<"mixed">>, _, _, _}, Decoded), + [Body] = element(5, Decoded), + ?assertMatch({<<"message">>, <<"rfc822">>, _, _, _}, Body), + Subbody = element(5, Body), + ?assertMatch({<<"text">>, <<"plain">>, _, _, _}, Subbody), + ?assertEqual(<<"This message contains only plain text.\r\n">>, element(5, Subbody)) + end}, + {"message, image, and rtf attachments.", fun() -> + Decoded = Getmail("message-image-text-attachments.eml"), + ?assertMatch({<<"multipart">>, <<"mixed">>, _, _, _}, Decoded), + ?assertEqual(3, length(element(5, Decoded))), + [Message, Rtf, Image] = element(5, Decoded), + ?assertMatch({<<"message">>, <<"rfc822">>, _, _, _}, Message), + Submessage = element(5, Message), + ?assertMatch({<<"text">>, <<"plain">>, _, _, <<"This message contains only plain text.\r\n">>}, Submessage), + + ?assertMatch({<<"text">>, <<"rtf">>, _, _, _}, Rtf), + ?assertEqual( + <<"{\\rtf1\\ansi\\ansicpg1252\\cocoartf949\\cocoasubrtf460\r\n{\\fonttbl\\f0\\fswiss\\fcharset0 Helvetica;}\r\n{\\colortbl;\\red255\\green255\\blue255;}\r\n\\margl1440\\margr1440\\vieww9000\\viewh8400\\viewkind0\r\n\\pard\\tx720\\tx1440\\tx2160\\tx2880\\tx3600\\tx4320\\tx5040\\tx5760\\tx6480\\tx7200\\tx7920\\tx8640\\ql\\qnatural\\pardirnatural\r\n\r\n\\f0\\fs24 \\cf0 This is a basic rtf file.}">>, + element(5, Rtf) + ), + + ?assertMatch({<<"image">>, <<"jpeg">>, _, _, _}, Image), + ?assertEqual(?IMAGE_MD5, erlang:md5(element(5, Image))) + end}, + {"alternative text/html with calendar attachment.", fun() -> + Decoded = Getmail("message-text-html-attachment.eml"), + ?assertMatch( + {<<"multipart">>, <<"mixed">>, _, _, [ + {<<"multipart">>, <<"alternative">>, _, _, [ + {<<"text">>, <<"plain">>, _, _, _}, + {<<"text">>, <<"html">>, _, _, _} + ]}, + {<<"text">>, <<"calendar">>, _, _, _} + ]}, + Decoded + ) + end}, + {"Outlook 2007 with leading tabs in quoted-printable.", fun() -> + Decoded = Getmail("outlook-2007.eml"), + ?assertMatch({<<"multipart">>, <<"alternative">>, _, _, _}, Decoded) + end}, + {"The gamut", fun() -> + % multipart/alternative + % text/plain + % multipart/mixed + % text/html + % message/rf822 + % multipart/mixed + % message/rfc822 + % text/plain + % text/html + % message/rtc822 + % text/plain + % text/html + % image/jpeg + % text/html + % text/rtf + % text/html + Decoded = Getmail("the-gamut.eml"), + ?assertMatch({<<"multipart">>, <<"alternative">>, _, _, _}, Decoded), + ?assertEqual(2, length(element(5, Decoded))), + [Toptext, Topmultipart] = element(5, Decoded), + ?assertMatch({<<"text">>, <<"plain">>, _, _, _}, Toptext), + ?assertEqual( + <<"This is rich text.\r\n\r\nThe list is html.\r\n\r\nAttchments:\r\nan email containing an attachment of an email.\r\nan email of only plain text.\r\nan image\r\nan rtf file.\r\n">>, + element(5, Toptext) + ), + ?assertEqual(9, length(element(5, Topmultipart))), + [Html, Messagewithin, Brhtml, _Message, Brhtml, Image, Brhtml, Rtf, Brhtml] = element(5, Topmultipart), + ?assertMatch({<<"text">>, <<"html">>, _, _, _}, Html), + ?assertEqual( + <<"This is rich text.

The list is html.

Attchments:
  • an email containing an attachment of an email.
  • an email of only plain text.
  • an image
  • an rtf file.
">>, + element(5, Html) + ), + + ?assertMatch({<<"message">>, <<"rfc822">>, _, _, _}, Messagewithin), + %?assertEqual(1, length(element(5, Messagewithin))), + ?assertMatch( + {<<"multipart">>, <<"mixed">>, _, _, [ + {<<"message">>, <<"rfc822">>, _, _, + {<<"text">>, <<"plain">>, _, _, <<"This message contains only plain text.\r\n">>}} + ]}, + element(5, Messagewithin) + ), + + ?assertMatch({<<"image">>, <<"jpeg">>, _, _, _}, Image), + ?assertEqual(?IMAGE_MD5, erlang:md5(element(5, Image))), + + ?assertMatch({<<"text">>, <<"rtf">>, _, _, _}, Rtf), + ?assertEqual( + <<"{\\rtf1\\ansi\\ansicpg1252\\cocoartf949\\cocoasubrtf460\r\n{\\fonttbl\\f0\\fswiss\\fcharset0 Helvetica;}\r\n{\\colortbl;\\red255\\green255\\blue255;}\r\n\\margl1440\\margr1440\\vieww9000\\viewh8400\\viewkind0\r\n\\pard\\tx720\\tx1440\\tx2160\\tx2880\\tx3600\\tx4320\\tx5040\\tx5760\\tx6480\\tx7200\\tx7920\\tx8640\\ql\\qnatural\\pardirnatural\r\n\r\n\\f0\\fs24 \\cf0 This is a basic rtf file.}">>, + element(5, Rtf) + ) + end}, + {"Plain text and 2 identical attachments", fun() -> + Decoded = Getmail("plain-text-and-two-identical-attachments.eml"), + ?assertMatch({<<"multipart">>, <<"mixed">>, _, _, _}, Decoded), + ?assertEqual(3, length(element(5, Decoded))), + [Plain, Attach1, Attach2] = element(5, Decoded), + ?assertEqual(Attach1, Attach2), + ?assertMatch({<<"text">>, <<"plain">>, _, _, _}, Plain), + ?assertEqual(<<"This message contains only plain text.\r\n">>, element(5, Plain)) + end}, + {"no \\r\\n before first boundary", fun() -> + {ok, Bin} = file:read_file("test/fixtures/html.eml"), + Decoded = decode(Bin), + ?assertEqual(2, length(element(5, Decoded))) + end}, + {"permissive malformed folded multibyte header decoder", fun() -> + {_, _, Headers, _, Body} = Getmail("malformed-folded-multibyte-header.eml"), + ?assertEqual(<<"Hello world\n">>, Body), + Subject = + <<78, 79, 68, 51, 50, 32, 83, 109, 97, 114, 116, 32, 83, 101, 99, 117, 114, 105, 116, 121, 32, 45, 32, + 208, 177, 208, 181, 209, 129, 208, 191, 208, 187, 208, 176, 209, 130, 208, 189, 208, 176, 209, 143, + 32, 208, 187, 208, 184, 209, 134, 208, 181, 208, 189, 208, 183, 208, 184, 209, 143>>, + ?assertEqual(Subject, proplists:get_value(<<"Subject">>, Headers)) + end}, + {"decode headers of multipart messages", fun() -> + {<<"multipart">>, _, _, _, [Inline, Attachment]} = Getmail("utf-attachment-name.eml"), + {<<"text">>, _, _, _, InlineBody} = Inline, + {<<"text">>, _, _, ContentHeaders, _AttachmentBody} = Attachment, + ContentTypeName = proplists:get_value( + <<"name">>, + maps:get( + content_type_params, ContentHeaders + ) + ), + DispositionName = proplists:get_value( + <<"filename">>, + maps:get( + disposition_params, ContentHeaders + ) + ), + + ?assertEqual(<<"Hello\r\n">>, InlineBody), + ?assert(ContentTypeName == DispositionName), + % Take the filename as a literal, to prevent character set issues with Erlang + % In utf-8 the filename is:"тестовый файл.txt" + Filename = + <<209, 130, 208, 181, 209, 129, 209, 130, 208, 190, 208, 178, 209, 139, 208, 185, 32, 209, 132, 208, + 176, 208, 185, 208, 187, 46, 116, 120, 116>>, + ?assertEqual(Filename, ContentTypeName), + ?assertEqual(Filename, DispositionName) + end}, + {"testcase1", fun() -> + Multipart = <<"multipart">>, + Alternative = <<"alternative">>, + Related = <<"related">>, + Mixed = <<"mixed">>, + Text = <<"text">>, + Html = <<"html">>, + Plain = <<"plain">>, + Message = <<"message">>, + Ref822 = <<"rfc822">>, + Image = <<"image">>, + Jpeg = <<"jpeg">>, + %Imagemd5 = <<69,175,198,78,52,72,6,233,147,22,50,137,128,180,169,50>>, + Imagemd5 = <<179, 151, 42, 139, 78, 14, 182, 78, 24, 160, 123, 221, 217, 14, 141, 5>>, + Decoded = Getmail("testcase1"), + ?assertMatch({Multipart, Mixed, _, _, [_, _]}, Decoded), + [Multi1, Message1] = element(5, Decoded), + ?assertMatch({Multipart, Alternative, _, _, [_, _]}, Multi1), + [Plain1, Html1] = element(5, Multi1), + ?assertMatch({Text, Plain, _, _, _}, Plain1), + ?assertMatch({Text, Html, _, _, _}, Html1), + ?assertMatch({Message, Ref822, _, _, _}, Message1), + Multi2 = element(5, Message1), + ?assertMatch({Multipart, Alternative, _, _, [_, _]}, Multi2), + [Plain2, Related1] = element(5, Multi2), + ?assertMatch({Text, Plain, _, _, _}, Plain2), + ?assertMatch({Multipart, Related, _, _, [_, _]}, Related1), + [Html2, Image1] = element(5, Related1), + ?assertMatch({Text, Html, _, _, _}, Html2), + ?assertMatch({Image, Jpeg, _, _, _}, Image1), + Resimage = erlang:md5(element(5, Image1)), + ?assertEqual(Imagemd5, Resimage) + end}, + {"testcase2", fun() -> + Multipart = <<"multipart">>, + Alternative = <<"alternative">>, + Mixed = <<"mixed">>, + Text = <<"text">>, + Html = <<"html">>, + Plain = <<"plain">>, + Message = <<"message">>, + Ref822 = <<"rfc822">>, + Application = <<"application">>, + Octetstream = <<"octet-stream">>, + Decoded = Getmail("testcase2"), + ?assertMatch({Multipart, Mixed, _, _, [_, _, _]}, Decoded), + [Plain1, Stream1, Message1] = element(5, Decoded), + ?assertMatch({Text, Plain, _, _, _}, Plain1), + ?assertMatch({Application, Octetstream, _, _, _}, Stream1), + ?assertMatch({Message, Ref822, _, _, _}, Message1), + Multi1 = element(5, Message1), + ?assertMatch({Multipart, Alternative, _, _, [_, _]}, Multi1), + [Plain2, Html1] = element(5, Multi1), + ?assertMatch({Text, Plain, _, _, _}, Plain2), + ?assertMatch({Text, Html, _, _, _}, Html1) + end} + ]. decode_quoted_printable_test_() -> - [ - {"bleh", - fun() -> - ?assertEqual("!", decode_quoted_printable_line(<<"=21">>, "")), - ?assertEqual("!!", decode_quoted_printable_line(<<"=21=21">>, "")), - ?assertEqual("=:=", decode_quoted_printable_line(<<"=3D:=3D">>, "")), - ?assertEqual("Thequickbrownfoxjumpedoverthelazydog.", decode_quoted_printable_line(<<"Thequickbrownfoxjumpedoverthelazydog.">>, "")) - end - }, - {"lowercase bleh", - fun() -> - ?assertEqual("=:=", decode_quoted_printable_line(<<"=3d:=3d">>, "")) - end - }, - {"input with spaces", - fun() -> - ?assertEqual("The quick brown fox jumped over the lazy dog.", decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog.">>, "")) - end - }, - {"input with tabs", - fun() -> - ?assertEqual("The\tquick brown fox jumped over\tthe lazy dog.", decode_quoted_printable_line(<<"The\tquick brown fox jumped over\tthe lazy dog.">>, "")) - end - }, - {"input with trailing spaces", - fun() -> - ?assertEqual("The quick brown fox jumped over the lazy dog.", decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog. ">>, "")) - end - }, - {"input with non-strippable trailing whitespace", - fun() -> - ?assertEqual("The quick brown fox jumped over the lazy dog. ", decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog. =20">>, "")), - ?assertEqual("The quick brown fox jumped over the lazy dog. \t", decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog. =09">>, "")), - ?assertEqual("The quick brown fox jumped over the lazy dog.\t \t \t \t ", decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog.\t \t \t =09=20">>, "")), - ?assertEqual("The quick brown fox jumped over the lazy dog.\t \t \t \t ", decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog.\t \t \t =09=20\t \t">>, "")) - end - }, - {"input with trailing tabs", - fun() -> - ?assertEqual("The quick brown fox jumped over the lazy dog.", decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog.\t\t\t\t\t">>, "")) - end - }, - {"soft new line", - fun() -> - ?assertEqual("The quick brown fox jumped over the lazy dog. ", decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog. =">>, "")) - end - }, - {"soft new line with trailing whitespace", - fun() -> - ?assertEqual("The quick brown fox jumped over the lazy dog. ", decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog. = ">>, "")) - end - }, - {"multiline stuff", - fun() -> - ?assertEqual(<<"Now's the time for all folk to come to the aid of their country.">>, decode_quoted_printable(<<"Now's the time =\r\nfor all folk to come=\r\n to the aid of their country.">>)), - ?assertEqual(<<"Now's the time\r\nfor all folk to come\r\n to the aid of their country.">>, decode_quoted_printable(<<"Now's the time\r\nfor all folk to come\r\n to the aid of their country.">>)), - ?assertEqual(<<"hello world">>, decode_quoted_printable(<<"hello world">>)), - ?assertEqual(<<"hello\r\n\r\nworld">>, decode_quoted_printable(<<"hello\r\n\r\nworld">>)) - end - }, - {"invalid input", - fun() -> - ?assertThrow(badchar, decode_quoted_printable_line(<<"=21=G1">>, "")), - ?assertThrow(badchar, decode_quoted_printable(<<"=21=D1 = g ">>)) - end - }, - %% TODO zotonic's iconv throws eilseq here. - % {"out of range characters should be stripped", - % fun() -> - % % character 150 is en-dash in windows 1252 - % ?assertEqual(<<"Foo bar"/utf8>>, decode_body(<<"quoted-printable">>, <<"Foo ", 150, " bar">>, "US-ASCII", "UTF-8//IGNORE")) - % end - % }, - {"out of range character in alternate charset should be converted", - fun() -> - % character 150 is en-dash in windows 1252 - ?assertEqual(<<"Foo ", 226, 128, 147, " bar">>, decode_body(<<"quoted-printable">>, <<"Foo ",150," bar">>, "Windows-1252", "UTF-8//IGNORE")) - end - }, - {"out of range character in alternate charset with no destination encoding should be stripped", - fun() -> - % character 150 is en-dash in windows 1252 - ?assertEqual(<<"Foo bar">>, decode_body(<<"quoted-printable">>, <<"Foo ",150," bar">>, "Windows-1252", none)) - end - }, - {"out of range character in alternate charset with no source encoding should be stripped", - fun() -> - % character 150 is en-dash in windows 1252 - ?assertEqual(<<"Foo bar">>, decode_body(<<"quoted-printable">>, <<"Foo ",150," bar">>, undefined, "UTF-8")) - end - }, - {"almost correct chatsets should work, eg. 'UTF8' instead of 'UTF-8'", - fun() -> - % character 150 is en-dash in windows 1252 - ?assertEqual(<<"Foo bar">>, decode_body(<<"quoted-printable">>, <<"Foo bar">>, <<"UTF8">>, "UTF-8")), - ?assertEqual(<<"Foo bar">>, decode_body(<<"quoted-printable">>, <<"Foo bar">>, <<"utf8">>, "UTF-8")) - end - } - ]. + [ + {"bleh", fun() -> + ?assertEqual("!", decode_quoted_printable_line(<<"=21">>, "")), + ?assertEqual("!!", decode_quoted_printable_line(<<"=21=21">>, "")), + ?assertEqual("=:=", decode_quoted_printable_line(<<"=3D:=3D">>, "")), + ?assertEqual( + "Thequickbrownfoxjumpedoverthelazydog.", + decode_quoted_printable_line(<<"Thequickbrownfoxjumpedoverthelazydog.">>, "") + ) + end}, + {"lowercase bleh", fun() -> + ?assertEqual("=:=", decode_quoted_printable_line(<<"=3d:=3d">>, "")) + end}, + {"input with spaces", fun() -> + ?assertEqual( + "The quick brown fox jumped over the lazy dog.", + decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog.">>, "") + ) + end}, + {"input with tabs", fun() -> + ?assertEqual( + "The\tquick brown fox jumped over\tthe lazy dog.", + decode_quoted_printable_line(<<"The\tquick brown fox jumped over\tthe lazy dog.">>, "") + ) + end}, + {"input with trailing spaces", fun() -> + ?assertEqual( + "The quick brown fox jumped over the lazy dog.", + decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog. ">>, "") + ) + end}, + {"input with non-strippable trailing whitespace", fun() -> + ?assertEqual( + "The quick brown fox jumped over the lazy dog. ", + decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog. =20">>, "") + ), + ?assertEqual( + "The quick brown fox jumped over the lazy dog. \t", + decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog. =09">>, "") + ), + ?assertEqual( + "The quick brown fox jumped over the lazy dog.\t \t \t \t ", + decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog.\t \t \t =09=20">>, "") + ), + ?assertEqual( + "The quick brown fox jumped over the lazy dog.\t \t \t \t ", + decode_quoted_printable_line( + <<"The quick brown fox jumped over the lazy dog.\t \t \t =09=20\t \t">>, "" + ) + ) + end}, + {"input with trailing tabs", fun() -> + ?assertEqual( + "The quick brown fox jumped over the lazy dog.", + decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog.\t\t\t\t\t">>, "") + ) + end}, + {"soft new line", fun() -> + ?assertEqual( + "The quick brown fox jumped over the lazy dog. ", + decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog. =">>, "") + ) + end}, + {"soft new line with trailing whitespace", fun() -> + ?assertEqual( + "The quick brown fox jumped over the lazy dog. ", + decode_quoted_printable_line(<<"The quick brown fox jumped over the lazy dog. = ">>, "") + ) + end}, + {"multiline stuff", fun() -> + ?assertEqual( + <<"Now's the time for all folk to come to the aid of their country.">>, + decode_quoted_printable( + <<"Now's the time =\r\nfor all folk to come=\r\n to the aid of their country.">> + ) + ), + ?assertEqual( + <<"Now's the time\r\nfor all folk to come\r\n to the aid of their country.">>, + decode_quoted_printable(<<"Now's the time\r\nfor all folk to come\r\n to the aid of their country.">>) + ), + ?assertEqual(<<"hello world">>, decode_quoted_printable(<<"hello world">>)), + ?assertEqual(<<"hello\r\n\r\nworld">>, decode_quoted_printable(<<"hello\r\n\r\nworld">>)) + end}, + {"invalid input", fun() -> + ?assertThrow(badchar, decode_quoted_printable_line(<<"=21=G1">>, "")), + ?assertThrow(badchar, decode_quoted_printable(<<"=21=D1 = g ">>)) + end}, + %% TODO zotonic's iconv throws eilseq here. + % {"out of range characters should be stripped", + % fun() -> + % % character 150 is en-dash in windows 1252 + % ?assertEqual(<<"Foo bar"/utf8>>, decode_body(<<"quoted-printable">>, <<"Foo ", 150, " bar">>, "US-ASCII", "UTF-8//IGNORE")) + % end + % }, + {"out of range character in alternate charset should be converted", fun() -> + % character 150 is en-dash in windows 1252 + ?assertEqual( + <<"Foo ", 226, 128, 147, " bar">>, + decode_body(<<"quoted-printable">>, <<"Foo ", 150, " bar">>, "Windows-1252", "UTF-8//IGNORE") + ) + end}, + {"out of range character in alternate charset with no destination encoding should be stripped", fun() -> + % character 150 is en-dash in windows 1252 + ?assertEqual( + <<"Foo bar">>, decode_body(<<"quoted-printable">>, <<"Foo ", 150, " bar">>, "Windows-1252", none) + ) + end}, + {"out of range character in alternate charset with no source encoding should be stripped", fun() -> + % character 150 is en-dash in windows 1252 + ?assertEqual( + <<"Foo bar">>, decode_body(<<"quoted-printable">>, <<"Foo ", 150, " bar">>, undefined, "UTF-8") + ) + end}, + {"almost correct chatsets should work, eg. 'UTF8' instead of 'UTF-8'", fun() -> + % character 150 is en-dash in windows 1252 + ?assertEqual(<<"Foo bar">>, decode_body(<<"quoted-printable">>, <<"Foo bar">>, <<"UTF8">>, "UTF-8")), + ?assertEqual(<<"Foo bar">>, decode_body(<<"quoted-printable">>, <<"Foo bar">>, <<"utf8">>, "UTF-8")) + end} + ]. valid_smtp_mime_7bit_test() -> - ?assert(valid_7bit(<<>>)), - ?assert(valid_7bit(<<"abcdefghijklmnopqrstuvwxyz0123456789">>)), - ?assert(valid_7bit(<<"abc\r\ndef">>)), - AllValidRange = (lists:seq(1, $\n - 1) - ++ lists:seq($\n + 1, $\r - 1) - ++ lists:seq($\r + 1, 127)), - ?assert(valid_7bit(list_to_binary(AllValidRange))), - ?assertNot(valid_7bit(<<"\n">>)), - ?assertNot(valid_7bit(<<"\r">>)), - ?assertNot(valid_7bit(<<"abc\ndef">>)), - ?assertNot(valid_7bit(<<"abc\rdef">>)), - ?assertNot(valid_7bit(<<"abc\n\rdef">>)), - ?assertNot(valid_7bit(<<128, 200, 255>>)), - ?assertNot(valid_7bit(<<0, 0, 0>>)), - ?assertNot(valid_7bit(<<"hello", 128, 0, 200>>)), - %% Long lines - Line800 = binary:copy(<<$a>>, 800), - ?assertNot(has_lines_over_998(Line800)), - Many800Lines = list_to_binary(lists:join("\r\n", lists:duplicate(10, Line800))), - ?assertNot(has_lines_over_998(Many800Lines)), - Line1000 = binary:copy(<<$a>>, 1000), - ?assert(has_lines_over_998(Line1000)), - Many1000Lines = list_to_binary(lists:join("\r\n", lists:duplicate(10, Line1000))), - ?assert(has_lines_over_998(Many1000Lines)), - ?assert(has_lines_over_998(<>)). + ?assert(valid_7bit(<<>>)), + ?assert(valid_7bit(<<"abcdefghijklmnopqrstuvwxyz0123456789">>)), + ?assert(valid_7bit(<<"abc\r\ndef">>)), + AllValidRange = + (lists:seq(1, $\n - 1) ++ + lists:seq($\n + 1, $\r - 1) ++ + lists:seq($\r + 1, 127)), + ?assert(valid_7bit(list_to_binary(AllValidRange))), + ?assertNot(valid_7bit(<<"\n">>)), + ?assertNot(valid_7bit(<<"\r">>)), + ?assertNot(valid_7bit(<<"abc\ndef">>)), + ?assertNot(valid_7bit(<<"abc\rdef">>)), + ?assertNot(valid_7bit(<<"abc\n\rdef">>)), + ?assertNot(valid_7bit(<<128, 200, 255>>)), + ?assertNot(valid_7bit(<<0, 0, 0>>)), + ?assertNot(valid_7bit(<<"hello", 128, 0, 200>>)), + %% Long lines + Line800 = binary:copy(<<$a>>, 800), + ?assertNot(has_lines_over_998(Line800)), + Many800Lines = list_to_binary(lists:join("\r\n", lists:duplicate(10, Line800))), + ?assertNot(has_lines_over_998(Many800Lines)), + Line1000 = binary:copy(<<$a>>, 1000), + ?assert(has_lines_over_998(Line1000)), + Many1000Lines = list_to_binary(lists:join("\r\n", lists:duplicate(10, Line1000))), + ?assert(has_lines_over_998(Many1000Lines)), + ?assert(has_lines_over_998(<>)). encode_quoted_printable_test_() -> - [ - {"bleh", - fun() -> - ?assertEqual([<<"!">>], encode_quoted_printable(<<"!">>)), - ?assertEqual([<<"!!">>], encode_quoted_printable(<<"!!">>)), - ?assertEqual([<<"=3D:=3D">>], encode_quoted_printable(<<"=:=">>)), - ?assertEqual([<<"Thequickbrownfoxjumpedoverthelazydog.">>], - encode_quoted_printable(<<"Thequickbrownfoxjumpedoverthelazydog.">>)) - end - }, - {"input with spaces", - fun() -> - ?assertEqual([<<"The quick brown fox jumped over the lazy dog.">>], - encode_quoted_printable(<<"The quick brown fox jumped over the lazy dog.">>)) - end - }, - {"input with tabs", - fun() -> - ?assertEqual([<<"The\tquick brown fox jumped over\tthe lazy dog.">>], - encode_quoted_printable(<<"The\tquick brown fox jumped over\tthe lazy dog.">>)) - end - }, - {"input with trailing spaces", - fun() -> - ?assertEqual([<<"The quick brown fox jumped over the lazy dog. =20\r\n">>], - encode_quoted_printable(<<"The quick brown fox jumped over the lazy dog. \r\n">>)), - ?assertEqual([<<"The quick brown fox jumped over the lazy dog. =20">>], - encode_quoted_printable(<<"The quick brown fox jumped over the lazy dog. ">>)) - end - }, - {"input with trailing tabs", - fun() -> - ?assertEqual([<<"The quick brown fox jumped over the lazy dog. =09\r\n">>], - encode_quoted_printable(<<"The quick brown fox jumped over the lazy dog. \r\n">>)), - ?assertEqual([<<"The quick brown fox jumped over the lazy dog. =09">>], - encode_quoted_printable(<<"The quick brown fox jumped over the lazy dog. ">>)) - end - }, - {"input with non-ascii characters", - fun() -> - ?assertEqual([<<"There's some n=F8n-=E1scii st=FCff in here\r\n">>], - encode_quoted_printable(<<"There's some n", 248, "n-", 225,"scii st", 252, "ff in here\r\n">>)) - end - }, - {"input with invisible non-ascii characters", - fun() -> - ?assertEqual([<<"There's some stuff=C2=A0in=C2=A0here\r\n">>], - encode_quoted_printable(<<"There's some stuff in here\r\n"/utf8>>)) - end - }, - {"add soft newlines", - fun() -> - ?assertEqual([<<"The quick brown fox jumped over the lazy dog. The quick brown fox jumped =\r\nover the lazy dog.">>], - encode_quoted_printable(<<"The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog.">>)), - ?assertEqual([<<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_ov=\r\ner_the_lazy_dog.">>], - encode_quoted_printable(<<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_over_the_lazy_dog.">>)), - ?assertEqual([<<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_o=\r\n=3Dver_the_lazy_dog.">>], - encode_quoted_printable(<<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_o=ver_the_lazy_dog.">>)), - ?assertEqual([<<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_=\r\n=3Dover_the_lazy_dog.">>], - encode_quoted_printable(<<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_=over_the_lazy_dog.">>)), - ?assertEqual([<<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_o =\r\nver_the_lazy_dog.">>], - encode_quoted_printable(<<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_o ver_the_lazy_dog.">>)) - end - }, - {"soft newline edge cases", - fun() -> - ?assertEqual([<<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345=\r\n" - "=20">>], - encode_quoted_printable(<<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345 ">>)), - ?assertEqual([<<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345=\r\n" - "=20\r\n">>], - encode_quoted_printable(<<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345 \r\n">>)), - ?assertEqual([<<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345=\r\n" - "=09">>], - encode_quoted_printable(<<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345 ">>)), - ?assertEqual([<<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345=\r\n" - "=09\r\n">>], - encode_quoted_printable(<<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345 \r\n">>)), - ?assertEqual([<<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 =\r\n" - "12345=3D">>], - encode_quoted_printable(<<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345=">>)), - ?assertEqual([<<" 23456789012345678901234567890123456789012345678901234567890123456789012345=\r\n" - "=20">>], - encode_quoted_printable(<<" 23456789012345678901234567890123456789012345678901234567890123456789012345 ">>)), - ?assertEqual([<<" =\r\n" - "234567890123456789012345678901234567890123456789012345678901234567890123456">>], - encode_quoted_printable(<<" 234567890123456789012345678901234567890123456789012345678901234567890123456">>)), - ?assertEqual([<<" 23456789012345678901234567890123456789012345678901234567890123456789012345=\r\n" - "=3D">>], - encode_quoted_printable(<<" 23456789012345678901234567890123456789012345678901234567890123456789012345=">>)) - end - } - ]. + [ + {"bleh", fun() -> + ?assertEqual([<<"!">>], encode_quoted_printable(<<"!">>)), + ?assertEqual([<<"!!">>], encode_quoted_printable(<<"!!">>)), + ?assertEqual([<<"=3D:=3D">>], encode_quoted_printable(<<"=:=">>)), + ?assertEqual( + [<<"Thequickbrownfoxjumpedoverthelazydog.">>], + encode_quoted_printable(<<"Thequickbrownfoxjumpedoverthelazydog.">>) + ) + end}, + {"input with spaces", fun() -> + ?assertEqual( + [<<"The quick brown fox jumped over the lazy dog.">>], + encode_quoted_printable(<<"The quick brown fox jumped over the lazy dog.">>) + ) + end}, + {"input with tabs", fun() -> + ?assertEqual( + [<<"The\tquick brown fox jumped over\tthe lazy dog.">>], + encode_quoted_printable(<<"The\tquick brown fox jumped over\tthe lazy dog.">>) + ) + end}, + {"input with trailing spaces", fun() -> + ?assertEqual( + [<<"The quick brown fox jumped over the lazy dog. =20\r\n">>], + encode_quoted_printable(<<"The quick brown fox jumped over the lazy dog. \r\n">>) + ), + ?assertEqual( + [<<"The quick brown fox jumped over the lazy dog. =20">>], + encode_quoted_printable(<<"The quick brown fox jumped over the lazy dog. ">>) + ) + end}, + {"input with trailing tabs", fun() -> + ?assertEqual( + [<<"The quick brown fox jumped over the lazy dog. =09\r\n">>], + encode_quoted_printable(<<"The quick brown fox jumped over the lazy dog. \r\n">>) + ), + ?assertEqual( + [<<"The quick brown fox jumped over the lazy dog. =09">>], + encode_quoted_printable(<<"The quick brown fox jumped over the lazy dog. ">>) + ) + end}, + {"input with non-ascii characters", fun() -> + ?assertEqual( + [<<"There's some n=F8n-=E1scii st=FCff in here\r\n">>], + encode_quoted_printable(<<"There's some n", 248, "n-", 225, "scii st", 252, "ff in here\r\n">>) + ) + end}, + {"input with invisible non-ascii characters", fun() -> + ?assertEqual( + [<<"There's some stuff=C2=A0in=C2=A0here\r\n">>], + encode_quoted_printable(<<"There's some stuff in here\r\n"/utf8>>) + ) + end}, + {"add soft newlines", fun() -> + ?assertEqual( + [ + <<"The quick brown fox jumped over the lazy dog. The quick brown fox jumped =\r\nover the lazy dog.">> + ], + encode_quoted_printable( + <<"The quick brown fox jumped over the lazy dog. The quick brown fox jumped over the lazy dog.">> + ) + ), + ?assertEqual( + [ + <<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_ov=\r\ner_the_lazy_dog.">> + ], + encode_quoted_printable( + <<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_over_the_lazy_dog.">> + ) + ), + ?assertEqual( + [ + <<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_o=\r\n=3Dver_the_lazy_dog.">> + ], + encode_quoted_printable( + <<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_o=ver_the_lazy_dog.">> + ) + ), + ?assertEqual( + [ + <<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_=\r\n=3Dover_the_lazy_dog.">> + ], + encode_quoted_printable( + <<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_=over_the_lazy_dog.">> + ) + ), + ?assertEqual( + [ + <<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_o =\r\nver_the_lazy_dog.">> + ], + encode_quoted_printable( + <<"The_quick_brown_fox_jumped_over_the_lazy_dog._The_quick_brown_fox_jumped_o ver_the_lazy_dog.">> + ) + ) + end}, + {"soft newline edge cases", fun() -> + ?assertEqual( + [ + << + "123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345=\r\n" + "=20" + >> + ], + encode_quoted_printable( + <<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345 ">> + ) + ), + ?assertEqual( + [ + << + "123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345=\r\n" + "=20\r\n" + >> + ], + encode_quoted_printable( + <<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345 \r\n">> + ) + ), + ?assertEqual( + [ + << + "123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345=\r\n" + "=09" + >> + ], + encode_quoted_printable( + <<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345 ">> + ) + ), + ?assertEqual( + [ + << + "123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345=\r\n" + "=09\r\n" + >> + ], + encode_quoted_printable( + <<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345 \r\n">> + ) + ), + ?assertEqual( + [ + << + "123456789 123456789 123456789 123456789 123456789 123456789 123456789 =\r\n" + "12345=3D" + >> + ], + encode_quoted_printable( + <<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12345=">> + ) + ), + ?assertEqual( + [ + << + " 23456789012345678901234567890123456789012345678901234567890123456789012345=\r\n" + "=20" + >> + ], + encode_quoted_printable( + <<" 23456789012345678901234567890123456789012345678901234567890123456789012345 ">> + ) + ), + ?assertEqual( + [ + << + " =\r\n" + "234567890123456789012345678901234567890123456789012345678901234567890123456" + >> + ], + encode_quoted_printable( + <<" 234567890123456789012345678901234567890123456789012345678901234567890123456">> + ) + ), + ?assertEqual( + [ + << + " 23456789012345678901234567890123456789012345678901234567890123456789012345=\r\n" + "=3D" + >> + ], + encode_quoted_printable( + <<" 23456789012345678901234567890123456789012345678901234567890123456789012345=">> + ) + ) + end} + ]. encode_parameter_test_() -> - [ - {"Token", - fun() -> - ?assertEqual([[<<"a">>, $=, <<"abcdefghijklmnopqrstuvwxyz$%&*#!">>]], - encode_parameters([{<<"a">>, <<"abcdefghijklmnopqrstuvwxyz$%&*#!">>}])) - end - }, - {"TSpecial", - fun() -> - Special = " ()<>@,;:/[]?=", - [ - ?assertEqual([[<<"a">>, $=, $", <>, $"]], encode_parameters([{<<"a">>, <>}])) - || C <- Special - ], - ?assertEqual([[<<"a">>, $=, $", <<$\\,$">>, $"]], encode_parameters([{<<"a">>, <<$">>}])), - ?assertEqual([[<<"a">>, $=, $", <<$\\,$\\>>, $"]], encode_parameters([{<<"a">>, <<$\\>>}])) - end - } - ]. + [ + {"Token", fun() -> + ?assertEqual( + [[<<"a">>, $=, <<"abcdefghijklmnopqrstuvwxyz$%&*#!">>]], + encode_parameters([{<<"a">>, <<"abcdefghijklmnopqrstuvwxyz$%&*#!">>}]) + ) + end}, + {"TSpecial", fun() -> + Special = " ()<>@,;:/[]?=", + [ + ?assertEqual([[<<"a">>, $=, $", <>, $"]], encode_parameters([{<<"a">>, <>}])) + || C <- Special + ], + ?assertEqual([[<<"a">>, $=, $", <<$\\, $">>, $"]], encode_parameters([{<<"a">>, <<$">>}])), + ?assertEqual([[<<"a">>, $=, $", <<$\\, $\\>>, $"]], encode_parameters([{<<"a">>, <<$\\>>}])) + end} + ]. rfc2047_decode_test_() -> - [ - {"Simple tests", - fun() -> - ?assertEqual(<<"Keith Moore "/utf8>>, decode_header(<<"=?US-ASCII?Q?Keith_Moore?= ">>, "utf-8")), - ?assertEqual(<<"Keld Jørn Simonsen "/utf8>>, decode_header(<<"=?ISO-8859-1?Q?Keld_J=F8rn_Simonsen?= ">>, "utf-8")), - ?assertEqual(<<"Olle Järnefors "/utf8>>, decode_header(<<"=?ISO-8859-1?Q?Olle_J=E4rnefors?= ">>, "utf-8")), - ?assertEqual(<<"André Pirard "/utf8>>, decode_header(<<"=?ISO-8859-1?Q?Andr=E9?= Pirard ">>, "utf-8")) - end - }, - {"encoded words separated by whitespace should have whitespace removed", - fun() -> - ?assertEqual(<<"If you can read this you understand the example.">>, decode_header(<<"=?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?= =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=">>, "utf-8")), - ?assertEqual(<<"ab">>, decode_header(<<"=?ISO-8859-1?Q?a?= =?ISO-8859-1?Q?b?=">>, "utf-8")), - ?assertEqual(<<"ab">>, decode_header(<<"=?ISO-8859-1?Q?a?= =?ISO-8859-1?Q?b?=">>, "utf-8")), - ?assertEqual(<<"ab">>, decode_header(<<"=?ISO-8859-1?Q?a?= - =?ISO-8859-1?Q?b?=">>, "utf-8")) - end - }, - {"underscores expand to spaces", - fun() -> - ?assertEqual(<<"a b">>, decode_header(<<"=?ISO-8859-1?Q?a_b?=">>, "utf-8")), - ?assertEqual(<<"a b">>, decode_header(<<"=?ISO-8859-1?Q?a?= =?ISO-8859-2?Q?_b?=">>, "utf-8")) - end - }, - {"edgecases", - fun() -> - ?assertEqual(<<"this is some text">>, decode_header(<<"=?iso-8859-1?q?this=20is=20some=20text?=">>, "utf-8")), - ?assertEqual(<<"=?iso-8859-1?q?this is some text?=">>, decode_header(<<"=?iso-8859-1?q?this is some text?=">>, "utf-8")) - end - }, - {"invalid character sequence handling", - fun() -> - ?assertException(throw, eilseq, decode_header(<<"=?us-ascii?B?dGhpcyBjb250YWlucyBhIGNvcHlyaWdodCCpIHN5bWJvbA==?=">>, "utf-8")), - %?assertEqual(<<"this contains a copyright symbol"/utf8>>, decode_header(<<"=?us-ascii?B?dGhpcyBjb250YWlucyBhIGNvcHlyaWdodCCpIHN5bWJvbA==?=">>, "utf-8//IGNORE")), - ?assertEqual(<<"this contains a copyright © symbol"/utf8>>, decode_header(<<"=?iso-8859-1?B?dGhpcyBjb250YWlucyBhIGNvcHlyaWdodCCpIHN5bWJvbA==?=">>, "utf-8//IGNORE")) - end - }, - {"multiple unicode email addresses", - fun() -> - ?assertEqual(<<"Jacek Złydach , chak de planet óóóó , Jacek Złydach , chak de planet óóóó "/utf8>>, - decode_header(<<"=?UTF-8?B?SmFjZWsgWsWCeWRhY2g=?= , =?UTF-8?B?Y2hhayBkZSBwbGFuZXQgw7PDs8Ozw7M=?= , =?UTF-8?B?SmFjZWsgWsWCeWRhY2g=?= , =?UTF-8?B?Y2hhayBkZSBwbGFuZXQgw7PDs8Ozw7M=?= ">>, "utf-8")) - end - }, - {"decode something I encoded myself", - fun() -> - A = <<"Jacek Złydach "/utf8>>, - ?assertEqual(A, decode_header(rfc2047_utf8_encode(A), "utf-8")) - end - } - ]. + [ + {"Simple tests", fun() -> + ?assertEqual( + <<"Keith Moore "/utf8>>, + decode_header(<<"=?US-ASCII?Q?Keith_Moore?= ">>, "utf-8") + ), + ?assertEqual( + <<"Keld Jørn Simonsen "/utf8>>, + decode_header(<<"=?ISO-8859-1?Q?Keld_J=F8rn_Simonsen?= ">>, "utf-8") + ), + ?assertEqual( + <<"Olle Järnefors "/utf8>>, + decode_header(<<"=?ISO-8859-1?Q?Olle_J=E4rnefors?= ">>, "utf-8") + ), + ?assertEqual( + <<"André Pirard "/utf8>>, + decode_header(<<"=?ISO-8859-1?Q?Andr=E9?= Pirard ">>, "utf-8") + ) + end}, + {"encoded words separated by whitespace should have whitespace removed", fun() -> + ?assertEqual( + <<"If you can read this you understand the example.">>, + decode_header( + <<"=?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?= =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=">>, + "utf-8" + ) + ), + ?assertEqual(<<"ab">>, decode_header(<<"=?ISO-8859-1?Q?a?= =?ISO-8859-1?Q?b?=">>, "utf-8")), + ?assertEqual(<<"ab">>, decode_header(<<"=?ISO-8859-1?Q?a?= =?ISO-8859-1?Q?b?=">>, "utf-8")), + ?assertEqual( + <<"ab">>, + decode_header( + <<"=?ISO-8859-1?Q?a?=\n" + " =?ISO-8859-1?Q?b?=">>, + "utf-8" + ) + ) + end}, + {"underscores expand to spaces", fun() -> + ?assertEqual(<<"a b">>, decode_header(<<"=?ISO-8859-1?Q?a_b?=">>, "utf-8")), + ?assertEqual(<<"a b">>, decode_header(<<"=?ISO-8859-1?Q?a?= =?ISO-8859-2?Q?_b?=">>, "utf-8")) + end}, + {"edgecases", fun() -> + ?assertEqual( + <<"this is some text">>, decode_header(<<"=?iso-8859-1?q?this=20is=20some=20text?=">>, "utf-8") + ), + ?assertEqual( + <<"=?iso-8859-1?q?this is some text?=">>, + decode_header(<<"=?iso-8859-1?q?this is some text?=">>, "utf-8") + ) + end}, + {"invalid character sequence handling", fun() -> + ?assertException( + throw, + eilseq, + decode_header(<<"=?us-ascii?B?dGhpcyBjb250YWlucyBhIGNvcHlyaWdodCCpIHN5bWJvbA==?=">>, "utf-8") + ), + %?assertEqual(<<"this contains a copyright symbol"/utf8>>, decode_header(<<"=?us-ascii?B?dGhpcyBjb250YWlucyBhIGNvcHlyaWdodCCpIHN5bWJvbA==?=">>, "utf-8//IGNORE")), + ?assertEqual( + <<"this contains a copyright © symbol"/utf8>>, + decode_header(<<"=?iso-8859-1?B?dGhpcyBjb250YWlucyBhIGNvcHlyaWdodCCpIHN5bWJvbA==?=">>, "utf-8//IGNORE") + ) + end}, + {"multiple unicode email addresses", fun() -> + ?assertEqual( + <<"Jacek Złydach , chak de planet óóóó , Jacek Złydach , chak de planet óóóó "/utf8>>, + decode_header( + <<"=?UTF-8?B?SmFjZWsgWsWCeWRhY2g=?= , =?UTF-8?B?Y2hhayBkZSBwbGFuZXQgw7PDs8Ozw7M=?= , =?UTF-8?B?SmFjZWsgWsWCeWRhY2g=?= , =?UTF-8?B?Y2hhayBkZSBwbGFuZXQgw7PDs8Ozw7M=?= ">>, + "utf-8" + ) + ) + end}, + {"decode something I encoded myself", fun() -> + A = <<"Jacek Złydach "/utf8>>, + ?assertEqual(A, decode_header(rfc2047_utf8_encode(A), "utf-8")) + end} + ]. rfc2047_utf8_encode_test_() -> - [ - {"Q-Encoding", - fun() -> - ?assertEqual(<<"=?UTF-8?Q?abcdefghijklmnopqrstuvwxyz?=">>, rfc2047_utf8_encode(q, <<"abcdefghijklmnopqrstuvwxyz">>, <<>>, 0, <<" ">>)), - ?assertEqual(<<"=?UTF-8?Q?ABCDEFGHIJKLMNOPQRSTUVWXYZ?=">>, rfc2047_utf8_encode(q, <<"ABCDEFGHIJKLMNOPQRSTUVWXYZ">>, <<>>, 0, <<" ">>)), - ?assertEqual(<<"=?UTF-8?Q?0123456789?=">>, rfc2047_utf8_encode(q, <<"0123456789">>, <<>>, 0, <<" ">>)), - ?assertEqual(<<"=?UTF-8?Q?!*+-/?=">>, rfc2047_utf8_encode(q, <<"!*+-/">>, <<>>, 0, <<" ">>)), - ?assertEqual( <<"=?UTF-8?Q?This_text_encodes_to_more_than_63_bytes=2E_Therefore=2C_it_shou?=\r\n" - " =?UTF-8?Q?ld_be_encoded_in_multiple_encoded_words=2E?=">>, - rfc2047_utf8_encode(q, <<"This text encodes to more than 63 bytes. Therefore, it should be encoded in multiple encoded words.">>, <<>>, 0, <<" ">>)), - ?assertEqual(<<"=?UTF-8?Q?This_text_encodes_to_more_than_63_bytes_with_offset_f?=\r\n" - "\t=?UTF-8?Q?or_a_parameter=2E_Therefore=2C_it_should_be_encoded_in_multipl?=\r\n" - "\t=?UTF-8?Q?e_encoded_words=2E?=">>, - rfc2047_utf8_encode(q, << - "This text encodes to more than 63 bytes with offset for a parameter. " - "Therefore, it should be encoded in multiple encoded words.">>, <<>>, 10, <<"\t">>)), - ?assertEqual(<< "=?UTF-8?Q?We_place_an_UTF8_4byte_character_over_the_breaking_point_here_?=\r\n" - " =?UTF-8?Q?=F0=9F=80=84?=">>, - rfc2047_utf8_encode(q, <<"We place an UTF8 4byte character over the breaking point here ", 16#F0, 16#9F, 16#80, 16#84>>, <<>>, 0, <<" ">>)) - end - }, - {"B-Encoding", - fun() -> - ?assertEqual(<<"=?UTF-8?B?U29tZSBzaG9ydCB0ZXh0Lg==?=">>, - rfc2047_utf8_encode(b, <<"Some short text.">>, <<>>, 0, <<" ">>)), - ?assertEqual(<< "=?UTF-8?B?VGhpcyB0ZXh0IGVuY29kZXMgdG8gbW9yZSB0aGFuIDYzIGJ5dGVzLiBUaGVy?=\r\n" - " =?UTF-8?B?ZWZvcmUsIGl0IHNob3VsZCBiZSBlbmNvZGVkIGluIG11bHRpcGxlIGVuY29k?=\r\n" - " =?UTF-8?B?ZWQgd29yZHMu?=">>, - rfc2047_utf8_encode(b, <<"This text encodes to more than 63 bytes. Therefore, it should be encoded in multiple encoded words.">>, <<>>, 1, <<" ">>)), - ?assertEqual(<< "=?UTF-8?B?AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKiss?=\r\n" - " =?UTF-8?B?LS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZ?=\r\n" - " =?UTF-8?B?WltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8=?=">>, - rfc2047_utf8_encode(b, << <> || X <- lists:seq(0, 16#7F) >>, <<>>, 1, <<" ">>)), - ?assertEqual(<< "=?UTF-8?B?UGxhY2UgYW4gVVRGOCA0Ynl0ZSBjaGFyYWN0ZXIgYXQgdGhlIGJyZWFr?=\r\n" - " =?UTF-8?B?8J+AhA==?=">>, - rfc2047_utf8_encode(b, <<"Place an UTF8 4byte character at the break", 16#F0, 16#9F, 16#80, 16#84>>, <<>>, 1, <<" ">>)) - end - }, - {"Pick encoding", - fun() -> - ?assertEqual(<<"asdf">>, rfc2047_utf8_encode(<<"asdf">>)), - ?assertEqual(<<"=?UTF-8?Q?x=09?=">>, rfc2047_utf8_encode(<<"x\t">>)), - ?assertEqual(<<"=?UTF-8?B?CXgJ?=">>, rfc2047_utf8_encode(<<"\tx\t">>)) - end - } - ]. + [ + {"Q-Encoding", fun() -> + ?assertEqual( + <<"=?UTF-8?Q?abcdefghijklmnopqrstuvwxyz?=">>, + rfc2047_utf8_encode(q, <<"abcdefghijklmnopqrstuvwxyz">>, <<>>, 0, <<" ">>) + ), + ?assertEqual( + <<"=?UTF-8?Q?ABCDEFGHIJKLMNOPQRSTUVWXYZ?=">>, + rfc2047_utf8_encode(q, <<"ABCDEFGHIJKLMNOPQRSTUVWXYZ">>, <<>>, 0, <<" ">>) + ), + ?assertEqual(<<"=?UTF-8?Q?0123456789?=">>, rfc2047_utf8_encode(q, <<"0123456789">>, <<>>, 0, <<" ">>)), + ?assertEqual(<<"=?UTF-8?Q?!*+-/?=">>, rfc2047_utf8_encode(q, <<"!*+-/">>, <<>>, 0, <<" ">>)), + ?assertEqual( + << + "=?UTF-8?Q?This_text_encodes_to_more_than_63_bytes=2E_Therefore=2C_it_shou?=\r\n" + " =?UTF-8?Q?ld_be_encoded_in_multiple_encoded_words=2E?=" + >>, + rfc2047_utf8_encode( + q, + <<"This text encodes to more than 63 bytes. Therefore, it should be encoded in multiple encoded words.">>, + <<>>, + 0, + <<" ">> + ) + ), + ?assertEqual( + << + "=?UTF-8?Q?This_text_encodes_to_more_than_63_bytes_with_offset_f?=\r\n" + "\t=?UTF-8?Q?or_a_parameter=2E_Therefore=2C_it_should_be_encoded_in_multipl?=\r\n" + "\t=?UTF-8?Q?e_encoded_words=2E?=" + >>, + rfc2047_utf8_encode( + q, + << + "This text encodes to more than 63 bytes with offset for a parameter. " + "Therefore, it should be encoded in multiple encoded words." + >>, + <<>>, + 10, + <<"\t">> + ) + ), + ?assertEqual( + << + "=?UTF-8?Q?We_place_an_UTF8_4byte_character_over_the_breaking_point_here_?=\r\n" + " =?UTF-8?Q?=F0=9F=80=84?=" + >>, + rfc2047_utf8_encode( + q, + <<"We place an UTF8 4byte character over the breaking point here ", 16#F0, 16#9F, 16#80, 16#84>>, + <<>>, + 0, + <<" ">> + ) + ) + end}, + {"B-Encoding", fun() -> + ?assertEqual( + <<"=?UTF-8?B?U29tZSBzaG9ydCB0ZXh0Lg==?=">>, + rfc2047_utf8_encode(b, <<"Some short text.">>, <<>>, 0, <<" ">>) + ), + ?assertEqual( + << + "=?UTF-8?B?VGhpcyB0ZXh0IGVuY29kZXMgdG8gbW9yZSB0aGFuIDYzIGJ5dGVzLiBUaGVy?=\r\n" + " =?UTF-8?B?ZWZvcmUsIGl0IHNob3VsZCBiZSBlbmNvZGVkIGluIG11bHRpcGxlIGVuY29k?=\r\n" + " =?UTF-8?B?ZWQgd29yZHMu?=" + >>, + rfc2047_utf8_encode( + b, + <<"This text encodes to more than 63 bytes. Therefore, it should be encoded in multiple encoded words.">>, + <<>>, + 1, + <<" ">> + ) + ), + ?assertEqual( + << + "=?UTF-8?B?AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKiss?=\r\n" + " =?UTF-8?B?LS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZ?=\r\n" + " =?UTF-8?B?WltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8=?=" + >>, + rfc2047_utf8_encode(b, <<<> || X <- lists:seq(0, 16#7F)>>, <<>>, 1, <<" ">>) + ), + ?assertEqual( + << + "=?UTF-8?B?UGxhY2UgYW4gVVRGOCA0Ynl0ZSBjaGFyYWN0ZXIgYXQgdGhlIGJyZWFr?=\r\n" + " =?UTF-8?B?8J+AhA==?=" + >>, + rfc2047_utf8_encode( + b, <<"Place an UTF8 4byte character at the break", 16#F0, 16#9F, 16#80, 16#84>>, <<>>, 1, <<" ">> + ) + ) + end}, + {"Pick encoding", fun() -> + ?assertEqual(<<"asdf">>, rfc2047_utf8_encode(<<"asdf">>)), + ?assertEqual(<<"=?UTF-8?Q?x=09?=">>, rfc2047_utf8_encode(<<"x\t">>)), + ?assertEqual(<<"=?UTF-8?B?CXgJ?=">>, rfc2047_utf8_encode(<<"\tx\t">>)) + end} + ]. encoding_test_() -> - Getmail = fun(File) -> - {ok, Email} = file:read_file(filename:join("test/fixtures/", File)), - decode(Email) - end, - [ - {"Simple email", - fun() -> - Email = {<<"text">>, <<"plain">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}, - {<<"Message-ID">>, <<"">>}, - {<<"MIME-Version">>, <<"1.0">>}, - {<<"Date">>, <<"Sun, 01 Nov 2009 14:44:47 +0200">>}], - #{content_type_params => - [{<<"charset">>,<<"US-ASCII">>}], - disposition => <<"inline">>}, - <<"This is a plain message">>}, - Result = <<"From: me@example.com\r\nTo: you@example.com\r\nSubject: This is a test\r\nMessage-ID: \r\nMIME-Version: 1.0\r\nDate: Sun, 01 Nov 2009 14:44:47 +0200\r\n\r\nThis is a plain message">>, - ?assertEqual(Result, encode(Email)) - end - }, - {"Email with UTF-8 characters", - fun() -> - Email = {<<"text">>, <<"plain">>, [ - {<<"Subject">>, <<"Fræderik Hølljen"/utf8>>}, - {<<"From">>, <<"Fræderik Hølljen "/utf8>>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Message-ID">>, <<"">>}, - {<<"MIME-Version">>, <<"1.0">>}, - {<<"Date">>, <<"Sun, 01 Nov 2009 14:44:47 +0200">>}], - #{content_type_params => - [{<<"charset">>,<<"US-ASCII">>}], - disposition => <<"inline">>}, - <<"This is a plain message">>}, - Result = <<"Subject: =?UTF-8?Q?Fr=C3=A6derik_H=C3=B8lljen?=\r\nFrom: =?UTF-8?Q?Fr=C3=A6derik_H=C3=B8lljen?= \r\nTo: you@example.com\r\nMessage-ID: \r\nMIME-Version: 1.0\r\nDate: Sun, 01 Nov 2009 14:44:47 +0200\r\n\r\nThis is a plain message">>, - ?assertEqual(Result, encode(Email)) - end - }, - {"Email with UTF-8 in attachment filename.", - fun() -> - FileName = << - "Čia labai ilgas el. laiško priedo pavadinimas su "/utf8, - "lietuviškomis ar kokiomis kitomis ne ascii raidėmis.pdf"/utf8 - >>, - Email = {<<"multipart">>, <<"mixed">>, - [ - {<<"From">>, <<"k.petrauskas@erisata.lt">>}, - {<<"Subject">>, <<"Čiobiškis"/utf8>>}, - {<<"Date">>, <<"Thu, 17 Dec 2020 20:12:33 +0200">>}, - {<<"Message-ID">>, <<"<47a08b7ff7d305087877361ca8eea1db@karolis.erisata.lt>">>} - ], - #{ - content_type_params => [ - {<<"boundary">>, <<"_=boundary-123=_">>} - ] - }, - [ - {<<"application">>, <<"pdf">>, [], - #{ - content_type_params => [ - {<<"name">>, FileName}, - {<<"disposition">>, <<"attachment">>} - ], - disposition => <<"attachment">>, - disposition_params => [{<<"filename">>, FileName}] - }, - <<"data">> - } - ] - }, - Result = << - "From: k.petrauskas@erisata.lt\r\n" - "Subject: =?UTF-8?Q?=C4=8Ciobi=C5=A1kis?=\r\n" - "Date: Thu, 17 Dec 2020 20:12:33 +0200\r\n" - "Message-ID: <47a08b7ff7d305087877361ca8eea1db@karolis.erisata.lt>\r\n" - "Content-Type: multipart/mixed;\r\n" - "\tboundary=\"_=boundary-123=_\"\r\n" - "MIME-Version: 1.0\r\n" - "\r\n" - "\r\n" - "--_=boundary-123=_\r\n" - "Content-Type: application/pdf;\r\n" - "\tname=\"=?UTF-8?Q?=C4=8Cia_labai_ilgas_el=2E_lai=C5=A1ko_priedo_pavadinima?=\r\n" - "\t=?UTF-8?Q?s_su_lietuvi=C5=A1komis_ar_kokiomis_kitomis_ne_ascii_raid?=\r\n" - "\t=?UTF-8?Q?=C4=97mis=2Epdf?=\";\r\n" - "\tdisposition=attachment\r\n" - "Content-Disposition: attachment;\r\n" - "\tfilename=\"=?UTF-8?Q?=C4=8Cia_labai_ilgas_el=2E_lai=C5=A1ko_priedo_pavadi?=\r\n" - "\t=?UTF-8?Q?nimas_su_lietuvi=C5=A1komis_ar_kokiomis_kitomis_ne_ascii_raid?=\r\n" - "\t=?UTF-8?Q?=C4=97mis=2Epdf?=\"\r\n" - "\r\n" - "data\r\n" - "--_=boundary-123=_--\r\n" - >>, - ?assertEqual(Result, encode(Email)) - end - }, - {"Email with special chars in From", - fun() -> - Email = {<<"text">>, <<"plain">>, [ - {<<"From">>, <<"\"Admin & ' ( \\\"hallo\\\" ) ; , [ ] WS\" ">>}, - {<<"Message-ID">>, <<"">>}, - {<<"MIME-Version">>, <<"1.0">>}, - {<<"Date">>, <<"Sun, 01 Nov 2009 14:44:47 +0200">>}], - #{}, - <<"This is a plain message">>}, - Result = <<"From: \"Admin & ' ( \\\"hallo\\\" ) ; , [ ] WS\" \r\nMessage-ID: \r\nMIME-Version: 1.0\r\nDate: Sun, 01 Nov 2009 14:44:47 +0200\r\n\r\nThis is a plain message">>, - ?assertEqual(Result, encode(Email)) - end - }, - {"multipart/alternative email", - fun() -> - Email = {<<"multipart">>, <<"alternative">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}, - {<<"MIME-Version">>, <<"1.0">>}, - {<<"Content-Type">>, - <<"multipart/alternative; boundary=wtf-123234234">>}], - #{content_type_params => - [{<<"boundary">>, <<"wtf-123234234">>}], - disposition => <<"inline">>, - disposition_params => []}, - [{<<"text">>,<<"plain">>, - [{<<"Content-Type">>, - <<"text/plain;charset=US-ASCII;format=flowed">>}, - {<<"Content-Transfer-Encoding">>,<<"7bit">>}], - #{content_type_params => - [{<<"charset">>,<<"US-ASCII">>}, - {<<"format">>,<<"flowed">>}], - disposition => <<"inline">>, - disposition_params => []}, - <<"This message contains rich text.">>}, - {<<"text">>,<<"html">>, - [{<<"Content-Type">>,<<"text/html;charset=US-ASCII">>}, - {<<"Content-Transfer-Encoding">>,<<"7bit">>}], - #{content_type_params => - [{<<"charset">>,<<"US-ASCII">>}], - disposition => <<"inline">>, - disposition_params =>[]}, - <<"This message also contains HTML">>}]}, - Result = decode(encode(Email)), - ?assertMatch({<<"multipart">>, <<"alternative">>, _, _, [{<<"text">>, - <<"plain">>, _, _, _}, {<<"text">>, <<"html">>, _, _, _}]}, - Result) - end - }, - {"multipart/alternative email with encoding", - fun() -> - Email = {<<"multipart">>, <<"alternative">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}, - {<<"MIME-Version">>, <<"1.0">>}, - {<<"Content-Type">>, - <<"multipart/alternative; boundary=wtf-123234234">>}], - #{content_type_params => - [{<<"boundary">>, <<"wtf-123234234">>}], - disposition => <<"inline">>, - disposition_params => []}, - [{<<"text">>,<<"plain">>, - [{<<"Content-Type">>, - <<"text/plain;charset=US-ASCII;format=flowed">>}, - {<<"Content-Transfer-Encoding">>,<<"quoted-printable">>}], - #{content_type_params => - [{<<"charset">>,<<"US-ASCII">>}, - {<<"format">>,<<"flowed">>}], - disposition => <<"inline">>, - disposition_params => []}, - <<"This message contains rich text.\r\n", - "and is =quoted printable= encoded!">>}, - {<<"text">>,<<"html">>, - [{<<"Content-Type">>,<<"text/html;charset=US-ASCII">>}, - {<<"Content-Transfer-Encoding">>,<<"base64">>}], - #{content_type_params => - [{<<"charset">>,<<"US-ASCII">>}], - disposition => <<"inline">>, - disposition_params => []}, - <<"This message also contains", - "HTML and is base64", - "encoded\r\n\r\n">>}]}, - Result = decode(encode(Email)), - ?assertMatch({<<"multipart">>, <<"alternative">>, _, _, [{<<"text">>, - <<"plain">>, _, _, <<"This message contains rich text.\r\n", - "and is =quoted printable= encoded!">>}, - {<<"text">>, <<"html">>, _, _, - <<"This message also contains", - "HTML and is base64", - "encoded\r\n\r\n">>}]}, - Result) - end - }, - {"multipart/mixed email with multipart/alternative does not add an extra empty lines", - fun() -> - Email = Getmail("message-text-html-attachment.eml"), - Encoded = encode(Email), - Re = re:run(Encoded, "(?:\\r\\n){3}", [global, {capture, all, binary}]), - ?assertMatch({match, [_]}, Re) - end - }, - {"Missing headers should be added", - fun() -> - Email = {<<"text">>, <<"plain">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}], - #{content_type_params => - [{<<"charset">>,<<"US-ASCII">>}], - disposition => <<"inline">>}, - <<"This is a plain message">>}, - Result = decode(encode(Email)), - ?assertNot(undefined == proplists:get_value(<<"Message-ID">>, element(3, Result))), - ?assertNot(undefined == proplists:get_value(<<"Date">>, element(3, Result))), - ?assertEqual(undefined, proplists:get_value(<<"References">>, element(3, Result))) - end - }, - {"Reference header should be added in presence of In-Reply-To", - fun() -> - Email = {<<"text">>, <<"plain">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"In-Reply-To">>, <<"">>}, - {<<"Subject">>, <<"This is a test">>}], - #{content_type_params => - [{<<"charset">>,<<"US-ASCII">>}], - disposition => <<"inline">>}, - <<"This is a plain message">>}, - Result = decode(encode(Email)), - ?assertEqual(<<"">>, proplists:get_value(<<"References">>, element(3, Result))) - end - }, - {"Reference header should be appended to in presence of In-Reply-To, if appropriate", - fun() -> - Email = {<<"text">>, <<"plain">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"In-Reply-To">>, <<"">>}, - {<<"References">>, <<"">>}, - {<<"Subject">>, <<"This is a test">>}], - #{content_type_params => - [{<<"charset">>,<<"US-ASCII">>}], - disposition => <<"inline">>}, - <<"This is a plain message">>}, - Result = decode(encode(Email)), - ?assertEqual(<<" ">>, proplists:get_value(<<"References">>, element(3, Result))) - end - }, - {"Reference header should NOT be appended to in presence of In-Reply-To, if already present", - fun() -> - Email = {<<"text">>, <<"plain">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"In-Reply-To">>, <<"">>}, - {<<"References">>, <<" ">>}, - {<<"Subject">>, <<"This is a test">>}], - #{content_type_params => - [{<<"charset">>,<<"US-ASCII">>}], - disposition => <<"inline">>}, - <<"This is a plain message">>}, - Result = decode(encode(Email)), - ?assertEqual(<<" ">>, proplists:get_value(<<"References">>, element(3, Result))) - end - }, - {"Content-Transfer-Encoding header should be added if missing and appropriate", - fun() -> - Email = {<<"text">>, <<"plain">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}], - #{}, - <<"This is a plain message with some non-ascii characters øÿ\r\nso there"/utf8>>}, - Encoded = encode(Email), - Result = decode(Encoded), - ?assertEqual(<<"quoted-printable">>, proplists:get_value(<<"Content-Transfer-Encoding">>, element(3, Result))), - Email2 = {<<"text">>, <<"plain">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}], - #{}, - <<"This is a plain message with no non-ascii characters">>}, - Encoded2 = encode(Email2), - Result2 = decode(Encoded2), - ?assertEqual(undefined, proplists:get_value(<<"Content-Transfer-Encoding">>, element(3, Result2))), - Email3 = {<<"text">>, <<"plain">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}], - #{transfer_encoding => <<"base64">>}, - <<"This is a plain message with no non-ascii characters">>}, - Encoded3 = encode(Email3), - Result3 = decode(Encoded3), - ?assertEqual(<<"base64">>, proplists:get_value(<<"Content-Transfer-Encoding">>, element(3, Result3))) - end - }, - {"Content-Type header should be added if missing and appropriate", - fun() -> - Email = {<<"text">>, <<"html">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}], - #{}, - <<"This is a HTML message with some non-ascii characters øÿ\r\nso there"/utf8>>}, - Encoded = encode(Email), - Result = decode(Encoded), - ?assertEqual(<<"quoted-printable">>, proplists:get_value(<<"Content-Transfer-Encoding">>, element(3, Result))), - ?assertMatch(<<"text/html;charset=utf-8">>, proplists:get_value(<<"Content-Type">>, element(3, Result))), - Email2 = {<<"text">>, <<"html">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}], - #{}, - <<"This is a HTML message with no non-ascii characters\r\nso there">>}, - Encoded2 = encode(Email2), - Result2 = decode(Encoded2), - ?assertMatch(<<"text/html;charset=us-ascii">>, proplists:get_value(<<"Content-Type">>, element(3, Result2))), - Email3 = {<<"text">>, <<"html">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}], - #{}, - <<"This is a text message with some invisible non-ascii characters\r\nso there"/utf8>>}, - Encoded3 = encode(Email3), - Result3 = decode(Encoded3), - ?assertMatch(<<"text/html;charset=utf-8">>, proplists:get_value(<<"Content-Type">>, element(3, Result3))) - end - }, - {"Content-Type header should be added for subparts too, if missing and appropriate", - fun() -> - Email4 = {<<"multipart">>, <<"alternative">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}], - #{}, - [{<<"text">>, <<"plain">>, [], #{}, <<"This is a multipart message with some invisible non-ascii characters\r\nso there"/utf8>>}]}, - Encoded4 = encode(Email4), - Result4 = decode(Encoded4), - ?assertMatch(<<"text/plain;charset=utf-8">>, proplists:get_value(<<"Content-Type">>, element(3, lists:nth(1,element(5, Result4))))) - end - }, - {"Content-Type header should be not added for subparts if they're text/plain us-ascii", - fun() -> - Email4 = {<<"multipart">>, <<"alternative">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}], - #{}, - [{<<"text">>, <<"plain">>, [], #{}, <<"This is a multipart message with no non-ascii characters\r\nso there">>}]}, - Encoded4 = encode(Email4), - Result4 = decode(Encoded4), - ?assertMatch(undefined, proplists:get_value(<<"Content-Type">>, element(3, lists:nth(1,element(5, Result4))))) - end - }, - {"Content-Type header should be added for subparts if they're text/html us-ascii", - fun() -> - Email4 = {<<"multipart">>, <<"alternative">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}], - #{}, - [{<<"text">>, <<"html">>, [], #{}, <<"This is a multipart message with no non-ascii characters\r\nso there">>}]}, - Encoded4 = encode(Email4), - Result4 = decode(Encoded4), - ?assertMatch(<<"text/html;charset=us-ascii">>, proplists:get_value(<<"Content-Type">>, element(3, lists:nth(1,element(5, Result4))))) - end - }, - {"A boundary should be generated if applicable", - fun() -> - Email = {<<"multipart">>, <<"alternative">>, [ - {<<"From">>, <<"me@example.com">>}, - {<<"To">>, <<"you@example.com">>}, - {<<"Subject">>, <<"This is a test">>}], - #{}, - [{<<"text">>,<<"plain">>, - [], - #{}, - <<"This message contains rich text.\r\n", - "and is =quoted printable= encoded!">>}, - {<<"text">>,<<"html">>, - [], - #{}, - <<"This message also contains", - "HTML and is base64", - "encoded\r\n\r\n">>}]}, - Encoded = encode(Email), - Result = decode(Encoded), - Boundary = proplists:get_value(<<"boundary">>, maps:get(content_type_params, element(4, Result))), - ?assert(is_binary(Boundary)), - % ensure we don't add the header multiple times - ?assertEqual(1, length(proplists:get_all_values(<<"Content-Type">>, element(3, Result)))), - % headers should be appended, not prepended - ?assertMatch({<<"From">>, _}, lists:nth(1, element(3, Result))), - ok - end - } - ]. + Getmail = fun(File) -> + {ok, Email} = file:read_file(filename:join("test/fixtures/", File)), + decode(Email) + end, + [ + {"Simple email", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>}, + {<<"Message-ID">>, <<"">>}, + {<<"MIME-Version">>, <<"1.0">>}, + {<<"Date">>, <<"Sun, 01 Nov 2009 14:44:47 +0200">>} + ], + #{ + content_type_params => + [{<<"charset">>, <<"US-ASCII">>}], + disposition => <<"inline">> + }, + <<"This is a plain message">>}, + Result = + <<"From: me@example.com\r\nTo: you@example.com\r\nSubject: This is a test\r\nMessage-ID: \r\nMIME-Version: 1.0\r\nDate: Sun, 01 Nov 2009 14:44:47 +0200\r\n\r\nThis is a plain message">>, + ?assertEqual(Result, encode(Email)) + end}, + {"Email with UTF-8 characters", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"Subject">>, <<"Fræderik Hølljen"/utf8>>}, + {<<"From">>, <<"Fræderik Hølljen "/utf8>>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Message-ID">>, <<"">>}, + {<<"MIME-Version">>, <<"1.0">>}, + {<<"Date">>, <<"Sun, 01 Nov 2009 14:44:47 +0200">>} + ], + #{ + content_type_params => + [{<<"charset">>, <<"US-ASCII">>}], + disposition => <<"inline">> + }, + <<"This is a plain message">>}, + Result = + <<"Subject: =?UTF-8?Q?Fr=C3=A6derik_H=C3=B8lljen?=\r\nFrom: =?UTF-8?Q?Fr=C3=A6derik_H=C3=B8lljen?= \r\nTo: you@example.com\r\nMessage-ID: \r\nMIME-Version: 1.0\r\nDate: Sun, 01 Nov 2009 14:44:47 +0200\r\n\r\nThis is a plain message">>, + ?assertEqual(Result, encode(Email)) + end}, + {"Email with UTF-8 in attachment filename.", fun() -> + FileName = << + "Čia labai ilgas el. laiško priedo pavadinimas su "/utf8, + "lietuviškomis ar kokiomis kitomis ne ascii raidėmis.pdf"/utf8 + >>, + Email = + {<<"multipart">>, <<"mixed">>, + [ + {<<"From">>, <<"k.petrauskas@erisata.lt">>}, + {<<"Subject">>, <<"Čiobiškis"/utf8>>}, + {<<"Date">>, <<"Thu, 17 Dec 2020 20:12:33 +0200">>}, + {<<"Message-ID">>, <<"<47a08b7ff7d305087877361ca8eea1db@karolis.erisata.lt>">>} + ], + #{ + content_type_params => [ + {<<"boundary">>, <<"_=boundary-123=_">>} + ] + }, + [ + {<<"application">>, <<"pdf">>, [], + #{ + content_type_params => [ + {<<"name">>, FileName}, + {<<"disposition">>, <<"attachment">>} + ], + disposition => <<"attachment">>, + disposition_params => [{<<"filename">>, FileName}] + }, + <<"data">>} + ]}, + Result = << + "From: k.petrauskas@erisata.lt\r\n" + "Subject: =?UTF-8?Q?=C4=8Ciobi=C5=A1kis?=\r\n" + "Date: Thu, 17 Dec 2020 20:12:33 +0200\r\n" + "Message-ID: <47a08b7ff7d305087877361ca8eea1db@karolis.erisata.lt>\r\n" + "Content-Type: multipart/mixed;\r\n" + "\tboundary=\"_=boundary-123=_\"\r\n" + "MIME-Version: 1.0\r\n" + "\r\n" + "\r\n" + "--_=boundary-123=_\r\n" + "Content-Type: application/pdf;\r\n" + "\tname=\"=?UTF-8?Q?=C4=8Cia_labai_ilgas_el=2E_lai=C5=A1ko_priedo_pavadinima?=\r\n" + "\t=?UTF-8?Q?s_su_lietuvi=C5=A1komis_ar_kokiomis_kitomis_ne_ascii_raid?=\r\n" + "\t=?UTF-8?Q?=C4=97mis=2Epdf?=\";\r\n" + "\tdisposition=attachment\r\n" + "Content-Disposition: attachment;\r\n" + "\tfilename=\"=?UTF-8?Q?=C4=8Cia_labai_ilgas_el=2E_lai=C5=A1ko_priedo_pavadi?=\r\n" + "\t=?UTF-8?Q?nimas_su_lietuvi=C5=A1komis_ar_kokiomis_kitomis_ne_ascii_raid?=\r\n" + "\t=?UTF-8?Q?=C4=97mis=2Epdf?=\"\r\n" + "\r\n" + "data\r\n" + "--_=boundary-123=_--\r\n" + >>, + ?assertEqual(Result, encode(Email)) + end}, + {"Email with special chars in From", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"\"Admin & ' ( \\\"hallo\\\" ) ; , [ ] WS\" ">>}, + {<<"Message-ID">>, <<"">>}, + {<<"MIME-Version">>, <<"1.0">>}, + {<<"Date">>, <<"Sun, 01 Nov 2009 14:44:47 +0200">>} + ], + #{}, <<"This is a plain message">>}, + Result = + <<"From: \"Admin & ' ( \\\"hallo\\\" ) ; , [ ] WS\" \r\nMessage-ID: \r\nMIME-Version: 1.0\r\nDate: Sun, 01 Nov 2009 14:44:47 +0200\r\n\r\nThis is a plain message">>, + ?assertEqual(Result, encode(Email)) + end}, + {"multipart/alternative email", fun() -> + Email = + {<<"multipart">>, <<"alternative">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>}, + {<<"MIME-Version">>, <<"1.0">>}, + {<<"Content-Type">>, <<"multipart/alternative; boundary=wtf-123234234">>} + ], + #{ + content_type_params => + [{<<"boundary">>, <<"wtf-123234234">>}], + disposition => <<"inline">>, + disposition_params => [] + }, + [ + {<<"text">>, <<"plain">>, + [ + {<<"Content-Type">>, <<"text/plain;charset=US-ASCII;format=flowed">>}, + {<<"Content-Transfer-Encoding">>, <<"7bit">>} + ], + #{ + content_type_params => + [ + {<<"charset">>, <<"US-ASCII">>}, + {<<"format">>, <<"flowed">>} + ], + disposition => <<"inline">>, + disposition_params => [] + }, + <<"This message contains rich text.">>}, + {<<"text">>, <<"html">>, + [ + {<<"Content-Type">>, <<"text/html;charset=US-ASCII">>}, + {<<"Content-Transfer-Encoding">>, <<"7bit">>} + ], + #{ + content_type_params => + [{<<"charset">>, <<"US-ASCII">>}], + disposition => <<"inline">>, + disposition_params => [] + }, + <<"This message also contains HTML">>} + ]}, + Result = decode(encode(Email)), + ?assertMatch( + {<<"multipart">>, <<"alternative">>, _, _, [ + {<<"text">>, <<"plain">>, _, _, _}, {<<"text">>, <<"html">>, _, _, _} + ]}, + Result + ) + end}, + {"multipart/alternative email with encoding", fun() -> + Email = + {<<"multipart">>, <<"alternative">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>}, + {<<"MIME-Version">>, <<"1.0">>}, + {<<"Content-Type">>, <<"multipart/alternative; boundary=wtf-123234234">>} + ], + #{ + content_type_params => + [{<<"boundary">>, <<"wtf-123234234">>}], + disposition => <<"inline">>, + disposition_params => [] + }, + [ + {<<"text">>, <<"plain">>, + [ + {<<"Content-Type">>, <<"text/plain;charset=US-ASCII;format=flowed">>}, + {<<"Content-Transfer-Encoding">>, <<"quoted-printable">>} + ], + #{ + content_type_params => + [ + {<<"charset">>, <<"US-ASCII">>}, + {<<"format">>, <<"flowed">>} + ], + disposition => <<"inline">>, + disposition_params => [] + }, + <<"This message contains rich text.\r\n", "and is =quoted printable= encoded!">>}, + {<<"text">>, <<"html">>, + [ + {<<"Content-Type">>, <<"text/html;charset=US-ASCII">>}, + {<<"Content-Transfer-Encoding">>, <<"base64">>} + ], + #{ + content_type_params => + [{<<"charset">>, <<"US-ASCII">>}], + disposition => <<"inline">>, + disposition_params => [] + }, + <<"This message also contains", "HTML and is base64", + "encoded\r\n\r\n">>} + ]}, + Result = decode(encode(Email)), + ?assertMatch( + {<<"multipart">>, <<"alternative">>, _, _, [ + {<<"text">>, <<"plain">>, _, _, + <<"This message contains rich text.\r\n", "and is =quoted printable= encoded!">>}, + {<<"text">>, <<"html">>, _, _, + <<"This message also contains", "HTML and is base64", + "encoded\r\n\r\n">>} + ]}, + Result + ) + end}, + {"multipart/mixed email with multipart/alternative does not add an extra empty lines", fun() -> + Email = Getmail("message-text-html-attachment.eml"), + Encoded = encode(Email), + Re = re:run(Encoded, "(?:\\r\\n){3}", [global, {capture, all, binary}]), + ?assertMatch({match, [_]}, Re) + end}, + {"Missing headers should be added", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{ + content_type_params => + [{<<"charset">>, <<"US-ASCII">>}], + disposition => <<"inline">> + }, + <<"This is a plain message">>}, + Result = decode(encode(Email)), + ?assertNot(undefined == proplists:get_value(<<"Message-ID">>, element(3, Result))), + ?assertNot(undefined == proplists:get_value(<<"Date">>, element(3, Result))), + ?assertEqual(undefined, proplists:get_value(<<"References">>, element(3, Result))) + end}, + {"Reference header should be added in presence of In-Reply-To", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"In-Reply-To">>, <<"">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{ + content_type_params => + [{<<"charset">>, <<"US-ASCII">>}], + disposition => <<"inline">> + }, + <<"This is a plain message">>}, + Result = decode(encode(Email)), + ?assertEqual(<<"">>, proplists:get_value(<<"References">>, element(3, Result))) + end}, + {"Reference header should be appended to in presence of In-Reply-To, if appropriate", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"In-Reply-To">>, <<"">>}, + {<<"References">>, <<"">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{ + content_type_params => + [{<<"charset">>, <<"US-ASCII">>}], + disposition => <<"inline">> + }, + <<"This is a plain message">>}, + Result = decode(encode(Email)), + ?assertEqual( + <<" ">>, proplists:get_value(<<"References">>, element(3, Result)) + ) + end}, + {"Reference header should NOT be appended to in presence of In-Reply-To, if already present", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"In-Reply-To">>, <<"">>}, + {<<"References">>, <<" ">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{ + content_type_params => + [{<<"charset">>, <<"US-ASCII">>}], + disposition => <<"inline">> + }, + <<"This is a plain message">>}, + Result = decode(encode(Email)), + ?assertEqual( + <<" ">>, proplists:get_value(<<"References">>, element(3, Result)) + ) + end}, + {"Content-Transfer-Encoding header should be added if missing and appropriate", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{}, <<"This is a plain message with some non-ascii characters øÿ\r\nso there"/utf8>>}, + Encoded = encode(Email), + Result = decode(Encoded), + ?assertEqual( + <<"quoted-printable">>, proplists:get_value(<<"Content-Transfer-Encoding">>, element(3, Result)) + ), + Email2 = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{}, <<"This is a plain message with no non-ascii characters">>}, + Encoded2 = encode(Email2), + Result2 = decode(Encoded2), + ?assertEqual(undefined, proplists:get_value(<<"Content-Transfer-Encoding">>, element(3, Result2))), + Email3 = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{transfer_encoding => <<"base64">>}, <<"This is a plain message with no non-ascii characters">>}, + Encoded3 = encode(Email3), + Result3 = decode(Encoded3), + ?assertEqual(<<"base64">>, proplists:get_value(<<"Content-Transfer-Encoding">>, element(3, Result3))) + end}, + {"Content-Type header should be added if missing and appropriate", fun() -> + Email = + {<<"text">>, <<"html">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{}, <<"This is a HTML message with some non-ascii characters øÿ\r\nso there"/utf8>>}, + Encoded = encode(Email), + Result = decode(Encoded), + ?assertEqual( + <<"quoted-printable">>, proplists:get_value(<<"Content-Transfer-Encoding">>, element(3, Result)) + ), + ?assertMatch(<<"text/html;charset=utf-8">>, proplists:get_value(<<"Content-Type">>, element(3, Result))), + Email2 = + {<<"text">>, <<"html">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{}, <<"This is a HTML message with no non-ascii characters\r\nso there">>}, + Encoded2 = encode(Email2), + Result2 = decode(Encoded2), + ?assertMatch( + <<"text/html;charset=us-ascii">>, proplists:get_value(<<"Content-Type">>, element(3, Result2)) + ), + Email3 = + {<<"text">>, <<"html">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{}, <<"This is a text message with some invisible non-ascii characters\r\nso there"/utf8>>}, + Encoded3 = encode(Email3), + Result3 = decode(Encoded3), + ?assertMatch(<<"text/html;charset=utf-8">>, proplists:get_value(<<"Content-Type">>, element(3, Result3))) + end}, + {"Content-Type header should be added for subparts too, if missing and appropriate", fun() -> + Email4 = + {<<"multipart">>, <<"alternative">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{}, [ + {<<"text">>, <<"plain">>, [], #{}, + <<"This is a multipart message with some invisible non-ascii characters\r\nso there"/utf8>>} + ]}, + Encoded4 = encode(Email4), + Result4 = decode(Encoded4), + ?assertMatch( + <<"text/plain;charset=utf-8">>, + proplists:get_value(<<"Content-Type">>, element(3, lists:nth(1, element(5, Result4)))) + ) + end}, + {"Content-Type header should be not added for subparts if they're text/plain us-ascii", fun() -> + Email4 = + {<<"multipart">>, <<"alternative">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{}, [ + {<<"text">>, <<"plain">>, [], #{}, + <<"This is a multipart message with no non-ascii characters\r\nso there">>} + ]}, + Encoded4 = encode(Email4), + Result4 = decode(Encoded4), + ?assertMatch( + undefined, proplists:get_value(<<"Content-Type">>, element(3, lists:nth(1, element(5, Result4)))) + ) + end}, + {"Content-Type header should be added for subparts if they're text/html us-ascii", fun() -> + Email4 = + {<<"multipart">>, <<"alternative">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{}, [ + {<<"text">>, <<"html">>, [], #{}, + <<"This is a multipart message with no non-ascii characters\r\nso there">>} + ]}, + Encoded4 = encode(Email4), + Result4 = decode(Encoded4), + ?assertMatch( + <<"text/html;charset=us-ascii">>, + proplists:get_value(<<"Content-Type">>, element(3, lists:nth(1, element(5, Result4)))) + ) + end}, + {"A boundary should be generated if applicable", fun() -> + Email = + {<<"multipart">>, <<"alternative">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"To">>, <<"you@example.com">>}, + {<<"Subject">>, <<"This is a test">>} + ], + #{}, [ + {<<"text">>, <<"plain">>, [], #{}, + <<"This message contains rich text.\r\n", "and is =quoted printable= encoded!">>}, + {<<"text">>, <<"html">>, [], #{}, + <<"This message also contains", "HTML and is base64", + "encoded\r\n\r\n">>} + ]}, + Encoded = encode(Email), + Result = decode(Encoded), + Boundary = proplists:get_value(<<"boundary">>, maps:get(content_type_params, element(4, Result))), + ?assert(is_binary(Boundary)), + % ensure we don't add the header multiple times + ?assertEqual(1, length(proplists:get_all_values(<<"Content-Type">>, element(3, Result)))), + % headers should be appended, not prepended + ?assertMatch({<<"From">>, _}, lists:nth(1, element(3, Result))), + ok + end} + ]. roundtrip_test_() -> - [ - {"roundtrip test for the gamut", - fun() -> - {ok, Email} = file:read_file("test/fixtures/the-gamut.eml"), - Decoded = decode(Email), - _Encoded = encode(Decoded), - %{ok, F1} = file:open("f1", [write]), - %{ok, F2} = file:open("f2", [write]), - %file:write(F1, Email), - %file:write(F2, Encoded), - %file:close(F1), - %file:close(F2), - %?assertEqual(Email, Email), - ok - end - }, - {"round trip plain text only email", - fun() -> - {ok, Email} = file:read_file("test/fixtures/Plain-text-only.eml"), - Decoded = decode(Email), - _Encoded = encode(Decoded), - %{ok, F1} = file:open("f1", [write]), - %{ok, F2} = file:open("f2", [write]), - %file:write(F1, Email), - %file:write(F2, Encoded), - %file:close(F1), - %file:close(F2), - %?assertEqual(Email, Email), - ok - end - }, - {"round trip quoted-printable email", - fun() -> - {ok, Email} = file:read_file("test/fixtures/testcase1"), - Decoded = decode(Email), - _Encoded = encode(Decoded), - %{ok, F1} = file:open("f1", [write]), - %{ok, F2} = file:open("f2", [write]), - %file:write(F1, Email), - %file:write(F2, Encoded), - %file:close(F1), - %file:close(F2), - %?assertEqual(Email, Email), - ok - end - } - ]. + [ + {"roundtrip test for the gamut", fun() -> + {ok, Email} = file:read_file("test/fixtures/the-gamut.eml"), + Decoded = decode(Email), + _Encoded = encode(Decoded), + %{ok, F1} = file:open("f1", [write]), + %{ok, F2} = file:open("f2", [write]), + %file:write(F1, Email), + %file:write(F2, Encoded), + %file:close(F1), + %file:close(F2), + %?assertEqual(Email, Email), + ok + end}, + {"round trip plain text only email", fun() -> + {ok, Email} = file:read_file("test/fixtures/Plain-text-only.eml"), + Decoded = decode(Email), + _Encoded = encode(Decoded), + %{ok, F1} = file:open("f1", [write]), + %{ok, F2} = file:open("f2", [write]), + %file:write(F1, Email), + %file:write(F2, Encoded), + %file:close(F1), + %file:close(F2), + %?assertEqual(Email, Email), + ok + end}, + {"round trip quoted-printable email", fun() -> + {ok, Email} = file:read_file("test/fixtures/testcase1"), + Decoded = decode(Email), + _Encoded = encode(Decoded), + %{ok, F1} = file:open("f1", [write]), + %{ok, F2} = file:open("f2", [write]), + %file:write(F1, Email), + %file:write(F2, Encoded), + %file:close(F1), + %file:close(F2), + %?assertEqual(Email, Email), + ok + end} + ]. dkim_canonicalization_test_() -> - %% * canonicalization from #3.4.5 - Hdrs = [<<"A : X\r\n">>, - <<"B : Y\t\r\n\tZ \r\n">>], + %% * canonicalization from #3.4.5 + Hdrs = [ + <<"A : X\r\n">>, + <<"B : Y\t\r\n\tZ \r\n">> + ], Body = <<" C \r\nD \t E\r\n\r\n\r\n">>, - [{"Simple body canonicalization", - fun() -> - ?assertEqual(<<" C \r\nD \t E\r\n">>, dkim_canonicalize_body(Body, simple)), - ?assertEqual(<<"\r\n">>, dkim_canonicalize_body(<<>>, simple)), - ?assertEqual(<<"\r\n">>, dkim_canonicalize_body(<<"\r\n\r\n\r\n">>, simple)), - ?assertEqual(<<"A\r\n\r\nB\r\n">>, dkim_canonicalize_body(<<"A\r\n\r\nB\r\n\r\n">>, simple)) - end}, - {"Simple headers canonicalization", - fun() -> - ?assertEqual([<<"A : X\r\n">>, - <<"B : Y\t\r\n\tZ \r\n">>], - dkim_canonicalize_headers(Hdrs, simple)) - end}, - {"Relaxed headers canonicalization", - fun() -> - ?assertEqual([<<"a:X">>, % \r\n's are stripped by current impl. - <<"b:Y Z">>], - dkim_canonicalize_headers(Hdrs, relaxed)) - end}]. + [ + {"Simple body canonicalization", fun() -> + ?assertEqual(<<" C \r\nD \t E\r\n">>, dkim_canonicalize_body(Body, simple)), + ?assertEqual(<<"\r\n">>, dkim_canonicalize_body(<<>>, simple)), + ?assertEqual(<<"\r\n">>, dkim_canonicalize_body(<<"\r\n\r\n\r\n">>, simple)), + ?assertEqual(<<"A\r\n\r\nB\r\n">>, dkim_canonicalize_body(<<"A\r\n\r\nB\r\n\r\n">>, simple)) + end}, + {"Simple headers canonicalization", fun() -> + ?assertEqual( + [ + <<"A : X\r\n">>, + <<"B : Y\t\r\n\tZ \r\n">> + ], + dkim_canonicalize_headers(Hdrs, simple) + ) + end}, + {"Relaxed headers canonicalization", fun() -> + % \r\n's are stripped by current impl. + ?assertEqual( + [ + <<"a:X">>, + <<"b:Y Z">> + ], + dkim_canonicalize_headers(Hdrs, relaxed) + ) + end} + ]. dkim_sign_rsa_test_() -> - %% * sign using test/fixtures/dkim*.pem - {ok, PrivKey} = file:read_file("test/fixtures/dkim-rsa-private.pem"), - [{"Sign simple", - fun() -> - Email = {<<"text">>, <<"plain">>, - [{<<"From">>, <<"me@example.com">>}, - {<<"Subject">>, <<"Hello world!">>}, - {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>}, - {<<"Message-ID">>, <<"the-id">>}, - {<<"Content-Type">>, <<"text/plain; charset=utf-8">>}], - #{}, - <<"123">>}, - Options = [{dkim, [{s, <<"foo.bar">>}, - {d, <<"example.com">>}, - {c, {simple, simple}}, - {t, {{2014, 2, 4}, {23, 15, 00}}}, - {x, {{2114, 2, 4}, {23, 15, 00}}}, - {private_key, {pem_plain, PrivKey}}]}], - - Enc = encode(Email, Options), - %% This `Enc' value can be verified, for example, by Python script - %% https://launchpad.net/dkimpy like: - %% >>> pubkey = ''.join(open("test/fixtures/dkim-rsa-public.pem").read().splitlines()[1:-1]) - %% >>> dns_mock = lambda *args: 'v=DKIM1; g=*; k=rsa; p=' + pubkey - %% >>> import dkim - %% >>> d = dkim.DKIM(mime_message) % pass `Enc' value as 1'st argument - %% >>> d.verify(dnsfunc=dns_mock) - %% True - {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc), - ?assertEqual(<<"DKIM-Signature">>, DkimHdrName), - ?assertEqual(<<"t=1391555700; x=4547229300; s=foo.bar; h=from:to:subject:date; d=example.com; c=simple/simple; " - "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=rsa-sha256; v=1; " - "b=Mtja7WpVvtOFT8rfzOS/2fRZ492jrgsHgD5YUl5zmPQ/NEEMjVhVX0JCkfZxWpxiKe" - "qwl7nTJy3xecdg12feGT1rGC+rV0vAX8LVc+AJ4T4A50hE8L4hpJ1Tv5rt2O2t0Xu1Wx" - "yH6Cmrhhh56istjL+ba+U1EHhV7uZXGpWXGa4=">>, DkimHdrVal) - end}, - {"Sign relaxed headers, simple body", - fun() -> - Email = {<<"text">>, <<"plain">>, - [{<<"From">>, <<"me@example.com">>}, - {<<"Subject">>, <<"Hello world!">>}, - {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>}, - {<<"Message-ID">>, <<"the-id-relaxed">>}, - {<<"Content-Type">>, <<"text/plain; charset=utf-8">>}], - #{}, - <<"123">>}, - Options = [{dkim, [{s, <<"foo.bar">>}, - {d, <<"example.com">>}, - {c, {relaxed, simple}}, - {private_key, {pem_plain, PrivKey}}]}], - - Enc = encode(Email, Options), - {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc), - ?assertEqual(<<"DKIM-Signature">>, DkimHdrName), - ?assertEqual( - <<"s=foo.bar; h=from:to:subject:date; d=example.com; c=relaxed/simple; " - "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=rsa-sha256; v=1; " - "b=dXxKq6A7m4A3AoS90feuLP+IxOyXFTPIibja52E2JCAyOsxvIGlI51xR1LvmEaelv9" - "jJTH9iGyAC7RzTKxrWV1QXayvr05bsTy3vDw7P4vfZ1gmspuP/3Icw+J8KEn+p6+CRrf" - "T97QadH42PT6XmO2v01q5nhMgNE4yQyf9DBJs=">>, DkimHdrVal) - end}]. + %% * sign using test/fixtures/dkim*.pem + {ok, PrivKey} = file:read_file("test/fixtures/dkim-rsa-private.pem"), + [ + {"Sign simple", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"Subject">>, <<"Hello world!">>}, + {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>}, + {<<"Message-ID">>, <<"the-id">>}, + {<<"Content-Type">>, <<"text/plain; charset=utf-8">>} + ], + #{}, <<"123">>}, + Options = [ + {dkim, [ + {s, <<"foo.bar">>}, + {d, <<"example.com">>}, + {c, {simple, simple}}, + {t, {{2014, 2, 4}, {23, 15, 00}}}, + {x, {{2114, 2, 4}, {23, 15, 00}}}, + {private_key, {pem_plain, PrivKey}} + ]} + ], + + Enc = encode(Email, Options), + %% This `Enc' value can be verified, for example, by Python script + %% https://launchpad.net/dkimpy like: + %% >>> pubkey = ''.join(open("test/fixtures/dkim-rsa-public.pem").read().splitlines()[1:-1]) + %% >>> dns_mock = lambda *args: 'v=DKIM1; g=*; k=rsa; p=' + pubkey + %% >>> import dkim + %% >>> d = dkim.DKIM(mime_message) % pass `Enc' value as 1'st argument + %% >>> d.verify(dnsfunc=dns_mock) + %% True + {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc), + ?assertEqual(<<"DKIM-Signature">>, DkimHdrName), + ?assertEqual( + << + "t=1391555700; x=4547229300; s=foo.bar; h=from:to:subject:date; d=example.com; c=simple/simple; " + "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=rsa-sha256; v=1; " + "b=Mtja7WpVvtOFT8rfzOS/2fRZ492jrgsHgD5YUl5zmPQ/NEEMjVhVX0JCkfZxWpxiKe" + "qwl7nTJy3xecdg12feGT1rGC+rV0vAX8LVc+AJ4T4A50hE8L4hpJ1Tv5rt2O2t0Xu1Wx" + "yH6Cmrhhh56istjL+ba+U1EHhV7uZXGpWXGa4=" + >>, + DkimHdrVal + ) + end}, + {"Sign relaxed headers, simple body", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"Subject">>, <<"Hello world!">>}, + {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>}, + {<<"Message-ID">>, <<"the-id-relaxed">>}, + {<<"Content-Type">>, <<"text/plain; charset=utf-8">>} + ], + #{}, <<"123">>}, + Options = [ + {dkim, [ + {s, <<"foo.bar">>}, + {d, <<"example.com">>}, + {c, {relaxed, simple}}, + {private_key, {pem_plain, PrivKey}} + ]} + ], + + Enc = encode(Email, Options), + {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc), + ?assertEqual(<<"DKIM-Signature">>, DkimHdrName), + ?assertEqual( + << + "s=foo.bar; h=from:to:subject:date; d=example.com; c=relaxed/simple; " + "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=rsa-sha256; v=1; " + "b=dXxKq6A7m4A3AoS90feuLP+IxOyXFTPIibja52E2JCAyOsxvIGlI51xR1LvmEaelv9" + "jJTH9iGyAC7RzTKxrWV1QXayvr05bsTy3vDw7P4vfZ1gmspuP/3Icw+J8KEn+p6+CRrf" + "T97QadH42PT6XmO2v01q5nhMgNE4yQyf9DBJs=" + >>, + DkimHdrVal + ) + end} + ]. dkim_sign_ed25519_test_() -> - case ed25519_supported() of - true -> - %% * sign using test/fixtures/dkim*.pem - {ok, PrivKey} = file:read_file("test/fixtures/dkim-ed25519-private.pem"), - [{"Sign simple", - fun() -> - Email = {<<"text">>, <<"plain">>, - [{<<"From">>, <<"me@example.com">>}, - {<<"Subject">>, <<"Hello world!">>}, - {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>}, - {<<"Message-ID">>, <<"the-id">>}, - {<<"Content-Type">>, <<"text/plain; charset=utf-8">>}], - #{}, - <<"123">>}, - Options = [{dkim, [{s, <<"foo.bar">>}, - {d, <<"example.com">>}, - {c, {simple, simple}}, - {a, 'ed25519-sha256'}, - {t, {{2014, 2, 4}, {23, 15, 00}}}, - {x, {{2114, 2, 4}, {23, 15, 00}}}, - {private_key, {pem_plain, PrivKey}}]}], - - Enc = encode(Email, Options), - %% This `Enc' value can be verified, for example, by Python script - %% https://launchpad.net/dkimpy like: - %% >>> pubkey = ''.join(open("test/fixtures/dkim-ed25519-public.pem").read().splitlines()[1:-1]) - %% >>> dns_mock = lambda *args: 'v=DKIM1; g=*; k=ed25519; p=' + pubkey - %% >>> import dkim - %% >>> d = dkim.DKIM(mime_message) % pass `Enc' value as 1'st argument - %% >>> d.verify(dnsfunc=dns_mock) - %% True - {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc), - ?assertEqual(<<"DKIM-Signature">>, DkimHdrName), - ?assertEqual(<<"t=1391555700; x=4547229300; s=foo.bar; h=from:to:subject:date; d=example.com; c=simple/simple; " - "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=ed25519-sha256; v=1; " - "b=bFPndkFlgpFbfVKBF9HiVkQQF/3ojOQT7ycrZYp0yYe4oyItUQexlvd+Q7BviiHv/seLVBESpBjLbthbfb5HDA==">>, - DkimHdrVal) - end}, - {"Sign relaxed headers, simple body", - fun() -> - Email = {<<"text">>, <<"plain">>, - [{<<"From">>, <<"me@example.com">>}, - {<<"Subject">>, <<"Hello world!">>}, - {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>}, - {<<"Message-ID">>, <<"the-id-relaxed">>}, - {<<"Content-Type">>, <<"text/plain; charset=utf-8">>}], - #{}, - <<"123">>}, - Options = [{dkim, [{s, <<"foo.bar">>}, - {d, <<"example.com">>}, - {c, {relaxed, simple}}, - {a, 'ed25519-sha256'}, - {private_key, {pem_plain, PrivKey}}]}], - - Enc = encode(Email, Options), - {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc), - ?assertEqual(<<"DKIM-Signature">>, DkimHdrName), - ?assertEqual(<<"s=foo.bar; h=from:to:subject:date; d=example.com; c=relaxed/simple; " - "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=ed25519-sha256; v=1; " - "b=f7wORU/qmPr4q891m5zmZMadPm9n9e596mBJHBD6tE51PAl4pHdpw9xRC1kwLGmxPTEK5SiQluPVTbDHVhVZBQ==">>, - DkimHdrVal) - end}]; - false -> - [] - end. + case ed25519_supported() of + true -> + %% * sign using test/fixtures/dkim*.pem + {ok, PrivKey} = file:read_file("test/fixtures/dkim-ed25519-private.pem"), + [ + {"Sign simple", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"Subject">>, <<"Hello world!">>}, + {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>}, + {<<"Message-ID">>, <<"the-id">>}, + {<<"Content-Type">>, <<"text/plain; charset=utf-8">>} + ], + #{}, <<"123">>}, + Options = [ + {dkim, [ + {s, <<"foo.bar">>}, + {d, <<"example.com">>}, + {c, {simple, simple}}, + {a, 'ed25519-sha256'}, + {t, {{2014, 2, 4}, {23, 15, 00}}}, + {x, {{2114, 2, 4}, {23, 15, 00}}}, + {private_key, {pem_plain, PrivKey}} + ]} + ], + + Enc = encode(Email, Options), + %% This `Enc' value can be verified, for example, by Python script + %% https://launchpad.net/dkimpy like: + %% >>> pubkey = ''.join(open("test/fixtures/dkim-ed25519-public.pem").read().splitlines()[1:-1]) + %% >>> dns_mock = lambda *args: 'v=DKIM1; g=*; k=ed25519; p=' + pubkey + %% >>> import dkim + %% >>> d = dkim.DKIM(mime_message) % pass `Enc' value as 1'st argument + %% >>> d.verify(dnsfunc=dns_mock) + %% True + {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc), + ?assertEqual(<<"DKIM-Signature">>, DkimHdrName), + ?assertEqual( + << + "t=1391555700; x=4547229300; s=foo.bar; h=from:to:subject:date; d=example.com; c=simple/simple; " + "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=ed25519-sha256; v=1; " + "b=bFPndkFlgpFbfVKBF9HiVkQQF/3ojOQT7ycrZYp0yYe4oyItUQexlvd+Q7BviiHv/seLVBESpBjLbthbfb5HDA==" + >>, + DkimHdrVal + ) + end}, + {"Sign relaxed headers, simple body", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"Subject">>, <<"Hello world!">>}, + {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>}, + {<<"Message-ID">>, <<"the-id-relaxed">>}, + {<<"Content-Type">>, <<"text/plain; charset=utf-8">>} + ], + #{}, <<"123">>}, + Options = [ + {dkim, [ + {s, <<"foo.bar">>}, + {d, <<"example.com">>}, + {c, {relaxed, simple}}, + {a, 'ed25519-sha256'}, + {private_key, {pem_plain, PrivKey}} + ]} + ], + + Enc = encode(Email, Options), + {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc), + ?assertEqual(<<"DKIM-Signature">>, DkimHdrName), + ?assertEqual( + << + "s=foo.bar; h=from:to:subject:date; d=example.com; c=relaxed/simple; " + "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=ed25519-sha256; v=1; " + "b=f7wORU/qmPr4q891m5zmZMadPm9n9e596mBJHBD6tE51PAl4pHdpw9xRC1kwLGmxPTEK5SiQluPVTbDHVhVZBQ==" + >>, + DkimHdrVal + ) + end} + ]; + false -> + [] + end. dkim_sign_ed25519_encrypted_key_test_() -> - case ed25519_supported() of - true -> - %% * sign using test/fixtures/dkim*.pem - {ok, EncryptedPrivKey} = - file:read_file("test/fixtures/dkim-ed25519-encrypted-private.pem"), - [{"Sign encrypted", - fun() -> - Email = {<<"text">>, <<"plain">>, - [{<<"From">>, <<"me@example.com">>}, - {<<"Subject">>, <<"Hello world!">>}, - {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>}, - {<<"Message-ID">>, <<"the-id">>}, - {<<"Content-Type">>, <<"text/plain; charset=utf-8">>}], - #{}, - <<"123">>}, - Options = [{dkim, [{s, <<"foo.bar">>}, - {d, <<"example.com">>}, - {c, {simple, simple}}, - {a, 'ed25519-sha256'}, - {t, {{2014, 2, 4}, {23, 15, 00}}}, - {x, {{2114, 2, 4}, {23, 15, 00}}}, - {private_key, {pem_encrypted, EncryptedPrivKey, "password"}}]}], - - Enc = encode(Email, Options), - %% This `Enc' value can be verified, for example, by Python script - %% https://launchpad.net/dkimpy like: - %% >>> pubkey = ''.join(open("test/fixtures/dkim-ed25519-public.pem").read().splitlines()[1:-1]) - %% >>> dns_mock = lambda *args: 'v=DKIM1; g=*; k=ed25519; p=' + pubkey - %% >>> import dkim - %% >>> d = dkim.DKIM(mime_message) % pass `Enc' value as 1'st argument - %% >>> d.verify(dnsfunc=dns_mock) - %% True - {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc), - ?assertEqual(<<"DKIM-Signature">>, DkimHdrName), - ?assertEqual(<<"t=1391555700; x=4547229300; s=foo.bar; h=from:to:subject:date; d=example.com; c=simple/simple; " - "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=ed25519-sha256; v=1; " - "b=JgsuW5OmKPk188YRmxs1cLA8mrAf9FNC+s/PYK7Vat7HF4l7FglcoWWHqm0/Cg7o/V+8bP1RNwes1xDKS8/wDQ==">>, - DkimHdrVal) - end}]; - false -> - [] - end. + case ed25519_supported() of + true -> + %% * sign using test/fixtures/dkim*.pem + {ok, EncryptedPrivKey} = + file:read_file("test/fixtures/dkim-ed25519-encrypted-private.pem"), + [ + {"Sign encrypted", fun() -> + Email = + {<<"text">>, <<"plain">>, + [ + {<<"From">>, <<"me@example.com">>}, + {<<"Subject">>, <<"Hello world!">>}, + {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>}, + {<<"Message-ID">>, <<"the-id">>}, + {<<"Content-Type">>, <<"text/plain; charset=utf-8">>} + ], + #{}, <<"123">>}, + Options = [ + {dkim, [ + {s, <<"foo.bar">>}, + {d, <<"example.com">>}, + {c, {simple, simple}}, + {a, 'ed25519-sha256'}, + {t, {{2014, 2, 4}, {23, 15, 00}}}, + {x, {{2114, 2, 4}, {23, 15, 00}}}, + {private_key, {pem_encrypted, EncryptedPrivKey, "password"}} + ]} + ], + + Enc = encode(Email, Options), + %% This `Enc' value can be verified, for example, by Python script + %% https://launchpad.net/dkimpy like: + %% >>> pubkey = ''.join(open("test/fixtures/dkim-ed25519-public.pem").read().splitlines()[1:-1]) + %% >>> dns_mock = lambda *args: 'v=DKIM1; g=*; k=ed25519; p=' + pubkey + %% >>> import dkim + %% >>> d = dkim.DKIM(mime_message) % pass `Enc' value as 1'st argument + %% >>> d.verify(dnsfunc=dns_mock) + %% True + {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc), + ?assertEqual(<<"DKIM-Signature">>, DkimHdrName), + ?assertEqual( + << + "t=1391555700; x=4547229300; s=foo.bar; h=from:to:subject:date; d=example.com; c=simple/simple; " + "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=ed25519-sha256; v=1; " + "b=JgsuW5OmKPk188YRmxs1cLA8mrAf9FNC+s/PYK7Vat7HF4l7FglcoWWHqm0/Cg7o/V+8bP1RNwes1xDKS8/wDQ==" + >>, + DkimHdrVal + ) + end} + ]; + false -> + [] + end. -endif. diff --git a/src/smtp_server_example.erl b/src/smtp_server_example.erl index 7ec2b486..6858d331 100644 --- a/src/smtp_server_example.erl +++ b/src/smtp_server_example.erl @@ -4,20 +4,33 @@ -module(smtp_server_example). -behaviour(gen_smtp_server_session). - --export([init/4, handle_HELO/2, handle_EHLO/3, handle_MAIL/2, handle_MAIL_extension/2, - handle_RCPT/2, handle_RCPT_extension/2, handle_DATA/4, handle_RSET/1, handle_VRFY/2, - handle_other/3, handle_AUTH/4, handle_STARTTLS/1, handle_info/2, handle_error/3, - code_change/3, terminate/2]). +-export([ + init/4, + handle_HELO/2, + handle_EHLO/3, + handle_MAIL/2, + handle_MAIL_extension/2, + handle_RCPT/2, + handle_RCPT_extension/2, + handle_DATA/4, + handle_RSET/1, + handle_VRFY/2, + handle_other/3, + handle_AUTH/4, + handle_STARTTLS/1, + handle_info/2, + handle_error/3, + code_change/3, + terminate/2 +]). -include_lib("hut/include/hut.hrl"). -define(RELAY, true). --record(state, - { - options = [] :: list() - }). +-record(state, { + options = [] :: list() +}). --type(error_message() :: {'error', string(), #state{}}). +-type error_message() :: {'error', string(), #state{}}. %% @doc Initialize the callback module's state for a new session. %% The arguments to the function are the SMTP server's hostname (for use in the SMTP banner), @@ -31,19 +44,23 @@ %% to ALL subsequent calls to the callback module, so it can be used to keep track of the SMTP %% session. You can also return `{stop, Reason, Message}' where the session will exit with Reason %% and send Message to the client. --spec init(Hostname :: inet:hostname(), SessionCount :: non_neg_integer(), - Address :: inet:ip_address(), Options :: list()) -> {'ok', iodata(), #state{}} | {'stop', any(), iodata()}. +-spec init( + Hostname :: inet:hostname(), + SessionCount :: non_neg_integer(), + Address :: inet:ip_address(), + Options :: list() +) -> {'ok', iodata(), #state{}} | {'stop', any(), iodata()}. init(Hostname, SessionCount, Address, Options) -> - ?log(info, "peer: ~p~n", [Address]), - case SessionCount > 20 of - false -> - Banner = [Hostname, " ESMTP smtp_server_example"], - State = #state{options = Options}, - {ok, Banner, State}; - true -> - ?log(warning, "Connection limit exceeded~n"), - {stop, normal, ["421 ", Hostname, " is too busy to accept mail right now"]} - end. + ?log(info, "peer: ~p~n", [Address]), + case SessionCount > 20 of + false -> + Banner = [Hostname, " ESMTP smtp_server_example"], + State = #state{options = Options}, + {ok, Banner, State}; + true -> + ?log(warning, "Connection limit exceeded~n"), + {stop, normal, ["421 ", Hostname, " is too busy to accept mail right now"]} + end. %% @doc Handle the HELO verb from the client. Arguments are the Hostname sent by the client as %% part of the HELO and the callback State. @@ -53,18 +70,20 @@ init(Hostname, SessionCount, Address, Options) -> %% , for example, by looking at the IP address passed in to the init function) and the new callback %% state. You can reject the HELO by returning `{error, Message, State}' and the Message will be %% sent back to the client. The reject message MUST contain the SMTP status code, eg. 554. --spec handle_HELO(Hostname :: binary(), State :: #state{}) -> {'ok', pos_integer(), #state{}} | {'ok', #state{}} | error_message(). +-spec handle_HELO(Hostname :: binary(), State :: #state{}) -> + {'ok', pos_integer(), #state{}} | {'ok', #state{}} | error_message(). handle_HELO(<<"invalid">>, State) -> - % contrived example - {error, "554 invalid hostname", State}; + % contrived example + {error, "554 invalid hostname", State}; handle_HELO(<<"trusted_host">>, State) -> - {ok, State}; %% no size limit because we trust them. + %% no size limit because we trust them. + {ok, State}; handle_HELO(Hostname, State) -> - ?log(info, "HELO from ~s~n", [Hostname]), - % 640kb of HELO should be enough for anyone. - MaxSize = proplists:get_value(size, State#state.options, 655360), - {ok, MaxSize, State}. - %If {ok, State} was returned here, we'd use the default 10mb limit + ?log(info, "HELO from ~s~n", [Hostname]), + % 640kb of HELO should be enough for anyone. + MaxSize = proplists:get_value(size, State#state.options, 655360), + {ok, MaxSize, State}. +%If {ok, State} was returned here, we'd use the default 10mb limit %% @doc Handle the EHLO verb from the client. As with EHLO the hostname is provided as an argument, %% but in addition to that the list of ESMTP Extensions enabled in the session is passed. This list @@ -73,29 +92,32 @@ handle_HELO(Hostname, State) -> %% The return values are `{ok, Extensions, State}' where Extensions is the new list of extensions %% to use for this session or `{error, Message, State}' where Message is the reject message as %% with handle_HELO. --spec handle_EHLO(Hostname :: binary(), Extensions :: list(), State :: #state{}) -> {'ok', list(), #state{}} | error_message(). +-spec handle_EHLO(Hostname :: binary(), Extensions :: list(), State :: #state{}) -> + {'ok', list(), #state{}} | error_message(). handle_EHLO(<<"invalid">>, _Extensions, State) -> - % contrived example - {error, "554 invalid hostname", State}; + % contrived example + {error, "554 invalid hostname", State}; handle_EHLO(Hostname, Extensions, State) -> - ?log(info, "EHLO from ~s~n", [Hostname]), - % You can advertise additional extensions, or remove some defaults - MyExtensions1 = case proplists:get_value(auth, State#state.options, false) of - true -> - % auth is enabled, so advertise it - Extensions ++ [{"AUTH", "PLAIN LOGIN CRAM-MD5"}, {"STARTTLS", true}]; - false -> - Extensions - end, - MyExtensions2 = case proplists:get_value(size, State#state.options) of - undefined -> - MyExtensions1; - infinity -> - [ {"SIZE", "0"} | lists:keydelete("SIZE", 1, MyExtensions1) ]; - Size when is_integer(Size), Size > 0 -> - [ {"SIZE", integer_to_list(Size)} | lists:keydelete("SIZE", 1, MyExtensions1) ] - end, - {ok, MyExtensions2, State}. + ?log(info, "EHLO from ~s~n", [Hostname]), + % You can advertise additional extensions, or remove some defaults + MyExtensions1 = + case proplists:get_value(auth, State#state.options, false) of + true -> + % auth is enabled, so advertise it + Extensions ++ [{"AUTH", "PLAIN LOGIN CRAM-MD5"}, {"STARTTLS", true}]; + false -> + Extensions + end, + MyExtensions2 = + case proplists:get_value(size, State#state.options) of + undefined -> + MyExtensions1; + infinity -> + [{"SIZE", "0"} | lists:keydelete("SIZE", 1, MyExtensions1)]; + Size when is_integer(Size), Size > 0 -> + [{"SIZE", integer_to_list(Size)} | lists:keydelete("SIZE", 1, MyExtensions1)] + end, + {ok, MyExtensions2, State}. %% @doc Handle the MAIL FROM verb. The From argument is the email address specified by the %% MAIL FROM command. Extensions to the MAIL verb are handled by the `handle_MAIL_extension' @@ -104,39 +126,40 @@ handle_EHLO(Hostname, Extensions, State) -> %% Return values are either `{ok, State}' or `{error, Message, State}' as before. -spec handle_MAIL(From :: binary(), State :: #state{}) -> {'ok', #state{}} | error_message(). handle_MAIL(<<"badguy@blacklist.com">>, State) -> - {error, "552 go away", State}; + {error, "552 go away", State}; handle_MAIL(From, State) -> - ?log(info, "Mail from ~s~n", [From]), - % you can accept or reject the FROM address here - {ok, State}. + ?log(info, "Mail from ~s~n", [From]), + % you can accept or reject the FROM address here + {ok, State}. %% @doc Handle an extension to the MAIL verb. Return either `{ok, State}' or `error' to reject %% the option. -spec handle_MAIL_extension(Extension :: binary(), State :: #state{}) -> {'ok', #state{}} | 'error'. handle_MAIL_extension(<<"X-SomeExtension">> = Extension, State) -> - ?log(info, "Mail from extension ~s~n", [Extension]), - % any MAIL extensions can be handled here - {ok, State}; + ?log(info, "Mail from extension ~s~n", [Extension]), + % any MAIL extensions can be handled here + {ok, State}; handle_MAIL_extension(Extension, _State) -> - ?log(warning, "Unknown MAIL FROM extension ~s~n", [Extension]), - error. + ?log(warning, "Unknown MAIL FROM extension ~s~n", [Extension]), + error. --spec handle_RCPT(To :: binary(), State :: #state{}) -> {'ok', #state{}} | {'error', string(), #state{}}. +-spec handle_RCPT(To :: binary(), State :: #state{}) -> + {'ok', #state{}} | {'error', string(), #state{}}. handle_RCPT(<<"nobody@example.com">>, State) -> - {error, "550 No such recipient", State}; + {error, "550 No such recipient", State}; handle_RCPT(To, State) -> - ?log(info, "Mail to ~s~n", [To]), - % you can accept or reject RCPT TO addresses here, one per call - {ok, State}. + ?log(info, "Mail to ~s~n", [To]), + % you can accept or reject RCPT TO addresses here, one per call + {ok, State}. -spec handle_RCPT_extension(Extension :: binary(), State :: #state{}) -> {'ok', #state{}} | 'error'. handle_RCPT_extension(<<"X-SomeExtension">> = Extension, State) -> - % any RCPT TO extensions can be handled here - ?log(info, "Mail to extension ~s~n", [Extension]), - {ok, State}; + % any RCPT TO extensions can be handled here + ?log(info, "Mail to extension ~s~n", [Extension]), + {ok, State}; handle_RCPT_extension(Extension, _State) -> - ?log(warning, "Unknown RCPT TO extension ~s~n", [Extension]), - error. + ?log(warning, "Unknown RCPT TO extension ~s~n", [Extension]), + error. %% @doc Handle the DATA verb from the client, which corresponds to the body of %% the message. After receiving the body, a SMTP server can put the email in @@ -163,78 +186,92 @@ handle_RCPT_extension(Extension, _State) -> %% %% According to the SMTP specification the, responsibility of delivering an %% email must be taken seriously and the servers MUST NOT loose the message. --spec handle_DATA(From :: binary(), - To :: [binary(),...], - Data :: binary(), - State :: #state{} - ) -> {ok | error, string(), #state{}} | - {multiple, [{ok | error, string()}], #state{}}. +-spec handle_DATA( + From :: binary(), + To :: [binary(), ...], + Data :: binary(), + State :: #state{} +) -> + {ok | error, string(), #state{}} + | {multiple, [{ok | error, string()}], #state{}}. handle_DATA(_From, _To, <<>>, State) -> - {error, "552 Message too small", State}; + {error, "552 Message too small", State}; handle_DATA(From, To, Data, State) -> - % if RELAY is true, then relay email to email address, else send email data to console - case proplists:get_value(relay, State#state.options, false) of - true -> relay(From, To, Data); - false -> - % some kind of unique id - Reference = lists:flatten([io_lib:format("~2.16.0b", [X]) || <> <= erlang:md5(term_to_binary(unique_id()))]), - case proplists:get_value(parse, State#state.options, false) of - false -> ok; - true -> - % In this example we try to decode the email - try mimemail:decode(Data) of - _Result -> - ?log(info, "Message decoded successfully!~n") - catch - What:Why -> - ?log(warning, "Message decode FAILED with ~p:~p~n", [What, Why]), - case proplists:get_value(dump, State#state.options, false) of - false -> ok; - true -> - %% optionally dump the failed email somewhere for analysis - File = "dump/"++Reference, - case filelib:ensure_dir(File) of - ok -> - file:write_file(File, Data); - _ -> - ok - end - end - end - end, - queue_or_deliver(From, To, Data, Reference, State) - end. + % if RELAY is true, then relay email to email address, else send email data to console + case proplists:get_value(relay, State#state.options, false) of + true -> + relay(From, To, Data); + false -> + % some kind of unique id + Reference = lists:flatten([ + io_lib:format("~2.16.0b", [X]) + || <> <= erlang:md5(term_to_binary(unique_id())) + ]), + case proplists:get_value(parse, State#state.options, false) of + false -> + ok; + true -> + % In this example we try to decode the email + try mimemail:decode(Data) of + _Result -> + ?log(info, "Message decoded successfully!~n") + catch + What:Why -> + ?log(warning, "Message decode FAILED with ~p:~p~n", [What, Why]), + case proplists:get_value(dump, State#state.options, false) of + false -> + ok; + true -> + %% optionally dump the failed email somewhere for analysis + File = "dump/" ++ Reference, + case filelib:ensure_dir(File) of + ok -> + file:write_file(File, Data); + _ -> + ok + end + end + end + end, + queue_or_deliver(From, To, Data, Reference, State) + end. -spec handle_RSET(State :: #state{}) -> #state{}. handle_RSET(State) -> - % reset any relevant internal state - State. + % reset any relevant internal state + State. --spec handle_VRFY(Address :: binary(), State :: #state{}) -> {'ok', string(), #state{}} | {'error', string(), #state{}}. +-spec handle_VRFY(Address :: binary(), State :: #state{}) -> + {'ok', string(), #state{}} | {'error', string(), #state{}}. handle_VRFY(<<"someuser">>, State) -> - {ok, "someuser@"++smtp_util:guess_FQDN(), State}; + {ok, "someuser@" ++ smtp_util:guess_FQDN(), State}; handle_VRFY(_Address, State) -> - {error, "252 VRFY disabled by policy, just send some mail", State}. + {error, "252 VRFY disabled by policy, just send some mail", State}. -spec handle_other(Verb :: binary(), Args :: binary(), #state{}) -> {string(), #state{}}. handle_other(Verb, _Args, State) -> - % You can implement other SMTP verbs here, if you need to - {["500 Error: command not recognized : '", Verb, "'"], State}. + % You can implement other SMTP verbs here, if you need to + {["500 Error: command not recognized : '", Verb, "'"], State}. %% this callback is OPTIONAL %% it only gets called if you add AUTH to your ESMTP extensions --spec handle_AUTH(Type :: 'login' | 'plain' | 'cram-md5', Username :: binary(), Password :: binary() | {binary(), binary()}, #state{}) -> {'ok', #state{}} | 'error'. +-spec handle_AUTH( + Type :: 'login' | 'plain' | 'cram-md5', + Username :: binary(), + Password :: binary() | {binary(), binary()}, + #state{} +) -> {'ok', #state{}} | 'error'. handle_AUTH(Type, <<"username">>, <<"PaSSw0rd">>, State) when Type =:= login; Type =:= plain -> - {ok, State}; + {ok, State}; handle_AUTH('cram-md5', <<"username">>, {Digest, Seed}, State) -> - case smtp_util:compute_cram_digest(<<"PaSSw0rd">>, Seed) of - Digest -> - {ok, State}; - _ -> - error - end; + case smtp_util:compute_cram_digest(<<"PaSSw0rd">>, Seed) of + Digest -> + {ok, State}; + _ -> + error + end; handle_AUTH(_Type, _Username, _Password, _State) -> - error. + error. %% this callback is OPTIONAL %% it only gets called if you add STARTTLS to your ESMTP extensions @@ -244,29 +281,29 @@ handle_STARTTLS(State) -> State. -spec handle_info(Info :: term(), State :: term()) -> - {noreply, NewState :: term()} | - {noreply, NewState :: term(), timeout() | hibernate} | - {stop, Reason :: term(), NewState :: term()}. + {noreply, NewState :: term()} + | {noreply, NewState :: term(), timeout() | hibernate} + | {stop, Reason :: term(), NewState :: term()}. handle_info(_Info, State) -> ?log(info, "handle_info(~p, ~p)", [_Info, State]), - {noreply, State}. + {noreply, State}. %% This optional callback is called when different kinds of protocol errors happen. %% Return {ok, State} to let gen_smtp decide how to act or {stop, Reason, #state{}} %% to stop the process with reason Reason immediately. -spec handle_error(gen_smtp_server_session:error_class(), any(), #state{}) -> - {ok, State} | {stop, any(), State}. + {ok, State} | {stop, any(), State}. handle_error(Class, Details, State) -> ?log(info, "handle_error(~p, ~p, ~p)", [Class, Details, State]), - {ok, State}. + {ok, State}. -spec code_change(OldVsn :: any(), State :: #state{}, Extra :: any()) -> {ok, #state{}}. code_change(_OldVsn, State, _Extra) -> - {ok, State}. + {ok, State}. -spec terminate(Reason :: any(), State :: #state{}) -> {'ok', any(), #state{}}. terminate(Reason, State) -> - {ok, Reason, State}. + {ok, Reason, State}. %%% Internal Functions %%% @@ -275,34 +312,38 @@ unique_id() -> -spec relay(binary(), [binary()], binary()) -> ok. relay(_, [], _) -> - ok; -relay(From, [To|Rest], Data) -> - % relay message to email address - [_User, Host] = string:tokens(binary_to_list(To), "@"), - gen_smtp_client:send({From, [To], erlang:binary_to_list(Data)}, [{relay, Host}]), - relay(From, Rest, Data). + ok; +relay(From, [To | Rest], Data) -> + % relay message to email address + [_User, Host] = string:tokens(binary_to_list(To), "@"), + gen_smtp_client:send({From, [To], erlang:binary_to_list(Data)}, [{relay, Host}]), + relay(From, Rest, Data). %% @doc Helps `handle_DATA' to deal with the received email. %% This function is not directly required by the behaviour. --spec queue_or_deliver(From :: binary(), - To :: [binary(),...], - Data :: binary(), - Reference :: string(), - State :: #state{} - ) -> {ok | error, string(), #state{}} | - {multiple, [{ok | error, string()}], #state{}}. +-spec queue_or_deliver( + From :: binary(), + To :: [binary(), ...], + Data :: binary(), + Reference :: string(), + State :: #state{} +) -> + {ok | error, string(), #state{}} + | {multiple, [{ok | error, string()}], #state{}}. queue_or_deliver(From, To, Data, Reference, State) -> - % At this point, if we return ok, we've accepted responsibility for the emaill - Length = byte_size(Data), - case proplists:get_value(protocol, State#state.options, smtp) of - smtp -> - ?log(info, "message from ~s to ~p queued as ~s, body length ~p~n", [From, To, Reference, Length]), - % ... should actually handle the email, - % if `ok` is returned we are taking the responsibility of the delivery. - {ok, ["queued as ~s", Reference], State}; - lmtp -> - ?log(info, "message from ~s delivered to ~p, body length ~p~n", [From, To, Length]), - Multiple = [{ok, ["delivered to ", Recipient]} || Recipient <- To], - % ... should actually handle the email for each recipient for each `ok` - {multiple, Multiple, State} - end. + % At this point, if we return ok, we've accepted responsibility for the emaill + Length = byte_size(Data), + case proplists:get_value(protocol, State#state.options, smtp) of + smtp -> + ?log(info, "message from ~s to ~p queued as ~s, body length ~p~n", [ + From, To, Reference, Length + ]), + % ... should actually handle the email, + % if `ok` is returned we are taking the responsibility of the delivery. + {ok, ["queued as ~s", Reference], State}; + lmtp -> + ?log(info, "message from ~s delivered to ~p, body length ~p~n", [From, To, Length]), + Multiple = [{ok, ["delivered to ", Recipient]} || Recipient <- To], + % ... should actually handle the email for each recipient for each `ok` + {multiple, Multiple, State} + end. diff --git a/src/smtp_socket.erl b/src/smtp_socket.erl index 46542463..e5eb7c03 100644 --- a/src/smtp_socket.erl +++ b/src/smtp_socket.erl @@ -22,31 +22,38 @@ %% @doc Facilitates transparent gen_tcp/ssl socket handling -module(smtp_socket). - --define(TCP_LISTEN_OPTIONS,[ {active, false}, - {backlog, 30}, - {ip,{0,0,0,0}}, - {keepalive, true}, - {packet, line}, - {reuseaddr, true}]). --define(TCP_CONNECT_OPTIONS,[ {active, false}, - {packet, line}, - {ip, {0,0,0,0}}, - {port, 0}]). --define(SSL_LISTEN_OPTIONS, [ {active, false}, - {backlog, 30}, - {certfile, "server.crt"}, - {depth, 0}, - {keepalive, true}, - {keyfile, "server.key"}, - {packet, line}, - {reuse_sessions, false}, - {reuseaddr, true}]). --define(SSL_CONNECT_OPTIONS,[ {active, false}, - {depth, 0}, - {packet, line}, - {ip, {0,0,0,0}}, - {port, 0}]). +-define(TCP_LISTEN_OPTIONS, [ + {active, false}, + {backlog, 30}, + {ip, {0, 0, 0, 0}}, + {keepalive, true}, + {packet, line}, + {reuseaddr, true} +]). +-define(TCP_CONNECT_OPTIONS, [ + {active, false}, + {packet, line}, + {ip, {0, 0, 0, 0}}, + {port, 0} +]). +-define(SSL_LISTEN_OPTIONS, [ + {active, false}, + {backlog, 30}, + {certfile, "server.crt"}, + {depth, 0}, + {keepalive, true}, + {keyfile, "server.key"}, + {packet, line}, + {reuse_sessions, false}, + {reuseaddr, true} +]). +-define(SSL_CONNECT_OPTIONS, [ + {active, false}, + {depth, 0}, + {packet, line}, + {ip, {0, 0, 0, 0}}, + {port, 0} +]). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). @@ -65,8 +72,8 @@ -export([begin_inet_async/1]). -export([handle_inet_async/1, handle_inet_async/2, handle_inet_async/3]). -export([extract_port_from_socket/1]). --export([to_ssl_server/1,to_ssl_server/2,to_ssl_server/3]). --export([to_ssl_client/1,to_ssl_client/2,to_ssl_client/3]). +-export([to_ssl_server/1, to_ssl_server/2, to_ssl_server/3]). +-export([to_ssl_client/1, to_ssl_client/2, to_ssl_client/3]). -export([type/1]). -type protocol() :: 'tcp' | 'ssl'. @@ -78,646 +85,733 @@ %%%----------------------------------------------------------------- %%% API %%%----------------------------------------------------------------- --spec connect(Protocol :: protocol(), Address :: address(), Port :: pos_integer()) -> {ok, socket()} | {error, any()}. +-spec connect(Protocol :: protocol(), Address :: address(), Port :: pos_integer()) -> + {ok, socket()} | {error, any()}. connect(Protocol, Address, Port) -> - connect(Protocol, Address, Port, [], infinity). + connect(Protocol, Address, Port, [], infinity). --spec connect(Protocol :: protocol(), Address :: address(), Port :: pos_integer(), Options :: list()) -> {ok, socket()} | {error, any()}. +-spec connect( + Protocol :: protocol(), Address :: address(), Port :: pos_integer(), Options :: list() +) -> {ok, socket()} | {error, any()}. connect(Protocol, Address, Port, Opts) -> - connect(Protocol, Address, Port, Opts, infinity). - --spec connect(Protocol :: protocol(), Address :: address(), Port :: pos_integer(), Options :: list(), Time :: non_neg_integer() | 'infinity') -> {ok, socket()} | {error, any()}. + connect(Protocol, Address, Port, Opts, infinity). + +-spec connect( + Protocol :: protocol(), + Address :: address(), + Port :: pos_integer(), + Options :: list(), + Time :: non_neg_integer() | 'infinity' +) -> {ok, socket()} | {error, any()}. connect(tcp, Address, Port, Opts, Time) -> - gen_tcp:connect(Address, Port, tcp_connect_options(Opts), Time); + gen_tcp:connect(Address, Port, tcp_connect_options(Opts), Time); connect(ssl, Address, Port, Opts, Time) -> - ssl:connect(Address, Port, ssl_connect_options(Opts), Time). - + ssl:connect(Address, Port, ssl_connect_options(Opts), Time). -spec listen(Protocol :: protocol(), Port :: pos_integer()) -> {ok, socket()} | {error, any()}. listen(Protocol, Port) -> - listen(Protocol, Port, []). + listen(Protocol, Port, []). --spec listen(Protocol :: protocol(), Port :: pos_integer(), Options :: list()) -> {ok, socket()} | {error, any()}. +-spec listen(Protocol :: protocol(), Port :: pos_integer(), Options :: list()) -> + {ok, socket()} | {error, any()}. listen(ssl, Port, Options) -> - ssl:listen(Port, ssl_listen_options(Options)); + ssl:listen(Port, ssl_listen_options(Options)); listen(tcp, Port, Options) -> - gen_tcp:listen(Port, tcp_listen_options(Options)). - + gen_tcp:listen(Port, tcp_listen_options(Options)). -spec accept(Socket :: socket()) -> {'ok', socket()} | {'error', any()}. accept(Socket) -> - accept(Socket, infinity). + accept(Socket, infinity). --spec accept(Socket :: socket(), Timeout :: pos_integer() | 'infinity') -> {'ok', socket()} | {'error', any()}. +-spec accept(Socket :: socket(), Timeout :: pos_integer() | 'infinity') -> + {'ok', socket()} | {'error', any()}. accept(Socket, Timeout) when is_port(Socket) -> - case gen_tcp:accept(Socket, Timeout) of - {ok, NewSocket} -> - {ok, Opts} = inet:getopts(Socket, [active,keepalive,packet,reuseaddr]), - inet:setopts(NewSocket, Opts), - {ok, NewSocket}; - {error, _} = Error -> - Error - end; - - + case gen_tcp:accept(Socket, Timeout) of + {ok, NewSocket} -> + {ok, Opts} = inet:getopts(Socket, [active, keepalive, packet, reuseaddr]), + inet:setopts(NewSocket, Opts), + {ok, NewSocket}; + {error, _} = Error -> + Error + end; accept(Socket, Timeout) -> - case ssl:transport_accept(Socket, Timeout) of - {ok, NewSocket} -> - ssl_handshake(NewSocket); - {error, _} = Error -> - Error - end. + case ssl:transport_accept(Socket, Timeout) of + {ok, NewSocket} -> + ssl_handshake(NewSocket); + {error, _} = Error -> + Error + end. -ifdef(OTP_RELEASE). ssl_handshake(Socket) -> - ssl:handshake(Socket). + ssl:handshake(Socket). ssl_handshake(Socket, Options, Timeout) -> - ssl:handshake(Socket, Options, Timeout). + ssl:handshake(Socket, Options, Timeout). -else. ssl_handshake(Socket) -> - case ssl:ssl_accept(Socket) of - ok -> {ok, Socket}; - {error, _} = Error -> Error - end. + case ssl:ssl_accept(Socket) of + ok -> {ok, Socket}; + {error, _} = Error -> Error + end. ssl_handshake(Socket, Options, Timeout) -> - case ssl:ssl_accept(Socket, Options, Timeout) of - {ok, _} = OK -> OK; - {error, _} = Error -> Error - end. + case ssl:ssl_accept(Socket, Options, Timeout) of + {ok, _} = OK -> OK; + {error, _} = Error -> Error + end. -endif. -spec send(Socket :: socket(), Data :: binary() | string() | iolist()) -> 'ok' | {'error', any()}. send(Socket, Data) when is_port(Socket) -> - gen_tcp:send(Socket, Data); + gen_tcp:send(Socket, Data); send(Socket, Data) -> - ssl:send(Socket, Data). + ssl:send(Socket, Data). -spec recv(Socket :: socket(), Length :: non_neg_integer()) -> {'ok', any()} | {'error', any()}. recv(Socket, Length) -> - recv(Socket, Length, infinity). + recv(Socket, Length, infinity). --spec recv(Socket :: socket(), Length :: non_neg_integer(), Timeout :: non_neg_integer() | 'infinity') -> {'ok', any()} | {'error', any()}. +-spec recv( + Socket :: socket(), Length :: non_neg_integer(), Timeout :: non_neg_integer() | 'infinity' +) -> {'ok', any()} | {'error', any()}. recv(Socket, Length, Timeout) when is_port(Socket) -> - gen_tcp:recv(Socket, Length, Timeout); + gen_tcp:recv(Socket, Length, Timeout); recv(Socket, Length, Timeout) -> - ssl:recv(Socket, Length, Timeout). + ssl:recv(Socket, Length, Timeout). -spec controlling_process(Socket :: socket(), NewOwner :: pid()) -> 'ok' | {'error', any()}. controlling_process(Socket, NewOwner) when is_port(Socket) -> - gen_tcp:controlling_process(Socket, NewOwner); + gen_tcp:controlling_process(Socket, NewOwner); controlling_process(Socket, NewOwner) -> - ssl:controlling_process(Socket, NewOwner). + ssl:controlling_process(Socket, NewOwner). --spec peername(Socket :: socket()) -> {ok, {inet:ip_address(), non_neg_integer()}} | {'error', any()}. +-spec peername(Socket :: socket()) -> + {ok, {inet:ip_address(), non_neg_integer()}} | {'error', any()}. peername(Socket) when is_port(Socket) -> - inet:peername(Socket); + inet:peername(Socket); peername(Socket) -> - ssl:peername(Socket). + ssl:peername(Socket). -spec close(Socket :: socket()) -> 'ok'. close(Socket) when is_port(Socket) -> - gen_tcp:close(Socket); + gen_tcp:close(Socket); close(Socket) -> - ssl:close(Socket). + ssl:close(Socket). --spec shutdown(Socket :: socket(), How :: 'read' | 'write' | 'read_write') -> 'ok' | {'error', any()}. +-spec shutdown(Socket :: socket(), How :: 'read' | 'write' | 'read_write') -> + 'ok' | {'error', any()}. shutdown(Socket, How) when is_port(Socket) -> - gen_tcp:shutdown(Socket, How); + gen_tcp:shutdown(Socket, How); shutdown(Socket, How) -> - ssl:shutdown(Socket, How). + ssl:shutdown(Socket, How). -spec active_once(Socket :: socket()) -> 'ok' | {'error', any()}. active_once(Socket) when is_port(Socket) -> - inet:setopts(Socket, [{active, once}]); + inet:setopts(Socket, [{active, once}]); active_once(Socket) -> - ssl:setopts(Socket, [{active, once}]). + ssl:setopts(Socket, [{active, once}]). -spec setopts(Socket :: socket(), Options :: list()) -> 'ok' | {'error', any()}. setopts(Socket, Options) when is_port(Socket) -> - inet:setopts(Socket, Options); + inet:setopts(Socket, Options); setopts(Socket, Options) -> - ssl:setopts(Socket, Options). + ssl:setopts(Socket, Options). -spec get_proto(Socket :: any()) -> 'tcp' | 'ssl'. get_proto(Socket) when is_port(Socket) -> - tcp; + tcp; get_proto(_Socket) -> - ssl. + ssl. %% @doc {inet_async,...} will be sent to current process when a client connects -spec begin_inet_async(Socket :: socket()) -> any(). begin_inet_async(Socket) when is_port(Socket) -> - prim_inet:async_accept(Socket, -1); + prim_inet:async_accept(Socket, -1); begin_inet_async(Socket) -> - Port = extract_port_from_socket(Socket), - begin_inet_async(Port). + Port = extract_port_from_socket(Socket), + begin_inet_async(Port). %% @doc handle the {inet_async,...} message --spec handle_inet_async(Message :: {'inet_async', socket(), any(), {'ok', socket()}}) -> {'ok', socket()}. -handle_inet_async({inet_async, ListenSocket, _, {ok,ClientSocket}}) -> - handle_inet_async(ListenSocket, ClientSocket, []). +-spec handle_inet_async(Message :: {'inet_async', socket(), any(), {'ok', socket()}}) -> + {'ok', socket()}. +handle_inet_async({inet_async, ListenSocket, _, {ok, ClientSocket}}) -> + handle_inet_async(ListenSocket, ClientSocket, []). -spec handle_inet_async(ListenSocket :: socket(), ClientSocket :: socket()) -> {'ok', socket()}. handle_inet_async(ListenObject, ClientSocket) -> - handle_inet_async(ListenObject, ClientSocket, []). + handle_inet_async(ListenObject, ClientSocket, []). --spec handle_inet_async(ListenSocket :: socket(), ClientSocket :: socket(), Options :: list()) -> {'ok', socket()}. +-spec handle_inet_async(ListenSocket :: socket(), ClientSocket :: socket(), Options :: list()) -> + {'ok', socket()}. handle_inet_async(ListenObject, ClientSocket, Options) -> - ListenSocket = extract_port_from_socket(ListenObject), - case set_sockopt(ListenSocket, ClientSocket) of - ok -> ok; - Error -> erlang:error(set_sockopt, Error) - end, - %% Signal the network driver that we are ready to accept another connection - begin_inet_async(ListenSocket), - %% If the listening socket is SSL then negotiate the client socket - case is_port(ListenObject) of - true -> - {ok, ClientSocket}; - false -> - {ok, UpgradedClientSocket} = to_ssl_server(ClientSocket, Options), - {ok, UpgradedClientSocket} - end. + ListenSocket = extract_port_from_socket(ListenObject), + case set_sockopt(ListenSocket, ClientSocket) of + ok -> ok; + Error -> erlang:error(set_sockopt, Error) + end, + %% Signal the network driver that we are ready to accept another connection + begin_inet_async(ListenSocket), + %% If the listening socket is SSL then negotiate the client socket + case is_port(ListenObject) of + true -> + {ok, ClientSocket}; + false -> + {ok, UpgradedClientSocket} = to_ssl_server(ClientSocket, Options), + {ok, UpgradedClientSocket} + end. %% @doc Upgrade a TCP connection to SSL -spec to_ssl_server(Socket :: socket()) -> {'ok', ssl:sslsocket()} | {'error', any()}. to_ssl_server(Socket) -> - to_ssl_server(Socket, []). + to_ssl_server(Socket, []). --spec to_ssl_server(Socket :: socket(), Options :: list()) -> {'ok', ssl:sslsocket()} | {'error', any()}. +-spec to_ssl_server(Socket :: socket(), Options :: list()) -> + {'ok', ssl:sslsocket()} | {'error', any()}. to_ssl_server(Socket, Options) -> - to_ssl_server(Socket, Options, infinity). + to_ssl_server(Socket, Options, infinity). --spec to_ssl_server(Socket :: socket(), Options :: list(), Timeout :: non_neg_integer() | 'infinity') -> {'ok', ssl:sslsocket()} | {'error', any()}. +-spec to_ssl_server( + Socket :: socket(), Options :: list(), Timeout :: non_neg_integer() | 'infinity' +) -> {'ok', ssl:sslsocket()} | {'error', any()}. to_ssl_server(Socket, Options, Timeout) when is_port(Socket) -> - ssl_handshake(Socket, ssl_listen_options(Options), Timeout); + ssl_handshake(Socket, ssl_listen_options(Options), Timeout); to_ssl_server(_Socket, _Options, _Timeout) -> - {error, already_ssl}. + {error, already_ssl}. -spec to_ssl_client(Socket :: socket()) -> {'ok', ssl:sslsocket()} | {'error', 'already_ssl'}. to_ssl_client(Socket) -> - to_ssl_client(Socket, []). + to_ssl_client(Socket, []). --spec to_ssl_client(Socket :: socket(), Options :: list()) -> {'ok', ssl:sslsocket()} | {'error', 'already_ssl'}. +-spec to_ssl_client(Socket :: socket(), Options :: list()) -> + {'ok', ssl:sslsocket()} | {'error', 'already_ssl'}. to_ssl_client(Socket, Options) -> - to_ssl_client(Socket, Options, infinity). + to_ssl_client(Socket, Options, infinity). --spec to_ssl_client(Socket :: socket(), Options :: list(), Timeout :: non_neg_integer() | 'infinity') -> {'ok', ssl:sslsocket()} | {'error', 'already_ssl'}. +-spec to_ssl_client( + Socket :: socket(), Options :: list(), Timeout :: non_neg_integer() | 'infinity' +) -> {'ok', ssl:sslsocket()} | {'error', 'already_ssl'}. to_ssl_client(Socket, Options, Timeout) when is_port(Socket) -> - ssl:connect(Socket, ssl_connect_options(Options), Timeout); + ssl:connect(Socket, ssl_connect_options(Options), Timeout); to_ssl_client(_Socket, _Options, _Timeout) -> - {error, already_ssl}. + {error, already_ssl}. -spec type(Socket :: socket()) -> protocol(). type(Socket) when is_port(Socket) -> - tcp; + tcp; type(_Socket) -> - ssl. + ssl. %%%----------------------------------------------------------------- %%% Internal functions (OS_Mon configuration) %%%----------------------------------------------------------------- -tcp_listen_options([Format|Options]) when Format =:= list; Format =:= binary -> - tcp_listen_options(Options, Format); +tcp_listen_options([Format | Options]) when Format =:= list; Format =:= binary -> + tcp_listen_options(Options, Format); tcp_listen_options(Options) -> - tcp_listen_options(Options, list). + tcp_listen_options(Options, list). tcp_listen_options(Options, Format) -> - parse_address([Format|proplist_merge(Options, ?TCP_LISTEN_OPTIONS)]). + parse_address([Format | proplist_merge(Options, ?TCP_LISTEN_OPTIONS)]). -ssl_listen_options([Format|Options]) when Format =:= list; Format =:= binary -> - ssl_listen_options(Options, Format); +ssl_listen_options([Format | Options]) when Format =:= list; Format =:= binary -> + ssl_listen_options(Options, Format); ssl_listen_options(Options) -> - ssl_listen_options(Options, list). + ssl_listen_options(Options, list). ssl_listen_options(Options, Format) -> - parse_address([Format|proplist_merge(Options, ?SSL_LISTEN_OPTIONS)]). + parse_address([Format | proplist_merge(Options, ?SSL_LISTEN_OPTIONS)]). -tcp_connect_options([Format|Options]) when Format =:= list; Format =:= binary -> - tcp_connect_options(Options, Format); +tcp_connect_options([Format | Options]) when Format =:= list; Format =:= binary -> + tcp_connect_options(Options, Format); tcp_connect_options(Options) -> - tcp_connect_options(Options, list). + tcp_connect_options(Options, list). tcp_connect_options(Options, Format) -> - parse_address([Format|proplist_merge(Options, ?TCP_CONNECT_OPTIONS)]). + parse_address([Format | proplist_merge(Options, ?TCP_CONNECT_OPTIONS)]). -ssl_connect_options([Format|Options]) when Format =:= list; Format =:= binary -> - ssl_connect_options(Options, Format); +ssl_connect_options([Format | Options]) when Format =:= list; Format =:= binary -> + ssl_connect_options(Options, Format); ssl_connect_options(Options) -> - ssl_connect_options(Options, list). + ssl_connect_options(Options, list). ssl_connect_options(Options, Format) -> - parse_address([Format|proplist_merge(Options, ?SSL_CONNECT_OPTIONS)]). + parse_address([Format | proplist_merge(Options, ?SSL_CONNECT_OPTIONS)]). proplist_merge(PrimaryList, DefaultList) -> - {PrimaryTuples, PrimaryOther} = lists:partition(fun(X) -> is_tuple(X) end, PrimaryList), - {DefaultTuples, DefaultOther} = lists:partition(fun(X) -> is_tuple(X) end, DefaultList), - MergedTuples = lists:ukeymerge(1, - lists:keysort(1, PrimaryTuples), - lists:keysort(1, DefaultTuples) - ), - MergedOther = lists:umerge(lists:sort(PrimaryOther), lists:sort(DefaultOther)), - MergedTuples ++ MergedOther. + {PrimaryTuples, PrimaryOther} = lists:partition(fun(X) -> is_tuple(X) end, PrimaryList), + {DefaultTuples, DefaultOther} = lists:partition(fun(X) -> is_tuple(X) end, DefaultList), + MergedTuples = lists:ukeymerge( + 1, + lists:keysort(1, PrimaryTuples), + lists:keysort(1, DefaultTuples) + ), + MergedOther = lists:umerge(lists:sort(PrimaryOther), lists:sort(DefaultOther)), + MergedTuples ++ MergedOther. parse_address(Options) -> - case proplists:get_value(ip, Options) of - X when is_tuple(X) -> - Options; - X when is_list(X) -> - case inet_parse:address(X) of - {error, _} = Error -> - erlang:error(Error); - {ok, IP} -> - proplists:delete(ip, Options) ++ [{ip, IP}] - end; - _ -> - Options - end. + case proplists:get_value(ip, Options) of + X when is_tuple(X) -> + Options; + X when is_list(X) -> + case inet_parse:address(X) of + {error, _} = Error -> + erlang:error(Error); + {ok, IP} -> + proplists:delete(ip, Options) ++ [{ip, IP}] + end; + _ -> + Options + end. -spec extract_port_from_socket(Socket :: socket()) -> port(). -extract_port_from_socket({sslsocket,_,{SSLPort,_}}) -> - SSLPort; +extract_port_from_socket({sslsocket, _, {SSLPort, _}}) -> + SSLPort; extract_port_from_socket(Socket) -> - Socket. + Socket. -spec set_sockopt(ListSock :: port(), CliSocket :: port()) -> 'ok' | any(). set_sockopt(ListenObject, ClientSocket) -> - ListenSocket = extract_port_from_socket(ListenObject), - true = inet_db:register_socket(ClientSocket, inet_tcp), - case prim_inet:getopts(ListenSocket, [active, nodelay, keepalive, delay_send, priority, tos]) of - {ok, Opts} -> - case prim_inet:setopts(ClientSocket, Opts) of - ok -> ok; - Error -> smtp_socket:close(ClientSocket), Error - end; - Error -> smtp_socket:close(ClientSocket), Error - end. + ListenSocket = extract_port_from_socket(ListenObject), + true = inet_db:register_socket(ClientSocket, inet_tcp), + case prim_inet:getopts(ListenSocket, [active, nodelay, keepalive, delay_send, priority, tos]) of + {ok, Opts} -> + case prim_inet:setopts(ClientSocket, Opts) of + ok -> + ok; + Error -> + smtp_socket:close(ClientSocket), + Error + end; + Error -> + smtp_socket:close(ClientSocket), + Error + end. -ifdef(TEST). -define(TEST_PORT, 7586). connect_test_() -> - [ - {"listen and connect via tcp", - fun() -> - Self = self(), - Port = ?TEST_PORT + 1, - Ref = make_ref(), - spawn(fun() -> - {ok, ListenSocket} = listen(tcp, Port), - ?assert(is_port(ListenSocket)), - Self ! {Ref, listen}, - {ok, ServerSocket} = accept(ListenSocket), - controlling_process(ServerSocket, Self), - Self ! {Ref, ListenSocket} - end), - receive {Ref, listen} -> ok end, - {ok, ClientSocket} = connect(tcp, "localhost", Port), - receive {Ref, ListenSocket} when is_port(ListenSocket) -> ok end, - ?assert(is_port(ClientSocket)), - close(ListenSocket) - end - }, - {"listen and connect via ssl", - fun() -> - Self = self(), - Port = ?TEST_PORT + 2, - Ref = make_ref(), - application:ensure_all_started(gen_smtp), - spawn(fun() -> - {ok, ListenSocket} = listen(ssl, Port, [{keyfile, "test/fixtures/mx1.example.com-server.key"}, - {certfile, "test/fixtures/mx1.example.com-server.crt"}]), - ?assertMatch([sslsocket|_], tuple_to_list(ListenSocket)), - Self ! {Ref, listen}, - {ok, ServerSocket} = accept(ListenSocket), - controlling_process(ServerSocket, Self), - Self ! {Ref, ListenSocket} - end), - receive {Ref, listen} -> ok end, - {ok, ClientSocket} = connect(ssl, "localhost", Port, []), - receive {Ref, {sslsocket,_,_} = ListenSocket} -> ok end, - ?assertMatch([sslsocket|_], tuple_to_list(ClientSocket)), - close(ListenSocket) - end - } - ]. + [ + {"listen and connect via tcp", fun() -> + Self = self(), + Port = ?TEST_PORT + 1, + Ref = make_ref(), + spawn(fun() -> + {ok, ListenSocket} = listen(tcp, Port), + ?assert(is_port(ListenSocket)), + Self ! {Ref, listen}, + {ok, ServerSocket} = accept(ListenSocket), + controlling_process(ServerSocket, Self), + Self ! {Ref, ListenSocket} + end), + receive + {Ref, listen} -> ok + end, + {ok, ClientSocket} = connect(tcp, "localhost", Port), + receive + {Ref, ListenSocket} when is_port(ListenSocket) -> ok + end, + ?assert(is_port(ClientSocket)), + close(ListenSocket) + end}, + {"listen and connect via ssl", fun() -> + Self = self(), + Port = ?TEST_PORT + 2, + Ref = make_ref(), + application:ensure_all_started(gen_smtp), + spawn(fun() -> + {ok, ListenSocket} = listen(ssl, Port, [ + {keyfile, "test/fixtures/mx1.example.com-server.key"}, + {certfile, "test/fixtures/mx1.example.com-server.crt"} + ]), + ?assertMatch([sslsocket | _], tuple_to_list(ListenSocket)), + Self ! {Ref, listen}, + {ok, ServerSocket} = accept(ListenSocket), + controlling_process(ServerSocket, Self), + Self ! {Ref, ListenSocket} + end), + receive + {Ref, listen} -> ok + end, + {ok, ClientSocket} = connect(ssl, "localhost", Port, []), + receive + {Ref, {sslsocket, _, _} = ListenSocket} -> ok + end, + ?assertMatch([sslsocket | _], tuple_to_list(ClientSocket)), + close(ListenSocket) + end} + ]. evented_connections_test_() -> - [ - {"current process receives connection to TCP listen sockets", - fun() -> - Port = ?TEST_PORT + 3, - {ok, ListenSocket} = listen(tcp, Port), - begin_inet_async(ListenSocket), - spawn(fun()-> connect(tcp, "localhost", Port) end), - receive - {inet_async, ListenSocket, _, {ok,ServerSocket}} -> ok - end, - {ok, NewServerSocket} = handle_inet_async(ListenSocket, ServerSocket), - ?assert(is_port(ServerSocket)), - ?assertEqual(ServerSocket, NewServerSocket), %% only true for TCP - ?assert(is_port(ListenSocket)), - % Stop the async - spawn(fun()-> connect(tcp, "localhost", Port) end), - receive _Ignored -> ok end, - close(NewServerSocket), - close(ListenSocket) - end - }, - {"current process receives connection to SSL listen sockets", - fun() -> - Port = ?TEST_PORT + 4, - application:ensure_all_started(gen_smtp), - {ok, ListenSocket} = listen(ssl, Port, [{keyfile, "test/fixtures/mx1.example.com-server.key"}, - {certfile, "test/fixtures/mx1.example.com-server.crt"}]), - begin_inet_async(ListenSocket), - spawn(fun()-> connect(ssl, "localhost", Port) end), - receive - {inet_async, _ListenPort, _, {ok,ServerSocket}} -> ok - end, - {ok, NewServerSocket} = handle_inet_async(ListenSocket, ServerSocket, [{keyfile, "test/fixtures/mx1.example.com-server.key"}, - {certfile, "test/fixtures/mx1.example.com-server.crt"}]), - ?assert(is_port(ServerSocket)), - ?assertMatch([sslsocket|_], tuple_to_list(NewServerSocket)), - ?assertMatch([sslsocket|_], tuple_to_list(ListenSocket)), - %Stop the async - spawn(fun()-> connect(ssl, "localhost", Port) end), - receive _Ignored -> ok end, - close(ListenSocket), - close(NewServerSocket), - ok - end - }, - %% TODO: figure out if the following passes because - %% of an incomplete test case or if this really is - %% a magical feature where a single listener - %% can respond to either ssl or tcp connections. - {"current TCP listener receives SSL connection", - fun() -> - Port = ?TEST_PORT + 5, - application:ensure_all_started(gen_smtp), - {ok, ListenSocket} = listen(tcp, Port), - begin_inet_async(ListenSocket), - spawn(fun()-> connect(ssl, "localhost", Port) end), - ServerSocket = receive - {inet_async, _ListenPort, _, {ok,ServerSocket0}} -> ServerSocket0 - end, - ?assertMatch({ok, ServerSocket}, handle_inet_async(ListenSocket, ServerSocket)), - ?assert(is_port(ListenSocket)), - ?assert(is_port(ServerSocket)), - {ok, NewServerSocket} = to_ssl_server(ServerSocket, [{certfile, "test/fixtures/mx1.example.com-server.crt"}, - {keyfile, "test/fixtures/mx1.example.com-server.key"}]), - ?assertMatch([sslsocket|_], tuple_to_list(NewServerSocket)), - % Stop the async - spawn(fun()-> connect(ssl, "localhost", Port) end), - receive _Ignored -> ok end, - close(ListenSocket), - close(NewServerSocket) - end - } - ]. + [ + {"current process receives connection to TCP listen sockets", fun() -> + Port = ?TEST_PORT + 3, + {ok, ListenSocket} = listen(tcp, Port), + begin_inet_async(ListenSocket), + spawn(fun() -> connect(tcp, "localhost", Port) end), + receive + {inet_async, ListenSocket, _, {ok, ServerSocket}} -> ok + end, + {ok, NewServerSocket} = handle_inet_async(ListenSocket, ServerSocket), + ?assert(is_port(ServerSocket)), + %% only true for TCP + ?assertEqual(ServerSocket, NewServerSocket), + ?assert(is_port(ListenSocket)), + % Stop the async + spawn(fun() -> connect(tcp, "localhost", Port) end), + receive + _Ignored -> ok + end, + close(NewServerSocket), + close(ListenSocket) + end}, + {"current process receives connection to SSL listen sockets", fun() -> + Port = ?TEST_PORT + 4, + application:ensure_all_started(gen_smtp), + {ok, ListenSocket} = listen(ssl, Port, [ + {keyfile, "test/fixtures/mx1.example.com-server.key"}, + {certfile, "test/fixtures/mx1.example.com-server.crt"} + ]), + begin_inet_async(ListenSocket), + spawn(fun() -> connect(ssl, "localhost", Port) end), + receive + {inet_async, _ListenPort, _, {ok, ServerSocket}} -> ok + end, + {ok, NewServerSocket} = handle_inet_async(ListenSocket, ServerSocket, [ + {keyfile, "test/fixtures/mx1.example.com-server.key"}, + {certfile, "test/fixtures/mx1.example.com-server.crt"} + ]), + ?assert(is_port(ServerSocket)), + ?assertMatch([sslsocket | _], tuple_to_list(NewServerSocket)), + ?assertMatch([sslsocket | _], tuple_to_list(ListenSocket)), + %Stop the async + spawn(fun() -> connect(ssl, "localhost", Port) end), + receive + _Ignored -> ok + end, + close(ListenSocket), + close(NewServerSocket), + ok + end}, + %% TODO: figure out if the following passes because + %% of an incomplete test case or if this really is + %% a magical feature where a single listener + %% can respond to either ssl or tcp connections. + {"current TCP listener receives SSL connection", fun() -> + Port = ?TEST_PORT + 5, + application:ensure_all_started(gen_smtp), + {ok, ListenSocket} = listen(tcp, Port), + begin_inet_async(ListenSocket), + spawn(fun() -> connect(ssl, "localhost", Port) end), + ServerSocket = + receive + {inet_async, _ListenPort, _, {ok, ServerSocket0}} -> ServerSocket0 + end, + ?assertMatch({ok, ServerSocket}, handle_inet_async(ListenSocket, ServerSocket)), + ?assert(is_port(ListenSocket)), + ?assert(is_port(ServerSocket)), + {ok, NewServerSocket} = to_ssl_server(ServerSocket, [ + {certfile, "test/fixtures/mx1.example.com-server.crt"}, + {keyfile, "test/fixtures/mx1.example.com-server.key"} + ]), + ?assertMatch([sslsocket | _], tuple_to_list(NewServerSocket)), + % Stop the async + spawn(fun() -> connect(ssl, "localhost", Port) end), + receive + _Ignored -> ok + end, + close(ListenSocket), + close(NewServerSocket) + end} + ]. accept_test_() -> - [ - {"Accept via tcp", - fun() -> - Port = ?TEST_PORT + 6, - {ok, ListenSocket} = listen(tcp, Port, tcp_listen_options([])), - ?assert(is_port(ListenSocket)), - spawn(fun()-> connect(ssl, "localhost", Port, tcp_connect_options([])) end), - {ok, ServerSocket} = accept(ListenSocket), - ?assert(is_port(ListenSocket)), - close(ServerSocket), - close(ListenSocket) - end - }, - {"Accept via ssl", - fun() -> - Port = ?TEST_PORT + 7, - application:ensure_all_started(gen_smtp), - {ok, ListenSocket} = listen(ssl, Port, [{keyfile, "test/fixtures/mx1.example.com-server.key"}, - {certfile, "test/fixtures/mx1.example.com-server.crt"}]), - ?assertMatch([sslsocket|_], tuple_to_list(ListenSocket)), - spawn(fun()->connect(ssl, "localhost", Port) end), - accept(ListenSocket), - close(ListenSocket) - end - } - ]. + [ + {"Accept via tcp", fun() -> + Port = ?TEST_PORT + 6, + {ok, ListenSocket} = listen(tcp, Port, tcp_listen_options([])), + ?assert(is_port(ListenSocket)), + spawn(fun() -> connect(ssl, "localhost", Port, tcp_connect_options([])) end), + {ok, ServerSocket} = accept(ListenSocket), + ?assert(is_port(ListenSocket)), + close(ServerSocket), + close(ListenSocket) + end}, + {"Accept via ssl", fun() -> + Port = ?TEST_PORT + 7, + application:ensure_all_started(gen_smtp), + {ok, ListenSocket} = listen(ssl, Port, [ + {keyfile, "test/fixtures/mx1.example.com-server.key"}, + {certfile, "test/fixtures/mx1.example.com-server.crt"} + ]), + ?assertMatch([sslsocket | _], tuple_to_list(ListenSocket)), + spawn(fun() -> connect(ssl, "localhost", Port) end), + accept(ListenSocket), + close(ListenSocket) + end} + ]. type_test_() -> - [ - {"a tcp socket returns 'tcp'", - fun() -> - {ok, ListenSocket} = listen(tcp, ?TEST_PORT + 8), - ?assertMatch(tcp, type(ListenSocket)), - close(ListenSocket) - end - }, - {"an ssl socket returns 'ssl'", - fun() -> - application:ensure_all_started(gen_smtp), - {ok, ListenSocket} = listen(ssl, ?TEST_PORT + 9, [{keyfile, "test/fixtures/mx1.example.com-server.key"}, - {certfile, "test/fixtures/mx1.example.com-server.crt"}]), - ?assertMatch(ssl, type(ListenSocket)), - close(ListenSocket) - end - } - ]. + [ + {"a tcp socket returns 'tcp'", fun() -> + {ok, ListenSocket} = listen(tcp, ?TEST_PORT + 8), + ?assertMatch(tcp, type(ListenSocket)), + close(ListenSocket) + end}, + {"an ssl socket returns 'ssl'", fun() -> + application:ensure_all_started(gen_smtp), + {ok, ListenSocket} = listen(ssl, ?TEST_PORT + 9, [ + {keyfile, "test/fixtures/mx1.example.com-server.key"}, + {certfile, "test/fixtures/mx1.example.com-server.crt"} + ]), + ?assertMatch(ssl, type(ListenSocket)), + close(ListenSocket) + end} + ]. active_once_test_() -> - [ - {"socket is set to active:once on tcp", - fun() -> - {ok, ListenSocket} = listen(tcp, ?TEST_PORT + 10, tcp_listen_options([])), - ?assertEqual({ok, [{active,false}]}, inet:getopts(ListenSocket, [active])), - active_once(ListenSocket), - ?assertEqual({ok, [{active,once}]}, inet:getopts(ListenSocket, [active])), - close(ListenSocket) - end - }, - {"socket is set to active:once on ssl", - fun() -> - {ok, ListenSocket} = listen(ssl, ?TEST_PORT + 11, ssl_listen_options([{keyfile, "test/fixtures/mx1.example.com-server.key"}, - {certfile, "test/fixtures/mx1.example.com-server.crt"}])), - ?assertEqual({ok, [{active,false}]}, ssl:getopts(ListenSocket, [active])), - active_once(ListenSocket), - ?assertEqual({ok, [{active,once}]}, ssl:getopts(ListenSocket, [active])), - close(ListenSocket) - end - } - ]. + [ + {"socket is set to active:once on tcp", fun() -> + {ok, ListenSocket} = listen(tcp, ?TEST_PORT + 10, tcp_listen_options([])), + ?assertEqual({ok, [{active, false}]}, inet:getopts(ListenSocket, [active])), + active_once(ListenSocket), + ?assertEqual({ok, [{active, once}]}, inet:getopts(ListenSocket, [active])), + close(ListenSocket) + end}, + {"socket is set to active:once on ssl", fun() -> + {ok, ListenSocket} = listen( + ssl, + ?TEST_PORT + 11, + ssl_listen_options([ + {keyfile, "test/fixtures/mx1.example.com-server.key"}, + {certfile, "test/fixtures/mx1.example.com-server.crt"} + ]) + ), + ?assertEqual({ok, [{active, false}]}, ssl:getopts(ListenSocket, [active])), + active_once(ListenSocket), + ?assertEqual({ok, [{active, once}]}, ssl:getopts(ListenSocket, [active])), + close(ListenSocket) + end} + ]. option_test_() -> - [ - {"tcp_listen_options has defaults", - fun() -> - ?assertEqual(lists:sort([list|?TCP_LISTEN_OPTIONS]), lists:sort(tcp_listen_options([]))) - end - }, - {"tcp_connect_options has defaults", - fun() -> - ?assertEqual(lists:sort([list|?TCP_CONNECT_OPTIONS]), lists:sort(tcp_connect_options([]))) - end - }, - {"ssl_listen_options has defaults", - fun() -> - ?assertEqual(lists:sort([list|?SSL_LISTEN_OPTIONS]), lists:sort(ssl_listen_options([]))) - end - }, - {"ssl_connect_options has defaults", - fun() -> - ?assertEqual(lists:sort([list|?SSL_CONNECT_OPTIONS]), lists:sort(ssl_connect_options([]))) - end - }, - {"tcp_listen_options defaults to list type", - fun() -> - ?assertEqual(lists:sort([list|?TCP_LISTEN_OPTIONS]), lists:sort(tcp_listen_options([{active,false}]))), - ?assertEqual(lists:sort([binary|?TCP_LISTEN_OPTIONS]), lists:sort(tcp_listen_options([binary,{active,false}]))) - end - }, - {"tcp_connect_options defaults to list type", - fun() -> - ?assertEqual(lists:sort([list|?TCP_CONNECT_OPTIONS]), lists:sort(tcp_connect_options([{active,false}]))), - ?assertEqual(lists:sort([binary|?TCP_CONNECT_OPTIONS]), lists:sort(tcp_connect_options([binary,{active,false}]))) - end - }, - {"ssl_listen_options defaults to list type", - fun() -> - ?assertEqual(lists:sort([list|?SSL_LISTEN_OPTIONS]), lists:sort(ssl_listen_options([{active,false}]))), - ?assertEqual(lists:sort([binary|?SSL_LISTEN_OPTIONS]), lists:sort(ssl_listen_options([binary,{active,false}]))) - end - }, - {"ssl_connect_options defaults to list type", - fun() -> - ?assertEqual(lists:sort([list|?SSL_CONNECT_OPTIONS]), lists:sort(ssl_connect_options([{active,false}]))), - ?assertEqual(lists:sort([binary|?SSL_CONNECT_OPTIONS]), lists:sort(ssl_connect_options([binary,{active,false}]))) - end - }, - {"tcp_listen_options merges provided proplist", - fun() -> - ?assertEqual([list|lists:keysort(1, [{active, true}, - {backlog, 30}, - {ip,{0,0,0,0}}, - {keepalive, true}, - {packet, 2}, - {reuseaddr, true}])], - tcp_listen_options([{active, true},{packet,2}])) - end - }, - {"tcp_connect_options merges provided proplist", - fun() -> - ?assertEqual(lists:sort([list,{active, true}, - {packet, 2}, - {ip,{0,0,0,0}}, - {port, 0}]), - lists:sort(tcp_connect_options([{active, true},{packet,2}]))) - end - }, - {"ssl_listen_options merges provided proplist", - fun() -> - ?assertEqual([list|lists:keysort(1, [{active, true}, - {backlog, 30}, - {certfile, "server.crt"}, - {depth, 0}, - {keepalive, true}, - {keyfile, "server.key"}, - {packet, 2}, - {reuse_sessions, false}, - {reuseaddr, true}])], - ssl_listen_options([{active, true},{packet,2}])), - ?assertEqual([list|lists:keysort(1, [{active, false}, - {backlog, 30}, - {certfile, "../server.crt"}, - {depth, 0}, - {keepalive, true}, - {keyfile, "../server.key"}, - {packet, line}, - {reuse_sessions, false}, - {reuseaddr, true}])], - ssl_listen_options([{certfile, "../server.crt"}, {keyfile, "../server.key"}])) - end - }, - {"ssl_connect_options merges provided proplist", - fun() -> - ?assertEqual(lists:sort([list,{active, true}, - {depth, 0}, - {ip, {0,0,0,0}}, - {port, 0}, - {packet, 2}]), - lists:sort(ssl_connect_options([{active, true},{packet,2}]))) - end - } - ]. + [ + {"tcp_listen_options has defaults", fun() -> + ?assertEqual( + lists:sort([list | ?TCP_LISTEN_OPTIONS]), lists:sort(tcp_listen_options([])) + ) + end}, + {"tcp_connect_options has defaults", fun() -> + ?assertEqual( + lists:sort([list | ?TCP_CONNECT_OPTIONS]), lists:sort(tcp_connect_options([])) + ) + end}, + {"ssl_listen_options has defaults", fun() -> + ?assertEqual( + lists:sort([list | ?SSL_LISTEN_OPTIONS]), lists:sort(ssl_listen_options([])) + ) + end}, + {"ssl_connect_options has defaults", fun() -> + ?assertEqual( + lists:sort([list | ?SSL_CONNECT_OPTIONS]), lists:sort(ssl_connect_options([])) + ) + end}, + {"tcp_listen_options defaults to list type", fun() -> + ?assertEqual( + lists:sort([list | ?TCP_LISTEN_OPTIONS]), + lists:sort(tcp_listen_options([{active, false}])) + ), + ?assertEqual( + lists:sort([binary | ?TCP_LISTEN_OPTIONS]), + lists:sort(tcp_listen_options([binary, {active, false}])) + ) + end}, + {"tcp_connect_options defaults to list type", fun() -> + ?assertEqual( + lists:sort([list | ?TCP_CONNECT_OPTIONS]), + lists:sort(tcp_connect_options([{active, false}])) + ), + ?assertEqual( + lists:sort([binary | ?TCP_CONNECT_OPTIONS]), + lists:sort(tcp_connect_options([binary, {active, false}])) + ) + end}, + {"ssl_listen_options defaults to list type", fun() -> + ?assertEqual( + lists:sort([list | ?SSL_LISTEN_OPTIONS]), + lists:sort(ssl_listen_options([{active, false}])) + ), + ?assertEqual( + lists:sort([binary | ?SSL_LISTEN_OPTIONS]), + lists:sort(ssl_listen_options([binary, {active, false}])) + ) + end}, + {"ssl_connect_options defaults to list type", fun() -> + ?assertEqual( + lists:sort([list | ?SSL_CONNECT_OPTIONS]), + lists:sort(ssl_connect_options([{active, false}])) + ), + ?assertEqual( + lists:sort([binary | ?SSL_CONNECT_OPTIONS]), + lists:sort(ssl_connect_options([binary, {active, false}])) + ) + end}, + {"tcp_listen_options merges provided proplist", fun() -> + ?assertEqual( + [ + list + | lists:keysort(1, [ + {active, true}, + {backlog, 30}, + {ip, {0, 0, 0, 0}}, + {keepalive, true}, + {packet, 2}, + {reuseaddr, true} + ]) + ], + tcp_listen_options([{active, true}, {packet, 2}]) + ) + end}, + {"tcp_connect_options merges provided proplist", fun() -> + ?assertEqual( + lists:sort([ + list, + {active, true}, + {packet, 2}, + {ip, {0, 0, 0, 0}}, + {port, 0} + ]), + lists:sort(tcp_connect_options([{active, true}, {packet, 2}])) + ) + end}, + {"ssl_listen_options merges provided proplist", fun() -> + ?assertEqual( + [ + list + | lists:keysort(1, [ + {active, true}, + {backlog, 30}, + {certfile, "server.crt"}, + {depth, 0}, + {keepalive, true}, + {keyfile, "server.key"}, + {packet, 2}, + {reuse_sessions, false}, + {reuseaddr, true} + ]) + ], + ssl_listen_options([{active, true}, {packet, 2}]) + ), + ?assertEqual( + [ + list + | lists:keysort(1, [ + {active, false}, + {backlog, 30}, + {certfile, "../server.crt"}, + {depth, 0}, + {keepalive, true}, + {keyfile, "../server.key"}, + {packet, line}, + {reuse_sessions, false}, + {reuseaddr, true} + ]) + ], + ssl_listen_options([{certfile, "../server.crt"}, {keyfile, "../server.key"}]) + ) + end}, + {"ssl_connect_options merges provided proplist", fun() -> + ?assertEqual( + lists:sort([ + list, + {active, true}, + {depth, 0}, + {ip, {0, 0, 0, 0}}, + {port, 0}, + {packet, 2} + ]), + lists:sort(ssl_connect_options([{active, true}, {packet, 2}])) + ) + end} + ]. ssl_upgrade_test_() -> - [ - {"TCP connection can be upgraded to ssl", - fun() -> - Self = self(), - Port = ?TEST_PORT + 12, - application:ensure_all_started(gen_smtp), - spawn(fun() -> - {ok, ListenSocket} = listen(tcp, Port), - Self ! listening, - {ok, ServerSocket} = accept(ListenSocket), - {ok, NewServerSocket} = smtp_socket:to_ssl_server( - ServerSocket, - [{keyfile, "test/fixtures/mx1.example.com-server.key"}, - {certfile, "test/fixtures/mx1.example.com-server.crt"}]), - Self ! {sock, NewServerSocket} - end), - receive listening -> ok end, - erlang:yield(), - {ok, ClientSocket} = connect(tcp, "localhost", Port), - ?assert(is_port(ClientSocket)), - {ok, NewClientSocket} = to_ssl_client(ClientSocket), - ?assertMatch([sslsocket|_], tuple_to_list(NewClientSocket)), - receive {sock, NewServerSocket} -> ok end, - ?assertMatch({sslsocket, _, _}, NewServerSocket), - close(NewClientSocket), - close(NewServerSocket) - end - }, - {"SSL server connection can't be upgraded again", - fun() -> - Self = self(), - Port = ?TEST_PORT + 13, - application:ensure_all_started(gen_smtp), - spawn(fun() -> - {ok, ListenSocket} = listen(ssl, Port, [{keyfile, "test/fixtures/mx1.example.com-server.key"}, - {certfile, "test/fixtures/mx1.example.com-server.crt"}]), - Self ! listening, - {ok, ServerSocket} = accept(ListenSocket), - ?assertMatch({error, already_ssl}, to_ssl_server(ServerSocket)), - close(ServerSocket) - end), - receive listening -> ok end, - erlang:yield(), - {ok, ClientSocket} = connect(ssl, "localhost", Port), - close(ClientSocket) - end - }, - {"SSL client connection can't be upgraded again", - fun() -> - Self = self(), - Port = ?TEST_PORT + 14, - application:ensure_all_started(gen_smtp), - spawn(fun() -> - {ok, ListenSocket} = listen(ssl, Port, [{keyfile, "test/fixtures/mx1.example.com-server.key"}, - {certfile, "test/fixtures/mx1.example.com-server.crt"}]), - Self ! listening, - {ok, ServerSocket} = accept(ListenSocket), - Self ! {sock, ServerSocket} - end), - receive listening -> ok end, - erlang:yield(), - {ok, ClientSocket} = connect(ssl, "localhost", Port), - receive {sock, ServerSocket} -> ok end, - ?assertMatch({error, already_ssl}, to_ssl_client(ClientSocket)), - close(ClientSocket), - close(ServerSocket) - end - } - ]. + [ + {"TCP connection can be upgraded to ssl", fun() -> + Self = self(), + Port = ?TEST_PORT + 12, + application:ensure_all_started(gen_smtp), + spawn(fun() -> + {ok, ListenSocket} = listen(tcp, Port), + Self ! listening, + {ok, ServerSocket} = accept(ListenSocket), + {ok, NewServerSocket} = smtp_socket:to_ssl_server( + ServerSocket, + [ + {keyfile, "test/fixtures/mx1.example.com-server.key"}, + {certfile, "test/fixtures/mx1.example.com-server.crt"} + ] + ), + Self ! {sock, NewServerSocket} + end), + receive + listening -> ok + end, + erlang:yield(), + {ok, ClientSocket} = connect(tcp, "localhost", Port), + ?assert(is_port(ClientSocket)), + {ok, NewClientSocket} = to_ssl_client(ClientSocket), + ?assertMatch([sslsocket | _], tuple_to_list(NewClientSocket)), + receive + {sock, NewServerSocket} -> ok + end, + ?assertMatch({sslsocket, _, _}, NewServerSocket), + close(NewClientSocket), + close(NewServerSocket) + end}, + {"SSL server connection can't be upgraded again", fun() -> + Self = self(), + Port = ?TEST_PORT + 13, + application:ensure_all_started(gen_smtp), + spawn(fun() -> + {ok, ListenSocket} = listen(ssl, Port, [ + {keyfile, "test/fixtures/mx1.example.com-server.key"}, + {certfile, "test/fixtures/mx1.example.com-server.crt"} + ]), + Self ! listening, + {ok, ServerSocket} = accept(ListenSocket), + ?assertMatch({error, already_ssl}, to_ssl_server(ServerSocket)), + close(ServerSocket) + end), + receive + listening -> ok + end, + erlang:yield(), + {ok, ClientSocket} = connect(ssl, "localhost", Port), + close(ClientSocket) + end}, + {"SSL client connection can't be upgraded again", fun() -> + Self = self(), + Port = ?TEST_PORT + 14, + application:ensure_all_started(gen_smtp), + spawn(fun() -> + {ok, ListenSocket} = listen(ssl, Port, [ + {keyfile, "test/fixtures/mx1.example.com-server.key"}, + {certfile, "test/fixtures/mx1.example.com-server.crt"} + ]), + Self ! listening, + {ok, ServerSocket} = accept(ListenSocket), + Self ! {sock, ServerSocket} + end), + receive + listening -> ok + end, + erlang:yield(), + {ok, ClientSocket} = connect(ssl, "localhost", Port), + receive + {sock, ServerSocket} -> ok + end, + ?assertMatch({error, already_ssl}, to_ssl_client(ClientSocket)), + close(ClientSocket), + close(ServerSocket) + end} + ]. -endif. diff --git a/src/smtp_util.erl b/src/smtp_util.erl index d6e27ada..bc7d2c63 100644 --- a/src/smtp_util.erl +++ b/src/smtp_util.erl @@ -24,120 +24,163 @@ -module(smtp_util). -export([ - mxlookup/1, guess_FQDN/0, compute_cram_digest/2, get_cram_string/1, - trim_crlf/1, rfc5322_timestamp/0, zone/0, generate_message_id/0, - parse_rfc822_addresses/1, - parse_rfc5322_addresses/1, - combine_rfc822_addresses/1, - generate_message_boundary/0]). + mxlookup/1, + guess_FQDN/0, + compute_cram_digest/2, + get_cram_string/1, + trim_crlf/1, + rfc5322_timestamp/0, + zone/0, + generate_message_id/0, + parse_rfc822_addresses/1, + parse_rfc5322_addresses/1, + combine_rfc822_addresses/1, + generate_message_boundary/0 +]). -include_lib("kernel/include/inet.hrl"). -type name_address() :: {Name :: string() | undefined, Address :: string()}. --deprecated([{parse_rfc822_addresses, 1}]). % Use parse_rfc5322_addresses/1 instead +% Use parse_rfc5322_addresses/1 instead +-deprecated([{parse_rfc822_addresses, 1}]). %% @doc returns a sorted list of mx servers for `Domain', lowest distance first mxlookup(Domain) -> - case whereis(inet_db) of - P when is_pid(P) -> - ok; - _ -> - inet_db:start() - end, - case lists:keyfind(nameserver, 1, inet_db:get_rc()) of - false -> - % we got no nameservers configured, suck in resolv.conf - inet_config:do_load_resolv(os:type(), longnames); - _ -> - ok - end, - case inet_res:lookup(Domain, in, mx) of - [] -> - lists:map(fun(X) -> {10, inet_parse:ntoa(X)} end, inet_res:lookup(Domain, in, a)); - Result -> - lists:sort(Result) - end. + case whereis(inet_db) of + P when is_pid(P) -> + ok; + _ -> + inet_db:start() + end, + case lists:keyfind(nameserver, 1, inet_db:get_rc()) of + false -> + % we got no nameservers configured, suck in resolv.conf + inet_config:do_load_resolv(os:type(), longnames); + _ -> + ok + end, + case inet_res:lookup(Domain, in, mx) of + [] -> + lists:map(fun(X) -> {10, inet_parse:ntoa(X)} end, inet_res:lookup(Domain, in, a)); + Result -> + lists:sort(Result) + end. %% @doc guess the current host's fully qualified domain name, on error return "localhost" -spec guess_FQDN() -> string(). guess_FQDN() -> - {ok, Hostname} = inet:gethostname(), + {ok, Hostname} = inet:gethostname(), guess_FQDN_1(Hostname, inet:gethostbyname(Hostname)). -guess_FQDN_1(_Hostname, {ok, #hostent{ h_name = FQDN }}) -> - FQDN; +guess_FQDN_1(_Hostname, {ok, #hostent{h_name = FQDN}}) -> + FQDN; guess_FQDN_1(Hostname, {error, nxdomain = Error}) -> - error_logger:info_msg("~p could not get FQDN for ~p (error ~p), using \"localhost\" instead.", - [?MODULE, Error, Hostname]), + error_logger:info_msg( + "~p could not get FQDN for ~p (error ~p), using \"localhost\" instead.", + [?MODULE, Error, Hostname] + ), "localhost". %% @doc Compute the CRAM digest of `Key' and `Data' -spec compute_cram_digest(Key :: binary(), Data :: binary()) -> binary(). compute_cram_digest(Key, Data) -> - Bin = hmac_md5(Key, Data), - list_to_binary([io_lib:format("~2.16.0b", [X]) || <> <= Bin]). + Bin = hmac_md5(Key, Data), + list_to_binary([io_lib:format("~2.16.0b", [X]) || <> <= Bin]). -ifdef(OTP_RELEASE). - -if(?OTP_RELEASE >= 23). - -define(CRYPTO_MAC, true). - -endif. +-if(?OTP_RELEASE >= 23). +-define(CRYPTO_MAC, true). +-endif. -endif. -ifdef(CRYPTO_MAC). hmac_md5(Key, Data) -> - crypto:mac(hmac, md5, Key, Data). + crypto:mac(hmac, md5, Key, Data). -else. hmac_md5(Key, Data) -> - crypto:hmac(md5, Key, Data). + crypto:hmac(md5, Key, Data). -endif. %% @doc Generate a seed string for CRAM. -spec get_cram_string(Hostname :: string()) -> string(). get_cram_string(Hostname) -> - binary_to_list(base64:encode(lists:flatten(io_lib:format("<~B.~B@~s>", [rand:uniform(4294967295), rand:uniform(4294967295), Hostname])))). + binary_to_list( + base64:encode( + lists:flatten( + io_lib:format("<~B.~B@~s>", [ + rand:uniform(4294967295), rand:uniform(4294967295), Hostname + ]) + ) + ) + ). %% @doc Trim \r\n from `String' -spec trim_crlf(String :: string()) -> string(). trim_crlf(String) -> - string:strip(string:strip(String, right, $\n), right, $\r). + string:strip(string:strip(String, right, $\n), right, $\r). -define(DAYS, ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]). --define(MONTHS, ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]). +-define(MONTHS, [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" +]). %% @doc Generate a RFC 5322 timestamp based on the current time rfc5322_timestamp() -> - {{Year, Month, Day}, {Hour, Minute, Second}} = calendar:local_time(), - NDay = calendar:day_of_the_week(Year, Month, Day), - DoW = lists:nth(NDay, ?DAYS), - MoY = lists:nth(Month, ?MONTHS), - io_lib:format("~s, ~b ~s ~b ~2..0b:~2..0b:~2..0b ~s", [DoW, Day, MoY, Year, Hour, Minute, Second, zone()]). + {{Year, Month, Day}, {Hour, Minute, Second}} = calendar:local_time(), + NDay = calendar:day_of_the_week(Year, Month, Day), + DoW = lists:nth(NDay, ?DAYS), + MoY = lists:nth(Month, ?MONTHS), + io_lib:format("~s, ~b ~s ~b ~2..0b:~2..0b:~2..0b ~s", [ + DoW, Day, MoY, Year, Hour, Minute, Second, zone() + ]). %% @doc Calculate the current timezone and format it like -0400. Borrowed from YAWS. zone() -> - Time = erlang:universaltime(), - LocalTime = calendar:universal_time_to_local_time(Time), - DiffSecs = calendar:datetime_to_gregorian_seconds(LocalTime) - - calendar:datetime_to_gregorian_seconds(Time), - zone((DiffSecs/3600)*100). + Time = erlang:universaltime(), + LocalTime = calendar:universal_time_to_local_time(Time), + DiffSecs = + calendar:datetime_to_gregorian_seconds(LocalTime) - + calendar:datetime_to_gregorian_seconds(Time), + zone((DiffSecs / 3600) * 100). %% Ugly reformatting code to get times like +0000 and -1300 zone(Val) when Val < 0 -> - io_lib:format("-~4..0w", [trunc(abs(Val))]); + io_lib:format("-~4..0w", [trunc(abs(Val))]); zone(Val) when Val >= 0 -> - io_lib:format("+~4..0w", [trunc(abs(Val))]). + io_lib:format("+~4..0w", [trunc(abs(Val))]). %% @doc Generate a unique message ID generate_message_id() -> - FQDN = guess_FQDN(), - Md5 = [io_lib:format("~2.16.0b", [X]) || <> <= erlang:md5(term_to_binary([unique_id(), FQDN]))], - io_lib:format("<~s@~s>", [Md5, FQDN]). + FQDN = guess_FQDN(), + Md5 = [ + io_lib:format("~2.16.0b", [X]) + || <> <= erlang:md5(term_to_binary([unique_id(), FQDN])) + ], + io_lib:format("<~s@~s>", [Md5, FQDN]). %% @doc Generate a unique MIME message boundary generate_message_boundary() -> - FQDN = guess_FQDN(), - ["_=", [io_lib:format("~2.36.0b", [X]) || <> <= erlang:md5(term_to_binary([unique_id(), FQDN]))], "=_"]. + FQDN = guess_FQDN(), + [ + "_=", + [ + io_lib:format("~2.36.0b", [X]) + || <> <= erlang:md5(term_to_binary([unique_id(), FQDN])) + ], + "=_" + ]. unique_id() -> {erlang:system_time(), erlang:unique_integer()}. @@ -145,33 +188,35 @@ unique_id() -> -define(is_whitespace(Ch), (Ch =< 32)). combine_rfc822_addresses([]) -> - <<>>; + <<>>; combine_rfc822_addresses(Addresses) -> - iolist_to_binary(combine_rfc822_addresses(Addresses, [])). + iolist_to_binary(combine_rfc822_addresses(Addresses, [])). combine_rfc822_addresses([], [32, $, | Acc]) -> - lists:reverse(Acc); -combine_rfc822_addresses([{undefined, Email}|Rest], Acc) -> - combine_rfc822_addresses(Rest, [32, $,, Email|Acc]); -combine_rfc822_addresses([{"", Email}|Rest], Acc) -> - combine_rfc822_addresses(Rest, [32, $,, Email|Acc]); -combine_rfc822_addresses([{<<>>, Email}|Rest], Acc) -> - combine_rfc822_addresses(Rest, [32, $,, Email|Acc]); -combine_rfc822_addresses([{Name, Email}|Rest], Acc) -> - Quoted = [ opt_quoted(Name)," <", Email, ">" ], - combine_rfc822_addresses(Rest, [32, $,, Quoted|Acc]). + lists:reverse(Acc); +combine_rfc822_addresses([{undefined, Email} | Rest], Acc) -> + combine_rfc822_addresses(Rest, [32, $,, Email | Acc]); +combine_rfc822_addresses([{"", Email} | Rest], Acc) -> + combine_rfc822_addresses(Rest, [32, $,, Email | Acc]); +combine_rfc822_addresses([{<<>>, Email} | Rest], Acc) -> + combine_rfc822_addresses(Rest, [32, $,, Email | Acc]); +combine_rfc822_addresses([{Name, Email} | Rest], Acc) -> + Quoted = [opt_quoted(Name), " <", Email, ">"], + combine_rfc822_addresses(Rest, [32, $,, Quoted | Acc]). opt_quoted(B) when is_binary(B) -> - opt_quoted(binary_to_list(B)); + opt_quoted(binary_to_list(B)); opt_quoted(S) when is_list(S) -> NoControls = lists:map( fun (C) when C < 32 -> 32; (C) -> C end, - S), + S + ), case lists:any(fun is_special/1, NoControls) of - false -> NoControls; + false -> + NoControls; true -> lists:flatten([ $", @@ -181,8 +226,10 @@ opt_quoted(S) when is_list(S) -> ($\\) -> [$\\, $\\]; (C) -> C end, - NoControls), - $"]) + NoControls + ), + $" + ]) end. % See https://www.w3.org/Protocols/rfc822/3_Lexical.html#z2 @@ -199,75 +246,75 @@ is_special($\") -> true; is_special($.) -> true; is_special($[) -> true; is_special($]) -> true; -is_special($') -> true; % special for some smtp servers +% special for some smtp servers +is_special($') -> true; is_special(_) -> false. %% @doc Parse list of mail addresses in RFC-5322#section-3.4 `mailbox-list' format -spec parse_rfc5322_addresses(string() | binary()) -> {ok, [name_address()]} | {error, any()}. parse_rfc5322_addresses(B) when is_binary(B) -> - parse_rfc5322_addresses(unicode:characters_to_list(B)); + parse_rfc5322_addresses(unicode:characters_to_list(B)); parse_rfc5322_addresses(S) when is_list(S) -> - case smtp_rfc5322_scan:string(S) of - {ok, Tokens, _L} -> - F = fun({Name, {addr, Local, Domain}}) -> - {Name, Local ++ "@" ++ Domain} - end, - case smtp_rfc5322_parse:parse(Tokens) of - {ok, {mailbox_list, AddrList}} -> - {ok, lists:map(F, AddrList)}; - {ok, {group, {_Groupame, AddrList}}} -> - {ok, lists:map(F, AddrList)}; - {error, _} = Err -> - Err - end; - {error, Reason, _L} -> - {error, Reason} - end. + case smtp_rfc5322_scan:string(S) of + {ok, Tokens, _L} -> + F = fun({Name, {addr, Local, Domain}}) -> + {Name, Local ++ "@" ++ Domain} + end, + case smtp_rfc5322_parse:parse(Tokens) of + {ok, {mailbox_list, AddrList}} -> + {ok, lists:map(F, AddrList)}; + {ok, {group, {_Groupame, AddrList}}} -> + {ok, lists:map(F, AddrList)}; + {error, _} = Err -> + Err + end; + {error, Reason, _L} -> + {error, Reason} + end. -spec parse_rfc822_addresses(string() | binary()) -> {ok, [name_address()]} | {error, any()}. parse_rfc822_addresses(B) when is_binary(B) -> - parse_rfc822_addresses(unicode:characters_to_list(B)); - + parse_rfc822_addresses(unicode:characters_to_list(B)); parse_rfc822_addresses(S) when is_list(S) -> - Scanned = lists:reverse([{'$end', 0}|scan_rfc822(S, [])]), - smtp_rfc822_parse:parse(Scanned). + Scanned = lists:reverse([{'$end', 0} | scan_rfc822(S, [])]), + smtp_rfc822_parse:parse(Scanned). scan_rfc822([], Acc) -> - Acc; -scan_rfc822([Ch|R], Acc) when ?is_whitespace(Ch) -> - scan_rfc822(R, Acc); -scan_rfc822([$"|R], Acc) -> - {Token, Rest} = scan_rfc822_scan_endquote(R, [], false), - scan_rfc822(Rest, [{string, 0, Token}|Acc]); -scan_rfc822([$,|Rest], Acc) -> - scan_rfc822(Rest, [{',', 0}|Acc]); -scan_rfc822([$<|Rest], Acc) -> - {Token, R} = scan_rfc822_scan_endpointybracket(Rest), - scan_rfc822(R, [{'>', 0}, {string, 0, Token}, {'<', 0}|Acc]); + Acc; +scan_rfc822([Ch | R], Acc) when ?is_whitespace(Ch) -> + scan_rfc822(R, Acc); +scan_rfc822([$" | R], Acc) -> + {Token, Rest} = scan_rfc822_scan_endquote(R, [], false), + scan_rfc822(Rest, [{string, 0, Token} | Acc]); +scan_rfc822([$, | Rest], Acc) -> + scan_rfc822(Rest, [{',', 0} | Acc]); +scan_rfc822([$< | Rest], Acc) -> + {Token, R} = scan_rfc822_scan_endpointybracket(Rest), + scan_rfc822(R, [{'>', 0}, {string, 0, Token}, {'<', 0} | Acc]); scan_rfc822(String, Acc) -> - %% Capture everything except "SP < > ," - case re:run(String, "^([^\s<>,]+)(.*)", [{capture, all_but_first, list}]) of - {match, [Token, Rest]} -> - scan_rfc822(Rest, [{string, 0, Token}|Acc]); - nomatch -> - [{string, 0, String}|Acc] - end. + %% Capture everything except "SP < > ," + case re:run(String, "^([^\s<>,]+)(.*)", [{capture, all_but_first, list}]) of + {match, [Token, Rest]} -> + scan_rfc822(Rest, [{string, 0, Token} | Acc]); + nomatch -> + [{string, 0, String} | Acc] + end. scan_rfc822_scan_endpointybracket(String) -> - case re:run(String, "(.*?)>(.*)", [{capture, all_but_first, list}]) of - {match, [Token, Rest]} -> - {Token, Rest}; - nomatch -> - {String, []} - end. + case re:run(String, "(.*?)>(.*)", [{capture, all_but_first, list}]) of + {match, [Token, Rest]} -> + {Token, Rest}; + nomatch -> + {String, []} + end. -scan_rfc822_scan_endquote([$\\|R], Acc, InEscape) -> - %% in escape - scan_rfc822_scan_endquote(R, Acc, not(InEscape)); -scan_rfc822_scan_endquote([$"|R], Acc, true) -> - scan_rfc822_scan_endquote(R, [$"|Acc], false); -scan_rfc822_scan_endquote([$"|Rest], Acc, false) -> - %% Done! - {lists:reverse(Acc), Rest}; -scan_rfc822_scan_endquote([Ch|Rest], Acc, _) -> - scan_rfc822_scan_endquote(Rest, [Ch|Acc], false). +scan_rfc822_scan_endquote([$\\ | R], Acc, InEscape) -> + %% in escape + scan_rfc822_scan_endquote(R, Acc, not (InEscape)); +scan_rfc822_scan_endquote([$" | R], Acc, true) -> + scan_rfc822_scan_endquote(R, [$" | Acc], false); +scan_rfc822_scan_endquote([$" | Rest], Acc, false) -> + %% Done! + {lists:reverse(Acc), Rest}; +scan_rfc822_scan_endquote([Ch | Rest], Acc, _) -> + scan_rfc822_scan_endquote(Rest, [Ch | Acc], false). diff --git a/test/gen_smtp_server_test.erl b/test/gen_smtp_server_test.erl index cb731614..53fa760a 100644 --- a/test/gen_smtp_server_test.erl +++ b/test/gen_smtp_server_test.erl @@ -5,11 +5,16 @@ -include_lib("eunit/include/eunit.hrl"). invalid_lmtp_port_test_() -> - {"gen_smtp_server should prevent starting LMTP on port 25 (RFC2023, section 5)", - fun() -> - Options = [{port, 25}, {sessionoptions, [{protocol, lmtp}]}], - [?_assertMatch({error, invalid_lmtp_port}, - gen_smtp_server:start(gen_smtp_server, Options)), - ?_assertError(invalid_lmtp_port, - gen_smtp_server:child_spec("LMTP Server", gen_smtp_server, Options))] - end}. + {"gen_smtp_server should prevent starting LMTP on port 25 (RFC2023, section 5)", fun() -> + Options = [{port, 25}, {sessionoptions, [{protocol, lmtp}]}], + [ + ?_assertMatch( + {error, invalid_lmtp_port}, + gen_smtp_server:start(gen_smtp_server, Options) + ), + ?_assertError( + invalid_lmtp_port, + gen_smtp_server:child_spec("LMTP Server", gen_smtp_server, Options) + ) + ] + end}. diff --git a/test/gen_smtp_util_test.erl b/test/gen_smtp_util_test.erl index d3c56468..2681ba72 100644 --- a/test/gen_smtp_util_test.erl +++ b/test/gen_smtp_util_test.erl @@ -9,102 +9,143 @@ test_test() -> smtp_util:parse_rfc822_addresses("foo bar"). parse_rfc822_addresses_test_() -> - F = fun smtp_util:parse_rfc822_addresses/1, - [{"Empty address list parse_rfc2822_addresses_test", - fun() -> - ?assertEqual({ok, []}, F(<<>>)), - ?assertEqual({ok, []}, F(<<" ">>)), - ?assertEqual({ok, []}, F(<<" \r\n\t ">>)), - ?assertEqual({ok, []}, F(<<" -">>)) - end}, - {"Group parse_rfc2822_addresses_test", - fun() -> - %% XXX: this is incorrect... - ?assertEqual({ok, [{undefined, "undisclosed-recipients:;"}]}, - F(<<"undisclosed-recipients:;">>)) - end}, - {"Multiple with comma parse_rfc2822_addresses_test", - fun() -> - ?assertEqual({ok, [{"Jan", "a,a@a.com"}, {undefined, "b@b.com"}]}, - F(<<"Jan ,b@b.com">>)) - - end}| parse_adresses_t(F)]. + F = fun smtp_util:parse_rfc822_addresses/1, + [ + {"Empty address list parse_rfc2822_addresses_test", fun() -> + ?assertEqual({ok, []}, F(<<>>)), + ?assertEqual({ok, []}, F(<<" ">>)), + ?assertEqual({ok, []}, F(<<" \r\n\t ">>)), + ?assertEqual({ok, []}, F(<<"\n">>)) + end}, + {"Group parse_rfc2822_addresses_test", fun() -> + %% XXX: this is incorrect... + ?assertEqual( + {ok, [{undefined, "undisclosed-recipients:;"}]}, + F(<<"undisclosed-recipients:;">>) + ) + end}, + {"Multiple with comma parse_rfc2822_addresses_test", fun() -> + ?assertEqual( + {ok, [{"Jan", "a,a@a.com"}, {undefined, "b@b.com"}]}, + F(<<"Jan ,b@b.com">>) + ) + end} + | parse_adresses_t(F) + ]. parse_rfc2822_addresses_test_() -> - F = fun smtp_util:parse_rfc5322_addresses/1, - [{"Group parse_rfc822_addresses_test", - fun() -> - %% rfc5322#section-3.4 - %% empty group - ?assertEqual({ok, []}, - F(<<"undisclosed-recipients:;">>)), - %% group with recipient list - ?assertEqual({ok, [{undefined, "a@a.com"}, {undefined, "b@b.com"}]}, - F(<<"friends:a@a.com,b@b.com;">>)) - end} | parse_adresses_t(F)]. + F = fun smtp_util:parse_rfc5322_addresses/1, + [ + {"Group parse_rfc822_addresses_test", fun() -> + %% rfc5322#section-3.4 + %% empty group + ?assertEqual( + {ok, []}, + F(<<"undisclosed-recipients:;">>) + ), + %% group with recipient list + ?assertEqual( + {ok, [{undefined, "a@a.com"}, {undefined, "b@b.com"}]}, + F(<<"friends:a@a.com,b@b.com;">>) + ) + end} + | parse_adresses_t(F) + ]. parse_adresses_t(F) -> - {_, FName} = erlang:fun_info(F, name), - FStr = atom_to_list(FName), + {_, FName} = erlang:fun_info(F, name), + FStr = atom_to_list(FName), [ - {"Single addresses " ++ FStr, - fun() -> - ?assertEqual({ok, [{undefined, "john@doe.com"}]}, - F(<<"john@doe.com">>)), - ?assertEqual({ok, [{"Fræderik Hølljen", "me@example.com"}]}, - F(<<"Fræderik Hølljen "/utf8>>)), - ?assertEqual({ok, [{undefined, "john@doe.com"}]}, - F(<<"">>)), - ?assertEqual({ok, [{"John", "john@doe.com"}]}, - F(<<"John ">>)), - ?assertEqual({ok, [{"John Doe", "john@doe.com"}]}, - F(<<"John Doe ">>)), - ?assertEqual({ok, [{"John Doe", "john@doe.com"}]}, - F(<<"\"John Doe\" ">>)), - ?assertEqual({ok, [{"John \"Mighty\" Doe", "john@doe.com"}]}, - F(<<"\"John \\\"Mighty\\\" Doe\" ">>)) - end}, - {"Multiple addresses " ++ FStr, - fun() -> - ?assertEqual({ok, [{undefined, "a@a.com"}, {undefined, "b@b.com"}]}, - F(<<"a@a.com,b@b.com">>)), - ?assertEqual({ok, [{undefined, "a@a.com"}, {undefined, "b@b.com"}]}, - F(<<",b@b.com">>)), - ?assertEqual({ok, [{"Jan", "a@a.com"}, {undefined, "b@b.com"}]}, - F(<<"Jan ,b@b.com">>)), - ?assertEqual({ok, [{"Jan", "a@a.com"}, {"Berend Botje", "b@b.com"}]}, - F(<<"Jan ,\"Berend Botje\" ">>)) - end} + {"Single addresses " ++ FStr, fun() -> + ?assertEqual( + {ok, [{undefined, "john@doe.com"}]}, + F(<<"john@doe.com">>) + ), + ?assertEqual( + {ok, [{"Fræderik Hølljen", "me@example.com"}]}, + F(<<"Fræderik Hølljen "/utf8>>) + ), + ?assertEqual( + {ok, [{undefined, "john@doe.com"}]}, + F(<<"">>) + ), + ?assertEqual( + {ok, [{"John", "john@doe.com"}]}, + F(<<"John ">>) + ), + ?assertEqual( + {ok, [{"John Doe", "john@doe.com"}]}, + F(<<"John Doe ">>) + ), + ?assertEqual( + {ok, [{"John Doe", "john@doe.com"}]}, + F(<<"\"John Doe\" ">>) + ), + ?assertEqual( + {ok, [{"John \"Mighty\" Doe", "john@doe.com"}]}, + F(<<"\"John \\\"Mighty\\\" Doe\" ">>) + ) + end}, + {"Multiple addresses " ++ FStr, fun() -> + ?assertEqual( + {ok, [{undefined, "a@a.com"}, {undefined, "b@b.com"}]}, + F(<<"a@a.com,b@b.com">>) + ), + ?assertEqual( + {ok, [{undefined, "a@a.com"}, {undefined, "b@b.com"}]}, + F(<<",b@b.com">>) + ), + ?assertEqual( + {ok, [{"Jan", "a@a.com"}, {undefined, "b@b.com"}]}, + F(<<"Jan ,b@b.com">>) + ), + ?assertEqual( + {ok, [{"Jan", "a@a.com"}, {"Berend Botje", "b@b.com"}]}, + F(<<"Jan ,\"Berend Botje\" ">>) + ) + end} ]. combine_rfc822_addresses_test_() -> [ - {"One address", - fun() -> - ?assertEqual(<<"john@doe.com">>, - smtp_util:combine_rfc822_addresses([{undefined, "john@doe.com"}])), - ?assertEqual(<<"John ">>, - smtp_util:combine_rfc822_addresses([{"John", "john@doe.com"}])), - ?assertEqual(<<"\"John \\\"Foo\" ">>, - smtp_util:combine_rfc822_addresses([{"John \"Foo", "john@doe.com"}])) - end}, - {"Multiple addresses", - fun() -> - ?assertEqual(<<"john@doe.com, foo@bar.com">>, - smtp_util:combine_rfc822_addresses([{undefined, "john@doe.com"}, {undefined, "foo@bar.com"}])), - ?assertEqual(<<"John , foo@bar.com">>, - smtp_util:combine_rfc822_addresses([{"John", "john@doe.com"}, {undefined, "foo@bar.com"}])) - end} + {"One address", fun() -> + ?assertEqual( + <<"john@doe.com">>, + smtp_util:combine_rfc822_addresses([{undefined, "john@doe.com"}]) + ), + ?assertEqual( + <<"John ">>, + smtp_util:combine_rfc822_addresses([{"John", "john@doe.com"}]) + ), + ?assertEqual( + <<"\"John \\\"Foo\" ">>, + smtp_util:combine_rfc822_addresses([{"John \"Foo", "john@doe.com"}]) + ) + end}, + {"Multiple addresses", fun() -> + ?assertEqual( + <<"john@doe.com, foo@bar.com">>, + smtp_util:combine_rfc822_addresses([ + {undefined, "john@doe.com"}, {undefined, "foo@bar.com"} + ]) + ), + ?assertEqual( + <<"John , foo@bar.com">>, + smtp_util:combine_rfc822_addresses([ + {"John", "john@doe.com"}, {undefined, "foo@bar.com"} + ]) + ) + end} ]. illegal_rfc822_addresses_test_() -> [ - {"Nested brackets", - fun() -> - ?assertEqual({error,{0,smtp_rfc822_parse, ["syntax error before: ","\">\""]}}, - smtp_util:parse_rfc822_addresses("a>")) - end} + {"Nested brackets", fun() -> + ?assertEqual( + {error, {0, smtp_rfc822_parse, ["syntax error before: ", "\">\""]}}, + smtp_util:parse_rfc822_addresses("a>") + ) + end} ]. rfc822_addresses_roundtrip_test() -> @@ -115,6 +156,8 @@ rfc822_addresses_roundtrip_test() -> rfc2047_utf8_encode_test() -> UnicodeString = unicode:characters_to_binary("€ € € € € 1234 € € € € 123 € € € € € 1234€"), - Encoded = << "=?UTF-8?B?4oKsIOKCrCDigqwg4oKsIOKCrCAxMjM0IOKCrCDigqwg4oKsIOKCrCAxMjMg?=\r\n" - " =?UTF-8?B?4oKsIOKCrCDigqwg4oKsIOKCrCAxMjM04oKs?=">>, + Encoded = << + "=?UTF-8?B?4oKsIOKCrCDigqwg4oKsIOKCrCAxMjM0IOKCrCDigqwg4oKsIOKCrCAxMjMg?=\r\n" + " =?UTF-8?B?4oKsIOKCrCDigqwg4oKsIOKCrCAxMjM04oKs?=" + >>, ?assertEqual(Encoded, mimemail:rfc2047_utf8_encode(UnicodeString)). diff --git a/test/prop_mimemail.erl b/test/prop_mimemail.erl index 88e11b52..ea563ff7 100644 --- a/test/prop_mimemail.erl +++ b/test/prop_mimemail.erl @@ -7,13 +7,13 @@ -module(prop_mimemail). -export([ - prop_plaintext_encode_no_crash/1, - prop_multipart_encode_no_crash/1, - prop_plaintext_encode_decode_match/1, - prop_multipart_encode_decode_match/1, - prop_encode_decode_no_mime_version_match/1, - prop_quoted_printable/1, - prop_smtp_compatible/1 + prop_plaintext_encode_no_crash/1, + prop_multipart_encode_no_crash/1, + prop_plaintext_encode_decode_match/1, + prop_multipart_encode_decode_match/1, + prop_encode_decode_no_mime_version_match/1, + prop_quoted_printable/1, + prop_smtp_compatible/1 ]). -include_lib("proper/include/proper.hrl"). @@ -24,9 +24,9 @@ prop_plaintext_encode_no_crash(doc) -> prop_plaintext_encode_no_crash() -> ?FORALL( - Mail, - gen_plaintext_mail(), - is_binary(mimemail:encode(Mail)) + Mail, + gen_plaintext_mail(), + is_binary(mimemail:encode(Mail)) ). prop_multipart_encode_no_crash(doc) -> @@ -34,189 +34,229 @@ prop_multipart_encode_no_crash(doc) -> prop_multipart_encode_no_crash() -> ?FORALL( - Mail, - gen_multipart_mail(), - is_binary(mimemail:encode(Mail)) + Mail, + gen_multipart_mail(), + is_binary(mimemail:encode(Mail)) ). prop_plaintext_encode_decode_match(doc) -> "Check that any plaintext mail can be encoded and decoded without" - " information loss or corruption". + " information loss or corruption". prop_plaintext_encode_decode_match() -> ?FORALL( - Mail, - gen_plaintext_mail(), - begin - Encoded = mimemail:encode(Mail), - Recoded = mimemail:decode(Encoded), - ?WHENFAIL( - io:format("Orig:~n~p~nEncoded:~n~p~nRecoded:~n~p~n", - [Mail, Encoded, Recoded]), - match(Mail, Recoded)) - end + Mail, + gen_plaintext_mail(), + begin + Encoded = mimemail:encode(Mail), + Recoded = mimemail:decode(Encoded), + ?WHENFAIL( + io:format( + "Orig:~n~p~nEncoded:~n~p~nRecoded:~n~p~n", + [Mail, Encoded, Recoded] + ), + match(Mail, Recoded) + ) + end ). prop_multipart_encode_decode_match(doc) -> "Check that any plaintext mail can be encoded and decoded without" - " information loss or corruption". + " information loss or corruption". prop_multipart_encode_decode_match() -> ?FORALL( - Mail, - gen_multipart_mail(), - begin - Encoded = mimemail:encode(Mail), - Recoded = mimemail:decode(Encoded), - ?WHENFAIL( - io:format("Orig:~n~p~nEncoded:~n~p~nRecoded:~n~p~n", - [Mail, Encoded, Recoded]), - match(Mail, Recoded)) - end + Mail, + gen_multipart_mail(), + begin + Encoded = mimemail:encode(Mail), + Recoded = mimemail:decode(Encoded), + ?WHENFAIL( + io:format( + "Orig:~n~p~nEncoded:~n~p~nRecoded:~n~p~n", + [Mail, Encoded, Recoded] + ), + match(Mail, Recoded) + ) + end ). prop_encode_decode_no_mime_version_match(doc) -> - "Make sure decoder is able to recover from situation when 'mime-version' header is missing". + "Make sure decoder is able to recover from situation when 'mime-version' header is missing". prop_encode_decode_no_mime_version_match() -> ?FORALL( - Mail, - proper_types:oneof([gen_plaintext_mail(), gen_multipart_mail()]), - begin - Encoded = mimemail:encode(Mail), - Recoded = mimemail:decode(strip_mime_version(Encoded), - [{allow_missing_version, true}, - {encoding, <<"utf-8">>}]), - ?WHENFAIL( - io:format("Orig:~n~p~nEncoded:~n~p~nRecoded:~n~p~n", - [Mail, Encoded, Recoded]), - match(Mail, Recoded)) - end + Mail, + proper_types:oneof([gen_plaintext_mail(), gen_multipart_mail()]), + begin + Encoded = mimemail:encode(Mail), + Recoded = mimemail:decode( + strip_mime_version(Encoded), + [ + {allow_missing_version, true}, + {encoding, <<"utf-8">>} + ] + ), + ?WHENFAIL( + io:format( + "Orig:~n~p~nEncoded:~n~p~nRecoded:~n~p~n", + [Mail, Encoded, Recoded] + ), + match(Mail, Recoded) + ) + end ). - -match({TypeA, SubTypeA, HeadersA, ParamsA, BodyA}, - {TypeB, SubTypeB, HeadersB, ParamsB, BodyB}) -> - ?assertEqual(TypeA, TypeB), - ?assertEqual(SubTypeA, SubTypeB), - ?assert(is_map(ParamsA)), - ?assert(is_map(ParamsB)), - maps:fold(fun(transfer_encoding, _, _) -> - []; %never added during decoding - (disposition, _, _) when not is_binary(BodyA); - BodyA =:= <<>> -> - []; - (disposition = K, V, _) when is_binary(BodyA) -> - %% disposition only applied for non-empty bodies - case re:replace(BodyA, "\s+", "", [global, {return, binary}]) of - <<>> -> []; - _ -> - ?assertEqual(V, maps:get(K, ParamsB)) - end; - (K, KVA, _) when K =:= content_type_params; - K =:= disposition_params -> - %% assert all Content-Type/Disposition from original mime do present in - %% recoded mime; keys should be lowercased - KVB = maps:get(K, ParamsB), - lists:foreach(fun({PKA, PVA}) -> - ?assert(lists:member({binstr:to_lower(PKA), PVA}, KVB)) - end, KVA); - (K, V, _) -> - ?assertEqual(V, maps:get(K, ParamsB)) - end, [], ParamsA), - %% XXX: we have to strip values of the body and headers, because it seems some types of - %% encoding do remove some of whitespaces from payload. Not sure if it's ok... - lists:foreach( - fun({K, VA}) -> - VB = proplists:get_value(K, HeadersB), - ?assertEqual(string:trim(VA, both, " "), - string:trim(VB, both, " "), - #{header => K, - b_headers => HeadersB}) - end, HeadersA), - case is_binary(BodyA) of - true -> - ?assertEqual(BodyA, BodyB), - true; - false -> - Bodies = lists:zip(BodyA, BodyB), - lists:all( - fun({SubBodyA, SubBodyB}) -> - match(SubBodyA, SubBodyB) - end, Bodies) - end. +match( + {TypeA, SubTypeA, HeadersA, ParamsA, BodyA}, + {TypeB, SubTypeB, HeadersB, ParamsB, BodyB} +) -> + ?assertEqual(TypeA, TypeB), + ?assertEqual(SubTypeA, SubTypeB), + ?assert(is_map(ParamsA)), + ?assert(is_map(ParamsB)), + maps:fold( + fun + (transfer_encoding, _, _) -> + %never added during decoding + []; + (disposition, _, _) when + not is_binary(BodyA); + BodyA =:= <<>> + -> + []; + (disposition = K, V, _) when is_binary(BodyA) -> + %% disposition only applied for non-empty bodies + case re:replace(BodyA, "\s+", "", [global, {return, binary}]) of + <<>> -> []; + _ -> ?assertEqual(V, maps:get(K, ParamsB)) + end; + (K, KVA, _) when + K =:= content_type_params; + K =:= disposition_params + -> + %% assert all Content-Type/Disposition from original mime do present in + %% recoded mime; keys should be lowercased + KVB = maps:get(K, ParamsB), + lists:foreach( + fun({PKA, PVA}) -> + ?assert(lists:member({binstr:to_lower(PKA), PVA}, KVB)) + end, + KVA + ); + (K, V, _) -> + ?assertEqual(V, maps:get(K, ParamsB)) + end, + [], + ParamsA + ), + %% XXX: we have to strip values of the body and headers, because it seems some types of + %% encoding do remove some of whitespaces from payload. Not sure if it's ok... + lists:foreach( + fun({K, VA}) -> + VB = proplists:get_value(K, HeadersB), + ?assertEqual( + string:trim(VA, both, " "), + string:trim(VB, both, " "), + #{ + header => K, + b_headers => HeadersB + } + ) + end, + HeadersA + ), + case is_binary(BodyA) of + true -> + ?assertEqual(BodyA, BodyB), + true; + false -> + Bodies = lists:zip(BodyA, BodyB), + lists:all( + fun({SubBodyA, SubBodyB}) -> + match(SubBodyA, SubBodyB) + end, + Bodies + ) + end. prop_quoted_printable(doc) -> - "Make sure quoted-printable encoder works as expected: " - "* No lines longer than 76 chars " - "* decode(encode(data)) returns the same result as original input". + "Make sure quoted-printable encoder works as expected: " + "* No lines longer than 76 chars " + "* decode(encode(data)) returns the same result as original input". prop_quoted_printable() -> - ?FORALL( - Body, - proper_types:oneof([?SIZED(Size, printable_ascii(Size * 50)), - ?SIZED(Size, printable_ascii_and_cariage(Size * 50)), - printable_ascii(), - printable_ascii_and_cariage(), - nonull_utf8(), - proper_types:binary()]), - begin - [QPEncoded] = mimemail:encode_quoted_printable(Body), - ?assertEqual(Body, mimemail:decode_quoted_printable(QPEncoded)), - ?assertNot(has_lines_over(QPEncoded, 76), #{encoded => QPEncoded, orig => Body}), - true - end). + ?FORALL( + Body, + proper_types:oneof([ + ?SIZED(Size, printable_ascii(Size * 50)), + ?SIZED(Size, printable_ascii_and_cariage(Size * 50)), + printable_ascii(), + printable_ascii_and_cariage(), + nonull_utf8(), + proper_types:binary() + ]), + begin + [QPEncoded] = mimemail:encode_quoted_printable(Body), + ?assertEqual(Body, mimemail:decode_quoted_printable(QPEncoded)), + ?assertNot(has_lines_over(QPEncoded, 76), #{encoded => QPEncoded, orig => Body}), + true + end + ). prop_smtp_compatible(doc) -> "Makes sure mimemail never produces output that is not compatible with SMTP, " - "See https://tools.ietf.org/html/rfc2045 and https://tools.ietf.org/html/rfc2049:" - "* Should not contain bare '\r' or '\n' (ie, $\r or $\n in any other form than '\r\n' pair). " - "* Should not contain ASCII codes above 127" - "* Should not contain 0 byte" - "* Should not have too long (over 1000 chars) lines". + "See https://tools.ietf.org/html/rfc2045 and https://tools.ietf.org/html/rfc2049:" + "* Should not contain bare '\r' or '\n' (ie, $\r or $\n in any other form than '\r\n' pair). " + "* Should not contain ASCII codes above 127" + "* Should not contain 0 byte" + "* Should not have too long (over 1000 chars) lines". prop_smtp_compatible() -> ?FORALL( - Mail, - proper_types:oneof([gen_multipart_mail(), gen_plaintext_mail()]), - begin - SevenByte = mimemail:encode(Mail), - ?assertNot(has_bare_cr_or_lf(SevenByte), SevenByte), - ?assertNot(has_bytes_above_127(SevenByte), SevenByte), - ?assertNot(has_zero_byte(SevenByte), SevenByte), - ?assertNot(has_lines_over(SevenByte, 1000), SevenByte), - true - end + Mail, + proper_types:oneof([gen_multipart_mail(), gen_plaintext_mail()]), + begin + SevenByte = mimemail:encode(Mail), + ?assertNot(has_bare_cr_or_lf(SevenByte), SevenByte), + ?assertNot(has_bytes_above_127(SevenByte), SevenByte), + ?assertNot(has_zero_byte(SevenByte), SevenByte), + ?assertNot(has_lines_over(SevenByte, 1000), SevenByte), + true + end ). has_bare_cr_or_lf(Mime) -> - WithoutCRLF = binary:replace(Mime, <<"\r\n">>, <<"">>, [global]), - case binary:match(WithoutCRLF, [<<"\r">>, <<"\n">>]) of - nomatch -> false; - {_, _} -> true - end. + WithoutCRLF = binary:replace(Mime, <<"\r\n">>, <<"">>, [global]), + case binary:match(WithoutCRLF, [<<"\r">>, <<"\n">>]) of + nomatch -> false; + {_, _} -> true + end. has_bytes_above_127(<>) when C > 127 -> - true; + true; has_bytes_above_127(<<_, Tail/binary>>) -> - has_bytes_above_127(Tail); + has_bytes_above_127(Tail); has_bytes_above_127(<<>>) -> - false. + false. has_zero_byte(Mime) -> - case binary:match(Mime, <<0>>) of - nomatch -> false; - {match, _} -> true - end. + case binary:match(Mime, <<0>>) of + nomatch -> false; + {match, _} -> true + end. has_lines_over(Mime, Limit) -> - lists:any(fun(Line) -> - byte_size(Line) > Limit - end, binary:split(Mime, <<"\r\n">>, [global])). + lists:any( + fun(Line) -> + byte_size(Line) > Limit + end, + binary:split(Mime, <<"\r\n">>, [global]) + ). strip_mime_version(MimeBin) -> - binary:replace(MimeBin, <<"MIME-Version: 1.0\r\n">>, <<>>). - %% re:replace(MimeBin, "mime-version: 1\\.0\\s*", "", [caseless, {return, binary}]). + binary:replace(MimeBin, <<"MIME-Version: 1.0\r\n">>, <<>>). +%% re:replace(MimeBin, "mime-version: 1\\.0\\s*", "", [caseless, {return, binary}]). %% %% Generators @@ -224,179 +264,209 @@ strip_mime_version(MimeBin) -> %% top-level multipart mimemail() gen_multipart_mail() -> - {<<"multipart">>, - proper_types:oneof([<<"mixed">>, <<"alternative">>]), - gen_top_headers(), - gen_props(outer), - %% Resizing to not create too many sub-bodies, because it's slow - ?SIZED(Size, - proper_types:resize( - max(1, Size div 2), - proper_types:list( - proper_types:oneof( - [gen_embedded_plaintext_mail(), - gen_embedded_html_mail(), - gen_embedded_attachment_mail()])))) - }. + {<<"multipart">>, proper_types:oneof([<<"mixed">>, <<"alternative">>]), gen_top_headers(), gen_props(outer), + %% Resizing to not create too many sub-bodies, because it's slow + ?SIZED( + Size, + proper_types:resize( + max(1, Size div 2), + proper_types:list( + proper_types:oneof( + [ + gen_embedded_plaintext_mail(), + gen_embedded_html_mail(), + gen_embedded_attachment_mail() + ] + ) + ) + ) + )}. %% top-level plaintext mimemail() gen_plaintext_mail() -> - {<<"text">>, <<"plain">>, - gen_top_headers(), - gen_props(outer), - proper_types:oneof([gen_body(), gen_nonempty_body()])}. + {<<"text">>, <<"plain">>, gen_top_headers(), gen_props(outer), + proper_types:oneof([gen_body(), gen_nonempty_body()])}. %% Plaintext mimemail(), that is safe to use inside multipart mails gen_embedded_plaintext_mail() -> - {<<"text">>, <<"plain">>, - gen_headers(), - gen_props(embedded), - gen_nonempty_body()}. + {<<"text">>, <<"plain">>, gen_headers(), gen_props(embedded), gen_nonempty_body()}. %% Pseudo-HTML mimemail(), that is safe to use inside multipart mails gen_embedded_html_mail() -> - {<<"text">>, <<"html">>, - gen_headers(), - #{content_type_params => [{<<"charset">>, <<"utf-8">>}], - disposition => <<"inline">>}, - ?LET(Body, - gen_body(), - <<"

", Body/binary, "

">>)}. + {<<"text">>, <<"html">>, gen_headers(), + #{ + content_type_params => [{<<"charset">>, <<"utf-8">>}], + disposition => <<"inline">> + }, + ?LET( + Body, + gen_body(), + <<"

", Body/binary, "

">> + )}. gen_embedded_attachment_mail() -> - {<<"application">>, <<"pdf">>, - gen_headers(), - gen_attachment_props(), - proper_types:non_empty(proper_types:binary())}. + {<<"application">>, <<"pdf">>, gen_headers(), gen_attachment_props(), + proper_types:non_empty(proper_types:binary())}. %% like gen_headers/0, but `From' is always there gen_top_headers() -> - ?LET(KV, gen_headers(), lists:ukeysort(1, [{<<"From">>, <<"test@example.com">>} | KV])). + ?LET(KV, gen_headers(), lists:ukeysort(1, [{<<"From">>, <<"test@example.com">>} | KV])). %% [{binary(), binary()}] gen_headers() -> - AddrHeaders = [<<"To">>, <<"Cc">>, <<"Bcc">>, <<"Reply-To">>, <<"From">>], - ContentHeaders = [<<"Content-Type">>, <<"Content-Disposition">>, <<"Content-Transfer-Encoding">>], - SpecialHeaders = AddrHeaders ++ ContentHeaders, - ?LET(KV, - proper_types:list( - proper_types:frequency( - [ - {5, - ?SUCHTHAT( - {K, _}, - gen_any_header(), - not lists:member(K, SpecialHeaders)) - }, - {1, - {proper_types:oneof(AddrHeaders), <<"to@example.com">>} - } - ])), - lists:ukeysort(1, KV)). + AddrHeaders = [<<"To">>, <<"Cc">>, <<"Bcc">>, <<"Reply-To">>, <<"From">>], + ContentHeaders = [ + <<"Content-Type">>, <<"Content-Disposition">>, <<"Content-Transfer-Encoding">> + ], + SpecialHeaders = AddrHeaders ++ ContentHeaders, + ?LET( + KV, + proper_types:list( + proper_types:frequency( + [ + {5, + ?SUCHTHAT( + {K, _}, + gen_any_header(), + not lists:member(K, SpecialHeaders) + )}, + {1, {proper_types:oneof(AddrHeaders), <<"to@example.com">>}} + ] + ) + ), + lists:ukeysort(1, KV) + ). %% This can generate invalid header when it requires some specific format gen_any_header() -> - {header_name(), - proper_types:oneof( - [nonull_utf8(), - printable_ascii_and_cariage(), - printable_ascii()])}. + { + header_name(), + proper_types:oneof( + [ + nonull_utf8(), + printable_ascii_and_cariage(), + printable_ascii() + ] + ) + }. %% #{atom() => any()} gen_props(Location) -> - Disposition = case Location of - outer -> []; - embedded -> [{disposition, proper_types:oneof([<<"inline">>, <<"attachment">>])}] - end, - ?LET(KV, - proper_types:list( - proper_types:oneof( - Disposition ++ - [ - {content_type_params, [{<<"charset">>, <<"utf-8">>}]}, - {transfer_encoding, proper_types:oneof([<<"base64">>, <<"quoted-printable">>])} - ] - ) - ), - maps:from_list(KV)). + Disposition = + case Location of + outer -> []; + embedded -> [{disposition, proper_types:oneof([<<"inline">>, <<"attachment">>])}] + end, + ?LET( + KV, + proper_types:list( + proper_types:oneof( + Disposition ++ + [ + {content_type_params, [{<<"charset">>, <<"utf-8">>}]}, + {transfer_encoding, proper_types:oneof([<<"base64">>, <<"quoted-printable">>])} + ] + ) + ), + maps:from_list(KV) + ). gen_attachment_props() -> - ?LET(KV, - proper_types:list( - proper_types:oneof( - [{content_type_params, gen_params()}, - {disposition_params, gen_params()}] - )), - maps:from_list([{disposition, <<"attachment">>}, - {transfer_encoding, <<"base64">>} | KV])). + ?LET( + KV, + proper_types:list( + proper_types:oneof( + [ + {content_type_params, gen_params()}, + {disposition_params, gen_params()} + ] + ) + ), + maps:from_list([ + {disposition, <<"attachment">>}, + {transfer_encoding, <<"base64">>} + | KV + ]) + ). gen_params() -> - proper_types:list( - { - header_name(), - header_name() - }). + proper_types:list( + { + header_name(), + header_name() + } + ). %% binary(), guaranteed to be not `<<>>'. Also, try to generate relatively large body gen_nonempty_body() -> - proper_types:oneof( - [ - proper_types:non_empty(?SIZED(Size, printable_ascii(Size * 30))), - proper_types:non_empty(?SIZED(Size, printable_ascii_and_cariage(Size * 30))), - proper_types:non_empty(nonull_utf8()) - ]). + proper_types:oneof( + [ + proper_types:non_empty(?SIZED(Size, printable_ascii(Size * 30))), + proper_types:non_empty(?SIZED(Size, printable_ascii_and_cariage(Size * 30))), + proper_types:non_empty(nonull_utf8()) + ] + ). %% binary() gen_body() -> - proper_types:oneof( - [ - printable_ascii(), - printable_ascii_and_cariage(), - nonull_utf8() - ]). + proper_types:oneof( + [ + printable_ascii(), + printable_ascii_and_cariage(), + nonull_utf8() + ] + ). %% `[0-9a-zA-Z_-]*' header_name() -> - %% let's limit header names to 20 characters. Too long header names can easily create very long lines - ?LET(OrigHdr, - proper_types:non_empty( - binary_of("-_" ++ - lists:seq($0, $9) ++ - lists:seq($A, $Z) ++ - lists:seq($a, $z))), - case OrigHdr of - <> -> Max20; - _ -> OrigHdr - end). + %% let's limit header names to 20 characters. Too long header names can easily create very long lines + ?LET( + OrigHdr, + proper_types:non_empty( + binary_of( + "-_" ++ + lists:seq($0, $9) ++ + lists:seq($A, $Z) ++ + lists:seq($a, $z) + ) + ), + case OrigHdr of + <> -> Max20; + _ -> OrigHdr + end + ). printable_ascii_and_cariage() -> - ?SIZED(Size, printable_ascii_and_cariage(Size)). + ?SIZED(Size, printable_ascii_and_cariage(Size)). printable_ascii_and_cariage(Size) -> binary_of("\t\r\n" ++ lists:seq(32, 126), Size). printable_ascii() -> - ?SIZED(Size, printable_ascii(Size)). + ?SIZED(Size, printable_ascii(Size)). printable_ascii(Size) -> binary_of(lists:seq(32, 126), Size). binary_of(Bytes) -> - ?SIZED(Size, binary_of(Bytes, Size)). + ?SIZED(Size, binary_of(Bytes, Size)). binary_of(Bytes, Size) -> - ?LET(List, - proper_types:resize(Size, proper_types:list(proper_types:oneof(Bytes))), - list_to_binary(List)). + ?LET( + List, + proper_types:resize(Size, proper_types:list(proper_types:oneof(Bytes))), + list_to_binary(List) + ). %% any utf-8, except 0 nonull_utf8() -> - ?SUCHTHAT( - Chars, - proper_unicode:utf8(), - case Chars of - <<>> -> - true; - _ -> - binary:match(Chars, <<0>>) =:= nomatch - end). + ?SUCHTHAT( + Chars, + proper_unicode:utf8(), + case Chars of + <<>> -> + true; + _ -> + binary:match(Chars, <<0>>) =:= nomatch + end + ). diff --git a/test/prop_rfc5322.erl b/test/prop_rfc5322.erl index 440a296a..f2cc68ae 100644 --- a/test/prop_rfc5322.erl +++ b/test/prop_rfc5322.erl @@ -9,10 +9,10 @@ -module(prop_rfc5322). -export([ - prop_encode_no_crash/1, - prop_encode_scan_no_crash/1, - prop_encode_decode_match/1, - prop_encode_decode_group/1 + prop_encode_no_crash/1, + prop_encode_scan_no_crash/1, + prop_encode_decode_match/1, + prop_encode_decode_group/1 ]). -include_lib("proper/include/proper.hrl"). @@ -23,9 +23,9 @@ prop_encode_no_crash(doc) -> prop_encode_no_crash() -> ?FORALL( - AddressList, - ?LET(Opts, use_unicode(), gen_address_list(Opts)), - is_binary(smtp_util:combine_rfc822_addresses(AddressList)) + AddressList, + ?LET(Opts, use_unicode(), gen_address_list(Opts)), + is_binary(smtp_util:combine_rfc822_addresses(AddressList)) ). prop_encode_scan_no_crash(doc) -> @@ -33,19 +33,22 @@ prop_encode_scan_no_crash(doc) -> prop_encode_scan_no_crash() -> ?FORALL( - AddressList, - ?LET(Opts, use_unicode(), gen_address_list(Opts)), - begin - Encoded = smtp_util:combine_rfc822_addresses(AddressList), - Res = smtp_rfc5322_scan:string(unicode:characters_to_list(Encoded)), - ?WHENFAIL( - io:format("AddrList:~n~p~nEncoded:~n~p~nRes:~n~p~n", - [AddressList, Encoded, Res]), - begin - ?assertMatch({ok, _, 1}, Res), - true - end) - end + AddressList, + ?LET(Opts, use_unicode(), gen_address_list(Opts)), + begin + Encoded = smtp_util:combine_rfc822_addresses(AddressList), + Res = smtp_rfc5322_scan:string(unicode:characters_to_list(Encoded)), + ?WHENFAIL( + io:format( + "AddrList:~n~p~nEncoded:~n~p~nRes:~n~p~n", + [AddressList, Encoded, Res] + ), + begin + ?assertMatch({ok, _, 1}, Res), + true + end + ) + end ). prop_encode_decode_match(doc) -> @@ -53,167 +56,215 @@ prop_encode_decode_match(doc) -> prop_encode_decode_match() -> ?FORALL( - AddressList, - ?LET(Opts, use_unicode(), gen_address_list(Opts)), - begin - Encoded = smtp_util:combine_rfc822_addresses(AddressList), - Res = smtp_util:parse_rfc5322_addresses(Encoded), - ?WHENFAIL( - io:format("AddrList:~n~p~nEncoded:~n~p~nRes:~n~p~nScan:~n~p~n", - [AddressList, Encoded, Res, - smtp_rfc5322_scan:string(unicode:characters_to_list(Encoded))]), - begin - {ok, Decoded} = Res, - Zip = lists:zip(AddressList, Decoded), - lists:all(fun match/1, Zip) - end) - end + AddressList, + ?LET(Opts, use_unicode(), gen_address_list(Opts)), + begin + Encoded = smtp_util:combine_rfc822_addresses(AddressList), + Res = smtp_util:parse_rfc5322_addresses(Encoded), + ?WHENFAIL( + io:format( + "AddrList:~n~p~nEncoded:~n~p~nRes:~n~p~nScan:~n~p~n", + [ + AddressList, + Encoded, + Res, + smtp_rfc5322_scan:string(unicode:characters_to_list(Encoded)) + ] + ), + begin + {ok, Decoded} = Res, + Zip = lists:zip(AddressList, Decoded), + lists:all(fun match/1, Zip) + end + ) + end ). -match({{OName, OAddr}, {undefined, RAddr}}) when OName == undefined; - OName == <<>>; - OName == "" -> - ?assertEqual(OAddr, unicode:characters_to_binary(RAddr)), - true; +match({{OName, OAddr}, {undefined, RAddr}}) when + OName == undefined; + OName == <<>>; + OName == "" +-> + ?assertEqual(OAddr, unicode:characters_to_binary(RAddr)), + true; match({{OName, OAddr}, {RName, RAddr}}) -> - %% smtp_util drops chars below 32 from "name" part. Not sure it's correct, but is probably - %% not a big deal. - ONameNoControl = lists:map( - fun(C) when C < 32 -> 32; - (C) -> C - end, - unicode:characters_to_list(OName)), - ?assertEqual(ONameNoControl, RName), - ?assertEqual(OAddr, unicode:characters_to_binary(RAddr)), - true. + %% smtp_util drops chars below 32 from "name" part. Not sure it's correct, but is probably + %% not a big deal. + ONameNoControl = lists:map( + fun + (C) when C < 32 -> 32; + (C) -> C + end, + unicode:characters_to_list(OName) + ), + ?assertEqual(ONameNoControl, RName), + ?assertEqual(OAddr, unicode:characters_to_binary(RAddr)), + true. prop_encode_decode_group(doc) -> - "Check that any RFC-5322-compliant 'group' can be serialized and parsed to the same result". + "Check that any RFC-5322-compliant 'group' can be serialized and parsed to the same result". prop_encode_decode_group() -> ?FORALL( - {Name, AddressList}, - ?LET(Opts, use_unicode(), gen_group(Opts)), - begin - Encoded = encode_group(Name, AddressList), - {ok, Tokens, _} = smtp_rfc5322_scan:string(unicode:characters_to_list(Encoded)), - Res = smtp_rfc5322_parse:parse(Tokens), - ?WHENFAIL( - io:format("Name: '~p'~n" - "AddressList: ~p~n" - "Encoded: ~p~n" - "Res: ~p~n", - [Name, AddressList, Encoded, Res]), - begin - ?assertMatch({ok, {group, {_, _}}}, Res), - {ok, {group, {ResName, ResList0}}} = Res, - ResList = - lists:map(fun({AName, {addr, Local, Domain}}) -> - {AName, Local ++ "@" ++ Domain} - end, ResList0), - ?assertEqual(unicode:characters_to_list(Name), ResName), - lists:all(fun match/1, lists:zip(AddressList, ResList)) - end) - end). + {Name, AddressList}, + ?LET(Opts, use_unicode(), gen_group(Opts)), + begin + Encoded = encode_group(Name, AddressList), + {ok, Tokens, _} = smtp_rfc5322_scan:string(unicode:characters_to_list(Encoded)), + Res = smtp_rfc5322_parse:parse(Tokens), + ?WHENFAIL( + io:format( + "Name: '~p'~n" + "AddressList: ~p~n" + "Encoded: ~p~n" + "Res: ~p~n", + [Name, AddressList, Encoded, Res] + ), + begin + ?assertMatch({ok, {group, {_, _}}}, Res), + {ok, {group, {ResName, ResList0}}} = Res, + ResList = + lists:map( + fun({AName, {addr, Local, Domain}}) -> + {AName, Local ++ "@" ++ Domain} + end, + ResList0 + ), + ?assertEqual(unicode:characters_to_list(Name), ResName), + lists:all(fun match/1, lists:zip(AddressList, ResList)) + end + ) + end + ). encode_group(Name, AddressList) -> - EncodedList = smtp_util:combine_rfc822_addresses(AddressList), - EncName = case binary:match(Name, <<"\"">>) of - nomatch -> Name; - _ -> - <<$\", (binary:replace(Name, <<"\"">>, <<"\\\"">>, [global]))/binary, $\">> - end, - <>. + EncodedList = smtp_util:combine_rfc822_addresses(AddressList), + EncName = + case binary:match(Name, <<"\"">>) of + nomatch -> Name; + _ -> <<$\", (binary:replace(Name, <<"\"">>, <<"\\\"">>, [global]))/binary, $\">> + end, + <>. use_unicode() -> - proper_types:oneof( - [#{}, #{}, #{unicode => true} - ]). + proper_types:oneof( + [#{}, #{}, #{unicode => true}] + ). gen_group(Opts) -> - {gen_phrase(Opts), - proper_types:oneof( - [gen_address_list(Opts), - [] %group might be empty - ])}. + { + gen_phrase(Opts), + proper_types:oneof( + [ + gen_address_list(Opts), + %group might be empty + [] + ] + ) + }. gen_address_list(Opts) -> - proper_types:non_empty( - proper_types:list( - proper_types:oneof( - [gen_anonymous_name_addr(Opts), - gen_named_name_addr(Opts) - ]) - )). + proper_types:non_empty( + proper_types:list( + proper_types:oneof( + [ + gen_anonymous_name_addr(Opts), + gen_named_name_addr(Opts) + ] + ) + ) + ). gen_anonymous_name_addr(Opts) -> - {proper_types:oneof( - ["", <<>>, undefined]), - gen_addr_spec(Opts)}. + { + proper_types:oneof( + ["", <<>>, undefined] + ), + gen_addr_spec(Opts) + }. gen_named_name_addr(Opts) -> - {gen_phrase(Opts), gen_addr_spec(Opts)}. + {gen_phrase(Opts), gen_addr_spec(Opts)}. -define(NO_WS_CTL, (lists:seq(1, 8) ++ [11, 12] ++ lists:seq(14, 31) ++ [127])). %% rfc5322#section-3.4 gen_addr_spec(Opts) -> - ?LET({Local, Domain}, - {gen_local_part(Opts), gen_domain(Opts)}, - <>). + ?LET( + {Local, Domain}, + {gen_local_part(Opts), gen_domain(Opts)}, + <> + ). gen_local_part(Opts) -> - proper_types:oneof( - [gen_dot_atom(Opts), gen_quoted_string(Opts)]). + proper_types:oneof( + [gen_dot_atom(Opts), gen_quoted_string(Opts)] + ). gen_domain(Opts) -> - proper_types:oneof( - [gen_dot_atom(Opts), gen_domain_literal(Opts)] - ). + proper_types:oneof( + [gen_dot_atom(Opts), gen_domain_literal(Opts)] + ). gen_domain_literal(Opts) -> - DText = maybe_utf8(?NO_WS_CTL ++ lists:seq(33, 90) ++ lists:seq(94, 126), Opts), - DContent = proper_types:oneof([<<"\\[">>, <<"\\]">> | DText]), - ?LET(Str, - proper_types:non_empty(proper_types:list(DContent)), - <<"[", (unicode:characters_to_binary(Str))/binary, "]">>). + DText = maybe_utf8(?NO_WS_CTL ++ lists:seq(33, 90) ++ lists:seq(94, 126), Opts), + DContent = proper_types:oneof([<<"\\[">>, <<"\\]">> | DText]), + ?LET( + Str, + proper_types:non_empty(proper_types:list(DContent)), + <<"[", (unicode:characters_to_binary(Str))/binary, "]">> + ). %% rfc5322#section-3.2.5 gen_phrase(Opts) -> - Word = proper_types:oneof( - [gen_atom(Opts), - gen_quoted_string(Opts)] - ), - ?LET(Words, - proper_types:non_empty(proper_types:list(Word)), - unicode:characters_to_binary(lists:join($\s, Words))). + Word = proper_types:oneof( + [ + gen_atom(Opts), + gen_quoted_string(Opts) + ] + ), + ?LET( + Words, + proper_types:non_empty(proper_types:list(Word)), + unicode:characters_to_binary(lists:join($\s, Words)) + ). %% rfc5322#section-3.2.5 gen_quoted_string(Opts) -> - QText = maybe_utf8(?NO_WS_CTL ++ [33] ++ lists:seq(35, 91) ++ lists:seq(93, 126), Opts), - %% QContent = [<<"\\\"">> | QText], - QContent = QText, - ?LET(Str, - proper_types:non_empty(proper_types:list(proper_types:oneof(QContent))), - unicode:characters_to_binary([$\", Str, $\"])). + QText = maybe_utf8(?NO_WS_CTL ++ [33] ++ lists:seq(35, 91) ++ lists:seq(93, 126), Opts), + %% QContent = [<<"\\\"">> | QText], + QContent = QText, + ?LET( + Str, + proper_types:non_empty(proper_types:list(proper_types:oneof(QContent))), + unicode:characters_to_binary([$\", Str, $\"]) + ). %% rfc5322#section-3.2.3 gen_dot_atom(Opts) -> - ?LET(Parts, - proper_types:non_empty(proper_types:list(gen_atom(Opts))), - unicode:characters_to_binary(lists:join($\., Parts))). + ?LET( + Parts, + proper_types:non_empty(proper_types:list(gen_atom(Opts))), + unicode:characters_to_binary(lists:join($\., Parts)) + ). gen_atom(Opts) -> - Spec = "!#$%&'*+-/=?^_`{|}~", - Atext = maybe_utf8(lists:seq($0, $9) ++ lists:seq($A, $Z) ++ lists:seq($a, $z) ++ Spec, Opts), - ?LET(Str, proper_types:non_empty(proper_types:list(proper_types:oneof(Atext))), - unicode:characters_to_binary(Str)). + Spec = "!#$%&'*+-/=?^_`{|}~", + Atext = maybe_utf8(lists:seq($0, $9) ++ lists:seq($A, $Z) ++ lists:seq($a, $z) ++ Spec, Opts), + ?LET( + Str, + proper_types:non_empty(proper_types:list(proper_types:oneof(Atext))), + unicode:characters_to_binary(Str) + ). maybe_utf8(Chars, #{unicode := true}) -> - %% See `proper_unicode.erl' - [proper_types:integer(16#80, 16#7FF), - proper_types:integer(16#800, 16#D7FF), proper_types:integer(16#E000, 16#FFFD), - proper_types:integer(16#10000, 16#10FFFF), - proper_types:oneof(Chars)]; + %% See `proper_unicode.erl' + [ + proper_types:integer(16#80, 16#7FF), + proper_types:integer(16#800, 16#D7FF), + proper_types:integer(16#E000, 16#FFFD), + proper_types:integer(16#10000, 16#10FFFF), + proper_types:oneof(Chars) + ]; maybe_utf8(Chars, _) -> - Chars. + Chars. From c6602db53060df498da542409777ee4b71a5006b Mon Sep 17 00:00:00 2001 From: Sergey Prokhorov Date: Thu, 27 Jan 2022 00:23:38 +0100 Subject: [PATCH 2/2] Add code formatter commit to blame-ignore file --- .git-blame-ignore-revs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..bca9be05 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,12 @@ +# git blame ignore list. +# +# This file contains a list of git hashes to be ignored by git blame. These +# revisions are considered "unimportant" in that they are unlikely to be what +# you are interested in when blaming. +# +# git blame --ignore-revs-file .git-blame-ignore-revs +# or +# git config blame.ignoreRevsFile .git-blame-ignore-revs + +# Code formatter applied: `rebar3 fmt` +3967bcbd349b2bf0c390f68c68bd1d79eb5ad1fc