mirror of
https://github.com/simple-login/app.git
synced 2024-11-16 17:08:30 +01:00
Merge pull request #247 from simple-login/domain-mailbox
Domain mailbox
This commit is contained in:
commit
852c13fb60
8 changed files with 260 additions and 24 deletions
36
README.md
36
README.md
|
@ -680,6 +680,42 @@ The code is formatted using https://github.com/psf/black, to format the code, si
|
||||||
black .
|
black .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Test sending email
|
||||||
|
|
||||||
|
[swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`.
|
||||||
|
|
||||||
|
[mailcatcher](https://github.com/sj26/mailcatcher) is used to receive forwarded emails.
|
||||||
|
|
||||||
|
There are several steps to set up the email handler
|
||||||
|
|
||||||
|
1) run mailcatcher
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mailcatcher
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Make sure to set the following variables in the `.env` file
|
||||||
|
|
||||||
|
```
|
||||||
|
NOT_SEND_EMAIL=true
|
||||||
|
POSTFIX_SERVER=localhost
|
||||||
|
POSTFIX_PORT=1025
|
||||||
|
```
|
||||||
|
|
||||||
|
3) Run email_handler
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python email_handler.py
|
||||||
|
```
|
||||||
|
|
||||||
|
4) Send a test email
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swaks --to e1@d1.localhost --from hey@google.com --server 127.0.0.1:20381
|
||||||
|
```
|
||||||
|
|
||||||
|
Now open http://localhost:1080/, you should see the test email.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
SimpleLogin current API clients are Chrome/Firefox/Safari extension and mobile (iOS/Android) app.
|
SimpleLogin current API clients are Chrome/Firefox/Safari extension and mobile (iOS/Android) app.
|
||||||
|
|
|
@ -98,7 +98,7 @@ def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]:
|
||||||
# try to create alias on-the-fly with custom-domain catch-all feature
|
# try to create alias on-the-fly with custom-domain catch-all feature
|
||||||
# check if alias is custom-domain alias and if the custom-domain has catch-all enabled
|
# check if alias is custom-domain alias and if the custom-domain has catch-all enabled
|
||||||
alias_domain = get_email_domain_part(address)
|
alias_domain = get_email_domain_part(address)
|
||||||
custom_domain = CustomDomain.get_by(domain=alias_domain)
|
custom_domain: CustomDomain = CustomDomain.get_by(domain=alias_domain)
|
||||||
|
|
||||||
if not custom_domain:
|
if not custom_domain:
|
||||||
return None
|
return None
|
||||||
|
@ -116,12 +116,18 @@ def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
LOG.d("create alias %s for domain %s", address, custom_domain)
|
LOG.d("create alias %s for domain %s", address, custom_domain)
|
||||||
|
mailboxes = custom_domain.mailboxes
|
||||||
alias = Alias.create(
|
alias = Alias.create(
|
||||||
email=address,
|
email=address,
|
||||||
user_id=custom_domain.user_id,
|
user_id=custom_domain.user_id,
|
||||||
custom_domain_id=custom_domain.id,
|
custom_domain_id=custom_domain.id,
|
||||||
automatic_creation=True,
|
automatic_creation=True,
|
||||||
mailbox_id=domain_user.default_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,
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return alias
|
return alias
|
||||||
|
|
|
@ -20,7 +20,10 @@
|
||||||
|
|
||||||
{% if not current_user.is_premium() %}
|
{% if not current_user.is_premium() %}
|
||||||
<div class="alert alert-danger" role="alert">
|
<div class="alert alert-danger" role="alert">
|
||||||
This feature is only available for Premium users. <a href="https://app.simplelogin.io/dashboard/pricing" target="_blank" rel="noopener">Upgrade<i class="fe fe-external-link"></i></a>
|
This feature is only available on Premium plan.
|
||||||
|
<a href="https://app.simplelogin.io/dashboard/pricing" target="_blank" rel="noopener">
|
||||||
|
Upgrade<i class="fe fe-external-link"></i>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -46,19 +49,58 @@
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h5>
|
</h5>
|
||||||
<h6 class="card-subtitle mb-2 text-muted">
|
|
||||||
|
<h6 class="card-subtitle mb-4 text-muted">
|
||||||
Created {{ custom_domain.created_at | dt }} <br>
|
Created {{ custom_domain.created_at | dt }} <br>
|
||||||
<span class="font-weight-bold">{{ custom_domain.nb_alias() }}</span> aliases.
|
<span class="font-weight-bold">{{ custom_domain.nb_alias() }}</span> aliases.
|
||||||
|
<br><br>
|
||||||
|
|
||||||
|
<b>Mailboxes:</b>
|
||||||
|
<i class="fe fe-info" data-toggle="tooltip"
|
||||||
|
title="Aliases created with this domain are automatically owned by these mailboxes">
|
||||||
|
</i>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
{% set domain_mailboxes=custom_domain.mailboxes %}
|
||||||
|
<form method="post" class="mt-2">
|
||||||
|
<input type="hidden" name="form-name" value="update">
|
||||||
|
<input type="hidden" name="domain-id" value="{{ custom_domain.id }}">
|
||||||
|
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="flex-grow-1 mr-2">
|
||||||
|
<select data-width="100%" required
|
||||||
|
class="mailbox-select" multiple name="mailbox_ids">
|
||||||
|
{% for mailbox in mailboxes %}
|
||||||
|
<option value="{{ mailbox.id }}" {% if mailbox in domain_mailboxes %}
|
||||||
|
selected {% endif %}>
|
||||||
|
{{ mailbox.email }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-primary btn-sm">Update</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
</h6>
|
</h6>
|
||||||
|
|
||||||
<a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}">Details ➡</a>
|
<a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}" class="mt-3">
|
||||||
|
Details ➡
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post">
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<form method="post" class="mt-2">
|
||||||
{{ new_custom_domain_form.csrf_token }}
|
{{ new_custom_domain_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="create">
|
<input type="hidden" name="form-name" value="create">
|
||||||
|
|
||||||
|
@ -66,13 +108,40 @@
|
||||||
|
|
||||||
{{ new_custom_domain_form.domain(class="form-control", placeholder="my-domain.com", maxlength=128) }}
|
{{ new_custom_domain_form.domain(class="form-control", placeholder="my-domain.com", maxlength=128) }}
|
||||||
{{ render_field_errors(new_custom_domain_form.domain) }}
|
{{ render_field_errors(new_custom_domain_form.domain) }}
|
||||||
<div class="small-text">Please use full path domain, for ex <em>my-subdomain.my-domain.com</em></div>
|
<div class="small-text">
|
||||||
|
Please use full path domain, for ex <em>my-subdomain.my-domain.com</em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 small-text alert alert-info">
|
||||||
|
By default, aliases created with your domain are "owned" by your default
|
||||||
|
mailbox <b>{{ current_user.default_mailbox.email }}</b>. <br>
|
||||||
|
This below option allow you to choose the mailbox(es) that a new alias automatically belongs to.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select data-width="100%"
|
||||||
|
class="mailbox-select" multiple name="mailbox_ids">
|
||||||
|
{% for mailbox in mailboxes %}
|
||||||
|
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}
|
||||||
|
selected {% endif %}>
|
||||||
|
{{ mailbox.email }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
<button class="btn btn-lg btn-success mt-2">Create</button>
|
<button class="btn btn-lg btn-success mt-2">Create</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
$('.mailbox-select').multipleSelect();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -29,7 +29,12 @@
|
||||||
This feature allows you to create aliases <b>on the fly</b>.
|
This feature allows you to create aliases <b>on the fly</b>.
|
||||||
Simply use <em>anything@{{ custom_domain.domain }}</em>
|
Simply use <em>anything@{{ custom_domain.domain }}</em>
|
||||||
next time you need an email address. <br>
|
next time you need an email address. <br>
|
||||||
The alias will be created the first time it receives an email.
|
The alias will be created the first time it receives an email
|
||||||
|
and automatically belong to <b>{{ custom_domain.domain }}</b> mailboxes (
|
||||||
|
{% for mailbox in custom_domain.mailboxes %}
|
||||||
|
<b>{{ mailbox.email }}</b>
|
||||||
|
{% if not loop.last %},{% endif %}
|
||||||
|
{% endfor %})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -67,7 +72,7 @@
|
||||||
name="alias-name"
|
name="alias-name"
|
||||||
placeholder="Alias name">
|
placeholder="Alias name">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary">Save</a>
|
<button class="btn btn-primary">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ from app.config import EMAIL_SERVERS_WITH_PRIORITY
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.email_utils import get_email_domain_part
|
from app.email_utils import get_email_domain_part
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import CustomDomain
|
from app.models import CustomDomain, Mailbox, DomainMailbox
|
||||||
|
|
||||||
|
|
||||||
class NewCustomDomainForm(FlaskForm):
|
class NewCustomDomainForm(FlaskForm):
|
||||||
|
@ -20,7 +20,7 @@ class NewCustomDomainForm(FlaskForm):
|
||||||
@login_required
|
@login_required
|
||||||
def custom_domain():
|
def custom_domain():
|
||||||
custom_domains = CustomDomain.query.filter_by(user_id=current_user.id).all()
|
custom_domains = CustomDomain.query.filter_by(user_id=current_user.id).all()
|
||||||
|
mailboxes = current_user.mailboxes()
|
||||||
new_custom_domain_form = NewCustomDomainForm()
|
new_custom_domain_form = NewCustomDomainForm()
|
||||||
|
|
||||||
errors = {}
|
errors = {}
|
||||||
|
@ -54,6 +54,28 @@ def custom_domain():
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
mailbox_ids = request.form.getlist("mailbox_ids")
|
||||||
|
if mailbox_ids:
|
||||||
|
# check if mailbox is not tempered with
|
||||||
|
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_domain"))
|
||||||
|
mailboxes.append(mailbox)
|
||||||
|
|
||||||
|
for mailbox in mailboxes:
|
||||||
|
DomainMailbox.create(
|
||||||
|
domain_id=new_custom_domain.id, mailbox_id=mailbox.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
flash(
|
flash(
|
||||||
f"New domain {new_custom_domain.domain} is created", "success"
|
f"New domain {new_custom_domain.domain} is created", "success"
|
||||||
)
|
)
|
||||||
|
@ -64,6 +86,43 @@ def custom_domain():
|
||||||
custom_domain_id=new_custom_domain.id,
|
custom_domain_id=new_custom_domain.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
elif request.form.get("form-name") == "update":
|
||||||
|
domain_id = request.form.get("domain-id")
|
||||||
|
domain = CustomDomain.get(domain_id)
|
||||||
|
|
||||||
|
if not domain or domain.user_id != current_user.id:
|
||||||
|
flash("Unknown error. Refresh the page", "warning")
|
||||||
|
return redirect(url_for("dashboard.custom_domain"))
|
||||||
|
|
||||||
|
mailbox_ids = request.form.getlist("mailbox_ids")
|
||||||
|
# check if mailbox is not tempered with
|
||||||
|
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_domain"))
|
||||||
|
mailboxes.append(mailbox)
|
||||||
|
|
||||||
|
if not mailboxes:
|
||||||
|
flash("You must select at least 1 mailbox", "warning")
|
||||||
|
return redirect(url_for("dashboard.custom_domain"))
|
||||||
|
|
||||||
|
# first remove all existing domain-mailboxes links
|
||||||
|
DomainMailbox.query.filter_by(domain_id=domain.id).delete()
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
for mailbox in mailboxes:
|
||||||
|
DomainMailbox.create(domain_id=domain.id, mailbox_id=mailbox.id)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Domain {domain.domain} has been updated", "success")
|
||||||
|
|
||||||
|
return redirect(url_for("dashboard.custom_domain"))
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard/custom_domain.html",
|
"dashboard/custom_domain.html",
|
||||||
|
@ -71,4 +130,5 @@ def custom_domain():
|
||||||
new_custom_domain_form=new_custom_domain_form,
|
new_custom_domain_form=new_custom_domain_form,
|
||||||
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
|
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
|
mailboxes=mailboxes,
|
||||||
)
|
)
|
||||||
|
|
|
@ -50,12 +50,9 @@ def directory():
|
||||||
dir_id = request.form.get("dir-id")
|
dir_id = request.form.get("dir-id")
|
||||||
dir = Directory.get(dir_id)
|
dir = Directory.get(dir_id)
|
||||||
|
|
||||||
if not dir:
|
if not dir or dir.user_id != current_user.id:
|
||||||
flash("Unknown error. Refresh the page", "warning")
|
flash("Unknown error. Refresh the page", "warning")
|
||||||
return redirect(url_for("dashboard.directory"))
|
return redirect(url_for("dashboard.directory"))
|
||||||
elif dir.user_id != current_user.id:
|
|
||||||
flash("You cannot delete this directory", "warning")
|
|
||||||
return redirect(url_for("dashboard.directory"))
|
|
||||||
|
|
||||||
mailbox_ids = request.form.getlist("mailbox_ids")
|
mailbox_ids = request.form.getlist("mailbox_ids")
|
||||||
# check if mailbox is not tempered with
|
# check if mailbox is not tempered with
|
||||||
|
@ -75,7 +72,7 @@ def directory():
|
||||||
flash("You must select at least 1 mailbox", "warning")
|
flash("You must select at least 1 mailbox", "warning")
|
||||||
return redirect(url_for("dashboard.directory"))
|
return redirect(url_for("dashboard.directory"))
|
||||||
|
|
||||||
# first remove all existing alias-mailboxes links
|
# first remove all existing directory-mailboxes links
|
||||||
DirectoryMailbox.query.filter_by(directory_id=dir.id).delete()
|
DirectoryMailbox.query.filter_by(directory_id=dir.id).delete()
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
@ -125,7 +122,7 @@ def directory():
|
||||||
or not mailbox.verified
|
or not mailbox.verified
|
||||||
):
|
):
|
||||||
flash("Something went wrong, please retry", "warning")
|
flash("Something went wrong, please retry", "warning")
|
||||||
return redirect(url_for("dashboard.custom_alias"))
|
return redirect(url_for("dashboard.directory"))
|
||||||
mailboxes.append(mailbox)
|
mailboxes.append(mailbox)
|
||||||
|
|
||||||
for mailbox in mailboxes:
|
for mailbox in mailboxes:
|
||||||
|
|
|
@ -1353,11 +1353,20 @@ class CustomDomain(db.Model, ModelMixin):
|
||||||
db.Boolean, nullable=False, default=False, server_default="0"
|
db.Boolean, nullable=False, default=False, server_default="0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_mailboxes = db.relationship("Mailbox", secondary="domain_mailbox", lazy="joined")
|
||||||
|
|
||||||
# an alias is created automatically the first time it receives an email
|
# an alias is created automatically the first time it receives an email
|
||||||
catch_all = db.Column(db.Boolean, nullable=False, default=False, server_default="0")
|
catch_all = db.Column(db.Boolean, nullable=False, default=False, server_default="0")
|
||||||
|
|
||||||
user = db.relationship(User, foreign_keys=[user_id])
|
user = db.relationship(User, foreign_keys=[user_id])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mailboxes(self):
|
||||||
|
if self._mailboxes:
|
||||||
|
return self._mailboxes
|
||||||
|
else:
|
||||||
|
return [self.user.default_mailbox]
|
||||||
|
|
||||||
def nb_alias(self):
|
def nb_alias(self):
|
||||||
return Alias.filter_by(custom_domain_id=self.id).count()
|
return Alias.filter_by(custom_domain_id=self.id).count()
|
||||||
|
|
||||||
|
@ -1625,6 +1634,21 @@ class DirectoryMailbox(db.Model, ModelMixin):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DomainMailbox(db.Model, ModelMixin):
|
||||||
|
"""store the owning mailboxes for a domain"""
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint("domain_id", "mailbox_id", name="uq_domain_mailbox"),
|
||||||
|
)
|
||||||
|
|
||||||
|
domain_id = db.Column(
|
||||||
|
db.ForeignKey(CustomDomain.id, ondelete="cascade"), nullable=False
|
||||||
|
)
|
||||||
|
mailbox_id = db.Column(
|
||||||
|
db.ForeignKey(Mailbox.id, ondelete="cascade"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_NB_RECOVERY_CODE = 8
|
_NB_RECOVERY_CODE = 8
|
||||||
_RECOVERY_CODE_LENGTH = 8
|
_RECOVERY_CODE_LENGTH = 8
|
||||||
|
|
||||||
|
|
39
migrations/versions/2020_080112_a2b95b04d1f7_.py
Normal file
39
migrations/versions/2020_080112_a2b95b04d1f7_.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: a2b95b04d1f7
|
||||||
|
Revises: b77ab8c47cc7
|
||||||
|
Create Date: 2020-08-01 12:43:56.049075
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a2b95b04d1f7'
|
||||||
|
down_revision = 'b77ab8c47cc7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('domain_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('domain_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('mailbox_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['domain_id'], ['custom_domain.id'], ondelete='cascade'),
|
||||||
|
sa.ForeignKeyConstraint(['mailbox_id'], ['mailbox.id'], ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('domain_id', 'mailbox_id', name='uq_domain_mailbox')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('domain_mailbox')
|
||||||
|
# ### end Alembic commands ###
|
Loading…
Reference in a new issue