Get the mailbox if possible from the email log

This commit is contained in:
Adrià Casajús 2022-05-10 23:34:57 +02:00
parent d2111d4768
commit 48554369bd
No known key found for this signature in database
GPG Key ID: F0033226A5AFC9B9
9 changed files with 122 additions and 48 deletions

View File

@ -19,6 +19,7 @@ DKIM_SIGNATURE = "DKIM-Signature"
X_SPAM_STATUS = "X-Spam-Status" X_SPAM_STATUS = "X-Spam-Status"
LIST_UNSUBSCRIBE = "List-Unsubscribe" LIST_UNSUBSCRIBE = "List-Unsubscribe"
LIST_UNSUBSCRIBE_POST = "List-Unsubscribe-Post" LIST_UNSUBSCRIBE_POST = "List-Unsubscribe-Post"
RETURN_PATH = "Return-Path"
# headers used to DKIM sign in order of preference # headers used to DKIM sign in order of preference
DKIM_HEADERS = [ DKIM_HEADERS = [

View File

@ -1409,7 +1409,9 @@ def generate_verp_email(
).lower() ).lower()
def get_verp_info_from_email(email: str) -> Optional[Tuple[VerpType, int]]: def get_verp_info_from_email(
email: str, validate_time: bool = True
) -> Optional[Tuple[VerpType, int]]:
"""This method processes the email address, checks if it's a signed verp email generated by us to receive bounces """This method processes the email address, checks if it's a signed verp email generated by us to receive bounces
and extracts the type of verp email and associated email log id/transactional email id stored as object_id and extracts the type of verp email and associated email log id/transactional email id stored as object_id
""" """
@ -1433,6 +1435,8 @@ def get_verp_info_from_email(email: str) -> Optional[Tuple[VerpType, int]]:
# verp type, object_id, time # verp type, object_id, time
if len(data) != 3: if len(data) != 3:
return None return None
if data[2] > (time.time() + VERP_MESSAGE_LIFETIME - VERP_TIME_START) / 60: if validate_time and (
data[2] > (time.time() + VERP_MESSAGE_LIFETIME - VERP_TIME_START) / 60
):
return None return None
return VerpType(data[0]), data[1] return VerpType(data[0]), data[1]

View File

@ -20,6 +20,7 @@ from app.email_utils import (
send_email_with_rate_control, send_email_with_rate_control,
parse_address_list, parse_address_list,
get_header_unicode, get_header_unicode,
get_verp_info_from_email,
) )
from app.log import LOG from app.log import LOG
from app.models import ( from app.models import (
@ -32,25 +33,44 @@ from app.models import (
Phase, Phase,
ProviderComplaintState, ProviderComplaintState,
RefusedEmail, RefusedEmail,
VerpType,
EmailLog,
Mailbox,
) )
@dataclass @dataclass
class OriginalAddresses: class OriginalMessageInformation:
sender: str sender_address: str
recipient: str rcpt_address: str
mailbox_address: Optional[str]
class ProviderComplaintOrigin(ABC): class ProviderComplaintOrigin(ABC):
@classmethod @classmethod
@abstractmethod @abstractmethod
def get_original_addresses(cls, message: Message) -> Optional[OriginalAddresses]: def get_original_addresses(
cls, message: Message
) -> Optional[OriginalMessageInformation]:
pass pass
@classmethod @classmethod
def sanitize_addresses( def _get_mailbox_id(cls, return_path: Optional[str]) -> Optional[Mailbox]:
if not return_path:
return None
_, return_path = parse_full_address(get_header_unicode(return_path))
verp_type, email_log_id = get_verp_info_from_email(return_path)
if verp_type == VerpType.transactional:
return None
email_log = EmailLog.get_by(id=email_log_id)
if email_log:
return email_log.mailbox.email
return None
@classmethod
def sanitize_addresses_and_extract_mailbox_id(
cls, rcpt_header: Optional[str], message: Message cls, rcpt_header: Optional[str], message: Message
) -> Optional[OriginalAddresses]: ) -> Optional[OriginalMessageInformation]:
""" """
If the rcpt_header is not None, use it as the valid rcpt address, otherwise try to extract it from the To header If the rcpt_header is not None, use it as the valid rcpt address, otherwise try to extract it from the To header
of the original message, since in the original message there can be more than one recipients. of the original message, since in the original message there can be more than one recipients.
@ -65,8 +85,15 @@ class ProviderComplaintOrigin(ABC):
LOG.w(f"Cannot find rcpt. Saved to {saved_file or 'nowhere'}") LOG.w(f"Cannot find rcpt. Saved to {saved_file or 'nowhere'}")
return None return None
rcpt_address = rcpt_list[0][1] rcpt_address = rcpt_list[0][1]
_, sender_address = parse_full_address(message[headers.FROM]) _, sender_address = parse_full_address(
return OriginalAddresses(sender_address, rcpt_address) get_header_unicode(message[headers.FROM])
)
return OriginalMessageInformation(
sender_address,
rcpt_address,
cls._get_mailbox_id(message[headers.RETURN_PATH]),
)
except ValueError: except ValueError:
saved_file = save_email_for_debugging(message, "ComplaintOriginalAddress") saved_file = save_email_for_debugging(message, "ComplaintOriginalAddress")
LOG.w(f"Cannot parse from header. Saved to {saved_file or 'nowhere'}") LOG.w(f"Cannot parse from header. Saved to {saved_file or 'nowhere'}")
@ -105,7 +132,9 @@ class ProviderComplaintYahoo(ProviderComplaintOrigin):
return None return None
@classmethod @classmethod
def get_original_addresses(cls, message: Message) -> Optional[OriginalAddresses]: def get_original_addresses(
cls, message: Message
) -> Optional[OriginalMessageInformation]:
""" """
Try to get the proper recipient from the report that yahoo adds as a port of the complaint. If we cannot find Try to get the proper recipient from the report that yahoo adds as a port of the complaint. If we cannot find
the rcpt in the report or we can't find the report, use the first address in the original message from the rcpt in the report or we can't find the report, use the first address in the original message from
@ -113,7 +142,7 @@ class ProviderComplaintYahoo(ProviderComplaintOrigin):
report = cls.get_feedback_report(message) report = cls.get_feedback_report(message)
original = cls.get_original_message(message) original = cls.get_original_message(message)
rcpt_header = report["original-rcpt-to"] rcpt_header = report["original-rcpt-to"]
return cls.sanitize_addresses(rcpt_header, original) return cls.sanitize_addresses_and_extract_mailbox_id(rcpt_header, original)
@classmethod @classmethod
def name(cls): def name(cls):
@ -134,13 +163,15 @@ class ProviderComplaintHotmail(ProviderComplaintOrigin):
return None return None
@classmethod @classmethod
def get_original_addresses(cls, message: Message) -> Optional[OriginalAddresses]: def get_original_addresses(
cls, message: Message
) -> Optional[OriginalMessageInformation]:
""" """
Try to get the proper recipient from original x-simplelogin-envelope-to header we add on delivery. Try to get the proper recipient from original x-simplelogin-envelope-to header we add on delivery.
If we can't find the header, use the first address in the original message from""" If we can't find the header, use the first address in the original message from"""
original = cls.get_original_message(message) original = cls.get_original_message(message)
rcpt_header = original["x-simplelogin-envelope-to"] rcpt_header = original["x-simplelogin-envelope-to"]
return cls.sanitize_addresses(rcpt_header, original) return cls.sanitize_addresses_and_extract_mailbox_id(rcpt_header, original)
@classmethod @classmethod
def name(cls): def name(cls):
@ -164,50 +195,55 @@ def find_alias_with_address(address: str) -> Optional[Alias]:
def handle_complaint(message: Message, origin: ProviderComplaintOrigin) -> bool: def handle_complaint(message: Message, origin: ProviderComplaintOrigin) -> bool:
addresses = origin.get_original_addresses(message) msg_info = origin.get_original_addresses(message)
if not addresses: if not msg_info:
return False return False
user = User.get_by(email=addresses.recipient) user = User.get_by(email=msg_info.rcpt_address)
if user: if user:
LOG.d(f"Handle provider {origin.name()} complaint for {user}") LOG.d(f"Handle provider {origin.name()} complaint for {user}")
report_complaint_to_user_in_transactional_phase(user, origin) report_complaint_to_user_in_transactional_phase(user, origin, msg_info)
return True return True
alias = find_alias_with_address(addresses.sender) alias = find_alias_with_address(msg_info.sender_address)
# the email is during a reply phase, from=alias and to=destination # the email is during a reply phase, from=alias and to=destination
if alias: if alias:
LOG.i( LOG.i(
f"Complaint from {origin.name} during reply phase {alias} -> {addresses.recipient}, {user}" f"Complaint from {origin.name} during reply phase {alias} -> {msg_info.rcpt_address}, {user}"
)
report_complaint_to_user_in_reply_phase(
alias, msg_info.rcpt_address, origin, msg_info
) )
report_complaint_to_user_in_reply_phase(alias, addresses.recipient, origin)
store_provider_complaint(alias, message) store_provider_complaint(alias, message)
return True return True
contact = Contact.get_by(reply_email=addresses.sender) contact = Contact.get_by(reply_email=msg_info.sender_address)
if contact: if contact:
alias = contact.alias alias = contact.alias
else: else:
alias = find_alias_with_address(addresses.recipient) alias = find_alias_with_address(msg_info.rcpt_address)
if not alias: if not alias:
LOG.e( LOG.e(
f"Cannot find alias for address {addresses.recipient} or contact with reply {addresses.sender}" f"Cannot find alias for address {msg_info.rcpt_address} or contact with reply {msg_info.sender_address}"
) )
return False return False
report_complaint_to_user_in_forward_phase(alias, origin) report_complaint_to_user_in_forward_phase(alias, origin, msg_info)
return True return True
def report_complaint_to_user_in_reply_phase( def report_complaint_to_user_in_reply_phase(
alias: Alias, to_address: str, origin: ProviderComplaintOrigin alias: Alias,
to_address: str,
origin: ProviderComplaintOrigin,
msg_info: OriginalMessageInformation,
): ):
capitalized_name = origin.name().capitalize() capitalized_name = origin.name().capitalize()
send_email_with_rate_control( send_email_with_rate_control(
alias.user, alias.user,
f"{ALERT_COMPLAINT_REPLY_PHASE}_{origin.name()}", f"{ALERT_COMPLAINT_REPLY_PHASE}_{origin.name()}",
alias.user.email, msg_info.mailbox_address or alias.mailbox.email,
f"Abuse report from {capitalized_name}", f"Abuse report from {capitalized_name}",
render( render(
"transactional/provider-complaint-reply-phase.txt.jinja2", "transactional/provider-complaint-reply-phase.txt.jinja2",
@ -222,13 +258,13 @@ def report_complaint_to_user_in_reply_phase(
def report_complaint_to_user_in_transactional_phase( def report_complaint_to_user_in_transactional_phase(
user: User, origin: ProviderComplaintOrigin user: User, origin: ProviderComplaintOrigin, msg_info: OriginalMessageInformation
): ):
capitalized_name = origin.name().capitalize() capitalized_name = origin.name().capitalize()
send_email_with_rate_control( send_email_with_rate_control(
user, user,
f"{ALERT_COMPLAINT_TRANSACTIONAL_PHASE}_{origin.name()}", f"{ALERT_COMPLAINT_TRANSACTIONAL_PHASE}_{origin.name()}",
user.email, msg_info.mailbox_address or user.email,
f"Abuse report from {capitalized_name}", f"Abuse report from {capitalized_name}",
render( render(
"transactional/provider-complaint-to-user.txt.jinja2", "transactional/provider-complaint-to-user.txt.jinja2",
@ -246,23 +282,24 @@ def report_complaint_to_user_in_transactional_phase(
def report_complaint_to_user_in_forward_phase( def report_complaint_to_user_in_forward_phase(
alias: Alias, origin: ProviderComplaintOrigin alias: Alias, origin: ProviderComplaintOrigin, msg_info: OriginalMessageInformation
): ):
capitalized_name = origin.name().capitalize() capitalized_name = origin.name().capitalize()
user = alias.user user = alias.user
mailbox_email = msg_info.mailbox_address or alias.mailbox.email
send_email_with_rate_control( send_email_with_rate_control(
user, user,
f"{ALERT_COMPLAINT_FORWARD_PHASE}_{origin.name()}", f"{ALERT_COMPLAINT_FORWARD_PHASE}_{origin.name()}",
user.email, mailbox_email,
f"Abuse report from {capitalized_name}", f"Abuse report from {capitalized_name}",
render( render(
"transactional/provider-complaint-forward-phase.txt.jinja2", "transactional/provider-complaint-forward-phase.txt.jinja2",
user=user, email=mailbox_email,
provider=capitalized_name, provider=capitalized_name,
), ),
render( render(
"transactional/provider-complaint-forward-phase.html", "transactional/provider-complaint-forward-phase.html",
user=user, email=mailbox_email,
provider=capitalized_name, provider=capitalized_name,
), ),
max_nb_alert=1, max_nb_alert=1,

View File

@ -1,5 +1,5 @@
[pytest] [pytest]
xaddopts = addopts =
--cov --cov
--cov-config coverage.ini --cov-config coverage.ini
--cov-report=html:htmlcov --cov-report=html:htmlcov

7
reset_local_db.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/sh
export DB_URI=postgresql://myuser:mypassword@localhost:15432/simplelogin
echo 'drop schema public cascade; create schema public;' | psql $DB_URI
poetry run alembic upgrade head
poetry run flask dummy-data

6
reset_test_db.sh Executable file
View File

@ -0,0 +1,6 @@
#!/bin/sh
export DB_URI=postgresql://myuser:mypassword@localhost:15432/test
echo 'drop schema public cascade; create schema public;' | psql $DB_URI
poetry run alembic upgrade head

View File

@ -6,7 +6,7 @@
{% endcall %} {% endcall %}
{% call text() %} {% call text() %}
{{ provider }} has informed us about an email sent to <b>{{ user.email }}</b> that might have been considered as spam, {{ provider }} has informed us about an email sent to <b>{{ email }}</b> that might have been considered as spam,
either by you or by {{ provider }} spam filter. either by you or by {{ provider }} spam filter.
{% endcall %} {% endcall %}

View File

@ -5,7 +5,7 @@ Hi,
This is SimpleLogin team. This is SimpleLogin team.
{{ provider }} has informed us about an email sent to {{ user.email }} that might have been considered as spam, {{ provider }} has informed us about an email sent to {{ email }} that might have been considered as spam,
either by you or by {{ provider }}. either by you or by {{ provider }}.
Please note that explicitly marking a SimpleLogin's forwarded email as Spam Please note that explicitly marking a SimpleLogin's forwarded email as Spam

View File

@ -8,12 +8,19 @@ from app.config import (
POSTMASTER, POSTMASTER,
) )
from app.db import Session from app.db import Session
from app.email import headers from app.email_utils import generate_verp_email
from app.handler.provider_complaint import ( from app.handler.provider_complaint import (
handle_hotmail_complaint, handle_hotmail_complaint,
handle_yahoo_complaint, handle_yahoo_complaint,
) )
from app.models import Alias, ProviderComplaint, SentAlert from app.models import (
Alias,
ProviderComplaint,
SentAlert,
EmailLog,
VerpType,
Contact,
)
from tests.utils import create_new_user, load_eml_file from tests.utils import create_new_user, load_eml_file
origins = [ origins = [
@ -23,13 +30,28 @@ origins = [
def prepare_complaint( def prepare_complaint(
provider_name: str, rcpt_address: str, sender_address: str provider_name: str, alias: Alias, rcpt_address: str, sender_address: str
) -> Message: ) -> Message:
contact = Contact.create(
user_id=alias.user.id,
alias_id=alias.id,
website_email="a@b.c",
reply_email="d@e.f",
commit=True,
)
elog = EmailLog.create(
user_id=alias.user.id,
mailbox_id=alias.user.default_mailbox_id,
contact_id=contact.id,
commit=True,
bounced=True,
)
return_path = generate_verp_email(VerpType.bounce_forward, elog.id)
return load_eml_file( return load_eml_file(
f"{provider_name}_complaint.eml", f"{provider_name}_complaint.eml",
{ {
"postmaster": POSTMASTER, "postmaster": POSTMASTER,
"return_path": "sl.something.other@simplelogin.co", "return_path": return_path,
"rcpt": rcpt_address, "rcpt": rcpt_address,
"sender": sender_address, "sender": sender_address,
"rcpt_comma_list": f"{rcpt_address},other_rcpt@somwhere.net", "rcpt_comma_list": f"{rcpt_address},other_rcpt@somwhere.net",
@ -40,7 +62,9 @@ def prepare_complaint(
@pytest.mark.parametrize("handle_ftor,provider", origins) @pytest.mark.parametrize("handle_ftor,provider", origins)
def test_provider_to_user(flask_client, handle_ftor, provider): def test_provider_to_user(flask_client, handle_ftor, provider):
user = create_new_user() user = create_new_user()
complaint = prepare_complaint(provider, user.email, "nobody@nowhere.net") alias = Alias.create_new_random(user)
Session.commit()
complaint = prepare_complaint(provider, alias, user.email, "nobody@nowhere.net")
assert handle_ftor(complaint) assert handle_ftor(complaint)
found = ProviderComplaint.filter_by(user_id=user.id).all() found = ProviderComplaint.filter_by(user_id=user.id).all()
assert len(found) == 0 assert len(found) == 0
@ -54,7 +78,7 @@ def test_provider_forward_phase(flask_client, handle_ftor, provider):
user = create_new_user() user = create_new_user()
alias = Alias.create_new_random(user) alias = Alias.create_new_random(user)
Session.commit() Session.commit()
complaint = prepare_complaint(provider, "nobody@nowhere.net", alias.email) complaint = prepare_complaint(provider, alias, "nobody@nowhere.net", alias.email)
assert handle_ftor(complaint) assert handle_ftor(complaint)
found = ProviderComplaint.filter_by(user_id=user.id).all() found = ProviderComplaint.filter_by(user_id=user.id).all()
assert len(found) == 1 assert len(found) == 1
@ -68,12 +92,7 @@ def test_provider_reply_phase(flask_client, handle_ftor, provider):
user = create_new_user() user = create_new_user()
alias = Alias.create_new_random(user) alias = Alias.create_new_random(user)
Session.commit() Session.commit()
original_message = Message() complaint = prepare_complaint(provider, alias, alias.email, "no@no.no")
original_message[headers.TO] = alias.email
original_message[headers.FROM] = "no@no.no"
original_message.set_payload("Contents")
complaint = prepare_complaint(provider, alias.email, "no@no.no")
assert handle_ftor(complaint) assert handle_ftor(complaint)
found = ProviderComplaint.filter_by(user_id=user.id).all() found = ProviderComplaint.filter_by(user_id=user.id).all()
assert len(found) == 0 assert len(found) == 0