Skip to content

Commit 0a23f35

Browse files
committed
Rate-limit emails sent by the same user in a period of time
1 parent 13b1136 commit 0a23f35

File tree

2 files changed

+97
-10
lines changed

2 files changed

+97
-10
lines changed

Diff for: h/services/email.py

+23
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import smtplib
44
from dataclasses import dataclass
5+
from datetime import UTC, datetime, timedelta
56

67
import pyramid_mailer
78
import pyramid_mailer.message
@@ -15,6 +16,8 @@
1516

1617
logger = get_task_logger(__name__)
1718

19+
DAILY_SENDER_MENTION_LIMIT = 100
20+
1821

1922
@dataclass(frozen=True)
2023
class EmailData:
@@ -51,6 +54,9 @@ def __init__(
5154
self._task_done_service = task_done_service
5255

5356
def send(self, email_data: EmailData, task_data: TaskData) -> None:
57+
if not self._allow_sending(task_data):
58+
return
59+
5460
if self._debug: # pragma: no cover
5561
logger.info("emailing in debug mode: check the `mail/` directory")
5662
try:
@@ -74,6 +80,23 @@ def send(self, email_data: EmailData, task_data: TaskData) -> None:
7480
)
7581
self._task_done_service.create(task_data)
7682

83+
def _allow_sending(self, task_data: TaskData) -> bool:
84+
if (
85+
task_data.tag == EmailTag.MENTION_NOTIFICATION
86+
and self._sender_limit_exceeded(task_data)
87+
):
88+
logger.warning(
89+
"Email not sent for sender_id=%s: limit exceeded",
90+
task_data.sender_id,
91+
)
92+
return False
93+
return True
94+
95+
def _sender_limit_exceeded(self, task_data: TaskData) -> bool:
96+
after = datetime.now(UTC) - timedelta(days=1)
97+
count = self._task_done_service.sender_mention_count(task_data.sender_id, after)
98+
return count > DAILY_SENDER_MENTION_LIMIT
99+
77100

78101
def factory(_context, request: Request) -> EmailService:
79102
mailer = pyramid_mailer.get_mailer(request)

Diff for: tests/unit/h/services/email_test.py

+74-10
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import smtplib
2+
from unittest import mock
23
from unittest.mock import sentinel
34

45
import pytest
56

6-
from h.services.email import EmailData, EmailService, EmailTag, TaskData, factory
7+
from h.services.email import (
8+
DAILY_SENDER_MENTION_LIMIT,
9+
EmailData,
10+
EmailService,
11+
EmailTag,
12+
TaskData,
13+
factory,
14+
)
715

816

917
class TestEmailService:
@@ -21,16 +29,17 @@ def test_send_creates_email_message(
2129
)
2230

2331
def test_send_creates_email_message_with_html_body(
24-
self, task_data, email_service, pyramid_mailer
32+
self, email_service, task_data, pyramid_mailer
2533
):
26-
email = EmailData(
34+
email_data = EmailData(
2735
recipients=["[email protected]"],
2836
subject="My email subject",
2937
body="Some text body",
3038
tag=EmailTag.TEST,
3139
html="<p>An HTML body</p>",
3240
)
33-
email_service.send(email, task_data)
41+
42+
email_service.send(email_data, task_data)
3443

3544
pyramid_mailer.message.Message.assert_called_once_with(
3645
recipients=["[email protected]"],
@@ -40,6 +49,60 @@ def test_send_creates_email_message_with_html_body(
4049
extra_headers={"X-MC-Tags": EmailTag.TEST},
4150
)
4251

52+
def test_send_creates_email_with_mention(
53+
self, email_data, task_data, email_service, pyramid_mailer, task_done_service
54+
):
55+
email_data = EmailData(
56+
recipients=["[email protected]"],
57+
subject="My email subject",
58+
body="Some text body",
59+
tag=EmailTag.MENTION_NOTIFICATION,
60+
)
61+
task_data = TaskData(
62+
tag=email_data.tag,
63+
sender_id=123,
64+
recipient_ids=[124],
65+
)
66+
task_done_service.sender_mention_count.return_value = DAILY_SENDER_MENTION_LIMIT
67+
68+
email_service.send(email_data, task_data)
69+
70+
task_done_service.sender_mention_count.assert_called_once_with(
71+
task_data.sender_id, mock.ANY
72+
)
73+
pyramid_mailer.message.Message.assert_called_once_with(
74+
recipients=["[email protected]"],
75+
subject="My email subject",
76+
body="Some text body",
77+
html=None,
78+
extra_headers={"X-MC-Tags": EmailTag.MENTION_NOTIFICATION},
79+
)
80+
81+
def test_send_does_not_create_email_with_mention(
82+
self, email_data, task_data, email_service, pyramid_mailer, task_done_service
83+
):
84+
email_data = EmailData(
85+
recipients=["[email protected]"],
86+
subject="My email subject",
87+
body="Some text body",
88+
tag=EmailTag.MENTION_NOTIFICATION,
89+
)
90+
task_data = TaskData(
91+
tag=email_data.tag,
92+
sender_id=123,
93+
recipient_ids=[124],
94+
)
95+
task_done_service.sender_mention_count.return_value = (
96+
DAILY_SENDER_MENTION_LIMIT + 1
97+
)
98+
99+
email_service.send(email_data, task_data)
100+
101+
task_done_service.sender_mention_count.assert_called_once_with(
102+
task_data.sender_id, mock.ANY
103+
)
104+
pyramid_mailer.message.Message.assert_not_called()
105+
43106
def test_send_dispatches_email_using_request_mailer(
44107
self, email_data, task_data, email_service, pyramid_mailer
45108
):
@@ -67,18 +130,19 @@ def test_send_logging(self, email_data, task_data, email_service, info_caplog):
67130
]
68131

69132
def test_send_logging_with_extra(self, email_data, email_service, info_caplog):
70-
user_id = 123
133+
sender_id = 123
134+
recipient_id = 124
71135
annotation_id = "annotation_id"
72136
task_data = TaskData(
73137
tag=email_data.tag,
74-
sender_id=user_id,
75-
recipient_ids=[user_id],
138+
sender_id=sender_id,
139+
recipient_ids=[recipient_id],
76140
extra={"annotation_id": annotation_id},
77141
)
78142
email_service.send(email_data, task_data)
79143

80144
assert info_caplog.messages == [
81-
f"Sent email: tag={task_data.tag!r}, sender_id={user_id}, recipient_ids={[user_id]}, annotation_id={annotation_id!r}"
145+
f"Sent email: tag={task_data.tag!r}, sender_id={sender_id}, recipient_ids={[recipient_id]}, annotation_id={annotation_id!r}"
82146
]
83147

84148
def test_send_creates_task_done(
@@ -87,7 +151,7 @@ def test_send_creates_task_done(
87151
task_data = TaskData(
88152
tag=email_data.tag,
89153
sender_id=123,
90-
recipient_ids=[123],
154+
recipient_ids=[124],
91155
extra={"annotation_id": "annotation_id"},
92156
)
93157
email_service.send(email_data, task_data)
@@ -108,7 +172,7 @@ def task_data(self):
108172
return TaskData(
109173
tag=EmailTag.TEST,
110174
sender_id=123,
111-
recipient_ids=[123],
175+
recipient_ids=[124],
112176
)
113177

114178
@pytest.fixture

0 commit comments

Comments
 (0)