aws-ses 0.4.3 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,6 @@ require 'ses/version'
23
23
  require 'ses/addresses'
24
24
 
25
25
  if defined?(Rails)
26
- major, minor = Rails.version.split('.')
26
+ major, minor = Rails.version.to_s.split('.')
27
27
  require 'actionmailer/ses_extension' if major == '2' && minor == '3'
28
- end
28
+ end
@@ -17,11 +17,31 @@ module AWS #:nodoc:
17
17
  # )
18
18
  #
19
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
+
20
34
  module SES
21
35
 
22
36
  API_VERSION = '2010-12-01'
23
-
37
+
38
+ DEFAULT_REGION = 'us-east-1'
39
+
40
+ SERVICE = 'ec2'
41
+
24
42
  DEFAULT_HOST = 'email.us-east-1.amazonaws.com'
43
+
44
+ DEFAULT_MESSAGE_ID_DOMAIN = 'email.amazonses.com'
25
45
 
26
46
  USER_AGENT = 'github-aws-ses-ruby-gem'
27
47
 
@@ -35,7 +55,7 @@ module AWS #:nodoc:
35
55
  # @param [Boolean] urlencode whether or not to url encode the result., true or false
36
56
  # @return [String] the signed and encoded string.
37
57
  def SES.encode(secret_access_key, str, urlencode=true)
38
- digest = OpenSSL::Digest::Digest.new('sha256')
58
+ digest = OpenSSL::Digest.new('sha256')
39
59
  b64_hmac =
40
60
  Base64.encode64(
41
61
  OpenSSL::HMAC.digest(digest, secret_access_key, str)).gsub("\n","")
@@ -55,13 +75,18 @@ module AWS #:nodoc:
55
75
  def SES.authorization_header(key, alg, sig)
56
76
  "AWS3-HTTPS AWSAccessKeyId=#{key}, Algorithm=#{alg}, Signature=#{sig}"
57
77
  end
58
-
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
+
59
83
  # AWS::SES::Base is the abstract super class of all classes who make requests against SES
60
84
  class Base
61
85
  include SendEmail
62
86
  include Info
63
87
 
64
- attr_reader :use_ssl, :server, :proxy_server, :port
88
+ attr_reader :use_ssl, :server, :proxy_server, :port, :message_id_domain, :signature_version, :region
89
+ attr_accessor :settings
65
90
 
66
91
  # @option options [String] :access_key_id ("") The user's AWS Access Key ID
67
92
  # @option options [String] :secret_access_key ("") The user's AWS Secret Access Key
@@ -69,6 +94,8 @@ module AWS #:nodoc:
69
94
  # @option options [String] :server ("email.us-east-1.amazonaws.com") The server API endpoint host
70
95
  # @option options [String] :proxy_server (nil) An HTTP proxy server FQDN
71
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
72
99
  # @return [Object] the object.
73
100
  def initialize( options = {} )
74
101
 
@@ -76,16 +103,22 @@ module AWS #:nodoc:
76
103
  :secret_access_key => "",
77
104
  :use_ssl => true,
78
105
  :server => DEFAULT_HOST,
106
+ :message_id_domain => DEFAULT_MESSAGE_ID_DOMAIN,
79
107
  :path => "/",
80
108
  :user_agent => USER_AGENT,
81
- :proxy_server => nil
109
+ :proxy_server => nil,
110
+ :region => DEFAULT_REGION
82
111
  }.merge(options)
83
112
 
113
+ @signature_version = options[:signature_version] || 2
84
114
  @server = options[:server]
115
+ @message_id_domain = options[:message_id_domain]
85
116
  @proxy_server = options[:proxy_server]
86
117
  @use_ssl = options[:use_ssl]
87
118
  @path = options[:path]
88
119
  @user_agent = options[:user_agent]
120
+ @region = options[:region]
121
+ @settings = {}
89
122
 
90
123
  raise ArgumentError, "No :access_key_id provided" if options[:access_key_id].nil? || options[:access_key_id].empty?
