Merge pull request #178 from simple-login/multiple-mailboxes

Multiple mailboxes
This commit is contained in:
Son Nguyen Kim 2020-05-16 12:20:36 +02:00 committed by GitHub
commit 08b470d2a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 595 additions and 168 deletions

View File

@ -881,7 +881,10 @@ If success, 200 with the list of aliases. Each alias has the following fields:
- nb_block
- nb_forward
- nb_reply
- mailbox
- mailbox: obsolete, should use `mailboxes` instead.
- id
- email
- mailboxes: list of mailbox, contains at least 1 mailbox.
- id
- email
- (optional) latest_activity:
@ -908,6 +911,16 @@ Here's an example:
"email": "a@b.c",
"id": 1
},
"mailboxes": [
{
"email": "m1@cd.ef",
"id": 2
},
{
"email": "john@wick.com",
"id": 1
}
],
"latest_activity": {
"action": "forward",
"contact": {
@ -921,31 +934,6 @@ Here's an example:
"nb_forward": 1,
"nb_reply": 0,
"note": null
},
{
"creation_date": "2020-04-06 17:57:14+00:00",
"creation_timestamp": 1586195834,
"email": "prefix0.hey@sl.local",
"name": null,
"enabled": true,
"id": 2,
"mailbox": {
"email": "a@b.c",
"id": 1
},
"latest_activity": {
"action": "forward",
"contact": {
"email": "c0@example.com",
"name": null,
"reverse_alias": "\"c0 at example.com\" <re0@SL>"
},
"timestamp": 1586195834
},
"nb_block": 0,
"nb_forward": 1,
"nb_reply": 0,
"note": null
}
]
}
@ -1054,6 +1042,7 @@ Input:
- (optional) `note` in request body
- (optional) `mailbox_id` in request body
- (optional) `name` in request body
- (optional) `mailbox_ids` in request body: array of mailbox_id
Output:
If success, return 200

View File

@ -13,6 +13,7 @@ from app.models import Alias, Contact, EmailLog, Mailbox
class AliasInfo:
alias: Alias
mailbox: Mailbox
mailboxes: [Mailbox]
nb_forward: int
nb_blocked: int
@ -21,6 +22,9 @@ class AliasInfo:
latest_email_log: EmailLog = None
latest_contact: Contact = None
def contain_mailbox(self, mailbox_id: int) -> bool:
return mailbox_id in [m.id for m in self.mailboxes]
def serialize_alias_info(alias_info: AliasInfo) -> dict:
return {
@ -54,6 +58,10 @@ def serialize_alias_info_v2(alias_info: AliasInfo) -> dict:
"nb_reply": alias_info.nb_reply,
# mailbox
"mailbox": {"id": alias_info.mailbox.id, "email": alias_info.mailbox.email},
"mailboxes": [
{"id": mailbox.id, "email": mailbox.email}
for mailbox in alias_info.mailboxes
],
}
if alias_info.latest_email_log:
email_log = alias_info.latest_email_log
@ -158,7 +166,13 @@ def get_alias_infos_with_pagination_v2(
q = q.group_by(Alias.id, Mailbox.id)
q = q.limit(PAGE_LIMIT).offset(page_id * PAGE_LIMIT)
q = list(q.limit(PAGE_LIMIT).offset(page_id * PAGE_LIMIT))
# preload alias.mailboxes to speed up
alias_ids = [alias.id for alias, _, _ in q]
Alias.query.options(joinedload(Alias._mailboxes)).filter(
Alias.id.in_(alias_ids)
).all()
for alias, mailbox, latest_activity in q:
ret.append(get_alias_info_v2(alias, mailbox))
@ -174,7 +188,12 @@ def get_alias_info(alias: Alias) -> AliasInfo:
)
alias_info = AliasInfo(
alias=alias, nb_blocked=0, nb_forward=0, nb_reply=0, mailbox=alias.mailbox
alias=alias,
nb_blocked=0,
nb_forward=0,
nb_reply=0,
mailbox=alias.mailbox,
mailboxes=[alias.mailbox],
)
for _, el in q:
@ -200,9 +219,21 @@ def get_alias_info_v2(alias: Alias, mailbox) -> AliasInfo:
latest_contact = None
alias_info = AliasInfo(
alias=alias, nb_blocked=0, nb_forward=0, nb_reply=0, mailbox=mailbox
alias=alias,
nb_blocked=0,
nb_forward=0,
nb_reply=0,
mailbox=mailbox,
mailboxes=[mailbox],
)
for m in alias._mailboxes:
alias_info.mailboxes.append(m)
# remove duplicates
# can happen that alias.mailbox_id also appears in AliasMailbox table
alias_info.mailboxes = list(set(alias_info.mailboxes))
for contact, email_log in q:
if email_log.is_reply:
alias_info.nb_reply += 1

View File

