aws-ses 0.4.3 → 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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