Skip to content

Commit ae8beed

Browse files
pcraciunoiuFergus Dixon
and
Fergus Dixon
authored
add support for SESv2 under feature flag (django-ses#269)
* add support for SESv2 under feature flag * update poetry version * fix send quota * update docs * add docstrings * remove test comment Co-authored-by: Fergus Dixon <[email protected]>
1 parent 829ee32 commit ae8beed

File tree

7 files changed

+293
-171
lines changed

7 files changed

+293
-171
lines changed

CHANGES.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ For a list of releases, see: https://github.com/django-ses/django-ses/releases/
77
The following changes are not yet released, but are code complete:
88

99
Pulls and Issues:
10-
- None
10+
- https://github.com/django-ses/django-ses/pull/267
1111

1212
Features:
13-
- None
13+
- Experimental SESv2 Client under feature flag
1414

1515
Changes:
1616
- None

README.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ Add the following to your settings.py::
7979
AWS_SES_REGION_NAME = 'us-west-2'
8080
AWS_SES_REGION_ENDPOINT = 'email.us-west-2.amazonaws.com'
8181

82+
# If you want to use the SESv2 client
83+
USE_SES_V2 = True
84+
8285
Alternatively, instead of `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, you
8386
can include the following two settings values. This is useful in situations
8487
where you would like to use a separate access key to send emails via SES than

django_ses/__init__.py

Lines changed: 118 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def __init__(self, fail_silently=False, aws_access_key=None,
6262
aws_region_endpoint=None, aws_auto_throttle=None, aws_config=None,
6363
dkim_domain=None, dkim_key=None, dkim_selector=None, dkim_headers=None,
6464
ses_source_arn=None, ses_from_arn=None, ses_return_path_arn=None,
65+
use_ses_v2=False,
6566
**kwargs):
6667

6768
super(SESBackend, self).__init__(fail_silently=fail_silently, **kwargs)
@@ -82,6 +83,8 @@ def __init__(self, fail_silently=False, aws_access_key=None,
8283
self.ses_from_arn = ses_from_arn or settings.AWS_SES_FROM_ARN
8384
self.ses_return_path_arn = ses_return_path_arn or settings.AWS_SES_RETURN_PATH_ARN
8485

86+
self._use_ses_v2 = use_ses_v2 or settings.USE_SES_V2
87+
8588
self.connection = None
8689

8790
def open(self):
@@ -93,7 +96,7 @@ def open(self):
9396

9497
try:
9598
self.connection = boto3.client(
96-
'ses',
99+
'sesv2' if self._use_ses_v2 else 'ses',
97100
aws_access_key_id=self._access_key_id,
98101
aws_secret_access_key=self._access_key,
99102
aws_session_token=self._session_token,
@@ -154,66 +157,14 @@ def send_messages(self, email_messages):
154157
# well below the actual SES throttle.
155158
# Set the setting to 0 or None to disable throttling.
156159
if self._throttle:
157-
global recent_send_times
158-
159-
now = datetime.now()
160-
161-
# Get and cache the current SES max-per-second rate limit
162-
# returned by the SES API.
163-
rate_limit = self.get_rate_limit()
164-
logger.debug("send_messages.throttle rate_limit='{}'".format(rate_limit))
165-
166-
# Prune from recent_send_times anything more than a few seconds
167-
# ago. Even though SES reports a maximum per-second, the way
168-
# they enforce the limit may not be on a one-second window.
169-
# To be safe, we use a two-second window (but allow 2 times the
170-
# rate limit) and then also have a default rate limit factor of
171-
# 0.5 so that we really limit the one-second amount in two
172-
# seconds.
173-
window = 2.0 # seconds
174-
window_start = now - timedelta(seconds=window)
175-
new_send_times = []
176-
for time in recent_send_times:
177-
if time > window_start:
178-
new_send_times.append(time)
179-
recent_send_times = new_send_times
180-
181-
# If the number of recent send times in the last 1/_throttle
182-
# seconds exceeds the rate limit, add a delay.
183-
# Since I'm not sure how Amazon determines at exactly what
184-
# point to throttle, better be safe than sorry and let in, say,
185-
# half of the allowed rate.
186-
if len(new_send_times) > rate_limit * window * self._throttle:
187-
# Sleep the remainder of the window period.
188-
delta = now - new_send_times[0]
189-
total_seconds = (delta.microseconds + (delta.seconds +
190-
delta.days * 24 * 3600) * 10**6) / 10**6
191-
delay = window - total_seconds
192-
if delay > 0:
193-
sleep(delay)
194-
195-
recent_send_times.append(now)
196-
# end of throttling
197-
198-
kwargs = dict(
199-
Source=source or message.from_email,
200-
Destinations=message.recipients(),
201-
# todo attachments?
202-
RawMessage={'Data': dkim_sign(message.message().as_string(),
203-
dkim_key=self.dkim_key,
204-
dkim_domain=self.dkim_domain,
205-
dkim_selector=self.dkim_selector,
206-
dkim_headers=self.dkim_headers)}
207-
)
208-
if self.ses_source_arn:
209-
kwargs['SourceArn'] = self.ses_source_arn
210-
if self.ses_from_arn:
211-
kwargs['FromArn'] = self.ses_from_arn
212-
if self.ses_return_path_arn:
213-
kwargs['ReturnPathArn'] = self.ses_return_path_arn
160+
self._update_throttling()
161+
162+
kwargs = self._get_send_email_parameters(message, source)
214163

215164
try:
216-
response = self.connection.send_raw_email(**kwargs)
165+
response = (self.connection.send_email(**kwargs)
166+
if self._use_ses_v2
167+
else self.connection.send_raw_email(**kwargs))
217168
message.extra_headers['status'] = 200
218169
message.extra_headers['message_id'] = response['MessageId']
219170
message.extra_headers['request_id'] = response['ResponseMetadata']['RequestId']
@@ -250,6 +201,94 @@ def send_messages(self, email_messages):
250201

251202
return num_sent
252203

204+
def _update_throttling(self):
205+
global recent_send_times
206+
now = datetime.now()
207+
# Get and cache the current SES max-per-second rate limit
208+
# returned by the SES API.
209+
rate_limit = self.get_rate_limit()
210+
logger.debug("send_messages.throttle rate_limit='{}'".format(rate_limit))
211+
# Prune from recent_send_times anything more than a few seconds
212+
# ago. Even though SES reports a maximum per-second, the way
213+
# they enforce the limit may not be on a one-second window.
214+
# To be safe, we use a two-second window (but allow 2 times the
215+
# rate limit) and then also have a default rate limit factor of
216+
# 0.5 so that we really limit the one-second amount in two
217+
# seconds.
218+
window = 2.0 # seconds
219+
window_start = now - timedelta(seconds=window)
220+
new_send_times = []
221+
for time in recent_send_times:
222+
if time > window_start:
223+
new_send_times.append(time)
224+
recent_send_times = new_send_times
225+
# If the number of recent send times in the last 1/_throttle
226+
# seconds exceeds the rate limit, add a delay.
227+
# Since I'm not sure how Amazon determines at exactly what
228+
# point to throttle, better be safe than sorry and let in, say,
229+
# half of the allowed rate.
230+
if len(new_send_times) > rate_limit * window * self._throttle:
231+
# Sleep the remainder of the window period.
232+
delta = now - new_send_times[0]
233+
total_seconds = (delta.microseconds + (delta.seconds +
234+
delta.days * 24 * 3600) * 10 ** 6) / 10 ** 6
235+
delay = window - total_seconds
236+
if delay > 0:
237+
sleep(delay)
238+
recent_send_times.append(now)
239+
# end of throttling
240+
241+
def _get_send_email_parameters(self, message, source):
242+
return (self._get_v2_parameters(message, source)
243+
if self._use_ses_v2
244+
else self._get_v1_parameters(message, source))
245+
246+
def _get_v2_parameters(self, message, source):
247+
"""V2-Style raw payload for `send_email`.
248+
249+
https://boto3.amazonaws.com/v1/documentation/api/1.26.31/reference/services/sesv2.html#SESV2.Client.send_email
250+
"""
251+
params = dict(
252+
FromEmailAddress=source or message.from_email,
253+
Destination={
254+
'ToAddresses': message.recipients()
255+
},
256+
Content={
257+
'Raw': {
258+
'Data': dkim_sign(message.message().as_string(),
259+
dkim_key=self.dkim_key,
260+
dkim_domain=self.dkim_domain,
261+
dkim_selector=self.dkim_selector,
262+
dkim_headers=self.dkim_headers)
263+
}
264+
}
265+
)
266+
if self.ses_from_arn or self.ses_source_arn:
267+
params['FromEmailAddressIdentityArn'] = self.ses_from_arn or self.ses_source_arn
268+
return params
269+
270+
def _get_v1_parameters(self, message, source):
271+
"""V1-Style raw payload for `send_raw_email`
272+
273+
https://boto3.amazonaws.com/v1/documentation/api/1.26.31/reference/services/ses.html#SES.Client.send_raw_email
274+
"""
275+
params = dict(
276+
Source=source or message.from_email,
277+
Destinations=message.recipients(),
278+
RawMessage={'Data': dkim_sign(message.message().as_string(),
279+
dkim_key=self.dkim_key,
280+
dkim_domain=self.dkim_domain,
281+
dkim_selector=self.dkim_selector,
282+
dkim_headers=self.dkim_headers)}
283+
)
284+
if self.ses_source_arn:
285+
params['SourceArn'] = self.ses_source_arn
286+
if self.ses_from_arn:
287+
params['FromArn'] = self.ses_from_arn
288+
if self.ses_return_path_arn:
289+
params['ReturnPathArn'] = self.ses_return_path_arn
290+
return params
291+
253292
def get_rate_limit(self):
254293
if self._access_key_id in cached_rate_limits:
255294
return cached_rate_limits[self._access_key_id]
@@ -259,11 +298,26 @@ def get_rate_limit(self):
259298
raise Exception(
260299
"No connection is available to check current SES rate limit.")
261300
try:
262-
quota_dict = self.connection.get_send_quota()
263-
max_per_second = quota_dict['MaxSendRate']
264-
ret = float(max_per_second)
265-
cached_rate_limits[self._access_key_id] = ret
266-
return ret
301+
return self._get_v2_send_quota() if self._use_ses_v2 else self._get_v1_send_quota()
267302
finally:
268303
if new_conn_created:
269304
self.close()
305+
306+
def _get_v2_send_quota(self):
307+
"""This needs to come from the `get_account` endpoint as a nested response in V2.
308+
309+
https://boto3.amazonaws.com/v1/documentation/api/1.26.31/reference/services/sesv2.html#SESV2.Client.get_account
310+
"""
311+
account_dict = self.connection.get_account()
312+
quota_dict = account_dict['SendQuota']
313+
max_per_second = quota_dict['MaxSendRate']
314+
ret = float(max_per_second)
315+
cached_rate_limits[self._access_key_id] = ret
316+
return ret
317+
318+
def _get_v1_send_quota(self):
319+
quota_dict = self.connection.get_send_quota()
320+
max_per_second = quota_dict['MaxSendRate']
321+
ret = float(max_per_second)
322+
cached_rate_limits[self._access_key_id] = ret
323+
return ret

django_ses/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
AWS_SES_FROM_ARN = getattr(settings, 'AWS_SES_FROM_ARN', None)
3838
AWS_SES_RETURN_PATH_ARN = getattr(settings, 'AWS_SES_RETURN_PATH_ARN', None)
3939

40+
USE_SES_V2 = getattr(settings, 'USE_SES_V2', False)
41+
4042
TIME_ZONE = settings.TIME_ZONE
4143

4244
VERIFY_EVENT_SIGNATURES = getattr(settings, 'AWS_SES_VERIFY_EVENT_SIGNATURES',

0 commit comments

Comments
 (0)