├── .gitignore ├── .gems ├── Makefile ├── test ├── helper.rb └── malone.rb ├── lib ├── malone │ └── test.rb └── malone.rb ├── malone.gemspec ├── CHANGELOG ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /pkg -------------------------------------------------------------------------------- /.gems: -------------------------------------------------------------------------------- 1 | cutest -v 1.2.2 2 | kuvert -v 0.0.1 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: .PHONY 2 | cutest test/*.rb 3 | 4 | .PHONY: 5 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift(File.expand_path("../lib", File.dirname(__FILE__))) 2 | 3 | require "cutest" 4 | require "malone" 5 | -------------------------------------------------------------------------------- /lib/malone/test.rb: -------------------------------------------------------------------------------- 1 | require "ostruct" 2 | 3 | class Malone 4 | def self.deliveries 5 | @deliveries ||= [] 6 | end 7 | 8 | def self.reset_deliveries 9 | @deliveries = nil 10 | end 11 | 12 | def deliver(*args) 13 | self.class.deliveries << OpenStruct.new(*args) 14 | end 15 | end 16 | 17 | 18 | -------------------------------------------------------------------------------- /malone.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'malone' 3 | s.version = "1.2.0" 4 | s.summary = %{The Mailman} 5 | s.description = %{Dead-simple Ruby mailing solution which always delivers.} 6 | s.date = "2011-01-10" 7 | s.author = "Cyril David" 8 | s.email = "cyx@cyx.is" 9 | s.homepage = "http://github.com/cyx/malone" 10 | s.files = `git ls-files`.split("\n") 11 | s.require_paths = ["lib"] 12 | 13 | s.add_dependency "kuvert", "~> 0.0" 14 | s.add_development_dependency "cutest", "~> 1.2" 15 | s.license = "MIT" 16 | end 17 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 1.0.0 2 | 3 | - Malone.configure has been replaced with Malone.connect 4 | Use Malone.current to retrieve the last configured Malone 5 | instance (using Malone.connect) 6 | 7 | - Malone.deliver has been replaced with an instance method. 8 | Call deliver (with the same arguments as before) on the 9 | return value of Malone.connect or Malone.current. 10 | 11 | - Malone#deliver now receives :text and :html instead of :body. 12 | 13 | - No need to pass in TLS. it's tries to do TLS automatically. 14 | 15 | - Errors during SMTP authentication (and any other error) aren't 16 | trapped silently now. (via @tizoc, @elpollila) 17 | 18 | - Configuration parameters are more strict (i.e. passing :password 19 | would throw a NoMethodError via @tizoc, @elpollila) 20 | 21 | - malone/sandbox has been renamed to malone/test. 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Cyril David 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | malone(1) -- send mail using smtp without any fuss 2 | ================================================== 3 | 4 | ## USAGE 5 | 6 | require "malone" 7 | 8 | # Typically you would do this somewhere in the bootstrapping 9 | # part of your application 10 | 11 | m = Malone.connect(url: "smtp://foo%40bar.com:pass@smtp.gmail.com:587", 12 | domain: "mysite.com") 13 | 14 | m.deliver(from: "me@me.com", to: "you@me.com", 15 | subject: "Test subject", text: "Great!") 16 | 17 | # Malone.current will now remember the last configuration you setup. 18 | Malone.current.config == m.config 19 | 20 | # Now you can also do Malone.deliver, which is syntactic sugar 21 | # for Malone.current.deliver 22 | Malone.deliver(from: "me@me.com", to: "you@me.com", 23 | subject: "Test subject", text: "Great!") 24 | 25 | # Also starting with Malone 1.0, you can pass in :html 26 | # for multipart emails. 27 | 28 | Malone.deliver(from: "me@me.com", to: "you@me.com", 29 | subject: "Test subject", 30 | text: "Great!", html: "Great!") 31 | 32 | 33 | ## TESTING 34 | 35 | require "malone/test" 36 | 37 | m = Malone.connect(url: "smtp://foo%40bar.com:pass@smtp.gmail.com:587", 38 | domain: "mysite.com") 39 | 40 | m.deliver(from: "me@me.com", to: "you@me.com", 41 | subject: "Test subject", text: "Great!") 42 | 43 | Malone.deliveries.size == 1 44 | # => true 45 | 46 | mail = Malone.deliveries.first 47 | 48 | "me@me.com" == mail.from 49 | # => true 50 | 51 | "you@me.com" == mail.to 52 | # => true 53 | 54 | "FooBar" == mail.text 55 | # => true 56 | 57 | "Hello World" == envelope.subject 58 | # => true 59 | 60 | ## INSTALLATION 61 | 62 | gem install malone 63 | 64 | ## CONFIGURATION TIPS 65 | 66 | If you're used to doing configuration via environment 67 | variables, similar to how Heroku does configuration, then 68 | you can simply set an environment variable in your 69 | production machine like so: 70 | 71 | export MALONE_URL=smtp://foo%40bar.com:pass@smtp.gmail.com:587 72 | 73 | Then you can connect using the environment variable in your 74 | code like so: 75 | 76 | Malone.connect(url: ENV["MALONE_URL"]) 77 | 78 | # or quite simply 79 | Malone.connect 80 | 81 | By default Malone tries for the environment variable `MALONE_URL` when 82 | you call `Malone.connect` without any arguments. 83 | -------------------------------------------------------------------------------- /lib/malone.rb: -------------------------------------------------------------------------------- 1 | require "cgi" 2 | require "kuvert" 3 | require "net/smtp" 4 | require "uri" 5 | 6 | class Malone 7 | attr :config 8 | 9 | def self.connect(options = {}) 10 | @config = Configuration.new(options) 11 | 12 | current 13 | end 14 | 15 | def self.current 16 | unless defined?(@config) 17 | raise RuntimeError, "Missing configuration: Try doing `Malone.connect`." 18 | end 19 | 20 | return new(@config) 21 | end 22 | 23 | def self.deliver(dict) 24 | current.deliver(dict) 25 | end 26 | 27 | def initialize(config) 28 | @config = config 29 | end 30 | 31 | def deliver(dict) 32 | mail = envelope(dict) 33 | yield mail if block_given? 34 | 35 | smtp = Net::SMTP.new(config.host, config.port) 36 | smtp.enable_starttls_auto if config.tls 37 | 38 | begin 39 | smtp.start(config.domain, config.user, config.password, config.auth) 40 | smtp.send_message(mail.to_s, mail.from.first, *mail.recipients) 41 | ensure 42 | smtp.finish if smtp.started? 43 | end 44 | end 45 | 46 | def envelope(dict) 47 | envelope = Kuvert.new 48 | envelope.from = dict[:from] 49 | envelope.to = dict[:to] 50 | envelope.replyto = dict[:replyto] 51 | envelope.cc = dict[:cc] if dict[:cc] 52 | envelope.bcc = dict[:bcc] if dict[:bcc] 53 | envelope.text = dict[:text] 54 | envelope.rawhtml = dict[:html] if dict[:html] 55 | envelope.subject = dict[:subject] 56 | 57 | envelope.attach(dict[:attach]) if dict[:attach] 58 | envelope.add_attachment_as(*dict[:attach_as]) if dict[:attach_as] 59 | 60 | return envelope 61 | end 62 | 63 | class Configuration 64 | attr_accessor :host 65 | attr_accessor :port 66 | attr_accessor :user 67 | attr_accessor :password 68 | attr_accessor :domain 69 | attr_accessor :tls 70 | 71 | attr :auth 72 | 73 | def initialize(options) 74 | opts = options.dup 75 | 76 | @tls = true 77 | 78 | url = opts.delete(:url) || ENV["MALONE_URL"] 79 | 80 | if url 81 | uri = URI(url) 82 | 83 | opts[:host] ||= uri.host 84 | opts[:port] ||= uri.port.to_i 85 | opts[:user] ||= unescaped(uri.user) 86 | opts[:password] ||= unescaped(uri.password) 87 | end 88 | 89 | opts.each do |key, val| 90 | send(:"#{key}=", val) 91 | end 92 | end 93 | 94 | def auth=(val) 95 | @auth = val && val.to_sym 96 | end 97 | 98 | private 99 | def unescaped(val) 100 | return if val.to_s.empty? 101 | 102 | CGI.unescape(val) 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/malone.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | test "basic configuration" do 4 | m = Malone.connect(host: "smtp.gmail.com", port: 587, 5 | user: "foo@bar.com", password: "pass1234", 6 | domain: "foo.com", auth: "login") 7 | 8 | c = m.config 9 | 10 | assert_equal "smtp.gmail.com", c.host 11 | assert_equal 587, c.port 12 | assert_equal "foo@bar.com", c.user 13 | assert_equal "pass1234", c.password 14 | assert_equal "foo.com", c.domain 15 | assert_equal :login, c.auth 16 | end 17 | 18 | test "configuration via url" do 19 | m = Malone.connect(url: "smtp://foo%40bar.com:pass1234@smtp.gmail.com:587") 20 | 21 | c = m.config 22 | 23 | assert_equal "smtp.gmail.com", c.host 24 | assert_equal 587, c.port 25 | assert_equal "foo@bar.com", c.user 26 | assert_equal "pass1234", c.password 27 | end 28 | 29 | test "configuration via url and params" do 30 | m = Malone.connect(url: "smtp://foo%40bar.com:pass1234@smtp.gmail.com:587", 31 | domain: "foo.com", auth: "login", password: "barbaz123") 32 | 33 | c = m.config 34 | 35 | assert_equal "smtp.gmail.com", c.host 36 | assert_equal 587, c.port 37 | assert_equal "foo@bar.com", c.user 38 | assert_equal "foo.com", c.domain 39 | assert_equal :login, c.auth 40 | assert_equal true, c.tls 41 | 42 | # We verify that parameters passed takes precedence over the URL. 43 | assert_equal "barbaz123", c.password 44 | end 45 | 46 | test "configuration via MALONE_URL" do 47 | ENV["MALONE_URL"] = "smtp://foo%40bar.com:pass1234@smtp.gmail.com:587" 48 | 49 | m = Malone.connect(domain: "foo.com", auth: "login", tls: false) 50 | c = m.config 51 | 52 | assert_equal "smtp.gmail.com", c.host 53 | assert_equal 587, c.port 54 | assert_equal "foo@bar.com", c.user 55 | assert_equal "foo.com", c.domain 56 | assert_equal :login, c.auth 57 | assert_equal false, c.tls 58 | end 59 | 60 | test "typos in configuration" do 61 | assert_raise NoMethodError do 62 | Malone.connect(pass: "pass") 63 | end 64 | end 65 | 66 | test "Malone.connect doesn't mutate the options" do 67 | ex = nil 68 | begin 69 | Malone.connect({}.freeze) 70 | rescue RuntimeError => ex 71 | end 72 | 73 | assert_equal nil, ex 74 | end 75 | 76 | test "Malone.current" do 77 | Malone.connect(url: "smtp://foo%40bar.com:pass1234@smtp.gmail.com:587") 78 | 79 | c = Malone.current.config 80 | 81 | assert_equal "smtp.gmail.com", c.host 82 | assert_equal 587, c.port 83 | assert_equal "foo@bar.com", c.user 84 | assert_equal "pass1234", c.password 85 | end 86 | 87 | test "#envelope" do 88 | m = Malone.connect 89 | 90 | mail = m.envelope(to: "recipient@me.com", from: "no-reply@mydomain.com", 91 | subject: "SUB", text: "TEXT", html: "

TEXT

", 92 | cc: "cc@me.com", bcc: "bcc@me.com", 93 | replyto: "other@me.com") 94 | 95 | assert_equal ["recipient@me.com"], mail.to 96 | assert_equal ["cc@me.com"], mail.cc 97 | assert_equal ["bcc@me.com"], mail.bcc 98 | assert_equal ["no-reply@mydomain.com"], mail.from 99 | assert_equal ["=?utf-8?Q?SUB?="], mail.subject 100 | assert_equal "other@me.com", mail.replyto 101 | 102 | assert_equal "TEXT", mail.instance_variable_get(:@text) 103 | assert_equal "

TEXT

", mail.instance_variable_get(:@html) 104 | end 105 | 106 | scope do 107 | class FakeSMTP < Struct.new(:host, :port) 108 | def enable_starttls_auto 109 | @enable_starttls_auto = true 110 | end 111 | 112 | def start(domain, user, password, auth) 113 | @domain, @user, @password, @auth = domain, user, password, auth 114 | 115 | @started = true 116 | end 117 | 118 | def started? 119 | defined?(@started) 120 | end 121 | 122 | def finish 123 | @finish = true 124 | end 125 | 126 | def send_message(blob, from, *recipients) 127 | @blob, @from, @recipients = blob, from, recipients 128 | end 129 | 130 | def [](key) 131 | instance_variable_get(:"@#{key}") 132 | end 133 | end 134 | 135 | module Net 136 | def SMTP.new(host, port) 137 | $smtp = FakeSMTP.new(host, port) 138 | end 139 | end 140 | 141 | setup do 142 | Malone.connect(url: "smtp://foo%40bar.com:pass1234@smtp.gmail.com:587", 143 | domain: "mydomain.com", auth: :login) 144 | end 145 | 146 | test "delivering successfully" do |m| 147 | m.deliver(to: "recipient@me.com", from: "no-reply@mydomain.com", 148 | subject: "SUB", text: "TEXT", cc: "cc@me.com", bcc: "bcc@me.com") 149 | 150 | assert_equal "smtp.gmail.com", $smtp.host 151 | assert_equal 587, $smtp.port 152 | 153 | assert $smtp[:enable_starttls_auto] 154 | assert_equal "mydomain.com", $smtp[:domain] 155 | assert_equal "foo@bar.com", $smtp[:user] 156 | assert_equal "pass1234", $smtp[:password] 157 | assert_equal :login, $smtp[:auth] 158 | 159 | 160 | assert_equal ["recipient@me.com", "cc@me.com", "bcc@me.com"], $smtp[:recipients] 161 | assert_equal "no-reply@mydomain.com", $smtp[:from] 162 | 163 | assert ! $smtp[:blob].include?("bcc@me.com") 164 | 165 | assert $smtp[:started] 166 | assert $smtp[:finish] 167 | end 168 | 169 | test "Malone.deliver forwards to Malone.current" do |m| 170 | Malone.deliver(to: "recipient@me.com", from: "no-reply@mydomain.com", 171 | subject: "SUB", text: "TEXT") 172 | 173 | assert_equal "smtp.gmail.com", $smtp.host 174 | assert_equal 587, $smtp.port 175 | 176 | assert $smtp[:enable_starttls_auto] 177 | assert_equal "mydomain.com", $smtp[:domain] 178 | assert_equal "foo@bar.com", $smtp[:user] 179 | assert_equal "pass1234", $smtp[:password] 180 | assert_equal :login, $smtp[:auth] 181 | 182 | assert_equal ["recipient@me.com"], $smtp[:recipients] 183 | assert_equal "no-reply@mydomain.com", $smtp[:from] 184 | 185 | assert $smtp[:started] 186 | assert $smtp[:finish] 187 | end 188 | 189 | test "adding custom headers" do |m| 190 | m.deliver(to: "recipient@me.com", from: "no-reply@mydomain.com", 191 | subject: "Happy new year!", text: "TEXT") do |mail| 192 | mail.add_header("X-MC-SendAt", "2016-01-01 00:00:00") 193 | end 194 | 195 | assert $smtp[:blob].include?("X-MC-SendAt: 2016-01-01 00:00:00") 196 | end 197 | 198 | test "calls #finish even when it fails during send_message" do |m| 199 | class FakeSMTP 200 | def send_message(*args) 201 | raise 202 | end 203 | end 204 | 205 | begin 206 | m.deliver(to: "recipient@me.com", from: "no-reply@mydomain.com", 207 | subject: "SUB", text: "TEXT") 208 | rescue 209 | end 210 | 211 | assert $smtp[:started] 212 | assert $smtp[:finish] 213 | end 214 | end 215 | 216 | test "sandbox" do 217 | require "malone/test" 218 | 219 | m = Malone.connect 220 | m.deliver(to: "recipient@me.com", from: "no-reply@mydomain.com", 221 | subject: "SUB", text: "TEXT", html: "

TEXT

") 222 | 223 | assert_equal 1, Malone.deliveries.size 224 | 225 | mail = Malone.deliveries.first 226 | 227 | assert_equal "no-reply@mydomain.com", mail.from 228 | assert_equal "recipient@me.com", mail.to 229 | assert_equal "SUB", mail.subject 230 | assert_equal "TEXT", mail.text 231 | assert_equal "

TEXT

", mail.html 232 | end 233 | 234 | test "resetting the test sandbox" do 235 | require "malone/test" 236 | 237 | m = Malone.connect 238 | 239 | 2.times do 240 | m.deliver(to: "recipient@me.com", from: "no-reply@mydomain.com", 241 | subject: "SUB", text: "test") 242 | end 243 | 244 | assert 1 < Malone.deliveries.size 245 | Malone.reset_deliveries 246 | assert_equal 0, Malone.deliveries.size 247 | end 248 | --------------------------------------------------------------------------------