Merge pull request #176 from simple-login/spf2
Alert user when SPF fails
This commit is contained in:
commit
c308e9f9bf
|
@ -255,6 +255,8 @@ APPLE_API_SECRET = os.environ.get("APPLE_API_SECRET")
|
|||
# for Mac App
|
||||
MACAPP_APPLE_API_SECRET = os.environ.get("MACAPP_APPLE_API_SECRET")
|
||||
|
||||
# <<<<< ALERT EMAIL >>>>
|
||||
|
||||
# maximal number of alerts that can be sent to the same email in 24h
|
||||
MAX_ALERT_24H = 4
|
||||
|
||||
|
@ -266,3 +268,7 @@ ALERT_BOUNCE_EMAIL = "bounce"
|
|||
|
||||
# When a forwarding email is detected as spam
|
||||
ALERT_SPAM_EMAIL = "spam"
|
||||
|
||||
ALERT_SPF = "spf"
|
||||
|
||||
# <<<<< END ALERT EMAIL >>>>
|
||||
|
|
|
@ -65,38 +65,6 @@
|
|||
<!-- END Change email -->
|
||||
|
||||
|
||||
{% if spf_available %}
|
||||
<!--
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="force-spf">
|
||||
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Enforce SPF
|
||||
<div class="small-text">
|
||||
Block emails to reverse alias if sender is not validated by SPF,
|
||||
even when SPF is configured as soft-fail.
|
||||
</div>
|
||||
</div>
|
||||
<label class="custom-switch cursor mt-2 pl-0"
|
||||
data-toggle="tooltip"
|
||||
{% if mailbox.force_spf %}
|
||||
title="Disable SPF enforcement"
|
||||
{% else %}
|
||||
title="Enable SPF enforcement"
|
||||
{% endif %}
|
||||
>
|
||||
<input type="checkbox" name="spf-status" class="custom-switch-input"
|
||||
{{ "checked" if mailbox.force_spf else "" }}>
|
||||
<span class="custom-switch-indicator"></span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
-->
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="pgp">
|
||||
|
@ -137,6 +105,46 @@
|
|||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h2 class="h4">Advanced Options</h2>
|
||||
|
||||
{% if spf_available %}
|
||||
<div class="card" id="spf">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="force-spf">
|
||||
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Enforce SPF
|
||||
<div class="small-text">
|
||||
To avoid email-spoofing, SimpleLogin blocks email that
|
||||
<em data-toggle="tooltip"
|
||||
title="Email that has your mailbox as envelope-sender address">seems</em> to come from your
|
||||
mailbox
|
||||
but sent from <em data-toggle="tooltip"
|
||||
title="IP Address that is not known by your mailbox email service">unknown</em>
|
||||
IP address.
|
||||
<br>
|
||||
Only turn off this option if you know what you're doing :).
|
||||
</div>
|
||||
</div>
|
||||
<label class="custom-switch cursor mt-2 pl-0"
|
||||
data-toggle="tooltip"
|
||||
{% if mailbox.force_spf %}
|
||||
title="Disable SPF enforcement"
|
||||
{% else %}
|
||||
title="Enable SPF enforcement"
|
||||
{% endif %}
|
||||
>
|
||||
<input type="checkbox" name="spf-status" class="custom-switch-input"
|
||||
{{ "checked" if mailbox.force_spf else "" }}>
|
||||
<span class="custom-switch-indicator"></span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -10,11 +10,13 @@ import collections
|
|||
import phpserialize
|
||||
import requests
|
||||
from Crypto.Hash import SHA1
|
||||
|
||||
# Crypto can be found at https://pypi.org/project/pycryptodome/
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Signature import PKCS1_v1_5
|
||||
|
||||
from app.config import PADDLE_PUBLIC_KEY_PATH, PADDLE_VENDOR_ID, PADDLE_AUTH_CODE
|
||||
|
||||
# Your Paddle public key.
|
||||
from app.log import LOG
|
||||
|
||||
|
|
184
email_handler.py
184
email_handler.py
|
@ -59,6 +59,7 @@ from app.config import (
|
|||
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
|
||||
ALERT_BOUNCE_EMAIL,
|
||||
ALERT_SPAM_EMAIL,
|
||||
ALERT_SPF,
|
||||
)
|
||||
from app.email_utils import (
|
||||
send_email,
|
||||
|
@ -476,84 +477,17 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str
|
|||
handle_bounce(contact, alias, msg, user, mailbox_email)
|
||||
return False, "550 SL E6"
|
||||
|
||||
mailb: Mailbox = Mailbox.get_by(email=mailbox_email)
|
||||
if ENFORCE_SPF and mailb.force_spf:
|
||||
if msg[_IP_HEADER]:
|
||||
LOG.d("Enforce SPF")
|
||||
try:
|
||||
r = spf.check2(i=msg[_IP_HEADER], s=envelope.mail_from.lower(), h=None)
|
||||
except Exception:
|
||||
LOG.error(
|
||||
"SPF error, mailbox %s, ip %s", mailbox_email, msg[_IP_HEADER]
|
||||
)
|
||||
else:
|
||||
# TODO: Handle temperr case (e.g. dns timeout)
|
||||
# only an absolute pass, or no SPF policy at all is 'valid'
|
||||
if r[0] not in ["pass", "none"]:
|
||||
LOG.error(
|
||||
"SPF fail for mailbox %s, reason %s, failed IP %s",
|
||||
mailbox_email,
|
||||
r[0],
|
||||
msg[_IP_HEADER],
|
||||
)
|
||||
return False, "451 SL E11"
|
||||
else:
|
||||
LOG.warning(
|
||||
"Could not find %s header %s -> %s", _IP_HEADER, mailbox_email, address,
|
||||
)
|
||||
mailbox: Mailbox = Mailbox.get_by(email=mailbox_email)
|
||||
if ENFORCE_SPF and mailbox.force_spf:
|
||||
ip = msg[_IP_HEADER]
|
||||
if not spf_pass(ip, envelope, mailbox, user, alias, address):
|
||||
return False, "451 SL E11"
|
||||
|
||||
delete_header(msg, _IP_HEADER)
|
||||
|
||||
# only mailbox can send email to the reply-email
|
||||
if envelope.mail_from.lower() != mailbox_email.lower():
|
||||
LOG.warning(
|
||||
f"Reply email can only be used by mailbox. "
|
||||
f"Actual mail_from: %s. msg from header: %s, Mailbox %s. reply_email %s",
|
||||
envelope.mail_from,
|
||||
msg["From"],
|
||||
mailbox_email,
|
||||
reply_email,
|
||||
)
|
||||
|
||||
send_email_with_rate_control(
|
||||
user,
|
||||
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
|
||||
mailbox_email,
|
||||
f"Reply from your alias {alias.email} only works from your mailbox",
|
||||
render(
|
||||
"transactional/reply-must-use-personal-email.txt",
|
||||
name=user.name,
|
||||
alias=alias.email,
|
||||
sender=envelope.mail_from,
|
||||
mailbox_email=mailbox_email,
|
||||
),
|
||||
render(
|
||||
"transactional/reply-must-use-personal-email.html",
|
||||
name=user.name,
|
||||
alias=alias.email,
|
||||
sender=envelope.mail_from,
|
||||
mailbox_email=mailbox_email,
|
||||
),
|
||||
)
|
||||
|
||||
# Notify sender that they cannot send emails to this address
|
||||
send_email_with_rate_control(
|
||||
user,
|
||||
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
|
||||
envelope.mail_from,
|
||||
f"Your email ({envelope.mail_from}) is not allowed to send emails to {reply_email}",
|
||||
render(
|
||||
"transactional/send-from-alias-from-unknown-sender.txt",
|
||||
sender=envelope.mail_from,
|
||||
reply_email=reply_email,
|
||||
),
|
||||
render(
|
||||
"transactional/send-from-alias-from-unknown-sender.html",
|
||||
sender=envelope.mail_from,
|
||||
reply_email=reply_email,
|
||||
),
|
||||
)
|
||||
|
||||
handle_unknown_mailbox(envelope, msg, mailbox, reply_email, user, alias)
|
||||
return False, "550 SL E7"
|
||||
|
||||
delete_header(msg, "DKIM-Signature")
|
||||
|
@ -619,6 +553,110 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str
|
|||
return True, "250 Message accepted for delivery"
|
||||
|
||||
|
||||
def spf_pass(
|
||||
ip: str, envelope, mailbox: Mailbox, user: User, alias: Alias, contact_email: str
|
||||
) -> bool:
|
||||
if ip:
|
||||
LOG.d("Enforce SPF")
|
||||
try:
|
||||
r = spf.check2(i=ip, s=envelope.mail_from.lower(), h=None)
|
||||
except Exception:
|
||||
LOG.error("SPF error, mailbox %s, ip %s", mailbox.email, ip)
|
||||
else:
|
||||
# TODO: Handle temperr case (e.g. dns timeout)
|
||||
# only an absolute pass, or no SPF policy at all is 'valid'
|
||||
if r[0] not in ["pass", "none"]:
|
||||
LOG.error(
|
||||
"SPF fail for mailbox %s, reason %s, failed IP %s",
|
||||
mailbox.email,
|
||||
r[0],
|
||||
ip,
|
||||
)
|
||||
send_email_with_rate_control(
|
||||
user,
|
||||
ALERT_SPF,
|
||||
mailbox.email,
|
||||
f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address",
|
||||
render(
|
||||
"transactional/spf-fail.txt",
|
||||
name=user.name,
|
||||
alias=alias.email,
|
||||
ip=ip,
|
||||
mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf",
|
||||
),
|
||||
render(
|
||||
"transactional/spf-fail.html",
|
||||
name=user.name,
|
||||
alias=alias.email,
|
||||
ip=ip,
|
||||
mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf",
|
||||
),
|
||||
)
|
||||
return False
|
||||
|
||||
else:
|
||||
LOG.warning(
|
||||
"Could not find %s header %s -> %s",
|
||||
_IP_HEADER,
|
||||
mailbox.email,
|
||||
contact_email,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_unknown_mailbox(
|
||||
envelope, msg, mailbox: Mailbox, reply_email: str, user: User, alias: Alias
|
||||
):
|
||||
LOG.warning(
|
||||
f"Reply email can only be used by mailbox. "
|
||||
f"Actual mail_from: %s. msg from header: %s, Mailbox %s. reply_email %s",
|
||||
envelope.mail_from,
|
||||
msg["From"],
|
||||
mailbox.email,
|
||||
reply_email,
|
||||
)
|
||||
|
||||
send_email_with_rate_control(
|
||||
user,
|
||||
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
|
||||
mailbox.email,
|
||||
f"Reply from your alias {alias.email} only works from your mailbox",
|
||||
render(
|
||||
"transactional/reply-must-use-personal-email.txt",
|
||||
name=user.name,
|
||||
alias=alias.email,
|
||||
sender=envelope.mail_from,
|
||||
mailbox_email=mailbox.email,
|
||||
),
|
||||
render(
|
||||
"transactional/reply-must-use-personal-email.html",
|
||||
name=user.name,
|
||||
alias=alias.email,
|
||||
sender=envelope.mail_from,
|
||||
mailbox_email=mailbox.email,
|
||||
),
|
||||
)
|
||||
|
||||
# Notify sender that they cannot send emails to this address
|
||||
send_email_with_rate_control(
|
||||
user,
|
||||
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
|
||||
envelope.mail_from,
|
||||
f"Your email ({envelope.mail_from}) is not allowed to send emails to {reply_email}",
|
||||
render(
|
||||
"transactional/send-from-alias-from-unknown-sender.txt",
|
||||
sender=envelope.mail_from,
|
||||
reply_email=reply_email,
|
||||
),
|
||||
render(
|
||||
"transactional/send-from-alias-from-unknown-sender.html",
|
||||
sender=envelope.mail_from,
|
||||
reply_email=reply_email,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def handle_bounce(
|
||||
contact: Contact, alias: Alias, msg: Message, user: User, mailbox_email: str
|
||||
):
|
||||
|
|
25
templates/emails/transactional/spf-fail.html
Normal file
25
templates/emails/transactional/spf-fail.html
Normal file
|
@ -0,0 +1,25 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{{ render_text("Hi " + name) }}
|
||||
|
||||
{% call text() %}
|
||||
We have recorded an attempt to send an email from your alias <b>{{ alias }}</b> from an unknown IP address
|
||||
<b>{{ ip }}</b>.
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
To prevent email-spoofing, SimpleLogin enforces the SPF (Sender Policy Framework).
|
||||
Emails sent from an IP address that is <b>unknown</b> by your email service are refused by default.
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
However you can turn off this option by going to {{ mailbox_url }}.
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
Please only turn this protection off this if you know what you're doing :).
|
||||
{% endcall %}
|
||||
|
||||
{{ render_text('Thanks, <br />SimpleLogin Team.') }}
|
||||
{% endblock %}
|
13
templates/emails/transactional/spf-fail.txt
Normal file
13
templates/emails/transactional/spf-fail.txt
Normal file
|
@ -0,0 +1,13 @@
|
|||
Hi {{name}}
|
||||
|
||||
We have recorded an attempt to send an email from your alias {{ alias }} from an unknown IP address {{ ip }}.
|
||||
|
||||
To prevent email-spoofing, SimpleLogin enforces the SPF (Sender Policy Framework).
|
||||
Emails sent from an IP address that is unknown by your email service are refused by default.
|
||||
|
||||
However you can turn off this option by going to {{mailbox_url}}.
|
||||
|
||||
Please only turn this protection off this if you know what you're doing :).
|
||||
|
||||
Best,
|
||||
SimpleLogin team.
|
Loading…
Reference in a new issue