├── VERSION ├── .document ├── TODO ├── lib └── aws │ ├── ses │ ├── version.rb │ ├── extensions.rb │ ├── addresses.rb │ ├── info.rb │ ├── response.rb │ ├── send_email.rb │ └── base.rb │ ├── ses.rb │ └── actionmailer │ └── ses_extension.rb ├── Gemfile ├── test ├── mocks │ └── fake_response.rb ├── response_test.rb ├── helper.rb ├── address_test.rb ├── fixtures.rb ├── extensions_test.rb ├── info_test.rb ├── base_test.rb └── send_email_test.rb ├── .gitignore ├── LICENSE ├── CHANGELOG ├── Gemfile.lock ├── Rakefile ├── README.erb ├── aws-ses.gemspec └── README.rdoc /VERSION: -------------------------------------------------------------------------------- 1 | 0.7.1 2 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * Use a better XML parser (and be consistent) 2 | * Rename Base to something else (probably Mailer): Nothing else actually inherits from Base, so that is a very poor naming convention. I intend to change it in the future 3 | * Integer responses should probably be cast to ints instead of left as strings -------------------------------------------------------------------------------- /lib/aws/ses/version.rb: -------------------------------------------------------------------------------- 1 | module AWS 2 | module SES 3 | module VERSION #:nodoc: 4 | MAJOR = '0' 5 | MINOR = '4' 6 | TINY = '4' 7 | BETA = Time.now.to_i.to_s 8 | end 9 | 10 | Version = [VERSION::MAJOR, VERSION::MINOR, VERSION::TINY, VERSION::BETA].compact * '.' 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | # Add dependencies required to use your gem here. 3 | # Example: 4 | # gem "activesupport", ">= 2.3.5" 5 | 6 | # Add dependencies to develop your gem here. 7 | # Include everything needed to run rake, tests, features, etc. 8 | group :development do 9 | gem "bundler", ">= 1.17" 10 | gem 'flexmock', '~> 0.8.11' 11 | gem "jeweler" 12 | gem "rake" 13 | gem "shoulda-context", ">= 0" 14 | gem 'test-unit' 15 | gem 'timecop' 16 | end 17 | 18 | gem 'builder' 19 | gem 'mail', '> 2.2.5' 20 | gem 'mime-types' 21 | gem 'xml-simple' 22 | -------------------------------------------------------------------------------- /test/mocks/fake_response.rb: -------------------------------------------------------------------------------- 1 | module AWS 2 | module SES 3 | class FakeResponse 4 | attr_reader :code, :body, :headers 5 | def initialize(options = {}) 6 | @code = options.delete(:code) || 200 7 | @body = options.delete(:body) || '' 8 | @headers = {'content-type' => 'application/xml'}.merge(options.delete(:headers) || {}) 9 | end 10 | 11 | # For ErrorResponse 12 | def response 13 | body 14 | end 15 | 16 | def [](header) 17 | headers[header] 18 | end 19 | 20 | def each(&block) 21 | headers.each(&block) 22 | end 23 | alias_method :each_header, :each 24 | end 25 | end 26 | end -------------------------------------------------------------------------------- /lib/aws/ses.rb: -------------------------------------------------------------------------------- 1 | %w[ base64 cgi openssl digest/sha1 net/https net/http rexml/document time ostruct mail].each { |f| require f } 2 | 3 | begin 4 | require 'URI' unless defined? URI 5 | rescue Exception => e 6 | # nothing 7 | end 8 | 9 | begin 10 | require 'xmlsimple' unless defined? XmlSimple 11 | rescue Exception => e 12 | require 'xml-simple' unless defined? XmlSimple 13 | end 14 | 15 | $:.unshift(File.dirname(__FILE__)) 16 | require 'ses/extensions' 17 | 18 | require 'ses/response' 19 | require 'ses/send_email' 20 | require 'ses/info' 21 | require 'ses/base' 22 | require 'ses/version' 23 | require 'ses/addresses' 24 | 25 | if defined?(Rails) 26 | major, minor = Rails.version.to_s.split('.') 27 | require 'actionmailer/ses_extension' if major == '2' && minor == '3' 28 | end 29 | -------------------------------------------------------------------------------- /lib/aws/actionmailer/ses_extension.rb: -------------------------------------------------------------------------------- 1 | # A quick little extension to use this lib with with rails 2.3.X 2 | # To use it, in your environment.rb or some_environment.rb you simply set 3 | # 4 | # config.after_initialize do 5 | # ActionMailer::Base.delivery_method = :amazon_ses 6 | # ActionMailer::Base.custom_amazon_ses_mailer = AWS::SES::Base.new(:secret_access_key => S3_CONFIG[:secret_access_key], :access_key_id => S3_CONFIG[:access_key_id]) 7 | # end 8 | 9 | module ActionMailer 10 | class Base 11 | cattr_accessor :custom_amazon_ses_mailer 12 | 13 | def perform_delivery_amazon_ses(mail) 14 | raise 'AWS::SES::Base has not been intitialized.' unless @@custom_amazon_ses_mailer 15 | @@custom_amazon_ses_mailer.deliver!(mail) 16 | end 17 | 18 | end 19 | end -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rcov generated 2 | coverage 3 | 4 | # rdoc generated 5 | rdoc 6 | 7 | # yard generated 8 | doc 9 | .yardoc 10 | 11 | # bundler 12 | .bundle 13 | 14 | # jeweler generated 15 | pkg 16 | 17 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 18 | # 19 | # * Create a file at ~/.gitignore 20 | # * Include files you want ignored 21 | # * Run: git config --global core.excludesfile ~/.gitignore 22 | # 23 | # After doing this, these files will be ignored in all your git projects, 24 | # saving you from having to 'pollute' every project you touch with them 25 | # 26 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 27 | # 28 | # For MacOS: 29 | # 30 | #.DS_Store 31 | # 32 | # For TextMate 33 | #*.tmproj 34 | #tmtags 35 | # 36 | # For emacs: 37 | #*~ 38 | #\#* 39 | #.\#* 40 | # 41 | # For vim: 42 | #*.swp 43 | # 44 | .rvmrc 45 | 46 | # ignore idea files 47 | .rakeTasks 48 | /workspace.xml 49 | .idea/ -------------------------------------------------------------------------------- /test/response_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../helper', __FILE__) 2 | 3 | class BaseResponseTest < Test::Unit::TestCase 4 | def setup 5 | @headers = {'content-type' => 'text/plain', 'date' => Time.now} 6 | @response = FakeResponse.new() 7 | @base_response = Response.new('ResponseAction', @response) 8 | end 9 | 10 | def test_status_predicates 11 | response = Proc.new {|code| Response.new('ResponseAction', FakeResponse.new(:code => code))} 12 | assert response[200].success? 13 | assert response[300].redirect? 14 | assert response[400].client_error? 15 | assert response[500].server_error? 16 | end 17 | 18 | def test_headers_passed_along_from_original_response 19 | assert_equal @response.headers, @base_response.headers 20 | assert_equal @response['date'], @base_response['date'] 21 | original_headers, new_headers = {}, {} 22 | @response.headers.each {|k,v| original_headers[k] = v} 23 | @base_response.each {|k,v| new_headers[k] = v} 24 | assert_equal original_headers, new_headers 25 | end 26 | end -------------------------------------------------------------------------------- /lib/aws/ses/extensions.rb: -------------------------------------------------------------------------------- 1 | #:stopdoc: 2 | module Kernel 3 | def __method__(depth = 0) 4 | caller[depth][/`([^']+)'/, 1] 5 | end if RUBY_VERSION <= '1.8.7' 6 | 7 | def __called_from__ 8 | caller[1][/`([^']+)'/, 1] 9 | end if RUBY_VERSION > '1.8.7' 10 | 11 | def expirable_memoize(reload = false, storage = nil) 12 | current_method = RUBY_VERSION > '1.8.7' ? __called_from__ : __method__(1) 13 | storage = "@#{storage || current_method}" 14 | if reload 15 | instance_variable_set(storage, nil) 16 | else 17 | if cache = instance_variable_get(storage) 18 | return cache 19 | end 20 | end 21 | instance_variable_set(storage, yield) 22 | end 23 | end 24 | 25 | class Module 26 | def memoized(method_name) 27 | original_method = "unmemoized_#{method_name}_#{Time.now.to_i}" 28 | alias_method original_method, method_name 29 | module_eval(<<-EVAL, __FILE__, __LINE__) 30 | def #{method_name}(reload = false, *args, &block) 31 | expirable_memoize(reload) do 32 | send(:#{original_method}, *args, &block) 33 | end 34 | end 35 | EVAL 36 | end 37 | end 38 | 39 | #:startdoc: 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright (c) 2011 Drew V. Blas 3 | Copyright (c) 2006-2009 Marcel Molina Jr. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in the 7 | Software without restriction, including without limitation the rights to use, 8 | copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the 9 | Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN 19 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 0.7.1: 2 | * Bugfix in sig version v4 detection 3 | 4 | 0.7.0: 5 | * Implement Authorization Header v4 (add config: `signature_version: 4`) 6 | * Wrap message ID in `<>` correctly 7 | 8 | 0.6.0: 9 | * Various small bug fixes 10 | 11 | 0.5.0: 12 | * Check for existence of html or text body when attachments are included 13 | * Message ids in mail objects should now reflect what is actually sent. 14 | 15 | 0.4.4: 16 | * Fixed error in initialized settings if you don't set them manually 17 | 18 | 0.4.3: 19 | * No more error from Rails 3.1 about incompatible gem versions 20 | * Better error message 21 | * No duplicate dependencies 22 | 23 | 0.4.2: 24 | * Added support for ReplyToAddresses, Destinations, and Source (pzb) 25 | * Added custom User-Agent header (pzb) 26 | * Widespread doc updates 27 | 28 | 0.4.1: 29 | * Removed many unneeded monkeypatch extensions 30 | * Tests ( and gem ) run in 1.9.2 31 | 32 | 0.4.0: 33 | * This version may have some small incompatibilities due to adding support for MessageId. Check that the result still matches what you previously expected. 34 | * Added MessageId support in responses 35 | * Improved resulting data from many responses 36 | * Added tests and documentation for quota and statistics 37 | * Added Rails 2.3.x support ( I HOPE!!! ) 38 | 39 | 0.3.2: 40 | * Removed unused extensions that were conflicting with S3 41 | 42 | 0.3.1: 43 | * Downgraded mail gem version required to enhance Rails 3.0.0 compatibility 44 | 45 | 0.3.0: 46 | * Added send_raw_email support 47 | * Added support for Rails3 48 | * Vastly improved error handling and got rid of a bunch of stuff that wasn't used 49 | 50 | 0.2.0: 51 | * Added info support 52 | * Added send_email 53 | 54 | 0.1.0: 55 | - Initial creation that supports: 56 | 57 | * addresses.list 58 | * addresses.verify(email) 59 | * addresses.delete(email) 60 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | addressable (2.4.0) 5 | builder (3.2.2) 6 | descendants_tracker (0.0.4) 7 | thread_safe (~> 0.3, >= 0.3.1) 8 | faraday (0.9.2) 9 | multipart-post (>= 1.2, < 3) 10 | flexmock (0.8.11) 11 | git (1.7.0) 12 | rchardet (~> 1.8) 13 | github_api (0.16.0) 14 | addressable (~> 2.4.0) 15 | descendants_tracker (~> 0.0.4) 16 | faraday (~> 0.8, < 0.10) 17 | hashie (>= 3.4) 18 | mime-types (>= 1.16, < 3.0) 19 | oauth2 (~> 1.0) 20 | hashie (4.1.0) 21 | highline (2.0.3) 22 | jeweler (2.3.9) 23 | builder 24 | bundler 25 | git (>= 1.2.5) 26 | github_api (~> 0.16.0) 27 | highline (>= 1.6.15) 28 | nokogiri (>= 1.5.10) 29 | psych 30 | rake 31 | rdoc 32 | semver2 33 | jwt (2.2.2) 34 | mail (2.6.1) 35 | mime-types (>= 1.16, < 3) 36 | mime-types (2.4.1) 37 | mini_portile2 (2.4.0) 38 | multi_json (1.15.0) 39 | multi_xml (0.6.0) 40 | multipart-post (2.1.1) 41 | nokogiri (1.10.10) 42 | mini_portile2 (~> 2.4.0) 43 | oauth2 (1.4.4) 44 | faraday (>= 0.8, < 2.0) 45 | jwt (>= 1.0, < 3.0) 46 | multi_json (~> 1.3) 47 | multi_xml (~> 0.5) 48 | rack (>= 1.2, < 3) 49 | power_assert (1.2.0) 50 | psych (3.1.0) 51 | rack (2.2.3) 52 | rake (13.0.1) 53 | rchardet (1.8.0) 54 | rdoc (6.2.0) 55 | semver2 (3.4.2) 56 | shoulda-context (1.2.1) 57 | test-unit (3.3.6) 58 | power_assert 59 | thread_safe (0.3.6) 60 | timecop (0.9.1) 61 | xml-simple (1.1.4) 62 | 63 | PLATFORMS 64 | ruby 65 | 66 | DEPENDENCIES 67 | builder 68 | bundler (>= 1.17) 69 | flexmock (~> 0.8.11) 70 | jeweler 71 | mail (> 2.2.5) 72 | mime-types 73 | rake 74 | shoulda-context 75 | test-unit 76 | timecop 77 | xml-simple 78 | 79 | BUNDLED WITH 80 | 1.17.3 81 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | begin 4 | Bundler.setup(:default, :development) 5 | rescue Bundler::BundlerError => e 6 | $stderr.puts e.message 7 | $stderr.puts "Run `bundle install` to install missing gems" 8 | exit e.status_code 9 | end 10 | require 'test/unit' 11 | require 'shoulda-context' 12 | 13 | begin 14 | require 'ruby-debug' 15 | rescue LoadError 16 | end 17 | 18 | require 'flexmock' 19 | require 'flexmock/test_unit' 20 | 21 | require File.dirname(__FILE__) + '/mocks/fake_response' 22 | require File.dirname(__FILE__) + '/fixtures' 23 | 24 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 25 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 26 | require 'aws/ses' 27 | require 'timecop' 28 | 29 | class Test::Unit::TestCase 30 | require 'net/http' 31 | require 'net/https' 32 | 33 | include AWS::SES 34 | 35 | def mock_connection(object, data = {}) 36 | return_values = case data 37 | when Hash 38 | FakeResponse.new(data) 39 | when Array 40 | data.map {|hash| FakeResponse.new(hash)} 41 | else 42 | abort "Response data for mock connection must be a Hash or an Array. Was #{data.inspect}." 43 | end 44 | 45 | connection = flexmock('Net::HTTP.new') do |mock| 46 | mock.should_receive(:post).and_return(*return_values).at_least.once 47 | end 48 | 49 | mock = flexmock(object) 50 | mock.should_receive(:connection).and_return(connection) 51 | mock 52 | end 53 | 54 | def generate_base 55 | Base.new(:access_key_id=>'123', :secret_access_key=>'abc') 56 | end 57 | end 58 | 59 | # Deals w/ http://github.com/thoughtbot/shoulda/issues/issue/117, see 60 | # http://stackoverflow.com/questions/3657972/nameerror-uninitialized-constant-testunitassertionfailederror-when-upgradin 61 | unless defined?(Test::Unit::AssertionFailedError) 62 | Test::Unit::AssertionFailedError = ActiveSupport::TestCase::Assertion 63 | end 64 | -------------------------------------------------------------------------------- /lib/aws/ses/addresses.rb: -------------------------------------------------------------------------------- 1 | module AWS 2 | module SES 3 | # AWS::SES::Addresses provides for: 4 | # * Listing verified e-mail addresses 5 | # * Adding new e-mail addresses to verify 6 | # * Deleting verified e-mail addresses 7 | # 8 | # You can access these methods as follows: 9 | # 10 | # ses = AWS::SES::Base.new( ... connection info ... ) 11 | # 12 | # # Get a list of verified addresses 13 | # ses.addresses.list.result 14 | # 15 | # # Add a new e-mail address to verify 16 | # ses.addresses.verify('jon@example.com') 17 | # 18 | # # Delete an e-mail address 19 | # ses.addresses.delete('jon@example.com') 20 | class Addresses < Base 21 | def initialize(ses) 22 | @ses = ses 23 | end 24 | 25 | # List all verified e-mail addresses 26 | # 27 | # Usage: 28 | # ses.addresses.list.result 29 | # => 30 | # ['email1@example.com', email2@example.com'] 31 | def list 32 | @ses.request('ListVerifiedEmailAddresses') 33 | end 34 | 35 | def verify(email) 36 | @ses.request('VerifyEmailAddress', 37 | 'EmailAddress' => email 38 | ) 39 | end 40 | 41 | def delete(email) 42 | @ses.request('DeleteVerifiedEmailAddress', 43 | 'EmailAddress' => email 44 | ) 45 | end 46 | end 47 | 48 | class ListVerifiedEmailAddressesResponse < AWS::SES::Response 49 | def result 50 | if members = parsed['ListVerifiedEmailAddressesResult']['VerifiedEmailAddresses'] 51 | [members['member']].flatten 52 | else 53 | [] 54 | end 55 | end 56 | memoized :result 57 | end 58 | 59 | class VerifyEmailAddressResponse < AWS::SES::Response 60 | end 61 | 62 | class DeleteVerifiedEmailAddressResponse < AWS::SES::Response 63 | def result 64 | success? 65 | end 66 | end 67 | 68 | class Base 69 | def addresses 70 | @addresses ||= Addresses.new(self) 71 | end 72 | end 73 | 74 | end 75 | end -------------------------------------------------------------------------------- /test/address_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../helper', __FILE__) 2 | 3 | class AddressTest < Test::Unit::TestCase 4 | context 'verifying an address' do 5 | setup do 6 | @base = generate_base 7 | end 8 | 9 | should 'return the correct response on success' do 10 | mock_connection(@base, :body => %{ 11 | 12 | 13 | abc-123 14 | 15 | 16 | }) 17 | 18 | result = @base.addresses.verify('user1@example.com') 19 | assert result.success? 20 | assert_equal 'abc-123', result.request_id 21 | end 22 | end 23 | 24 | context 'listing verified addressess' do 25 | setup do 26 | @base = generate_base 27 | end 28 | 29 | should 'return the correct response on success' do 30 | mock_connection(@base, :body => %{ 31 | 32 | 33 | 34 | user1@example.com 35 | 36 | 37 | 38 | abc-123 39 | 40 | 41 | }) 42 | 43 | result = @base.addresses.list 44 | 45 | assert result.success? 46 | assert_equal 'abc-123', result.request_id 47 | assert_equal %w{user1@example.com}, result.result 48 | end 49 | end 50 | 51 | 52 | context 'deleting a verified addressess' do 53 | setup do 54 | @base = generate_base 55 | end 56 | 57 | should 'return the correct response on success' do 58 | mock_connection(@base, :body => %{ 59 | 60 | 61 | abc-123 62 | 63 | 64 | }) 65 | 66 | result = @base.addresses.delete('user1@example.com') 67 | 68 | assert result.success? 69 | assert_equal 'abc-123', result.request_id 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | begin 4 | Bundler.setup(:default, :development) 5 | rescue Bundler::BundlerError => e 6 | $stderr.puts e.message 7 | $stderr.puts "Run `bundle install` to install missing gems" 8 | exit e.status_code 9 | end 10 | require 'rake' 11 | 12 | require 'erb' 13 | 14 | require 'rake/testtask' 15 | Rake::TestTask.new(:test) do |test| 16 | test.libs << 'lib' << 'test' 17 | test.pattern = 'test/**/*_test.rb' 18 | test.verbose = true 19 | end 20 | 21 | # require 'rcov/rcovtask' 22 | # Rcov::RcovTask.new do |test| 23 | # test.libs << 'test' 24 | # test.pattern = 'test/**/*_test.rb' 25 | # test.verbose = true 26 | # end 27 | 28 | task :default => :test 29 | 30 | require 'rdoc/task' 31 | require File.dirname(__FILE__) + '/lib/aws/ses' 32 | 33 | namespace :doc do 34 | Rake::RDocTask.new do |rdoc| 35 | rdoc.rdoc_dir = 'doc' 36 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 37 | rdoc.title = "AWS::SES -- Support for Amazon SES's REST api #{version}" 38 | rdoc.options << '--line-numbers' << '--inline-source' 39 | rdoc.rdoc_files.include('README.rdoc') 40 | rdoc.rdoc_files.include('LICENSE') 41 | rdoc.rdoc_files.include('CHANGELOG') 42 | rdoc.rdoc_files.include('TODO') 43 | rdoc.rdoc_files.include('VERSION') 44 | rdoc.rdoc_files.include('lib/**/*.rb') 45 | end 46 | 47 | task :rdoc => 'doc:readme' 48 | 49 | task :refresh => :rerdoc do 50 | system 'open doc/index.html' 51 | end 52 | 53 | desc "Generate readme.rdoc from readme.erb" 54 | task :readme do 55 | require 'support/rdoc/code_info' 56 | RDoc::CodeInfo.parse('lib/**/*.rb') 57 | 58 | strip_comments = lambda {|comment| comment.gsub(/^# ?/, '')} 59 | docs_for = lambda do |location| 60 | info = RDoc::CodeInfo.for(location) 61 | raise RuntimeError, "Couldn't find documentation for `#{location}'" unless info 62 | strip_comments[info.comment] 63 | end 64 | 65 | open('README.rdoc', 'w') do |file| 66 | file.write ERB.new(IO.read('README.erb')).result(binding) 67 | end 68 | end 69 | end 70 | 71 | require 'jeweler' 72 | Jeweler::Tasks.new do |gem| 73 | # gem is a Gem::Specification... see http://guides.rubygems.org/specification-reference/ for more options 74 | gem.name = "aws-ses" 75 | gem.homepage = "http://github.com/drewblas/aws-ses" 76 | gem.license = "MIT" 77 | gem.summary = "Client library for Amazon's Simple Email Service's REST API" 78 | gem.description = "Client library for Amazon's Simple Email Service's REST API" 79 | gem.email = "drew.blas@gmail.com" 80 | gem.authors = ["Drew Blas", "Marcel Molina Jr."] 81 | # dependencies defined in Gemfile 82 | end 83 | Jeweler::RubygemsDotOrgTasks.new 84 | -------------------------------------------------------------------------------- /test/fixtures.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | module AWS 4 | module SES 5 | # When this file is loaded, for each fixture file, a module is created within the Fixtures module 6 | # with the same name as the fixture file. For each fixture in that fixture file, a singleton method is 7 | # added to the module with the name of the given fixture, returning the value of the fixture. 8 | # 9 | # For example: 10 | # 11 | # A fixture in buckets.yml named empty_bucket_list with value hi! 12 | # would be made available like so: 13 | # 14 | # Fixtures::Buckets.empty_bucket_list 15 | # => "hi!" 16 | # 17 | # Alternatively you can treat the fixture module like a hash 18 | # 19 | # Fixtures::Buckets[:empty_bucket_list] 20 | # => "hi!" 21 | # 22 | # You can find out all available fixtures by calling 23 | # 24 | # Fixtures.fixtures 25 | # => ["Buckets"] 26 | # 27 | # And all the fixtures contained in a given fixture by calling 28 | # 29 | # Fixtures::Buckets.fixtures 30 | # => ["bucket_list_with_more_than_one_bucket", "bucket_list_with_one_bucket", "empty_bucket_list"] 31 | module Fixtures 32 | class << self 33 | def create_fixtures 34 | files.each do |file| 35 | create_fixture_for(file) 36 | end 37 | end 38 | 39 | def create_fixture_for(file) 40 | fixtures = YAML.load_file(path(file)) 41 | fixture_module = Module.new 42 | 43 | fixtures.each do |name, value| 44 | fixture_module.module_eval(<<-EVAL, __FILE__, __LINE__) 45 | def #{name} 46 | #{value.inspect} 47 | end 48 | module_function :#{name} 49 | EVAL 50 | end 51 | 52 | fixture_module.module_eval(<<-EVAL, __FILE__, __LINE__) 53 | module_function 54 | 55 | def fixtures 56 | #{fixtures.keys.sort.inspect} 57 | end 58 | 59 | def [](name) 60 | send(name) if fixtures.include?(name.to_s) 61 | end 62 | EVAL 63 | 64 | const_set(module_name(file), fixture_module) 65 | end 66 | 67 | def fixtures 68 | constants.sort 69 | end 70 | 71 | private 72 | 73 | def files 74 | Dir.glob(File.dirname(__FILE__) + '/fixtures/*.yml').map {|fixture| File.basename(fixture)} 75 | end 76 | 77 | def module_name(file_name) 78 | File.basename(file_name, '.*').capitalize 79 | end 80 | 81 | def path(file_name) 82 | File.join(File.dirname(__FILE__), 'fixtures', file_name) 83 | end 84 | end 85 | 86 | create_fixtures 87 | end 88 | end 89 | end -------------------------------------------------------------------------------- /README.erb: -------------------------------------------------------------------------------- 1 | = AWS::SES 2 | 3 | <%= docs_for['AWS::SES'] %> 4 | 5 | == Send E-mail 6 | 7 | <%= docs_for ['AWS::SES::SendEmail'] %> 8 | 9 | == Addresses 10 | 11 | <%= docs_for['AWS::SES::Addresses'] %> 12 | 13 | == Info 14 | 15 | <%= docs_for['AWS::SES::Info'] %> 16 | 17 | == Rails 18 | 19 | This gem is compatible with Rails >= 3.0.0 and Ruby 2.3.x 20 | 21 | To use, first add the gem to your Gemfile: 22 | 23 | gem "aws-ses", "~> 0.7.1", :require => 'aws/ses' 24 | 25 | == For Rails 3.x 26 | 27 | Then, add your Amazon credentials and extend ActionMailer in `config/initializers/amazon_ses.rb`: 28 | 29 | ActionMailer::Base.add_delivery_method :ses, AWS::SES::Base, 30 | :access_key_id => 'abc', 31 | :secret_access_key => '123', 32 | :signature_version => 4 33 | 34 | Then set the delivery method in `config/environments/*rb` as appropriate: 35 | 36 | config.action_mailer.delivery_method = :ses 37 | 38 | == For Rails 2.3.x 39 | 40 | Then set the delivery method in `config/environments/*rb` as appropriate: 41 | 42 | config.after_initialize do 43 | ActionMailer::Base.delivery_method = :amazon_ses 44 | ActionMailer::Base.custom_amazon_ses_mailer = AWS::SES::Base.new(:secret_access_key => 'abc', :access_key_id => '123') 45 | end 46 | 47 | == Issues 48 | 49 | === HTTP Segmentation fault 50 | 51 | If you get this error: 52 | net/http.rb:677: [BUG] Segmentation fault 53 | 54 | It means that you are not running with SSL enabled in ruby. Re-compile ruby with ssl support or add this option to your environment: 55 | RUBYOPT="-r openssl" 56 | 57 | === Rejected sending 58 | 59 | If you are receiving this message and you HAVE verified the [source] please check to be sure you are not in sandbox mode! 60 | "Email address is not verified.MessageRejected (AWS::Error)" 61 | If you have not been granted production access, you will have to verify all recipients as well. 62 | 63 | http://docs.amazonwebservices.com/ses/2010-12-01/DeveloperGuide/index.html?InitialSetup.Customer.html 64 | 65 | == Source 66 | 67 | Available at: https://github.com/drewblas/aws-ses 68 | 69 | == Contributing to aws-ses 70 | 71 | * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet 72 | * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it 73 | * Fork the project 74 | * Start a feature/bugfix branch 75 | * Commit and push until you are happy with your contribution 76 | * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. 77 | * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it. 78 | 79 | == Copyright 80 | 81 | Copyright (c) 2020 Drew Blas. See LICENSE for further details. 82 | 83 | == Thanks 84 | 85 | Special thanks to Marcel Molina Jr. for his creation of AWS::S3 which I used portions of to get things working. 86 | 87 | === Other Contributors: 88 | 89 | * croaky 90 | * nathanbertram 91 | * sshaw 92 | * teeparham (documentation) 93 | * pzb 94 | * NicholasHely 95 | * m33h 96 | -------------------------------------------------------------------------------- /test/extensions_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../helper', __FILE__) 2 | 3 | class KerneltExtensionsTest < Test::Unit::TestCase 4 | class Foo 5 | def foo 6 | __method__ 7 | end 8 | 9 | def bar 10 | foo 11 | end 12 | 13 | def baz 14 | bar 15 | end 16 | end 17 | 18 | class Bar 19 | def foo 20 | calling_method 21 | end 22 | 23 | def bar 24 | calling_method 25 | end 26 | 27 | def calling_method 28 | __method__(1) 29 | end 30 | end 31 | 32 | def test___method___works_regardless_of_nesting 33 | f = Foo.new 34 | [:foo, :bar, :baz].each do |method| 35 | assert_equal 'foo', f.send(method) 36 | end 37 | end 38 | 39 | def test___method___depth 40 | b = Bar.new 41 | assert_equal 'foo', b.foo 42 | assert_equal 'bar', b.bar 43 | end 44 | end if RUBY_VERSION <= '1.8.7' 45 | 46 | class ModuleExtensionsTest < Test::Unit::TestCase 47 | class Foo 48 | def foo(reload = false) 49 | expirable_memoize(reload) do 50 | Time.now 51 | end 52 | end 53 | 54 | def bar(reload = false) 55 | expirable_memoize(reload, :baz) do 56 | Time.now 57 | end 58 | end 59 | 60 | def quux 61 | Time.now 62 | end 63 | memoized :quux 64 | end 65 | 66 | def setup 67 | @instance = Foo.new 68 | end 69 | 70 | def test_memoize 71 | assert !instance_variables_of(@instance).include?('@foo') 72 | cached_result = @instance.foo 73 | assert_equal cached_result, @instance.foo 74 | assert instance_variables_of(@instance).include?('@foo') 75 | assert_equal cached_result, @instance.send(:instance_variable_get, :@foo) 76 | assert_not_equal cached_result, new_cache = @instance.foo(:reload) 77 | assert_equal new_cache, @instance.foo 78 | assert_equal new_cache, @instance.send(:instance_variable_get, :@foo) 79 | end 80 | 81 | def test_customizing_memoize_storage 82 | assert !instance_variables_of(@instance).include?('@bar') 83 | assert !instance_variables_of(@instance).include?('@baz') 84 | cached_result = @instance.bar 85 | assert !instance_variables_of(@instance).include?('@bar') 86 | assert instance_variables_of(@instance).include?('@baz') 87 | assert_equal cached_result, @instance.bar 88 | assert_equal cached_result, @instance.send(:instance_variable_get, :@baz) 89 | assert_nil @instance.send(:instance_variable_get, :@bar) 90 | end 91 | 92 | def test_memoized 93 | assert !instance_variables_of(@instance).include?('@quux') 94 | cached_result = @instance.quux 95 | assert_equal cached_result, @instance.quux 96 | assert instance_variables_of(@instance).include?('@quux') 97 | assert_equal cached_result, @instance.send(:instance_variable_get, :@quux) 98 | assert_not_equal cached_result, new_cache = @instance.quux(:reload) 99 | assert_equal new_cache, @instance.quux 100 | assert_equal new_cache, @instance.send(:instance_variable_get, :@quux) 101 | end 102 | 103 | private 104 | # For 1.9 compatibility 105 | def instance_variables_of(object) 106 | object.instance_variables.map do |instance_variable| 107 | instance_variable.to_s 108 | end 109 | end 110 | 111 | end 112 | -------------------------------------------------------------------------------- /lib/aws/ses/info.rb: -------------------------------------------------------------------------------- 1 | module AWS 2 | module SES 3 | # Adds functionality for the statistics and info send quota data that Amazon SES makes available 4 | # 5 | # You can access these methods as follows: 6 | # 7 | # ses = AWS::SES::Base.new( ... connection info ... ) 8 | # 9 | # == Get the quota information 10 | # response = ses.quota 11 | # # How many e-mails you've sent in the last 24 hours 12 | # response.sent_last_24_hours 13 | # # How many e-mails you're allowed to send in 24 hours 14 | # response.max_24_hour_send 15 | # # How many e-mails you can send per second 16 | # response.max_send_rate 17 | # 18 | # == Get detailed send statistics 19 | # The result is a list of data points, representing the last two weeks of sending activity. 20 | # Each data point in the list contains statistics for a 15-minute interval. 21 | # GetSendStatisticsResponse#data_points is an array where each element is a hash with give string keys: 22 | # 23 | # * +Bounces+ 24 | # * +DeliveryAttempts+ 25 | # * +Rejects+ 26 | # * +Complaints+ 27 | # * +Timestamp+ 28 | # 29 | # response = ses.statistics 30 | # response.data_points # => 31 | # [{"Bounces"=>"0", 32 | # "Timestamp"=>"2011-01-26T16:30:00Z", 33 | # "DeliveryAttempts"=>"1", 34 | # "Rejects"=>"0", 35 | # "Complaints"=>"0"}, 36 | # {"Bounces"=>"0", 37 | # "Timestamp"=>"2011-02-09T14:45:00Z", 38 | # "DeliveryAttempts"=>"3", 39 | # "Rejects"=>"0", 40 | # "Complaints"=>"0"}, 41 | # {"Bounces"=>"0", 42 | # "Timestamp"=>"2011-01-31T15:30:00Z", 43 | # "DeliveryAttempts"=>"3", 44 | # "Rejects"=>"0", 45 | # "Complaints"=>"0"}, 46 | # {"Bounces"=>"0", 47 | # "Timestamp"=>"2011-01-31T16:00:00Z", 48 | # "DeliveryAttempts"=>"3", 49 | # "Rejects"=>"0", 50 | # "Complaints"=>"0"}] 51 | 52 | module Info 53 | # Returns quota information provided by SES 54 | # 55 | # The return format inside the response result will look like: 56 | # {"SentLast24Hours"=>"0.0", "MaxSendRate"=>"1.0", "Max24HourSend"=>"200.0"} 57 | def quota 58 | request('GetSendQuota') 59 | end 60 | 61 | def statistics 62 | request('GetSendStatistics') 63 | end 64 | end 65 | 66 | class GetSendQuotaResponse < AWS::SES::Response 67 | def result 68 | parsed['GetSendQuotaResult'] 69 | end 70 | 71 | def sent_last_24_hours 72 | result['SentLast24Hours'] 73 | end 74 | 75 | def max_24_hour_send 76 | result['Max24HourSend'] 77 | end 78 | 79 | def max_send_rate 80 | result['MaxSendRate'] 81 | end 82 | end 83 | 84 | class GetSendStatisticsResponse < AWS::SES::Response 85 | def result 86 | if members = parsed['GetSendStatisticsResult']['SendDataPoints'] 87 | [members['member']].flatten 88 | else 89 | [] 90 | end 91 | end 92 | 93 | memoized :result 94 | 95 | def data_points 96 | result 97 | end 98 | end 99 | end 100 | end -------------------------------------------------------------------------------- /lib/aws/ses/response.rb: -------------------------------------------------------------------------------- 1 | module AWS 2 | module SES 3 | class Response < String 4 | attr_reader :response, :body, :parsed, :action 5 | 6 | def initialize(action, response) 7 | @action = action 8 | @response = response 9 | @body = response.body.to_s 10 | super(body) 11 | end 12 | 13 | def headers 14 | headers = {} 15 | response.each do |header, value| 16 | headers[header] = value 17 | end 18 | headers 19 | end 20 | memoized :headers 21 | 22 | def [](header) 23 | headers[header] 24 | end 25 | 26 | def each(&block) 27 | headers.each(&block) 28 | end 29 | 30 | def code 31 | response.code.to_i 32 | end 33 | 34 | {:success => 200..299, :redirect => 300..399, 35 | :client_error => 400..499, :server_error => 500..599}.each do |result, code_range| 36 | class_eval(<<-EVAL, __FILE__, __LINE__) 37 | def #{result}? 38 | return false unless response 39 | (#{code_range}).include? code 40 | end 41 | EVAL 42 | end 43 | 44 | def error? 45 | !success? && (response['content-type'] == 'application/xml' || response['content-type'] == 'text/xml') 46 | end 47 | 48 | def error 49 | parsed['Error'] 50 | end 51 | memoized :error 52 | 53 | def parsed 54 | parse_options = { 'forcearray' => ['item', 'member'], 'suppressempty' => nil, 'keeproot' => false } 55 | # parse_options = { 'suppressempty' => nil, 'keeproot' => false } 56 | 57 | XmlSimple.xml_in(body, parse_options) 58 | end 59 | memoized :parsed 60 | 61 | # It's expected that each subclass of Response will override this method with what part of response is relevant 62 | def result 63 | parsed 64 | end 65 | 66 | def request_id 67 | error? ? parsed['RequestId'] : parsed['ResponseMetadata']['RequestId'] 68 | end 69 | 70 | def inspect 71 | "#<%s:0x%s %s %s %s>" % [self.class, object_id, request_id, response.code, response.message] 72 | end 73 | end # class Response 74 | 75 | # Requests whose response code is between 300 and 599 and contain an in their body 76 | # are wrapped in an Error::Response. This Error::Response contains an Error object which raises an exception 77 | # that corresponds to the error in the response body. The exception object contains the ErrorResponse, so 78 | # in all cases where a request happens, you can rescue ResponseError and have access to the ErrorResponse and 79 | # its Error object which contains information about the ResponseError. 80 | # 81 | # begin 82 | # Bucket.create(..) 83 | # rescue ResponseError => exception 84 | # exception.response 85 | # # => 86 | # exception.response.error 87 | # # => 88 | # end 89 | class ResponseError < StandardError 90 | attr_reader :response 91 | def initialize(response) 92 | @response = response 93 | super("AWS::SES Response Error: #{message}") 94 | end 95 | 96 | def code 97 | @response.code 98 | end 99 | 100 | def message 101 | "#{@response.error['Code']} - #{@response.error['Message']}" 102 | end 103 | 104 | def inspect 105 | "#<%s:0x%s %s %s '%s'>" % [self.class.name, object_id, @response.request_id, code, message] 106 | end 107 | end 108 | end # module SES 109 | end # module AWS 110 | 111 | -------------------------------------------------------------------------------- /test/info_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../helper', __FILE__) 2 | 3 | class InfoTest < Test::Unit::TestCase 4 | context 'getting send quota' do 5 | setup do 6 | @base = generate_base 7 | end 8 | 9 | should 'return the correct response on success' do 10 | mock_connection(@base, :body => %{ 11 | 12 | 13 | 0.0 14 | 1000.0 15 | 1.0 16 | 17 | 18 | abc-123 19 | 20 | 21 | }) 22 | 23 | result = @base.quota 24 | assert result.success? 25 | assert_equal 'abc-123', result.request_id 26 | assert_equal '0.0', result.sent_last_24_hours 27 | assert_equal '1000.0', result.max_24_hour_send 28 | assert_equal '1.0', result.max_send_rate 29 | end 30 | end 31 | 32 | context 'getting send statistics' do 33 | setup do 34 | @base = generate_base 35 | end 36 | 37 | should 'return the correct response on success' do 38 | mock_connection(@base, :body => %{ 39 | 40 | 41 | 42 | 43 | 3 44 | 2011-01-31T15:31:00Z 45 | 2 46 | 4 47 | 1 48 | 49 | 50 | 3 51 | 2011-01-31T16:01:00Z 52 | 0 53 | 0 54 | 0 55 | 56 | 57 | 1 58 | 2011-01-26T16:31:00Z 59 | 0 60 | 0 61 | 0 62 | 63 | 64 | 65 | 66 | abc-123 67 | 68 | 69 | }) 70 | 71 | result = @base.statistics 72 | 73 | assert result.success? 74 | assert_equal 'abc-123', result.request_id 75 | 76 | assert_equal 3, result.data_points.size 77 | 78 | d = result.data_points.first 79 | 80 | assert_equal '2', d['Rejects'] 81 | assert_equal '3', d['DeliveryAttempts'] 82 | assert_equal '4', d['Bounces'] 83 | assert_equal '1', d['Complaints'] 84 | end 85 | end 86 | 87 | 88 | context 'deleting a verified addressess' do 89 | setup do 90 | @base = generate_base 91 | end 92 | 93 | should 'return the correct response on success' do 94 | mock_connection(@base, :body => %{ 95 | 96 | 97 | abc-123 98 | 99 | 100 | }) 101 | 102 | result = @base.addresses.delete('user1@example.com') 103 | 104 | assert result.success? 105 | assert_equal 'abc-123', result.request_id 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /aws-ses.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec' 4 | # -*- encoding: utf-8 -*- 5 | # stub: aws-ses 0.7.1 ruby lib 6 | 7 | Gem::Specification.new do |s| 8 | s.name = "aws-ses".freeze 9 | s.version = "0.7.1" 10 | 11 | s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= 12 | s.require_paths = ["lib".freeze] 13 | s.authors = ["Drew Blas".freeze, "Marcel Molina Jr.".freeze] 14 | s.date = "2020-09-30" 15 | s.description = "Client library for Amazon's Simple Email Service's REST API".freeze 16 | s.email = "drew.blas@gmail.com".freeze 17 | s.extra_rdoc_files = [ 18 | "CHANGELOG", 19 | "LICENSE", 20 | "README.erb", 21 | "README.rdoc", 22 | "TODO" 23 | ] 24 | s.files = [ 25 | ".document", 26 | "CHANGELOG", 27 | "Gemfile", 28 | "Gemfile.lock", 29 | "LICENSE", 30 | "README.erb", 31 | "README.rdoc", 32 | "Rakefile", 33 | "TODO", 34 | "VERSION", 35 | "aws-ses.gemspec", 36 | "lib/aws/actionmailer/ses_extension.rb", 37 | "lib/aws/ses.rb", 38 | "lib/aws/ses/addresses.rb", 39 | "lib/aws/ses/base.rb", 40 | "lib/aws/ses/extensions.rb", 41 | "lib/aws/ses/info.rb", 42 | "lib/aws/ses/response.rb", 43 | "lib/aws/ses/send_email.rb", 44 | "lib/aws/ses/version.rb", 45 | "test/address_test.rb", 46 | "test/base_test.rb", 47 | "test/extensions_test.rb", 48 | "test/fixtures.rb", 49 | "test/helper.rb", 50 | "test/info_test.rb", 51 | "test/mocks/fake_response.rb", 52 | "test/response_test.rb", 53 | "test/send_email_test.rb" 54 | ] 55 | s.homepage = "http://github.com/drewblas/aws-ses".freeze 56 | s.licenses = ["MIT".freeze] 57 | s.rubygems_version = "2.5.2.3".freeze 58 | s.summary = "Client library for Amazon's Simple Email Service's REST API".freeze 59 | 60 | if s.respond_to? :specification_version then 61 | s.specification_version = 4 62 | 63 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 64 | s.add_runtime_dependency(%q.freeze, [">= 0"]) 65 | s.add_runtime_dependency(%q.freeze, ["> 2.2.5"]) 66 | s.add_runtime_dependency(%q.freeze, [">= 0"]) 67 | s.add_runtime_dependency(%q.freeze, [">= 0"]) 68 | s.add_development_dependency(%q.freeze, [">= 1.17"]) 69 | s.add_development_dependency(%q.freeze, ["~> 0.8.11"]) 70 | s.add_development_dependency(%q.freeze, [">= 0"]) 71 | s.add_development_dependency(%q.freeze, [">= 0"]) 72 | s.add_development_dependency(%q.freeze, [">= 0"]) 73 | s.add_development_dependency(%q.freeze, [">= 0"]) 74 | s.add_development_dependency(%q.freeze, [">= 0"]) 75 | else 76 | s.add_dependency(%q.freeze, [">= 0"]) 77 | s.add_dependency(%q.freeze, ["> 2.2.5"]) 78 | s.add_dependency(%q.freeze, [">= 0"]) 79 | s.add_dependency(%q.freeze, [">= 0"]) 80 | s.add_dependency(%q.freeze, [">= 1.17"]) 81 | s.add_dependency(%q.freeze, ["~> 0.8.11"]) 82 | s.add_dependency(%q.freeze, [">= 0"]) 83 | s.add_dependency(%q.freeze, [">= 0"]) 84 | s.add_dependency(%q.freeze, [">= 0"]) 85 | s.add_dependency(%q.freeze, [">= 0"]) 86 | s.add_dependency(%q.freeze, [">= 0"]) 87 | end 88 | else 89 | s.add_dependency(%q.freeze, [">= 0"]) 90 | s.add_dependency(%q.freeze, ["> 2.2.5"]) 91 | s.add_dependency(%q.freeze, [">= 0"]) 92 | s.add_dependency(%q.freeze, [">= 0"]) 93 | s.add_dependency(%q.freeze, [">= 1.17"]) 94 | s.add_dependency(%q.freeze, ["~> 0.8.11"]) 95 | s.add_dependency(%q.freeze, [">= 0"]) 96 | s.add_dependency(%q.freeze, [">= 0"]) 97 | s.add_dependency(%q.freeze, [">= 0"]) 98 | s.add_dependency(%q.freeze, [">= 0"]) 99 | s.add_dependency(%q.freeze, [">= 0"]) 100 | end 101 | end 102 | 103 | -------------------------------------------------------------------------------- /test/base_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../helper', __FILE__) 2 | 3 | class BaseTest < Test::Unit::TestCase 4 | def test_connection_established 5 | instance = Base.new(:access_key_id => '123', :secret_access_key => 'abc') 6 | 7 | assert_not_nil instance.instance_variable_get("@http") 8 | end 9 | 10 | def test_failed_response 11 | @base = generate_base 12 | mock_connection(@base, {:code => 403, :body => %{ 13 | 14 | 15 | 16 | Sender 17 | 18 | 19 | ValidationError 20 | 21 | 22 | Value null at 'message.subject' failed to satisfy constraint: Member must not be null 23 | 24 | 25 | 26 | 42d59b56-7407-4c4a-be0f-4c88daeea257 27 | 28 | 29 | }}) 30 | 31 | assert_raises ResponseError do 32 | result = @base.request('', {}) 33 | end 34 | 35 | # assert !result.success? 36 | # assert result.error? 37 | # assert result.error.error? 38 | # assert_equal 'ValidationError', result.error.code 39 | end 40 | 41 | def test_ses_authorization_header_v2 42 | aws_access_key_id = 'fake_aws_key_id' 43 | aws_secret_access_key = 'fake_aws_access_key' 44 | timestamp = Time.new(2020, 7, 2, 7, 17, 58, '+00:00') 45 | 46 | base = ::AWS::SES::Base.new( 47 | access_key_id: aws_access_key_id, 48 | secret_access_key: aws_secret_access_key 49 | ) 50 | 51 | assert_equal 'AWS3-HTTPS AWSAccessKeyId=fake_aws_key_id, Algorithm=HmacSHA256, Signature=eHh/cPIJJUc1+RMCueAi50EPlYxkZNXMrxtGxjkBD1w=', base.get_aws_auth_param(timestamp.httpdate, aws_secret_access_key) 52 | end 53 | 54 | def test_ses_authorization_header_v4 55 | aws_access_key_id = 'fake_aws_key_id' 56 | aws_secret_access_key = 'fake_aws_access_key' 57 | time = Time.new(2020, 7, 2, 7, 17, 58, '+00:00') 58 | ::Timecop.freeze(time) 59 | 60 | base = ::AWS::SES::Base.new( 61 | server: 'ec2.amazonaws.com', 62 | signature_version: 4, 63 | access_key_id: aws_access_key_id, 64 | secret_access_key: aws_secret_access_key 65 | ) 66 | 67 | assert_equal 'AWS4-HMAC-SHA256 Credential=fake_aws_key_id/20200702/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-date, Signature=c0465b36efd110b14a1c6dcca3e105085ed2bfb2a3fd3b3586cc459326ab43aa', base.get_aws_auth_param(time.httpdate, aws_secret_access_key, 'DescribeRegions', 4) 68 | Timecop.return 69 | end 70 | 71 | def test_ses_authorization_header_v4_changed_host 72 | aws_access_key_id = 'fake_aws_key_id' 73 | aws_secret_access_key = 'fake_aws_access_key' 74 | time = Time.new(2020, 7, 2, 7, 17, 58, '+00:00') 75 | ::Timecop.freeze(time) 76 | 77 | base = ::AWS::SES::Base.new( 78 | server: 'email.us-east-1.amazonaws.com', 79 | signature_version: 4, 80 | access_key_id: aws_access_key_id, 81 | secret_access_key: aws_secret_access_key 82 | ) 83 | 84 | assert_equal 'AWS4-HMAC-SHA256 Credential=fake_aws_key_id/20200702/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-date, Signature=b872601457070ab98e7038bdcd4dc1f5eab586ececf9908525474408b0740515', base.get_aws_auth_param(time.httpdate, aws_secret_access_key, 'DescribeRegions', 4) 85 | Timecop.return 86 | end 87 | 88 | def test_ses_authorization_header_v4_changed_region 89 | aws_access_key_id = 'fake_aws_key_id' 90 | aws_secret_access_key = 'fake_aws_access_key' 91 | time = Time.new(2020, 7, 2, 7, 17, 58, '+00:00') 92 | ::Timecop.freeze(time) 93 | 94 | base = ::AWS::SES::Base.new( 95 | server: 'email.us-east-1.amazonaws.com', 96 | signature_version: 4, 97 | access_key_id: aws_access_key_id, 98 | secret_access_key: aws_secret_access_key, 99 | region: 'eu-west-1' 100 | ) 101 | 102 | assert_not_equal 'AWS4-HMAC-SHA256 Credential=fake_aws_key_id/20200702/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-date, Signature=b872601457070ab98e7038bdcd4dc1f5eab586ececf9908525474408b0740515', base.get_aws_auth_param(time.httpdate, aws_secret_access_key, 'DescribeRegions', 4) 103 | Timecop.return 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/send_email_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../helper', __FILE__) 2 | 3 | class SendEmailTest < Test::Unit::TestCase 4 | context 'when sending email' do 5 | setup do 6 | @base = generate_base 7 | @basic_email = { :from => 'jon@example.com', 8 | :to => 'dave@example.com', 9 | :subject => 'Subject1', 10 | :text_body => 'Body1' } 11 | end 12 | 13 | context 'adding to a hash' do 14 | should 'add array elements to the hash' do 15 | hash = {} 16 | ary = ['x', 'y'] 17 | @base.send(:add_array_to_hash!, hash, 'SomeKey', ary) 18 | expected = {'SomeKey.member.1' => 'x', 'SomeKey.member.2' => 'y'} 19 | assert_equal expected, hash 20 | end 21 | 22 | should 'add singular elements to the hash' do 23 | hash = {} 24 | ary = 'z' 25 | @base.send(:add_array_to_hash!, hash, 'SomeKey', ary) 26 | expected = {'SomeKey.member.1' => 'z'} 27 | assert_equal expected, hash 28 | end 29 | end 30 | 31 | should 'send an e-mail' do 32 | mock_connection(@base, :body => %{ 33 | 34 | 35 | abc-123 36 | 37 | 38 | xyz-123 39 | 40 | 41 | }) 42 | 43 | result = @base.send_email @basic_email 44 | assert result.success? 45 | assert_equal 'abc-123', result.message_id 46 | assert_equal 'xyz-123', result.request_id 47 | end 48 | 49 | should 'throw ArgumentException when attachment supplied without a body' do 50 | #mock_connection(@base, :body => %{ 51 | # 52 | # 53 | # abc-123 54 | # 55 | # 56 | # xyz-123 57 | # 58 | # 59 | #}) 60 | message = Mail.new({:from => 'jon@example.com', :to => 'dave@example.com', :subject => 'Subject1'}) 61 | message.attachments['foo'] = { :mime_type => 'application/csv', :content => '1,2,3' } 62 | assert_raise ArgumentError do 63 | result = @base.send_raw_email message 64 | end 65 | end 66 | 67 | should 'send a raw e-mail with a hash object' do 68 | mock_connection(@base, :body => %{ 69 | 70 | 71 | abc-123 72 | 73 | 74 | xyz-123 75 | 76 | 77 | }) 78 | 79 | result = @base.send_raw_email(@basic_email) 80 | assert result.success? 81 | assert_equal 'abc-123', result.message_id 82 | assert_equal 'xyz-123', result.request_id 83 | end 84 | 85 | should 'send a raw e-mail with a mail object' do 86 | mock_connection(@base, :body => %{ 87 | 88 | 89 | abc-123 90 | 91 | 92 | xyz-123 93 | 94 | 95 | }) 96 | 97 | message = Mail.new(@basic_email) 98 | result = @base.send_raw_email(message) 99 | assert result.success? 100 | assert_equal 'abc-123', result.message_id 101 | assert_equal 'xyz-123', result.request_id 102 | assert message.errors.empty? 103 | assert_equal 'abc-123@email.amazonses.com', message.message_id 104 | end 105 | 106 | context "with a standard email" do 107 | setup do 108 | @message = Mail.new({:from => 'jon@example.com', :to => 'dave@example.com', :cc => 'sally@example.com', :subject => 'Subject1', :body => "test body"}) 109 | end 110 | 111 | should "add the 2 addresses to the Destinations" do 112 | package = @base.send("build_raw_email", @message) 113 | assert_equal 'dave@example.com', package['Destinations.member.1'] 114 | assert_equal 'sally@example.com', package['Destinations.member.2'] 115 | end 116 | 117 | should "be able to override the e-mail destinations" do 118 | dest_options = {:to => "another@example.com", :cc => "bob@example.com" } 119 | package = @base.send("build_raw_email", @message, dest_options) 120 | assert_equal 'another@example.com', package['Destinations.member.1'] 121 | assert_equal 'bob@example.com', package['Destinations.member.2'] 122 | end 123 | end 124 | 125 | should "add the bcc address to the email destinations" do 126 | message = Mail.new({:from => 'jon@example.com', :bcc => "robin@example.com", :to => 'dave@example.com', :subject => 'Subject1', :body => "test body"}) 127 | package = @base.send("build_raw_email", message) 128 | assert_equal 'dave@example.com', package['Destinations.member.1'] 129 | assert_equal 'robin@example.com', package['Destinations.member.2'] 130 | end 131 | 132 | should "when only bcc address in the email" do 133 | message = Mail.new({:from => 'jon@example.com', :bcc => ["robin@example.com", 'dave@example.com'], :subject => 'Subject1', :body => "test body"}) 134 | package = @base.send("build_raw_email", message) 135 | assert_equal 'robin@example.com', package['Destinations.member.1'] 136 | assert_equal 'dave@example.com', package['Destinations.member.2'] 137 | end 138 | 139 | should "add the mail addresses to the email destination" do 140 | message = Mail.new({:from => 'jon@example.com', :to => ["robin@example.com", 'dave@example.com'], :subject => 'Subject1', :body => "test body"}) 141 | 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/aws/ses/send_email.rb: -------------------------------------------------------------------------------- 1 | module AWS 2 | module SES 3 | # Adds functionality for send_email and send_raw_email 4 | # Use the following to send an e-mail: 5 | # 6 | # ses = AWS::SES::Base.new( ... connection info ... ) 7 | # ses.send_email :to => ['jon@example.com', 'dave@example.com'], 8 | # :source => '"Steve Smith" ', 9 | # :subject => 'Subject Line' 10 | # :text_body => 'Internal text body' 11 | # 12 | # By default, the email "from" display address is whatever is before the @. 13 | # To change the display from, use the format: 14 | # 15 | # "Steve Smith" 16 | # 17 | # You can also send Mail objects using send_raw_email: 18 | # 19 | # m = Mail.new( :to => ..., :from => ... ) 20 | # ses.send_raw_email(m) 21 | # 22 | # send_raw_email will also take a hash and pass it through Mail.new automatically as well. 23 | # 24 | module SendEmail 25 | 26 | # Sends an email through SES 27 | # 28 | # the destination parameters can be: 29 | # 30 | # [A single e-mail string] "jon@example.com" 31 | # [A array of e-mail addresses] ['jon@example.com', 'dave@example.com'] 32 | # 33 | # --- 34 | # = "Email address is not verified.MessageRejected (AWS::Error)" 35 | # If you are receiving this message and you HAVE verified the [source] please check to be sure you are not in sandbox mode! 36 | # If you have not been granted production access, you will have to verify all recipients as well. 37 | # http://docs.amazonwebservices.com/ses/2010-12-01/DeveloperGuide/index.html?InitialSetup.Customer.html 38 | # --- 39 | # 40 | # @option options [String] :source Source e-mail (from) 41 | # @option options [String] :from alias for :source 42 | # @option options [String] :to Destination e-mails 43 | # @option options [String] :cc Destination e-mails 44 | # @option options [String] :bcc Destination e-mails 45 | # @option options [String] :subject 46 | # @option options [String] :html_body 47 | # @option options [String] :text_body 48 | # @option options [String] :return_path The email address to which bounce notifications are to be forwarded. If the message cannot be delivered to the recipient, then an error message will be returned from the recipient's ISP; this message will then be forwarded to the email address specified by the ReturnPath parameter. 49 | # @option options [String] :reply_to The reploy-to email address(es) for the message. If the recipient replies to the message, each reply-to address will receive the reply. 50 | # @option options 51 | # @return [Response] the response to sending this e-mail 52 | def send_email(options = {}) 53 | package = {} 54 | 55 | package['Source'] = options[:source] || options[:from] 56 | 57 | add_array_to_hash!(package, 'Destination.ToAddresses', options[:to]) if options[:to] 58 | add_array_to_hash!(package, 'Destination.CcAddresses', options[:cc]) if options[:cc] 59 | add_array_to_hash!(package, 'Destination.BccAddresses', options[:bcc]) if options[:bcc] 60 | 61 | package['Message.Subject.Data'] = options[:subject] 62 | 63 | package['Message.Body.Html.Data'] = options[:html_body] if options[:html_body] 64 | package['Message.Body.Text.Data'] = options[:text_body] || options[:body] if options[:text_body] || options[:body] 65 | 66 | package['ReturnPath'] = options[:return_path] if options[:return_path] 67 | 68 | add_array_to_hash!(package, 'ReplyToAddresses', options[:reply_to]) if options[:reply_to] 69 | 70 | request('SendEmail', package) 71 | end 72 | 73 | # Sends using the SendRawEmail method 74 | # This gives the most control and flexibility 75 | # 76 | # This uses the underlying Mail object from the mail gem 77 | # You can pass in a Mail object, a Hash of params that will be parsed by Mail.new, or just a string 78 | # 79 | # Note that the params are different from send_email 80 | # Specifically, the following fields from send_email will NOT work: 81 | # 82 | # * :source 83 | # * :html_body 84 | # * :text_body 85 | # 86 | # send_email accepts the aliases of :from & :body in order to be more compatible with the Mail gem 87 | # 88 | # This method is aliased as deliver and deliver! for compatibility (especially with Rails) 89 | # 90 | # @option mail [String] A raw string that is a properly formatted e-mail message 91 | # @option mail [Hash] A hash that will be parsed by Mail.new 92 | # @option mail [Mail] A mail object, ready to be encoded 93 | # @option args [String] :source The sender's email address 94 | # @option args [String] :destinations A list of destinations for the message. 95 | # @option args [String] :from alias for :source 96 | # @option args [String] :to alias for :destinations 97 | # @return [Response] 98 | def send_raw_email(mail, args = {}) 99 | message = mail.is_a?(Hash) ? Mail.new(mail) : mail 100 | raise ArgumentError, "Attachment provided without message body" if message.has_attachments? && message.text_part.nil? && message.html_part.nil? 101 | 102 | raw_email = build_raw_email(message, args) 103 | result = request('SendRawEmail', raw_email) 104 | message.message_id = "<#{result.parsed['SendRawEmailResult']['MessageId']}@#{message_id_domain}>" 105 | result 106 | end 107 | 108 | alias :deliver! :send_raw_email 109 | alias :deliver :send_raw_email 110 | 111 | private 112 | 113 | def build_raw_email(message, args = {}) 114 | # the message.to_s includes the :to and :cc addresses 115 | package = { 'RawMessage.Data' => Base64::encode64(message.to_s) } 116 | package['Source'] = args[:from] if args[:from] 117 | package['Source'] = args[:source] if args[:source] 118 | if args[:destinations] 119 | add_array_to_hash!(package, 'Destinations', args[:destinations]) 120 | else 121 | mail_addresses = [message.to, message.cc, message.bcc].flatten.compact 122 | args_addresses = [args[:to], args[:cc], args[:bcc]].flatten.compact 123 | 124 | mail_addresses = args_addresses unless args_addresses.empty? 125 | 126 | add_array_to_hash!(package, 'Destinations', mail_addresses) 127 | end 128 | package 129 | end 130 | 131 | # Adds all elements of the ary with the appropriate member elements 132 | def add_array_to_hash!(hash, key, ary) 133 | cnt = 1 134 | [*ary].each do |o| 135 | hash["#{key}.member.#{cnt}"] = o 136 | cnt += 1 137 | end 138 | end 139 | end 140 | 141 | class EmailResponse < AWS::SES::Response 142 | def result 143 | super["#{action}Result"] 144 | end 145 | 146 | def message_id 147 | result['MessageId'] 148 | end 149 | end 150 | 151 | class SendEmailResponse < EmailResponse 152 | 153 | end 154 | 155 | class SendRawEmailResponse < EmailResponse 156 | 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = AWS::SES 2 | 3 | AWS::SES is a Ruby library for Amazon's Simple Email Service's REST API (http://aws.amazon.com/ses). 4 | 5 | == Getting started 6 | 7 | To get started you need to require 'aws/ses': 8 | 9 | % irb -rubygems 10 | irb(main):001:0> require 'aws/ses' 11 | # => true 12 | 13 | Before you can do anything, you must establish a connection using Base.new. A basic connection would look something like this: 14 | 15 | ses = AWS::SES::Base.new( 16 | :access_key_id => 'abc', 17 | :secret_access_key => '123' 18 | ) 19 | 20 | The minimum connection options that you must specify are your access key id and your secret access key. 21 | 22 | === Connecting to a server from another region 23 | 24 | The default server API endpoint is "email.us-east-1.amazonaws.com", corresponding to the US East 1 region. 25 | 26 | To connect to a different one, just pass it as a parameter to the AWS::SES::Base initializer: 27 | 28 | ses = AWS::SES::Base.new( 29 | :access_key_id => 'abc', 30 | :secret_access_key => '123', 31 | :server => 'email.eu-west-1.amazonaws.com', 32 | :message_id_domain => 'eu-west-1.amazonses.com' 33 | ) 34 | 35 | == Send E-mail 36 | 37 | Adds functionality for send_email and send_raw_email 38 | Use the following to send an e-mail: 39 | 40 | ses = AWS::SES::Base.new( ... connection info ... ) 41 | ses.send_email( 42 | :to => ['jon@example.com', 'dave@example.com'], 43 | :source => '"Steve Smith" ', 44 | :subject => 'Subject Line', 45 | :text_body => 'Internal text body' 46 | ) 47 | By default, the email "from" display address is whatever is before the @. 48 | To change the display from, use the format: 49 | 50 | "Steve Smith" 51 | 52 | You can also send Mail objects using send_raw_email: 53 | 54 | m = Mail.new( :to => ..., :from => ... ) 55 | ses.send_raw_email(m) 56 | 57 | send_raw_email will also take a hash and pass it through Mail.new automatically as well. 58 | 59 | 60 | 61 | == Addresses 62 | 63 | AWS::SES::Addresses provides for: 64 | * Listing verified e-mail addresses 65 | * Adding new e-mail addresses to verify 66 | * Deleting verified e-mail addresses 67 | 68 | You can access these methods as follows: 69 | 70 | ses = AWS::SES::Base.new( ... connection info ... ) 71 | 72 | # Get a list of verified addresses 73 | ses.addresses.list.result 74 | 75 | # Add a new e-mail address to verify 76 | ses.addresses.verify('jon@example.com') 77 | 78 | # Delete an e-mail address 79 | ses.addresses.delete('jon@example.com') 80 | 81 | 82 | == Info 83 | 84 | Adds functionality for the statistics and info send quota data that Amazon SES makes available 85 | 86 | You can access these methods as follows: 87 | 88 | ses = AWS::SES::Base.new( ... connection info ... ) 89 | 90 | == Get the quota information 91 | response = ses.quota 92 | # How many e-mails you've sent in the last 24 hours 93 | response.sent_last_24_hours 94 | # How many e-mails you're allowed to send in 24 hours 95 | response.max_24_hour_send 96 | # How many e-mails you can send per second 97 | response.max_send_rate 98 | 99 | == Get detailed send statistics 100 | The result is a list of data points, representing the last two weeks of sending activity. 101 | Each data point in the list contains statistics for a 15-minute interval. 102 | GetSendStatisticsResponse#data_points is an array where each element is a hash with give string keys: 103 | 104 | * +Bounces+ 105 | * +DeliveryAttempts+ 106 | * +Rejects+ 107 | * +Complaints+ 108 | * +Timestamp+ 109 | 110 | For example: 111 | 112 | response = ses.statistics 113 | response.data_points 114 | 115 | will return: 116 | 117 | [{"Bounces"=>"0", 118 | "Timestamp"=>"2011-01-26T16:30:00Z", 119 | "DeliveryAttempts"=>"1", 120 | "Rejects"=>"0", 121 | "Complaints"=>"0"}, 122 | {"Bounces"=>"0", 123 | "Timestamp"=>"2011-02-09T14:45:00Z", 124 | "DeliveryAttempts"=>"3", 125 | "Rejects"=>"0", 126 | "Complaints"=>"0"}, 127 | {"Bounces"=>"0", 128 | "Timestamp"=>"2011-01-31T15:30:00Z", 129 | "DeliveryAttempts"=>"3", 130 | "Rejects"=>"0", 131 | "Complaints"=>"0"}, 132 | {"Bounces"=>"0", 133 | "Timestamp"=>"2011-01-31T16:00:00Z", 134 | "DeliveryAttempts"=>"3", 135 | "Rejects"=>"0", 136 | "Complaints"=>"0"}] 137 | 138 | == Rails 139 | 140 | This gem is compatible with Rails >= 3.0.0 and Ruby 2.3.x 141 | 142 | To use, first add the gem to your Gemfile: 143 | 144 | gem "aws-ses", "~> 0.7.0", :require => 'aws/ses' 145 | 146 | == For Rails 3.x 147 | 148 | Then, add your Amazon credentials and extend ActionMailer in `config/initializers/amazon_ses.rb`: 149 | 150 | ActionMailer::Base.add_delivery_method :ses, AWS::SES::Base, 151 | :access_key_id => 'abc', 152 | :secret_access_key => '123', 153 | :signature_version => 4 154 | 155 | Then set the delivery method in `config/environments/*.rb` as appropriate: 156 | 157 | config.action_mailer.delivery_method = :ses 158 | 159 | == For Rails 2.3.x 160 | 161 | Then set the delivery method in `config/environments/*rb` as appropriate: 162 | 163 | config.after_initialize do 164 | ActionMailer::Base.delivery_method = :amazon_ses 165 | ActionMailer::Base.custom_amazon_ses_mailer = AWS::SES::Base.new(:secret_access_key => 'abc', :access_key_id => '123') 166 | end 167 | 168 | == Issues 169 | 170 | === HTTP Segmentation fault 171 | 172 | If you get this error: 173 | net/http.rb:677: [BUG] Segmentation fault 174 | 175 | It means that you are not running with SSL enabled in ruby. Re-compile ruby with ssl support or add this option to your environment: 176 | RUBYOPT="-r openssl" 177 | 178 | === Rejected sending 179 | 180 | If you are receiving this message and you HAVE verified the [source] please check to be sure you are not in sandbox mode! 181 | "Email address is not verified.MessageRejected (AWS::Error)" 182 | If you have not been granted production access, you will have to verify all recipients as well. 183 | 184 | To verify email addresses and domains: 185 | https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-addresses-and-domains.html 186 | 187 | To request production access: 188 | https://docs.aws.amazon.com/ses/latest/DeveloperGuide/request-production-access.html 189 | 190 | == Source 191 | 192 | Available at: https://github.com/drewblas/aws-ses 193 | 194 | == Contributing to aws-ses 195 | 196 | * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet 197 | * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it 198 | * Fork the project 199 | * Start a feature/bugfix branch 200 | * Commit and push until you are happy with your contribution 201 | * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. 202 | * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it. 203 | 204 | == Copyright 205 | 206 | Copyright (c) 2011 Drew Blas. See LICENSE for further details. 207 | 208 | == Thanks 209 | 210 | Special thanks to Marcel Molina Jr. for his creation of AWS::S3 which I used portions of to get things working. 211 | 212 | === Other Contributors: 213 | 214 | * croaky 215 | * nathanbertram 216 | * sshaw 217 | * teeparham (documentation) 218 | * pzb 219 | -------------------------------------------------------------------------------- /lib/aws/ses/base.rb: -------------------------------------------------------------------------------- 1 | module AWS #:nodoc: 2 | # AWS::SES is a Ruby library for Amazon's Simple Email Service's REST API (http://aws.amazon.com/ses). 3 | # 4 | # == Getting started 5 | # 6 | # To get started you need to require 'aws/ses': 7 | # 8 | # % irb -rubygems 9 | # irb(main):001:0> require 'aws/ses' 10 | # # => true 11 | # 12 | # Before you can do anything, you must establish a connection using Base.new. A basic connection would look something like this: 13 | # 14 | # ses = AWS::SES::Base.new( 15 | # :access_key_id => 'abc', 16 | # :secret_access_key => '123' 17 | # ) 18 | # 19 | # The minimum connection options that you must specify are your access key id and your secret access key. 20 | # 21 | # === Connecting to a server from another region 22 | # 23 | # The default server API endpoint is "email.us-east-1.amazonaws.com", corresponding to the US East 1 region. 24 | # To connect to a different one, just pass it as a parameter to the AWS::SES::Base initializer: 25 | # 26 | # ses = AWS::SES::Base.new( 27 | # :access_key_id => 'abc', 28 | # :secret_access_key => '123', 29 | # :server => 'email.eu-west-1.amazonaws.com', 30 | # :message_id_domain => 'eu-west-1.amazonses.com' 31 | # ) 32 | # 33 | 34 | module SES 35 | 36 | API_VERSION = '2010-12-01' 37 | 38 | DEFAULT_REGION = 'us-east-1' 39 | 40 | SERVICE = 'ec2' 41 | 42 | DEFAULT_HOST = 'email.us-east-1.amazonaws.com' 43 | 44 | DEFAULT_MESSAGE_ID_DOMAIN = 'email.amazonses.com' 45 | 46 | USER_AGENT = 'github-aws-ses-ruby-gem' 47 | 48 | # Encodes the given string with the secret_access_key by taking the 49 | # hmac-sha1 sum, and then base64 encoding it. Optionally, it will also 50 | # url encode the result of that to protect the string if it's going to 51 | # be used as a query string parameter. 52 | # 53 | # @param [String] secret_access_key the user's secret access key for signing. 54 | # @param [String] str the string to be hashed and encoded. 55 | # @param [Boolean] urlencode whether or not to url encode the result., true or false 56 | # @return [String] the signed and encoded string. 57 | def SES.encode(secret_access_key, str, urlencode=true) 58 | digest = OpenSSL::Digest.new('sha256') 59 | b64_hmac = 60 | Base64.encode64( 61 | OpenSSL::HMAC.digest(digest, secret_access_key, str)).gsub("\n","") 62 | 63 | if urlencode 64 | return CGI::escape(b64_hmac) 65 | else 66 | return b64_hmac 67 | end 68 | end 69 | 70 | # Generates the HTTP Header String that Amazon looks for 71 | # 72 | # @param [String] key the AWS Access Key ID 73 | # @param [String] alg the algorithm used for the signature 74 | # @param [String] sig the signature itself 75 | def SES.authorization_header(key, alg, sig) 76 | "AWS3-HTTPS AWSAccessKeyId=#{key}, Algorithm=#{alg}, Signature=#{sig}" 77 | end 78 | 79 | def SES.authorization_header_v4(credential, signed_headers, signature) 80 | "AWS4-HMAC-SHA256 Credential=#{credential}, SignedHeaders=#{signed_headers}, Signature=#{signature}" 81 | end 82 | 83 | # AWS::SES::Base is the abstract super class of all classes who make requests against SES 84 | class Base 85 | include SendEmail 86 | include Info 87 | 88 | attr_reader :use_ssl, :server, :proxy_server, :port, :message_id_domain, :signature_version, :region 89 | attr_accessor :settings 90 | 91 | # @option options [String] :access_key_id ("") The user's AWS Access Key ID 92 | # @option options [String] :secret_access_key ("") The user's AWS Secret Access Key 93 | # @option options [Boolean] :use_ssl (true) Connect using SSL? 94 | # @option options [String] :server ("email.us-east-1.amazonaws.com") The server API endpoint host 95 | # @option options [String] :proxy_server (nil) An HTTP proxy server FQDN 96 | # @option options [String] :user_agent ("github-aws-ses-ruby-gem") The HTTP User-Agent header value 97 | # @option options [String] :region ("us-east-1") The server API endpoint host 98 | # @option options [String] :message_id_domain ("us-east-1.amazonses.com") Domain used to build message_id header 99 | # @return [Object] the object. 100 | def initialize( options = {} ) 101 | 102 | options = { :access_key_id => "", 103 | :secret_access_key => "", 104 | :use_ssl => true, 105 | :server => DEFAULT_HOST, 106 | :message_id_domain => DEFAULT_MESSAGE_ID_DOMAIN, 107 | :path => "/", 108 | :user_agent => USER_AGENT, 109 | :proxy_server => nil, 110 | :region => DEFAULT_REGION 111 | }.merge(options) 112 | 113 | @signature_version = options[:signature_version] || 2 114 | @server = options[:server] 115 | @message_id_domain = options[:message_id_domain] 116 | @proxy_server = options[:proxy_server] 117 | @use_ssl = options[:use_ssl] 118 | @path = options[:path] 119 | @user_agent = options[:user_agent] 120 | @region = options[:region] 121 | @settings = {} 122 | 123 | raise ArgumentError, "No :access_key_id provided" if options[:access_key_id].nil? || options[:access_key_id].empty? 124 | raise ArgumentError, "No :secret_access_key provided" if options[:secret_access_key].nil? || options[:secret_access_key].empty? 125 | raise ArgumentError, "No :use_ssl value provided" if options[:use_ssl].nil? 126 | raise ArgumentError, "Invalid :use_ssl value provided, only 'true' or 'false' allowed" unless options[:use_ssl] == true || options[:use_ssl] == false 127 | raise ArgumentError, "No :server provided" if options[:server].nil? || options[:server].empty? 128 | 129 | if options[:port] 130 | # user-specified port 131 | @port = options[:port] 132 | elsif @use_ssl 133 | # https 134 | @port = 443 135 | else 136 | # http 137 | @port = 80 138 | end 139 | 140 | @access_key_id = options[:access_key_id] 141 | @secret_access_key = options[:secret_access_key] 142 | 143 | # Use proxy server if defined 144 | # Based on patch by Mathias Dalheimer. 20070217 145 | proxy = @proxy_server ? URI.parse(@proxy_server) : OpenStruct.new 146 | @http = Net::HTTP::Proxy( proxy.host, 147 | proxy.port, 148 | proxy.user, 149 | proxy.password).new(options[:server], @port) 150 | 151 | @http.use_ssl = @use_ssl 152 | end 153 | 154 | def connection 155 | @http 156 | end 157 | 158 | # Make the connection to AWS passing in our request. 159 | # allow us to have a one line call in each method which will do all of the work 160 | # in making the actual request to AWS. 161 | def request(action, params = {}) 162 | # Use a copy so that we don't modify the caller's Hash, remove any keys that have nil or empty values 163 | params = params.reject { |key, value| value.nil? or value.empty?} 164 | 165 | timestamp = Time.now.getutc 166 | 167 | params.merge!( {"Action" => action, 168 | "SignatureVersion" => signature_version.to_s, 169 | "SignatureMethod" => 'HmacSHA256', 170 | "AWSAccessKeyId" => @access_key_id, 171 | "Version" => API_VERSION, 172 | "Timestamp" => timestamp.iso8601 } ) 173 | 174 | query = params.sort.collect do |param| 175 | CGI::escape(param[0]) + "=" + CGI::escape(param[1]) 176 | end.join("&") 177 | 178 | req = {} 179 | 180 | req['X-Amzn-Authorization'] = get_aws_auth_param(timestamp.httpdate, @secret_access_key, action, signature_version) 181 | req['Date'] = timestamp.httpdate 182 | req['User-Agent'] = @user_agent 183 | 184 | response = connection.post(@path, query, req) 185 | 186 | response_class = AWS::SES.const_get( "#{action}Response" ) 187 | result = response_class.new(action, response) 188 | 189 | if result.error? 190 | raise ResponseError.new(result) 191 | end 192 | 193 | result 194 | end 195 | 196 | # Set the Authorization header using AWS signed header authentication 197 | def get_aws_auth_param(timestamp, secret_access_key, action = '', signature_version = 2) 198 | raise(ArgumentError, "signature_version must be `2` or `4`") unless signature_version == 2 || signature_version == 4 199 | encoded_canonical = SES.encode(secret_access_key, timestamp, false) 200 | 201 | if signature_version == 4 202 | SES.authorization_header_v4(sig_v4_auth_credential, sig_v4_auth_signed_headers, sig_v4_auth_signature(action)) 203 | else 204 | SES.authorization_header(@access_key_id, 'HmacSHA256', encoded_canonical) 205 | end 206 | end 207 | 208 | private 209 | 210 | def sig_v4_auth_credential 211 | @access_key_id + '/' + credential_scope 212 | end 213 | 214 | def sig_v4_auth_signed_headers 215 | 'host;x-amz-date' 216 | end 217 | 218 | def credential_scope 219 | datestamp + '/' + region + '/' + SERVICE + '/' + 'aws4_request' 220 | end 221 | 222 | def string_to_sign(for_action) 223 | "AWS4-HMAC-SHA256\n" + amzdate + "\n" + credential_scope + "\n" + Digest::SHA256.hexdigest(canonical_request(for_action).encode('utf-8').b) 224 | end 225 | 226 | 227 | def amzdate 228 | Time.now.utc.strftime('%Y%m%dT%H%M%SZ') 229 | end 230 | 231 | def datestamp 232 | Time.now.utc.strftime('%Y%m%d') 233 | end 234 | 235 | def canonical_request(for_action) 236 | "GET" + "\n" + "/" + "\n" + canonical_querystring(for_action) + "\n" + canonical_headers + "\n" + sig_v4_auth_signed_headers + "\n" + payload_hash 237 | end 238 | 239 | def canonical_querystring(action) 240 | "Action=#{action}&Version=2013-10-15" 241 | end 242 | 243 | def canonical_headers 244 | 'host:' + server + "\n" + 'x-amz-date:' + amzdate + "\n" 245 | end 246 | 247 | def payload_hash 248 | Digest::SHA256.hexdigest(''.encode('utf-8')) 249 | end 250 | 251 | def sig_v4_auth_signature(for_action) 252 | signing_key = getSignatureKey(@secret_access_key, datestamp, region, SERVICE) 253 | 254 | OpenSSL::HMAC.hexdigest("SHA256", signing_key, string_to_sign(for_action).encode('utf-8')) 255 | end 256 | 257 | def getSignatureKey(key, dateStamp, regionName, serviceName) 258 | kDate = sign(('AWS4' + key).encode('utf-8'), dateStamp) 259 | kRegion = sign(kDate, regionName) 260 | kService = sign(kRegion, serviceName) 261 | kSigning = sign(kService, 'aws4_request') 262 | 263 | kSigning 264 | end 265 | 266 | def sign(key, msg) 267 | OpenSSL::HMAC.digest("SHA256", key, msg.encode('utf-8')) 268 | end 269 | end # class Base 270 | end # Module SES 271 | end # Module AWS 272 | --------------------------------------------------------------------------------