Skip to content

Commit 9714f73

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

File tree

2 files changed

+98
-10
lines changed

2 files changed

+98
-10
lines changed

Diff for: h/services/email.py

+24
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 = 5
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,23 @@ 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+
"Email not sent for sender_id=%s: limit reached",
95+
task_data.sender_id,
96+
)
97+
return False
98+
return True
99+
100+
def _sender_limit_reached(self, task_data: TaskData) -> bool:
101+
after = datetime.now(UTC) - timedelta(days=1)
102+
count = self._task_done_service.sender_mention_count(task_data.sender_id, after)
103+
return count >= DAILY_SENDER_MENTION_LIMIT
104+
81105

82106
def factory(_context, request: Request) -> EmailService:
83107
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]"],
@@ -60,6 +69,60 @@ 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_email_with_mention(
73+
self, email_data, task_data, email_service, pyramid_mailer, task_done_service
74+
):
75+
email_data = EmailData(
76+
recipients=["[email protected]"],
77+
subject="My email subject",
78+
body="Some text body",
79+
tag=EmailTag.MENTION_NOTIFICATION,
80+
)
81+
task_data = TaskData(
82+
tag=email_data.tag,
83+
sender_id=123,
84+
recipient_ids=[124],
85+
)
86+
task_done_service.sender_mention_count.return_value = DAILY_SENDER_MENTION_LIMIT
87+
88+
email_service.send(email_data, task_data)
89+
90+
task_done_service.sender_mention_count.assert_called_once_with(
91+
task_data.sender_id, mock.ANY
92+
)
93+
pyramid_mailer.message.Message.assert_called_once_with(
94+
recipients=["[email protected]"],
95+
subject="My email subject",
96+
body="Some text body",
97+
html=None,
98+
extra_headers={"X-MC-Tags": EmailTag.MENTION_NOTIFICATION},
99+
)
100+
101+
def test_send_does_not_create_email_with_mention(
102+
self, email_data, task_data, email_service, pyramid_mailer, task_done_service
103+
):
104+
email_data = EmailData(
105+
recipients=["[email protected]"],
106+
subject="My email subject",
107+
body="Some text body",
108+
tag=EmailTag.MENTION_NOTIFICATION,
109+
)
110+
task_data = TaskData(
111+
tag=email_data.tag,
112+
sender_id=123,
113+
recipient_ids=[124],
114+
)
115+
task_done_service.sender_mention_count.return_value = (
116+
DAILY_SENDER_MENTION_LIMIT + 1
117+
)
118+
119+
email_service.send(email_data, task_data)
120+
121+
task_done_service.sender_mention_count.assert_called_once_with(
122+
task_data.sender_id, mock.ANY
123+
)
124+
pyramid_mailer.message.Message.assert_not_called()
125+
63126
def test_send_dispatches_email_using_request_mailer(
64127
self, email_data, task_data, email_service, pyramid_mailer
65128
):
@@ -87,18 +150,19 @@ def test_send_logging(self, email_data, task_data, email_service, info_caplog):
87150
]
88151

89152
def test_send_logging_with_extra(self, email_data, email_service, info_caplog):
90-
user_id = 123
153+
sender_id = 123
154+
recipient_id = 124
91155
annotation_id = "annotation_id"
92156
task_data = TaskData(
93157
tag=email_data.tag,
94-
sender_id=user_id,
95-
recipient_ids=[user_id],
158+
sender_id=sender_id,
159+
recipient_ids=[recipient_id],
96160
extra={"annotation_id": annotation_id},
97161
)
98162
email_service.send(email_data, task_data)
99163

100164
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}"
165+
f"Sent email: tag={task_data.tag!r}, sender_id={sender_id}, recipient_ids={[recipient_id]}, annotation_id={annotation_id!r}"
102166
]
103167

104168
def test_send_creates_task_done(
@@ -107,7 +171,7 @@ def test_send_creates_task_done(
107171
task_data = TaskData(
108172
tag=email_data.tag,
109173
sender_id=123,
110-
recipient_ids=[123],
174+
recipient_ids=[124],
111175
extra={"annotation_id": "annotation_id"},
112176
)
113177
email_service.send(email_data, task_data)
@@ -128,7 +192,7 @@ def task_data(self):
128192
return TaskData(
129193
tag=EmailTag.TEST,
130194
sender_id=123,
131-
recipient_ids=[123],
195+
recipient_ids=[124],
132196
)
133197

134198
@pytest.fixture

0 commit comments

Comments
 (0)