Переглянути джерело

Merge pull request #175 from simple-login/rate-control

Email Rate control
Son Nguyen Kim 5 роки тому
батько
коміт
31341ecae7

+ 12 - 0
app/config.py

@@ -254,3 +254,15 @@ with open(get_abs_path(DISPOSABLE_FILE_PATH), "r") as f:
 APPLE_API_SECRET = os.environ.get("APPLE_API_SECRET")
 # for Mac App
 MACAPP_APPLE_API_SECRET = os.environ.get("MACAPP_APPLE_API_SECRET")
+
+# maximal number of alerts that can be sent to the same email in 24h
+MAX_ALERT_24H = 4
+
+# When a reverse-alias receives emails from un unknown mailbox
+ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX = "reverse_alias_unknown_mailbox"
+
+# When a forwarding email is bounced
+ALERT_BOUNCE_EMAIL = "bounce"
+
+# When a forwarding email is detected as spam
+ALERT_SPAM_EMAIL = "spam"

+ 41 - 1
app/email_utils.py

@@ -8,6 +8,7 @@ from email.utils import make_msgid, formatdate, parseaddr
 from smtplib import SMTP
 from typing import Optional
 
+import arrow
 import dkim
 from jinja2 import Environment, FileSystemLoader
 
@@ -24,10 +25,12 @@ from app.config import (
     POSTFIX_SUBMISSION_TLS,
     MAX_NB_EMAIL_FREE_PLAN,
     DISPOSABLE_EMAIL_DOMAINS,
+    MAX_ALERT_24H,
 )
 from app.dns_utils import get_mx_domains
+from app.extensions import db
 from app.log import LOG
-from app.models import Mailbox, User
+from app.models import Mailbox, User, SentAlert
 
 
 def render(template_name, **kwargs) -> str:
