Skip to content

SMTP settings via url #1360

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rdoc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Features:

* Updated README to improve around sending multipart mail @kapfenho
* Add delivery_interceptors method to Mail class to fetch registered interceptors @ghousemohamed
* Configure smtp-delivery using a url @eval

Code Improvements:

Expand Down
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,14 +278,56 @@ mail.delivery_method :sendmail
mail.deliver
```

Sending via smtp (for example to [mailcatcher](https://github.com/sj26/mailcatcher))
```ruby
#### Sending via SMTP

Sending via smtp (for example to [mailcatcher](https://github.com/sj26/mailcatcher)):
```ruby
Mail.defaults do
delivery_method :smtp, address: "localhost", port: 1025
end
```

A url is also accepted:
```ruby
Mail.defaults do
# The following (note the URL-encoded userinfo)...:
delivery_method :smtp, url: 'smtp://user%40gmail.com:[email protected]'

# ...would be equivalent to:
delivery_method :smtp, { :address => 'smtp.gmail.com',
:port => 587,
:user_name => '[email protected]',
:password => 'app-password',
:tls => false,
:enable_starttls => :always }

# Settings from the url take precedence in case of clashes. Otherwise all unique values are combined:
delivery_method :smtp, {
url: 'smtps://smtp.gmail.com?domain=foo.org',
address: 'bar.com', # <-- ignored
# Added:
user_name: '[email protected]',
password: credentials.dig(:smtp, :password)
}

# Provide additional settings or overrides via the query string of the url:
delivery_method :smtp, url: '<above-url>?domain=foo.org&authentication=login'

# Note that the authentication can also be passed as part of the scheme:
delivery_method :smtp, url: 'smtps+login://smtp.gmail.com?domain=foo.org'

# For localhost (or 127.0.0.1) it assumes no tls, nor starttls.
# So the following would work using [mailcatcher](https://github.com/sj26/mailcatcher):
delivery_method :smtp, url: 'smtp://127.0.0.1:1025'

# Scheme 'smtps' enables `tls` and uses port 465:
delivery_method :smtp, url: 'smtps://user%40gmail.com:[email protected]'

# See Mail::SMTP::UrlResolver::DEFAULTS for the defaults used for various schemes and addresses.
end
```

#### Sending via Exim

Exim requires its own delivery manager, and can be used like so:

Expand All @@ -295,6 +337,8 @@ mail.delivery_method :exim, :location => "/usr/bin/exim"
mail.deliver
```

#### Logging

Mail may be "delivered" to a logfile, too, for development and testing:

```ruby
Expand Down
138 changes: 133 additions & 5 deletions lib/mail/network/delivery_methods/smtp.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true
require 'mail/smtp_envelope'
require 'mail/utilities'

module Mail
# == Sending Email with SMTP
Expand Down Expand Up @@ -35,6 +36,37 @@ module Mail
# :password => '<password>',
# :authentication => 'plain',
# :enable_starttls => :auto }
#
# # ...or using the url-attribute (note the trailing 's' in the scheme
# # to set `tls`, also note the URL-encoded userinfo):
# delivery_method :smtp,
# { :url => 'smtps://user%40gmail.com:[email protected]' }
# end
#
# See {Mail::SMTP::UrlResolver::DEFAULTS} what defaults are applied for certain schemes.
# The scheme can also contain the authentication-type, e.g. `smtps+cram-md5://...`.
#
# === Sending via Fastmail
#
# Mail.defaults do
# delivery_method :smtp,
# { :url => 'smtps://user%40fastmail.fm:[email protected]' }
#
# # Settings from the url take precedence:
# delivery_method :smtp, {
# url: 'smtps://smtp.fastmail.com?domain=foo.org',
# domain: 'domain.org', # <-- ignored
# user_name: '[email protected]',
# password: credentials.dig(:smtp, :password)
# }
# end
#
# === Local development
#
# Using {https://github.com/sj26/mailcatcher mailcatcher}:
#
# Mail.defaults do
# delivery_method :smtp, url: 'smtp://127.0.0.1:1025'
# end
#
# === Configuring TLS/SSL and STARTTLS
Expand Down Expand Up @@ -82,6 +114,97 @@ module Mail
#
# mail.deliver!
class SMTP
class UrlResolver

DEFAULTS = {
"smtp" => Hash.new({
:port => 587,
:tls => false,
:enable_starttls => :always
}).merge({
"localhost" => {
:port => 25,
:tls => false,
:enable_starttls => false
},
"127.0.0.1" => {
:port => 25,
:tls => false,
:enable_starttls => false
}}),
"smtps" => Hash.new({
:port => 465,
:tls => true,
:enable_starttls => false
})
}

def initialize(url)
@uri = url.is_a?(URI) ? url : uri_parser.parse(url)
unless @uri.scheme && DEFAULTS.has_key?(uri_scheme)
raise ArgumentError, "#{url} is not a valid SMTP-url. Required format: smtp(s)://host?domain=sender.org"
end
@query = uri.query
end

def to_hash
config = raw_config
config.map { |key, value| config[key] = uri_parser.unescape(value) if value.is_a? String }
config
end

private
attr_reader :uri

def raw_config
scheme_defaults.merge(query_hash).merge({
:address => uri.host,
:port => uri.port,
:user_name => uri.user,
:password => uri.password,
:authentication => uri_scheme_authentication
}.delete_if {|_key, value| Utilities.blank?(value) })
end

def uri_parser
Utilities.uri_parser
end

def uri_scheme_and_authentication
(uri.scheme.split("+") + [ nil ]).take(2)
end

def uri_scheme
uri_scheme_and_authentication.first
end

def uri_scheme_authentication
uri_scheme_and_authentication.last
end

def query_hash
@query_hash = begin
result = Hash[(@query || "").split("&").map { |pair| k,v = pair.split("="); [k.to_sym, v] }]

result[:open_timeout] &&= result[:open_timeout].to_i
result[:read_timeout] &&= result[:read_timeout].to_i
result[:enable_starttls] &&= begin
case result[:enable_starttls]
when "always", "auto" then result[:enable_starttls].to_sym
else
result[:enable_starttls] != "false"
end
end
result[:enable_starttls_auto] &&= result[:enable_starttls_auto] != 'false'
result
end
end

def scheme_defaults
DEFAULTS[uri_scheme][uri.host]
end
end

attr_accessor :settings

DEFAULTS = {
Expand All @@ -100,8 +223,13 @@ class SMTP
:read_timeout => 5
}

def initialize(values)
self.settings = DEFAULTS.merge(values)
def initialize(config)
settings = DEFAULTS.merge(config)

if config[:url]
settings = settings.merge(UrlResolver.new(config.delete(:url)).to_hash)
end
self.settings = settings
end

def deliver!(mail)
Expand All @@ -126,7 +254,7 @@ def smtp_starttls
return false if smtp_tls?

if setting_provided?(:enable_starttls) && settings[:enable_starttls]
# enable_starttls: provided and truthy
# enable_starttls: truthy
case settings[:enable_starttls]
when :auto then :auto
when :always then :always
Expand All @@ -139,8 +267,8 @@ def smtp_starttls
settings[:enable_starttls_auto] ? :auto : false
else
# enable_starttls_auto: not provided
# enable_starttls: when provided then false
# use :auto when neither enable_starttls* provided
# enable_starttls: false when provided
# use :auto as fallback when neither enable_starttls* provided
setting_provided?(:enable_starttls) ? false : :auto
end
end
Expand Down
82 changes: 82 additions & 0 deletions spec/mail/network/delivery_methods/smtp_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,88 @@
Mail.defaults { delivery_method :smtp, {} }
end

describe 'settings via url' do
def smtp_delivery_settings_for_url(url, options = {})
Mail.defaults { delivery_method :smtp, {:url => url}.merge(options) }

Mail.delivery_method.settings
end

it 'provides smtp defaults for localhost' do
expect(smtp_delivery_settings_for_url('smtp://localhost')).to \
include(:port => 25,
:tls => false,
:enable_starttls => false)
end

it 'provides smtp defaults for non-localhost' do
expect(smtp_delivery_settings_for_url('smtp://gmail.com')).to \
include(:port => 587,
:enable_starttls => :always)
end

it 'provides smtps defaults' do
expect(smtp_delivery_settings_for_url('smtps://gmail.com')).to \
include(:port => 465,
:tls => true,
:enable_starttls => false)
end

it 'ignores url when nil' do
expect(smtp_delivery_settings_for_url(nil, {host: "gmail.com", port: 465})).to \
include(:port => 465,
:host => "gmail.com")
end

it 'parses the url correctly and coerces where needed' do
url = 'smtp://mailer.org:12345?domain=sender.org&authentication=login&openssl_verify_mode=peer&open_timeout=1&read_timeout=2&enable_starttls=always&enable_starttls_auto=true'
expect(smtp_delivery_settings_for_url(url)).to \
include(:address => 'mailer.org',
:port => 12345,
:domain => 'sender.org',
:authentication => 'login',
:openssl_verify_mode => 'peer',
:tls => false,
:open_timeout => 1,
:read_timeout => 2,
:enable_starttls => :always,
:enable_starttls_auto => true)
end

it 'gives precedence to attributes from the url' do
config = {:address => 'ignored', :domain => 'ignored'}
expect(smtp_delivery_settings_for_url('smtp://user:pw@real-host?domain=real-domain.org', config)).not_to \
include(config)
end

it 'accepts additional attributes' do
config = { password: "secr@t" }
expect(smtp_delivery_settings_for_url('smtps://user@host', config)).to include(config)
end

it 'unescapes credentials' do
expect(smtp_delivery_settings_for_url('smtps://user%40host:pw-with-%[email protected]')).to \
include(:user_name => 'user@host',
:password => 'pw-with-/')
end

it 'accepts an URI' do
expect(smtp_delivery_settings_for_url(URI.parse('smtp://host?enable_starttls=auto'))).to \
include(:address => 'host', :enable_starttls => :auto)
end

it 'should raise an error for url without smtp(s) scheme' do
expect {
smtp_delivery_settings_for_url('foo://host')
}.to raise_error(/is not a valid SMTP-url/)
end

it 'allows for authencation to be provided as part of the scheme' do
expect(smtp_delivery_settings_for_url(URI.parse('smtp+plain://host'))).to \
include(authentication: "plain")
end
end

describe "general usage" do
it "should send emails from given settings" do

Expand Down
Loading