91
124
  raise ArgumentError, "No :secret_access_key provided" if options[:secret_access_key].nil? || options[:secret_access_key].empty?
@@ -116,14 +149,8 @@ module AWS #:nodoc:
116
149
  proxy.password).new(options[:server], @port)
117
150
 
118
151
  @http.use_ssl = @use_ssl
119
-
120
- # Don't verify the SSL certificates. Avoids SSL Cert warning in log on every GET.
121
- @http.verify_mode = OpenSSL::SSL::VERIFY_NONE
122
-
123
152
  end
124
153
 
125
- attr_accessor :settings
126
-
127
154
  def connection
128
155
  @http
129
156
  end
@@ -138,7 +165,7 @@ module AWS #:nodoc:
138
165
  timestamp = Time.now.getutc
139
166
 
140
167
  params.merge!( {"Action" => action,
141
- "SignatureVersion" => "2",
168
+ "SignatureVersion" => signature_version.to_s,
142
169
  "SignatureMethod" => 'HmacSHA256',
143
170
  "AWSAccessKeyId" => @access_key_id,
144
171
  "Version" => API_VERSION,
@@ -150,9 +177,9 @@ module AWS #:nodoc:
150
177
 
151
178
  req = {}
152
179
 
153
- req['X-Amzn-Authorization'] = get_aws_auth_param(timestamp.httpdate, @secret_access_key)
180
+ req['X-Amzn-Authorization'] = get_aws_auth_param(timestamp.httpdate, @secret_access_key, action, signature_version)
154
181
  req['Date'] = timestamp.httpdate
155
- req['User-Agent'] = @user_agent
182
+ req['User-Agent'] = @user_agent
156
183
 
157
184
  response = connection.post(@path, query, req)
158
185
 
@@ -167,9 +194,77 @@ module AWS #:nodoc:
167
194
  end
168
195
 
169
196
  # Set the Authorization header using AWS signed header authentication
170
- def get_aws_auth_param(timestamp, secret_access_key)
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
171
199
  encoded_canonical = SES.encode(secret_access_key, timestamp, false)
172
- SES.authorization_header(@access_key_id, 'HmacSHA256', encoded_canonical)
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'))
173
268
  end
174
269
  end # class Base
175
270
  end # Module SES
@@ -9,24 +9,24 @@ module AWS
9
9
  # :subject => 'Subject Line'
10
10
  # :text_body => 'Internal text body'
11
11
  #
12
- # By default, the email "from" display address is whatever is before the @.
13
- # To change the display from, use the format:
12
+ # By default, the email "from" display address is whatever is before the @.
13
+ # To change the display from, use the format:
14
14
  #
15
15
  # "Steve Smith" <steve@example.com>
16
16
  #
17
17
  # You can also send Mail objects using send_raw_email:
18
- #
18
+ #
19
19
  # m = Mail.new( :to => ..., :from => ... )
20
20
  # ses.send_raw_email(m)
21
21
  #
22
22
  # send_raw_email will also take a hash and pass it through Mail.new automatically as well.
23
23
  #
24
24
  module SendEmail
25
-
25
+
26
26
  # Sends an email through SES
27
- #
27
+ #
28
28
  # the destination parameters can be:
29
- #
29
+ #
30
30
  # [A single e-mail string] "jon@example.com"
31
31
  # [A array of e-mail addresses] ['jon@example.com', 'dave@example.com']
32
32
  #
@@ -51,31 +51,31 @@ module AWS
51
51
  # @return [Response] the response to sending this e-mail
52
52
  def send_email(options = {})
53
53
  package = {}
54
-
54
+
55
55
  package['Source'] = options[:source] || options[:from]
56
-
56
+
57
57
  add_array_to_hash!(package, 'Destination.ToAddresses', options[:to]) if options[:to]
58
58
  add_array_to_hash!(package, 'Destination.CcAddresses', options[:cc]) if options[:cc]
59
59
  add_array_to_hash!(package, 'Destination.BccAddresses', options[:bcc]) if options[:bcc]
60
-
60
+
61
61
  package['Message.Subject.Data'] = options[:subject]
62
-
62
+
63
63
  package['Message.Body.Html.Data'] = options[:html_body] if options[:html_body]
64
64
  package['Message.Body.Text.Data'] = options[:text_body] || options[:body] if options[:text_body] || options[:body]
65
-
65
+
66
66
  package['ReturnPath'] = options[:return_path] if options[:return_path]
67
-
67
+
68
68
  add_array_to_hash!(package, 'ReplyToAddresses', options[:reply_to]) if options[:reply_to]
69
-
69
+
70
70
  request('SendEmail', package)
71
71
  end
72
-
72
+
73
73
  # Sends using the SendRawEmail method
74
74
  # This gives the most control and flexibility
75
75
  #
76
76
  # This uses the underlying Mail object from the mail gem
77
77
  # You can pass in a Mail object, a Hash of params that will be parsed by Mail.new, or just a string
78
- #
78
+ #
79
79
  # Note that the params are different from send_email
80
80
  # Specifically, the following fields from send_email will NOT work:
81
81
  #
@@ -96,23 +96,38 @@ module AWS
96
96
  # @option args [String] :to alias for :destinations
97
97
  # @return [Response]
98
98
  def send_raw_email(mail, args = {})
99
- message = mail.is_a?(Hash) ? Mail.new(mail).to_s : mail.to_s
100
- package = { 'RawMessage.Data' => Base64::encode64(message) }
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) }
101
116
  package['Source'] = args[:from] if args[:from]
