Repository: pypi/warehouse

Issue:
"Migrate to API tokens" sent many many times
<!--
    NOTE: This issue should be for problems with PyPI itself, including:
    * pypi.org
    * test.pypi.org
    * files.pythonhosted.org

    This issue should NOT be for a project installed from PyPI. If you are
    having an issue with a specific package, you should reach out to the
    maintainers of that project directly instead.

    Furthermore, this issue should NOT be for any non-PyPI properties (like
    python.org, docs.python.org, etc.)

    If your problem is related to search (a new or updated project doesn't
    appear in the PyPI search results), please wait for a couple of hours
    and check again before reporting it. The search index may take some
    time to be updated.
-->

**Describe the bug**
I recently uploaded with `twine` and basic auth and was sent *12* emails .  I uploaded a total of 4 files so at most I expect 4 emails and ideally only 1

**Expected behavior**
not to receive 12 duplicate emails in quick succession

**To Reproduce**
upload a package using `twine` with basic auth

**My Platform**

```console
$ twine --version
twine version 3.7.1 (importlib_metadata: 4.10.0, pkginfo: 1.8.2, requests:
2.27.1, requests-toolbelt: 0.9.1, tqdm: 4.62.3)
$ ~/opt/venv/bin/python --version --version
Python 3.8.10 (default, Nov 26 2021, 20:14:08) 
[GCC 9.3.0]
$ lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 20.04.4 LTS
Release:	20.04
Codename:	focal
```

**Additional context**
n/a

<summary> a raw email

<details>

```
Subject: [PyPI] Migrate to API tokens for uploading to PyPI
From: PyPI <noreply@pypi.org>
To: asottile <...>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="===============3208068886958749528=="
Message-ID: <0101017f4330a776-e90c22a8-0638-478b-9aa3-cda048ea52e1-000000@us-west-2.amazonses.com>
Date: Tue, 1 Mar 2022 01:55:36 +0000
Feedback-ID: 1.us-west-2.ZRFc7KXPaRh5E01FOWpg1s1VfcS7E0CO2O03ZZnJB3U=:AmazonSES
X-SES-Outgoing: 2022.03.01-54.240.27.21
X-Area1Security-Origin: EXTERNAL
X-Area1Security-Processed: 086babc1a6160fb7de2a5f52e72e77ec;2;NONE;2022-03-01T01:55:47

--===============3208068886958749528==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable


# What?

During your recent upload or upload attempt to PyPI, we noticed you used ba=
sic authentication (username & password). However, your account has two-fac=
tor authentication (2FA) enabled.

In the near future, PyPI will begin prohibiting uploads using basic authent=
ication for accounts with two-factor authentication enabled. Instead, we wi=
ll require API tokens to be used.

# What should I do?

First, generate an API token for your account or project at https://pypi.or=
g/manage/account/token/. Then, use this token when publishing instead of yo=
ur username and password. See https://pypi.org/help/#apitoken for help usin=
g API tokens to publish.





--===============3208068886958749528==
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
MIME-Version: 1.0

<!DOCTYPE html>
<html>
  <head>
    <meta charset=3D"utf-8">
    <meta name=3D"viewport" content=3D"width=3Ddevice-width">

    </head>
  <body>
   =20
<h3>What?</h3>
<p>
  During your recent upload or upload attempt to PyPI, we noticed you used =
basic authentication (username &amp; password). However, your account has t=
wo-factor authentication (2FA) enabled.
</p>
<p>
  In the near future, PyPI will begin prohibiting uploads using basic authe=
ntication for accounts with two-factor authentication enabled. Instead, we =
will require API tokens to be used.
</p>

<h3>What should I do?</h3>
<p>
  First, generate an API token for your account or project at https://pypi.=
org/manage/account/token/. Then, use this token when publishing instead of =
your username and password. See https://pypi.org/help/#apitoken for help us=
ing API tokens to publish.
</p>


   =20
  </body>
</html>

--===============3208068886958749528==--
```