@@ -235,6 +238,43 @@ def send_email(
     smtp.sendmail(SUPPORT_EMAIL, to_email, msg_raw)
 
 
+def send_email_with_rate_control(
+    user: User,
+    alert_type: str,
+    to_email: str,
+    subject,
+    plaintext,
+    html=None,
+    bounced_email: Optional[Message] = None,
+) -> bool:
+    """Same as send_email with rate control over alert_type.
+    For now no more than _MAX_ALERT_24h alert can be sent in the last 24h
+
+    Return true if the email is sent, otherwise False
+    """
+    to_email = to_email.lower().strip()
+    one_day_ago = arrow.now().shift(days=-1)
+    nb_alert = (
+        SentAlert.query.filter_by(alert_type=alert_type, to_email=to_email)
+        .filter(SentAlert.created_at > one_day_ago)
+        .count()
+    )
+
+    if nb_alert > MAX_ALERT_24H:
+        LOG.error(
+            "%s emails were sent to %s in the last 24h, alert type %s",
+            nb_alert,
+            to_email,
+            alert_type,
+        )
+        return False
+
+    SentAlert.create(user_id=user.id, alert_type=alert_type, to_email=to_email)
+    db.session.commit()
+    send_email(to_email, subject, plaintext, html, bounced_email)
+    return True
+
+
 def get_email_local_part(address):
     """
     Get the local part from email

+ 18 - 0
app/models.py

@@ -1248,3 +1248,21 @@ class Referral(db.Model, ModelMixin):
 
     def link(self):
         return f"{LANDING_PAGE_URL}?slref={self.code}"
+
+
+class SentAlert(db.Model, ModelMixin):
+    """keep track of alerts sent to user.
+    User can receive an alert when there's abnormal activity on their aliases such as
+    - reverse-alias not used by the owning mailbox
+    - SPF fails when using the reverse-alias
+    - bounced email
+    - ...
+
+    Different rate controls can then be implemented based on SentAlert:
+    - only once alert: an alert type should be sent only once
+    - max number of sent per 24H: an alert type should not be sent more than X times in 24h
+    """
+
+    user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
+    to_email = db.Column(db.String(256), nullable=False)
+    alert_type = db.Column(db.String(256), nullable=False)

+ 19 - 5
email_handler.py

@@ -56,6 +56,9 @@ from app.config import (
     UNSUBSCRIBER,
     LOAD_PGP_EMAIL_HANDLER,
     ENFORCE_SPF,
+    ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
+    ALERT_BOUNCE_EMAIL,
+    ALERT_SPAM_EMAIL,
 )
 from app.email_utils import (
     send_email,
@@ -70,6 +73,7 @@ from app.email_utils import (
     get_spam_info,
     get_orig_message_from_spamassassin_report,
     parseaddr_unicode,
+    send_email_with_rate_control,
 )
 from app.extensions import db
 from app.greylisting import greylisting_needed
@@ -511,7 +515,9 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str
             reply_email,
         )
 
-        send_email(
+        send_email_with_rate_control(
+            user,
+            ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
             mailbox_email,
             f"Reply from your alias {alias.email} only works from your mailbox",
             render(
@@ -531,7 +537,9 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str
         )
 
         # Notify sender that they cannot send emails to this address
-        send_email(
+        send_email_with_rate_control(
+            user,
+            ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
             envelope.mail_from,
             f"Your email ({envelope.mail_from}) is not allowed to send emails to {reply_email}",
             render(
@@ -660,7 +668,9 @@ def handle_bounce(
             contact.website_email,
             address,
         )
-        send_email(
+        send_email_with_rate_control(
+            user,
+            ALERT_BOUNCE_EMAIL,
             # use user mail here as only user is authenticated to see the refused email
             user.email,
             f"Email from {contact.website_email} to {address} cannot be delivered to your inbox",
@@ -695,7 +705,9 @@ def handle_bounce(
         alias.enabled = False
         db.session.commit()
 
-        send_email(
+        send_email_with_rate_control(
+            user,
+            ALERT_BOUNCE_EMAIL,
             # use user mail here as only user is authenticated to see the refused email
             user.email,
             f"Alias {address} has been disabled due to second undelivered email from {contact.website_email}",
@@ -765,7 +777,9 @@ def handle_spam(
         contact.website_email,
         alias.email,
     )
-    send_email(
+    send_email_with_rate_control(
+        user,
+        ALERT_SPAM_EMAIL,
         mailbox_email,
         f"Email from {contact.website_email} to {alias.email} is detected as spam",
         render(

+ 38 - 0
migrations/versions/2020_050920_a5e3c6693dc6_.py

@@ -0,0 +1,38 @@
+"""empty message
+
+Revision ID: a5e3c6693dc6
+Revises: a3a7c518ea70
+Create Date: 2020-05-09 20:45:15.014387
+
+"""
+import sqlalchemy_utils
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'a5e3c6693dc6'
+down_revision = 'a3a7c518ea70'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('sent_alert',
+    sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+    sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
+    sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
+    sa.Column('user_id', sa.Integer(), nullable=False),
+    sa.Column('to_email', sa.String(length=256), nullable=False),
+    sa.Column('alert_type', sa.String(length=256), nullable=False),
+    sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('sent_alert')
+    # ### end Alembic commands ###

+ 17 - 0
tests/test_email_utils.py

@@ -1,5 +1,6 @@
 from email.message import EmailMessage
 
+from app.config import MAX_ALERT_24H
 from app.email_utils import (
     get_email_domain_part,
     email_belongs_to_alias_domains,
@@ -7,6 +8,7 @@ from app.email_utils import (
     delete_header,
     add_or_replace_header,
     parseaddr_unicode,
+    send_email_with_rate_control,
 )
 from app.extensions import db
 from app.models import User, CustomDomain
@@ -101,3 +103,18 @@ def test_parseaddr_unicode():
         "pöstal",
         "abcd@gmail.com",
     )
+
+
+def test_send_email_with_rate_control(flask_client):
+    user = User.create(
+        email="a@b.c", password="password", name="Test User", activated=True
+    )
+    db.session.commit()
+
+    for _ in range(MAX_ALERT_24H + 1):
+        assert send_email_with_rate_control(
+            user, "test alert type", "abcd@gmail.com", "subject", "plaintext"
+        )
+    assert not send_email_with_rate_control(
+        user, "test alert type", "abcd@gmail.com", "subject", "plaintext"
+    )