Skip to content

Commit ab582e9

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

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:
@@ -51,6 +55,9 @@ def __init__(
5155
self._task_done_service = task_done_service
5256

5357
def send(self, email_data: EmailData, task_data: TaskData) -> None:
58+
if not self._allow_sending(task_data):
59+
return
60+
5461
if self._debug: # pragma: no cover
5562
logger.info("emailing in debug mode: check the `mail/` directory")
5663
try:
@@ -74,6 +81,23 @@ def send(self, email_data: EmailData, task_data: TaskData) -> None:
7481
)
7582
self._task_done_service.create(task_data)
7683

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

78102
def factory(_context, request: Request) -> EmailService:
79103
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)