Jelajahi Sumber

Take into account spamassassin spam report

Son NK 5 tahun lalu
induk
melakukan
9500cc6cee

+ 33 - 0
app/email_utils.py

@@ -365,6 +365,21 @@ def get_orig_message_from_bounce(msg: Message) -> Message:
             return part
 
 
+def get_orig_message_from_spamassassin_report(msg: Message) -> Message:
+    """parse the original email from Spamassassin report"""
+    i = 0
+    for part in msg.walk():
+        i += 1
+
+        # the original message is the 4th part
+        # 1st part is the root part,  multipart/report
+        # 2nd is text/plain, SpamAssassin part
+        # 3rd is the original message in message/rfc822 content type
+        # 4th is original message
+        if i == 4:
+            return part
+
+
 def new_addr(old_addr, new_email, user: User) -> str:
     """replace First Last <first@example.com> by
     first@example.com by SimpleLogin <new_email>
@@ -399,3 +414,21 @@ def get_addrs_from_header(msg: Message, header) -> [str]:
 
     # do not return empty string
     return [r for r in ret if r]
+
+
+def get_spam_info(msg: Message) -> (bool, str):
+    """parse SpamAssassin header to detect whether a message is classified as spam.
+    Return (is spam, spam status detail)
+    The header format is
+    ```X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
+  DKIM_VALID_AU,RCVD_IN_DNSWL_BLOCKED,RCVD_IN_MSPIKE_H2,SPF_PASS,
+  URIBL_BLOCKED autolearn=unavailable autolearn_force=no version=3.4.2```
+    """
+    spamassassin_status = msg["X-Spam-Status"]
+    if not spamassassin_status:
+        return False, ""
+
+    # yes or no
+    spamassassin_answer = spamassassin_status[: spamassassin_status.find(",")]
+
+    return spamassassin_answer.lower() == "yes", spamassassin_status

+ 91 - 1
email_handler.py

@@ -68,6 +68,8 @@ from app.email_utils import (
     delete_all_headers_except,
     new_addr,
     get_addrs_from_header,
+    get_spam_info,
+    get_orig_message_from_spamassassin_report,
 )
 from app.extensions import db
 from app.log import LOG
@@ -424,12 +426,25 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s
         LOG.d("Forward from %s to %s, nothing to do", envelope.mail_from, mailbox_email)
         return False, "550 SL ignored"
 
+    contact = get_or_create_contact(msg["From"], alias)
+
+    spam_check = True
+
     # create PGP email if needed
     if mailbox.pgp_finger_print and user.is_premium():
         LOG.d("Encrypt message using mailbox %s", mailbox)
         msg = prepare_pgp_message(msg, mailbox.pgp_finger_print)
 
-    contact = get_or_create_contact(msg["From"], alias)
+        # no need to spam check for encrypted message
+        spam_check = False
+
+    if spam_check:
+        is_spam, spam_status = get_spam_info(msg)
+        if is_spam:
+            LOG.warning("Email detected as spam. Alias: %s, from: %s", alias, contact)
+            handle_spam(contact, alias, msg, user, mailbox_email, spam_status)
+            return False, "550 SL ignored"
+
     forward_log = EmailLog.create(contact_id=contact.id, user_id=contact.user_id)
 
     if alias.enabled:
@@ -750,6 +765,81 @@ def handle_bounce(
         )
 
 
+def handle_spam(
+    contact: Contact,
+    alias: Alias,
+    msg: Message,
+    user: User,
+    mailbox_email: str,
+    spam_status: str,
+):
+    email_log: EmailLog = EmailLog.create(
+        contact_id=contact.id,
+        user_id=contact.user_id,
+        is_spam=True,
+        spam_status=spam_status,
+    )
+    db.session.commit()
+
+    # Store the report & original email
+    orig_msg = get_orig_message_from_spamassassin_report(msg)
+    # generate a name for the email
+    random_name = str(uuid.uuid4())
+
+    full_report_path = f"refused-emails/full-{random_name}.eml"
+    s3.upload_email_from_bytesio(full_report_path, BytesIO(msg.as_bytes()), random_name)
+
+    file_path = None
+    if orig_msg:
+        file_path = f"refused-emails/{random_name}.eml"
+        s3.upload_email_from_bytesio(
+            file_path, BytesIO(orig_msg.as_bytes()), random_name
+        )
+
+    refused_email = RefusedEmail.create(
+        path=file_path, full_report_path=full_report_path, user_id=user.id
+    )
+    db.session.flush()
+
+    email_log.refused_email_id = refused_email.id
+    db.session.commit()
+
+    LOG.d("Create spam email %s", refused_email)
+
+    refused_email_url = URL + f"/dashboard/refused_email?highlight_id=" + str(email_log.id)
+    disable_alias_link = f"{URL}/dashboard/unsubscribe/{alias.id}"
+
+    # inform user
+    LOG.d(
+        "Inform user %s about spam email sent by %s to alias %s",
+        user,
+        contact.website_from,
+        alias.email,
+    )
+    send_email(
+        mailbox_email,
+        f"Email from {contact.website_from} to {alias.email} is detected as spam",
+        render(
+            "transactional/spam-email.txt",
+            name=user.name,
+            alias=alias,
+            website_from=contact.website_from,
+            website_email=contact.website_email,
+            disable_alias_link=disable_alias_link,
+            refused_email_url=refused_email_url,
+        ),
+        render(
+            "transactional/spam-email.html",
+            name=user.name,
+            alias=alias,
+            website_from=contact.website_from,
+            website_email=contact.website_email,
+            disable_alias_link=disable_alias_link,
+            refused_email_url=refused_email_url,
+        ),
+    )
+
+
 def handle_unsubscribe(envelope):
     message_data = envelope.content.decode("utf8", errors="replace")
     msg = Parser(policy=SMTPUTF8).parsestr(message_data)

+ 21 - 0
templates/emails/transactional/spam-email.html

@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+
+{% block content %}
+  {{ render_text("Hi " + name) }}
+  {{ render_text("An email sent to your alias <b>" + alias.email + "</b> from <b>" + website_email + "</b> is detected as spam by our Spam Detection Engine (SpamAssassin).") }}
+
+  {{ render_text('In most of the cases, the email will be refused by your email provider.') }}
+
+  {{ render_button("View the email", refused_email_url) }}
+
+  {{ render_text('The email is automatically deleted in 7 days.') }}
+
+  {{ render_text('Your alias <b>' + alias.email + '</b> is probably in the hands of a spammer now. In this case, you should disable or delete the alias immediately.') }}
+
+  {{ render_button("Disable alias", disable_alias_link) }}
+
+  {{ render_text('Please let us know if you have any question by replying to this email.') }}
+
+  {{ render_text('Thanks, <br />SimpleLogin Team.') }}
+  {{ raw_url(disable_alias_link) }}
+{% endblock %}

+ 19 - 0
templates/emails/transactional/spam-email.txt

@@ -0,0 +1,19 @@
+Hi {{name}}
+
+An email sent to your alias {{alias.email}} from {{website_from}} is detected as spam by our Spam Detection Engine (SpamAssassin).
+
+In most of the cases, the email will be refused by your email provider.
+
+You can view this email here:
+{{ refused_email_url }}
+
+The email is automatically deleted in 7 days.
+
+Your alias {{alias}} is probably in the hands of a spammer now. In this case, you should disable or delete the alias immediately.
+
+{{disable_alias_link}}
+
+Please let us know if you have any question by replying to this email.
+
+Best,
+SimpleLogin team.