diff --git a/app/admin_model.py b/app/admin_model.py
index 9d6b8e19..17f28133 100644
--- a/app/admin_model.py
+++ b/app/admin_model.py
@@ -1,8 +1,12 @@
+from typing import Optional
+
import arrow
import sqlalchemy
+from flask_admin.model.template import EndpointLinkRowAction
+from markupsafe import Markup
-from app import models
-from flask import redirect, url_for, request, flash
+from app import models, s3
+from flask import redirect, url_for, request, flash, Response
from flask_admin import expose, AdminIndexView
from flask_admin.actions import action
from flask_admin.contrib import sqla
@@ -17,6 +21,9 @@ from app.models import (
AppleSubscription,
AdminAuditLog,
AuditLogActionEnum,
+ TransactionalComplaintState,
+ Phase,
+ TransactionalComplaint,
)
@@ -366,3 +373,78 @@ class AdminAuditLogAdmin(SLModelView):
"action": _admin_action_formatter,
"created_at": _admin_created_at_formatter,
}
+
+
+def _transactionalcomplaint_state_formatter(view, context, model, name):
+ return "{} ({})".format(TransactionalComplaintState(model.state).name, model.state)
+
+
+def _transactionalcomplaint_phase_formatter(view, context, model, name):
+ return Phase(model.phase).name
+
+
+def _transactionalcomplaint_refused_email_id_formatter(view, context, model, name):
+ markupstring = "{}".format(
+ url_for(".download_eml", id=model.id), model.refused_email.full_report_path
+ )
+ return Markup(markupstring)
+
+
+class TransactionalComplaintAdmin(SLModelView):
+ column_searchable_list = ["id", "user.id", "created_at"]
+ column_filters = ["user.id", "state"]
+ column_hide_backrefs = False
+ can_edit = False
+ can_create = False
+ can_delete = False
+
+ column_formatters = {
+ "created_at": _admin_created_at_formatter,
+ "updated_at": _admin_created_at_formatter,
+ "state": _transactionalcomplaint_state_formatter,
+ "phase": _transactionalcomplaint_phase_formatter,
+ "refused_email": _transactionalcomplaint_refused_email_id_formatter,
+ }
+
+ column_extra_row_actions = [ # Add a new action button
+ EndpointLinkRowAction("fa fa-check-square", ".mark_ok"),
+ ]
+
+ def _get_complaint(self) -> Optional[TransactionalComplaint]:
+ complain_id = request.args.get("id")
+ if complain_id is None:
+ flash("Missing id", "error")
+ return None
+ complaint = TransactionalComplaint.get_by(id=complain_id)
+ if not complaint:
+ flash("Could not find complaint", "error")
+ return None
+ return complaint
+
+ @expose("/mark_ok", methods=["GET"])
+ def mark_ok(self):
+ complaint = self._get_complaint()
+ if not complaint:
+ return redirect("/admin/transactionalcomplaint/")
+ complaint.state = TransactionalComplaintState.reviewed.value
+ Session.commit()
+ return redirect("/admin/transactionalcomplaint/")
+
+ @expose("/download_eml", methods=["GET"])
+ def download_eml(self):
+ complaint = self._get_complaint()
+ if not complaint:
+ return redirect("/admin/transactionalcomplaint/")
+ eml_path = complaint.refused_email.full_report_path
+ eml_data = s3.download_email(eml_path)
+ AdminAuditLog.downloaded_transactional_complaint(current_user.id, complaint.id)
+ Session.commit()
+ return Response(
+ eml_data,
+ mimetype="message/rfc822",
+ headers={
+ "Content-Disposition": "attachment;filename={}".format(
+ complaint.refused_email.path
+ )
+ },
+ )
diff --git a/app/config.py b/app/config.py
index 475a661e..9fcbdb70 100644
--- a/app/config.py
+++ b/app/config.py
@@ -336,10 +336,9 @@ AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN = "custom_domain_mx_record_issue"
# alert when a new alias is about to be created on a disabled directory
ALERT_DIRECTORY_DISABLED_ALIAS_CREATION = "alert_directory_disabled_alias_creation"
-ALERT_HOTMAIL_COMPLAINT = "alert_hotmail_complaint"
-ALERT_HOTMAIL_COMPLAINT_REPLY_PHASE = "alert_hotmail_complaint_reply_phase"
-ALERT_HOTMAIL_COMPLAINT_TRANSACTIONAL = "alert_hotmail_complaint_transactional"
-ALERT_YAHOO_COMPLAINT = "alert_yahoo_complaint"
+ALERT_COMPLAINT_REPLY_PHASE = "alert_complaint_reply_phase"
+ALERT_COMPLAINT_FORWARD_PHASE = "alert_complaint_forward_phase"
+ALERT_COMPLAINT_TO_USER = "alert_complaint_to_user"
ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc"
diff --git a/app/email_utils.py b/app/email_utils.py
index c837077b..0561e399 100644
--- a/app/email_utils.py
+++ b/app/email_utils.py
@@ -699,30 +699,6 @@ def get_mailbox_bounce_info(bounce_report: Message) -> Optional[Message]:
return part
-def get_orig_message_from_hotmail_complaint(msg: Message) -> Optional[Message]:
- i = 0
- for part in msg.walk():
- i += 1
-
- # 1st part is the container
- # 2nd part is the empty body
- # 3rd is original message
- if i == 3:
- return part
-
-
-def get_orig_message_from_yahoo_complaint(msg: Message) -> Optional[Message]:
- i = 0
- for part in msg.walk():
- i += 1
-
- # 1st part is the container
- # 2nd part is the empty body
- # 6th is original message
- if i == 6:
- return part
-
-
def get_header_from_bounce(msg: Message, header: str) -> str:
"""using regex to get header value from bounce message
get_orig_message_from_bounce is better. This should be the last option
diff --git a/app/handler/spamd_result.py b/app/handler/spamd_result.py
index 57cc250b..e3bd3cb6 100644
--- a/app/handler/spamd_result.py
+++ b/app/handler/spamd_result.py
@@ -4,16 +4,10 @@ from typing import Dict, Optional
import newrelic
from app.email import headers
-from app.models import EnumE
+from app.models import EnumE, Phase
from email.message import Message
-class Phase(EnumE):
- unknown = 0
- forward = 1
- reply = 2
-
-
class DmarcCheckResult(EnumE):
allow = 0
soft_fail = 1
diff --git a/app/handler/transactional_complaint.py b/app/handler/transactional_complaint.py
new file mode 100644
index 00000000..ad0bd105
--- /dev/null
+++ b/app/handler/transactional_complaint.py
@@ -0,0 +1,229 @@
+import uuid
+from abc import ABC, abstractmethod
+from io import BytesIO
+from mailbox import Message
+from typing import Optional
+
+from app import s3
+from app.config import (
+ ALERT_COMPLAINT_REPLY_PHASE,
+ ALERT_COMPLAINT_TO_USER,
+ ALERT_COMPLAINT_FORWARD_PHASE,
+)
+from app.email import headers
+from app.email_utils import (
+ get_header_unicode,
+ parse_full_address,
+ save_email_for_debugging,
+ to_bytes,
+ render,
+ send_email_with_rate_control,
+)
+from app.log import LOG
+from app.models import (
+ User,
+ Alias,
+ DeletedAlias,
+ DomainDeletedAlias,
+ Contact,
+ TransactionalComplaint,
+ Phase,
+ TransactionalComplaintState,
+ RefusedEmail,
+)
+
+
+class TransactionalComplaintOrigin(ABC):
+ @classmethod
+ @abstractmethod
+ def get_original_message(cls, message: Message) -> Optional[Message]:
+ pass
+
+ @classmethod
+ @abstractmethod
+ def name(cls):
+ pass
+
+
+class TransactionalYahooOrigin(TransactionalComplaintOrigin):
+ @classmethod
+ def get_original_message(cls, message: Message) -> Optional[Message]:
+ wanted_part = 6
+ for part in message.walk():
+ wanted_part -= 1
+ if wanted_part == 0:
+ return part
+ return None
+
+ @classmethod
+ def name(cls):
+ return "yahoo"
+
+
+class TransactionalHotmailOrigin(TransactionalComplaintOrigin):
+ @classmethod
+ def get_original_message(cls, message: Message) -> Optional[Message]:
+ wanted_part = 3
+ for part in message.walk():
+ wanted_part -= 1
+ if wanted_part == 0:
+ return part
+ return None
+
+ @classmethod
+ def name(cls):
+ return "hotmail"
+
+
+def handle_hotmail_complaint(message: Message) -> bool:
+ return handle_complaint(message, TransactionalHotmailOrigin())
+
+
+def handle_yahoo_complaint(message: Message) -> bool:
+ return handle_complaint(message, TransactionalYahooOrigin())
+
+
+def find_alias_with_address(address: str) -> Optional[Alias]:
+ return (
+ Alias.get_by(email=address)
+ or DeletedAlias.get_by(email=address)
+ or DomainDeletedAlias.get_by(email=address)
+ )
+
+
+def handle_complaint(message: Message, origin: TransactionalComplaintOrigin) -> bool:
+ original_message = origin.get_original_message(message)
+
+ try:
+ _, to_address = parse_full_address(
+ get_header_unicode(original_message[headers.TO])
+ )
+ _, from_address = parse_full_address(
+ get_header_unicode(original_message[headers.FROM])
+ )
+ except ValueError:
+ saved_file = save_email_for_debugging(message, "FromParseFailed")
+ LOG.w("Cannot parse from header. Saved to {}".format(saved_file or "nowhere"))
+ return True
+
+ user = User.get_by(email=to_address)
+ if user:
+ LOG.d("Handle transactional {} complaint for {}".format(origin.name(), user))
+ report_complaint_to_user(user, origin)
+ return True
+
+ alias = find_alias_with_address(from_address)
+ # the email is during a reply phase, from=alias and to=destination
+ if alias:
+ LOG.i(
+ "Complaint from {} during reply phase {} -> {}, {}".format(
+ origin.name(), alias, to_address, user
+ )
+ )
+ report_complaint_to_user_in_reply_phase(alias, to_address, origin)
+ store_transactional_complaint(alias, message)
+ return True
+
+ contact = Contact.get_by(reply_email=from_address)
+ if contact:
+ alias = contact.alias
+ else:
+ alias = find_alias_with_address(to_address)
+
+ if not alias:
+ LOG.e(
+ f"Cannot find alias from address {to_address} or contact with reply {from_address}"
+ )
+ return False
+
+ report_complaint_to_user_in_forward_phase(alias, origin)
+ return True
+
+
+def report_complaint_to_user_in_reply_phase(
+ alias: Alias, to_address: str, origin: TransactionalComplaintOrigin
+):
+ capitalized_name = origin.name().capitalize()
+ send_email_with_rate_control(
+ alias.user,
+ f"{ALERT_COMPLAINT_REPLY_PHASE}_{origin.name()}",
+ alias.user.email,
+ f"Abuse report from {capitalized_name}",
+ render(
+ "transactional/transactional-complaint-reply-phase.txt.jinja2",
+ user=alias.user,
+ alias=alias,
+ destination=to_address,
+ provider=capitalized_name,
+ ),
+ max_nb_alert=1,
+ nb_day=7,
+ )
+
+
+def report_complaint_to_user(user: User, origin: TransactionalComplaintOrigin):
+ capitalized_name = origin.name().capitalize()
+ send_email_with_rate_control(
+ user,
+ f"{ALERT_COMPLAINT_TO_USER}_{origin.name()}",
+ user.email,
+ f"Abuse report from {capitalized_name}",
+ render(
+ "transactional/transactional-complaint-to-user.txt.jinja2",
+ user=user,
+ provider=capitalized_name,
+ ),
+ render(
+ "transactional/transactional-complaint-to-user.html",
+ user=user,
+ provider=capitalized_name,
+ ),
+ max_nb_alert=1,
+ nb_day=7,
+ )
+
+
+def report_complaint_to_user_in_forward_phase(
+ alias: Alias, origin: TransactionalComplaintOrigin
+):
+ capitalized_name = origin.name().capitalize()
+ user = alias.user
+ send_email_with_rate_control(
+ user,
+ f"{ALERT_COMPLAINT_FORWARD_PHASE}_{origin.name()}",
+ user.email,
+ f"Abuse report from {capitalized_name}",
+ render(
+ "transactional/transactional-complaint-forward-phase.txt.jinja2",
+ user=user,
+ provider=capitalized_name,
+ ),
+ render(
+ "transactional/transactional-complaint-forward-phase.html",
+ user=user,
+ provider=capitalized_name,
+ ),
+ max_nb_alert=1,
+ nb_day=7,
+ )
+
+
+def store_transactional_complaint(alias, message):
+ email_name = f"reply-{uuid.uuid4().hex}.eml"
+ full_report_path = f"transactional_complaint/{email_name}"
+ s3.upload_email_from_bytesio(
+ full_report_path, BytesIO(to_bytes(message)), email_name
+ )
+ refused_email = RefusedEmail.create(
+ full_report_path=full_report_path,
+ user_id=alias.user_id,
+ path=email_name,
+ commit=True,
+ )
+ TransactionalComplaint.create(
+ user_id=alias.user_id,
+ state=TransactionalComplaintState.new.value,
+ phase=Phase.reply.value,
+ refused_email_id=refused_email.id,
+ commit=True,
+ )
diff --git a/app/models.py b/app/models.py
index ed30ca52..67109903 100644
--- a/app/models.py
+++ b/app/models.py
@@ -235,6 +235,13 @@ class AuditLogActionEnum(EnumE):
disable_2fa = 5
logged_as_user = 6
extend_subscription = 7
+ download_transactional_complaint = 8
+
+
+class Phase(EnumE):
+ unknown = 0
+ forward = 1
+ reply = 2
class VerpType(EnumE):
@@ -2967,6 +2974,7 @@ class AdminAuditLog(Base):
action=AuditLogActionEnum.logged_as_user.value,
model="User",
model_id=user_id,
+ data={},
)
@classmethod
@@ -2988,5 +2996,32 @@ class AdminAuditLog(Base):
},
)
+ @classmethod
+ def downloaded_transactional_complaint(cls, admin_user_id: int, complaint_id: int):
+ cls.create(
+ admin_user_id=admin_user_id,
+ action=AuditLogActionEnum.download_transactional_complaint.value,
+ model="TransactionalComplaint",
+ model_id=complaint_id,
+ data={},
+ )
-# endregion
+
+class TransactionalComplaintState(EnumE):
+ new = 0
+ reviewed = 1
+
+
+class TransactionalComplaint(Base, ModelMixin):
+ __tablename__ = "transactional_complaint"
+
+ user_id = sa.Column(sa.ForeignKey("users.id"), nullable=False)
+ state = sa.Column(sa.Integer, nullable=False)
+ phase = sa.Column(sa.Integer, nullable=False)
+ # Point to the email that has been refused
+ refused_email_id = sa.Column(
+ sa.ForeignKey("refused_email.id", ondelete="cascade"), nullable=True
+ )
+
+ user = orm.relationship(User, foreign_keys=[user_id])
+ refused_email = orm.relationship(RefusedEmail, foreign_keys=[refused_email_id])
diff --git a/app/s3.py b/app/s3.py
index 6c4ae2d8..5a639992 100644
--- a/app/s3.py
+++ b/app/s3.py
@@ -1,5 +1,6 @@
import os
from io import BytesIO
+from typing import Optional
import boto3
import requests
@@ -61,6 +62,23 @@ def upload_email_from_bytesio(path: str, bs: BytesIO, filename):
)
+def download_email(path: str) -> Optional[str]:
+ if LOCAL_FILE_UPLOAD:
+ file_path = os.path.join(UPLOAD_DIR, path)
+ with open(file_path, "rb") as f:
+ return f.read()
+ resp = (
+ _session.resource("s3")
+ .Bucket(BUCKET)
+ .get_object(
+ Key=path,
+ )
+ )
+ if not resp or "Body" not in resp:
+ return None
+ return resp["Body"].read
+
+
def upload_from_url(url: str, upload_path):
r = requests.get(url)
upload_from_bytesio(upload_path, BytesIO(r.content))
diff --git a/email_handler.py b/email_handler.py
index 25be0ba1..d64dce45 100644
--- a/email_handler.py
+++ b/email_handler.py
@@ -79,10 +79,6 @@ from app.config import (
ENABLE_SPAM_ASSASSIN,
BOUNCE_PREFIX_FOR_REPLY_PHASE,
POSTMASTER,
- ALERT_HOTMAIL_COMPLAINT,
- ALERT_YAHOO_COMPLAINT,
- ALERT_HOTMAIL_COMPLAINT_TRANSACTIONAL,
- ALERT_HOTMAIL_COMPLAINT_REPLY_PHASE,
OLD_UNSUBSCRIBER,
ALERT_FROM_ADDRESS_IS_REVERSE_ALIAS,
ALERT_TO_NOREPLY,
@@ -122,9 +118,7 @@ from app.email_utils import (
sanitize_header,
get_queue_id,
should_ignore_bounce,
- get_orig_message_from_hotmail_complaint,
parse_full_address,
- get_orig_message_from_yahoo_complaint,
get_mailbox_bounce_info,
save_email_for_debugging,
save_envelope_for_debugging,
@@ -146,6 +140,10 @@ from app.handler.spamd_result import (
SpamdResult,
SPFCheckResult,
)
+from app.handler.transactional_complaint import (
+ handle_hotmail_complaint,
+ handle_yahoo_complaint,
+)
from app.log import LOG, set_message_id
from app.models import (
Alias,
@@ -159,8 +157,6 @@ from app.models import (
TransactionalEmail,
IgnoredEmail,
MessageIDMatching,
- DeletedAlias,
- DomainDeletedAlias,
Notification,
VerpType,
)
@@ -1518,191 +1514,6 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
)
-def handle_hotmail_complaint(msg: Message) -> bool:
- """
- Handle hotmail complaint sent to postmaster
- Return True if the complaint can be handled, False otherwise
- """
- orig_msg = get_orig_message_from_hotmail_complaint(msg)
- to_header = orig_msg[headers.TO]
- from_header = orig_msg[headers.FROM]
-
- user = User.get_by(email=to_header)
- if user:
- LOG.d("Handle transactional hotmail complaint for %s", user)
- handle_hotmail_complain_for_transactional_email(user)
- return True
-
- try:
- _, from_address = parse_full_address(get_header_unicode(from_header))
- alias = Alias.get_by(email=from_address)
-
- # the email is during a reply phase, from=alias and to=destination
- if alias:
- user = alias.user
- LOG.i(
- "Hotmail complaint during reply phase %s -> %s, %s",
- alias,
- to_header,
- user,
- )
- send_email_with_rate_control(
- user,
- ALERT_HOTMAIL_COMPLAINT_REPLY_PHASE,
- user.email,
- f"Hotmail abuse report",
- render(
- "transactional/hotmail-complaint-reply-phase.txt.jinja2",
- user=user,
- alias=alias,
- destination=to_header,
- ),
- max_nb_alert=1,
- nb_day=7,
- )
- return True
-
- except ValueError:
- LOG.w("Cannot parse %s", from_header)
-
- alias = None
-
- # try parsing the from_header which might contain the reverse alias
- try:
- _, reverse_alias = parse_full_address(get_header_unicode(from_header))
- contact = Contact.get_by(reply_email=reverse_alias)
- if contact:
- alias = contact.alias
- LOG.d("find %s through %s", alias, contact)
- else:
- LOG.d("No contact found for %s", reverse_alias)
- except ValueError:
- LOG.w("Cannot parse %s", from_header)
-
- # try parsing the to_header which is usually the alias
- if not alias:
- try:
- _, alias_address = parse_full_address(get_header_unicode(to_header))
- except ValueError:
- LOG.w("Cannot parse %s", to_header)
- else:
- alias = Alias.get_by(email=alias_address)
- if not alias:
- if DeletedAlias.get_by(
- email=alias_address
- ) or DomainDeletedAlias.get_by(email=alias_address):
- LOG.w("Alias %s is deleted", alias_address)
- return True
-
- if not alias:
- LOG.e(
- "Cannot parse alias from to header %s and from header %s",
- to_header,
- from_header,
- )
- return False
-
- user = alias.user
- LOG.d("Handle hotmail complaint for %s %s %s", alias, user, alias.mailboxes)
-
- send_email_with_rate_control(
- user,
- ALERT_HOTMAIL_COMPLAINT,
- user.email,
- f"Hotmail abuse report",
- render(
- "transactional/hotmail-complaint.txt.jinja2",
- alias=alias,
- ),
- render(
- "transactional/hotmail-complaint.html",
- alias=alias,
- ),
- max_nb_alert=1,
- nb_day=7,
- )
-
- return True
-
-
-def handle_hotmail_complain_for_transactional_email(user):
- """Handle the case when a transactional email is set as Spam by user or by HotMail"""
- send_email_with_rate_control(
- user,
- ALERT_HOTMAIL_COMPLAINT_TRANSACTIONAL,
- user.email,
- f"Hotmail abuse report",
- render("transactional/hotmail-transactional-complaint.txt.jinja2", user=user),
- render("transactional/hotmail-transactional-complaint.html", user=user),
- max_nb_alert=1,
- nb_day=7,
- )
-
- return True
-
-
-def handle_yahoo_complaint(msg: Message) -> bool:
- """
- Handle yahoo complaint sent to postmaster
- Return True if the complaint can be handled, False otherwise
- """
- orig_msg = get_orig_message_from_yahoo_complaint(msg)
- to_header = orig_msg[headers.TO]
- if not to_header:
- LOG.e("cannot find the alias")
- return False
-
- user = User.get_by(email=to_header)
- if user:
- LOG.d("Handle transactional yahoo complaint for %s", user)
- handle_yahoo_complain_for_transactional_email(user)
- return True
-
- _, alias_address = parse_full_address(get_header_unicode(to_header))
- alias = Alias.get_by(email=alias_address)
-
- if not alias:
- LOG.w("No alias for %s", alias_address)
- return False
-
- user = alias.user
- LOG.w("Handle yahoo complaint for %s %s %s", alias, user, alias.mailboxes)
-
- send_email_with_rate_control(
- user,
- ALERT_YAHOO_COMPLAINT,
- user.email,
- f"Yahoo abuse report",
- render(
- "transactional/yahoo-complaint.txt.jinja2",
- alias=alias,
- ),
- render(
- "transactional/yahoo-complaint.html",
- alias=alias,
- ),
- max_nb_alert=2,
- )
-
- return True
-
-
-def handle_yahoo_complain_for_transactional_email(user):
- """Handle the case when a transactional email is set as Spam by user or by Yahoo"""
- send_email_with_rate_control(
- user,
- ALERT_YAHOO_COMPLAINT,
- user.email,
- f"Yahoo abuse report",
- render("transactional/yahoo-transactional-complaint.txt.jinja2", user=user),
- render("transactional/yahoo-transactional-complaint.html", user=user),
- max_nb_alert=1,
- nb_day=7,
- )
-
- return True
-
-
def handle_bounce_reply_phase(envelope, msg: Message, email_log: EmailLog):
"""
Handle reply phase bounce
diff --git a/migrations/versions/2022_041916_45588d9bb475_store_transactional_complaints_for_.py b/migrations/versions/2022_041916_45588d9bb475_store_transactional_complaints_for_.py
new file mode 100644
index 00000000..5c6a9a61
--- /dev/null
+++ b/migrations/versions/2022_041916_45588d9bb475_store_transactional_complaints_for_.py
@@ -0,0 +1,36 @@
+"""Store transactional complaints for admins to verify
+
+Revision ID: 45588d9bb475
+Revises: b500363567e3
+Create Date: 2022-04-19 16:17:42.798792
+
+"""
+import sqlalchemy_utils
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '45588d9bb475'
+down_revision = 'b500363567e3'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+ op.create_table(
+ "transactional_complaint",
+ 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("state", sa.Integer, nullable=False),
+ sa.Column("phase", sa.Integer, nullable=False),
+ sa.Column("refused_email_id", sa.Integer, nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
+ sa.ForeignKeyConstraint(['refused_email_id'], ['refused_email.id'], ondelete='cascade'),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+
+def downgrade():
+ op.drop_table("transactional_complaint")
diff --git a/server.py b/server.py
index d05308c1..8b7106af 100644
--- a/server.py
+++ b/server.py
@@ -37,6 +37,7 @@ from app.admin_model import (
CouponAdmin,
CustomDomainAdmin,
AdminAuditLogAdmin,
+ TransactionalComplaintAdmin,
)
from app.api.base import api_bp
from app.auth.base import auth_bp
@@ -90,6 +91,7 @@ from app.models import (
ManualSubscription,
Coupon,
AdminAuditLog,
+ TransactionalComplaint,
)
from app.monitor.base import monitor_bp
from app.oauth.base import oauth_bp
@@ -691,6 +693,7 @@ def init_admin(app):
admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session))
admin.add_view(CustomDomainAdmin(CustomDomain, Session))
admin.add_view(AdminAuditLogAdmin(AdminAuditLog, Session))
+ admin.add_view(TransactionalComplaintAdmin(TransactionalComplaint, Session))
def register_custom_commands(app):
diff --git a/templates/emails/transactional/hotmail-complaint-reply-phase.txt.jinja2 b/templates/emails/transactional/hotmail-complaint-reply-phase.txt.jinja2
deleted file mode 100644
index 934a96d1..00000000
--- a/templates/emails/transactional/hotmail-complaint-reply-phase.txt.jinja2
+++ /dev/null
@@ -1,15 +0,0 @@
-{% extends "base.txt.jinja2" %}
-
-{% block content %}
-Hi,
-
-This is SimpleLogin team.
-
-Hotmail has informed us about an email sent from your alias {{ alias.email }} to {{ destination }} that might have been considered as spam, either by the recipient or by their Hotmail spam filter.
-
-Please note that sending non-solicited from a SimpleLogin alias infringes our terms and condition as it severely affects SimpleLogin email delivery.
-
-If somehow the recipient's Hotmail considers a forwarded email as Spam, it helps us a lot if you can ask them to move the email out of their Spam folder.
-
-Don't hesitate to get in touch with us if you need more information.
-{% endblock %}
diff --git a/templates/emails/transactional/hotmail-complaint.html b/templates/emails/transactional/hotmail-complaint.html
deleted file mode 100644
index fbc59520..00000000
--- a/templates/emails/transactional/hotmail-complaint.html
+++ /dev/null
@@ -1,41 +0,0 @@
-{% extends "base.html" %}
-
-{% block content %}
- {% call text() %}
- This is SimpleLogin team.
- {% endcall %}
-
- {% call text() %}
- Hotmail has informed us about an email sent to {{ alias.email }} that might have been considered as spam,
- either by you or by Hotmail spam filter.
- {% endcall %}
-
- {% call text() %}
- Please note that explicitly marking a SimpleLogin's forwarded email as Spam affects SimpleLogin email delivery,
- has a negative effect for all users and
- is a violation of our terms and condition.
- {% endcall %}
-
- {% call text() %}
- If that’s the case, please disable the alias instead if you don't want to receive the emails sent to this alias.
- {% endcall %}
-
- {% call text() %}
- If somehow Hotmail considers a forwarded email as Spam, it will help us if you can move the email out of the Spam
- folder.
- You can also set up a filter to avoid this from happening in the future using this guide at
- https://simplelogin.io/help/
- {% endcall %}
-
- {% call text() %}
- Don't hesitate to get in touch with us if you need more information.
- {% endcall %}
-
- {% call text() %}
- Best,
- SimpleLogin Team.
- {% endcall %}
-
-{% endblock %}
-
-
diff --git a/templates/emails/transactional/hotmail-complaint.txt.jinja2 b/templates/emails/transactional/hotmail-complaint.txt.jinja2
deleted file mode 100644
index e27a96e0..00000000
--- a/templates/emails/transactional/hotmail-complaint.txt.jinja2
+++ /dev/null
@@ -1,20 +0,0 @@
-{% extends "base.txt.jinja2" %}
-
-{% block content %}
-Hi,
-
-This is SimpleLogin team.
-
-Hotmail has informed us about an email sent to {{ alias.email }} that might have been considered as spam,
-either by you or by Hotmail spam filter.
-
-Please note that explicitly marking a SimpleLogin's forwarded email as Spam affects SimpleLogin email delivery,
-has a negative effect for all users and is a violation of our terms and condition.
-
-If that’s the case, please disable the alias instead if you don't want to receive the emails sent to this alias.
-
-If somehow Hotmail considers a forwarded email as Spam, it will help us if you can move the email out of the Spam folder.
-You can also set up a filter to avoid this from happening in the future using this guide at https://simplelogin.io/help/
-
-Don't hesitate to get in touch with us if you need more information.
-{% endblock %}
diff --git a/templates/emails/transactional/hotmail-transactional-complaint.html b/templates/emails/transactional/hotmail-transactional-complaint.html
deleted file mode 100644
index 0d794bab..00000000
--- a/templates/emails/transactional/hotmail-transactional-complaint.html
+++ /dev/null
@@ -1,42 +0,0 @@
-{% extends "base.html" %}
-
-{% block content %}
- {% call text() %}
- This is SimpleLogin team.
- {% endcall %}
-
- {% call text() %}
- Hotmail has informed us about an email sent to {{ user.email }} that might have been considered as spam,
- either by you or by Hotmail spam filter.
- {% endcall %}
-
- {% call text() %}
- Please note that explicitly marking a SimpleLogin's forwarded email as Spam
- affects SimpleLogin email delivery,
- has a negative effect for all users and is a violation of our terms and condition.
- {% endcall %}
-
- {% call text() %}
- If somehow Hotmail considers a forwarded email as Spam, it helps us if you can move the email
- out of the Spam folder. You can also set up a filter to avoid this
- from happening in the future using this guide at
- https://simplelogin.io/docs/getting-started/troubleshooting/
-
- {% endcall %}
-
- {% call text() %}
- Please don't put our emails into the Spam folder. This can end up in your account being disabled on SimpleLogin.
- {% endcall %}
-
- {% call text() %}
- Don't hesitate to get in touch with us if you need more information.
- {% endcall %}
-
- {% call text() %}
- Best,
- SimpleLogin Team.
- {% endcall %}
-
-{% endblock %}
-
-
diff --git a/templates/emails/transactional/hotmail-transactional-complaint.txt.jinja2 b/templates/emails/transactional/hotmail-transactional-complaint.txt.jinja2
deleted file mode 100644
index 7969130c..00000000
--- a/templates/emails/transactional/hotmail-transactional-complaint.txt.jinja2
+++ /dev/null
@@ -1,23 +0,0 @@
-{% extends "base.txt.jinja2" %}
-
-{% block content %}
-Hi,
-
-This is SimpleLogin team.
-
-Hotmail has informed us about an email sent to {{ user.email }} that might have been considered as spam,
-either by you or by Hotmail.
-
-Please note that explicitly marking a SimpleLogin's forwarded email as Spam
- affects SimpleLogin email delivery,
- has a negative effect for all users and is a violation of our terms and condition.
-
-If somehow Hotmail considers a forwarded email as Spam, it helps us if you can move the email
- out of the Spam folder. You can also set up a filter to avoid this
- from happening in the future using this guide at
- https://simplelogin.io/docs/getting-started/troubleshooting/
-
-Please don't put our emails into the Spam folder. This can end up in your account being disabled on SimpleLogin.
-
-Don't hesitate to get in touch with us if you need more information.
-{% endblock %}
diff --git a/templates/emails/transactional/yahoo-transactional-complaint.html b/templates/emails/transactional/transactional-complaint-forward-phase.html
similarity index 73%
rename from templates/emails/transactional/yahoo-transactional-complaint.html
rename to templates/emails/transactional/transactional-complaint-forward-phase.html
index 9d8db38f..a1502b1f 100644
--- a/templates/emails/transactional/yahoo-transactional-complaint.html
+++ b/templates/emails/transactional/transactional-complaint-forward-phase.html
@@ -6,18 +6,17 @@
{% endcall %}
{% call text() %}
- Yahoo has informed us about an email sent to {{ user.email }} that might have been considered as spam,
- either by you or by Yahoo spam filter.
+ {{ provider }} has informed us about an email sent to {{ user.email }} that might have been considered as spam,
+ either by you or by {{ provider }} spam filter.
{% endcall %}
{% call text() %}
- Please note that explicitly marking a SimpleLogin's forwarded email as Spam
- affects SimpleLogin email delivery,
+ Please note that explicitly marking a SimpleLogin's forwarded email as Spam affects SimpleLogin email delivery,
has a negative effect for all users and is a violation of our terms and condition.
{% endcall %}
{% call text() %}
- If somehow Yahoo considers a forwarded email as Spam, it helps us if you can move the email
+ If somehow {{ provider }} considers a forwarded email as Spam, it helps us if you can move the email
out of the Spam folder. You can also set up a filter to avoid this
from happening in the future using this guide at
https://simplelogin.io/docs/getting-started/troubleshooting/
diff --git a/templates/emails/transactional/yahoo-transactional-complaint.txt.jinja2 b/templates/emails/transactional/transactional-complaint-forward-phase.txt.jinja2
similarity index 70%
rename from templates/emails/transactional/yahoo-transactional-complaint.txt.jinja2
rename to templates/emails/transactional/transactional-complaint-forward-phase.txt.jinja2
index 46b18b31..428674e1 100644
--- a/templates/emails/transactional/yahoo-transactional-complaint.txt.jinja2
+++ b/templates/emails/transactional/transactional-complaint-forward-phase.txt.jinja2
@@ -5,14 +5,14 @@ Hi,
This is SimpleLogin team.
-Yahoo has informed us about an email sent to {{ user.email }} that might have been considered as spam,
-either by you or by Yahoo.
+{{ provider }} has informed us about an email sent to {{ user.email }} that might have been considered as spam,
+either by you or by {{ provider }}.
Please note that explicitly marking a SimpleLogin's forwarded email as Spam
affects SimpleLogin email delivery,
has a negative effect for all users and is a violation of our terms and condition.
-If somehow Yahoo considers a forwarded email as Spam, it helps us if you can move the email
+If somehow {{ provider }} considers a forwarded email as Spam, it helps us if you can move the email
out of the Spam folder. You can also set up a filter to avoid this
from happening in the future using this guide at
https://simplelogin.io/docs/getting-started/troubleshooting/
diff --git a/templates/emails/transactional/transactional-complaint-reply-phase.txt.jinja2 b/templates/emails/transactional/transactional-complaint-reply-phase.txt.jinja2
new file mode 100644
index 00000000..76e5fbda
--- /dev/null
+++ b/templates/emails/transactional/transactional-complaint-reply-phase.txt.jinja2
@@ -0,0 +1,15 @@
+{% extends "base.txt.jinja2" %}
+
+{% block content %}
+Hi,
+
+This is SimpleLogin team.
+
+We have received a report from {{ provider }} informing us about an email sent from your alias {{ alias.email }} to {{ destination }} that might have been considered as spam, either by the recipient or by their spam filter.
+
+Please note that sending non-solicited from a SimpleLogin alias infringes our terms and condition as it severely affects SimpleLogin email delivery.
+
+If somehow the recipient's provider considers a forwarded email as Spam, it helps us a lot if you can ask them to move the email out of their Spam folder.
+
+Don't hesitate to get in touch with us if you need more information.
+{% endblock %}
diff --git a/templates/emails/transactional/yahoo-complaint.html b/templates/emails/transactional/transactional-complaint-to-user.html
similarity index 88%
rename from templates/emails/transactional/yahoo-complaint.html
rename to templates/emails/transactional/transactional-complaint-to-user.html
index c9814ce1..d224f98b 100644
--- a/templates/emails/transactional/yahoo-complaint.html
+++ b/templates/emails/transactional/transactional-complaint-to-user.html
@@ -6,7 +6,7 @@
{% endcall %}
{% call text() %}
- Yahoo has informed us about an email sent to {{ alias.email }} that might have been marked as spam.
+ {{ provider }} has informed us about an email sent to {{ user.email }} that might have been marked as spam.
{% endcall %}
{% call text() %}
diff --git a/templates/emails/transactional/yahoo-complaint.txt.jinja2 b/templates/emails/transactional/transactional-complaint-to-user.txt.jinja2
similarity index 84%
rename from templates/emails/transactional/yahoo-complaint.txt.jinja2
rename to templates/emails/transactional/transactional-complaint-to-user.txt.jinja2
index 8007e985..a90add48 100644
--- a/templates/emails/transactional/yahoo-complaint.txt.jinja2
+++ b/templates/emails/transactional/transactional-complaint-to-user.txt.jinja2
@@ -5,7 +5,7 @@ Hi,
This is SimpleLogin team.
-Yahoo has informed us about an email sent to {{ alias.email }} that might have been marked as spam.
+{{ provider }} has informed us about an email sent to {{ user.email }} that might have been marked as spam.
Please note that explicitly marking a SimpleLogin's forwarded email as Spam affects SimpleLogin email delivery,
has a negative effect for all users and is a violation of our terms and condition.
diff --git a/tests/handler/test_transactional_complaints.py b/tests/handler/test_transactional_complaints.py
new file mode 100644
index 00000000..49dd5004
--- /dev/null
+++ b/tests/handler/test_transactional_complaints.py
@@ -0,0 +1,92 @@
+import email
+from email.message import Message
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+import pytest
+
+from app.config import (
+ ALERT_COMPLAINT_FORWARD_PHASE,
+ ALERT_COMPLAINT_REPLY_PHASE,
+ ALERT_COMPLAINT_TO_USER,
+)
+from app.db import Session
+from app.email import headers
+from app.handler.transactional_complaint import (
+ handle_hotmail_complaint,
+ handle_yahoo_complaint,
+)
+from app.models import Alias, TransactionalComplaint, SentAlert
+from tests.utils import create_new_user
+
+origins = [
+ [handle_yahoo_complaint, "yahoo", 6],
+ [handle_hotmail_complaint, "hotmail", 3],
+]
+
+
+def prepare_complaint(message: Message, part_num: int) -> Message:
+ complaint = MIMEMultipart("related")
+ # When walking, part 0 is the full message so we -1, and we want to be part N so -1 again
+ for i in range(part_num - 2):
+ document = MIMEText("text", "plain")
+ document.set_payload(f"Part {i}")
+ complaint.attach(document)
+ complaint.attach(message)
+
+ return email.message_from_bytes(complaint.as_bytes())
+
+
+@pytest.mark.parametrize("handle_ftor,provider,part_num", origins)
+def test_transactional_to_user(flask_client, handle_ftor, provider, part_num):
+ user = create_new_user()
+ original_message = Message()
+ original_message[headers.TO] = user.email
+ original_message[headers.FROM] = "nobody@nowhere.net"
+ original_message.set_payload("Contents")
+
+ complaint = prepare_complaint(original_message, part_num)
+ assert handle_ftor(complaint)
+ found = TransactionalComplaint.filter_by(user_id=user.id).all()
+ assert len(found) == 0
+ alerts = SentAlert.filter_by(user_id=user.id).all()
+ assert len(alerts) == 1
+ assert alerts[0].alert_type == f"{ALERT_COMPLAINT_TO_USER}_{provider}"
+
+
+@pytest.mark.parametrize("handle_ftor,provider,part_num", origins)
+def test_transactional_forward_phase(flask_client, handle_ftor, provider, part_num):
+ user = create_new_user()
+ alias = Alias.create_new_random(user)
+ Session.commit()
+ original_message = Message()
+ original_message[headers.TO] = "nobody@nowhere.net"
+ original_message[headers.FROM] = alias.email
+ original_message.set_payload("Contents")
+
+ complaint = prepare_complaint(original_message, part_num)
+ assert handle_ftor(complaint)
+ found = TransactionalComplaint.filter_by(user_id=user.id).all()
+ assert len(found) == 1
+ alerts = SentAlert.filter_by(user_id=user.id).all()
+ assert len(alerts) == 1
+ assert alerts[0].alert_type == f"{ALERT_COMPLAINT_REPLY_PHASE}_{provider}"
+
+
+@pytest.mark.parametrize("handle_ftor,provider,part_num", origins)
+def test_transactional_reply_phase(flask_client, handle_ftor, provider, part_num):
+ user = create_new_user()
+ alias = Alias.create_new_random(user)
+ Session.commit()
+ original_message = Message()
+ original_message[headers.TO] = alias.email
+ original_message[headers.FROM] = "no@no.no"
+ original_message.set_payload("Contents")
+
+ complaint = prepare_complaint(original_message, part_num)
+ assert handle_ftor(complaint)
+ found = TransactionalComplaint.filter_by(user_id=user.id).all()
+ assert len(found) == 0
+ alerts = SentAlert.filter_by(user_id=user.id).all()
+ assert len(alerts) == 1
+ assert alerts[0].alert_type == f"{ALERT_COMPLAINT_FORWARD_PHASE}_{provider}"
diff --git a/tests/utils.py b/tests/utils.py
index 9d4b5d46..758d65ac 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -11,17 +11,11 @@ from flask import url_for
from app.models import User
-# keep track of the number of user
-_nb_user = 0
-
def create_new_user() -> User:
- global _nb_user
- _nb_user += 1
-
# new user has a different email address
user = User.create(
- email=f"{_nb_user}@mailbox.test",
+ email=f"user{random.random()}@mailbox.test",
password="password",
name="Test User",
activated=True,