Store bounces in the reply phase to prevent abuse
This commit is contained in:
parent
99d31698e7
commit
c573ef655e
|
@ -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 = "<a href='{}'>{}</a>".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
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -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])
|
||||
|
|
18
app/s3.py
18
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))
|
||||
|
|
197
email_handler.py
197
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
|
||||
|
|
|
@ -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")
|
|
@ -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):
|
||||
|
|
|
@ -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 %}
|
|
@ -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 <b>{{ alias.email }}</b> 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, <br/>
|
||||
SimpleLogin Team.
|
||||
{% endcall %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -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 %}
|
|
@ -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 <b>{{ user.email }}</b> 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, <br/>
|
||||
SimpleLogin Team.
|
||||
{% endcall %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -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 %}
|
|
@ -6,18 +6,17 @@
|
|||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
Yahoo has informed us about an email sent to <b>{{ user.email }}</b> that might have been considered as spam,
|
||||
either by you or by Yahoo spam filter.
|
||||
{{ provider }} has informed us about an email sent to <b>{{ user.email }}</b> 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/
|
|
@ -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/
|
|
@ -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 %}
|
|
@ -6,7 +6,7 @@
|
|||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
Yahoo has informed us about an email sent to <b>{{ alias.email }}</b> that might have been marked as spam.
|
||||
{{ provider }} has informed us about an email sent to <b>{{ user.email }}</b> that might have been marked as spam.
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
|
@ -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.
|
|
@ -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}"
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue