%%% Copyright 2009 Andrew Thompson . All rights reserved. %%% %%% Redistribution and use in source and binary forms, with or without %%% modification, are permitted provided that the following conditions are met: %%% %%% 1. Redistributions of source code must retain the above copyright notice, %%% this list of conditions and the following disclaimer. %%% 2. Redistributions in binary form must reproduce the above copyright %%% notice, this list of conditions and the following disclaimer in the %%% documentation and/or other materials provided with the distribution. %%% %%% THIS SOFTWARE IS PROVIDED BY THE FREEBSD PROJECT ``AS IS'' AND ANY EXPRESS OR %%% IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF %%% MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO %%% EVENT SHALL THE FREEBSD PROJECT OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, %%% INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES %%% (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; %%% LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND %%% ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT %%% (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS %%% SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. %% @doc A module for decoding/encoding MIME 1.0 email. %% The encoder and decoder operate on the same data structure, which is as follows: %% A 5-tuple with the following elements: `{Type, SubType, Headers, Parameters, Body}'. %% %% `Type' and `SubType' are the MIME type of the email, examples are `text/plain' or %% `multipart/alternative'. The decoder splits these into 2 fields so you can filter by %% the main type or by the subtype. %% %% `Headers' consists of a list of key/value pairs of binary values eg. %% `{<<"From">>, <<"Andrew Thompson ">>}'. There is no parsing of %% the header aside from un-wrapping the lines and splitting the header name from the %% header value. %% %% `Parameters' is a list of 3 key/value tuples. The 3 keys are `<<"content-type-params">>', %% `<<"disposition">>' and `<<"disposition-params">>'. %% `content-type-params' is a key/value list of parameters on the content-type header, this %% usually consists of things like charset and the format parameters. `disposition' indicates %% how the data wants to be displayed, this is usually 'inline'. `disposition-params' is a list of %% disposition information, eg. the filename this section should be saved as, the modification %% date the file should be saved with, etc. %% %% Finally, `Body' can be one of several different types, depending on the structure of the email. %% For a simple email, the body will usually be a binary consisting of the message body, In the %% case of a multipart email, its a list of these 5-tuple MIME structures. The third possibility, %% in the case of a message/rfc822 attachment, body can be a single 5-tuple MIME structure. %% %% You should see the relevant RFCs (2045, 2046, 2047, etc.) for more information. %% %% Note that parts of this module (e.g., `decode/2') use the %% iconv library for string conversion, %% which you will need to explicitly list as a dependency. -module(mimemail). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -export([rfc2047_utf8_encode/1]). -endif. -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 ]). -include_lib("kernel/include/logger.hrl"). -define(LOGGER_META, #{domain => [gen_smtp]}). -define(DEFAULT_MIME_VERSION, <<"1.0">>). -define(DEFAULT_OPTIONS, [ % 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()}] }. -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()} ]. -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). -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). decode(OrigHeaders, Body, Options) -> ?LOG_DEBUG("headers: ~p", [OrigHeaders], ?LOGGER_META), 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, []). %% @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 ]); encode(_, _) -> ?LOG_DEBUG("Not a mime-decoded DATA", ?LOGGER_META), erlang:error(non_mime). decode_headers(Headers, _, none) -> Headers; decode_headers([], Acc, _Charset) -> lists:reverse(Acc); decode_headers([{Key, Value} | Headers], 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()}. -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. 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)]; decode_header_tokens_strict([Data | 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}; decode_header_tokens_permissive([], _, Stack) -> 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); decode_header_tokens_permissive([Data | Tokens], Charset, 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}. 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", [SubType, Boundary], ?LOGGER_META ), 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", [Type, SubType], ?LOGGER_META), 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}). -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", [Headers], ?LOGGER_META), 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). -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) -> Value. -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); parse_with_comments(<<>>, _Acc, Depth, _Quotes) when Depth > 0 -> erlang:error(unterminated_comment); parse_with_comments(<<>>, Acc, _Depth, _Quotes) -> 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/binary>>, Acc, Depth, Quotes) when Depth > 0 -> 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/binary>>, 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/binary>>, Acc, Depth, Quotes) when Depth > 0, not 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(<>, Acc, Depth, Quotes) -> parse_with_comments(Tail, [H | Acc], Depth, Quotes). -spec parse_content_type (Value :: 'undefined') -> 'undefined'; (Value :: binary()) -> {binary(), binary(), [{binary(), binary()}]}. parse_content_type(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()}]}. parse_content_disposition(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}. 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. split_body_by_boundary_(<<>>, _Boundary, Acc, _Options) -> 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. -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. parse_headers(Body, <>, []) when H =:= $\s; H =:= $\t -> % 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", [FieldName, FieldValue], ?LOGGER_META), 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], ?LOGGER_META), 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, undefined, _OutEncoding) -> 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); decode_body(Type, Body, InEncoding, OutEncoding) -> 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; decode_body(Type, Body) -> 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). decode_quoted_printable(Body) -> decode_quoted_printable(Body, false, <<>>, <<>>). %% End of Body decode_quoted_printable(<<>>, _HasSoftEOL, _WSPs, Acc) -> Acc; %% CRLF after Soft Linebreak decode_quoted_printable(<<$\r, $\n, More/binary>>, true, _WSPs, Acc) -> decode_quoted_printable(More, false, <<>>, Acc); %% Space or Tab after Soft Linebreak decode_quoted_printable(<>, true, _WSPs, Acc) when C =:= $\s; C =:= $\t -> decode_quoted_printable(More, true, <<>>, Acc); %% Other character after Soft Linebreak decode_quoted_printable(_Body, true, _WSPs, _Acc) -> throw(badchar); %% CRLF decode_quoted_printable(<<$\r, $\n, More/binary>>, false, _WSPs, Acc) -> decode_quoted_printable(More, false, <<>>, <>); %% Space or Tab decode_quoted_printable(<>, false, WSPs, Acc) when C =:= $\s; C =:= $\t -> decode_quoted_printable(More, false, <>, Acc); %% Encoded char decode_quoted_printable(<<$=, C1, C2, More/binary>>, false, WSPs, Acc) when C1 >= $0 andalso C1 =< $9 orelse C1 >= $A andalso C1 =< $F orelse C1 >= $a andalso C1 =< $f, C2 >= $0 andalso C2 =< $9 orelse C2 >= $A andalso C2 =< $F orelse C2 >= $a andalso C2 =< $f -> decode_quoted_printable(More, false, <<>>, <>); %% Soft Linebreak decode_quoted_printable(<<$=, More/binary>>, false, WSPs, Acc) -> decode_quoted_printable(More, true, <<>>, <>); %% Plain character decode_quoted_printable(<>, false, WSPs, Acc) -> decode_quoted_printable(More, false, <<>>, <>). check_headers(Headers) -> Checked = [<<"MIME-Version">>, <<"Date">>, <<"From">>, <<"Message-ID">>, <<"References">>, <<"Subject">>], check_headers(Checked, lists:reverse(Headers)). check_headers([], 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 -> % 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). 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 ); 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. guess_best_encoding(Body) -> case valid_7bit(Body) of true -> <<"7bit">>; false -> choose_transformation(Body) end. choose_transformation(<>) -> %% 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 % 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 %% * \r and \n are only allowed as `\r\n' pair, but not standalone (bare) %% * 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(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. %% @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). has_lines_over_998(Bin, nomatch, Offset, _) -> %% Last line is over 998? (byte_size(Bin) - Offset) >= 998; has_lines_over_998(_Bin, {FoundAt, 2}, Offset, _Patern) when (FoundAt - Offset) >= 998 -> 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 ). -spec encode_parameters([{Name :: binary(), Value :: binary()}]) -> [Parameter :: binary()]. encode_parameters([[]]) -> []; encode_parameters(Parameters) -> lists:foldr( fun({Name, Value}, Acc) -> {Method, EncLen} = decide_param_encoding_method(Value), EncParams = encode_parameter(Method, Name, Value, EncLen), EncParams ++ Acc end, [], Parameters ). %% Encode a parameter value according to the determined representation %% (see decide_param_encoding_method/1). %% %% If necessary, ie when lines would become longer than 76 characters %% (leaving room for the leading continuation WSP and the ending semicolon), %% the values are folded following the schema described in RFC2231 section 3. -spec encode_parameter( Method :: (plain | quote | encode | encode_utf8), Name :: binary(), Value :: binary(), EncLen :: non_neg_integer() ) -> [ChunkParameter :: binary()]. encode_parameter(Method, Name, Value, EncLen) -> encode_parameter(Method, Name, 0, Value, EncLen, []). encode_parameter(_Method, _Name, _Index, <<>>, _EncLen, Acc) -> lists:reverse(Acc); encode_parameter(encode_utf8, Name, 0, Value, EncLen, _Acc) when byte_size(Name) + 9 + EncLen =< 76 -> {Encoded, <<>>} = encode_param_value(encode, Value, 67 - byte_size(Name)), [<>]; encode_parameter(encode_utf8, Name, 0, Value, EncLen, Acc) -> {Encoded, More} = encode_param_value(encode, Value, 65 - byte_size(Name)), encode_parameter(encode, Name, 1, More, EncLen, [<> | Acc]); encode_parameter(encode, Name, 0, Value, EncLen, _Acc) when byte_size(Name) + 4 + EncLen =< 76 -> {Encoded, <<>>} = encode_param_value(encode, Value, 72 - byte_size(Name)), [<>]; encode_parameter(encode, Name, 0, Value, EncLen, Acc) -> Prefix = <>, {Encoded, More} = encode_param_value(encode, Value, 73 - byte_size(Prefix)), encode_parameter(encode, Name, 1, More, EncLen, [<> | Acc]); encode_parameter(encode, Name, Index, Value, EncLen, Acc) -> Prefix = <>, {Encoded, More} = encode_param_value(encode, Value, 75 - byte_size(Prefix)), encode_parameter(encode, Name, Index + 1, More, EncLen, [<>]; encode_parameter(quote, Name, Index, Value, EncLen, Acc) -> Prefix = <>, {Quoted, More} = encode_param_value(quote, Value, 73 - byte_size(Prefix)), encode_parameter(quote, Name, Index + 1, More, EncLen, [<> | Acc]); encode_parameter(plain, Name, 0, Value, EncLen, _Acc) when byte_size(Name) + 1 + EncLen =< 76 -> {Plain, <<>>} = encode_param_value(plain, Value, 75 - byte_size(Name)), [<>]; encode_parameter(plain, Name, Index, Value, EncLen, Acc) -> Prefix = <>, {Plain, More} = encode_param_value(plain, Value, 75 - byte_size(Prefix)), encode_parameter(plain, Name, Index + 1, More, EncLen, [<> | Acc]). %% Encode a parameter value according to the method %% given as the first argument. -spec encode_param_value(Method :: (plain | quote | encode), Value :: binary(), Len :: integer()) -> {Chunk :: binary(), Rest :: binary()}. encode_param_value(plain, Value, Len) -> %% No encoding necessary, return (part of) the %% value as-is. Len1 = max(Len, 1), case Value of <> -> {Part, More}; _ -> {Value, <<>>} end; encode_param_value(quote, Value, Len) -> %% See encode_param_value_quote/3 encode_param_value_quote(Value, Len, <<>>); encode_param_value(encode, Value, Len) -> %% See encode_param_value_encode/3 encode_param_value_encode(Value, Len, <<>>). %% Encode (part of) a parameter value by quoting. %% " and \ are escaped by preceding it with a \, %% everything else remains as-is. encode_param_value_quote(<<>>, _Len, Acc) -> {Acc, <<>>}; encode_param_value_quote(All = <>, Len, Acc) -> case C =:= $" orelse C =:= $\\ of true when Len >= 2; Acc =:= <<>> -> encode_param_value_quote(More, Len - 2, <>); false when Len >= 1; Acc =:= <<>> -> encode_param_value_quote(More, Len - 1, <>); _ -> {Acc, All} end. %% Encode (part of) a parameter value according to RFC2231. %% Tspecials, CTLs, spaces, *, ', % and the bytes of %% multi-byte UTF-8 characters are encoded as a % followed %% by the hex representation of the byte values. %% Everything else remains as-is. encode_param_value_encode(<<>>, _Len, Acc) -> {Acc, <<>>}; encode_param_value_encode(All = <>, Len, Acc) when C =< 16#1F; C =:= 16#7F; C =:= $(; C =:= $); C =:= $<; C =:= $>; C =:= $@; C =:= $,; C =:= $;; C =:= $:; C =:= $/; C =:= $[; C =:= $]; C =:= $?; C =:= $=; C =:= $\s; C =:= $*; C =:= $'; C =:= $% -> case Len >= 3 orelse Acc =:= <<>> of true -> <> = <>, encode_param_value_encode(More, Len - 3, <>); false -> {Acc, All} end; encode_param_value_encode(All = <>, Len, Acc) -> case C >= 16#80 of true when Len >= 3; Acc =:= <<>> -> <> = <>, encode_param_value_encode(More, Len - 3, <>); false when Len >= 1; Acc =:= <<>> -> encode_param_value_encode(More, Len - 1, <>); _ -> {Acc, All} end. %% Analyze a parameter value and decide how it must be represented based %% on the characters contained. At the same time, calculate the number of %% bytes the value will take up when encoded for the determined representation. %% %% plain - value contains no tspecials, CTLs or UTF-8 characters -> representation as-is %% quote - value contains tspecials (but no CTLs or UTF-8 characters) -> must be quoted %% encode - value contains CTLs -> must be encoded according to RFC2231 %% encode_utf8 - value contains UTF-8 characters -> must be encoded according to RFC2231 -spec decide_param_encoding_method(Value :: binary()) -> {Method :: (plain | quote | encode | encode_utf8), EncodedLength :: non_neg_integer()}. decide_param_encoding_method(Value) -> decide_param_encoding_method(Value, plain, 0, 0, 0). decide_param_encoding_method(<<>>, Method, LP, LQ, LE) -> L = case Method of plain -> %% LP contains the length for method plain LP; quote -> %% LQ contains the length for method quote LQ; encode -> %% LE contains the length for method encode LE; encode_utf8 -> %% LE contains the length for method encode_utf8 LE end, {Method, L}; decide_param_encoding_method(<>, Method, LP, LQ, LE) when byte_size(<>) > 1 -> %% multibyte UTF-8 requires encoding and charset; requires 3 bytes decide_param_encoding_method( Rest, change_param_encoding_method(Method, encode_utf8), LP, LQ, LE + 3 * byte_size(<>) ); decide_param_encoding_method(<>, Method, LP, LQ, LE) when C =< 16#1F; C >= 16#7F -> %% CTLs and upper ASCII requires encoding; requires 3 bytes decide_param_encoding_method(Rest, change_param_encoding_method(Method, encode), LP, LQ, LE + 3); decide_param_encoding_method(<>, Method, LP, LQ, LE) when C =:= $"; C =:= $\\ -> %% " and \ requires quoting; requires 2 bytes when quoting is used, 3 when encoding is used decide_param_encoding_method(Rest, change_param_encoding_method(Method, quote), LP, LQ + 2, LE + 3); decide_param_encoding_method(<>, Method, LP, LQ, LE) when C =:= $(; C =:= $); C =:= $<; C =:= $>; C =:= $@; C =:= $,; C =:= $;; C =:= $:; C =:= $/; C =:= $[; C =:= $]; C =:= $?; C =:= $=; C =:= $\s -> %% tspecials require quoting decide_param_encoding_method(Rest, change_param_encoding_method(Method, quote), LP, LQ + 1, LE + 3); decide_param_encoding_method(<>, Method, LP, LQ, LE) when C =:= $*; C =:= $'; C =:= $% -> %% *, ' and % require 3 bytes when encoding is used, otherwise 1 decide_param_encoding_method(Rest, Method, LP + 1, LQ + 1, LE + 3); decide_param_encoding_method(<<_, Rest/binary>>, Method, LP, LQ, LE) -> %% plain characters require 1 byte decide_param_encoding_method(Rest, Method, LP + 1, LQ + 1, LE + 1). -spec change_param_encoding_method(CurMethod, NewMethod) -> Method when CurMethod :: Method, NewMethod :: Method, Method :: (plain | quote | encode | encode_utf8). change_param_encoding_method(Method, Method) -> %% no change Method; change_param_encoding_method(CurMethod, NewMethod) -> %% between the current and the new encoding method, pick the highest-ranking change_param_encoding_method([encode_utf8, encode, quote], CurMethod, NewMethod). change_param_encoding_method([CurMethod | _More], CurMethod, _NewMethod) -> CurMethod; change_param_encoding_method([NewMethod | _More], _CurMethod, NewMethod) -> NewMethod; change_param_encoding_method([_ | More], CurMethod, NewMethod) -> change_param_encoding_method(More, CurMethod, NewMethod); change_param_encoding_method([], _CurMethod, NewMethod) -> NewMethod. 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; maybe_encode_folded_header(_H, 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, 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)); encode_header_value(H, Value) when H =:= <<"Content-Type">>; H =:= <<"Content-Disposition">> -> % Parameters are already encoded. Value; encode_header_value(_, 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) -> % 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({<<"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); 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 ); encode_component_part(Part) -> ?LOG_DEBUG("encode_component_part couldn't match Part to: ~p", [Part], ?LOGGER_META), []. encode_body(undefined, 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. wrap_to_76(String) -> [wrap_to_76(String, [])]. wrap_to_76(<<>>, Acc) -> list_to_binary(lists:reverse(Acc)); wrap_to_76(<>, Acc) -> wrap_to_76(Tail, [<<"\r\n">>, Head | Acc]); wrap_to_76(Head, Acc) -> list_to_binary(lists:reverse([<<"\r\n">>, Head | Acc])). encode_quoted_printable(Body) -> [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); % 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; % 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; % Character elsewhere encode_quoted_printable(<>, Acc, LineLen, HasWSP, WordAcc, WordLen) -> 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))>>; encode_quoted_printable_char($\s, false) -> <<$\s>>; encode_quoted_printable_char($\t, false) -> <<$\t>>; encode_quoted_printable_char($=, _Force) -> <<$=, $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, false) -> <>. get_default_encoding() -> <<"utf-8//IGNORE">>. % convert some common invalid character names into the correct ones fix_encoding(Encoding) when Encoding == <<"utf8">>; Encoding == <<"UTF8">> -> <<"UTF-8">>; fix_encoding(Encoding) -> Encoding. % Characters allowed to appear unencoded (RFC 2047 Sections 4.2 and 5): % * lowercase ASCII letters % * uppercase ASCII letters % * decimal digits % * "!" % * "*" % * "+" % * "-" % * "/" % 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 =:= $/) ). %% @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, 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; rfc2047_utf8_encode(Value, PrefixLen, LineIndent) -> rfc2047_utf8_encode(list_to_binary(Value), PrefixLen, LineIndent). rfc2047_utf8_encode(_Enc, <<>>, Acc, _PrefixLen, _LineIndent) -> 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); 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). rfc2047_utf8_encode(Enc, <<>>, Acc, WordAcc, _PrefixLen, LineIndent, _Left) -> 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. rfc2047_append_word(Acc, <<>>, _Enc, _LineIndent) -> % empty word Acc; rfc2047_append_word(<<>>, Word, Enc, _LineIndent) -> % first word in Acc rfc2047_encode_word(Word, Enc); rfc2047_append_word(Acc, Word, Enc, LineIndent) -> % subsequent word in Acc <>. rfc2047_encode_word(Word, q) -> <<"=?UTF-8?Q?", (rfc2047_q_encode(Word))/binary, "?=">>; rfc2047_encode_word(Word, b) -> <<"=?UTF-8?B?", (base64:encode(Word))/binary, "?=">>. rfc2047_q_encode(<<>>) -> <<>>; rfc2047_q_encode(<<$\s, More/binary>>) -> % SPACE -> _ <<$_, (rfc2047_q_encode(More))/binary>>; rfc2047_q_encode(<>) when ?is_rfc2047_q_allowed(C) -> % character which needs no encoding <>; rfc2047_q_encode(<>) -> % characters which need encoding -> =XY <<$=, (hex(N1)), (hex(N2)), (rfc2047_q_encode(More))/binary>>. is_ascii_printable(<<>>) -> 'true'; is_ascii_printable(<>) when H >= 32 andalso H =< 126 -> is_ascii_printable(T); is_ascii_printable(_) -> 'false'. hex(N) when N >= 10 -> N + $A - 10; hex(N) -> N + $0. unhex(C) when C >= $a -> C - $a + 10; unhex(C) when C >= $A -> C - $A + 10; unhex(C) -> C - $0. partition_count_bytes(Fun, Bin) -> partition_count_bytes(Fun, Bin, {0, 0}). partition_count_bytes(_Fun, <<>>, 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). %% @doc DKIM sign an email %% DKIM sign functions %% RFC 6376 %% `h' - list of headers to sign (lowercased binary) %% `c' - {Headers, Body} canonicalization type. Only {simple, simple} and %% {relaxed, simple} supported for now. %% be located in "foo.bar._domainkey.example.com" (see RFC-6376 #3.6.2.1). %% `t' - signature timestamp: 'now' or UTC {Date, Time} %% `x' - signature expiration time: UTC {Date, Time} %% `a` - signing algorithm (default: `rsa-sha256`): %% `private_key' - private key, to sign emails. May be of 2 types: encrypted and %% plain in PEM format: %% RSA %% `{pem_plain, KeyBinary}' - generated by openssl genrsa -out out-file.pem 1024 %% `{pem_encrypted, KeyBinary, Password}' - generated by, eg %% openssl genrsa -des3 -out out-file.pem 1024 %% RFC8463 %% Ed25519 - Erlang/OTP 24.1+ only! %% `{pem_plain, KeyBinary}' - generated by openssl genpkey -algorithm ed25519 -out out-file.pem %% `{pem_encrypted, KeyBinary, Password}' - generated by, eg %% openssl genpkey -des3 -algorithm ed25519 -out out-file.pem %% 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), %% {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]. dkim_canonicalize_headers(Headers, simple) -> Headers; dkim_canonicalize_headers(Headers, relaxed) -> 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([]) -> []. dkim_canonicalize_body(<<>>, simple) -> <<"\r\n">>; dkim_canonicalize_body(Body, simple) -> re:replace(Body, "(\r\n)*$", "\r\n", [{return, binary}]); dkim_canonicalize_body(_Body, relaxed) -> throw({not_supported, dkim_body_relaxed}). dkim_hash_body(CanonicBody) -> crypto:hash(sha256, CanonicBody). %% crypto:sha256(CanonicBody). %% RFC 5.5 & 3.7 dkim_hash_data(CanonicHeaders, DkimHeader) -> 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]. 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. 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); 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). dkim_make_header(Tags) -> %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">>; dkim_encode_tag(a, Algorithm) -> %% algorithm <<"a=", (atom_to_binary(Algorithm, utf8))/binary>>; dkim_encode_tag(b, undefined) -> %% signature (when hashing with no digest) <<"b=">>; dkim_encode_tag(b, V) -> %% signature B64Sign = base64:encode(V), <<"b=", B64Sign/binary>>; dkim_encode_tag(bh, V) -> %% 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>>; dkim_encode_tag(h, Hdrs) -> %% 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>>; dkim_encode_tag(l, IntVal) -> %% 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">>; dkim_encode_tag(s, Selector) -> %% selector <<"s=", Selector/binary>>; dkim_encode_tag(t, now) -> dkim_encode_tag(t, calendar:universal_time()); dkim_encode_tag(t, DateTime) -> %% 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>>; %% 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 [QPValue] = encode_quoted_printable(Value), binary:replace(QPValue, <<";">>, <<"=3B">>). datetime_to_bin_timestamp(DateTime) -> % 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} ]. 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} ]. 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} ]. 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>>). 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} ]. decode_quoted_printable_test_() -> [ {"bleh", fun() -> ?assertEqual(<<"!">>, decode_quoted_printable(<<"=21">>)), ?assertEqual(<<"!!">>, decode_quoted_printable(<<"=21=21">>)), ?assertEqual(<<"=:=">>, decode_quoted_printable(<<"=3D:=3D">>)), ?assertEqual( <<"Thequickbrownfoxjumpedoverthelazydog.">>, decode_quoted_printable(<<"Thequickbrownfoxjumpedoverthelazydog.">>) ) end}, {"lowercase bleh", fun() -> ?assertEqual(<<"=:=">>, decode_quoted_printable(<<"=3d:=3d">>)) end}, {"input with spaces", fun() -> ?assertEqual( <<"The quick brown fox jumped over the lazy dog.">>, decode_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.">>, decode_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.">>, decode_quoted_printable(<<"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(<<"The quick brown fox jumped over the lazy dog. =20">>) ), ?assertEqual( <<"The quick brown fox jumped over the lazy dog. \t">>, decode_quoted_printable(<<"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(<<"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( <<"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(<<"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(<<"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(<<"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(<<"=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(<>)). 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} ]. encode_parameter_test_() -> [ {"Token", fun() -> ?assertEqual( [<<"a=abcdefghijklmnopqrstuvwxyz$%&*#!">>], encode_parameters([{<<"a">>, <<"abcdefghijklmnopqrstuvwxyz$%&*#!">>}]) ), ?assertEqual( [<<"a=12345678901234567890123456789012345678901234567890123456789012345678901234">>], encode_parameters([ {<<"a">>, <<"12345678901234567890123456789012345678901234567890123456789012345678901234">>} ]) ), ?assertEqual( [ <<"a*0=123456789012345678901234567890123456789012345678901234567890123456789012">>, <<"a*1=345">> ], encode_parameters([ {<<"a">>, <<"123456789012345678901234567890123456789012345678901234567890123456789012345">>} ]) ) end}, {"TSpecial", fun() -> Special = " ()<>@,;:/[]?=", [ ?assertEqual([<<"a=", $", C, $">>], encode_parameters([{<<"a">>, <>}])) || C <- Special ], ?assertEqual([<<"a=", $", $\\, $", $">>], encode_parameters([{<<"a">>, <<$">>}])), ?assertEqual([<<"a=", $", $\\, $\\, $">>], encode_parameters([{<<"a">>, <<$\\>>}])), ?assertEqual( [<<"a=\"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12\"">>], encode_parameters([ {<<"a">>, <<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 12">>} ]) ), ?assertEqual( [ <<"a*0=\"123456789 123456789 123456789 123456789 123456789 123456789 123456789 \"">>, <<"a*1=\"123\"">> ], encode_parameters([ {<<"a">>, <<"123456789 123456789 123456789 123456789 123456789 123456789 123456789 123">>} ]) ) end}, {"RFC2231 encoded", fun() -> ?assertEqual( [<<"a*=''1234567%001234567%001234567%001234567%001234567%001234567%001234567%001">>], encode_parameters([ {<<"a">>, <<"1234567", 0, "1234567", 0, "1234567", 0, "1234567", 0, "1234567", 0, "1234567", 0, "1234567", 0, "1">>} ]) ), ?assertEqual( [ <<"a*0*=''1234567%001234567%001234567%001234567%001234567%001234567%001234567">>, <<"a*1*=%0012">> ], encode_parameters([ {<<"a">>, <<"1234567", 0, "1234567", 0, "1234567", 0, "1234567", 0, "1234567", 0, "1234567", 0, "1234567", 0, "12">>} ]) ) end}, {"RFC2231 encoded with UTF-8", fun() -> ?assertEqual( [<<"a*=UTF-8''1234%C2%A01234%C2%A01234%C2%A01234%C2%A01234%C2%A01234%C2%A01234">>], encode_parameters([ {<<"a">>, <<"1234", 16#c2, 16#a0, "1234", 16#c2, 16#a0, "1234", 16#c2, 16#a0, "1234", 16#c2, 16#a0, "1234", 16#c2, 16#a0, "1234", 16#c2, 16#a0, "1234">>} ]) ), ?assertEqual( [ <<"a*0*=UTF-8''1234%C2%A01234%C2%A01234%C2%A01234%C2%A01234%C2%A01234%C2%A01234">>, <<"a*1*=%C2%A0">> ], encode_parameters([ {<<"a">>, <<"1234", 16#c2, 16#a0, "1234", 16#c2, 16#a0, "1234", 16#c2, 16#a0, "1234", 16#c2, 16#a0, "1234", 16#c2, 16#a0, "1234", 16#c2, 16#a0, "1234", 16#c2, 16#a0>>} ]) ) 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?=\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} ]. 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*0*=UTF-8''%C4%8Cia%20labai%20ilgas%20el.%20lai%C5%A1ko%20priedo%20pavad;\r\n" "\tname*1*=inimas%20su%20lietuvi%C5%A1komis%20ar%20kokiomis%20kitomis%20ne%20as;\r\n" "\tname*2*=cii%20raid%C4%97mis.pdf;\r\n" "\tdisposition=attachment\r\n" "Content-Disposition: attachment;\r\n" "\tfilename*0*=UTF-8''%C4%8Cia%20labai%20ilgas%20el.%20lai%C5%A1ko%20priedo%20p;\r\n" "\tfilename*1*=avadinimas%20su%20lietuvi%C5%A1komis%20ar%20kokiomis%20kitomis;\r\n" "\tfilename*2*=%20ne%20ascii%20raid%C4%97mis.pdf\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\n" "Message-ID: \r\n" "MIME-Version: 1.0\r\n" "Date: Sun, 01 Nov 2009 14:44:47 +0200\r\n" "\r\n" "This 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} ]. dkim_canonicalization_test_() -> %% * 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() -> % \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} ]. 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. 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. -endif.