├── lib ├── mailroom.ex └── mailroom │ ├── backwards_compatible_logger.ex │ ├── imap │ ├── envelope.ex │ ├── body_structure.ex │ └── utils.ex │ ├── inbox │ └── match_utils.ex │ ├── socket.ex │ ├── pop3.ex │ ├── smtp.ex │ ├── inbox.ex │ └── imap.ex ├── .formatter.exs ├── test ├── test_helper.exs ├── mailroom_test.exs ├── support │ ├── certificate.pem │ ├── key.pem │ └── test_server.ex └── mailroom │ ├── imap │ ├── utils_test.exs │ └── body_structure_tests.exs │ ├── pop3_test.exs │ ├── inbox │ └── match_utils_test.exs │ ├── smtp_test.exs │ └── inbox_test.exs ├── config ├── test.exs └── config.exs ├── .gitignore ├── README.md ├── LICENSE ├── .github └── workflows │ └── elixir.yml ├── mix.exs ├── mix.lock └── docs └── multi_lingual_emails.md /lib/mailroom.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailroom do 2 | end 3 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], 3 | ] 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Mailroom.TestServer.Application.start(nil, nil) 2 | ExUnit.start() 3 | -------------------------------------------------------------------------------- /test/mailroom_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MailroomTest do 2 | use ExUnit.Case 3 | doctest Mailroom 4 | end 5 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :ex_unit, 4 | refute_receive_timeout: 900, 5 | assert_receive_timeout: 900 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | .tool-versions 19 | .vscode -------------------------------------------------------------------------------- /lib/mailroom/backwards_compatible_logger.ex: -------------------------------------------------------------------------------- 1 | # From https://jeffkreeftmeijer.com/elixir-backwards-compatible-logger/ 2 | defmodule Mailroom.BackwardsCompatibleLogger do 3 | require Logger 4 | 5 | defdelegate debug(message), to: Logger 6 | defdelegate info(message), to: Logger 7 | defdelegate error(message), to: Logger 8 | 9 | case Version.compare(System.version(), "1.11.0") do 10 | :lt -> defdelegate warning(message), to: Logger, as: :warn 11 | _ -> defdelegate warning(message), to: Logger 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mailroom 2 | 3 | Send, receive and process emails. 4 | 5 | ## Example: 6 | 7 | ```elixir 8 | alias Mailroom.POP3 9 | 10 | {:ok, client} = POP3.connect(server, username, password, port: port, ssl: true) 11 | client 12 | |> POP3.list 13 | |> Enum.each(fn(mail) -> 14 | {:ok, message} = POP3.retrieve(client, mail) 15 | # process message 16 | :ok = POP3.delete(client, mail) 17 | end) 18 | :ok = POP3.reset(client) 19 | :ok = POP3.close(client) 20 | ``` 21 | 22 | ## Installation 23 | 24 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 25 | 26 | 1. Add `mailroom` to your list of dependencies in `mix.exs`: 27 | 28 | ```elixir 29 | def deps do 30 | [{:mailroom, "~> 0.5.0"}] 31 | end 32 | ``` 33 | 34 | 2. Ensure `mailroom` is started before your application: 35 | 36 | ```elixir 37 | def application do 38 | [applications: [:mailroom]] 39 | end 40 | ``` 41 | 42 | ## Multi-lingual and Multi-encoded Emails 43 | 44 | See [multi_lingual_emails.md](docs/multi_lingual_emails.md) for more information. 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Andrew Timberlake 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/support/certificate.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDADCCAegCCQCAOWQgI81poDANBgkqhkiG9w0BAQsFADBCMQswCQYDVQQGEwJa 3 | QTERMA8GA1UECgwITWFpbHJvb20xDDAKBgNVBAsMA0RldjESMBAGA1UEAwwJbG9j 4 | YWxob3N0MB4XDTIzMDgwMjA2NDAxNloXDTI0MDgwMTA2NDAxNlowQjELMAkGA1UE 5 | BhMCWkExETAPBgNVBAoMCE1haWxyb29tMQwwCgYDVQQLDANEZXYxEjAQBgNVBAMM 6 | CWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMMfEdQ3 7 | 7UiOVmYYgrzqhnqTemAF2DVW2cIveOd8twLqwcwI9vINUTwu0RuHLpZIDl5QGc6/ 8 | RgHuTvAO2faJ/UfFKNlDjnWxyBKQAVGtPdP0VSZAQCzdArK7ZpNIG5i/d12YkBEf 9 | Oa6wZ0eeiRk+pe1yWY5wsPsKfKch437I/0q4+iUqlDWWhntgclo+ZKAotHCNaRbK 10 | FsRH4rImBVaq5MjBDEc2WXY9zQ0PlY+VlSbGhamOaIPWLNw5/BVF5xrutkOk95zt 11 | tOAf3s0yD6IN6GZiZOd8efF6qCHHr/Wc1q4n99U7zp2nGN3rxLW1hsIoXyqCqGNx 12 | uDxGfQlTcpUWYMMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAd0dVbk6AynoOdoqH 13 | pRoE2j9MyTkAeJyEJ+LUYLNdzuW6dMGdWGx8Xhg0iZ71yZG8CvO7VAC4FjFKjINn 14 | 4pUA0iwbFEQ0vUIQ1nrc2Hl9yP5nkim5I8GVggHB3QbVdWGiE9pno/4wyMeNhb03 15 | ph1uMTkyawR3Wvz0WI3u1zDcKAqIO7vxXBi/32qTPz9RRz0/p49dTJz80RjDi2yd 16 | FLsJWSh0vxvtr7aloAWqX1iJ/TLrP4rMpocrIANQGiZZ28cEUj4vP2gg6Xe6Ssda 17 | TrxUpQ5xvDx8FCsu3xcRC5HJCyH55+ERJ5370uhNK25XeM2RKWqrvct3XrXnf4ZX 18 | wPCWYQ== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :mailroom, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:mailroom, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | environment_config = Path.join([__DIR__, "#{Mix.env()}.exs"]) 31 | if File.exists?(environment_config), do: import_config("#{Mix.env()}.exs") 32 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | env: 10 | MIX_ENV: test 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | name: Build and test 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | matrix: 22 | elixir: ['1.14.5', '1.15.4', '1.16.3', '1.17.3', '1.18.2'] 23 | erlang: ['24.3', '25.3', '26.0', '27.2'] 24 | exclude: 25 | - elixir: '1.14.5' 26 | erlang: '27.1' 27 | - elixir: '1.15.4' 28 | erlang: '27.1' 29 | - elixir: '1.16.3' 30 | erlang: '27.1' 31 | - elixir: '1.17.3' 32 | erlang: '24.3' 33 | - elixir: '1.18.2' 34 | erlang: '24.3' 35 | steps: 36 | - uses: actions/checkout@v3 37 | - name: Set up Elixir 38 | uses: erlef/setup-beam@v1 39 | with: 40 | version-type: 'loose' 41 | elixir-version: ${{ matrix.elixir }} 42 | otp-version: ${{ matrix.erlang }} 43 | - name: Restore dependencies cache 44 | uses: actions/cache@v2 45 | with: 46 | path: deps 47 | key: ${{ runner.os }}-${{ matrix.erlang }}-${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }} 48 | restore-keys: ${{ runner.os }}-${{ matrix.erlang }}-${{ matrix.elixir }}-mix- 49 | - name: Install dependencies 50 | run: mix deps.get 51 | - name: Run tests 52 | run: mix test 53 | -------------------------------------------------------------------------------- /test/support/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDDHxHUN+1IjlZm 3 | GIK86oZ6k3pgBdg1VtnCL3jnfLcC6sHMCPbyDVE8LtEbhy6WSA5eUBnOv0YB7k7w 4 | Dtn2if1HxSjZQ451scgSkAFRrT3T9FUmQEAs3QKyu2aTSBuYv3ddmJARHzmusGdH 5 | nokZPqXtclmOcLD7CnynIeN+yP9KuPolKpQ1loZ7YHJaPmSgKLRwjWkWyhbER+Ky 6 | JgVWquTIwQxHNll2Pc0ND5WPlZUmxoWpjmiD1izcOfwVReca7rZDpPec7bTgH97N 7 | Mg+iDehmYmTnfHnxeqghx6/1nNauJ/fVO86dpxjd68S1tYbCKF8qgqhjcbg8Rn0J 8 | U3KVFmDDAgMBAAECggEARNZkQtuMQgm9X32MOjv/P6ViChhlw8OlRYXcAMcWBdhu 9 | kJ4lCRY7r6DQswaJIAnBz/IweKkweKPrg3Op3/mkurpLBAN6cflLnYjifj9BTdKo 10 | a7tFKM68EfRdZt6MpeH+qa2WPgQnJIMlnLXIpnhcr52lDMSjG54ChjBFg0hEEMb9 11 | oHxPy+CVuV6uFemg7zzkgh4E4SgwCwDrTHKctRAOeifR5DVTwJ8tPDHe6wU6dtGG 12 | ZqOOCND8wizzOesYUUv9kn6v+8HzIa9Bi9lYKSYGu2qakEHDsPpPb84JQIGzVF/T 13 | k7lvCGH4ruMswcU2agx/nYWVPFiVSjbqN5LUJ/slWQKBgQDvQl3GHh01WttdwJfn 14 | sl7fHusvppHpNqXQYGtKG8YcU/eRJSfn5vu4kUr993r/3R130Jo3Du+AIx6uEMUh 15 | 87WM+FObcQLw5su0Y3XiNCn6HCb9mIDWVEtzKJlE5owQXD8MhWkBLZU5D1V6Lonx 16 | NEchqO/LDrqkpoTxy/sowQAHxwKBgQDQxhoCHihDCFsZzC0HH04dkJWUeM+LJ8fN 17 | XmLLWtFBvRL0mw0EBeW6fuZzHM1nl6F9RrDYPnKklU1skTRsv6Dl50zFV2BJdAPv 18 | Hsy68s9TqXik3xV1xFXr+SG9azhINPn7Ye0lWltyvIeoWrRmsZ8v9Bz+JlBJTzlU 19 | P+9tPUq3JQKBgGaK0xXuIiaT4iC0QmaTFAYcgj/R7Ac/3KEKMfF5JddORXR3sDq7 20 | zHa2DqX6Q0UVx2NqjC28wPjyFFwV4+dBRDY+19ZvAQuuXu/ZENT59mOzXSKjEdTK 21 | G+wnIkldZfo9DuiW5QIUPzhNUh0jhQtTlIjglqU4ktWuaJwZCHPXA+RRAoGBAJNp 22 | vcWPC7jBNqpRi93CkgK4K5Ypc8p7LU5qffG+z5DOc4r5zEfx7hMwNYJEbRjfbSyq 23 | 9IXMGmDx9zhYkx7SdEbBemjBWIClBX4bTk4W0qPtPv2Tc3CzUEQNpnA8PAJoPbbt 24 | n2hHk9jBHDyYx1bXxmIyySH/ZaNN6Fn/xwxoQdBtAoGBANCP1uSxh6MAdBMYyHXs 25 | 09bpBjks0t97SiO6mYoPWdKZrimveOfDvTNzV68Fj9MS/vZK1FEjaWOLSHrpnTYI 26 | UI7yfBKlwT9jcjs8g0rxrlRhRh7jQFKZIHC0OWUVVQ3IsmS2odrZ8sattrd6vee6 27 | FOxk4fwvC0xYAtibGfJ5A44s 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.Mixfile do 2 | use Mix.Project 3 | 4 | @github_url "https://github.com/andrewtimberlake/mailroom" 5 | @version "0.6.0" 6 | 7 | def project do 8 | [ 9 | app: :mailroom, 10 | name: "Mailroom", 11 | version: @version, 12 | elixir: "~> 1.10", 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | build_embedded: Mix.env() == :prod, 15 | start_permanent: Mix.env() == :prod, 16 | deps: deps(), 17 | source_url: @github_url, 18 | docs: fn -> 19 | [ 20 | source_ref: "v#{@version}", 21 | canonical: "http://hexdocs.pm/mailroom", 22 | main: "Mailroom", 23 | source_url: @github_url, 24 | extras: ["README.md"] 25 | ] 26 | end, 27 | description: description(), 28 | package: package() 29 | ] 30 | end 31 | 32 | defp elixirc_paths(:test), do: ["lib", "test/support"] 33 | defp elixirc_paths(_), do: ["lib"] 34 | 35 | # Configuration for the OTP application 36 | # 37 | # Type "mix help compile.app" for more information 38 | def application do 39 | [extra_applications: [:logger, :mail, :ssl]] 40 | end 41 | 42 | # Dependencies can be Hex packages: 43 | # 44 | # {:mydep, "~> 0.3.0"} 45 | # 46 | # Or git/path repositories: 47 | # 48 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 49 | # 50 | # Type "mix help deps" for more examples and options 51 | defp deps do 52 | [ 53 | {:mail, "~> 0.2"}, 54 | # DEV 55 | {:credo, "~> 1.0", only: :dev}, 56 | # Docs 57 | {:ex_doc, "~> 0.14", only: [:dev, :docs]}, 58 | {:earmark, "~> 1.0", only: [:dev, :docs]} 59 | ] 60 | end 61 | 62 | defp description do 63 | """ 64 | A library for sending, receving and processing emails. 65 | """ 66 | end 67 | 68 | defp package do 69 | [ 70 | maintainers: ["Andrew Timberlake"], 71 | contributors: ["Andrew Timberlake"], 72 | licenses: ["MIT"], 73 | links: %{"Github" => @github_url} 74 | ] 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/mailroom/imap/envelope.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.IMAP.Envelope do 2 | defstruct ~w[date date_string subject from sender reply_to to cc bcc in_reply_to message_id]a 3 | 4 | defmodule Address do 5 | defstruct ~w[name mailbox_name host_name email]a 6 | 7 | def new(name, mailbox_name, host_name) do 8 | %__MODULE__{ 9 | name: name, 10 | mailbox_name: mailbox_name, 11 | host_name: host_name, 12 | email: join_email(mailbox_name, host_name) 13 | } 14 | end 15 | 16 | defp join_email(mailbox_name, host_name) do 17 | [mailbox_name, host_name] 18 | |> Enum.filter(& &1) 19 | |> Enum.join("@") 20 | end 21 | 22 | def normalize(nil), do: nil 23 | def normalize([]), do: [] 24 | 25 | def normalize(%__MODULE__{} = address) do 26 | %{name: name, mailbox_name: mailbox_name, host_name: host_name} = address 27 | new(downcase(name), downcase(mailbox_name), downcase(host_name)) 28 | end 29 | 30 | def normalize([address | tail]), do: [normalize(address) | normalize(tail)] 31 | 32 | defp downcase(nil), do: nil 33 | defp downcase(string), do: String.downcase(string) 34 | end 35 | 36 | @doc ~S""" 37 | Generates an `Envelope` struct from the IMAP ENVELOPE list 38 | """ 39 | def new(list) do 40 | [date, subject, from, sender, reply_to, to, cc, bcc, in_reply_to, message_id] = list 41 | 42 | datetime = Mail.Parsers.RFC2822.to_datetime(date) 43 | from = parse_addresses(from) 44 | sender = parse_addresses(sender) 45 | reply_to = parse_addresses(reply_to) 46 | to = parse_addresses(to) 47 | cc = parse_addresses(cc) 48 | bcc = parse_addresses(bcc) 49 | 50 | %__MODULE__{ 51 | date_string: date, 52 | date: datetime, 53 | subject: subject, 54 | from: from, 55 | sender: sender, 56 | reply_to: reply_to, 57 | to: to, 58 | cc: cc, 59 | bcc: bcc, 60 | in_reply_to: in_reply_to, 61 | message_id: message_id 62 | } 63 | end 64 | 65 | def normalize(%__MODULE__{} = envelope) do 66 | %{ 67 | from: from, 68 | sender: sender, 69 | reply_to: reply_to, 70 | to: to, 71 | cc: cc, 72 | bcc: bcc, 73 | in_reply_to: in_reply_to, 74 | message_id: message_id 75 | } = envelope 76 | 77 | %{ 78 | envelope 79 | | from: Address.normalize(from), 80 | sender: Address.normalize(sender), 81 | reply_to: Address.normalize(reply_to), 82 | to: Address.normalize(to), 83 | cc: Address.normalize(cc), 84 | bcc: Address.normalize(bcc), 85 | in_reply_to: downcase(in_reply_to), 86 | message_id: downcase(message_id) 87 | } 88 | end 89 | 90 | defp downcase(nil), do: nil 91 | defp downcase(string), do: String.downcase(string) 92 | 93 | defp parse_addresses(nil), do: [] 94 | defp parse_addresses([]), do: [] 95 | 96 | defp parse_addresses(values) do 97 | Enum.map(values, fn [name, _smtp_source_route, mailbox_name, host_name] -> 98 | Address.new(name, mailbox_name, host_name) 99 | end) 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/mailroom/inbox/match_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.Inbox.MatchUtils do 2 | alias Mailroom.IMAP.{Envelope, BodyStructure} 3 | 4 | def match_recipient(%{recipients: recipients}, pattern) do 5 | match_in_list(recipients, pattern) 6 | end 7 | 8 | def match_to(%{to: to}, pattern) do 9 | match_in_list(to, pattern) 10 | end 11 | 12 | def match_to(_, _pattern), do: false 13 | 14 | def match_cc(%{cc: cc}, pattern) do 15 | match_in_list(cc, pattern) 16 | end 17 | 18 | def match_cc(_, _pattern), do: false 19 | 20 | def match_bcc(%{bcc: bcc}, pattern) do 21 | match_in_list(bcc, pattern) 22 | end 23 | 24 | def match_bcc(_, _pattern), do: false 25 | 26 | def match_from(%{from: from}, pattern) do 27 | match_in_list(from, pattern) 28 | end 29 | 30 | def match_from(_, _pattern), do: false 31 | 32 | def match_subject(%{subject: pattern}, pattern), do: true 33 | def match_subject(%{subject: subject}, %Regex{} = pattern), do: Regex.match?(pattern, subject) 34 | def match_subject(_, _pattern), do: false 35 | 36 | def match_has_attachment?(%{has_attachment: true}), do: true 37 | def match_has_attachment?(%{has_attachment: false}), do: false 38 | def match_has_attachment?(_), do: false 39 | 40 | def match_header(%{headers: headers}, header_name, %Regex{} = pattern) do 41 | headers[String.downcase(header_name)] 42 | |> List.wrap() 43 | |> Enum.any?(fn header_value -> Regex.match?(pattern, header_value) end) 44 | end 45 | 46 | def match_header(%{headers: headers}, header_name, pattern) do 47 | headers[String.downcase(header_name)] 48 | |> List.wrap() 49 | |> Enum.any?(&(&1 == pattern)) 50 | end 51 | 52 | def match_header(_, _header_name, _pattern), do: false 53 | 54 | def match_all(_), do: true 55 | 56 | defp match_in_list(nil, _pattern), do: false 57 | defp match_in_list(string, pattern) when is_binary(string), do: match_in_list([string], pattern) 58 | defp match_in_list([], _pattern), do: false 59 | 60 | defp match_in_list([string | tail], %Regex{} = pattern) do 61 | if Regex.match?(pattern, string) do 62 | true 63 | else 64 | match_in_list(tail, pattern) 65 | end 66 | end 67 | 68 | defp match_in_list([pattern | _], pattern), do: true 69 | defp match_in_list([_head | tail], pattern), do: match_in_list(tail, pattern) 70 | 71 | def generate_mail_info(%{envelope: %Envelope{} = envelope} = response, opts) do 72 | parser_opts = Keyword.get(opts, :parser_opts, []) 73 | 74 | %Envelope{to: to, cc: cc, bcc: bcc, from: from, reply_to: reply_to, subject: subject} = 75 | envelope 76 | 77 | to = get_email_addresses(to) 78 | cc = get_email_addresses(cc) 79 | bcc = get_email_addresses(bcc) 80 | 81 | recipients = Enum.flat_map([to, cc, bcc], & &1) |> Enum.uniq() 82 | 83 | has_attachment = 84 | case response do 85 | %{body_structure: part} -> 86 | BodyStructure.has_attachment?(part) 87 | 88 | _ -> 89 | false 90 | end 91 | 92 | headers = 93 | case response do 94 | %{"BODY[HEADER]" => headers} -> 95 | try do 96 | %{headers: headers} = 97 | Mail.Parsers.RFC2822.parse(headers <> "\r\n", parser_opts) 98 | 99 | headers 100 | rescue 101 | _ -> 102 | %{} 103 | end 104 | 105 | _ -> 106 | %{} 107 | end 108 | 109 | {_, subject} = 110 | Mail.Parsers.RFC2822.parse_header("Subject: #{subject}", parser_opts) 111 | 112 | %{ 113 | recipients: recipients, 114 | to: to, 115 | cc: cc, 116 | bcc: bcc, 117 | from: get_email_addresses(from), 118 | reply_to: get_email_addresses(reply_to), 119 | subject: subject, 120 | has_attachment: has_attachment, 121 | headers: headers 122 | } 123 | end 124 | 125 | def generate_mail_info(%{envelope: :error}, _opts) do 126 | :error 127 | end 128 | 129 | defp get_email_addresses(list) do 130 | Enum.map(List.wrap(list), &String.downcase(&1.email)) 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/mailroom/imap/body_structure.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.IMAP.BodyStructure do 2 | defmodule Part do 3 | defstruct section: nil, 4 | params: %{}, 5 | multipart: false, 6 | type: nil, 7 | id: nil, 8 | description: nil, 9 | encoding: nil, 10 | encoded_size: nil, 11 | disposition: nil, 12 | file_name: nil, 13 | parts: [] 14 | end 15 | 16 | @doc ~S""" 17 | Generates a `BodyStructure` struct from the IMAP ENVELOPE list 18 | """ 19 | def new(list) do 20 | list 21 | |> build_structure 22 | |> number_sections 23 | end 24 | 25 | def has_attachment?(%Part{disposition: "attachment"}), do: true 26 | def has_attachment?(%Part{parts: []}), do: false 27 | def has_attachment?(%Part{parts: parts}), do: Enum.any?(parts, &has_attachment?/1) 28 | 29 | def get_attachments(body_structure, acc \\ []) 30 | def get_attachments(%Part{parts: []}, acc), do: Enum.reverse(acc) 31 | def get_attachments(%Part{parts: parts}, acc), do: get_attachments(parts, acc) 32 | 33 | def get_attachments([%Part{disposition: "attachment"} = part | tail], acc), 34 | do: get_attachments(tail, [part | acc]) 35 | 36 | def get_attachments([], acc), do: Enum.reverse(acc) 37 | def get_attachments([_part | tail], acc), do: get_attachments(tail, acc) 38 | 39 | defp build_structure([[_ | _] | _rest] = list) do 40 | parse_multipart(list) 41 | end 42 | 43 | defp build_structure(list) do 44 | [type, sub_type, params, id, description, encoding, encoded_size | tail] = list 45 | 46 | %Part{ 47 | type: String.downcase("#{type}/#{sub_type}"), 48 | params: parse_params(params), 49 | id: id, 50 | description: description, 51 | encoding: downcase(encoding), 52 | encoded_size: to_integer(encoded_size), 53 | disposition: parse_disposition(tail), 54 | file_name: parse_file_name(tail) 55 | } 56 | end 57 | 58 | defp parse_multipart(list, parts \\ []) 59 | 60 | defp parse_multipart([[_ | _] = part | rest], parts) do 61 | parse_multipart(rest, [part | parts]) 62 | end 63 | 64 | defp parse_multipart([<> | _rest], parts) do 65 | parts = parts |> Enum.reverse() |> Enum.map(&build_structure/1) 66 | %Part{type: String.downcase(type), multipart: true, parts: parts} 67 | end 68 | 69 | defp parse_params(list, params \\ %{}) 70 | defp parse_params(nil, params), do: params 71 | defp parse_params([], params), do: params 72 | 73 | defp parse_params([name, value | tail], params) do 74 | parse_params(tail, Map.put(params, String.downcase(name), value)) 75 | end 76 | 77 | defp parse_disposition([]), do: nil 78 | defp parse_disposition([[disposition, [_ | _]] | _tail]), do: String.downcase(disposition) 79 | defp parse_disposition([_ | tail]), do: parse_disposition(tail) 80 | 81 | defp parse_file_name([]), do: nil 82 | defp parse_file_name([[_, [_ | _] = params] | _tail]), do: file_name_from_params(params) 83 | defp parse_file_name([_ | tail]), do: parse_file_name(tail) 84 | 85 | defp file_name_from_params([]), do: nil 86 | defp file_name_from_params(["FILENAME", file_name | _tail]), do: file_name 87 | defp file_name_from_params(["filename", file_name | _tail]), do: file_name 88 | defp file_name_from_params([_, _ | tail]), do: file_name_from_params(tail) 89 | 90 | defp number_sections(map, prefix \\ nil, section \\ nil) 91 | 92 | defp number_sections(map, prefix, section) do 93 | section = [prefix, section] |> Enum.filter(& &1) |> join(".") 94 | 95 | parts = 96 | map.parts 97 | |> Enum.with_index(1) 98 | |> Enum.map(fn {part, index} -> 99 | number_sections(part, section, index) 100 | end) 101 | 102 | %{map | section: section, parts: parts} 103 | end 104 | 105 | defp join([], _joiner), do: nil 106 | defp join(enum, joiner), do: Enum.join(enum, joiner) 107 | 108 | defp to_integer(nil), do: nil 109 | defp to_integer(string), do: String.to_integer(string) 110 | 111 | defp downcase(nil), do: nil 112 | defp downcase(string), do: String.downcase(string) 113 | end 114 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, 5 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 6 | "earmark": {:hex, :earmark, "1.4.47", "7e7596b84fe4ebeb8751e14cbaeaf4d7a0237708f2ce43630cfd9065551f94ca", [:mix], [], "hexpm", "3e96bebea2c2d95f3b346a7ff22285bc68a99fbabdad9b655aa9c6be06c698f8"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 8 | "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [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", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 9 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 10 | "gettext": {:hex, :gettext, "0.16.1", "e2130b25eebcbe02bb343b119a07ae2c7e28bd4b146c4a154da2ffb2b3507af2", [:mix], [], "hexpm"}, 11 | "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 14 | "mail": {:hex, :mail, "0.5.1", "6383a61620aea24675c96e34b9019dede1bfc9a37ee10ce5a5cafcc7c5e48743", [:mix], [], "hexpm", "595144340b74f23d651ea2b4a72a896819940478d7425dfa302ea3b5c9041ec9"}, 15 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [], [], "hexpm"}, 19 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 21 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, 22 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, 23 | "tzdata": {:hex, :tzdata, "0.5.20", "304b9e98a02840fb32a43ec111ffbe517863c8566eb04a061f1c4dbb90b4d84c", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 24 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, 25 | } 26 | -------------------------------------------------------------------------------- /lib/mailroom/socket.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.Socket do 2 | @moduledoc """ 3 | Abstracts away working with normal sockets or SSL sockets. 4 | 5 | ## Examples 6 | 7 | {:ok, socket} = #{inspect(__MODULE__)}.connect("localhost", 110) 8 | {:ok, ssl_socket} = #{inspect(__MODULE__)}.connect("localhost", 110, ssl: true) 9 | 10 | #{inspect(__MODULE__)}.send(socket, "Hello World") 11 | #{inspect(__MODULE__)}.send(ssl_socket, "Hello World") 12 | 13 | #{inspect(__MODULE__)}.close(socket) 14 | #{inspect(__MODULE__)}.close(ssl_socket) 15 | """ 16 | 17 | @timeout 15_000 18 | 19 | @type t :: %__MODULE__{} 20 | defstruct socket: nil, 21 | ssl: false, 22 | timeout: @timeout, 23 | debug: false, 24 | connect_opts: [], 25 | ssl_opts: [] 26 | 27 | @doc """ 28 | Connect to a TCP `server` on `port` 29 | 30 | The following options are available: 31 | 32 | - `ssl` - default `false`, connect via SSL or not 33 | - `timeout` - default `#{inspect(@timeout)}`, sets the socket connect and receive timeout 34 | - `debug` - default `false`, if true, will print out connection communication 35 | 36 | ## Examples 37 | 38 | {:ok, socket} = #{inspect(__MODULE__)}.connect("localhost", 110, ssl: true) 39 | """ 40 | @spec connect(String.t(), integer, Keyword.t()) :: {:ok, t} | {:error, String.t()} 41 | @connect_opts [packet: :line, reuseaddr: true, active: false, keepalive: true] 42 | # @ssl_connect_opts [depth: 0, verify: :verify_none] 43 | @ssl_connect_opts [depth: 0] 44 | def connect(server, port, opts \\ []) do 45 | {state, opts} = parse_opts(opts) 46 | if state.debug, do: IO.puts("[connecting]") 47 | 48 | connect_opts = Keyword.merge(@connect_opts, opts) 49 | ssl_opts = Keyword.merge(@ssl_connect_opts, state.ssl_opts) 50 | addr = String.to_charlist(server) 51 | 52 | case do_connect(addr, state.ssl, port, [:binary | connect_opts], ssl_opts, state.timeout) do 53 | {:ok, socket} -> 54 | {:ok, %{state | socket: socket, connect_opts: connect_opts}} 55 | 56 | {:error, reason} -> 57 | {:error, inspect(reason)} 58 | end 59 | end 60 | 61 | defp parse_opts(opts, state \\ %__MODULE__{}, acc \\ []) 62 | defp parse_opts([], state, acc), do: {state, acc} 63 | 64 | defp parse_opts([{:ssl, ssl} | tail], state, acc), 65 | do: parse_opts(tail, %{state | ssl: ssl}, acc) 66 | 67 | defp parse_opts([{:ssl_opts, ssl_opts} | tail], state, acc), 68 | do: parse_opts(tail, %{state | ssl_opts: ssl_opts}, acc) 69 | 70 | defp parse_opts([{:debug, debug} | tail], state, acc), 71 | do: parse_opts(tail, %{state | debug: debug}, acc) 72 | 73 | defp parse_opts([opt | tail], state, acc), 74 | do: parse_opts(tail, state, [opt | acc]) 75 | 76 | defp do_connect(addr, true, port, opts, ssl_opts, timeout), 77 | do: :ssl.connect(addr, port, opts ++ ssl_opts, timeout) 78 | 79 | defp do_connect(addr, false, port, opts, _ssl_opts, timeout), 80 | do: :gen_tcp.connect(addr, port, opts, timeout) 81 | 82 | @doc """ 83 | Receive a line from the socket 84 | 85 | ## Examples 86 | 87 | {:ok, line} = #{inspect(__MODULE__)}.recv(socket) 88 | """ 89 | @spec recv(t) :: {:ok, String.t()} | {:error, String.t()} 90 | def recv(%{debug: debug, ssl: ssl} = socket) do 91 | case do_recv(socket) do 92 | {:ok, line} -> 93 | if debug, do: IO.write(["> ", tag_debug(ssl), line]) 94 | {:ok, String.replace_suffix(line, "\r\n", "")} 95 | 96 | {:error, reason} -> 97 | {:error, inspect(reason)} 98 | end 99 | end 100 | 101 | defp do_recv(%{socket: socket, ssl: true, timeout: timeout}), 102 | do: :ssl.recv(socket, 0, timeout) 103 | 104 | defp do_recv(%{socket: socket, ssl: false, timeout: timeout}), 105 | do: :gen_tcp.recv(socket, 0, timeout) 106 | 107 | defp tag_debug(true), do: "[ssl] " 108 | defp tag_debug(false), do: "[tcp] " 109 | 110 | @doc """ 111 | Send data on a socket 112 | 113 | ## Examples 114 | 115 | :ok = #{inspect(__MODULE__)}.send(socket) 116 | """ 117 | @spec send(t, String.t()) :: :ok | {:error, String.t()} 118 | def send(%{debug: debug, ssl: ssl} = socket, data) do 119 | if debug, do: IO.write(["< ", tag_debug(ssl), data]) 120 | 121 | case do_send(socket, data) do 122 | :ok -> :ok 123 | {:error, reason} -> {:error, inspect(reason)} 124 | end 125 | end 126 | 127 | defp do_send(%{socket: socket, ssl: true}, data), 128 | do: :ssl.send(socket, data) 129 | 130 | defp do_send(%{socket: socket, ssl: false}, data), 131 | do: :gen_tcp.send(socket, data) 132 | 133 | def ssl_client(%{socket: socket, ssl: true}), 134 | do: socket 135 | 136 | def ssl_client( 137 | %{socket: socket, timeout: timeout, connect_opts: connect_opts, ssl_opts: ssl_opts} = 138 | client 139 | ) do 140 | case :ssl.connect(socket, @ssl_connect_opts ++ connect_opts ++ ssl_opts, timeout) do 141 | {:ok, socket} -> {:ok, %{client | socket: socket, ssl: true}} 142 | {:error, {key, reason}} -> {:error, {key, inspect(reason)}} 143 | {:error, reason} -> {:error, inspect(reason)} 144 | end 145 | end 146 | 147 | @doc """ 148 | Closes the connection 149 | 150 | ## Examples 151 | 152 | :ok = #{inspect(__MODULE__)}.close(socket) 153 | """ 154 | @spec close(t) :: :ok 155 | def close(%{debug: debug} = socket) do 156 | if debug, do: IO.puts("[closing connection]") 157 | do_close(socket) 158 | end 159 | 160 | defp do_close(%{socket: socket, ssl: true}), 161 | do: :ssl.close(socket) 162 | 163 | defp do_close(%{socket: socket, ssl: false}), 164 | do: :gen_tcp.close(socket) 165 | end 166 | -------------------------------------------------------------------------------- /docs/multi_lingual_emails.md: -------------------------------------------------------------------------------- 1 | # Handling Multi-lingual and Multi-encoded Emails 2 | When working with multilingual emails from various sources (and different encodings, in particular from outlook), you may encounter messages in different languages and character encodings. 3 | 4 | Mailroom provides a way to handle these through the `:charset_handler` option. 5 | 6 | ## The Character Encoding Challenge 7 | 8 | Email clients around the world use different character encodings to represent their text. Common email character encodings include: 9 | 10 | - `iso-8859-1` (Latin-1) 11 | - `utf-8` (Unicode) 12 | - And others 13 | 14 | When processing these emails, you need to ensure that the character data is correctly converted to valid UTF-8. This avoids garbled text and/or processing errors. 15 | 16 | ## Configuring Charset Handling in Mailroom 17 | 18 | Mailroom supports custom charset handling through the `:parser_opts` configuration and passes these to the mail library, which parses each part of the email. providing a `:charset_handler` function. 19 | 20 | NOTE: Be sure to handle utf-8 and ascii text without encoding, and a fallback for unknown charsets. This is important to prevent processing errors. 21 | 22 | ### Example Implementation 23 | 24 | Here's how you can implement charset handling in your Mailroom Inbox module: 25 | 26 | ```elixir 27 | defmodule YourApp.ImapClient do 28 | use Mailroom.Inbox 29 | require Logger 30 | 31 | def config(_opts) do 32 | [ 33 | username: "your_username", 34 | password: "your_password", 35 | server: "imap.example.com", 36 | # other options 37 | # ... 38 | # charset_handler for each email part that specifies a charset 39 | parser_opts: [charset_handler: &handle_charset/2] 40 | ] 41 | end 42 | 43 | # Match and process emails as usual 44 | match do 45 | fetch_mail 46 | process(YourApp.ImapClient, :process_email) 47 | end 48 | 49 | 50 | def process_email(%Mailroom.Inbox.MessageContext{message: message}) do 51 | # process the email inject the message into your application 52 | end 53 | 54 | # Your charset handling functions - convert to utf-8 (elixir default) 55 | defp handle_charset("windows-1252", string), 56 | do: :unicode.characters_to_binary(string, :windows_1252, :utf8) 57 | 58 | defp handle_charset("iso-8859-1", string), 59 | do: :unicode.characters_to_binary(string, :latin1, :utf8) 60 | 61 | defp handle_charset("us-ascii", string), 62 | do: :unicode.characters_to_binary(string, :ascii, :utf8) 63 | 64 | # UTF-8 strings can pass through unchanged (elixir default) 65 | defp handle_charset("utf-8", string), do: string 66 | 67 | # FALLBACK: Handle unexpected charsets 68 | defp handle_charset(charset_name, string) do 69 | Logger.error("Unexpected charset: #{charset_name} with an invalid string") 70 | # You can choose to raise an error or attempt a fallback conversion 71 | raise "Unexpected charset: #{charset_name} with an invalid string" 72 | # Alternatively, you could replace invalid characters with a chosen valid character: 73 | # <<0xFFFD::utf8>> `�`, "?", "_", etc. - for example: 74 | # replace_invalid(string, <<0xFFFD::utf8>>) 75 | end 76 | end 77 | ``` 78 | 79 | ## How It Works 80 | 81 | 1. When an email is parsed, Mailroom extracts the charset information from the email headers 82 | 2. For each part of the email with a specified charset, Mailroom (passes `charset_handler` function) to mail library 83 | 3. Your handler function converts the string from the source encoding to UTF-8 (for each mail part) 84 | 4. The resulting UTF-8 string is used in the parsed email 85 | 86 | For unexpected charsets, you have several options: 87 | 88 | 1. **Log and discard** - For non-critical applications 89 | 2. **Raise an error** - When correct encoding is essential 90 | 3. **Try sanitizing** - replace invalid characters with a placeholder 91 | 92 | ```elixir 93 | # safely replace invalid characters with a placeholder 94 | # this approach deals with large binaries efficiently 95 | defp replace_invalid(binary, replacement) do 96 | replace_invalid(binary, binary, 0, 0, [], replacement) 97 | end 98 | 99 | defp replace_invalid(<<>>, original, offset, len, acc, _replacement) do 100 | acc = [acc, binary_part(original, offset, len)] 101 | IO.iodata_to_binary(acc) 102 | end 103 | 104 | defp replace_invalid(<>, original, offset, len, acc, replacement) do 105 | char_len = byte_size(<>) 106 | replace_invalid(rest, original, offset, len + char_len, acc, replacement) 107 | end 108 | 109 | defp replace_invalid(<<_, rest::binary>>, original, offset, len, acc, replacement) do 110 | acc = [acc, binary_part(original, offset, len), replacement] 111 | replace_invalid(rest, original, offset + len + 1, 0, acc, replacement) 112 | end 113 | ``` 114 | 115 | Usage: 116 | 117 | ```elixir 118 | invalid_string = "abcd" <> <<233>> <> "f" 119 | sanitized_string = replace_invalid(invalid_string, <<0xFFFD::utf8>>) 120 | # sanitized_string returns: 121 | "abcd�f" 122 | ``` 123 | 124 | ## Handling Common Encodings 125 | 126 | Erlang's `:unicode` module provides the `characters_to_binary/3` function which can convert between various encodings - see documentation [here](https://www.erlang.org/docs/28/apps/stdlib/unicode.html#characters_to_binary/1): 127 | 128 | | Email Charset | Erlang Encoding Term | 129 | |---------------|----------------------| 130 | | iso-8859-1 | `:latin1` | 131 | | utf-8 | `:utf8` | 132 | 133 | For other encodings, you may need to use additional libraries or implement custom conversion logic. 134 | 135 | ## Testing Charset Handling 136 | 137 | To test your charset handler implementation, you can send emails with all your expected/known encodings (subject and/or attachment) to your inbox. Running these various emails through your application will help you ensure that the charset handler is handling the encoding as you expect. 138 | 139 | ## Conclusion 140 | 141 | Properly handling character encodings is essential for working with international emails. By implementing a custom charset handler, you can ensure that email content is correctly converted to UTF-8, this allows `mailroom` and your application to handle multilingual and multi-encoded emails correctly. 142 | -------------------------------------------------------------------------------- /test/mailroom/imap/utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.IMAP.UtilsTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Mailroom.IMAP.Utils 5 | 6 | describe "parse_list/1" do 7 | test "with simple list" do 8 | assert parse_list("(one two three)") == {["one", "two", "three"], ""} 9 | end 10 | 11 | test "with empty list" do 12 | assert parse_list("()") == {[], ""} 13 | end 14 | 15 | test "with nested list" do 16 | assert parse_list("(one (two three) four)") == {["one", ["two", "three"], "four"], ""} 17 | end 18 | 19 | test "with nested lists" do 20 | assert parse_list("(one (two (three)) four)") == {["one", ["two", ["three"]], "four"], ""} 21 | end 22 | 23 | test "with nested empty list" do 24 | assert parse_list("(one () four)") == {["one", [], "four"], ""} 25 | end 26 | 27 | test "with doubly nested list" do 28 | assert parse_list("(one ((two three)) four)") == {["one", [["two", "three"]], "four"], ""} 29 | end 30 | 31 | test "with strings" do 32 | assert parse_list("(one \"two three\" four)") == {["one", "two three", "four"], ""} 33 | end 34 | 35 | test "with literal string" do 36 | assert parse_list("(BODY[TEXT] {8}\r\nTest 1\r\n)\r\n") == 37 | {["BODY[TEXT]", "Test 1\r\n"], "\r\n"} 38 | end 39 | 40 | test "with NIL" do 41 | assert parse_list("(one NIL four)") == {["one", nil, "four"], ""} 42 | assert parse_list("(NIL)") == {[nil], ""} 43 | end 44 | 45 | test "with extraneous data" do 46 | assert parse_list("(one two) three") == {["one", "two"], " three"} 47 | assert parse_list("(one two)\r\n") == {["one", "two"], "\r\n"} 48 | end 49 | end 50 | 51 | test "parse_list_only/1" do 52 | assert parse_list_only("(one two)") == ["one", "two"] 53 | assert parse_list_only("(one (two three))") == ["one", ["two", "three"]] 54 | assert parse_list_only("(one ((two three)))") == ["one", [["two", "three"]]] 55 | assert parse_list_only("(one (\"two\" (three)))\r\n") == ["one", ["two", ["three"]]] 56 | assert parse_list_only("(one (two) (three))") == ["one", ["two"], ["three"]] 57 | end 58 | 59 | test "parse_string/1" do 60 | assert parse_string("one two") == {"one", " two"} 61 | assert parse_string("one\r\n") == {"one", "\r\n"} 62 | assert parse_string("\"one\" two") == {"one", " two"} 63 | assert parse_string("\"one two\"") == {"one two", ""} 64 | assert parse_string("\"one\\\"two\"") == {"one\"two", ""} 65 | end 66 | 67 | test "parse_string_only/1" do 68 | assert parse_string_only("one\r\n") == "one" 69 | assert parse_string_only("\"one\" two") == "one" 70 | assert parse_string_only("\"one two\"") == "one two" 71 | assert parse_string_only("\"one\\\"two\"") == "one\"two" 72 | end 73 | 74 | test "items_to_list/1" do 75 | assert items_to_list("MESSAGES") == ["(", "MESSAGES", ")"] 76 | assert items_to_list(["MESSAGES", "RECENT"]) == ["(", "MESSAGES", " ", "RECENT", ")"] 77 | assert items_to_list([:messages, :recent]) == ["(", "MESSAGES", " ", "RECENT", ")"] 78 | end 79 | 80 | test "list_to_status_items/1" do 81 | assert list_to_status_items(["MESSAGES", "4", "RECENT", "2", "UNSEEN", "1"]) == %{ 82 | messages: 4, 83 | recent: 2, 84 | unseen: 1 85 | } 86 | end 87 | 88 | test "list_to_items/1" do 89 | map = 90 | list_to_items([ 91 | "RFC822.SIZE", 92 | "3325", 93 | "INTERNALDATE", 94 | "26-Oct-2016 12:23:20 +0000", 95 | "FLAGS", 96 | ["Seen"] 97 | ]) 98 | 99 | assert map == %{ 100 | rfc822_size: "3325", 101 | internal_date: "26-Oct-2016 12:23:20 +0000", 102 | flags: ["Seen"] 103 | } 104 | end 105 | 106 | test "flags_to_list/1" do 107 | assert flags_to_list(["\\Seen", "\\Answered"]) == ["(", "\\Seen", " ", "\\Answered", ")"] 108 | 109 | assert flags_to_list([:seen, :answered, :flagged, :deleted, :draft, :recent]) == [ 110 | "(", 111 | "\\Seen", 112 | " ", 113 | "\\Answered", 114 | " ", 115 | "\\Flagged", 116 | " ", 117 | "\\Deleted", 118 | " ", 119 | "\\Draft", 120 | " ", 121 | "\\Recent", 122 | ")" 123 | ] 124 | end 125 | 126 | test "list_to_flags/1" do 127 | assert list_to_flags([ 128 | "\\Seen", 129 | "\\Answered", 130 | "\\Flagged", 131 | "\\Deleted", 132 | "\\Draft", 133 | "\\Recent", 134 | "Other" 135 | ]) == [:seen, :answered, :flagged, :deleted, :draft, :recent, "Other"] 136 | end 137 | 138 | describe "parse_number/1" do 139 | test "with a single digit" do 140 | assert parse_number("1") == 1 141 | assert parse_number("0") == 0 142 | end 143 | 144 | test "with multiple digits" do 145 | assert parse_number("12345") == 12345 146 | assert parse_number("352841") == 352_841 147 | end 148 | 149 | test "with digits followed by other data" do 150 | assert parse_number("12345Bob") == 12345 151 | assert parse_number("352841 more data") == 352_841 152 | end 153 | end 154 | 155 | test "quote_string/1" do 156 | assert quote_string("string") == "\"string\"" 157 | assert quote_string("str ing") == "\"str ing\"" 158 | assert quote_string("str\"ing") == "\"str\\\"ing\"" 159 | end 160 | 161 | test "numbers_to_sequences/1" do 162 | assert numbers_to_sequences([]) == [] 163 | assert numbers_to_sequences([1, 2, 4]) == [1..2, 4] 164 | assert numbers_to_sequences([1, 4, 2]) == [1..2, 4] 165 | 166 | assert numbers_to_sequences([1, 2, 3, 4, 7, 9, 11, 22, 23, 24, 25, 26, 30]) == [ 167 | 1..4, 168 | 7, 169 | 9, 170 | 11, 171 | 22..26, 172 | 30 173 | ] 174 | 175 | assert numbers_to_sequences([1, 2, 3, 4, 7, 9, 11, 22, 23, 24, 25, 26, 30, 45, 46]) == [ 176 | 1..4, 177 | 7, 178 | 9, 179 | 11, 180 | 22..26, 181 | 30, 182 | 45..46 183 | ] 184 | 185 | assert numbers_to_sequences([1, 3, 5, 7, 9, 2, 4, 6, 8, 10]) == [1..10] 186 | assert numbers_to_sequences([1, 3, 5, 7, 9, 2, 4, 6, 8, 10, 1, 3, 5, 7, 9]) == [1..10] 187 | end 188 | 189 | test "parse_timestamp/1" do 190 | assert parse_timestamp("21-Jun-2018 17:51:47 +0000") == ~U"2018-06-21 17:51:47Z" 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /test/mailroom/pop3_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.POP3Test do 2 | use ExUnit.Case, async: true 3 | doctest Mailroom.POP3 4 | 5 | alias Mailroom.{POP3, TestServer} 6 | 7 | [true, false] 8 | |> Enum.each(fn ssl -> 9 | description = if ssl, do: " (with SSL)", else: "" 10 | 11 | test "login #{description}" do 12 | server = TestServer.start(ssl: unquote(ssl)) 13 | 14 | TestServer.expect(server, fn expectations -> 15 | expectations 16 | |> TestServer.on(:connect, "+OK Test server ready.\r\n") 17 | |> TestServer.on("USER test@example.com\r\n", "+OK\r\n") 18 | |> TestServer.on("PASS P@55w0rD\r\n", "+OK Logged in.\r\n") 19 | end) 20 | 21 | assert {:ok, _client} = 22 | POP3.connect(server.address, "test@example.com", "P@55w0rD", 23 | port: server.port, 24 | ssl: unquote(ssl), 25 | ssl_opts: [verify: :verify_none] 26 | ) 27 | end 28 | 29 | test "login wrong password #{description}" do 30 | server = TestServer.start(ssl: unquote(ssl)) 31 | 32 | TestServer.expect(server, fn expectations -> 33 | expectations 34 | |> TestServer.on(:connect, "+OK Test server ready.\r\n") 35 | |> TestServer.on("USER test@example.com\r\n", "+OK\r\n") 36 | |> TestServer.on("PASS P@55w0rD\r\n", "-ERR [AUTH] Authentication failure.\r\n") 37 | end) 38 | 39 | assert {:error, :authentication, "[AUTH] Authentication failure."} == 40 | POP3.connect(server.address, "test@example.com", "P@55w0rD", 41 | port: server.port, 42 | ssl: unquote(ssl), 43 | ssl_opts: [verify: :verify_none] 44 | ) 45 | end 46 | 47 | test "stat #{description}" do 48 | server = TestServer.start(ssl: unquote(ssl)) 49 | 50 | TestServer.expect(server, fn expectations -> 51 | expectations 52 | |> TestServer.on(:connect, "+OK Test server ready.\r\n") 53 | |> TestServer.on("USER test@example.com\r\n", "+OK\r\n") 54 | |> TestServer.on("PASS P@55w0rD\r\n", "+OK Logged in.\r\n") 55 | |> TestServer.on("STAT\r\n", "+OK 1 123\r\n") 56 | end) 57 | 58 | assert {:ok, client} = 59 | POP3.connect(server.address, "test@example.com", "P@55w0rD", 60 | port: server.port, 61 | ssl: unquote(ssl), 62 | ssl_opts: [verify: :verify_none] 63 | ) 64 | 65 | assert POP3.stat(client) == {1, 123} 66 | end 67 | 68 | test "list #{description}" do 69 | server = TestServer.start(ssl: unquote(ssl)) 70 | 71 | TestServer.expect(server, fn expectations -> 72 | expectations 73 | |> TestServer.on(:connect, "+OK Test server ready.\r\n") 74 | |> TestServer.on("USER test@example.com\r\n", "+OK\r\n") 75 | |> TestServer.on("PASS P@55w0rD\r\n", "+OK Logged in.\r\n") 76 | |> TestServer.on("LIST\r\n", "+OK 2 234\r\n1 121\r\n2 113\r\n.\r\n") 77 | end) 78 | 79 | {:ok, client} = 80 | POP3.connect(server.address, "test@example.com", "P@55w0rD", 81 | port: server.port, 82 | ssl: unquote(ssl), 83 | ssl_opts: [verify: :verify_none] 84 | ) 85 | 86 | assert client |> POP3.list() == [{1, 121}, {2, 113}] 87 | end 88 | 89 | test "retrieve #{description}" do 90 | server = TestServer.start(ssl: unquote(ssl)) 91 | 92 | msg = 93 | """ 94 | Date: Tue, 27 Sep 2016 13:30:56 +0200 95 | To: user@example.com 96 | From: sender@example.com 97 | Subject: This is a test 98 | 99 | This is a test message 100 | """ 101 | |> String.replace("\n", "\r\n") 102 | 103 | TestServer.expect(server, fn expectations -> 104 | expectations 105 | |> TestServer.on(:connect, "+OK Test server ready.\r\n") 106 | |> TestServer.on("USER test@example.com\r\n", "+OK\r\n") 107 | |> TestServer.on("PASS P@55w0rD\r\n", "+OK Logged in.\r\n") 108 | |> TestServer.on("STAT\r\n", "+OK 2 234\r\n") 109 | |> TestServer.on( 110 | "RETR 1\r\n", 111 | """ 112 | +OK 123 octets 113 | #{msg} 114 | . 115 | """ 116 | |> String.replace(~r/(? 136 | expectations 137 | |> TestServer.on(:connect, "+OK Test server ready.\r\n") 138 | |> TestServer.on("USER test@example.com\r\n", "+OK\r\n") 139 | |> TestServer.on("PASS P@55w0rD\r\n", "+OK Logged in.\r\n") 140 | |> TestServer.on("STAT\r\n", "+OK 2 234\r\n") 141 | |> TestServer.on("DELE 1\r\n", "+OK\r\n") 142 | end) 143 | 144 | {:ok, client} = 145 | POP3.connect(server.address, "test@example.com", "P@55w0rD", 146 | port: server.port, 147 | ssl: unquote(ssl), 148 | ssl_opts: [verify: :verify_none] 149 | ) 150 | 151 | {2, 234} = POP3.stat(client) 152 | assert :ok == POP3.delete(client, 1) 153 | end 154 | 155 | test "reset #{description}" do 156 | server = TestServer.start(ssl: unquote(ssl)) 157 | 158 | TestServer.expect(server, fn expectations -> 159 | expectations 160 | |> TestServer.on(:connect, "+OK Test server ready.\r\n") 161 | |> TestServer.on("USER test@example.com\r\n", "+OK\r\n") 162 | |> TestServer.on("PASS P@55w0rD\r\n", "+OK Logged in.\r\n") 163 | |> TestServer.on("RSET\r\n", "+OK\r\n") 164 | end) 165 | 166 | assert {:ok, client} = 167 | POP3.connect(server.address, "test@example.com", "P@55w0rD", 168 | port: server.port, 169 | ssl: unquote(ssl), 170 | ssl_opts: [verify: :verify_none] 171 | ) 172 | 173 | assert POP3.reset(client) == :ok 174 | end 175 | 176 | test "quit #{description}" do 177 | server = TestServer.start(ssl: unquote(ssl)) 178 | 179 | TestServer.expect(server, fn expectations -> 180 | expectations 181 | |> TestServer.on(:connect, "+OK Test server ready.\r\n") 182 | |> TestServer.on("USER test@example.com\r\n", "+OK\r\n") 183 | |> TestServer.on("PASS P@55w0rD\r\n", "+OK Logged in.\r\n") 184 | |> TestServer.on("QUIT\r\n", "+OK Bye.\r\n") 185 | end) 186 | 187 | assert {:ok, client} = 188 | POP3.connect(server.address, "test@example.com", "P@55w0rD", 189 | port: server.port, 190 | ssl: unquote(ssl), 191 | ssl_opts: [verify: :verify_none] 192 | ) 193 | 194 | assert POP3.quit(client) == :ok 195 | end 196 | end) 197 | end 198 | -------------------------------------------------------------------------------- /lib/mailroom/pop3.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.POP3 do 2 | @moduledoc """ 3 | Handles communication with a POP3 server. 4 | 5 | ## Example: 6 | 7 | {:ok, socket} = #{inspect(__MODULE__)}.connect("pop3.server", "username", "password") 8 | socket 9 | |> #{inspect(__MODULE__)}.list 10 | |> Enum.each(fn(mail) -> 11 | message = 12 | socket 13 | |> #{inspect(__MODULE__)}.retrieve(mail) 14 | |> Enum.join("\\n") 15 | # … process message 16 | #{inspect(__MODULE__)}.delete(socket, mail) 17 | end) 18 | #{inspect(__MODULE__)}.reset(socket) 19 | #{inspect(__MODULE__)}.close(socket) 20 | """ 21 | 22 | alias Mailroom.Socket 23 | 24 | @doc """ 25 | Connect to the POP3 server 26 | 27 | The following options are available: 28 | 29 | - `ssl` - default `false`, connect via SSL or not 30 | - `port` - default `110` (`995` if SSL), the port to connect to 31 | - `timeout` - default `15_000`, the timeout for connection and communication 32 | 33 | ## Examples: 34 | 35 | #{inspect(__MODULE__)}.connect("pop3.myserver", "me", "secret", ssl: true) 36 | {:ok, %#{inspect(Socket)}{}} 37 | """ 38 | def connect(server, username, password, options \\ []) do 39 | opts = parse_opts(options) 40 | 41 | {:ok, socket} = 42 | Socket.connect(server, opts.port, ssl: opts.ssl, debug: opts.debug, ssl_opts: opts.ssl_opts) 43 | 44 | case login(socket, username, password) do 45 | :ok -> {:ok, socket} 46 | {:error, reason} -> {:error, :authentication, reason} 47 | end 48 | end 49 | 50 | defp parse_opts(opts, acc \\ %{ssl: false, port: nil, debug: false, ssl_opts: []}) 51 | 52 | defp parse_opts([], acc), 53 | do: set_default_port(acc) 54 | 55 | defp parse_opts([{:ssl, ssl} | tail], acc), 56 | do: parse_opts(tail, Map.put(acc, :ssl, ssl)) 57 | 58 | defp parse_opts([{:ssl_opts, ssl_opts} | tail], acc), 59 | do: parse_opts(tail, Map.put(acc, :ssl_opts, ssl_opts)) 60 | 61 | defp parse_opts([{:port, port} | tail], acc), 62 | do: parse_opts(tail, Map.put(acc, :port, port)) 63 | 64 | defp parse_opts([{:debug, debug} | tail], acc), 65 | do: parse_opts(tail, Map.put(acc, :debug, debug)) 66 | 67 | defp parse_opts([_ | tail], acc), 68 | do: parse_opts(tail, acc) 69 | 70 | defp set_default_port(%{port: nil, ssl: false} = opts), 71 | do: %{opts | port: 110} 72 | 73 | defp set_default_port(%{port: nil, ssl: true} = opts), 74 | do: %{opts | port: 995} 75 | 76 | defp set_default_port(opts), 77 | do: opts 78 | 79 | @doc """ 80 | Sends the QUIT command and closes the connection 81 | 82 | ## Examples: 83 | 84 | #{inspect(__MODULE__)}.close(socket) 85 | :ok 86 | """ 87 | def close(socket) do 88 | quit(socket) 89 | Socket.close(socket) 90 | end 91 | 92 | @doc """ 93 | Retrieves the number of available messages and the total size in octets 94 | 95 | ## Examples: 96 | 97 | #{inspect(__MODULE__)}.stat(socket) 98 | {12, 13579} 99 | """ 100 | def stat(socket) do 101 | {:ok, data} = send_stat(socket) 102 | parse_stat(trim(data)) 103 | end 104 | 105 | @doc """ 106 | Retrieves a list of all messages 107 | 108 | ## Examples: 109 | 110 | #{inspect(__MODULE__)}.list(socket) 111 | [{1, 100}, {2, 200}] 112 | """ 113 | def list(socket) do 114 | {:ok, data} = send_list(socket) 115 | 116 | data 117 | |> Enum.drop(1) 118 | |> Enum.reduce([], fn 119 | ".", acc -> acc 120 | stat, acc -> [parse_stat(stat) | acc] 121 | end) 122 | |> Enum.reverse() 123 | end 124 | 125 | @doc """ 126 | Retrieves a message. 127 | 128 | ## Examples: 129 | 130 | > #{inspect(__MODULE__)}.retrieve(socket, {1, 100}) 131 | ["Date: Fri, 30 Sep 2016 10:48:00 +0200", "Subject: Test message", "To: user@example.com", "", "Test message"] 132 | > #{inspect(__MODULE__)}.retrieve(socket, 1) 133 | ["Date: Fri, 30 Sep 2016 10:48:00 +0200", "Subject: Test message", "To: user@example.com", "", "Test message"] 134 | """ 135 | def retrieve(socket, mail) 136 | 137 | def retrieve(socket, {id, _size}), 138 | do: retrieve(socket, id) 139 | 140 | def retrieve(socket, id) do 141 | :ok = Socket.send(socket, "RETR #{id}\r\n") 142 | lines = receive_till(socket, ".") 143 | {:ok, tl(lines)} 144 | end 145 | 146 | @doc """ 147 | Marks a message for deletion. 148 | 149 | ## Examples: 150 | 151 | > #{inspect(__MODULE__)}.delete(socket, {1, 100}) 152 | :ok 153 | > #{inspect(__MODULE__)}.delete(socket, 1) 154 | :ok 155 | """ 156 | def delete(socket, mail) 157 | 158 | def delete(socket, {id, _size}), 159 | do: delete(socket, id) 160 | 161 | def delete(socket, id) do 162 | :ok = Socket.send(socket, "DELE #{id}\r\n") 163 | {:ok, _} = recv(socket) 164 | :ok 165 | end 166 | 167 | @doc """ 168 | Resets all messages marked for deletion. 169 | 170 | ## Examples: 171 | 172 | > #{inspect(__MODULE__)}.reset(socket) 173 | :ok 174 | """ 175 | def reset(socket) do 176 | :ok = Socket.send(socket, "RSET\r\n") 177 | {:ok, _} = recv(socket) 178 | :ok 179 | end 180 | 181 | @doc """ 182 | Sends the QUIT command to end the transaction. 183 | 184 | ## Examples: 185 | 186 | > #{inspect(__MODULE__)}.reset(socket) 187 | :ok 188 | """ 189 | def quit(socket) do 190 | :ok = Socket.send(socket, "QUIT\r\n") 191 | {:ok, _} = recv(socket) 192 | :ok 193 | end 194 | 195 | defp login(socket, username, password) do 196 | with {:ok, _} <- recv(socket), 197 | {:ok, _} <- send_user(socket, username), 198 | {:ok, _} <- send_pass(socket, password), 199 | do: :ok 200 | end 201 | 202 | defp send_user(socket, username) do 203 | :ok = Socket.send(socket, "USER " <> username <> "\r\n") 204 | recv(socket) 205 | end 206 | 207 | defp send_pass(socket, password) do 208 | :ok = Socket.send(socket, "PASS " <> password <> "\r\n") 209 | recv(socket) 210 | end 211 | 212 | defp send_list(socket) do 213 | :ok = Socket.send(socket, "LIST\r\n") 214 | {:ok, receive_till(socket, ".")} 215 | end 216 | 217 | defp send_stat(socket) do 218 | :ok = Socket.send(socket, "STAT\r\n") 219 | recv(socket) 220 | end 221 | 222 | defp receive_till(socket, match, acc \\ []) 223 | 224 | defp receive_till(socket, match, acc) do 225 | {:ok, data} = Socket.recv(socket) 226 | check_if_end_of_stream(socket, match, data, acc) 227 | end 228 | 229 | defp check_if_end_of_stream(_socket, match, match, acc), 230 | do: Enum.reverse(acc) 231 | 232 | defp check_if_end_of_stream(socket, match, data, acc), 233 | do: receive_till(socket, match, [data | acc]) 234 | 235 | defp parse_stat(data, count \\ "", size \\ nil) 236 | 237 | defp parse_stat("", count, size), 238 | do: {String.to_integer(count), String.to_integer(size)} 239 | 240 | defp parse_stat(" ", count, size), 241 | do: {String.to_integer(count), String.to_integer(size)} 242 | 243 | defp parse_stat(<<"\r", _rest::binary>>, count, size), 244 | do: {String.to_integer(count), String.to_integer(size)} 245 | 246 | defp parse_stat(<<" ", rest::binary>>, count, nil), 247 | do: parse_stat(rest, count, "") 248 | 249 | defp parse_stat(<>, count, nil), 250 | do: parse_stat(rest, count <> char, nil) 251 | 252 | defp parse_stat(<>, count, size), 253 | do: parse_stat(rest, count, size <> char) 254 | 255 | defp recv(socket) do 256 | {:ok, msg} = Socket.recv(socket) 257 | 258 | case msg do 259 | <<"+OK", msg::binary>> -> 260 | {:ok, msg} 261 | 262 | <<"-ERR", reason::binary>> -> 263 | {:error, trim(reason)} 264 | end 265 | end 266 | 267 | if function_exported?(String, :trim, 1) do 268 | defp trim(string), do: String.trim(string) 269 | else 270 | defp trim(string), do: String.strip(string) 271 | end 272 | end 273 | -------------------------------------------------------------------------------- /test/support/test_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.TestServer.Application do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | DynamicSupervisor.start_link(strategy: :one_for_one, name: Mailroom.TestServer.Supervisor) 6 | end 7 | end 8 | 9 | defmodule Mailroom.TestServer do 10 | use GenServer 11 | 12 | @tcp_opts [:binary, packet: :line, active: false, reuseaddr: true] 13 | 14 | def start_link(opts \\ []) do 15 | GenServer.start_link(__MODULE__, opts) 16 | end 17 | 18 | def init(opts) do 19 | ssl = Keyword.get(opts, :ssl, false) 20 | {:ok, socket} = get_socket(ssl) 21 | {:ok, port} = get_port(socket) 22 | {:ok, %{address: "localhost", port: port, socket: socket, ssl: ssl, result: :ok}} 23 | end 24 | 25 | def call(pid, request) do 26 | GenServer.call(pid, request, :infinity) 27 | end 28 | 29 | def cast(pid, request) do 30 | GenServer.cast(pid, request) 31 | end 32 | 33 | def handle_call(:setup, _from, %{address: address, port: port} = state), 34 | do: {:reply, {address, port}, state} 35 | 36 | def handle_call(:on_exit, _from, state), 37 | do: {:reply, state.result, state} 38 | 39 | def handle_call(request, from, state) do 40 | IO.puts("handle_call(#{inspect(request)}, #{inspect(from)}, #{inspect(state)})") 41 | {:reply, :ok, state} 42 | end 43 | 44 | def handle_cast({:start, expectations}, state) do 45 | state = Map.put(state, :expectations, expectations) 46 | {:ok, client} = accept_connection(state.socket) 47 | result = serve_client(client, state.expectations) 48 | state = Map.put(state, :result, result) 49 | {:noreply, state} 50 | end 51 | 52 | def handle_info({:ssl_closed, _}, state) do 53 | {:noreply, state} 54 | end 55 | 56 | def serve_client(socket, conversation, response \\ nil) 57 | 58 | def serve_client(socket, [{:connect, response, options} | tail], nil) do 59 | socket_send(socket, response) 60 | socket = upgrade_to_ssl(socket, options) 61 | {:ok, data} = socket_recv(socket) 62 | serve_client(socket, tail, data) 63 | end 64 | 65 | def serve_client(socket, [{[data], response, options} | tail], data) do 66 | socket_send(socket, response) 67 | socket = upgrade_to_ssl(socket, options) 68 | {:ok, data} = socket_recv(socket) 69 | serve_client(socket, tail, data) 70 | end 71 | 72 | def serve_client(socket, [{[data], response, _options}], data) do 73 | socket_send(socket, response) 74 | :ok 75 | end 76 | 77 | def serve_client(socket, [{data, response, _options}], data) do 78 | socket_send(socket, response) 79 | :ok 80 | end 81 | 82 | def serve_client(socket, [{[data | data_tail], response, options} | tail], data) do 83 | {:ok, data} = socket_recv(socket) 84 | serve_client(socket, [{data_tail, response, options} | tail], data) 85 | end 86 | 87 | def serve_client(socket, [{data, response, options} | tail], data) do 88 | socket_send(socket, response) 89 | socket = upgrade_to_ssl(socket, options) 90 | {:ok, data} = socket_recv(socket) 91 | serve_client(socket, tail, data) 92 | end 93 | 94 | def serve_client(socket, [{expected, _response, _options} | _tail], actual) do 95 | socket_send(socket, "Expected #{inspect(expected)} but received #{inspect(actual)}\r\n") 96 | {:error, expected, actual} 97 | end 98 | 99 | defp upgrade_to_ssl({:sslsocket, _, _} = socket, _options), do: socket 100 | 101 | defp upgrade_to_ssl(socket, options) do 102 | if Keyword.get(options, :ssl) do 103 | opts = [ 104 | [certfile: Path.join(__DIR__, "certificate.pem"), keyfile: Path.join(__DIR__, "key.pem")] 105 | | @tcp_opts 106 | ] 107 | 108 | {:ok, socket} = handshake(socket, opts, 1_000) 109 | 110 | socket 111 | else 112 | socket 113 | end 114 | end 115 | 116 | def start(opts \\ []) do 117 | case DynamicSupervisor.start_child( 118 | Mailroom.TestServer.Supervisor, 119 | {Mailroom.TestServer, opts} 120 | ) do 121 | {:ok, pid} -> 122 | {address, port} = call(pid, :setup) 123 | 124 | ExUnit.Callbacks.on_exit({__MODULE__, pid}, fn -> 125 | case __MODULE__.call(pid, :on_exit) do 126 | :ok -> 127 | :ok 128 | 129 | {:error, expected, actual} -> 130 | raise ExUnit.AssertionError, 131 | "TestServer expected #{inspect(expected)} but received #{inspect(actual)}" 132 | end 133 | end) 134 | 135 | %{pid: pid, address: address, port: port} 136 | 137 | other -> 138 | other 139 | end 140 | end 141 | 142 | def expect(server, func) do 143 | expectations = 144 | func.([]) 145 | |> Enum.reverse() 146 | |> add_tags() 147 | 148 | cast(server.pid, {:start, expectations}) 149 | end 150 | 151 | def on(expectations, cmd, resp, options \\ []) do 152 | [{cmd, resp, options} | expectations] 153 | end 154 | 155 | def tagged(expectations, cmd, resp, options \\ []) do 156 | [{:tagged, cmd, resp, options} | expectations] 157 | end 158 | 159 | defp add_tags(list, cmd_number \\ 0) 160 | defp add_tags([], _), do: [] 161 | 162 | defp add_tags([{:tagged, "IDLE\r\n" = cmd, resp, options} | tail], cmd_number) do 163 | [{add_tag(cmd, cmd_number), add_tag(resp, cmd_number), options} | add_tags(tail, cmd_number)] 164 | end 165 | 166 | defp add_tags([{:tagged, cmd, resp, options} | tail], cmd_number) do 167 | [ 168 | {add_tag(cmd, cmd_number), add_tag(resp, cmd_number), options} 169 | | add_tags(tail, cmd_number + 1) 170 | ] 171 | end 172 | 173 | defp add_tags([head | tail], cmd_number) do 174 | [head | add_tags(tail, cmd_number + 1)] 175 | end 176 | 177 | defp add_tag([], _), do: [] 178 | 179 | defp add_tag([command | tail], cmd_number), 180 | do: [add_tag(command, cmd_number) | add_tag(tail, cmd_number)] 181 | 182 | defp add_tag(command, _) when is_atom(command), do: command 183 | defp add_tag("DONE\r\n", _), do: "DONE\r\n" 184 | defp add_tag(<<"*", _rest::binary>> = command, _), do: command 185 | defp add_tag(<<"+", _rest::binary>> = command, _), do: command 186 | 187 | defp add_tag(command, cmd_number), 188 | do: "A#{String.pad_leading(to_string(cmd_number), 3, "0")} #{command}" 189 | 190 | defp get_socket(false), 191 | do: :gen_tcp.listen(0, @tcp_opts) 192 | 193 | defp get_socket(true) do 194 | :ok = :ssl.start() 195 | 196 | :ssl.listen(0, [ 197 | [certfile: Path.join(__DIR__, "certificate.pem"), keyfile: Path.join(__DIR__, "key.pem")] 198 | | @tcp_opts 199 | ]) 200 | end 201 | 202 | defp get_port({:sslsocket, _, {socket, _}}), 203 | do: get_port(socket) 204 | 205 | defp get_port(socket), 206 | do: :inet.port(socket) 207 | 208 | defp accept_connection({:sslsocket, _, _} = socket) do 209 | {:ok, socket} = :ssl.transport_accept(socket) 210 | 211 | handshake(socket, 1_000) 212 | end 213 | 214 | defp accept_connection(socket), 215 | do: :gen_tcp.accept(socket, 1_000) 216 | 217 | defp socket_send({:sslsocket, _, _} = socket, data), 218 | do: :ssl.send(socket, data) 219 | 220 | defp socket_send(socket, data), 221 | do: :gen_tcp.send(socket, data) 222 | 223 | defp socket_recv({:sslsocket, _, _} = socket), 224 | do: :ssl.recv(socket, 0, 1_000) 225 | 226 | defp socket_recv(socket), 227 | do: :gen_tcp.recv(socket, 0, 1_000) 228 | 229 | if Code.ensure_loaded?(:ssl) && 230 | function_exported?(:ssl, :handshake, 2) do 231 | defp handshake(socket, timeout) do 232 | :ssl.handshake(socket, timeout) 233 | end 234 | else 235 | defp handshake(socket, timeout) do 236 | :ok = :ssl.ssl_accept(socket, timeout) 237 | {:ok, socket} 238 | end 239 | end 240 | 241 | if Code.ensure_loaded?(:ssl) && 242 | function_exported?(:ssl, :handshake, 3) do 243 | defp handshake(socket, opts, timeout) do 244 | :ssl.handshake(socket, opts, timeout) 245 | end 246 | else 247 | defp handshake(socket, opts, timeout) do 248 | :ok = :ssl.ssl_accept(socket, opts, timeout) 249 | {:ok, socket} 250 | end 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /test/mailroom/inbox/match_utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.Inbox.MatchUtilsTest do 2 | use ExUnit.Case, async: true 3 | import Mailroom.Inbox.MatchUtils 4 | 5 | alias Mailroom.IMAP.{BodyStructure, Envelope} 6 | alias BodyStructure.Part 7 | 8 | test "match_recipient/2 with binary" do 9 | mail_info = %{ 10 | recipients: [ 11 | "test-to@example.com", 12 | "other-to@example.com", 13 | "test-cc@example.com", 14 | "other-cc@example.com", 15 | "test-bcc@example.com" 16 | ], 17 | to: ["test-to@example.com", "other-to@example.com"], 18 | cc: ["test-cc@example.com", "other-cc@example.com"], 19 | bcc: "test-bcc@example.com" 20 | } 21 | 22 | assert match_recipient(mail_info, "test-to@example.com") 23 | assert match_recipient(mail_info, "other-to@example.com") 24 | 25 | assert match_recipient(mail_info, "test-cc@example.com") 26 | assert match_recipient(mail_info, "other-cc@example.com") 27 | 28 | assert match_recipient(mail_info, "test-bcc@example.com") 29 | 30 | refute match_recipient(mail_info, "no-to@example.com") 31 | refute match_recipient(mail_info, "no-cc@example.com") 32 | refute match_recipient(mail_info, "no-bcc@example.com") 33 | end 34 | 35 | test "match_recipient/2 with Regex" do 36 | mail_info = %{ 37 | recipients: [ 38 | "test-to@example.com", 39 | "other-to@example.com", 40 | "test-cc@example.com", 41 | "other-cc@example.com", 42 | "test-bcc@example.com" 43 | ], 44 | to: ["test-to@example.com", "other-to@example.com"], 45 | cc: ["test-cc@example.com", "other-cc@example.com"], 46 | bcc: "test-bcc@example.com" 47 | } 48 | 49 | assert match_recipient(mail_info, ~r/to@example/) 50 | assert match_recipient(mail_info, ~r/bcc/) 51 | refute match_recipient(mail_info, ~r/\d+/) 52 | end 53 | 54 | test "match_to/2 with binary" do 55 | mail_info = %{ 56 | to: ["test-to@example.com", "other-to@example.com"], 57 | cc: ["test-cc@example.com", "other-cc@example.com"], 58 | bcc: "test-bcc@example.com" 59 | } 60 | 61 | assert match_to(mail_info, "test-to@example.com") 62 | assert match_to(mail_info, "other-to@example.com") 63 | 64 | refute match_to(mail_info, "test-cc@example.com") 65 | refute match_to(mail_info, "other-cc@example.com") 66 | 67 | refute match_to(mail_info, "test-bcc@example.com") 68 | 69 | refute match_to(mail_info, "no-to@example.com") 70 | refute match_to(mail_info, "no-cc@example.com") 71 | refute match_to(mail_info, "no-bcc@example.com") 72 | end 73 | 74 | test "match_cc/2 with binary" do 75 | mail_info = %{ 76 | to: ["test-to@example.com", "other-to@example.com"], 77 | cc: ["test-cc@example.com", "other-cc@example.com"], 78 | bcc: "test-bcc@example.com" 79 | } 80 | 81 | refute match_cc(mail_info, "test-to@example.com") 82 | refute match_cc(mail_info, "other-to@example.com") 83 | 84 | assert match_cc(mail_info, "test-cc@example.com") 85 | assert match_cc(mail_info, "other-cc@example.com") 86 | 87 | refute match_cc(mail_info, "test-bcc@example.com") 88 | 89 | refute match_cc(mail_info, "no-to@example.com") 90 | refute match_cc(mail_info, "no-cc@example.com") 91 | refute match_cc(mail_info, "no-bcc@example.com") 92 | end 93 | 94 | test "match_bcc/2 with binary" do 95 | mail_info = %{ 96 | to: ["test-to@example.com", "other-to@example.com"], 97 | cc: ["test-cc@example.com", "other-cc@example.com"], 98 | bcc: "test-bcc@example.com" 99 | } 100 | 101 | refute match_bcc(mail_info, "test-to@example.com") 102 | refute match_bcc(mail_info, "other-to@example.com") 103 | 104 | refute match_bcc(mail_info, "test-cc@example.com") 105 | refute match_bcc(mail_info, "other-cc@example.com") 106 | 107 | assert match_bcc(mail_info, "test-bcc@example.com") 108 | 109 | refute match_bcc(mail_info, "no-to@example.com") 110 | refute match_bcc(mail_info, "no-cc@example.com") 111 | refute match_bcc(mail_info, "no-bcc@example.com") 112 | end 113 | 114 | test "match_from/2 with binary" do 115 | mail_info = %{ 116 | from: ["test-from@example.com", "other-from@example.com"] 117 | } 118 | 119 | assert match_from(mail_info, "test-from@example.com") 120 | assert match_from(mail_info, "other-from@example.com") 121 | refute match_from(mail_info, "no-from@example.com") 122 | end 123 | 124 | test "match_subject/2 with binary" do 125 | mail_info = %{ 126 | subject: "Test subject" 127 | } 128 | 129 | assert match_subject(mail_info, "Test subject") 130 | refute match_subject(mail_info, "Test subject with more") 131 | end 132 | 133 | test "match_subject/2 with Regex" do 134 | mail_info = %{ 135 | subject: "Test subject" 136 | } 137 | 138 | assert match_subject(mail_info, ~r/test/i) 139 | refute match_subject(mail_info, ~r/\d+/) 140 | end 141 | 142 | test "match_has_attachment?/1" do 143 | assert match_has_attachment?(%{has_attachment: true}) 144 | refute match_has_attachment?(%{has_attachment: false}) 145 | end 146 | 147 | test "generate_match_info/1" do 148 | envelope = 149 | Envelope.new([ 150 | "Wed, 26 Oct 2016 14:23:14 +0200", 151 | "Test subject", 152 | [["John Doe", nil, "john", "example.com"]], 153 | [["John Doe", nil, "JOHN", "EXAMPLE.COM"]], 154 | [["John Doe", nil, "reply", "example.com"]], 155 | [[nil, nil, "dev", "debtflow.co.za"]], 156 | nil, 157 | nil, 158 | nil, 159 | "" 160 | ]) 161 | 162 | body_structure = %Part{ 163 | description: nil, 164 | disposition: nil, 165 | encoded_size: nil, 166 | encoding: nil, 167 | file_name: nil, 168 | id: nil, 169 | multipart: true, 170 | params: %{}, 171 | parts: [ 172 | %Part{ 173 | description: nil, 174 | disposition: nil, 175 | encoded_size: 438, 176 | encoding: "7bit", 177 | file_name: nil, 178 | id: nil, 179 | multipart: false, 180 | params: %{"charset" => "utf-8"}, 181 | parts: [], 182 | section: "1", 183 | type: "text/plain" 184 | }, 185 | %Part{ 186 | description: nil, 187 | disposition: "attachment", 188 | encoded_size: 81800, 189 | encoding: "base64", 190 | file_name: "Image.pdf", 191 | id: nil, 192 | multipart: false, 193 | params: %{"name" => "Image.pdf"}, 194 | parts: [], 195 | section: "2", 196 | type: "application/octet-stream" 197 | } 198 | ], 199 | section: nil, 200 | type: "mixed" 201 | } 202 | 203 | assert %{ 204 | to: ["dev@debtflow.co.za"], 205 | cc: [], 206 | bcc: [], 207 | from: ["john@example.com"], 208 | reply_to: ["reply@example.com"], 209 | subject: "Test subject" 210 | } = generate_mail_info(%{envelope: envelope, body_structure: body_structure}, []) 211 | end 212 | 213 | test "generate_match_info/1 with invalid data" do 214 | envelope = 215 | Envelope.new([ 216 | "Wed, 26 Oct 2016 14:23:14 +0200", 217 | "Test subject", 218 | [["John Doe", nil, "john", "example.com"]], 219 | [["John Doe", nil, "JOHN", "EXAMPLE.COM"]], 220 | [["John Doe", nil, "reply", "example.com"]], 221 | [[nil, nil, "dev", "debtflow.co.za"]], 222 | nil, 223 | nil, 224 | nil, 225 | "" 226 | ]) 227 | 228 | headers = "wrong" 229 | 230 | assert %{ 231 | to: ["dev@debtflow.co.za"], 232 | cc: [], 233 | bcc: [], 234 | from: ["john@example.com"], 235 | reply_to: ["reply@example.com"], 236 | subject: "Test subject", 237 | headers: %{} 238 | } = generate_mail_info(%{:envelope => envelope, "BODY[HEADER]" => headers}, []) 239 | end 240 | end 241 | -------------------------------------------------------------------------------- /lib/mailroom/smtp.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.SMTP do 2 | alias Mailroom.Socket 3 | 4 | def connect(server, options \\ []) do 5 | opts = parse_opts(options) 6 | 7 | {:ok, socket} = 8 | Socket.connect(server, opts.port, ssl: opts.ssl, debug: opts.debug, ssl_opts: opts.ssl_opts) 9 | 10 | with {:ok, _banner} <- read_banner(socket), 11 | {:ok, extensions} <- greet(socket), 12 | {:ok, socket, extensions} <- try_starttls(socket, extensions), 13 | {:ok, socket} <- try_auth(socket, extensions, options) do 14 | {:ok, socket} 15 | else 16 | err -> err 17 | end 18 | end 19 | 20 | defp parse_opts(opts, acc \\ %{ssl: false, port: 25, debug: false, ssl_opts: []}) 21 | defp parse_opts([], acc), do: acc 22 | 23 | defp parse_opts([{:ssl, ssl} | tail], acc), 24 | do: parse_opts(tail, Map.put(acc, :ssl, ssl)) 25 | 26 | defp parse_opts([{:ssl_opts, ssl_opts} | tail], acc), 27 | do: parse_opts(tail, Map.put(acc, :ssl_opts, ssl_opts)) 28 | 29 | defp parse_opts([{:port, port} | tail], acc), 30 | do: parse_opts(tail, Map.put(acc, :port, port)) 31 | 32 | defp parse_opts([{:debug, debug} | tail], acc), 33 | do: parse_opts(tail, Map.put(acc, :debug, debug)) 34 | 35 | defp parse_opts([_ | tail], acc), 36 | do: parse_opts(tail, acc) 37 | 38 | defp read_banner(socket) do 39 | {:ok, line} = Socket.recv(socket) 40 | parse_banner(line) 41 | end 42 | 43 | defp parse_banner(<<"220 ", banner::binary>>), 44 | do: {:ok, banner} 45 | 46 | defp parse_banner(_), 47 | do: {:error, "Unexpected banner"} 48 | 49 | defp greet(socket) do 50 | case try_ehlo(socket) do 51 | {:ok, lines} -> 52 | {:ok, parse_exentions(lines)} 53 | 54 | :error -> 55 | {:ok, {"250", _}} = send_helo(socket) 56 | {:ok, []} 57 | end 58 | end 59 | 60 | defp try_ehlo(socket) do 61 | Socket.send(socket, ["EHLO ", fqdn(), "\r\n"]) 62 | {:ok, lines} = read_potentially_multiline_response(socket) 63 | 64 | case hd(lines) do 65 | {"500", _} -> :error 66 | {<<"4", _::binary>>, _} -> :temp_error 67 | _ -> {:ok, lines} 68 | end 69 | end 70 | 71 | defp send_helo(socket) do 72 | Socket.send(socket, ["HELO ", fqdn(), "\r\n"]) 73 | {:ok, data} = Socket.recv(socket) 74 | parse_smtp_response(data) 75 | end 76 | 77 | defp parse_smtp_response(<<"2", code::binary-size(2), " ", domain::binary>>), 78 | do: {:ok, {"2" <> code, domain}} 79 | 80 | defp parse_smtp_response(<<"3", code::binary-size(2), " ", domain::binary>>), 81 | do: {:ok, {"3" <> code, domain}} 82 | 83 | defp parse_smtp_response(<<"4", code::binary-size(2), " ", reason::binary>>), 84 | do: {:temp_error, {"4" <> code, reason}} 85 | 86 | defp parse_smtp_response(<<"5", code::binary-size(2), " ", reason::binary>>), 87 | do: {:error, {"5" <> code, reason}} 88 | 89 | defp parse_exentions(lines, acc \\ []) 90 | defp parse_exentions([], acc), do: Enum.reverse(acc) 91 | 92 | defp parse_exentions([line | tail], acc), 93 | do: parse_exentions(tail, [parse_exention(line) | acc]) 94 | 95 | defp parse_exention({_code, line}), 96 | do: String.split(line, " ", parts: 2) 97 | 98 | defp read_potentially_multiline_response(socket) do 99 | {:ok, data} = Socket.recv(socket) 100 | parse_potentially_multiline_response(data, socket) 101 | end 102 | 103 | defp parse_potentially_multiline_response(data, socket, acc \\ []) 104 | 105 | defp parse_potentially_multiline_response( 106 | <>, 107 | _socket, 108 | acc 109 | ) do 110 | acc = [{code, rest} | acc] 111 | {:ok, Enum.reverse(acc)} 112 | end 113 | 114 | defp parse_potentially_multiline_response( 115 | <>, 116 | socket, 117 | acc 118 | ) do 119 | acc = [{code, rest} | acc] 120 | {:ok, data} = Socket.recv(socket) 121 | parse_potentially_multiline_response(data, socket, acc) 122 | end 123 | 124 | defp try_starttls(socket, extensions) do 125 | if supports_extension?("STARTTLS", extensions) do 126 | # TODO: Need to handle error case 127 | {:ok, socket} = do_starttls(socket) 128 | # TODO: Need to handle error case 129 | {:ok, extensions} = greet(socket) 130 | {:ok, socket, extensions} 131 | else 132 | {:ok, socket, extensions} 133 | end 134 | end 135 | 136 | defp supports_extension?(_name, []), do: false 137 | defp supports_extension?(name, [[name | _] | _tail]), do: true 138 | defp supports_extension?(name, [_ | tail]), do: supports_extension?(name, tail) 139 | 140 | defp get_parameters(_name, []), do: false 141 | defp get_parameters(name, [[name, parameters] | _tail]), do: parameters 142 | defp get_parameters(name, [_ | tail]), do: get_parameters(name, tail) 143 | 144 | defp do_starttls(socket) do 145 | Socket.send(socket, "STARTTLS\r\n") 146 | {:ok, data} = Socket.recv(socket) 147 | {:ok, {"220", _message}} = parse_smtp_response(data) 148 | Socket.ssl_client(socket) 149 | end 150 | 151 | defp try_auth(socket, extensions, options) do 152 | if supports_extension?("AUTH", extensions) do 153 | params = get_parameters("AUTH", extensions) 154 | 155 | case do_auth(socket, params, options) do 156 | {:ok, _} -> {:ok, socket} 157 | other -> other 158 | end 159 | else 160 | {:ok, socket} 161 | end 162 | end 163 | 164 | defp do_auth(socket, params, options) do 165 | username = Keyword.get(options, :username) 166 | password = Keyword.get(options, :password) 167 | 168 | auth_options = String.split(params, " ") 169 | 170 | do_auth(socket, auth_options, username, password) 171 | end 172 | 173 | defp do_auth(_socket, _options, nil, _password), 174 | do: {:error, "Missing username"} 175 | 176 | defp do_auth(_socket, _options, _username, nil), 177 | do: {:error, "Missing password"} 178 | 179 | defp do_auth(socket, ["PLAIN" | _tail], username, password) do 180 | auth_string = Base.encode64("\0" <> username <> "\0" <> password) 181 | Socket.send(socket, ["AUTH PLAIN ", auth_string, "\r\n"]) 182 | {:ok, data} = Socket.recv(socket) 183 | parse_smtp_response(data) 184 | end 185 | 186 | defp do_auth(socket, ["LOGIN" | _tail], username, password) do 187 | Socket.send(socket, ["AUTH LOGIN\r\n"]) 188 | # Socket.send(socket, ["AUTH PLAIN ", auth_string, "\r\n"]) 189 | {:ok, data} = Socket.recv(socket) 190 | {:ok, {"334", message}} = parse_smtp_response(data) 191 | "username:" = decode_base64_lowercase(message) 192 | user = Base.encode64(username) 193 | Socket.send(socket, [user, "\r\n"]) 194 | {:ok, data} = Socket.recv(socket) 195 | {:ok, {"334", message}} = parse_smtp_response(data) 196 | "password:" = decode_base64_lowercase(message) 197 | pass = Base.encode64(password) 198 | Socket.send(socket, [pass, "\r\n"]) 199 | {:ok, data} = Socket.recv(socket) 200 | parse_smtp_response(data) 201 | end 202 | 203 | defp do_auth(socket, [_opt | tail], username, password), 204 | do: do_auth(socket, tail, username, password) 205 | 206 | defp decode_base64_lowercase(string), 207 | do: string |> Base.decode64!() |> String.downcase() 208 | 209 | def send_message(socket, from, to, message) do 210 | Socket.send(socket, ["MAIL FROM: <", from, ">\r\n"]) 211 | {:ok, data} = Socket.recv(socket) 212 | {:ok, {"250", _ok}} = parse_smtp_response(data) 213 | 214 | Socket.send(socket, ["RCPT TO: <", to, ">\r\n"]) 215 | {:ok, data} = Socket.recv(socket) 216 | {:ok, {"250", _}} = parse_smtp_response(data) 217 | 218 | Socket.send(socket, "DATA\r\n") 219 | {:ok, data} = Socket.recv(socket) 220 | {:ok, {"354", _ok}} = parse_smtp_response(data) 221 | 222 | message 223 | |> String.split(~r/\r\n/) 224 | |> Enum.each(fn line -> 225 | :ok = Socket.send(socket, [line, "\r\n"]) 226 | end) 227 | 228 | :ok = Socket.send(socket, ".\r\n") 229 | {:ok, data} = Socket.recv(socket) 230 | {:ok, {"250", _}} = parse_smtp_response(data) 231 | :ok 232 | end 233 | 234 | def quit(socket) do 235 | Socket.send(socket, "QUIT\r\n") 236 | {:ok, data} = Socket.recv(socket) 237 | {:ok, {"221", _message}} = parse_smtp_response(data) 238 | end 239 | 240 | def fqdn do 241 | {:ok, name} = :inet.gethostname() 242 | {:ok, hostent} = :inet.gethostbyname(name) 243 | {:hostent, name, _aliases, :inet, _, _addresses} = hostent 244 | to_string(name) 245 | end 246 | end 247 | -------------------------------------------------------------------------------- /lib/mailroom/imap/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.IMAP.Utils do 2 | @moduledoc false 3 | def parse_list_only(string) do 4 | {list, _rest} = parse_list(string) 5 | list 6 | end 7 | 8 | def parse_list(string, depth \\ 0, temp \\ nil, acc \\ nil) 9 | 10 | def parse_list(<<"(", rest::binary>>, depth, _temp, acc) do 11 | {list, rest} = parse_list(rest, depth + 1, nil, []) 12 | acc = if acc, do: [list | acc], else: list 13 | 14 | if depth == 0 do 15 | {acc, rest} 16 | else 17 | parse_list(rest, depth, nil, acc) 18 | end 19 | end 20 | 21 | def parse_list(<<")", rest::binary>>, _depth, temp, acc), 22 | do: {Enum.reverse(prepend_to_list(acc, temp)), rest} 23 | 24 | def parse_list(<<"\"", _rest::binary>> = string, depth, _temp, acc) do 25 | {string, rest} = parse_string(string) 26 | parse_list(rest, depth, string, acc) 27 | end 28 | 29 | def parse_list(<<"{", rest::binary>>, depth, _temp, acc) do 30 | {octets, <<"\r\n", rest::binary>>} = read_until(rest, "}") 31 | octets = String.to_integer(octets) 32 | <> = rest 33 | parse_list(rest, depth, nil, prepend_to_list(acc, string)) 34 | end 35 | 36 | def parse_list(<<" ", rest::binary>>, depth, nil, acc), 37 | do: parse_list(rest, depth, nil, acc) 38 | 39 | def parse_list(<<"\r", rest::binary>>, depth, temp, acc), 40 | do: parse_list(rest, depth, "\r", prepend_to_list(acc, temp)) 41 | 42 | def parse_list(<<" ", rest::binary>>, depth, temp, acc), 43 | do: parse_list(rest, depth, nil, prepend_to_list(acc, temp)) 44 | 45 | def parse_list(<>, depth, nil, acc), 46 | do: parse_list(rest, depth, <>, acc) 47 | 48 | def parse_list(<>, depth, temp, acc), 49 | do: parse_list(rest, depth, <>, acc) 50 | 51 | defp prepend_to_list(nil, item), do: List.wrap(item) 52 | defp prepend_to_list(list, nil), do: list 53 | defp prepend_to_list(nil, "NIL"), do: [nil] 54 | defp prepend_to_list(list, "NIL"), do: [nil | list] 55 | defp prepend_to_list(list, item), do: [item | list] 56 | 57 | def parse_string_only(string) do 58 | {string, _} = parse_string(string) 59 | string 60 | end 61 | 62 | defp read_until(string, char, acc \\ []) 63 | 64 | defp read_until(<>, <>, acc), 65 | do: {:erlang.iolist_to_binary(Enum.reverse(acc)), rest} 66 | 67 | defp read_until(<>, until, acc), 68 | do: read_until(rest, until, [char | acc]) 69 | 70 | def parse_string(string), 71 | do: do_parse_string(String.next_grapheme(string), false, []) 72 | 73 | defp do_parse_string({"\\", rest}, inquotes, acc) do 74 | {grapheme, rest} = String.next_grapheme(rest) 75 | do_parse_string(String.next_grapheme(rest), inquotes, [grapheme | acc]) 76 | end 77 | 78 | defp do_parse_string({"\"", rest}, false, acc), 79 | do: do_parse_string(String.next_grapheme(rest), true, acc) 80 | 81 | defp do_parse_string({"\"", rest}, true, acc), 82 | do: {IO.iodata_to_binary(Enum.reverse(acc)), rest} 83 | 84 | defp do_parse_string({" ", rest}, false, acc), 85 | do: {IO.iodata_to_binary(Enum.reverse(acc)), <<" ", rest::binary>>} 86 | 87 | defp do_parse_string({"\r\n", rest}, false, acc), 88 | do: {IO.iodata_to_binary(Enum.reverse(acc)), <<"\r\n", rest::binary>>} 89 | 90 | defp do_parse_string({nil, rest}, false, acc), 91 | do: {IO.iodata_to_binary(Enum.reverse(acc)), <<" ", rest::binary>>} 92 | 93 | defp do_parse_string({grapheme, rest}, inquotes, acc), 94 | do: do_parse_string(String.next_grapheme(rest), inquotes, [grapheme | acc]) 95 | 96 | def items_to_list(list, acc \\ []) 97 | 98 | def items_to_list([], [" " | acc]), 99 | do: Enum.reverse([")" | acc]) 100 | 101 | def items_to_list(list, []), 102 | do: items_to_list(list, ["("]) 103 | 104 | def items_to_list([head | tail], acc), 105 | do: items_to_list(tail, [" ", item_to_string(head) | acc]) 106 | 107 | def items_to_list(non_list, acc), 108 | do: items_to_list(List.wrap(non_list), acc) 109 | 110 | defp item_to_string(string) when is_binary(string), do: string 111 | 112 | [ 113 | # STATUS 114 | messages: "MESSAGES", 115 | recent: "RECENT", 116 | unseen: "UNSEEN", 117 | uid_next: "UIDNEXT", 118 | uid_validity: "UIDVALIDITY", 119 | # FETCH 120 | all: "ALL", 121 | answered: "ANSWERED", 122 | fast: "FAST", 123 | full: "FULL", 124 | body: "BODY", 125 | # "BODY[
]<>", 126 | # "BODY.PEEK[
]<>", 127 | body_structure: "BODYSTRUCTURE", 128 | envelope: "ENVELOPE", 129 | flags: "FLAGS", 130 | internal_date: "INTERNALDATE", 131 | rfc822: "RFC822", 132 | rfc822_header: "RFC822.HEADER", 133 | rfc822_size: "RFC822.SIZE", 134 | rfc822_text: "RFC822.TEXT", 135 | uid: "UID", 136 | header: "BODY.PEEK[HEADER]" 137 | ] 138 | |> Enum.each(fn {atom, string} -> 139 | defp item_to_string(unquote(atom)), do: unquote(string) 140 | defp item_to_atom(unquote(string)), do: unquote(atom) 141 | end) 142 | 143 | defp item_to_atom(string), do: string 144 | 145 | def list_to_items(list, acc \\ %{}) 146 | def list_to_items([], acc), do: acc 147 | 148 | def list_to_items([item, value | tail], acc), 149 | do: list_to_items(tail, Map.put_new(acc, item_to_atom(item), value)) 150 | 151 | def list_to_status_items(list, acc \\ %{}) 152 | def list_to_status_items([], acc), do: acc 153 | 154 | def list_to_status_items([item, count | tail], acc), 155 | do: list_to_status_items(tail, Map.put_new(acc, item_to_atom(item), parse_number(count))) 156 | 157 | def flags_to_list(list, acc \\ []) 158 | 159 | def flags_to_list([], [" " | acc]), 160 | do: Enum.reverse([")" | acc]) 161 | 162 | def flags_to_list(list, []), 163 | do: flags_to_list(list, ["("]) 164 | 165 | def flags_to_list([head | tail], acc), 166 | do: flags_to_list(tail, [" ", flag_to_string(head) | acc]) 167 | 168 | def flags_to_list(non_list, acc), 169 | do: flags_to_list(List.wrap(non_list), acc) 170 | 171 | defp flag_to_string(string) when is_binary(string), do: string 172 | 173 | [ 174 | seen: "\\Seen", 175 | answered: "\\Answered", 176 | flagged: "\\Flagged", 177 | deleted: "\\Deleted", 178 | draft: "\\Draft", 179 | recent: "\\Recent" 180 | ] 181 | |> Enum.each(fn {atom, string} -> 182 | defp flag_to_string(unquote(atom)), do: unquote(string) 183 | defp flag_to_atom(unquote(string)), do: unquote(atom) 184 | end) 185 | 186 | defp flag_to_atom(string), do: string 187 | 188 | def list_to_flags(list), 189 | do: Enum.map(list, &flag_to_atom/1) 190 | 191 | def parse_number(string, acc \\ "") 192 | 193 | 0..9 194 | |> Enum.map(&Integer.to_string/1) 195 | |> Enum.each(fn digit -> 196 | def parse_number(<>, acc), 197 | do: parse_number(rest, <>) 198 | end) 199 | 200 | def parse_number(_, acc), 201 | do: String.to_integer(acc) 202 | 203 | def parse_timestamp( 204 | <> 207 | ) do 208 | Mail.Parsers.RFC2822.to_datetime( 209 | date <> 210 | " " <> 211 | month <> 212 | " " <> year <> " " <> hour <> ":" <> minute <> ":" <> second <> " (" <> timezone <> ")" 213 | ) 214 | end 215 | 216 | def quote_string(string), 217 | do: do_quote_string(String.next_grapheme(string), ["\""]) 218 | 219 | defp do_quote_string({"\"", rest}, acc), 220 | do: do_quote_string(String.next_grapheme(rest), ["\\\"" | acc]) 221 | 222 | defp do_quote_string({grapheme, rest}, acc), 223 | do: do_quote_string(String.next_grapheme(rest), [grapheme | acc]) 224 | 225 | defp do_quote_string(nil, acc), 226 | do: IO.iodata_to_binary(Enum.reverse(["\"" | acc])) 227 | 228 | def numbers_to_sequences([]), do: [] 229 | 230 | def numbers_to_sequences(list) do 231 | list 232 | |> Enum.sort() 233 | |> Enum.uniq() 234 | |> do_numbers_to_sequences(nil, []) 235 | end 236 | 237 | defp do_numbers_to_sequences([], temp, acc), 238 | do: [temp | acc] |> Enum.reverse() 239 | 240 | defp do_numbers_to_sequences([number | tail], nil, acc), 241 | do: do_numbers_to_sequences(tail, number, acc) 242 | 243 | defp do_numbers_to_sequences([number | tail], temp, acc) when number - 1 == temp, 244 | do: do_numbers_to_sequences(tail, temp..number, acc) 245 | 246 | defp do_numbers_to_sequences([number | tail], %Range{first: first, last: last}, acc) 247 | when number - 1 == last, 248 | do: do_numbers_to_sequences(tail, first..number, acc) 249 | 250 | defp do_numbers_to_sequences(list, temp, acc), 251 | do: do_numbers_to_sequences(list, nil, [temp | acc]) 252 | end 253 | -------------------------------------------------------------------------------- /test/mailroom/smtp_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.SMTPTest do 2 | use ExUnit.Case, async: true 3 | doctest Mailroom.SMTP 4 | 5 | alias Mailroom.{SMTP, TestServer} 6 | 7 | test "SMTP server doesn't support EHLO" do 8 | server = TestServer.start() 9 | 10 | TestServer.expect(server, fn expectations -> 11 | expectations 12 | |> TestServer.on( 13 | :connect, 14 | "220 myserver.com.\r\n" 15 | ) 16 | |> TestServer.on( 17 | "EHLO #{SMTP.fqdn()}\r\n", 18 | "500 WAT\r\n" 19 | ) 20 | |> TestServer.on( 21 | "HELO #{SMTP.fqdn()}\r\n", 22 | "250 myserver.com\r\n" 23 | ) 24 | |> TestServer.on( 25 | "QUIT\r\n", 26 | "221 Bye\r\n" 27 | ) 28 | end) 29 | 30 | {:ok, client} = SMTP.connect(server.address, port: server.port) 31 | SMTP.quit(client) 32 | end 33 | 34 | test "send mail" do 35 | server = TestServer.start() 36 | 37 | msg = 38 | """ 39 | Date: Fri, 30 Sep 2016 12:02:00 +0200 40 | From: me@localhost 41 | To: you@localhost 42 | Subject: Test message 43 | 44 | This is a test message 45 | """ 46 | |> String.replace(~r/(? Enum.map(&(&1 <> "\r\n")) 49 | 50 | TestServer.expect(server, fn expectations -> 51 | expectations 52 | |> TestServer.on( 53 | :connect, 54 | "220 myserver.com.\r\n" 55 | ) 56 | |> TestServer.on( 57 | "EHLO #{SMTP.fqdn()}\r\n", 58 | "250-myserver.com\r\n250-SIZE\r\n250 HELP\r\n" 59 | ) 60 | |> TestServer.on( 61 | "MAIL FROM: \r\n", 62 | "250 OK\r\n" 63 | ) 64 | |> TestServer.on( 65 | "RCPT TO: \r\n", 66 | "250 OK\r\n" 67 | ) 68 | |> TestServer.on( 69 | "DATA\r\n", 70 | "354 Send message content; end with .\r\n" 71 | ) 72 | |> TestServer.on( 73 | lines ++ [".\r\n"], 74 | "250 OK\r\n" 75 | ) 76 | |> TestServer.on( 77 | "QUIT\r\n", 78 | "221 Bye\r\n" 79 | ) 80 | end) 81 | 82 | {:ok, client} = SMTP.connect(server.address, port: server.port) 83 | :ok = SMTP.send_message(client, "me@localhost", "you@localhost", msg) 84 | SMTP.quit(client) 85 | end 86 | 87 | test "SMTP with TLS" do 88 | server = TestServer.start() 89 | 90 | TestServer.expect(server, fn expectations -> 91 | expectations 92 | |> TestServer.on( 93 | :connect, 94 | "220 myserver.com.\r\n" 95 | ) 96 | |> TestServer.on( 97 | "EHLO #{SMTP.fqdn()}\r\n", 98 | "250-myserver.com\r\n250-SIZE\r\n250-STARTTLS\r\n250 HELP\r\n" 99 | ) 100 | |> TestServer.on( 101 | "STARTTLS\r\n", 102 | "220 TLS go ahead\r\n", 103 | ssl: true 104 | ) 105 | |> TestServer.on( 106 | "EHLO #{SMTP.fqdn()}\r\n", 107 | "250-myserver.com\r\n250-SIZE\r\n250 HELP\r\n" 108 | ) 109 | |> TestServer.on( 110 | "QUIT\r\n", 111 | "221 Bye\r\n" 112 | ) 113 | end) 114 | 115 | {:ok, client} = 116 | SMTP.connect(server.address, port: server.port, ssl_opts: [verify: :verify_none]) 117 | 118 | SMTP.quit(client) 119 | end 120 | 121 | test "SMTP with AUTH PLAIN" do 122 | server = TestServer.start() 123 | 124 | TestServer.expect(server, fn expectations -> 125 | expectations 126 | |> TestServer.on( 127 | :connect, 128 | "220 myserver.com.\r\n" 129 | ) 130 | |> TestServer.on( 131 | "EHLO #{SMTP.fqdn()}\r\n", 132 | "250-myserver.com\r\n250 AUTH PLAIN\r\n" 133 | ) 134 | |> TestServer.on( 135 | "AUTH PLAIN AHVzZXJuYW1lAHBhc3N3b3Jk\r\n", 136 | "235 Authenticated\r\n" 137 | ) 138 | |> TestServer.on( 139 | "QUIT\r\n", 140 | "221 Bye\r\n" 141 | ) 142 | end) 143 | 144 | {:ok, client} = 145 | SMTP.connect(server.address, port: server.port, username: "username", password: "password") 146 | 147 | SMTP.quit(client) 148 | end 149 | 150 | test "SMTP with AUTH PLAIN no username" do 151 | server = TestServer.start() 152 | 153 | TestServer.expect(server, fn expectations -> 154 | expectations 155 | |> TestServer.on( 156 | :connect, 157 | "220 myserver.com.\r\n" 158 | ) 159 | |> TestServer.on( 160 | "EHLO #{SMTP.fqdn()}\r\n", 161 | "250-myserver.com\r\n250 AUTH PLAIN\r\n" 162 | ) 163 | end) 164 | 165 | assert {:error, "Missing username"} = SMTP.connect(server.address, port: server.port) 166 | end 167 | 168 | test "SMTP with AUTH PLAIN no password" do 169 | server = TestServer.start() 170 | 171 | TestServer.expect(server, fn expectations -> 172 | expectations 173 | |> TestServer.on( 174 | :connect, 175 | "220 myserver.com.\r\n" 176 | ) 177 | |> TestServer.on( 178 | "EHLO #{SMTP.fqdn()}\r\n", 179 | "250-myserver.com\r\n250 AUTH PLAIN\r\n" 180 | ) 181 | end) 182 | 183 | assert {:error, "Missing password"} = 184 | SMTP.connect(server.address, port: server.port, username: "username") 185 | end 186 | 187 | test "SMTP with AUTH LOGIN" do 188 | server = TestServer.start() 189 | 190 | TestServer.expect(server, fn expectations -> 191 | expectations 192 | |> TestServer.on( 193 | :connect, 194 | "220 myserver.com.\r\n" 195 | ) 196 | |> TestServer.on( 197 | "EHLO #{SMTP.fqdn()}\r\n", 198 | "250-myserver.com\r\n250 AUTH LOGIN PLAIN\r\n" 199 | ) 200 | |> TestServer.on( 201 | "AUTH LOGIN\r\n", 202 | "334 VXNlcm5hbWU6\r\n" 203 | ) 204 | |> TestServer.on( 205 | "dXNlcm5hbWU=\r\n", 206 | "334 UGFzc3dvcmQ6\r\n" 207 | ) 208 | |> TestServer.on( 209 | "cGFzc3dvcmQ=\r\n", 210 | "235 Authenticated\r\n" 211 | ) 212 | |> TestServer.on( 213 | "QUIT\r\n", 214 | "221 Bye\r\n" 215 | ) 216 | end) 217 | 218 | {:ok, client} = 219 | SMTP.connect(server.address, port: server.port, username: "username", password: "password") 220 | 221 | SMTP.quit(client) 222 | end 223 | 224 | test "SMTP with AUTH LOGIN after TLS" do 225 | server = TestServer.start() 226 | 227 | TestServer.expect(server, fn expectations -> 228 | expectations 229 | |> TestServer.on( 230 | :connect, 231 | "220 myserver.com.\r\n" 232 | ) 233 | |> TestServer.on( 234 | "EHLO #{SMTP.fqdn()}\r\n", 235 | "250-myserver.com\r\n250-SIZE\r\n250-STARTTLS\r\n250 HELP\r\n" 236 | ) 237 | |> TestServer.on( 238 | "STARTTLS\r\n", 239 | "220 TLS go ahead\r\n", 240 | ssl: true 241 | ) 242 | |> TestServer.on( 243 | "EHLO #{SMTP.fqdn()}\r\n", 244 | "250-myserver.com\r\n250-SIZE\r\n250 AUTH LOGIN PLAIN\r\n" 245 | ) 246 | |> TestServer.on( 247 | "AUTH LOGIN\r\n", 248 | "334 VXNlcm5hbWU6\r\n" 249 | ) 250 | |> TestServer.on( 251 | "dXNlcm5hbWU=\r\n", 252 | "334 UGFzc3dvcmQ6\r\n" 253 | ) 254 | |> TestServer.on( 255 | "cGFzc3dvcmQ=\r\n", 256 | "235 Authenticated\r\n" 257 | ) 258 | |> TestServer.on( 259 | "QUIT\r\n", 260 | "221 Bye\r\n" 261 | ) 262 | end) 263 | 264 | {:ok, client} = 265 | SMTP.connect(server.address, 266 | port: server.port, 267 | username: "username", 268 | password: "password", 269 | ssl_opts: [verify: :verify_none] 270 | ) 271 | 272 | SMTP.quit(client) 273 | end 274 | 275 | # test "connect" do 276 | # server = Application.get_env(:mailroom, :smtp_server) 277 | # # port = Application.get_env(:mailroom, :smtp_port) 278 | # # username = Application.get_env(:mailroom, :smtp_username) 279 | # # password = Application.get_env(:mailroom, :smtp_password) 280 | # ssl = Application.get_env(:mailroom, :smtp_ssl, false) 281 | 282 | # server = "smtp.gmail.com" 283 | # port = 587 284 | 285 | # server = "smtp.sparkpostmail.com" 286 | # port = 587 287 | 288 | # # :dbg.start() |> IO.inspect 289 | # # :dbg.tracer |> IO.inspect 290 | # # # :dbg.tpl(:gen_smtp_client, :_, [{:_, [], [{:return_trace}]}]) 291 | # # # :dbg.tpl(:socket, :_, [{:_, [], [{:return_trace}]}]) 292 | # # # :dbg.tpl(:inet, :_, [{:_, [], [{:return_trace}]}]) 293 | # # :dbg.tpl(:inet, :setopts, [{:_, [], [{:return_trace}]}]) 294 | # # :dbg.tpl(:ssl, :transport_accept, [{:_, [], [{:return_trace}]}]) 295 | # # :dbg.tpl(:ssl, :ssl_accept, [{:_, [], [{:return_trace}]}]) 296 | # # :dbg.tpl(:ssl, :setopts, [{:_, [], [{:return_trace}]}]) 297 | # # [:connect, :listen, :send, :recv, :controlling_process, :peername, :close, :shutdown] 298 | # # |> Enum.each(fn(name) -> 299 | # # :dbg.tpl(:gen_tcp, name, [{:_, [], [{:return_trace}]}]) 300 | # # :dbg.tpl(:ssl, name, [{:_, [], [{:return_trace}]}]) 301 | # # end) 302 | # # :dbg.p(:all, :c) |> IO.inspect 303 | 304 | # # :gen_smtp_client.send_blocking({'andrew@debtflow.co.za', ['andrew@andrewtimberlake.com'], 'Date: Fri, 30 Sep 2016 16:20:00 0+200\r\nTo: andrew@andrewtimberlake.com\r\nFrom: andrew@debtflow.co.za\r\nSubject: Test via gen_smtp\r\n\r\nTest email'}, [relay: server, username: 'SMTP_Injection', password: '1e93422c950625e742bb4899e1496b1f15e819e9', port: port, no_mx_lookups: true]) 305 | 306 | # # :dbg.start() |> IO.inspect 307 | # # :dbg.tracer |> IO.inspect 308 | # # :dbg.tpl(:gen_smtp_client, :_, [{:_, [], [{:return_trace}]}]) |> IO.inspect 309 | # # :dbg.tpl(:socket, :_, [{:_, [], [{:return_trace}]}]) |> IO.inspect 310 | # # :dbg.tpl(:ssl, :connect, [{:_, [], [{:return_trace}]}]) |> IO.inspect 311 | # # :dbg.p(:all, :c) |> IO.inspect 312 | 313 | # msg = """ 314 | # Date: Fri, 30 Sep 2016 12:02:00 +0200 315 | # From: andrew@debtflow.co.za 316 | # To: andrew@andrewtimberlake.com 317 | # Subject: Test message 318 | 319 | # This is a test message 320 | # """ 321 | # {:ok, client} = SMTP.connect(server, ssl: ssl, port: 587, debug: true, username: "SMTP_Injection", password: "1e93422c950625e742bb4899e1496b1f15e819e9") 322 | # :ok = SMTP.send_message(client, "andrew@debtflow.co.za", "andrew@andrewtimberlake.com", msg) 323 | # SMTP.quit(client) 324 | # end 325 | end 326 | -------------------------------------------------------------------------------- /lib/mailroom/inbox.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.Inbox do 2 | alias Mailroom.BackwardsCompatibleLogger, as: Logger 3 | 4 | defmodule Match do 5 | defstruct patterns: [], module: nil, function: nil, fetch_mail: false 6 | end 7 | 8 | defmodule State do 9 | defstruct opts: [], client: nil, assigns: %{} 10 | end 11 | 12 | defmodule MessageContext do 13 | defstruct id: nil, 14 | type: :imap, 15 | mail_info: nil, 16 | mail: nil, 17 | message: nil, 18 | assigns: %{} 19 | end 20 | 21 | defp supports_continue? do 22 | case Integer.parse(to_string(:erlang.system_info(:otp_release))) do 23 | {version, _} -> version >= 21 24 | _ -> false 25 | end 26 | end 27 | 28 | defmacro __using__(opts \\ []) do 29 | default_opts = [use_continue: supports_continue?()] 30 | opts = Keyword.merge(default_opts, opts) 31 | 32 | quote location: :keep do 33 | require Mailroom.Inbox 34 | import Mailroom.Inbox 35 | require Logger 36 | use GenServer 37 | import Mailroom.Inbox.MatchUtils 38 | 39 | alias Mailroom.IMAP 40 | 41 | @matches [] 42 | @before_compile unquote(__MODULE__) 43 | 44 | def init(args) do 45 | opts = __MODULE__.config(args) 46 | 47 | state = %State{opts: opts, assigns: Keyword.get(opts, :assigns, %{})} 48 | 49 | if Keyword.get(unquote(opts), :use_continue) do 50 | {:ok, state, {:continue, :after_init}} 51 | else 52 | send(self(), :after_init) 53 | {:ok, state} 54 | end 55 | end 56 | 57 | def config(opts), do: opts 58 | 59 | def start_link(args) do 60 | GenServer.start_link(__MODULE__, args) 61 | end 62 | 63 | def close(pid), do: GenServer.call(pid, :close) 64 | 65 | def handle_call(:close, _from, %{client: client} = state) do 66 | if client do 67 | IMAP.cancel_idle(client) 68 | IMAP.logout(client) 69 | end 70 | 71 | {:reply, :ok, %{state | client: nil}} 72 | end 73 | 74 | def handle_info(:after_init, state) do 75 | # IO.puts("handle_info(:after_init, #{inspect(state)})") 76 | handle_continue(:after_init, state) 77 | end 78 | 79 | def handle_info(:idle_notify, %{client: client} = state) do 80 | if client, do: process_mailbox(client, state) 81 | {:noreply, state} 82 | end 83 | 84 | def handle_info({:ssl_closed, _}, state) do 85 | handle_continue(:after_init, state) 86 | end 87 | 88 | def handle_continue(:after_init, %{opts: opts} = state) do 89 | # IO.puts("handle_continue(:after_init, #{inspect(state)})") 90 | 91 | server = Keyword.get(opts, :server) 92 | 93 | server 94 | |> IMAP.connect( 95 | Keyword.get(opts, :username), 96 | Keyword.get(opts, :password), 97 | opts 98 | ) 99 | |> case do 100 | {:ok, client} -> 101 | folder = Keyword.get(opts, :folder, :inbox) 102 | 103 | Logger.info("Connecting to #{folder} on #{server}") 104 | IMAP.select(client, folder) 105 | process_mailbox(client, state) 106 | 107 | {:noreply, %{state | client: client}} 108 | 109 | {:error, reason} -> 110 | Logger.error("Connection failed: #{inspect(reason)}") 111 | {:stop, reason, state} 112 | end 113 | end 114 | 115 | defp idle(client) do 116 | IMAP.idle(client, self(), :idle_notify) 117 | end 118 | 119 | defoverridable config: 1 120 | end 121 | end 122 | 123 | defmacro match(do: match_block) do 124 | matches = 125 | case match_block do 126 | {:__block__, _, matches} -> matches 127 | {:process, _, _} = process -> [process] 128 | {:ignore, _, _} = ignore -> [ignore] 129 | item -> [item] 130 | end 131 | 132 | {process, matches} = 133 | case Enum.reverse(matches) do 134 | [{:ignore, _, _} = process | matches] -> {process, Enum.reverse(matches)} 135 | [{:process, _, _} = process | matches] -> {process, Enum.reverse(matches)} 136 | _ -> raise("A match block must have a call to process/0 to ignore/0") 137 | end 138 | 139 | {commands, matches} = 140 | Enum.split_with(matches, fn {func_name, _, _} -> func_name in ~w[fetch_mail]a end) 141 | 142 | fetch_mail = 143 | Enum.any?(commands, fn 144 | {:fetch_mail, _, _} -> true 145 | _ -> false 146 | end) 147 | 148 | {module, function} = 149 | case process do 150 | {:process, _, [module, function]} -> {module, function} 151 | {:process, _, [function]} -> {nil, function} 152 | {:ignore, _, _} -> {:ignore, nil} 153 | end 154 | 155 | quote do 156 | @matches [ 157 | %Match{ 158 | patterns: unquote(Macro.escape(matches)), 159 | module: unquote(module), 160 | function: unquote(function), 161 | fetch_mail: unquote(fetch_mail) 162 | } 163 | | @matches 164 | ] 165 | end 166 | end 167 | 168 | defmacro __before_compile__(env) do 169 | matches = 170 | env.module 171 | |> Module.get_attribute(:matches) 172 | |> Enum.reverse() 173 | |> Enum.map(fn match -> 174 | %{patterns: patterns, module: module, function: function, fetch_mail: fetch_mail} = match 175 | 176 | patterns = 177 | Enum.map(patterns, fn {func_name, context, arguments} -> 178 | {:&, [], [{:"match_#{func_name}", context, [{:&, [], [1]} | List.wrap(arguments)]}]} 179 | end) 180 | 181 | {:{}, [], [patterns, module, function, fetch_mail]} 182 | end) 183 | 184 | fetch_items_required = [ 185 | :envelope 186 | | env.module 187 | |> Module.get_attribute(:matches) 188 | |> Enum.flat_map(fn %{patterns: patterns} -> patterns end) 189 | |> Enum.flat_map(fn 190 | {:has_attachment?, _, _} -> [:body_structure] 191 | {:header, _, _} -> [:header] 192 | _ -> [] 193 | end) 194 | |> Enum.uniq() 195 | ] 196 | 197 | quote location: :keep do 198 | defp process_mailbox(client, %{assigns: assigns, opts: opts}) do 199 | emails = Mailroom.IMAP.email_count(client) 200 | 201 | if emails > 0 do 202 | Logger.debug("Processing #{emails} emails") 203 | 204 | Mailroom.IMAP.search(client, "UNSEEN", unquote(fetch_items_required), fn {msg_id, 205 | response} -> 206 | case generate_mail_info(response, opts) do 207 | :error -> 208 | Logger.error(fn -> 209 | "Unable to process envelope #{inspect(response)}" 210 | end) 211 | 212 | Mailroom.IMAP.add_flags(client, msg_id, [:seen]) 213 | 214 | mail_info -> 215 | try do 216 | case perform_match(client, msg_id, mail_info, assigns, opts) do 217 | :delete -> 218 | Mailroom.IMAP.add_flags(client, msg_id, [:deleted]) 219 | 220 | :ignore -> 221 | Mailroom.IMAP.add_flags(client, msg_id, [:deleted]) 222 | 223 | :seen -> 224 | Mailroom.IMAP.add_flags(client, msg_id, [:seen]) 225 | 226 | other -> 227 | Logger.warning("Unexpected process response #{inspect(other)}") 228 | Mailroom.IMAP.add_flags(client, msg_id, [:seen]) 229 | end 230 | catch 231 | kind, reason -> 232 | Logger.error(fn -> 233 | "Error processing #{inspect(mail_info)} -> #{inspect(kind)}, #{inspect(reason)}, #{Exception.format_stacktrace()}" 234 | end) 235 | 236 | Mailroom.IMAP.add_flags(client, msg_id, [:seen]) 237 | # :erlang.raise(kind, reason, stack) 238 | end 239 | end 240 | end) 241 | 242 | Mailroom.IMAP.expunge(client) 243 | end 244 | 245 | Logger.debug("Entering IDLE") 246 | idle(client) 247 | end 248 | 249 | def do_match(mail_info) do 250 | match = 251 | unquote(matches) 252 | |> Enum.find(fn {patterns, _, _, _} -> 253 | Enum.all?(patterns, & &1.(mail_info)) 254 | end) 255 | 256 | case match do 257 | nil -> 258 | :no_match 259 | 260 | {_, module, function, fetch_mail} -> 261 | {module, function, fetch_mail} 262 | end 263 | end 264 | 265 | def perform_match(client, msg_id, mail_info, assigns \\ %{}, opts \\ []) do 266 | {result, mod_fun} = 267 | case do_match(mail_info) do 268 | :no_match -> 269 | {:no_match, nil} 270 | 271 | {:ignore, _, _} -> 272 | {:ignore, nil} 273 | 274 | {module, function, fetch_mail} -> 275 | {mail, message} = 276 | if fetch_mail, do: fetch_mail(client, msg_id, opts), else: {nil, nil} 277 | 278 | context = %MessageContext{ 279 | id: msg_id, 280 | mail_info: mail_info, 281 | mail: mail, 282 | message: message, 283 | assigns: assigns 284 | } 285 | 286 | # Logger.debug(" match: #{module || __MODULE__}##{function}") 287 | mod = module || __MODULE__ 288 | 289 | {apply(mod, function, [context]), {mod, function}} 290 | end 291 | 292 | Logger.info(fn -> 293 | %{to: to, from: from, subject: subject} = mail_info 294 | 295 | "Processing msg:#{msg_id} TO:#{log_email(to)} FROM:#{log_email(from)} SUBJECT:#{inspect(subject)}#{log_mod_fun(mod_fun)} -> #{inspect(result)}" 296 | end) 297 | 298 | result 299 | end 300 | 301 | defp fetch_mail(client, msg_id, opts) do 302 | {:ok, [{^msg_id, %{"BODY[]" => mail}}]} = 303 | Mailroom.IMAP.fetch(client, msg_id, "BODY.PEEK[]") 304 | 305 | {mail, Mail.Parsers.RFC2822.parse(mail, Keyword.get(opts, :parser_opts, []))} 306 | end 307 | 308 | defp log_email([]), do: "Unknown" 309 | defp log_email([email | _]), do: email 310 | 311 | defp log_mod_fun(nil), do: "" 312 | defp log_mod_fun({mod, fun}), do: " using #{inspect(mod)}##{fun}" 313 | end 314 | 315 | # |> print_macro 316 | end 317 | 318 | # defp print_macro(quoted) do 319 | # quoted |> Macro.to_string() |> IO.puts() 320 | # quoted 321 | # end 322 | end 323 | -------------------------------------------------------------------------------- /test/mailroom/imap/body_structure_tests.exs: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.IMAP.BodyStructureTests do 2 | use ExUnit.Case, async: true 3 | 4 | alias Mailroom.IMAP.BodyStructure 5 | alias Mailroom.IMAP.BodyStructure.Part 6 | 7 | test "has_attachment?/1" do 8 | body_structure = %Part{ 9 | description: nil, 10 | disposition: nil, 11 | encoded_size: nil, 12 | encoding: nil, 13 | file_name: nil, 14 | id: nil, 15 | multipart: true, 16 | params: %{}, 17 | parts: [ 18 | %Part{ 19 | description: nil, 20 | disposition: nil, 21 | encoded_size: 438, 22 | encoding: "7bit", 23 | file_name: nil, 24 | id: nil, 25 | multipart: false, 26 | params: %{"charset" => "utf-8"}, 27 | parts: [], 28 | section: "1", 29 | type: "text/plain" 30 | }, 31 | %Part{ 32 | description: nil, 33 | disposition: "attachment", 34 | encoded_size: 81800, 35 | encoding: "base64", 36 | file_name: "Image.pdf", 37 | id: nil, 38 | multipart: false, 39 | params: %{"name" => "Image.pdf"}, 40 | parts: [], 41 | section: "2", 42 | type: "application/octet-stream" 43 | } 44 | ], 45 | section: nil, 46 | type: "mixed" 47 | } 48 | 49 | assert BodyStructure.has_attachment?(body_structure) 50 | 51 | body_structure = %Part{ 52 | description: nil, 53 | disposition: nil, 54 | encoded_size: 1315, 55 | encoding: "quoted-printable", 56 | file_name: nil, 57 | id: nil, 58 | multipart: false, 59 | params: %{"charset" => "iso-8859-1"}, 60 | parts: [], 61 | section: nil, 62 | type: "text/plain" 63 | } 64 | 65 | refute BodyStructure.has_attachment?(body_structure) 66 | 67 | body_structure = %Part{ 68 | description: nil, 69 | disposition: nil, 70 | encoded_size: nil, 71 | encoding: nil, 72 | file_name: nil, 73 | id: nil, 74 | multipart: true, 75 | params: %{}, 76 | parts: [ 77 | %Part{ 78 | description: nil, 79 | disposition: nil, 80 | encoded_size: 2234, 81 | encoding: "quoted-printable", 82 | file_name: nil, 83 | id: nil, 84 | multipart: false, 85 | params: %{"charset" => "iso-8859-1"}, 86 | parts: [], 87 | section: "1", 88 | type: "text/plain" 89 | }, 90 | %Part{ 91 | description: nil, 92 | disposition: nil, 93 | encoded_size: 2987, 94 | encoding: "quoted-printable", 95 | file_name: nil, 96 | id: nil, 97 | multipart: false, 98 | params: %{"charset" => "iso-8859-1"}, 99 | parts: [], 100 | section: "2", 101 | type: "text/html" 102 | } 103 | ], 104 | section: nil, 105 | type: "alternative" 106 | } 107 | 108 | refute BodyStructure.has_attachment?(body_structure) 109 | 110 | body_structure = %Part{ 111 | description: nil, 112 | disposition: nil, 113 | encoded_size: nil, 114 | encoding: nil, 115 | file_name: nil, 116 | id: nil, 117 | multipart: true, 118 | params: %{}, 119 | parts: [ 120 | %Part{ 121 | description: nil, 122 | disposition: nil, 123 | encoded_size: 119, 124 | encoding: "7bit", 125 | file_name: nil, 126 | id: nil, 127 | multipart: false, 128 | params: %{"charset" => "US-ASCII"}, 129 | parts: [], 130 | section: "1", 131 | type: "text/html" 132 | }, 133 | %Part{ 134 | description: nil, 135 | disposition: "inline", 136 | encoded_size: 143_804, 137 | encoding: "base64", 138 | file_name: "4356415.jpg", 139 | id: "<0__=rhksjt>", 140 | multipart: false, 141 | params: %{"name" => "4356415.jpg"}, 142 | parts: [], 143 | section: "2", 144 | type: "image/jpeg" 145 | } 146 | ], 147 | section: nil, 148 | type: "related" 149 | } 150 | 151 | refute BodyStructure.has_attachment?(body_structure) 152 | 153 | body_structure = %Part{ 154 | description: nil, 155 | disposition: nil, 156 | encoded_size: nil, 157 | encoding: nil, 158 | file_name: nil, 159 | id: nil, 160 | multipart: true, 161 | params: %{}, 162 | parts: [ 163 | %Part{ 164 | description: nil, 165 | disposition: nil, 166 | encoded_size: 2815, 167 | encoding: "quoted-printable", 168 | file_name: nil, 169 | id: nil, 170 | multipart: false, 171 | params: %{"charset" => "ISO-8859-1", "format" => "flowed"}, 172 | parts: [], 173 | section: "1", 174 | type: "text/plain" 175 | }, 176 | %Part{ 177 | description: nil, 178 | disposition: nil, 179 | encoded_size: nil, 180 | encoding: nil, 181 | file_name: nil, 182 | id: nil, 183 | multipart: true, 184 | params: %{}, 185 | parts: [ 186 | %Part{ 187 | description: nil, 188 | disposition: nil, 189 | encoded_size: 4171, 190 | encoding: "quoted-printable", 191 | file_name: nil, 192 | id: nil, 193 | multipart: false, 194 | params: %{"charset" => "ISO-8859-1"}, 195 | parts: [], 196 | section: "2.1", 197 | type: "text/html" 198 | }, 199 | %Part{ 200 | description: nil, 201 | disposition: nil, 202 | encoded_size: 189_906, 203 | encoding: "base64", 204 | file_name: nil, 205 | id: "<3245dsf7435>", 206 | multipart: false, 207 | params: %{"name" => "image.jpg"}, 208 | parts: [], 209 | section: "2.2", 210 | type: "image/jpeg" 211 | }, 212 | %Part{ 213 | description: nil, 214 | disposition: nil, 215 | encoded_size: 1090, 216 | encoding: "base64", 217 | file_name: nil, 218 | id: "<32f6324f>", 219 | multipart: false, 220 | params: %{"name" => "other.gif"}, 221 | parts: [], 222 | section: "2.3", 223 | type: "image/gif" 224 | } 225 | ], 226 | section: "2", 227 | type: "related" 228 | } 229 | ], 230 | section: nil, 231 | type: "alternative" 232 | } 233 | 234 | refute BodyStructure.has_attachment?(body_structure) 235 | 236 | body_structure = %Part{ 237 | description: nil, 238 | disposition: nil, 239 | encoded_size: nil, 240 | encoding: nil, 241 | file_name: nil, 242 | id: nil, 243 | multipart: true, 244 | params: %{}, 245 | parts: [ 246 | %Part{ 247 | description: nil, 248 | disposition: nil, 249 | encoded_size: 4692, 250 | encoding: "quoted-printable", 251 | file_name: nil, 252 | id: nil, 253 | multipart: false, 254 | params: %{"charset" => "ISO-8859-1"}, 255 | parts: [], 256 | section: "1", 257 | type: "text/html" 258 | }, 259 | %Part{ 260 | description: nil, 261 | disposition: "attachment", 262 | encoded_size: 38838, 263 | encoding: "base64", 264 | file_name: "pages.pdf", 265 | id: nil, 266 | multipart: false, 267 | params: %{"name" => "pages.pdf"}, 268 | parts: [], 269 | section: "2", 270 | type: "application/pdf" 271 | } 272 | ], 273 | section: nil, 274 | type: "mixed" 275 | } 276 | 277 | assert BodyStructure.has_attachment?(body_structure) 278 | 279 | body_structure = %Part{ 280 | description: nil, 281 | disposition: nil, 282 | encoded_size: nil, 283 | encoding: nil, 284 | file_name: nil, 285 | id: nil, 286 | multipart: true, 287 | params: %{}, 288 | parts: [ 289 | %Part{ 290 | description: nil, 291 | disposition: nil, 292 | encoded_size: nil, 293 | encoding: nil, 294 | file_name: nil, 295 | id: nil, 296 | multipart: true, 297 | params: %{}, 298 | parts: [ 299 | %Part{ 300 | description: nil, 301 | disposition: nil, 302 | encoded_size: 403, 303 | encoding: "quoted-printable", 304 | file_name: nil, 305 | id: nil, 306 | multipart: false, 307 | params: %{"charset" => "UTF-8"}, 308 | parts: [], 309 | section: "1.1", 310 | type: "text/plain" 311 | }, 312 | %Part{ 313 | description: nil, 314 | disposition: nil, 315 | encoded_size: 421, 316 | encoding: "quoted-printable", 317 | file_name: nil, 318 | id: nil, 319 | multipart: false, 320 | params: %{"charset" => "UTF-8"}, 321 | parts: [], 322 | section: "1.2", 323 | type: "text/html" 324 | } 325 | ], 326 | section: "1", 327 | type: "alternative" 328 | }, 329 | %Part{ 330 | description: nil, 331 | disposition: "attachment", 332 | encoded_size: 110_000, 333 | encoding: "base64", 334 | file_name: "letter.doc", 335 | id: nil, 336 | multipart: false, 337 | params: %{"name" => "letter.doc"}, 338 | parts: [], 339 | section: "2", 340 | type: "application/msword" 341 | } 342 | ], 343 | section: nil, 344 | type: "mixed" 345 | } 346 | 347 | assert BodyStructure.has_attachment?(body_structure) 348 | end 349 | 350 | test "get_attachments/1" do 351 | body_structure = %Part{ 352 | description: nil, 353 | disposition: nil, 354 | encoded_size: nil, 355 | encoding: nil, 356 | file_name: nil, 357 | id: nil, 358 | multipart: true, 359 | params: %{}, 360 | parts: [ 361 | %Part{ 362 | description: nil, 363 | disposition: nil, 364 | encoded_size: 438, 365 | encoding: "7bit", 366 | file_name: nil, 367 | id: nil, 368 | multipart: false, 369 | params: %{"charset" => "utf-8"}, 370 | parts: [], 371 | section: "1", 372 | type: "text/plain" 373 | }, 374 | %Part{ 375 | description: nil, 376 | disposition: "attachment", 377 | encoded_size: 81800, 378 | encoding: "base64", 379 | file_name: "Image.pdf", 380 | id: nil, 381 | multipart: false, 382 | params: %{"name" => "Image.pdf"}, 383 | parts: [], 384 | section: "2", 385 | type: "application/octet-stream" 386 | } 387 | ], 388 | section: nil, 389 | type: "mixed" 390 | } 391 | 392 | assert [ 393 | %Part{ 394 | disposition: "attachment", 395 | file_name: "Image.pdf", 396 | section: "2", 397 | type: "application/octet-stream" 398 | } 399 | ] = BodyStructure.get_attachments(body_structure) 400 | 401 | body_structure = %Part{ 402 | description: nil, 403 | disposition: nil, 404 | encoded_size: nil, 405 | encoding: nil, 406 | file_name: nil, 407 | id: nil, 408 | multipart: true, 409 | params: %{}, 410 | parts: [ 411 | %Part{ 412 | description: nil, 413 | disposition: nil, 414 | encoded_size: 119, 415 | encoding: "7bit", 416 | file_name: nil, 417 | id: nil, 418 | multipart: false, 419 | params: %{"charset" => "US-ASCII"}, 420 | parts: [], 421 | section: "1", 422 | type: "text/html" 423 | }, 424 | %Part{ 425 | description: nil, 426 | disposition: "inline", 427 | encoded_size: 143_804, 428 | encoding: "base64", 429 | file_name: "4356415.jpg", 430 | id: "<0__=rhksjt>", 431 | multipart: false, 432 | params: %{"name" => "4356415.jpg"}, 433 | parts: [], 434 | section: "2", 435 | type: "image/jpeg" 436 | } 437 | ], 438 | section: nil, 439 | type: "related" 440 | } 441 | 442 | assert [] = BodyStructure.get_attachments(body_structure) 443 | 444 | body_structure = %Part{ 445 | description: nil, 446 | disposition: nil, 447 | encoded_size: nil, 448 | encoding: nil, 449 | file_name: nil, 450 | id: nil, 451 | multipart: true, 452 | params: %{}, 453 | parts: [ 454 | %Part{ 455 | description: nil, 456 | disposition: nil, 457 | encoded_size: nil, 458 | encoding: nil, 459 | file_name: nil, 460 | id: nil, 461 | multipart: true, 462 | params: %{}, 463 | parts: [ 464 | %Part{ 465 | description: nil, 466 | disposition: nil, 467 | encoded_size: 9, 468 | encoding: "7bit", 469 | file_name: nil, 470 | id: nil, 471 | multipart: false, 472 | params: %{"charset" => "utf-8"}, 473 | parts: [], 474 | section: "1.1", 475 | type: "text/plain" 476 | }, 477 | %Part{ 478 | description: nil, 479 | disposition: nil, 480 | encoded_size: 195, 481 | encoding: "quoted-printable", 482 | file_name: nil, 483 | id: nil, 484 | multipart: false, 485 | params: %{"charset" => "utf-8"}, 486 | parts: [], 487 | section: "1.2", 488 | type: "text/html" 489 | } 490 | ], 491 | section: "1", 492 | type: "alternative" 493 | }, 494 | %Part{ 495 | description: nil, 496 | disposition: "attachment", 497 | encoded_size: 2, 498 | encoding: "base64", 499 | file_name: "test.csv", 500 | id: nil, 501 | multipart: false, 502 | params: %{}, 503 | parts: [], 504 | section: "2", 505 | type: "application/octet-stream" 506 | }, 507 | %Part{ 508 | description: nil, 509 | disposition: "attachment", 510 | encoded_size: 14_348_938, 511 | encoding: "base64", 512 | file_name: "test.doc", 513 | id: nil, 514 | multipart: false, 515 | params: %{}, 516 | parts: [], 517 | section: "3", 518 | type: "application/octet-stream" 519 | }, 520 | %Part{ 521 | description: nil, 522 | disposition: "attachment", 523 | encoded_size: 240, 524 | encoding: "base64", 525 | file_name: "test.mid", 526 | id: nil, 527 | multipart: false, 528 | params: %{}, 529 | parts: [], 530 | section: "4", 531 | type: "application/octet-stream" 532 | }, 533 | %Part{ 534 | description: nil, 535 | disposition: "attachment", 536 | encoded_size: 10, 537 | encoding: "base64", 538 | file_name: "test.txt", 539 | id: nil, 540 | multipart: false, 541 | params: %{}, 542 | parts: [], 543 | section: "5", 544 | type: "text/plain" 545 | }, 546 | %Part{ 547 | description: nil, 548 | disposition: "attachment", 549 | encoded_size: 974, 550 | encoding: "base64", 551 | file_name: "test.wav", 552 | id: nil, 553 | multipart: false, 554 | params: %{}, 555 | parts: [], 556 | section: "6", 557 | type: "application/octet-stream" 558 | } 559 | ], 560 | section: nil, 561 | type: "mixed" 562 | } 563 | 564 | assert [ 565 | %Part{ 566 | disposition: "attachment", 567 | file_name: "test.csv", 568 | section: "2", 569 | type: "application/octet-stream" 570 | }, 571 | %Part{ 572 | disposition: "attachment", 573 | file_name: "test.doc", 574 | section: "3", 575 | type: "application/octet-stream" 576 | }, 577 | %Part{ 578 | disposition: "attachment", 579 | file_name: "test.mid", 580 | section: "4", 581 | type: "application/octet-stream" 582 | }, 583 | %Part{ 584 | disposition: "attachment", 585 | file_name: "test.txt", 586 | section: "5", 587 | type: "text/plain" 588 | }, 589 | %Part{ 590 | disposition: "attachment", 591 | file_name: "test.wav", 592 | section: "6", 593 | type: "application/octet-stream" 594 | } 595 | ] = BodyStructure.get_attachments(body_structure) 596 | end 597 | end 598 | -------------------------------------------------------------------------------- /test/mailroom/inbox_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.InboxTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Mailroom.TestServer 5 | 6 | @debug false 7 | 8 | defmodule TestMailProcessor do 9 | def match_subject_regex(%{id: msg_id, assigns: %{test_pid: pid}}) do 10 | send(pid, {:matched_subject_regex, msg_id}) 11 | :delete 12 | end 13 | 14 | def match_subject_string(%{id: msg_id, assigns: %{test_pid: pid}}) do 15 | send(pid, {:matched_subject_string, msg_id}) 16 | :delete 17 | end 18 | 19 | def match_has_attachment(%{id: msg_id, assigns: %{test_pid: pid}}) do 20 | send(pid, {:matched_has_attachment, msg_id}) 21 | :delete 22 | end 23 | 24 | def match_and_fetch(%{id: msg_id, mail: mail, message: message, assigns: %{test_pid: pid}}) do 25 | send(pid, {:match_and_fetch, msg_id, mail, message}) 26 | :delete 27 | end 28 | 29 | def match_header(%{id: msg_id, mail: nil, message: nil, assigns: %{test_pid: pid}}) do 30 | send(pid, {:match_header, msg_id}) 31 | :delete 32 | end 33 | 34 | def match_all(%{id: msg_id, assigns: %{test_pid: pid}}) do 35 | send(pid, {:match_all, msg_id}) 36 | :delete 37 | end 38 | end 39 | 40 | defmodule TestMailRouter do 41 | use Mailroom.Inbox 42 | 43 | def config(opts) do 44 | Keyword.merge(opts, username: "test@example.com", password: "P@55w0rD") 45 | end 46 | 47 | match do 48 | recipient(~r/(john|jane)@example.com/) 49 | 50 | process(:match_to) 51 | end 52 | 53 | match do 54 | to("ignore@example.com") 55 | 56 | ignore 57 | end 58 | 59 | match do 60 | subject(~r/test \d+/i) 61 | 62 | process(TestMailProcessor, :match_subject_regex) 63 | end 64 | 65 | match do 66 | subject("Testing 3") 67 | 68 | process(TestMailProcessor, :match_subject_string) 69 | end 70 | 71 | match do 72 | subject("To be fetched") 73 | 74 | fetch_mail 75 | process(TestMailProcessor, :match_and_fetch) 76 | end 77 | 78 | match do 79 | has_attachment? 80 | 81 | process(TestMailProcessor, :match_has_attachment) 82 | end 83 | 84 | match do 85 | all 86 | 87 | process(TestMailProcessor, :match_all) 88 | end 89 | 90 | def match_to(%{id: msg_id, assigns: %{test_pid: pid}}) do 91 | send(pid, {:matched_to, msg_id}) 92 | :delete 93 | end 94 | end 95 | 96 | defmodule TestMailHeaderRouter do 97 | use Mailroom.Inbox 98 | 99 | def config(opts) do 100 | Keyword.merge(opts, username: "test@example.com", password: "P@55w0rD") 101 | end 102 | 103 | match do 104 | header("In-Reply-To", ~r/message-id/) 105 | 106 | process(TestMailProcessor, :match_header) 107 | end 108 | 109 | match do 110 | all 111 | 112 | process(TestMailProcessor, :match_all) 113 | end 114 | 115 | def match_to(%{id: msg_id, assigns: %{test_pid: pid}}) do 116 | send(pid, {:matched_to, msg_id}) 117 | :delete 118 | end 119 | end 120 | 121 | test "Can match on any TO" do 122 | server = TestServer.start(ssl: true) 123 | 124 | TestServer.expect(server, fn expectations -> 125 | expectations 126 | |> TestServer.tagged(:connect, "* OK IMAP ready\r\n") 127 | |> TestServer.tagged("LOGIN \"test@example.com\" \"P@55w0rD\"\r\n", [ 128 | "* CAPABILITY (IMAPrev4)\r\n", 129 | "OK test@example.com authenticated (Success)\r\n" 130 | ]) 131 | |> TestServer.tagged("SELECT INBOX\r\n", [ 132 | "* FLAGS (\\Flagged \\Draft \\Deleted \\Seen)\r\n", 133 | "* OK [PERMANENTFLAGS (\\Flagged \\Draft \\Deleted \\Seen \\*)] Flags permitted\r\n", 134 | "* 0 EXISTS\r\n", 135 | "* 0 RECENT\r\n", 136 | "OK [READ-WRITE] INBOX selected. (Success)\r\n" 137 | ]) 138 | |> TestServer.tagged("IDLE\r\n", [ 139 | "+ idling\r\n", 140 | "* 3 EXISTS\r\n" 141 | ]) 142 | |> TestServer.tagged("DONE\r\n", [ 143 | "OK IDLE terminated\r\n" 144 | ]) 145 | |> TestServer.tagged("SEARCH UNSEEN\r\n", [ 146 | "* SEARCH 1 2 3\r\n", 147 | "OK Success\r\n" 148 | ]) 149 | |> TestServer.tagged("FETCH 1:3 (ENVELOPE BODYSTRUCTURE)\r\n", [ 150 | "* 1 FETCH (ENVELOPE (\"Wed, 26 Oct 2016 14:23:14 +0200\" \"The subject\" ((\"Bob Jones\" NIL \"bob\" \"example.com\")) ((\"Bob Jones\" NIL \"bob\" \"example.com\")) ((\"Bob Jones\" NIL \"bob\" \"example.com\")) ((\"John Doe\" NIL \"john\" \"example.com\")) NIL NIL NIL \"\") BODYSTRUCTURE (\"TEXT\" \"PLAIN\" (\"CHARSET\" \"iso-8859-1\") NIL NIL \"QUOTED-PRINTABLE\" 1315 42 NIL NIL NIL NIL))\r\n", 151 | "* 2 FETCH (ENVELOPE (\"Wed, 26 Oct 2016 14:23:14 +0200\" \"The subject\" ((\"Bob Jones\" NIL \"bob\" \"example.com\")) ((\"Bob Jones\" NIL \"bob\" \"example.com\")) ((\"Bob Jones\" NIL \"bob\" \"example.com\")) ((\"Jane Doe\" NIL \"JANE\" \"EXAMPLE.COM\")) NIL NIL NIL \"\") BODYSTRUCTURE (\"TEXT\" \"PLAIN\" (\"CHARSET\" \"iso-8859-1\") NIL NIL \"QUOTED-PRINTABLE\" 1315 42 NIL NIL NIL NIL))\r\n", 152 | "* 3 FETCH (ENVELOPE (\"Wed, 26 Oct 2016 14:24:15 +0200\" \"The subject\" ((\"Jane Doe\" NIL \"jane\" \"example.com\")) ((\"Jane Doe\" NIL \"jane\" \"example.com\")) ((\"Jane Doe\" NIL \"jane\" \"example.com\")) ((NIL NIL \"george\" \"example.com\")) NIL NIL \"652E7B61-60F6-421C-B954-4178BB769B27.example.com\" \"<28D03E0E-47EE-4AEF-BDE6-54ADB0EF28FD.example.com>\") BODYSTRUCTURE (\"TEXT\" \"PLAIN\" (\"CHARSET\" \"iso-8859-1\") NIL NIL \"QUOTED-PRINTABLE\" 1315 42 NIL NIL NIL NIL))\r\n", 153 | "OK Success\r\n" 154 | ]) 155 | |> TestServer.tagged("STORE 1 +FLAGS (\\Deleted)\r\n", [ 156 | "* 1 FETCH (FLAGS (\\Deleted))\r\n", 157 | "OK Store completed\r\n" 158 | ]) 159 | |> TestServer.tagged("STORE 2 +FLAGS (\\Deleted)\r\n", [ 160 | "* 2 FETCH (FLAGS (\\Deleted))\r\n", 161 | "OK Store completed\r\n" 162 | ]) 163 | |> TestServer.tagged("STORE 3 +FLAGS (\\Deleted)\r\n", [ 164 | "* 3 FETCH (FLAGS (\\Deleted))\r\n", 165 | "OK Store completed\r\n" 166 | ]) 167 | |> TestServer.tagged("EXPUNGE\r\n", [ 168 | "* 1 EXPUNGE\r\n", 169 | "* 2 EXPUNGE\r\n", 170 | "* 3 EXPUNGE\r\n", 171 | "OK Expunge completed\r\n" 172 | ]) 173 | |> TestServer.tagged("IDLE\r\n", [ 174 | "+ idling\r\n" 175 | ]) 176 | |> TestServer.tagged("DONE\r\n", [ 177 | "OK IDLE terminated\r\n" 178 | ]) 179 | |> TestServer.tagged("LOGOUT\r\n", [ 180 | "* BYE We're out of here\r\n", 181 | "OK Logged out\r\n" 182 | ]) 183 | end) 184 | 185 | ExUnit.CaptureLog.capture_log(fn -> 186 | {:ok, pid} = 187 | TestMailRouter.start_link( 188 | server: server.address, 189 | port: server.port, 190 | ssl: true, 191 | ssl_opts: [verify: :verify_none], 192 | assigns: %{test_pid: self()}, 193 | debug: @debug 194 | ) 195 | 196 | assert_receive({:matched_to, 1}) 197 | assert_receive({:matched_to, 2}) 198 | refute_receive({:matched_to, _}) 199 | TestMailRouter.close(pid) 200 | end) 201 | end 202 | 203 | test "Can match on a subject" do 204 | server = TestServer.start(ssl: true) 205 | 206 | TestServer.expect(server, fn expectations -> 207 | expectations 208 | |> TestServer.tagged(:connect, "* OK IMAP ready\r\n") 209 | |> TestServer.tagged("LOGIN \"test@example.com\" \"P@55w0rD\"\r\n", [ 210 | "* CAPABILITY (IMAPrev4)\r\n", 211 | "OK test@example.com authenticated (Success)\r\n" 212 | ]) 213 | |> TestServer.tagged("SELECT INBOX\r\n", [ 214 | "* FLAGS (\\Flagged \\Draft \\Deleted \\Seen)\r\n", 215 | "* OK [PERMANENTFLAGS (\\Flagged \\Draft \\Deleted \\Seen \\*)] Flags permitted\r\n", 216 | "* 0 EXISTS\r\n", 217 | "* 0 RECENT\r\n", 218 | "OK [READ-WRITE] INBOX selected. (Success)\r\n" 219 | ]) 220 | |> TestServer.tagged("IDLE\r\n", [ 221 | "+ idling\r\n", 222 | "* 3 EXISTS\r\n" 223 | ]) 224 | |> TestServer.tagged("DONE\r\n", [ 225 | "OK IDLE terminated\r\n" 226 | ]) 227 | |> TestServer.tagged("SEARCH UNSEEN\r\n", [ 228 | "* SEARCH 1 2 3\r\n", 229 | "OK Success\r\n" 230 | ]) 231 | |> TestServer.tagged("FETCH 1:3 (ENVELOPE BODYSTRUCTURE)\r\n", [ 232 | "* 1 FETCH (ENVELOPE (\"Wed, 26 Oct 2016 14:23:14 +0200\" \"First one\" ((\"Bob Jones\" NIL \"bob\" \"example.com\")) ((\"Bob Jones\" NIL \"bob\" \"example.com\")) ((\"Bob Jones\" NIL \"bob\" \"example.com\")) ((\"John Doe\" NIL \"bruce\" \"example.com\")) NIL NIL NIL \"\") BODYSTRUCTURE (\"TEXT\" \"PLAIN\" (\"CHARSET\" \"iso-8859-1\") NIL NIL \"QUOTED-PRINTABLE\" 1315 42 NIL NIL NIL NIL))\r\n", 233 | "* 2 FETCH (ENVELOPE (\"Wed, 26 Oct 2016 14:24:15 +0200\" \"Test 2\" ((\"Jane Doe\" NIL \"jane\" \"example.com\")) ((\"Jane Doe\" NIL \"jane\" \"example.com\")) ((\"Jane Doe\" NIL \"jane\" \"example.com\")) ((NIL NIL \"george\" \"example.com\")) NIL NIL \"652E7B61-60F6-421C-B954-4178BB769B27.example.com\" \"<28D03E0E-47EE-4AEF-BDE6-54ADB0EF28FD.example.com>\") BODYSTRUCTURE (\"TEXT\" \"PLAIN\" (\"CHARSET\" \"iso-8859-1\") NIL NIL \"QUOTED-PRINTABLE\" 1315 42 NIL NIL NIL NIL))\r\n", 234 | "* 3 FETCH (ENVELOPE (\"Wed, 26 Oct 2016 14:24:15 +0200\" \"Testing 3\" ((\"Jane Doe\" NIL \"jane\" \"example.com\")) ((\"Jane Doe\" NIL \"jane\" \"example.com\")) ((\"Jane Doe\" NIL \"jane\" \"example.com\")) ((NIL NIL \"george\" \"example.com\")) NIL NIL \"652E7B61-60F6-421C-B954-4178BB769B27.example.com\" \"<28D03E0E-47EE-4AEF-BDE6-54ADB0EF28FD.example.com>\") BODYSTRUCTURE (\"TEXT\" \"PLAIN\" (\"CHARSET\" \"iso-8859-1\") NIL NIL \"QUOTED-PRINTABLE\" 1315 42 NIL NIL NIL NIL))\r\n", 235 | "OK Success\r\n" 236 | ]) 237 | |> TestServer.tagged("STORE 1 +FLAGS (\\Deleted)\r\n", [ 238 | "* 1 FETCH (FLAGS (\\Deleted))\r\n", 239 | "OK Store completed\r\n" 240 | ]) 241 | |> TestServer.tagged("STORE 2 +FLAGS (\\Deleted)\r\n", [ 242 | "* 2 FETCH (FLAGS (\\Deleted))\r\n", 243 | "OK Store completed\r\n" 244 | ]) 245 | |> TestServer.tagged("STORE 3 +FLAGS (\\Deleted)\r\n", [ 246 | "* 3 FETCH (FLAGS (\\Deleted))\r\n", 247 | "OK Store completed\r\n" 248 | ]) 249 | |> TestServer.tagged("EXPUNGE\r\n", [ 250 | "* 2 EXPUNGE\r\n", 251 | "* 3 EXPUNGE\r\n", 252 | "OK Expunge completed\r\n" 253 | ]) 254 | |> TestServer.tagged("IDLE\r\n", [ 255 | "+ idling\r\n" 256 | ]) 257 | |> TestServer.tagged("DONE\r\n", [ 258 | "OK IDLE terminated\r\n" 259 | ]) 260 | |> TestServer.tagged("LOGOUT\r\n", [ 261 | "* BYE We're out of here\r\n", 262 | "OK Logged out\r\n" 263 | ]) 264 | end) 265 | 266 | ExUnit.CaptureLog.capture_log(fn -> 267 | {:ok, pid} = 268 | TestMailRouter.start_link( 269 | server: server.address, 270 | port: server.port, 271 | ssl: true, 272 | ssl_opts: [verify: :verify_none], 273 | assigns: %{test_pid: self()}, 274 | debug: @debug 275 | ) 276 | 277 | assert_receive({:matched_subject_regex, 2}) 278 | assert_receive({:matched_subject_string, 3}) 279 | assert_receive({:match_all, 1}) 280 | refute_receive({:matched_to, _}) 281 | TestMailRouter.close(pid) 282 | end) 283 | end 284 | 285 | test "Can match on an having an attachment" do 286 | server = TestServer.start(ssl: true) 287 | 288 | TestServer.expect(server, fn expectations -> 289 | expectations 290 | |> TestServer.tagged(:connect, "* OK IMAP ready\r\n") 291 | |> TestServer.tagged("LOGIN \"test@example.com\" \"P@55w0rD\"\r\n", [ 292 | "* CAPABILITY (IMAPrev4)\r\n", 293 | "OK test@example.com authenticated (Success)\r\n" 294 | ]) 295 | |> TestServer.tagged("SELECT INBOX\r\n", [ 296 | "* FLAGS (\\Flagged \\Draft \\Deleted \\Seen)\r\n", 297 | "* OK [PERMANENTFLAGS (\\Flagged \\Draft \\Deleted \\Seen \\*)] Flags permitted\r\n", 298 | "* 3 EXISTS\r\n", 299 | "* 0 RECENT\r\n", 300 | "OK [READ-WRITE] INBOX selected. (Success)\r\n" 301 | ]) 302 | |> TestServer.tagged("SEARCH UNSEEN\r\n", [ 303 | "* SEARCH 1 2 3\r\n", 304 | "OK Success\r\n" 305 | ]) 306 | |> TestServer.tagged("FETCH 1:3 (ENVELOPE BODYSTRUCTURE)\r\n", [ 307 | ~s[* 1 FETCH (ENVELOPE ("Mon, 10 Jun 2019 11:57:36 +0200" "Attached." (("Andrew Timberlake" NIL "andrew" "internuity.net")) (("Andrew Timberlake" NIL "andrew" "internuity.net")) (("Andrew Timberlake" NIL "andrew" "internuity.net")) ((NIL NIL "george" "example.com")) NIL NIL "" "<4cff0831-67f5-4457-b60a-3331ba893348@Spark>") BODYSTRUCTURE ((("TEXT" "PLAIN" ("CHARSET" "utf-8") NIL NIL "7BIT" 8 1 NIL ("INLINE" NIL) NIL)(("TEXT" "HTML" ("CHARSET" "utf-8") NIL NIL "QUOTED-PRINTABLE" 293 6 NIL ("INLINE" NIL) NIL)("IMAGE" "PNG" NIL "" NIL "BASE64" 3934 NIL ("INLINE" ("FILENAME" "3M-Logo.png")) NIL) "RELATED" ("BOUNDARY" "5cfe29a0_507ed7ab_d312") NIL NIL) "ALTERNATIVE" ("BOUNDARY" "5cfe29a0_2eb141f2_d312") NIL NIL)("APPLICATION" "OCTET-STREAM" NIL NIL NIL "BASE64" 2730568 NIL ("ATTACHMENT" ("FILENAME" "test.pdf")) NIL) "MIXED" ("BOUNDARY" "5cfe29a0_41b71efb_d312") NIL NIL))\r\n], 308 | ~s[* 2 FETCH (ENVELOPE ("Tue, 11 Jun 2019 09:21:23 +0200" "Test with multiple attachments" (("Andrew Timberlake" NIL "andrew" "internuity.net")) (("Andrew Timberlake" NIL "andrew" "internuity.net")) (("Andrew Timberlake" NIL "andrew" "internuity.net")) ((NIL NIL "george" "example.com")) NIL NIL "<60f5c212-a3a0-464d-b287-6d81f46a1359@Spark>" "") BODYSTRUCTURE ((("TEXT" "PLAIN" ("CHARSET" "utf-8") NIL NIL "7BIT" 9 1 NIL ("INLINE" NIL) NIL)("TEXT" "HTML" ("CHARSET" "utf-8") NIL NIL "QUOTED-PRINTABLE" 195 4 NIL ("INLINE" NIL) NIL) "ALTERNATIVE" ("BOUNDARY" "5cff5678_ded7263_d312") NIL NIL)("APPLICATION" "OCTET-STREAM" NIL NIL NIL "BASE64" 2 NIL ("ATTACHMENT" ("FILENAME" "test.csv")) NIL)("APPLICATION" "OCTET-STREAM" NIL NIL NIL "BASE64" 14348938 NIL ("ATTACHMENT" ("FILENAME" "test.doc")) NIL)("APPLICATION" "OCTET-STREAM" NIL NIL NIL "BASE64" 240 NIL ("ATTACHMENT" ("FILENAME" "test.mid")) NIL)("TEXT" "PLAIN" NIL NIL NIL "BASE64" 10 1 NIL ("ATTACHMENT" ("FILENAME" "test.txt")) NIL)("APPLICATION" "OCTET-STREAM" NIL NIL NIL "BASE64" 974 NIL ("ATTACHMENT" ("FILENAME" "test.wav")) NIL) "MIXED" ("BOUNDARY" "5cff5678_7fdcc233_d312") NIL NIL))\r\n], 309 | "* 3 FETCH (ENVELOPE (\"Wed, 26 Oct 2016 14:24:15 +0200\" \"Another one\" ((\"Jane Doe\" NIL \"jane\" \"example.com\")) ((\"Jane Doe\" NIL \"jane\" \"example.com\")) ((\"Jane Doe\" NIL \"jane\" \"example.com\")) ((NIL NIL \"george\" \"example.com\")) NIL NIL \"652E7B61-60F6-421C-B954-4178BB769B27.example.com\" \"<28D03E0E-47EE-4AEF-BDE6-54ADB0EF28FD.example.com>\") BODYSTRUCTURE (\"TEXT\" \"PLAIN\" (\"CHARSET\" \"iso-8859-1\") NIL NIL \"QUOTED-PRINTABLE\" 1315 42 NIL NIL NIL NIL))\r\n", 310 | "OK Success\r\n" 311 | ]) 312 | |> TestServer.tagged("STORE 1 +FLAGS (\\Deleted)\r\n", [ 313 | "* 1 FETCH (FLAGS (\\Deleted))\r\n", 314 | "OK Store completed\r\n" 315 | ]) 316 | |> TestServer.tagged("STORE 2 +FLAGS (\\Deleted)\r\n", [ 317 | "* 2 FETCH (FLAGS (\\Deleted))\r\n", 318 | "OK Store completed\r\n" 319 | ]) 320 | |> TestServer.tagged("STORE 3 +FLAGS (\\Deleted)\r\n", [ 321 | "* 2 FETCH (FLAGS (\\Deleted))\r\n", 322 | "OK Store completed\r\n" 323 | ]) 324 | |> TestServer.tagged("EXPUNGE\r\n", [ 325 | "* 1 EXPUNGE\r\n", 326 | "* 2 EXPUNGE\r\n", 327 | "* 3 EXPUNGE\r\n", 328 | "OK Expunge completed\r\n" 329 | ]) 330 | |> TestServer.tagged("IDLE\r\n", [ 331 | "+ idling\r\n" 332 | ]) 333 | |> TestServer.tagged("DONE\r\n", [ 334 | "OK IDLE terminated\r\n" 335 | ]) 336 | |> TestServer.tagged("LOGOUT\r\n", [ 337 | "* BYE We're out of here\r\n", 338 | "OK Logged out\r\n" 339 | ]) 340 | end) 341 | 342 | log = 343 | ExUnit.CaptureLog.capture_log(fn -> 344 | {:ok, pid} = 345 | TestMailRouter.start_link( 346 | server: server.address, 347 | port: server.port, 348 | ssl: true, 349 | ssl_opts: [verify: :verify_none], 350 | assigns: %{test_pid: self()}, 351 | debug: @debug 352 | ) 353 | 354 | assert_receive({:matched_has_attachment, 1}) 355 | assert_receive({:matched_has_attachment, 2}) 356 | assert_receive({:match_all, 3}) 357 | TestMailRouter.close(pid) 358 | end) 359 | 360 | assert log =~ "Processing 3 emails" 361 | 362 | assert log =~ 363 | "Processing msg:1 TO:george@example.com FROM:andrew@internuity.net SUBJECT:\"Attached.\" using Mailroom.InboxTest.TestMailProcessor#match_has_attachment -> :delete" 364 | 365 | assert log =~ 366 | "Processing msg:2 TO:george@example.com FROM:andrew@internuity.net SUBJECT:\"Test with multiple attachments\" using Mailroom.InboxTest.TestMailProcessor#match_has_attachment -> :delete" 367 | 368 | assert log =~ 369 | "Processing msg:3 TO:george@example.com FROM:jane@example.com SUBJECT:\"Another one\" using Mailroom.InboxTest.TestMailProcessor#match_all -> :delete" 370 | end 371 | 372 | test "ignore an email" do 373 | server = TestServer.start(ssl: true) 374 | 375 | TestServer.expect(server, fn expectations -> 376 | expectations 377 | |> TestServer.tagged(:connect, "* OK IMAP ready\r\n") 378 | |> TestServer.tagged("LOGIN \"test@example.com\" \"P@55w0rD\"\r\n", [ 379 | "* CAPABILITY (IMAPrev4)\r\n", 380 | "OK test@example.com authenticated (Success)\r\n" 381 | ]) 382 | |> TestServer.tagged("SELECT INBOX\r\n", [ 383 | "* FLAGS (\\Flagged \\Draft \\Deleted \\Seen)\r\n", 384 | "* OK [PERMANENTFLAGS (\\Flagged \\Draft \\Deleted \\Seen \\*)] Flags permitted\r\n", 385 | "* 1 EXISTS\r\n", 386 | "* 1 RECENT\r\n", 387 | "OK [READ-WRITE] INBOX selected. (Success)\r\n" 388 | ]) 389 | |> TestServer.tagged("SEARCH UNSEEN\r\n", [ 390 | "* SEARCH 1\r\n", 391 | "OK Success\r\n" 392 | ]) 393 | |> TestServer.tagged("FETCH 1 (ENVELOPE BODYSTRUCTURE)\r\n", [ 394 | ~s[* 1 FETCH (ENVELOPE ("Mon, 10 Jun 2019 11:57:36 +0200" "Attached." (("Andrew Timberlake" NIL "andrew" "internuity.net")) (("Andrew Timberlake" NIL "andrew" "internuity.net")) (("Andrew Timberlake" NIL "andrew" "internuity.net")) ((NIL NIL "ignore" "example.com")) NIL NIL "" "<4cff0831-67f5-4457-b60a-3331ba893348@Spark>") BODYSTRUCTURE ((("TEXT" "PLAIN" ("CHARSET" "utf-8") NIL NIL "7BIT" 8 1 NIL ("INLINE" NIL) NIL)(("TEXT" "HTML" ("CHARSET" "utf-8") NIL NIL "QUOTED-PRINTABLE" 293 6 NIL ("INLINE" NIL) NIL)("IMAGE" "PNG" NIL "" NIL "BASE64" 3934 NIL ("INLINE" ("FILENAME" "3M-Logo.png")) NIL) "RELATED" ("BOUNDARY" "5cfe29a0_507ed7ab_d312") NIL NIL) "ALTERNATIVE" ("BOUNDARY" "5cfe29a0_2eb141f2_d312") NIL NIL)("APPLICATION" "OCTET-STREAM" NIL NIL NIL "BASE64" 2730568 NIL ("ATTACHMENT" ("FILENAME" "test.pdf")) NIL) "MIXED" ("BOUNDARY" "5cfe29a0_41b71efb_d312") NIL NIL))\r\n], 395 | "OK Success\r\n" 396 | ]) 397 | |> TestServer.tagged("STORE 1 +FLAGS (\\Deleted)\r\n", [ 398 | "* 1 FETCH (FLAGS (\\Deleted))\r\n", 399 | "OK Store completed\r\n" 400 | ]) 401 | |> TestServer.tagged("EXPUNGE\r\n", [ 402 | "* 1 EXPUNGE\r\n", 403 | "OK Expunge completed\r\n" 404 | ]) 405 | |> TestServer.tagged("IDLE\r\n", [ 406 | "+ idling\r\n" 407 | ]) 408 | |> TestServer.tagged("DONE\r\n", [ 409 | "OK IDLE terminated\r\n" 410 | ]) 411 | |> TestServer.tagged("LOGOUT\r\n", [ 412 | "* BYE We're out of here\r\n", 413 | "OK Logged out\r\n" 414 | ]) 415 | end) 416 | 417 | log = 418 | ExUnit.CaptureLog.capture_log(fn -> 419 | {:ok, pid} = 420 | TestMailRouter.start_link( 421 | server: server.address, 422 | port: server.port, 423 | ssl: true, 424 | ssl_opts: [verify: :verify_none], 425 | assigns: %{test_pid: self()}, 426 | debug: @debug 427 | ) 428 | 429 | # refute_received _ 430 | TestMailRouter.close(pid) 431 | end) 432 | 433 | assert log =~ "Processing 1 emails" 434 | 435 | assert log =~ 436 | "Processing msg:1 TO:ignore@example.com FROM:andrew@internuity.net SUBJECT:\"Attached.\" -> :ignore" 437 | end 438 | 439 | test "Fetch email in handler" do 440 | server = TestServer.start(ssl: true) 441 | 442 | TestServer.expect(server, fn expectations -> 443 | expectations 444 | |> TestServer.tagged(:connect, "* OK IMAP ready\r\n") 445 | |> TestServer.tagged("LOGIN \"test@example.com\" \"P@55w0rD\"\r\n", [ 446 | "* CAPABILITY (IMAPrev4)\r\n", 447 | "OK test@example.com authenticated (Success)\r\n" 448 | ]) 449 | |> TestServer.tagged("SELECT INBOX\r\n", [ 450 | "* FLAGS (\\Flagged \\Draft \\Deleted \\Seen)\r\n", 451 | "* OK [PERMANENTFLAGS (\\Flagged \\Draft \\Deleted \\Seen \\*)] Flags permitted\r\n", 452 | "* 1 EXISTS\r\n", 453 | "* 0 RECENT\r\n", 454 | "OK [READ-WRITE] INBOX selected. (Success)\r\n" 455 | ]) 456 | |> TestServer.tagged("SEARCH UNSEEN\r\n", [ 457 | "* SEARCH 1\r\n", 458 | "OK Success\r\n" 459 | ]) 460 | |> TestServer.tagged("FETCH 1 (ENVELOPE BODYSTRUCTURE)\r\n", [ 461 | ~s[* 1 FETCH (ENVELOPE ("Mon, 10 Jun 2019 11:57:36 +0200" "To be fetched" (("Andrew Timberlake" NIL "andrew" "internuity.net")) (("Andrew Timberlake" NIL "andrew" "internuity.net")) (("Andrew Timberlake" NIL "andrew" "internuity.net")) ((NIL NIL "george" "example.com")) NIL NIL "" "<4cff0831-67f5-4457-b60a-3331ba893348@Spark>") BODYSTRUCTURE ((("TEXT" "PLAIN" ("CHARSET" "utf-8") NIL NIL "7BIT" 8 1 NIL ("INLINE" NIL) NIL)(("TEXT" "HTML" ("CHARSET" "utf-8") NIL NIL "QUOTED-PRINTABLE" 293 6 NIL ("INLINE" NIL) NIL)("IMAGE" "PNG" NIL "" NIL "BASE64" 3934 NIL ("INLINE" ("FILENAME" "3M-Logo.png")) NIL) "RELATED" ("BOUNDARY" "5cfe29a0_507ed7ab_d312") NIL NIL) "ALTERNATIVE" ("BOUNDARY" "5cfe29a0_2eb141f2_d312") NIL NIL)("APPLICATION" "OCTET-STREAM" NIL NIL NIL "BASE64" 2730568 NIL ("ATTACHMENT" ("FILENAME" "test.pdf")) NIL) "MIXED" ("BOUNDARY" "5cfe29a0_41b71efb_d312") NIL NIL))\r\n], 462 | "OK Success\r\n" 463 | ]) 464 | |> TestServer.tagged("FETCH 1 (BODY.PEEK[])\r\n", [ 465 | "* 1 FETCH (BODY[] {17}\r\nSubject: Test\r\n\r\n)\r\n", 466 | "OK Success\r\n" 467 | ]) 468 | |> TestServer.tagged("STORE 1 +FLAGS (\\Deleted)\r\n", [ 469 | "* 1 FETCH (FLAGS (\\Deleted))\r\n", 470 | "OK Store completed\r\n" 471 | ]) 472 | |> TestServer.tagged("EXPUNGE\r\n", [ 473 | "* 1 EXPUNGE\r\n", 474 | "OK Expunge completed\r\n" 475 | ]) 476 | |> TestServer.tagged("IDLE\r\n", [ 477 | "+ idling\r\n" 478 | ]) 479 | |> TestServer.tagged("DONE\r\n", [ 480 | "OK IDLE terminated\r\n" 481 | ]) 482 | |> TestServer.tagged("LOGOUT\r\n", [ 483 | "* BYE We're out of here\r\n", 484 | "OK Logged out\r\n" 485 | ]) 486 | end) 487 | 488 | log = 489 | ExUnit.CaptureLog.capture_log(fn -> 490 | {:ok, pid} = 491 | TestMailRouter.start_link( 492 | server: server.address, 493 | port: server.port, 494 | ssl: true, 495 | ssl_opts: [verify: :verify_none], 496 | assigns: %{test_pid: self()}, 497 | debug: @debug 498 | ) 499 | 500 | assert_receive({:match_and_fetch, 1, <<"Subject: ", _rest::binary>>, %Mail.Message{}}) 501 | TestMailRouter.close(pid) 502 | end) 503 | 504 | assert log =~ "Processing 1 emails" 505 | 506 | assert log =~ 507 | "Processing msg:1 TO:george@example.com FROM:andrew@internuity.net SUBJECT:\"To be fetched\" using Mailroom.InboxTest.TestMailProcessor#match_and_fetch -> :delete" 508 | end 509 | 510 | test "Match by header" do 511 | server = TestServer.start(ssl: true) 512 | 513 | headers = 514 | Mail.build_multipart() 515 | |> Mail.put_from("andrew@internuity.net") 516 | |> Mail.put_subject("Test with header") 517 | |> Mail.put_to("reply@example.com") 518 | |> Mail.Message.put_header("in-reply-to", "message-id") 519 | |> Mail.Renderers.RFC2822.render() 520 | |> String.split("\r\n\r\n") 521 | |> List.first() 522 | 523 | TestServer.expect(server, fn expectations -> 524 | expectations 525 | |> TestServer.tagged(:connect, "* OK IMAP ready\r\n") 526 | |> TestServer.tagged("LOGIN \"test@example.com\" \"P@55w0rD\"\r\n", [ 527 | "* CAPABILITY (IMAPrev4)\r\n", 528 | "OK test@example.com authenticated (Success)\r\n" 529 | ]) 530 | |> TestServer.tagged("SELECT INBOX\r\n", [ 531 | "* FLAGS (\\Flagged \\Draft \\Deleted \\Seen)\r\n", 532 | "* OK [PERMANENTFLAGS (\\Flagged \\Draft \\Deleted \\Seen \\*)] Flags permitted\r\n", 533 | "* 1 EXISTS\r\n", 534 | "* 0 RECENT\r\n", 535 | "OK [READ-WRITE] INBOX selected. (Success)\r\n" 536 | ]) 537 | |> TestServer.tagged("SEARCH UNSEEN\r\n", [ 538 | "* SEARCH 1\r\n", 539 | "OK Success\r\n" 540 | ]) 541 | |> TestServer.tagged("FETCH 1 (ENVELOPE BODY.PEEK[HEADER])\r\n", [ 542 | ~s[* 1 FETCH (ENVELOPE ("Mon, 27 Jul 2020 11:57:36 +0200" "Test with header" (("Andrew Timberlake" NIL "andrew" "internuity.net")) (("Andrew Timberlake" NIL "andrew" "internuity.net")) (("Andrew Timberlake" NIL "andrew" "internuity.net")) ((NIL NIL "reply" "example.com")) NIL NIL "" "<4cff0831-67f5-4457-b60a-3331ba893348@Spark>") BODY\[HEADER\] {#{byte_size(headers)}}\r\n#{headers})\r\n], 543 | "OK Success\r\n" 544 | ]) 545 | |> TestServer.tagged("STORE 1 +FLAGS (\\Deleted)\r\n", [ 546 | "* 1 FETCH (FLAGS (\\Deleted))\r\n", 547 | "OK Store completed\r\n" 548 | ]) 549 | |> TestServer.tagged("EXPUNGE\r\n", [ 550 | "* 1 EXPUNGE\r\n", 551 | "OK Expunge completed\r\n" 552 | ]) 553 | |> TestServer.tagged("IDLE\r\n", [ 554 | "+ idling\r\n" 555 | ]) 556 | |> TestServer.tagged("DONE\r\n", [ 557 | "OK IDLE terminated\r\n" 558 | ]) 559 | |> TestServer.tagged("LOGOUT\r\n", [ 560 | "* BYE We're out of here\r\n", 561 | "OK Logged out\r\n" 562 | ]) 563 | end) 564 | 565 | log = 566 | ExUnit.CaptureLog.capture_log(fn -> 567 | {:ok, pid} = 568 | TestMailHeaderRouter.start_link( 569 | server: server.address, 570 | port: server.port, 571 | ssl: true, 572 | ssl_opts: [verify: :verify_none], 573 | assigns: %{test_pid: self()}, 574 | debug: @debug 575 | ) 576 | 577 | assert_receive({:match_header, 1}) 578 | TestMailHeaderRouter.close(pid) 579 | end) 580 | 581 | assert log =~ "Processing 1 emails" 582 | 583 | assert log =~ 584 | "Processing msg:1 TO:reply@example.com FROM:andrew@internuity.net SUBJECT:\"Test with header\" using Mailroom.InboxTest.TestMailProcessor#match_header -> :delete" 585 | end 586 | 587 | test "failure to connect" do 588 | Process.flag(:trap_exit, true) 589 | 590 | log = 591 | ExUnit.CaptureLog.capture_log(fn -> 592 | {:ok, pid} = 593 | TestMailHeaderRouter.start_link( 594 | server: "server.wrong.tld", 595 | port: 143, 596 | ssl: false, 597 | ssl_opts: [verify: :verify_none], 598 | assigns: %{test_pid: self()}, 599 | debug: @debug 600 | ) 601 | 602 | assert_receive {:EXIT, ^pid, :unable_to_connect} 603 | end) 604 | 605 | assert log =~ "Connection failed: :unable_to_connect" 606 | end 607 | end 608 | -------------------------------------------------------------------------------- /lib/mailroom/imap.ex: -------------------------------------------------------------------------------- 1 | defmodule Mailroom.IMAP do 2 | alias Mailroom.BackwardsCompatibleLogger, as: Logger 3 | use GenServer 4 | 5 | import Mailroom.IMAP.Utils 6 | alias Mailroom.IMAP.{Envelope, BodyStructure} 7 | 8 | defmodule State do 9 | @moduledoc false 10 | defstruct socket: nil, 11 | state: :unauthenticated, 12 | ssl: false, 13 | ssl_opts: [], 14 | debug: false, 15 | cmd_map: %{}, 16 | cmd_number: 1, 17 | capability: [], 18 | flags: [], 19 | permanent_flags: [], 20 | uid_validity: nil, 21 | uid_next: nil, 22 | unseen: 0, 23 | highest_mod_seq: nil, 24 | recent: 0, 25 | exists: 0, 26 | temp: nil, 27 | mailbox: nil, 28 | idle_caller: nil, 29 | idle_reply_msg: nil, 30 | idle_timer: nil 31 | end 32 | 33 | @moduledoc """ 34 | Handles communication with a IMAP server. 35 | 36 | ## Example: 37 | 38 | {:ok, client} = #{inspect(__MODULE__)}.connect("imap.server", "username", "password") 39 | client 40 | |> #{inspect(__MODULE__)}.list 41 | |> Enum.each(fn(mail) -> 42 | message = 43 | client 44 | |> #{inspect(__MODULE__)}.retrieve(mail) 45 | |> Enum.join("\\n") 46 | # … process message 47 | #{inspect(__MODULE__)}.delete(client, mail) 48 | end) 49 | #{inspect(__MODULE__)}.reset(client) 50 | #{inspect(__MODULE__)}.close(client) 51 | """ 52 | 53 | alias Mailroom.Socket 54 | 55 | @doc """ 56 | Connect to the IMAP server 57 | 58 | The following options are available: 59 | 60 | - `ssl` - default `false`, connect via SSL or not 61 | - `port` - default `110` (`995` if SSL), the port to connect to 62 | - `timeout` - default `15_000`, the timeout for connection and communication 63 | 64 | ## Examples: 65 | 66 | #{inspect(__MODULE__)}.connect("imap.server", "me", "secret", ssl: true) 67 | {:ok, pid} 68 | """ 69 | def connect(server, username, password, options \\ []) do 70 | opts = parse_opts(options) 71 | 72 | with {:ok, pid} <- GenServer.start_link(__MODULE__, opts), 73 | {:ok, _} <- GenServer.call(pid, {:connect, server, opts.port, opts.ssl_opts}), 74 | {:ok, _msg} <- login(pid, username, password) do 75 | {:ok, pid} 76 | end 77 | end 78 | 79 | defp parse_opts(opts, acc \\ %{ssl: false, port: nil, debug: false, ssl_opts: []}) 80 | 81 | defp parse_opts([], acc), 82 | do: set_default_port(acc) 83 | 84 | defp parse_opts([{:ssl, ssl} | tail], acc), 85 | do: parse_opts(tail, Map.put(acc, :ssl, ssl)) 86 | 87 | defp parse_opts([{:ssl_opts, ssl_opts} | tail], acc), 88 | do: parse_opts(tail, Map.put(acc, :ssl_opts, ssl_opts)) 89 | 90 | defp parse_opts([{:port, port} | tail], acc), 91 | do: parse_opts(tail, Map.put(acc, :port, port)) 92 | 93 | defp parse_opts([{:debug, debug} | tail], acc), 94 | do: parse_opts(tail, Map.put(acc, :debug, debug)) 95 | 96 | defp parse_opts([_ | tail], acc), 97 | do: parse_opts(tail, acc) 98 | 99 | defp set_default_port(%{port: nil, ssl: false} = opts), 100 | do: %{opts | port: 143} 101 | 102 | defp set_default_port(%{port: nil, ssl: true} = opts), 103 | do: %{opts | port: 993} 104 | 105 | defp set_default_port(opts), 106 | do: opts 107 | 108 | defp login(pid, username, password) do 109 | case GenServer.call(pid, {:login, username, password}) do 110 | {:ok, msg} -> {:ok, msg} 111 | {:error, reason} -> {:error, {:authentication, reason}} 112 | end 113 | end 114 | 115 | def select(pid, mailbox_name), 116 | do: GenServer.call(pid, {:select, mailbox_name}) && pid 117 | 118 | def examine(pid, mailbox_name), 119 | do: GenServer.call(pid, {:examine, mailbox_name}) && pid 120 | 121 | def list(pid, reference \\ "", mailbox_name \\ "*"), 122 | do: GenServer.call(pid, {:list, reference, mailbox_name}) 123 | 124 | def status(pid, mailbox_name, items), 125 | do: GenServer.call(pid, {:status, mailbox_name, items}) 126 | 127 | @doc ~S""" 128 | Fetches the items for the specified message or range of messages 129 | 130 | ## Examples: 131 | 132 | > IMAP.fetch(client, 1, [:uid]) 133 | #… 134 | > IMAP.fetch(client, 1..3, [:fast, :uid]) 135 | #… 136 | """ 137 | def fetch(pid, number_or_range, items_list, func \\ nil, opts \\ []) do 138 | {:ok, list} = 139 | GenServer.call( 140 | pid, 141 | {:fetch, number_or_range, items_list}, 142 | Keyword.get(opts, :timeout, 300_000) 143 | ) 144 | 145 | if func do 146 | Enum.each(list, func) 147 | pid 148 | else 149 | {:ok, list} 150 | end 151 | end 152 | 153 | def uid_fetch(pid, number_or_range, items_list, opts \\ []) do 154 | GenServer.call( 155 | pid, 156 | {:uid_fetch, number_or_range, items_list}, 157 | Keyword.get(opts, :timeout, 300_000) 158 | ) 159 | end 160 | 161 | def search(pid, query, items_list \\ nil, func \\ nil) do 162 | {:ok, list} = GenServer.call(pid, {:search, query}, 60_000) 163 | 164 | if func do 165 | list 166 | |> numbers_to_sequences 167 | |> Enum.each(fn number_or_range -> 168 | {:ok, list} = fetch(pid, number_or_range, items_list) 169 | Enum.each(list, func) 170 | end) 171 | 172 | pid 173 | else 174 | {:ok, list} 175 | end 176 | end 177 | 178 | def uid_search(pid, query) do 179 | GenServer.call(pid, {:uid_search, query}, 60_000) 180 | end 181 | 182 | def each(pid, items_list \\ [:envelope], func) do 183 | pid 184 | |> email_count 185 | |> split_into_ranges(100) 186 | |> Enum.each(fn range -> 187 | fetch(pid, range, items_list, fn {msg_id, response} -> 188 | func.({msg_id, response}) 189 | end) 190 | end) 191 | 192 | pid 193 | end 194 | 195 | defp split_into_ranges(0, _chunks), do: [] 196 | defp split_into_ranges(length, chunks, start \\ 0) 197 | 198 | defp split_into_ranges(length, chunks, start) when start + chunks < length do 199 | [(start + 1)..(start + chunks) | split_into_ranges(length, chunks, start + chunks)] 200 | end 201 | 202 | defp split_into_ranges(length, _chunks, start) do 203 | [(start + 1)..length] 204 | end 205 | 206 | def remove_flags(pid, number_or_range, flags, opts \\ []), 207 | do: GenServer.call(pid, {:remove_flags, number_or_range, flags, opts}) && pid 208 | 209 | def add_flags(pid, number_or_range, flags, opts \\ []), 210 | do: GenServer.call(pid, {:add_flags, number_or_range, flags, opts}) && pid 211 | 212 | def set_flags(pid, number_or_range, flags, opts \\ []), 213 | do: GenServer.call(pid, {:set_flags, number_or_range, flags, opts}) && pid 214 | 215 | def copy(pid, sequence, mailbox_name), 216 | do: GenServer.call(pid, {:copy, sequence, mailbox_name}) && pid 217 | 218 | def expunge(pid), 219 | do: GenServer.call(pid, :expunge) && pid 220 | 221 | @doc ~S""" 222 | ## Options 223 | - `:timeout` - (integer) number of milliseconds before terminating the idle command if no update has been received. Defaults to `1_500_00` (25 minutes) 224 | """ 225 | def idle(pid, opts \\ []) do 226 | timeout = Keyword.get(opts, :timeout, 1_500_000) 227 | GenServer.call(pid, {:idle, timeout}, :infinity) && pid 228 | end 229 | 230 | def idle(pid, callback_pid, callback_message, opts \\ []) when is_pid(callback_pid) do 231 | timeout = Keyword.get(opts, :timeout, 1_500_000) 232 | GenServer.cast(pid, {:idle, timeout, callback_pid, callback_message}) && pid 233 | end 234 | 235 | def cancel_idle(pid) do 236 | GenServer.call(pid, :cancel_idle) && pid 237 | end 238 | 239 | def close(pid), 240 | do: GenServer.call(pid, :close) && pid 241 | 242 | def logout(pid), 243 | do: GenServer.call(pid, :logout) && pid 244 | 245 | def email_count(pid), 246 | do: GenServer.call(pid, :email_count) 247 | 248 | def recent_count(pid), 249 | do: GenServer.call(pid, :recent_count) 250 | 251 | def unseen_count(pid), 252 | do: GenServer.call(pid, :unseen_count) 253 | 254 | def mailbox(pid), 255 | do: GenServer.call(pid, :mailbox) 256 | 257 | def state(pid), 258 | do: GenServer.call(pid, :state) 259 | 260 | def init(opts) do 261 | {:ok, %State{debug: opts.debug, ssl: opts.ssl}} 262 | end 263 | 264 | def handle_call({:connect, server, port, ssl_opts}, from, state) do 265 | server 266 | |> Socket.connect(port, 267 | ssl: state.ssl, 268 | debug: state.debug, 269 | active: true, 270 | ssl_opts: ssl_opts 271 | ) 272 | |> case do 273 | {:ok, socket} -> 274 | {:noreply, %{state | socket: socket, cmd_map: %{connect: from}}} 275 | 276 | {:error, _} -> 277 | {:reply, {:error, :unable_to_connect}, state} 278 | end 279 | end 280 | 281 | def handle_call({:login, username, password}, from, %{capability: capability} = state) do 282 | if Enum.member?(capability, "STARTTLS") do 283 | {:noreply, 284 | send_command(from, "STARTTLS", %{state | temp: %{username: username, password: password}})} 285 | else 286 | {:noreply, 287 | send_command( 288 | from, 289 | ["LOGIN", " ", quote_string(username), " ", quote_string(password)], 290 | state 291 | )} 292 | end 293 | end 294 | 295 | def handle_call({:select, :inbox}, from, state), 296 | do: handle_call({:select, "INBOX"}, from, state) 297 | 298 | def handle_call({:select, mailbox}, from, state), 299 | do: {:noreply, send_command(from, ["SELECT", " ", mailbox], %{state | temp: mailbox})} 300 | 301 | def handle_call({:examine, :inbox}, from, state), 302 | do: handle_call({:examine, "INBOX"}, from, state) 303 | 304 | def handle_call({:examine, mailbox}, from, state), 305 | do: {:noreply, send_command(from, ["EXAMINE", " ", mailbox], %{state | temp: mailbox})} 306 | 307 | def handle_call({:list, reference, mailbox_name}, from, state) do 308 | {:noreply, 309 | send_command( 310 | from, 311 | ["LIST", " ", quote_string(reference), " ", quote_string(mailbox_name)], 312 | state 313 | )} 314 | end 315 | 316 | def handle_call({:status, mailbox_name, items}, from, state) do 317 | {:noreply, 318 | send_command( 319 | from, 320 | ["STATUS", " ", quote_string(mailbox_name), " ", items_to_list(items)], 321 | state 322 | )} 323 | end 324 | 325 | def handle_call({:fetch, sequence, items}, from, state) do 326 | {:noreply, 327 | send_command(from, ["FETCH", " ", to_sequence(sequence), " ", items_to_list(items)], %{ 328 | state 329 | | temp: [] 330 | })} 331 | end 332 | 333 | def handle_call({:uid_fetch, sequence, items}, from, state) do 334 | {:noreply, 335 | send_command(from, ["UID FETCH", " ", to_sequence(sequence), " ", items_to_list(items)], %{ 336 | state 337 | | temp: [] 338 | })} 339 | end 340 | 341 | def handle_call({:search, query}, from, state), 342 | do: {:noreply, send_command(from, ["SEARCH", " ", query], %{state | temp: []})} 343 | 344 | def handle_call({:uid_search, query}, from, state), 345 | do: {:noreply, send_command(from, ["UID SEARCH", " ", query], %{state | temp: []})} 346 | 347 | [remove_flags: "-FLAGS", add_flags: "+FLAGS", set_flags: "FLAGS"] 348 | |> Enum.each(fn {func_name, command} -> 349 | def handle_call({unquote(func_name), sequence, flags, opts}, from, state) do 350 | {:noreply, 351 | send_command( 352 | from, 353 | [ 354 | "STORE", 355 | " ", 356 | to_sequence(sequence), 357 | " ", 358 | unquote(command), 359 | store_silent(opts), 360 | " ", 361 | flags_to_list(flags) 362 | ], 363 | %{state | temp: []} 364 | )} 365 | end 366 | end) 367 | 368 | def handle_call({:copy, sequence, mailbox_name}, from, state) do 369 | {:noreply, 370 | send_command( 371 | from, 372 | ["COPY", " ", to_sequence(sequence), " ", quote_string(mailbox_name)], 373 | state 374 | )} 375 | end 376 | 377 | def handle_call(:expunge, from, state), 378 | do: {:noreply, send_command(from, "EXPUNGE", state)} 379 | 380 | def handle_call({:idle, timeout}, from, state) do 381 | timer = Process.send_after(self(), :idle_timeout, timeout) 382 | {:noreply, send_command(from, "IDLE", %{state | idle_caller: from, idle_timer: timer})} 383 | end 384 | 385 | def handle_call( 386 | :cancel_idle, 387 | _from, 388 | %{socket: socket, idle_caller: caller, idle_timer: timer} = state 389 | ) do 390 | if caller do 391 | cancel_idle(socket, timer) 392 | end 393 | 394 | {:reply, :ok, state} 395 | end 396 | 397 | def handle_call(:close, from, state), 398 | do: {:noreply, send_command(from, "CLOSE", state)} 399 | 400 | def handle_call(:logout, from, state), 401 | do: {:noreply, send_command(from, "LOGOUT", state)} 402 | 403 | def handle_call(:email_count, _from, %{exists: exists} = state), 404 | do: {:reply, exists, state} 405 | 406 | def handle_call(:recent_count, _from, %{recent: recent} = state), 407 | do: {:reply, recent, state} 408 | 409 | def handle_call(:unseen_count, _from, %{unseen: unseen} = state), 410 | do: {:reply, unseen, state} 411 | 412 | def handle_call(:mailbox, _from, %{mailbox: mailbox} = state), 413 | do: {:reply, mailbox, state} 414 | 415 | def handle_call(:state, _from, %{state: connection_state} = state), 416 | do: {:reply, connection_state, state} 417 | 418 | def handle_cast({:idle, timeout, reply_to, reply_with}, state) do 419 | timer = Process.send_after(self(), :idle_timeout, timeout) 420 | 421 | {:noreply, 422 | send_command(nil, "IDLE", %{ 423 | state 424 | | idle_caller: reply_to, 425 | idle_reply_msg: reply_with, 426 | idle_timer: timer 427 | })} 428 | end 429 | 430 | def handle_info({:ssl, _socket, msg}, state) do 431 | if state.debug, do: IO.write(["> [ssl] ", msg]) 432 | handle_response(msg, state) 433 | end 434 | 435 | def handle_info({:tcp, _socket, msg}, state) do 436 | if state.debug, do: IO.write(["> [tcp] ", msg]) 437 | handle_response(msg, state) 438 | end 439 | 440 | def handle_info(:idle_timeout, %{socket: socket} = state) do 441 | cancel_idle(socket, nil) 442 | {:noreply, state} 443 | end 444 | 445 | def handle_info({:ssl_closed, _}, state) do 446 | Logger.warning("SSL closed") 447 | {:stop, :ssl_closed, state} 448 | end 449 | 450 | defp cancel_idle(socket, timer) do 451 | if timer, do: Process.cancel_timer(timer) 452 | :ok = Socket.send(socket, ["DONE\r\n"]) 453 | end 454 | 455 | defp handle_response( 456 | <<"* OK ", msg::binary>>, 457 | %{state: :unauthenticated, cmd_map: %{connect: caller} = cmd_map} = state 458 | ) do 459 | state = process_connection_message(msg, state) 460 | GenServer.reply(caller, {:ok, msg}) 461 | {:noreply, %{state | cmd_map: Map.delete(cmd_map, :connect)}} 462 | end 463 | 464 | defp handle_response(<<"* OK [PERMANENTFLAGS ", msg::binary>>, state), 465 | do: {:noreply, %{state | permanent_flags: parse_list_only(msg)}} 466 | 467 | defp handle_response(<<"* OK [UIDVALIDITY ", msg::binary>>, state), 468 | do: {:noreply, %{state | uid_validity: parse_number(msg)}} 469 | 470 | defp handle_response(<<"* OK [UNSEEN ", msg::binary>>, state), 471 | do: {:noreply, %{state | unseen: parse_number(msg)}} 472 | 473 | defp handle_response(<<"* OK [UIDNEXT ", msg::binary>>, state), 474 | do: {:noreply, %{state | uid_next: parse_number(msg)}} 475 | 476 | defp handle_response(<<"* OK [HIGHESTMODSEQ ", msg::binary>>, state), 477 | do: {:noreply, %{state | highest_mod_seq: parse_number(msg)}} 478 | 479 | defp handle_response(<<"* OK [CAPABILITY ", msg::binary>>, state) do 480 | {:noreply, %{state | capability: parse_capability(msg)}} 481 | end 482 | 483 | defp handle_response(<<"* CAPABILITY ", msg::binary>>, state), 484 | do: {:noreply, %{state | capability: parse_capability(msg)}} 485 | 486 | defp handle_response(<<"* LIST ", rest::binary>>, %{temp: temp} = state) do 487 | {flags, <<" ", rest::binary>>} = parse_list(rest) 488 | {delimiter, <<" ", rest::binary>>} = parse_string(rest) 489 | mailbox = parse_string_only(rest) 490 | {:noreply, %{state | temp: [{mailbox, delimiter, flags} | List.wrap(temp)]}} 491 | end 492 | 493 | defp handle_response(<<"* STATUS ", rest::binary>>, state) do 494 | {_mailbox, <>} = parse_string(rest) 495 | response = parse_list_only(rest) 496 | response = list_to_status_items(response) 497 | {:noreply, %{state | temp: response}} 498 | end 499 | 500 | defp handle_response(<<"* FLAGS ", msg::binary>>, state), 501 | do: {:noreply, %{state | flags: parse_list_only(msg)}} 502 | 503 | defp handle_response(<<"* SEARCH ", msg::binary>>, state) do 504 | sequence_numbers = 505 | msg 506 | |> trim() 507 | |> String.split(" ") 508 | |> Enum.map(&String.to_integer/1) 509 | 510 | {:noreply, %{state | temp: sequence_numbers}} 511 | end 512 | 513 | defp handle_response(<<"* SEARCH", _msg::binary>>, state), 514 | do: {:noreply, state} 515 | 516 | defp handle_response(<<"* BYE ", _msg::binary>>, state), 517 | do: {:noreply, state} 518 | 519 | defp handle_response(<<"* ", msg::binary>>, state) do 520 | case String.split(msg, " ", parts: 3) do 521 | [number, "EXISTS\r\n"] -> 522 | handle_exists(String.to_integer(number), state) 523 | 524 | [number, "RECENT\r\n"] -> 525 | {:noreply, %{state | recent: String.to_integer(number)}} 526 | 527 | [_number, "EXPUNGE\r\n"] -> 528 | {:noreply, %{state | exists: state.exists - 1}} 529 | 530 | [number, "FETCH", rest] -> 531 | data = process_fetch_data(rest, state) 532 | 533 | {:noreply, 534 | %{state | temp: [{String.to_integer(number), parse_fetch_response(data)} | state.temp]}} 535 | 536 | # Ignore OK Still here response during IDLE 537 | ["OK" | _rest] -> 538 | {:noreply, state} 539 | 540 | _ -> 541 | Logger.warning("Unknown untagged response: #{msg}") 542 | {:noreply, state} 543 | end 544 | end 545 | 546 | defp handle_response(<<"+ idling", _rest::binary>>, state), 547 | do: {:noreply, state} 548 | 549 | defp handle_response(<>, state), 550 | do: handle_tagged_response(cmd_tag, msg, state) 551 | 552 | defp handle_response(msg, state) do 553 | Logger.warning("handle_response(socket, #{inspect(msg)}, #{inspect(state)})") 554 | {:noreply, state} 555 | end 556 | 557 | defp process_fetch_data(data, state) do 558 | case Regex.run(~r/\A(.+ {(\d+)}\r\n)\z/sm, data) do 559 | [_, initial, bytes] -> 560 | data = fetch_all_data(String.to_integer(bytes), 0, [initial], state) 561 | process_fetch_data(data, state) 562 | 563 | _ -> 564 | data 565 | end 566 | end 567 | 568 | defp handle_exists(number, %{socket: socket, idle_caller: caller, idle_timer: timer} = state) do 569 | if caller do 570 | cancel_idle(socket, timer) 571 | end 572 | 573 | {:noreply, %{state | exists: number}} 574 | end 575 | 576 | defp parse_fetch_response(string) do 577 | string 578 | |> parse_list_only 579 | |> list_to_items 580 | |> parse_fetch_results 581 | end 582 | 583 | defp parse_fetch_results(%{} = map) do 584 | map 585 | |> Enum.map(fn {key, value} -> 586 | try do 587 | parse_fetch_item(key, value) 588 | rescue 589 | _ -> 590 | {key, :error} 591 | end 592 | end) 593 | |> Map.new() 594 | end 595 | 596 | defp parse_fetch_item(:internal_date, datetime), 597 | do: {:internal_date, parse_timestamp(datetime)} 598 | 599 | defp parse_fetch_item(:uid, datetime), 600 | do: {:uid, parse_number(datetime)} 601 | 602 | defp parse_fetch_item(:envelope, envelope), 603 | do: {:envelope, Envelope.new(envelope)} 604 | 605 | defp parse_fetch_item(:body_structure, body_structure), 606 | do: {:body_structure, BodyStructure.new(body_structure)} 607 | 608 | defp parse_fetch_item(key, value), 609 | do: {key, value} 610 | 611 | defp to_sequence(number) when is_integer(number), 612 | do: Integer.to_string(number) 613 | 614 | defp to_sequence(%Range{first: first, last: first}), 615 | do: to_sequence(first) 616 | 617 | defp to_sequence(%Range{first: first, last: last}), 618 | do: [Integer.to_string(first), ":", Integer.to_string(last)] 619 | 620 | defp store_silent([]), do: "" 621 | 622 | defp store_silent([{:silent, _} | _tail]), 623 | do: ".SILENT" 624 | 625 | defp store_silent([_ | tail]), 626 | do: store_silent(tail) 627 | 628 | defp process_connection_message(<<"[CAPABILITY ", msg::binary>>, state), 629 | do: %{state | capability: parse_capability(msg)} 630 | 631 | defp process_connection_message(_msg, state), do: state 632 | 633 | defp handle_tagged_response(cmd_tag, <<"OK ", msg::binary>>, %{cmd_map: cmd_map} = state), 634 | do: process_command_response(cmd_tag, cmd_map[cmd_tag], msg, state) 635 | 636 | # defp handle_tagged_response(cmd_tag, <<"OK ", msg :: binary>>, state), 637 | # do: send_reply(cmd_tag, String.strip(msg), state) 638 | defp handle_tagged_response(cmd_tag, <<"NO ", msg::binary>>, %{cmd_map: cmd_map} = state), 639 | do: process_command_error(cmd_tag, cmd_map[cmd_tag], msg, state) 640 | 641 | defp handle_tagged_response(_cmd_tag, <<"BAD ", msg::binary>>, _state), 642 | do: raise("Bad command #{msg}") 643 | 644 | defp process_command_response( 645 | cmd_tag, 646 | %{command: "STARTTLS", caller: caller}, 647 | _msg, 648 | %{socket: socket, cmd_map: cmd_map, temp: %{username: username, password: password}} = 649 | state 650 | ) do 651 | {:ok, ssl_socket} = Socket.ssl_client(socket) 652 | state = %{state | cmd_map: Map.delete(cmd_map, cmd_tag)} 653 | state = %{state | socket: ssl_socket, capability: nil} 654 | 655 | {:noreply, 656 | send_command(caller, ["LOGIN", " ", quote_string(username), " ", quote_string(password)], %{ 657 | state 658 | | temp: nil 659 | })} 660 | end 661 | 662 | defp process_command_response( 663 | cmd_tag, 664 | %{command: "LOGIN", caller: caller}, 665 | msg, 666 | %{capability: capability} = state 667 | ) do 668 | state = remove_command_from_state(state, cmd_tag) 669 | state = process_connection_message(msg, state) 670 | 671 | if capability == [] do 672 | {:noreply, send_command(caller, "CAPABILITY", %{state | temp: msg})} 673 | else 674 | send_reply(caller, msg, %{state | state: :authenticated}) 675 | end 676 | end 677 | 678 | defp process_command_response( 679 | cmd_tag, 680 | %{command: "LOGOUT", caller: caller}, 681 | _msg, 682 | %{temp: {:error, error}} = state 683 | ) do 684 | state = remove_command_from_state(state, cmd_tag) 685 | send_error(caller, error, %{state | state: :logged_out}) 686 | end 687 | 688 | defp process_command_response(cmd_tag, %{command: "LOGOUT", caller: caller}, msg, state) do 689 | send_reply(caller, msg, %{remove_command_from_state(state, cmd_tag) | state: :logged_out}) 690 | end 691 | 692 | defp process_command_response( 693 | cmd_tag, 694 | %{command: "SELECT", caller: caller}, 695 | msg, 696 | %{temp: temp} = state 697 | ) do 698 | send_reply(caller, msg, %{ 699 | remove_command_from_state(state, cmd_tag) 700 | | state: :selected, 701 | mailbox: parse_mailbox({temp, msg}) 702 | }) 703 | end 704 | 705 | defp process_command_response( 706 | cmd_tag, 707 | %{command: "EXAMINE", caller: caller}, 708 | msg, 709 | %{temp: temp} = state 710 | ) do 711 | send_reply(caller, msg, %{ 712 | remove_command_from_state(state, cmd_tag) 713 | | state: :selected, 714 | mailbox: parse_mailbox({temp, msg}) 715 | }) 716 | end 717 | 718 | defp process_command_response( 719 | cmd_tag, 720 | %{command: "LIST", caller: caller}, 721 | _msg, 722 | %{temp: temp} = state 723 | ) 724 | when is_list(temp), 725 | do: send_reply(caller, Enum.reverse(temp), remove_command_from_state(state, cmd_tag)) 726 | 727 | defp process_command_response( 728 | cmd_tag, 729 | %{command: "STATUS", caller: caller}, 730 | _msg, 731 | %{temp: temp} = state 732 | ), 733 | do: send_reply(caller, temp, remove_command_from_state(state, cmd_tag)) 734 | 735 | defp process_command_response( 736 | cmd_tag, 737 | %{command: "FETCH", caller: caller}, 738 | _msg, 739 | %{temp: temp} = state 740 | ) do 741 | results = 742 | temp 743 | |> Enum.reverse() 744 | |> Enum.sort_by(fn {id, _result} -> id end) 745 | |> flatten_fetch_results() 746 | 747 | send_reply(caller, results, remove_command_from_state(state, cmd_tag)) 748 | end 749 | 750 | defp process_command_response( 751 | cmd_tag, 752 | %{command: "UID FETCH", caller: caller}, 753 | _msg, 754 | %{temp: temp} = state 755 | ), 756 | do: send_reply(caller, Enum.reverse(temp), remove_command_from_state(state, cmd_tag)) 757 | 758 | defp process_command_response( 759 | cmd_tag, 760 | %{command: "SEARCH", caller: caller}, 761 | _msg, 762 | %{temp: temp} = state 763 | ), 764 | do: send_reply(caller, temp, remove_command_from_state(state, cmd_tag)) 765 | 766 | defp process_command_response( 767 | cmd_tag, 768 | %{command: "UID SEARCH", caller: caller}, 769 | _msg, 770 | %{temp: temp} = state 771 | ), 772 | do: send_reply(caller, temp, remove_command_from_state(state, cmd_tag)) 773 | 774 | defp process_command_response( 775 | cmd_tag, 776 | %{command: "STORE", caller: caller}, 777 | _msg, 778 | %{temp: temp} = state 779 | ), 780 | do: send_reply(caller, Enum.reverse(temp), remove_command_from_state(state, cmd_tag)) 781 | 782 | defp process_command_response( 783 | cmd_tag, 784 | %{command: "CAPABILITY", caller: caller}, 785 | msg, 786 | %{temp: temp} = state 787 | ), 788 | do: send_reply(caller, temp || msg, remove_command_from_state(state, cmd_tag)) 789 | 790 | defp process_command_response(cmd_tag, %{command: "COPY", caller: caller}, msg, state), 791 | do: send_reply(caller, msg, remove_command_from_state(state, cmd_tag)) 792 | 793 | defp process_command_response(cmd_tag, %{command: "EXPUNGE", caller: caller}, msg, state), 794 | do: send_reply(caller, msg, remove_command_from_state(state, cmd_tag)) 795 | 796 | defp process_command_response(cmd_tag, %{command: "CLOSE", caller: caller}, msg, state) do 797 | send_reply(caller, msg, %{ 798 | remove_command_from_state(state, cmd_tag) 799 | | state: :authenticated, 800 | mailbox: nil 801 | }) 802 | end 803 | 804 | defp process_command_response( 805 | cmd_tag, 806 | %{command: "IDLE"}, 807 | _msg, 808 | %{idle_caller: caller, idle_reply_msg: idle_reply_msg} = state 809 | ) 810 | when not is_nil(idle_reply_msg) do 811 | send(caller, idle_reply_msg) 812 | 813 | {:noreply, 814 | %{ 815 | remove_command_from_state(state, cmd_tag) 816 | | idle_caller: nil, 817 | idle_reply_msg: nil, 818 | idle_timer: nil 819 | }} 820 | end 821 | 822 | defp process_command_response(cmd_tag, %{command: "IDLE", caller: caller}, _msg, state) do 823 | send_reply(caller, :ok, %{ 824 | remove_command_from_state(state, cmd_tag) 825 | | idle_caller: nil, 826 | idle_reply_msg: nil, 827 | idle_timer: nil 828 | }) 829 | end 830 | 831 | defp process_command_response(cmd_tag, %{command: command}, msg, state) do 832 | Logger.warning("Command not processed: #{cmd_tag} OK #{msg} - #{command} - #{inspect(state)}") 833 | {:noreply, state} 834 | end 835 | 836 | defp process_command_error(cmd_tag, %{command: "LOGIN", caller: caller}, msg, state) do 837 | state = remove_command_from_state(state, cmd_tag) 838 | {:noreply, send_command(caller, "LOGOUT", %{state | temp: {:error, msg}})} 839 | end 840 | 841 | defp process_command_error(cmd_tag, %{caller: caller}, msg, state), 842 | do: send_error(caller, msg, remove_command_from_state(state, cmd_tag)) 843 | 844 | defp remove_command_from_state(%{cmd_map: cmd_map} = state, cmd_tag), 845 | do: %{state | cmd_map: Map.delete(cmd_map, cmd_tag)} 846 | 847 | defp send_command( 848 | caller, 849 | command, 850 | %{socket: socket, cmd_number: cmd_number, cmd_map: cmd_map} = state 851 | ) do 852 | cmd_tag = "A#{String.pad_leading(Integer.to_string(cmd_number), 3, "0")}" 853 | :ok = Socket.send(socket, [cmd_tag, " ", command, "\r\n"]) 854 | 855 | %{ 856 | state 857 | | cmd_number: increment_command_number(cmd_number), 858 | cmd_map: Map.put_new(cmd_map, cmd_tag, %{command: hd(List.wrap(command)), caller: caller}) 859 | } 860 | end 861 | 862 | defp increment_command_number(999), do: 1 863 | defp increment_command_number(number), do: number + 1 864 | 865 | defp fetch_all_data(bytes_required, bytes_read, acc, _state) when bytes_read > bytes_required, 866 | do: :erlang.iolist_to_binary(Enum.reverse(acc)) 867 | 868 | defp fetch_all_data(bytes, bytes, acc, state), 869 | do: :erlang.iolist_to_binary(Enum.reverse([get_next_line(state) | acc])) 870 | 871 | defp fetch_all_data(bytes_required, bytes_read, acc, state) do 872 | line = get_next_line(state) 873 | fetch_all_data(bytes_required, bytes_read + byte_size(line), [line | acc], state) 874 | end 875 | 876 | defp get_next_line(%{debug: debug}) do 877 | receive do 878 | {:ssl, _socket, data} -> 879 | if debug, do: IO.write(["> [ssl] ", data]) 880 | data 881 | 882 | {:tcp, _socket, data} -> 883 | if debug, do: IO.write(["> [tcp] ", data]) 884 | data 885 | end 886 | end 887 | 888 | defp send_reply(caller, msg, state) do 889 | GenServer.reply(caller, {:ok, msg}) 890 | {:noreply, state} 891 | end 892 | 893 | defp send_error(caller, err_msg, state) do 894 | GenServer.reply(caller, {:error, trim(err_msg)}) 895 | {:noreply, state} 896 | end 897 | 898 | defp parse_mailbox({"INBOX", msg}), 899 | do: parse_mailbox({:inbox, msg}) 900 | 901 | defp parse_mailbox({name, <<"[READ-ONLY]", _rest::binary>>}), 902 | do: {name, :r} 903 | 904 | defp parse_mailbox({name, <<"[READ-WRITE]", _rest::binary>>}), 905 | do: {name, :rw} 906 | 907 | defp parse_capability(string) do 908 | [list | _] = String.split(trim(string), "]", parts: 2) 909 | String.split(list, " ") 910 | end 911 | 912 | if function_exported?(String, :trim, 1) do 913 | defp trim(string), do: String.trim(string) 914 | else 915 | defp trim(string), do: String.strip(string) 916 | end 917 | 918 | defp flatten_fetch_results([]), do: [] 919 | 920 | defp flatten_fetch_results([{id, result}, {id, result2} | rest]) do 921 | [{id, Map.merge(result, result2)} | flatten_fetch_results(rest)] 922 | end 923 | 924 | defp flatten_fetch_results([head | tail]) do 925 | [head | flatten_fetch_results(tail)] 926 | end 927 | end 928 | --------------------------------------------------------------------------------