├── test ├── test_helper.exs └── gmail │ ├── http_test.exs │ ├── thread │ └── worker_test.exs │ ├── utils_test.exs │ ├── oauth2_test.exs │ ├── base_test.exs │ ├── user_test.exs │ ├── message_attachment_test.exs │ ├── payload_test.exs │ ├── history_test.exs │ ├── draft_test.exs │ ├── label_test.exs │ ├── message_test.exs │ └── thread_test.exs ├── .gitignore ├── config ├── test.exs ├── dev.exs.sample └── config.exs ├── lib ├── gmail │ ├── user_manager.ex │ ├── supervisor.ex │ ├── body.ex │ ├── payload.ex │ ├── thread │ │ ├── pool_worker.ex │ │ └── pool.ex │ ├── message │ │ ├── pool_worker.ex │ │ └── pool.ex │ ├── history.ex │ ├── message_attachment.ex │ ├── utils.ex │ ├── base.ex │ ├── draft.ex │ ├── oauth2.ex │ ├── http.ex │ ├── message.ex │ ├── thread.ex │ ├── label.ex │ └── user.ex └── gmail.ex ├── .travis.yml ├── LICENSE ├── mix.exs ├── README.md ├── .credo.exs └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Application.ensure_all_started(:bypass) 3 | -------------------------------------------------------------------------------- /test/gmail/http_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.HTTPTest do 4 | use ExUnit.Case 5 | 6 | 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | erl_crash.dump 6 | *.ez 7 | *.beam 8 | dev.exs 9 | config/dev.exs 10 | .envrc 11 | doc 12 | tags 13 | docs 14 | .tool-versions 15 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :bypass, enable_debug_log: false 4 | 5 | config :gmail, :oauth2, 6 | client_id: "fake-client-id", 7 | client_secret: "fake-client-secret" 8 | -------------------------------------------------------------------------------- /test/gmail/thread/worker_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.Thread.WorkerTest do 4 | 5 | use ExUnit.Case 6 | 7 | test "dummy" do 8 | assert 1 == 1 9 | end 10 | 11 | 12 | end 13 | -------------------------------------------------------------------------------- /config/dev.exs.sample: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :gmail, :oauth2, [ 4 | client_id: "CLIENT_ID", 5 | client_secret: "CLIENT_SECRET" 6 | ] 7 | 8 | config :gmail, :thread, 9 | pool_size: 100 10 | 11 | config :gmail, :message, 12 | pool_size: 100 13 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :gmail, :thread, 4 | pool_size: 100 5 | 6 | config :gmail, :message, 7 | pool_size: 100 8 | 9 | path = __DIR__ |> Path.expand |> Path.join("#{Mix.env}.exs") 10 | if File.exists?(path), do: import_config "#{Mix.env}.exs" 11 | -------------------------------------------------------------------------------- /lib/gmail/user_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.UserManager do 2 | 3 | @moduledoc """ 4 | Supervises user processes. 5 | """ 6 | 7 | use Supervisor 8 | 9 | @doc false 10 | def start_link do 11 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 12 | end 13 | 14 | @doc false 15 | def init(:ok) do 16 | [worker(Gmail.User, [], restart: :transient)] 17 | |> supervise(strategy: :simple_one_for_one) 18 | end 19 | 20 | end 21 | -------------------------------------------------------------------------------- /test/gmail/utils_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.UtilsTest do 4 | use ExUnit.Case 5 | alias Gmail.Utils 6 | 7 | test "loads config" do 8 | assert 100 == Utils.load_config(:thread, :pool_size) 9 | end 10 | 11 | test "loads config with a default" do 12 | assert 101 == Utils.load_config(:thread, :other_pool_size, 101) 13 | end 14 | 15 | test "load non existent config with a default" do 16 | assert 102 == Utils.load_config(:absent, :pool_size, 102) 17 | end 18 | 19 | end 20 | 21 | -------------------------------------------------------------------------------- /lib/gmail/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Supervisor do 2 | 3 | @moduledoc """ 4 | Supervises all the things. 5 | """ 6 | 7 | use Supervisor 8 | 9 | @doc false 10 | def start_link do 11 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 12 | end 13 | 14 | @doc false 15 | def init(:ok) do 16 | children = [ 17 | supervisor(Gmail.UserManager, []), 18 | supervisor(Gmail.Thread.Pool, []), 19 | supervisor(Gmail.Message.Pool, []) 20 | ] 21 | supervise(children, strategy: :one_for_one) 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /test/gmail/oauth2_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.OAuth2Test do 4 | 5 | use ExUnit.Case 6 | import Mock 7 | 8 | doctest Gmail.OAuth2 9 | 10 | # TODO use bypass instead of a mock 11 | test "refreshes an expired access token" do 12 | expires_in = 10 13 | access_token = "fake_access_token" 14 | body = "{ \"access_token\": \"#{access_token}\", \"expires_in\": #{expires_in}}" 15 | response = %HTTPoison.Response{body: body} 16 | with_mock HTTPoison, [ post: fn _url, _payload, _headers -> {:ok, response} end ] do 17 | assert {^access_token, _} = Gmail.OAuth2.refresh_access_token("fake-refresh-token") 18 | end 19 | end 20 | 21 | end 22 | -------------------------------------------------------------------------------- /test/gmail/base_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.BaseTest do 4 | use ExUnit.Case 5 | 6 | test "uses the url in the app config if there is one" do 7 | url = "http://appconfig.example.com" 8 | Application.put_env :gmail, :api, %{url: url} 9 | assert Gmail.Base.base_url == url 10 | end 11 | 12 | test "uses the default base url if nothing is set in the app config" do 13 | Application.delete_env :gmail, :api 14 | assert Gmail.Base.base_url == "https://www.googleapis.com/gmail/v1/" 15 | end 16 | 17 | test "uses the default base url if app config is set but has no url" do 18 | Application.put_env :gmail, :api, %{nothing: "here"} 19 | assert Gmail.Base.base_url == "https://www.googleapis.com/gmail/v1/" 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /lib/gmail/body.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Body do 2 | 3 | @moduledoc """ 4 | Helper functions for dealing with email bodies. 5 | """ 6 | 7 | alias __MODULE__ 8 | alias Gmail.Utils 9 | 10 | defstruct size: 0, data: "", attachment_id: "" 11 | @type t :: %__MODULE__{} 12 | 13 | @doc """ 14 | Converts the email body, attempting to decode from Base64 if there is body data. 15 | """ 16 | @spec convert(Map.t) :: Body.t 17 | def convert(body) do 18 | {data, body} = body |> Utils.atomise_keys |> Map.pop(:data) 19 | body = if data, do: Map.put(body, :data, decode_body(data)), else: body 20 | struct(Body, body) 21 | end 22 | 23 | @spec decode_body(String.t) :: String.t 24 | defp decode_body(data) do 25 | case Base.decode64(data) do 26 | {:ok, message} -> 27 | message 28 | :error -> 29 | data 30 | end 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /test/gmail/user_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.UserTest do 4 | 5 | use ExUnit.Case 6 | import Mock 7 | 8 | setup do 9 | user_id = "user12@example.com" 10 | {:ok, %{ 11 | user_id: user_id 12 | }} 13 | end 14 | 15 | test "stops a user process instance that has been started", %{user_id: user_id} do 16 | with_mock Gmail.OAuth2, [refresh_access_token: fn(_) -> {"not-an-access-token", 100000000000000} end] do 17 | {:ok, _pid} = Gmail.User.start_mail(user_id, "not-a-refresh-token") 18 | assert nil != Process.whereis(String.to_atom(user_id)) 19 | assert :ok == Gmail.User.stop_mail(user_id) 20 | end 21 | end 22 | 23 | test "attempts to stop a user process instance that has not been started", %{user_id: user_id} do 24 | assert nil == Process.whereis(String.to_atom(user_id)) 25 | assert :ok == Gmail.User.stop_mail(user_id) 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /lib/gmail/payload.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Payload do 2 | 3 | @moduledoc """ 4 | Utils functions for dealing with email payloads. 5 | """ 6 | 7 | alias __MODULE__ 8 | alias Gmail.{Body, Utils} 9 | 10 | defstruct part_id: "", 11 | mime_type: "", 12 | filename: "", 13 | headers: [], 14 | body: %Body{}, 15 | parts: [] 16 | 17 | @type t :: %__MODULE__{} 18 | 19 | @doc """ 20 | Converts an email payload. 21 | """ 22 | @spec convert(map) :: Payload.t 23 | def convert(result) do 24 | {body, payload} = 25 | result 26 | |> Utils.atomise_keys 27 | |> Map.pop(:body) 28 | {parts, payload} = Map.pop(payload, :parts) 29 | payload = struct(Payload, payload) 30 | payload = if body, do: Map.put(payload, :body, Body.convert(body)), else: payload 31 | if parts do 32 | parts = Enum.map(parts, &convert/1) 33 | Map.put(payload, :parts, parts) 34 | else 35 | payload 36 | end 37 | end 38 | 39 | end 40 | -------------------------------------------------------------------------------- /lib/gmail/thread/pool_worker.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Thread.PoolWorker do 2 | 3 | @moduledoc """ 4 | A thread pool worker. 5 | """ 6 | 7 | use GenServer 8 | alias Gmail.{Thread, User} 9 | 10 | @doc false 11 | def start_link([]) do 12 | GenServer.start_link(__MODULE__, [], []) 13 | end 14 | 15 | @doc false 16 | def init(state) do 17 | {:ok, state} 18 | end 19 | 20 | @doc false 21 | def handle_call({:get, user_id, thread_id, params, state}, _from, worker_state) do 22 | result = 23 | user_id 24 | |> Thread.get(thread_id, params) 25 | |> User.http_execute(state) 26 | |> Thread.handle_thread_response 27 | {:reply, result, worker_state} 28 | end 29 | 30 | @doc """ 31 | Gets a thread. 32 | """ 33 | @spec get(pid, String.t, String.t, map, map) :: {atom, map} 34 | def get(pid, user_id, thread_id, params, state) do 35 | GenServer.call(pid, {:get, user_id, thread_id, params, state}, :infinity) 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /lib/gmail/message/pool_worker.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Message.PoolWorker do 2 | 3 | @moduledoc """ 4 | A message pool worker. 5 | """ 6 | 7 | use GenServer 8 | alias Gmail.{Message, User} 9 | 10 | @doc false 11 | def start_link([]) do 12 | GenServer.start_link(__MODULE__, [], []) 13 | end 14 | 15 | @doc false 16 | def init(state) do 17 | {:ok, state} 18 | end 19 | 20 | @doc false 21 | def handle_call({:get, user_id, message_id, params, state}, _from, worker_state) do 22 | result = 23 | user_id 24 | |> Message.get(message_id, params) 25 | |> User.http_execute(state) 26 | |> Message.handle_message_response 27 | {:reply, result, worker_state} 28 | end 29 | 30 | @doc """ 31 | Gets a message. 32 | """ 33 | @spec get(pid, String.t, String.t, map, map) :: {atom, map} 34 | def get(pid, user_id, message_id, params, state) do 35 | GenServer.call(pid, {:get, user_id, message_id, params, state}, :infinity) 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | env: 3 | - MIX_ENV=test 4 | elixir: 5 | - 1.3.0 6 | otp_release: 7 | - 18.2.1 8 | script: 9 | - MIX_ENV=test mix do deps.get, test && mix compile && mix coveralls.travis 10 | notifications: 11 | slack: 12 | secure: kRpGSGtNgRXLdNMZkfs9pB9/9mezVpboGazwNsDH77Ym9JT1UW9hVx0JI0mI8jvRrHiKbnY/HbirGZdRraxt0VbRPwv1psPhWAv/r0xpHikDOXVxxdS1wcLZNuMyO1tdP6IQzD2Gkr0MA9TkvpiE6bjQ7dTDJiCrQxwBeplShX47p7KPij5VLDsHmt6eF8KFmZF6ftL1o1EjVA81+hU1uTvx+IJQ2uFG40dYVONGtNOH4gug2Kx8xuJq5lnqvViIm3BVGjnjid1O+9daHdypTcZWAoG6Y1Hx1+/1CHhZ9V5ndN4T4LasztaNc2pJmxALv1Q8osxzmpXMYGkkUukZnbN/MfmS1/yef7Z7ahdCSSOyCHAhFfo9jeNBMiqVmm5L6LacCIyN5fnC6wd8S7FkzMEJP2gP94h+v8MIiCGzOraJxxG0WFCpaQ7I9/0vjjqjPAMAmhdl5ym8/0f+G7xl+1Fx9UmHDTRpJEQ9l8XoVdbNA330xNrNImZd+ZTC0KdYHkRGWKm72bK4aMpaHFtfCG5E1Er7c8ifSATjgbscD5n991ixrzyksDOLoJGe8xxN9oPZVYP6onUk8K9pUKuXHH0FPvw0uOjeMuu8dX8crFfa/EzXRQWBR9VsIc1x6KCnMA73wnAtaJVNs2wQKkp8Oi689n+7KBD/YUMaQWpDaOA= 13 | after_script: 14 | - MIX_ENV=docs mix deps.get 15 | - MIX_ENV=docs mix inch.report 16 | -------------------------------------------------------------------------------- /lib/gmail/history.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.History do 2 | 3 | alias Gmail.Utils 4 | 5 | @moduledoc """ 6 | Lists the history of all changes to the given mailbox. 7 | """ 8 | 9 | import Gmail.Base 10 | 11 | @doc """ 12 | Lists the history of all changes to the given mailbox. History results are returned in 13 | chronological order (increasing `historyId`). 14 | """ 15 | @spec list(String.t, map) :: {atom, String.t, String.t} 16 | def list(user_id, params) do 17 | available_options = [:label_id, :max_results, :page_token, :start_history_id] 18 | path = querify_params("users/#{user_id}/history", available_options, params) 19 | {:get, base_url(), path} 20 | end 21 | 22 | @doc """ 23 | Handles a history response from the Gmail API. 24 | """ 25 | def handle_history_response(response) do 26 | case response do 27 | {:ok, %{"error" => %{"code" => 404}}} -> 28 | :not_found 29 | {:ok, %{"error" => %{"code" => 400, "errors" => errors}}} -> 30 | {:error, errors} 31 | {:ok, %{"history" => history}} -> 32 | {:ok, Utils.atomise_keys(history)} 33 | end 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Craig Paterson 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 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /lib/gmail/thread/pool.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Thread.Pool do 2 | 3 | @moduledoc """ 4 | A pool of workers for handling thread operations. 5 | """ 6 | 7 | alias Gmail.Thread.PoolWorker 8 | alias Gmail.Utils 9 | require Logger 10 | 11 | @default_pool_size 20 12 | 13 | @doc false 14 | def start_link do 15 | poolboy_config = [ 16 | {:name, {:local, :thread_pool}}, 17 | {:worker_module, PoolWorker}, 18 | {:size, pool_size()}, 19 | {:max_overflow, 0} 20 | ] 21 | 22 | children = [ 23 | :poolboy.child_spec(:thread_pool, poolboy_config, []) 24 | ] 25 | 26 | options = [ 27 | strategy: :one_for_one, 28 | name: __MODULE__ 29 | ] 30 | 31 | Supervisor.start_link(children, options) 32 | end 33 | 34 | @doc """ 35 | Gets a thread. 36 | """ 37 | @spec get(String.t, String.t, map, map) :: {atom, map} 38 | def get(user_id, thread_id, params, state) do 39 | :poolboy.transaction( 40 | :thread_pool, 41 | fn pid -> 42 | PoolWorker.get(pid, user_id, thread_id, params, state) 43 | end, 44 | :infinity) 45 | end 46 | 47 | @spec pool_size() :: integer 48 | defp pool_size do 49 | Utils.load_config(:thread, :pool_size, @default_pool_size) 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /lib/gmail/message/pool.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Message.Pool do 2 | 3 | @moduledoc """ 4 | A pool of workers for handling message operations. 5 | """ 6 | 7 | alias Gmail.Message.PoolWorker 8 | alias Gmail.Utils 9 | require Logger 10 | 11 | @default_pool_size 20 12 | 13 | @doc false 14 | def start_link do 15 | poolboy_config = [ 16 | {:name, {:local, :__gmail_message_pool}}, 17 | {:worker_module, PoolWorker}, 18 | {:size, pool_size()}, 19 | {:max_overflow, 0} 20 | ] 21 | 22 | children = [ 23 | :poolboy.child_spec(:__gmail_message_pool, poolboy_config, []) 24 | ] 25 | 26 | options = [ 27 | strategy: :one_for_one, 28 | name: __MODULE__ 29 | ] 30 | 31 | Supervisor.start_link(children, options) 32 | end 33 | 34 | @doc """ 35 | Gets a message. 36 | """ 37 | @spec get(String.t, String.t, map, map) :: {atom, map} 38 | def get(user_id, message_id, params, state) do 39 | :poolboy.transaction( 40 | :__gmail_message_pool, 41 | fn pid -> 42 | PoolWorker.get(pid, user_id, message_id, params, state) 43 | end, 44 | :infinity) 45 | end 46 | 47 | @spec pool_size() :: integer 48 | defp pool_size do 49 | Utils.load_config(:message, :pool_size, @default_pool_size) 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :gmail, 6 | version: "0.1.20", 7 | build_embedded: Mix.env == :prod, 8 | start_permanent: Mix.env == :prod, 9 | deps: deps(), 10 | test_coverage: [tool: ExCoveralls], 11 | preferred_cli_env: ["coveralls": :test, "coveralls.detail": :test, "coveralls.post": :test], 12 | description: "A simple Gmail REST API client for Elixir", 13 | package: package()] 14 | end 15 | 16 | def application do 17 | [extra_applications: [:logger], 18 | mod: {Gmail, []}] 19 | end 20 | 21 | defp deps do 22 | [ 23 | {:poolboy, "~> 1.5"}, 24 | {:httpoison, "~> 1.1"}, 25 | {:poison, "~> 2.1 or ~> 3.1"}, 26 | {:mock, "~> 0.1", only: :test}, 27 | {:excoveralls, "~> 0.5", only: :test}, 28 | {:earmark, "~> 1.0", only: :dev}, 29 | {:ex_doc, "~> 0.13", only: :dev}, 30 | {:dialyxir, "~> 0.3", only: :dev}, 31 | {:credo, "~> 0.3", only: :dev}, 32 | {:bypass, "~> 0.1", only: :test}, 33 | {:inch_ex, "~> 0.5", only: :docs}, 34 | {:mix_test_watch, "~> 0.2", only: :dev} 35 | ] 36 | end 37 | 38 | defp package do 39 | [ 40 | files: ["lib", "mix.exs", "README*", "LICENSE*"], 41 | licenses: ["MIT"], 42 | maintainers: ["Craig Paterson"], 43 | links: %{"Github" => "https://github.com/craigp/elixir-gmail"} 44 | ] 45 | end 46 | 47 | end 48 | -------------------------------------------------------------------------------- /lib/gmail/message_attachment.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.MessageAttachment do 2 | 3 | @moduledoc """ 4 | An email message attachment. 5 | """ 6 | 7 | alias __MODULE__ 8 | alias Gmail.Utils 9 | import Gmail.Base 10 | 11 | @doc """ 12 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/messages/attachments 13 | """ 14 | defstruct attachmentId: "", 15 | size: 0, 16 | data: "" 17 | 18 | @type t :: %__MODULE__{} 19 | 20 | @doc """ 21 | Gets the specified attachment. 22 | 23 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/messages/attachments/get 24 | """ 25 | @spec get(String.t, String.t, String.t) :: {atom, String.t, String.t} 26 | def get(user_id, message_id, id) do 27 | path = querify_params("users/#{user_id}/messages/#{message_id}/attachments/#{id}", [], %{}) 28 | {:get, base_url(), path} 29 | end 30 | 31 | @doc """ 32 | Converts a Gmail API attachment resource into a local struct. 33 | """ 34 | @spec convert(map) :: MessageAttachment.t 35 | def convert(message) do 36 | attachment = message |> Utils.atomise_keys 37 | struct(MessageAttachment, attachment) 38 | end 39 | 40 | @doc """ 41 | Handles an attachment resource response from the Gmail API. 42 | """ 43 | def handle_attachment_response(response) do 44 | response 45 | |> handle_error 46 | |> case do 47 | {:error, message} -> 48 | {:error, message} 49 | {:ok, raw_message} -> 50 | {:ok, MessageAttachment.convert(raw_message)} 51 | end 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /test/gmail/message_attachment_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.MessageAttachmentTest do 4 | use ExUnit.Case 5 | import Mock 6 | 7 | setup do 8 | user_id = "user@example.com" 9 | access_token = "xxx-xxx-xxx" 10 | bypass = Bypass.open 11 | Application.put_env :gmail, :api, %{url: "http://localhost:#{bypass.port}/gmail/v1/"} 12 | Gmail.User.stop_mail(user_id) 13 | 14 | with_mock Gmail.OAuth2, [refresh_access_token: fn(_) -> {access_token, 100000000000000} end] do 15 | {:ok, _server_pid} = Gmail.User.start_mail(user_id, "dummy-refresh-token") 16 | end 17 | 18 | { 19 | :ok, 20 | access_token: access_token, 21 | bypass: bypass, 22 | user_id: user_id, 23 | } 24 | end 25 | 26 | test "gets message attachment", state do 27 | attachment_id = "22222" 28 | message_id = "11111" 29 | result = %{ 30 | size: 30, 31 | data: "VGhpcyBpcyBhIHRlc3QgdGV4dCBkb2N1bWVudC4K" 32 | } 33 | 34 | Bypass.expect state[:bypass], fn conn -> 35 | assert "/gmail/v1/users/#{state[:user_id]}/messages/#{message_id}/attachments/#{attachment_id}" == conn.request_path 36 | 37 | assert "" == conn.query_string 38 | 39 | assert {"authorization", "Bearer #{state[:access_token]}"} in conn.req_headers 40 | 41 | assert "GET" == conn.method 42 | 43 | {:ok, json} = Poison.encode(result) 44 | 45 | Plug.Conn.resp(conn, 200, json) 46 | end 47 | 48 | {:ok, result} = Gmail.User.attachment(state[:user_id], message_id, attachment_id) 49 | 50 | assert result == %Gmail.MessageAttachment{size: 30, data: "VGhpcyBpcyBhIHRlc3QgdGV4dCBkb2N1bWVudC4K"} 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/gmail/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Utils do 2 | 3 | @moduledoc """ 4 | General helper functions. 5 | """ 6 | 7 | @doc """ 8 | Converts a map with string keys to a map with atom keys 9 | """ 10 | @spec atomise_keys(any) :: map 11 | def atomise_keys(map) when is_map(map) do 12 | Enum.reduce(map, %{}, &atomise_key/2) 13 | end 14 | 15 | def atomise_keys(list) when is_list(list) do 16 | Enum.map(list, &atomise_keys/1) 17 | end 18 | 19 | def atomise_keys(not_a_map) do 20 | not_a_map 21 | end 22 | 23 | def atomise_key({key, val}, map) when is_binary(key) do 24 | key = 25 | key 26 | |> Macro.underscore 27 | |> String.to_atom 28 | atomise_key({key, val}, map) 29 | end 30 | 31 | def atomise_key({key, val}, map) do 32 | Map.put(map, key, atomise_keys(val)) 33 | end 34 | 35 | @doc """ 36 | Camelizes a string (with the first letter in lower case) 37 | """ 38 | def camelize(str) when is_atom(str) do 39 | str |> Atom.to_string |> camelize 40 | end 41 | 42 | def camelize(str) do 43 | [first|rest] = str |> Macro.camelize |> String.codepoints 44 | [String.downcase(first)|rest] |> Enum.join 45 | end 46 | 47 | @doc """ 48 | Loads the config value for a specified subject and key. 49 | """ 50 | @spec load_config(atom, atom, any) :: any 51 | def load_config(subject, key, default) when is_atom(subject) and is_atom(key) do 52 | subject 53 | |> load_config 54 | |> Keyword.get(key, default) 55 | end 56 | 57 | @spec load_config(atom, atom) :: any 58 | def load_config(subject, key) when is_atom(subject) and is_atom(key) do 59 | load_config(subject, key, nil) 60 | end 61 | 62 | @spec load_config(atom) :: list 63 | def load_config(subject) do 64 | Application.get_env(:gmail, subject, []) 65 | end 66 | 67 | end 68 | -------------------------------------------------------------------------------- /lib/gmail.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail do 2 | 3 | @moduledoc """ 4 | A simple Gmail REST API client for Elixir. 5 | 6 | You can find the hex package [here](https://hex.pm/packages/gmail), and the docs [here](http://hexdocs.pm/gmail). 7 | 8 | You can find documentation for Gmail's API at https://developers.google.com/gmail/api/ 9 | 10 | ## Usage 11 | 12 | First, add the client to your `mix.exs` dependencies: 13 | 14 | ```elixir 15 | def deps do 16 | [{:gmail, "~> 0.1"}] 17 | end 18 | ``` 19 | 20 | Then run `$ mix do deps.get, compile` to download and compile your dependencies. 21 | 22 | Finally, add the `:gmail` application as your list of applications in `mix.exs`: 23 | 24 | ```elixir 25 | def application do 26 | [applications: [:logger, :gmail]] 27 | end 28 | ``` 29 | 30 | Before you can work with mail for a user you'll need to start a process for them. 31 | 32 | ```elixir 33 | {:ok, pid} = Gmail.User.start_mail("user@example.com", "user-refresh-token") 34 | ``` 35 | 36 | When a user process starts it will automatically fetch a new access token for that user. Then 37 | you can start playing with mail: 38 | 39 | ```elixir 40 | # fetch a list of threads 41 | {:ok, threads, next_page_token} = Gmail.User.threads("user@example.com") 42 | 43 | # fetch the next page of threads using a page token 44 | {:ok, _, _} = Gmail.User.threads("user@example.com", %{page_token: next_page_token}) 45 | 46 | # fetch a thread by ID 47 | {:ok, thread} = Gmail.User.thread("user@example.com", "1233454566") 48 | 49 | # fetch a list of labels 50 | {:ok, labels} = Gmail.User.labels("user@example.com") 51 | ``` 52 | 53 | Check the docs for a more complete list of functionality. 54 | """ 55 | 56 | use Application 57 | # alias Gmail.Thread 58 | 59 | # @spec search(String.t) :: {atom, [Thread.t]} 60 | # defdelegate search(query), to: Thread 61 | 62 | def start(_type, _args) do 63 | Gmail.Supervisor.start_link 64 | end 65 | 66 | def stop(_args) do 67 | # noop 68 | end 69 | 70 | end 71 | -------------------------------------------------------------------------------- /test/gmail/payload_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.PayloadTest do 4 | 5 | use ExUnit.Case 6 | 7 | test "converts a payload with a part ID and parts" do 8 | mimeType = "mimeType" 9 | filename = "some_file_name" 10 | headers = ["header_1", "header_2"] 11 | body = %{"data" => "body_data", "size" => 12} 12 | parts = [%{ 13 | "partId" => "1", 14 | "filename" => filename, 15 | "mimeType" => mimeType, 16 | "headers" => headers, 17 | "body" => body, 18 | "parts" => [] 19 | }, %{ 20 | "partId" => "2", 21 | "filename" => filename, 22 | "mimeType" => mimeType, 23 | "headers" => headers, 24 | "body" => body 25 | }] 26 | payload = %{"mimeType" => mimeType, 27 | "filename" => filename, 28 | "headers" => headers, 29 | "body" => body, 30 | "parts" => parts} 31 | Gmail.Payload.convert(payload) 32 | end 33 | 34 | test "converts a payload with parts but no part ID" do 35 | mimeType = "mimeType" 36 | filename = "some_file_name" 37 | headers = ["header_1", "header_2"] 38 | body = %{"data" => "body_data", "size" => 12} 39 | parts = [%{ 40 | "partId" => "1", 41 | "filename" => filename, 42 | "mimeType" => mimeType, 43 | "headers" => headers, 44 | "body" => body 45 | }, %{ 46 | "partId" => "2", 47 | "filename" => filename, 48 | "mimeType" => mimeType, 49 | "headers" => headers, 50 | "body" => body 51 | }] 52 | payload = %{"mimeType" => mimeType, 53 | "filename" => filename, 54 | "headers" => headers, 55 | "body" => body, 56 | "parts" => parts} 57 | Gmail.Payload.convert(payload) 58 | end 59 | 60 | test "converts a payload with no parts or part ID" do 61 | filename = "some_file_name" 62 | headers = ["header_1", "header_2"] 63 | body = %{"data" => "body_data", "size" => 12} 64 | mimeType = "mimeType" 65 | payload = %{"mimeType" => mimeType, 66 | "filename" => filename, 67 | "headers" => headers, 68 | "body" => body} 69 | Gmail.Payload.convert(payload) 70 | end 71 | 72 | end 73 | -------------------------------------------------------------------------------- /lib/gmail/base.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Base do 2 | 3 | @moduledoc """ 4 | Base class for common functionality. 5 | """ 6 | 7 | alias Gmail.Utils 8 | 9 | @default_base_url "https://www.googleapis.com/gmail/v1/" 10 | 11 | @doc """ 12 | Gets the base URL for Gmail API requests 13 | """ 14 | @spec base_url() :: String.t 15 | def base_url do 16 | case Application.fetch_env(:gmail, :api) do 17 | {:ok, %{url: url}} -> 18 | url 19 | {:ok, api_config} -> 20 | Application.put_env(:gmail, :api, Map.put(api_config, :url, @default_base_url)) 21 | base_url() 22 | :error -> 23 | Application.put_env(:gmail, :api, %{url: @default_base_url}) 24 | base_url() 25 | end 26 | end 27 | 28 | @spec querify_params(String.t, list, map) :: String.t 29 | def querify_params(path, available_options, params) do 30 | if Enum.empty?(params) do 31 | path 32 | else 33 | query = 34 | params 35 | |> Map.keys 36 | |> Enum.filter(fn key -> key in available_options end) 37 | |> Enum.reduce(Map.new, fn key, query -> 38 | string_key = Utils.camelize(key) 39 | val = if is_list(params[key]) do 40 | Enum.join(params[key], ",") 41 | else 42 | params[key] 43 | end 44 | Map.put(query, string_key, val) 45 | end) 46 | if Enum.empty?(query) do 47 | path 48 | else 49 | path <> "?" <> URI.encode_query(query) 50 | end 51 | end 52 | end 53 | 54 | @spec handle_error({atom, map}) :: {atom, String.t} | {atom, map} 55 | @spec handle_error({atom, String.t}) :: {atom, String.t} | {atom, map} 56 | def handle_error(response) do 57 | case response do 58 | {:ok, %{"error" => %{"code" => 404}}} -> 59 | {:error, :not_found} 60 | {:ok, %{"error" => %{"code" => 400, "errors" => errors}}} -> 61 | [%{"message" => error_message}|_rest] = errors 62 | {:error, error_message} 63 | {:ok, %{"error" => details}} -> 64 | {:error, details} 65 | {:ok, other} -> 66 | {:ok, other} 67 | {:error, reason} -> 68 | {:error, reason} 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/gmail/draft.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Draft do 2 | 3 | @moduledoc""" 4 | A draft email in the user's mailbox. 5 | """ 6 | 7 | alias __MODULE__ 8 | alias Gmail.{Message} 9 | import Gmail.Base 10 | 11 | @doc """ 12 | > Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/drafts#resource 13 | """ 14 | defstruct id: "", 15 | message: nil 16 | 17 | @type t :: %__MODULE__{} 18 | 19 | @doc """ 20 | Gets the specified draft. 21 | 22 | > Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/drafts/get 23 | """ 24 | @spec get(String.t, String.t) :: {atom, String.t, String.t} 25 | def get(user_id, draft_id) do 26 | {:get, base_url(), "users/#{user_id}/drafts/#{draft_id}"} 27 | end 28 | 29 | @doc """ 30 | Lists the drafts in the user's mailbox. 31 | 32 | > Gmail API Documentation: https://developers.google.com/gmail/api/v1/reference/users/drafts/list 33 | """ 34 | @spec list(String.t) :: {atom, String.t, String.t} 35 | def list(user_id) do 36 | {:get, base_url(), "users/#{user_id}/drafts"} 37 | end 38 | 39 | @doc """ 40 | Immediately and permanently deletes the specified draft. Does not simply trash it. 41 | 42 | > Gmail API Documentation: https://developers.google.com/gmail/api/v1/reference/users/drafts/delete 43 | """ 44 | @spec delete(String.t, String.t) :: {atom, String.t, String.t} 45 | def delete(user_id, draft_id) do 46 | {:delete, base_url(), "users/#{user_id}/drafts/#{draft_id}"} 47 | end 48 | 49 | @doc """ 50 | Sends the specified, existing draft to the recipients in the `To`, `Cc`, and `Bcc` headers. 51 | 52 | > Gmail API Documentation: https://developers.google.com/gmail/api/v1/reference/users/drafts/send 53 | """ 54 | @spec send(String.t, String.t) :: {atom, String.t, String.t, map} 55 | def send(user_id, draft_id) do 56 | {:post, base_url(), "users/#{user_id}/drafts/send", %{"id" => draft_id}} 57 | end 58 | 59 | @doc """ 60 | Converts a Gmail API draft resource into a local struct. 61 | """ 62 | @spec convert(map) :: Draft.t 63 | def convert(%{"id" => id, 64 | "message" => %{"id" => message_id, "threadId" => thread_id}}) do 65 | %Draft{ 66 | id: id, 67 | message: %Message{id: message_id, thread_id: thread_id} 68 | } 69 | end 70 | 71 | end 72 | 73 | -------------------------------------------------------------------------------- /test/gmail/history_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.HistoryTest do 4 | 5 | use ExUnit.Case 6 | import Mock 7 | 8 | setup do 9 | user_id = "user@example.com" 10 | access_token = "xxx-xxx-xxx" 11 | history = %{"history" => [ 12 | %{"historyId" => 12345}, 13 | %{"historyId" => 12346}, 14 | ]} 15 | bypass = Bypass.open 16 | Application.put_env :gmail, :api, %{url: "http://localhost:#{bypass.port}/gmail/v1/"} 17 | Gmail.User.stop_mail(user_id) 18 | with_mock Gmail.OAuth2, [refresh_access_token: fn(_) -> {access_token, 100000000000000} end] do 19 | {:ok, _server_pid} = Gmail.User.start_mail(user_id, "dummy-refresh-token") 20 | end 21 | {:ok, 22 | access_token: access_token, 23 | user_id: user_id, 24 | bypass: bypass, 25 | history: history 26 | } 27 | end 28 | 29 | test "gets a list of history items", %{ 30 | bypass: bypass, 31 | user_id: user_id, 32 | access_token: access_token, 33 | history: %{"history" => history_items} = history 34 | } do 35 | Bypass.expect bypass, fn conn -> 36 | assert "/gmail/v1/users/#{user_id}/history" == conn.request_path 37 | assert "" == conn.query_string 38 | assert "GET" == conn.method 39 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 40 | {:ok, json} = Poison.encode(history) 41 | Plug.Conn.resp(conn, 200, json) 42 | end 43 | {:ok, user_history} = Gmail.User.history(user_id) 44 | assert user_history == Gmail.Utils.atomise_keys(history_items) 45 | end 46 | 47 | test "gets a list of history items with a max number of results", %{ 48 | bypass: bypass, 49 | user_id: user_id, 50 | access_token: access_token, 51 | history: %{"history" => history_items} = history 52 | } do 53 | Bypass.expect bypass, fn conn -> 54 | assert "/gmail/v1/users/#{user_id}/history" == conn.request_path 55 | assert URI.encode_query(%{"maxResults" => 20}) == conn.query_string 56 | assert "GET" == conn.method 57 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 58 | {:ok, json} = Poison.encode(history) 59 | Plug.Conn.resp(conn, 200, json) 60 | end 61 | {:ok, user_history} = Gmail.User.history(user_id, %{max_results: 20}) 62 | assert user_history == Gmail.Utils.atomise_keys(history_items) 63 | end 64 | 65 | end 66 | -------------------------------------------------------------------------------- /lib/gmail/oauth2.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.OAuth2 do 2 | 3 | @moduledoc """ 4 | OAuth2 access token handling. 5 | """ 6 | 7 | alias Gmail.Utils 8 | import Poison, only: [decode: 1] 9 | 10 | @token_url "https://accounts.google.com/o/oauth2/token" 11 | @token_headers %{"Content-Type" => "application/x-www-form-urlencoded"} 12 | 13 | # Client API {{{ # 14 | 15 | @doc ~S""" 16 | Checks if an access token has expired. 17 | 18 | ### Examples 19 | 20 | iex> Gmail.OAuth2.access_token_expired?(%{expires_at: 1}) 21 | true 22 | 23 | iex> Gmail.OAuth2.access_token_expired?(%{expires_at: (DateTime.to_unix(DateTime.utc_now) + 10)}) 24 | false 25 | 26 | """ 27 | @spec access_token_expired?(map) :: boolean 28 | def access_token_expired?(%{expires_at: expires_at}) do 29 | :os.system_time(:seconds) >= expires_at 30 | end 31 | 32 | @spec refresh_access_token(String.t) :: {String.t, number} 33 | def refresh_access_token(refresh_token) when is_binary(refresh_token) do 34 | {:ok, access_token, expires_at} = do_refresh_access_token(refresh_token) 35 | {access_token, expires_at} 36 | end 37 | 38 | # }}} Client API # 39 | 40 | # Private functions {{{ # 41 | 42 | @typep refresh_access_token_response :: {atom, map} | {atom, String.t, number} 43 | @spec do_refresh_access_token(String.t) :: refresh_access_token_response 44 | @spec do_refresh_access_token(list, String.t) :: refresh_access_token_response 45 | 46 | defp do_refresh_access_token(refresh_token) when is_binary(refresh_token) do 47 | :oauth2 48 | |> Utils.load_config 49 | |> Enum.into(%{}) 50 | |> do_refresh_access_token(refresh_token) 51 | end 52 | 53 | defp do_refresh_access_token(%{client_id: client_id, client_secret: client_secret}, refresh_token) when is_binary(refresh_token) do 54 | payload = %{ 55 | client_id: client_id, 56 | client_secret: client_secret, 57 | refresh_token: refresh_token, 58 | grant_type: "refresh_token" 59 | } |> URI.encode_query 60 | case HTTPoison.post(@token_url, payload, @token_headers) do 61 | {:ok, %HTTPoison.Response{body: body}} -> 62 | case decode(body) do 63 | {:ok, %{"access_token" => access_token, "expires_in" => expires_in}} -> 64 | {:ok, access_token, (:os.system_time(:seconds) + expires_in)} 65 | other -> 66 | {:error, other} 67 | end 68 | not_ok -> 69 | {:error, not_ok} 70 | end 71 | end 72 | 73 | # }}} Private functions # 74 | 75 | end 76 | -------------------------------------------------------------------------------- /lib/gmail/http.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.HTTP do 2 | 3 | @moduledoc """ 4 | HTTP request handling. 5 | """ 6 | 7 | import Poison, only: [decode: 1, encode: 1] 8 | alias HTTPoison.Response 9 | 10 | # Client API {{{ # 11 | 12 | @doc """ 13 | Executes an HTTP action based on the command provided. 14 | """ 15 | @spec execute({atom, String.t, String.t}, map) :: {atom, map} | {atom, String.t} 16 | @spec execute({atom, String.t, String.t, map}, map) :: {atom, map} | {atom, String.t} 17 | 18 | def execute({:get, url, path}, %{access_token: access_token}) do 19 | (url <> path) 20 | |> HTTPoison.get(get_headers(access_token), timeout: :infinity, recv_timeout: :infinity) 21 | |> do_parse_response 22 | end 23 | 24 | def execute({:post, url, path, data}, %{access_token: access_token}) do 25 | {:ok, json} = encode(data) 26 | (url <> path) 27 | |> HTTPoison.post(json, get_headers(access_token), timeout: :infinity, recv_timeout: :infinity) 28 | |> do_parse_response 29 | end 30 | 31 | def execute({:post, url, path}, %{access_token: access_token}) do 32 | (url <> path) 33 | |> HTTPoison.post("", get_headers(access_token), timeout: :infinity, recv_timeout: :infinity) 34 | |> do_parse_response 35 | end 36 | 37 | def execute({:delete, url, path}, %{access_token: access_token}) do 38 | (url <> path) 39 | |> HTTPoison.delete(get_headers(access_token), timeout: :infinity, recv_timeout: :infinity) 40 | |> do_parse_response 41 | end 42 | 43 | def execute({:put, url, path, data}, %{access_token: access_token}) do 44 | {:ok, json} = encode(data) 45 | (url <> path) 46 | |> HTTPoison.put(json, get_headers(access_token), timeout: :infinity, recv_timeout: :infinity) 47 | |> do_parse_response 48 | end 49 | 50 | def execute({:patch, url, path, data}, %{access_token: access_token}) do 51 | {:ok, json} = encode(data) 52 | (url <> path) 53 | |> HTTPoison.patch(json, get_headers(access_token), timeout: :infinity, recv_timeout: :infinity) 54 | |> do_parse_response 55 | end 56 | 57 | # }}} Client API # 58 | 59 | # Private functions {{{ # 60 | 61 | @spec do_parse_response({atom, Response.t}) :: {atom, map} | {atom, String.t} 62 | defp do_parse_response({:ok, %Response{body: body}}) when byte_size(body) > 0 do 63 | decode(body) 64 | end 65 | 66 | defp do_parse_response({:ok, _response}) do 67 | {:ok, %{}} 68 | end 69 | 70 | defp do_parse_response({:error, %HTTPoison.Error{reason: reason}}) do 71 | {:error, reason} 72 | end 73 | 74 | @spec get_headers(String.t) :: [{String.t, String.t}] 75 | defp get_headers(token) do 76 | [ 77 | {"Authorization", "Bearer #{token}"}, 78 | {"Content-Type", "application/json"} 79 | ] 80 | end 81 | 82 | # }}} Private functions # 83 | 84 | end 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | elixir-gmail 2 | ============ 3 | [![Build Status](https://secure.travis-ci.org/craigp/elixir-gmail.png?branch=master "Build Status")](http://travis-ci.org/craigp/elixir-gmail) 4 | [![Coverage Status](https://coveralls.io/repos/craigp/elixir-gmail/badge.svg?branch=master&service=github)](https://coveralls.io/github/craigp/elixir-gmail?branch=master) 5 | [![hex.pm version](https://img.shields.io/hexpm/v/gmail.svg)](https://hex.pm/packages/gmail) 6 | [![hex.pm downloads](https://img.shields.io/hexpm/dt/gmail.svg)](https://hex.pm/packages/gmail) 7 | [![Inline docs](http://inch-ci.org/github/craigp/elixir-gmail.svg?branch=master&style=flat)](http://inch-ci.org/github/craigp/elixir-gmail) 8 | 9 | A simple Gmail REST API client for Elixir. 10 | 11 | You can find the hex package [here](https://hex.pm/packages/gmail), and the docs [here](http://hexdocs.pm/gmail). 12 | 13 | You can find documentation for Gmail's API at https://developers.google.com/gmail/api/ 14 | 15 | ## Usage 16 | 17 | First, add the client to your `mix.exs` dependencies: 18 | 19 | ```elixir 20 | def deps do 21 | [{:gmail, "~> 0.1"}] 22 | end 23 | ``` 24 | 25 | Then run `$ mix do deps.get, compile` to download and compile your dependencies. 26 | 27 | Finally, add the `:gmail` application as your list of applications in `mix.exs`: 28 | 29 | ```elixir 30 | def application do 31 | [applications: [:logger, :gmail]] 32 | end 33 | ``` 34 | 35 | Before you can work with mail for a user you'll need to start a process for them. 36 | 37 | ```elixir 38 | {:ok, pid} = Gmail.User.start_mail("user@example.com", "user-refresh-token") 39 | ``` 40 | 41 | When a user process starts it will automatically fetch a new access token for that user. Then 42 | you can start playing with mail: 43 | 44 | ```elixir 45 | # fetch a list of threads 46 | {:ok, threads, next_page_token} = Gmail.User.threads("user@example.com") 47 | 48 | # fetch the next page of threads using a page token 49 | {:ok, _, _} = Gmail.User.threads("user@example.com", %{page_token: next_page_token}) 50 | 51 | # fetch a thread by ID 52 | {:ok, thread} = Gmail.User.thread("user@example.com", "1233454566") 53 | 54 | # fetch a list of labels 55 | {:ok, labels} = Gmail.User.labels("user@example.com") 56 | ``` 57 | 58 | Check the docs for a more complete list of functionality. 59 | 60 | ## API Support 61 | 62 | * [ ] Threads 63 | * [x] `get` 64 | * [x] `list` 65 | * [ ] `modify` 66 | * [x] `delete` 67 | * [x] `trash` 68 | * [x] `untrash` 69 | * [ ] Messages 70 | * [x] `delete` 71 | * [x] `get` 72 | * [ ] `insert` 73 | * [x] `list` 74 | * [x] `modify` 75 | * [ ] `send` 76 | * [x] `trash` 77 | * [x] `untrash` 78 | * [ ] `import` 79 | * [ ] `batchDelete` 80 | * [x] Labels 81 | * [x] `create` 82 | * [x] `delete` 83 | * [x] `list` 84 | * [x] `update` 85 | * [x] `get` 86 | * [x] `update` 87 | * [x] `patch` 88 | * [ ] Drafts 89 | * [x] `list` 90 | * [x] `get` 91 | * [x] `delete` 92 | * [ ] `update` 93 | * [ ] `create` 94 | * [x] `send` 95 | * [ ] `send` (with upload) 96 | * [x] History 97 | * [x] `list` 98 | * [x] Attachments 99 | * [x] `get` (thanks to @killtheliterate) 100 | 101 | ## Auth 102 | 103 | As of now the library doesn't do the initial auth generation for you; you'll 104 | need to create an app on the [Google Developer 105 | Console](https://console.developers.google.com/) to get a client ID and secret 106 | and authorize a user to get an authorization code, which you can trade for an 107 | access token. 108 | 109 | The library will however, when you supply a refresh token, use that to refresh 110 | an expired access token for you. Take a look in the `dev.exs.sample` config 111 | file to see what your config should look like. 112 | 113 | ## TODO 114 | 115 | * [x] Stop mocking HTTP requests and use [Bypass](https://github.com/PSPDFKit-labs/bypass) instead 116 | * [x] Add format option when fetching threads 117 | * [x] .. and messages 118 | * [ ] .. and drafts 119 | * [ ] Batched requests 120 | * [ ] Document the config (specifically pool size) 121 | 122 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | name: "default", 16 | # 17 | # these are the files included in the analysis 18 | files: %{ 19 | # 20 | # you can give explicit globs or simply directories 21 | # in the latter case `**/*.{ex,exs}` will be used 22 | included: ["lib/", "src/", "web/", "apps/"], 23 | excluded: [~r"/_build/", ~r"/deps/"] 24 | }, 25 | # 26 | # If you create your own checks, you must specify the source files for 27 | # them here, so they can be loaded by Credo before running the analysis. 28 | requires: [], 29 | # 30 | # Credo automatically checks for updates, like e.g. Hex does. 31 | # You can disable this behaviour below: 32 | check_for_updates: true, 33 | # 34 | # If you want to enforce a style guide and need a more traditional linting 35 | # experience, you can change `strict` to true below: 36 | strict: false, 37 | # 38 | # You can customize the parameters of any check by adding a second element 39 | # to the tuple. 40 | # 41 | # To disable a check put `false` as second element: 42 | # 43 | # {Credo.Check.Design.DuplicatedCode, false} 44 | # 45 | checks: [ 46 | {Credo.Check.Consistency.ExceptionNames}, 47 | {Credo.Check.Consistency.LineEndings}, 48 | {Credo.Check.Consistency.SpaceAroundOperators}, 49 | {Credo.Check.Consistency.SpaceInParentheses}, 50 | {Credo.Check.Consistency.TabsOrSpaces}, 51 | 52 | # For some checks, like AliasUsage, you can only customize the priority 53 | # Priority values are: `low, normal, high, higher` 54 | {Credo.Check.Design.AliasUsage, priority: :low}, 55 | 56 | # For others you can set parameters 57 | 58 | # If you don't want the `setup` and `test` macro calls in ExUnit tests 59 | # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just 60 | # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. 61 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 62 | 63 | # You can also customize the exit_status of each check. 64 | # If you don't want TODO comments to cause `mix credo` to fail, just 65 | # set this value to 0 (zero). 66 | {Credo.Check.Design.TagTODO, exit_status: 2}, 67 | {Credo.Check.Design.TagFIXME}, 68 | 69 | {Credo.Check.Readability.FunctionNames}, 70 | {Credo.Check.Readability.LargeNumbers}, 71 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 180}, 72 | {Credo.Check.Readability.ModuleAttributeNames}, 73 | {Credo.Check.Readability.ModuleDoc}, 74 | {Credo.Check.Readability.ModuleNames}, 75 | {Credo.Check.Readability.ParenthesesInCondition}, 76 | {Credo.Check.Readability.PredicateFunctionNames}, 77 | {Credo.Check.Readability.TrailingBlankLine}, 78 | {Credo.Check.Readability.TrailingWhiteSpace}, 79 | {Credo.Check.Readability.VariableNames}, 80 | 81 | {Credo.Check.Refactor.ABCSize}, 82 | # {Credo.Check.Refactor.CaseTrivialMatches}, # deprecated in 0.4.0 83 | {Credo.Check.Refactor.CondStatements}, 84 | {Credo.Check.Refactor.FunctionArity}, 85 | {Credo.Check.Refactor.MatchInCondition}, 86 | {Credo.Check.Refactor.PipeChainStart}, 87 | {Credo.Check.Refactor.CyclomaticComplexity}, 88 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 89 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 90 | {Credo.Check.Refactor.Nesting}, 91 | {Credo.Check.Refactor.UnlessWithElse}, 92 | 93 | {Credo.Check.Warning.IExPry}, 94 | {Credo.Check.Warning.IoInspect}, 95 | {Credo.Check.Warning.NameRedeclarationByAssignment}, 96 | {Credo.Check.Warning.NameRedeclarationByCase}, 97 | {Credo.Check.Warning.NameRedeclarationByDef}, 98 | {Credo.Check.Warning.NameRedeclarationByFn}, 99 | {Credo.Check.Warning.OperationOnSameValues}, 100 | {Credo.Check.Warning.BoolOperationOnSameValues}, 101 | {Credo.Check.Warning.UnusedEnumOperation}, 102 | {Credo.Check.Warning.UnusedKeywordOperation}, 103 | {Credo.Check.Warning.UnusedListOperation}, 104 | {Credo.Check.Warning.UnusedStringOperation}, 105 | {Credo.Check.Warning.UnusedTupleOperation}, 106 | {Credo.Check.Warning.OperationWithConstantResult}, 107 | 108 | # Custom checks can be created using `mix credo.gen.check`. 109 | # 110 | ] 111 | } 112 | ] 113 | } 114 | -------------------------------------------------------------------------------- /lib/gmail/message.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Message do 2 | 3 | @moduledoc """ 4 | An email message. 5 | """ 6 | 7 | alias __MODULE__ 8 | alias Gmail.{Payload, Utils} 9 | import Gmail.Base 10 | 11 | @doc """ 12 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/messages#resource 13 | """ 14 | defstruct id: "", 15 | thread_id: "", 16 | label_ids: [], 17 | snippet: "", 18 | history_id: nil, 19 | payload: %Gmail.Payload{}, 20 | size_estimate: nil, 21 | raw: "" 22 | 23 | @type t :: %__MODULE__{} 24 | 25 | @doc """ 26 | Gets the specified message. 27 | 28 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/messages/get 29 | """ 30 | @spec get(String.t, String.t, map) :: {atom, String.t, String.t} 31 | def get(user_id, message_id, params) do 32 | available_options = [:format, :metadata_headers] 33 | path = querify_params("users/#{user_id}/messages/#{message_id}", available_options, params) 34 | {:get, base_url(), path} 35 | end 36 | 37 | @doc """ 38 | Immediately and permanently deletes the specified message. This operation cannot be undone. Prefer `trash` instead. 39 | 40 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/messages/delete 41 | """ 42 | @spec delete(String.t, String.t) :: {atom, String.t, String.t} 43 | def delete(user_id, message_id) do 44 | {:delete, base_url(), "users/#{user_id}/messages/#{message_id}"} 45 | end 46 | 47 | @doc """ 48 | Moves the specified message to the trash. 49 | 50 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/messages/trash 51 | """ 52 | @spec trash(String.t, String.t) :: {atom, String.t, String.t} 53 | def trash(user_id, message_id) do 54 | {:post, base_url(), "users/#{user_id}/messages/#{message_id}/trash"} 55 | end 56 | 57 | @doc """ 58 | Removes the specified message from the trash. 59 | 60 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/messages/untrash 61 | """ 62 | @spec untrash(String.t, String.t) :: {atom, String.t, String.t} 63 | def untrash(user_id, message_id) do 64 | {:post, base_url(), "users/#{user_id}/messages/#{message_id}/untrash"} 65 | end 66 | 67 | @doc """ 68 | Searches for messages in the user's mailbox. 69 | 70 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/messages/list 71 | """ 72 | @spec search(String.t, String.t, map) :: {atom, String.t, String.t} 73 | def search(user_id, query, params) when is_binary(query) do 74 | list(user_id, Map.put(params, :q, query)) 75 | end 76 | 77 | @doc """ 78 | Lists the messages in the user's mailbox. 79 | 80 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/messages/list 81 | """ 82 | @spec list(String.t, map) :: {atom, String.t, String.t} 83 | def list(user_id, params) do 84 | available_options = [:max_results, :include_spam_trash, :label_ids, :page_token, :q] 85 | path = querify_params("users/#{user_id}/messages", available_options, params) 86 | {:get, base_url(), path} 87 | end 88 | 89 | @doc """ 90 | Modifies the labels on the specified message. 91 | 92 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/messages/modify#http-request 93 | """ 94 | def modify(user_id, message_id, labels_to_add, labels_to_remove) do 95 | {:post, base_url(), "users/#{user_id}/messages/#{message_id}/modify", %{ 96 | "addLabelIds" => labels_to_add, 97 | "removeLabelIds" => labels_to_remove 98 | }} 99 | end 100 | 101 | @doc """ 102 | Converts a Gmail API message resource into a local struct. 103 | """ 104 | @spec convert(map) :: Message.t 105 | def convert(message) do 106 | {payload, message} = 107 | message 108 | |> Utils.atomise_keys 109 | |> Map.pop(:payload) 110 | message = struct(Message, message) 111 | if payload, do: Map.put(message, :payload, Payload.convert(payload)), else: message 112 | end 113 | 114 | @doc """ 115 | Handles a message resource response from the Gmail API. 116 | """ 117 | def handle_message_response(response) do 118 | response 119 | |> handle_error 120 | |> case do 121 | {:error, message} -> 122 | {:error, message} 123 | {:ok, raw_message} -> 124 | {:ok, Message.convert(raw_message)} 125 | end 126 | end 127 | 128 | @doc """ 129 | Handles a message list response from the Gmail API. 130 | """ 131 | def handle_message_list_response(response) do 132 | response 133 | |> handle_error 134 | |> case do 135 | {:error, message} -> 136 | {:error, message} 137 | {:ok, %{"messages" => msgs}} -> 138 | {:ok, Enum.map(msgs, fn(%{"id" => id, "threadId" => thread_id}) -> %Message{id: id, thread_id: thread_id} end)} 139 | {:ok, %{"resultSizeEstimate" => 0}} -> 140 | {:ok, []} 141 | end 142 | end 143 | 144 | @doc """ 145 | Handles a message delete response from the Gmail API. 146 | """ 147 | def handle_message_delete_response(response) do 148 | response 149 | |> handle_error 150 | |> case do 151 | {:error, message} -> 152 | {:error, message} 153 | {:ok, _} -> 154 | :ok 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /lib/gmail/thread.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Thread do 2 | 3 | @moduledoc """ 4 | A collection of messages representing a conversation. 5 | """ 6 | 7 | alias __MODULE__ 8 | import Gmail.Base 9 | alias Gmail.{Utils, Message} 10 | 11 | @doc """ 12 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/threads#resource 13 | """ 14 | defstruct id: "", 15 | snippet: "", 16 | history_id: "", 17 | messages: [] 18 | 19 | @type t :: %__MODULE__{} 20 | 21 | @doc """ 22 | Gets the specified thread. 23 | 24 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/threads/get 25 | """ 26 | @spec get(String.t, String.t, map) :: {atom, String.t, String.t} 27 | def get(user_id, thread_id, params) do 28 | available_options = [:format, :metadata_headers] 29 | path = querify_params("users/#{user_id}/threads/#{thread_id}", available_options, params) 30 | {:get, base_url(), path} 31 | end 32 | 33 | @doc """ 34 | Searches for threads in the user's mailbox. 35 | 36 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/threads/list 37 | """ 38 | @spec search(String.t, String.t, map) :: {atom, String.t, String.t} 39 | def search(user_id, query, params) when is_binary(query) do 40 | list(user_id, Map.put(params, :q, query)) 41 | end 42 | 43 | @doc """ 44 | Lists the threads in the user's mailbox. 45 | 46 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/threads/list 47 | """ 48 | @spec list(String.t, map) :: {atom, String.t, String.t} 49 | def list(user_id, params) when is_binary(user_id) do 50 | available_options = [:max_results, :include_spam_trash, :label_ids, :page_token, :q] 51 | path = querify_params("users/#{user_id}/threads", available_options, params) 52 | {:get, base_url(), path} 53 | end 54 | 55 | @doc """ 56 | Immediately and permanently deletes the specified thread. This operation cannot be undone. Prefer `trash` instead. 57 | 58 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/threads/delete 59 | """ 60 | @spec delete(String.t, String.t) :: {atom, String.t, String.t} 61 | def delete(user_id, thread_id) do 62 | {:delete, base_url(), "users/#{user_id}/threads/#{thread_id}"} 63 | end 64 | 65 | @doc """ 66 | Moves the specified thread to the trash. 67 | 68 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/threads/trash 69 | """ 70 | @spec trash(String.t, String.t) :: {atom, String.t, String.t} 71 | def trash(user_id, thread_id) do 72 | {:post, base_url(), "users/#{user_id}/threads/#{thread_id}/trash"} 73 | end 74 | 75 | @doc """ 76 | Removes the specified thread from the trash. 77 | 78 | Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/threads/untrash 79 | """ 80 | @spec untrash(String.t, String.t) :: {atom, String.t, String.t} 81 | def untrash(user_id, thread_id) do 82 | {:post, base_url(), "users/#{user_id}/threads/#{thread_id}/untrash"} 83 | end 84 | 85 | @doc """ 86 | Handles a thread resource response from the Gmail API. 87 | """ 88 | @spec handle_thread_response({atom, String.t}) :: {atom, String.t} | {atom, map} 89 | @spec handle_thread_response({atom, map}) :: {atom, String.t} | {atom, map} 90 | def handle_thread_response(response) do 91 | response 92 | |> handle_error 93 | |> case do 94 | {:error, detail} -> 95 | {:error, detail} 96 | {:ok, %{"id" => id, "historyId" => history_id, "messages" => messages}} -> 97 | {:ok, %Thread{ 98 | id: id, 99 | history_id: history_id, 100 | messages: Enum.map(messages, &Message.convert/1) 101 | }} 102 | end 103 | end 104 | 105 | @doc """ 106 | Handles a thread list response from the Gmail API. 107 | """ 108 | @spec handle_thread_list_response(atom | {atom, map | String.t}) :: {atom, String.t | map} | {atom, map, String.t} 109 | def handle_thread_list_response(response) do 110 | response 111 | |> handle_error 112 | |> case do 113 | {:error, detail} -> 114 | {:error, detail} 115 | {:ok, %{"threads" => raw_threads, "nextPageToken" => next_page_token}} -> 116 | threads = 117 | raw_threads 118 | |> Enum.map(fn thread -> 119 | struct(Thread, Utils.atomise_keys(thread)) 120 | end) 121 | {:ok, threads, next_page_token} 122 | {:ok, %{"threads" => raw_threads}} -> 123 | threads = 124 | raw_threads 125 | |> Enum.map(fn thread -> 126 | struct(Thread, Utils.atomise_keys(thread)) 127 | end) 128 | {:ok, threads} 129 | {:ok, %{"resultSizeEstimate" => 0}} -> 130 | {:ok, []} 131 | end 132 | end 133 | 134 | @doc """ 135 | Handles a thread delete response from the Gmail API. 136 | """ 137 | @spec handle_thread_delete_response({atom, map}) :: {atom, String.t | map} | {atom, map, String.t} | atom 138 | @spec handle_thread_delete_response({atom, String.t}) :: {atom, String.t | map} | {atom, map, String.t} | atom 139 | def handle_thread_delete_response(response) do 140 | response 141 | |> handle_error 142 | |> case do 143 | {:error, detail} -> 144 | {:error, detail} 145 | {:ok, _} -> 146 | :ok 147 | end 148 | end 149 | 150 | end 151 | -------------------------------------------------------------------------------- /lib/gmail/label.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.Label do 2 | 3 | @moduledoc""" 4 | Labels are used to categorize messages and threads within the user's mailbox. 5 | """ 6 | 7 | alias __MODULE__ 8 | import Gmail.Base 9 | 10 | @doc """ 11 | > Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/labels#resource 12 | """ 13 | defstruct id: nil, 14 | name: nil, 15 | message_list_visibility: nil, 16 | label_list_visibility: nil, 17 | type: nil, 18 | messages_total: nil, 19 | messages_unread: nil, 20 | threads_total: nil, 21 | threads_unread: nil 22 | 23 | @type t :: %__MODULE__{} 24 | 25 | @doc """ 26 | Creates a new label. 27 | 28 | > Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/labels/create 29 | """ 30 | @spec create(String.t, String.t) :: {atom, String.t, String.t, map} 31 | def create(user_id, label_name) do 32 | {:post, base_url(), "users/#{user_id}/labels", %{"name" => label_name}} 33 | end 34 | 35 | @doc """ 36 | Updates the specified label. 37 | 38 | Google API Documentation: https://developers.google.com/gmail/api/v1/reference/users/labels/update 39 | """ 40 | @spec update(String.t, Label.t) :: {atom, String.t, String.t, map} 41 | def update(user_id, %Label{id: id} = label) do 42 | {:put, base_url(), "users/#{user_id}/labels/#{id}", convert_for_update(label)} 43 | end 44 | 45 | @doc """ 46 | Updates the specified label. This method supports patch semantics. 47 | 48 | Google API Documentation: https://developers.google.com/gmail/api/v1/reference/users/labels/patch 49 | """ 50 | @spec patch(String.t, Label.t) :: {atom, String.t, String.t, map} 51 | def patch(user_id, %Label{id: id} = label) do 52 | {:patch, base_url(), "users/#{user_id}/labels/#{id}", convert_for_patch(label)} 53 | end 54 | 55 | @doc """ 56 | Immediately and permanently deletes the specified label and removes it from any messages and threads that it is applied to. 57 | 58 | Google API Documentation: https://developers.google.com/gmail/api/v1/reference/users/labels/delete 59 | """ 60 | @spec delete(String.t, String.t) :: {atom, String.t, String.t} 61 | def delete(user_id, label_id) do 62 | {:delete, base_url(), "users/#{user_id}/labels/#{label_id}"} 63 | end 64 | 65 | @doc """ 66 | Gets the specified label. 67 | 68 | > Gmail API documentation: https://developers.google.com/gmail/api/v1/reference/users/labels/get 69 | """ 70 | @spec get(String.t, String.t) :: {atom, String.t, String.t} 71 | def get(user_id, label_id) do 72 | {:get, base_url(), "users/#{user_id}/labels/#{label_id}"} 73 | end 74 | 75 | @doc """ 76 | Lists all labels in the user's mailbox. 77 | 78 | > Gmail API Documentation: https://developers.google.com/gmail/api/v1/reference/users/labels/list 79 | """ 80 | @spec list(String.t) :: {atom, String.t, String.t} 81 | def list(user_id) do 82 | {:get, base_url(), "users/#{user_id}/labels"} 83 | end 84 | 85 | @doc """ 86 | Converts a Gmail API label resource into a local struct. 87 | """ 88 | @spec convert(map) :: Label.t 89 | def convert(result) do 90 | Enum.reduce(result, %Label{}, fn({key, value}, label) -> 91 | %{label | (key |> Macro.underscore |> String.to_atom) => value} 92 | end) 93 | end 94 | 95 | @doc """ 96 | Handles a label resource response from the Gmail API. 97 | """ 98 | @spec handle_label_response({atom, map}) :: {atom, String.t} | {atom, map} 99 | @spec handle_label_response({atom, String.t}) :: {atom, String.t} | {atom, map} 100 | def handle_label_response(response) do 101 | response 102 | |> handle_error 103 | |> case do 104 | {:error, message} -> 105 | {:error, message} 106 | {:ok, %{"error" => details}} -> 107 | {:error, details} 108 | {:ok, raw_label} -> 109 | {:ok, convert(raw_label)} 110 | end 111 | end 112 | 113 | @doc """ 114 | Handles a label list response from the Gmail API. 115 | """ 116 | @spec handle_label_list_response(atom | {atom, map | String.t}) :: {atom, String.t | map} 117 | def handle_label_list_response(response) do 118 | response 119 | |> handle_error 120 | |> case do 121 | {:error, message} -> 122 | {:error, message} 123 | {:ok, %{"labels" => raw_labels}} -> 124 | {:ok, Enum.map(raw_labels, &convert/1)} 125 | end 126 | end 127 | 128 | @doc """ 129 | Handles a label delete response from the Gmail API. 130 | """ 131 | @spec handle_label_delete_response(atom | {atom, map | String.t}) :: {atom, String.t} | {atom, map} | atom 132 | def handle_label_delete_response(response) do 133 | response 134 | |> handle_error 135 | |> case do 136 | {:error, message} -> 137 | {:error, message} 138 | {:ok, _} -> 139 | :ok 140 | end 141 | end 142 | 143 | @spec convert_for_patch(Label.t) :: map 144 | defp convert_for_patch(label) do 145 | label |> Map.from_struct |> Enum.reduce(%{}, fn({key, value}, map) -> 146 | if value do 147 | {first_letter, rest} = key |> Atom.to_string |> Macro.camelize |> String.split_at(1) 148 | Map.put(map, String.downcase(first_letter) <> rest, value) 149 | else 150 | map 151 | end 152 | end) 153 | end 154 | 155 | @spec convert_for_update(Label.t) :: map 156 | defp convert_for_update(%Label{ 157 | id: id, 158 | name: name, 159 | label_list_visibility: label_list_visibility, 160 | message_list_visibility: message_list_visibility 161 | }) do 162 | %{ 163 | "id" => id, 164 | "name" => name, 165 | "labelListVisibility" => label_list_visibility, 166 | "messageListVisibility" => message_list_visibility 167 | } 168 | end 169 | 170 | end 171 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 3 | "bypass": {:hex, :bypass, "0.8.1", "16d409e05530ece4a72fabcf021a3e5c7e15dcc77f911423196a0c551f2a15ca", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "combine": {:hex, :combine, "0.9.1", "5fd778ee77032ae593bf79aedb8519d9e36283e4f869abd98c2d6029ca476db8", [:mix], []}, 6 | "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"}, 8 | "credo": {:hex, :credo, "0.9.2", "841d316612f568beb22ba310d816353dddf31c2d94aa488ae5a27bb53760d0bf", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 10 | "earmark": {:hex, :earmark, "1.2.5", "4d21980d5d2862a2e13ec3c49ad9ad783ffc7ca5769cf6ff891a4553fbaae761", [:mix], [], "hexpm"}, 11 | "ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "excoveralls": {:hex, :excoveralls, "0.8.2", "b941a08a1842d7aa629e0bbc969186a4cefdd035bad9fe15d43aaaaaeb8fae36", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 14 | "file_system": {:hex, :file_system, "0.2.5", "a3060f063b116daf56c044c273f65202e36f75ec42e678dc10653056d3366054", [:mix], [], "hexpm"}, 15 | "fs": {:hex, :fs, "2.12.0", "ad631efacc9a5683c8eaa1b274e24fa64a1b8eb30747e9595b93bec7e492e25e", [:rebar3], []}, 16 | "gettext": {:hex, :gettext, "0.11.0", "80c1dd42d270482418fa158ec5ba073d2980e3718bacad86f3d4ad71d5667679", [:mix], []}, 17 | "hackney": {:hex, :hackney, "1.12.1", "8bf2d0e11e722e533903fe126e14d6e7e94d9b7983ced595b75f532e04b7fdc7", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 18 | "httpoison": {:hex, :httpoison, "1.1.1", "96ed7ab79f78a31081bb523eefec205fd2900a02cda6dbc2300e7a1226219566", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 19 | "idna": {:hex, :idna, "5.1.1", "cbc3b2fa1645113267cc59c760bafa64b2ea0334635ef06dbac8801e42f7279c", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 20 | "inch_ex": {:hex, :inch_ex, "0.5.6", "418357418a553baa6d04eccd1b44171936817db61f4c0840112b420b8e378e67", [:mix], [{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, 21 | "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, 22 | "meck": {:hex, :meck, "0.8.9", "64c5c0bd8bcca3a180b44196265c8ed7594e16bcc845d0698ec6b4e577f48188", [:rebar3], [], "hexpm"}, 23 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 24 | "mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], [], "hexpm"}, 25 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, 26 | "mix_test_watch": {:hex, :mix_test_watch, "0.6.0", "5e206ed04860555a455de2983937efd3ce79f42bd8536fc6b900cc286f5bb830", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"}, 27 | "mock": {:hex, :mock, "0.3.1", "994f00150f79a0ea50dc9d86134cd9ebd0d177ad60bd04d1e46336cdfdb98ff9", [:mix], [{:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, 28 | "parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"}, 29 | "plug": {:hex, :plug, "1.5.1", "1ff35bdecfb616f1a2b1c935ab5e4c47303f866cb929d2a76f0541e553a58165", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.3", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, 30 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 31 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, 32 | "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, 33 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, 34 | "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.6"}, 35 | "timex": {:hex, :timex, "2.2.1", "0d69012a7fd69f4cbdaa00cc5f2a5f30f1bed56072fb362ed4bddf60db343022", [:mix], [{:gettext, "~> 0.10", [hex: :gettext, optional: false]}, {:combine, "~> 0.7", [hex: :combine, optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, optional: false]}]}, 36 | "tzdata": {:hex, :tzdata, "0.5.8", "a4ffe564783c6519e4df230a5d0e1cf44b7db7f576bcae76d05540b5da5b6143", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, optional: false]}]}, 37 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, 38 | } 39 | -------------------------------------------------------------------------------- /test/gmail/draft_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.DraftTest do 4 | 5 | use ExUnit.Case 6 | import Mock 7 | 8 | setup do 9 | 10 | user_id = "user@example.com" 11 | access_token = "xxx-xxx-xxx" 12 | access_token_rec = %{access_token: access_token} 13 | 14 | draft_id = "1497963903688490569" 15 | 16 | draft = %{"id" => "1497963903688490569", 17 | "message" => %{"id" => "14c9d65152e73310", 18 | "threadId" => "14c99e3025c516a0"}} 19 | 20 | drafts = %{"drafts" => [%{"id" => "1497963903688490569", 21 | "message" => %{"id" => "14c9d65152e73310", 22 | "threadId" => "14c99e3025c516a0"}}, 23 | %{"id" => "1492564013423058235", 24 | "message" => %{"id" => "14b6a70ff09cdd3b", 25 | "threadId" => "14b643d16976ad29"}}, 26 | %{"id" => "1478425285387648346", 27 | "message" => %{"id" => "14846bf6ca7c7d5a", 28 | "threadId" => "14844b4410da5151"}}]} 29 | 30 | expected_result = %Gmail.Draft{ 31 | id: draft_id, 32 | message: %Gmail.Message{ 33 | id: "14c9d65152e73310", 34 | thread_id: "14c99e3025c516a0" 35 | } 36 | } 37 | 38 | expected_results = [%Gmail.Draft{ 39 | id: "1497963903688490569", 40 | message: %Gmail.Message{ 41 | id: "14c9d65152e73310", 42 | thread_id: "14c99e3025c516a0" 43 | } 44 | }, %Gmail.Draft{ 45 | id: "1492564013423058235", 46 | message: %Gmail.Message{ 47 | id: "14b6a70ff09cdd3b", 48 | thread_id: "14b643d16976ad29" 49 | } 50 | }, %Gmail.Draft{ 51 | id: "1478425285387648346", 52 | message: %Gmail.Message{ 53 | id: "14846bf6ca7c7d5a", 54 | thread_id: "14844b4410da5151" 55 | } 56 | }] 57 | 58 | bypass = Bypass.open 59 | Application.put_env :gmail, :api, %{url: "http://localhost:#{bypass.port}/gmail/v1/"} 60 | 61 | Gmail.User.stop_mail(user_id) 62 | with_mock Gmail.OAuth2, [refresh_access_token: fn(_) -> {access_token, 100000000000000} end] do 63 | {:ok, _server_pid} = Gmail.User.start_mail(user_id, "dummy-refresh-token") 64 | end 65 | 66 | {:ok, %{ 67 | draft_id: draft_id, 68 | draft: draft, 69 | expected_result: expected_result, 70 | access_token: access_token, 71 | access_token_rec: access_token_rec, 72 | drafts: drafts, 73 | expected_results: expected_results, 74 | draft_not_found: %{"error" => %{"code" => 404}}, 75 | bypass: bypass, 76 | send_response: %{"id" => "1530e43ba9b4c6e0", "labelIds" => ["SENT"], "threadId" => "14c99e3025c516a0"}, 77 | user_id: user_id 78 | }} 79 | end 80 | 81 | test "sends a draft", %{ 82 | draft_id: draft_id, 83 | bypass: bypass, 84 | access_token: access_token, 85 | send_response: send_response, 86 | user_id: user_id 87 | } do 88 | data = %{"id" => draft_id} 89 | Bypass.expect bypass, fn conn -> 90 | {:ok, body, _} = Plug.Conn.read_body(conn) 91 | {:ok, body_params} = body |> Poison.decode 92 | assert body_params == data 93 | assert "/gmail/v1/users/#{user_id}/drafts/send" == conn.request_path 94 | assert "" == conn.query_string 95 | assert "POST" == conn.method 96 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 97 | {:ok, json} = Poison.encode(send_response) 98 | Plug.Conn.resp(conn, 200, json) 99 | end 100 | {:ok, %{thread_id: _thread_id}} = Gmail.User.draft(:send, user_id, draft_id) 101 | end 102 | 103 | test "reports a :not_found when sending a draft that doesn't exist", %{ 104 | draft_id: draft_id, 105 | bypass: bypass, 106 | access_token: access_token, 107 | draft_not_found: draft_not_found, 108 | user_id: user_id 109 | } do 110 | Bypass.expect bypass, fn conn -> 111 | assert "/gmail/v1/users/#{user_id}/drafts/send" == conn.request_path 112 | assert "" == conn.query_string 113 | assert "POST" == conn.method 114 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 115 | {:ok, json} = Poison.encode(draft_not_found) 116 | Plug.Conn.resp(conn, 200, json) 117 | end 118 | {:error, :not_found} = Gmail.User.draft(:send, user_id, draft_id) 119 | end 120 | 121 | # test "creates a new draft" do 122 | # end 123 | 124 | test "deletes a draft", %{ 125 | draft_id: draft_id, 126 | bypass: bypass, 127 | user_id: user_id 128 | } do 129 | Bypass.expect bypass, fn conn -> 130 | assert "/gmail/v1/users/#{user_id}/drafts/#{draft_id}" == conn.request_path 131 | assert "" == conn.query_string 132 | assert "DELETE" == conn.method 133 | Plug.Conn.resp(conn, 200, "") 134 | end 135 | assert :ok == Gmail.User.draft(:delete, user_id, draft_id) 136 | end 137 | 138 | test "reports :not_found when deleting a draft that doesn't exist", %{ 139 | draft_id: draft_id, 140 | bypass: bypass, 141 | draft_not_found: draft_not_found, 142 | user_id: user_id 143 | } do 144 | Bypass.expect bypass, fn conn -> 145 | assert "/gmail/v1/users/#{user_id}/drafts/#{draft_id}" == conn.request_path 146 | assert "" == conn.query_string 147 | assert "DELETE" == conn.method 148 | {:ok, json} = Poison.encode(draft_not_found) 149 | Plug.Conn.resp(conn, 200, json) 150 | end 151 | {:error, :not_found} = Gmail.User.draft(:delete, user_id, draft_id) 152 | end 153 | 154 | test "lists all drafts", %{ 155 | drafts: drafts, 156 | expected_results: expected_results, 157 | access_token: access_token, 158 | bypass: bypass, 159 | user_id: user_id 160 | } do 161 | Bypass.expect bypass, fn conn -> 162 | assert "/gmail/v1/users/#{user_id}/drafts" == conn.request_path 163 | assert "" == conn.query_string 164 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 165 | assert "GET" == conn.method 166 | {:ok, json} = Poison.encode(drafts) 167 | Plug.Conn.resp(conn, 200, json) 168 | end 169 | {:ok, results} = Gmail.User.drafts(user_id) 170 | assert results == expected_results 171 | end 172 | 173 | # test "updates a draft" do 174 | 175 | # end 176 | 177 | test "gets a draft", %{ 178 | draft: draft, 179 | draft_id: draft_id, 180 | expected_result: expected_result, 181 | bypass: bypass, 182 | user_id: user_id 183 | } do 184 | Bypass.expect bypass, fn conn -> 185 | assert "/gmail/v1/users/#{user_id}/drafts/#{draft_id}" == conn.request_path 186 | assert "" == conn.query_string 187 | {:ok, json} = Poison.encode(draft) 188 | Plug.Conn.resp(conn, 200, json) 189 | end 190 | {:ok, draft} = Gmail.User.draft(user_id, draft_id) 191 | assert draft == expected_result 192 | end 193 | 194 | test "reports :not_found for a draft that doesn't exist", %{ 195 | draft_id: draft_id, 196 | bypass: bypass, 197 | draft_not_found: draft_not_found, 198 | user_id: user_id 199 | } do 200 | Bypass.expect bypass, fn conn -> 201 | assert "/gmail/v1/users/#{user_id}/drafts/#{draft_id}" == conn.request_path 202 | assert "" == conn.query_string 203 | {:ok, json} = Poison.encode(draft_not_found) 204 | Plug.Conn.resp(conn, 200, json) 205 | end 206 | {:error, :not_found} = Gmail.User.draft(user_id, draft_id) 207 | end 208 | 209 | end 210 | -------------------------------------------------------------------------------- /test/gmail/label_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.LabelTest do 4 | 5 | use ExUnit.Case 6 | import Mock 7 | 8 | setup do 9 | 10 | access_token = "xxx-xxx-xxx" 11 | access_token_rec = %{access_token: access_token} 12 | 13 | label_id = "Label_22" 14 | label_name = "Cool Label" 15 | label_type = "user" 16 | user_id = "user@example.com" 17 | 18 | label = %{ 19 | "id" => label_id, 20 | "name" => label_name, 21 | "type" => label_type, 22 | "labelListVisibility" => "labelShow", 23 | "messageListVisibility" => "show" 24 | } 25 | 26 | labels = %{"labels" => [label]} 27 | 28 | expected_result = %Gmail.Label{ 29 | id: label_id, 30 | name: label_name, 31 | type: label_type, 32 | label_list_visibility: "labelShow", 33 | message_list_visibility: "show" 34 | } 35 | 36 | expected_results = [expected_result] 37 | 38 | error_message_1 = "Error #1" 39 | errors = [ 40 | %{"message" => error_message_1}, 41 | %{"message" => "Error #2"} 42 | ] 43 | 44 | error_content = %{"code" => 400, "errors" => errors} 45 | 46 | bypass = Bypass.open 47 | Application.put_env :gmail, :api, %{url: "http://localhost:#{bypass.port}/gmail/v1/"} 48 | 49 | Gmail.User.stop_mail(user_id) 50 | with_mock Gmail.OAuth2, [refresh_access_token: fn(_) -> {access_token, 100000000000000} end] do 51 | {:ok, _server_pid} = Gmail.User.start_mail(user_id, "dummy-refresh-token") 52 | end 53 | 54 | {:ok, %{ 55 | access_token: access_token, 56 | access_token_rec: access_token_rec, 57 | label_id: label_id, 58 | label_name: label_name, 59 | label: label, 60 | labels: labels, 61 | expected_result: expected_result, 62 | expected_results: expected_results, 63 | label_not_found: %{"error" => %{"code" => 404}}, 64 | four_hundred_error: %{"error" => error_content}, 65 | four_hundred_error_content: error_content, 66 | bypass: bypass, 67 | user_id: user_id, 68 | error_message_1: error_message_1 69 | }} 70 | end 71 | 72 | test "creates a new label", %{ 73 | label: label, 74 | label_name: label_name, 75 | expected_result: expected_result, 76 | bypass: bypass, 77 | access_token: access_token, 78 | user_id: user_id 79 | } do 80 | Bypass.expect bypass, fn conn -> 81 | assert "/gmail/v1/users/#{user_id}/labels" == conn.request_path 82 | assert "" == conn.query_string 83 | assert "POST" == conn.method 84 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 85 | {:ok, json} = Poison.encode(label) 86 | Plug.Conn.resp(conn, 200, json) 87 | end 88 | {:ok, label} = Gmail.User.label(:create, user_id, label_name) 89 | assert expected_result == label 90 | end 91 | 92 | test "deletes a label", %{ 93 | bypass: bypass, 94 | label_id: label_id, 95 | user_id: user_id 96 | } do 97 | Bypass.expect bypass, fn conn -> 98 | assert "/gmail/v1/users/#{user_id}/labels/#{label_id}" == conn.request_path 99 | assert "" == conn.query_string 100 | assert "DELETE" == conn.method 101 | {:ok, json} = Poison.encode(nil) 102 | Plug.Conn.resp(conn, 200, json) 103 | end 104 | :ok = Gmail.User.label(:delete, user_id, label_id) 105 | end 106 | 107 | test "reports a :not_found when deleting a label that doesn't exist", %{ 108 | bypass: bypass, 109 | label_not_found: label_not_found, 110 | label_id: label_id, 111 | user_id: user_id 112 | } do 113 | Bypass.expect bypass, fn conn -> 114 | assert "/gmail/v1/users/#{user_id}/labels/#{label_id}" == conn.request_path 115 | assert "" == conn.query_string 116 | {:ok, json} = Poison.encode(label_not_found) 117 | Plug.Conn.resp(conn, 200, json) 118 | end 119 | {:error, :not_found} = Gmail.User.label(:delete, user_id, label_id) 120 | end 121 | 122 | test "handles a 400 error when deleting a label", %{ 123 | bypass: bypass, 124 | label_id: label_id, 125 | four_hundred_error: four_hundred_error, 126 | user_id: user_id 127 | } do 128 | Bypass.expect bypass, fn conn -> 129 | assert "/gmail/v1/users/#{user_id}/labels/#{label_id}" == conn.request_path 130 | assert "" == conn.query_string 131 | {:ok, json} = Poison.encode(four_hundred_error) 132 | Plug.Conn.resp(conn, 200, json) 133 | end 134 | {:error, "Error #1"} = Gmail.User.label(:delete, user_id, label_id) 135 | end 136 | 137 | test "lists all labels", %{ 138 | labels: labels, 139 | expected_results: expected_results, 140 | bypass: bypass, 141 | user_id: user_id 142 | } do 143 | Bypass.expect bypass, fn conn -> 144 | assert "/gmail/v1/users/#{user_id}/labels" == conn.request_path 145 | assert "" == conn.query_string 146 | assert "GET" == conn.method 147 | {:ok, json} = Poison.encode(labels) 148 | Plug.Conn.resp(conn, 200, json) 149 | end 150 | {:ok, labels} = Gmail.User.labels(user_id) 151 | assert expected_results == labels 152 | end 153 | 154 | test "updates a label", %{ 155 | label: label, 156 | label_name: label_name, 157 | expected_result: expected_result, 158 | bypass: bypass, 159 | label_id: label_id, 160 | user_id: user_id 161 | } do 162 | Bypass.expect bypass, fn conn -> 163 | {:ok, body, _} = Plug.Conn.read_body(conn) 164 | {:ok, body_params} = body |> Poison.decode 165 | assert body_params == %{ 166 | "id" => label_id, 167 | "name" => label_name, 168 | "labelListVisibility" => "labelShow", 169 | "messageListVisibility" => "show" 170 | } 171 | assert "/gmail/v1/users/#{user_id}/labels/#{label_id}" == conn.request_path 172 | assert "" == conn.query_string 173 | assert "PUT" == conn.method 174 | {:ok, json} = Poison.encode(label) 175 | Plug.Conn.resp(conn, 200, json) 176 | end 177 | {:ok, label} = Gmail.User.label(:update, user_id, expected_result) 178 | assert expected_result == label 179 | end 180 | 181 | test "handles an error when updating a label", %{ 182 | expected_result: expected_result, 183 | bypass: bypass, 184 | label_id: label_id, 185 | four_hundred_error: four_hundred_error, 186 | user_id: user_id, 187 | error_message_1: error_message_1 188 | } do 189 | Bypass.expect bypass, fn conn -> 190 | assert "/gmail/v1/users/#{user_id}/labels/#{label_id}" == conn.request_path 191 | assert "" == conn.query_string 192 | {:ok, json} = Poison.encode(four_hundred_error) 193 | Plug.Conn.resp(conn, 200, json) 194 | end 195 | {:error, error_detail} = Gmail.User.label(:update, user_id, expected_result) 196 | assert error_message_1 == error_detail 197 | end 198 | 199 | test "gets a label", %{ 200 | label: label, 201 | expected_result: expected_result, 202 | bypass: bypass, 203 | label_id: label_id, 204 | user_id: user_id 205 | } do 206 | Bypass.expect bypass, fn conn -> 207 | assert "/gmail/v1/users/#{user_id}/labels/#{label_id}" == conn.request_path 208 | assert "" == conn.query_string 209 | assert "GET" == conn.method 210 | {:ok, json} = Poison.encode(label) 211 | Plug.Conn.resp(conn, 200, json) 212 | end 213 | {:ok, label} = Gmail.User.label(user_id, label_id) 214 | assert expected_result == label 215 | end 216 | 217 | test "reports :not_found for a label that doesn't exist", %{ 218 | bypass: bypass, 219 | label_not_found: label_not_found, 220 | label_id: label_id, 221 | user_id: user_id 222 | } do 223 | Bypass.expect bypass, fn conn -> 224 | assert "/gmail/v1/users/#{user_id}/labels/#{label_id}" == conn.request_path 225 | assert "" == conn.query_string 226 | {:ok, json} = Poison.encode(label_not_found) 227 | Plug.Conn.resp(conn, 200, json) 228 | end 229 | {:error, :not_found} = Gmail.User.label(user_id, label_id) 230 | end 231 | 232 | test "handles a 400 error when getting a label", %{ 233 | bypass: bypass, 234 | four_hundred_error: four_hundred_error, 235 | label_id: label_id, 236 | user_id: user_id 237 | } do 238 | Bypass.expect bypass, fn conn -> 239 | assert "/gmail/v1/users/#{user_id}/labels/#{label_id}" == conn.request_path 240 | assert "" == conn.query_string 241 | {:ok, json} = Poison.encode(four_hundred_error) 242 | Plug.Conn.resp(conn, 200, json) 243 | end 244 | {:error, "Error #1"} = Gmail.User.label(user_id, label_id) 245 | end 246 | 247 | test "patches a label", %{ 248 | label: label, 249 | label_name: label_name, 250 | expected_result: expected_result, 251 | bypass: bypass, 252 | label_name: label_name, 253 | label_id: label_id, 254 | expected_result: expected_result, 255 | user_id: user_id 256 | } do 257 | new_label_name = "Something Else" 258 | patched_label = %{label | "name" => new_label_name} 259 | expected_result = %{expected_result | name: new_label_name} 260 | patch_label = %Gmail.Label{id: label_id, name: new_label_name} 261 | Bypass.expect bypass, fn conn -> 262 | {:ok, body, _} = Plug.Conn.read_body(conn) 263 | {:ok, body_params} = body |> Poison.decode 264 | assert body_params == %{ 265 | "id" => label_id, 266 | "name" => new_label_name 267 | } 268 | assert "/gmail/v1/users/#{user_id}/labels/#{label_id}" == conn.request_path 269 | assert "" == conn.query_string 270 | assert "PATCH" == conn.method 271 | {:ok, json} = Poison.encode(patched_label) 272 | Plug.Conn.resp(conn, 200, json) 273 | end 274 | {:ok, label} = Gmail.User.label(:patch, user_id, patch_label) 275 | assert expected_result == label 276 | end 277 | 278 | test "handles an error when patching a label", %{ 279 | bypass: bypass, 280 | four_hundred_error: four_hundred_error, 281 | label_id: label_id, 282 | user_id: user_id, 283 | error_message_1: error_message_1 284 | } do 285 | new_label_name = "Something Else" 286 | patch_label = %Gmail.Label{id: label_id, name: new_label_name} 287 | Bypass.expect bypass, fn conn -> 288 | assert "/gmail/v1/users/#{user_id}/labels/#{label_id}" == conn.request_path 289 | assert "" == conn.query_string 290 | {:ok, json} = Poison.encode(four_hundred_error) 291 | Plug.Conn.resp(conn, 200, json) 292 | end 293 | {:error, error_detail} = Gmail.User.label(:patch, user_id, patch_label) 294 | assert error_message_1 == error_detail 295 | end 296 | 297 | end 298 | -------------------------------------------------------------------------------- /test/gmail/message_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.MessageTest do 4 | 5 | use ExUnit.Case 6 | import Mock 7 | 8 | setup do 9 | message_id = "23443513177" 10 | thread_id = "234234234" 11 | message = %{"id" => message_id, 12 | "threadId" => thread_id, 13 | "labelIds" => ["INBOX", "CATEGORY_PERSONAL"], 14 | "snippet" => "This is a message snippet", 15 | "historyId" => "12123", 16 | "payload" => %{"mimeType" => "text/html", 17 | "filename" => "", 18 | "headers" => ["header-1", "header-2"], 19 | "body" => %{"data" => Base.encode64("the actual body"), "size" => 234}, 20 | "parts" => []}, 21 | "sizeEstimate" => 23433 22 | } 23 | user_id = "user@example.com" 24 | 25 | search_result = %{"messages" => [message]} 26 | 27 | expected_search_result = [%Gmail.Message{id: message_id, thread_id: thread_id}] 28 | 29 | expected_result = %Gmail.Message{history_id: "12123", id: message_id, 30 | label_ids: ["INBOX", "CATEGORY_PERSONAL"], 31 | payload: %Gmail.Payload{body: %Gmail.Body{data: "the actual body", 32 | size: 234}, filename: "", headers: ["header-1", "header-2"], 33 | mime_type: "text/html", part_id: "", parts: []}, raw: "", 34 | size_estimate: 23433, snippet: "This is a message snippet", 35 | thread_id: thread_id} 36 | 37 | access_token = "xxx-xxx-xxx" 38 | access_token_rec = %{access_token: access_token} 39 | 40 | errors = [ 41 | %{"message" => "Error #1"}, 42 | %{"message" => "Error #2"} 43 | ] 44 | 45 | bypass = Bypass.open 46 | Application.put_env :gmail, :api, %{url: "http://localhost:#{bypass.port}/gmail/v1/"} 47 | 48 | Gmail.User.stop_mail(user_id) 49 | with_mock Gmail.OAuth2, [refresh_access_token: fn(_) -> {access_token, 100000000000000} end] do 50 | {:ok, _server_pid} = Gmail.User.start_mail(user_id, "dummy-refresh-token") 51 | end 52 | 53 | {:ok, 54 | message_id: message_id, 55 | message: message, 56 | access_token: access_token, 57 | access_token_rec: access_token_rec, 58 | expected_result: expected_result, 59 | search_result: search_result, 60 | expected_search_result: expected_search_result, 61 | message_not_found: %{"error" => %{"code" => 404}}, 62 | four_hundred_error: %{"error" => %{"code" => 400, "errors" => errors}}, 63 | bypass: bypass, 64 | user_id: user_id 65 | } 66 | end 67 | 68 | test "gets messages", %{ 69 | search_result: search_result, 70 | bypass: bypass, 71 | access_token: access_token, 72 | user_id: user_id, 73 | expected_search_result: expected_search_result 74 | } do 75 | Bypass.expect bypass, fn conn -> 76 | assert "/gmail/v1/users/#{user_id}/messages" == conn.request_path 77 | assert "" == conn.query_string 78 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 79 | assert "GET" == conn.method 80 | {:ok, json} = Poison.encode(search_result) 81 | Plug.Conn.resp(conn, 200, json) 82 | end 83 | {:ok, result} = Gmail.User.messages(user_id) 84 | assert result == expected_search_result 85 | end 86 | 87 | test "handles no messages being returned", %{ 88 | bypass: bypass, 89 | access_token: access_token, 90 | user_id: user_id 91 | } do 92 | Bypass.expect bypass, fn conn -> 93 | assert "/gmail/v1/users/#{user_id}/messages" == conn.request_path 94 | assert "" == conn.query_string 95 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 96 | assert "GET" == conn.method 97 | {:ok, json} = Poison.encode(%{"resultSizeEstimate" => 0}) 98 | Plug.Conn.resp(conn, 200, json) 99 | end 100 | {:ok, result} = Gmail.User.messages(user_id) 101 | assert result == [] 102 | end 103 | 104 | test "gets a message", %{ 105 | message: message, 106 | message_id: message_id, 107 | expected_result: expected_result, 108 | bypass: bypass, 109 | access_token: access_token, 110 | user_id: user_id 111 | } do 112 | Bypass.expect bypass, fn conn -> 113 | assert "/gmail/v1/users/#{user_id}/messages/#{message_id}" == conn.request_path 114 | assert "" == conn.query_string 115 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 116 | assert "GET" == conn.method 117 | {:ok, json} = Poison.encode(message) 118 | Plug.Conn.resp(conn, 200, json) 119 | end 120 | {:ok, message} = Gmail.User.message(user_id, message_id) 121 | assert message == expected_result 122 | end 123 | 124 | test "gets multiple messages", %{ 125 | message: message, 126 | message_id: message_id, 127 | expected_result: expected_result, 128 | bypass: bypass, 129 | access_token: access_token, 130 | user_id: user_id 131 | } do 132 | Bypass.expect bypass, fn conn -> 133 | assert "/gmail/v1/users/#{user_id}/messages/#{message_id}" == conn.request_path 134 | assert "" == conn.query_string 135 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 136 | assert "GET" == conn.method 137 | {:ok, json} = Poison.encode(message) 138 | Plug.Conn.resp(conn, 200, json) 139 | end 140 | {:ok, message} = Gmail.User.messages(user_id, [message_id]) 141 | assert message == [expected_result] 142 | end 143 | 144 | test "gets a message (body not base64 encoded, just for test coverage)", %{ 145 | message: message, 146 | message_id: message_id, 147 | bypass: bypass, 148 | user_id: user_id 149 | } do 150 | body = %{message["payload"]["body"] | "data" => "not a base64 string"} 151 | payload = %{message["payload"] | "body" => body} 152 | message = %{message | "payload" => payload} 153 | Bypass.expect bypass, fn conn -> 154 | assert "/gmail/v1/users/#{user_id}/messages/#{message_id}" == conn.request_path 155 | assert "" == conn.query_string 156 | {:ok, json} = Poison.encode(message) 157 | Plug.Conn.resp(conn, 200, json) 158 | end 159 | {:ok, _message} = Gmail.User.message(user_id, message_id) 160 | end 161 | 162 | test "reports :not_found for a message that doesn't exist", %{ 163 | message_id: message_id, 164 | bypass: bypass, 165 | message_not_found: message_not_found, 166 | user_id: user_id 167 | } do 168 | Bypass.expect bypass, fn conn -> 169 | assert "/gmail/v1/users/#{user_id}/messages/#{message_id}" == conn.request_path 170 | assert "" == conn.query_string 171 | {:ok, json} = Poison.encode(message_not_found) 172 | Plug.Conn.resp(conn, 200, json) 173 | end 174 | {:error, :not_found} = Gmail.User.message(user_id, message_id) 175 | end 176 | 177 | test "deletes a message", %{ 178 | message_id: message_id, 179 | access_token: access_token, 180 | bypass: bypass, 181 | user_id: user_id 182 | } do 183 | Bypass.expect bypass, fn conn -> 184 | assert "/gmail/v1/users/#{user_id}/messages/#{message_id}" == conn.request_path 185 | assert "" == conn.query_string 186 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 187 | assert "DELETE" == conn.method 188 | Plug.Conn.resp(conn, 200, "") 189 | end 190 | assert :ok == Gmail.User.message(:delete, user_id, message_id) 191 | end 192 | 193 | test "trashes a message", %{ 194 | message_id: message_id, 195 | access_token: access_token, 196 | bypass: bypass, 197 | user_id: user_id, 198 | message: message, 199 | expected_result: expected_result 200 | } do 201 | Bypass.expect bypass, fn conn -> 202 | assert "/gmail/v1/users/#{user_id}/messages/#{message_id}/trash" == conn.request_path 203 | assert "" == conn.query_string 204 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 205 | assert "POST" == conn.method 206 | {:ok, json} = Poison.encode(message) 207 | Plug.Conn.resp(conn, 200, json) 208 | end 209 | {:ok, result} = Gmail.User.message(:trash, user_id, message_id) 210 | assert result == expected_result 211 | end 212 | 213 | test "untrashes a message", %{ 214 | message_id: message_id, 215 | access_token: access_token, 216 | bypass: bypass, 217 | user_id: user_id, 218 | message: message, 219 | expected_result: expected_result 220 | } do 221 | Bypass.expect bypass, fn conn -> 222 | assert "/gmail/v1/users/#{user_id}/messages/#{message_id}/untrash" == conn.request_path 223 | assert "" == conn.query_string 224 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 225 | assert "POST" == conn.method 226 | {:ok, json} = Poison.encode(message) 227 | Plug.Conn.resp(conn, 200, json) 228 | end 229 | {:ok, result} = Gmail.User.message(:untrash, user_id, message_id) 230 | assert result == expected_result 231 | end 232 | 233 | test "handles a 400 error from the API", %{ 234 | message_id: message_id, 235 | bypass: bypass, 236 | four_hundred_error: four_hundred_error, 237 | user_id: user_id 238 | } do 239 | Bypass.expect bypass, fn conn -> 240 | assert "/gmail/v1/users/#{user_id}/messages/#{message_id}" == conn.request_path 241 | assert "" == conn.query_string 242 | {:ok, json} = Poison.encode(four_hundred_error) 243 | Plug.Conn.resp(conn, 200, json) 244 | end 245 | {:error, "Error #1"} = Gmail.User.message(user_id, message_id) 246 | end 247 | 248 | test "performs a message search", %{ 249 | bypass: bypass, 250 | search_result: search_result, 251 | expected_search_result: expected_search_result, 252 | user_id: user_id 253 | } do 254 | Bypass.expect bypass, fn conn -> 255 | assert "/gmail/v1/users/#{user_id}/messages" == conn.request_path 256 | assert URI.encode_query(%{"q" => "in:Inbox"}) == conn.query_string 257 | assert "GET" == conn.method 258 | {:ok, json} = Poison.encode(search_result) 259 | Plug.Conn.resp(conn, 200, json) 260 | end 261 | {:ok, results} = Gmail.User.search(user_id, :message, "in:Inbox") 262 | assert results == expected_search_result 263 | end 264 | 265 | test "gets a list of messages", %{ 266 | message: message, 267 | bypass: bypass, 268 | expected_search_result: expected_search_result, 269 | user_id: user_id 270 | } do 271 | Bypass.expect bypass, fn conn -> 272 | assert "/gmail/v1/users/#{user_id}/messages" == conn.request_path 273 | assert "" == conn.query_string 274 | assert "GET" == conn.method 275 | {:ok, json} = Poison.encode(%{"messages" => [message]}) 276 | Plug.Conn.resp(conn, 200, json) 277 | end 278 | {:ok, results} = Gmail.User.messages(user_id) 279 | assert results == expected_search_result 280 | end 281 | 282 | test "modifies the labels on a message", %{ 283 | message_id: message_id, 284 | access_token: access_token, 285 | bypass: bypass, 286 | user_id: user_id, 287 | message: message, 288 | expected_result: expected_result 289 | } do 290 | labels_to_add = ["LABEL1", "LABEL2"] 291 | labels_to_remove = ["LABEL3", "LABEL4"] 292 | Bypass.expect bypass, fn conn -> 293 | {:ok, body, _} = Plug.Conn.read_body(conn) 294 | {:ok, body_params} = body |> Poison.decode 295 | assert body_params == %{ 296 | "addLabelIds" => labels_to_add, 297 | "removeLabelIds" => labels_to_remove 298 | } 299 | assert "/gmail/v1/users/#{user_id}/messages/#{message_id}/modify" == conn.request_path 300 | assert "" == conn.query_string 301 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 302 | assert "POST" == conn.method 303 | {:ok, json} = Poison.encode(message) 304 | Plug.Conn.resp(conn, 200, json) 305 | end 306 | {:ok, result} = Gmail.User.message(:modify, user_id, message_id, labels_to_add, labels_to_remove) 307 | assert result == expected_result 308 | end 309 | 310 | end 311 | -------------------------------------------------------------------------------- /test/gmail/thread_test.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | defmodule Gmail.ThreadTest do 4 | 5 | use ExUnit.Case 6 | import Mock 7 | 8 | setup do 9 | other_thread_id = "6576897" 10 | thread_id = "34534345" 11 | history_id = "2435435" 12 | next_page_token = "23121233" 13 | user_id = "user@example.com" 14 | 15 | expected_result = %Gmail.Thread{history_id: history_id, id: thread_id, 16 | messages: [%Gmail.Message{history_id: "12123", id: "23443513177", 17 | label_ids: ["INBOX", "CATEGORY_PERSONAL"], 18 | payload: %Gmail.Payload{body: %Gmail.Body{data: "the actual body", 19 | size: 234}, filename: "", headers: ["header-1", "header-2"], 20 | mime_type: "text/html", part_id: "", parts: []}, raw: "", 21 | size_estimate: 23433, snippet: "This is a message snippet", 22 | thread_id: thread_id}], snippet: ""} 23 | 24 | message = %{"id" => "23443513177", 25 | "threadId" => thread_id, 26 | "labelIds" => ["INBOX", "CATEGORY_PERSONAL"], 27 | "snippet" => "This is a message snippet", 28 | "historyId" => "12123", 29 | "payload" => %{"mimeType" => "text/html", 30 | "filename" => "", 31 | "headers" => ["header-1", "header-2"], 32 | "body" => %{"data" => Base.encode64("the actual body"), "size" => 234}, 33 | "parts" => []}, 34 | "sizeEstimate" => 23433 35 | } 36 | 37 | thread = %{ 38 | "id" => thread_id, 39 | "historyId" => "2435435", 40 | "messages" => [message], 41 | "snippet" => "Thread #1" 42 | } 43 | 44 | other_thread = %{ 45 | "id" => other_thread_id, 46 | "historyId" => "2435435", 47 | "messages" => [], 48 | "snippet" => "Thread #1" 49 | } 50 | 51 | threads = %{ 52 | "threads" => [thread, other_thread], 53 | "nextPageToken" => next_page_token 54 | } 55 | 56 | access_token = "xxx-xxx-xxx" 57 | access_token_rec = %{access_token: access_token} 58 | 59 | search_results = %{"threads" => [%{ 60 | "id" => thread_id, 61 | "historyId" => "2435435", 62 | "snippet" => "Thread #1" 63 | }, 64 | %{ 65 | "id" => "6576897", 66 | "historyId" => "2435435", 67 | "snippet" => "Thread #1" 68 | }] 69 | } 70 | 71 | list_results = %{"threads" => [%{ 72 | "id" => thread_id, 73 | "historyId" => "2435435", 74 | "snippet" => "Thread #1" 75 | }, 76 | %{ 77 | "id" => "6576897", 78 | "historyId" => "2435435", 79 | "snippet" => "Thread #1" 80 | }], 81 | "nextPageToken" => next_page_token 82 | } 83 | 84 | expected_search_results = [ 85 | %Gmail.Thread{ 86 | id: thread_id, 87 | history_id: "2435435", 88 | snippet: "Thread #1" 89 | }, 90 | %Gmail.Thread{ 91 | id: "6576897", 92 | history_id: "2435435", 93 | snippet: "Thread #1" 94 | } 95 | ] 96 | 97 | errors = [ 98 | %{"message" => "Error #1"}, 99 | %{"message" => "Error #2"} 100 | ] 101 | 102 | bypass = Bypass.open 103 | Application.put_env :gmail, :api, %{url: "http://localhost:#{bypass.port}/gmail/v1/"} 104 | 105 | Gmail.User.stop_mail(user_id) 106 | with_mock Gmail.OAuth2, [refresh_access_token: fn(_) -> {access_token, 100000000000000} end] do 107 | {:ok, _server_pid} = Gmail.User.start_mail(user_id, "dummy-refresh-token") 108 | end 109 | 110 | {:ok, 111 | next_page_token: next_page_token, 112 | thread_id: thread_id, 113 | other_thread_id: other_thread_id, 114 | threads: threads, 115 | thread: thread, 116 | other_thread: other_thread, 117 | message: message, 118 | expected_result: expected_result, 119 | access_token: access_token, 120 | access_token_rec: access_token_rec, 121 | search_results: search_results, 122 | expected_search_results: expected_search_results, 123 | thread_not_found: %{"error" => %{"code" => 404}}, 124 | four_hundred_error: %{"error" => %{"code" => 400, "errors" => errors}}, 125 | bypass: bypass, 126 | list_results: list_results, 127 | user_id: user_id 128 | } 129 | end 130 | 131 | test "gets a thread", %{ 132 | thread: thread, 133 | thread_id: thread_id, 134 | access_token: access_token, 135 | expected_result: expected_result, 136 | bypass: bypass, 137 | user_id: user_id 138 | } do 139 | Bypass.expect bypass, fn conn -> 140 | assert "/gmail/v1/users/#{user_id}/threads/#{thread_id}" == conn.request_path 141 | assert "" == conn.query_string 142 | assert "GET" == conn.method 143 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 144 | {:ok, json} = Poison.encode(thread) 145 | Plug.Conn.resp(conn, 200, json) 146 | end 147 | {:ok, thread} = Gmail.User.thread(user_id, thread_id) 148 | assert expected_result == thread 149 | end 150 | 151 | test "handles failure when the API is unreachable when getting a thread", %{ 152 | thread: thread, 153 | thread_id: thread_id, 154 | access_token: access_token, 155 | expected_result: expected_result, 156 | bypass: bypass, 157 | user_id: user_id 158 | } do 159 | Bypass.expect bypass, fn conn -> 160 | assert "/gmail/v1/users/#{user_id}/threads/#{thread_id}" == conn.request_path 161 | assert "" == conn.query_string 162 | assert "GET" == conn.method 163 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 164 | {:ok, json} = Poison.encode(thread) 165 | Plug.Conn.resp(conn, 200, json) 166 | end 167 | Bypass.down(bypass) 168 | {:error, :econnrefused} = Gmail.User.thread(user_id, thread_id) 169 | Bypass.up(bypass) 170 | {:ok, thread} = Gmail.User.thread(user_id, thread_id) 171 | assert expected_result == thread 172 | end 173 | 174 | test "refreshes an expired access token when getting a thread", %{ 175 | thread: thread, 176 | thread_id: thread_id, 177 | access_token: access_token, 178 | expected_result: expected_result, 179 | bypass: bypass, 180 | user_id: user_id 181 | } do 182 | Bypass.expect bypass, fn conn -> 183 | assert "/gmail/v1/users/#{user_id}/threads/#{thread_id}" == conn.request_path 184 | assert "" == conn.query_string 185 | assert "GET" == conn.method 186 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 187 | {:ok, json} = Poison.encode(thread) 188 | Plug.Conn.resp(conn, 200, json) 189 | end 190 | with_mock Gmail.OAuth2, [ 191 | refresh_access_token: fn(_) -> {access_token, 100000000000000} end, 192 | access_token_expired?: fn(_) -> true end 193 | ] do 194 | {:ok, thread} = Gmail.User.thread(user_id, thread_id) 195 | assert expected_result == thread 196 | assert called Gmail.OAuth2.refresh_access_token("dummy-refresh-token") 197 | end 198 | end 199 | 200 | test "gets a thread, specifying the full format", %{ 201 | thread: thread, 202 | thread_id: thread_id, 203 | access_token: access_token, 204 | expected_result: expected_result, 205 | bypass: bypass, 206 | user_id: user_id 207 | } do 208 | Bypass.expect bypass, fn conn -> 209 | assert "/gmail/v1/users/#{user_id}/threads/#{thread_id}" == conn.request_path 210 | assert "format=full" == conn.query_string 211 | assert "GET" == conn.method 212 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 213 | {:ok, json} = Poison.encode(thread) 214 | Plug.Conn.resp(conn, 200, json) 215 | end 216 | {:ok, thread} = Gmail.User.thread(user_id, thread_id, %{format: "full"}) 217 | assert expected_result == thread 218 | end 219 | 220 | test "gets a thread, specifying the metadata format, with headers", %{ 221 | thread: thread, 222 | thread_id: thread_id, 223 | access_token: access_token, 224 | expected_result: expected_result, 225 | bypass: bypass, 226 | user_id: user_id 227 | } do 228 | Bypass.expect bypass, fn conn -> 229 | assert "/gmail/v1/users/#{user_id}/threads/#{thread_id}" == conn.request_path 230 | assert URI.encode_query(%{"format" => "metadata", "metadataHeaders" => "header1,header1"}) == conn.query_string 231 | assert "GET" == conn.method 232 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 233 | {:ok, json} = Poison.encode(thread) 234 | Plug.Conn.resp(conn, 200, json) 235 | end 236 | {:ok, thread} = Gmail.User.thread(user_id, thread_id, %{format: "metadata", metadata_headers: ["header1", "header1"]}) 237 | assert expected_result == thread 238 | end 239 | 240 | test "reports :not_found for a thread that doesn't exist", %{ 241 | thread_not_found: thread_not_found, 242 | thread_id: thread_id, 243 | bypass: bypass, 244 | user_id: user_id 245 | } do 246 | Bypass.expect bypass, fn conn -> 247 | {:ok, json} = Poison.encode(thread_not_found) 248 | Plug.Conn.resp(conn, 200, json) 249 | end 250 | {:error, :not_found} = Gmail.User.thread(user_id, thread_id) 251 | end 252 | 253 | test "gets multiple threads", %{ 254 | thread: %{"id" => thread_id} = thread, 255 | access_token: access_token, 256 | bypass: bypass, 257 | user_id: user_id, 258 | expected_result: expected_result 259 | } do 260 | Bypass.expect bypass, fn conn -> 261 | assert "/gmail/v1/users/#{user_id}/threads/#{thread_id}" == conn.request_path 262 | assert "GET" == conn.method 263 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 264 | {:ok, json} = Poison.encode(thread) 265 | Plug.Conn.resp(conn, 200, json) 266 | end 267 | {:ok, thread} = Gmail.User.threads(user_id, [thread_id]) 268 | assert [expected_result] == thread 269 | end 270 | 271 | test "handles a 400 error from the API", %{ 272 | four_hundred_error: four_hundred_error, 273 | thread_id: thread_id, 274 | bypass: bypass, 275 | user_id: user_id 276 | } do 277 | Bypass.expect bypass, fn conn -> 278 | {:ok, json} = Poison.encode(four_hundred_error) 279 | Plug.Conn.resp(conn, 200, json) 280 | end 281 | {:error, "Error #1"} = Gmail.User.thread(user_id, thread_id) 282 | end 283 | 284 | test "deletes a thread", %{ 285 | thread_id: thread_id, 286 | access_token: access_token, 287 | bypass: bypass, 288 | user_id: user_id 289 | } do 290 | Bypass.expect bypass, fn conn -> 291 | assert "/gmail/v1/users/#{user_id}/threads/#{thread_id}" == conn.request_path 292 | assert "" == conn.query_string 293 | assert "DELETE" == conn.method 294 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 295 | Plug.Conn.resp(conn, 200, "") 296 | end 297 | assert :ok == Gmail.User.thread(:delete, user_id, thread_id) 298 | end 299 | 300 | test "trashes a thread", %{ 301 | thread_id: thread_id, 302 | access_token: access_token, 303 | bypass: bypass, 304 | user_id: user_id, 305 | thread: thread, 306 | expected_result: expected_result, 307 | } do 308 | Bypass.expect bypass, fn conn -> 309 | assert "/gmail/v1/users/#{user_id}/threads/#{thread_id}/trash" == conn.request_path 310 | assert "" == conn.query_string 311 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 312 | assert "POST" == conn.method 313 | {:ok, json} = Poison.encode(thread) 314 | Plug.Conn.resp(conn, 200, json) 315 | end 316 | {:ok, result} = Gmail.User.thread(:trash, user_id, thread_id) 317 | assert result == expected_result 318 | end 319 | 320 | test "untrashes a thread", %{ 321 | thread_id: thread_id, 322 | access_token: access_token, 323 | bypass: bypass, 324 | user_id: user_id, 325 | thread: thread, 326 | expected_result: expected_result, 327 | } do 328 | Bypass.expect bypass, fn conn -> 329 | assert "/gmail/v1/users/#{user_id}/threads/#{thread_id}/untrash" == conn.request_path 330 | assert "" == conn.query_string 331 | assert {"authorization", "Bearer #{access_token}"} in conn.req_headers 332 | assert "POST" == conn.method 333 | {:ok, json} = Poison.encode(thread) 334 | Plug.Conn.resp(conn, 200, json) 335 | end 336 | {:ok, result} = Gmail.User.thread(:untrash, user_id, thread_id) 337 | assert result == expected_result 338 | end 339 | 340 | test "performs a thread search", %{ 341 | bypass: bypass, 342 | search_results: search_results, 343 | expected_search_results: expected_search_results, 344 | user_id: user_id 345 | } do 346 | Bypass.expect bypass, fn conn -> 347 | assert "/gmail/v1/users/#{user_id}/threads" == conn.request_path 348 | assert URI.encode_query(%{"q" => "in:Inbox"}) == conn.query_string 349 | assert "GET" == conn.method 350 | {:ok, json} = Poison.encode(search_results) 351 | Plug.Conn.resp(conn, 200, json) 352 | end 353 | {:ok, results} = Gmail.User.search(user_id, :thread, "in:Inbox") 354 | assert expected_search_results === results 355 | end 356 | 357 | test "gets a list of threads", %{ 358 | bypass: bypass, 359 | expected_search_results: expected_search_results, 360 | list_results: list_results, 361 | next_page_token: next_page_token, 362 | user_id: user_id 363 | } do 364 | Bypass.expect bypass, fn conn -> 365 | assert "/gmail/v1/users/#{user_id}/threads" == conn.request_path 366 | assert "" == conn.query_string 367 | assert "GET" == conn.method 368 | {:ok, json} = Poison.encode(list_results) 369 | Plug.Conn.resp(conn, 200, json) 370 | end 371 | {:ok, results, page_token} = Gmail.User.threads(user_id) 372 | assert expected_search_results == results 373 | assert page_token == next_page_token 374 | end 375 | 376 | test "gets a list of threads with a page token", %{ 377 | bypass: bypass, 378 | expected_search_results: expected_search_results, 379 | list_results: list_results, 380 | next_page_token: next_page_token, 381 | user_id: user_id 382 | } do 383 | requested_page_token = "435453455" 384 | Bypass.expect bypass, fn conn -> 385 | assert "/gmail/v1/users/#{user_id}/threads" == conn.request_path 386 | assert "pageToken=#{requested_page_token}" == conn.query_string 387 | {:ok, json} = Poison.encode(list_results) 388 | Plug.Conn.resp(conn, 200, json) 389 | end 390 | params = %{page_token: requested_page_token} 391 | {:ok, results, page_token} = Gmail.User.threads(user_id, params) 392 | assert expected_search_results === results 393 | assert next_page_token == page_token 394 | end 395 | 396 | test "properly sends the maxResults query parameter", %{ 397 | bypass: bypass, 398 | list_results: list_results, 399 | user_id: user_id 400 | } do 401 | max_results = 20 402 | Bypass.expect bypass, fn conn -> 403 | assert "/gmail/v1/users/#{user_id}/threads" == conn.request_path 404 | assert "maxResults=#{max_results}" == conn.query_string 405 | {:ok, json} = Poison.encode(list_results) 406 | Plug.Conn.resp(conn, 200, json) 407 | end 408 | params = %{max_results: max_results} 409 | {:ok, _results, _page_token} = Gmail.User.threads(user_id, params) 410 | end 411 | 412 | test "gets a list of threads with a user and params without page token (ignoring invalid param)", %{ 413 | bypass: bypass, 414 | expected_search_results: expected_search_results, 415 | list_results: list_results, 416 | user_id: user_id 417 | } do 418 | Bypass.expect bypass, fn conn -> 419 | assert "/gmail/v1/users/#{user_id}/threads" == conn.request_path 420 | assert "" == conn.query_string 421 | {:ok, json} = Poison.encode(list_results) 422 | Plug.Conn.resp(conn, 200, json) 423 | end 424 | params = %{page_token_yarr: "345345345"} 425 | {:ok, results, _page_token} = Gmail.User.threads(user_id, params) 426 | assert expected_search_results === results 427 | end 428 | 429 | end 430 | -------------------------------------------------------------------------------- /lib/gmail/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Gmail.User do 2 | require Logger 3 | 4 | @moduledoc """ 5 | Represents a user's mailbox, holding it's config and tokens. 6 | """ 7 | 8 | use GenServer 9 | alias Gmail.{Thread, Message, MessageAttachment, HTTP, Label, Draft, OAuth2, History} 10 | 11 | # Server API {{{ # 12 | 13 | @doc false 14 | def start_link({user_id, refresh_token}) do 15 | GenServer.start_link(__MODULE__, {user_id, refresh_token}, name: String.to_atom(user_id)) 16 | end 17 | 18 | @doc false 19 | def init({user_id, refresh_token}) do 20 | {access_token, expires_at} = OAuth2.refresh_access_token(refresh_token) 21 | state = Map.new(user_id: user_id, refresh_token: refresh_token, 22 | access_token: access_token, expires_at: expires_at) 23 | {:ok, state} 24 | end 25 | 26 | @doc false 27 | def handle_cast({:update_access_token, access_token, expires_at}, state) do 28 | {:noreply, %{state | access_token: access_token, expires_at: expires_at}} 29 | end 30 | 31 | @doc false 32 | def handle_cast(:stop, state) do 33 | {:stop, :normal, state} 34 | end 35 | 36 | # Threads {{{ # 37 | 38 | @doc false 39 | def handle_call({:thread, {:list, params}}, _from, %{user_id: user_id} = state) do 40 | result = 41 | user_id 42 | |> Thread.list(params) 43 | |> http_execute(state) 44 | |> Thread.handle_thread_list_response 45 | {:reply, result, state} 46 | end 47 | 48 | @doc false 49 | def handle_call({:thread, {:get, thread_ids, params}}, _from, %{user_id: user_id} = state) when is_list(thread_ids) do 50 | threads = 51 | thread_ids 52 | |> Enum.map(fn id -> 53 | Task.async(fn -> 54 | {:ok, thread} = Gmail.Thread.Pool.get(user_id, id, params, state) 55 | thread 56 | end) 57 | end) 58 | |> Enum.map(fn task -> Task.await(task, :infinity) end) 59 | {:reply, {:ok, threads}, state} 60 | end 61 | 62 | @doc false 63 | def handle_call({:thread, {:get, thread_id, params}}, _from, %{user_id: user_id} = state) do 64 | result = Gmail.Thread.Pool.get(user_id, thread_id, params, state) 65 | {:reply, result, state} 66 | end 67 | 68 | @doc false 69 | def handle_call({:thread, {:delete, thread_id}}, _from, %{user_id: user_id} = state) do 70 | result = 71 | user_id 72 | |> Thread.delete(thread_id) 73 | |> http_execute(state) 74 | |> Thread.handle_thread_delete_response 75 | {:reply, result, state} 76 | end 77 | 78 | @doc false 79 | def handle_call({:thread, {:trash, thread_id}}, _from, %{user_id: user_id} = state) do 80 | result = 81 | user_id 82 | |> Thread.trash(thread_id) 83 | |> http_execute(state) 84 | |> Thread.handle_thread_response 85 | {:reply, result, state} 86 | end 87 | 88 | @doc false 89 | def handle_call({:thread, {:untrash, thread_id}}, _from, %{user_id: user_id} = state) do 90 | result = 91 | user_id 92 | |> Thread.untrash(thread_id) 93 | |> http_execute(state) 94 | |> Thread.handle_thread_response 95 | {:reply, result, state} 96 | end 97 | 98 | @doc false 99 | def handle_call({:search, :thread, query, params}, _from, %{user_id: user_id} = state) do 100 | result = 101 | user_id 102 | |> Thread.search(query, params) 103 | |> http_execute(state) 104 | |> Thread.handle_thread_list_response 105 | {:reply, result, state} 106 | end 107 | 108 | # }}} Threads # 109 | 110 | # Messages {{{ # 111 | 112 | @doc false 113 | def handle_call({:message, {:list, params}}, _from, %{user_id: user_id} = state) do 114 | result = 115 | user_id 116 | |> Message.list(params) 117 | |> http_execute(state) 118 | |> Message.handle_message_list_response 119 | {:reply, result, state} 120 | end 121 | 122 | @doc false 123 | def handle_call({:message, {:get, message_ids, params}}, _from, %{user_id: user_id} = state) when is_list(message_ids) do 124 | messages = 125 | message_ids 126 | |> Enum.map(fn id -> 127 | Task.async(fn -> 128 | {:ok, message} = Gmail.Message.Pool.get(user_id, id, params, state) 129 | message 130 | end) 131 | end) 132 | |> Enum.map(fn task -> Task.await(task, :infinity) end) 133 | {:reply, {:ok, messages}, state} 134 | end 135 | 136 | @doc false 137 | def handle_call({:message, {:get, message_id, params}}, _from, %{user_id: user_id} = state) do 138 | result = Gmail.Message.Pool.get(user_id, message_id, params, state) 139 | {:reply, result, state} 140 | end 141 | 142 | @doc false 143 | def handle_call({:message, {:delete, message_id}}, _from, %{user_id: user_id} = state) do 144 | result = 145 | user_id 146 | |> Message.delete(message_id) 147 | |> http_execute(state) 148 | |> Message.handle_message_delete_response 149 | {:reply, result, state} 150 | end 151 | 152 | @doc false 153 | def handle_call({:message, {:trash, message_id}}, _from, %{user_id: user_id} = state) do 154 | result = 155 | user_id 156 | |> Message.trash(message_id) 157 | |> http_execute(state) 158 | |> Message.handle_message_response 159 | {:reply, result, state} 160 | end 161 | 162 | @doc false 163 | def handle_call({:message, {:untrash, message_id}}, _from, %{user_id: user_id} = state) do 164 | result = 165 | user_id 166 | |> Message.untrash(message_id) 167 | |> http_execute(state) 168 | |> Message.handle_message_response 169 | {:reply, result, state} 170 | end 171 | 172 | @doc false 173 | def handle_call({:search, :message, query, params}, _from, %{user_id: user_id} = state) do 174 | result = 175 | user_id 176 | |> Message.search(query, params) 177 | |> http_execute(state) 178 | |> Message.handle_message_list_response 179 | {:reply, result, state} 180 | end 181 | 182 | @doc false 183 | def handle_call({:message, {:modify, message_id, labels_to_add, labels_to_remove}}, _from, %{user_id: user_id} = state) do 184 | result = 185 | user_id 186 | |> Message.modify(message_id, labels_to_add, labels_to_remove) 187 | |> http_execute(state) 188 | |> Message.handle_message_response 189 | {:reply, result, state} 190 | end 191 | 192 | # }}} Messages # 193 | 194 | # Attachments {{{ # 195 | 196 | @doc false 197 | def handle_call({:attachment, {:get, message_id, id}}, _from, %{user_id: user_id} = state) do 198 | result = 199 | user_id 200 | |> MessageAttachment.get(message_id, id) 201 | |> http_execute(state) 202 | |> MessageAttachment.handle_attachment_response 203 | {:reply, result, state} 204 | end 205 | 206 | # }}} Attachments # 207 | 208 | # Labels {{{ # 209 | 210 | @doc false 211 | def handle_call({:label, {:list}}, _from, %{user_id: user_id} = state) do 212 | result = 213 | user_id 214 | |> Label.list 215 | |> http_execute(state) 216 | |> Label.handle_label_list_response 217 | {:reply, result, state} 218 | end 219 | 220 | def handle_call({:label, {:get, label_id}}, _from, %{user_id: user_id} = state) do 221 | result = 222 | user_id 223 | |> Label.get(label_id) 224 | |> http_execute(state) 225 | |> Label.handle_label_response 226 | {:reply, result, state} 227 | end 228 | 229 | def handle_call({:label, {:create, label_name}}, _from, %{user_id: user_id} = state) do 230 | result = 231 | user_id 232 | |> Label.create(label_name) 233 | |> http_execute(state) 234 | |> Label.handle_label_response 235 | {:reply, result, state} 236 | end 237 | 238 | def handle_call({:label, {:delete, label_id}}, _from, %{user_id: user_id} = state) do 239 | result = 240 | user_id 241 | |> Label.delete(label_id) 242 | |> http_execute(state) 243 | |> Label.handle_label_delete_response 244 | {:reply, result, state} 245 | end 246 | 247 | def handle_call({:label, {:update, label}}, _from, %{user_id: user_id} = state) do 248 | result = 249 | user_id 250 | |> Label.update(label) 251 | |> http_execute(state) 252 | |> Label.handle_label_response 253 | {:reply, result, state} 254 | end 255 | 256 | def handle_call({:label, {:patch, label}}, _from, %{user_id: user_id} = state) do 257 | result = 258 | user_id 259 | |> Label.patch(label) 260 | |> http_execute(state) 261 | |> Label.handle_label_response 262 | {:reply, result, state} 263 | end 264 | 265 | # }}} Labels # 266 | 267 | # Drafts {{{ # 268 | 269 | def handle_call({:draft, {:list}}, _from, %{user_id: user_id} = state) do 270 | result = 271 | user_id 272 | |> Draft.list 273 | |> http_execute(state) 274 | |> case do 275 | {:ok, %{"error" => details}} -> 276 | {:error, details} 277 | {:ok, %{"resultSizeEstimate" => 0}} -> 278 | {:ok, []} 279 | {:ok, %{"drafts" => raw_drafts}} -> 280 | {:ok, Enum.map(raw_drafts, &Draft.convert/1)} 281 | end 282 | {:reply, result, state} 283 | end 284 | 285 | def handle_call({:draft, {:get, draft_id}}, _from, %{user_id: user_id} = state) do 286 | result = 287 | user_id 288 | |> Draft.get(draft_id) 289 | |> http_execute(state) 290 | |> case do 291 | {:ok, %{"error" => %{"code" => 404}}} -> 292 | {:error, :not_found} 293 | {:ok, %{"error" => %{"code" => 400, "errors" => errors}}} -> 294 | [%{"message" => error_message}|_rest] = errors 295 | {:error, error_message} 296 | {:ok, %{"error" => details}} -> 297 | {:error, details} 298 | {:ok, raw_message} -> 299 | {:ok, Draft.convert(raw_message)} 300 | end 301 | {:reply, result, state} 302 | end 303 | 304 | def handle_call({:draft, {:delete, draft_id}}, _from, %{user_id: user_id} = state) do 305 | result = 306 | user_id 307 | |> Draft.delete(draft_id) 308 | |> http_execute(state) 309 | |> case do 310 | {:ok, %{"error" => %{"code" => 404}}} -> 311 | {:error, :not_found} 312 | {:ok, %{"error" => %{"code" => 400, "errors" => errors}}} -> 313 | [%{"message" => error_message}|_rest] = errors 314 | {:error, error_message} 315 | {:ok, _} -> 316 | :ok 317 | end 318 | {:reply, result, state} 319 | end 320 | 321 | def handle_call({:draft, {:send, draft_id}}, _from, %{user_id: user_id} = state) do 322 | result = 323 | user_id 324 | |> Draft.send(draft_id) 325 | |> http_execute(state) 326 | |> case do 327 | {:ok, %{"error" => %{"code" => 404}}} -> 328 | {:error, :not_found} 329 | {:ok, %{"error" => detail}} -> 330 | {:error, detail} 331 | {:ok, %{"threadId" => thread_id}} -> 332 | {:ok, %{thread_id: thread_id}} 333 | end 334 | {:reply, result, state} 335 | end 336 | 337 | # }}} Drafts # 338 | 339 | # History {{{ # 340 | 341 | @doc false 342 | def handle_call({:history, {:list, params}}, _from, %{user_id: user_id} = state) do 343 | result = 344 | user_id 345 | |> History.list(params) 346 | |> http_execute(state) 347 | |> History.handle_history_response 348 | {:reply, result, state} 349 | end 350 | 351 | # }}} History # 352 | 353 | # }}} Server API # 354 | 355 | # Client API {{{ # 356 | 357 | # Server control {{{ # 358 | 359 | @doc """ 360 | Starts the process for the specified user. 361 | """ 362 | @spec start_mail(String.t, String.t) :: {atom, pid} | {atom, map} 363 | def start_mail(user_id, refresh_token) do 364 | case Supervisor.start_child(Gmail.UserManager, [{user_id, refresh_token}]) do 365 | {:ok, pid} -> 366 | {:ok, pid} 367 | {:error, {:already_started, pid}} -> 368 | {:ok, pid} 369 | {:error, details} -> 370 | {:error, details} 371 | end 372 | end 373 | 374 | @doc """ 375 | Stops the process for the specified user. 376 | """ 377 | @spec stop_mail(atom | String.t) :: :ok 378 | def stop_mail(user_id) when is_binary(user_id), do: user_id |> String.to_atom |> stop_mail 379 | 380 | def stop_mail(user_id) when is_atom(user_id) do 381 | if Process.whereis(user_id) do 382 | GenServer.cast(user_id, :stop) 383 | else 384 | :ok 385 | end 386 | end 387 | 388 | # }}} Server control # 389 | 390 | # Threads {{{ # 391 | 392 | @spec threads(String.t) :: atom 393 | @spec threads(String.t, map) :: atom 394 | @spec threads(String.t, list) :: atom 395 | @spec threads(String.t, list, map) :: atom 396 | @spec thread(String.t, String.t) :: atom 397 | @spec thread(String.t, String.t, map) :: atom 398 | @spec thread(atom, String.t, String.t) :: atom 399 | 400 | @doc """ 401 | Lists the threads in the specified user's mailbox. 402 | """ 403 | def threads(user_id), do: threads(user_id, %{}) 404 | 405 | def threads(user_id, params) when is_map(params) do 406 | call(user_id, {:thread, {:list, params}}, :infinity) 407 | end 408 | 409 | @doc """ 410 | Gets all the requested threads from the specified user's mailbox. 411 | """ 412 | def threads(user_id, thread_ids) when is_list(thread_ids), do: threads(user_id, thread_ids, %{}) 413 | 414 | def threads(user_id, thread_ids, params) when is_list(thread_ids) do 415 | call(user_id, {:thread, {:get, thread_ids, params}}, :infinity) 416 | end 417 | 418 | @doc """ 419 | Gets a thread from the specified user's mailbox. 420 | """ 421 | def thread(user_id, thread_id) when is_binary(user_id), do: thread(user_id, thread_id, %{}) 422 | 423 | def thread(user_id, thread_id, params) when is_binary(user_id) do 424 | call(user_id, {:thread, {:get, thread_id, params}}, :infinity) 425 | end 426 | 427 | @doc """ 428 | Deletes the specified thread from the user's mailbox. 429 | """ 430 | def thread(:delete, user_id, thread_id) do 431 | call(user_id, {:thread, {:delete, thread_id}}, :infinity) 432 | end 433 | 434 | @doc """ 435 | Trashes the specified thread from the user's mailbox. 436 | """ 437 | def thread(:trash, user_id, thread_id) do 438 | call(user_id, {:thread, {:trash, thread_id}}, :infinity) 439 | end 440 | 441 | @doc """ 442 | Removes the specified thread from the trash in the user's mailbox. 443 | """ 444 | def thread(:untrash, user_id, thread_id) do 445 | call(user_id, {:thread, {:untrash, thread_id}}, :infinity) 446 | end 447 | 448 | # }}} Threads # 449 | 450 | # Messages {{{ # 451 | 452 | @doc """ 453 | Lists the messages in the specified user's mailbox. 454 | """ 455 | @spec messages(atom, map) :: atom 456 | @spec messages(String.t, map) :: atom 457 | def messages(user_id, params \\ %{}) 458 | def messages(user_id, params) when is_map(params) do 459 | call(user_id, {:message, {:list, params}}, :infinity) 460 | end 461 | 462 | @doc """ 463 | Gets all the requested messages from the specified user's mailbox. 464 | """ 465 | def messages(user_id, message_ids) when is_list(message_ids) do 466 | messages(user_id, message_ids, %{}) 467 | end 468 | 469 | def messages(user_id, message_ids, params) when is_list(message_ids) do 470 | call(user_id, {:message, {:get, message_ids, params}}, :infinity) 471 | end 472 | 473 | @doc """ 474 | Gets a message from the specified user's mailbox. 475 | """ 476 | @spec message(atom, String.t, map) :: atom 477 | @spec message(String.t, String.t, map) :: atom 478 | def message(user_id, message_id) when is_binary(user_id), do: message(user_id, message_id, %{}) 479 | 480 | @doc """ 481 | Gets a message from the specified user's mailbox. 482 | """ 483 | def message(user_id, message_id, params) when is_binary(user_id) do 484 | call(user_id, {:message, {:get, message_id, params}}, :infinity) 485 | end 486 | 487 | @doc """ 488 | Deletes the specified message from the user's mailbox. 489 | """ 490 | def message(:delete, user_id, message_id) do 491 | call(user_id, {:message, {:delete, message_id}}, :infinity) 492 | end 493 | 494 | @doc """ 495 | Trashes the specified message from the user's mailbox. 496 | """ 497 | def message(:trash, user_id, message_id) do 498 | call(user_id, {:message, {:trash, message_id}}, :infinity) 499 | end 500 | 501 | @doc """ 502 | Removes the specified message from the trash in the user's mailbox. 503 | """ 504 | def message(:untrash, user_id, message_id) do 505 | call(user_id, {:message, {:untrash, message_id}}, :infinity) 506 | end 507 | 508 | @doc """ 509 | Modifies the labels on a message in the specified user's mailbox. 510 | """ 511 | def message(:modify, user_id, message_id, labels_to_add, labels_to_remove) do 512 | call(user_id, {:message, {:modify, message_id, labels_to_add, labels_to_remove}}) 513 | end 514 | 515 | @doc """ 516 | Searches for messages or threads in the specified user's mailbox. 517 | """ 518 | @spec search(atom, atom, String.t, map) :: atom 519 | @spec search(String.t, atom, String.t, map) :: atom 520 | def search(user_id, thread_or_message, query, params \\ %{}) do 521 | call(user_id, {:search, thread_or_message, query, params}, :infinity) 522 | end 523 | 524 | # }}} Messages # 525 | 526 | # Attachments {{{ # 527 | 528 | @doc """ 529 | Gets an attachment from the specified user's mailbox. 530 | """ 531 | def attachment(user_id, message_id, id) do 532 | call(user_id, {:attachment, {:get, message_id, id}}) 533 | end 534 | 535 | # }}} Attachments # 536 | 537 | # Labels {{{ # 538 | 539 | @spec labels(String.t) :: atom 540 | @spec label(String.t, String.t) :: atom 541 | @spec label(atom, String.t, map) :: atom 542 | 543 | @doc """ 544 | Lists all labels in the specified user's mailbox. 545 | """ 546 | def labels(user_id) do 547 | call(user_id, {:label, {:list}}, :infinity) 548 | end 549 | 550 | @doc """ 551 | Gets a label from the specified user's mailbox. 552 | """ 553 | def label(user_id, label_id) do 554 | call(user_id, {:label, {:get, label_id}}, :infinity) 555 | end 556 | 557 | @doc """ 558 | Creates a label in the specified user's mailbox. 559 | """ 560 | @spec label(atom, String.t, String.t) :: atom 561 | def label(:create, user_id, label_name) do 562 | call(user_id, {:label, {:create, label_name}}, :infinity) 563 | end 564 | 565 | @doc """ 566 | Deletes a label from the specified user's mailbox. 567 | """ 568 | def label(:delete, user_id, label_id) do 569 | call(user_id, {:label, {:delete, label_id}}, :infinity) 570 | end 571 | 572 | @doc """ 573 | Updates a label in the specified user's mailbox. 574 | """ 575 | def label(:update, user_id, %Label{} = label) do 576 | call(user_id, {:label, {:update, label}}, :infinity) 577 | end 578 | 579 | @doc """ 580 | Patches a label in the specified user's mailbox. 581 | """ 582 | def label(:patch, user_id, %Label{} = label) do 583 | call(user_id, {:label, {:patch, label}}, :infinity) 584 | end 585 | 586 | # }}} Labels # 587 | 588 | # Drafts {{{ # 589 | 590 | @spec drafts(String.t) :: atom 591 | @spec draft(String.t, String.t) :: atom 592 | @spec draft(atom, String.t, String.t) :: atom 593 | 594 | @doc """ 595 | Lists the drafts in the specified user's mailbox. 596 | """ 597 | def drafts(user_id) do 598 | call(user_id, {:draft, {:list}}, :infinity) 599 | end 600 | 601 | @doc """ 602 | Gets a draft from the specified user's mailbox. 603 | """ 604 | def draft(user_id, draft_id) do 605 | call(user_id, {:draft, {:get, draft_id}}, :infinity) 606 | end 607 | 608 | @doc """ 609 | Deletes a draft from the specified user's mailbox. 610 | """ 611 | def draft(:delete, user_id, draft_id) do 612 | call(user_id, {:draft, {:delete, draft_id}}, :infinity) 613 | end 614 | 615 | @doc """ 616 | Sends a draft from the specified user's mailbox. 617 | """ 618 | def draft(:send, user_id, draft_id) do 619 | call(user_id, {:draft, {:send, draft_id}}, :infinity) 620 | end 621 | 622 | # }}} Drafts # 623 | 624 | # History {{{ # 625 | 626 | @spec history(String.t, map) :: atom 627 | 628 | @doc """ 629 | Lists the hsitory for the specified user's mailbox. 630 | """ 631 | def history(user_id, params \\ %{}) do 632 | call(user_id, {:history, {:list, params}}, :infinity) 633 | end 634 | 635 | # }}} History # 636 | 637 | @doc """ 638 | Executes an HTTP action. 639 | """ 640 | @spec http_execute({atom, String.t, String.t}, map) :: {atom, map} | {atom, String.t} 641 | @spec http_execute({atom, String.t, String.t, map}, map) :: {atom, map} | {atom, String.t} 642 | def http_execute(action, %{refresh_token: refresh_token, user_id: user_id} = state) do 643 | state = if OAuth2.access_token_expired?(state) do 644 | Logger.debug "Refreshing access token for #{user_id}" 645 | {access_token, expires_at} = OAuth2.refresh_access_token(refresh_token) 646 | GenServer.cast(String.to_atom(user_id), {:update_access_token, access_token, expires_at}) 647 | %{state | access_token: access_token} 648 | else 649 | state 650 | end 651 | HTTP.execute(action, state) 652 | end 653 | 654 | # }}} Client API # 655 | 656 | # Private functions {{{ # 657 | 658 | @spec call(String.t | atom, tuple, number | atom) :: atom 659 | defp call(user_id, action, timeout \\ :infinity) 660 | 661 | defp call(user_id, action, timeout) when is_binary(user_id) do 662 | user_id |> String.to_atom |> call(action, timeout) 663 | end 664 | 665 | defp call(user_id, action, timeout) when is_atom(user_id) do 666 | GenServer.call(user_id, action, timeout) 667 | end 668 | 669 | # }}} Private functions # 670 | 671 | end 672 | --------------------------------------------------------------------------------