Skip to content

Commit 6e8a8ce

Browse files
committed
Rate-limit emails sent by the same user in a period of time
1 parent a6a9308 commit 6e8a8ce

File tree

2 files changed

+123
-10
lines changed

2 files changed

+123
-10
lines changed

h/services/email.py

+26
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,9 @@
1516

1617
logger = get_task_logger(__name__)
1718

19+
# Limit for the number of mention emails sent by a single user in a day to prevent abuse
20+
DAILY_SENDER_MENTION_LIMIT = 100
21+
1822

1923
@dataclass(frozen=True)
2024
class EmailData:
@@ -55,6 +59,9 @@ def __init__(
5559
self._task_done_service = task_done_service
5660

5761
def send(self, email_data: EmailData, task_data: TaskData) -> None:
62+
if not self._allow_sending(task_data):
63+
return
64+
5865
if self._debug: # pragma: no cover
5966
logger.info("emailing in debug mode: check the `mail/` directory")
6067
try:
@@ -78,6 +85,25 @@ def send(self, email_data: EmailData, task_data: TaskData) -> None:
7885
)
7986
self._task_done_service.create(task_data)
8087

88+
def _allow_sending(self, task_data: TaskData) -> bool:
89+
if (
90+
task_data.tag == EmailTag.MENTION_NOTIFICATION
91+
and self._sender_limit_reached(task_data)
92+
):
93+
logger.warning(
94+
"Not sending email: tag=%r sender_id=%s recipient_ids=%s. Sender limit reached.",
95+
task_data.tag,
96+
task_data.sender_id,
97+
task_data.recipient_ids,
98+
)
99+
return False
100+
return True
101+
102+
def _sender_limit_reached(self, task_data: TaskData) -> bool:
103+
after = datetime.now(UTC) - timedelta(days=1)
104+
count = self._task_done_service.sender_mention_count(task_data.sender_id, after)
105+
return count >= DAILY_SENDER_MENTION_LIMIT
106+
81107

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

tests/unit/h/services/email_test.py

+97-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]"],
@@ -60,6 +69,48 @@ def test_send_creates_email_message_with_subaccount(
6069
extra_headers={"X-MC-Tags": EmailTag.TEST, "X-MC-Subaccount": "subaccount"},
6170
)
6271

72+
def test_send_creates_mention_email_when_sender_limit_not_reached(
73+
self,
74+
mention_email_data,
75+
mention_task_data,
76+
email_service,
77+
pyramid_mailer,
78+
task_done_service,
79+
):
80+
task_done_service.sender_mention_count.return_value = (
81+
DAILY_SENDER_MENTION_LIMIT - 1
82+
)
83+
84+
email_service.send(mention_email_data, mention_task_data)
85+
86+
task_done_service.sender_mention_count.assert_called_once_with(
87+
mention_task_data.sender_id, mock.ANY
88+
)
89+
pyramid_mailer.message.Message.assert_called_once_with(
90+
recipients=["[email protected]"],
91+
subject="My email subject",
92+
body="Some text body",
93+
html=None,
94+
extra_headers={"X-MC-Tags": EmailTag.MENTION_NOTIFICATION},
95+
)
96+
97+
def test_send_does_not_create_mention_email_when_sender_limit_reached(
98+
self,
99+
mention_email_data,
100+
mention_task_data,
101+
email_service,
102+
pyramid_mailer,
103+
task_done_service,
104+
):
105+
task_done_service.sender_mention_count.return_value = DAILY_SENDER_MENTION_LIMIT
106+
107+
email_service.send(mention_email_data, mention_task_data)
108+
109+
task_done_service.sender_mention_count.assert_called_once_with(
110+
mention_task_data.sender_id, mock.ANY
111+
)
112+
pyramid_mailer.message.Message.assert_not_called()
113+
63114
def test_send_dispatches_email_using_request_mailer(
64115
self, email_data, task_data, email_service, pyramid_mailer
65116
):
@@ -87,18 +138,36 @@ def test_send_logging(self, email_data, task_data, email_service, info_caplog):
87138
]
88139

89140
def test_send_logging_with_extra(self, email_data, email_service, info_caplog):
90-
user_id = 123
141+
sender_id = 123
142+
recipient_id = 124
91143
annotation_id = "annotation_id"
92144
task_data = TaskData(
93145
tag=email_data.tag,
94-
sender_id=user_id,
95-
recipient_ids=[user_id],
146+
sender_id=sender_id,
147+
recipient_ids=[recipient_id],
96148
extra={"annotation_id": annotation_id},
97149
)
150+
98151
email_service.send(email_data, task_data)
99152

100153
assert info_caplog.messages == [
101-
f"Sent email: tag={task_data.tag!r}, sender_id={user_id}, recipient_ids={[user_id]}, annotation_id={annotation_id!r}"
154+
f"Sent email: tag={task_data.tag!r}, sender_id={sender_id}, recipient_ids={[recipient_id]}, annotation_id={annotation_id!r}"
155+
]
156+
157+
def test_sender_limit_reached_logging(
158+
self,
159+
mention_email_data,
160+
mention_task_data,
161+
email_service,
162+
task_done_service,
163+
info_caplog,
164+
):
165+
task_done_service.sender_mention_count.return_value = DAILY_SENDER_MENTION_LIMIT
166+
167+
email_service.send(mention_email_data, mention_task_data)
168+
169+
assert info_caplog.messages == [
170+
f"Not sending email: tag={mention_task_data.tag!r} sender_id={mention_task_data.sender_id} recipient_ids={mention_task_data.recipient_ids}. Sender limit reached."
102171
]
103172

104173
def test_send_creates_task_done(
@@ -107,9 +176,10 @@ def test_send_creates_task_done(
107176
task_data = TaskData(
108177
tag=email_data.tag,
109178
sender_id=123,
110-
recipient_ids=[123],
179+
recipient_ids=[124],
111180
extra={"annotation_id": "annotation_id"},
112181
)
182+
113183
email_service.send(email_data, task_data)
114184

115185
task_done_service.create.assert_called_once_with(task_data)
@@ -128,7 +198,24 @@ def task_data(self):
128198
return TaskData(
129199
tag=EmailTag.TEST,
130200
sender_id=123,
131-
recipient_ids=[123],
201+
recipient_ids=[124],
202+
)
203+
204+
@pytest.fixture
205+
def mention_email_data(self):
206+
return EmailData(
207+
recipients=["[email protected]"],
208+
subject="My email subject",
209+
body="Some text body",
210+
tag=EmailTag.MENTION_NOTIFICATION,
211+
)
212+
213+
@pytest.fixture
214+
def mention_task_data(self):
215+
return TaskData(
216+
tag=EmailTag.MENTION_NOTIFICATION,
217+
sender_id=123,
218+
recipient_ids=[124],
132219
)
133220

134221
@pytest.fixture

0 commit comments

Comments
 (0)