diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d304ff3 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..525a2d4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +on: + pull_request: + push: + branches: + - master + +jobs: + mix-test: + runs-on: ubuntu-22.04 + + strategy: + fail-fast: false + matrix: + include: + - elixir: "1.12" + otp: "24" + - elixir: "1.16" + otp: "25" + - elixir: "1.17" + otp: "26" + - elixir: "1.18" + otp: "27" + - elixir: "1.19" + otp: "28" + lint: lint + + steps: + - uses: actions/checkout@v5 + + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.otp}} + elixir-version: ${{matrix.elixir}} + + - uses: actions/cache@v4 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-${{matrix.otp}}-${{matrix.elixir}}-${{ hashFiles('**/mix.lock') }} + restore-keys: deps-${{ runner.os }}-${{matrix.otp}}-${{matrix.elixir}} + + - run: mix deps.get + + - run: mix format --check-formatted + if: ${{ matrix.lint }} + + - run: mix deps.unlock --check-unused + if: ${{ matrix.lint }} + + - run: mix deps.compile + + - run: mix compile --warnings-as-errors + if: ${{ matrix.lint }} diff --git a/CHANGELOG.md b/CHANGELOG.md index deb1e29..83d2834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [0.2.2] (2025-11-12) + +### Fixed + +- Warnings emitted by the Elixir's compiler v1.19.2 +- Warnings emitted by a negative range given to `Enum.slice/2` + ## [0.2.1] (2024-03-12) ### Fixed diff --git a/README.md b/README.md index 260aa28..4bcbdaa 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -mailibex [![Build Status](https://travis-ci.org/kbrw/mailibex.svg?branch=master)](https://travis-ci.org/kbrw/mailibex) +mailibex ![GitHub branch check runs](https://img.shields.io/github/check-runs/kbrw/mailibex/master) ======== Library containing Email related implementations in Elixir : dkim, spf, dmark, mimemail, smtp diff --git a/lib/dkim.ex b/lib/dkim.ex index fdac039..275fd29 100644 --- a/lib/dkim.ex +++ b/lib/dkim.ex @@ -1,167 +1,238 @@ defmodule DKIM do - defstruct h: [:to,:from,:date,:subject,:"message-id",:"mime-version"], c: %{header: :relaxed, body: :simple}, - d: "example.org", s: "default", a: %{sig: :rsa, hash: :sha256}, b: "", bh: "", l: nil, v: "1", - x: nil, i: nil, q: "dns/txt", t: nil, z: nil - - def check(mail) when is_binary(mail), do: - check(MimeMail.from_string(mail)) - def check(%MimeMail{headers: headers,body: {:raw,body}}=mail) do - mail = decode_headers(mail) - case mail.headers[:'dkim-signature'] do - nil -> :none + defstruct h: [:to, :from, :date, :subject, :"message-id", :"mime-version"], + c: %{header: :relaxed, body: :simple}, + d: "example.org", + s: "default", + a: %{sig: :rsa, hash: :sha256}, + b: "", + bh: "", + l: nil, + v: "1", + x: nil, + i: nil, + q: "dns/txt", + t: nil, + z: nil + + def check(mail) when is_binary(mail), do: check(MimeMail.from_string(mail)) + + def check(%MimeMail{headers: headers, body: {:raw, body}} = mail) do + mail = decode_headers(mail) + + case mail.headers[:"dkim-signature"] do + nil -> + :none + sig -> - if (sig.bh == body_hash(body,sig)) do - case :inet_res.lookup('#{sig.s}._domainkey.#{sig.d}', :in, :txt, edns: 0) do - [rec|_] -> + if sig.bh == body_hash(body, sig) do + case :inet_res.lookup(~c"#{sig.s}._domainkey.#{sig.d}", :in, :txt, edns: 0) do + [rec | _] -> pubkey = MimeMail.Params.parse_header(IO.chardata_to_string(rec)) - if :"#{pubkey[:k]||"rsa"}" == sig.a.sig do - case extract_key64(pubkey[:p]||"") do - {:ok,key}-> - header_h = headers_hash(headers,sig) - if :crypto.verify(:rsa,:sha256,header_h,sig.b,key) do - {:pass,{sig.s,sig.d}} - else {:permfail,:sig_not_match} end - :error-> {:permfail,:invalid_pub_key} end - else {:permfail,:sig_algo_not_match} end - _ -> :tempfail end - else {:permfail,:body_hash_no_match} end + + if :"#{pubkey[:k] || "rsa"}" == sig.a.sig do + case extract_key64(pubkey[:p] || "") do + {:ok, key} -> + header_h = headers_hash(headers, sig) + + if :crypto.verify(:rsa, :sha256, header_h, sig.b, key) do + {:pass, {sig.s, sig.d}} + else + {:permfail, :sig_not_match} + end + + :error -> + {:permfail, :invalid_pub_key} + end + else + {:permfail, :sig_algo_not_match} + end + + _ -> + :tempfail + end + else + {:permfail, :body_hash_no_match} + end end end - def sign(mail,key,sig_params \\ []) do - sig = struct(DKIM,sig_params) - %{body: {:raw,body}}=encoded_mail=MimeMail.encode_body(mail) #ensure body is binary - sig = %{sig| bh: body_hash(body,sig)} #add body hash - encoded_mail = MimeMail.encode_headers(%{encoded_mail|headers: Keyword.put(encoded_mail.headers,:'dkim-signature',sig)}) #encoded mail without dkim.b - sig = %{sig| b: encoded_mail.headers |> headers_hash(sig) |> :public_key.sign(:sha256,key)} - %{encoded_mail|headers: Keyword.put(encoded_mail.headers,:'dkim-signature',sig)} + def sign(mail, key, sig_params \\ []) do + sig = struct(DKIM, sig_params) + # ensure body is binary + %{body: {:raw, body}} = encoded_mail = MimeMail.encode_body(mail) + # add body hash + sig = %{sig | bh: body_hash(body, sig)} + # encoded mail without dkim.b + encoded_mail = + MimeMail.encode_headers(%{ + encoded_mail + | headers: Keyword.put(encoded_mail.headers, :"dkim-signature", sig) + }) + + sig = %{sig | b: encoded_mail.headers |> headers_hash(sig) |> :public_key.sign(:sha256, key)} + %{encoded_mail | headers: Keyword.put(encoded_mail.headers, :"dkim-signature", sig)} end - def decode_headers(%MimeMail{headers: headers}=mail) do - case headers[:'dkim-signature'] do - {:raw,raw} -> + def decode_headers(%MimeMail{headers: headers} = mail) do + case headers[:"dkim-signature"] do + {:raw, raw} -> unquoted = MimeMail.header_value(raw) - sig = struct(DKIM,for({k,v}<-MimeMail.Params.parse_header(unquoted),do: {k,decode_field(k,v)})) - put_in(mail,[:headers,:'dkim-signature'],sig) - _ -> mail + + sig = + struct( + DKIM, + for({k, v} <- MimeMail.Params.parse_header(unquoted), do: {k, decode_field(k, v)}) + ) + + put_in(mail, [:headers, :"dkim-signature"], sig) + + _ -> + mail end end - defp decode_field(:c,c) do - case String.split(c,"/") do - [t] -> %{header: :"#{t}",body: :simple} - [t1,t2] -> %{header: :"#{t1}",body: :"#{t2}"} - _ -> %{header: :simple,body: :simple} + defp decode_field(:c, c) do + case String.split(c, "/") do + [t] -> %{header: :"#{t}", body: :simple} + [t1, t2] -> %{header: :"#{t1}", body: :"#{t2}"} + _ -> %{header: :simple, body: :simple} end end - defp decode_field(:b,b) do - case Base.decode64(String.replace(b,~r/\s/,"")) do - {:ok,b}->b - :error-> "" + + defp decode_field(:b, b) do + case Base.decode64(String.replace(b, ~r/\s/, "")) do + {:ok, b} -> b + :error -> "" end end - defp decode_field(:a,a) do - case String.split(a,"-") do - [sig,hash]->%{sig: :"#{sig}",hash: :"#{hash}"} - _ ->%{sig: :rsa, hash: :sha256} + + defp decode_field(:a, a) do + case String.split(a, "-") do + [sig, hash] -> %{sig: :"#{sig}", hash: :"#{hash}"} + _ -> %{sig: :rsa, hash: :sha256} end end - defp decode_field(:l,l) do + + defp decode_field(:l, l) do case Integer.parse(l) do - :error->nil - {l,_}->l + :error -> nil + {l, _} -> l end end - defp decode_field(:bh,bh) do - case (bh |> String.replace(~r/\s/,"") |> Base.decode64) do - {:ok,b}->b - :error-> "" + + defp decode_field(:bh, bh) do + case bh |> String.replace(~r/\s/, "") |> Base.decode64() do + {:ok, b} -> b + :error -> "" end end - defp decode_field(:h,h), do: - (h|>String.downcase|>String.split(":")|>Enum.map(&String.to_atom/1)) - defp decode_field(_,e), do: e - def canon_header(header,:simple), do: header - def canon_header(header,:relaxed) do - [k,v] = String.split(header,~r/\s*:\s*/, parts: 2) - "#{String.downcase(k)}:#{v |> MimeMail.unfold_header() |> String.replace(~r"[\t ]+"," ") |> String.trim_trailing()}" + defp decode_field(:h, h), + do: h |> String.downcase() |> String.split(":") |> Enum.map(&String.to_atom/1) + + defp decode_field(_, e), do: e + + def canon_header(header, :simple), do: header + + def canon_header(header, :relaxed) do + [k, v] = String.split(header, ~r/\s*:\s*/, parts: 2) + + "#{String.downcase(k)}:#{v |> MimeMail.unfold_header() |> String.replace(~r"[\t ]+", " ") |> String.trim_trailing()}" end - def canon_body(body,:simple), do: - String.replace(body,~r/(\r\n)*$/,"\r\n", global: false) - def canon_body(body,:relaxed) do - body + def canon_body(body, :simple), do: String.replace(body, ~r/(\r\n)*$/, "\r\n", global: false) + + def canon_body(body, :relaxed) do + body |> String.replace(~r/[\t ]+\r\n/, "\r\n") |> String.replace(~r/[\t ]+/, " ") - |> String.replace(~r/(\r\n)*$/,"\r\n", global: false) + |> String.replace(~r/(\r\n)*$/, "\r\n", global: false) end - def truncate_body(body,nil), do: body - def truncate_body(body,l) when is_integer(l) do - <> = body + def truncate_body(body, nil), do: body + + def truncate_body(body, l) when is_integer(l) do + <> = body trunc_body end - - def hash(bin,:sha256), do: :crypto.hash(:sha256,bin) - def hash(bin,:sha1), do: :crypto.hash(:sha,bin) - def body_hash(body,sig) do + def hash(bin, :sha256), do: :crypto.hash(:sha256, bin) + def hash(bin, :sha1), do: :crypto.hash(:sha, bin) + + def body_hash(body, sig) do body - |>canon_body(sig.c.body) - |>truncate_body(sig.l) - |>hash(sig.a.hash) + |> canon_body(sig.c.body) + |> truncate_body(sig.l) + |> hash(sig.a.hash) end - def headers_hash(headers,sig) do - {:raw,rawsig} = headers[:"dkim-signature"] + + def headers_hash(headers, sig) do + {:raw, rawsig} = headers[:"dkim-signature"] + sig.h - |> Enum.filter(&Keyword.has_key?(headers,&1)) - |> Enum.map(fn k-> - {:raw,v}=headers[k] - canon_header(v,sig.c.header) - end) - |> Enum.concat([rawsig - |>canon_header(sig.c.header) - |>String.replace(~r/b=[^;]*/,"b=")]) + |> Enum.filter(&Keyword.has_key?(headers, &1)) + |> Enum.map(fn k -> + {:raw, v} = headers[k] + canon_header(v, sig.c.header) + end) + |> Enum.concat([ + rawsig + |> canon_header(sig.c.header) + |> String.replace(~r/b=[^;]*/, "b=") + ]) |> Enum.join("\r\n") end def extract_key64(data64) do - case Base.decode64(String.replace(data64,~r/\s/,"")) do - {:ok,data}->extract_key(data) + case Base.decode64(String.replace(data64, ~r/\s/, "")) do + {:ok, data} -> extract_key(data) _ -> :error end end + # asn sizeof pubkey,rsapubkey,modulus > 128 <=> len = {1::1,lensize::7,objlen::lensize} # asn sizeof exp, algoid < 128 <=> len = {objlen::8} # ASN1 pubkey::SEQ(48){ algo::SEQ(48){Algoid,Algoparams}, pubkey::BITSTRING(3) } - def extract_key(<<48,1::size(1)-unit(1),ll0::size(7),_l0::size(ll0)-unit(8), - 48,l1,_algoid::size(l1)-binary, - 3,1::size(1)-unit(1),ll2::size(7),l2::size(ll2)-unit(8),des_rsapub::size(l2)-binary>>) do + def extract_key( + <<48, 1::size(1)-unit(1), ll0::size(7), _l0::size(ll0)-unit(8), 48, l1, + _algoid::size(l1)-binary, 3, 1::size(1)-unit(1), ll2::size(7), l2::size(ll2)-unit(8), + des_rsapub::size(l2)-binary>> + ) do extract_key(des_rsapub) end + # ASN1 rsapubkey::SEQ(48){ modulus::INT(2), exp::INT(2) } - def extract_key(<<48,1::size(1)-unit(1),ll0::size(7),_l0::size(ll0)-unit(8), - 2,1::size(1)-unit(1),ll1::size(7),l1::size(ll1)-unit(8),mod::size(l1)-binary, - 2,l2,exp::size(l2)-unit(8)-binary>>) do - {:ok,[exp,mod]} + def extract_key( + <<48, 1::size(1)-unit(1), ll0::size(7), _l0::size(ll0)-unit(8), 2, 1::size(1)-unit(1), + ll1::size(7), l1::size(ll1)-unit(8), mod::size(l1)-binary, 2, l2, + exp::size(l2)-unit(8)-binary>> + ) do + {:ok, [exp, mod]} end - def extract_key(<<0,rest::binary>>), do: extract_key(rest) # strip leading 0 if needed + + # strip leading 0 if needed + def extract_key(<<0, rest::binary>>), do: extract_key(rest) def extract_key(_), do: :error end defimpl MimeMail.Header, for: DKIM do def to_ascii(dkim) do - for({k,v}<-Map.from_struct(dkim), v !== nil,do: "#{k}=#{encode_field(k,v)}") + for({k, v} <- Map.from_struct(dkim), v !== nil, do: "#{k}=#{encode_field(k, v)}") |> Enum.join("; ") end - defp encode_field(:h,h), do: Enum.join(h,":") - defp encode_field(:c,c), do: "#{c.header}/#{c.body}" - defp encode_field(:a,a), do: "#{a.sig}-#{a.hash}" - defp encode_field(:bh,bh), do: Base.encode64(bh) - defp encode_field(:b,b), do: (Base.encode64(b) |> chunk_hard([]) |> Enum.join("\r\n ")) - defp encode_field(_,e), do: Kernel.to_string(e) - defp chunk_hard(<>,acc), do: chunk_hard(rest,[vline|acc]) - defp chunk_hard(other,acc), do: Enum.reverse([other|acc]) + defp encode_field(:h, h), do: Enum.join(h, ":") + defp encode_field(:c, c), do: "#{c.header}/#{c.body}" + defp encode_field(:a, a), do: "#{a.sig}-#{a.hash}" + defp encode_field(:bh, bh), do: Base.encode64(bh) + + defp encode_field(:b, b), + do: Base.encode64(b) |> chunk_hard([]) |> Enum.join("\r\n ") + + defp encode_field(_, e), do: Kernel.to_string(e) + + defp chunk_hard(<>, acc), + do: chunk_hard(rest, [vline | acc]) + + defp chunk_hard(other, acc), do: Enum.reverse([other | acc]) end diff --git a/lib/dmarc.ex b/lib/dmarc.ex index 6c64878..0b59e89 100644 --- a/lib/dmarc.ex +++ b/lib/dmarc.ex @@ -9,42 +9,63 @@ defmodule DMARC do |> organization() end - :ssl.start ; :inets.start + :ssl.start() + :inets.start() url = "https://publicsuffix.org/list/effective_tld_names.dat" - case :httpc.request(:get,{'#{url}',[]},[], body_format: :binary) do - {:ok,{{_,200,_},_,r}} -> + + case :httpc.request(:get, {~c"#{url}", []}, [], body_format: :binary) do + {:ok, {{_, 200, _}, _, r}} -> Logger.debug("Download suffix from #{url}") r + e -> Logger.error("Download failed! fallback on \"priv/suffix.data\"\nERROR: #{inspect(e)}") File.read!("#{:code.priv_dir(:mailibex)}/suffix.data") end |> String.trim() |> String.split("\n") - |> Enum.filter(fn # remove comments - <> -> c not in [?\s,?/] - _-> false + # remove comments + |> Enum.filter(fn + <> -> c not in [?\s, ?/] + _ -> false + end) + # divide domain components + |> Enum.map(&String.split(&1, ".")) + # sort rule by priority + |> Enum.sort(fn + # exception rules are first ones + ["!" <> _ | _], _ -> true + # + _, ["!" <> _ | _] -> false + # else priority to longest prefix match + x, y -> length(x) > length(y) end) - |> Enum.map(&String.split(&1,".")) # divide domain components - |> Enum.sort(fn # sort rule by priority - ["!"<>_|_],_ -> true # exception rules are first ones - _,["!"<>_|_] -> false # - x,y -> length(x) > length(y) # else priority to longest prefix match - end) - |> Enum.each(fn spec-> + |> Enum.each(fn spec -> org_match = spec |> Enum.reverse() |> Enum.map(fn - "!" <> rest -> rest # remove exception mark ! - "*" -> quote do _ end # "*" component matches anything, so convert it to "_" - x -> x # match other components as they are + # remove exception mark ! + "!" <> rest -> + rest + + # "*" component matches anything, so convert it to "_" + "*" -> + quote do + _ + end + + # match other components as they are + x -> + x end) - |> Enum.concat(quote do: [_org|_rest]) # ["com","*","pref"] -> must match ["com",_,"pref",_org|_rest] + # ["com","*","pref"] -> must match ["com",_,"pref",_org|_rest] + |> Enum.concat(quote do: [_org | _rest]) - org_len = length(spec) + 1 # and 3+1=4 first components is organization + # and 3+1=4 first components is organization + org_len = length(spec) + 1 - def organization(unquote(org_match)=host) do + def organization(unquote(org_match) = host) do host |> Enum.take(unquote(org_len)) |> Enum.reverse() @@ -52,13 +73,13 @@ defmodule DMARC do end end) - def organization([unknown_tld,org|_]) do + def organization([unknown_tld, org | _]) do "#{org}.#{unknown_tld}" end def organization(host) do host - |> Enum.reverse + |> Enum.reverse() |> Enum.join(".") end end diff --git a/lib/mime_types.ex b/lib/mime_types.ex index 5546010..ad8c06a 100644 --- a/lib/mime_types.ex +++ b/lib/mime_types.ex @@ -1,120 +1,169 @@ defmodule MimeTypes do - :ssl.start ; :inets.start - {ext2mime,mime2ext} = case :httpc.request('https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types') do - {:ok,{{_,200,_},_,r}} -> "#{r}" - _ -> File.read!("#{:code.priv_dir(:mailibex)}/mime.types") - end - |> String.trim() - |> String.split("\n") - |> Enum.filter(¬(Regex.match?(~r"^\s*#",&1)))#remove comments - |> Enum.reduce({%{}, []},fn line,{ext2mime,mime2ext}-> #construct dict and reverse dict ext->mime - [mime|exts] = line |> String.trim() |> String.split(~r/\s+/) - {Enum.into(Enum.map(exts, fn ext -> {ext, mime} end),ext2mime),[{mime,hd(exts)}|mime2ext]} - end) + :ssl.start() + :inets.start() + + {ext2mime, mime2ext} = + case :httpc.request( + ~c"https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types" + ) do + {:ok, {{_, 200, _}, _, r}} -> "#{r}" + _ -> File.read!("#{:code.priv_dir(:mailibex)}/mime.types") + end + |> String.trim() + |> String.split("\n") + # remove comments + |> Enum.filter(&(not Regex.match?(~r"^\s*#", &1))) + # construct dict and reverse dict ext->mime + |> Enum.reduce({%{}, []}, fn line, {ext2mime, mime2ext} -> + [mime | exts] = line |> String.trim() |> String.split(~r/\s+/) + + {Enum.into(Enum.map(exts, fn ext -> {ext, mime} end), ext2mime), + [{mime, hd(exts)} | mime2ext]} + end) def ext2mime(""), do: "text/plain" - ext2mime |> Enum.uniq_by(&elem(&1,0)) |> Enum.sort_by(& &1 |> elem(0) |> byte_size) |> Enum.reverse |> Enum.each(fn {ext,mime}-> - def ext2mime(unquote("."<>ext)), do: unquote(mime) + + ext2mime + |> Enum.uniq_by(&elem(&1, 0)) + |> Enum.sort_by(&(&1 |> elem(0) |> byte_size())) + |> Enum.reverse() + |> Enum.each(fn {ext, mime} -> + def ext2mime(unquote("." <> ext)), do: unquote(mime) end) + def ext2mime(_), do: "application/octet-stream" - Enum.each mime2ext, fn {mime,ext}-> - def mime2ext(unquote(mime)), do: unquote("."<>ext) - end + Enum.each(mime2ext, fn {mime, ext} -> + def mime2ext(unquote(mime)), do: unquote("." <> ext) + end) + def mime2ext(_), do: ".bin" - def path2mime(path), do: - (path |> Path.extname |> String.downcase |> ext2mime) - - def bin2ext(<<0x89,"PNG\r\n",0x1A,"\n",_::binary>>), do: ".png" - def bin2ext(<<0xFF,0xD8,0xFF,_,_,_,"JFIF\0",_::binary>>), do: ".jpg" - def bin2ext(<<"GIF8",v,"a",_::binary>>) when v in [?7,?9], do: ".gif" - def bin2ext(<<"BM",len::size(32)-little,_::binary>>=bin) when byte_size(bin) == len, do: ".bmp" - def bin2ext("8BPS"<>_), do: ".psd" - def bin2ext("II*"<>_), do: ".tiff" - def bin2ext(<<"RIFF",_len::size(32)-little,"AVI ",_::binary>>), do: ".avi" - def bin2ext(<<"RIFF",_len::size(32)-little,"WAVE",_::binary>>), do: ".wav" - def bin2ext(<<_::size(32),"ftyp",_::binary>>), do: ".mp4" - def bin2ext(<<1::size(24),streamid,_::binary>>) when streamid in [0xB3,0xBA], do: ".mpg" - def bin2ext(<<0b11111111111::size(11),mpeg::size(2),0b01::size(2),_::size(1),_::binary>>) when mpeg in [0b11,0b10], do: ".mp3" - def bin2ext(<<0b11111111111::size(11),mpeg::size(2),0b10::size(2),_::size(1),_::binary>>) when mpeg in [0b11,0b10], do: ".mp2" - def bin2ext(<<"MThd",6::size(32),_::binary>>), do: ".mid" - def bin2ext(<<"OggS",0,2,_::binary>>), do: ".ogg" - def bin2ext(<<0x3026B2758E66CF11A6D900AA0062CE6C::size(128),_::binary>>), do: ".wmv" #handle ASF only for wmv files (need parsing to find wma) - def bin2ext("fLaC"<>_), do: ".flac" - def bin2ext(<<".ra",0xfd,version::size(16),_::binary>>) when version in [3,4,5], do: ".ram" - def bin2ext(<<".RMF",0,0,0,_::binary>>), do: ".rm" - def bin2ext(<<0x1A,0x45,0xDF,0xA3,_::binary>>=bin) do #in case of ebml file, parse it to retrieve file type - case EBML.parse(bin)[:"EBML"].()[:"DocType"].() do - "matroska"->".mkv" - "webm"->".webm" + def path2mime(path), do: path |> Path.extname() |> String.downcase() |> ext2mime() + + def bin2ext(<<0x89, "PNG\r\n", 0x1A, "\n", _::binary>>), do: ".png" + def bin2ext(<<0xFF, 0xD8, 0xFF, _, _, _, "JFIF\0", _::binary>>), do: ".jpg" + def bin2ext(<<"GIF8", v, "a", _::binary>>) when v in [?7, ?9], do: ".gif" + + def bin2ext(<<"BM", len::size(32)-little, _::binary>> = bin) when byte_size(bin) == len, + do: ".bmp" + + def bin2ext("8BPS" <> _), do: ".psd" + def bin2ext("II*" <> _), do: ".tiff" + def bin2ext(<<"RIFF", _len::size(32)-little, "AVI ", _::binary>>), do: ".avi" + def bin2ext(<<"RIFF", _len::size(32)-little, "WAVE", _::binary>>), do: ".wav" + def bin2ext(<<_::size(32), "ftyp", _::binary>>), do: ".mp4" + def bin2ext(<<1::size(24), streamid, _::binary>>) when streamid in [0xB3, 0xBA], do: ".mpg" + + def bin2ext(<<0b11111111111::size(11), mpeg::size(2), 0b01::size(2), _::size(1), _::binary>>) + when mpeg in [0b11, 0b10], do: ".mp3" + + def bin2ext(<<0b11111111111::size(11), mpeg::size(2), 0b10::size(2), _::size(1), _::binary>>) + when mpeg in [0b11, 0b10], do: ".mp2" + + def bin2ext(<<"MThd", 6::size(32), _::binary>>), do: ".mid" + def bin2ext(<<"OggS", 0, 2, _::binary>>), do: ".ogg" + # handle ASF only for wmv files (need parsing to find wma) + def bin2ext(<<0x3026B2758E66CF11A6D900AA0062CE6C::size(128), _::binary>>), do: ".wmv" + def bin2ext("fLaC" <> _), do: ".flac" + def bin2ext(<<".ra", 0xFD, version::size(16), _::binary>>) when version in [3, 4, 5], do: ".ram" + def bin2ext(<<".RMF", 0, 0, 0, _::binary>>), do: ".rm" + # in case of ebml file, parse it to retrieve file type + def bin2ext(<<0x1A, 0x45, 0xDF, 0xA3, _::binary>> = bin) do + case EBML.parse(bin)[:EBML].()[:DocType].() do + "matroska" -> ".mkv" + "webm" -> ".webm" end end - def bin2ext(<<0x04034b50::size(32)-little,_::binary>>=zip) do - {:ok,ziph} = :zip.zip_open(zip,[:memory]) - {:ok,files} = :zip.zip_list_dir(ziph) - files = for {:zip_file,name,_,_,_,_}<-files, do: name - res = cond do - Enum.all?(['[Content_Types].xml','word/styles.xml'],&(&1 in files))-> ".docx" - Enum.all?(['[Content_Types].xml','xl/styles.xml'],&(&1 in files))-> ".xlsx" - Enum.all?(['[Content_Types].xml','ppt/presProps.xml'],&(&1 in files))-> ".pptx" - Enum.all?(['content.xml','styles.xml','META-INF/manifest.xml'],&(&1 in files))-> - {:ok,{_,manifest}}=:zip.zip_get('META-INF/manifest.xml',ziph) - case Regex.run(~r/media-type="([^"]*)"/,manifest) do #" - [_,"application/vnd.oasis.opendocument.text"]->".odt" - [_,"application/vnd.oasis.opendocument.presentation"]->".odp" - [_,"application/vnd.oasis.opendocument.spreadsheet"]->".ods" - [_,"application/vnd.oasis.opendocument.graphics"]->".odg" - [_,"application/vnd.oasis.opendocument.chart"]->".odc" - [_,"application/vnd.oasis.opendocument.formula"]->".odf" - [_,"application/vnd.oasis.opendocument.image"]->".odi" - [_,"application/vnd.oasis.opendocument.base"]->".odb" - [_,"application/vnd.oasis.opendocument.database"]->".odb" - end - 'META-INF/MANIFEST.MF' in files->".jar" - true -> ".zip" - end + + def bin2ext(<<0x04034B50::size(32)-little, _::binary>> = zip) do + {:ok, ziph} = :zip.zip_open(zip, [:memory]) + {:ok, files} = :zip.zip_list_dir(ziph) + files = for {:zip_file, name, _, _, _, _} <- files, do: name + + res = + cond do + Enum.all?([~c"[Content_Types].xml", ~c"word/styles.xml"], &(&1 in files)) -> + ".docx" + + Enum.all?([~c"[Content_Types].xml", ~c"xl/styles.xml"], &(&1 in files)) -> + ".xlsx" + + Enum.all?([~c"[Content_Types].xml", ~c"ppt/presProps.xml"], &(&1 in files)) -> + ".pptx" + + Enum.all?([~c"content.xml", ~c"styles.xml", ~c"META-INF/manifest.xml"], &(&1 in files)) -> + {:ok, {_, manifest}} = :zip.zip_get(~c"META-INF/manifest.xml", ziph) + # " + case Regex.run(~r/media-type="([^"]*)"/, manifest) do + [_, "application/vnd.oasis.opendocument.text"] -> ".odt" + [_, "application/vnd.oasis.opendocument.presentation"] -> ".odp" + [_, "application/vnd.oasis.opendocument.spreadsheet"] -> ".ods" + [_, "application/vnd.oasis.opendocument.graphics"] -> ".odg" + [_, "application/vnd.oasis.opendocument.chart"] -> ".odc" + [_, "application/vnd.oasis.opendocument.formula"] -> ".odf" + [_, "application/vnd.oasis.opendocument.image"] -> ".odi" + [_, "application/vnd.oasis.opendocument.base"] -> ".odb" + [_, "application/vnd.oasis.opendocument.database"] -> ".odb" + end + + ~c"META-INF/MANIFEST.MF" in files -> + ".jar" + + true -> + ".zip" + end + :zip.zip_close(ziph) res end - def bin2ext(<<"Rar!",0x1A,0x07,_::binary>>), do: ".rar" - def bin2ext(<<_::size(257)-binary,"ustar\000",_::binary>>), do: ".tar" - def bin2ext(<<31,139,_::binary>>), do: ".gz" - def bin2ext("BZh"<>_), do: ".bz2" - def bin2ext(<<"7z",0xBC,0xAF,0x27,0x1C,_::binary>>), do: ".7z" - def bin2ext("wOFF"<>_), do: ".woff" - def bin2ext("wOF2"<>_), do: ".woff" - def bin2ext(<<48,1::size(1)-unit(1),lenlen::size(7),len::size(lenlen)-unit(8),_::size(len)-binary>>), do: ".der" - def bin2ext(<<48,len,_::size(len)-binary>>), do: ".der" - def bin2ext("-----BEGIN CERTIFICATE-----"<>_), do: ".crt" - def bin2ext("-----BEGIN "<>_), do: ".pem" - def bin2ext(<<0xEF,0xBB,0xBF,rest::binary>>), do: bin2ext(rest) - def bin2ext(<<0xd0cf11e0a1b11ae1::size(64),_::binary>>), do: ".doc" #compound file is doc (do not handle .xls,.ppt) - def bin2ext("BEGIN:VCARD\r\nVERSION:"<>_), do: ".vcf" - def bin2ext("BEGIN:VCALENDAR"<>_), do: ".ics" - def bin2ext(<<"%PDF-",_v1,?.,_v2,_::binary>>), do: ".pdf" - def bin2ext("{\\rtf"<>_), do: ".rtf" - def bin2ext("{"<>_), do: ".json" - def bin2ext("["<>_), do: ".json" - def bin2ext("#!/"<>_), do: ".sh" - def bin2ext("_), do: ".html" - def bin2ext("_), do: ".html" - def bin2ext("_), do: ".svg" - def bin2ext("_), do: ".rss" - def bin2ext("_), do: ".xml" - def bin2ext("_), do: ".html" - def bin2ext("_), do: ".svg" - def bin2ext("_), do: ".rss" - def bin2ext(<<">) do + + def bin2ext(<<"Rar!", 0x1A, 0x07, _::binary>>), do: ".rar" + def bin2ext(<<_::size(257)-binary, "ustar\000", _::binary>>), do: ".tar" + def bin2ext(<<31, 139, _::binary>>), do: ".gz" + def bin2ext("BZh" <> _), do: ".bz2" + def bin2ext(<<"7z", 0xBC, 0xAF, 0x27, 0x1C, _::binary>>), do: ".7z" + def bin2ext("wOFF" <> _), do: ".woff" + def bin2ext("wOF2" <> _), do: ".woff" + + def bin2ext( + <<48, 1::size(1)-unit(1), lenlen::size(7), len::size(lenlen)-unit(8), + _::size(len)-binary>> + ), + do: ".der" + + def bin2ext(<<48, len, _::size(len)-binary>>), do: ".der" + def bin2ext("-----BEGIN CERTIFICATE-----" <> _), do: ".crt" + def bin2ext("-----BEGIN " <> _), do: ".pem" + def bin2ext(<<0xEF, 0xBB, 0xBF, rest::binary>>), do: bin2ext(rest) + # compound file is doc (do not handle .xls,.ppt) + def bin2ext(<<0xD0CF11E0A1B11AE1::size(64), _::binary>>), do: ".doc" + def bin2ext("BEGIN:VCARD\r\nVERSION:" <> _), do: ".vcf" + def bin2ext("BEGIN:VCALENDAR" <> _), do: ".ics" + def bin2ext(<<"%PDF-", _v1, ?., _v2, _::binary>>), do: ".pdf" + def bin2ext("{\\rtf" <> _), do: ".rtf" + def bin2ext("{" <> _), do: ".json" + def bin2ext("[" <> _), do: ".json" + def bin2ext("#!/" <> _), do: ".sh" + def bin2ext(" _), do: ".html" + def bin2ext(" _), do: ".html" + def bin2ext(" _), do: ".svg" + def bin2ext(" _), do: ".rss" + def bin2ext(" _), do: ".xml" + def bin2ext(" _), do: ".html" + def bin2ext(" _), do: ".svg" + def bin2ext(" _), do: ".rss" + + def bin2ext(<<">) do cond do - String.contains?(begin,"".svg" - String.contains?(begin,"".html" - String.contains?(begin,"".rss" - true->".xml" + String.contains?(begin, " ".svg" + String.contains?(begin, " ".html" + String.contains?(begin, " ".rss" + true -> ".xml" end end - def bin2ext(content), do: - if(String.printable?(content), do: ".txt", else: ".bin") + + def bin2ext(content), do: if(String.printable?(content), do: ".txt", else: ".bin") end defmodule EBML do @@ -129,48 +178,80 @@ defmodule EBML do > EBML.parse(File.read!("sample.mkv"))[:"EBML"].()[:"DocType"].() "matroska" """ - def parse(bin), do: parse(bin,[]) - def parse("",acc), do: Enum.reverse(acc) - def parse(bin,acc) do - {class,bin} = case bin do - <<1::size(1),data::size(7),tail::binary>> -> {<<1::size(1),data::size(7)>>,tail} - <<1::size(2),data::size(14),tail::binary>>->{<<1::size(2),data::size(14)>>,tail} - <<1::size(3),data::size(21),tail::binary>>->{<<1::size(3),data::size(21)>>,tail} - <<1::size(4),data::size(28),tail::binary>>->{<<1::size(4),data::size(28)>>,tail} - end - {len,data,bin} = case bin do - <<1::size(1),len::size(7),data::size(len)-binary,tail::binary>>->{len,data,tail} - <<1::size(2),len::size(14),data::size(len)-binary,tail::binary>>->{len,data,tail} - <<1::size(3),len::size(21),data::size(len)-binary,tail::binary>>->{len,data,tail} - <<1::size(4),len::size(28),data::size(len)-binary,tail::binary>>->{len,data,tail} - <<1::size(5),len::size(35),data::size(len)-binary,tail::binary>>->{len,data,tail} - <<1::size(6),len::size(42),data::size(len)-binary,tail::binary>>->{len,data,tail} - <<1::size(7),len::size(49),data::size(len)-binary,tail::binary>>->{len,data,tail} - <<1::size(8),len::size(56),data::size(len)-binary,tail::binary>>->{len,data,tail} - end - {key,type} = class|>Base.encode16|>key_of - parse(bin,[{:"#{key}",fn -> convert(type,len,data) end}|acc]) + def parse(bin), do: parse(bin, []) + def parse("", acc), do: Enum.reverse(acc) + + def parse(bin, acc) do + {class, bin} = + case bin do + <<1::size(1), data::size(7), tail::binary>> -> {<<1::size(1), data::size(7)>>, tail} + <<1::size(2), data::size(14), tail::binary>> -> {<<1::size(2), data::size(14)>>, tail} + <<1::size(3), data::size(21), tail::binary>> -> {<<1::size(3), data::size(21)>>, tail} + <<1::size(4), data::size(28), tail::binary>> -> {<<1::size(4), data::size(28)>>, tail} + end + + {len, data, bin} = + case bin do + <<1::size(1), len::size(7), data::size(len)-binary, tail::binary>> -> {len, data, tail} + <<1::size(2), len::size(14), data::size(len)-binary, tail::binary>> -> {len, data, tail} + <<1::size(3), len::size(21), data::size(len)-binary, tail::binary>> -> {len, data, tail} + <<1::size(4), len::size(28), data::size(len)-binary, tail::binary>> -> {len, data, tail} + <<1::size(5), len::size(35), data::size(len)-binary, tail::binary>> -> {len, data, tail} + <<1::size(6), len::size(42), data::size(len)-binary, tail::binary>> -> {len, data, tail} + <<1::size(7), len::size(49), data::size(len)-binary, tail::binary>> -> {len, data, tail} + <<1::size(8), len::size(56), data::size(len)-binary, tail::binary>> -> {len, data, tail} + end + + {key, type} = class |> Base.encode16() |> key_of() + parse(bin, [{:"#{key}", fn -> convert(type, len, data) end} | acc]) end - defp convert(:master,_,bin), do: parse(bin) - defp convert(:integer,len,bin), do: (<>=bin;i) - defp convert(:uinteger,len,bin), do: (<>=bin;i) - defp convert(:float,len,bin), do: (<>=bin;f) - defp convert(:string,_,bin), do: String.trim(bin, "\0") - defp convert(:"utf-8",_,bin), do: String.trim(bin, "\0") - defp convert(:binary,_,bin), do: bin - defp convert(:date,8,<>) do - ts = 978307200 + div(since2001,1_000_000_000) - :calendar.now_to_datetime {div(ts,1_000_000),rem(ts,1_000_000),0} + defp convert(:master, _, bin), do: parse(bin) + + defp convert(:integer, len, bin), + do: + ( + <> = bin + i + ) + + defp convert(:uinteger, len, bin), + do: + ( + <> = bin + i + ) + + defp convert(:float, len, bin), + do: + ( + <> = bin + f + ) + + defp convert(:string, _, bin), do: String.trim(bin, "\0") + defp convert(:"utf-8", _, bin), do: String.trim(bin, "\0") + defp convert(:binary, _, bin), do: bin + + defp convert(:date, 8, <>) do + ts = 978_307_200 + div(since2001, 1_000_000_000) + :calendar.now_to_datetime({div(ts, 1_000_000), rem(ts, 1_000_000), 0}) end - ebml_spec = case :httpc.request('https://raw.githubusercontent.com/Matroska-Org/foundation-source/master/spectool/specdata.xml') do - {:ok,{{_,200,_},_,r}} -> "#{r}" - _ -> File.read!("#{:code.priv_dir(:mailibex)}/ebml.xml") - end - Regex.scan(~r/]*name="([^"]*)"[^>]* id="0x([^"]*)"[^>]* type="([^"]*)"[^>]*>/,ebml_spec) #" - |> Enum.each(fn [_,key,hexkey,type]-> - def key_of(unquote(hexkey)), do: {unquote(key),unquote(:"#{type}")} + ebml_spec = + case :httpc.request( + ~c"https://raw.githubusercontent.com/Matroska-Org/foundation-source/master/spectool/specdata.xml" + ) do + {:ok, {{_, 200, _}, _, r}} -> "#{r}" + _ -> File.read!("#{:code.priv_dir(:mailibex)}/ebml.xml") + end + + # " + Regex.scan( + ~r/]*name="([^"]*)"[^>]* id="0x([^"]*)"[^>]* type="([^"]*)"[^>]*>/, + ebml_spec + ) + |> Enum.each(fn [_, key, hexkey, type] -> + def key_of(unquote(hexkey)), do: {unquote(key), unquote(:"#{type}")} end) end - diff --git a/lib/mimemail.ex b/lib/mimemail.ex index e92d2d1..aae64b6 100644 --- a/lib/mimemail.ex +++ b/lib/mimemail.ex @@ -1,168 +1,237 @@ defmodule MimeMail do - @type header :: {:raw,binary} | MimeMail.Header.t #ever the raw line or any term implementing MimeMail.Header.to_ascii - @type body :: binary | [MimeMail.t] | {:raw,binary} #ever the raw body or list of mail for multipart or binary for decoded content - @type t :: %MimeMail{headers: [{key::binary,header}], body: body} + # ever the raw line or any term implementing MimeMail.Header.to_ascii + @type header :: {:raw, binary} | MimeMail.Header.t() + # ever the raw body or list of mail for multipart or binary for decoded content + @type body :: binary | [MimeMail.t()] | {:raw, binary} + @type t :: %MimeMail{headers: [{key :: binary, header}], body: body} defstruct headers: [], body: "" @behaviour Access - defdelegate get_and_update(dict,k,v), to: Map - defdelegate fetch(dict,k), to: Map - defdelegate get(dict,k,v), to: Map - defdelegate pop(dict,k), to: Map + defdelegate get_and_update(dict, k, v), to: Map + defdelegate fetch(dict, k), to: Map + defdelegate get(dict, k, v), to: Map + defdelegate pop(dict, k), to: Map + + def fix_linebreak(data), do: Regex.replace(~r/(? two_parts - _one_part = [h] -> [h, ""] - end - headers=headers - |> String.replace(~r/\r\n([^\t ])/,"\r\n!\\1") - |> String.split("\r\n!") - |> Enum.map(&{String.split(&1,~r/\s*:/,parts: 2),&1}) - headers=for {[k,_],v}<-headers, do: {:"#{String.downcase(k)}", {:raw,v}} - %MimeMail{headers: headers, body: {:raw,body}} + + [headers, body] = + case String.split(data, "\r\n\r\n", parts: 2) do + two_parts = [_, _] -> two_parts + _one_part = [h] -> [h, ""] + end + + headers = + headers + |> String.replace(~r/\r\n([^\t ])/, "\r\n!\\1") + |> String.split("\r\n!") + |> Enum.map(&{String.split(&1, ~r/\s*:/, parts: 2), &1}) + + headers = for {[k, _], v} <- headers, do: {:"#{String.downcase(k)}", {:raw, v}} + %MimeMail{headers: headers, body: {:raw, body}} end - def to_string(%MimeMail{}=mail) do - %{body: {:raw,body},headers: headers} = mail |> encode_body |> encode_headers - headers = for({_k,{:raw,v}}<-headers,do: v) |> Enum.join("\r\n") + def to_string(%MimeMail{} = mail) do + %{body: {:raw, body}, headers: headers} = mail |> encode_body() |> encode_headers() + headers = for({_k, {:raw, v}} <- headers, do: v) |> Enum.join("\r\n") headers <> "\r\n\r\n" <> body end - def decode_headers(%MimeMail{}=mail,decoders) do - Enum.reduce(decoders,mail,fn decoder,acc-> decoder.decode_headers(acc) end) + def decode_headers(%MimeMail{} = mail, decoders) do + Enum.reduce(decoders, mail, fn decoder, acc -> decoder.decode_headers(acc) end) end - def encode_headers(%MimeMail{headers: headers}=mail) do - %{mail|headers: for({k,v}<-headers, do: {k,encode_header(k,v)})} + + def encode_headers(%MimeMail{headers: headers} = mail) do + %{mail | headers: for({k, v} <- headers, do: {k, encode_header(k, v)})} end - def ok_or({:ok,res},_), do: res - def ok_or(_,default), do: default + def ok_or({:ok, res}, _), do: res + def ok_or(_, default), do: default - def decode_body(%MimeMail{body: {:raw,body}}=mail) do + def decode_body(%MimeMail{body: {:raw, body}} = mail) do %{headers: headers} = mail = MimeMail.CTParams.decode_headers(mail) - body = case headers[:'content-transfer-encoding'] do - {"quoted-printable",_}-> body |> qp_to_binary - {"base64",_}-> body |> String.replace(~r/\s/,"") |> Base.decode64 |> ok_or("") - _ -> body - end - body = case headers[:'content-type'] do - {"multipart/"<>_,%{boundary: bound}}-> - escaped_bound = Regex.escape(bound) - body - |> String.split(~r"\s*--#{escaped_bound}\s*") - |> Enum.slice(1..-2) - |> Enum.map(&from_string/1) + + body = + case headers[:"content-transfer-encoding"] do + {"quoted-printable", _} -> body |> qp_to_binary() + {"base64", _} -> body |> String.replace(~r/\s/, "") |> Base.decode64() |> ok_or("") + _ -> body + end + + body = + case headers[:"content-type"] do + {"multipart/" <> _, %{boundary: bound}} -> + escaped_bound = Regex.escape(bound) + + body + |> String.split(~r"\s*--#{escaped_bound}\s*") + |> Enum.drop(-1) + |> Enum.drop(1) + |> Enum.map(&from_string/1) |> Enum.map(&decode_body/1) - {"text/"<>_,%{charset: charset}} -> - body |> Iconv.conv(charset,"utf8") |> ok_or(ensure_ascii(body)) |> ensure_utf8 - _ -> body - end - %{mail|body: body} + + {"text/" <> _, %{charset: charset}} -> + body |> Iconv.conv(charset, "utf8") |> ok_or(ensure_ascii(body)) |> ensure_utf8() + + _ -> + body + end + + %{mail | body: body} end - def decode_body(%MimeMail{body: _}=mail), do: mail - def encode_body(%MimeMail{body: {:raw,_body}}=mail), do: mail - def encode_body(%MimeMail{body: body}=mail) when is_binary(body) do + def decode_body(%MimeMail{body: _} = mail), do: mail + + def encode_body(%MimeMail{body: {:raw, _body}} = mail), do: mail + + def encode_body(%MimeMail{body: body} = mail) when is_binary(body) do mail = MimeMail.CTParams.decode_headers(mail) - case mail.headers[:'content-type'] do - {"text/"<>_=type,params}-> - headers = Keyword.drop(mail.headers,[:'content-type',:'content-transfer-encoding']) ++[ - 'content-type': {type,Map.put(params,:charset,"utf-8")}, - 'content-transfer-encoding': "quoted-printable" - ] - %{mail|headers: headers, body: {:raw,string_to_qp(body)}} - _-> - headers = Keyword.delete(mail.headers,:'content-transfer-encoding') - ++['content-transfer-encoding': "base64"] - %{mail|headers: headers, body: {:raw,(body |> Base.encode64 |> chunk64 |> Enum.join("\r\n"))}} + + case mail.headers[:"content-type"] do + {"text/" <> _ = type, params} -> + headers = + Keyword.drop(mail.headers, [:"content-type", :"content-transfer-encoding"]) ++ + [ + "content-type": {type, Map.put(params, :charset, "utf-8")}, + "content-transfer-encoding": "quoted-printable" + ] + + %{mail | headers: headers, body: {:raw, string_to_qp(body)}} + + _ -> + headers = + Keyword.delete(mail.headers, :"content-transfer-encoding") ++ + ["content-transfer-encoding": "base64"] + + %{ + mail + | headers: headers, + body: {:raw, body |> Base.encode64() |> chunk64() |> Enum.join("\r\n")} + } end end - def encode_body(%MimeMail{body: childs}=mail) when is_list(childs) do + + def encode_body(%MimeMail{body: childs} = mail) when is_list(childs) do mail = MimeMail.CTParams.decode_headers(mail) boundary = Base.encode16(:crypto.strong_rand_bytes(20), case: :lower) full_boundary = "--#{boundary}" - {"multipart/"<>_=type,params} = mail.headers[:'content-type'] - headers = Keyword.delete(mail.headers,:'content-type') - ++['content-type': {type,Map.put(params,:boundary,boundary)}] - body = childs |> Enum.map(&MimeMail.to_string/1) |> Enum.join("\r\n\r\n"<>full_boundary<>"\r\n") - %{mail|body: {:raw,"#{full_boundary}\r\n#{body}\r\n\r\n#{full_boundary}--\r\n"}, headers: headers} + {"multipart/" <> _ = type, params} = mail.headers[:"content-type"] + + headers = + Keyword.delete(mail.headers, :"content-type") ++ + ["content-type": {type, Map.put(params, :boundary, boundary)}] + + body = + childs + |> Enum.map(&MimeMail.to_string/1) + |> Enum.join("\r\n\r\n" <> full_boundary <> "\r\n") + + %{ + mail + | body: {:raw, "#{full_boundary}\r\n#{body}\r\n\r\n#{full_boundary}--\r\n"}, + headers: headers + } end - defp chunk64(<>), do: [vline|chunk64(rest)] + defp chunk64(<>), do: [vline | chunk64(rest)] defp chunk64(other), do: [other] def string_to_qp(str) do - str |> String.split("\r\n") |> Enum.map(fn line-> - {eol,line} = '#{line}' |> Enum.reverse |> Enum.split_while(&(&1==?\t or &1==?\s)) - Enum.concat(Enum.map(eol,&char_to_qp/1),Enum.map(line,fn - c when c == ?\t or (c < 127 and c > 31 and c !== ?=) -> c - c -> char_to_qp(c) - end)) |> Enum.reverse |> Kernel.to_string |> chunk_line - end) |> Enum.join("\r\n") + str + |> String.split("\r\n") + |> Enum.map(fn line -> + {eol, line} = ~c"#{line}" |> Enum.reverse() |> Enum.split_while(&(&1 == ?\t or &1 == ?\s)) + + Enum.concat( + Enum.map(eol, &char_to_qp/1), + Enum.map(line, fn + c when c == ?\t or (c < 127 and c > 31 and c !== ?=) -> c + c -> char_to_qp(c) + end) + ) + |> Enum.reverse() + |> Kernel.to_string() + |> chunk_line() + end) + |> Enum.join("\r\n") end - defp char_to_qp(char), do: for(<>)>>,into: "",do: <>) - defp chunk_line(<>), do: (vline<>"=\r\n"<>chunk_line("="<>rest)) - defp chunk_line(<>), do: (vline<>"=\r\n"<>chunk_line("="<>rest)) - defp chunk_line(<>), do: (vline<>"=\r\n"<>chunk_line(rest)) + + defp char_to_qp(char), + do: for(<>)>>, into: "", do: <>) + + defp chunk_line(<>), + do: vline <> "=\r\n" <> chunk_line("=" <> rest) + + defp chunk_line(<>), + do: vline <> "=\r\n" <> chunk_line("=" <> rest) + + defp chunk_line(<>), + do: vline <> "=\r\n" <> chunk_line(rest) + defp chunk_line(other), do: other - - def qp_to_binary(str), do: - (str |> String.trim_trailing() |> String.trim_trailing("=") |> qp_to_binary([])) - def qp_to_binary("=\r\n"<>rest,acc), do: - qp_to_binary(rest,acc) - def qp_to_binary(<><>rest,acc), do: - qp_to_binary(rest,[<> |> String.upcase |> Base.decode16! | acc]) - def qp_to_binary(<>,acc), do: - qp_to_binary(rest,[c | acc]) - def qp_to_binary("",acc), do: - (acc |> Enum.reverse |> IO.iodata_to_binary) - - def unfold_header(value), do: - String.replace(value,~r/\r\n([\t ])/,"\\1") - - def fold_header(header), do: - (header |> String.split("\r\n") |> Enum.map(&fold_header(&1,[])) |> Enum.join("\r\n")) - def fold_header(<>,acc) do - case (to_charlist(line) |> Enum.reverse |> Enum.split_while(&(&1!==?\t and &1!==?\s))) do - {eol,[]}-> fold_header(rest,[eol|acc]) - {eol,bol}-> fold_header("#{Enum.reverse(eol)}"<>rest,["\r\n ",bol|acc]) + + def qp_to_binary(str), + do: str |> String.trim_trailing() |> String.trim_trailing("=") |> qp_to_binary([]) + + def qp_to_binary("=\r\n" <> rest, acc), do: qp_to_binary(rest, acc) + + def qp_to_binary(<> <> rest, acc), + do: qp_to_binary(rest, [<> |> String.upcase() |> Base.decode16!() | acc]) + + def qp_to_binary(<>, acc), do: qp_to_binary(rest, [c | acc]) + def qp_to_binary("", acc), do: acc |> Enum.reverse() |> IO.iodata_to_binary() + + def unfold_header(value), do: String.replace(value, ~r/\r\n([\t ])/, "\\1") + + def fold_header(header), + do: header |> String.split("\r\n") |> Enum.map(&fold_header(&1, [])) |> Enum.join("\r\n") + + def fold_header(<>, acc) do + case to_charlist(line) |> Enum.reverse() |> Enum.split_while(&(&1 !== ?\t and &1 !== ?\s)) do + {eol, []} -> fold_header(rest, [eol | acc]) + {eol, bol} -> fold_header("#{Enum.reverse(eol)}" <> rest, ["\r\n ", bol | acc]) end end - def fold_header(other,acc), do: - ((acc |> List.flatten |> Enum.reverse |> Kernel.to_string)<>other) - def header_value({:raw,value}), do: header_value(value) + def fold_header(other, acc), + do: (acc |> List.flatten() |> Enum.reverse() |> Kernel.to_string()) <> other + + def header_value({:raw, value}), do: header_value(value) + def header_value(value) do - case String.split(value,~r/:\s*/, parts: 2) do - [_]->"" - [_,v]-> unfold_header(v) + case String.split(value, ~r/:\s*/, parts: 2) do + [_] -> "" + [_, v] -> unfold_header(v) end end - def ensure_ascii(bin), do: - Kernel.to_string(for(<>, (c<127 and c>31) or c in [?\t,?\r,?\n], do: c)) + def ensure_ascii(bin), + do: Kernel.to_string(for(<>, (c < 127 and c > 31) or c in [?\t, ?\r, ?\n], do: c)) + def ensure_utf8(bin) do - bin + bin |> String.chunk(:printable) |> Enum.filter(&String.printable?/1) - |> Kernel.to_string + |> Kernel.to_string() end - def encode_header(_,{:raw,value}), do: {:raw,value} - def encode_header(key,value) do - key = key |> encode_header_key |> ensure_ascii - value = value |> MimeMail.Header.to_ascii |> ensure_ascii |> fold_header - {:raw,"#{key}: #{value}"} + def encode_header(_, {:raw, value}), do: {:raw, value} + + def encode_header(key, value) do + key = key |> encode_header_key() |> ensure_ascii() + value = value |> MimeMail.Header.to_ascii() |> ensure_ascii() |> fold_header() + {:raw, "#{key}: #{value}"} end - def encode_header_key(key), do: - (String.split("#{key}","-") |> Enum.map(&header_key/1) |> Enum.join("-")) - def header_key(word) when word in ["dkim","spf","x","id","mime"], do: #acronym, upcase - String.upcase(word) - def header_key(word), do: # not acronym, camelcase - String.capitalize(word) + + def encode_header_key(key), + do: String.split("#{key}", "-") |> Enum.map(&header_key/1) |> Enum.join("-") + + # acronym, upcase + def header_key(word) when word in ["dkim", "spf", "x", "id", "mime"], do: String.upcase(word) + # not acronym, camelcase + def header_key(word), do: String.capitalize(word) end defprotocol MimeMail.Header do @@ -172,10 +241,14 @@ end defmodule Iconv do @on_load :init def init do - ret = :erlang.load_nif('#{:code.priv_dir(:mailibex)}/Elixir.Iconv_nif',0) + ret = :erlang.load_nif(~c"#{:code.priv_dir(:mailibex)}/Elixir.Iconv_nif", 0) + case ret do - {:error, {:load_failed, _} }-> - if Code.ensure_loaded?(Codepagex), do: :ok, else: {:error, "Codepagex is not available. Cannot fallback."} + {:error, {:load_failed, _}} -> + if Code.ensure_loaded?(Codepagex), + do: :ok, + else: {:error, "Codepagex is not available. Cannot fallback."} + other -> other end @@ -186,19 +259,28 @@ defmodule Iconv do Fallback to Codepagex when iconv is unavailable """ - @spec conv(binary, binary, binary) :: binary | {:error, term} | {:error, binary, term} | {:incomplete, binary, binary} + @spec conv(binary, binary, binary) :: + binary | {:error, term} | {:error, binary, term} | {:incomplete, binary, binary} def conv(str, from, _to = "utf8") do - from = from |> String.replace(~r|[-/]|, "_") |> String.downcase + from = from |> String.replace(~r|[-/]|, "_") |> String.downcase() + case from do - "utf_8" -> str - "utf_" <> _bit -> :unicode.characters_to_binary(str) - "us_ascii" -> Codepagex.to_string!(str, :ascii) + "utf_8" -> + str + + "utf_" <> _bit -> + :unicode.characters_to_binary(str) + + "us_ascii" -> + Codepagex.to_string!(str, :ascii) + other_from -> - case Codepagex.to_string(str, other_from, Codepagex.use_utf_replacement) do + case Codepagex.to_string(str, other_from, Codepagex.use_utf_replacement()) do {:ok, utf8_binary, _} -> utf8_binary {:error, term, _} -> {:error, term} end end end + def conv(_str, _from, _to), do: exit(:nif_library_not_loaded) end diff --git a/lib/mimemail_flat.ex b/lib/mimemail_flat.ex index 3e8a450..5e5facb 100644 --- a/lib/mimemail_flat.ex +++ b/lib/mimemail_flat.ex @@ -1,137 +1,189 @@ defmodule MimeMail.Flat do def to_mail(headers_flat_body) do - {flat_body,headers} = Enum.split_with(headers_flat_body,fn {k,_}->k in [:txt,:html,:ical,:attach,:include,:attach_in] end) - htmlcontent = mail_htmlcontent(flat_body[:html],for({:include,v}<-flat_body,do: expand_attached(v))) + {flat_body, headers} = + Enum.split_with(headers_flat_body, fn {k, _} -> + k in [:txt, :html, :ical, :attach, :include, :attach_in] + end) + + htmlcontent = + mail_htmlcontent(flat_body[:html], for({:include, v} <- flat_body, do: expand_attached(v))) + plaincontent = mail_plaincontent(flat_body[:txt]) icalcontent = mail_icalcontent(flat_body[:ical]) - content = mail_content([plaincontent,htmlcontent,icalcontent] |> Enum.reject(&is_nil/1)) - %{headers: bodyheaders, body: body} = mail_final(content,for({:attach,v}<-flat_body,do: expand_attached(v)), - for({:attach_in,v}<-flat_body,do: expand_attached(v))) - %MimeMail{headers: headers++bodyheaders, body: body} + content = mail_content([plaincontent, htmlcontent, icalcontent] |> Enum.reject(&is_nil/1)) + + %{headers: bodyheaders, body: body} = + mail_final( + content, + for({:attach, v} <- flat_body, do: expand_attached(v)), + for({:attach_in, v} <- flat_body, do: expand_attached(v)) + ) + + %MimeMail{headers: headers ++ bodyheaders, body: body} end - def from_mail(%MimeMail{}=mail) do + def from_mail(%MimeMail{} = mail) do mail - |> MimeMail.decode_body - |> find_bodies + |> MimeMail.decode_body() + |> find_bodies() |> Enum.concat(mail.headers) - |> Enum.filter(fn {k,_}-> k not in [:inline,:'content-type',:'content-disposition',:'content-transfer-encoding',:'content-id'] end) + |> Enum.filter(fn {k, _} -> + k not in [ + :inline, + :"content-type", + :"content-disposition", + :"content-transfer-encoding", + :"content-id" + ] + end) end - def update_mail(%MimeMail{}=mail,updatefn) do - mail |> from_mail |> updatefn.() |> to_mail + def update_mail(%MimeMail{} = mail, updatefn) do + mail |> from_mail() |> updatefn.() |> to_mail() end - defp expand_attached({_id,_ct,_body}=attached), do: - attached - defp expand_attached({id,body}), do: - {id,MimeTypes.path2mime(id),body} - defp expand_attached(body) when is_binary(body), do: - expand_attached({gen_id(MimeTypes.bin2ext(body)),body}) + defp expand_attached({_id, _ct, _body} = attached), do: attached + defp expand_attached({id, body}), do: {id, MimeTypes.path2mime(id), body} + + defp expand_attached(body) when is_binary(body), + do: expand_attached({gen_id(MimeTypes.bin2ext(body)), body}) + + def find_bodies(childs) when is_list(childs), + do: List.flatten(for(child <- childs, do: find_bodies(child))) - def find_bodies(childs) when is_list(childs), do: - List.flatten(for(child<-childs, do: find_bodies(child))) def find_bodies(%MimeMail{headers: headers, body: body}) do - find_bodies(headers[:'content-type'],headers[:'content-disposition'],headers[:'content-id'],body) + find_bodies( + headers[:"content-type"], + headers[:"content-disposition"], + headers[:"content-id"], + body + ) end - def find_bodies({"multipart/mixed",_},_,_,childs) do - find_bodies(childs) |> Enum.map(fn - {:inline,{_,_,_}=child}->{:attach_in,child} - {_,{_,_,_}=child}->{:attach,child} - txt_or_html->txt_or_html + def find_bodies({"multipart/mixed", _}, _, _, childs) do + find_bodies(childs) + |> Enum.map(fn + {:inline, {_, _, _} = child} -> {:attach_in, child} + {_, {_, _, _} = child} -> {:attach, child} + txt_or_html -> txt_or_html end) end - def find_bodies({"multipart/related",_},_,_,childs) do - find_bodies(childs) |> Enum.map(fn - {_,{_,_,_}=child}->{:include,child} - other->other + + def find_bodies({"multipart/related", _}, _, _, childs) do + find_bodies(childs) + |> Enum.map(fn + {_, {_, _, _} = child} -> {:include, child} + other -> other end) end - def find_bodies({"multipart/alternative",_},_,_,childs) do + + def find_bodies({"multipart/alternative", _}, _, _, childs) do find_bodies(childs) end + # default content type is content/plain : - def find_bodies(nil,cd,id,body), do: - find_bodies({"content/plain",%{}},cd,id,body) + def find_bodies(nil, cd, id, body), do: find_bodies({"content/plain", %{}}, cd, id, body) # cases where html and txt are not attachements - def find_bodies({"text/html",_},{"inline",_},_,body), do: - [html: body] - def find_bodies({"text/html",_},nil,_,body), do: - [html: body] - def find_bodies({"text/plain",_},{"inline",_},_,body), do: - [txt: body] - def find_bodies({"text/plain",_},nil,_,body), do: - [txt: body] - def find_bodies({"text/calendar",%{method: method}},{"inline",_},_,body), do: - [ical: {method,body}] - def find_bodies({"text/calendar",%{method: method}},nil,_,body), do: - [ical: {method,body}] + def find_bodies({"text/html", _}, {"inline", _}, _, body), do: [html: body] + def find_bodies({"text/html", _}, nil, _, body), do: [html: body] + def find_bodies({"text/plain", _}, {"inline", _}, _, body), do: [txt: body] + def find_bodies({"text/plain", _}, nil, _, body), do: [txt: body] + + def find_bodies({"text/calendar", %{method: method}}, {"inline", _}, _, body), + do: [ical: {method, body}] + + def find_bodies({"text/calendar", %{method: method}}, nil, _, body), do: [ical: {method, body}] # default disposition is attachments, default id is name or guess from mime - def find_bodies(ct,nil,id,body), do: - find_bodies(ct,{"attachment",%{}},id,body) - def find_bodies({mime,ctparams}=ct,{_,cdparams}=cd,nil,body), do: - find_bodies(ct,cd,{"<#{ctparams[:name]||cdparams[:filename]||gen_id(MimeTypes.mime2ext(mime))}>",%{}},body) - def find_bodies({mime,_},{"inline",_},{id,_},body), do: - [inline: {(id |> String.trim_trailing(">") |> String.trim_leading("<")),mime,body}] - def find_bodies({mime,_},{"attachment",_},{id,_},body), do: - [attach: {(id |> String.trim_trailing(">") |> String.trim_leading("<")),mime,body}] - - def gen_id(ext), do: - "#{Base.encode16(:crypto.strong_rand_bytes(16), case: :lower)}#{ext}" - - defp mail_htmlcontent(nil,_), do: nil - defp mail_htmlcontent(body,[]), do: - %MimeMail{headers: ['content-type': {"text/html",%{}}], body: body} - defp mail_htmlcontent(body,included), do: - %MimeMail{ - headers: ['content-type': {"multipart/related",%{}}], - body: [mail_htmlcontent(body,[]) | for {id,contenttype,binary}<-included do - %MimeMail{ - headers: ['content-type': {contenttype,%{name: id}}, - 'content-disposition': {"inline",%{filename: id}}, - 'content-id': "<#{id}>"], - body: binary - } - end] + def find_bodies(ct, nil, id, body), do: find_bodies(ct, {"attachment", %{}}, id, body) + + def find_bodies({mime, ctparams} = ct, {_, cdparams} = cd, nil, body), + do: + find_bodies( + ct, + cd, + {"<#{ctparams[:name] || cdparams[:filename] || gen_id(MimeTypes.mime2ext(mime))}>", %{}}, + body + ) + + def find_bodies({mime, _}, {"inline", _}, {id, _}, body), + do: [inline: {id |> String.trim_trailing(">") |> String.trim_leading("<"), mime, body}] + + def find_bodies({mime, _}, {"attachment", _}, {id, _}, body), + do: [attach: {id |> String.trim_trailing(">") |> String.trim_leading("<"), mime, body}] + + def gen_id(ext), do: "#{Base.encode16(:crypto.strong_rand_bytes(16), case: :lower)}#{ext}" + + defp mail_htmlcontent(nil, _), do: nil + + defp mail_htmlcontent(body, []), + do: %MimeMail{headers: ["content-type": {"text/html", %{}}], body: body} + + defp mail_htmlcontent(body, included), + do: %MimeMail{ + headers: ["content-type": {"multipart/related", %{}}], + body: [ + mail_htmlcontent(body, []) + | for {id, contenttype, binary} <- included do + %MimeMail{ + headers: [ + "content-type": {contenttype, %{name: id}}, + "content-disposition": {"inline", %{filename: id}}, + "content-id": "<#{id}>" + ], + body: binary + } + end + ] } defp mail_plaincontent(nil), do: nil - defp mail_plaincontent(body), do: - %MimeMail{headers: ['content-type': {"text/plain",%{}}], body: body} + + defp mail_plaincontent(body), + do: %MimeMail{headers: ["content-type": {"text/plain", %{}}], body: body} defp mail_icalcontent(nil), do: nil - defp mail_icalcontent(body) when is_binary(body), do: mail_icalcontent({:request,body}) - defp mail_icalcontent({method,body}), do: - %MimeMail{headers: ['content-type': {"text/calendar",%{method: String.upcase("#{method}")}}], body: body} + defp mail_icalcontent(body) when is_binary(body), do: mail_icalcontent({:request, body}) + + defp mail_icalcontent({method, body}), + do: %MimeMail{ + headers: ["content-type": {"text/calendar", %{method: String.upcase("#{method}")}}], + body: body + } defp mail_content([]), do: mail_plaincontent(" ") defp mail_content([singlecontent]), do: singlecontent - defp mail_content([_|_]=contents), do: - %MimeMail{ - headers: ['content-type': {"multipart/alternative",%{}}], - body: contents + + defp mail_content([_ | _] = contents), + do: %MimeMail{ + headers: ["content-type": {"multipart/alternative", %{}}], + body: contents } - defp mail_final(content,[],[]), do: content - defp mail_final(content,attached,attached_in), do: - %MimeMail{ - headers: ['content-type': {"multipart/mixed",%{}}], - body: [content | - for {name,contenttype,binary}<-attached do - %MimeMail{ - headers: ['content-type': {contenttype,%{name: name}}, - 'content-disposition': {"attachment",%{filename: name}}], - body: binary - } - end ++ - for {name,contenttype,binary}<-attached_in do - %MimeMail{ - headers: ['content-type': {contenttype,%{name: name}}, - 'content-disposition': {"inline",%{filename: name}}], - body: binary - } - end - ] + defp mail_final(content, [], []), do: content + + defp mail_final(content, attached, attached_in), + do: %MimeMail{ + headers: ["content-type": {"multipart/mixed", %{}}], + body: [ + content + | for {name, contenttype, binary} <- attached do + %MimeMail{ + headers: [ + "content-type": {contenttype, %{name: name}}, + "content-disposition": {"attachment", %{filename: name}} + ], + body: binary + } + end ++ + for {name, contenttype, binary} <- attached_in do + %MimeMail{ + headers: [ + "content-type": {contenttype, %{name: name}}, + "content-disposition": {"inline", %{filename: name}} + ], + body: binary + } + end + ] } end diff --git a/lib/mimemail_headers.ex b/lib/mimemail_headers.ex index 5fcdae2..965bf84 100644 --- a/lib/mimemail_headers.ex +++ b/lib/mimemail_headers.ex @@ -2,16 +2,17 @@ defmodule MimeMail.Address do defstruct name: nil, address: "" def decode(addr_spec) do - case Regex.run(~r/^([^<]*)<([^>]*)>/,addr_spec) do - [_,desc,addr]->%MimeMail.Address{name: MimeMail.Words.word_decode(desc), address: addr} + case Regex.run(~r/^([^<]*)<([^>]*)>/, addr_spec) do + [_, desc, addr] -> %MimeMail.Address{name: MimeMail.Words.word_decode(desc), address: addr} _ -> %MimeMail.Address{name: nil, address: String.trim(addr_spec)} end end defimpl MimeMail.Header, for: MimeMail.Address do def to_ascii(%{name: nil, address: address}), do: address - def to_ascii(%{name: name, address: address}), do: - "#{MimeMail.Words.word_encode name} <#{address}>" + + def to_ascii(%{name: name, address: address}), + do: "#{MimeMail.Words.word_encode(name)} <#{address}>" end end @@ -19,66 +20,108 @@ defmodule MimeMail.Emails do def parse_header(data) do data |> String.trim() |> String.split(~r/\s*,\s*/) |> Enum.map(&MimeMail.Address.decode/1) end - def decode_headers(%MimeMail{headers: headers}=mail) do - parsed=for {k,{:raw,v}}<-headers, k in [:from,:to,:cc,:cci,:'delivered-to'] do - {k,v|>MimeMail.header_value|>parse_header} - end - %{mail| headers: Keyword.merge(headers, parsed)} + + def decode_headers(%MimeMail{headers: headers} = mail) do + parsed = + for {k, {:raw, v}} <- headers, k in [:from, :to, :cc, :cci, :"delivered-to"] do + {k, v |> MimeMail.header_value() |> parse_header()} + end + + %{mail | headers: Keyword.merge(headers, parsed)} end - defimpl MimeMail.Header, for: List do # a list header is a mailbox spec list - def to_ascii(mail_list) do # a mail is a struct %{name: nil, address: ""} - mail_list - |> Enum.filter(&match?(%MimeMail.Address{},&1)) - |> Enum.map(&MimeMail.Header.to_ascii/1) |> Enum.join(", ") + + # a list header is a mailbox spec list + defimpl MimeMail.Header, for: List do + # a mail is a struct %{name: nil, address: ""} + def to_ascii(mail_list) do + mail_list + |> Enum.filter(&match?(%MimeMail.Address{}, &1)) + |> Enum.map(&MimeMail.Header.to_ascii/1) + |> Enum.join(", ") end end end defmodule MimeMail.Params do - def parse_header(bin), do: parse_kv(bin<>";",:key,[],[]) - - def parse_kv(<>,:key,keyacc,acc) when c in [?\s,?\t,?\r,?\n,?;], do: - parse_kv(rest,:key,keyacc,acc) # not allowed characters in key, skip - def parse_kv(<>,:key,keyacc,acc), do: - parse_kv(rest,:quotedvalue,[],[{:"#{keyacc|>Enum.reverse|>to_string|>String.downcase}",""}|acc]) # enter in a quoted value, save key in res acc - def parse_kv(<>,:key,keyacc,acc), do: - parse_kv(rest,:value,[],[{:"#{keyacc|>Enum.reverse|>to_string|>String.downcase}",""}|acc]) # enter in a simple value, save key in res acc - def parse_kv(<>,:key,keyacc,acc), do: - parse_kv(rest,:key,[c|keyacc],acc) # allowed char in key, add to key acc - def parse_kv(<>,:quotedvalue,valueacc,acc), do: - parse_kv(rest,:quotedvalue,[?"|valueacc],acc) # \" in quoted value is " - def parse_kv(<>,:quotedvalue,valueacc,[{key,_}|acc]), do: - parse_kv(rest,:key,[],[{key,"#{Enum.reverse(valueacc)}"}|acc]) # " in quoted value end the value - def parse_kv(<>,:value,valueacc,[{key,_}|acc]), do: - parse_kv(rest,:key,[],[{key,String.trim("#{Enum.reverse(valueacc)}")}|acc]) # ; in simple value ends the value and strip it - def parse_kv(<>,isvalue,valueacc,acc) when isvalue in [:value,:quotedvalue], do: - parse_kv(rest,isvalue,[c|valueacc],acc) # allowed char in value, add to acc - def parse_kv(_,_,_,acc), do: - Enum.into(acc,%{}) # if no match just return kv acc as map - - defimpl MimeMail.Header, for: Map do # a map header is "key1=value1; key2=value2" + def parse_header(bin), do: parse_kv(bin <> ";", :key, [], []) + + def parse_kv(<>, :key, keyacc, acc) when c in [?\s, ?\t, ?\r, ?\n, ?;], + # not allowed characters in key, skip + do: parse_kv(rest, :key, keyacc, acc) + + def parse_kv(<>, :key, keyacc, acc), + # enter in a quoted value, save key in res acc + do: + parse_kv(rest, :quotedvalue, [], [ + {:"#{keyacc |> Enum.reverse() |> to_string() |> String.downcase()}", ""} | acc + ]) + + def parse_kv(<>, :key, keyacc, acc), + # enter in a simple value, save key in res acc + do: + parse_kv(rest, :value, [], [ + {:"#{keyacc |> Enum.reverse() |> to_string() |> String.downcase()}", ""} | acc + ]) + + def parse_kv(<>, :key, keyacc, acc), + # allowed char in key, add to key acc + do: parse_kv(rest, :key, [c | keyacc], acc) + + def parse_kv(<>, :quotedvalue, valueacc, acc), + # \" in quoted value is " + do: parse_kv(rest, :quotedvalue, [?" | valueacc], acc) + + def parse_kv(<>, :quotedvalue, valueacc, [{key, _} | acc]), + # " in quoted value end the value + do: parse_kv(rest, :key, [], [{key, "#{Enum.reverse(valueacc)}"} | acc]) + + def parse_kv(<>, :value, valueacc, [{key, _} | acc]), + # ; in simple value ends the value and strip it + do: parse_kv(rest, :key, [], [{key, String.trim("#{Enum.reverse(valueacc)}")} | acc]) + + def parse_kv(<>, isvalue, valueacc, acc) + when isvalue in [:value, :quotedvalue], + # allowed char in value, add to acc + do: parse_kv(rest, isvalue, [c | valueacc], acc) + + def parse_kv(_, _, _, acc), + # if no match just return kv acc as map + do: Enum.into(acc, %{}) + + # a map header is "key1=value1; key2=value2" + defimpl MimeMail.Header, for: Map do def to_ascii(params) do - params |> Enum.map(fn {k,v}->"#{k}=#{v}" end) |> Enum.join("; ") + params |> Enum.map(fn {k, v} -> "#{k}=#{v}" end) |> Enum.join("; ") end end end + defmodule MimeMail.CTParams do def parse_header(data) do - case String.split(data,~r"\s*;\s*", parts: 2) do - [value,params] -> {value,MimeMail.Params.parse_header(params)} - [value] -> {value,%{}} + case String.split(data, ~r"\s*;\s*", parts: 2) do + [value, params] -> {value, MimeMail.Params.parse_header(params)} + [value] -> {value, %{}} end end - def normalize({value,m},k) when k in - [:"content-type",:"content-transfer-encoding",:"content-disposition"], do: {String.downcase(value),m} - def normalize(h,_), do: h - def decode_headers(%MimeMail{headers: headers}=mail) do - parsed_mail_headers=for {k,{:raw,v}}<-headers,match?("content-"<>_,"#{k}"), do: {k,v|>MimeMail.header_value|>parse_header|>normalize(k)} - %{mail| headers: Keyword.merge(headers, parsed_mail_headers)} + + def normalize({value, m}, k) + when k in [:"content-type", :"content-transfer-encoding", :"content-disposition"], + do: {String.downcase(value), m} + + def normalize(h, _), do: h + + def decode_headers(%MimeMail{headers: headers} = mail) do + parsed_mail_headers = + for {k, {:raw, v}} <- headers, + match?("content-" <> _, "#{k}"), + do: {k, v |> MimeMail.header_value() |> parse_header() |> normalize(k)} + + %{mail | headers: Keyword.merge(headers, parsed_mail_headers)} end - defimpl MimeMail.Header, for: Tuple do # a 2 tuple header is "value; key1=value1; key2=value2" - def to_ascii({value,%{}=params}) do + # a 2 tuple header is "value; key1=value1; key2=value2" + defimpl MimeMail.Header, for: Tuple do + def to_ascii({value, %{} = params}) do "#{value}; #{MimeMail.Header.to_ascii(params)}" end end @@ -86,56 +129,80 @@ end defmodule MimeMail.Words do def is_ascii(str) do - [] == for(<>, (c>126 or c<32) and not(c in [?\t,?\r,?\n]), do: c) + [] == for(<>, (c > 126 or c < 32) and c not in [?\t, ?\r, ?\n], do: c) end + def word_encode(line) do - if is_ascii(line) do line else - for <> do - case char do + if is_ascii(line) do + line + else + for <> do + case char do ?\s -> ?_ - c when c < 127 and c > 32 and c !== ?= and c !== ?? and c !== ?_-> c - c -> for(<>)>>,into: "",do: <>) + c when c < 127 and c > 32 and c !== ?= and c !== ?? and c !== ?_ -> c + c -> for(<>)>>, into: "", do: <>) end - end |> to_string |> chunk_line |> Enum.map(&"=?UTF-8?Q?#{&1}?=") |> Enum.join("\r\n ") + end + |> to_string() + |> chunk_line() + |> Enum.map(&"=?UTF-8?Q?#{&1}?=") + |> Enum.join("\r\n ") end end - defp chunk_line(<>), do: [vline|chunk_line("="<>rest)] - defp chunk_line(<>), do: [vline|chunk_line("="<>rest)] - defp chunk_line(<>), do: [vline|chunk_line(rest)] + + defp chunk_line(<>), + do: [vline | chunk_line("=" <> rest)] + + defp chunk_line(<>), + do: [vline | chunk_line("=" <> rest)] + + defp chunk_line(<>), do: [vline | chunk_line(rest)] defp chunk_line(other), do: [other] def word_decode(str) do - str |> String.split(~r/\s+/) |> Enum.map(&single_word_decode/1) |> Enum.join() |> String.trim_trailing() + str + |> String.split(~r/\s+/) + |> Enum.map(&single_word_decode/1) + |> Enum.join() + |> String.trim_trailing() end - def single_word_decode("=?"<>rest = str) do - case String.split(rest,"?") do - [enc,"Q",enc_str,"="] -> - str = q_to_binary(enc_str,[]) - MimeMail.ok_or(Iconv.conv(str,enc,"utf8"),MimeMail.ensure_ascii(str)) - [enc,"B",enc_str,"="] -> + def single_word_decode("=?" <> rest = str) do + case String.split(rest, "?") do + [enc, "Q", enc_str, "="] -> + str = q_to_binary(enc_str, []) + MimeMail.ok_or(Iconv.conv(str, enc, "utf8"), MimeMail.ensure_ascii(str)) + + [enc, "B", enc_str, "="] -> str = Base.decode64(enc_str) |> MimeMail.ok_or(enc_str) - MimeMail.ok_or(Iconv.conv(str,enc,"utf8"),MimeMail.ensure_ascii(str)) - _ -> "#{str} " + MimeMail.ok_or(Iconv.conv(str, enc, "utf8"), MimeMail.ensure_ascii(str)) + + _ -> + "#{str} " end end + def single_word_decode(str), do: "#{str} " - def q_to_binary("_"<>rest,acc), do: - q_to_binary(rest,[?\s|acc]) - def q_to_binary(<><>rest,acc), do: - q_to_binary(rest,[<> |> String.upcase |> Base.decode16! | acc]) - def q_to_binary(<>,acc), do: - q_to_binary(rest,[c | acc]) - def q_to_binary("",acc), do: - (acc |> Enum.reverse |> IO.iodata_to_binary) - - def decode_headers(%MimeMail{headers: headers}=mail) do - parsed_mail_headers=for {k,{:raw,v}}<-headers, k in [:subject], do: {k,v|>MimeMail.header_value|>word_decode} - %{mail| headers: Keyword.merge(headers, parsed_mail_headers)} + def q_to_binary("_" <> rest, acc), do: q_to_binary(rest, [?\s | acc]) + + def q_to_binary(<> <> rest, acc), + do: q_to_binary(rest, [<> |> String.upcase() |> Base.decode16!() | acc]) + + def q_to_binary(<>, acc), do: q_to_binary(rest, [c | acc]) + def q_to_binary("", acc), do: acc |> Enum.reverse() |> IO.iodata_to_binary() + + def decode_headers(%MimeMail{headers: headers} = mail) do + parsed_mail_headers = + for {k, {:raw, v}} <- headers, + k in [:subject], + do: {k, v |> MimeMail.header_value() |> word_decode()} + + %{mail | headers: Keyword.merge(headers, parsed_mail_headers)} end - defimpl MimeMail.Header, for: BitString do # a 2 tuple header is "value; key1=value1; key2=value2" + # a 2 tuple header is "value; key1=value1; key2=value2" + defimpl MimeMail.Header, for: BitString do def to_ascii(value) do MimeMail.Words.word_encode(value) end diff --git a/lib/spf.ex b/lib/spf.ex index 857713a..a4c483b 100644 --- a/lib/spf.ex +++ b/lib/spf.ex @@ -1,5 +1,4 @@ defmodule SPF do - @doc """ params: - sender: sender mail to check @@ -10,286 +9,410 @@ defmodule SPF do > SPF.check("me@gmail.com",{217,0,3,4}, server_domain: "mta.example.com", helo: "yahoo.fr") """ - def check(sender,ip,param_list \\ []) do - domain = sender|>String.split("@")|>Enum.at(1) + def check(sender, ip, param_list \\ []) do + domain = sender |> String.split("@") |> Enum.at(1) server_domain = param_list[:server_domain] || guess_fqdn() helo = param_list[:helo] || "unknown" - checkhost_params = %{sender: sender,client_ip: ip, server_domain: server_domain, helo: helo, domain: domain} + + checkhost_params = %{ + sender: sender, + client_ip: ip, + server_domain: server_domain, + helo: helo, + domain: domain + } + lookup_limit_reset() + if param_list[:spf] do - apply_rule(param_list[:spf],checkhost_params) + apply_rule(param_list[:spf], checkhost_params) else check_host(checkhost_params) end end def guess_fqdn do - {:ok,host} = :inet.gethostname - {:ok,{:hostent,fqdn,_,_,_,_}} = :inet.gethostbyname(host) + {:ok, host} = :inet.gethostname() + {:ok, {:hostent, fqdn, _, _, _, _}} = :inet.gethostbyname(host) "#{fqdn}" end # check_host param = %{sender: "me@example.org", client_ip: {1,2,3,4}, helo: "relay.com", server_domain: "me.com", domain: "example.org"} # check_host returns : none, :neutral,:pass,{:fail,msg},:softfail,:temperror,:permerror defp check_host(params) do - if lookup_limit_exceeded() do :permerror else - case :inet_res.lookup('#{params.domain}', :in, :txt, edns: 0) do - [] -> :temperror + if lookup_limit_exceeded() do + :permerror + else + case :inet_res.lookup(~c"#{params.domain}", :in, :txt, edns: 0) do + [] -> + :temperror + recs -> - rules=recs|>Enum.map(&Enum.join/1)|>Enum.filter(&match?("v=sp"<>_,&1)) + rules = recs |> Enum.map(&Enum.join/1) |> Enum.filter(&match?("v=sp" <> _, &1)) + case rules do - ["v=sp1 "<>rule] -> apply_rule(rule,params) - ["v=spf1 "<>rule] -> apply_rule(rule,params) + ["v=sp1 " <> rule] -> apply_rule(rule, params) + ["v=spf1 " <> rule] -> apply_rule(rule, params) _ -> :none end end end end - def apply_rule(rule,params) do + def apply_rule(rule, params) do try do terms = rule |> String.trim() |> String.split(" ") - {modifiers,mechanisms} = Enum.split_with(terms,&Regex.match?(~r/^[^:\/]+=/,&1)) - modifiers = Enum.map modifiers, fn modifier-> - [name,value]=String.split(modifier,"=") - {:"#{name}",target_name(value,params) || ""} - end - matches = Enum.map mechanisms, fn term-> - fn-> - {ret,term} = return(term) - case term_match(term,params) do - :match->ret - :notmatch->false - other->other + {modifiers, mechanisms} = Enum.split_with(terms, &Regex.match?(~r/^[^:\/]+=/, &1)) + + modifiers = + Enum.map(modifiers, fn modifier -> + [name, value] = String.split(modifier, "=") + {:"#{name}", target_name(value, params) || ""} + end) + + matches = + Enum.map(mechanisms, fn term -> + fn -> + {ret, term} = return(term) + + case term_match(term, params) do + :match -> ret + :notmatch -> false + other -> other + end end - end - end - result = Enum.find_value(matches,&(&1.())) - result = result || if modifiers[:redirect] do - case check_host(%{params|domain: modifiers[:redirect]}) do - :none->:permerror - other->other - end - end + end) + + result = Enum.find_value(matches, & &1.()) + + result = + result || + if modifiers[:redirect] do + case check_host(%{params | domain: modifiers[:redirect]}) do + :none -> :permerror + other -> other + end + end + result = result || :neutral - defaultfail = "domain of #{params.sender} does not designate #{:inet.ntoa params.client_ip} as permitted sender" - case {result,modifiers[:exp]} do - {:fail,nil}->{:fail,defaultfail} - {:fail,expdomain}-> + + defaultfail = + "domain of #{params.sender} does not designate #{:inet.ntoa(params.client_ip)} as permitted sender" + + case {result, modifiers[:exp]} do + {:fail, nil} -> + {:fail, defaultfail} + + {:fail, expdomain} -> try do false = lookup_limit_exceeded() - [rec] = :inet_res.lookup('#{expdomain}', :in, :txt, edns: 0) - {:fail,rec|>IO.chardata_to_string|>target_name(params)} - catch _, _ -> {:fail,defaultfail} + [rec] = :inet_res.lookup(~c"#{expdomain}", :in, :txt, edns: 0) + {:fail, rec |> IO.chardata_to_string() |> target_name(params)} + catch + _, _ -> {:fail, defaultfail} end - {ret,_}->ret + + {ret, _} -> + ret end catch _, r -> - IO.puts inspect r - IO.puts inspect(__STACKTRACE__, pretty: true) + IO.puts(inspect(r)) + IO.puts(inspect(__STACKTRACE__, pretty: true)) :permerror end end - defp return("+"<>rest), do: {:pass,rest} - defp return("-"<>rest), do: {:fail,rest} - defp return("~"<>rest), do: {:softfail,rest} - defp return("?"<>rest), do: {:neutral,rest} - defp return(term), do: {:pass,term} + defp return("+" <> rest), do: {:pass, rest} + defp return("-" <> rest), do: {:fail, rest} + defp return("~" <> rest), do: {:softfail, rest} + defp return("?" <> rest), do: {:neutral, rest} + defp return(term), do: {:pass, term} + + def term_match("all", _), do: :match - def term_match("all",_), do: :match - def term_match("include:"<>domain_spec,params) do - case check_host(%{params|domain: target_name(domain_spec,params)}) do + def term_match("include:" <> domain_spec, params) do + case check_host(%{params | domain: target_name(domain_spec, params)}) do :pass -> :match - {:fail,_} -> :notmatch - res when res in [:softfail,:neutral] -> :notmatch - res when res in [:permerror,:none] -> :permerror + {:fail, _} -> :notmatch + res when res in [:softfail, :neutral] -> :notmatch + res when res in [:permerror, :none] -> :permerror :temperror -> :temperror end end - def term_match("a"<>arg,params) do - domain_spec = if match?(":"<>_,arg), do: String.trim_leading(arg, ":"), else: params.domain<>arg + + def term_match("a" <> arg, params) do + domain_spec = + if match?(":" <> _, arg), do: String.trim_leading(arg, ":"), else: params.domain <> arg + family = if tuple_size(params.client_ip) == 4, do: :inet, else: :inet6 - {domain,prefix}=extract_prefix(target_name(domain_spec,params),family) + {domain, prefix} = extract_prefix(target_name(domain_spec, params), family) false = lookup_limit_exceeded() - case :inet_res.gethostbyname('#{domain}',family) do - {:ok,{:hostent,_,_,_,_,ip_list}}-> - if Enum.any?(ip_list,&ip_in_network(params.client_ip,&1,prefix)), do: :match, else: :notmatch - _->:notmatch + + case :inet_res.gethostbyname(~c"#{domain}", family) do + {:ok, {:hostent, _, _, _, _, ip_list}} -> + if Enum.any?(ip_list, &ip_in_network(params.client_ip, &1, prefix)), + do: :match, + else: :notmatch + + _ -> + :notmatch end end - def term_match("mx"<>arg,params) do - domain_spec = if match?(":"<>_,arg), do: String.trim_leading(arg, ":"), else: params.domain<>arg + + def term_match("mx" <> arg, params) do + domain_spec = + if match?(":" <> _, arg), do: String.trim_leading(arg, ":"), else: params.domain <> arg + family = if tuple_size(params.client_ip) == 4, do: :inet, else: :inet6 - {domain,prefix}=extract_prefix(target_name(domain_spec,params),family) + {domain, prefix} = extract_prefix(target_name(domain_spec, params), family) false = lookup_limit_exceeded() - case :inet_res.lookup('#{domain}', :in, :mx, edns: 0) do - []->:notmatch - res-> - Enum.find_value(res,fn {_prio,name}-> + + case :inet_res.lookup(~c"#{domain}", :in, :mx, edns: 0) do + [] -> + :notmatch + + res -> + Enum.find_value(res, fn {_prio, name} -> false = lookup_limit_exceeded() - case :inet_res.gethostbyname(name,family) do - {:ok,{:hostent,_,_,_,_,ip_list}}-> - if Enum.any?(ip_list,&ip_in_network(params.client_ip,&1,prefix)), do: :match - _->false + + case :inet_res.gethostbyname(name, family) do + {:ok, {:hostent, _, _, _, _, ip_list}} -> + if Enum.any?(ip_list, &ip_in_network(params.client_ip, &1, prefix)), do: :match + + _ -> + false end end) || :notmatch end end - def term_match("ptr"<>arg,params) do - domain_spec = if arg=="", do: params.domain, else: String.trim_leading(arg, ":") + + def term_match("ptr" <> arg, params) do + domain_spec = if arg == "", do: params.domain, else: String.trim_leading(arg, ":") family = if tuple_size(params.client_ip) == 4, do: :inet, else: :inet6 false = lookup_limit_exceeded() + case :inet_res.gethostbyaddr(params.client_ip) do - {:ok,{:hostent,name,_,_,_,_}}-> + {:ok, {:hostent, name, _, _, _, _}} -> false = lookup_limit_exceeded() - case :inet_res.gethostbyname(name,family) do - {:ok,{:hostent,_,_,_,_,ip_list}}-> + + case :inet_res.gethostbyname(name, family) do + {:ok, {:hostent, _, _, _, _, ip_list}} -> if params.client_ip in ip_list do - if String.ends_with?("#{name}",target_name(domain_spec,params)), do: :match, else: :notmatch - else :notmatch end - _->:notmatch + if String.ends_with?("#{name}", target_name(domain_spec, params)), + do: :match, + else: :notmatch + else + :notmatch + end + + _ -> + :notmatch end - {:error,_}->:notmatch + + {:error, _} -> + :notmatch end end - def term_match(<<"ip",v,":",addr_spec::binary>>,params) when v in [?4,?6] do + + def term_match(<<"ip", v, ":", addr_spec::binary>>, params) when v in [?4, ?6] do family = if tuple_size(params.client_ip) == 4, do: :inet, else: :inet6 - if (family==:inet and v !== ?4) or (family==:inet6 and v !== ?6) do :notmatch else - {addr_spec,prefix}=extract_prefix(target_name(addr_spec,params),family) - case :inet.parse_address('#{addr_spec}') do - {:ok,addr} when (tuple_size(addr)==4 and family==:inet) or - (tuple_size(addr)==8 and family==:inet6)-> - if ip_in_network(params.client_ip,addr,prefix), do: :match, else: :notmatch + + if (family == :inet and v !== ?4) or (family == :inet6 and v !== ?6) do + :notmatch + else + {addr_spec, prefix} = extract_prefix(target_name(addr_spec, params), family) + + case :inet.parse_address(~c"#{addr_spec}") do + {:ok, addr} + when (tuple_size(addr) == 4 and family == :inet) or + (tuple_size(addr) == 8 and family == :inet6) -> + if ip_in_network(params.client_ip, addr, prefix), do: :match, else: :notmatch end end end - def term_match("exists:"<>domain_spec,params) do + + def term_match("exists:" <> domain_spec, params) do false = lookup_limit_exceeded() - case :inet_res.gethostbyname('#{target_name(domain_spec,params)}',:inet) do - {:ok,{:hostent,_,_,_,_,ip_list}} when ip_list != [] -> :match - _->:notmatch + + case :inet_res.gethostbyname(~c"#{target_name(domain_spec, params)}", :inet) do + {:ok, {:hostent, _, _, _, _, ip_list}} when ip_list != [] -> :match + _ -> :notmatch end end - def lookup_limit_reset, do: - Process.put(:lookups,0) + def lookup_limit_reset, do: Process.put(:lookups, 0) + def lookup_limit_exceeded do - case Process.get(:lookups,0) do - 10 -> true - count -> Process.put(:lookups,count+1) ; false - end + case Process.get(:lookups, 0) do + 10 -> + true + + count -> + Process.put(:lookups, count + 1) + false + end end - def extract_prefix(domain_spec,family) when is_binary(domain_spec), do: - extract_prefix(String.split(domain_spec,"/"),family) + def extract_prefix(domain_spec, family) when is_binary(domain_spec), + do: extract_prefix(String.split(domain_spec, "/"), family) - def extract_prefix([domain,_,v6pref],:inet6), do: - extract_prefix([domain,v6pref],:inet6) - def extract_prefix([domain,pref|_],_) do - {pref,_}=Integer.parse(pref) - {domain,pref} + def extract_prefix([domain, _, v6pref], :inet6), do: extract_prefix([domain, v6pref], :inet6) + + def extract_prefix([domain, pref | _], _) do + {pref, _} = Integer.parse(pref) + {domain, pref} end - def extract_prefix([domain],:inet), do: - {domain,32} - def extract_prefix([domain],:inet6), do: - {domain,128} - - defp bin_ip({ip1,ip2,ip3,ip4}), do: - <> - defp bin_ip({ip1,ip2,ip3,ip4,ip5,ip6,ip7,ip8}), do: - <> + + def extract_prefix([domain], :inet), do: {domain, 32} + def extract_prefix([domain], :inet6), do: {domain, 128} + + defp bin_ip({ip1, ip2, ip3, ip4}), + do: <> + + defp bin_ip({ip1, ip2, ip3, ip4, ip5, ip6, ip7, ip8}), + do: + <> + defp int_ip(addr) do - ip = bin_ip(addr) ; bitlen = bit_size(ip) + ip = bin_ip(addr) + bitlen = bit_size(ip) <> = ip - {bitlen,ip} + {bitlen, ip} end import Bitwise - def ip_in_network(addr,net_addr,bitprefix) do - {{bitlen,net_ip},{bitlen,ip}} = {int_ip(net_addr),int_ip(addr)} - <> = :binary.copy(<<0b11111111>>,div(bitlen,8)) - mask = fullone <<< (bitlen-bitprefix) # mask is bitprefix*1 + (bitlen-bitprefix)*0 + + def ip_in_network(addr, net_addr, bitprefix) do + {{bitlen, net_ip}, {bitlen, ip}} = {int_ip(net_addr), int_ip(addr)} + <> = :binary.copy(<<0b11111111>>, div(bitlen, 8)) + # mask is bitprefix*1 + (bitlen-bitprefix)*0 + mask = fullone <<< (bitlen - bitprefix) (mask &&& net_ip) == (mask &&& ip) end - def target_name(name,params), do: - target_name(name,params,[]) - - def target_name("",_,acc), do: - (acc |> Enum.reverse |> to_string) - def target_name(<<"%{",macro,rest::binary>>,params,acc) - when macro in [?s,?l,?o,?d,?i,?p,?h,?c,?r,?t,?v,?S,?L,?O,?D,?I,?P,?H,?C,?R,?T,?V] do - expanded = target_name_macro(String.downcase(<>),params) - [transfo,rest] = String.split(rest,"}",parts: 2) - expanded = case Integer.parse(String.downcase(transfo)) do - {digits,splitspec}->target_name_transfo(expanded,-digits,splitspec) - :error->target_name_transfo(expanded,0,transfo) - end - target_name(rest,params,[URI.encode(expanded)|acc]) + def target_name(name, params), do: target_name(name, params, []) + + def target_name("", _, acc), do: acc |> Enum.reverse() |> to_string() + + def target_name(<<"%{", macro, rest::binary>>, params, acc) + when macro in [ + ?s, + ?l, + ?o, + ?d, + ?i, + ?p, + ?h, + ?c, + ?r, + ?t, + ?v, + ?S, + ?L, + ?O, + ?D, + ?I, + ?P, + ?H, + ?C, + ?R, + ?T, + ?V + ] do + expanded = target_name_macro(String.downcase(<>), params) + [transfo, rest] = String.split(rest, "}", parts: 2) + + expanded = + case Integer.parse(String.downcase(transfo)) do + {digits, splitspec} -> target_name_transfo(expanded, -digits, splitspec) + :error -> target_name_transfo(expanded, 0, transfo) + end + + target_name(rest, params, [URI.encode(expanded) | acc]) end - def target_name("%%"<>rest,params,acc), do: - target_name(rest,params,[?%|acc]) - def target_name("%_"<>rest,params,acc), do: - target_name(rest,params,[?\s|acc]) - def target_name("%-"<>rest,params,acc), do: - target_name(rest,params,["%20"|acc]) - def target_name("%"<>_,_,_), do: throw(:wrongmacro) - def target_name(<>,params,acc), do: - target_name(rest,params,[c|acc]) - - def target_name_macro("s",%{sender: sender}), do: sender - def target_name_macro("l",%{sender: sender}), do: (sender|>String.split("@")|>hd) - def target_name_macro("o",%{sender: sender}), do: (sender|>String.split("@")|>Enum.at(1)) - def target_name_macro("d",%{domain: domain}), do: domain - def target_name_macro("i",%{client_ip: {ip1,ip2,ip3,ip4,ip5,ip6,ip7,ip8}}) do - <> + + def target_name("%%" <> rest, params, acc), do: target_name(rest, params, [?% | acc]) + def target_name("%_" <> rest, params, acc), do: target_name(rest, params, [?\s | acc]) + def target_name("%-" <> rest, params, acc), do: target_name(rest, params, ["%20" | acc]) + def target_name("%" <> _, _, _), do: throw(:wrongmacro) + def target_name(<>, params, acc), do: target_name(rest, params, [c | acc]) + + def target_name_macro("s", %{sender: sender}), do: sender + def target_name_macro("l", %{sender: sender}), do: sender |> String.split("@") |> hd() + def target_name_macro("o", %{sender: sender}), do: sender |> String.split("@") |> Enum.at(1) + def target_name_macro("d", %{domain: domain}), do: domain + + def target_name_macro("i", %{client_ip: {ip1, ip2, ip3, ip4, ip5, ip6, ip7, ip8}}) do + <> |> Base.encode16(case: :lower) |> String.split("") |> Enum.join(".") |> String.trim(".") end - def target_name_macro("i",%{client_ip: {_,_,_,_}=ip4}) do - ip4 |> Tuple.to_list |> Enum.join(".") + + def target_name_macro("i", %{client_ip: {_, _, _, _} = ip4}) do + ip4 |> Tuple.to_list() |> Enum.join(".") end - def target_name_macro("p",%{client_ip: ip}) do + + def target_name_macro("p", %{client_ip: ip}) do false = lookup_limit_exceeded() family = if tuple_size(ip) == 4, do: :inet, else: :inet6 + case :inet_res.gethostbyaddr(ip) do - {:ok,{:hostent,name,_,_,_,_}}-> + {:ok, {:hostent, name, _, _, _, _}} -> if not lookup_limit_exceeded() do - case :inet_res.gethostbyname(name,family) do - {:ok,{:hostent,_,_,_,_,ip_list}}-> - if ip in ip_list do "#{name}" else "unknown" end - _->"unknown" + case :inet_res.gethostbyname(name, family) do + {:ok, {:hostent, _, _, _, _, ip_list}} -> + if ip in ip_list do + "#{name}" + else + "unknown" + end + + _ -> + "unknown" end end - {:error,_}->"unknown" + + {:error, _} -> + "unknown" end end - def target_name_macro("v",%{client_ip: ip}) when tuple_size(ip) == 4, do: "in-addr" - def target_name_macro("v",%{client_ip: ip}) when tuple_size(ip) == 8, do: "ip6" - def target_name_macro("h",%{helo: helo}), do: helo - def target_name_macro("c",%{client_ip: ip}), do: "#{:inet.ntoa ip}" - def target_name_macro("r",%{server_domain: server_domain}), do: server_domain - def target_name_macro("t",_) do - {megasec,sec,_}=:os.timestamp - "#{megasec*1_000_000+sec}" + + def target_name_macro("v", %{client_ip: ip}) when tuple_size(ip) == 4, do: "in-addr" + def target_name_macro("v", %{client_ip: ip}) when tuple_size(ip) == 8, do: "ip6" + def target_name_macro("h", %{helo: helo}), do: helo + def target_name_macro("c", %{client_ip: ip}), do: "#{:inet.ntoa(ip)}" + def target_name_macro("r", %{server_domain: server_domain}), do: server_domain + + def target_name_macro("t", _) do + {megasec, sec, _} = :os.timestamp() + "#{megasec * 1_000_000 + sec}" end - def target_name_transfo(expanded,start_index,"r"<>delimiters), do: - target_name_transfo(expanded,start_index,true,delimiters) - def target_name_transfo(expanded,start_index,delimiters), do: - target_name_transfo(expanded,start_index,false,delimiters) - - def target_name_transfo(expanded,start_index,reversed?,""), do: - target_name_transfo(expanded,start_index,reversed?,".") - def target_name_transfo(expanded,start_index,reversed?,delimiters) do - delimiters = for <>, c in [?.,?-,?+,?,,?/,?_,?=], into: "", do: <> - components=String.split(expanded,Regex.compile!("["<>delimiters<>"]")) - components=if reversed?, do: Enum.reverse(components), else: components - components=Enum.slice(components,max(-length(components),start_index)..-1) - Enum.join(components,".") + def target_name_transfo(expanded, start_index, "r" <> delimiters), + do: target_name_transfo(expanded, start_index, true, delimiters) + + def target_name_transfo(expanded, start_index, delimiters), + do: target_name_transfo(expanded, start_index, false, delimiters) + + def target_name_transfo(expanded, start_index, reversed?, ""), + do: target_name_transfo(expanded, start_index, reversed?, ".") + + def target_name_transfo(expanded, start_index, reversed?, delimiters) do + delimiters = for <>, c in [?., ?-, ?+, ?,, ?/, ?_, ?=], into: "", do: <> + components = String.split(expanded, Regex.compile!("[" <> delimiters <> "]")) + components = if reversed?, do: Enum.reverse(components), else: components + len = length(components) + + components = + if start_index == 0 || len <= -start_index, + do: components, + else: Enum.drop(components, len + start_index) + + Enum.join(components, ".") end end diff --git a/mix.exs b/mix.exs index a4bb74e..bac53f4 100644 --- a/mix.exs +++ b/mix.exs @@ -18,17 +18,25 @@ defmodule Mix.Tasks.Compile.Iconv do 4. Once the dll is compiled in your priv folder, MSYS2 is no longer required as the dll compiled is native and redistributable. """ def run(_) do - lib_ext = if {:win32, :nt} == :os.type, do: "dll", else: "so" + lib_ext = if {:win32, :nt} == :os.type(), do: "dll", else: "so" lib_file = "priv/Elixir.Iconv_nif.#{lib_ext}" + if not File.exists?(lib_file) do - [i_erts]=Path.wildcard("#{:code.root_dir}/erts*/include") - i_ei=:code.lib_dir(:erl_interface,:include) - l_ei=:code.lib_dir(:erl_interface,:lib) + [i_erts] = Path.wildcard("#{:code.root_dir()}/erts*/include") + i_ei = Path.join(:code.lib_dir(:erl_interface), "include") + l_ei = Path.join(:code.lib_dir(:erl_interface), "lib") args = "-L\"#{l_ei}\" -lei -I\"#{i_ei}\" -I\"#{i_erts}\" -Wall -shared -fPIC" - args = args <> if {:unix, :darwin}==:os.type, do: " -undefined dynamic_lookup -dynamiclib", else: "" - args = args <> if {:win32, :nt}==:os.type, do: " -liconv", else: "" - Mix.shell.info to_string :os.cmd('gcc #{args} -v -o #{lib_file} c_src/iconv_nif.c') + + args = + args <> + if {:unix, :darwin} == :os.type(), + do: " -undefined dynamic_lookup -dynamiclib", + else: "" + + args = args <> if {:win32, :nt} == :os.type(), do: " -liconv", else: "" + Mix.shell().info(to_string(:os.cmd(~c"gcc #{args} -v -o #{lib_file} c_src/iconv_nif.c"))) end + :ok end end @@ -38,21 +46,22 @@ defmodule Mailibex.Mixfile do def app, do: :mailibex - def version, do: "0.2.1" + def version, do: "0.2.2" def source_url, do: "https://github.com/kbrw/#{app()}" def project do - [app: app(), - version: version(), - elixir: "~> 1.12", - description: description(), - package: package(), - compilers: [:iconv, :elixir, :app], - deps: deps(), - docs: docs(), - elixirc_options: [warnings_as_errors: true], - ] + [ + app: app(), + version: version(), + elixir: "~> 1.12", + description: description(), + package: package(), + compilers: [:iconv, :elixir, :app], + deps: deps(), + docs: docs(), + elixirc_options: [warnings_as_errors: true] + ] end def application do @@ -63,18 +72,19 @@ defmodule Mailibex.Mixfile do :inets, :logger, :public_key, - :ssl, - ], + :ssl + ] ] end defp package do - [ maintainers: ["Arnaud Wetzel","heri16"], + [ + maintainers: ["Arnaud Wetzel", "heri16"], licenses: ["The MIT License (MIT)"], links: %{ "Changelog" => "https://hexdocs.pm/#{app()}/changelog.html", "GitHub" => source_url() - }, + } ] end @@ -103,7 +113,7 @@ defmodule Mailibex.Mixfile do main: "readme", source_url: source_url(), # We need to git tag with the corresponding format. - source_ref: "v#{version()}", + source_ref: "v#{version()}" ] end end diff --git a/mix.lock b/mix.lock index b1155a3..f54bc8f 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,7 @@ %{ "codepagex": {:hex, :codepagex, "0.1.6", "49110d09a25ee336a983281a48ef883da4c6190481e0b063afe2db481af6117e", [:mix], [], "hexpm", "1521461097dde281edf084062f525a4edc6a5e49f4fd1f5ec41c9c4955d5bd59"}, - "earmark": {:hex, :earmark, "1.3.6", "ce1d0675e10a5bb46b007549362bd3f5f08908843957687d8484fe7f37466b19", [:mix], [], "hexpm", "1476378df80982302d5a7857b6a11dd0230865057dec6d16544afecc6bc6b4c2"}, "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, - "ex_doc": {:hex, :ex_doc, "0.31.1", "8a2355ac42b1cc7b2379da9e40243f2670143721dd50748bf6c3b1184dae2089", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3178c3a407c557d8343479e1ff117a96fd31bafe52a039079593fb0524ef61b0"}, + "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"}, "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.4", "29563475afa9b8a2add1b7a9c8fb68d06ca7737648f28398e04461f008b69521", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f4ed47ecda66de70dd817698a703f8816daa91272e7e45812469498614ae8b29"}, diff --git a/test/dkim_test.exs b/test/dkim_test.exs index 5b069a7..2498588 100644 --- a/test/dkim_test.exs +++ b/test/dkim_test.exs @@ -2,58 +2,89 @@ defmodule DKIMTest do use ExUnit.Case setup_all do - :code.unstick_dir(:code.lib_dir(:kernel)++'/ebin') + :code.unstick_dir(:code.lib_dir(:kernel) ++ ~c"/ebin") previous_compiler_options = Code.compiler_options(ignore_module_conflict: true) - defmodule :inet_res do #mock external dns calls to hard define DKIM pub key when mock mails were constructed - def lookup(dns,type,class,_opts), do: lookup(dns,type,class) - def lookup('20120113._domainkey.gmail.com',:in,:txt) do - [['k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1Kd87/UeJjenpabgbFwh+eBCsSTrqmwIYYvywlbhbqoo2DymndFkbjOVIPIldNs/m40KF+yzMn1skyoxcTUGCQs8g3FgD2Ap3ZB5DekAo5wMmk4wimDO+U8QzI3SD0', '7y2+07wlNWwIt8svnxgdxGkVbbhzY8i+RQ9DpSVpPbF7ykQxtKXkv/ahW3KjViiAH+ghvvIhkx4xYSIc9oSwVmAl5OctMEeWUwg8Istjqz8BZeTWbf41fbNhte7Y+YqZOwq1Sd0DbvYAD9NOZK9vlfuac0598HY+vtSBczUiKERHv1yRbcaQtZFh5wtiRrN04BLUTD21MycBX5jYchHjPY/wIDAQAB']] + # mock external dns calls to hard define DKIM pub key when mock mails were constructed + defmodule :inet_res do + def lookup(dns, type, class, _opts), do: lookup(dns, type, class) + + def lookup(~c"20120113._domainkey.gmail.com", :in, :txt) do + [ + [ + ~c"k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1Kd87/UeJjenpabgbFwh+eBCsSTrqmwIYYvywlbhbqoo2DymndFkbjOVIPIldNs/m40KF+yzMn1skyoxcTUGCQs8g3FgD2Ap3ZB5DekAo5wMmk4wimDO+U8QzI3SD0", + ~c"7y2+07wlNWwIt8svnxgdxGkVbbhzY8i+RQ9DpSVpPbF7ykQxtKXkv/ahW3KjViiAH+ghvvIhkx4xYSIc9oSwVmAl5OctMEeWUwg8Istjqz8BZeTWbf41fbNhte7Y+YqZOwq1Sd0DbvYAD9NOZK9vlfuac0598HY+vtSBczUiKERHv1yRbcaQtZFh5wtiRrN04BLUTD21MycBX5jYchHjPY/wIDAQAB" + ] + ] end - def lookup('cobrason._domainkey.order.brendy.fr',:in,:txt) do - [['k=rsa; t=y; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAofRImu739BK3m4Qj6uxZr/IBb2Jk5xuxY17pBgRp1ANAPFqJBg1mgUiwooT5n6/EjSA3dvt8MarlGNl+fOOOY02IWttkXW0fXWxW324iNaNE1aSyhHaP7dTmcSE3BnVjOVUGbZ5voLxjULq5+Ml1sy5Xt17cW38I0gja4ZtC0HQ9aUv4+eWZwxv4WIWpPUVH', 'qEFEptOHc1v1YbKO8lo9JFlO1wVvnQjEpWbg5ORGxaBnr92I0bZ2Hm5gU4WHOUiPKKk7J94wpO1KV++SGLaCeHDV8cW9e3RgGJs2IQzpjMDTyGEyHTo5WrgN3d9AOljyb2GOCnFEZ3lqI/+4XXbyHQIDAQAB']] + + def lookup(~c"cobrason._domainkey.order.brendy.fr", :in, :txt) do + [ + [ + ~c"k=rsa; t=y; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAofRImu739BK3m4Qj6uxZr/IBb2Jk5xuxY17pBgRp1ANAPFqJBg1mgUiwooT5n6/EjSA3dvt8MarlGNl+fOOOY02IWttkXW0fXWxW324iNaNE1aSyhHaP7dTmcSE3BnVjOVUGbZ5voLxjULq5+Ml1sy5Xt17cW38I0gja4ZtC0HQ9aUv4+eWZwxv4WIWpPUVH", + ~c"qEFEptOHc1v1YbKO8lo9JFlO1wVvnQjEpWbg5ORGxaBnr92I0bZ2Hm5gU4WHOUiPKKk7J94wpO1KV++SGLaCeHDV8cW9e3RgGJs2IQzpjMDTyGEyHTo5WrgN3d9AOljyb2GOCnFEZ3lqI/+4XXbyHQIDAQAB" + ] + ] end - def lookup('amazon201209._domainkey.amazon.fr',:in,:txt) do - [['p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDAjRD+MR0FAGIKMgpN6eK/z7Z+ojWuvb3a79qsLS3IU/hdifXxhoi9+ttd4eUBZKfwtSGVg8uOxGFJpPnp4MvZUgb8L6ZCkB/Big6l9JGPNHXCUo4e3RIQJzgWOuqPpO8pS+8HOJiH+fjxGwTZipiK353MlTudq9b6z8Gn8HCXkQIDAQAB;']] + + def lookup(~c"amazon201209._domainkey.amazon.fr", :in, :txt) do + [ + [ + ~c"p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDAjRD+MR0FAGIKMgpN6eK/z7Z+ojWuvb3a79qsLS3IU/hdifXxhoi9+ttd4eUBZKfwtSGVg8uOxGFJpPnp4MvZUgb8L6ZCkB/Big6l9JGPNHXCUo4e3RIQJzgWOuqPpO8pS+8HOJiH+fjxGwTZipiK353MlTudq9b6z8Gn8HCXkQIDAQAB;" + ] + ] end - def lookup(_,_,_), do: [] + + def lookup(_, _, _), do: [] end + _ = Code.compiler_options(previous_compiler_options) :ok end def check(file) do - file |> File.read! |> MimeMail.from_string |> DKIM.check + file |> File.read!() |> MimeMail.from_string() |> DKIM.check() end test "amazon DKIM check" do - assert {:pass,_} = check("test/mails/amazon.eml") + assert {:pass, _} = check("test/mails/amazon.eml") end - test "DKIM relaxed/relaxed check" do # test cases from mail sended by gmail - assert {:pass,_} = check("test/mails/valid_dkim_relaxed_canon.eml") - assert {:pass,_} = check("test/mails/valid_dkim_relaxed_uncanon.eml") - assert {:permfail,:body_hash_no_match} = check("test/mails/invalid_dkim_bodyh.eml") + # test cases from mail sended by gmail + test "DKIM relaxed/relaxed check" do + assert {:pass, _} = check("test/mails/valid_dkim_relaxed_canon.eml") + assert {:pass, _} = check("test/mails/valid_dkim_relaxed_uncanon.eml") + assert {:permfail, :body_hash_no_match} = check("test/mails/invalid_dkim_bodyh.eml") assert :tempfail = check("test/mails/invalid_dkim_dns.eml") - assert {:permfail,:sig_not_match} = check("test/mails/invalid_dkim_sig.eml") + assert {:permfail, :sig_not_match} = check("test/mails/invalid_dkim_sig.eml") end - test "DKIM relaxed/simple check" do # test cases from mail sended by gen_smtp_client - assert {:pass,_} = check("test/mails/valid_dkim_relaxedsimple_canon.eml") - assert {:pass,_} = check("test/mails/valid_dkim_relaxedsimple_uncanon.eml") - assert {:permfail,:body_hash_no_match} = check("test/mails/invalid_dkim_relaxedsimple_uncanon.eml") + + # test cases from mail sended by gen_smtp_client + test "DKIM relaxed/simple check" do + assert {:pass, _} = check("test/mails/valid_dkim_relaxedsimple_canon.eml") + assert {:pass, _} = check("test/mails/valid_dkim_relaxedsimple_uncanon.eml") + + assert {:permfail, :body_hash_no_match} = + check("test/mails/invalid_dkim_relaxedsimple_uncanon.eml") end - test "DKIM simple/simple check" do # test cases from mail sended by gen_smtp_client - assert {:pass,_} = check("test/mails/valid_dkim_simple_canon.eml") - assert {:permfail,:sig_not_match} = check("test/mails/invalid_dkim_simple_uncanon.eml") + + # test cases from mail sended by gen_smtp_client + test "DKIM simple/simple check" do + assert {:pass, _} = check("test/mails/valid_dkim_simple_canon.eml") + assert {:permfail, :sig_not_match} = check("test/mails/invalid_dkim_simple_uncanon.eml") end test "DKIM signature round trip" do - [rsaentry] = :public_key.pem_decode(File.read!("test/mails/key.pem")) - assert {:pass,_} = - File.read!("test/mails/valid_dkim_relaxed_canon.eml") - |> MimeMail.from_string - |> DKIM.sign(:public_key.pem_entry_decode(rsaentry), d: "order.brendy.fr", s: "cobrason") - |> MimeMail.to_string - |> MimeMail.from_string - |> DKIM.check + [rsaentry] = :public_key.pem_decode(File.read!("test/mails/key.pem")) + + assert {:pass, _} = + File.read!("test/mails/valid_dkim_relaxed_canon.eml") + |> MimeMail.from_string() + |> DKIM.sign(:public_key.pem_entry_decode(rsaentry), + d: "order.brendy.fr", + s: "cobrason" + ) + |> MimeMail.to_string() + |> MimeMail.from_string() + |> DKIM.check() end end diff --git a/test/dmarc_test.exs b/test/dmarc_test.exs index 354ecc0..5fdd176 100644 --- a/test/dmarc_test.exs +++ b/test/dmarc_test.exs @@ -2,88 +2,88 @@ defmodule DMARCTest do use ExUnit.Case test "Mixed case." do - assert "example.com" = DMARC.organization "example.COM" - assert "example.com" = DMARC.organization "WwW.example.COM" + assert "example.com" = DMARC.organization("example.COM") + assert "example.com" = DMARC.organization("WwW.example.COM") end test "Unlisted TLD." do - assert "example.example" = DMARC.organization "example.example" - assert "example.example" = DMARC.organization "b.example.example" - assert "example.example" = DMARC.organization "a.b.example.example" + assert "example.example" = DMARC.organization("example.example") + assert "example.example" = DMARC.organization("b.example.example") + assert "example.example" = DMARC.organization("a.b.example.example") end test "TLD with only 1 rule." do - assert "domain.biz" = DMARC.organization "domain.biz" - assert "domain.biz" = DMARC.organization "b.domain.biz" - assert "domain.biz" = DMARC.organization "a.b.domain.biz" + assert "domain.biz" = DMARC.organization("domain.biz") + assert "domain.biz" = DMARC.organization("b.domain.biz") + assert "domain.biz" = DMARC.organization("a.b.domain.biz") end test "TLD with some 2-level rules." do - assert "example.com" = DMARC.organization "example.com" - assert "example.com" = DMARC.organization "b.example.com" - assert "example.com" = DMARC.organization "a.b.example.com" - assert "uk.com" = DMARC.organization "uk.com" - assert "example.uk.com" = DMARC.organization "example.uk.com" - assert "example.uk.com" = DMARC.organization "b.example.uk.com" - assert "example.uk.com" = DMARC.organization "a.b.example.uk.com" - assert "test.ac" = DMARC.organization "test.ac" + assert "example.com" = DMARC.organization("example.com") + assert "example.com" = DMARC.organization("b.example.com") + assert "example.com" = DMARC.organization("a.b.example.com") + assert "uk.com" = DMARC.organization("uk.com") + assert "example.uk.com" = DMARC.organization("example.uk.com") + assert "example.uk.com" = DMARC.organization("b.example.uk.com") + assert "example.uk.com" = DMARC.organization("a.b.example.uk.com") + assert "test.ac" = DMARC.organization("test.ac") end test "TLD with only 1 (wildcard) rule." do - assert "cy" = DMARC.organization "cy" - assert "c.cy" = DMARC.organization "c.cy" - assert "c.cy" = DMARC.organization "b.c.cy" - assert "c.cy" = DMARC.organization "a.b.c.cy" + assert "cy" = DMARC.organization("cy") + assert "c.cy" = DMARC.organization("c.cy") + assert "c.cy" = DMARC.organization("b.c.cy") + assert "c.cy" = DMARC.organization("a.b.c.cy") end test "More complex TLD." do - assert "jp" = DMARC.organization "jp" - assert "test.jp" = DMARC.organization "test.jp" - assert "test.jp" = DMARC.organization "www.test.jp" - assert "ac.jp" = DMARC.organization "ac.jp" - assert "test.ac.jp" = DMARC.organization "test.ac.jp" - assert "test.ac.jp" = DMARC.organization "www.test.ac.jp" - assert "kyoto.jp" = DMARC.organization "kyoto.jp" - assert "test.kyoto.jp" = DMARC.organization "test.kyoto.jp" - assert "ide.kyoto.jp" = DMARC.organization "ide.kyoto.jp" - assert "b.ide.kyoto.jp" = DMARC.organization "b.ide.kyoto.jp" - assert "b.ide.kyoto.jp" = DMARC.organization "a.b.ide.kyoto.jp" - #assert "c.kobe.jp" = DMARC.organization "c.kobe.jp" - assert "b.c.kobe.jp" = DMARC.organization "b.c.kobe.jp" - assert "b.c.kobe.jp" = DMARC.organization "a.b.c.kobe.jp" - assert "www.city.kobe.jp" = DMARC.organization "www.city.kobe.jp" + assert "jp" = DMARC.organization("jp") + assert "test.jp" = DMARC.organization("test.jp") + assert "test.jp" = DMARC.organization("www.test.jp") + assert "ac.jp" = DMARC.organization("ac.jp") + assert "test.ac.jp" = DMARC.organization("test.ac.jp") + assert "test.ac.jp" = DMARC.organization("www.test.ac.jp") + assert "kyoto.jp" = DMARC.organization("kyoto.jp") + assert "test.kyoto.jp" = DMARC.organization("test.kyoto.jp") + assert "ide.kyoto.jp" = DMARC.organization("ide.kyoto.jp") + assert "b.ide.kyoto.jp" = DMARC.organization("b.ide.kyoto.jp") + assert "b.ide.kyoto.jp" = DMARC.organization("a.b.ide.kyoto.jp") + # assert "c.kobe.jp" = DMARC.organization "c.kobe.jp" + assert "b.c.kobe.jp" = DMARC.organization("b.c.kobe.jp") + assert "b.c.kobe.jp" = DMARC.organization("a.b.c.kobe.jp") + assert "www.city.kobe.jp" = DMARC.organization("www.city.kobe.jp") end test "TLD with a wildcard rule and exceptions." do - assert "ck" = DMARC.organization "ck" - assert "test.ck" = DMARC.organization "test.ck" - assert "b.test.ck" = DMARC.organization "b.test.ck" - assert "b.test.ck" = DMARC.organization "a.b.test.ck" - assert "www.ck" = DMARC.organization "www.ck" - #assert "www.ck" = DMARC.organization "www.www.ck" + assert "ck" = DMARC.organization("ck") + assert "test.ck" = DMARC.organization("test.ck") + assert "b.test.ck" = DMARC.organization("b.test.ck") + assert "b.test.ck" = DMARC.organization("a.b.test.ck") + assert "www.ck" = DMARC.organization("www.ck") + # assert "www.ck" = DMARC.organization "www.www.ck" end test "US K12." do - assert "us" = DMARC.organization "us" - assert "test.us" = DMARC.organization "test.us" - assert "test.us" = DMARC.organization "www.test.us" - assert "ak.us" = DMARC.organization "ak.us" - assert "test.ak.us" = DMARC.organization "test.ak.us" - assert "test.ak.us" = DMARC.organization "www.test.ak.us" - assert "k12.ak.us" = DMARC.organization "k12.ak.us" - assert "test.k12.ak.us" = DMARC.organization "test.k12.ak.us" - assert "test.k12.ak.us" = DMARC.organization "www.test.k12.ak.us" + assert "us" = DMARC.organization("us") + assert "test.us" = DMARC.organization("test.us") + assert "test.us" = DMARC.organization("www.test.us") + assert "ak.us" = DMARC.organization("ak.us") + assert "test.ak.us" = DMARC.organization("test.ak.us") + assert "test.ak.us" = DMARC.organization("www.test.ak.us") + assert "k12.ak.us" = DMARC.organization("k12.ak.us") + assert "test.k12.ak.us" = DMARC.organization("test.k12.ak.us") + assert "test.k12.ak.us" = DMARC.organization("www.test.k12.ak.us") end test "IDN labels." do - assert "食狮.com.cn" = DMARC.organization "食狮.com.cn" - assert "食狮.公司.cn" = DMARC.organization "食狮.公司.cn" - assert "食狮.公司.cn" = DMARC.organization "www.食狮.公司.cn" - assert "shishi.公司.cn" = DMARC.organization "shishi.公司.cn" - assert "公司.cn" = DMARC.organization "公司.cn" - assert "食狮.中国" = DMARC.organization "食狮.中国" - assert "食狮.中国" = DMARC.organization "www.食狮.中国" - assert "shishi.中国" = DMARC.organization "shishi.中国" - assert "中国" = DMARC.organization "中国" + assert "食狮.com.cn" = DMARC.organization("食狮.com.cn") + assert "食狮.公司.cn" = DMARC.organization("食狮.公司.cn") + assert "食狮.公司.cn" = DMARC.organization("www.食狮.公司.cn") + assert "shishi.公司.cn" = DMARC.organization("shishi.公司.cn") + assert "公司.cn" = DMARC.organization("公司.cn") + assert "食狮.中国" = DMARC.organization("食狮.中国") + assert "食狮.中国" = DMARC.organization("www.食狮.中国") + assert "shishi.中国" = DMARC.organization("shishi.中国") + assert "中国" = DMARC.organization("中国") end end diff --git a/test/flat_mail_test.exs b/test/flat_mail_test.exs index 8ed0564..7c6b8f0 100644 --- a/test/flat_mail_test.exs +++ b/test/flat_mail_test.exs @@ -2,44 +2,64 @@ defmodule FlatMailTest do use ExUnit.Case test "unflat mail with attachments only" do - mail = MimeMail.Flat.to_mail txt: "coucou arnaud", attach: "du texte attaché", attach: File.read!("test/mimes/sample.7z") - assert {"multipart/mixed",_} = mail.headers[:'content-type'] - [text,text_attached,archive] = mail.body - assert {"text/plain",%{}} = text.headers[:'content-type'] - assert {"text/plain",%{name: ct_txt_name}} = text_attached.headers[:'content-type'] - assert {"application/x-7z-compressed",%{name: ct_7z_name}} = archive.headers[:'content-type'] - assert nil == text.headers[:'content-disposition'] - assert {"attachment",%{filename: cd_txt_name}} = text_attached.headers[:'content-disposition'] - assert {"attachment",%{filename: cd_7z_name}} = archive.headers[:'content-disposition'] - assert String.contains?(ct_txt_name,".txt") - assert String.contains?(cd_txt_name,".txt") - assert String.contains?(ct_7z_name,".7z") - assert String.contains?(cd_7z_name,".7z") + mail = + MimeMail.Flat.to_mail( + txt: "coucou arnaud", + attach: "du texte attaché", + attach: File.read!("test/mimes/sample.7z") + ) + + assert {"multipart/mixed", _} = mail.headers[:"content-type"] + [text, text_attached, archive] = mail.body + assert {"text/plain", %{}} = text.headers[:"content-type"] + assert {"text/plain", %{name: ct_txt_name}} = text_attached.headers[:"content-type"] + assert {"application/x-7z-compressed", %{name: ct_7z_name}} = archive.headers[:"content-type"] + assert nil == text.headers[:"content-disposition"] + + assert {"attachment", %{filename: cd_txt_name}} = + text_attached.headers[:"content-disposition"] + + assert {"attachment", %{filename: cd_7z_name}} = archive.headers[:"content-disposition"] + assert String.contains?(ct_txt_name, ".txt") + assert String.contains?(cd_txt_name, ".txt") + assert String.contains?(ct_7z_name, ".7z") + assert String.contains?(cd_7z_name, ".7z") end test "flat mail mixed(alternative(txt,html))" do - flat = File.read!("test/mails/amazon.eml") - |> MimeMail.from_string - |> MimeMail.Flat.from_mail - assert [{:txt, txt},{:html, html}|_headers] = flat + flat = + File.read!("test/mails/amazon.eml") + |> MimeMail.from_string() + |> MimeMail.Flat.from_mail() + + assert [{:txt, txt}, {:html, html} | _headers] = flat assert is_binary(txt) assert is_binary(html) end test "flat mail alternative(txt,related(html,img))" do - flat = File.read!("test/mails/free.eml") - |> MimeMail.from_string - |> MimeMail.Flat.from_mail - assert [{:txt, txt},{:html, html},{:include,{"imglogo","image/png",png}}|_headers] = flat + flat = + File.read!("test/mails/free.eml") + |> MimeMail.from_string() + |> MimeMail.Flat.from_mail() + + assert [{:txt, txt}, {:html, html}, {:include, {"imglogo", "image/png", png}} | _headers] = + flat + assert ".txt" = MimeTypes.bin2ext(txt) assert ".html" = MimeTypes.bin2ext(html) assert ".png" = MimeTypes.bin2ext(png) - flat = flat - |> MimeMail.Flat.to_mail - |> MimeMail.to_string - |> MimeMail.from_string - |> MimeMail.Flat.from_mail - assert [{:txt, txt},{:html, html},{:include,{"imglogo","image/png",png}}|_headers] = flat + + flat = + flat + |> MimeMail.Flat.to_mail() + |> MimeMail.to_string() + |> MimeMail.from_string() + |> MimeMail.Flat.from_mail() + + assert [{:txt, txt}, {:html, html}, {:include, {"imglogo", "image/png", png}} | _headers] = + flat + assert ".txt" = MimeTypes.bin2ext(txt) assert ".html" = MimeTypes.bin2ext(html) assert ".png" = MimeTypes.bin2ext(png) diff --git a/test/mime_headers_test.exs b/test/mime_headers_test.exs index 18cc106..1f7cbb6 100644 --- a/test/mime_headers_test.exs +++ b/test/mime_headers_test.exs @@ -2,51 +2,66 @@ defmodule MimeHeadersTest do use ExUnit.Case test "decoded params with quoted string" do - assert %{withoutquote: "with no quote", - withquote: " with; some \"quotes\" "} - = MimeMail.Params.parse_header(" withoutquote = with no quote ; WithQuote =\" with; some \\\"quotes\\\" \"") + assert %{withoutquote: "with no quote", withquote: " with; some \"quotes\" "} = + MimeMail.Params.parse_header( + " withoutquote = with no quote ; WithQuote =\" with; some \\\"quotes\\\" \"" + ) end test "encode str into an encoded-word" do - assert "=?UTF-8?Q?J=C3=A9r=C3=B4me_Nicolle?=" - = MimeMail.Words.word_encode("Jérôme Nicolle") + assert "=?UTF-8?Q?J=C3=A9r=C3=B4me_Nicolle?=" = + MimeMail.Words.word_encode("Jérôme Nicolle") end test "decode addresses headers" do - mail = File.read!("test/mails/encoded.eml") - |> MimeMail.from_string - |> MimeMail.Emails.decode_headers - assert [%MimeMail.Address{name: "Jérôme Nicolle", address: "jerome@ceriz.fr"}] - = mail.headers[:from] - assert [%MimeMail.Address{address: "frnog@frnog.org"}] - = mail.headers[:to] + mail = + File.read!("test/mails/encoded.eml") + |> MimeMail.from_string() + |> MimeMail.Emails.decode_headers() + + assert [%MimeMail.Address{name: "Jérôme Nicolle", address: "jerome@ceriz.fr"}] = + mail.headers[:from] + + assert [%MimeMail.Address{address: "frnog@frnog.org"}] = + mail.headers[:to] end test "encode addresses headers" do - mail=%MimeMail{headers: [ - to: [%MimeMail.Address{address: "frnog@frnog.org"}, - %MimeMail.Address{name: "Jérôme Nicolle", address: "jerome@ceriz.fr"}], - from: %MimeMail.Address{address: "frnog@frnog.org"} - ]} + mail = %MimeMail{ + headers: [ + to: [ + %MimeMail.Address{address: "frnog@frnog.org"}, + %MimeMail.Address{name: "Jérôme Nicolle", address: "jerome@ceriz.fr"} + ], + from: %MimeMail.Address{address: "frnog@frnog.org"} + ] + } + headers = MimeMail.encode_headers(mail).headers - assert "frnog@frnog.org, =?UTF-8?Q?J=C3=A9r=C3=B4me_Nicolle?= " - = (headers[:to] |> MimeMail.header_value |> String.replace(~r/\s+/," ")) + + assert "frnog@frnog.org, =?UTF-8?Q?J=C3=A9r=C3=B4me_Nicolle?= " = + headers[:to] |> MimeMail.header_value() |> String.replace(~r/\s+/, " ") + assert "frnog@frnog.org" = MimeMail.header_value(headers[:from]) end - + test "round trip encoded-words" do - assert "Jérôme Nicolle gave me €" - = ("Jérôme Nicolle gave me €" |> MimeMail.Words.word_encode |> MimeMail.Words.word_decode) + assert "Jérôme Nicolle gave me €" = + "Jérôme Nicolle gave me €" + |> MimeMail.Words.word_encode() + |> MimeMail.Words.word_decode() end test "decode str from base 64 encoded-word" do - assert "Jérôme Nicolle" - = MimeMail.Words.word_decode("=?UTF-8?B?SsOpcsO0bWUgTmljb2xsZQ==?=") + assert "Jérôme Nicolle" = + MimeMail.Words.word_decode("=?UTF-8?B?SsOpcsO0bWUgTmljb2xsZQ==?=") end test "decode str from q-encoded-word" do - assert "[FRnOG] [TECH] ToS implémentée chez certains transitaires" - = MimeMail.Words.word_decode("[FRnOG] =?UTF-8?Q?=5BTECH=5D_ToS_impl=C3=A9ment=C3=A9e_chez_certa?=\r\n =?UTF-8?Q?ins_transitaires?=") + assert "[FRnOG] [TECH] ToS implémentée chez certains transitaires" = + MimeMail.Words.word_decode( + "[FRnOG] =?UTF-8?Q?=5BTECH=5D_ToS_impl=C3=A9ment=C3=A9e_chez_certa?=\r\n =?UTF-8?Q?ins_transitaires?=" + ) end test "encode str into multiple encoded-word, test line length and round trip" do @@ -54,13 +69,17 @@ defmodule MimeHeadersTest do please talk to me, please stop my subject becomes too long, pleeeeeease !! , \ please stop my subject becomes too long, pleeeeeease !! , \ please stop my subject becomes too long, pleeeeeease !!" - Enum.each String.split(to_enc,"\r\n"), fn line-> + + Enum.each(String.split(to_enc, "\r\n"), fn line -> assert String.length(line) > 78 - end + end) + enc = MimeMail.Words.word_encode(to_enc) - Enum.each String.split(enc,"\r\n"), fn line-> + + Enum.each(String.split(enc, "\r\n"), fn line -> assert String.length(line) < 78 - end + end) + assert ^to_enc = MimeMail.Words.word_decode(enc) end end diff --git a/test/mime_test.exs b/test/mime_test.exs index 6369f1b..a358d07 100644 --- a/test/mime_test.exs +++ b/test/mime_test.exs @@ -11,14 +11,16 @@ defmodule MimeMailTest do """ test "qp encoding => no line > 76 char && only ascii && no space at end of lines" do res = MimeMail.string_to_qp(@qp_test) - Enum.each String.split(res,"\r\n"), fn line-> - assert !Regex.match? ~r/\s+$/, line + + Enum.each(String.split(res, "\r\n"), fn line -> + assert !Regex.match?(~r/\s+$/, line) assert String.length(line) < 77 - assert [] = Enum.filter('#{line}',&(&1 < 32 or &1 > 127)) - end + assert [] = Enum.filter(~c"#{line}", &(&1 < 32 or &1 > 127)) + end) end + test "round trip quoted-printable" do - assert @qp_test = (@qp_test |> MimeMail.string_to_qp |> MimeMail.qp_to_binary) + assert @qp_test = @qp_test |> MimeMail.string_to_qp() |> MimeMail.qp_to_binary() end test "decode qp basic" do @@ -26,7 +28,9 @@ defmodule MimeMailTest do assert "!!" = MimeMail.qp_to_binary("=21=21") assert "=:=" = MimeMail.qp_to_binary("=3D:=3D") assert "€" = MimeMail.qp_to_binary("=E2=82=AC") - assert "Thequickbrownfoxjumpedoverthelazydog." = MimeMail.qp_to_binary("Thequickbrownfoxjumpedoverthelazydog.") + + assert "Thequickbrownfoxjumpedoverthelazydog." = + MimeMail.qp_to_binary("Thequickbrownfoxjumpedoverthelazydog.") end test "decode qp lowercase" do @@ -34,83 +38,121 @@ defmodule MimeMailTest do end test "decode qp with spaces" do - assert "The quick brown fox jumped over the lazy dog." = MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog.") + assert "The quick brown fox jumped over the lazy dog." = + MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog.") end test "decode qp with tabs" do - assert "The\tquick brown fox jumped over\tthe lazy dog." = MimeMail.qp_to_binary("The\tquick brown fox jumped over\tthe lazy dog.") + assert "The\tquick brown fox jumped over\tthe lazy dog." = + MimeMail.qp_to_binary("The\tquick brown fox jumped over\tthe lazy dog.") end test "decode qp with trailing spaces" do - assert "The quick brown fox jumped over the lazy dog." = MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog. ") + assert "The quick brown fox jumped over the lazy dog." = + MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog. ") end test "decode qp with non-strippable trailing whitespace" do - assert "The quick brown fox jumped over the lazy dog. " = MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog. =20") - assert "The quick brown fox jumped over the lazy dog. \t" = MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog. =09") - assert "The quick brown fox jumped over the lazy dog.\t \t \t \t " = MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog.\t \t \t =09=20") - assert "The quick brown fox jumped over the lazy dog.\t \t \t \t " = MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog.\t \t \t =09=20\t \t") + assert "The quick brown fox jumped over the lazy dog. " = + MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog. =20") + + assert "The quick brown fox jumped over the lazy dog. \t" = + MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog. =09") + + assert "The quick brown fox jumped over the lazy dog.\t \t \t \t " = + MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog.\t \t \t =09=20") + + assert "The quick brown fox jumped over the lazy dog.\t \t \t \t " = + MimeMail.qp_to_binary( + "The quick brown fox jumped over the lazy dog.\t \t \t =09=20\t \t" + ) end test "decode qp with trailing tabs" do - assert "The quick brown fox jumped over the lazy dog." = MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog.\t\t\t\t\t") + assert "The quick brown fox jumped over the lazy dog." = + MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog.\t\t\t\t\t") end test "decode qp with soft new line" do - assert "The quick brown fox jumped over the lazy dog. " = MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog. =") + assert "The quick brown fox jumped over the lazy dog. " = + MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog. =") end + test "decode qp soft new line with trailing whitespace" do - assert "The quick brown fox jumped over the lazy dog. " = MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog. = ") + assert "The quick brown fox jumped over the lazy dog. " = + MimeMail.qp_to_binary("The quick brown fox jumped over the lazy dog. = ") end + test "decode qp multiline stuff" do - assert "Now's the time for all folk to come to the aid of their country." = MimeMail.qp_to_binary("Now's the time =\r\nfor all folk to come=\r\n to the aid of their country.") - assert "Now's the time\r\nfor all folk to come\r\n to the aid of their country." = MimeMail.qp_to_binary("Now's the time\r\nfor all folk to come\r\n to the aid of their country.") + assert "Now's the time for all folk to come to the aid of their country." = + MimeMail.qp_to_binary( + "Now's the time =\r\nfor all folk to come=\r\n to the aid of their country." + ) + + assert "Now's the time\r\nfor all folk to come\r\n to the aid of their country." = + MimeMail.qp_to_binary( + "Now's the time\r\nfor all folk to come\r\n to the aid of their country." + ) + assert "hello world" = MimeMail.qp_to_binary("hello world") assert "hello\r\n\r\nworld" = MimeMail.qp_to_binary("hello\r\n\r\nworld") end + test "decode qp invalid input" do - assert_raise(ArgumentError, fn->MimeMail.qp_to_binary("=21=G1") end) - assert_raise(ArgumentError, fn->MimeMail.qp_to_binary("=21=D1 = g ") end) + assert_raise(ArgumentError, fn -> MimeMail.qp_to_binary("=21=G1") end) + assert_raise(ArgumentError, fn -> MimeMail.qp_to_binary("=21=D1 = g ") end) end @header "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce at ultrices augue, et vulputate dui. Nullam quis magna quam. Donec venenatis lobortis viverra. Donec at tincidunt urna. Cras et tortor porta mauris cursus dictum. Morbi tempor venenatis tortor eget scelerisque." test "fold header create lines < 76 char" do - Enum.each String.split(MimeMail.fold_header(@header),"\r\n"), fn line-> + Enum.each(String.split(MimeMail.fold_header(@header), "\r\n"), fn line -> assert String.length(line) < 77 - end + end) end test "roundtrip body encoding decoding" do - decoded = File.read!("test/mails/encoded.eml") - |> MimeMail.from_string - |> MimeMail.decode_headers([DKIM,MimeMail.Emails,MimeMail.Words,MimeMail.CTParams]) - |> MimeMail.decode_body - roundtrip = decoded - |> MimeMail.to_string - |> MimeMail.from_string - |> MimeMail.decode_body + decoded = + File.read!("test/mails/encoded.eml") + |> MimeMail.from_string() + |> MimeMail.decode_headers([DKIM, MimeMail.Emails, MimeMail.Words, MimeMail.CTParams]) + |> MimeMail.decode_body() + + roundtrip = + decoded + |> MimeMail.to_string() + |> MimeMail.from_string() + |> MimeMail.decode_body() + assert String.trim_trailing(decoded.body) == String.trim_trailing(roundtrip.body) end test "email bodies with wrong encoding must be converted to printable utf8" do - decoded = File.read!("test/mails/free.eml") - |> MimeMail.from_string - |> MimeMail.decode_headers([DKIM,MimeMail.Emails,MimeMail.Words,MimeMail.CTParams]) - |> MimeMail.decode_body - for child<-decoded.body, match?({"text/"<>_,_},child.headers[:'content-type']) do + decoded = + File.read!("test/mails/free.eml") + |> MimeMail.from_string() + |> MimeMail.decode_headers([DKIM, MimeMail.Emails, MimeMail.Words, MimeMail.CTParams]) + |> MimeMail.decode_body() + + for child <- decoded.body, match?({"text/" <> _, _}, child.headers[:"content-type"]) do assert String.printable?(child.body) end end test "multipart tree decoding [txt,[html,png]]" do # 137 80 78 71 13 10 26 10 are png signature bytes - decoded = File.read!("test/mails/free.eml") - |> MimeMail.from_string - |> MimeMail.decode_body - assert [%{body: _txt}, - %{body: [ - %{body: "_}, - %{body: <<137,80,78,71,13,10,26,10,_::binary>>} - ]}] = decoded.body + decoded = + File.read!("test/mails/free.eml") + |> MimeMail.from_string() + |> MimeMail.decode_body() + + assert [ + %{body: _txt}, + %{ + body: [ + %{body: " _}, + %{body: <<137, 80, 78, 71, 13, 10, 26, 10, _::binary>>} + ] + } + ] = decoded.body end end diff --git a/test/mime_types_test.exs b/test/mime_types_test.exs index 39c827a..2753aa4 100644 --- a/test/mime_types_test.exs +++ b/test/mime_types_test.exs @@ -13,7 +13,7 @@ defmodule MimeTypesTest do end test "guess extensions from binaries" do - for f<-Path.wildcard("test/mimes/*") do + for f <- Path.wildcard("test/mimes/*") do assert Path.extname(f) == MimeTypes.bin2ext(File.read!(f)) end end diff --git a/test/spf_test.exs b/test/spf_test.exs index 52a7776..406a3d8 100644 --- a/test/spf_test.exs +++ b/test/spf_test.exs @@ -3,231 +3,316 @@ defmodule SPFTest do @moduledoc "most of the tests comes directly from rfc7208" setup_all do - :code.unstick_dir(:code.lib_dir(:kernel)++'/ebin') + :code.unstick_dir(:code.lib_dir(:kernel) ++ ~c"/ebin") previous_compiler_options = Code.compiler_options(ignore_module_conflict: true) - defmodule :inet_res do #mock external dns calls to hard define SPF rules - def lookup(dns,type,class,_opts), do: lookup(dns,type,class) - - def gethostbyname('colo.example.com',_), do: - {:ok,{:hostent,nil,nil,nil,nil,[{127,0,2,1},{127,0,2,2}]}} - def gethostbyname('_spf2.example.com',_), do: - {:ok,{:hostent,nil,nil,nil,nil,[{127,0,3,2}]}} - def gethostbyname('_spf5.example.com',_), do: - {:ok,{:hostent,nil,nil,nil,nil,[{127,0,3,5}]}} - - def gethostbyname('mx1.example.com',_), do: - {:ok,{:hostent,nil,nil,nil,nil,[{127,0,1,1},{127,0,1,2}]}} - def gethostbyname('mx2.example.com',_), do: - {:ok,{:hostent,nil,nil,nil,nil,[{127,0,1,3},{127,0,1,4}]}} + # mock external dns calls to hard define SPF rules + defmodule :inet_res do + def lookup(dns, type, class, _opts), do: lookup(dns, type, class) + + def gethostbyname(~c"colo.example.com", _), + do: {:ok, {:hostent, nil, nil, nil, nil, [{127, 0, 2, 1}, {127, 0, 2, 2}]}} + + def gethostbyname(~c"_spf2.example.com", _), + do: {:ok, {:hostent, nil, nil, nil, nil, [{127, 0, 3, 2}]}} + + def gethostbyname(~c"_spf5.example.com", _), + do: {:ok, {:hostent, nil, nil, nil, nil, [{127, 0, 3, 5}]}} + + def gethostbyname(~c"mx1.example.com", _), + do: {:ok, {:hostent, nil, nil, nil, nil, [{127, 0, 1, 1}, {127, 0, 1, 2}]}} + + def gethostbyname(~c"mx2.example.com", _), + do: {:ok, {:hostent, nil, nil, nil, nil, [{127, 0, 1, 3}, {127, 0, 1, 4}]}} # examples of rfc7208 appendix A - def gethostbyname('example.com',_), do: - {:ok,{:hostent,nil,nil,nil,nil,[{192,0,2,10},{192,0,2,11}]}} - def gethostbyname('amy.example.com',_), do: - {:ok,{:hostent,nil,nil,nil,nil,[{192,0,2,65}]}} - def gethostbyname('bob.example.com',_), do: - {:ok,{:hostent,nil,nil,nil,nil,[{192,0,2,66}]}} - def gethostbyname('mail-a.example.com',_), do: - {:ok,{:hostent,nil,nil,nil,nil,[{192,0,2,129}]}} - def gethostbyname('mail-b.example.com',_), do: - {:ok,{:hostent,nil,nil,nil,nil,[{192,0,2,130}]}} - def gethostbyname('www.example.com',_), do: - {:ok,{:hostent,nil,nil,nil,nil,[{192,0,2,10},{192,0,2,11}]}} - def gethostbyname('mail-c.example.org',_), do: - {:ok,{:hostent,nil,nil,nil,nil,[{192,0,2,140}]}} - - def gethostbyname(_,_), do: {:error,nil} - - def gethostbyaddr({127,0,0,1}), do: - {:ok,{:hostent,'example.com',nil,nil,nil,[]}} - def gethostbyaddr({127,0,3,2}), do: - {:ok,{:hostent,'_spf2.example.com',nil,nil,nil,[]}} + def gethostbyname(~c"example.com", _), + do: {:ok, {:hostent, nil, nil, nil, nil, [{192, 0, 2, 10}, {192, 0, 2, 11}]}} + + def gethostbyname(~c"amy.example.com", _), + do: {:ok, {:hostent, nil, nil, nil, nil, [{192, 0, 2, 65}]}} + + def gethostbyname(~c"bob.example.com", _), + do: {:ok, {:hostent, nil, nil, nil, nil, [{192, 0, 2, 66}]}} + + def gethostbyname(~c"mail-a.example.com", _), + do: {:ok, {:hostent, nil, nil, nil, nil, [{192, 0, 2, 129}]}} + + def gethostbyname(~c"mail-b.example.com", _), + do: {:ok, {:hostent, nil, nil, nil, nil, [{192, 0, 2, 130}]}} + + def gethostbyname(~c"www.example.com", _), + do: {:ok, {:hostent, nil, nil, nil, nil, [{192, 0, 2, 10}, {192, 0, 2, 11}]}} + + def gethostbyname(~c"mail-c.example.org", _), + do: {:ok, {:hostent, nil, nil, nil, nil, [{192, 0, 2, 140}]}} + + def gethostbyname(_, _), do: {:error, nil} + + def gethostbyaddr({127, 0, 0, 1}), do: {:ok, {:hostent, ~c"example.com", nil, nil, nil, []}} + + def gethostbyaddr({127, 0, 3, 2}), + do: {:ok, {:hostent, ~c"_spf2.example.com", nil, nil, nil, []}} # examples of rfc7208 appendix A - def gethostbyaddr({192,0,2,10}), do: - {:ok,{:hostent,'example.com',nil,nil,nil,nil}} - def gethostbyaddr({192,0,2,11}), do: - {:ok,{:hostent,'example.com',nil,nil,nil,nil}} - def gethostbyaddr({192,0,2,65}), do: - {:ok,{:hostent,'amy.example.com',nil,nil,nil,nil}} - def gethostbyaddr({192,0,2,66}), do: - {:ok,{:hostent,'bob.example.com',nil,nil,nil,nil}} - def gethostbyaddr({192,0,2,129}), do: - {:ok,{:hostent,'mail-a.example.com',nil,nil,nil,nil}} - def gethostbyaddr({192,0,2,130}), do: - {:ok,{:hostent,'mail-b.example.com',nil,nil,nil,nil}} - def gethostbyaddr({192,0,2,140}), do: - {:ok,{:hostent,'mail-c.example.org',nil,nil,nil,nil}} - def gethostbyaddr({10,0,0,4}), do: - {:ok,{:hostent,'bob.example.com',nil,nil,nil,nil}} - - def gethostbyaddr(_), do: {:error,nil} - - def lookup('_spf1.example.com',:in,:txt), do: - [['v=spf1 +mx a:colo.example.com/28 -all']] - def lookup('_spf1.example.com',:in,:mx), do: - [{1,'mx1.example.com'},{2,'mx2.example.com'}] - def lookup('_spf2.example.com',:in,:txt), do: - [['v=spf1 -ptr +all']] - def lookup('_spf4.example.com',:in,:txt), do: - [['v=spf1 -mx redirect=_spf1.example.com']] - def lookup('_spf4.example.com',:in,:mx), do: - [{1,'mx1.example.com'},{2,'mx2.example.com'}] - def lookup('_spf5.example.com',:in,:txt), do: - [['v=spf1 a mx -all']] - def lookup('_spf5.example.com',:in,:mx), do: - [{1,'mx1.example.com'},{2,'mx2.example.com'}] - def lookup('_spf6.example.com',:in,:txt), do: - [['v=spf1 include:_spf1.example.com include:_spf2.example.com -all']] - def lookup('_spf7.example.com',:in,:txt), do: - [['v=spf1 exists:%{ir}.%{l1r+-}._spf.%{d} -all']] - def lookup('_spf8.example.com',:in,:txt), do: - [['v=spf1 redirect=_spf.example.com']] - def lookup('_spf9.example.com',:in,:txt), do: - [['v=spf1 mx:example.com -all']] - def lookup('_spf10.example.com',:in,:txt), do: - [['v=spf1 mx -all exp=explain._spf.%{d}']] - def lookup('explain._spf._spf10.example.com',:in,:txt), do: - [['See http://%{d}','/why.html?s=%{S}&i=%{I}']] - def lookup('_spf11.example.com',:in,:txt), do: - [['v=spf1 mx -all exp=explain._spf.%{d}']] - def lookup('_spf111.example.com',:in,:txt), do: - [['v=spf1 mx -all exp=explain._spf.%{d}']] - def lookup('explain._spf._spf111.example.com',:in,:txt), do: - [['this message is %(d) wrong']] - def lookup('_spf12.example.com',:in,:txt), do: - [['v=spf1 ip4:192.0.2.1 ip4:192.0.2.129 -all']] - def lookup('_spf13.example.com',:in,:txt), do: - [['v=spf1 a:authorized-spf.example.com -all']] - def lookup('_spf14.example.com',:in,:txt), do: - [['v=spf1 mx:example.com -all']] - def lookup('_spf15.example.com',:in,:txt), do: - [['v=spf1 ip4:192.0.2.0/24 mx -all']] - def lookup('_spf16.example.com',:in,:txt), do: - [['v=spf1 -all']] - def lookup('_spf17.example.com',:in,:txt), do: - [['v=spf1 a -all']] - def lookup('_spf27.example.com',:in,:txt), do: - [['v=spf1 include:example.com include:example.net -all']] - def lookup('_spf28.example.com',:in,:txt), do: - [['v=spf1 ','-include:ip4._spf.%{d} ','-include:ptr._spf.%{d} ','+all']] - def lookup('_spf29.example.com',:in,:txt), do: - [['v=spf1 -ip4:192.0.2.0/24 +all']] + def gethostbyaddr({192, 0, 2, 10}), + do: {:ok, {:hostent, ~c"example.com", nil, nil, nil, nil}} + + def gethostbyaddr({192, 0, 2, 11}), + do: {:ok, {:hostent, ~c"example.com", nil, nil, nil, nil}} + + def gethostbyaddr({192, 0, 2, 65}), + do: {:ok, {:hostent, ~c"amy.example.com", nil, nil, nil, nil}} + + def gethostbyaddr({192, 0, 2, 66}), + do: {:ok, {:hostent, ~c"bob.example.com", nil, nil, nil, nil}} + + def gethostbyaddr({192, 0, 2, 129}), + do: {:ok, {:hostent, ~c"mail-a.example.com", nil, nil, nil, nil}} + + def gethostbyaddr({192, 0, 2, 130}), + do: {:ok, {:hostent, ~c"mail-b.example.com", nil, nil, nil, nil}} + + def gethostbyaddr({192, 0, 2, 140}), + do: {:ok, {:hostent, ~c"mail-c.example.org", nil, nil, nil, nil}} + + def gethostbyaddr({10, 0, 0, 4}), + do: {:ok, {:hostent, ~c"bob.example.com", nil, nil, nil, nil}} + + def gethostbyaddr(_), do: {:error, nil} + + def lookup(~c"_spf1.example.com", :in, :txt), + do: [[~c"v=spf1 +mx a:colo.example.com/28 -all"]] + + def lookup(~c"_spf1.example.com", :in, :mx), + do: [{1, ~c"mx1.example.com"}, {2, ~c"mx2.example.com"}] + + def lookup(~c"_spf2.example.com", :in, :txt), do: [[~c"v=spf1 -ptr +all"]] + + def lookup(~c"_spf4.example.com", :in, :txt), + do: [[~c"v=spf1 -mx redirect=_spf1.example.com"]] + + def lookup(~c"_spf4.example.com", :in, :mx), + do: [{1, ~c"mx1.example.com"}, {2, ~c"mx2.example.com"}] + + def lookup(~c"_spf5.example.com", :in, :txt), do: [[~c"v=spf1 a mx -all"]] + + def lookup(~c"_spf5.example.com", :in, :mx), + do: [{1, ~c"mx1.example.com"}, {2, ~c"mx2.example.com"}] + + def lookup(~c"_spf6.example.com", :in, :txt), + do: [[~c"v=spf1 include:_spf1.example.com include:_spf2.example.com -all"]] + + def lookup(~c"_spf7.example.com", :in, :txt), + do: [[~c"v=spf1 exists:%{ir}.%{l1r+-}._spf.%{d} -all"]] + + def lookup(~c"_spf8.example.com", :in, :txt), do: [[~c"v=spf1 redirect=_spf.example.com"]] + def lookup(~c"_spf9.example.com", :in, :txt), do: [[~c"v=spf1 mx:example.com -all"]] + + def lookup(~c"_spf10.example.com", :in, :txt), + do: [[~c"v=spf1 mx -all exp=explain._spf.%{d}"]] + + def lookup(~c"explain._spf._spf10.example.com", :in, :txt), + do: [[~c"See http://%{d}", ~c"/why.html?s=%{S}&i=%{I}"]] + + def lookup(~c"_spf11.example.com", :in, :txt), + do: [[~c"v=spf1 mx -all exp=explain._spf.%{d}"]] + + def lookup(~c"_spf111.example.com", :in, :txt), + do: [[~c"v=spf1 mx -all exp=explain._spf.%{d}"]] + + def lookup(~c"explain._spf._spf111.example.com", :in, :txt), + do: [[~c"this message is %(d) wrong"]] + + def lookup(~c"_spf12.example.com", :in, :txt), + do: [[~c"v=spf1 ip4:192.0.2.1 ip4:192.0.2.129 -all"]] + + def lookup(~c"_spf13.example.com", :in, :txt), + do: [[~c"v=spf1 a:authorized-spf.example.com -all"]] + + def lookup(~c"_spf14.example.com", :in, :txt), do: [[~c"v=spf1 mx:example.com -all"]] + def lookup(~c"_spf15.example.com", :in, :txt), do: [[~c"v=spf1 ip4:192.0.2.0/24 mx -all"]] + def lookup(~c"_spf16.example.com", :in, :txt), do: [[~c"v=spf1 -all"]] + def lookup(~c"_spf17.example.com", :in, :txt), do: [[~c"v=spf1 a -all"]] + + def lookup(~c"_spf27.example.com", :in, :txt), + do: [[~c"v=spf1 include:example.com include:example.net -all"]] + + def lookup(~c"_spf28.example.com", :in, :txt), + do: [[~c"v=spf1 ", ~c"-include:ip4._spf.%{d} ", ~c"-include:ptr._spf.%{d} ", ~c"+all"]] + + def lookup(~c"_spf29.example.com", :in, :txt), do: [[~c"v=spf1 -ip4:192.0.2.0/24 +all"]] # examples of rfc7208 appendix A - def lookup('example.com',:in,:mx), do: - [{10,'mail-a.example.com'},{20,'mail-b.example.com'}] - def lookup('example.org',:in,:mx), do: - [{10,'mail-c.example.org'}] + def lookup(~c"example.com", :in, :mx), + do: [{10, ~c"mail-a.example.com"}, {20, ~c"mail-b.example.com"}] - def lookup(_,_,_), do: [] + def lookup(~c"example.org", :in, :mx), do: [{10, ~c"mail-c.example.org"}] + + def lookup(_, _, _), do: [] end + _ = Code.compiler_options(previous_compiler_options) :ok end - + test "single macro expansion" do - params = %{sender: "strong-bad@email.example.com", client_ip: {192,0,2,3}, helo: "mx.example.com", - curr_domain: "server.com",domain: "email.example.com"} - assert "strong-bad@email.example.com"=SPF.target_name("%{s}",params) - assert "email.example.com"=SPF.target_name("%{o}",params) - assert "email.example.com"=SPF.target_name("%{d}",params) - assert "email.example.com"=SPF.target_name("%{d4}",params) - assert "email.example.com"=SPF.target_name("%{d3}",params) - assert "example.com"=SPF.target_name("%{d2}",params) - assert "com"=SPF.target_name("%{d1}",params) - assert "com.example.email"=SPF.target_name("%{dr}",params) - assert "example.email"=SPF.target_name("%{d2r}",params) - assert "strong-bad"=SPF.target_name("%{l}",params) - assert "strong.bad"=SPF.target_name("%{l-}",params) - assert "strong-bad"=SPF.target_name("%{lr}",params) - assert "bad.strong"=SPF.target_name("%{lr-}",params) - assert "strong"=SPF.target_name("%{l1r-}",params) + params = %{ + sender: "strong-bad@email.example.com", + client_ip: {192, 0, 2, 3}, + helo: "mx.example.com", + curr_domain: "server.com", + domain: "email.example.com" + } + + assert "strong-bad@email.example.com" = SPF.target_name("%{s}", params) + assert "email.example.com" = SPF.target_name("%{o}", params) + assert "email.example.com" = SPF.target_name("%{d}", params) + assert "email.example.com" = SPF.target_name("%{d4}", params) + assert "email.example.com" = SPF.target_name("%{d3}", params) + assert "example.com" = SPF.target_name("%{d2}", params) + assert "com" = SPF.target_name("%{d1}", params) + assert "com.example.email" = SPF.target_name("%{dr}", params) + assert "example.email" = SPF.target_name("%{d2r}", params) + assert "strong-bad" = SPF.target_name("%{l}", params) + assert "strong.bad" = SPF.target_name("%{l-}", params) + assert "strong-bad" = SPF.target_name("%{lr}", params) + assert "bad.strong" = SPF.target_name("%{lr-}", params) + assert "strong" = SPF.target_name("%{l1r-}", params) end - + test "complex string macro expansion" do - params = %{sender: "strong-bad@email.example.com", client_ip: {192,0,2,3}, helo: "mx.example.org", - curr_domain: "server.com",domain: "email.example.com"} - assert "3.2.0.192.in-addr._spf.example.com"=SPF.target_name("%{ir}.%{v}._spf.%{d2}",params) - assert "bad.strong.lp._spf.example.com"=SPF.target_name("%{lr-}.lp._spf.%{d2}",params) - assert "bad.strong.lp.3.2.0.192.in-addr._spf.example.com"=SPF.target_name("%{lr-}.lp.%{ir}.%{v}._spf.%{d2}",params) - assert "3.2.0.192.in-addr.strong.lp._spf.example.com"=SPF.target_name("%{ir}.%{v}.%{l1r-}.lp._spf.%{d2}",params) - assert "example.com.trusted-domains.example.net"=SPF.target_name("%{d2}.trusted-domains.example.net",params) + params = %{ + sender: "strong-bad@email.example.com", + client_ip: {192, 0, 2, 3}, + helo: "mx.example.org", + curr_domain: "server.com", + domain: "email.example.com" + } + + assert "3.2.0.192.in-addr._spf.example.com" = SPF.target_name("%{ir}.%{v}._spf.%{d2}", params) + assert "bad.strong.lp._spf.example.com" = SPF.target_name("%{lr-}.lp._spf.%{d2}", params) + + assert "bad.strong.lp.3.2.0.192.in-addr._spf.example.com" = + SPF.target_name("%{lr-}.lp.%{ir}.%{v}._spf.%{d2}", params) + + assert "3.2.0.192.in-addr.strong.lp._spf.example.com" = + SPF.target_name("%{ir}.%{v}.%{l1r-}.lp._spf.%{d2}", params) + + assert "example.com.trusted-domains.example.net" = + SPF.target_name("%{d2}.trusted-domains.example.net", params) end - + test "ipv6 macro expansion" do - params = %{sender: "strong-bad@email.example.com", client_ip: {8193, 3512, 0, 0, 0, 0, 0, 51969}, helo: "mx.example.org", - curr_domain: "server.com",domain: "email.example.com"} - assert "1.0.b.c.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6._spf.example.com" - =SPF.target_name("%{ir}.%{v}._spf.%{d2}",params) + params = %{ + sender: "strong-bad@email.example.com", + client_ip: {8193, 3512, 0, 0, 0, 0, 0, 51969}, + helo: "mx.example.org", + curr_domain: "server.com", + domain: "email.example.com" + } + + assert "1.0.b.c.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6._spf.example.com" = + SPF.target_name("%{ir}.%{v}._spf.%{d2}", params) end - + test "mix spf policies" do # _spf1.example.com => v=spf1 +mx a:colo.example.com/28 -all - assert :pass = # not match mx rule, but match a/28 rule - SPF.check("toto@_spf1.example.com",{127,0,2,3}) - assert {:fail,_} = # not match mx rule, neither "a/28" rule, so -all - SPF.check("toto@_spf1.example.com",{127,0,1,10}) - assert :pass = # match first mx rule - SPF.check("toto@_spf1.example.com",{127,0,1,4}) + # not match mx rule, but match a/28 rule + assert :pass = + SPF.check("toto@_spf1.example.com", {127, 0, 2, 3}) + + # not match mx rule, neither "a/28" rule, so -all + assert {:fail, _} = + SPF.check("toto@_spf1.example.com", {127, 0, 1, 10}) + + # match first mx rule + assert :pass = + SPF.check("toto@_spf1.example.com", {127, 0, 1, 4}) + # _spf2.example.com => v=spf1 -ptr +all - assert :pass = # has no ptr, so match +all - SPF.check("toto@_spf2.example.com",{128,0,0,1}) - assert :pass = # has ptr but not subdomain of _spf2.example.com, so match +all - SPF.check("toto@_spf2.example.com",{127,0,0,1}) - assert {:fail,_} = # has ptr subdomain of _spf2.example.com - SPF.check("toto@_spf2.example.com",{127,0,3,2}) + # has no ptr, so match +all + assert :pass = + SPF.check("toto@_spf2.example.com", {128, 0, 0, 1}) + + # has ptr but not subdomain of _spf2.example.com, so match +all + assert :pass = + SPF.check("toto@_spf2.example.com", {127, 0, 0, 1}) + + # has ptr subdomain of _spf2.example.com + assert {:fail, _} = + SPF.check("toto@_spf2.example.com", {127, 0, 3, 2}) + # _spf4.example.com => v=spf1 -mx redirect=_spf1.example.com - assert {:fail,_} = # match mx rule, so no redirection and fail - SPF.check("toto@_spf4.example.com",{127,0,1,2}) - assert :pass = # not match mx rule, but match a/28 rule of _spf1 - SPF.check("toto@_spf4.example.com",{127,0,2,3}) + # match mx rule, so no redirection and fail + assert {:fail, _} = + SPF.check("toto@_spf4.example.com", {127, 0, 1, 2}) + + # not match mx rule, but match a/28 rule of _spf1 + assert :pass = + SPF.check("toto@_spf4.example.com", {127, 0, 2, 3}) end - + test "use a custom fail message : exp= or default" do # _spf10.example.com => v=spf1 mx -all exp=explain._spf.%{d} - assert {:fail,"See http://_spf10.example.com/why.html?s=toto@_spf10.example.com&i=127.0.2.3"} = - SPF.check("toto@_spf10.example.com",{127,0,2,3}) + assert {:fail, "See http://_spf10.example.com/why.html?s=toto@_spf10.example.com&i=127.0.2.3"} = + SPF.check("toto@_spf10.example.com", {127, 0, 2, 3}) + # _spf11 same as 10 but not txt record at explain._spf._spf11.example.com - assert {:fail,"domain of "<>_} = - SPF.check("toto@_spf11.example.com",{127,0,2,3}) + assert {:fail, "domain of " <> _} = + SPF.check("toto@_spf11.example.com", {127, 0, 2, 3}) + # _spf111 same as 10 but a txt record is malformed at explain._spf._spf111.example.com - assert {:fail,"domain of "<>_} = - SPF.check("toto@_spf111.example.com",{127,0,2,3}) + assert {:fail, "domain of " <> _} = + SPF.check("toto@_spf111.example.com", {127, 0, 2, 3}) end test "rfc7208 appendix A" do assert :pass = - SPF.check("toto@example.com",{200,200,200,200}, spf: "+all") + SPF.check("toto@example.com", {200, 200, 200, 200}, spf: "+all") + assert :pass = - SPF.check("toto@example.com",{192,0,2,10}, spf: "a -all") + SPF.check("toto@example.com", {192, 0, 2, 10}, spf: "a -all") + assert :pass = - SPF.check("toto@example.com",{192,0,2,11}, spf: "a -all") - assert {:fail,_} = - SPF.check("toto@example.com",{192,0,2,11}, spf: "a:example.org -all") + SPF.check("toto@example.com", {192, 0, 2, 11}, spf: "a -all") + + assert {:fail, _} = + SPF.check("toto@example.com", {192, 0, 2, 11}, spf: "a:example.org -all") + assert :pass = - SPF.check("toto@example.com",{192,0,2,129}, spf: "mx -all") + SPF.check("toto@example.com", {192, 0, 2, 129}, spf: "mx -all") + assert :pass = - SPF.check("toto@example.com",{192,0,2,130}, spf: "mx -all") + SPF.check("toto@example.com", {192, 0, 2, 130}, spf: "mx -all") + assert :pass = - SPF.check("toto@example.com",{192,0,2,140}, spf: "mx:example.org -all") + SPF.check("toto@example.com", {192, 0, 2, 140}, spf: "mx:example.org -all") + assert :pass = - SPF.check("toto@example.com",{192,0,2,130}, spf: "mx mx:example.org -all") + SPF.check("toto@example.com", {192, 0, 2, 130}, spf: "mx mx:example.org -all") + assert :pass = - SPF.check("toto@example.com",{192,0,2,140}, spf: "mx mx:example.org -all") + SPF.check("toto@example.com", {192, 0, 2, 140}, spf: "mx mx:example.org -all") + assert :pass = - SPF.check("toto@example.com",{192,0,2,131}, spf: "mx/30 mx:example.org/30 -all") - assert {:fail,_} = - SPF.check("toto@example.com",{192,0,2,132}, spf: "mx/30 mx:example.org/30 -all") + SPF.check("toto@example.com", {192, 0, 2, 131}, spf: "mx/30 mx:example.org/30 -all") + + assert {:fail, _} = + SPF.check("toto@example.com", {192, 0, 2, 132}, spf: "mx/30 mx:example.org/30 -all") + assert :pass = - SPF.check("toto@example.com",{192,0,2,143}, spf: "mx/30 mx:example.org/30 -all") + SPF.check("toto@example.com", {192, 0, 2, 143}, spf: "mx/30 mx:example.org/30 -all") + assert :pass = - SPF.check("toto@example.com",{192,0,2,65}, spf: "ptr -all") - assert {:fail,_} = - SPF.check("toto@example.com",{192,0,2,140}, spf: "ptr -all") - assert {:fail,_} = - SPF.check("toto@example.com",{10,0,0,4}, spf: "ptr -all") - assert {:fail,_} = - SPF.check("toto@example.com",{192,0,2,65}, spf: "ip4:192.0.2.128/28 -all") + SPF.check("toto@example.com", {192, 0, 2, 65}, spf: "ptr -all") + + assert {:fail, _} = + SPF.check("toto@example.com", {192, 0, 2, 140}, spf: "ptr -all") + + assert {:fail, _} = + SPF.check("toto@example.com", {10, 0, 0, 4}, spf: "ptr -all") + + assert {:fail, _} = + SPF.check("toto@example.com", {192, 0, 2, 65}, spf: "ip4:192.0.2.128/28 -all") end end