Merge pull request #178 from simple-login/multiple-mailboxes
Multiple mailboxes
This commit is contained in:
commit
08b470d2a6
41
README.md
41
README.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }}).
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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("@"):
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
202
email_handler.py
202
email_handler.py
|
@ -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
|
||||
|
|
41
migrations/versions/2020_051016_bf11ab2f0a7a_.py
Normal file
41
migrations/versions/2020_051016_bf11ab2f0a7a_.py
Normal 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 ###
|
31
migrations/versions/2020_051018_1759f73274ee_.py
Normal file
31
migrations/versions/2020_051018_1759f73274ee_.py
Normal 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 ###
|
31
migrations/versions/2020_051516_552d735a2f1f_.py
Normal file
31
migrations/versions/2020_051516_552d735a2f1f_.py
Normal 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 ###
|
16
server.py
16
server.py
|
@ -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
5
static/package-lock.json
generated
vendored
|
@ -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
1
static/package.json
vendored
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue