Repository: duolicious/duolicious-backend

Issue:
Check email sending is correct
I'm not sure the email-sending logic is thread safe, so there might need to be a queue to send emails in some parts of the code.

```
Traceback (most recent call last):
  File "/app/smtp/__init__.py", line 76, in send
    self._try_send(
  File "/app/smtp/__init__.py", line 63, in _try_send
    self.smtp.sendmail(
  File "/usr/local/lib/python3.11/smtplib.py", line 881, in sendmail
    (code, resp) = self.mail(from_addr, esmtp_opts)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/smtplib.py", line 545, in mail
    self.putcmd("mail", "FROM:%s%s" % (quoteaddr(sender), optionlist))
  File "/usr/local/lib/python3.11/smtplib.py", line 378, in putcmd
    self.send(f'{s}{CRLF}')
  File "/usr/local/lib/python3.11/smtplib.py", line 365, in send
    raise SMTPServerDisconnected('please run connect() first')
smtplib.SMTPServerDisconnected: please run connect() first

First attempt to send mail failed. Trying again.
Establishing connection to SMTP server at email-smtp.us-west-1.amazonaws.com
Failed to connect to SMTP server: Server not connected
Traceback (most recent call last):
  File "/app/smtp/__init__.py", line 76, in send
    self._try_send(
  File "/app/smtp/__init__.py", line 63, in _try_send
    self.smtp.sendmail(
  File "/usr/local/lib/python3.11/smtplib.py", line 887, in sendmail
    raise SMTPSenderRefused(code, resp, from_addr)
smtplib.SMTPSenderRefused: (451, b'4.4.2 Timeout waiting for data from client.', 'report@duolicious.app')

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/app/smtp/__init__.py", line 86, in send
    self._try_send(
  File "/app/smtp/__init__.py", line 63, in _try_send
    self.smtp.sendmail(
  File "/usr/local/lib/python3.11/smtplib.py", line 881, in sendmail
    (code, resp) = self.mail(from_addr, esmtp_opts)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/smtplib.py", line 545, in mail
    self.putcmd("mail", "FROM:%s%s" % (quoteaddr(sender), optionlist))
  File "/usr/local/lib/python3.11/smtplib.py", line 378, in putcmd
    self.send(f'{s}{CRLF}')
  File "/usr/local/lib/python3.11/smtplib.py", line 365, in send
    raise SMTPServerDisconnected('please run connect() first')
smtplib.SMTPServerDisconnected: please run connect() first

Second attempt to send mail failed. Giving up.
```

Discussion:
@duogenesis: Closed by https://github.com/duolicious/duolicious-backend/pull/738

Solution patch:
diff --git a/antiabuse/childsafety/__init__.py b/antiabuse/childsafety/__init__.py
index 5ce30b7..a202867 100644
--- a/antiabuse/childsafety/__init__.py
+++ b/antiabuse/childsafety/__init__.py
@@ -6,10 +6,10 @@ _re_adult_ages_as_words = r'(eighteen|nineteen|twenty)'
 
 _re_adult_ages = rf'({_re_adult_ages_as_numbers}|{_re_adult_ages_as_words})'
 
-_re_minor_ages_as_numbers = r'(1[0-7][mfy]?)'
+_re_minor_ages_as_numbers = r'(1[1-7][mfy]?)'
 
 _re_minor_ages_as_words = (
-    '(ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen)')
+    '(eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen)')
 
 _re_minor_ages = rf'({_re_minor_ages_as_numbers}|{_re_minor_ages_as_words})'
 
@@ -51,17 +51,20 @@ _re_neg_prev = {
 
 _re_neg_next = {
     # Measure words
-    "lb", "lbs", "pound", "pounds", "kg", "kilo", "kilos", "kilogram", "kilograms",
-    "km", "kilometer", "kilometers", "kilometre", "kilometres", "mile", "miles", "mi",
-    "ft", "feet", "inch", "inches", "cm", "mm", "meter", "meters", "metre", "metres",
-    "oz", "ounces", "g", "grams", "bmi", "%", "percent", "reps", "rep", "sets", "set",
-    "hour", "hours", "hr", "hrs", "minute", "minutes", "min", "mins",
-    "second", "seconds", "sec", "secs", "times", "dollar", "dollars",
+    'lb', 'lbs', 'pound', 'pounds', 'kg', 'kilo', 'kilos', 'kilogram', 'kilograms',
+    'km', 'kilometer', 'kilometers', 'kilometre', 'kilometres', 'mile', 'miles', 'mi',
+    'ft', 'feet', 'inch', 'inches', 'cm', 'mm', 'meter', 'meters', 'metre', 'metres',
+    'oz', 'ounces', 'g', 'grams', 'bmi', '%', 'percent', 'reps', 'rep', 'sets', 'set',
+    'hour', 'hours', 'hr', 'hrs', 'minute', 'minutes', 'min', 'mins',
+    'second', 'seconds', 'sec', 'secs', 'times', 'dollar', 'dollars',
 
     # Months
-    "january","february","march","april","may","june","july",
-    "august","september","october","november","december",
-    "jan","feb","mar","apr","jun","jul","aug","sept","sep","oct","nov","dec",
+    'january','february','march','april','may','june','july',
+    'august','september','october','november','december',
+    'jan','feb','mar','apr','jun','jul','aug','sept','sep','oct','nov','dec',
+
+    # Number suffixes
+    'million', 'billion', 'thousand', 'hundred',
 
     # Misc patterns
     'years\s+ago',
diff --git a/antiabuse/lodgereport/__init__.py b/antiabuse/lodgereport/__init__.py
index 325a792..fb7dc46 100644
--- a/antiabuse/lodgereport/__init__.py
+++ b/antiabuse/lodgereport/__init__.py
@@ -2,6 +2,7 @@ from database import api_tx
 from antiabuse.sql import (
     Q_LAST_MESSAGES,
     Q_MAKE_REPORT,
+    Q_INSERT_SKIPPED,
 )
 from typing import Any
 import os
@@ -229,3 +230,22 @@ def lodge_report(subject_uuid: str, object_uuid: str, reason: str):
             last_messages=last_messages,
         )
     ).start()
+
+
+def skip_by_uuid(subject_uuid: str, object_uuid: str, reason: str):
+    params = dict(
+        subject_uuid=subject_uuid,
+        object_uuid=object_uuid,
+        reported=bool(reason),
+        report_reason=reason or '',
+    )
+
+    with api_tx() as tx:
+        tx.execute(Q_INSERT_SKIPPED, params=params)
+
+    if reason:
+        lodge_report(
+            subject_uuid=subject_uuid,
+            object_uuid=object_uuid,
+            reason=reason
+        )
diff --git a/antiabuse/sql/__init__.py b/antiabuse/sql/__init__.py
index 9c94fbd..bc81953 100644
--- a/antiabuse/sql/__init__.py
+++ b/antiabuse/sql/__init__.py
@@ -194,3 +194,42 @@ ON
 ORDER BY
     (id = (SELECT id FROM subject_person_id)) DESC
 """
+
+Q_INSERT_SKIPPED = """
+WITH subject_person_id AS (
+    SELECT
+        id
+    FROM
+        person
+    WHERE
+        uuid = %(subject_uuid)s
+), object_person_id AS (
+    SELECT
+        id
+    FROM
+        person
+    WHERE
+        uuid = %(object_uuid)s
+), q1 AS (
+    INSERT INTO skipped (
+        subject_person_id,
+        object_person_id,
+        reported,
+        report_reason
+    ) VALUES (
+        (SELECT id FROM subject_person_id),
+        (SELECT id FROM object_person_id),
+        %(reported)s,
+        %(report_reason)s
+    ) ON CONFLICT DO NOTHING
+), q2 AS (
+    DELETE FROM search_cache
+    WHERE
+        searcher_person_id = (SELECT id FROM subject_person_id) AND
+        prospect_person_id = (SELECT id FROM object_person_id)
+    OR
+        searcher_person_id = (SELECT id FROM object_person_id) AND
+        prospect_person_id = (SELECT id FROM subject_person_id)
+)
+SELECT 1
+"""
diff --git a/banned-club.sql b/banned-club.sql
index 00bca15..84dc412 100644
--- a/banned-club.sql
+++ b/banned-club.sql
@@ -559,7 +559,11 @@ VALUES
     ('cockmaxxing'),
     ('cocknballs'),
     ('cock pornis'),
+    ('cockring'),
     ('cock sucker'),
+    ('cock warming'),
+    ('cockwarming'),
+    ('cockwarming enjoyer'),
     ('cocky go boing boing'),
     ('cocky want boing'),
     ('code gay ass'),
diff --git a/service/cron/profilereporter/__init__.py b/service/cron/profilereporter/__init__.py
index d95f011..24c1a0c 100644
--- a/service/cron/profilereporter/__init__.py
+++ b/service/cron/profilereporter/__init__.py
@@ -11,7 +11,7 @@ import asyncio
 import os
 import random
 from antiabuse.childsafety import potential_minor
-from antiabuse.lodgereport import lodge_report
+from antiabuse.lodgereport import skip_by_uuid
 
 PROFILE_REPORTER_POLL_SECONDS = int(os.environ.get(
     'DUO_CRON_PROFILE_REPORTER_POLL_SECONDS',
@@ -29,7 +29,7 @@ async def report_profiles_once():
     for row in rows:
         if potential_minor(row['about']):
             print(f'{__name__} -', row['object_uuid'], 'reported')
-            lodge_report(
+            skip_by_uuid(
                 subject_uuid=row['subject_uuid'],
                 object_uuid=row['object_uuid'],
                 reason='Automatically lodged report: Possible minor'
diff --git a/service/person/__init__.py b/service/person/__init__.py
index 3365e59..64cf4d0 100644
--- a/service/person/__init__.py
+++ b/service/person/__init__.py
@@ -26,7 +26,7 @@ from antiabuse.antispam.signupemail import (
     normalize_email,
 )
 from antiabuse.lodgereport import (
-    lodge_report,
+    skip_by_uuid,
 )
 import blurhash
 import numpy
@@ -676,22 +676,12 @@ def post_skip_by_uuid(req: t.PostSkip, s: t.SessionInfo, prospect_uuid: str):
     if not s.person_uuid:
         return 'Authentication required', 401
 
-    params = dict(
+    skip_by_uuid(
         subject_uuid=s.person_uuid,
         object_uuid=prospect_uuid,
-        reported=bool(req.report_reason),
-        report_reason=req.report_reason or '',
+        reason=req.report_reason or '',
     )
 
-    with api_tx() as tx:
-        tx.execute(Q_INSERT_SKIPPED, params=params)
-
-    if req.report_reason:
-        lodge_report(
-            subject_uuid=s.person_uuid,
-            object_uuid=prospect_uuid,
-            reason=req.report_reason
-        )
 
 def post_unskip(s: t.SessionInfo, prospect_person_id: int):
     params = dict(
diff --git a/service/person/sql/__init__.py b/service/person/sql/__init__.py
index cdcc9cf..bb34da6 100644
--- a/service/person/sql/__init__.py
+++ b/service/person/sql/__init__.py
@@ -1075,45 +1075,6 @@ WHERE
     id = %(person_id)s
 """
 
