Store bounces in the reply phase to prevent abuse

This commit is contained in:
Adrià Casajús 2022-04-14 18:46:11 +02:00
parent 99d31698e7
commit c573ef655e
No known key found for this signature in database
GPG Key ID: F0033226A5AFC9B9
22 changed files with 531 additions and 389 deletions

View File

@ -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
)
},
)

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -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])

View File

@ -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))

View File

@ -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

View File

@ -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")

View File

@ -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):

View File

@ -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 %}

View File

@ -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 thats 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 %}

View File

@ -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 thats 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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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/

View File

@ -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/

View File

@ -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 %}

View File

@ -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() %}

View File

@ -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.

View File

@ -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}"

View File

@ -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,