Merge pull request #175 from simple-login/rate-control

Email Rate control
This commit is contained in:
Son Nguyen Kim 2020-05-09 20:47:08 +02:00 committed by GitHub
commit 31341ecae7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 145 additions and 6 deletions

View File

@ -254,3 +254,15 @@ with open(get_abs_path(DISPOSABLE_FILE_PATH), "r") as f:
APPLE_API_SECRET = os.environ.get("APPLE_API_SECRET") APPLE_API_SECRET = os.environ.get("APPLE_API_SECRET")
# for Mac App # for Mac App
MACAPP_APPLE_API_SECRET = os.environ.get("MACAPP_APPLE_API_SECRET") MACAPP_APPLE_API_SECRET = os.environ.get("MACAPP_APPLE_API_SECRET")
# maximal number of alerts that can be sent to the same email in 24h
MAX_ALERT_24H = 4
# When a reverse-alias receives emails from un unknown mailbox
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX = "reverse_alias_unknown_mailbox"
# When a forwarding email is bounced
ALERT_BOUNCE_EMAIL = "bounce"
# When a forwarding email is detected as spam
ALERT_SPAM_EMAIL = "spam"

View File

@ -8,6 +8,7 @@ from email.utils import make_msgid, formatdate, parseaddr
from smtplib import SMTP from smtplib import SMTP
from typing import Optional from typing import Optional
import arrow
import dkim import dkim
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
@ -24,10 +25,12 @@ from app.config import (
POSTFIX_SUBMISSION_TLS, POSTFIX_SUBMISSION_TLS,
MAX_NB_EMAIL_FREE_PLAN, MAX_NB_EMAIL_FREE_PLAN,
DISPOSABLE_EMAIL_DOMAINS, DISPOSABLE_EMAIL_DOMAINS,
MAX_ALERT_24H,
) )
from app.dns_utils import get_mx_domains from app.dns_utils import get_mx_domains
from app.extensions import db
from app.log import LOG from app.log import LOG
from app.models import Mailbox, User from app.models import Mailbox, User, SentAlert
def render(template_name, **kwargs) -> str: def render(template_name, **kwargs) -> str:
@ -235,6 +238,43 @@ def send_email(
smtp.sendmail(SUPPORT_EMAIL, to_email, msg_raw) smtp.sendmail(SUPPORT_EMAIL, to_email, msg_raw)
def send_email_with_rate_control(
user: User,
alert_type: str,
to_email: str,
subject,
plaintext,
html=None,
bounced_email: Optional[Message] = None,
) -> bool:
"""Same as send_email with rate control over alert_type.
For now no more than _MAX_ALERT_24h alert can be sent in the last 24h
Return true if the email is sent, otherwise False
"""
to_email = to_email.lower().strip()
one_day_ago = arrow.now().shift(days=-1)
nb_alert = (
SentAlert.query.filter_by(alert_type=alert_type, to_email=to_email)
.filter(SentAlert.created_at > one_day_ago)
.count()
)
if nb_alert > MAX_ALERT_24H:
LOG.error(
"%s emails were sent to %s in the last 24h, alert type %s",
nb_alert,
to_email,
alert_type,
)
return False
SentAlert.create(user_id=user.id, alert_type=alert_type, to_email=to_email)
db.session.commit()
send_email(to_email, subject, plaintext, html, bounced_email)
return True
def get_email_local_part(address): def get_email_local_part(address):
""" """
Get the local part from email Get the local part from email

View File

@ -1248,3 +1248,21 @@ class Referral(db.Model, ModelMixin):
def link(self): def link(self):
return f"{LANDING_PAGE_URL}?slref={self.code}" return f"{LANDING_PAGE_URL}?slref={self.code}"
class SentAlert(db.Model, ModelMixin):
"""keep track of alerts sent to user.
User can receive an alert when there's abnormal activity on their aliases such as
- reverse-alias not used by the owning mailbox
- SPF fails when using the reverse-alias
- bounced email
- ...
Different rate controls can then be implemented based on SentAlert:
- only once alert: an alert type should be sent only once
- max number of sent per 24H: an alert type should not be sent more than X times in 24h
"""
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
to_email = db.Column(db.String(256), nullable=False)
alert_type = db.Column(db.String(256), nullable=False)

View File

@ -56,6 +56,9 @@ from app.config import (
UNSUBSCRIBER, UNSUBSCRIBER,
LOAD_PGP_EMAIL_HANDLER, LOAD_PGP_EMAIL_HANDLER,
ENFORCE_SPF, ENFORCE_SPF,
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
ALERT_BOUNCE_EMAIL,
ALERT_SPAM_EMAIL,
) )
from app.email_utils import ( from app.email_utils import (
send_email, send_email,
@ -70,6 +73,7 @@ from app.email_utils import (
get_spam_info, get_spam_info,
get_orig_message_from_spamassassin_report, get_orig_message_from_spamassassin_report,
parseaddr_unicode, parseaddr_unicode,
send_email_with_rate_control,
) )
from app.extensions import db from app.extensions import db
from app.greylisting import greylisting_needed from app.greylisting import greylisting_needed
@ -511,7 +515,9 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str
reply_email, reply_email,
) )
send_email( send_email_with_rate_control(
user,
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
mailbox_email, mailbox_email,
f"Reply from your alias {alias.email} only works from your mailbox", f"Reply from your alias {alias.email} only works from your mailbox",
render( render(
@ -531,7 +537,9 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str
) )
# Notify sender that they cannot send emails to this address # Notify sender that they cannot send emails to this address
send_email( send_email_with_rate_control(
user,
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
envelope.mail_from, envelope.mail_from,
f"Your email ({envelope.mail_from}) is not allowed to send emails to {reply_email}", f"Your email ({envelope.mail_from}) is not allowed to send emails to {reply_email}",
render( render(
@ -660,7 +668,9 @@ def handle_bounce(
contact.website_email, contact.website_email,
address, address,
) )
send_email( send_email_with_rate_control(
user,
ALERT_BOUNCE_EMAIL,
# use user mail here as only user is authenticated to see the refused email # use user mail here as only user is authenticated to see the refused email
user.email, user.email,
f"Email from {contact.website_email} to {address} cannot be delivered to your inbox", f"Email from {contact.website_email} to {address} cannot be delivered to your inbox",
@ -695,7 +705,9 @@ def handle_bounce(
alias.enabled = False alias.enabled = False
db.session.commit() db.session.commit()
send_email( send_email_with_rate_control(
user,
ALERT_BOUNCE_EMAIL,
# use user mail here as only user is authenticated to see the refused email # use user mail here as only user is authenticated to see the refused email
user.email, user.email,
f"Alias {address} has been disabled due to second undelivered email from {contact.website_email}", f"Alias {address} has been disabled due to second undelivered email from {contact.website_email}",
@ -765,7 +777,9 @@ def handle_spam(
contact.website_email, contact.website_email,
alias.email, alias.email,
) )
send_email( send_email_with_rate_control(
user,
ALERT_SPAM_EMAIL,
mailbox_email, mailbox_email,
f"Email from {contact.website_email} to {alias.email} is detected as spam", f"Email from {contact.website_email} to {alias.email} is detected as spam",
render( render(

View File

@ -0,0 +1,38 @@
"""empty message
Revision ID: a5e3c6693dc6
Revises: a3a7c518ea70
Create Date: 2020-05-09 20:45:15.014387
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a5e3c6693dc6'
down_revision = 'a3a7c518ea70'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sent_alert',
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('to_email', sa.String(length=256), nullable=False),
sa.Column('alert_type', sa.String(length=256), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('sent_alert')
# ### end Alembic commands ###

View File

@ -1,5 +1,6 @@
from email.message import EmailMessage from email.message import EmailMessage
from app.config import MAX_ALERT_24H
from app.email_utils import ( from app.email_utils import (
get_email_domain_part, get_email_domain_part,
email_belongs_to_alias_domains, email_belongs_to_alias_domains,
@ -7,6 +8,7 @@ from app.email_utils import (
delete_header, delete_header,
add_or_replace_header, add_or_replace_header,
parseaddr_unicode, parseaddr_unicode,
send_email_with_rate_control,
) )
from app.extensions import db from app.extensions import db
from app.models import User, CustomDomain from app.models import User, CustomDomain
@ -101,3 +103,18 @@ def test_parseaddr_unicode():
"pöstal", "pöstal",
"abcd@gmail.com", "abcd@gmail.com",
) )
def test_send_email_with_rate_control(flask_client):
user = User.create(
email="a@b.c", password="password", name="Test User", activated=True
)
db.session.commit()
for _ in range(MAX_ALERT_24H + 1):
assert send_email_with_rate_control(
user, "test alert type", "abcd@gmail.com", "subject", "plaintext"
)
assert not send_email_with_rate_control(
user, "test alert type", "abcd@gmail.com", "subject", "plaintext"
)