-Q_INSERT_SKIPPED = """
-WITH subject_person_id AS (
-    SELECT
-        id
-    FROM
-        person
-    WHERE
-        uuid = %(subject_uuid)s
-), object_person_id AS (
-    SELECT
-        id
-    FROM
-        person
-    WHERE
-        uuid = %(object_uuid)s
-), q1 AS (
-    INSERT INTO skipped (
-        subject_person_id,
-        object_person_id,
-        reported,
-        report_reason
-    ) VALUES (
-        (SELECT id FROM subject_person_id),
-        (SELECT id FROM object_person_id),
-        %(reported)s,
-        %(report_reason)s
-    ) ON CONFLICT DO NOTHING
-), q2 AS (
-    DELETE FROM search_cache
-    WHERE
-        searcher_person_id = (SELECT id FROM subject_person_id) AND
-        prospect_person_id = (SELECT id FROM object_person_id)
-    OR
-        searcher_person_id = (SELECT id FROM object_person_id) AND
-        prospect_person_id = (SELECT id FROM subject_person_id)
-)
-SELECT 1
-"""
-
 Q_DELETE_SKIPPED = """
 DELETE FROM skipped
 WHERE
diff --git a/service/search/sql/__init__.py b/service/search/sql/__init__.py
index a28d7cf..c06235e 100644
--- a/service/search/sql/__init__.py
+++ b/service/search/sql/__init__.py
@@ -80,7 +80,8 @@ WITH searcher AS (
             WHERE
                 person_id = %(searcher_person_id)s
         ) AS club_preference,
-        date_of_birth
+        date_of_birth,
+        count_answers
     FROM
         person
     WHERE
@@ -540,6 +541,8 @@ WITH searcher AS (
         profile_photo_uuid IS NOT NULL
     AND
         verified
+    AND
+        (SELECT count_answers > 0 FROM searcher)
 )
 INSERT INTO search_cache (
     searcher_person_id,
@@ -708,7 +711,8 @@ ON
 Q_QUIZ_SEARCH = f"""
 WITH searcher AS (
     SELECT
-        personality
+        personality,
+        count_answers
     FROM
         person
     WHERE
@@ -717,13 +721,16 @@ WITH searcher AS (
     SELECT
         count(*) >= 250 AS x
     FROM
-        search_cache
+        search_cache,
+        searcher
     WHERE
         searcher_person_id = %(searcher_person_id)s
     AND
         profile_photo_uuid IS NOT NULL
     AND
         verified
+    AND
+        (SELECT count_answers > 0 FROM searcher)
 ), page AS (
     SELECT
         prospect_person_id,
diff --git a/smtp/__init__.py b/smtp/__init__.py
index ecba829..ba02f0a 100644
--- a/smtp/__init__.py
+++ b/smtp/__init__.py
@@ -1,98 +1,158 @@
-import traceback
+"""Thread‑safe SMTP helper with typed API and automatic retries.
+"""
+
+import os
 import smtplib
+import threading
+import time
+import traceback
+from contextlib import suppress
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
-import os
-import time
 
-SMTP_HOST = os.environ['DUO_SMTP_HOST']
-SMTP_PORT = os.environ['DUO_SMTP_PORT']
-SMTP_USER = os.environ['DUO_SMTP_USER']
-SMTP_PASS = os.environ['DUO_SMTP_PASS']
+SMTP_HOST: str = os.environ["DUO_SMTP_HOST"]
+SMTP_PORT: int = int(os.environ["DUO_SMTP_PORT"])
+SMTP_USER: str = os.environ["DUO_SMTP_USER"]
+SMTP_PASS: str = os.environ["DUO_SMTP_PASS"]
+
 
 class Smtp:
-    def __init__(self, host: str, port: int, username: str, password: str):
-        self.host = host
-        self.port = port
-        self.username = username
-        self.password = password
-        self.smtp = None
+    def __init__(
+        self,
+        host: str,
+        port: int,
+        username: str,
+        password: str,
+    ) -> None:
+        self.host: str = host
+        self.port: int = port
+        self.username: str = username
+        self.password: str = password
+        self._smtp: smtplib.SMTP | None = None
 
-        self._connect()
+        self._lock: threading.RLock = threading.RLock()
 
-    def _connect(self):
-        try:
-            print(f'Establishing connection to SMTP server at {self.host}')
-            self.smtp = smtplib.SMTP(self.host, self.port)
-            self.smtp.starttls()
-            self.smtp.login(self.username, self.password)
-            print(f'Connection to SMTP server at {self.host} established')
-        except Exception as e:
-            print(f'Failed to connect to SMTP server: {e}')
-
-    def _reconnect(self):
-        if self.smtp:
-            try:
-                self.smtp.quit()
-            except Exception as e:
-                print(f'Error while quitting SMTP connection: {e}')
         self._connect()
 
-    def __del__(self):
-        if self.smtp:
+    def _connect(self) -> None:
+        """(Re)‑establish an SMTP connection (protected by *lock*)."""
+        with self._lock:
+            if self._smtp is not None:
+                try:
+                    self._smtp.noop()
+                    return  # connection still healthy
+                except smtplib.SMTPServerDisconnected:
+                    self._smtp = None
+
             try:
-                self.smtp.quit()
-            except Exception as e:
-                print(f'Error while quitting SMTP connection: {e}')
+                print(f"Establishing connection to SMTP server at {self.host}")
+                smtp = smtplib.SMTP(self.host, self.port, timeout=30)
+                smtp.ehlo()
+
+                if smtp.has_extn("starttls"):
+                    smtp.starttls()
+                    smtp.ehlo()  # re-identify as TLS is now in effect
+                    print("STARTTLS supported and initiated.")
+                else:
+                    print("STARTTLS not supported by server.")
+
+                smtp.login(self.username, self.password)
+                self._smtp = smtp
+                print(f"Connection to SMTP server at {self.host} established")
+            except Exception as exc:
+                print(f"Failed to connect to SMTP server: {exc}")
+                self._smtp = None
+                raise
 
     def _try_send(
         self,
+        *,
         subject: str,
         body: str,
         to_addr: str,
         from_addr: str | None = None,
-    ):
-        _from_addr = from_addr or 'no-reply@duolicious.app'
+    ) -> None:
+        if self._smtp is None:
+            # Lazily reconnect if previous attempt failed.
+            self._connect()
+
+        if self._smtp is None:
+            raise Exception("Connection couldn't be established")
 
-        msg = MIMEMultipart('alternative')
-        msg['From'] = f'Duolicious <{_from_addr}>'
-        msg['To'] = to_addr
-        msg['Subject'] = subject
-        msg.attach(MIMEText(body, 'html'))
+        _from_addr: str = from_addr or "no-reply@duolicious.app"
 
-        self.smtp.sendmail(
+        msg = MIMEMultipart("alternative")
+        msg["From"] = f"Duolicious <{_from_addr}>"
+        msg["To"] = to_addr
+        msg["Subject"] = subject
+        msg.attach(MIMEText(body, "html"))
+
+        self._smtp.sendmail(
             from_addr=_from_addr,
-            to_addrs=to_addr,
+            to_addrs=[to_addr],
             msg=msg.as_string(),
         )
 
     def send(
-            self,
-            subject: str,
-            body: str,
-            to_addr: str,
-            from_addr: str | None = None):
-        try:
-            self._try_send(
-                    subject=subject,
-                    body=body,
-                    to_addr=to_addr,
-                    from_addr=from_addr)
-        except:
-            print(traceback.format_exc())
-            print('First attempt to send mail failed. Trying again.')
+        self,
+        *,
+        subject: str,
+        body: str,
+        to_addr: str,
+        from_addr: str | None = None,
+        retries: int | None = None,
+        backoff: int | None = None,
+    ) -> None:
+        """Send an email, retrying on failure.
+
+        Back‑off doubles on every failed attempt: *backoff* × 2^(n - 1).
+        """
+        max_attempts: int = 1 + (2 if retries is None else retries)
+
+        for attempt in range(1, max_attempts + 1):
             try:
-                self._connect()
-                self._try_send(
+                with self._lock:
+                    self._try_send(
                         subject=subject,
                         body=body,
                         to_addr=to_addr,
-                        from_addr=from_addr)
-            except:
+                        from_addr=from_addr,
+                    )
+                return  # Success
+            except Exception:
                 print(traceback.format_exc())
-                print('Second attempt to send mail failed. Giving up.')
+                if attempt == max_attempts:
+                    print("All retry attempts exhausted. Giving up.")
 
-def make_aws_smtp():
+                delay_base: float = 1.0 if backoff is None else backoff
+                delay = delay_base * (2 ** (attempt - 1))
+                print(f"Attempt {attempt} failed; retrying in {delay:.1f}s.")
+                time.sleep(delay)
+
+                # Best effort reconnect for the next iteration
+                with suppress(Exception):
+                    self._connect()
+
+    # ------------------------------------------------------------------
+
+    def quit(self) -> None:
+        """Explicitly close the SMTP connection."""
+        with self._lock:
+            if self._smtp is not None:
+                try:
+                    self._smtp.quit()
+                except Exception as exc:
+                    print(f"Error while quitting SMTP connection: {exc}")
+                finally:
+                    self._smtp = None
+
+    def __del__(self) -> None:
+        with suppress(Exception):  # _smtp may already be closed
+            self.quit()
+
+
+def make_aws_smtp() -> Smtp:
     return Smtp(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS)
 
-aws_smtp = make_aws_smtp();
+
+aws_smtp: Smtp = make_aws_smtp()
diff --git a/verification/__init__.py b/verification/__init__.py
index 6e16e14..c98c687 100644
--- a/verification/__init__.py
+++ b/verification/__init__.py
@@ -339,13 +339,13 @@ async def verify(
     claimed_gender: str,
     claimed_ethnicity: str | None,
 ) -> VerificationResult:
-    if _mock_response_file:
+    if _mock_response_file and _mock_response_file.exists():
         with _mock_response_file.open('r') as f:
             response: str | None = f.read()
     else:
         try:
             response = (await AsyncOpenAI().chat.completions.create(
-                model="gpt-4o-2024-08-06",
+                model="gpt-4.1-2025-04-14",
                 response_format={"type": "json_object"},
                 temperature=0.0,
                 frequency_penalty=0.0,


Test patch:
diff --git a/antiabuse/childsafety/test_init.py b/antiabuse/childsafety/test_init.py
index 99b3fab..c0d9468 100644
--- a/antiabuse/childsafety/test_init.py
+++ b/antiabuse/childsafety/test_init.py
@@ -392,6 +392,11 @@ This must be like the 15th time I've signed up T_T
 Photos of memes, things i like and me doing cosplay in the 4th one
                 """.strip()))
 
+        self.assertFalse(
+                potential_minor("""
+I have 10 million things to do today
+                """.strip()))
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/test/functionality1/search.sh b/test/functionality1/search.sh
index 3296643..96a8696 100755
--- a/test/functionality1/search.sh
+++ b/test/functionality1/search.sh
@@ -349,6 +349,10 @@ test_verified_promoted () {
 
   assume_role searcher
 
+  q "
+  update person
+  set count_answers = 1
+  where name = 'searcher'"
   q "
   update person
   set personality = array_full(47, 1e-3)"