@ -20,7 +20,7 @@ from app.dashboard.views.alias_log import get_alias_log
from app.email_utils import parseaddr_unicode
from app.extensions import db
from app.log import LOG
from app.models import Alias, Contact, Mailbox
from app.models import Alias, Contact, Mailbox, AliasMailbox
from app.utils import random_string
@ -85,6 +85,8 @@ def get_aliases_v2():
- nb_block
- nb_reply
- note
- mailbox
- mailboxes
- (optional) latest_activity:
- timestamp
- action: forward|reply|block|bounced
@ -252,8 +254,9 @@ def update_alias(alias_id):
Update alias note
Input:
alias_id: in url
note: in body
name: in body
note (optional): in body
name (optional): in body
mailbox_id (optional): in body
Output:
200
"""
@ -282,6 +285,35 @@ def update_alias(alias_id):
alias.mailbox_id = mailbox_id
changed = True
if "mailbox_ids" in data:
mailbox_ids = [int(m_id) for m_id in data.get("mailbox_ids")]
mailboxes: [Mailbox] = []
# check if all mailboxes belong to user
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id or not mailbox.verified:
return jsonify(error="Forbidden"), 400
mailboxes.append(mailbox)
if not mailboxes:
return jsonify(error="Must choose at least one mailbox"), 400
# <<< update alias mailboxes >>>
# first remove all existing alias-mailboxes links
AliasMailbox.query.filter_by(alias_id=alias.id).delete()
db.session.flush()
# then add all new mailboxes
for i, mailbox in enumerate(mailboxes):
if i == 0:
alias.mailbox_id = mailboxes[0].id
else:
AliasMailbox.create(alias_id=alias.id, mailbox_id=mailbox.id)
# <<< END update alias mailboxes >>>
changed = True
if "name" in data:
new_name = data.get("name")
alias.name = new_name

View File

@ -26,7 +26,15 @@
</p>
<p>
{% if alias.mailbox_id %}
Make sure you send the email from the mailbox <b>{{ alias.mailbox.email }}</b>.
{% if alias.mailboxes | length == 1 %}
Make sure you send the email from the mailbox <b>{{ alias.mailbox.email }}</b>.
{% else %}
Make sure you send the email from one of the following mailboxes: <br>
{% for mailbox in alias.mailboxes %}
- <b>{{ mailbox.email }}</b> <br>
{% endfor %}
{% endif %}
This is because only the mailbox that owns the alias can send emails from it.
{% else %}
Make sure you send the email from your personal email address ({{ current_user.email }}).

View File

@ -129,7 +129,7 @@
<img src="{{ url_for('static', filename='arrows/forward-arrow.svg') }}" class="arrow">
<span class="ml-2">{{ log.alias }}</span>
<img src="{{ url_for('static', filename='arrows/blocked-arrow.svg') }}" class="arrow">
<span class="ml-2">{{ log.mailbox }}</span>
<span class="ml-2">{{ log.email_log.bounced_mailbox() }}</span>
</div>
{% else %}
<div>

View File

@ -29,6 +29,7 @@
<div class="row mb-2">
<div class="col-sm-6 mb-1 p-1" style="min-width: 4em">
<input name="prefix" class="form-control"
id="prefix"
type="text"
pattern="[0-9a-z-_]{1,}"
title="Only lowercase letter, number, dash (-), underscore (_) can be used in alias prefix."
@ -57,32 +58,33 @@
<div class="row mb-2">
<div class="col p-1">
<select class="form-control" name="mailbox">
<select data-width="100%"
class="mailbox-select" id="mailboxes" multiple name="mailboxes">
{% for mailbox in mailboxes %}
<option value="{{ mailbox }}">
{{ mailbox }}
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}
selected {% endif %}>
{{ mailbox.email }}
</option>
{% endfor %}
</select>
<div class="small-text">
The mailbox that owns this alias.
The mailbox(es) that owns this alias.
</div>
</div>
</div>
<div class="row mb-2">
<div class="col p-1">
<textarea name="note"
class="form-control"
rows="3"
placeholder="Note, can be anything to help you remember WHY you create this alias. This field is optional."></textarea>
<textarea name="note"
class="form-control"
rows="3"
placeholder="Note, can be anything to help you remember WHY you create this alias. This field is optional."></textarea>
</div>
</div>
<div class="row">
<div class="col p-1">
<button class="btn btn-primary mt-1">Create</button>
<span id="submit" class="btn btn-primary mt-1">Create</span>
</div>
</div>
</form>
@ -91,3 +93,28 @@
{% endblock %}
{% block script %}
<script>
$('.mailbox-select').multipleSelect();
$("#submit").on("click", async function () {
let that = $(this);
let mailbox_ids = $(`#mailboxes`).val();
let prefix = $('#prefix').val();
if (mailbox_ids.length == 0) {
toastr.error("You must select at least a mailbox", "Error");
return;
}
if (!prefix) {
toastr.error("Alias cannot be empty", "Error");
return;
}
that.closest("form").submit();
})
</script>
{% endblock %}

View File

@ -307,10 +307,11 @@
<div class="small-text">Current mailbox</div>
<div class="d-flex">
<div class="flex-grow-1 mr-2">
<select id="mailbox-{{ alias.id }}"
class="form-control form-control-sm" name="mailbox">
<select required id="mailbox-{{ alias.id }}"
data-width="100%"
class="mailbox-select" multiple name="mailbox">
{% for mailbox in mailboxes %}
<option value="{{ mailbox.id }}" {% if mailbox.id == alias_info.mailbox.id %}
<option value="{{ mailbox.id }}" {% if alias_info.contain_mailbox(mailbox.id) %}
selected {% endif %}>
{{ mailbox.email }}
</option>
@ -488,6 +489,7 @@
{% block script %}
<script>
$('.mailbox-select').multipleSelect();
{% if show_intro %}
// only show intro when screen is big enough to show "developer" tab
@ -595,7 +597,12 @@
$(".save-mailbox").on("click", async function () {
let aliasId = $(this).data("alias");
let mailbox_id = $(`#mailbox-${aliasId}`).val();
let mailbox_ids = $(`#mailbox-${aliasId}`).val();
if (mailbox_ids.length == 0) {
toastr.error("You must select at least a mailbox", "Error");
return;
}
try {
let res = await fetch(`/api/aliases/${aliasId}`, {
@ -604,7 +611,7 @@
"Content-Type": "application/json",
},
body: JSON.stringify({
mailbox_id: mailbox_id,
mailbox_ids: mailbox_ids,
}),
});

View File

@ -16,7 +16,7 @@ class AliasLog:
is_reply: bool
blocked: bool
bounced: bool
mailbox: str
email_log: EmailLog
def __init__(self, **kwargs):
for k, v in kwargs.items():
@ -63,7 +63,6 @@ def alias_log(alias_id, page_id):
def get_alias_log(alias: Alias, page_id=0) -> [AliasLog]:
logs: [AliasLog] = []
mailbox = alias.mailbox_email()
q = (
db.session.query(Contact, EmailLog)
@ -83,7 +82,7 @@ def get_alias_log(alias: Alias, page_id=0) -> [AliasLog]:
is_reply=email_log.is_reply,
blocked=email_log.blocked,
bounced=email_log.bounced,
mailbox=mailbox,
email_log=email_log,
)
logs.append(al)
logs = sorted(logs, key=lambda l: l.when, reverse=True)

View File

@ -11,7 +11,7 @@ from app.dashboard.base import dashboard_bp
from app.email_utils import email_belongs_to_alias_domains
from app.extensions import db
from app.log import LOG
from app.models import Alias, CustomDomain, DeletedAlias, Mailbox, User
from app.models import Alias, CustomDomain, DeletedAlias, Mailbox, User, AliasMailbox
from app.utils import convert_to_id, random_word, word_exist
signer = TimestampSigner(CUSTOM_ALIAS_SECRET)
@ -54,20 +54,30 @@ def custom_alias():
# List of (is_custom_domain, alias-suffix, time-signed alias-suffix)
suffixes = available_suffixes(current_user)
mailboxes = [mb.email for mb in current_user.mailboxes()]
mailboxes = current_user.mailboxes()
if request.method == "POST":
alias_prefix = request.form.get("prefix")
signed_suffix = request.form.get("suffix")
mailbox_email = request.form.get("mailbox")
mailbox_ids = request.form.getlist("mailboxes")
alias_note = request.form.get("note")
# check if mailbox is not tempered with
if mailbox_email != current_user.email:
mailbox = Mailbox.get_by(email=mailbox_email, user_id=current_user.id)
if not mailbox or mailbox.user_id != current_user.id:
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if (
not mailbox
or mailbox.user_id != current_user.id
or not mailbox.verified
):
flash("Something went wrong, please retry", "warning")
return redirect(url_for("dashboard.custom_alias"))
mailboxes.append(mailbox)
if not mailboxes:
flash("At least one mailbox must be selected", "error")
return redirect(url_for("dashboard.custom_alias"))
# hypothesis: user will click on the button in the 600 secs
try:
@ -91,14 +101,18 @@ def custom_alias():
"warning",
)
else:
mailbox = Mailbox.get_by(email=mailbox_email, user_id=current_user.id)
alias = Alias.create(
user_id=current_user.id,
email=full_alias,
note=alias_note,
mailbox_id=mailbox.id,
mailbox_id=mailboxes[0].id,
)
db.session.flush()
for i in range(1, len(mailboxes)):
AliasMailbox.create(
alias_id=alias.id, mailbox_id=mailboxes[i].id,
)
# get the custom_domain_id if alias is created with a custom domain
if alias_suffix.startswith("@"):

View File

@ -10,6 +10,7 @@ from flask import url_for
from flask_login import UserMixin
from sqlalchemy import text, desc, CheckConstraint
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload
from sqlalchemy_utils import ArrowType
from app import s3
@ -188,6 +189,8 @@ class User(db.Model, ModelMixin, UserMixin):
db.Boolean, default=False, nullable=False, server_default="0"
)
default_mailbox = db.relationship("Mailbox", foreign_keys=[default_mailbox_id])
@classmethod
def create(cls, email, name, password=None, **kwargs):
user: User = super(User, cls).create(email=email, name=name, **kwargs)
@ -634,8 +637,20 @@ class Alias(db.Model, ModelMixin):
db.ForeignKey("mailbox.id", ondelete="cascade"), nullable=False
)
# prefix _ to avoid this object being used accidentally.
# To have the list of all mailboxes, should use AliasInfo instead
_mailboxes = db.relationship("Mailbox", secondary="alias_mailbox", lazy="joined")
user = db.relationship(User)
mailbox = db.relationship("Mailbox")
mailbox = db.relationship("Mailbox", lazy="joined")
@property
def mailboxes(self):
ret = [self.mailbox]
for m in self._mailboxes:
ret.append(m)
return ret
@classmethod
def create(cls, **kw):
@ -909,11 +924,23 @@ class EmailLog(db.Model, ModelMixin):
db.ForeignKey("refused_email.id", ondelete="SET NULL"), nullable=True
)
# in case of bounce, record on what mailbox the email has been bounced
# useful when an alias has several mailboxes
bounced_mailbox_id = db.Column(
db.ForeignKey("mailbox.id", ondelete="cascade"), nullable=True
)
refused_email = db.relationship("RefusedEmail")
forward = db.relationship(Contact)
contact = db.relationship(Contact)
def bounced_mailbox(self) -> str:
if self.bounced_mailbox_id:
return Mailbox.get(self.bounced_mailbox_id).email
# retro-compatibility
return self.contact.alias.mailboxes[0].email
def get_action(self) -> str:
"""return the action name: forward|reply|block|bounced"""
if self.is_reply:
@ -1181,8 +1208,16 @@ class Mailbox(db.Model, ModelMixin):
# Put all aliases belonging to this mailbox to global trash
try:
for alias in Alias.query.filter_by(mailbox_id=obj_id):
DeletedAlias.create(email=alias.email)
db.session.commit()
# special handling for alias that has several mailboxes and has mailbox_id=obj_id
if len(alias.mailboxes) > 1:
# use the first mailbox found in alias._mailboxes
first_mb = alias._mailboxes[0]
alias.mailbox_id = first_mb.id
alias._mailboxes.remove(first_mb)
else:
# only put aliases that have mailbox as a single mailbox into trash
DeletedAlias.create(email=alias.email)
db.session.commit()
# this can happen when a previously deleted alias is re-created via catch-all or directory feature
except IntegrityError:
LOG.error("Some aliases have been added before to DeletedAlias")
@ -1271,3 +1306,14 @@ class SentAlert(db.Model, ModelMixin):
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)
class AliasMailbox(db.Model, ModelMixin):
__table_args__ = (
db.UniqueConstraint("alias_id", "mailbox_id", name="uq_alias_mailbox"),
)
alias_id = db.Column(db.ForeignKey(Alias.id, ondelete="cascade"), nullable=False)
mailbox_id = db.Column(
db.ForeignKey(Mailbox.id, ondelete="cascade"), nullable=False
)

View File

@ -31,14 +31,8 @@ It should contain the following info:
"""
import email
import re
import arrow
import spf
import time
import uuid
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import Envelope
from email import encoders
from email.message import Message
from email.mime.application import MIMEApplication
@ -46,6 +40,12 @@ from email.mime.multipart import MIMEMultipart
from email.utils import parseaddr, formataddr
from io import BytesIO
from smtplib import SMTP
from typing import List, Tuple
import arrow
import spf
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import Envelope
from app import pgp_utils, s3
from app.alias_utils import try_auto_create
@ -94,12 +94,9 @@ from app.utils import random_string
from init_app import load_pgp_public_keys
from server import create_app
# used when an alias receives email from its own mailbox
# can happen when user "Reply All" on some email clients
_SELF_FORWARDING_STATUS = "550 SL self-forward"
_IP_HEADER = "X-SimpleLogin-Client-IP"
_MAILBOX_ID_HEADER = "X-SimpleLogin-Mailbox-ID"
# fix the database connection leak issue
# use this method instead of create_app
@ -331,11 +328,13 @@ def prepare_pgp_message(orig_msg: Message, pgp_fingerprint: str):
return msg
def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str):
def handle_forward(
envelope, smtp: SMTP, msg: Message, rcpt_to: str
) -> List[Tuple[bool, str]]:
"""return whether an email has been delivered and
the smtp status ("250 Message accepted", "550 Non-existent email address", etc)
"""
address = rcpt_to.lower() # alias@SL
address = rcpt_to.lower().strip() # alias@SL
alias = Alias.get_by(email=address)
if not alias:
@ -343,18 +342,7 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s
alias = try_auto_create(address)
if not alias:
LOG.d("alias %s cannot be created on-the-fly, return 550", address)
return False, "550 SL E3"
mailbox = alias.mailbox
mailbox_email = mailbox.email
user = alias.user
# Sometimes when user clicks on "reply all"
# an email is sent to the same alias that the previous message is destined to
if envelope.mail_from == mailbox_email:
# nothing to do
LOG.d("Forward from %s to %s, nothing to do", envelope.mail_from, mailbox_email)
return False, _SELF_FORWARDING_STATUS
return [(False, "550 SL E3")]
contact = get_or_create_contact(msg["From"], envelope.mail_from, alias)
email_log = EmailLog.create(contact_id=contact.id, user_id=contact.user_id)
@ -364,15 +352,41 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s
email_log.blocked = True
db.session.commit()
return True, "250 Message accepted for delivery"
# do not return 5** to allow user to receive emails later when alias is enabled
return [(True, "250 Message accepted for delivery")]
user = alias.user
ret = []
for mailbox in alias.mailboxes:
ret.append(
forward_email_to_mailbox(
alias, msg, email_log, contact, envelope, smtp, mailbox, user
)
)
return ret
def forward_email_to_mailbox(
alias,
msg: Message,
email_log: EmailLog,
contact: Contact,
envelope,
smtp: SMTP,
mailbox,
user,
) -> (bool, str):
LOG.d("Forward %s -> %s -> %s", contact, alias, mailbox)
spam_check = True
is_spam, spam_status = get_spam_info(msg)
if is_spam:
LOG.warning("Email detected as spam. Alias: %s, from: %s", alias, contact)
email_log.is_spam = True
email_log.spam_status = spam_status
handle_spam(contact, alias, msg, user, mailbox_email, email_log)
handle_spam(contact, alias, msg, user, mailbox.email, email_log)
return False, "550 SL E1"
# create PGP email if needed
@ -388,6 +402,7 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s
delete_header(msg, "Sender")
delete_header(msg, _IP_HEADER)
add_or_replace_header(msg, _MAILBOX_ID_HEADER, str(mailbox.id))
# change the from header so the sender comes from @SL
# so it can pass DMARC check
@ -427,7 +442,7 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s
LOG.d(
"Forward mail from %s to %s, mail_options %s, rcpt_options %s ",
contact.website_email,
mailbox_email,
mailbox.email,
envelope.mail_options,
envelope.rcpt_options,
)
@ -436,7 +451,7 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s
# encode message raw directly instead
smtp.sendmail(
contact.reply_email,
mailbox_email,
mailbox.email,
msg.as_bytes(),
envelope.mail_options,
envelope.rcpt_options,
@ -451,7 +466,7 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str
return whether an email has been delivered and
the smtp status ("250 Message accepted", "550 Non-existent email address", etc)
"""
reply_email = rcpt_to.lower()
reply_email = rcpt_to.lower().strip()
# reply_email must end with EMAIL_DOMAIN
if not reply_email.endswith(EMAIL_DOMAIN):
@ -473,24 +488,26 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str
return False, "550 SL E5"
user = alias.user
mailbox_email = alias.mailbox_email()
mail_from = envelope.mail_from.lower().strip()
# bounce email initiated by Postfix
# can happen in case emails cannot be delivered to user-email
# in this case Postfix will try to send a bounce report to original sender, which is
# the "reply email"
if envelope.mail_from == "<>":
if mail_from == "<>":
LOG.warning(
"Bounce when sending to alias %s from %s, user %s",
alias,
contact.website_email,
alias.user,
"Bounce when sending to alias %s from %s, user %s", alias, contact, user,
)
handle_bounce(contact, alias, msg, user, mailbox_email)
handle_bounce(contact, alias, msg, user)
return False, "550 SL E6"
mailbox: Mailbox = Mailbox.get_by(email=mailbox_email)
mailbox = Mailbox.get_by(email=mail_from, user_id=user.id)
if not mailbox or mailbox not in alias.mailboxes:
# only mailbox can send email to the reply-email
handle_unknown_mailbox(envelope, msg, reply_email, user, alias)
return False, "550 SL E7"
if ENFORCE_SPF and mailbox.force_spf:
ip = msg[_IP_HEADER]
if not spf_pass(ip, envelope, mailbox, user, alias, contact.website_email, msg):
@ -499,13 +516,7 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str
delete_header(msg, _IP_HEADER)
# only mailbox can send email to the reply-email
if envelope.mail_from.lower() != mailbox_email.lower():
handle_unknown_mailbox(envelope, msg, mailbox, reply_email, user, alias)
return False, "550 SL E7"
delete_header(msg, "DKIM-Signature")
delete_header(msg, "Received")
# make the email comes from alias
@ -631,36 +642,33 @@ def spf_pass(
return True
def handle_unknown_mailbox(
envelope, msg, mailbox: Mailbox, reply_email: str, user: User, alias: Alias
):
def handle_unknown_mailbox(envelope, msg, 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",
f"Actual mail_from: %s. msg from header: %s, reverse-alias %s, %s %s",
envelope.mail_from,
msg["From"],
mailbox.email,
reply_email,
alias,
user,
)
send_email_with_rate_control(
user,
ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX,
mailbox.email,
user.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,
alias=alias,
sender=envelope.mail_from,
mailbox_email=mailbox.email,
),
render(
"transactional/reply-must-use-personal-email.html",
name=user.name,
alias=alias.email,
alias=alias,
sender=envelope.mail_from,
mailbox_email=mailbox.email,
),
)
@ -683,9 +691,7 @@ def handle_unknown_mailbox(
)
def handle_bounce(
contact: Contact, alias: Alias, msg: Message, user: User, mailbox_email: str
):
def handle_bounce(contact: Contact, alias: Alias, msg: Message, user: User):
address = alias.email
email_log: EmailLog = EmailLog.create(
contact_id=contact.id, bounced=True, user_id=contact.user_id
@ -703,12 +709,28 @@ def handle_bounce(
full_report_path = f"refused-emails/full-{random_name}.eml"
s3.upload_email_from_bytesio(full_report_path, BytesIO(msg.as_bytes()), random_name)
file_path = None
if orig_msg:
file_path = f"refused-emails/{random_name}.eml"
s3.upload_email_from_bytesio(
file_path, BytesIO(orig_msg.as_bytes()), random_name
if not orig_msg:
LOG.error(
"Cannot parse original message from bounce message %s %s %s",
alias,
user,
contact,
)
return
file_path = f"refused-emails/{random_name}.eml"
s3.upload_email_from_bytesio(file_path, BytesIO(orig_msg.as_bytes()), random_name)
mailbox_id = int(orig_msg[_MAILBOX_ID_HEADER])
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id:
LOG.error(
"Tampered message mailbox_id %s, %s, %s, %s",
mailbox_id,
user,
alias,
contact,
)
return
refused_email = RefusedEmail.create(
path=file_path, full_report_path=full_report_path, user_id=user.id
@ -716,6 +738,7 @@ def handle_bounce(
db.session.flush()
email_log.refused_email_id = refused_email.id
email_log.bounced_mailbox_id = mailbox.id
db.session.commit()
LOG.d("Create refused email %s", refused_email)
@ -745,7 +768,7 @@ def handle_bounce(
website_email=contact.website_email,
disable_alias_link=disable_alias_link,
refused_email_url=refused_email_url,
mailbox_email=mailbox_email,
mailbox_email=mailbox.email,
),
render(
"transactional/bounced-email.html",
@ -754,7 +777,7 @@ def handle_bounce(
website_email=contact.website_email,
disable_alias_link=disable_alias_link,
refused_email_url=refused_email_url,
mailbox_email=mailbox_email,
mailbox_email=mailbox.email,
),
# cannot include bounce email as it can contain spammy text
# bounced_email=msg,
@ -781,7 +804,7 @@ def handle_bounce(
alias=alias,
website_email=contact.website_email,
refused_email_url=refused_email_url,
mailbox_email=mailbox_email,
mailbox_email=mailbox.email,
),
render(
"transactional/automatic-disable-alias.html",
@ -789,7 +812,7 @@ def handle_bounce(
alias=alias,
website_email=contact.website_email,
refused_email_url=refused_email_url,
mailbox_email=mailbox_email,
mailbox_email=mailbox.email,
),
# cannot include bounce email as it can contain spammy text
# bounced_email=msg,
@ -888,7 +911,9 @@ def handle_unsubscribe(envelope: Envelope):
return "550 SL E9"
# This sender cannot unsubscribe
if alias.mailbox_email() != envelope.mail_from:
mail_from = envelope.mail_from.lower().strip()
mailbox = Mailbox.get_by(user_id=alias.user_id, email=mail_from)
if not mailbox or mailbox not in alias.mailboxes:
LOG.d("%s cannot disable alias %s", envelope.mail_from, alias)
return "550 SL E10"
@ -898,22 +923,23 @@ def handle_unsubscribe(envelope: Envelope):
user = alias.user
enable_alias_url = URL + f"/dashboard/?highlight_alias_id={alias.id}"
send_email(
envelope.mail_from,
f"Alias {alias.email} has been disabled successfully",
render(
"transactional/unsubscribe-disable-alias.txt",
user=user,
alias=alias.email,
enable_alias_url=enable_alias_url,
),
render(
"transactional/unsubscribe-disable-alias.html",
user=user,
alias=alias.email,
enable_alias_url=enable_alias_url,
),
)
for mailbox in alias.mailboxes:
send_email(
mailbox.email,
f"Alias {alias.email} has been disabled successfully",
render(
"transactional/unsubscribe-disable-alias.txt",
user=user,
alias=alias.email,
enable_alias_url=enable_alias_url,
),
render(
"transactional/unsubscribe-disable-alias.html",
user=user,
alias=alias.email,
enable_alias_url=enable_alias_url,
),
)
return "250 Unsubscribe request accepted"
@ -947,14 +973,10 @@ def handle(envelope: Envelope, smtp: SMTP) -> str:
res.append((is_delivered, smtp_status))
else: # Forward case
LOG.debug(">>> Forward phase %s -> %s", envelope.mail_from, rcpt_to)
is_delivered, smtp_status = handle_forward(envelope, smtp, msg, rcpt_to)
res.append((is_delivered, smtp_status))
# special handling for self-forwarding
# just consider success delivery in this case
if len(res) == 1 and res[0][1] == _SELF_FORWARDING_STATUS:
LOG.d("Self-forwarding, ignore")
return "250 SL OK"
for is_delivered, smtp_status in handle_forward(
envelope, smtp, msg, rcpt_to
):
res.append((is_delivered, smtp_status))
for (is_success, smtp_status) in res:
# Consider all deliveries successful if 1 delivery is successful

View File

@ -0,0 +1,41 @@
"""empty message
Revision ID: bf11ab2f0a7a
Revises: a5e3c6693dc6
Create Date: 2020-05-10 16:41:48.038484
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bf11ab2f0a7a'
down_revision = 'a5e3c6693dc6'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('alias_mailbox',
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('alias_id', sa.Integer(), nullable=False),
sa.Column('mailbox_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['alias_id'], ['alias.id'], ondelete='cascade'),
sa.ForeignKeyConstraint(['mailbox_id'], ['mailbox.id'], ondelete='cascade'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('alias_id', 'mailbox_id', name='uq_alias_mailbox')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('alias_mailbox')
# ### end Alembic commands ###

View File

@ -0,0 +1,31 @@
"""empty message
Revision ID: 1759f73274ee
Revises: bf11ab2f0a7a
Create Date: 2020-05-10 18:33:55.376369
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1759f73274ee'
down_revision = 'bf11ab2f0a7a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('email_log', sa.Column('bounced_mailbox_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'email_log', 'mailbox', ['bounced_mailbox_id'], ['id'], ondelete='cascade')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'email_log', type_='foreignkey')
op.drop_column('email_log', 'bounced_mailbox_id')
# ### end Alembic commands ###

View File

@ -0,0 +1,31 @@
"""empty message
Revision ID: 552d735a2f1f
Revises: 1759f73274ee
Create Date: 2020-05-15 16:33:23.558895
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '552d735a2f1f'
down_revision = '1759f73274ee'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('alias_mailbox_user_id_fkey', 'alias_mailbox', type_='foreignkey')
op.drop_column('alias_mailbox', 'user_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('alias_mailbox', sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False))
op.create_foreign_key('alias_mailbox_user_id_fkey', 'alias_mailbox', 'users', ['user_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###

View File

@ -52,6 +52,7 @@ from app.models import (
Contact,
EmailLog,
Referral,
AliasMailbox,
)
from app.monitor.base import monitor_bp
from app.oauth.base import oauth_bp
@ -77,6 +78,9 @@ def create_app() -> Flask:
app.config["SQLALCHEMY_DATABASE_URI"] = DB_URI
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
# enable to print all queries generated by sqlalchemy
# app.config["SQLALCHEMY_ECHO"] = True
app.secret_key = FLASK_SECRET
app.config["TEMPLATES_AUTO_RELOAD"] = True
@ -165,13 +169,20 @@ def fake_data():
m1 = Mailbox.create(user_id=user.id, email="m1@cd.ef", verified=True)
db.session.commit()
for i in range(30):
for i in range(31):
if i % 2 == 0:
a = Alias.create_new(user, f"e{i}@", mailbox_id=m1.id)
else:
a = Alias.create_new(user, f"e{i}@")
db.session.commit()
if i % 5 == 0:
if i % 2 == 0:
AliasMailbox.create(alias_id=a.id, mailbox_id=user.default_mailbox_id)
else:
AliasMailbox.create(alias_id=a.id, mailbox_id=m1.id)
db.session.commit()
# some aliases don't have any activity
if i % 3 != 0:
contact = Contact.create(
@ -538,9 +549,6 @@ if __name__ == "__main__":
#
# toolbar = DebugToolbarExtension(app)
# enable to print all queries generated by sqlalchemy
# app.config["SQLALCHEMY_ECHO"] = True
# warning: only used in local
if RESET_DB:
LOG.warning("reset db, add fake data")

5
static/package-lock.json generated vendored
View File

@ -91,6 +91,11 @@
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz",
"integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw=="
},
"multiple-select": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/multiple-select/-/multiple-select-1.5.2.tgz",
"integrity": "sha512-sTNNRrjnTtB1b1+HTKcjQ/mjWY7Gvigo9F3C/3oTQCTFEpYzwaRYFPRAOu2SogfA1hEfyJTXjyS1VAbanJMsmA=="
},
"popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",

1
static/package.json vendored
View File

@ -20,6 +20,7 @@
"bootbox": "^5.4.0",
"font-awesome": "^4.7.0",
"intro.js": "^2.9.3",
"multiple-select": "^1.5.2",
"qrious": "^4.0.2",
"toastr": "^2.1.4",
"vue": "^2.6.11"

View File

@ -60,6 +60,13 @@
<script src="{{ url_for('static', filename='node_modules/bootbox/dist/bootbox.min.js') }}"></script>
<!-- Multiple-select library -->
<link rel="stylesheet"
href="{{ url_for('static', filename='node_modules/multiple-select/dist/multiple-select.min.css') }}">
<script
src="{{ url_for('static', filename='node_modules/multiple-select/dist/multiple-select.min.js') }}"></script>
<link rel="stylesheet" type="text/css" href="/static/style.css?v={{ VERSION }}">
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>

View File

@ -2,10 +2,27 @@
{% block content %}
{{ render_text("Hi " + name) }}
{{ render_text("We have recorded an attempt to send an email from your alias <b>"+ alias +"</b> using <b>" + sender + "</b>.") }}
{{ render_text("Please note that sending from this alias only works from <b>" + mailbox_email + "</b>.") }}
{{ render_text("Indeed, only you (or the mailbox that owns <b>" + alias + "</b>) can send emails on behalf of this alias.") }}
{{ render_text('Thanks, <br />SimpleLogin Team.') }}
{% call text() %}
We have recorded an attempt to send an email from your alias <b>{{ alias.email }}</b> using <b>{{ sender }}</b>>
{% endcall %}
{% call text() %}
Please note that sending from this alias only works from one of these mailboxes: <br>
{% for mailbox in alias.mailboxes %}
- {{ mailbox.email }} <br>
{% endfor %}
{% endcall %}
{% call text() %}
Indeed only you can send emails on behalf of your alias.
{% endcall %}
{% call text() %}
Thanks, <br/>
SimpleLogin Team.
{% endcall %}
{% endblock %}

View File

@ -1,8 +1,15 @@
Hi {{name}}
We have recorded an attempt to send an email from your alias {{ alias }} using {{ sender }}.
We have recorded an attempt to send an email from your alias {{ alias.email }} using {{ sender }}.
Please note that sending from this alias only works from {{mailbox_email}}: only you (i.e. no one else) can send emails on behalf of your alias.
Please note that sending from this alias only works from one of these mailboxes:
{% for mailbox in alias.mailboxes %}
- {{mailbox.email}}
{% endfor %}
Indeed only you can send emails on behalf of your alias.
Best,
SimpleLogin team.

View File

@ -184,6 +184,11 @@ def test_get_aliases_v2(flask_client):
assert "id" in r0["mailbox"]
assert "email" in r0["mailbox"]
assert r0["mailboxes"]
for mailbox in r0["mailboxes"]:
assert "id" in mailbox
assert "email" in mailbox
def test_delete_alias(flask_client):
user = User.create(
@ -357,6 +362,43 @@ def test_update_alias_name(flask_client):
assert alias.name == "Test Name"
def test_update_alias_mailboxes(flask_client):
user = User.create(
email="a@b.c", password="password", name="Test User", activated=True
)
db.session.commit()
mb1 = Mailbox.create(user_id=user.id, email="ab1@cd.com", verified=True)
mb2 = Mailbox.create(user_id=user.id, email="ab2@cd.com", verified=True)
# create api_key
api_key = ApiKey.create(user.id, "for test")
db.session.commit()
alias = Alias.create_new_random(user)
db.session.commit()
r = flask_client.put(
url_for("api.update_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"mailbox_ids": [mb1.id, mb2.id]},
)
assert r.status_code == 200
alias = Alias.get(alias.id)
assert alias.mailbox
assert len(alias._mailboxes) == 1
# fail when update with empty mailboxes
r = flask_client.put(
url_for("api.update_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"mailbox_ids": []},
)
assert r.status_code == 400
def test_alias_contacts(flask_client):
user = User.create(
email="a@b.c", password="password", name="Test User", activated=True

View File

@ -7,7 +7,7 @@ from app.dashboard.views.custom_alias import (
available_suffixes,
)
from app.extensions import db
from app.models import Mailbox, CustomDomain
from app.models import Mailbox, CustomDomain, Alias
from app.utils import random_word
from tests.utils import login
@ -20,15 +20,50 @@ def test_add_alias_success(flask_client):
suffix = f".{word}@{EMAIL_DOMAIN}"
suffix = signer.sign(suffix).decode()
# create with a single mailbox
r = flask_client.post(
url_for("dashboard.custom_alias"),
data={"prefix": "prefix", "suffix": suffix, "mailbox": user.email,},
data={
"prefix": "prefix",
"suffix": suffix,
"mailboxes": [user.default_mailbox_id],
},
follow_redirects=True,
)
assert r.status_code == 200
assert f"Alias prefix.{word}@{EMAIL_DOMAIN} has been created" in str(r.data)
alias = Alias.query.order_by(Alias.created_at.desc()).first()
assert not alias._mailboxes
def test_add_alias_multiple_mailboxes(flask_client):
user = login(flask_client)
db.session.commit()
word = random_word()
suffix = f".{word}@{EMAIL_DOMAIN}"
suffix = signer.sign(suffix).decode()
# create with a multiple mailboxes
mb1 = Mailbox.create(user_id=user.id, email="m1@example.com", verified=True)
db.session.commit()
r = flask_client.post(
url_for("dashboard.custom_alias"),
data={
"prefix": "prefix",
"suffix": suffix,
"mailboxes": [user.default_mailbox_id, mb1.id],
},
follow_redirects=True,
)
assert r.status_code == 200
assert f"Alias prefix.{word}@{EMAIL_DOMAIN} has been created" in str(r.data)
alias = Alias.query.order_by(Alias.created_at.desc()).first()
assert alias._mailboxes
def test_not_show_unverified_mailbox(flask_client):
"""make sure user unverified mailbox is not shown to user"""

View File

@ -5,7 +5,7 @@ import pytest
from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN
from app.email_utils import parseaddr_unicode
from app.extensions import db
from app.models import generate_email, User, Alias, Contact
from app.models import generate_email, User, Alias, Contact, Mailbox, AliasMailbox
def test_generate_email(flask_client):
@ -133,3 +133,30 @@ def test_new_addr(flask_client):
"Nhơn Nguyễn - abcd at example.com",
"rep@sl",
)
def test_mailbox_delete(flask_client):
user = User.create(
email="a@b.c", password="password", name="Test User", activated=True
)
db.session.commit()
m1 = Mailbox.create(user_id=user.id, email="m1@example.com", verified=True)
m2 = Mailbox.create(user_id=user.id, email="m2@example.com", verified=True)
m3 = Mailbox.create(user_id=user.id, email="m3@example.com", verified=True)
db.session.commit()
# alias has 2 mailboxes
alias = Alias.create_new(user, "prefix", mailbox_id=m1.id)
db.session.commit()
alias._mailboxes.append(m2)
alias._mailboxes.append(m3)
db.session.commit()
assert len(alias.mailboxes) == 3
# delete m1, should not delete alias
Mailbox.delete(m1.id)
alias = Alias.get(alias.id)
assert len(alias.mailboxes) == 2