├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── echo.ex └── echo │ ├── adapters │ ├── behaviour.ex │ ├── email.ex │ └── logger.ex │ ├── hooks.ex │ └── preferences │ ├── allow_all.ex │ ├── behaviour.ex │ └── deny_all.ex ├── mix.exs ├── mix.lock └── test ├── echo ├── echo_test.exs └── preferences │ ├── allow_all_test.exs │ └── deny_all_test.exs ├── support ├── assertions.ex ├── email_hook.ex └── test_preferences.ex └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | /cover 4 | /doc 5 | erl_crash.dump 6 | *.ez 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.2.0 4 | otp_release: 5 | - 18.0 6 | sudo: false 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Zachary Moshansky 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * The names of the contributors may not be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 17 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [Echo - ECHO Echo echo...](https://en.wikipedia.org/wiki/Echo_(mythology)) 2 | ====== 3 | 4 | A simple & highly extendable, meta-notification system; Echo checks notification preferences & dispatch notifications to different adapters (ex. email, logger, analytics, sms, etc.). 5 | 6 | [![Build Status](https://travis-ci.org/zmoshansky/echo.svg)](https://travis-ci.org/zmoshansky/echo) [![Hex.pm](http://img.shields.io/hexpm/v/echo.svg)](https://hex.pm/packages/echo) [![Hex.pm](http://img.shields.io/hexpm/dt/echo.svg)](https://hex.pm/packages/echo) [![Github Issues](http://githubbadges.herokuapp.com/zmoshansky/echo/issues.svg)](https://github.com/zmoshansky/echo/issues) [![Pending Pull-Requests](http://githubbadges.herokuapp.com/zmoshansky/echo/pulls.svg)](https://github.com/zmoshansky/echo/pulls) 7 | 8 | #### Description #### 9 | Echo is designed to be highly adaptable to your notification needs through different adapters and per adapter hooks. A notification is dispatched with `Echo.notify/2` which then calls on each registered adapter, requesting that it delivers the notification. Each adapter is passed an `event_type`, of your designation, and `data` that it may use to deliver the notification. 10 | 11 | #### Config #### 12 | Echo is easily configured. A possible sample configuration is given below. Custom Preferences & Adapters are easy to build, look at `lib/echo/adapters/email` & `test/support/test_preferences.ex` for examples. Hooks allow you to specify the module which implements `@behaviour Echo.Hooks`, which generates the adapter specific data needed to deliver a notification (see `test/support/email_hook` for an example); this might include: selecting the correct template, parsing data, or even nothing at all. Doing nothing allows unknown event types to be skipped by adapters. 13 | 14 | ``` 15 | config :echo, Echo, 16 | preferences: Echo.Preferences.AllowAll, 17 | adapters: [Echo.Adapters.Logger, Echo.Adapters.Email, YourApp.CustomAdapter], 18 | hooks: %{ 19 | email: YourApp.EmailHook 20 | } 21 | ``` 22 | 23 | #### Architecture #### 24 | Echo is somewhat like a pub-sub system; events are published, and adapters that are capable of handling them dispatch the notification accordingly. The intended flow is: 25 | 26 | - `preferences` module provides an ACL to check whether an event should be delivered on a particular adapter according to a user's preferences (ex. deny newsletters, but allow billing emails). 27 | - `hooks` provide a way to prepare arguments to an adapter based on the `event_type` & `data`. A hook can prevent it's adapter from delivering the notification if no data is returned. This means notifications are only sent for adapters that are configured to handle them, otherwise, they are harmlessly disregarded by that adapter. 28 | 29 | ex.) In the code below, an email is sent for `:user_register`, but not `:log_analytics`; `Adapter.Email` will harmlessly skip it as an `:unknown_event`. 30 | ``` 31 | Echo.notify(:log_analytics, data) 32 | ... 33 | Echo.notify(:user_register, data) 34 | ``` 35 | ``` 36 | defmodule App.EmailHook 37 | @behaviour Echo.Hooks 38 | 39 | def message(event_type, data) do 40 | ... 41 | template = case event_type do 42 | :user_register -> "emails/register.html.eex" 43 | :user_forgot_password -> "emails/forgot_password.html.eex" 44 | _ -> nil 45 | end 46 | ... 47 | end 48 | end 49 | ``` 50 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | config :logger, :console, 12 | level: :warn, 13 | format: "$date $time [$level] $metadata$message\n", 14 | metadata: [:user_id] 15 | 16 | config :echo, Echo, 17 | preferences: Echo.Preferences.Test, 18 | adapters: [Echo.Adapters.Logger, Echo.Adapters.Email], 19 | hooks: %{ 20 | email: Echo.Test.EmailHook 21 | } 22 | -------------------------------------------------------------------------------- /lib/echo.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo do 2 | @moduledoc """ 3 | Dispatches notifications using configurable adapters 4 | """ 5 | 6 | @default_config %{ 7 | preferences: Echo.Preferences.AllowAll, 8 | adapters: [Echo.Adapters.Logger] 9 | } 10 | 11 | @doc """ 12 | Merges the application config with the default configuration options specified by Echo. 13 | """ 14 | @spec config() :: Map.t 15 | def config do 16 | Map.merge @default_config, Enum.into(Application.get_env(:echo, Echo), %{}) 17 | end 18 | 19 | @doc """ 20 | Notify calls dispatch/2 on each registered adapter, which checks preferences then sends notification. 21 | 22 | A List of tuples is returned of the form {:ok, __MODULE__} or {:error, __MODULE__, term | String.t} 23 | indicating the status of each call. 24 | """ 25 | @spec notify(term | String.t, Map.t) :: List.t | nil 26 | def notify(event_type, data) do 27 | if Echo.config[:preferences].notification_allowed?(event_type, data) do 28 | Enum.map Echo.config[:adapters], fn(adapter) -> adapter.dispatch(event_type, data) end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/echo/adapters/behaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo.Adapters.Behaviour do 2 | @callback notify(term | String.t | nil, any) :: {:ok, term, any} | {:error, term, term | String.t} 3 | 4 | @doc false 5 | defmacro __using__(_opts) do 6 | quote do 7 | @behaviour Echo.Adapters.Behaviour 8 | 9 | @spec dispatch(term | String.t | nil, Map.t | nil) :: Tuple.t 10 | def dispatch(event_type, data) do 11 | if Echo.config[:preferences].notification_allowed?(__MODULE__, event_type, data) do 12 | notify(event_type, data) 13 | else 14 | {:error, __MODULE__, :permission_denied} 15 | end 16 | end 17 | 18 | defoverridable [dispatch: 2] 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/echo/adapters/email.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo.Adapters.Email do 2 | @moduledoc """ 3 | An Email adapter powered by [Mailman](https://github.com/kamilc/mailman) 4 | 5 | When using this adapter, add the appropriate dependency to your mix file. 6 | See Adapter Project page for up-to-date details. 7 | 8 | ex.) 9 | ``` 10 | {:echo, "~> x.x.x"} 11 | {:mailman, "~> x.x.x"}, 12 | {:eiconv, github: "zotonic/eiconv"} 13 | ``` 14 | """ 15 | use Echo.Adapters.Behaviour 16 | 17 | def notify(event_type, data) do 18 | config = Echo.config.hooks[:email].config(event_type, data) 19 | message = Echo.config.hooks[:email].message(event_type, data) 20 | if message do 21 | {:ok, __MODULE__, Mailman.deliver(message, config)} 22 | else 23 | {:error, __MODULE__, :unknown_event} 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/echo/adapters/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo.Adapters.Logger do 2 | use Echo.Adapters.Behaviour 3 | require Logger 4 | 5 | def notify(event_type, data) do 6 | {:ok, __MODULE__, Logger.info "EventType: #{inspect event_type}; Data: #{inspect data}; #{__MODULE__}"} 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/echo/hooks.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo.Hooks do 2 | @moduledoc """ 3 | message/2 must be implemented, returning the data for an adapter to deliver a notification. 4 | """ 5 | @callback message(term | String.t | nil, any) :: any 6 | end 7 | -------------------------------------------------------------------------------- /lib/echo/preferences/allow_all.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo.Preferences.AllowAll do 2 | @moduledoc """ 3 | Base implementation allowing all notifications. 4 | """ 5 | 6 | @behaviour Echo.Preferences.Behaviour 7 | def notification_allowed?(_event_type, _data), do: true 8 | def notification_allowed?(_adapter, _event_type, _data), do: true 9 | 10 | @doc false 11 | defmacro __using__(_opts) do 12 | quote do 13 | def notification_allowed?(_event_type, _data), do: true 14 | def notification_allowed?(_adapter, _event_type, _data), do: true 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/echo/preferences/behaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo.Preferences.Behaviour do 2 | @moduledoc """ 3 | notification_allowed?/2 & notification_allowed?/3 must be provided 4 | """ 5 | 6 | @callback notification_allowed?(term | String.t | nil, Map.t | nil) :: boolean 7 | @callback notification_allowed?(term | String.t | nil, term | String.t | nil, Map.t | nil) :: boolean 8 | end -------------------------------------------------------------------------------- /lib/echo/preferences/deny_all.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo.Preferences.DenyAll do 2 | @moduledoc """ 3 | Base implementation denying all notifications. 4 | """ 5 | 6 | @behaviour Echo.Preferences.Behaviour 7 | def notification_allowed?(_event_type, _data), do: false 8 | def notification_allowed?(_adapter, _event_type, _data), do: false 9 | 10 | @doc false 11 | defmacro __using__(_opts) do 12 | quote do 13 | def notification_allowed?(_event_type, _data), do: false 14 | def notification_allowed?(_adapter, _event_type, _data), do: false 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Echo.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :echo, 7 | version: "0.2.0", 8 | elixir: "~> 1.2.0", 9 | elixirc_paths: elixirc_paths(Mix.env), 10 | description: description, 11 | package: package, 12 | deps: deps 13 | ] 14 | end 15 | 16 | def application do 17 | [applications: applications(Mix.env)] 18 | end 19 | 20 | # 21 | # Private 22 | # 23 | 24 | # Specifies which paths to compile per environment 25 | defp elixirc_paths(:test), do: ["test/support"] ++ elixirc_paths(:prod) 26 | defp elixirc_paths(_), do: ["lib"] 27 | 28 | 29 | defp applications(:test) do 30 | [:logger] ++ applications(:prod) 31 | end 32 | 33 | defp applications(_) do 34 | [] 35 | end 36 | 37 | defp deps do 38 | [ 39 | {:ex_doc, ">=0.1.0", only: [:dev, :test]}, 40 | {:earmark, ">= 0.0.0", only: [:dev, :test]}, 41 | {:mailman, "~> 0.2.0", only: [:test]}, 42 | {:eiconv, github: "zotonic/eiconv", only: [:test]} 43 | ] 44 | end 45 | 46 | defp package do 47 | [ 48 | files: ["lib", "mix.exs", "README*", "LICENSE*"], 49 | maintainers: ["Zachary Moshansky"], 50 | licenses: ["BSD 3-Clause"], 51 | links: %{"GitHub" => "https://github.com/zmoshansky/echo"} 52 | ] 53 | end 54 | 55 | defp description do 56 | """ 57 | A simple & highly extendable, meta-notification system; Echo checks 58 | notification preferences & dispatch notifications to different adapters 59 | (ex. email, logger, analytics, sms, etc.) 60 | """ 61 | end 62 | 63 | end 64 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"earmark": {:hex, :earmark, "0.1.17"}, 2 | "eiconv": {:git, "https://github.com/zotonic/eiconv.git", "644fb5e7bd6640fbd073f4d28957914ea979aea0", []}, 3 | "ex_doc": {:hex, :ex_doc, "0.10.0"}, 4 | "gen_smtp": {:hex, :gen_smtp, "0.9.0"}, 5 | "mailman": {:hex, :mailman, "0.2.0"}} 6 | -------------------------------------------------------------------------------- /test/echo/echo_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EchoTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "config values for Echo Testing" do 5 | config = Echo.config 6 | assert config.preferences == Echo.Preferences.Test 7 | assert config.adapters == [Echo.Adapters.Logger, Echo.Adapters.Email] 8 | end 9 | 10 | # See test/support/test_preferences.ex for the policies that dictate these tests 11 | # Example of global policy 12 | test "notify returns nil if no adapters are dispatched" do 13 | assert Echo.notify(:comment, %{user: %{type: 'admin', status: 'nil'}}) == nil 14 | end 15 | 16 | # Example of adapter specific policy 17 | test "notify returns a status list for each adapter" do 18 | assert Echo.notify(:register, %{user: %{type: 'user', status: 'blocked'}}) == [{:ok, Echo.Adapters.Logger, :ok}, {:error, Echo.Adapters.Email, :permission_denied}] 19 | end 20 | 21 | # Example of adapter opting not to implement an event_type 22 | test "notify returns a status list for each adapter2" do 23 | assert Echo.notify(:comment, %{user: %{type: 'user', status: 'unblocked'}}) == [{:ok, Echo.Adapters.Logger, :ok}, {:error, Echo.Adapters.Email, :unknown_event}] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/echo/preferences/allow_all_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Echo.Preferences.AllowAllTest do 2 | use ExUnit.Case 3 | 4 | test "notification_allowed?/2 always returns true" do 5 | assert Echo.Preferences.AllowAll.notification_allowed? :foo, %{} 6 | assert Echo.Preferences.AllowAll.notification_allowed? nil, nil 7 | end 8 | 9 | test "notification_allowed?/3 always returns true" do 10 | assert Echo.Preferences.AllowAll.notification_allowed? :foo, :bar, %{} 11 | assert Echo.Preferences.AllowAll.notification_allowed? :nil, nil, nil 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/echo/preferences/deny_all_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Echo.Preferences.DenyAllTest do 2 | use ExUnit.Case 3 | 4 | test "notification_allowed?/2 always returns true" do 5 | refute Echo.Preferences.DenyAll.notification_allowed? :foo, %{} 6 | refute Echo.Preferences.DenyAll.notification_allowed? nil, nil 7 | end 8 | 9 | test "notification_allowed?/3 always returns true" do 10 | refute Echo.Preferences.DenyAll.notification_allowed? :foo, :bar, %{} 11 | refute Echo.Preferences.DenyAll.notification_allowed? :nil, nil, nil 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/support/assertions.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo.Test.Assertions do 2 | @moduledoc """ 3 | Unwraps the authentication results and assert {:ok, _} | {:error, _} 4 | """ 5 | 6 | defmacro assert_ok(arg) do 7 | quote do 8 | case unquote(arg) do 9 | {:ok, _} -> assert(true) 10 | {:error, e} -> assert(false, e) 11 | end 12 | end 13 | end 14 | 15 | defmacro assert_error(arg) do 16 | quote do 17 | case unquote(arg) do 18 | {:ok, o} -> assert(false, o) 19 | {:error, _} -> assert(true) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/support/email_hook.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo.Test.EmailHook do 2 | @behaviour Echo.Hooks 3 | 4 | # TODO - Remove pending deletion in Mailman 5 | def config(_event_type, _data) do 6 | %Mailman.Context{ 7 | config: %Mailman.TestConfig{}, 8 | composer: %Mailman.EexComposeConfig{ 9 | html_file: true, 10 | text_file: true 11 | } 12 | } 13 | end 14 | 15 | def message(event_type, data) do 16 | template = "emails/#{event_type}" 17 | subject = case event_type do 18 | :register -> "Welcome to Echo!" 19 | _ -> nil 20 | end 21 | 22 | if subject do 23 | %Mailman.Email{ 24 | subject: subject, 25 | from: "accounts@example.com", 26 | reply_to: '', 27 | to: [ data.user.email ], 28 | cc: [], 29 | bcc: [], 30 | attachments: [], 31 | data: %{ 32 | name: data.user.email 33 | }, 34 | text: template <> ".txt.eex", 35 | html: template <> ".html.eex" 36 | } 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/support/test_preferences.ex: -------------------------------------------------------------------------------- 1 | defmodule Echo.Preferences.Test do 2 | @moduledoc """ 3 | An example of how to define policies around notification preferences 4 | """ 5 | 6 | # Example of permit/deny notifications for all adapters 7 | ######################### 8 | 9 | # Deny any notifications if the data.user is admin 10 | def notification_allowed?(_event_type, data) do 11 | if data && data.user.type == 'admin' do 12 | false 13 | else 14 | true 15 | end 16 | end 17 | 18 | 19 | # Example of permit/deny notifications for a specific adapter 20 | ######################### 21 | 22 | # Deny emails if user is blocked 23 | def notification_allowed?(Echo.Adapters.Email, _event_type, data) do 24 | if data && data.user.status == 'blocked' do 25 | false 26 | else 27 | true 28 | end 29 | end 30 | 31 | 32 | # Use pattern matching to catch all remaining options 33 | ######################### 34 | 35 | # When using the preferences as a blacklist: default to allowing 36 | use Echo.Preferences.AllowAll 37 | 38 | # Alternatively, design this file as a whitelist: default to blocking 39 | # use Echo.Preferences.DenyAll 40 | end 41 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------