102
117
  package['Source'] = args[:source] if args[:source]
103
118
  if args[:destinations]
104
119
  add_array_to_hash!(package, 'Destinations', args[:destinations])
105
120
  else
106
- add_array_to_hash!(package, 'Destinations', args[:to]) if args[:to]
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)
107
127
  end
108
- request('SendRawEmail', package)
128
+ package
109
129
  end
110
130
 
111
- alias :deliver! :send_raw_email
112
- alias :deliver :send_raw_email
113
-
114
- private
115
-
116
131
  # Adds all elements of the ary with the appropriate member elements
117
132
  def add_array_to_hash!(hash, key, ary)
118
133
  cnt = 1
@@ -122,23 +137,23 @@ module AWS
122
137
  end
123
138
  end
124
139
  end
125
-
140
+
126
141
  class EmailResponse < AWS::SES::Response
127
142
  def result
128
143
  super["#{action}Result"]
129
144
  end
130
-
145
+
131
146
  def message_id
132
147
  result['MessageId']
133
148
  end
134
149
  end
135
-
150
+
136
151
  class SendEmailResponse < EmailResponse
137
-
152
+
138
153
  end
139
-
154
+
140
155
  class SendRawEmailResponse < EmailResponse
141
-
156
+
142
157
  end
143
158
  end
144
159
  end
@@ -3,7 +3,7 @@ module AWS
3
3
  module VERSION #:nodoc:
4
4
  MAJOR = '0'
5
5
  MINOR = '4'
6
- TINY = '2'
6
+ TINY = '4'
7
7
  BETA = Time.now.to_i.to_s
8
8
  end
9
9
 
@@ -37,4 +37,69 @@ class BaseTest < Test::Unit::TestCase
37
37
  # assert result.error.error?
38
38
  # assert_equal 'ValidationError', result.error.code
39
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
40
105
  end
@@ -24,6 +24,7 @@ require File.dirname(__FILE__) + '/fixtures'
24
24
  $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
25
25
  $LOAD_PATH.unshift(File.dirname(__FILE__))
26
26
  require 'aws/ses'
27
+ require 'timecop'
27
28
 
28
29
  class Test::Unit::TestCase
29
30
  require 'net/http'
@@ -54,3 +55,9 @@ class Test::Unit::TestCase
54
55
  Base.new(:access_key_id=>'123', :secret_access_key=>'abc')
55
56
  end
56
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