diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..ec2d8ea --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +erlang: 20.3 +elixir 1.6.3 diff --git a/lib/agala_email.ex b/lib/agala_email.ex index 71b7070..2c19b5c 100644 --- a/lib/agala_email.ex +++ b/lib/agala_email.ex @@ -1,14 +1,13 @@ defmodule Agala.Provider.Email do + use Agala.Provider + @moduledoc """ Module providing email connection. """ - use Agala.Provider - - def init(bot_params) do - { - :ok, - bot_params - } + defmacro __using__(:handler) do + quote location: :keep do + import Agala.Provider.Email.Helpers + end end end diff --git a/lib/helpers.ex b/lib/helpers.ex new file mode 100644 index 0000000..55508d8 --- /dev/null +++ b/lib/helpers.ex @@ -0,0 +1,10 @@ +defmodule Agala.Provider.Email.Helpers do + import Bamboo.Email + + def generate_message(to, from, subject) do + new_email + |> to(to) + |> from(from) + |> subject(subject) + end +end diff --git a/lib/poller.ex b/lib/poller.ex new file mode 100644 index 0000000..4bf146e --- /dev/null +++ b/lib/poller.ex @@ -0,0 +1,63 @@ +defmodule Agala.Provider.Email.Poller do + @pop3 Agala.Provider.Email.Protocol.Pop3 + @pop3_mock Agala.Provider.Email.Protocol.Pop3.Mock + + use Agala.Bot.Common.Poller + alias Agala.BotParams + + ####################################################################################### + ### Initialize section + ####################################################################################### + + @spec bootstrap(Agala.BotParams.t()) :: {:ok, Agala.BotParams} + def bootstrap(bot_params) do + { + :ok, + Map.put(bot_params, :private, %{ + mail_fetcher_module: (Mix.env() == :test && @pop3_mock) || @pop3 + }) + } + end + + ####################################################################################### + ### Get updates section + ####################################################################################### + + @spec get_updates(bot_params :: Agala.BotParams.t()) :: {list(), Agala.BotParams.t()} + def get_updates(bot_params = %BotParams{}) do + mail_proto = private_options(bot_params) + + {:ok, client} = mail_proto.connect(mail_options(bot_params)) + {:ok, mails} = mail_proto.scan(client) + + Logger.debug("Email retrieving started. Total count of messages in box: #{length(mails)}") + + updates = + Enum.map(mails, fn {id, _} -> + # @todo: use poolboy and asynk tasks + {:ok, bin_message} = mail_proto.retrieve(client, id) + + mail_proto.delete(client, id) + mail_proto.parse_binary(bin_message) + end) + + mail_proto.disconnect(client) + + :timer.sleep(updates_interval(bot_params)) + + {updates, bot_params} + end + + defp private_options(%BotParams{ + private: %{ + mail_fetcher_module: mail_fetcher_module + } + }), + do: mail_fetcher_module + + defp updates_interval(%BotParams{provider_params: %{updates_interval: interval}}), do: interval + + defp mail_options(%BotParams{} = opts) do + Map.get(opts, :provider_params) + end +end diff --git a/lib/protocol/pop3.ex b/lib/protocol/pop3.ex new file mode 100644 index 0000000..7e873f9 --- /dev/null +++ b/lib/protocol/pop3.ex @@ -0,0 +1,51 @@ +defmodule Agala.Provider.Email.Protocol.Pop3 do + @moduledoc """ + Implementation of Pop3 mail protocol for Agala framework. + Based on nico-amsterdam/pop3mail + """ + + def connect(%{email: email, password: password, server: server, port: port}) do + :epop_client.connect(email, password, [{:addr, server}, {:port, port}, {:user, email}, :ssl]) + end + + def scan(client) do + :epop_client.scan(client) + end + + def retrieve(client, id) do + :epop_client.bin_retrieve(client, id) + end + + def delete(client, id) do + :epop_client.delete(client, id) + end + + def disconnect(client) do + :epop_client.quit(client) + end + + def parse_binary(raw_binary) do + {:message, h, c} = + raw_binary + |> :epop_message.bin_parse() + |> parse_content + |> parse_headers + + {h, c} + end + + def parse_headers({:message, headers, content}) do + parsed_headers = + headers + |> Enum.reduce(%{}, fn {:header, key, val}, acc -> + Map.put(acc, key, val) + end) + + {:message, parsed_headers, content} + end + + def parse_content({:message, headers, content}) do + parsed_content = Pop3mail.decode_body_content(headers, content) + {:message, headers, parsed_content} + end +end diff --git a/lib/protocol/pop3_mock.ex b/lib/protocol/pop3_mock.ex new file mode 100644 index 0000000..a8d44e7 --- /dev/null +++ b/lib/protocol/pop3_mock.ex @@ -0,0 +1,45 @@ +defmodule Agala.Provider.Email.Protocol.Pop3.Mock do + @moduledoc """ + Implementation of mock module for Pop3 mail protocol of Agala framework. + For testing purposes + """ + alias Agala.Provider.Email.Protocol.Pop3 + + def connect(%{email: _, password: _, server: _, port: _, login: _}) do + {:ok, + {:sk, 'user', 'mail.example.com', + {:sslsocket, {:gen_tcp, "some_port", :tls_connection, :undefined}, self()}, 995, false, + false, true}} + end + + def scan(_client) do + {:ok, [{1, 1948}, {2, 1923}, {3, 1123}]} + end + + def retrieve(_client, id) do + {:ok, + "To: \r\nFrom: Sender \r\nSubject: =?UTF-8?B?0LLQvtC/0YDQvtGBINC/0L4g0LrRgNC10LTQuNGC0YM=?=\r\nDate: Tue, 30 Jan 2018 16:22:53 +0300\r\nUser-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:52.0) Gecko/20100101\r\n Thunderbird/52.5.2\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"; format=flowed\r\nContent-Transfer-Encoding: 8bit\r\nContent-Language: en-US\r\n\r\nmessage number #{ + id + }\r\n\r\n"} + end + + def delete(_client, _id) do + :ok + end + + def disconnect(_client) do + :ok + end + + def parse_binary(raw_binary) do + Pop3.parse_binary(raw_binary) + end + + def parse_headers({:message, headers, content}) do + Pop3.parse_headers({:message, headers, content}) + end + + def parse_content({:message, headers, content}) do + Pop3.parse_content({:message, headers, content}) + end +end diff --git a/mix.exs b/mix.exs index bd401ca..e680688 100644 --- a/mix.exs +++ b/mix.exs @@ -23,11 +23,12 @@ defmodule AgalaEmail.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:agala, "~> 2.0.2"}, + {:agala, "~> 3.0"}, {:ex_doc, "> 0.0.0", only: :dev}, - {:credo, "~> 0.8", only: [:dev, :test]} - # {:dep_from_hexpm, "~> 0.3.0"}, - # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, + {:credo, "~> 0.8", only: [:dev, :test]}, + {:pop3mail, "~> 1.3"}, + {:erlpop, github: "nico-amsterdam/erlpop"}, + {:bamboo, "~> 0.8"} ] end diff --git a/mix.lock b/mix.lock index 8e22afb..c792409 100644 --- a/mix.lock +++ b/mix.lock @@ -1,17 +1,28 @@ %{ - "agala": {:hex, :agala, "2.0.2", "9a4706cafb4f371ceca503a60b317c33b78fc7e618676ffa5bffba7bcf931748", [:mix], [{:excoveralls, "~> 0.7.4", [hex: :excoveralls, repo: "hexpm", optional: false]}], "hexpm"}, + "agala": {:hex, :agala, "3.0.0", "00eba9262858c65b835cc9eebdf488137c70d9a26d1473349663b1a8a930d218", [:mix], [], "hexpm"}, + "bamboo": {:hex, :bamboo, "0.8.0", "573889a3efcb906bb9d25a1c4caa4ca22f479235e1b8cc3260d8b88dabeb4b14", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, - "certifi": {:hex, :certifi, "2.0.0", "a0c0e475107135f76b8c1d5bc7efb33cd3815cb3cf3dea7aefdd174dabead064", [:rebar3], [], "hexpm"}, - "credo": {:hex, :credo, "0.8.10", "261862bb7363247762e1063713bb85df2bbd84af8d8610d1272cd9c1943bba63", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, - "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.18.2", "993e0a95e9fbb790ac54ea58e700b45b299bd48bc44b4ae0404f28161f37a83e", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, + "certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, + "credo": {:hex, :credo, "0.9.3", "76fa3e9e497ab282e0cf64b98a624aa11da702854c52c82db1bf24e54ab7c97a", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, + "earmark": {:hex, :earmark, "1.2.5", "4d21980d5d2862a2e13ec3c49ad9ad783ffc7ca5769cf6ff891a4553fbaae761", [:mix], [], "hexpm"}, + "erlpop": {:git, "https://github.com/nico-amsterdam/erlpop.git", "811c20ba5566e38eb3f724d2bf3b76fb4d286df2", []}, + "ex_doc": {:hex, :ex_doc, "0.18.4", "4406b8891cecf1352f49975c6d554e62e4341ceb41b9338949077b0d4a97b949", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, "excoveralls": {:hex, :excoveralls, "0.7.5", "339e433e5d3bce09400dc8de7b9040741a409c93917849916c136a0f51fdc183", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, - "hackney": {:hex, :hackney, "1.11.0", "4951ee019df102492dabba66a09e305f61919a8a183a7860236c0fde586134b6", [:rebar3], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, - "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, + "fastglobal": {:hex, :fastglobal, "1.0.0", "f3133a0cda8e9408aac7281ec579c4b4a8386ce0e99ca55f746b9f58192f455b", [:mix], [], "hexpm"}, + "hackney": {:hex, :hackney, "1.13.0", "24edc8cd2b28e1c652593833862435c80661834f6c9344e84b6a2255e7aeef03", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.2", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, + "iconv": {:hex, :iconv, "1.0.6", "3b424a80039059767f1037dc6a49ff07c2f88df14068c16dc938c4f377a77b4c", [:rebar3], [{:p1_utils, "1.0.10", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm"}, + "idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, + "mime": {:hex, :mime, "1.3.0", "5e8d45a39e95c650900d03f897fbf99ae04f60ab1daa4a34c7a20a5151b7a5fe", [:mix], [], "hexpm"}, "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, + "p1_utils": {:hex, :p1_utils, "1.0.10", "a6d6927114bac79cf6468a10824125492034af7071adc6ed5ebc4ddb443845d4", [:rebar3], [], "hexpm"}, + "parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"}, + "plug": {:hex, :plug, "1.6.1", "c62fe7623d035020cf989820b38490460e6903ab7eee29e234b7586e9b6c91d6", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, + "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, + "pop3mail": {:hex, :pop3mail, "1.3.0", "5eba88226a54572debe30667ceadd2f8e4ea104f3309fea8f74a768d71ee512c", [:mix], [], "hexpm"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, } diff --git a/test/email_receiver_test.exs b/test/email_receiver_test.exs new file mode 100644 index 0000000..4087032 --- /dev/null +++ b/test/email_receiver_test.exs @@ -0,0 +1,22 @@ +defmodule EmailReceiverTest do + use ExUnit.Case + doctest Agala.Provider.Email + alias Agala.BotParams + alias Agala.Provider.Email.Poller + + @bot_configuration %BotParams{ + provider_params: %{ + login: "user", + password: "secure", + server: "mail.server.ru", + port: 995, + email: "user@example.com", + updates_interval: 5 * 1_000 + } + } + + test "get_updates call applies handler" do + {:ok, bot_params} = Poller.bootstrap(@bot_configuration) + assert {_updates, ^bot_params} = Poller.get_updates(bot_params) + end +end diff --git a/test/email_test.exs b/test/email_test.exs index ee4770e..37a1f74 100644 --- a/test/email_test.exs +++ b/test/email_test.exs @@ -6,10 +6,22 @@ defmodule EmailTest do test "init returns :ok and bot params" do bot_configuration = %BotParams{ provider_params: %{ - username: "user", password: "secure", server: "mail.server.ru" + username: "user", + password: "secure", + server: "mail.server.ru" } } - assert {:ok, ^bot_configuration} = Agala.Provider.Email.init(bot_configuration) + expected_bot_configuration = + Map.put( + bot_configuration, + :private, + %{ + mail_fetcher_module: Agala.Provider.Email.Protocol.Pop3.Mock + } + ) + + assert {:ok, ^expected_bot_configuration} = + Agala.Provider.Email.Poller.bootstrap(bot_configuration) end end diff --git a/test/protocol/pop3_test.exs b/test/protocol/pop3_test.exs new file mode 100644 index 0000000..612ceff --- /dev/null +++ b/test/protocol/pop3_test.exs @@ -0,0 +1,63 @@ +defmodule Email.Protocol.Pop3Test do + use ExUnit.Case + doctest Agala.Provider.Email.Protocol.Pop3 + alias Agala.Provider.Email.Protocol.Pop3 + + test "parse_headers returns map of headers" do + exp_result = %{ + "To" => "", + "From" => "Sender ", + "Subject" => + "=?UTF-8?B?0LLQvtC/0YDQvtGBINC/0L4g0LrRgNC10LTQuNGC0YM=?=", + "Message-ID" => "<7145c09c-ce5a-0b39-139d-40aeb323e668@cti.ru>", + "Date" => "Tue, 30 Jan 2018 16:22:53 +0300", + "User-Agent" => + "Mozilla/5.0 (X11; Linux x86_64; rv:52.0) Gecko/20100101 Thunderbird/52.5.2", + "MIME-Version" => "1.0", + "Content-Type" => "text/plain; charset=\"utf-8\"; format=flowed", + "Content-Transfer-Encoding" => "8bit", + "Content-Language" => "en-US" + } + assert {:message, ^exp_result, _} = Pop3.parse_headers({:message, mock_headers(), mock_content()}) + end + + test "parse_content returns list of parts" do + exp_result = [ + %Pop3mail.Part{ + boundary: "", + charset: "utf-8", + content: "пожалуйста не присылайте коллекторов\r\n\r\n", + content_id: "", + content_location: "", + filename: "", + filename_charset: "us-ascii", + index: 1, + inline: nil, + media_type: "text/plain", + path: "" + } + ] + assert {:message, _, ^exp_result} = Pop3.parse_content({:message, mock_headers(), mock_content()}) + end + + defp mock_headers() do + [ + {:header, "To", ""}, + {:header, "From", "Sender "}, + {:header, "Subject", + "=?UTF-8?B?0LLQvtC/0YDQvtGBINC/0L4g0LrRgNC10LTQuNGC0YM=?="}, + {:header, "Message-ID", "<7145c09c-ce5a-0b39-139d-40aeb323e668@cti.ru>"}, + {:header, "Date", "Tue, 30 Jan 2018 16:22:53 +0300"}, + {:header, "User-Agent", + "Mozilla/5.0 (X11; Linux x86_64; rv:52.0) Gecko/20100101 Thunderbird/52.5.2"}, + {:header, "MIME-Version", "1.0"}, + {:header, "Content-Type", "text/plain; charset=\"utf-8\"; format=flowed"}, + {:header, "Content-Transfer-Encoding", "8bit"}, + {:header, "Content-Language", "en-US"} + ] + end + + defp mock_content() do + "пожалуйста не присылайте коллекторов\r\n\r\n" + end +end