</details>
</summary>



Discussion:

Resolving patch:
diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py
--- a/warehouse/email/__init__.py
+++ b/warehouse/email/__init__.py
@@ -10,6 +10,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import datetime
 import functools
 
 from email.headerregistry import Address
@@ -65,7 +66,15 @@ def send_email(task, request, recipient, msg, success_event):
         task.retry(exc=exc)
 
 
-def _send_email_to_user(request, user, msg, *, email=None, allow_unverified=False):
+def _send_email_to_user(
+    request,
+    user,
+    msg,
+    *,
+    email=None,
+    allow_unverified=False,
+    repeat_window=None,
+):
     # If we were not given a specific email object, then we'll default to using
     # the User's primary email address.
     if email is None:
@@ -78,6 +87,13 @@ def _send_email_to_user(request, user, msg, *, email=None, allow_unverified=Fals
     if email is None or not (email.verified or allow_unverified):
         return
 
+    # If we've already sent this email within the repeat_window, don't send it.
+    if repeat_window is not None:
+        sender = request.find_service(IEmailSender)
+        last_sent = sender.last_sent(to=email.email, subject=msg.subject)
+        if last_sent and (datetime.datetime.now() - last_sent) <= repeat_window:
+            return
+
     request.task(send_email).delay(
         _compute_recipient(user, email.email),
         {
@@ -98,7 +114,12 @@ def _send_email_to_user(request, user, msg, *, email=None, allow_unverified=Fals
     )
 
 
-def _email(name, *, allow_unverified=False):
+def _email(
+    name,
+    *,
+    allow_unverified=False,
+    repeat_window=None,
+):
     """
     This decorator is used to turn an e function into an email sending function!
 
@@ -148,7 +169,12 @@ def wrapper(request, user_or_users, **kwargs):
                     user, email = recipient, None
 
                 _send_email_to_user(
-                    request, user, msg, email=email, allow_unverified=allow_unverified
+                    request,
+                    user,
+                    msg,
+                    email=email,
+                    allow_unverified=allow_unverified,
+                    repeat_window=repeat_window,
                 )
 
             return context
@@ -211,7 +237,11 @@ def send_token_compromised_email_leak(request, user, *, public_url, origin):
     return {"username": user.username, "public_url": public_url, "origin": origin}
 
 
-@_email("basic-auth-with-2fa", allow_unverified=True)
+@_email(
+    "basic-auth-with-2fa",
+    allow_unverified=True,
+    repeat_window=datetime.timedelta(days=1),
+)
 def send_basic_auth_with_two_factor_email(request, user):
     return {}
 
diff --git a/warehouse/email/interfaces.py b/warehouse/email/interfaces.py
--- a/warehouse/email/interfaces.py
+++ b/warehouse/email/interfaces.py
@@ -24,3 +24,8 @@ def send(recipient, message):
         """
         Sends an EmailMessage to the given recipient.
         """
+
+    def last_sent(to, subject):
+        """
+        Determines when an email was last sent, if at all
+        """
diff --git a/warehouse/email/services.py b/warehouse/email/services.py
--- a/warehouse/email/services.py
+++ b/warehouse/email/services.py
@@ -79,6 +79,10 @@ def send(self, recipient, message):
             )
         )
 
+    def last_sent(self, to, subject):
+        # We don't store previously sent emails, so nothing to comapre against
+        return None
+
 
 @implementer(IEmailSender)
 class SESEmailSender:
@@ -127,6 +131,19 @@ def send(self, recipient, message):
             )
         )
 
+    def last_sent(self, to, subject):
+        last_email = (
+            self._db.query(SESEmailMessage)
+            .filter(
+                SESEmailMessage.to == to,
+                SESEmailMessage.subject == subject,
+            )
+            .order_by(SESEmailMessage.created.desc())
+            .first()
+        )
+        if last_email:
+            return last_email.created
+
 
 class ConsoleAndSMTPEmailSender(SMTPEmailSender):
     def send(self, recipient, message):


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -30,6 +30,7 @@
 from pyramid.i18n import TranslationString
 from pyramid.static import ManifestCacheBuster
 from pyramid_jinja2 import IJinja2Environment
+from pyramid_mailer.mailer import DummyMailer
 from pytest_postgresql.config import get_config
 from pytest_postgresql.janitor import DatabaseJanitor
 from sqlalchemy import event
@@ -38,6 +39,9 @@
 
 from warehouse import admin, config, static
 from warehouse.accounts import services as account_services
+from warehouse.accounts.interfaces import ITokenService
+from warehouse.email import services as email_services
+from warehouse.email.interfaces import IEmailSender
 from warehouse.macaroons import services as macaroon_services
 from warehouse.metrics import IMetricsService
 
@@ -113,11 +117,14 @@ def find_service(self, iface=None, context=None, name=""):
 
 
 @pytest.fixture
-def pyramid_services(metrics):
+def pyramid_services(metrics, email_service, token_service):
     services = _Services()
 
     # Register our global services.
     services.register_service(metrics, IMetricsService, None, name="")
+    services.register_service(email_service, IEmailSender, None, name="")
+    services.register_service(token_service, ITokenService, None, name="password")
+    services.register_service(token_service, ITokenService, None, name="email")
 
     return services
 
@@ -281,6 +288,13 @@ def token_service(app_config):
     return account_services.TokenService(secret="secret", salt="salt", max_age=21600)
 
 
+@pytest.fixture
+def email_service():
+    return email_services.SMTPEmailSender(
+        mailer=DummyMailer(), sender="noreply@pypi.dev"
+    )
+
+
 class QueryRecorder:
     def __init__(self):
         self.queries = []
diff --git a/tests/unit/email/test_init.py b/tests/unit/email/test_init.py
--- a/tests/unit/email/test_init.py
+++ b/tests/unit/email/test_init.py
@@ -19,7 +19,7 @@
 from sqlalchemy.orm.exc import NoResultFound
 
 from warehouse import email
-from warehouse.accounts.interfaces import ITokenService, IUserService
+from warehouse.accounts.interfaces import IUserService
 from warehouse.email.interfaces import IEmailSender
 from warehouse.email.services import EmailMessage
 
@@ -192,6 +192,30 @@ def test_doesnt_send_with_unverified(self, primary_email, address):
         assert request.task.calls == []
         assert task.delay.calls == []
 
+    def test_doesnt_send_within_reset_window(self, pyramid_request, pyramid_services):
+        email_service = pretend.stub(
+            last_sent=pretend.call_recorder(
+                lambda to, subject: datetime.datetime.now()
+                - datetime.timedelta(seconds=69)
+            )
+        )
+        pyramid_services.register_service(email_service, IEmailSender, None, name="")
+
+        task = pretend.stub(delay=pretend.call_recorder(lambda *a, **kw: None))
+        pyramid_request.task = pretend.call_recorder(lambda x: task)
+
+        address = "foo@example.com"
+        user = pretend.stub(primary_email=pretend.stub(email=address, verified=True))
+
+        msg = EmailMessage(subject="My Subject", body_text="My Body")
+
+        email._send_email_to_user(
+            pyramid_request, user, msg, repeat_window=datetime.timedelta(seconds=420)
+        )
+
+        assert pyramid_request.task.calls == []
+        assert task.delay.calls == []
+
     @pytest.mark.parametrize(
         ("username", "primary_email", "address", "expected"),
         [
@@ -424,9 +448,6 @@ def test_send_password_reset_email(
             stub_email = pretend.stub(email=email_addr, verified=verified)
         pyramid_request.method = "POST"
         token_service.dumps = pretend.call_recorder(lambda a: "TOKEN")
-        pyramid_request.find_service = pretend.call_recorder(
-            lambda *a, **kw: token_service
-        )
 
         subject_renderer = pyramid_config.testing_add_renderer(
             "email/password-reset/subject.txt"
@@ -479,9 +500,6 @@ def test_send_password_reset_email(
                 }
             )
         ]
-        assert pyramid_request.find_service.calls == [
-            pretend.call(ITokenService, name="password")
-        ]
         assert pyramid_request.task.calls == [pretend.call(send_email)]
         assert send_email.delay.calls == [
             pretend.call(
@@ -523,9 +541,6 @@ def test_email_verification_email(
         stub_email = pretend.stub(id="id", email="email@example.com", verified=False)
         pyramid_request.method = "POST"
         token_service.dumps = pretend.call_recorder(lambda a: "TOKEN")
-        pyramid_request.find_service = pretend.call_recorder(
-            lambda *a, **kw: token_service
-        )
 
         subject_renderer = pyramid_config.testing_add_renderer(
             "email/verify-email/subject.txt"
@@ -571,9 +586,6 @@ def test_email_verification_email(
         assert token_service.dumps.calls == [
             pretend.call({"action": "email-verify", "email.id": str(stub_email.id)})
         ]
-        assert pyramid_request.find_service.calls == [
-            pretend.call(ITokenService, name="email")
-        ]
         assert pyramid_request.task.calls == [pretend.call(send_email)]
         assert send_email.delay.calls == [
             pretend.call(
diff --git a/tests/unit/email/test_services.py b/tests/unit/email/test_services.py
--- a/tests/unit/email/test_services.py
+++ b/tests/unit/email/test_services.py
@@ -137,6 +137,12 @@ def test_send(self, sender_class):
         assert msg.recipients == ["sombody@example.com"]
         assert msg.sender == "DevPyPI <noreply@example.com>"
 
+    def test_last_sent(self, sender_class):
+        mailer = DummyMailer()
+        service = sender_class(mailer, sender="DevPyPI <noreply@example.com>")
+
+        assert service.last_sent(to=pretend.stub(), subject=pretend.stub) is None
+
 
 class TestConsoleAndSMTPEmailSender:
     def test_send(self, capsys):
@@ -306,3 +312,53 @@ def test_send_with_unicode_and_html(self, db_session):
         assert em.from_ == "noreply@example.com"
         assert em.to == "somebody@example.com"
         assert em.subject == "This is a Subject"
+
+    def test_last_sent(self, db_session):
+        to = "me@example.com"
+        subject = "I care about this"
+
+        # Send some random emails
+        aws_client = pretend.stub(
+            send_raw_email=pretend.call_recorder(
+                lambda *a, **kw: {"MessageId": str(uuid.uuid4()) + "-ses"}
+            )
+        )
+        sender = SESEmailSender(
+            aws_client, sender="DevPyPI <noreply@example.com>", db=db_session
+        )
+        for address in [to, "somebody_else@example.com"]:
+            for subject in [subject, "I do not care about this"]:
+                sender.send(
+                    f"Foobar <{ to }>",
+                    EmailMessage(
+                        subject=subject, body_text="This is a plain text body"
+                    ),
+                )
+
+        # Send the last email that we care about
+        resp = {"MessageId": str(uuid.uuid4()) + "-ses"}
+        aws_client = pretend.stub(
+            send_raw_email=pretend.call_recorder(lambda *a, **kw: resp)
+        )
+        sender = SESEmailSender(
+            aws_client, sender="DevPyPI <noreply@example.com>", db=db_session
+        )
+        sender.send(
+            f"Foobar <{ to }>",
+            EmailMessage(subject=subject, body_text="This is a plain text body"),
+        )
+
+        em = (
+            db_session.query(SESEmailMessage)
+            .filter_by(message_id=resp["MessageId"])
+            .one()
+        )
+
+        assert sender.last_sent(to, subject) == em.created
+
+    def test_last_sent_none(self, db_session):
+        to = "me@example.com"
+        subject = "I care about this"
+        sender = SESEmailSender(pretend.stub(), sender=pretend.stub(), db=db_session)
+
+        assert sender.last_sent(to, subject) is None