mirror of
https://github.com/simple-login/app.git
synced 2024-11-13 07:31:12 +01:00
Merge pull request #175 from simple-login/rate-control
Email Rate control
This commit is contained in:
commit
31341ecae7
6 changed files with 145 additions and 6 deletions
|
@ -254,3 +254,15 @@ with open(get_abs_path(DISPOSABLE_FILE_PATH), "r") as f:
|
|||
APPLE_API_SECRET = os.environ.get("APPLE_API_SECRET")
|
||||
# for Mac App
|
||||
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"
|
||||
|
|
|
@ -8,6 +8,7 @@ from email.utils import make_msgid, formatdate, parseaddr
|
|||
from smtplib import SMTP
|
||||
from typing import Optional
|
||||
|
||||
import arrow
|
||||
import dkim
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
|
@ -24,10 +25,12 @@ from app.config import (
|
|||
POSTFIX_SUBMISSION_TLS,
|
||||
MAX_NB_EMAIL_FREE_PLAN,
|
||||
DISPOSABLE_EMAIL_DOMAINS,
|
||||
MAX_ALERT_24H,
|
||||
)
|
||||
from app.dns_utils import get_mx_domains
|
||||
from app.extensions import db
|
||||
from app.log import LOG
|
||||
from app.models import Mailbox, User
|
||||
from app.models import Mailbox, User, SentAlert
|
||||
|
||||
|
||||
def render(template_name, **kwargs) -> str:
|
||||
|
@ -235,6 +238,43 @@ def send_email(
|
|||
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):
|
||||
"""
|
||||
Get the local part from email
|
||||
|
|
|
@ -1248,3 +1248,21 @@ class Referral(db.Model, ModelMixin):
|
|||
|
||||
def link(self):
|
||||
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)
|
||||
|
|
|
@ -56,6 +56,9 @@ from app.config import (
|
|||
UNSUBSCRIBER,
|
||||
LOAD_PGP_EMAIL_HANDLER,
|
||||
ENFORCE_SPF,
|
||||
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
|
||||
ALERT_BOUNCE_EMAIL,
|
||||
ALERT_SPAM_EMAIL,
|
||||
)
|
||||
from app.email_utils import (
|
||||
send_email,
|
||||
|
@ -70,6 +73,7 @@ from app.email_utils import (
|
|||
get_spam_info,
|
||||
get_orig_message_from_spamassassin_report,
|
||||
parseaddr_unicode,
|
||||
send_email_with_rate_control,
|
||||
)
|
||||
from app.extensions import db
|
||||
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,
|
||||
)
|
||||
|
||||
send_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(
|
||||
|
@ -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
|
||||
send_email(
|
||||
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(
|
||||
|
@ -660,7 +668,9 @@ def handle_bounce(
|
|||
contact.website_email,
|
||||
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
|
||||
user.email,
|
||||
f"Email from {contact.website_email} to {address} cannot be delivered to your inbox",
|
||||
|
@ -695,7 +705,9 @@ def handle_bounce(
|
|||
alias.enabled = False
|
||||
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
|
||||
user.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,
|
||||
alias.email,
|
||||
)
|
||||
send_email(
|
||||
send_email_with_rate_control(
|
||||
user,
|
||||
ALERT_SPAM_EMAIL,
|
||||
mailbox_email,
|
||||
f"Email from {contact.website_email} to {alias.email} is detected as spam",
|
||||
render(
|
||||
|
|
38
migrations/versions/2020_050920_a5e3c6693dc6_.py
Normal file
38
migrations/versions/2020_050920_a5e3c6693dc6_.py
Normal 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 ###
|
|
@ -1,5 +1,6 @@
|
|||
from email.message import EmailMessage
|
||||
|
||||
from app.config import MAX_ALERT_24H
|
||||
from app.email_utils import (
|
||||
get_email_domain_part,
|
||||
email_belongs_to_alias_domains,
|
||||
|
@ -7,6 +8,7 @@ from app.email_utils import (
|
|||
delete_header,
|
||||
add_or_replace_header,
|
||||
parseaddr_unicode,
|
||||
send_email_with_rate_control,
|
||||
)
|
||||
from app.extensions import db
|
||||
from app.models import User, CustomDomain
|
||||
|
@ -101,3 +103,18 @@ def test_parseaddr_unicode():
|
|||
"pöstal",
|
||||
"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"
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue