Compare commits
17 Commits
Author | SHA1 | Date | |
---|---|---|---|
556fae02d5 | |||
fd4c67c3d1 | |||
edef254529 | |||
357f0cca57 | |||
8ce90e27f7 | |||
3ecc8d36f9 | |||
14f4829fab | |||
63ac89e952 | |||
8896f00124 | |||
d313c94f77 | |||
39fcf2e48f | |||
41a5a65f51 | |||
1d0c7ec4a0 | |||
4de5b8eb6d | |||
0942f5eba3 | |||
dae6f64482 | |||
e7f0f81d85 |
2
app/.github/workflows/main.yml
vendored
2
app/.github/workflows/main.yml
vendored
@ -109,7 +109,7 @@ jobs:
|
|||||||
GITHUB_ACTIONS_TEST: true
|
GITHUB_ACTIONS_TEST: true
|
||||||
|
|
||||||
- name: Archive code coverage results
|
- name: Archive code coverage results
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: code-coverage-report
|
name: code-coverage-report
|
||||||
path: htmlcov
|
path: htmlcov
|
||||||
|
1
app/.gitignore
vendored
1
app/.gitignore
vendored
@ -11,7 +11,6 @@ db.sqlite-journal
|
|||||||
static/upload
|
static/upload
|
||||||
venv/
|
venv/
|
||||||
.venv
|
.venv
|
||||||
.python-version
|
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov
|
htmlcov
|
||||||
adhoc
|
adhoc
|
||||||
|
@ -8,7 +8,7 @@ repos:
|
|||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- repo: https://github.com/Riverside-Healthcare/djLint
|
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||||
rev: v1.3.0
|
rev: v1.34.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: djlint-jinja
|
- id: djlint-jinja
|
||||||
files: '.*\.html'
|
files: '.*\.html'
|
||||||
@ -22,4 +22,3 @@ repos:
|
|||||||
args: [ --fix ]
|
args: [ --fix ]
|
||||||
# Run the formatter.
|
# Run the formatter.
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|
||||||
|
@ -20,15 +20,15 @@ SimpleLogin backend consists of 2 main components:
|
|||||||
## Install dependencies
|
## Install dependencies
|
||||||
|
|
||||||
The project requires:
|
The project requires:
|
||||||
- Python 3.7+ and [poetry](https://python-poetry.org/) to manage dependencies
|
- Python 3.10 and poetry to manage dependencies
|
||||||
- Node v10 for front-end.
|
- Node v10 for front-end.
|
||||||
- Postgres 12+
|
- Postgres 13+
|
||||||
|
|
||||||
First, install all dependencies by running the following command.
|
First, install all dependencies by running the following command.
|
||||||
Feel free to use `virtualenv` or similar tools to isolate development environment.
|
Feel free to use `virtualenv` or similar tools to isolate development environment.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
poetry install
|
poetry sync
|
||||||
```
|
```
|
||||||
|
|
||||||
On Mac, sometimes you might need to install some other packages via `brew`:
|
On Mac, sometimes you might need to install some other packages via `brew`:
|
||||||
@ -223,6 +223,31 @@ Now open http://localhost:1080/ (or http://localhost:1080/ for MailHog), you sho
|
|||||||
## Job runner
|
## Job runner
|
||||||
|
|
||||||
Some features require a job handler (such as GDPR data export). To test such feature you need to run the job_runner
|
Some features require a job handler (such as GDPR data export). To test such feature you need to run the job_runner
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python job_runner.py
|
python job_runner.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
# Setup for Mac
|
||||||
|
|
||||||
|
There are several ways to setup Python and manage the project dependencies on Mac. For info we have successfully used this setup on a Mac silicon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# we haven't managed to make python 3.12 work
|
||||||
|
brew install python3.10
|
||||||
|
|
||||||
|
# make sure to update the PATH so python, pip point to Python3
|
||||||
|
# for us it can be done by adding "export PATH=/opt/homebrew/opt/python@3.10/libexec/bin:$PATH" to .zprofile
|
||||||
|
|
||||||
|
# Although pipx is the recommended way to install poetry,
|
||||||
|
# install pipx via brew will automatically install python 3.12
|
||||||
|
# and poetry will then use python 3.12
|
||||||
|
# so we recommend using poetry this way instead
|
||||||
|
curl -sSL https://install.python-poetry.org | python3 -
|
||||||
|
|
||||||
|
poetry install
|
||||||
|
|
||||||
|
# activate the virtualenv and you should be good to go!
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
```
|
@ -541,7 +541,7 @@ exit
|
|||||||
|
|
||||||
Once you've created all your desired login accounts, add these lines to `/simplelogin.env` to disable further registrations:
|
Once you've created all your desired login accounts, add these lines to `/simplelogin.env` to disable further registrations:
|
||||||
|
|
||||||
```
|
```.env
|
||||||
DISABLE_REGISTRATION=1
|
DISABLE_REGISTRATION=1
|
||||||
DISABLE_ONBOARDING=true
|
DISABLE_ONBOARDING=true
|
||||||
```
|
```
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
from flask_admin import BaseView
|
||||||
|
from flask_admin.form import SecureForm
|
||||||
from flask_admin.model.template import EndpointLinkRowAction
|
from flask_admin.model.template import EndpointLinkRowAction
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
@ -27,10 +30,27 @@ from app.models import (
|
|||||||
Alias,
|
Alias,
|
||||||
Newsletter,
|
Newsletter,
|
||||||
PADDLE_SUBSCRIPTION_GRACE_DAYS,
|
PADDLE_SUBSCRIPTION_GRACE_DAYS,
|
||||||
|
Mailbox,
|
||||||
|
DeletedAlias,
|
||||||
|
DomainDeletedAlias,
|
||||||
|
PartnerUser,
|
||||||
)
|
)
|
||||||
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
|
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_action_formatter(view, context, model, name):
|
||||||
|
action_name = AuditLogActionEnum.get_name(model.action)
|
||||||
|
return "{} ({})".format(action_name, model.action)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_date_formatter(view, context, model, name):
|
||||||
|
return model.created_at.format()
|
||||||
|
|
||||||
|
|
||||||
|
def _user_upgrade_channel_formatter(view, context, model, name):
|
||||||
|
return Markup(model.upgrade_channel)
|
||||||
|
|
||||||
|
|
||||||
class SLModelView(sqla.ModelView):
|
class SLModelView(sqla.ModelView):
|
||||||
column_default_sort = ("id", True)
|
column_default_sort = ("id", True)
|
||||||
column_display_pk = True
|
column_display_pk = True
|
||||||
@ -95,11 +115,8 @@ class SLAdminIndexView(AdminIndexView):
|
|||||||
return redirect("/admin/user")
|
return redirect("/admin/user")
|
||||||
|
|
||||||
|
|
||||||
def _user_upgrade_channel_formatter(view, context, model, name):
|
|
||||||
return Markup(model.upgrade_channel)
|
|
||||||
|
|
||||||
|
|
||||||
class UserAdmin(SLModelView):
|
class UserAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
column_searchable_list = ["email", "id"]
|
column_searchable_list = ["email", "id"]
|
||||||
column_exclude_list = [
|
column_exclude_list = [
|
||||||
"salt",
|
"salt",
|
||||||
@ -118,6 +135,8 @@ class UserAdmin(SLModelView):
|
|||||||
|
|
||||||
column_formatters = {
|
column_formatters = {
|
||||||
"upgrade_channel": _user_upgrade_channel_formatter,
|
"upgrade_channel": _user_upgrade_channel_formatter,
|
||||||
|
"created_at": _admin_date_formatter,
|
||||||
|
"updated_at": _admin_date_formatter,
|
||||||
}
|
}
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
@ -344,17 +363,29 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
|
|||||||
|
|
||||||
|
|
||||||
class EmailLogAdmin(SLModelView):
|
class EmailLogAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
column_searchable_list = ["id"]
|
column_searchable_list = ["id"]
|
||||||
column_filters = ["id", "user.email", "mailbox.email", "contact.website_email"]
|
column_filters = ["id", "user.email", "mailbox.email", "contact.website_email"]
|
||||||
|
|
||||||
can_edit = False
|
can_edit = False
|
||||||
can_create = False
|
can_create = False
|
||||||
|
|
||||||
|
column_formatters = {
|
||||||
|
"created_at": _admin_date_formatter,
|
||||||
|
"updated_at": _admin_date_formatter,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AliasAdmin(SLModelView):
|
class AliasAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
column_searchable_list = ["id", "user.email", "email", "mailbox.email"]
|
column_searchable_list = ["id", "user.email", "email", "mailbox.email"]
|
||||||
column_filters = ["id", "user.email", "email", "mailbox.email"]
|
column_filters = ["id", "user.email", "email", "mailbox.email"]
|
||||||
|
|
||||||
|
column_formatters = {
|
||||||
|
"created_at": _admin_date_formatter,
|
||||||
|
"updated_at": _admin_date_formatter,
|
||||||
|
}
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
"disable_email_spoofing_check",
|
"disable_email_spoofing_check",
|
||||||
"Disable email spoofing protection",
|
"Disable email spoofing protection",
|
||||||
@ -377,9 +408,15 @@ class AliasAdmin(SLModelView):
|
|||||||
|
|
||||||
|
|
||||||
class MailboxAdmin(SLModelView):
|
class MailboxAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
column_searchable_list = ["id", "user.email", "email"]
|
column_searchable_list = ["id", "user.email", "email"]
|
||||||
column_filters = ["id", "user.email", "email"]
|
column_filters = ["id", "user.email", "email"]
|
||||||
|
|
||||||
|
column_formatters = {
|
||||||
|
"created_at": _admin_date_formatter,
|
||||||
|
"updated_at": _admin_date_formatter,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# class LifetimeCouponAdmin(SLModelView):
|
# class LifetimeCouponAdmin(SLModelView):
|
||||||
# can_edit = True
|
# can_edit = True
|
||||||
@ -387,14 +424,26 @@ class MailboxAdmin(SLModelView):
|
|||||||
|
|
||||||
|
|
||||||
class CouponAdmin(SLModelView):
|
class CouponAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
can_edit = False
|
can_edit = False
|
||||||
can_create = True
|
can_create = True
|
||||||
|
|
||||||
|
column_formatters = {
|
||||||
|
"created_at": _admin_date_formatter,
|
||||||
|
"updated_at": _admin_date_formatter,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ManualSubscriptionAdmin(SLModelView):
|
class ManualSubscriptionAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
can_edit = True
|
can_edit = True
|
||||||
column_searchable_list = ["id", "user.email"]
|
column_searchable_list = ["id", "user.email"]
|
||||||
|
|
||||||
|
column_formatters = {
|
||||||
|
"created_at": _admin_date_formatter,
|
||||||
|
"updated_at": _admin_date_formatter,
|
||||||
|
}
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
"extend_1y",
|
"extend_1y",
|
||||||
"Extend for 1 year",
|
"Extend for 1 year",
|
||||||
@ -433,15 +482,27 @@ class ManualSubscriptionAdmin(SLModelView):
|
|||||||
|
|
||||||
|
|
||||||
class CustomDomainAdmin(SLModelView):
|
class CustomDomainAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
column_searchable_list = ["domain", "user.email", "user.id"]
|
column_searchable_list = ["domain", "user.email", "user.id"]
|
||||||
column_exclude_list = ["ownership_txt_token"]
|
column_exclude_list = ["ownership_txt_token"]
|
||||||
can_edit = False
|
can_edit = False
|
||||||
|
|
||||||
|
column_formatters = {
|
||||||
|
"created_at": _admin_date_formatter,
|
||||||
|
"updated_at": _admin_date_formatter,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ReferralAdmin(SLModelView):
|
class ReferralAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
column_searchable_list = ["id", "user.email", "code", "name"]
|
column_searchable_list = ["id", "user.email", "code", "name"]
|
||||||
column_filters = ["id", "user.email", "code", "name"]
|
column_filters = ["id", "user.email", "code", "name"]
|
||||||
|
|
||||||
|
column_formatters = {
|
||||||
|
"created_at": _admin_date_formatter,
|
||||||
|
"updated_at": _admin_date_formatter,
|
||||||
|
}
|
||||||
|
|
||||||
def scaffold_list_columns(self):
|
def scaffold_list_columns(self):
|
||||||
ret = super().scaffold_list_columns()
|
ret = super().scaffold_list_columns()
|
||||||
ret.insert(0, "nb_user")
|
ret.insert(0, "nb_user")
|
||||||
@ -457,16 +518,8 @@ class ReferralAdmin(SLModelView):
|
|||||||
# can_delete = True
|
# can_delete = True
|
||||||
|
|
||||||
|
|
||||||
def _admin_action_formatter(view, context, model, name):
|
|
||||||
action_name = AuditLogActionEnum.get_name(model.action)
|
|
||||||
return "{} ({})".format(action_name, model.action)
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_created_at_formatter(view, context, model, name):
|
|
||||||
return model.created_at.format()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminAuditLogAdmin(SLModelView):
|
class AdminAuditLogAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
column_searchable_list = ["admin.id", "admin.email", "model_id", "created_at"]
|
column_searchable_list = ["admin.id", "admin.email", "model_id", "created_at"]
|
||||||
column_filters = ["admin.id", "admin.email", "model_id", "created_at"]
|
column_filters = ["admin.id", "admin.email", "model_id", "created_at"]
|
||||||
column_exclude_list = ["id"]
|
column_exclude_list = ["id"]
|
||||||
@ -477,7 +530,8 @@ class AdminAuditLogAdmin(SLModelView):
|
|||||||
|
|
||||||
column_formatters = {
|
column_formatters = {
|
||||||
"action": _admin_action_formatter,
|
"action": _admin_action_formatter,
|
||||||
"created_at": _admin_created_at_formatter,
|
"created_at": _admin_date_formatter,
|
||||||
|
"updated_at": _admin_date_formatter,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -497,6 +551,7 @@ def _transactionalcomplaint_refused_email_id_formatter(view, context, model, nam
|
|||||||
|
|
||||||
|
|
||||||
class ProviderComplaintAdmin(SLModelView):
|
class ProviderComplaintAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
column_searchable_list = ["id", "user.id", "created_at"]
|
column_searchable_list = ["id", "user.id", "created_at"]
|
||||||
column_filters = ["user.id", "state"]
|
column_filters = ["user.id", "state"]
|
||||||
column_hide_backrefs = False
|
column_hide_backrefs = False
|
||||||
@ -505,8 +560,8 @@ class ProviderComplaintAdmin(SLModelView):
|
|||||||
can_delete = False
|
can_delete = False
|
||||||
|
|
||||||
column_formatters = {
|
column_formatters = {
|
||||||
"created_at": _admin_created_at_formatter,
|
"created_at": _admin_date_formatter,
|
||||||
"updated_at": _admin_created_at_formatter,
|
"updated_at": _admin_date_formatter,
|
||||||
"state": _transactionalcomplaint_state_formatter,
|
"state": _transactionalcomplaint_state_formatter,
|
||||||
"phase": _transactionalcomplaint_phase_formatter,
|
"phase": _transactionalcomplaint_phase_formatter,
|
||||||
"refused_email": _transactionalcomplaint_refused_email_id_formatter,
|
"refused_email": _transactionalcomplaint_refused_email_id_formatter,
|
||||||
@ -567,6 +622,7 @@ def _newsletter_html_formatter(view, context, model: Newsletter, name):
|
|||||||
|
|
||||||
|
|
||||||
class NewsletterAdmin(SLModelView):
|
class NewsletterAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
list_template = "admin/model/newsletter-list.html"
|
list_template = "admin/model/newsletter-list.html"
|
||||||
edit_template = "admin/model/newsletter-edit.html"
|
edit_template = "admin/model/newsletter-edit.html"
|
||||||
edit_modal = False
|
edit_modal = False
|
||||||
@ -648,6 +704,7 @@ class NewsletterAdmin(SLModelView):
|
|||||||
|
|
||||||
|
|
||||||
class NewsletterUserAdmin(SLModelView):
|
class NewsletterUserAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
column_searchable_list = ["id"]
|
column_searchable_list = ["id"]
|
||||||
column_filters = ["id", "user.email", "newsletter.subject"]
|
column_filters = ["id", "user.email", "newsletter.subject"]
|
||||||
column_exclude_list = ["created_at", "updated_at", "id"]
|
column_exclude_list = ["created_at", "updated_at", "id"]
|
||||||
@ -657,17 +714,112 @@ class NewsletterUserAdmin(SLModelView):
|
|||||||
|
|
||||||
|
|
||||||
class DailyMetricAdmin(SLModelView):
|
class DailyMetricAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
column_exclude_list = ["created_at", "updated_at", "id"]
|
column_exclude_list = ["created_at", "updated_at", "id"]
|
||||||
|
|
||||||
can_export = True
|
can_export = True
|
||||||
|
|
||||||
|
|
||||||
class MetricAdmin(SLModelView):
|
class MetricAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
column_exclude_list = ["created_at", "updated_at", "id"]
|
column_exclude_list = ["created_at", "updated_at", "id"]
|
||||||
|
|
||||||
can_export = True
|
can_export = True
|
||||||
|
|
||||||
|
|
||||||
class InvalidMailboxDomainAdmin(SLModelView):
|
class InvalidMailboxDomainAdmin(SLModelView):
|
||||||
|
form_base_class = SecureForm
|
||||||
can_create = True
|
can_create = True
|
||||||
can_delete = True
|
can_delete = True
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSearchResult:
|
||||||
|
no_match: bool = True
|
||||||
|
alias: Optional[Alias] = None
|
||||||
|
mailbox: list[Mailbox] = []
|
||||||
|
mailbox_count: int = 0
|
||||||
|
deleted_alias: Optional[DeletedAlias] = None
|
||||||
|
deleted_custom_alias: Optional[DomainDeletedAlias] = None
|
||||||
|
user: Optional[User] = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_email(email: str) -> EmailSearchResult:
|
||||||
|
output = EmailSearchResult()
|
||||||
|
alias = Alias.get_by(email=email)
|
||||||
|
if alias:
|
||||||
|
output.alias = alias
|
||||||
|
output.no_match = False
|
||||||
|
user = User.get_by(email=email)
|
||||||
|
if user:
|
||||||
|
output.user = user
|
||||||
|
output.no_match = False
|
||||||
|
mailboxes = (
|
||||||
|
Mailbox.filter_by(email=email).order_by(Mailbox.id.desc()).limit(10).all()
|
||||||
|
)
|
||||||
|
if mailboxes:
|
||||||
|
output.mailbox = mailboxes
|
||||||
|
output.mailbox_count = Mailbox.filter_by(email=email).count()
|
||||||
|
output.no_match = False
|
||||||
|
deleted_alias = DeletedAlias.get_by(email=email)
|
||||||
|
if deleted_alias:
|
||||||
|
output.deleted_alias = deleted_alias
|
||||||
|
output.no_match = False
|
||||||
|
domain_deleted_alias = DomainDeletedAlias.get_by(email=email)
|
||||||
|
if domain_deleted_alias:
|
||||||
|
output.domain_deleted_alias = domain_deleted_alias
|
||||||
|
output.no_match = False
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSearchHelpers:
|
||||||
|
@staticmethod
|
||||||
|
def mailbox_list(user: User) -> list[Mailbox]:
|
||||||
|
return (
|
||||||
|
Mailbox.filter_by(user_id=user.id)
|
||||||
|
.order_by(Mailbox.id.asc())
|
||||||
|
.limit(10)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mailbox_count(user: User) -> int:
|
||||||
|
return Mailbox.filter_by(user_id=user.id).order_by(Mailbox.id.desc()).count()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def alias_list(user: User) -> list[Alias]:
|
||||||
|
return (
|
||||||
|
Alias.filter_by(user_id=user.id).order_by(Alias.id.desc()).limit(10).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def alias_count(user: User) -> int:
|
||||||
|
return Alias.filter_by(user_id=user.id).count()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def partner_user(user: User) -> Optional[PartnerUser]:
|
||||||
|
return PartnerUser.get_by(user_id=user.id)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSearchAdmin(BaseView):
|
||||||
|
def is_accessible(self):
|
||||||
|
return current_user.is_authenticated and current_user.is_admin
|
||||||
|
|
||||||
|
def inaccessible_callback(self, name, **kwargs):
|
||||||
|
# redirect to login page if user doesn't have access
|
||||||
|
flash("You don't have access to the admin page", "error")
|
||||||
|
return redirect(url_for("dashboard.index", next=request.url))
|
||||||
|
|
||||||
|
@expose("/", methods=["GET", "POST"])
|
||||||
|
def index(self):
|
||||||
|
search = EmailSearchResult()
|
||||||
|
email = request.args.get("email")
|
||||||
|
if email is not None and len(email) > 0:
|
||||||
|
email = email.strip()
|
||||||
|
search = EmailSearchResult.from_email(email)
|
||||||
|
|
||||||
|
return self.render(
|
||||||
|
"admin/email_search.html",
|
||||||
|
email=email,
|
||||||
|
data=search,
|
||||||
|
helper=EmailSearchHelpers,
|
||||||
|
)
|
||||||
|
@ -64,8 +64,12 @@ def verify_prefix_suffix(
|
|||||||
# SimpleLogin domain case:
|
# SimpleLogin domain case:
|
||||||
# 1) alias_suffix must start with "." and
|
# 1) alias_suffix must start with "." and
|
||||||
# 2) alias_domain_prefix must come from the word list
|
# 2) alias_domain_prefix must come from the word list
|
||||||
|
available_sl_domains = [
|
||||||
|
sl_domain.domain
|
||||||
|
for sl_domain in user.get_sl_domains(alias_options=alias_options)
|
||||||
|
]
|
||||||
if (
|
if (
|
||||||
alias_domain in user.available_sl_domains(alias_options=alias_options)
|
alias_domain in available_sl_domains
|
||||||
and alias_domain not in user_custom_domains
|
and alias_domain not in user_custom_domains
|
||||||
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
|
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
|
||||||
and not config.DISABLE_ALIAS_SUFFIX
|
and not config.DISABLE_ALIAS_SUFFIX
|
||||||
@ -80,9 +84,7 @@ def verify_prefix_suffix(
|
|||||||
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if alias_domain not in user.available_sl_domains(
|
if alias_domain not in available_sl_domains:
|
||||||
alias_options=alias_options
|
|
||||||
):
|
|
||||||
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -63,12 +63,16 @@ def get_user_if_alias_would_auto_create(
|
|||||||
# Prevent addresses with unicode characters (🤯) in them for now.
|
# Prevent addresses with unicode characters (🤯) in them for now.
|
||||||
validate_email(address, check_deliverability=False, allow_smtputf8=False)
|
validate_email(address, check_deliverability=False, allow_smtputf8=False)
|
||||||
except EmailNotValidError:
|
except EmailNotValidError:
|
||||||
|
LOG.i(f"Not creating alias for {address} because email is invalid")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
domain_and_rule = check_if_alias_can_be_auto_created_for_custom_domain(
|
domain_and_rule = check_if_alias_can_be_auto_created_for_custom_domain(
|
||||||
address, notify_user=notify_user
|
address, notify_user=notify_user
|
||||||
)
|
)
|
||||||
if DomainDeletedAlias.get_by(email=address):
|
if DomainDeletedAlias.get_by(email=address):
|
||||||
|
LOG.i(
|
||||||
|
f"Not creating alias for {address} because it was previously deleted for this domain"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
if domain_and_rule:
|
if domain_and_rule:
|
||||||
return domain_and_rule[0].user
|
return domain_and_rule[0].user
|
||||||
@ -93,6 +97,9 @@ def check_if_alias_can_be_auto_created_for_custom_domain(
|
|||||||
custom_domain: CustomDomain = CustomDomain.get_by(domain=alias_domain)
|
custom_domain: CustomDomain = CustomDomain.get_by(domain=alias_domain)
|
||||||
|
|
||||||
if not custom_domain:
|
if not custom_domain:
|
||||||
|
LOG.i(
|
||||||
|
f"Cannot auto-create custom domain alias for {address} because there's no custom domain for {alias_domain}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user: User = custom_domain.user
|
user: User = custom_domain.user
|
||||||
@ -108,6 +115,9 @@ def check_if_alias_can_be_auto_created_for_custom_domain(
|
|||||||
|
|
||||||
if not custom_domain.catch_all:
|
if not custom_domain.catch_all:
|
||||||
if len(custom_domain.auto_create_rules) == 0:
|
if len(custom_domain.auto_create_rules) == 0:
|
||||||
|
LOG.i(
|
||||||
|
f"Cannot create alias {address} for domain {custom_domain} because it has no catch-all and no rules"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
local = get_email_local_part(address)
|
local = get_email_local_part(address)
|
||||||
|
|
||||||
@ -121,7 +131,7 @@ def check_if_alias_can_be_auto_created_for_custom_domain(
|
|||||||
)
|
)
|
||||||
return custom_domain, rule
|
return custom_domain, rule
|
||||||
else: # no rule passes
|
else: # no rule passes
|
||||||
LOG.d("no rule passed to create %s", local)
|
LOG.d(f"No rule matches auto-create {address} for domain {custom_domain}")
|
||||||
return None
|
return None
|
||||||
LOG.d("Create alias via catchall")
|
LOG.d("Create alias via catchall")
|
||||||
|
|
||||||
@ -148,6 +158,7 @@ def check_if_alias_can_be_auto_created_for_a_directory(
|
|||||||
sep = "#"
|
sep = "#"
|
||||||
else:
|
else:
|
||||||
# if there's no directory separator in the alias, no way to auto-create it
|
# if there's no directory separator in the alias, no way to auto-create it
|
||||||
|
LOG.info(f"Cannot auto-create {address} since it has no directory separator")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
directory_name = address[: address.find(sep)]
|
directory_name = address[: address.find(sep)]
|
||||||
@ -155,6 +166,9 @@ def check_if_alias_can_be_auto_created_for_a_directory(
|
|||||||
|
|
||||||
directory = Directory.get_by(name=directory_name)
|
directory = Directory.get_by(name=directory_name)
|
||||||
if not directory:
|
if not directory:
|
||||||
|
LOG.info(
|
||||||
|
f"Cannot auto-create {address} because there is no directory for {directory_name}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user: User = directory.user
|
user: User = directory.user
|
||||||
@ -163,12 +177,17 @@ def check_if_alias_can_be_auto_created_for_a_directory(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if not user.can_create_new_alias():
|
if not user.can_create_new_alias():
|
||||||
LOG.d(f"{user} can't create new directory alias {address}")
|
LOG.d(
|
||||||
|
f"{user} can't create new directory alias {address} because user cannot create aliases"
|
||||||
|
)
|
||||||
if notify_user:
|
if notify_user:
|
||||||
send_cannot_create_directory_alias(user, address, directory_name)
|
send_cannot_create_directory_alias(user, address, directory_name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if directory.disabled:
|
if directory.disabled:
|
||||||
|
LOG.d(
|
||||||
|
f"{user} can't create new directory alias {address} bcause directory is disabled"
|
||||||
|
)
|
||||||
if notify_user:
|
if notify_user:
|
||||||
send_cannot_create_directory_alias_disabled(user, address, directory_name)
|
send_cannot_create_directory_alias_disabled(user, address, directory_name)
|
||||||
return None
|
return None
|
||||||
@ -311,7 +330,10 @@ def try_auto_create_via_domain(address: str) -> Optional[Alias]:
|
|||||||
|
|
||||||
|
|
||||||
def delete_alias(
|
def delete_alias(
|
||||||
alias: Alias, user: User, reason: AliasDeleteReason = AliasDeleteReason.Unspecified
|
alias: Alias,
|
||||||
|
user: User,
|
||||||
|
reason: AliasDeleteReason = AliasDeleteReason.Unspecified,
|
||||||
|
commit: bool = False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Delete an alias and add it to either global or domain trash
|
Delete an alias and add it to either global or domain trash
|
||||||
@ -341,12 +363,17 @@ def delete_alias(
|
|||||||
Session.commit()
|
Session.commit()
|
||||||
LOG.i(f"Moving {alias} to global trash {deleted_alias}")
|
LOG.i(f"Moving {alias} to global trash {deleted_alias}")
|
||||||
|
|
||||||
|
alias_id = alias.id
|
||||||
|
alias_email = alias.email
|
||||||
Alias.filter(Alias.id == alias.id).delete()
|
Alias.filter(Alias.id == alias.id).delete()
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
EventDispatcher.send_event(
|
EventDispatcher.send_event(
|
||||||
user, EventContent(alias_deleted=AliasDeleted(alias_id=alias.id))
|
user,
|
||||||
|
EventContent(alias_deleted=AliasDeleted(id=alias_id, email=alias_email)),
|
||||||
)
|
)
|
||||||
|
if commit:
|
||||||
|
Session.commit()
|
||||||
|
|
||||||
|
|
||||||
def aliases_for_mailbox(mailbox: Mailbox) -> [Alias]:
|
def aliases_for_mailbox(mailbox: Mailbox) -> [Alias]:
|
||||||
@ -482,7 +509,10 @@ def change_alias_status(alias: Alias, enabled: bool, commit: bool = False):
|
|||||||
alias.enabled = enabled
|
alias.enabled = enabled
|
||||||
|
|
||||||
event = AliasStatusChanged(
|
event = AliasStatusChanged(
|
||||||
alias_id=alias.id, alias_email=alias.email, enabled=enabled
|
id=alias.id,
|
||||||
|
email=alias.email,
|
||||||
|
enabled=enabled,
|
||||||
|
created_at=int(alias.created_at.timestamp),
|
||||||
)
|
)
|
||||||
EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event))
|
EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event))
|
||||||
|
|
||||||
|
@ -5,7 +5,6 @@ import arrow
|
|||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
from app import constants
|
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.models import ApiKey
|
from app.models import ApiKey
|
||||||
|
|
||||||
@ -19,9 +18,10 @@ def authorize_request() -> Optional[Tuple[str, int]]:
|
|||||||
api_key = ApiKey.get_by(code=api_code)
|
api_key = ApiKey.get_by(code=api_code)
|
||||||
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
if current_user.is_authenticated and request.headers.get(
|
if current_user.is_authenticated:
|
||||||
constants.HEADER_ALLOW_API_COOKIES
|
# if current_user.is_authenticated and request.headers.get(
|
||||||
):
|
# constants.HEADER_ALLOW_API_COOKIES
|
||||||
|
# ):
|
||||||
g.user = current_user
|
g.user = current_user
|
||||||
else:
|
else:
|
||||||
return jsonify(error="Wrong api key"), 401
|
return jsonify(error="Wrong api key"), 401
|
||||||
|
@ -1,22 +1,18 @@
|
|||||||
from smtplib import SMTPRecipientsRefused
|
from smtplib import SMTPRecipientsRefused
|
||||||
|
|
||||||
import arrow
|
|
||||||
from flask import g
|
from flask import g
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
|
from app import mailbox_utils
|
||||||
from app.api.base import api_bp, require_api_auth
|
from app.api.base import api_bp, require_api_auth
|
||||||
from app.config import JOB_DELETE_MAILBOX
|
|
||||||
from app.dashboard.views.mailbox import send_verification_email
|
|
||||||
from app.dashboard.views.mailbox_detail import verify_mailbox_change
|
from app.dashboard.views.mailbox_detail import verify_mailbox_change
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.email_utils import (
|
from app.email_utils import (
|
||||||
mailbox_already_used,
|
mailbox_already_used,
|
||||||
email_can_be_used_as_mailbox,
|
email_can_be_used_as_mailbox,
|
||||||
)
|
)
|
||||||
from app.email_validation import is_valid_email
|
from app.models import Mailbox
|
||||||
from app.log import LOG
|
|
||||||
from app.models import Mailbox, Job
|
|
||||||
from app.utils import sanitize_email
|
from app.utils import sanitize_email
|
||||||
|
|
||||||
|
|
||||||
@ -44,26 +40,10 @@ def create_mailbox():
|
|||||||
user = g.user
|
user = g.user
|
||||||
mailbox_email = sanitize_email(request.get_json().get("email"))
|
mailbox_email = sanitize_email(request.get_json().get("email"))
|
||||||
|
|
||||||
if not user.is_premium():
|
try:
|
||||||
return jsonify(error="Only premium plan can add additional mailbox"), 400
|
new_mailbox = mailbox_utils.create_mailbox(user, mailbox_email).mailbox
|
||||||
|
except mailbox_utils.MailboxError as e:
|
||||||
if not is_valid_email(mailbox_email):
|
return jsonify(error=e.msg), 400
|
||||||
return jsonify(error=f"{mailbox_email} invalid"), 400
|
|
||||||
elif mailbox_already_used(mailbox_email, user):
|
|
||||||
return jsonify(error=f"{mailbox_email} already used"), 400
|
|
||||||
elif not email_can_be_used_as_mailbox(mailbox_email):
|
|
||||||
return (
|
|
||||||
jsonify(
|
|
||||||
error=f"{mailbox_email} cannot be used. Please note a mailbox cannot "
|
|
||||||
f"be a disposable email address"
|
|
||||||
),
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
new_mailbox = Mailbox.create(email=mailbox_email, user_id=user.id)
|
|
||||||
Session.commit()
|
|
||||||
|
|
||||||
send_verification_email(user, new_mailbox)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
jsonify(mailbox_to_dict(new_mailbox)),
|
jsonify(mailbox_to_dict(new_mailbox)),
|
||||||
@ -86,47 +66,17 @@ def delete_mailbox(mailbox_id):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
user = g.user
|
user = g.user
|
||||||
mailbox = Mailbox.get(mailbox_id)
|
|
||||||
|
|
||||||
if not mailbox or mailbox.user_id != user.id:
|
|
||||||
return jsonify(error="Forbidden"), 403
|
|
||||||
|
|
||||||
if mailbox.id == user.default_mailbox_id:
|
|
||||||
return jsonify(error="You cannot delete the default mailbox"), 400
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
transfer_mailbox_id = data.get("transfer_aliases_to")
|
transfer_mailbox_id = data.get("transfer_aliases_to")
|
||||||
if transfer_mailbox_id and int(transfer_mailbox_id) >= 0:
|
if transfer_mailbox_id and int(transfer_mailbox_id) >= 0:
|
||||||
transfer_mailbox = Mailbox.get(transfer_mailbox_id)
|
transfer_mailbox_id = int(transfer_mailbox_id)
|
||||||
|
else:
|
||||||
|
transfer_mailbox_id = None
|
||||||
|
|
||||||
if not transfer_mailbox or transfer_mailbox.user_id != user.id:
|
try:
|
||||||
return (
|
mailbox_utils.delete_mailbox(user, mailbox_id, transfer_mailbox_id)
|
||||||
jsonify(error="You must transfer the aliases to a mailbox you own."),
|
except mailbox_utils.MailboxError as e:
|
||||||
403,
|
return jsonify(error=e.msg), 400
|
||||||
)
|
|
||||||
|
|
||||||
if transfer_mailbox_id == mailbox_id:
|
|
||||||
return (
|
|
||||||
jsonify(
|
|
||||||
error="You can not transfer the aliases to the mailbox you want to delete."
|
|
||||||
),
|
|
||||||
400,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not transfer_mailbox.verified:
|
|
||||||
return jsonify(error="Your new mailbox is not verified"), 400
|
|
||||||
|
|
||||||
# Schedule delete account job
|
|
||||||
LOG.w("schedule delete mailbox job for %s", mailbox)
|
|
||||||
Job.create(
|
|
||||||
name=JOB_DELETE_MAILBOX,
|
|
||||||
payload={
|
|
||||||
"mailbox_id": mailbox.id,
|
|
||||||
"transfer_mailbox_id": transfer_mailbox_id,
|
|
||||||
},
|
|
||||||
run_at=arrow.now(),
|
|
||||||
commit=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(deleted=True), 200
|
return jsonify(deleted=True), 200
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ from app.api.base import api_bp, require_api_auth
|
|||||||
from app.config import SESSION_COOKIE_NAME
|
from app.config import SESSION_COOKIE_NAME
|
||||||
from app.dashboard.views.index import get_stats
|
from app.dashboard.views.index import get_stats
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
|
from app.image_validation import detect_image_format, ImageFormat
|
||||||
from app.models import ApiKey, File, PartnerUser, User
|
from app.models import ApiKey, File, PartnerUser, User
|
||||||
from app.proton.utils import get_proton_partner
|
from app.proton.utils import get_proton_partner
|
||||||
from app.session import logout_session
|
from app.session import logout_session
|
||||||
@ -78,7 +79,6 @@ def update_user_info():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|
||||||
if "profile_picture" in data:
|
if "profile_picture" in data:
|
||||||
if data["profile_picture"] is None:
|
|
||||||
if user.profile_picture_id:
|
if user.profile_picture_id:
|
||||||
file = user.profile_picture
|
file = user.profile_picture
|
||||||
user.profile_picture_id = None
|
user.profile_picture_id = None
|
||||||
@ -89,6 +89,8 @@ def update_user_info():
|
|||||||
Session.flush()
|
Session.flush()
|
||||||
else:
|
else:
|
||||||
raw_data = base64.decodebytes(data["profile_picture"].encode())
|
raw_data = base64.decodebytes(data["profile_picture"].encode())
|
||||||
|
if detect_image_format(raw_data) == ImageFormat.Unknown:
|
||||||
|
return jsonify(error="Unsupported image format"), 400
|
||||||
file_path = random_string(30)
|
file_path = random_string(30)
|
||||||
file = File.create(user_id=user.id, path=file_path)
|
file = File.create(user_id=user.id, path=file_path)
|
||||||
Session.flush()
|
Session.flush()
|
||||||
|
@ -115,7 +115,8 @@ def register():
|
|||||||
|
|
||||||
|
|
||||||
def send_activation_email(user, next_url):
|
def send_activation_email(user, next_url):
|
||||||
# the activation code is valid for 1h
|
# the activation code is valid for 1h and delete all previous codes
|
||||||
|
Session.query(ActivationCode).filter(ActivationCode.user_id == user.id).delete()
|
||||||
activation = ActivationCode.create(user_id=user.id, code=random_string(30))
|
activation = ActivationCode.create(user_id=user.id, code=random_string(30))
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import random
|
|||||||
import socket
|
import socket
|
||||||
import string
|
import string
|
||||||
from ast import literal_eval
|
from ast import literal_eval
|
||||||
from typing import Callable, List
|
from typing import Callable, List, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@ -35,6 +35,33 @@ def sl_getenv(env_var: str, default_factory: Callable = None):
|
|||||||
return literal_eval(value)
|
return literal_eval(value)
|
||||||
|
|
||||||
|
|
||||||
|
def get_env_dict(env_var: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Get an env variable and convert it into a python dictionary with keys and values as strings.
|
||||||
|
Args:
|
||||||
|
env_var (str): env var, example: SL_DB
|
||||||
|
|
||||||
|
Syntax is: key1=value1;key2=value2
|
||||||
|
Components separated by ;
|
||||||
|
key and value separated by =
|
||||||
|
"""
|
||||||
|
value = os.getenv(env_var)
|
||||||
|
if not value:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
components = value.split(";")
|
||||||
|
result = {}
|
||||||
|
for component in components:
|
||||||
|
if component == "":
|
||||||
|
continue
|
||||||
|
parts = component.split("=")
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise Exception(f"Invalid config for env var {env_var}")
|
||||||
|
result[parts[0].strip()] = parts[1].strip()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
config_file = os.environ.get("CONFIG")
|
config_file = os.environ.get("CONFIG")
|
||||||
if config_file:
|
if config_file:
|
||||||
config_file = get_abs_path(config_file)
|
config_file = get_abs_path(config_file)
|
||||||
@ -588,3 +615,45 @@ EVENT_WEBHOOK = os.environ.get("EVENT_WEBHOOK", None)
|
|||||||
# We want it disabled by default, so only skip if defined
|
# We want it disabled by default, so only skip if defined
|
||||||
EVENT_WEBHOOK_SKIP_VERIFY_SSL = "EVENT_WEBHOOK_SKIP_VERIFY_SSL" in os.environ
|
EVENT_WEBHOOK_SKIP_VERIFY_SSL = "EVENT_WEBHOOK_SKIP_VERIFY_SSL" in os.environ
|
||||||
EVENT_WEBHOOK_DISABLE = "EVENT_WEBHOOK_DISABLE" in os.environ
|
EVENT_WEBHOOK_DISABLE = "EVENT_WEBHOOK_DISABLE" in os.environ
|
||||||
|
|
||||||
|
|
||||||
|
def read_webhook_enabled_user_ids() -> Optional[List[int]]:
|
||||||
|
user_ids = os.environ.get("EVENT_WEBHOOK_ENABLED_USER_IDS", None)
|
||||||
|
if user_ids is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ids = []
|
||||||
|
for user_id in user_ids.split(","):
|
||||||
|
try:
|
||||||
|
ids.append(int(user_id.strip()))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
EVENT_WEBHOOK_ENABLED_USER_IDS: Optional[List[int]] = read_webhook_enabled_user_ids()
|
||||||
|
|
||||||
|
# Allow to define a different DB_URI for the event listener, in case we want to skip the connection pool
|
||||||
|
# It defaults to the regular DB_URI in case it's needed
|
||||||
|
EVENT_LISTENER_DB_URI = os.environ.get("EVENT_LISTENER_DB_URI", DB_URI)
|
||||||
|
|
||||||
|
|
||||||
|
def read_partner_dict(var: str) -> dict[int, str]:
|
||||||
|
partner_value = get_env_dict(var)
|
||||||
|
if len(partner_value) == 0:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
res: dict[int, str] = {}
|
||||||
|
for partner_id in partner_value.keys():
|
||||||
|
try:
|
||||||
|
partner_id_int = int(partner_id.strip())
|
||||||
|
res[partner_id_int] = partner_value[partner_id]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
PARTNER_DOMAINS: dict[int, str] = read_partner_dict("PARTNER_DOMAINS")
|
||||||
|
PARTNER_DOMAIN_VALIDATION_PREFIXES: dict[int, str] = read_partner_dict(
|
||||||
|
"PARTNER_DOMAIN_VALIDATION_PREFIXES"
|
||||||
|
)
|
||||||
|
@ -1 +1,2 @@
|
|||||||
HEADER_ALLOW_API_COOKIES = "X-Sl-Allowcookies"
|
HEADER_ALLOW_API_COOKIES = "X-Sl-Allowcookies"
|
||||||
|
DMARC_RECORD = "v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s"
|
||||||
|
89
app/app/contact_utils.py
Normal file
89
app/app/contact_utils.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.db import Session
|
||||||
|
from app.email_utils import generate_reply_email
|
||||||
|
from app.email_validation import is_valid_email
|
||||||
|
from app.log import LOG
|
||||||
|
from app.models import Contact, Alias
|
||||||
|
from app.utils import sanitize_email
|
||||||
|
|
||||||
|
|
||||||
|
class ContactCreateError(Enum):
|
||||||
|
InvalidEmail = "Invalid email"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ContactCreateResult:
|
||||||
|
contact: Optional[Contact]
|
||||||
|
error: Optional[ContactCreateError]
|
||||||
|
|
||||||
|
|
||||||
|
def __update_contact_if_needed(
|
||||||
|
contact: Contact, name: Optional[str], mail_from: Optional[str]
|
||||||
|
) -> ContactCreateResult:
|
||||||
|
if name and contact.name != name:
|
||||||
|
LOG.d(f"Setting {contact} name to {name}")
|
||||||
|
contact.name = name
|
||||||
|
Session.commit()
|
||||||
|
if mail_from and contact.mail_from is None:
|
||||||
|
LOG.d(f"Setting {contact} mail_from to {mail_from}")
|
||||||
|
contact.mail_from = mail_from
|
||||||
|
Session.commit()
|
||||||
|
return ContactCreateResult(contact, None)
|
||||||
|
|
||||||
|
|
||||||
|
def create_contact(
|
||||||
|
email: str,
|
||||||
|
name: Optional[str],
|
||||||
|
alias: Alias,
|
||||||
|
mail_from: Optional[str] = None,
|
||||||
|
allow_empty_email: bool = False,
|
||||||
|
automatic_created: bool = False,
|
||||||
|
from_partner: bool = False,
|
||||||
|
) -> ContactCreateResult:
|
||||||
|
if name is not None:
|
||||||
|
name = name[: Contact.MAX_NAME_LENGTH]
|
||||||
|
if name is not None and "\x00" in name:
|
||||||
|
LOG.w("Cannot use contact name because has \\x00")
|
||||||
|
name = ""
|
||||||
|
if not is_valid_email(email):
|
||||||
|
LOG.w(f"invalid contact email {email}")
|
||||||
|
if not allow_empty_email:
|
||||||
|
return ContactCreateResult(None, ContactCreateError.InvalidEmail)
|
||||||
|
LOG.d("Create a contact with invalid email for %s", alias)
|
||||||
|
# either reuse a contact with empty email or create a new contact with empty email
|
||||||
|
email = ""
|
||||||
|
email = sanitize_email(email, not_lower=True)
|
||||||
|
contact = Contact.get_by(alias_id=alias.id, website_email=email)
|
||||||
|
if contact is not None:
|
||||||
|
return __update_contact_if_needed(contact, name, mail_from)
|
||||||
|
reply_email = generate_reply_email(email, alias)
|
||||||
|
try:
|
||||||
|
flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0
|
||||||
|
contact = Contact.create(
|
||||||
|
user_id=alias.user_id,
|
||||||
|
alias_id=alias.id,
|
||||||
|
website_email=email,
|
||||||
|
name=name,
|
||||||
|
reply_email=reply_email,
|
||||||
|
mail_from=mail_from,
|
||||||
|
automatic_created=automatic_created,
|
||||||
|
flags=flags,
|
||||||
|
invalid_email=email == "",
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
LOG.d(
|
||||||
|
f"Created contact {contact} for alias {alias} with email {email} invalid_email={contact.invalid_email}"
|
||||||
|
)
|
||||||
|
except IntegrityError:
|
||||||
|
Session.rollback()
|
||||||
|
LOG.info(
|
||||||
|
f"Contact with email {email} for alias_id {alias.id} already existed, fetching from DB"
|
||||||
|
)
|
||||||
|
contact = Contact.get_by(alias_id=alias.id, website_email=email)
|
||||||
|
return __update_contact_if_needed(contact, name, mail_from)
|
||||||
|
return ContactCreateResult(contact, None)
|
142
app/app/custom_domain_utils.py
Normal file
142
app/app/custom_domain_utils.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import arrow
|
||||||
|
import re
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.config import JOB_DELETE_DOMAIN
|
||||||
|
from app.db import Session
|
||||||
|
from app.email_utils import get_email_domain_part
|
||||||
|
from app.log import LOG
|
||||||
|
from app.models import User, CustomDomain, SLDomain, Mailbox, Job
|
||||||
|
|
||||||
|
_ALLOWED_DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CreateCustomDomainResult:
|
||||||
|
message: str = ""
|
||||||
|
message_category: str = ""
|
||||||
|
success: bool = False
|
||||||
|
instance: Optional[CustomDomain] = None
|
||||||
|
redirect: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CannotUseDomainReason(Enum):
|
||||||
|
InvalidDomain = 1
|
||||||
|
BuiltinDomain = 2
|
||||||
|
DomainAlreadyUsed = 3
|
||||||
|
DomainPartOfUserEmail = 4
|
||||||
|
DomainUserInMailbox = 5
|
||||||
|
|
||||||
|
def message(self, domain: str) -> str:
|
||||||
|
if self == CannotUseDomainReason.InvalidDomain:
|
||||||
|
return "This is not a valid domain"
|
||||||
|
elif self == CannotUseDomainReason.BuiltinDomain:
|
||||||
|
return "A custom domain cannot be a built-in domain."
|
||||||
|
elif self == CannotUseDomainReason.DomainAlreadyUsed:
|
||||||
|
return f"{domain} already used"
|
||||||
|
elif self == CannotUseDomainReason.DomainPartOfUserEmail:
|
||||||
|
return "You cannot add a domain that you are currently using for your personal email. Please change your personal email to your real email"
|
||||||
|
elif self == CannotUseDomainReason.DomainUserInMailbox:
|
||||||
|
return f"{domain} already used in a SimpleLogin mailbox"
|
||||||
|
else:
|
||||||
|
raise Exception("Invalid CannotUseDomainReason")
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_domain(domain: str) -> bool:
|
||||||
|
"""
|
||||||
|
Checks that a domain is valid according to RFC 1035
|
||||||
|
"""
|
||||||
|
if len(domain) > 255:
|
||||||
|
return False
|
||||||
|
if domain.endswith("."):
|
||||||
|
domain = domain[:-1] # Strip the trailing dot
|
||||||
|
labels = domain.split(".")
|
||||||
|
if not labels:
|
||||||
|
return False
|
||||||
|
for label in labels:
|
||||||
|
if not _ALLOWED_DOMAIN_REGEX.match(label):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_domain(domain: str) -> str:
|
||||||
|
new_domain = domain.lower().strip()
|
||||||
|
if new_domain.startswith("http://"):
|
||||||
|
new_domain = new_domain[len("http://") :]
|
||||||
|
|
||||||
|
if new_domain.startswith("https://"):
|
||||||
|
new_domain = new_domain[len("https://") :]
|
||||||
|
|
||||||
|
return new_domain
|
||||||
|
|
||||||
|
|
||||||
|
def can_domain_be_used(user: User, domain: str) -> Optional[CannotUseDomainReason]:
|
||||||
|
if not is_valid_domain(domain):
|
||||||
|
return CannotUseDomainReason.InvalidDomain
|
||||||
|
elif SLDomain.get_by(domain=domain):
|
||||||
|
return CannotUseDomainReason.BuiltinDomain
|
||||||
|
elif CustomDomain.get_by(domain=domain):
|
||||||
|
return CannotUseDomainReason.DomainAlreadyUsed
|
||||||
|
elif get_email_domain_part(user.email) == domain:
|
||||||
|
return CannotUseDomainReason.DomainPartOfUserEmail
|
||||||
|
elif Mailbox.filter(
|
||||||
|
Mailbox.verified.is_(True), Mailbox.email.endswith(f"@{domain}")
|
||||||
|
).first():
|
||||||
|
return CannotUseDomainReason.DomainUserInMailbox
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_custom_domain(
|
||||||
|
user: User, domain: str, partner_id: Optional[int] = None
|
||||||
|
) -> CreateCustomDomainResult:
|
||||||
|
if not user.is_premium():
|
||||||
|
return CreateCustomDomainResult(
|
||||||
|
message="Only premium plan can add custom domain",
|
||||||
|
message_category="warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
new_domain = sanitize_domain(domain)
|
||||||
|
domain_forbidden_cause = can_domain_be_used(user, new_domain)
|
||||||
|
if domain_forbidden_cause:
|
||||||
|
return CreateCustomDomainResult(
|
||||||
|
message=domain_forbidden_cause.message(new_domain), message_category="error"
|
||||||
|
)
|
||||||
|
|
||||||
|
new_custom_domain = CustomDomain.create(domain=new_domain, user_id=user.id)
|
||||||
|
|
||||||
|
# new domain has ownership verified if its parent has the ownership verified
|
||||||
|
for root_cd in user.custom_domains:
|
||||||
|
if new_domain.endswith("." + root_cd.domain) and root_cd.ownership_verified:
|
||||||
|
LOG.i(
|
||||||
|
"%s ownership verified thanks to %s",
|
||||||
|
new_custom_domain,
|
||||||
|
root_cd,
|
||||||
|
)
|
||||||
|
new_custom_domain.ownership_verified = True
|
||||||
|
|
||||||
|
# Add the partner_id in case it's passed
|
||||||
|
if partner_id is not None:
|
||||||
|
new_custom_domain.partner_id = partner_id
|
||||||
|
|
||||||
|
Session.commit()
|
||||||
|
|
||||||
|
return CreateCustomDomainResult(
|
||||||
|
success=True,
|
||||||
|
instance=new_custom_domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_custom_domain(domain: CustomDomain):
|
||||||
|
# Schedule delete domain job
|
||||||
|
LOG.w("schedule delete domain job for %s", domain)
|
||||||
|
domain.pending_deletion = True
|
||||||
|
Job.create(
|
||||||
|
name=JOB_DELETE_DOMAIN,
|
||||||
|
payload={"custom_domain_id": domain.id},
|
||||||
|
run_at=arrow.now(),
|
||||||
|
commit=True,
|
||||||
|
)
|
@ -1,37 +1,157 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app import config
|
||||||
|
from app.constants import DMARC_RECORD
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.dns_utils import get_cname_record
|
from app.dns_utils import (
|
||||||
|
DNSClient,
|
||||||
|
is_mx_equivalent,
|
||||||
|
get_network_dns_client,
|
||||||
|
)
|
||||||
from app.models import CustomDomain
|
from app.models import CustomDomain
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DomainValidationResult:
|
||||||
|
success: bool
|
||||||
|
errors: [str]
|
||||||
|
|
||||||
|
|
||||||
class CustomDomainValidation:
|
class CustomDomainValidation:
|
||||||
def __init__(self, dkim_domain: str):
|
def __init__(
|
||||||
|
self,
|
||||||
|
dkim_domain: str,
|
||||||
|
dns_client: DNSClient = get_network_dns_client(),
|
||||||
|
partner_domains: Optional[dict[int, str]] = None,
|
||||||
|
partner_domains_validation_prefixes: Optional[dict[int, str]] = None,
|
||||||
|
):
|
||||||
self.dkim_domain = dkim_domain
|
self.dkim_domain = dkim_domain
|
||||||
self._dkim_records = {
|
self._dns_client = dns_client
|
||||||
(f"{key}._domainkey", f"{key}._domainkey.{self.dkim_domain}")
|
self._partner_domains = partner_domains or config.PARTNER_DOMAINS
|
||||||
|
self._partner_domain_validation_prefixes = (
|
||||||
|
partner_domains_validation_prefixes
|
||||||
|
or config.PARTNER_DOMAIN_VALIDATION_PREFIXES
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_ownership_verification_record(self, domain: CustomDomain) -> str:
|
||||||
|
prefix = "sl"
|
||||||
|
if (
|
||||||
|
domain.partner_id is not None
|
||||||
|
and domain.partner_id in self._partner_domain_validation_prefixes
|
||||||
|
):
|
||||||
|
prefix = self._partner_domain_validation_prefixes[domain.partner_id]
|
||||||
|
return f"{prefix}-verification={domain.ownership_txt_token}"
|
||||||
|
|
||||||
|
def get_dkim_records(self, domain: CustomDomain) -> {str: str}:
|
||||||
|
"""
|
||||||
|
Get a list of dkim records to set up. Depending on the custom_domain, whether if it's from a partner or not,
|
||||||
|
it will return the default ones or the partner ones.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# By default use the default domain
|
||||||
|
dkim_domain = self.dkim_domain
|
||||||
|
if domain.partner_id is not None:
|
||||||
|
# Domain is from a partner. Retrieve the partner config and use that domain if exists
|
||||||
|
dkim_domain = self._partner_domains.get(domain.partner_id, dkim_domain)
|
||||||
|
|
||||||
|
return {
|
||||||
|
f"{key}._domainkey": f"{key}._domainkey.{dkim_domain}"
|
||||||
for key in ("dkim", "dkim02", "dkim03")
|
for key in ("dkim", "dkim02", "dkim03")
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_dkim_records(self) -> {str: str}:
|
|
||||||
"""
|
|
||||||
Get a list of dkim records to set up. It will be
|
|
||||||
|
|
||||||
"""
|
|
||||||
return self._dkim_records
|
|
||||||
|
|
||||||
def validate_dkim_records(self, custom_domain: CustomDomain) -> dict[str, str]:
|
def validate_dkim_records(self, custom_domain: CustomDomain) -> dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Check if dkim records are properly set for this custom domain.
|
Check if dkim records are properly set for this custom domain.
|
||||||
Returns empty list if all records are ok. Other-wise return the records that aren't properly configured
|
Returns empty list if all records are ok. Other-wise return the records that aren't properly configured
|
||||||
"""
|
"""
|
||||||
|
correct_records = {}
|
||||||
invalid_records = {}
|
invalid_records = {}
|
||||||
for prefix, expected_record in self.get_dkim_records():
|
expected_records = self.get_dkim_records(custom_domain)
|
||||||
|
for prefix, expected_record in expected_records.items():
|
||||||
custom_record = f"{prefix}.{custom_domain.domain}"
|
custom_record = f"{prefix}.{custom_domain.domain}"
|
||||||
dkim_record = get_cname_record(custom_record)
|
dkim_record = self._dns_client.get_cname_record(custom_record)
|
||||||
if dkim_record != expected_record:
|
if dkim_record == expected_record:
|
||||||
|
correct_records[prefix] = custom_record
|
||||||
|
else:
|
||||||
invalid_records[custom_record] = dkim_record or "empty"
|
invalid_records[custom_record] = dkim_record or "empty"
|
||||||
# HACK: If dkim is enabled, don't disable it to give users time to update their CNAMES
|
|
||||||
|
# HACK
|
||||||
|
# As initially we only had one dkim record, we want to allow users that had only the original dkim record and
|
||||||
|
# the domain validated to continue seeing it as validated (although showing them the missing records).
|
||||||
|
# However, if not even the original dkim record is right, even if the domain was dkim_verified in the past,
|
||||||
|
# we will remove the dkim_verified flag.
|
||||||
|
# This is done in order to give users with the old dkim config (only one) to update their CNAMEs
|
||||||
if custom_domain.dkim_verified:
|
if custom_domain.dkim_verified:
|
||||||
|
# Check if at least the original dkim is there
|
||||||
|
if correct_records.get("dkim._domainkey") is not None:
|
||||||
|
# Original dkim record is there. Return the missing records (if any) and don't clear the flag
|
||||||
return invalid_records
|
return invalid_records
|
||||||
|
|
||||||
|
# Original DKIM record is not there, which means the DKIM config is not finished. Proceed with the
|
||||||
|
# rest of the code path, returning the invalid records and clearing the flag
|
||||||
custom_domain.dkim_verified = len(invalid_records) == 0
|
custom_domain.dkim_verified = len(invalid_records) == 0
|
||||||
Session.commit()
|
Session.commit()
|
||||||
return invalid_records
|
return invalid_records
|
||||||
|
|
||||||
|
def validate_domain_ownership(
|
||||||
|
self, custom_domain: CustomDomain
|
||||||
|
) -> DomainValidationResult:
|
||||||
|
"""
|
||||||
|
Check if the custom_domain has added the ownership verification records
|
||||||
|
"""
|
||||||
|
txt_records = self._dns_client.get_txt_record(custom_domain.domain)
|
||||||
|
expected_verification_record = self.get_ownership_verification_record(
|
||||||
|
custom_domain
|
||||||
|
)
|
||||||
|
|
||||||
|
if expected_verification_record in txt_records:
|
||||||
|
custom_domain.ownership_verified = True
|
||||||
|
Session.commit()
|
||||||
|
return DomainValidationResult(success=True, errors=[])
|
||||||
|
else:
|
||||||
|
return DomainValidationResult(success=False, errors=txt_records)
|
||||||
|
|
||||||
|
def validate_mx_records(
|
||||||
|
self, custom_domain: CustomDomain
|
||||||
|
) -> DomainValidationResult:
|
||||||
|
mx_domains = self._dns_client.get_mx_domains(custom_domain.domain)
|
||||||
|
|
||||||
|
if not is_mx_equivalent(mx_domains, config.EMAIL_SERVERS_WITH_PRIORITY):
|
||||||
|
return DomainValidationResult(
|
||||||
|
success=False,
|
||||||
|
errors=[f"{priority} {domain}" for (priority, domain) in mx_domains],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
custom_domain.verified = True
|
||||||
|
Session.commit()
|
||||||
|
return DomainValidationResult(success=True, errors=[])
|
||||||
|
|
||||||
|
def validate_spf_records(
|
||||||
|
self, custom_domain: CustomDomain
|
||||||
|
) -> DomainValidationResult:
|
||||||
|
spf_domains = self._dns_client.get_spf_domain(custom_domain.domain)
|
||||||
|
if config.EMAIL_DOMAIN in spf_domains:
|
||||||
|
custom_domain.spf_verified = True
|
||||||
|
Session.commit()
|
||||||
|
return DomainValidationResult(success=True, errors=[])
|
||||||
|
else:
|
||||||
|
custom_domain.spf_verified = False
|
||||||
|
Session.commit()
|
||||||
|
return DomainValidationResult(
|
||||||
|
success=False,
|
||||||
|
errors=self._dns_client.get_txt_record(custom_domain.domain),
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_dmarc_records(
|
||||||
|
self, custom_domain: CustomDomain
|
||||||
|
) -> DomainValidationResult:
|
||||||
|
txt_records = self._dns_client.get_txt_record("_dmarc." + custom_domain.domain)
|
||||||
|
if DMARC_RECORD in txt_records:
|
||||||
|
custom_domain.dmarc_verified = True
|
||||||
|
Session.commit()
|
||||||
|
return DomainValidationResult(success=True, errors=[])
|
||||||
|
else:
|
||||||
|
custom_domain.dmarc_verified = False
|
||||||
|
Session.commit()
|
||||||
|
return DomainValidationResult(success=False, errors=txt_records)
|
||||||
|
@ -5,11 +5,9 @@ from wtforms import StringField, validators
|
|||||||
|
|
||||||
from app import parallel_limiter
|
from app import parallel_limiter
|
||||||
from app.config import EMAIL_SERVERS_WITH_PRIORITY
|
from app.config import EMAIL_SERVERS_WITH_PRIORITY
|
||||||
|
from app.custom_domain_utils import create_custom_domain
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.db import Session
|
from app.models import CustomDomain
|
||||||
from app.email_utils import get_email_domain_part
|
|
||||||
from app.log import LOG
|
|
||||||
from app.models import CustomDomain, Mailbox, DomainMailbox, SLDomain
|
|
||||||
|
|
||||||
|
|
||||||
class NewCustomDomainForm(FlaskForm):
|
class NewCustomDomainForm(FlaskForm):
|
||||||
@ -25,11 +23,8 @@ def custom_domain():
|
|||||||
custom_domains = CustomDomain.filter_by(
|
custom_domains = CustomDomain.filter_by(
|
||||||
user_id=current_user.id, is_sl_subdomain=False
|
user_id=current_user.id, is_sl_subdomain=False
|
||||||
).all()
|
).all()
|
||||||
mailboxes = current_user.mailboxes()
|
|
||||||
new_custom_domain_form = NewCustomDomainForm()
|
new_custom_domain_form = NewCustomDomainForm()
|
||||||
|
|
||||||
errors = {}
|
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if request.form.get("form-name") == "create":
|
if request.form.get("form-name") == "create":
|
||||||
if not current_user.is_premium():
|
if not current_user.is_premium():
|
||||||
@ -37,87 +32,25 @@ def custom_domain():
|
|||||||
return redirect(url_for("dashboard.custom_domain"))
|
return redirect(url_for("dashboard.custom_domain"))
|
||||||
|
|
||||||
if new_custom_domain_form.validate():
|
if new_custom_domain_form.validate():
|
||||||
new_domain = new_custom_domain_form.domain.data.lower().strip()
|
res = create_custom_domain(
|
||||||
|
user=current_user, domain=new_custom_domain_form.domain.data
|
||||||
if new_domain.startswith("http://"):
|
|
||||||
new_domain = new_domain[len("http://") :]
|
|
||||||
|
|
||||||
if new_domain.startswith("https://"):
|
|
||||||
new_domain = new_domain[len("https://") :]
|
|
||||||
|
|
||||||
if SLDomain.get_by(domain=new_domain):
|
|
||||||
flash("A custom domain cannot be a built-in domain.", "error")
|
|
||||||
elif CustomDomain.get_by(domain=new_domain):
|
|
||||||
flash(f"{new_domain} already used", "error")
|
|
||||||
elif get_email_domain_part(current_user.email) == new_domain:
|
|
||||||
flash(
|
|
||||||
"You cannot add a domain that you are currently using for your personal email. "
|
|
||||||
"Please change your personal email to your real email",
|
|
||||||
"error",
|
|
||||||
)
|
)
|
||||||
elif Mailbox.filter(
|
if res.success:
|
||||||
Mailbox.verified.is_(True), Mailbox.email.endswith(f"@{new_domain}")
|
flash(f"New domain {res.instance.domain} is created", "success")
|
||||||
).first():
|
|
||||||
flash(
|
|
||||||
f"{new_domain} already used in a SimpleLogin mailbox", "error"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
new_custom_domain = CustomDomain.create(
|
|
||||||
domain=new_domain, user_id=current_user.id
|
|
||||||
)
|
|
||||||
# new domain has ownership verified if its parent has the ownership verified
|
|
||||||
for root_cd in current_user.custom_domains:
|
|
||||||
if (
|
|
||||||
new_domain.endswith("." + root_cd.domain)
|
|
||||||
and root_cd.ownership_verified
|
|
||||||
):
|
|
||||||
LOG.i(
|
|
||||||
"%s ownership verified thanks to %s",
|
|
||||||
new_custom_domain,
|
|
||||||
root_cd,
|
|
||||||
)
|
|
||||||
new_custom_domain.ownership_verified = True
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
Session.commit()
|
|
||||||
|
|
||||||
flash(
|
|
||||||
f"New domain {new_custom_domain.domain} is created", "success"
|
|
||||||
)
|
|
||||||
|
|
||||||
return redirect(
|
return redirect(
|
||||||
url_for(
|
url_for(
|
||||||
"dashboard.domain_detail_dns",
|
"dashboard.domain_detail_dns",
|
||||||
custom_domain_id=new_custom_domain.id,
|
custom_domain_id=res.instance.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
flash(res.message, res.message_category)
|
||||||
|
if res.redirect:
|
||||||
|
return redirect(url_for(res.redirect))
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard/custom_domain.html",
|
"dashboard/custom_domain.html",
|
||||||
custom_domains=custom_domains,
|
custom_domains=custom_domains,
|
||||||
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,
|
|
||||||
mailboxes=mailboxes,
|
|
||||||
)
|
)
|
||||||
|
@ -1,22 +1,16 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
import arrow
|
|
||||||
from flask import render_template, request, redirect, url_for, flash
|
from flask import render_template, request, redirect, url_for, flash
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, validators, IntegerField
|
from wtforms import StringField, validators, IntegerField
|
||||||
|
|
||||||
from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_DOMAIN, JOB_DELETE_DOMAIN
|
from app.constants import DMARC_RECORD
|
||||||
|
from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_DOMAIN
|
||||||
|
from app.custom_domain_utils import delete_custom_domain
|
||||||
from app.custom_domain_validation import CustomDomainValidation
|
from app.custom_domain_validation import CustomDomainValidation
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.dns_utils import (
|
|
||||||
get_mx_domains,
|
|
||||||
get_spf_domain,
|
|
||||||
get_txt_record,
|
|
||||||
is_mx_equivalent,
|
|
||||||
)
|
|
||||||
from app.log import LOG
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
CustomDomain,
|
CustomDomain,
|
||||||
Alias,
|
Alias,
|
||||||
@ -25,7 +19,6 @@ from app.models import (
|
|||||||
DomainMailbox,
|
DomainMailbox,
|
||||||
AutoCreateRule,
|
AutoCreateRule,
|
||||||
AutoCreateRuleMailbox,
|
AutoCreateRuleMailbox,
|
||||||
Job,
|
|
||||||
)
|
)
|
||||||
from app.regex_utils import regex_match
|
from app.regex_utils import regex_match
|
||||||
from app.utils import random_string, CSRFValidationForm
|
from app.utils import random_string, CSRFValidationForm
|
||||||
@ -49,8 +42,6 @@ def domain_detail_dns(custom_domain_id):
|
|||||||
domain_validator = CustomDomainValidation(EMAIL_DOMAIN)
|
domain_validator = CustomDomainValidation(EMAIL_DOMAIN)
|
||||||
csrf_form = CSRFValidationForm()
|
csrf_form = CSRFValidationForm()
|
||||||
|
|
||||||
dmarc_record = "v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s"
|
|
||||||
|
|
||||||
mx_ok = spf_ok = dkim_ok = dmarc_ok = ownership_ok = True
|
mx_ok = spf_ok = dkim_ok = dmarc_ok = ownership_ok = True
|
||||||
mx_errors = spf_errors = dkim_errors = dmarc_errors = ownership_errors = []
|
mx_errors = spf_errors = dkim_errors = dmarc_errors = ownership_errors = []
|
||||||
|
|
||||||
@ -59,15 +50,14 @@ def domain_detail_dns(custom_domain_id):
|
|||||||
flash("Invalid request", "warning")
|
flash("Invalid request", "warning")
|
||||||
return redirect(request.url)
|
return redirect(request.url)
|
||||||
if request.form.get("form-name") == "check-ownership":
|
if request.form.get("form-name") == "check-ownership":
|
||||||
txt_records = get_txt_record(custom_domain.domain)
|
ownership_validation_result = domain_validator.validate_domain_ownership(
|
||||||
|
custom_domain
|
||||||
if custom_domain.get_ownership_dns_txt_value() in txt_records:
|
)
|
||||||
|
if ownership_validation_result.success:
|
||||||
flash(
|
flash(
|
||||||
"Domain ownership is verified. Please proceed to the other records setup",
|
"Domain ownership is verified. Please proceed to the other records setup",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
custom_domain.ownership_verified = True
|
|
||||||
Session.commit()
|
|
||||||
return redirect(
|
return redirect(
|
||||||
url_for(
|
url_for(
|
||||||
"dashboard.domain_detail_dns",
|
"dashboard.domain_detail_dns",
|
||||||
@ -78,36 +68,28 @@ def domain_detail_dns(custom_domain_id):
|
|||||||
else:
|
else:
|
||||||
flash("We can't find the needed TXT record", "error")
|
flash("We can't find the needed TXT record", "error")
|
||||||
ownership_ok = False
|
ownership_ok = False
|
||||||
ownership_errors = txt_records
|
ownership_errors = ownership_validation_result.errors
|
||||||
|
|
||||||
elif request.form.get("form-name") == "check-mx":
|
elif request.form.get("form-name") == "check-mx":
|
||||||
mx_domains = get_mx_domains(custom_domain.domain)
|
mx_validation_result = domain_validator.validate_mx_records(custom_domain)
|
||||||
|
if mx_validation_result.success:
|
||||||
if not is_mx_equivalent(mx_domains, EMAIL_SERVERS_WITH_PRIORITY):
|
|
||||||
flash("The MX record is not correctly set", "warning")
|
|
||||||
|
|
||||||
mx_ok = False
|
|
||||||
# build mx_errors to show to user
|
|
||||||
mx_errors = [
|
|
||||||
f"{priority} {domain}" for (priority, domain) in mx_domains
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
flash(
|
flash(
|
||||||
"Your domain can start receiving emails. You can now use it to create alias",
|
"Your domain can start receiving emails. You can now use it to create alias",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
custom_domain.verified = True
|
|
||||||
Session.commit()
|
|
||||||
return redirect(
|
return redirect(
|
||||||
url_for(
|
url_for(
|
||||||
"dashboard.domain_detail_dns", custom_domain_id=custom_domain.id
|
"dashboard.domain_detail_dns", custom_domain_id=custom_domain.id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
flash("The MX record is not correctly set", "warning")
|
||||||
|
mx_ok = False
|
||||||
|
mx_errors = mx_validation_result.errors
|
||||||
|
|
||||||
elif request.form.get("form-name") == "check-spf":
|
elif request.form.get("form-name") == "check-spf":
|
||||||
spf_domains = get_spf_domain(custom_domain.domain)
|
spf_validation_result = domain_validator.validate_spf_records(custom_domain)
|
||||||
if EMAIL_DOMAIN in spf_domains:
|
if spf_validation_result.success:
|
||||||
custom_domain.spf_verified = True
|
|
||||||
Session.commit()
|
|
||||||
flash("SPF is setup correctly", "success")
|
flash("SPF is setup correctly", "success")
|
||||||
return redirect(
|
return redirect(
|
||||||
url_for(
|
url_for(
|
||||||
@ -115,14 +97,12 @@ def domain_detail_dns(custom_domain_id):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
custom_domain.spf_verified = False
|
|
||||||
Session.commit()
|
|
||||||
flash(
|
flash(
|
||||||
f"SPF: {EMAIL_DOMAIN} is not included in your SPF record.",
|
f"SPF: {EMAIL_DOMAIN} is not included in your SPF record.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
spf_ok = False
|
spf_ok = False
|
||||||
spf_errors = get_txt_record(custom_domain.domain)
|
spf_errors = spf_validation_result.errors
|
||||||
|
|
||||||
elif request.form.get("form-name") == "check-dkim":
|
elif request.form.get("form-name") == "check-dkim":
|
||||||
dkim_errors = domain_validator.validate_dkim_records(custom_domain)
|
dkim_errors = domain_validator.validate_dkim_records(custom_domain)
|
||||||
@ -138,10 +118,10 @@ def domain_detail_dns(custom_domain_id):
|
|||||||
flash("DKIM: the CNAME record is not correctly set", "warning")
|
flash("DKIM: the CNAME record is not correctly set", "warning")
|
||||||
|
|
||||||
elif request.form.get("form-name") == "check-dmarc":
|
elif request.form.get("form-name") == "check-dmarc":
|
||||||
txt_records = get_txt_record("_dmarc." + custom_domain.domain)
|
dmarc_validation_result = domain_validator.validate_dmarc_records(
|
||||||
if dmarc_record in txt_records:
|
custom_domain
|
||||||
custom_domain.dmarc_verified = True
|
)
|
||||||
Session.commit()
|
if dmarc_validation_result.success:
|
||||||
flash("DMARC is setup correctly", "success")
|
flash("DMARC is setup correctly", "success")
|
||||||
return redirect(
|
return redirect(
|
||||||
url_for(
|
url_for(
|
||||||
@ -149,19 +129,21 @@ def domain_detail_dns(custom_domain_id):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
custom_domain.dmarc_verified = False
|
|
||||||
Session.commit()
|
|
||||||
flash(
|
flash(
|
||||||
"DMARC: The TXT record is not correctly set",
|
"DMARC: The TXT record is not correctly set",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
dmarc_ok = False
|
dmarc_ok = False
|
||||||
dmarc_errors = txt_records
|
dmarc_errors = dmarc_validation_result.errors
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard/domain_detail/dns.html",
|
"dashboard/domain_detail/dns.html",
|
||||||
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
|
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
|
||||||
dkim_records=domain_validator.get_dkim_records(),
|
ownership_record=domain_validator.get_ownership_verification_record(
|
||||||
|
custom_domain
|
||||||
|
),
|
||||||
|
dkim_records=domain_validator.get_dkim_records(custom_domain),
|
||||||
|
dmarc_record=DMARC_RECORD,
|
||||||
**locals(),
|
**locals(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -279,16 +261,8 @@ def domain_detail(custom_domain_id):
|
|||||||
|
|
||||||
elif request.form.get("form-name") == "delete":
|
elif request.form.get("form-name") == "delete":
|
||||||
name = custom_domain.domain
|
name = custom_domain.domain
|
||||||
LOG.d("Schedule deleting %s", custom_domain)
|
|
||||||
|
|
||||||
# Schedule delete domain job
|
delete_custom_domain(custom_domain)
|
||||||
LOG.w("schedule delete domain job for %s", custom_domain)
|
|
||||||
Job.create(
|
|
||||||
name=JOB_DELETE_DOMAIN,
|
|
||||||
payload={"custom_domain_id": custom_domain.id},
|
|
||||||
run_at=arrow.now(),
|
|
||||||
commit=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
flash(
|
flash(
|
||||||
f"{name} scheduled for deletion."
|
f"{name} scheduled for deletion."
|
||||||
|
@ -145,7 +145,7 @@ def index():
|
|||||||
LOG.i(f"User {current_user} requested deletion of alias {alias}")
|
LOG.i(f"User {current_user} requested deletion of alias {alias}")
|
||||||
email = alias.email
|
email = alias.email
|
||||||
alias_utils.delete_alias(
|
alias_utils.delete_alias(
|
||||||
alias, current_user, AliasDeleteReason.ManualAction
|
alias, current_user, AliasDeleteReason.ManualAction, commit=True
|
||||||
)
|
)
|
||||||
flash(f"Alias {email} has been deleted", "success")
|
flash(f"Alias {email} has been deleted", "success")
|
||||||
elif request.form.get("form-name") == "disable-alias":
|
elif request.form.get("form-name") == "disable-alias":
|
||||||
|
@ -2,7 +2,6 @@ import base64
|
|||||||
import binascii
|
import binascii
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import arrow
|
|
||||||
from flask import render_template, request, redirect, url_for, flash
|
from flask import render_template, request, redirect, url_for, flash
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
@ -10,19 +9,12 @@ from itsdangerous import TimestampSigner
|
|||||||
from wtforms import validators, IntegerField
|
from wtforms import validators, IntegerField
|
||||||
from wtforms.fields.html5 import EmailField
|
from wtforms.fields.html5 import EmailField
|
||||||
|
|
||||||
from app import parallel_limiter
|
from app import parallel_limiter, mailbox_utils, user_settings
|
||||||
from app.config import MAILBOX_SECRET, URL, JOB_DELETE_MAILBOX
|
from app.config import MAILBOX_SECRET
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.email_utils import (
|
|
||||||
email_can_be_used_as_mailbox,
|
|
||||||
mailbox_already_used,
|
|
||||||
render,
|
|
||||||
send_email,
|
|
||||||
)
|
|
||||||
from app.email_validation import is_valid_email
|
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import Mailbox, Job
|
from app.models import Mailbox
|
||||||
from app.utils import CSRFValidationForm
|
from app.utils import CSRFValidationForm
|
||||||
|
|
||||||
|
|
||||||
@ -58,118 +50,59 @@ def mailbox_route():
|
|||||||
if not delete_mailbox_form.validate():
|
if not delete_mailbox_form.validate():
|
||||||
flash("Invalid request", "warning")
|
flash("Invalid request", "warning")
|
||||||
return redirect(request.url)
|
return redirect(request.url)
|
||||||
mailbox = Mailbox.get(delete_mailbox_form.mailbox_id.data)
|
try:
|
||||||
|
mailbox = mailbox_utils.delete_mailbox(
|
||||||
if not mailbox or mailbox.user_id != current_user.id:
|
current_user,
|
||||||
flash("Invalid mailbox. Refresh the page", "warning")
|
delete_mailbox_form.mailbox_id.data,
|
||||||
return redirect(url_for("dashboard.mailbox_route"))
|
delete_mailbox_form.transfer_mailbox_id.data,
|
||||||
|
|
||||||
if mailbox.id == current_user.default_mailbox_id:
|
|
||||||
flash("You cannot delete default mailbox", "error")
|
|
||||||
return redirect(url_for("dashboard.mailbox_route"))
|
|
||||||
|
|
||||||
transfer_mailbox_id = delete_mailbox_form.transfer_mailbox_id.data
|
|
||||||
if transfer_mailbox_id and transfer_mailbox_id > 0:
|
|
||||||
transfer_mailbox = Mailbox.get(transfer_mailbox_id)
|
|
||||||
|
|
||||||
if not transfer_mailbox or transfer_mailbox.user_id != current_user.id:
|
|
||||||
flash(
|
|
||||||
"You must transfer the aliases to a mailbox you own.", "error"
|
|
||||||
)
|
)
|
||||||
|
except mailbox_utils.MailboxError as e:
|
||||||
|
flash(e.msg, "warning")
|
||||||
return redirect(url_for("dashboard.mailbox_route"))
|
return redirect(url_for("dashboard.mailbox_route"))
|
||||||
|
|
||||||
if transfer_mailbox.id == mailbox.id:
|
|
||||||
flash(
|
|
||||||
"You can not transfer the aliases to the mailbox you want to delete.",
|
|
||||||
"error",
|
|
||||||
)
|
|
||||||
return redirect(url_for("dashboard.mailbox_route"))
|
|
||||||
|
|
||||||
if not transfer_mailbox.verified:
|
|
||||||
flash("Your new mailbox is not verified", "error")
|
|
||||||
return redirect(url_for("dashboard.mailbox_route"))
|
|
||||||
|
|
||||||
# Schedule delete account job
|
|
||||||
LOG.w(
|
|
||||||
f"schedule delete mailbox job for {mailbox.id} with transfer to mailbox {transfer_mailbox_id}"
|
|
||||||
)
|
|
||||||
Job.create(
|
|
||||||
name=JOB_DELETE_MAILBOX,
|
|
||||||
payload={
|
|
||||||
"mailbox_id": mailbox.id,
|
|
||||||
"transfer_mailbox_id": transfer_mailbox_id
|
|
||||||
if transfer_mailbox_id > 0
|
|
||||||
else None,
|
|
||||||
},
|
|
||||||
run_at=arrow.now(),
|
|
||||||
commit=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
flash(
|
flash(
|
||||||
f"Mailbox {mailbox.email} scheduled for deletion."
|
f"Mailbox {mailbox.email} scheduled for deletion."
|
||||||
f"You will receive a confirmation email when the deletion is finished",
|
f"You will receive a confirmation email when the deletion is finished",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
|
|
||||||
return redirect(url_for("dashboard.mailbox_route"))
|
return redirect(url_for("dashboard.mailbox_route"))
|
||||||
|
|
||||||
if request.form.get("form-name") == "set-default":
|
if request.form.get("form-name") == "set-default":
|
||||||
if not csrf_form.validate():
|
if not csrf_form.validate():
|
||||||
flash("Invalid request", "warning")
|
flash("Invalid request", "warning")
|
||||||
return redirect(request.url)
|
return redirect(request.url)
|
||||||
|
try:
|
||||||
mailbox_id = request.form.get("mailbox_id")
|
mailbox_id = request.form.get("mailbox_id")
|
||||||
mailbox = Mailbox.get(mailbox_id)
|
mailbox = user_settings.set_default_mailbox(current_user, mailbox_id)
|
||||||
|
except user_settings.CannotSetMailbox as e:
|
||||||
if not mailbox or mailbox.user_id != current_user.id:
|
flash(e.msg, "warning")
|
||||||
flash("Unknown error. Refresh the page", "warning")
|
|
||||||
return redirect(url_for("dashboard.mailbox_route"))
|
return redirect(url_for("dashboard.mailbox_route"))
|
||||||
|
|
||||||
if mailbox.id == current_user.default_mailbox_id:
|
|
||||||
flash("This mailbox is already default one", "error")
|
|
||||||
return redirect(url_for("dashboard.mailbox_route"))
|
|
||||||
|
|
||||||
if not mailbox.verified:
|
|
||||||
flash("Cannot set unverified mailbox as default", "error")
|
|
||||||
return redirect(url_for("dashboard.mailbox_route"))
|
|
||||||
|
|
||||||
current_user.default_mailbox_id = mailbox.id
|
|
||||||
Session.commit()
|
|
||||||
flash(f"Mailbox {mailbox.email} is set as Default Mailbox", "success")
|
flash(f"Mailbox {mailbox.email} is set as Default Mailbox", "success")
|
||||||
|
|
||||||
return redirect(url_for("dashboard.mailbox_route"))
|
return redirect(url_for("dashboard.mailbox_route"))
|
||||||
|
|
||||||
elif request.form.get("form-name") == "create":
|
elif request.form.get("form-name") == "create":
|
||||||
if not current_user.is_premium():
|
if not new_mailbox_form.validate():
|
||||||
flash("Only premium plan can add additional mailbox", "warning")
|
flash("Invalid request", "warning")
|
||||||
|
return redirect(request.url)
|
||||||
|
mailbox_email = new_mailbox_form.email.data.lower().strip().replace(" ", "")
|
||||||
|
try:
|
||||||
|
mailbox = mailbox_utils.create_mailbox(
|
||||||
|
current_user, mailbox_email
|
||||||
|
).mailbox
|
||||||
|
except mailbox_utils.MailboxError as e:
|
||||||
|
flash(e.msg, "warning")
|
||||||
return redirect(url_for("dashboard.mailbox_route"))
|
return redirect(url_for("dashboard.mailbox_route"))
|
||||||
|
|
||||||
if new_mailbox_form.validate():
|
|
||||||
mailbox_email = (
|
|
||||||
new_mailbox_form.email.data.lower().strip().replace(" ", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_valid_email(mailbox_email):
|
|
||||||
flash(f"{mailbox_email} invalid", "error")
|
|
||||||
elif mailbox_already_used(mailbox_email, current_user):
|
|
||||||
flash(f"{mailbox_email} already used", "error")
|
|
||||||
elif not email_can_be_used_as_mailbox(mailbox_email):
|
|
||||||
flash(f"You cannot use {mailbox_email}.", "error")
|
|
||||||
else:
|
|
||||||
new_mailbox = Mailbox.create(
|
|
||||||
email=mailbox_email, user_id=current_user.id
|
|
||||||
)
|
|
||||||
Session.commit()
|
|
||||||
|
|
||||||
send_verification_email(current_user, new_mailbox)
|
|
||||||
|
|
||||||
flash(
|
flash(
|
||||||
f"You are going to receive an email to confirm {mailbox_email}.",
|
f"You are going to receive an email to confirm {mailbox.email}.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
|
|
||||||
return redirect(
|
return redirect(
|
||||||
url_for(
|
url_for(
|
||||||
"dashboard.mailbox_detail_route",
|
"dashboard.mailbox_detail_route",
|
||||||
mailbox_id=new_mailbox.id,
|
mailbox_id=mailbox.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -182,34 +115,25 @@ def mailbox_route():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_verification_email(user, mailbox):
|
|
||||||
s = TimestampSigner(MAILBOX_SECRET)
|
|
||||||
encoded_data = json.dumps([mailbox.id, mailbox.email]).encode("utf-8")
|
|
||||||
b64_data = base64.urlsafe_b64encode(encoded_data)
|
|
||||||
mailbox_id_signed = s.sign(b64_data).decode()
|
|
||||||
verification_url = (
|
|
||||||
URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}"
|
|
||||||
)
|
|
||||||
send_email(
|
|
||||||
mailbox.email,
|
|
||||||
f"Please confirm your mailbox {mailbox.email}",
|
|
||||||
render(
|
|
||||||
"transactional/verify-mailbox.txt.jinja2",
|
|
||||||
user=user,
|
|
||||||
link=verification_url,
|
|
||||||
mailbox_email=mailbox.email,
|
|
||||||
),
|
|
||||||
render(
|
|
||||||
"transactional/verify-mailbox.html",
|
|
||||||
user=user,
|
|
||||||
link=verification_url,
|
|
||||||
mailbox_email=mailbox.email,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route("/mailbox_verify")
|
@dashboard_bp.route("/mailbox_verify")
|
||||||
|
@login_required
|
||||||
def mailbox_verify():
|
def mailbox_verify():
|
||||||
|
mailbox_id = request.args.get("mailbox_id")
|
||||||
|
code = request.args.get("code")
|
||||||
|
if not code:
|
||||||
|
# Old way
|
||||||
|
return verify_with_signed_secret(mailbox_id)
|
||||||
|
try:
|
||||||
|
mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code)
|
||||||
|
except mailbox_utils.MailboxError as e:
|
||||||
|
LOG.i(f"Cannot verify mailbox {mailbox_id} because of {e}")
|
||||||
|
flash(f"Cannot verify mailbox: {e.msg}", "error")
|
||||||
|
return redirect(url_for("dashboard.mailbox_route"))
|
||||||
|
LOG.d("Mailbox %s is verified", mailbox)
|
||||||
|
return render_template("dashboard/mailbox_validation.html", mailbox=mailbox)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_with_signed_secret(request: str):
|
||||||
s = TimestampSigner(MAILBOX_SECRET)
|
s = TimestampSigner(MAILBOX_SECRET)
|
||||||
mailbox_verify_request = request.args.get("mailbox_id")
|
mailbox_verify_request = request.args.get("mailbox_id")
|
||||||
try:
|
try:
|
||||||
|
@ -14,7 +14,7 @@ from flask_wtf import FlaskForm
|
|||||||
from flask_wtf.file import FileField
|
from flask_wtf.file import FileField
|
||||||
from wtforms import StringField, validators
|
from wtforms import StringField, validators
|
||||||
|
|
||||||
from app import s3
|
from app import s3, user_settings
|
||||||
from app.config import (
|
from app.config import (
|
||||||
FIRST_ALIAS_DOMAIN,
|
FIRST_ALIAS_DOMAIN,
|
||||||
ALIAS_RANDOM_SUFFIX_LENGTH,
|
ALIAS_RANDOM_SUFFIX_LENGTH,
|
||||||
@ -31,12 +31,10 @@ from app.models import (
|
|||||||
PlanEnum,
|
PlanEnum,
|
||||||
File,
|
File,
|
||||||
EmailChange,
|
EmailChange,
|
||||||
CustomDomain,
|
|
||||||
AliasGeneratorEnum,
|
AliasGeneratorEnum,
|
||||||
AliasSuffixEnum,
|
AliasSuffixEnum,
|
||||||
ManualSubscription,
|
ManualSubscription,
|
||||||
SenderFormatEnum,
|
SenderFormatEnum,
|
||||||
SLDomain,
|
|
||||||
CoinbaseSubscription,
|
CoinbaseSubscription,
|
||||||
AppleSubscription,
|
AppleSubscription,
|
||||||
PartnerUser,
|
PartnerUser,
|
||||||
@ -166,39 +164,12 @@ def setting():
|
|||||||
return redirect(url_for("dashboard.setting"))
|
return redirect(url_for("dashboard.setting"))
|
||||||
elif request.form.get("form-name") == "change-random-alias-default-domain":
|
elif request.form.get("form-name") == "change-random-alias-default-domain":
|
||||||
default_domain = request.form.get("random-alias-default-domain")
|
default_domain = request.form.get("random-alias-default-domain")
|
||||||
|
try:
|
||||||
if default_domain:
|
user_settings.set_default_alias_domain(current_user, default_domain)
|
||||||
sl_domain: SLDomain = SLDomain.get_by(domain=default_domain)
|
except user_settings.CannotSetAlias as e:
|
||||||
if sl_domain:
|
flash(e.msg, "error")
|
||||||
if sl_domain.premium_only and not current_user.is_premium():
|
|
||||||
flash("You cannot use this domain", "error")
|
|
||||||
return redirect(url_for("dashboard.setting"))
|
return redirect(url_for("dashboard.setting"))
|
||||||
|
|
||||||
current_user.default_alias_public_domain_id = sl_domain.id
|
|
||||||
current_user.default_alias_custom_domain_id = None
|
|
||||||
else:
|
|
||||||
custom_domain = CustomDomain.get_by(domain=default_domain)
|
|
||||||
if custom_domain:
|
|
||||||
# sanity check
|
|
||||||
if (
|
|
||||||
custom_domain.user_id != current_user.id
|
|
||||||
or not custom_domain.verified
|
|
||||||
):
|
|
||||||
LOG.w(
|
|
||||||
"%s cannot use domain %s", current_user, custom_domain
|
|
||||||
)
|
|
||||||
flash(f"Domain {default_domain} can't be used", "error")
|
|
||||||
return redirect(request.url)
|
|
||||||
else:
|
|
||||||
current_user.default_alias_custom_domain_id = (
|
|
||||||
custom_domain.id
|
|
||||||
)
|
|
||||||
current_user.default_alias_public_domain_id = None
|
|
||||||
|
|
||||||
else:
|
|
||||||
current_user.default_alias_custom_domain_id = None
|
|
||||||
current_user.default_alias_public_domain_id = None
|
|
||||||
|
|
||||||
Session.commit()
|
Session.commit()
|
||||||
flash("Your preference has been updated", "success")
|
flash("Your preference has been updated", "success")
|
||||||
return redirect(url_for("dashboard.setting"))
|
return redirect(url_for("dashboard.setting"))
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import request, render_template, redirect, url_for, flash
|
from flask import request, render_template, redirect, url_for, flash
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
@ -11,6 +12,7 @@ from app.config import ADMIN_EMAIL
|
|||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.developer.base import developer_bp
|
from app.developer.base import developer_bp
|
||||||
from app.email_utils import send_email
|
from app.email_utils import send_email
|
||||||
|
from app.image_validation import detect_image_format, ImageFormat
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import Client, RedirectUri, File, Referral
|
from app.models import Client, RedirectUri, File, Referral
|
||||||
from app.utils import random_string
|
from app.utils import random_string
|
||||||
@ -46,16 +48,25 @@ def client_detail(client_id):
|
|||||||
approval_form.description.data = client.description
|
approval_form.description.data = client.description
|
||||||
|
|
||||||
if action == "edit" and form.validate_on_submit():
|
if action == "edit" and form.validate_on_submit():
|
||||||
|
parsed_url = urlparse(form.url.data)
|
||||||
|
if parsed_url.scheme != "https":
|
||||||
|
flash("Only https urls are allowed", "error")
|
||||||
|
return redirect(url_for("developer.index"))
|
||||||
client.name = form.name.data
|
client.name = form.name.data
|
||||||
client.home_url = form.url.data
|
client.home_url = form.url.data
|
||||||
|
|
||||||
if form.icon.data:
|
if form.icon.data:
|
||||||
# todo: remove current icon if any
|
icon_data = form.icon.data.read(10240)
|
||||||
# todo: handle remove icon
|
if detect_image_format(icon_data) == ImageFormat.Unknown:
|
||||||
|
flash("Unknown file format", "warning")
|
||||||
|
return redirect(url_for("developer.index"))
|
||||||
|
if client.icon:
|
||||||
|
s3.delete(client.icon_id)
|
||||||
|
File.delete(client.icon)
|
||||||
file_path = random_string(30)
|
file_path = random_string(30)
|
||||||
file = File.create(path=file_path, user_id=client.user_id)
|
file = File.create(path=file_path, user_id=client.user_id)
|
||||||
|
|
||||||
s3.upload_from_bytesio(file_path, BytesIO(form.icon.data.read()))
|
s3.upload_from_bytesio(file_path, BytesIO(icon_data))
|
||||||
|
|
||||||
Session.flush()
|
Session.flush()
|
||||||
LOG.d("upload file %s to s3", file)
|
LOG.d("upload file %s to s3", file)
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import render_template, redirect, url_for, flash
|
from flask import render_template, redirect, url_for, flash
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
@ -20,6 +22,10 @@ def new_client():
|
|||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
client = Client.create_new(form.name.data, current_user.id)
|
client = Client.create_new(form.name.data, current_user.id)
|
||||||
|
parsed_url = urlparse(form.url.data)
|
||||||
|
if parsed_url.scheme != "https":
|
||||||
|
flash("Only https urls are allowed", "error")
|
||||||
|
return redirect(url_for("developer.new_client"))
|
||||||
client.home_url = form.url.data
|
client.home_url = form.url.data
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
|
@ -1,100 +1,13 @@
|
|||||||
from app import config
|
from abc import ABC, abstractmethod
|
||||||
from typing import Optional, List, Tuple
|
from typing import List, Tuple, Optional
|
||||||
|
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
|
|
||||||
|
from app.config import NAMESERVERS
|
||||||
def _get_dns_resolver():
|
|
||||||
my_resolver = dns.resolver.Resolver()
|
|
||||||
my_resolver.nameservers = config.NAMESERVERS
|
|
||||||
|
|
||||||
return my_resolver
|
|
||||||
|
|
||||||
|
|
||||||
def get_ns(hostname) -> [str]:
|
|
||||||
try:
|
|
||||||
answers = _get_dns_resolver().resolve(hostname, "NS", search=True)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
return [a.to_text() for a in answers]
|
|
||||||
|
|
||||||
|
|
||||||
def get_cname_record(hostname) -> Optional[str]:
|
|
||||||
"""Return the CNAME record if exists for a domain, WITHOUT the trailing period at the end"""
|
|
||||||
try:
|
|
||||||
answers = _get_dns_resolver().resolve(hostname, "CNAME", search=True)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for a in answers:
|
|
||||||
ret = a.to_text()
|
|
||||||
return ret[:-1]
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_mx_domains(hostname) -> [(int, str)]:
|
|
||||||
"""return list of (priority, domain name) sorted by priority (lowest priority first)
|
|
||||||
domain name ends with a "." at the end.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
answers = _get_dns_resolver().resolve(hostname, "MX", search=True)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
ret = []
|
|
||||||
|
|
||||||
for a in answers:
|
|
||||||
record = a.to_text() # for ex '20 alt2.aspmx.l.google.com.'
|
|
||||||
parts = record.split(" ")
|
|
||||||
|
|
||||||
ret.append((int(parts[0]), parts[1]))
|
|
||||||
|
|
||||||
return sorted(ret, key=lambda prio_domain: prio_domain[0])
|
|
||||||
|
|
||||||
|
|
||||||
_include_spf = "include:"
|
_include_spf = "include:"
|
||||||
|
|
||||||
|
|
||||||
def get_spf_domain(hostname) -> [str]:
|
|
||||||
"""return all domains listed in *include:*"""
|
|
||||||
try:
|
|
||||||
answers = _get_dns_resolver().resolve(hostname, "TXT", search=True)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
ret = []
|
|
||||||
|
|
||||||
for a in answers: # type: dns.rdtypes.ANY.TXT.TXT
|
|
||||||
for record in a.strings:
|
|
||||||
record = record.decode() # record is bytes
|
|
||||||
|
|
||||||
if record.startswith("v=spf1"):
|
|
||||||
parts = record.split(" ")
|
|
||||||
for part in parts:
|
|
||||||
if part.startswith(_include_spf):
|
|
||||||
ret.append(part[part.find(_include_spf) + len(_include_spf) :])
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def get_txt_record(hostname) -> [str]:
|
|
||||||
try:
|
|
||||||
answers = _get_dns_resolver().resolve(hostname, "TXT", search=True)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
ret = []
|
|
||||||
|
|
||||||
for a in answers: # type: dns.rdtypes.ANY.TXT.TXT
|
|
||||||
for record in a.strings:
|
|
||||||
record = record.decode() # record is bytes
|
|
||||||
|
|
||||||
ret.append(record)
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def is_mx_equivalent(
|
def is_mx_equivalent(
|
||||||
mx_domains: List[Tuple[int, str]], ref_mx_domains: List[Tuple[int, str]]
|
mx_domains: List[Tuple[int, str]], ref_mx_domains: List[Tuple[int, str]]
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@ -105,16 +18,127 @@ def is_mx_equivalent(
|
|||||||
The priority order is taken into account but not the priority number.
|
The priority order is taken into account but not the priority number.
|
||||||
For example, [(1, domain1), (2, domain2)] is equivalent to [(10, domain1), (20, domain2)]
|
For example, [(1, domain1), (2, domain2)] is equivalent to [(10, domain1), (20, domain2)]
|
||||||
"""
|
"""
|
||||||
mx_domains = sorted(mx_domains, key=lambda priority_domain: priority_domain[0])
|
mx_domains = sorted(mx_domains, key=lambda x: x[0])
|
||||||
ref_mx_domains = sorted(
|
ref_mx_domains = sorted(ref_mx_domains, key=lambda x: x[0])
|
||||||
ref_mx_domains, key=lambda priority_domain: priority_domain[0]
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(mx_domains) < len(ref_mx_domains):
|
if len(mx_domains) < len(ref_mx_domains):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for i in range(0, len(ref_mx_domains)):
|
for i in range(len(ref_mx_domains)):
|
||||||
if mx_domains[i][1] != ref_mx_domains[i][1]:
|
if mx_domains[i][1] != ref_mx_domains[i][1]:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class DNSClient(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def get_cname_record(self, hostname: str) -> Optional[str]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_mx_domains(self, hostname: str) -> List[Tuple[int, str]]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_spf_domain(self, hostname: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
return all domains listed in *include:*
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
records = self.get_txt_record(hostname)
|
||||||
|
ret = []
|
||||||
|
for record in records:
|
||||||
|
if record.startswith("v=spf1"):
|
||||||
|
parts = record.split(" ")
|
||||||
|
for part in parts:
|
||||||
|
if part.startswith(_include_spf):
|
||||||
|
ret.append(
|
||||||
|
part[part.find(_include_spf) + len(_include_spf) :]
|
||||||
|
)
|
||||||
|
return ret
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_txt_record(self, hostname: str) -> List[str]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkDNSClient(DNSClient):
|
||||||
|
def __init__(self, nameservers: List[str]):
|
||||||
|
self._resolver = dns.resolver.Resolver()
|
||||||
|
self._resolver.nameservers = nameservers
|
||||||
|
|
||||||
|
def get_cname_record(self, hostname: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Return the CNAME record if exists for a domain, WITHOUT the trailing period at the end
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
answers = self._resolver.resolve(hostname, "CNAME", search=True)
|
||||||
|
for a in answers:
|
||||||
|
ret = a.to_text()
|
||||||
|
return ret[:-1]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_mx_domains(self, hostname: str) -> List[Tuple[int, str]]:
|
||||||
|
"""
|
||||||
|
return list of (priority, domain name) sorted by priority (lowest priority first)
|
||||||
|
domain name ends with a "." at the end.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
answers = self._resolver.resolve(hostname, "MX", search=True)
|
||||||
|
ret = []
|
||||||
|
for a in answers:
|
||||||
|
record = a.to_text() # for ex '20 alt2.aspmx.l.google.com.'
|
||||||
|
parts = record.split(" ")
|
||||||
|
ret.append((int(parts[0]), parts[1]))
|
||||||
|
return sorted(ret, key=lambda x: x[0])
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_txt_record(self, hostname: str) -> List[str]:
|
||||||
|
try:
|
||||||
|
answers = self._resolver.resolve(hostname, "TXT", search=True)
|
||||||
|
ret = []
|
||||||
|
for a in answers: # type: dns.rdtypes.ANY.TXT.TXT
|
||||||
|
for record in a.strings:
|
||||||
|
ret.append(record.decode())
|
||||||
|
return ret
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryDNSClient(DNSClient):
|
||||||
|
def __init__(self):
|
||||||
|
self.cname_records: dict[str, Optional[str]] = {}
|
||||||
|
self.mx_records: dict[str, List[Tuple[int, str]]] = {}
|
||||||
|
self.spf_records: dict[str, List[str]] = {}
|
||||||
|
self.txt_records: dict[str, List[str]] = {}
|
||||||
|
|
||||||
|
def set_cname_record(self, hostname: str, cname: str):
|
||||||
|
self.cname_records[hostname] = cname
|
||||||
|
|
||||||
|
def set_mx_records(self, hostname: str, mx_list: List[Tuple[int, str]]):
|
||||||
|
self.mx_records[hostname] = mx_list
|
||||||
|
|
||||||
|
def set_txt_record(self, hostname: str, txt_list: List[str]):
|
||||||
|
self.txt_records[hostname] = txt_list
|
||||||
|
|
||||||
|
def get_cname_record(self, hostname: str) -> Optional[str]:
|
||||||
|
return self.cname_records.get(hostname)
|
||||||
|
|
||||||
|
def get_mx_domains(self, hostname: str) -> List[Tuple[int, str]]:
|
||||||
|
mx_list = self.mx_records.get(hostname, [])
|
||||||
|
return sorted(mx_list, key=lambda x: x[0])
|
||||||
|
|
||||||
|
def get_txt_record(self, hostname: str) -> List[str]:
|
||||||
|
return self.txt_records.get(hostname, [])
|
||||||
|
|
||||||
|
|
||||||
|
def get_network_dns_client() -> NetworkDNSClient:
|
||||||
|
return NetworkDNSClient(NAMESERVERS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_mx_domains(hostname: str) -> [(int, str)]:
|
||||||
|
return get_network_dns_client().get_mx_domains(hostname)
|
||||||
|
@ -548,7 +548,9 @@ def can_create_directory_for_address(email_address: str) -> bool:
|
|||||||
for domain in config.ALIAS_DOMAINS:
|
for domain in config.ALIAS_DOMAINS:
|
||||||
if email_address.endswith("@" + domain):
|
if email_address.endswith("@" + domain):
|
||||||
return True
|
return True
|
||||||
|
LOG.i(
|
||||||
|
f"Cannot create address in directory for {email_address} since it does not belong to a valid directory domain"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
import newrelic.agent
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.errors import ProtonPartnerNotSetUp
|
from app.errors import ProtonPartnerNotSetUp
|
||||||
from app.events.generated import event_pb2
|
from app.events.generated import event_pb2
|
||||||
|
from app.log import LOG
|
||||||
from app.models import User, PartnerUser, SyncEvent
|
from app.models import User, PartnerUser, SyncEvent
|
||||||
from app.proton.utils import get_proton_partner
|
from app.proton.utils import get_proton_partner
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -26,22 +30,47 @@ class PostgresDispatcher(Dispatcher):
|
|||||||
return PostgresDispatcher()
|
return PostgresDispatcher()
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalDispatcher:
|
||||||
|
__dispatcher: Optional[Dispatcher] = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_dispatcher() -> Dispatcher:
|
||||||
|
if not GlobalDispatcher.__dispatcher:
|
||||||
|
GlobalDispatcher.__dispatcher = PostgresDispatcher.get()
|
||||||
|
return GlobalDispatcher.__dispatcher
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set_dispatcher(dispatcher: Optional[Dispatcher]):
|
||||||
|
GlobalDispatcher.__dispatcher = dispatcher
|
||||||
|
|
||||||
|
|
||||||
class EventDispatcher:
|
class EventDispatcher:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send_event(
|
def send_event(
|
||||||
user: User,
|
user: User,
|
||||||
content: event_pb2.EventContent,
|
content: event_pb2.EventContent,
|
||||||
dispatcher: Dispatcher = PostgresDispatcher.get(),
|
dispatcher: Optional[Dispatcher] = None,
|
||||||
skip_if_webhook_missing: bool = True,
|
skip_if_webhook_missing: bool = True,
|
||||||
):
|
):
|
||||||
|
if dispatcher is None:
|
||||||
|
dispatcher = GlobalDispatcher.get_dispatcher()
|
||||||
if config.EVENT_WEBHOOK_DISABLE:
|
if config.EVENT_WEBHOOK_DISABLE:
|
||||||
|
LOG.i("Not sending events because webhook is disabled")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not config.EVENT_WEBHOOK and skip_if_webhook_missing:
|
if not config.EVENT_WEBHOOK and skip_if_webhook_missing:
|
||||||
|
LOG.i(
|
||||||
|
"Not sending events because webhook is not configured and allowed to be empty"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if config.EVENT_WEBHOOK_ENABLED_USER_IDS is not None:
|
||||||
|
if user.id not in config.EVENT_WEBHOOK_ENABLED_USER_IDS:
|
||||||
return
|
return
|
||||||
|
|
||||||
partner_user = EventDispatcher.__partner_user(user.id)
|
partner_user = EventDispatcher.__partner_user(user.id)
|
||||||
if not partner_user:
|
if not partner_user:
|
||||||
|
LOG.i(f"Not sending events because there's no partner user for user {user}")
|
||||||
return
|
return
|
||||||
|
|
||||||
event = event_pb2.Event(
|
event = event_pb2.Event(
|
||||||
@ -54,6 +83,10 @@ class EventDispatcher:
|
|||||||
serialized = event.SerializeToString()
|
serialized = event.SerializeToString()
|
||||||
dispatcher.send(serialized)
|
dispatcher.send(serialized)
|
||||||
|
|
||||||
|
event_type = content.WhichOneof("content")
|
||||||
|
newrelic.agent.record_custom_event("EventStoredToDb", {"type": event_type})
|
||||||
|
LOG.i("Sent event to the dispatcher")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __partner_user(user_id: int) -> Optional[PartnerUser]:
|
def __partner_user(user_id: int) -> Optional[PartnerUser]:
|
||||||
# Check if the current user has a partner_id
|
# Check if the current user has a partner_id
|
||||||
|
@ -24,7 +24,7 @@ _sym_db = _symbol_database.Default()
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\x12simplelogin_events\"(\n\x0fUserPlanChanged\x12\x15\n\rplan_end_time\x18\x01 \x01(\r\"\r\n\x0bUserDeleted\"Z\n\x0c\x41liasCreated\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\x12\x12\n\nalias_note\x18\x03 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x04 \x01(\x08\"L\n\x12\x41liasStatusChanged\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x03 \x01(\x08\"5\n\x0c\x41liasDeleted\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\"D\n\x10\x41liasCreatedList\x12\x30\n\x06\x65vents\x18\x01 \x03(\x0b\x32 .simplelogin_events.AliasCreated\"\x93\x03\n\x0c\x45ventContent\x12?\n\x10user_plan_change\x18\x01 \x01(\x0b\x32#.simplelogin_events.UserPlanChangedH\x00\x12\x37\n\x0cuser_deleted\x18\x02 \x01(\x0b\x32\x1f.simplelogin_events.UserDeletedH\x00\x12\x39\n\ralias_created\x18\x03 \x01(\x0b\x32 .simplelogin_events.AliasCreatedH\x00\x12\x45\n\x13\x61lias_status_change\x18\x04 \x01(\x0b\x32&.simplelogin_events.AliasStatusChangedH\x00\x12\x39\n\ralias_deleted\x18\x05 \x01(\x0b\x32 .simplelogin_events.AliasDeletedH\x00\x12\x41\n\x11\x61lias_create_list\x18\x06 \x01(\x0b\x32$.simplelogin_events.AliasCreatedListH\x00\x42\t\n\x07\x63ontent\"y\n\x05\x45vent\x12\x0f\n\x07user_id\x18\x01 \x01(\r\x12\x18\n\x10\x65xternal_user_id\x18\x02 \x01(\t\x12\x12\n\npartner_id\x18\x03 \x01(\r\x12\x31\n\x07\x63ontent\x18\x04 \x01(\x0b\x32 .simplelogin_events.EventContentb\x06proto3')
|
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\x12simplelogin_events\"(\n\x0fUserPlanChanged\x12\x15\n\rplan_end_time\x18\x01 \x01(\r\"\r\n\x0bUserDeleted\"\\\n\x0c\x41liasCreated\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05\x65mail\x18\x02 \x01(\t\x12\x0c\n\x04note\x18\x03 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x04 \x01(\x08\x12\x12\n\ncreated_at\x18\x05 \x01(\r\"T\n\x12\x41liasStatusChanged\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05\x65mail\x18\x02 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x03 \x01(\x08\x12\x12\n\ncreated_at\x18\x04 \x01(\r\")\n\x0c\x41liasDeleted\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05\x65mail\x18\x02 \x01(\t\"D\n\x10\x41liasCreatedList\x12\x30\n\x06\x65vents\x18\x01 \x03(\x0b\x32 .simplelogin_events.AliasCreated\"\x93\x03\n\x0c\x45ventContent\x12?\n\x10user_plan_change\x18\x01 \x01(\x0b\x32#.simplelogin_events.UserPlanChangedH\x00\x12\x37\n\x0cuser_deleted\x18\x02 \x01(\x0b\x32\x1f.simplelogin_events.UserDeletedH\x00\x12\x39\n\ralias_created\x18\x03 \x01(\x0b\x32 .simplelogin_events.AliasCreatedH\x00\x12\x45\n\x13\x61lias_status_change\x18\x04 \x01(\x0b\x32&.simplelogin_events.AliasStatusChangedH\x00\x12\x39\n\ralias_deleted\x18\x05 \x01(\x0b\x32 .simplelogin_events.AliasDeletedH\x00\x12\x41\n\x11\x61lias_create_list\x18\x06 \x01(\x0b\x32$.simplelogin_events.AliasCreatedListH\x00\x42\t\n\x07\x63ontent\"y\n\x05\x45vent\x12\x0f\n\x07user_id\x18\x01 \x01(\r\x12\x18\n\x10\x65xternal_user_id\x18\x02 \x01(\t\x12\x12\n\npartner_id\x18\x03 \x01(\r\x12\x31\n\x07\x63ontent\x18\x04 \x01(\x0b\x32 .simplelogin_events.EventContentb\x06proto3')
|
||||||
|
|
||||||
_globals = globals()
|
_globals = globals()
|
||||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||||
@ -36,15 +36,15 @@ if not _descriptor._USE_C_DESCRIPTORS:
|
|||||||
_globals['_USERDELETED']._serialized_start=77
|
_globals['_USERDELETED']._serialized_start=77
|
||||||
_globals['_USERDELETED']._serialized_end=90
|
_globals['_USERDELETED']._serialized_end=90
|
||||||
_globals['_ALIASCREATED']._serialized_start=92
|
_globals['_ALIASCREATED']._serialized_start=92
|
||||||
_globals['_ALIASCREATED']._serialized_end=182
|
_globals['_ALIASCREATED']._serialized_end=184
|
||||||
_globals['_ALIASSTATUSCHANGED']._serialized_start=184
|
_globals['_ALIASSTATUSCHANGED']._serialized_start=186
|
||||||
_globals['_ALIASSTATUSCHANGED']._serialized_end=260
|
_globals['_ALIASSTATUSCHANGED']._serialized_end=270
|
||||||
_globals['_ALIASDELETED']._serialized_start=262
|
_globals['_ALIASDELETED']._serialized_start=272
|
||||||
_globals['_ALIASDELETED']._serialized_end=315
|
_globals['_ALIASDELETED']._serialized_end=313
|
||||||
_globals['_ALIASCREATEDLIST']._serialized_start=317
|
_globals['_ALIASCREATEDLIST']._serialized_start=315
|
||||||
_globals['_ALIASCREATEDLIST']._serialized_end=385
|
_globals['_ALIASCREATEDLIST']._serialized_end=383
|
||||||
_globals['_EVENTCONTENT']._serialized_start=388
|
_globals['_EVENTCONTENT']._serialized_start=386
|
||||||
_globals['_EVENTCONTENT']._serialized_end=791
|
_globals['_EVENTCONTENT']._serialized_end=789
|
||||||
_globals['_EVENT']._serialized_start=793
|
_globals['_EVENT']._serialized_start=791
|
||||||
_globals['_EVENT']._serialized_end=914
|
_globals['_EVENT']._serialized_end=912
|
||||||
# @@protoc_insertion_point(module_scope)
|
# @@protoc_insertion_point(module_scope)
|
||||||
|
@ -16,34 +16,38 @@ class UserDeleted(_message.Message):
|
|||||||
def __init__(self) -> None: ...
|
def __init__(self) -> None: ...
|
||||||
|
|
||||||
class AliasCreated(_message.Message):
|
class AliasCreated(_message.Message):
|
||||||
__slots__ = ("alias_id", "alias_email", "alias_note", "enabled")
|
__slots__ = ("id", "email", "note", "enabled", "created_at")
|
||||||
ALIAS_ID_FIELD_NUMBER: _ClassVar[int]
|
ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
ALIAS_EMAIL_FIELD_NUMBER: _ClassVar[int]
|
EMAIL_FIELD_NUMBER: _ClassVar[int]
|
||||||
ALIAS_NOTE_FIELD_NUMBER: _ClassVar[int]
|
NOTE_FIELD_NUMBER: _ClassVar[int]
|
||||||
ENABLED_FIELD_NUMBER: _ClassVar[int]
|
ENABLED_FIELD_NUMBER: _ClassVar[int]
|
||||||
alias_id: int
|
CREATED_AT_FIELD_NUMBER: _ClassVar[int]
|
||||||
alias_email: str
|
id: int
|
||||||
alias_note: str
|
email: str
|
||||||
|
note: str
|
||||||
enabled: bool
|
enabled: bool
|
||||||
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ..., alias_note: _Optional[str] = ..., enabled: bool = ...) -> None: ...
|
created_at: int
|
||||||
|
def __init__(self, id: _Optional[int] = ..., email: _Optional[str] = ..., note: _Optional[str] = ..., enabled: bool = ..., created_at: _Optional[int] = ...) -> None: ...
|
||||||
|
|
||||||
class AliasStatusChanged(_message.Message):
|
class AliasStatusChanged(_message.Message):
|
||||||
__slots__ = ("alias_id", "alias_email", "enabled")
|
__slots__ = ("id", "email", "enabled", "created_at")
|
||||||
ALIAS_ID_FIELD_NUMBER: _ClassVar[int]
|
ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
ALIAS_EMAIL_FIELD_NUMBER: _ClassVar[int]
|
EMAIL_FIELD_NUMBER: _ClassVar[int]
|
||||||
ENABLED_FIELD_NUMBER: _ClassVar[int]
|
ENABLED_FIELD_NUMBER: _ClassVar[int]
|
||||||
alias_id: int
|
CREATED_AT_FIELD_NUMBER: _ClassVar[int]
|
||||||
alias_email: str
|
id: int
|
||||||
|
email: str
|
||||||
enabled: bool
|
enabled: bool
|
||||||
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ..., enabled: bool = ...) -> None: ...
|
created_at: int
|
||||||
|
def __init__(self, id: _Optional[int] = ..., email: _Optional[str] = ..., enabled: bool = ..., created_at: _Optional[int] = ...) -> None: ...
|
||||||
|
|
||||||
class AliasDeleted(_message.Message):
|
class AliasDeleted(_message.Message):
|
||||||
__slots__ = ("alias_id", "alias_email")
|
__slots__ = ("id", "email")
|
||||||
ALIAS_ID_FIELD_NUMBER: _ClassVar[int]
|
ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
ALIAS_EMAIL_FIELD_NUMBER: _ClassVar[int]
|
EMAIL_FIELD_NUMBER: _ClassVar[int]
|
||||||
alias_id: int
|
id: int
|
||||||
alias_email: str
|
email: str
|
||||||
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ...) -> None: ...
|
def __init__(self, id: _Optional[int] = ..., email: _Optional[str] = ...) -> None: ...
|
||||||
|
|
||||||
class AliasCreatedList(_message.Message):
|
class AliasCreatedList(_message.Message):
|
||||||
__slots__ = ("events",)
|
__slots__ = ("events",)
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import newrelic.agent
|
||||||
|
|
||||||
from app.events.event_dispatcher import EventDispatcher, Dispatcher
|
from app.events.event_dispatcher import EventDispatcher, Dispatcher
|
||||||
from app.events.generated.event_pb2 import EventContent, AliasCreated, AliasCreatedList
|
from app.events.generated.event_pb2 import EventContent, AliasCreated, AliasCreatedList
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
@ -12,6 +14,7 @@ def send_alias_creation_events_for_user(
|
|||||||
return
|
return
|
||||||
chunk_size = min(chunk_size, 50)
|
chunk_size = min(chunk_size, 50)
|
||||||
event_list = []
|
event_list = []
|
||||||
|
LOG.i("Sending alias create events for user {user}")
|
||||||
for alias in (
|
for alias in (
|
||||||
Alias.yield_per_query(chunk_size)
|
Alias.yield_per_query(chunk_size)
|
||||||
.filter_by(user_id=user.id)
|
.filter_by(user_id=user.id)
|
||||||
@ -19,22 +22,31 @@ def send_alias_creation_events_for_user(
|
|||||||
):
|
):
|
||||||
event_list.append(
|
event_list.append(
|
||||||
AliasCreated(
|
AliasCreated(
|
||||||
alias_id=alias.id,
|
id=alias.id,
|
||||||
alias_email=alias.email,
|
email=alias.email,
|
||||||
alias_note=alias.note,
|
note=alias.note,
|
||||||
enabled=alias.enabled,
|
enabled=alias.enabled,
|
||||||
|
created_at=int(alias.created_at.timestamp),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if len(event_list) >= chunk_size:
|
if len(event_list) >= chunk_size:
|
||||||
|
LOG.i(f"Sending {len(event_list)} alias create event for {user}")
|
||||||
EventDispatcher.send_event(
|
EventDispatcher.send_event(
|
||||||
user,
|
user,
|
||||||
EventContent(alias_create_list=AliasCreatedList(events=event_list)),
|
EventContent(alias_create_list=AliasCreatedList(events=event_list)),
|
||||||
dispatcher=dispatcher,
|
dispatcher=dispatcher,
|
||||||
)
|
)
|
||||||
|
newrelic.agent.record_custom_metric(
|
||||||
|
"Custom/event_alias_created_event", len(event_list)
|
||||||
|
)
|
||||||
event_list = []
|
event_list = []
|
||||||
if len(event_list) > 0:
|
if len(event_list) > 0:
|
||||||
|
LOG.i(f"Sending {len(event_list)} alias create event for {user}")
|
||||||
EventDispatcher.send_event(
|
EventDispatcher.send_event(
|
||||||
user,
|
user,
|
||||||
EventContent(alias_create_list=AliasCreatedList(events=event_list)),
|
EventContent(alias_create_list=AliasCreatedList(events=event_list)),
|
||||||
dispatcher=dispatcher,
|
dispatcher=dispatcher,
|
||||||
)
|
)
|
||||||
|
newrelic.agent.record_custom_metric(
|
||||||
|
"Custom/event_alias_created_event", len(event_list)
|
||||||
|
)
|
||||||
|
260
app/app/mailbox_utils.py
Normal file
260
app/app/mailbox_utils.py
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import dataclasses
|
||||||
|
import secrets
|
||||||
|
import random
|
||||||
|
from typing import Optional
|
||||||
|
import arrow
|
||||||
|
|
||||||
|
from app import config
|
||||||
|
from app.config import JOB_DELETE_MAILBOX
|
||||||
|
from app.db import Session
|
||||||
|
from app.email_utils import (
|
||||||
|
mailbox_already_used,
|
||||||
|
email_can_be_used_as_mailbox,
|
||||||
|
send_email,
|
||||||
|
render,
|
||||||
|
)
|
||||||
|
from app.email_validation import is_valid_email
|
||||||
|
from app.log import LOG
|
||||||
|
from app.models import User, Mailbox, Job, MailboxActivation
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class CreateMailboxOutput:
|
||||||
|
mailbox: Mailbox
|
||||||
|
activation: Optional[MailboxActivation]
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxError(Exception):
|
||||||
|
def __init__(self, msg: str):
|
||||||
|
self.msg = msg
|
||||||
|
|
||||||
|
|
||||||
|
class OnlyPaidError(MailboxError):
|
||||||
|
def __init__(self):
|
||||||
|
self.msg = "Only available for paid plans"
|
||||||
|
|
||||||
|
|
||||||
|
class CannotVerifyError(MailboxError):
|
||||||
|
def __init__(self, msg: str):
|
||||||
|
self.msg = msg
|
||||||
|
|
||||||
|
|
||||||
|
MAX_ACTIVATION_TRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def create_mailbox(
|
||||||
|
user: User,
|
||||||
|
email: str,
|
||||||
|
verified: bool = False,
|
||||||
|
send_email: bool = True,
|
||||||
|
use_digit_codes: bool = False,
|
||||||
|
send_link: bool = True,
|
||||||
|
) -> CreateMailboxOutput:
|
||||||
|
if not user.is_premium():
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} has tried to create mailbox with {email} but is not premium"
|
||||||
|
)
|
||||||
|
raise OnlyPaidError()
|
||||||
|
if not is_valid_email(email):
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} has tried to create mailbox with {email} but is not valid email"
|
||||||
|
)
|
||||||
|
raise MailboxError("Invalid email")
|
||||||
|
elif mailbox_already_used(email, user):
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} has tried to create mailbox with {email} but email is already used"
|
||||||
|
)
|
||||||
|
raise MailboxError("Email already used")
|
||||||
|
elif not email_can_be_used_as_mailbox(email):
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} has tried to create mailbox with {email} but email is invalid"
|
||||||
|
)
|
||||||
|
raise MailboxError("Invalid email")
|
||||||
|
new_mailbox = Mailbox.create(
|
||||||
|
email=email, user_id=user.id, verified=verified, commit=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if verified:
|
||||||
|
LOG.i(f"User {user} as created a pre-verified mailbox with {email}")
|
||||||
|
return CreateMailboxOutput(mailbox=new_mailbox, activation=None)
|
||||||
|
|
||||||
|
LOG.i(f"User {user} has created mailbox with {email}")
|
||||||
|
activation = generate_activation_code(new_mailbox, use_digit_code=use_digit_codes)
|
||||||
|
output = CreateMailboxOutput(mailbox=new_mailbox, activation=activation)
|
||||||
|
|
||||||
|
if not send_email:
|
||||||
|
LOG.i(f"Skipping sending validation email for mailbox {new_mailbox}")
|
||||||
|
return output
|
||||||
|
|
||||||
|
send_verification_email(
|
||||||
|
user,
|
||||||
|
new_mailbox,
|
||||||
|
activation=activation,
|
||||||
|
send_link=send_link,
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def delete_mailbox(
|
||||||
|
user: User, mailbox_id: int, transfer_mailbox_id: Optional[int]
|
||||||
|
) -> Mailbox:
|
||||||
|
mailbox = Mailbox.get(mailbox_id)
|
||||||
|
|
||||||
|
if not mailbox or mailbox.user_id != user.id:
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} has tried to delete another user's mailbox with {mailbox_id}"
|
||||||
|
)
|
||||||
|
raise MailboxError("Invalid mailbox")
|
||||||
|
|
||||||
|
if mailbox.id == user.default_mailbox_id:
|
||||||
|
LOG.i(f"User {user} has tried to delete the default mailbox")
|
||||||
|
raise MailboxError("Cannot delete your default mailbox")
|
||||||
|
|
||||||
|
if transfer_mailbox_id and transfer_mailbox_id > 0:
|
||||||
|
transfer_mailbox = Mailbox.get(transfer_mailbox_id)
|
||||||
|
|
||||||
|
if not transfer_mailbox or transfer_mailbox.user_id != user.id:
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} has tried to transfer to a mailbox owned by another user"
|
||||||
|
)
|
||||||
|
raise MailboxError("You must transfer the aliases to a mailbox you own")
|
||||||
|
|
||||||
|
if transfer_mailbox.id == mailbox.id:
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} has tried to transfer to the same mailbox he is deleting"
|
||||||
|
)
|
||||||
|
raise MailboxError(
|
||||||
|
"You can not transfer the aliases to the mailbox you want to delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not transfer_mailbox.verified:
|
||||||
|
LOG.i(f"User {user} has tried to transfer to a non verified mailbox")
|
||||||
|
MailboxError("Your new mailbox is not verified")
|
||||||
|
|
||||||
|
# Schedule delete account job
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} has scheduled delete mailbox job for {mailbox.id} with transfer to mailbox {transfer_mailbox_id}"
|
||||||
|
)
|
||||||
|
Job.create(
|
||||||
|
name=JOB_DELETE_MAILBOX,
|
||||||
|
payload={
|
||||||
|
"mailbox_id": mailbox.id,
|
||||||
|
"transfer_mailbox_id": transfer_mailbox_id
|
||||||
|
if transfer_mailbox_id and transfer_mailbox_id > 0
|
||||||
|
else None,
|
||||||
|
},
|
||||||
|
run_at=arrow.now(),
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
return mailbox
|
||||||
|
|
||||||
|
|
||||||
|
def clear_activation_codes_for_mailbox(mailbox: Mailbox):
|
||||||
|
Session.query(MailboxActivation).filter(
|
||||||
|
MailboxActivation.mailbox_id == mailbox.id
|
||||||
|
).delete()
|
||||||
|
Session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
|
||||||
|
mailbox = Mailbox.get(mailbox_id)
|
||||||
|
if not mailbox:
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} failed to verify mailbox {mailbox_id} because it does not exist"
|
||||||
|
)
|
||||||
|
raise MailboxError("Invalid mailbox")
|
||||||
|
if mailbox.verified:
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} failed to verify mailbox {mailbox_id} because it's already verified"
|
||||||
|
)
|
||||||
|
clear_activation_codes_for_mailbox(mailbox)
|
||||||
|
return mailbox
|
||||||
|
if mailbox.user_id != user.id:
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} failed to verify mailbox {mailbox_id} because it's owned by another user"
|
||||||
|
)
|
||||||
|
raise MailboxError("Invalid mailbox")
|
||||||
|
|
||||||
|
activation = (
|
||||||
|
MailboxActivation.filter(MailboxActivation.mailbox_id == mailbox_id)
|
||||||
|
.order_by(MailboxActivation.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not activation:
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} failed to verify mailbox {mailbox_id} because there is no activation"
|
||||||
|
)
|
||||||
|
raise MailboxError("Invalid code")
|
||||||
|
if activation.tries >= MAX_ACTIVATION_TRIES:
|
||||||
|
LOG.i(f"User {user} failed to verify mailbox {mailbox_id} more than 3 times")
|
||||||
|
clear_activation_codes_for_mailbox(mailbox)
|
||||||
|
raise CannotVerifyError("Invalid activation code. Please request another code.")
|
||||||
|
if activation.created_at < arrow.now().shift(minutes=-15):
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} failed to verify mailbox {mailbox_id} because code is too old"
|
||||||
|
)
|
||||||
|
clear_activation_codes_for_mailbox(mailbox)
|
||||||
|
raise CannotVerifyError("Invalid activation code. Please request another code.")
|
||||||
|
if code != activation.code:
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} failed to verify mailbox {mailbox_id} because code does not match"
|
||||||
|
)
|
||||||
|
activation.tries = activation.tries + 1
|
||||||
|
Session.commit()
|
||||||
|
raise CannotVerifyError("Invalid activation code")
|
||||||
|
LOG.i(f"User {user} has verified mailbox {mailbox_id}")
|
||||||
|
mailbox.verified = True
|
||||||
|
clear_activation_codes_for_mailbox(mailbox)
|
||||||
|
return mailbox
|
||||||
|
|
||||||
|
|
||||||
|
def generate_activation_code(
|
||||||
|
mailbox: Mailbox, use_digit_code: bool = False
|
||||||
|
) -> MailboxActivation:
|
||||||
|
clear_activation_codes_for_mailbox(mailbox)
|
||||||
|
if use_digit_code:
|
||||||
|
code = "{:06d}".format(random.randint(1, 999999))
|
||||||
|
else:
|
||||||
|
code = secrets.token_urlsafe(16)
|
||||||
|
return MailboxActivation.create(
|
||||||
|
mailbox_id=mailbox.id,
|
||||||
|
code=code,
|
||||||
|
tries=0,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_verification_email(
|
||||||
|
user: User, mailbox: Mailbox, activation: MailboxActivation, send_link: bool = True
|
||||||
|
):
|
||||||
|
LOG.i(
|
||||||
|
f"Sending mailbox verification email to {mailbox.email} with send link={send_link}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if send_link:
|
||||||
|
verification_url = (
|
||||||
|
config.URL
|
||||||
|
+ "/dashboard/mailbox_verify"
|
||||||
|
+ f"?mailbox_id={mailbox.id}&code={activation.code}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
verification_url = None
|
||||||
|
|
||||||
|
send_email(
|
||||||
|
mailbox.email,
|
||||||
|
f"Please confirm your mailbox {mailbox.email}",
|
||||||
|
render(
|
||||||
|
"transactional/verify-mailbox.txt.jinja2",
|
||||||
|
user=user,
|
||||||
|
code=activation.code,
|
||||||
|
link=verification_url,
|
||||||
|
mailbox_email=mailbox.email,
|
||||||
|
),
|
||||||
|
render(
|
||||||
|
"transactional/verify-mailbox.html",
|
||||||
|
user=user,
|
||||||
|
code=activation.code,
|
||||||
|
link=verification_url,
|
||||||
|
mailbox_email=mailbox.email,
|
||||||
|
),
|
||||||
|
)
|
@ -973,7 +973,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
def has_custom_domain(self):
|
def has_custom_domain(self):
|
||||||
return CustomDomain.filter_by(user_id=self.id, verified=True).count() > 0
|
return CustomDomain.filter_by(user_id=self.id, verified=True).count() > 0
|
||||||
|
|
||||||
def custom_domains(self):
|
def custom_domains(self) -> List["CustomDomain"]:
|
||||||
return CustomDomain.filter_by(user_id=self.id, verified=True).all()
|
return CustomDomain.filter_by(user_id=self.id, verified=True).all()
|
||||||
|
|
||||||
def available_domains_for_random_alias(
|
def available_domains_for_random_alias(
|
||||||
@ -985,8 +985,8 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
- the domain
|
- the domain
|
||||||
"""
|
"""
|
||||||
res = []
|
res = []
|
||||||
for domain in self.available_sl_domains(alias_options=alias_options):
|
for domain in self.get_sl_domains(alias_options=alias_options):
|
||||||
res.append((True, domain))
|
res.append((True, domain.domain))
|
||||||
|
|
||||||
for custom_domain in self.verified_custom_domains():
|
for custom_domain in self.verified_custom_domains():
|
||||||
res.append((False, custom_domain.domain))
|
res.append((False, custom_domain.domain))
|
||||||
@ -1128,7 +1128,10 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
- Verified custom domains
|
- Verified custom domains
|
||||||
|
|
||||||
"""
|
"""
|
||||||
domains = self.available_sl_domains(alias_options=alias_options)
|
domains = [
|
||||||
|
sl_domain.domain
|
||||||
|
for sl_domain in self.get_sl_domains(alias_options=alias_options)
|
||||||
|
]
|
||||||
|
|
||||||
for custom_domain in self.verified_custom_domains():
|
for custom_domain in self.verified_custom_domains():
|
||||||
domains.append(custom_domain.domain)
|
domains.append(custom_domain.domain)
|
||||||
@ -1657,18 +1660,6 @@ class Alias(Base, ModelMixin):
|
|||||||
Session.add(new_alias)
|
Session.add(new_alias)
|
||||||
DailyMetric.get_or_create_today_metric().nb_alias += 1
|
DailyMetric.get_or_create_today_metric().nb_alias += 1
|
||||||
|
|
||||||
# Internal import to avoid global import cycles
|
|
||||||
from app.events.event_dispatcher import EventDispatcher
|
|
||||||
from app.events.generated.event_pb2 import AliasCreated, EventContent
|
|
||||||
|
|
||||||
event = AliasCreated(
|
|
||||||
alias_id=new_alias.id,
|
|
||||||
alias_email=new_alias.email,
|
|
||||||
alias_note=new_alias.note,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
EventDispatcher.send_event(user, EventContent(alias_created=event))
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
new_alias.flags & cls.FLAG_PARTNER_CREATED > 0
|
new_alias.flags & cls.FLAG_PARTNER_CREATED > 0
|
||||||
and new_alias.user.flags & User.FLAG_CREATED_ALIAS_FROM_PARTNER == 0
|
and new_alias.user.flags & User.FLAG_CREATED_ALIAS_FROM_PARTNER == 0
|
||||||
@ -1681,6 +1672,19 @@ class Alias(Base, ModelMixin):
|
|||||||
if flush:
|
if flush:
|
||||||
Session.flush()
|
Session.flush()
|
||||||
|
|
||||||
|
# Internal import to avoid global import cycles
|
||||||
|
from app.events.event_dispatcher import EventDispatcher
|
||||||
|
from app.events.generated.event_pb2 import AliasCreated, EventContent
|
||||||
|
|
||||||
|
event = AliasCreated(
|
||||||
|
id=new_alias.id,
|
||||||
|
email=new_alias.email,
|
||||||
|
note=new_alias.note,
|
||||||
|
enabled=True,
|
||||||
|
created_at=int(new_alias.created_at.timestamp),
|
||||||
|
)
|
||||||
|
EventDispatcher.send_event(user, EventContent(alias_created=event))
|
||||||
|
|
||||||
return new_alias
|
return new_alias
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -1859,6 +1863,8 @@ class Contact(Base, ModelMixin):
|
|||||||
|
|
||||||
MAX_NAME_LENGTH = 512
|
MAX_NAME_LENGTH = 512
|
||||||
|
|
||||||
|
FLAG_PARTNER_CREATED = 1 << 0
|
||||||
|
|
||||||
__tablename__ = "contact"
|
__tablename__ = "contact"
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@ -1917,6 +1923,9 @@ class Contact(Base, ModelMixin):
|
|||||||
# whether contact is created automatically during the forward phase
|
# whether contact is created automatically during the forward phase
|
||||||
automatic_created = sa.Column(sa.Boolean, nullable=True, default=False)
|
automatic_created = sa.Column(sa.Boolean, nullable=True, default=False)
|
||||||
|
|
||||||
|
# contact flags
|
||||||
|
flags = sa.Column(sa.Integer, nullable=False, default=0, server_default="0")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def email(self):
|
def email(self):
|
||||||
return self.website_email
|
return self.website_email
|
||||||
@ -2415,6 +2424,18 @@ class CustomDomain(Base, ModelMixin):
|
|||||||
sa.Boolean, nullable=False, default=False, server_default="0"
|
sa.Boolean, nullable=False, default=False, server_default="0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
partner_id = sa.Column(
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("partner.id"),
|
||||||
|
nullable=True,
|
||||||
|
default=None,
|
||||||
|
server_default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
pending_deletion = sa.Column(
|
||||||
|
sa.Boolean, nullable=False, default=False, server_default="0"
|
||||||
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index(
|
Index(
|
||||||
"ix_unique_domain", # Index name
|
"ix_unique_domain", # Index name
|
||||||
@ -2439,9 +2460,6 @@ class CustomDomain(Base, ModelMixin):
|
|||||||
def get_trash_url(self):
|
def get_trash_url(self):
|
||||||
return config.URL + f"/dashboard/domains/{self.id}/trash"
|
return config.URL + f"/dashboard/domains/{self.id}/trash"
|
||||||
|
|
||||||
def get_ownership_dns_txt_value(self):
|
|
||||||
return f"sl-verification={self.ownership_txt_token}"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, **kwargs):
|
def create(cls, **kwargs):
|
||||||
domain = kwargs.get("domain")
|
domain = kwargs.get("domain")
|
||||||
@ -2483,7 +2501,7 @@ class CustomDomain(Base, ModelMixin):
|
|||||||
return sorted(self._auto_create_rules, key=lambda rule: rule.order)
|
return sorted(self._auto_create_rules, key=lambda rule: rule.order)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Custom Domain {self.domain}>"
|
return f"<Custom Domain {self.id} {self.domain}>"
|
||||||
|
|
||||||
|
|
||||||
class AutoCreateRule(Base, ModelMixin):
|
class AutoCreateRule(Base, ModelMixin):
|
||||||
@ -2801,6 +2819,16 @@ class Mailbox(Base, ModelMixin):
|
|||||||
return f"<Mailbox {self.id} {self.email}>"
|
return f"<Mailbox {self.id} {self.email}>"
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxActivation(Base, ModelMixin):
|
||||||
|
__tablename__ = "mailbox_activation"
|
||||||
|
|
||||||
|
mailbox_id = sa.Column(
|
||||||
|
sa.ForeignKey(Mailbox.id, ondelete="cascade"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
code = sa.Column(sa.String(32), nullable=False, index=True)
|
||||||
|
tries = sa.Column(sa.Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class AccountActivation(Base, ModelMixin):
|
class AccountActivation(Base, ModelMixin):
|
||||||
"""contains code to activate the user account when they sign up on mobile"""
|
"""contains code to activate the user account when they sign up on mobile"""
|
||||||
|
|
||||||
@ -3114,7 +3142,7 @@ class SLDomain(Base, ModelMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<SLDomain {self.domain} {'Premium' if self.premium_only else 'Free'}"
|
return f"<SLDomain {self.id} {self.domain} {'Premium' if self.premium_only else 'Free'}>"
|
||||||
|
|
||||||
|
|
||||||
class Monitoring(Base, ModelMixin):
|
class Monitoring(Base, ModelMixin):
|
||||||
@ -3484,6 +3512,7 @@ class AdminAuditLog(Base):
|
|||||||
action=AuditLogActionEnum.stop_trial.value,
|
action=AuditLogActionEnum.stop_trial.value,
|
||||||
model="User",
|
model="User",
|
||||||
model_id=user_id,
|
model_id=user_id,
|
||||||
|
data={},
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -3729,6 +3758,7 @@ class SyncEvent(Base, ModelMixin):
|
|||||||
taken_time = sa.Column(
|
taken_time = sa.Column(
|
||||||
ArrowType, default=None, nullable=True, server_default=None, index=True
|
ArrowType, default=None, nullable=True, server_default=None, index=True
|
||||||
)
|
)
|
||||||
|
retry_count = sa.Column(sa.Integer, default=0, nullable=False, server_default="0")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
sa.Index("ix_sync_event_created_at", "created_at"),
|
sa.Index("ix_sync_event_created_at", "created_at"),
|
||||||
@ -3750,7 +3780,7 @@ class SyncEvent(Base, ModelMixin):
|
|||||||
return res.rowcount > 0
|
return res.rowcount > 0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_dead_letter(cls, older_than: Arrow) -> [SyncEvent]:
|
def get_dead_letter(cls, older_than: Arrow, max_retries: int) -> [SyncEvent]:
|
||||||
return (
|
return (
|
||||||
SyncEvent.filter(
|
SyncEvent.filter(
|
||||||
(
|
(
|
||||||
@ -3763,6 +3793,7 @@ class SyncEvent(Base, ModelMixin):
|
|||||||
& (SyncEvent.created_at < older_than)
|
& (SyncEvent.created_at < older_than)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
& (SyncEvent.retry_count < max_retries)
|
||||||
)
|
)
|
||||||
.order_by(SyncEvent.id)
|
.order_by(SyncEvent.id)
|
||||||
.limit(100)
|
.limit(100)
|
||||||
|
@ -1,7 +1,13 @@
|
|||||||
from app.onboarding.base import onboarding_bp
|
from app.onboarding.base import onboarding_bp
|
||||||
from flask import render_template
|
from flask import render_template, url_for, redirect
|
||||||
|
|
||||||
|
|
||||||
@onboarding_bp.route("/", methods=["GET"])
|
@onboarding_bp.route("/", methods=["GET"])
|
||||||
def index():
|
def index():
|
||||||
return render_template("onboarding/index.html")
|
# Do the redirect to ensure cookies are set because they are SameSite=lax/strict
|
||||||
|
return redirect(url_for("onboarding.setup"))
|
||||||
|
|
||||||
|
|
||||||
|
@onboarding_bp.route("/setup", methods=["GET"])
|
||||||
|
def setup():
|
||||||
|
return render_template("onboarding/setup.html")
|
||||||
|
@ -2,9 +2,11 @@ from dataclasses import dataclass
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import arrow
|
||||||
|
|
||||||
|
from app import config
|
||||||
from app.errors import LinkException
|
from app.errors import LinkException
|
||||||
from app.models import User, Partner
|
from app.models import User, Partner, Job
|
||||||
from app.proton.proton_client import ProtonClient, ProtonUser
|
from app.proton.proton_client import ProtonClient, ProtonUser
|
||||||
from app.account_linking import (
|
from app.account_linking import (
|
||||||
process_login_case,
|
process_login_case,
|
||||||
@ -41,12 +43,21 @@ class ProtonCallbackHandler:
|
|||||||
def __init__(self, proton_client: ProtonClient):
|
def __init__(self, proton_client: ProtonClient):
|
||||||
self.proton_client = proton_client
|
self.proton_client = proton_client
|
||||||
|
|
||||||
|
def _initial_alias_sync(self, user: User):
|
||||||
|
Job.create(
|
||||||
|
name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
|
||||||
|
payload={"user_id": user.id},
|
||||||
|
run_at=arrow.now(),
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
def handle_login(self, partner: Partner) -> ProtonCallbackResult:
|
def handle_login(self, partner: Partner) -> ProtonCallbackResult:
|
||||||
try:
|
try:
|
||||||
user = self.__get_partner_user()
|
user = self.__get_partner_user()
|
||||||
if user is None:
|
if user is None:
|
||||||
return generate_account_not_allowed_to_log_in()
|
return generate_account_not_allowed_to_log_in()
|
||||||
res = process_login_case(user, partner)
|
res = process_login_case(user, partner)
|
||||||
|
self._initial_alias_sync(res.user)
|
||||||
return ProtonCallbackResult(
|
return ProtonCallbackResult(
|
||||||
redirect_to_login=False,
|
redirect_to_login=False,
|
||||||
flash_message=None,
|
flash_message=None,
|
||||||
@ -75,6 +86,7 @@ class ProtonCallbackHandler:
|
|||||||
if user is None:
|
if user is None:
|
||||||
return generate_account_not_allowed_to_log_in()
|
return generate_account_not_allowed_to_log_in()
|
||||||
res = process_link_case(user, current_user, partner)
|
res = process_link_case(user, current_user, partner)
|
||||||
|
self._initial_alias_sync(res.user)
|
||||||
return ProtonCallbackResult(
|
return ProtonCallbackResult(
|
||||||
redirect_to_login=False,
|
redirect_to_login=False,
|
||||||
flash_message="Account successfully linked",
|
flash_message="Account successfully linked",
|
||||||
|
@ -2,6 +2,7 @@ import requests
|
|||||||
from requests import RequestException
|
from requests import RequestException
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
|
from app.db import Session
|
||||||
from app.events.event_dispatcher import EventDispatcher
|
from app.events.event_dispatcher import EventDispatcher
|
||||||
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
|
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
@ -29,10 +30,11 @@ def execute_subscription_webhook(user: User):
|
|||||||
LOG.i("Sent request to subscription update webhook successfully")
|
LOG.i("Sent request to subscription update webhook successfully")
|
||||||
else:
|
else:
|
||||||
LOG.i(
|
LOG.i(
|
||||||
f"Request to webhook failed with statue {response.status_code}: {response.text}"
|
f"Request to webhook failed with status {response.status_code}: {response.text}"
|
||||||
)
|
)
|
||||||
except RequestException as e:
|
except RequestException as e:
|
||||||
LOG.error(f"Subscription request exception: {e}")
|
LOG.error(f"Subscription request exception: {e}")
|
||||||
|
|
||||||
event = UserPlanChanged(plan_end_time=sl_subscription_end)
|
event = UserPlanChanged(plan_end_time=sl_subscription_end)
|
||||||
EventDispatcher.send_event(user, EventContent(user_plan_change=event))
|
EventDispatcher.send_event(user, EventContent(user_plan_change=event))
|
||||||
|
Session.commit()
|
||||||
|
71
app/app/user_settings.py
Normal file
71
app/app/user_settings.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.db import Session
|
||||||
|
from app.log import LOG
|
||||||
|
from app.models import User, SLDomain, CustomDomain, Mailbox
|
||||||
|
|
||||||
|
|
||||||
|
class CannotSetAlias(Exception):
|
||||||
|
def __init__(self, msg: str):
|
||||||
|
self.msg = msg
|
||||||
|
|
||||||
|
|
||||||
|
class CannotSetMailbox(Exception):
|
||||||
|
def __init__(self, msg: str):
|
||||||
|
self.msg = msg
|
||||||
|
|
||||||
|
|
||||||
|
def set_default_alias_domain(user: User, domain_name: Optional[str]):
|
||||||
|
if not domain_name:
|
||||||
|
LOG.i(f"User {user} has set no domain as default domain")
|
||||||
|
user.default_alias_public_domain_id = None
|
||||||
|
user.default_alias_custom_domain_id = None
|
||||||
|
Session.flush()
|
||||||
|
return
|
||||||
|
|
||||||
|
sl_domain: SLDomain = SLDomain.get_by(domain=domain_name)
|
||||||
|
if sl_domain:
|
||||||
|
if sl_domain.hidden:
|
||||||
|
LOG.i(f"User {user} has tried to set up a hidden domain as default domain")
|
||||||
|
raise CannotSetAlias("Domain does not exist")
|
||||||
|
if sl_domain.premium_only and not user.is_premium():
|
||||||
|
LOG.i(f"User {user} has tried to set up a premium domain as default domain")
|
||||||
|
raise CannotSetAlias("You cannot use this domain")
|
||||||
|
LOG.i(f"User {user} has set public {sl_domain} as default domain")
|
||||||
|
user.default_alias_public_domain_id = sl_domain.id
|
||||||
|
user.default_alias_custom_domain_id = None
|
||||||
|
Session.flush()
|
||||||
|
return
|
||||||
|
custom_domain = CustomDomain.get_by(domain=domain_name)
|
||||||
|
if not custom_domain:
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} has tried to set up an non existing domain as default domain"
|
||||||
|
)
|
||||||
|
raise CannotSetAlias("Domain does not exist or it hasn't been verified")
|
||||||
|
if custom_domain.user_id != user.id or not custom_domain.verified:
|
||||||
|
LOG.i(
|
||||||
|
f"User {user} has tried to set domain {custom_domain} as default domain that does not belong to the user or that is not verified"
|
||||||
|
)
|
||||||
|
raise CannotSetAlias("Domain does not exist or it hasn't been verified")
|
||||||
|
LOG.i(f"User {user} has set custom {custom_domain} as default domain")
|
||||||
|
user.default_alias_public_domain_id = None
|
||||||
|
user.default_alias_custom_domain_id = custom_domain.id
|
||||||
|
Session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def set_default_mailbox(user: User, mailbox_id: int) -> Mailbox:
|
||||||
|
mailbox = Mailbox.get(mailbox_id)
|
||||||
|
|
||||||
|
if not mailbox or mailbox.user_id != user.id:
|
||||||
|
raise CannotSetMailbox("Invalid mailbox")
|
||||||
|
|
||||||
|
if not mailbox.verified:
|
||||||
|
raise CannotSetMailbox("This is mailbox is not verified")
|
||||||
|
|
||||||
|
if mailbox.id == user.default_mailbox_id:
|
||||||
|
return mailbox
|
||||||
|
LOG.i(f"User {user} has set mailbox {mailbox} as his default one")
|
||||||
|
|
||||||
|
user.default_mailbox_id = mailbox.id
|
||||||
|
Session.commit()
|
||||||
|
return mailbox
|
@ -52,7 +52,7 @@ from flanker.addresslib import address
|
|||||||
from flanker.addresslib.address import EmailAddress
|
from flanker.addresslib.address import EmailAddress
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from app import pgp_utils, s3, config
|
from app import pgp_utils, s3, config, contact_utils
|
||||||
from app.alias_utils import try_auto_create, change_alias_status
|
from app.alias_utils import try_auto_create, change_alias_status
|
||||||
from app.config import (
|
from app.config import (
|
||||||
EMAIL_DOMAIN,
|
EMAIL_DOMAIN,
|
||||||
@ -195,80 +195,16 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
|||||||
mail_from,
|
mail_from,
|
||||||
)
|
)
|
||||||
contact_email = mail_from
|
contact_email = mail_from
|
||||||
|
contact_result = contact_utils.create_contact(
|
||||||
if not is_valid_email(contact_email):
|
email=contact_email,
|
||||||
LOG.w(
|
|
||||||
"invalid contact email %s. Parse from %s %s",
|
|
||||||
contact_email,
|
|
||||||
from_header,
|
|
||||||
mail_from,
|
|
||||||
)
|
|
||||||
# either reuse a contact with empty email or create a new contact with empty email
|
|
||||||
contact_email = ""
|
|
||||||
|
|
||||||
contact_email = sanitize_email(contact_email, not_lower=True)
|
|
||||||
|
|
||||||
if contact_name and "\x00" in contact_name:
|
|
||||||
LOG.w("issue with contact name %s", contact_name)
|
|
||||||
contact_name = ""
|
|
||||||
|
|
||||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
|
||||||
if contact:
|
|
||||||
if contact.name != contact_name:
|
|
||||||
LOG.d(
|
|
||||||
"Update contact %s name %s to %s",
|
|
||||||
contact,
|
|
||||||
contact.name,
|
|
||||||
contact_name,
|
|
||||||
)
|
|
||||||
contact.name = contact_name
|
|
||||||
Session.commit()
|
|
||||||
|
|
||||||
# contact created in the past does not have mail_from and from_header field
|
|
||||||
if not contact.mail_from and mail_from:
|
|
||||||
LOG.d(
|
|
||||||
"Set contact mail_from %s: %s to %s",
|
|
||||||
contact,
|
|
||||||
contact.mail_from,
|
|
||||||
mail_from,
|
|
||||||
)
|
|
||||||
contact.mail_from = mail_from
|
|
||||||
Session.commit()
|
|
||||||
else:
|
|
||||||
alias_id = alias.id
|
|
||||||
try:
|
|
||||||
contact_email_for_reply = (
|
|
||||||
contact_email if is_valid_email(contact_email) else ""
|
|
||||||
)
|
|
||||||
contact = Contact.create(
|
|
||||||
user_id=alias.user_id,
|
|
||||||
alias_id=alias_id,
|
|
||||||
website_email=contact_email,
|
|
||||||
name=contact_name,
|
name=contact_name,
|
||||||
|
alias=alias,
|
||||||
mail_from=mail_from,
|
mail_from=mail_from,
|
||||||
reply_email=generate_reply_email(contact_email_for_reply, alias),
|
allow_empty_email=True,
|
||||||
automatic_created=True,
|
automatic_created=True,
|
||||||
|
from_partner=False,
|
||||||
)
|
)
|
||||||
if not contact_email:
|
return contact_result.contact
|
||||||
LOG.d("Create a contact with invalid email for %s", alias)
|
|
||||||
contact.invalid_email = True
|
|
||||||
|
|
||||||
LOG.d(
|
|
||||||
"create contact %s for %s, reverse alias:%s",
|
|
||||||
contact_email,
|
|
||||||
alias,
|
|
||||||
contact.reply_email,
|
|
||||||
)
|
|
||||||
|
|
||||||
Session.commit()
|
|
||||||
except IntegrityError:
|
|
||||||
# No need to manually rollback, as IntegrityError already rolls back
|
|
||||||
LOG.info(
|
|
||||||
f"Contact with email {contact_email} for alias_id {alias_id} already existed, fetching from DB"
|
|
||||||
)
|
|
||||||
contact = Contact.get_by(alias_id=alias_id, website_email=contact_email)
|
|
||||||
|
|
||||||
return contact
|
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_reply_to_contact(
|
def get_or_create_reply_to_contact(
|
||||||
@ -293,33 +229,7 @@ def get_or_create_reply_to_contact(
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_address)
|
return contact_utils.create_contact(contact_address, contact_name, alias).contact
|
||||||
if contact:
|
|
||||||
return contact
|
|
||||||
else:
|
|
||||||
LOG.d(
|
|
||||||
"create contact %s for alias %s via reply-to header %s",
|
|
||||||
contact_address,
|
|
||||||
alias,
|
|
||||||
reply_to_header,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
contact = Contact.create(
|
|
||||||
user_id=alias.user_id,
|
|
||||||
alias_id=alias.id,
|
|
||||||
website_email=contact_address,
|
|
||||||
name=contact_name,
|
|
||||||
reply_email=generate_reply_email(contact_address, alias),
|
|
||||||
automatic_created=True,
|
|
||||||
)
|
|
||||||
Session.commit()
|
|
||||||
except IntegrityError:
|
|
||||||
LOG.w("Contact %s %s already exist", alias, contact_address)
|
|
||||||
Session.rollback()
|
|
||||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_address)
|
|
||||||
|
|
||||||
return contact
|
|
||||||
|
|
||||||
|
|
||||||
def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
||||||
@ -817,7 +727,7 @@ def forward_email_to_mailbox(
|
|||||||
|
|
||||||
email_log = EmailLog.create(
|
email_log = EmailLog.create(
|
||||||
contact_id=contact.id,
|
contact_id=contact.id,
|
||||||
user_id=user.id,
|
user_id=contact.user_id,
|
||||||
mailbox_id=mailbox.id,
|
mailbox_id=mailbox.id,
|
||||||
alias_id=contact.alias_id,
|
alias_id=contact.alias_id,
|
||||||
message_id=str(msg[headers.MESSAGE_ID]),
|
message_id=str(msg[headers.MESSAGE_ID]),
|
||||||
|
@ -2,12 +2,15 @@ import argparse
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sys import argv, exit
|
from sys import argv, exit
|
||||||
|
|
||||||
from app.config import DB_URI
|
from app.config import EVENT_LISTENER_DB_URI
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
|
from events import event_debugger
|
||||||
from events.runner import Runner
|
from events.runner import Runner
|
||||||
from events.event_source import DeadLetterEventSource, PostgresEventSource
|
from events.event_source import DeadLetterEventSource, PostgresEventSource
|
||||||
from events.event_sink import ConsoleEventSink, HttpEventSink
|
from events.event_sink import ConsoleEventSink, HttpEventSink
|
||||||
|
|
||||||
|
_DEFAULT_MAX_RETRIES = 10
|
||||||
|
|
||||||
|
|
||||||
class Mode(Enum):
|
class Mode(Enum):
|
||||||
DEAD_LETTER = "dead_letter"
|
DEAD_LETTER = "dead_letter"
|
||||||
@ -23,13 +26,13 @@ class Mode(Enum):
|
|||||||
raise ValueError(f"Invalid mode: {value}")
|
raise ValueError(f"Invalid mode: {value}")
|
||||||
|
|
||||||
|
|
||||||
def main(mode: Mode, dry_run: bool):
|
def main(mode: Mode, dry_run: bool, max_retries: int):
|
||||||
if mode == Mode.DEAD_LETTER:
|
if mode == Mode.DEAD_LETTER:
|
||||||
LOG.i("Using DeadLetterEventSource")
|
LOG.i("Using DeadLetterEventSource")
|
||||||
source = DeadLetterEventSource()
|
source = DeadLetterEventSource(max_retries)
|
||||||
elif mode == Mode.LISTENER:
|
elif mode == Mode.LISTENER:
|
||||||
LOG.i("Using PostgresEventSource")
|
LOG.i("Using PostgresEventSource")
|
||||||
source = PostgresEventSource(DB_URI)
|
source = PostgresEventSource(EVENT_LISTENER_DB_URI)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid mode: {mode}")
|
raise ValueError(f"Invalid mode: {mode}")
|
||||||
|
|
||||||
@ -44,21 +47,67 @@ def main(mode: Mode, dry_run: bool):
|
|||||||
runner.run()
|
runner.run()
|
||||||
|
|
||||||
|
|
||||||
|
def debug_event(event_id: str):
|
||||||
|
LOG.i(f"Debugging event {event_id}")
|
||||||
|
try:
|
||||||
|
event_id_int = int(event_id)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid event id: {event_id}")
|
||||||
|
event_debugger.debug_event(event_id_int)
|
||||||
|
|
||||||
|
|
||||||
|
def run_event(event_id: str, delete_on_success: bool):
|
||||||
|
LOG.i(f"Running event {event_id}")
|
||||||
|
try:
|
||||||
|
event_id_int = int(event_id)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid event id: {event_id}")
|
||||||
|
event_debugger.run_event(event_id_int, delete_on_success)
|
||||||
|
|
||||||
|
|
||||||
def args():
|
def args():
|
||||||
parser = argparse.ArgumentParser(description="Run event listener")
|
parser = argparse.ArgumentParser(description="Run event listener")
|
||||||
parser.add_argument(
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
"mode",
|
|
||||||
help="Mode to run",
|
listener_parser = subparsers.add_parser(Mode.LISTENER.value)
|
||||||
choices=[Mode.DEAD_LETTER.value, Mode.LISTENER.value],
|
listener_parser.add_argument(
|
||||||
|
"--max-retries", type=int, default=_DEFAULT_MAX_RETRIES
|
||||||
)
|
)
|
||||||
parser.add_argument("--dry-run", help="Dry run mode", action="store_true")
|
listener_parser.add_argument("--dry-run", action="store_true")
|
||||||
|
|
||||||
|
dead_letter_parser = subparsers.add_parser(Mode.DEAD_LETTER.value)
|
||||||
|
dead_letter_parser.add_argument(
|
||||||
|
"--max-retries", type=int, default=_DEFAULT_MAX_RETRIES
|
||||||
|
)
|
||||||
|
dead_letter_parser.add_argument("--dry-run", action="store_true")
|
||||||
|
|
||||||
|
debug_parser = subparsers.add_parser("debug")
|
||||||
|
debug_parser.add_argument("event_id", help="ID of the event to debug")
|
||||||
|
|
||||||
|
run_parser = subparsers.add_parser("run")
|
||||||
|
run_parser.add_argument("event_id", help="ID of the event to run")
|
||||||
|
run_parser.add_argument("--delete-on-success", action="store_true")
|
||||||
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if len(argv) < 2:
|
if len(argv) < 2:
|
||||||
print("Invalid usage. Pass 'listener' or 'dead_letter' as argument")
|
print("Invalid usage. Pass a valid subcommand as argument")
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
args = args()
|
args = args()
|
||||||
main(Mode.from_str(args.mode), args.dry_run)
|
|
||||||
|
if args.command in [Mode.LISTENER.value, Mode.DEAD_LETTER.value]:
|
||||||
|
main(
|
||||||
|
mode=Mode.from_str(args.command),
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
max_retries=args.max_retries,
|
||||||
|
)
|
||||||
|
elif args.command == "debug":
|
||||||
|
debug_event(args.event_id)
|
||||||
|
elif args.command == "run":
|
||||||
|
run_event(args.event_id, args.delete_on_success)
|
||||||
|
else:
|
||||||
|
print("Invalid command")
|
||||||
|
exit(1)
|
||||||
|
43
app/events/event_debugger.py
Normal file
43
app/events/event_debugger.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from app.events.generated import event_pb2
|
||||||
|
from app.models import SyncEvent
|
||||||
|
from events.event_sink import HttpEventSink
|
||||||
|
|
||||||
|
|
||||||
|
def debug_event(event_id: int):
|
||||||
|
event = SyncEvent.get_by(id=event_id)
|
||||||
|
if not event:
|
||||||
|
print("Event not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Info for event {event_id}")
|
||||||
|
print(f"- Created at: {event.created_at}")
|
||||||
|
print(f"- Updated at: {event.updated_at}")
|
||||||
|
print(f"- Taken time: {event.taken_time}")
|
||||||
|
print(f"- Retry count: {event.retry_count}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Event contents")
|
||||||
|
event_contents = event.content
|
||||||
|
parsed = event_pb2.Event.FromString(event_contents)
|
||||||
|
|
||||||
|
print(f"- UserID: {parsed.user_id}")
|
||||||
|
print(f"- ExternalUserID: {parsed.external_user_id}")
|
||||||
|
print(f"- PartnerID: {parsed.partner_id}")
|
||||||
|
|
||||||
|
content = parsed.content
|
||||||
|
print(f"Content: {content}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_event(event_id: int, delete_on_success: bool = True):
|
||||||
|
event = SyncEvent.get_by(id=event_id)
|
||||||
|
if not event:
|
||||||
|
print("Event not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Processing event {event_id}")
|
||||||
|
sink = HttpEventSink()
|
||||||
|
res = sink.process(event)
|
||||||
|
if res:
|
||||||
|
print(f"Processed event {event_id}")
|
||||||
|
if delete_on_success:
|
||||||
|
SyncEvent.delete(event_id, commit=True)
|
@ -1,4 +1,5 @@
|
|||||||
import requests
|
import requests
|
||||||
|
import newrelic.agent
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from app.config import EVENT_WEBHOOK, EVENT_WEBHOOK_SKIP_VERIFY_SSL
|
from app.config import EVENT_WEBHOOK, EVENT_WEBHOOK_SKIP_VERIFY_SSL
|
||||||
@ -26,6 +27,9 @@ class HttpEventSink(EventSink):
|
|||||||
headers={"Content-Type": "application/x-protobuf"},
|
headers={"Content-Type": "application/x-protobuf"},
|
||||||
verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL,
|
verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL,
|
||||||
)
|
)
|
||||||
|
newrelic.agent.record_custom_event(
|
||||||
|
"EventSentToPartner", {"http_code": res.status_code}
|
||||||
|
)
|
||||||
if res.status_code != 200:
|
if res.status_code != 200:
|
||||||
LOG.warning(
|
LOG.warning(
|
||||||
f"Failed to send event to webhook: {res.status_code} {res.text}"
|
f"Failed to send event to webhook: {res.status_code} {res.text}"
|
||||||
|
@ -4,6 +4,8 @@ import psycopg2
|
|||||||
import select
|
import select
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from app.db import Session
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import SyncEvent
|
from app.models import SyncEvent
|
||||||
from app.events.event_dispatcher import NOTIFICATION_CHANNEL
|
from app.events.event_dispatcher import NOTIFICATION_CHANNEL
|
||||||
@ -44,6 +46,7 @@ class PostgresEventSource(EventSource):
|
|||||||
cursor = self.__connection.cursor()
|
cursor = self.__connection.cursor()
|
||||||
cursor.execute(f"LISTEN {NOTIFICATION_CHANNEL};")
|
cursor.execute(f"LISTEN {NOTIFICATION_CHANNEL};")
|
||||||
|
|
||||||
|
LOG.info("Starting to listen to events")
|
||||||
while True:
|
while True:
|
||||||
if select.select([self.__connection], [], [], 5) != ([], [], []):
|
if select.select([self.__connection], [], [], 5) != ([], [], []):
|
||||||
self.__connection.poll()
|
self.__connection.poll()
|
||||||
@ -66,6 +69,7 @@ class PostgresEventSource(EventSource):
|
|||||||
LOG.info(f"Could not find event with id={notify.payload}")
|
LOG.info(f"Could not find event with id={notify.payload}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.warn(f"Error getting event: {e}")
|
LOG.warn(f"Error getting event: {e}")
|
||||||
|
Session.close() # Ensure we get a new connection and we don't leave a dangling tx
|
||||||
|
|
||||||
def __connect(self):
|
def __connect(self):
|
||||||
self.__connection = psycopg2.connect(self.__connection_string)
|
self.__connection = psycopg2.connect(self.__connection_string)
|
||||||
@ -76,6 +80,9 @@ class PostgresEventSource(EventSource):
|
|||||||
|
|
||||||
|
|
||||||
class DeadLetterEventSource(EventSource):
|
class DeadLetterEventSource(EventSource):
|
||||||
|
def __init__(self, max_retries: int):
|
||||||
|
self.__max_retries = max_retries
|
||||||
|
|
||||||
@newrelic.agent.background_task()
|
@newrelic.agent.background_task()
|
||||||
def run(self, on_event: Callable[[SyncEvent], NoReturn]):
|
def run(self, on_event: Callable[[SyncEvent], NoReturn]):
|
||||||
while True:
|
while True:
|
||||||
@ -83,7 +90,9 @@ class DeadLetterEventSource(EventSource):
|
|||||||
threshold = arrow.utcnow().shift(
|
threshold = arrow.utcnow().shift(
|
||||||
minutes=-_DEAD_LETTER_THRESHOLD_MINUTES
|
minutes=-_DEAD_LETTER_THRESHOLD_MINUTES
|
||||||
)
|
)
|
||||||
events = SyncEvent.get_dead_letter(older_than=threshold)
|
events = SyncEvent.get_dead_letter(
|
||||||
|
older_than=threshold, max_retries=self.__max_retries
|
||||||
|
)
|
||||||
if events:
|
if events:
|
||||||
LOG.info(f"Got {len(events)} dead letter events")
|
LOG.info(f"Got {len(events)} dead letter events")
|
||||||
if events:
|
if events:
|
||||||
@ -92,7 +101,8 @@ class DeadLetterEventSource(EventSource):
|
|||||||
)
|
)
|
||||||
for event in events:
|
for event in events:
|
||||||
on_event(event)
|
on_event(event)
|
||||||
else:
|
Session.close() # Ensure that we have a new connection and we don't have a dangling tx with a lock
|
||||||
|
if not events:
|
||||||
LOG.debug("No dead letter events")
|
LOG.debug("No dead letter events")
|
||||||
sleep(_DEAD_LETTER_INTERVAL_SECONDS)
|
sleep(_DEAD_LETTER_INTERVAL_SECONDS)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -2,6 +2,7 @@ import arrow
|
|||||||
import newrelic.agent
|
import newrelic.agent
|
||||||
|
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
|
from app.db import Session
|
||||||
from app.models import SyncEvent
|
from app.models import SyncEvent
|
||||||
from events.event_sink import EventSink
|
from events.event_sink import EventSink
|
||||||
from events.event_source import EventSource
|
from events.event_source import EventSource
|
||||||
@ -37,6 +38,9 @@ class Runner:
|
|||||||
"Custom/sync_event_elapsed_time",
|
"Custom/sync_event_elapsed_time",
|
||||||
time_between_taken_and_created.total_seconds(),
|
time_between_taken_and_created.total_seconds(),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
event.retry_count = event.retry_count + 1
|
||||||
|
Session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.warn(f"Exception processing event [id={event.id}]: {e}")
|
LOG.warn(f"Exception processing event [id={event.id}]: {e}")
|
||||||
newrelic.agent.record_custom_metric("Custom/sync_event_failed", 1)
|
newrelic.agent.record_custom_metric("Custom/sync_event_failed", 1)
|
||||||
|
@ -3,7 +3,7 @@ Run scheduled jobs.
|
|||||||
Not meant for running job at precise time (+- 1h)
|
Not meant for running job at precise time (+- 1h)
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
from sqlalchemy.sql.expression import or_, and_
|
from sqlalchemy.sql.expression import or_, and_
|
||||||
@ -14,6 +14,7 @@ from app.email_utils import (
|
|||||||
send_email,
|
send_email,
|
||||||
render,
|
render,
|
||||||
)
|
)
|
||||||
|
from app.events.event_dispatcher import PostgresDispatcher
|
||||||
from app.import_utils import handle_batch_import
|
from app.import_utils import handle_batch_import
|
||||||
from app.jobs.event_jobs import send_alias_creation_events_for_user
|
from app.jobs.event_jobs import send_alias_creation_events_for_user
|
||||||
from app.jobs.export_user_data_job import ExportUserDataJob
|
from app.jobs.export_user_data_job import ExportUserDataJob
|
||||||
@ -239,7 +240,7 @@ def process_job(job: Job):
|
|||||||
|
|
||||||
elif job.name == config.JOB_DELETE_DOMAIN:
|
elif job.name == config.JOB_DELETE_DOMAIN:
|
||||||
custom_domain_id = job.payload.get("custom_domain_id")
|
custom_domain_id = job.payload.get("custom_domain_id")
|
||||||
custom_domain = CustomDomain.get(custom_domain_id)
|
custom_domain: Optional[CustomDomain] = CustomDomain.get(custom_domain_id)
|
||||||
if not custom_domain:
|
if not custom_domain:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -251,14 +252,15 @@ def process_job(job: Job):
|
|||||||
|
|
||||||
LOG.d("Domain %s deleted", domain_name)
|
LOG.d("Domain %s deleted", domain_name)
|
||||||
|
|
||||||
|
if custom_domain.partner_id is None:
|
||||||
send_email(
|
send_email(
|
||||||
user.email,
|
user.email,
|
||||||
f"Your domain {domain_name} has been deleted",
|
f"Your domain {domain_name} has been deleted",
|
||||||
f"""Domain {domain_name} along with its aliases are deleted successfully.
|
f"""Domain {domain_name} along with its aliases are deleted successfully.
|
||||||
|
|
||||||
Regards,
|
Regards,
|
||||||
SimpleLogin team.
|
SimpleLogin team.
|
||||||
""",
|
""",
|
||||||
retries=3,
|
retries=3,
|
||||||
)
|
)
|
||||||
elif job.name == config.JOB_SEND_USER_REPORT:
|
elif job.name == config.JOB_SEND_USER_REPORT:
|
||||||
@ -276,7 +278,9 @@ SimpleLogin team.
|
|||||||
user = User.get(user_id)
|
user = User.get(user_id)
|
||||||
if user and user.activated:
|
if user and user.activated:
|
||||||
LOG.d(f"Sending alias creation events for {user}")
|
LOG.d(f"Sending alias creation events for {user}")
|
||||||
send_alias_creation_events_for_user(user)
|
send_alias_creation_events_for_user(
|
||||||
|
user, dispatcher=PostgresDispatcher.get()
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
LOG.e("Unknown job name %s", job.name)
|
LOG.e("Unknown job name %s", job.name)
|
||||||
|
|
||||||
|
@ -745,8 +745,6 @@ bullish
|
|||||||
bullpen
|
bullpen
|
||||||
bullring
|
bullring
|
||||||
bullseye
|
bullseye
|
||||||
bullwhip
|
|
||||||
bully
|
|
||||||
bunch
|
bunch
|
||||||
bundle
|
bundle
|
||||||
bungee
|
bungee
|
||||||
@ -1149,7 +1147,6 @@ coherence
|
|||||||
coherent
|
coherent
|
||||||
cohesive
|
cohesive
|
||||||
coil
|
coil
|
||||||
coke
|
|
||||||
cola
|
cola
|
||||||
cold
|
cold
|
||||||
coleslaw
|
coleslaw
|
||||||
@ -1674,8 +1671,6 @@ delta
|
|||||||
deluge
|
deluge
|
||||||
delusion
|
delusion
|
||||||
deluxe
|
deluxe
|
||||||
demanding
|
|
||||||
demeaning
|
|
||||||
demeanor
|
demeanor
|
||||||
demise
|
demise
|
||||||
democracy
|
democracy
|
||||||
@ -1897,9 +1892,6 @@ divisible
|
|||||||
divisibly
|
divisibly
|
||||||
division
|
division
|
||||||
divisive
|
divisive
|
||||||
divorcee
|
|
||||||
dizziness
|
|
||||||
dizzy
|
|
||||||
doable
|
doable
|
||||||
docile
|
docile
|
||||||
dock
|
dock
|
||||||
@ -1913,7 +1905,6 @@ dole
|
|||||||
dollar
|
dollar
|
||||||
dollhouse
|
dollhouse
|
||||||
dollop
|
dollop
|
||||||
dolly
|
|
||||||
dolphin
|
dolphin
|
||||||
domain
|
domain
|
||||||
domelike
|
domelike
|
||||||
@ -2027,7 +2018,6 @@ duh
|
|||||||
duke
|
duke
|
||||||
dumping
|
dumping
|
||||||
dumpling
|
dumpling
|
||||||
dumpster
|
|
||||||
duo
|
duo
|
||||||
dupe
|
dupe
|
||||||
duplex
|
duplex
|
||||||
@ -2036,14 +2026,12 @@ duplicity
|
|||||||
durable
|
durable
|
||||||
durably
|
durably
|
||||||
duration
|
duration
|
||||||
duress
|
|
||||||
during
|
during
|
||||||
dusk
|
dusk
|
||||||
dust
|
dust
|
||||||
dutiful
|
dutiful
|
||||||
duty
|
duty
|
||||||
duvet
|
duvet
|
||||||
dwarf
|
|
||||||
dweeb
|
dweeb
|
||||||
dwelled
|
dwelled
|
||||||
dweller
|
dweller
|
||||||
@ -3782,10 +3770,6 @@ makeshift
|
|||||||
making
|
making
|
||||||
malformed
|
malformed
|
||||||
malt
|
malt
|
||||||
mama
|
|
||||||
mammal
|
|
||||||
mammary
|
|
||||||
mammogram
|
|
||||||
manager
|
manager
|
||||||
managing
|
managing
|
||||||
manatee
|
manatee
|
||||||
@ -3798,7 +3782,6 @@ mangle
|
|||||||
mango
|
mango
|
||||||
mangy
|
mangy
|
||||||
manhandle
|
manhandle
|
||||||
manhole
|
|
||||||
manhood
|
manhood
|
||||||
manhunt
|
manhunt
|
||||||
manicotti
|
manicotti
|
||||||
@ -3813,7 +3796,6 @@ manmade
|
|||||||
manned
|
manned
|
||||||
mannish
|
mannish
|
||||||
manor
|
manor
|
||||||
manpower
|
|
||||||
mantis
|
mantis
|
||||||
mantra
|
mantra
|
||||||
manual
|
manual
|
||||||
@ -3850,7 +3832,6 @@ mashed
|
|||||||
mashing
|
mashing
|
||||||
massager
|
massager
|
||||||
masses
|
masses
|
||||||
massive
|
|
||||||
mastiff
|
mastiff
|
||||||
matador
|
matador
|
||||||
matchbook
|
matchbook
|
||||||
@ -3863,15 +3844,11 @@ maternal
|
|||||||
maternity
|
maternity
|
||||||
math
|
math
|
||||||
mating
|
mating
|
||||||
matriarch
|
|
||||||
matrimony
|
matrimony
|
||||||
matrix
|
matrix
|
||||||
matron
|
matron
|
||||||
matted
|
matted
|
||||||
matter
|
matter
|
||||||
maturely
|
|
||||||
maturing
|
|
||||||
maturity
|
|
||||||
mauve
|
mauve
|
||||||
maverick
|
maverick
|
||||||
maximize
|
maximize
|
||||||
@ -3891,9 +3868,6 @@ modify
|
|||||||
modular
|
modular
|
||||||
modulator
|
modulator
|
||||||
module
|
module
|
||||||
moisten
|
|
||||||
moistness
|
|
||||||
moisture
|
|
||||||
molar
|
molar
|
||||||
molasses
|
molasses
|
||||||
mold
|
mold
|
||||||
@ -3946,11 +3920,7 @@ morality
|
|||||||
morally
|
morally
|
||||||
morbidity
|
morbidity
|
||||||
morbidly
|
morbidly
|
||||||
morphine
|
|
||||||
morphing
|
|
||||||
morse
|
morse
|
||||||
mortality
|
|
||||||
mortally
|
|
||||||
mortician
|
mortician
|
||||||
mortified
|
mortified
|
||||||
mortify
|
mortify
|
||||||
@ -3976,7 +3946,6 @@ mournful
|
|||||||
mouse
|
mouse
|
||||||
mousiness
|
mousiness
|
||||||
moustache
|
moustache
|
||||||
mousy
|
|
||||||
mouth
|
mouth
|
||||||
movable
|
movable
|
||||||
move
|
move
|
||||||
@ -3985,7 +3954,6 @@ moving
|
|||||||
mower
|
mower
|
||||||
mowing
|
mowing
|
||||||
much
|
much
|
||||||
muck
|
|
||||||
mud
|
mud
|
||||||
mug
|
mug
|
||||||
mulberry
|
mulberry
|
||||||
@ -4002,7 +3970,6 @@ mumbling
|
|||||||
mumbo
|
mumbo
|
||||||
mummified
|
mummified
|
||||||
mummify
|
mummify
|
||||||
mummy
|
|
||||||
mumps
|
mumps
|
||||||
munchkin
|
munchkin
|
||||||
mundane
|
mundane
|
||||||
@ -4798,7 +4765,6 @@ princess
|
|||||||
print
|
print
|
||||||
prior
|
prior
|
||||||
prism
|
prism
|
||||||
prison
|
|
||||||
prissy
|
prissy
|
||||||
pristine
|
pristine
|
||||||
privacy
|
privacy
|
||||||
@ -4822,8 +4788,6 @@ prodigal
|
|||||||
prodigy
|
prodigy
|
||||||
produce
|
produce
|
||||||
product
|
product
|
||||||
profane
|
|
||||||
profanity
|
|
||||||
professed
|
professed
|
||||||
professor
|
professor
|
||||||
profile
|
profile
|
||||||
@ -5992,10 +5956,6 @@ slit
|
|||||||
sliver
|
sliver
|
||||||
slobbery
|
slobbery
|
||||||
slogan
|
slogan
|
||||||
sloped
|
|
||||||
sloping
|
|
||||||
sloppily
|
|
||||||
sloppy
|
|
||||||
slot
|
slot
|
||||||
slouching
|
slouching
|
||||||
slouchy
|
slouchy
|
||||||
@ -6011,7 +5971,6 @@ smartness
|
|||||||
smasher
|
smasher
|
||||||
smashing
|
smashing
|
||||||
smashup
|
smashup
|
||||||
smell
|
|
||||||
smelting
|
smelting
|
||||||
smile
|
smile
|
||||||
smilingly
|
smilingly
|
||||||
@ -6021,11 +5980,6 @@ smith
|
|||||||
smitten
|
smitten
|
||||||
smock
|
smock
|
||||||
smog
|
smog
|
||||||
smoked
|
|
||||||
smokeless
|
|
||||||
smokiness
|
|
||||||
smoking
|
|
||||||
smoky
|
|
||||||
smolder
|
smolder
|
||||||
smooth
|
smooth
|
||||||
smother
|
smother
|
||||||
@ -6047,7 +6001,6 @@ sneer
|
|||||||
sneeze
|
sneeze
|
||||||
sneezing
|
sneezing
|
||||||
snide
|
snide
|
||||||
sniff
|
|
||||||
snippet
|
snippet
|
||||||
snipping
|
snipping
|
||||||
snitch
|
snitch
|
||||||
@ -6203,7 +6156,6 @@ squiggle
|
|||||||
squiggly
|
squiggly
|
||||||
squint
|
squint
|
||||||
squire
|
squire
|
||||||
squirt
|
|
||||||
squishier
|
squishier
|
||||||
squishy
|
squishy
|
||||||
stability
|
stability
|
||||||
@ -6323,7 +6275,6 @@ stoning
|
|||||||
stony
|
stony
|
||||||
stood
|
stood
|
||||||
stooge
|
stooge
|
||||||
stool
|
|
||||||
stoop
|
stoop
|
||||||
stoplight
|
stoplight
|
||||||
stoppable
|
stoppable
|
||||||
@ -6458,12 +6409,9 @@ subwoofer
|
|||||||
subzero
|
subzero
|
||||||
succulent
|
succulent
|
||||||
such
|
such
|
||||||
suction
|
|
||||||
sudden
|
sudden
|
||||||
sudoku
|
sudoku
|
||||||
suds
|
suds
|
||||||
sufferer
|
|
||||||
suffering
|
|
||||||
suffice
|
suffice
|
||||||
suffix
|
suffix
|
||||||
suffocate
|
suffocate
|
||||||
@ -6515,7 +6463,6 @@ surplus
|
|||||||
surprise
|
surprise
|
||||||
surreal
|
surreal
|
||||||
surrender
|
surrender
|
||||||
surrogate
|
|
||||||
surround
|
surround
|
||||||
survey
|
survey
|
||||||
survival
|
survival
|
||||||
@ -6528,7 +6475,6 @@ suspend
|
|||||||
suspense
|
suspense
|
||||||
sustained
|
sustained
|
||||||
sustainer
|
sustainer
|
||||||
swab
|
|
||||||
swaddling
|
swaddling
|
||||||
swagger
|
swagger
|
||||||
swampland
|
swampland
|
||||||
@ -6536,7 +6482,6 @@ swan
|
|||||||
swapping
|
swapping
|
||||||
swarm
|
swarm
|
||||||
sway
|
sway
|
||||||
swear
|
|
||||||
sweat
|
sweat
|
||||||
sweep
|
sweep
|
||||||
swell
|
swell
|
||||||
@ -6605,9 +6550,6 @@ talcum
|
|||||||
talisman
|
talisman
|
||||||
tall
|
tall
|
||||||
talon
|
talon
|
||||||
tamale
|
|
||||||
tameness
|
|
||||||
tamer
|
|
||||||
tamper
|
tamper
|
||||||
tank
|
tank
|
||||||
tanned
|
tanned
|
||||||
@ -6647,7 +6589,6 @@ thaw
|
|||||||
theater
|
theater
|
||||||
theatrics
|
theatrics
|
||||||
thee
|
thee
|
||||||
theft
|
|
||||||
theme
|
theme
|
||||||
theology
|
theology
|
||||||
theorize
|
theorize
|
||||||
@ -6752,7 +6693,6 @@ trade
|
|||||||
trading
|
trading
|
||||||
tradition
|
tradition
|
||||||
traffic
|
traffic
|
||||||
tragedy
|
|
||||||
trailing
|
trailing
|
||||||
trailside
|
trailside
|
||||||
train
|
train
|
||||||
@ -6772,7 +6712,6 @@ trapped
|
|||||||
trapper
|
trapper
|
||||||
trapping
|
trapping
|
||||||
traps
|
traps
|
||||||
trash
|
|
||||||
travel
|
travel
|
||||||
traverse
|
traverse
|
||||||
travesty
|
travesty
|
||||||
|
@ -0,0 +1,28 @@
|
|||||||
|
"""add retry count to sync event
|
||||||
|
|
||||||
|
Revision ID: 56d08955fcab
|
||||||
|
Revises: d608b8e48082
|
||||||
|
Create Date: 2024-07-19 08:21:19.979973
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '56d08955fcab'
|
||||||
|
down_revision = 'd608b8e48082'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('sync_event', sa.Column('retry_count', sa.Integer(), server_default='0', nullable=False, default=0))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('sync_event', 'retry_count')
|
||||||
|
# ### end Alembic commands ###
|
42
app/migrations/versions/2024_073011_1c14339aae90_.py
Normal file
42
app/migrations/versions/2024_073011_1c14339aae90_.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 1c14339aae90
|
||||||
|
Revises: 56d08955fcab
|
||||||
|
Create Date: 2024-07-30 11:46:32.460221
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1c14339aae90'
|
||||||
|
down_revision = '56d08955fcab'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('mailbox_activation',
|
||||||
|
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('mailbox_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('tries', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['mailbox_id'], ['mailbox.id'], ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_mailbox_activation_code'), 'mailbox_activation', ['code'], unique=False)
|
||||||
|
op.create_index(op.f('ix_mailbox_activation_mailbox_id'), 'mailbox_activation', ['mailbox_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_mailbox_activation_mailbox_id'), table_name='mailbox_activation')
|
||||||
|
op.drop_index(op.f('ix_mailbox_activation_code'), table_name='mailbox_activation')
|
||||||
|
op.drop_table('mailbox_activation')
|
||||||
|
# ### end Alembic commands ###
|
@ -0,0 +1,30 @@
|
|||||||
|
"""Custom Domain partner id
|
||||||
|
|
||||||
|
Revision ID: 2441b7ff5da9
|
||||||
|
Revises: 1c14339aae90
|
||||||
|
Create Date: 2024-09-13 15:43:02.425964
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2441b7ff5da9'
|
||||||
|
down_revision = '1c14339aae90'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('custom_domain', sa.Column('partner_id', sa.Integer(), nullable=True, default=None, server_default=None))
|
||||||
|
op.create_foreign_key(None, 'custom_domain', 'partner', ['partner_id'], ['id'])
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint(None, 'custom_domain', type_='foreignkey')
|
||||||
|
op.drop_column('custom_domain', 'partner_id')
|
||||||
|
# ### end Alembic commands ###
|
@ -0,0 +1,31 @@
|
|||||||
|
"""contact.flags and custom_domain.pending_deletion
|
||||||
|
|
||||||
|
Revision ID: 88dd7a0abf54
|
||||||
|
Revises: 2441b7ff5da9
|
||||||
|
Create Date: 2024-09-19 15:41:20.910374
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '88dd7a0abf54'
|
||||||
|
down_revision = '2441b7ff5da9'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('contact', sa.Column('flags', sa.Integer(), server_default='0', nullable=False))
|
||||||
|
op.add_column('custom_domain', sa.Column('pending_deletion', sa.Boolean(), server_default='0', nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('custom_domain', 'pending_deletion')
|
||||||
|
op.drop_column('contact', 'flags')
|
||||||
|
# ### end Alembic commands ###
|
@ -125,6 +125,21 @@ def log_events_pending_dead_letter():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@newrelic.agent.background_task()
|
||||||
|
def log_failed_events():
|
||||||
|
r = Session.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM sync_event
|
||||||
|
WHERE retry_count >= 10;
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
failed_events = list(r)[0][0]
|
||||||
|
|
||||||
|
LOG.d("number of failed events %s", failed_events)
|
||||||
|
newrelic.agent.record_custom_metric("Custom/sync_events_failed", failed_events)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
exporter = MetricExporter(get_newrelic_license())
|
exporter = MetricExporter(get_newrelic_license())
|
||||||
while True:
|
while True:
|
||||||
@ -132,6 +147,7 @@ if __name__ == "__main__":
|
|||||||
log_nb_db_connection()
|
log_nb_db_connection()
|
||||||
log_pending_to_process_events()
|
log_pending_to_process_events()
|
||||||
log_events_pending_dead_letter()
|
log_events_pending_dead_letter()
|
||||||
|
log_failed_events()
|
||||||
Session.close()
|
Session.close()
|
||||||
|
|
||||||
exporter.run()
|
exporter.run()
|
||||||
|
49
app/oneshot/alias_partner_set_flag_and_clear_note.py
Normal file
49
app/oneshot/alias_partner_set_flag_and_clear_note.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.models import Alias
|
||||||
|
from app.db import Session
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="Backfill alias", description="Update alias notes and backfill flag"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-s", "--start_alias_id", default=0, type=int, help="Initial alias_id"
|
||||||
|
)
|
||||||
|
parser.add_argument("-e", "--end_alias_id", default=0, type=int, help="Last alias_id")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
alias_id_start = args.start_alias_id
|
||||||
|
max_alias_id = args.end_alias_id
|
||||||
|
if max_alias_id == 0:
|
||||||
|
max_alias_id = Session.query(func.max(Alias.id)).scalar()
|
||||||
|
|
||||||
|
print(f"Checking alias {alias_id_start} to {max_alias_id}")
|
||||||
|
step = 1000
|
||||||
|
noteSql = "(note = 'Created through Proton' or note = 'Created through partner Proton')"
|
||||||
|
alias_query = f"UPDATE alias set note = NULL, flags = flags | :flag where id>=:start AND id<:end and {noteSql}"
|
||||||
|
updated = 0
|
||||||
|
start_time = time.time()
|
||||||
|
for batch_start in range(alias_id_start, max_alias_id, step):
|
||||||
|
rows_done = Session.execute(
|
||||||
|
alias_query,
|
||||||
|
{
|
||||||
|
"start": batch_start,
|
||||||
|
"end": batch_start + step,
|
||||||
|
"flag": Alias.FLAG_PARTNER_CREATED,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
updated += rows_done.rowcount
|
||||||
|
Session.commit()
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
time_per_alias = elapsed / (updated + 1)
|
||||||
|
last_batch_id = batch_start + step
|
||||||
|
remaining = max_alias_id - last_batch_id
|
||||||
|
time_remaining = (max_alias_id - last_batch_id) * time_per_alias
|
||||||
|
hours_remaining = time_remaining / 3600.0
|
||||||
|
print(
|
||||||
|
f"\rAlias {batch_start}/{max_alias_id} {updated} {hours_remaining:.2f}hrs remaining"
|
||||||
|
)
|
||||||
|
print("")
|
392
app/poetry.lock
generated
392
app/poetry.lock
generated
@ -1,4 +1,4 @@
|
|||||||
# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand.
|
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohttp"
|
name = "aiohttp"
|
||||||
@ -276,21 +276,6 @@ files = [
|
|||||||
{file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"},
|
{file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "backports.entry-points-selectable"
|
|
||||||
version = "1.1.1"
|
|
||||||
description = "Compatibility shim providing selectable entry points for older implementations"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7"
|
|
||||||
files = [
|
|
||||||
{file = "backports.entry_points_selectable-1.1.1-py2.py3-none-any.whl", hash = "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b"},
|
|
||||||
{file = "backports.entry_points_selectable-1.1.1.tar.gz", hash = "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"]
|
|
||||||
testing = ["pytest", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "bcrypt"
|
||||||
version = "3.2.0"
|
version = "3.2.0"
|
||||||
@ -491,13 +476,13 @@ pycparser = "*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfgv"
|
name = "cfgv"
|
||||||
version = "3.2.0"
|
version = "3.4.0"
|
||||||
description = "Validate configuration and produce human readable error messages."
|
description = "Validate configuration and produce human readable error messages."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6.1"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"},
|
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
|
||||||
{file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"},
|
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -690,6 +675,21 @@ sdist = ["setuptools-rust (>=0.11.4)"]
|
|||||||
ssh = ["bcrypt (>=3.1.5)"]
|
ssh = ["bcrypt (>=3.1.5)"]
|
||||||
test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"]
|
test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssbeautifier"
|
||||||
|
version = "1.15.1"
|
||||||
|
description = "CSS unobfuscator and beautifier."
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
files = [
|
||||||
|
{file = "cssbeautifier-1.15.1.tar.gz", hash = "sha256:9f7064362aedd559c55eeecf6b6bed65e05f33488dcbe39044f0403c26e1c006"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
editorconfig = ">=0.12.2"
|
||||||
|
jsbeautifier = "*"
|
||||||
|
six = ">=1.13.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decorator"
|
name = "decorator"
|
||||||
version = "4.4.2"
|
version = "4.4.2"
|
||||||
@ -734,41 +734,40 @@ graph = ["objgraph (>=1.7.2)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "distlib"
|
name = "distlib"
|
||||||
version = "0.3.1"
|
version = "0.3.8"
|
||||||
description = "Distribution utilities"
|
description = "Distribution utilities"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
files = [
|
files = [
|
||||||
{file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"},
|
{file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
|
||||||
{file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"},
|
{file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "djlint"
|
name = "djlint"
|
||||||
version = "1.3.0"
|
version = "1.34.1"
|
||||||
description = "HTML Template Linter and Formatter"
|
description = "HTML Template Linter and Formatter"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7,<4.0"
|
python-versions = ">=3.8.0,<4.0.0"
|
||||||
files = [
|
files = [
|
||||||
{file = "djlint-1.3.0-py3-none-any.whl", hash = "sha256:0c986bf542cdac3025d431a5b15e6c3977f652f2e76e408dbb5e7aaab6b73d99"},
|
{file = "djlint-1.34.1-py3-none-any.whl", hash = "sha256:96ff1c464fb6f061130ebc88663a2ea524d7ec51f4b56221a2b3f0320a3cfce8"},
|
||||||
{file = "djlint-1.3.0.tar.gz", hash = "sha256:b2d8e6c0a14f88da165296f0da05795d15299b7ab0a9093d670ce9ffd867bc79"},
|
{file = "djlint-1.34.1.tar.gz", hash = "sha256:db93fa008d19eaadb0454edf1704931d14469d48508daba2df9941111f408346"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
click = ">=8.0.1,<9.0.0"
|
click = ">=8.0.1,<9.0.0"
|
||||||
colorama = ">=0.4.4,<0.5.0"
|
colorama = ">=0.4.4,<0.5.0"
|
||||||
|
cssbeautifier = ">=1.14.4,<2.0.0"
|
||||||
html-tag-names = ">=0.1.2,<0.2.0"
|
html-tag-names = ">=0.1.2,<0.2.0"
|
||||||
html-void-elements = ">=0.1.0,<0.2.0"
|
html-void-elements = ">=0.1.0,<0.2.0"
|
||||||
importlib-metadata = ">=4.11.0,<5.0.0"
|
jsbeautifier = ">=1.14.4,<2.0.0"
|
||||||
pathspec = ">=0.9.0,<0.10.0"
|
json5 = ">=0.9.11,<0.10.0"
|
||||||
|
pathspec = ">=0.12.0,<0.13.0"
|
||||||
PyYAML = ">=6.0,<7.0"
|
PyYAML = ">=6.0,<7.0"
|
||||||
regex = ">=2022.1.18,<2023.0.0"
|
regex = ">=2023.0.0,<2024.0.0"
|
||||||
tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""}
|
tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""}
|
||||||
tqdm = ">=4.62.2,<5.0.0"
|
tqdm = ">=4.62.2,<5.0.0"
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
test = ["coverage (>=6.3.1,<7.0.0)", "pytest (>=7.0.1,<8.0.0)", "pytest-cov (>=3.0.0,<4.0.0)"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dkimpy"
|
name = "dkimpy"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@ -806,6 +805,16 @@ doh = ["requests", "requests-toolbelt"]
|
|||||||
idna = ["idna (>=2.1)"]
|
idna = ["idna (>=2.1)"]
|
||||||
trio = ["sniffio (>=1.1)", "trio (>=0.14.0)"]
|
trio = ["sniffio (>=1.1)", "trio (>=0.14.0)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "editorconfig"
|
||||||
|
version = "0.12.4"
|
||||||
|
description = "EditorConfig File Locator and Interpreter for Python"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
files = [
|
||||||
|
{file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "email-validator"
|
name = "email-validator"
|
||||||
version = "1.1.3"
|
version = "1.1.3"
|
||||||
@ -851,15 +860,20 @@ requests = "*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.0.12"
|
version = "3.15.4"
|
||||||
description = "A platform independent file lock."
|
description = "A platform independent file lock."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
|
{file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"},
|
||||||
{file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
|
{file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
|
||||||
|
testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"]
|
||||||
|
typing = ["typing-extensions (>=4.8)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flanker"
|
name = "flanker"
|
||||||
version = "0.9.11"
|
version = "0.9.11"
|
||||||
@ -1358,7 +1372,6 @@ files = [
|
|||||||
{file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"},
|
{file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"},
|
||||||
{file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"},
|
{file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"},
|
||||||
{file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"},
|
{file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"},
|
||||||
{file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c"},
|
|
||||||
{file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"},
|
{file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"},
|
||||||
{file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"},
|
{file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"},
|
||||||
{file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"},
|
{file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"},
|
||||||
@ -1367,7 +1380,6 @@ files = [
|
|||||||
{file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"},
|
{file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"},
|
||||||
{file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"},
|
{file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"},
|
||||||
{file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"},
|
{file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"},
|
||||||
{file = "greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47"},
|
|
||||||
{file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"},
|
{file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"},
|
||||||
{file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"},
|
{file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"},
|
||||||
{file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"},
|
{file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"},
|
||||||
@ -1397,7 +1409,6 @@ files = [
|
|||||||
{file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"},
|
{file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"},
|
||||||
{file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"},
|
{file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"},
|
||||||
{file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"},
|
{file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"},
|
||||||
{file = "greenlet-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1"},
|
|
||||||
{file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"},
|
{file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"},
|
||||||
{file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"},
|
{file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"},
|
||||||
{file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"},
|
{file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"},
|
||||||
@ -1406,7 +1417,6 @@ files = [
|
|||||||
{file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"},
|
{file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"},
|
||||||
{file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"},
|
{file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"},
|
||||||
{file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"},
|
{file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"},
|
||||||
{file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417"},
|
|
||||||
{file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"},
|
{file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"},
|
||||||
{file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"},
|
{file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"},
|
||||||
{file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"},
|
{file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"},
|
||||||
@ -1495,17 +1505,17 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""}
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "identify"
|
name = "identify"
|
||||||
version = "1.5.5"
|
version = "2.6.0"
|
||||||
description = "File identification library for Python"
|
description = "File identification library for Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "identify-1.5.5-py2.py3-none-any.whl", hash = "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d"},
|
{file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"},
|
||||||
{file = "identify-1.5.5.tar.gz", hash = "sha256:7c22c384a2c9b32c5cc891d13f923f6b2653aa83e2d75d8f79be240d6c86c4f4"},
|
{file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
license = ["editdistance"]
|
license = ["ukkonen"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
@ -1518,25 +1528,6 @@ files = [
|
|||||||
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
|
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "importlib-metadata"
|
|
||||||
version = "4.12.0"
|
|
||||||
description = "Read metadata from Python packages"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
files = [
|
|
||||||
{file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"},
|
|
||||||
{file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
zipp = ">=0.5"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"]
|
|
||||||
perf = ["ipython"]
|
|
||||||
testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@ -1669,6 +1660,31 @@ files = [
|
|||||||
{file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"},
|
{file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsbeautifier"
|
||||||
|
version = "1.15.1"
|
||||||
|
description = "JavaScript unobfuscator and beautifier."
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
files = [
|
||||||
|
{file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
editorconfig = ">=0.12.2"
|
||||||
|
six = ">=1.13.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "json5"
|
||||||
|
version = "0.9.25"
|
||||||
|
description = "A Python implementation of the JSON5 data format."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
files = [
|
||||||
|
{file = "json5-0.9.25-py3-none-any.whl", hash = "sha256:34ed7d834b1341a86987ed52f3f76cd8ee184394906b6e22a1e0deb9ab294e8f"},
|
||||||
|
{file = "json5-0.9.25.tar.gz", hash = "sha256:548e41b9be043f9426776f05df8635a00fe06104ea51ed24b67f908856e151ae"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jwcrypto"
|
name = "jwcrypto"
|
||||||
version = "0.8"
|
version = "0.8"
|
||||||
@ -1959,13 +1975,13 @@ urllib3 = ">=1.7,<2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nodeenv"
|
name = "nodeenv"
|
||||||
version = "1.5.0"
|
version = "1.9.1"
|
||||||
description = "Node.js virtual environment builder"
|
description = "Node.js virtual environment builder"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9"},
|
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
|
||||||
{file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"},
|
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2015,13 +2031,13 @@ testing = ["docopt", "pytest (>=3.0.7)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathspec"
|
name = "pathspec"
|
||||||
version = "0.9.0"
|
version = "0.12.1"
|
||||||
description = "Utility library for gitignore style pattern matching of file paths."
|
description = "Utility library for gitignore style pattern matching of file paths."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
|
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
|
||||||
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
|
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2117,13 +2133,13 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pre-commit"
|
name = "pre-commit"
|
||||||
version = "2.17.0"
|
version = "3.8.0"
|
||||||
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6.1"
|
python-versions = ">=3.9"
|
||||||
files = [
|
files = [
|
||||||
{file = "pre_commit-2.17.0-py2.py3-none-any.whl", hash = "sha256:725fa7459782d7bec5ead072810e47351de01709be838c2ce1726b9591dad616"},
|
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
|
||||||
{file = "pre_commit-2.17.0.tar.gz", hash = "sha256:c1a8040ff15ad3d648c70cc3e55b93e4d2d5b687320955505587fd79bbaed06a"},
|
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
@ -2131,8 +2147,7 @@ cfgv = ">=2.0.0"
|
|||||||
identify = ">=1.0.0"
|
identify = ">=1.0.0"
|
||||||
nodeenv = ">=0.11.1"
|
nodeenv = ">=0.11.1"
|
||||||
pyyaml = ">=5.1"
|
pyyaml = ">=5.1"
|
||||||
toml = "*"
|
virtualenv = ">=20.10.0"
|
||||||
virtualenv = ">=20.0.8"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prompt-toolkit"
|
name = "prompt-toolkit"
|
||||||
@ -2665,85 +2680,104 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "2022.6.2"
|
version = "2023.12.25"
|
||||||
description = "Alternative regular expression module, to replace re."
|
description = "Alternative regular expression module, to replace re."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "regex-2022.6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:042d122f9fee3ceb6d7e3067d56557df697d1aad4ff5f64ecce4dc13a90a7c01"},
|
{file = "regex-2023.12.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0694219a1d54336fd0445ea382d49d36882415c0134ee1e8332afd1529f0baa5"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffef4b30785dc2d1604dfb7cf9fca5dc27cd86d65f7c2a9ec34d6d3ae4565ec2"},
|
{file = "regex-2023.12.25-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b014333bd0217ad3d54c143de9d4b9a3ca1c5a29a6d0d554952ea071cff0f1f8"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0afa6a601acf3c0dc6de4e8d7d8bbce4e82f8542df746226cd35d4a6c15e9456"},
|
{file = "regex-2023.12.25-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d865984b3f71f6d0af64d0d88f5733521698f6c16f445bb09ce746c92c97c586"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a11cbe8eb5fb332ae474895b5ead99392a4ea568bd2a258ab8df883e9c2bf92"},
|
{file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e0eabac536b4cc7f57a5f3d095bfa557860ab912f25965e08fe1545e2ed8b4c"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c1f62ee2ba880e221bc950651a1a4b0176083d70a066c83a50ef0cb9b178e12"},
|
{file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c25a8ad70e716f96e13a637802813f65d8a6760ef48672aa3502f4c24ea8b400"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aba3d13c77173e9bfed2c2cea7fc319f11c89a36fcec08755e8fb169cf3b0df"},
|
{file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9b6d73353f777630626f403b0652055ebfe8ff142a44ec2cf18ae470395766e"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:249437f7f5b233792234aeeecb14b0aab1566280de42dfc97c26e6f718297d68"},
|
{file = "regex-2023.12.25-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9cc99d6946d750eb75827cb53c4371b8b0fe89c733a94b1573c9dd16ea6c9e4"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:179410c79fa86ef318d58ace233f95b87b05a1db6dc493fa29404a43f4b215e2"},
|
{file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88d1f7bef20c721359d8675f7d9f8e414ec5003d8f642fdfd8087777ff7f94b5"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5e201b1232d81ca1a7a22ab2f08e1eccad4e111579fd7f3bbf60b21ef4a16cea"},
|
{file = "regex-2023.12.25-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cb3fe77aec8f1995611f966d0c656fdce398317f850d0e6e7aebdfe61f40e1cd"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fdecb225d0f1d50d4b26ac423e0032e76d46a788b83b4e299a520717a47d968c"},
|
{file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7aa47c2e9ea33a4a2a05f40fcd3ea36d73853a2aae7b4feab6fc85f8bf2c9704"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:be57f9c7b0b423c66c266a26ad143b2c5514997c05dd32ce7ca95c8b209c2288"},
|
{file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:df26481f0c7a3f8739fecb3e81bc9da3fcfae34d6c094563b9d4670b047312e1"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ed657a07d8a47ef447224ea00478f1c7095065dfe70a89e7280e5f50a5725131"},
|
{file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c40281f7d70baf6e0db0c2f7472b31609f5bc2748fe7275ea65a0b4601d9b392"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24908aefed23dd065b4a668c0b4ca04d56b7f09d8c8e89636cf6c24e64e67a1e"},
|
{file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d94a1db462d5690ebf6ae86d11c5e420042b9898af5dcf278bd97d6bda065423"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-win32.whl", hash = "sha256:775694cd0bb2c4accf2f1cdd007381b33ec8b59842736fe61bdbad45f2ac7427"},
|
{file = "regex-2023.12.25-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ba1b30765a55acf15dce3f364e4928b80858fa8f979ad41f862358939bdd1f2f"},
|
||||||
{file = "regex-2022.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:809bbbbbcf8258049b031d80932ba71627d2274029386f0452e9950bcfa2c6e8"},
|
{file = "regex-2023.12.25-cp310-cp310-win32.whl", hash = "sha256:150c39f5b964e4d7dba46a7962a088fbc91f06e606f023ce57bb347a3b2d4630"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2b5d983eb0adf2049d41f95205bdc3de4e6cc2350e9c80d4409d3a75229de"},
|
{file = "regex-2023.12.25-cp310-cp310-win_amd64.whl", hash = "sha256:09da66917262d9481c719599116c7dc0c321ffcec4b1f510c4f8a066f8768105"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f4c101746a8dac0401abefa716b357c546e61ea2e3d4a564a9db9eac57ccbce"},
|
{file = "regex-2023.12.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b9d811f72210fa9306aeb88385b8f8bcef0dfbf3873410413c00aa94c56c2b6"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:166ae7674d0a0e0f8044e7335ba86d0716c9d49465cff1b153f908e0470b8300"},
|
{file = "regex-2023.12.25-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d902a43085a308cef32c0d3aea962524b725403fd9373dea18110904003bac97"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c5eac5d8a8ac9ccf00805d02a968a36f5c967db6c7d2b747ab9ed782b3b3a28b"},
|
{file = "regex-2023.12.25-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d166eafc19f4718df38887b2bbe1467a4f74a9830e8605089ea7a30dd4da8887"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f57823f35b18d82b201c1b27ce4e55f88e79e81d9ca07b50ce625d33823e1439"},
|
{file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7ad32824b7f02bb3c9f80306d405a1d9b7bb89362d68b3c5a9be53836caebdb"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4d42e3b7b23473729adbf76103e7df75f9167a5a80b1257ca30688352b4bb2dc"},
|
{file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:636ba0a77de609d6510235b7f0e77ec494d2657108f777e8765efc060094c98c"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b2932e728bee0a634fe55ee54d598054a5a9ffe4cd2be21ba2b4b8e5f8064c2c"},
|
{file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fda75704357805eb953a3ee15a2b240694a9a514548cd49b3c5124b4e2ad01b"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:17764683ea01c2b8f103d99ae9de2473a74340df13ce306c49a721f0b1f0eb9e"},
|
{file = "regex-2023.12.25-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f72cbae7f6b01591f90814250e636065850c5926751af02bb48da94dfced7baa"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:2ac29b834100d2c171085ceba0d4a1e7046c434ddffc1434dbc7f9d59af1e945"},
|
{file = "regex-2023.12.25-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2a0b1857f18b11e3b0e54ddfefc96af46b0896fb678c85f63fb8c37518b3e7"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:f43522fb5d676c99282ca4e2d41e8e2388427c0cf703db6b4a66e49b10b699a8"},
|
{file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7502534e55c7c36c0978c91ba6f61703faf7ce733715ca48f499d3dbbd7657e0"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:9faa01818dad9111dbf2af26c6e3c45140ccbd1192c3a0981f196255bf7ec5e6"},
|
{file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e8c7e08bb566de4faaf11984af13f6bcf6a08f327b13631d41d62592681d24fe"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:17443f99b8f255273731f915fdbfea4d78d809bb9c3aaf67b889039825d06515"},
|
{file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:283fc8eed679758de38fe493b7d7d84a198b558942b03f017b1f94dda8efae80"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-win32.whl", hash = "sha256:4a5449adef907919d4ce7a1eab2e27d0211d1b255bf0b8f5dd330ad8707e0fc3"},
|
{file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f44dd4d68697559d007462b0a3a1d9acd61d97072b71f6d1968daef26bc744bd"},
|
||||||
{file = "regex-2022.6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:4d206703a96a39763b5b45cf42645776f5553768ea7f3c2c1a39a4f59cafd4ba"},
|
{file = "regex-2023.12.25-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:67d3ccfc590e5e7197750fcb3a2915b416a53e2de847a728cfa60141054123d4"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fcd7c432202bcb8b642c3f43d5bcafc5930d82fe5b2bf2c008162df258445c1d"},
|
{file = "regex-2023.12.25-cp311-cp311-win32.whl", hash = "sha256:68191f80a9bad283432385961d9efe09d783bcd36ed35a60fb1ff3f1ec2efe87"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:186c5a4a4c40621f64d771038ede20fca6c61a9faa8178f9e305aaa0c2442a97"},
|
{file = "regex-2023.12.25-cp311-cp311-win_amd64.whl", hash = "sha256:7d2af3f6b8419661a0c421584cfe8aaec1c0e435ce7e47ee2a97e344b98f794f"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:047b2d1323a51190c01b6604f49fe09682a5c85d3c1b2c8b67c1cd68419ce3c4"},
|
{file = "regex-2023.12.25-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8a0ccf52bb37d1a700375a6b395bff5dd15c50acb745f7db30415bae3c2b0715"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30637e7fa4acfed444525b1ab9683f714be617862820578c9fd4e944d4d9ad1f"},
|
{file = "regex-2023.12.25-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c3c4a78615b7762740531c27cf46e2f388d8d727d0c0c739e72048beb26c8a9d"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3adafe6f2c6d86dbf3313866b61180530ca4dcd0c264932dc8fa1ffb10871d58"},
|
{file = "regex-2023.12.25-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ad83e7545b4ab69216cef4cc47e344d19622e28aabec61574b20257c65466d6a"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:67ae3601edf86e15ebe40885e5bfdd6002d34879070be15cf18fc0d80ea24fed"},
|
{file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7a635871143661feccce3979e1727c4e094f2bdfd3ec4b90dfd4f16f571a87a"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:48dddddce0ea7e7c3e92c1e0c5a28c13ca4dc9cf7e996c706d00479652bff76c"},
|
{file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d498eea3f581fbe1b34b59c697512a8baef88212f92e4c7830fcc1499f5b45a5"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:68e5c641645351eb9eb12c465876e76b53717f99e9b92aea7a2dd645a87aa7aa"},
|
{file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f7cd5754d02a56ae4ebb91b33461dc67be8e3e0153f593c509e21d219c5060"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8fd5f8ae42f789538bb634bdfd69b9aa357e76fdfd7ad720f32f8994c0d84f1e"},
|
{file = "regex-2023.12.25-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51f4b32f793812714fd5307222a7f77e739b9bc566dc94a18126aba3b92b98a3"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:71988a76fcb68cc091e901fddbcac0f9ad9a475da222c47d3cf8db0876cb5344"},
|
{file = "regex-2023.12.25-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba99d8077424501b9616b43a2d208095746fb1284fc5ba490139651f971d39d9"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:4b8838f70be3ce9e706df9d72f88a0aa7d4c1fea61488e06fdf292ccb70ad2be"},
|
{file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4bfc2b16e3ba8850e0e262467275dd4d62f0d045e0e9eda2bc65078c0110a11f"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:663dca677bd3d2e2b5b7d0329e9f24247e6f38f3b740dd9a778a8ef41a76af41"},
|
{file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8c2c19dae8a3eb0ea45a8448356ed561be843b13cbc34b840922ddf565498c1c"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-win32.whl", hash = "sha256:24963f0b13cc63db336d8da2a533986419890d128c551baacd934c249d51a779"},
|
{file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:60080bb3d8617d96f0fb7e19796384cc2467447ef1c491694850ebd3670bc457"},
|
||||||
{file = "regex-2022.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:ceff75127f828dfe7ceb17b94113ec2df4df274c4cd5533bb299cb099a18a8ca"},
|
{file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b77e27b79448e34c2c51c09836033056a0547aa360c45eeeb67803da7b0eedaf"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a6f2698cfa8340dfe4c0597782776b393ba2274fe4c079900c7c74f68752705"},
|
{file = "regex-2023.12.25-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:518440c991f514331f4850a63560321f833979d145d7d81186dbe2f19e27ae3d"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8a08ace913c4101f0dc0be605c108a3761842efd5f41a3005565ee5d169fb2b"},
|
{file = "regex-2023.12.25-cp312-cp312-win32.whl", hash = "sha256:e2610e9406d3b0073636a3a2e80db05a02f0c3169b5632022b4e81c0364bcda5"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26dbe90b724efef7820c3cf4a0e5be7f130149f3d2762782e4e8ac2aea284a0b"},
|
{file = "regex-2023.12.25-cp312-cp312-win_amd64.whl", hash = "sha256:cc37b9aeebab425f11f27e5e9e6cf580be7206c6582a64467a14dda211abc232"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5f759a1726b995dc896e86f17f9c0582b54eb4ead00ed5ef0b5b22260eaf2d0"},
|
{file = "regex-2023.12.25-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:da695d75ac97cb1cd725adac136d25ca687da4536154cdc2815f576e4da11c69"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1fc26bb3415e7aa7495c000a2c13bf08ce037775db98c1a3fac9ff04478b6930"},
|
{file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d126361607b33c4eb7b36debc173bf25d7805847346dd4d99b5499e1fef52bc7"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52684da32d9003367dc1a1c07e059b9bbaf135ad0764cd47d8ac3dba2df109bc"},
|
{file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4719bb05094d7d8563a450cf8738d2e1061420f79cfcc1fa7f0a44744c4d8f73"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c1264eb40a71cf2bff43d6694ab7254438ca19ef330175060262b3c8dd3931a"},
|
{file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5dd58946bce44b53b06d94aa95560d0b243eb2fe64227cba50017a8d8b3cd3e2"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bc635ab319c9b515236bdf327530acda99be995f9d3b9f148ab1f60b2431e970"},
|
{file = "regex-2023.12.25-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22a86d9fff2009302c440b9d799ef2fe322416d2d58fc124b926aa89365ec482"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:27624b490b5d8880f25dac67e1e2ea93dfef5300b98c6755f585799230d6c746"},
|
{file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aae8101919e8aa05ecfe6322b278f41ce2994c4a430303c4cd163fef746e04f"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:555f7596fd1f123f8c3a67974c01d6ef80b9769e04d660d6c1a7cc3e6cff7069"},
|
{file = "regex-2023.12.25-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e692296c4cc2873967771345a876bcfc1c547e8dd695c6b89342488b0ea55cd8"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:933e72fbe1829cbd59da2bc51ccd73d73162f087f88521a87a8ec9cb0cf10fa8"},
|
{file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:263ef5cc10979837f243950637fffb06e8daed7f1ac1e39d5910fd29929e489a"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:cff5c87e941292c97d11dc81bd20679f56a2830f0f0e32f75b8ed6e0eb40f704"},
|
{file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:d6f7e255e5fa94642a0724e35406e6cb7001c09d476ab5fce002f652b36d0c39"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c757f3a27b6345de13ef3ca956aa805d7734ce68023e84d0fc74e1f09ce66f7a"},
|
{file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:88ad44e220e22b63b0f8f81f007e8abbb92874d8ced66f32571ef8beb0643b2b"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-win32.whl", hash = "sha256:a58d21dd1a2d6b50ed091554ff85e448fce3fe33a4db8b55d0eba2ca957ed626"},
|
{file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:3a17d3ede18f9cedcbe23d2daa8a2cd6f59fe2bf082c567e43083bba3fb00347"},
|
||||||
{file = "regex-2022.6.2-cp38-cp38-win_amd64.whl", hash = "sha256:495a4165172848503303ed05c9d0409428f789acc27050fe2cf0a4549188a7d5"},
|
{file = "regex-2023.12.25-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d15b274f9e15b1a0b7a45d2ac86d1f634d983ca40d6b886721626c47a400bf39"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1ab5cf7d09515548044e69d3a0ec77c63d7b9dfff4afc19653f638b992573126"},
|
{file = "regex-2023.12.25-cp37-cp37m-win32.whl", hash = "sha256:ed19b3a05ae0c97dd8f75a5d8f21f7723a8c33bbc555da6bbe1f96c470139d3c"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c1ea28f0ee6cbe4c0367c939b015d915aa9875f6e061ba1cf0796ca9a3010570"},
|
{file = "regex-2023.12.25-cp37-cp37m-win_amd64.whl", hash = "sha256:a6d1047952c0b8104a1d371f88f4ab62e6275567d4458c1e26e9627ad489b445"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3de1ecf26ce85521bf73897828b6d0687cc6cf271fb6ff32ac63d26b21f5e764"},
|
{file = "regex-2023.12.25-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b43523d7bc2abd757119dbfb38af91b5735eea45537ec6ec3a5ec3f9562a1c53"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa7c7044aabdad2329974be2246babcc21d3ede852b3971a90fd8c2056c20360"},
|
{file = "regex-2023.12.25-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:efb2d82f33b2212898f1659fb1c2e9ac30493ac41e4d53123da374c3b5541e64"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53d69d77e9cfe468b000314dd656be85bb9e96de088a64f75fe128dfe1bf30dd"},
|
{file = "regex-2023.12.25-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b7fca9205b59c1a3d5031f7e64ed627a1074730a51c2a80e97653e3e9fa0d415"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c8d61883a38b1289fba9944a19a361875b5c0170b83cdcc95ea180247c1b7d3"},
|
{file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086dd15e9435b393ae06f96ab69ab2d333f5d65cbe65ca5a3ef0ec9564dfe770"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5429202bef174a3760690d912e3a80060b323199a61cef6c6c29b30ce09fd17"},
|
{file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e81469f7d01efed9b53740aedd26085f20d49da65f9c1f41e822a33992cb1590"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e85b10280cf1e334a7c95629f6cbbfe30b815a4ea5f1e28d31f79eb92c2c3d93"},
|
{file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34e4af5b27232f68042aa40a91c3b9bb4da0eeb31b7632e0091afc4310afe6cb"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c400dfed4137f32127ea4063447006d7153c974c680bf0fb1b724cce9f8567fc"},
|
{file = "regex-2023.12.25-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9852b76ab558e45b20bf1893b59af64a28bd3820b0c2efc80e0a70a4a3ea51c1"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7f648037c503985aed39f85088acab6f1eb6a0482d7c6c665a5712c9ad9eaefc"},
|
{file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff100b203092af77d1a5a7abe085b3506b7eaaf9abf65b73b7d6905b6cb76988"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e7b2ff451f6c305b516281ec45425dd423223c8063218c5310d6f72a0a7a517c"},
|
{file = "regex-2023.12.25-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cc038b2d8b1470364b1888a98fd22d616fba2b6309c5b5f181ad4483e0017861"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:be456b4313a86be41706319c397c09d9fdd2e5cdfde208292a277b867e99e3d1"},
|
{file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:094ba386bb5c01e54e14434d4caabf6583334090865b23ef58e0424a6286d3dc"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c3db393b21b53d7e1d3f881b64c29d886cbfdd3df007e31de68b329edbab7d02"},
|
{file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5cd05d0f57846d8ba4b71d9c00f6f37d6b97d5e5ef8b3c3840426a475c8f70f4"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-win32.whl", hash = "sha256:d70596f20a03cb5f935d6e4aad9170a490d88fc4633679bf00c652e9def4619e"},
|
{file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:9aa1a67bbf0f957bbe096375887b2505f5d8ae16bf04488e8b0f334c36e31360"},
|
||||||
{file = "regex-2022.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:3b9b6289e03dbe6a6096880d8ac166cb23c38b4896ad235edee789d4e8697152"},
|
{file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:98a2636994f943b871786c9e82bfe7883ecdaba2ef5df54e1450fa9869d1f756"},
|
||||||
{file = "regex-2022.6.2.tar.gz", hash = "sha256:f7b43acb2c46fb2cd506965b2d9cf4c5e64c9c612bac26c1187933c7296bf08c"},
|
{file = "regex-2023.12.25-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:37f8e93a81fc5e5bd8db7e10e62dc64261bcd88f8d7e6640aaebe9bc180d9ce2"},
|
||||||
|
{file = "regex-2023.12.25-cp38-cp38-win32.whl", hash = "sha256:d78bd484930c1da2b9679290a41cdb25cc127d783768a0369d6b449e72f88beb"},
|
||||||
|
{file = "regex-2023.12.25-cp38-cp38-win_amd64.whl", hash = "sha256:b521dcecebc5b978b447f0f69b5b7f3840eac454862270406a39837ffae4e697"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f7bc09bc9c29ebead055bcba136a67378f03d66bf359e87d0f7c759d6d4ffa31"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e14b73607d6231f3cc4622809c196b540a6a44e903bcfad940779c80dffa7be7"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9eda5f7a50141291beda3edd00abc2d4a5b16c29c92daf8d5bd76934150f3edc"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc6bb9aa69aacf0f6032c307da718f61a40cf970849e471254e0e91c56ffca95"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:298dc6354d414bc921581be85695d18912bea163a8b23cac9a2562bbcd5088b1"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f4e475a80ecbd15896a976aa0b386c5525d0ed34d5c600b6d3ebac0a67c7ddf"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:531ac6cf22b53e0696f8e1d56ce2396311254eb806111ddd3922c9d937151dae"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22f3470f7524b6da61e2020672df2f3063676aff444db1daa283c2ea4ed259d6"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:89723d2112697feaa320c9d351e5f5e7b841e83f8b143dba8e2d2b5f04e10923"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0ecf44ddf9171cd7566ef1768047f6e66975788258b1c6c6ca78098b95cf9a3d"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:905466ad1702ed4acfd67a902af50b8db1feeb9781436372261808df7a2a7bca"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:4558410b7a5607a645e9804a3e9dd509af12fb72b9825b13791a37cd417d73a5"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:7e316026cc1095f2a3e8cc012822c99f413b702eaa2ca5408a513609488cb62f"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3b1de218d5375cd6ac4b5493e0b9f3df2be331e86520f23382f216c137913d20"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-win32.whl", hash = "sha256:11a963f8e25ab5c61348d090bf1b07f1953929c13bd2309a0662e9ff680763c9"},
|
||||||
|
{file = "regex-2023.12.25-cp39-cp39-win_amd64.whl", hash = "sha256:e693e233ac92ba83a87024e1d32b5f9ab15ca55ddd916d878146f4e3406b5c91"},
|
||||||
|
{file = "regex-2023.12.25.tar.gz", hash = "sha256:29171aa128da69afdf4bde412d5bedc335f2ca8fcfe4489038577d05f16181e5"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -3130,17 +3164,6 @@ idna = "*"
|
|||||||
requests = ">=2.1.0"
|
requests = ">=2.1.0"
|
||||||
requests-file = ">=1.4"
|
requests-file = ">=1.4"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml"
|
|
||||||
version = "0.10.1"
|
|
||||||
description = "Python Library for Tom's Obvious, Minimal Language"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
files = [
|
|
||||||
{file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
|
|
||||||
{file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tomli"
|
name = "tomli"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@ -3288,25 +3311,23 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "virtualenv"
|
name = "virtualenv"
|
||||||
version = "20.8.1"
|
version = "20.21.1"
|
||||||
description = "Virtual Python Environment builder"
|
description = "Virtual Python Environment builder"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "virtualenv-20.8.1-py2.py3-none-any.whl", hash = "sha256:10062e34c204b5e4ec5f62e6ef2473f8ba76513a9a617e873f1f8fb4a519d300"},
|
{file = "virtualenv-20.21.1-py3-none-any.whl", hash = "sha256:09ddbe1af0c8ed2bb4d6ed226b9e6415718ad18aef9fa0ba023d96b7a8356049"},
|
||||||
{file = "virtualenv-20.8.1.tar.gz", hash = "sha256:bcc17f0b3a29670dd777d6f0755a4c04f28815395bca279cdcb213b97199a6b8"},
|
{file = "virtualenv-20.21.1.tar.gz", hash = "sha256:4c104ccde994f8b108163cf9ba58f3d11511d9403de87fb9b4f52bf33dbc8668"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
"backports.entry-points-selectable" = ">=1.0.4"
|
distlib = ">=0.3.6,<1"
|
||||||
distlib = ">=0.3.1,<1"
|
filelock = ">=3.4.1,<4"
|
||||||
filelock = ">=3.0.0,<4"
|
platformdirs = ">=2.4,<4"
|
||||||
platformdirs = ">=2,<3"
|
|
||||||
six = ">=1.9.0,<2"
|
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
|
docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"]
|
||||||
testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)"]
|
test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "watchtower"
|
name = "watchtower"
|
||||||
@ -3605,21 +3626,6 @@ files = [
|
|||||||
idna = ">=2.0"
|
idna = ">=2.0"
|
||||||
multidict = ">=4.0"
|
multidict = ">=4.0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zipp"
|
|
||||||
version = "3.2.0"
|
|
||||||
description = "Backport of pathlib-compatible object wrapper for zip files"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
files = [
|
|
||||||
{file = "zipp-3.2.0-py3-none-any.whl", hash = "sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6"},
|
|
||||||
{file = "zipp-3.2.0.tar.gz", hash = "sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"]
|
|
||||||
testing = ["func-timeout", "jaraco.itertools", "jaraco.test (>=3.2.0)", "pytest (>=3.5,!=3.7.3)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-flake8", "pytest-mypy"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zope.event"
|
name = "zope.event"
|
||||||
version = "4.5.0"
|
version = "4.5.0"
|
||||||
@ -3698,4 +3704,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.10"
|
python-versions = "^3.10"
|
||||||
content-hash = "01afc410d21eeac0a0ac7e8ef6eeb0a991cf4bc091c3351049263462e205ff63"
|
content-hash = "22b9a61e9999a215aacb889b3790ee1a6840ce249aea2e3d16c6113243d5c126"
|
||||||
|
@ -10,21 +10,23 @@ message UserDeleted {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message AliasCreated {
|
message AliasCreated {
|
||||||
uint32 alias_id = 1;
|
uint32 id = 1;
|
||||||
string alias_email = 2;
|
string email = 2;
|
||||||
string alias_note = 3;
|
string note = 3;
|
||||||
bool enabled = 4;
|
bool enabled = 4;
|
||||||
|
uint32 created_at = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AliasStatusChanged {
|
message AliasStatusChanged {
|
||||||
uint32 alias_id = 1;
|
uint32 id = 1;
|
||||||
string alias_email = 2;
|
string email = 2;
|
||||||
bool enabled = 3;
|
bool enabled = 3;
|
||||||
|
uint32 created_at = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AliasDeleted {
|
message AliasDeleted {
|
||||||
uint32 alias_id = 1;
|
uint32 id = 1;
|
||||||
string alias_email = 2;
|
string email = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AliasCreatedList {
|
message AliasCreatedList {
|
||||||
|
@ -121,13 +121,13 @@ aiospamc = "0.10"
|
|||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^7.0.0"
|
pytest = "^7.0.0"
|
||||||
pytest-cov = "^3.0.0"
|
pytest-cov = "^3.0.0"
|
||||||
pre-commit = "^2.17.0"
|
|
||||||
black = "^22.1.0"
|
black = "^22.1.0"
|
||||||
djlint = "^1.3.0"
|
djlint = "^1.3.0"
|
||||||
pylint = "^2.14.4"
|
pylint = "^2.14.4"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
ruff = "^0.1.5"
|
ruff = "^0.1.5"
|
||||||
|
pre-commit = "^3.8.0"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry>=0.12"]
|
requires = ["poetry>=0.12"]
|
||||||
|
@ -45,6 +45,7 @@ from app.admin_model import (
|
|||||||
DailyMetricAdmin,
|
DailyMetricAdmin,
|
||||||
MetricAdmin,
|
MetricAdmin,
|
||||||
InvalidMailboxDomainAdmin,
|
InvalidMailboxDomainAdmin,
|
||||||
|
EmailSearchAdmin,
|
||||||
)
|
)
|
||||||
from app.api.base import api_bp
|
from app.api.base import api_bp
|
||||||
from app.auth.base import auth_bp
|
from app.auth.base import auth_bp
|
||||||
@ -200,7 +201,7 @@ def create_app() -> Flask:
|
|||||||
"username": "admin",
|
"username": "admin",
|
||||||
"password": FLASK_PROFILER_PASSWORD,
|
"password": FLASK_PROFILER_PASSWORD,
|
||||||
},
|
},
|
||||||
"ignore": ["^/static/.*", "/git", "/exception"],
|
"ignore": ["^/static/.*", "/git", "/exception", "/health"],
|
||||||
}
|
}
|
||||||
flask_profiler.init_app(app)
|
flask_profiler.init_app(app)
|
||||||
|
|
||||||
@ -218,6 +219,10 @@ def create_app() -> Flask:
|
|||||||
def cleanup(resp_or_exc):
|
def cleanup(resp_or_exc):
|
||||||
Session.remove()
|
Session.remove()
|
||||||
|
|
||||||
|
@app.route("/health", methods=["GET"])
|
||||||
|
def healthcheck():
|
||||||
|
return "success", 200
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@ -282,7 +287,9 @@ def set_index_page(app):
|
|||||||
and not request.path.startswith("/_debug_toolbar")
|
and not request.path.startswith("/_debug_toolbar")
|
||||||
and not request.path.startswith("/git")
|
and not request.path.startswith("/git")
|
||||||
and not request.path.startswith("/favicon.ico")
|
and not request.path.startswith("/favicon.ico")
|
||||||
|
and not request.path.startswith("/health")
|
||||||
):
|
):
|
||||||
|
start_time = g.start_time or time.time()
|
||||||
LOG.d(
|
LOG.d(
|
||||||
"%s %s %s %s %s, takes %s",
|
"%s %s %s %s %s, takes %s",
|
||||||
request.remote_addr,
|
request.remote_addr,
|
||||||
@ -290,7 +297,7 @@ def set_index_page(app):
|
|||||||
request.path,
|
request.path,
|
||||||
request.args,
|
request.args,
|
||||||
res.status_code,
|
res.status_code,
|
||||||
time.time() - g.start_time,
|
time.time() - start_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
return res
|
return res
|
||||||
@ -780,6 +787,7 @@ def init_admin(app):
|
|||||||
admin.add_view(UserAdmin(User, Session))
|
admin.add_view(UserAdmin(User, Session))
|
||||||
admin.add_view(AliasAdmin(Alias, Session))
|
admin.add_view(AliasAdmin(Alias, Session))
|
||||||
admin.add_view(MailboxAdmin(Mailbox, Session))
|
admin.add_view(MailboxAdmin(Mailbox, Session))
|
||||||
|
admin.add_view(EmailSearchAdmin(name="Email Search", endpoint="email_search"))
|
||||||
admin.add_view(CouponAdmin(Coupon, Session))
|
admin.add_view(CouponAdmin(Coupon, Session))
|
||||||
admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session))
|
admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session))
|
||||||
admin.add_view(CustomDomainAdmin(CustomDomain, Session))
|
admin.add_view(CustomDomainAdmin(CustomDomain, Session))
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<label class="col-sm-2 col-form-label">{{ field.label }}</label>
|
<label class="col-sm-2 col-form-label">{{ field.label }}</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
{{ field(**kwargs)|safe }}
|
{{ field(**kwargs) |safe }}
|
||||||
<small class="form-text text-muted">{{ field.description }}</small>
|
<small class="form-text text-muted">{{ field.description }}</small>
|
||||||
{% if field.errors %}
|
{% if field.errors %}
|
||||||
|
|
||||||
|
220
app/templates/admin/email_search.html
Normal file
220
app/templates/admin/email_search.html
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
{% extends 'admin/master.html' %}
|
||||||
|
|
||||||
|
{% macro show_user(user) -%}
|
||||||
|
<h4>User {{ user.email }} with ID {{ user.id }}.</h4>
|
||||||
|
{% set pu = helper.partner_user(user) %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">User ID</th>
|
||||||
|
<th scope="col">Email</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Paid</th>
|
||||||
|
<th>Subscription</th>
|
||||||
|
<th>Created At</th>
|
||||||
|
<th>Updated At</th>
|
||||||
|
<th>Connected with Proton account</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.id }}</td>
|
||||||
|
<td><a href="?email={{ user.email }}">{{ user.email }}</a></td>
|
||||||
|
{% if user.disabled %}
|
||||||
|
|
||||||
|
<td class="text-danger">Disabled</td>
|
||||||
|
{% else %}
|
||||||
|
<td class="text-success">Enabled</td>
|
||||||
|
{% endif %}
|
||||||
|
<td>{{ "yes" if user.is_paid() else "No" }}</td>
|
||||||
|
<td>{{ user.get_active_subscription() }}</td>
|
||||||
|
<td>{{ user.created_at }}</td>
|
||||||
|
<td>{{ user.updated_at }}</td>
|
||||||
|
{% if pu %}
|
||||||
|
|
||||||
|
<td><a href="?email={{ pu.partner_email }}">{{ pu.partner_email }}</a></td>
|
||||||
|
{% else %}
|
||||||
|
<td>No</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{%- endmacro %}
|
||||||
|
{% macro list_mailboxes(mbox_count, mboxes) %}
|
||||||
|
<h4>
|
||||||
|
{{ mbox_count }} Mailboxes found.
|
||||||
|
{% if mbox_count>10 %}Showing only the last 10.{% endif %}
|
||||||
|
</h4>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Mailbox ID</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Verified</th>
|
||||||
|
<th>Created At</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for mailbox in mboxes %}
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ mailbox.id }}</td>
|
||||||
|
<td><a href="?email={{mailbox.email}}">{{mailbox.email}}</a></td>
|
||||||
|
<td>{{ "Yes" if mailbox.verified else "No" }}</td>
|
||||||
|
<td>
|
||||||
|
{{ mailbox.created_at }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endmacro %}
|
||||||
|
{% macro list_alias(alias_count, aliases) %}
|
||||||
|
<h4>
|
||||||
|
{{ alias_count }} Aliases found.
|
||||||
|
{% if alias_count>10 %}Showing only the last 10.{% endif %}
|
||||||
|
</h4>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Alias ID
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Verified
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Created At
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for alias in aliases %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ alias.id }}</td>
|
||||||
|
<td><a href="?email={{alias.email}}">{{alias.email}}</a></td>
|
||||||
|
<td>{{ "Yes" if alias.verified else "No" }}</td>
|
||||||
|
<td>{{ alias.created_at }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endmacro %}
|
||||||
|
{% macro show_deleted_alias(deleted_alias) -%}
|
||||||
|
<h4>Deleted Alias {{ deleted_alias.email }} with ID {{ deleted_alias.id }}.</h4>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Deleted Alias ID</th>
|
||||||
|
<th scope="col">Email</th>
|
||||||
|
<th scope="col">Deleted At</th>
|
||||||
|
<th scope="col">Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{{ deleted_alias.id }}</td>
|
||||||
|
<td>{{ deleted_alias.email }}</td>
|
||||||
|
<td>{{ deleted_alias.created_at }}</td>
|
||||||
|
<td>{{ deleted_alias.reason }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{%- endmacro %}
|
||||||
|
{% macro show_domain_deleted_alias(dom_deleted_alias) -%}
|
||||||
|
<h4>
|
||||||
|
Domain Deleted Alias {{ dom_deleted_alias.email }} with ID {{ dom_deleted_alias.id }} for domain {{ dom_deleted_alias.domain.domain }}
|
||||||
|
</h4>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Deleted Alias ID</th>
|
||||||
|
<th scope="col">Email</th>
|
||||||
|
<th scope="col">Domain</th>
|
||||||
|
<th scope="col">Domain ID</th>
|
||||||
|
<th scope="col">Domain owner user ID</th>
|
||||||
|
<th scope="col">Domain owner user email</th>
|
||||||
|
<th scope="col">Deleted At</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{{ dom_deleted_alias.id }}</td>
|
||||||
|
<td>{{ dom_deleted_alias.email }}</td>
|
||||||
|
<td>{{ dom_deleted_alias.domain.domain }}</td>
|
||||||
|
<td>{{ dom_deleted_alias.domain.id }}</td>
|
||||||
|
<td>{{ dom_deleted_alias.domain.user_id }}</td>
|
||||||
|
<td>{{ dom_deleted_alias.created_at }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ show_user(data.domain_deleted_alias.domain.user) }}
|
||||||
|
{%- endmacro %}
|
||||||
|
{% block body %}
|
||||||
|
|
||||||
|
<div class="border border-dark border-2 mt-1 mb-2 p-3">
|
||||||
|
<form method="get">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email to search:</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
name="email"
|
||||||
|
value="{{ email or '' }}" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% if data.no_match and email %}
|
||||||
|
|
||||||
|
<div class="border border-dark border-2 mt-1 mb-2 p-3 alert alert-warning"
|
||||||
|
role="alert">No user, alias or mailbox found for {{ email }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if data.alias %}
|
||||||
|
|
||||||
|
<div class="border border-dark border-2 mt-1 mb-2 p-3">
|
||||||
|
<h3 class="mb-3">Found Alias {{ data.alias.email }}</h3>
|
||||||
|
{{ list_alias(1,[data.alias]) }}
|
||||||
|
{{ show_user(data.alias.user) }}
|
||||||
|
{{ list_mailboxes(helper.mailbox_count(data.alias.user) , helper.mailbox_list(data.alias.user) ) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if data.user %}
|
||||||
|
|
||||||
|
<div class="border border-dark border-2 mt-1 mb-2 p-3">
|
||||||
|
<h3 class="mb-3">Found User {{ data.user.email }}</h3>
|
||||||
|
{{ show_user(data.user) }}
|
||||||
|
{{ list_mailboxes(helper.mailbox_count(data.user) , helper.mailbox_list(data.user) ) }}
|
||||||
|
{{ list_alias(helper.alias_count(data.user) ,helper.alias_list(data.user)) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if data.mailbox_count > 10 %}
|
||||||
|
<h3>Found more than 10 mailboxes for {{ email }}. Showing the last 10</h3>
|
||||||
|
{% elif data.mailbox_count > 0 %}
|
||||||
|
<h3>Found {{ data.mailbox_count }} mailbox(es) for {{ email }}</h3>
|
||||||
|
{% endif %}
|
||||||
|
{% for mailbox in data.mailbox %}
|
||||||
|
|
||||||
|
<div class="border border-dark mt-1 mb-2 p-3">
|
||||||
|
<h3 class="mb-3">Found Mailbox {{ mailbox.email }}</h3>
|
||||||
|
{{ list_mailboxes(1, [mailbox]) }}
|
||||||
|
{{ show_user(mailbox.user) }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if data.deleted_alias %}
|
||||||
|
|
||||||
|
<div class="border border-dark mt-1 mb-2 p-3">
|
||||||
|
<h3 class="mb-3">Found DeletedAlias {{ data.deleted_alias.email }}</h3>
|
||||||
|
{{ show_deleted_alias(data.deleted_alias) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if data.domain_deleted_alias %}
|
||||||
|
|
||||||
|
<div class="border border-dark mt-1 mb-2 p-3">
|
||||||
|
<h3 class="mb-3">Found DomainDeletedAlias {{ data.domain_deleted_alias.email }}</h3>
|
||||||
|
{{ show_domain_deleted_alias(data.domain_deleted_alias) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
@ -11,11 +11,11 @@ Based on https://github.com/flask-admin/flask-admin/issues/974#issuecomment-1682
|
|||||||
<input name="user_id"
|
<input name="user_id"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="User ID"
|
placeholder="User ID"
|
||||||
aria-describedby="userID"/>
|
aria-describedby="userID" />
|
||||||
<input name="to_address"
|
<input name="to_address"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Specify an address to receive the newsletter for testing"
|
placeholder="Specify an address to receive the newsletter for testing"
|
||||||
aria-describedby="Email address"/>
|
aria-describedby="Email address" />
|
||||||
</li>
|
</li>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block tail %}
|
{% block tail %}
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div class="text-center text-muted small mt-4">
|
<div class="text-center text-muted small mt-4">
|
||||||
Ask for another activation email?
|
Ask for another activation email?
|
||||||
<a href="{{ url_for('auth.resend_activation') }}" style="color: #4d21ff">Resend</a>
|
<a href="{{ url_for("auth.resend_activation") }}" style="color: #4d21ff">Resend</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
Please go to
|
Please go to
|
||||||
<a href="{{ url_for('dashboard.setting') }}">settings</a>
|
<a href="{{ url_for("dashboard.setting") }}">settings</a>
|
||||||
page to re-send the confirmation email.
|
page to re-send the confirmation email.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
<div class="text-muted mt-5" style="margin-top: 1em;">
|
<div class="text-muted mt-5" style="margin-top: 1em;">
|
||||||
Don't have your key with you?
|
Don't have your key with you?
|
||||||
<br />
|
<br />
|
||||||
<a href="{{ url_for('auth.mfa') }}">Verify by One-Time Password</a>
|
<a href="{{ url_for("auth.mfa") }}">Verify by One-Time Password</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<hr />
|
<hr />
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="text-center text-muted">
|
<div class="text-center text-muted">
|
||||||
Forget it,
|
Forget it,
|
||||||
<a href="{{ url_for('auth.login') }}">send me back</a>
|
<a href="{{ url_for("auth.login") }}">send me back</a>
|
||||||
to the sign in screen.
|
to the sign in screen.
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div class="text-center text-muted small mb-4">
|
<div class="text-center text-muted small mb-4">
|
||||||
You haven't received the activation email?
|
You haven't received the activation email?
|
||||||
<a href="{{ url_for('auth.resend_activation') }}">Resend</a>
|
<a href="{{ url_for("auth.resend_activation") }}">Resend</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="card" style="border-radius: 2%">
|
<div class="card" style="border-radius: 2%">
|
||||||
@ -25,7 +25,7 @@
|
|||||||
{{ form.password(class="form-control", type="password") }}
|
{{ form.password(class="form-control", type="password") }}
|
||||||
{{ render_field_errors(form.password) }}
|
{{ render_field_errors(form.password) }}
|
||||||
<div class="text-muted">
|
<div class="text-muted">
|
||||||
<a href="{{ url_for('auth.forgot_password') }}" class="small">I forgot my password</a>
|
<a href="{{ url_for("auth.forgot_password") }}" class="small">I forgot my password</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
@ -57,6 +57,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-center text-muted mt-2">
|
<div class="text-center text-muted mt-2">
|
||||||
Don't have an account yet?
|
Don't have an account yet?
|
||||||
<a href="{{ url_for('auth.register') }}">Sign up</a>
|
<a href="{{ url_for("auth.register") }}">Sign up</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -30,7 +30,7 @@
|
|||||||
<div class="text-muted mt-5" style="margin-top: 1em;">
|
<div class="text-muted mt-5" style="margin-top: 1em;">
|
||||||
Having trouble with your authenticator?
|
Having trouble with your authenticator?
|
||||||
<br />
|
<br />
|
||||||
<a href="{{ url_for('auth.fido') }}">
|
<a href="{{ url_for("auth.fido") }}">
|
||||||
Verify by your security
|
Verify by your security
|
||||||
key
|
key
|
||||||
</a>
|
</a>
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
<!-- TODO: add terms
|
<!-- TODO: add terms
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="custom-control custom-checkbox">
|
<label class="custom-control custom-checkbox">
|
||||||
<input type="checkbox" class="custom-control-input"/>
|
<input type="checkbox" class="custom-control-input" />
|
||||||
<span class="custom-control-label">Agree the <a href="terms.html">terms and policy</a></span>
|
<span class="custom-control-label">Agree the <a href="terms.html">terms and policy</a></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -69,6 +69,6 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="text-center text-muted mb-6">
|
<div class="text-center text-muted mb-6">
|
||||||
Already have account?
|
Already have account?
|
||||||
<a href="{{ url_for('auth.login') }}">Sign in</a>
|
<a href="{{ url_for("auth.login") }}">Sign in</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -19,6 +19,6 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="text-center text-muted">
|
<div class="text-center text-muted">
|
||||||
Don't have account yet?
|
Don't have account yet?
|
||||||
<a href="{{ url_for('auth.register') }}">Sign up</a>
|
<a href="{{ url_for("auth.register") }}">Sign up</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -29,7 +29,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center p-3"
|
<div class="text-center p-3"
|
||||||
style="font-size: 12px; font-weight: 300; margin: auto">
|
style="font-size: 12px;
|
||||||
|
font-weight: 300;
|
||||||
|
margin: auto">
|
||||||
<span class="badge badge-warning">Warning</span>
|
<span class="badge badge-warning">Warning</span>
|
||||||
Please note that social login is now <b>deprecated</b>.
|
Please note that social login is now <b>deprecated</b>.
|
||||||
<br />
|
<br />
|
||||||
@ -39,8 +41,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center text-muted mt-2">
|
<div class="text-center text-muted mt-2">
|
||||||
<a href="{{ url_for('auth.register') }}">Sign up</a>
|
<a href="{{ url_for("auth.register") }}">Sign up</a>
|
||||||
/
|
/
|
||||||
<a href="{{ url_for('auth.login') }}">Login</a>
|
<a href="{{ url_for("auth.login") }}">Login</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
{% from "_formhelpers.html" import render_field, render_field_errors %}
|
{% from "_formhelpers.html" import render_field, render_field_errors %}
|
||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en"
|
<html lang="en"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
data-theme="{%- if request.cookies.get('dark-mode') == 'true' -%} dark{%- endif -%}">
|
data-theme="{%- if request.cookies.get('dark-mode') == 'true' -%} dark{%- endif -%}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport"
|
<meta name="viewport"
|
||||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"/>
|
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||||
<meta http-equiv="Content-Language" content="en" />
|
<meta http-equiv="Content-Language" content="en" />
|
||||||
<meta name="msapplication-TileColor" content="#2d89ef" />
|
<meta name="msapplication-TileColor" content="#2d89ef" />
|
||||||
<meta name="theme-color" content="#4188c9" />
|
<meta name="theme-color" content="#4188c9" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style"
|
<meta name="apple-mobile-web-app-status-bar-style"
|
||||||
content="black-translucent"/>
|
content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="HandheldFriendly" content="True" />
|
<meta name="HandheldFriendly" content="True" />
|
||||||
@ -23,7 +23,7 @@
|
|||||||
<!-- Yandex -->
|
<!-- Yandex -->
|
||||||
<meta name="yandex-verification" content="c9e5d4d68bc983a1" />
|
<meta name="yandex-verification" content="c9e5d4d68bc983a1" />
|
||||||
<meta name="description"
|
<meta name="description"
|
||||||
content="Protect your email address with email ALIAS. Create a different email alias for each website. No more phishing, or spam."/>
|
content="Protect your email address with email ALIAS. Create a different email alias for each website. No more phishing, or spam." />
|
||||||
<link rel="icon" href="/static/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href="/static/favicon.ico" type="image/x-icon" />
|
||||||
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico" />
|
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico" />
|
||||||
<link rel="canonical" href="{{ CANONICAL_URL }}" />
|
<link rel="canonical" href="{{ CANONICAL_URL }}" />
|
||||||
@ -32,7 +32,7 @@
|
|||||||
| SimpleLogin
|
| SimpleLogin
|
||||||
</title>
|
</title>
|
||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
href="{{ url_for('static', filename='node_modules/font-awesome/css/font-awesome.css') }}"/>
|
href="{{ url_for('static', filename='node_modules/font-awesome/css/font-awesome.css') }}" />
|
||||||
<!-- Dashboard Core -->
|
<!-- Dashboard Core -->
|
||||||
<link href="/static/assets/css/dashboard.css" rel="stylesheet" />
|
<link href="/static/assets/css/dashboard.css" rel="stylesheet" />
|
||||||
<!-- Tabler JS -->
|
<!-- Tabler JS -->
|
||||||
@ -51,19 +51,19 @@
|
|||||||
<!-- IntroJS -->
|
<!-- IntroJS -->
|
||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
type="text/css"
|
type="text/css"
|
||||||
href="{{ url_for('static', filename='node_modules/intro.js/minified/introjs.min.css') }}"/>
|
href="{{ url_for('static', filename='node_modules/intro.js/minified/introjs.min.css') }}" />
|
||||||
<script src="{{ url_for('static', filename='node_modules/intro.js/minified/intro.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='node_modules/intro.js/minified/intro.min.js') }}"></script>
|
||||||
<!-- Sentry -->
|
<!-- Sentry -->
|
||||||
<script src="{{ url_for('static', filename='node_modules/@sentry/browser/build/bundle.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='node_modules/@sentry/browser/build/bundle.min.js') }}"></script>
|
||||||
<link rel="stylesheet" href="/static/vendor/bootstrap-social.min.css" />
|
<link rel="stylesheet" href="/static/vendor/bootstrap-social.min.css" />
|
||||||
<!-- Toastr library -->
|
<!-- Toastr library -->
|
||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
href="{{ url_for('static', filename='node_modules/toastr/build/toastr.min.css') }}"/>
|
href="{{ url_for('static', filename='node_modules/toastr/build/toastr.min.css') }}" />
|
||||||
<script src="{{ url_for('static', filename='node_modules/toastr/build/toastr.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='node_modules/toastr/build/toastr.min.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='node_modules/bootbox/dist/bootbox.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='node_modules/bootbox/dist/bootbox.min.js') }}"></script>
|
||||||
<!-- Multiple-select library -->
|
<!-- Multiple-select library -->
|
||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
href="{{ url_for('static', filename='node_modules/multiple-select/dist/multiple-select.min.css') }}"/>
|
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>
|
<script src="{{ url_for('static', filename='node_modules/multiple-select/dist/multiple-select.min.js') }}"></script>
|
||||||
<!-- Parseley library -->
|
<!-- Parseley library -->
|
||||||
<script src="{{ url_for('static', filename='node_modules/parsleyjs/dist/parsley.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='node_modules/parsleyjs/dist/parsley.min.js') }}"></script>
|
||||||
@ -75,10 +75,10 @@
|
|||||||
<script async defer data-domain=”{{ PLAUSIBLE_DOMAIN }}” src=”{{ PLAUSIBLE_HOST }}/js/plausible.outbound-links.js”></script>
|
<script async defer data-domain=”{{ PLAUSIBLE_DOMAIN }}” src=”{{ PLAUSIBLE_HOST }}/js/plausible.outbound-links.js”></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
href="{{ url_for('static', filename='darkmode.css') }}?v={{ VERSION }}"/>
|
href="{{ url_for('static', filename='darkmode.css') }}?v={{ VERSION }}" />
|
||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
type="text/css"
|
type="text/css"
|
||||||
href="/static/style.css?v={{ VERSION }}"/>
|
href="/static/style.css?v={{ VERSION }}" />
|
||||||
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
||||||
<script>toastr.options.closeButton = true;</script>
|
<script>toastr.options.closeButton = true;</script>
|
||||||
<!-- For additional head -->
|
<!-- For additional head -->
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
This email address is used to log in to SimpleLogin.
|
This email address is used to log in to SimpleLogin.
|
||||||
<br />
|
<br />
|
||||||
If you want to change the mailbox that emails are forwarded to, use the
|
If you want to change the mailbox that emails are forwarded to, use the
|
||||||
<a href="{{ url_for('dashboard.mailbox_route') }}">
|
<a href="{{ url_for("dashboard.mailbox_route") }}">
|
||||||
<i class="fe fe-inbox"></i> Mailboxes page
|
<i class="fe fe-inbox"></i> Mailboxes page
|
||||||
</a>
|
</a>
|
||||||
instead.
|
instead.
|
||||||
@ -50,14 +50,14 @@
|
|||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<span class="text-danger float-left">Pending email change: {{ pending_email }}</span>
|
<span class="text-danger float-left">Pending email change: {{ pending_email }}</span>
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
action="{{ url_for('dashboard.resend_email_change') }}"
|
action="{{ url_for("dashboard.resend_email_change") }}"
|
||||||
class="float-left ml-2">
|
class="float-left ml-2">
|
||||||
{{ change_email_form.csrf_token }}
|
{{ change_email_form.csrf_token }}
|
||||||
<a onclick="this.closest('form').submit()"
|
<a onclick="this.closest('form').submit()"
|
||||||
class="btn btn-secondary btn-sm">Resend confirmation email</a>
|
class="btn btn-secondary btn-sm">Resend confirmation email</a>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
action="{{ url_for('dashboard.cancel_email_change') }}"
|
action="{{ url_for("dashboard.cancel_email_change") }}"
|
||||||
class="float-left ml-2">
|
class="float-left ml-2">
|
||||||
{{ change_email_form.csrf_token }}
|
{{ change_email_form.csrf_token }}
|
||||||
<a onclick="this.closest('form').submit()"
|
<a onclick="this.closest('form').submit()"
|
||||||
@ -91,10 +91,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if not current_user.enable_otp %}
|
{% if not current_user.enable_otp %}
|
||||||
|
|
||||||
<a href="{{ url_for('dashboard.mfa_setup') }}"
|
<a href="{{ url_for("dashboard.mfa_setup") }}"
|
||||||
class="btn btn-outline-primary">Setup TOTP</a>
|
class="btn btn-outline-primary">Setup TOTP</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('dashboard.mfa_cancel') }}"
|
<a href="{{ url_for("dashboard.mfa_cancel") }}"
|
||||||
class="btn btn-outline-danger">Disable TOTP</a>
|
class="btn btn-outline-danger">Disable TOTP</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -111,10 +111,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if current_user.fido_uuid is none %}
|
{% if current_user.fido_uuid is none %}
|
||||||
|
|
||||||
<a href="{{ url_for('dashboard.fido_setup') }}"
|
<a href="{{ url_for("dashboard.fido_setup") }}"
|
||||||
class="btn btn-outline-primary">Setup WebAuthn</a>
|
class="btn btn-outline-primary">Setup WebAuthn</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('dashboard.fido_manage') }}"
|
<a href="{{ url_for("dashboard.fido_manage") }}"
|
||||||
class="btn btn-outline-info">Manage WebAuthn</a>
|
class="btn btn-outline-info">Manage WebAuthn</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -146,7 +146,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">Account Deletion</div>
|
<div class="card-title">Account Deletion</div>
|
||||||
<div class="mb-3">If SimpleLogin isn't the right fit for you, you can simply delete your account.</div>
|
<div class="mb-3">If SimpleLogin isn't the right fit for you, you can simply delete your account.</div>
|
||||||
<a href="{{ url_for('dashboard.delete_account') }}"
|
<a href="{{ url_for("dashboard.delete_account") }}"
|
||||||
class="btn btn-outline-danger">Delete account</a>
|
class="btn btn-outline-danger">Delete account</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
<br />
|
<br />
|
||||||
<img src="/static/images/reverse-alias.svg"
|
<img src="/static/images/reverse-alias.svg"
|
||||||
style="border: 1px solid"
|
style="border: 1px solid"
|
||||||
class="my-2 img-fluid"/>
|
class="my-2 img-fluid" />
|
||||||
</p>
|
</p>
|
||||||
<p>This might seem like "magic" but trust us, only the first time is a bit awkward.</p>
|
<p>This might seem like "magic" but trust us, only the first time is a bit awkward.</p>
|
||||||
<p>
|
<p>
|
||||||
@ -75,9 +75,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<button disabled
|
<button disabled
|
||||||
title="Upgrade to premium to create reverse-aliases"
|
title="Upgrade to premium to create reverse-aliases"
|
||||||
class="btn btn-primary mt-2">
|
class="btn btn-primary mt-2">Create reverse-alias</button>
|
||||||
Create reverse-alias
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -98,9 +96,7 @@
|
|||||||
{% if highlight_contact_id %}
|
{% if highlight_contact_id %}
|
||||||
|
|
||||||
<a href="{{ url_for("dashboard.alias_contact_manager", alias_id=alias.id, highlight_contact_id=highlight_contact_id) }}"
|
<a href="{{ url_for("dashboard.alias_contact_manager", alias_id=alias.id, highlight_contact_id=highlight_contact_id) }}"
|
||||||
class="btn btn-light">
|
class="btn btn-light">Reset</a>
|
||||||
Reset
|
|
||||||
</a>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for("dashboard.alias_contact_manager", alias_id=alias.id) }}"
|
<a href="{{ url_for("dashboard.alias_contact_manager", alias_id=alias.id) }}"
|
||||||
class="btn btn-light">Reset</a>
|
class="btn btn-light">Reset</a>
|
||||||
@ -114,7 +110,7 @@
|
|||||||
|
|
||||||
{% set contact = contact_info.contact %}
|
{% set contact = contact_info.contact %}
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="my-2 p-2 card {% if contact.id == highlight_contact_id %} highlight-row{% endif %}">
|
<div class="my-2 p-2 card {% if contact.id == highlight_contact_id %}highlight-row{% endif %}">
|
||||||
<div class="mb-2 row">
|
<div class="mb-2 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<span class="font-weight-bold">{{ contact.website_email }}</span>
|
<span class="font-weight-bold">{{ contact.website_email }}</span>
|
||||||
@ -139,15 +135,11 @@
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
data-toggle="tooltip"
|
data-toggle="tooltip"
|
||||||
title="You can click on this to open your email client. Or use the copy button 👉"
|
title="You can click on this to open your email client. Or use the copy button 👉"
|
||||||
class="font-weight-bold">
|
class="font-weight-bold">*************************</a>
|
||||||
*************************
|
|
||||||
</a>
|
|
||||||
<span class="clipboard btn btn-sm btn-success copy-btn"
|
<span class="clipboard btn btn-sm btn-success copy-btn"
|
||||||
data-toggle="tooltip"
|
data-toggle="tooltip"
|
||||||
title="Copy the reverse-alias to clipboard"
|
title="Copy the reverse-alias to clipboard"
|
||||||
data-clipboard-text="{{ contact.website_send_to() }}">
|
data-clipboard-text="{{ contact.website_send_to() }}">Copy reverse-alias</span>
|
||||||
Copy reverse-alias
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2 text-muted small-text">
|
<div class="mb-2 text-muted small-text">
|
||||||
@ -207,14 +199,12 @@
|
|||||||
<nav aria-label="Contact navigation">
|
<nav aria-label="Contact navigation">
|
||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="btn btn-outline-secondary {% if page == 0 %}disabled{% endif %}"
|
<a class="btn btn-outline-secondary {% if page == 0 %}disabled{% endif %}" href="{{ url_for('dashboard.alias_contact_manager', alias_id=alias.id, page=page-1) }}">
|
||||||
href="{{ url_for('dashboard.alias_contact_manager', alias_id=alias.id, page=page-1) }}">
|
|
||||||
Previous
|
Previous
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}"
|
<a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}" href="{{ url_for('dashboard.alias_contact_manager', alias_id=alias.id, page=page+1) }}">
|
||||||
href="{{ url_for('dashboard.alias_contact_manager', alias_id=alias.id, page=page+1) }}">
|
|
||||||
Next
|
Next
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -13,7 +13,9 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="subheader">Total</div>
|
<div class="subheader">Total</div>
|
||||||
<div class="text-muted"
|
<div class="text-muted"
|
||||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
style="order: 2;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: .8rem">Last 14 days</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h1 m-0">{{ total }}</div>
|
<div class="h1 m-0">{{ total }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -25,7 +27,9 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="subheader">Forwarded</div>
|
<div class="subheader">Forwarded</div>
|
||||||
<div class="text-muted"
|
<div class="text-muted"
|
||||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
style="order: 2;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: .8rem">Last 14 days</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h1 m-0">{{ email_forwarded }}</div>
|
<div class="h1 m-0">{{ email_forwarded }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -37,7 +41,9 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="subheader">Replies/Sent</div>
|
<div class="subheader">Replies/Sent</div>
|
||||||
<div class="text-muted"
|
<div class="text-muted"
|
||||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
style="order: 2;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: .8rem">Last 14 days</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h1 m-0">{{ email_replied }}</div>
|
<div class="h1 m-0">{{ email_replied }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -49,7 +55,9 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="subheader">Blocked</div>
|
<div class="subheader">Blocked</div>
|
||||||
<div class="text-muted"
|
<div class="text-muted"
|
||||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
style="order: 2;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: .8rem">Last 14 days</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h1 m-0">{{ email_blocked }}</div>
|
<div class="h1 m-0">{{ email_blocked }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -111,14 +119,12 @@
|
|||||||
<nav aria-label="Alias log navigation">
|
<nav aria-label="Alias log navigation">
|
||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="btn btn-outline-secondary {% if page_id == 0 %}disabled{% endif %}"
|
<a class="btn btn-outline-secondary {% if page_id == 0 %}disabled{% endif %}" href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id-1) }}">
|
||||||
href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id-1) }}">
|
|
||||||
Previous
|
Previous
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}"
|
<a class="btn btn-outline-secondary {% if last_page %}disabled{% endif %}" href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id+1) }}">
|
||||||
href="{{ url_for('dashboard.alias_log', alias_id=alias_id, page_id=page_id+1) }}">
|
|
||||||
Next
|
Next
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -15,10 +15,7 @@
|
|||||||
<select data-width="100%" class="mailbox-select" multiple name="mailbox_ids">
|
<select data-width="100%" class="mailbox-select" multiple name="mailbox_ids">
|
||||||
{% for mailbox in mailboxes %}
|
{% for mailbox in mailboxes %}
|
||||||
|
|
||||||
<option value="{{ mailbox.id }}"
|
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}selected{% endif %}>{{ mailbox.email }}</option>
|
||||||
{% if mailbox.id == current_user.default_mailbox_id %} selected{% endif %}>
|
|
||||||
{{ mailbox.email }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-success mt-2">Confirm</button>
|
<button class="btn btn-success mt-2">Confirm</button>
|
||||||
|
@ -16,9 +16,7 @@
|
|||||||
<em data-toggle="tooltip"
|
<em data-toggle="tooltip"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
class="clipboard"
|
class="clipboard"
|
||||||
data-clipboard-text="{{ alias_transfer_url }}">
|
data-clipboard-text="{{ alias_transfer_url }}">{{ alias_transfer_url }}</em>
|
||||||
{{ alias_transfer_url }}
|
|
||||||
</em>
|
|
||||||
<p class="mt-5">
|
<p class="mt-5">
|
||||||
Please copy the transfer URL. <strong>We won't be able to display it again</strong>. If you need to access it again you can generate a new URL.
|
Please copy the transfer URL. <strong>We won't be able to display it again</strong>. If you need to access it again you can generate a new URL.
|
||||||
</p>
|
</p>
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
<br />
|
<br />
|
||||||
The period left in the current subscription isn't taken into account.
|
The period left in the current subscription isn't taken into account.
|
||||||
<br />
|
<br />
|
||||||
<a href="{{ url_for('dashboard.pricing') }}"
|
<a href="{{ url_for("dashboard.pricing") }}"
|
||||||
class="btn btn-primary mt-2">Re-subscribe</a>
|
class="btn btn-primary mt-2">Re-subscribe</a>
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -43,12 +43,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">PGP Public Key</label>
|
<label class="form-label">PGP Public Key</label>
|
||||||
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="(Drag and drop or paste your pgp public key here) -----BEGIN PGP PUBLIC KEY BLOCK-----">{{ contact.pgp_public_key or "" }}</textarea>
|
<textarea name="pgp"
|
||||||
|
{% if not current_user.is_premium() %}disabled{% endif %}
|
||||||
|
class="form-control"
|
||||||
|
rows="10"
|
||||||
|
id="pgp-public-key"
|
||||||
|
placeholder="(Drag and drop or paste your pgp public key here) -----BEGIN PGP PUBLIC KEY BLOCK-----">{{ contact.pgp_public_key or "" }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
|
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}disabled{% endif %} value="save">Save</button>
|
||||||
disabled {% endif %} value="save">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
{% if contact.pgp_finger_print %}
|
{% if contact.pgp_finger_print %}
|
||||||
|
|
||||||
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
|
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
|
||||||
|
@ -74,10 +74,7 @@
|
|||||||
required>
|
required>
|
||||||
{% for mailbox in mailboxes %}
|
{% for mailbox in mailboxes %}
|
||||||
|
|
||||||
<option value="{{ mailbox.id }}"
|
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}selected{% endif %}>{{ mailbox.email }}</option>
|
||||||
{% if mailbox.id == current_user.default_mailbox_id %} selected{% endif %}>
|
|
||||||
{{ mailbox.email }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="small-text">The mailbox(es) that owns this alias.</div>
|
<div class="small-text">The mailbox(es) that owns this alias.</div>
|
||||||
@ -102,7 +99,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
$('.mailbox-select').multipleSelect();
|
$('.mailbox-select').multipleSelect();
|
||||||
|
|
||||||
|
@ -30,9 +30,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="alert alert-primary collapse {% if not custom_domains %} show{% endif %}"
|
<div class="alert alert-primary collapse {% if not custom_domains %}show{% endif %}" id="howtouse" role="alert">
|
||||||
id="howtouse"
|
|
||||||
role="alert">
|
|
||||||
By adding your domain, you can create aliases like <b>hi@my-domain.com</b>
|
By adding your domain, you can create aliases like <b>hi@my-domain.com</b>
|
||||||
<br />
|
<br />
|
||||||
You can also enable <b>catch-all</b> to create aliases on-the-fly:
|
You can also enable <b>catch-all</b> to create aliases on-the-fly:
|
||||||
@ -50,18 +48,14 @@
|
|||||||
{% if custom_domain.ownership_verified and not custom_domain.verified %}
|
{% if custom_domain.ownership_verified and not custom_domain.verified %}
|
||||||
|
|
||||||
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id, _anchor='dns-setup') }}"
|
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id, _anchor='dns-setup') }}"
|
||||||
class="btn btn-info btn-sm">
|
class="btn btn-info btn-sm">Ownership verified. Setup the DNS</a>
|
||||||
Ownership verified. Setup the DNS
|
|
||||||
</a>
|
|
||||||
{% elif custom_domain.ownership_verified and custom_domain.verified %}
|
{% elif custom_domain.ownership_verified and custom_domain.verified %}
|
||||||
<span class="badge badge-success">Domain ready</span>
|
<span class="badge badge-success">Domain ready</span>
|
||||||
<!-- custom_domain.ownership_verified is False -->
|
<!-- custom_domain.ownership_verified is False -->
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id, _anchor='ownership-form') }}"
|
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id, _anchor='ownership-form') }}"
|
||||||
class="btn btn-warning btn-sm"
|
class="btn btn-warning btn-sm"
|
||||||
role="button">
|
role="button">Verify domain ownership</a>
|
||||||
Verify domain ownership
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h5>
|
</h5>
|
||||||
<h6 class="card-subtitle mb-4 text-muted">
|
<h6 class="card-subtitle mb-4 text-muted">
|
||||||
@ -100,4 +94,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}<script>$('.mailbox-select').multipleSelect();</script>{% endblock %}
|
|
||||||
|
@ -22,9 +22,7 @@
|
|||||||
|
|
||||||
<div class="alert alert-danger" role="alert">This feature is only available in premium plan.</div>
|
<div class="alert alert-danger" role="alert">This feature is only available in premium plan.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="alert alert-primary collapse {% if not dirs %} show{% endif %}"
|
<div class="alert alert-primary collapse {% if not dirs %}show{% endif %}" id="howtouse" role="alert">
|
||||||
id="howtouse"
|
|
||||||
role="alert">
|
|
||||||
<div>
|
<div>
|
||||||
Directory allows you to create aliases <b>on the fly</b>.
|
Directory allows you to create aliases <b>on the fly</b>.
|
||||||
</div>
|
</div>
|
||||||
@ -68,10 +66,10 @@
|
|||||||
<form method="post">
|
<form method="post">
|
||||||
{{ toggle_dir_form.csrf_token }}
|
{{ toggle_dir_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="toggle-directory">
|
<input type="hidden" name="form-name" value="toggle-directory">
|
||||||
{{ toggle_dir_form.directory_id( type="hidden", value=dir.id) }}
|
{{ toggle_dir_form.directory_id(type="hidden", value=dir.id) }}
|
||||||
<label class="custom-switch cursor" style="padding-left: 1rem" data-toggle="tooltip" {% if dir.disabled %}
|
<label class="custom-switch cursor" style="padding-left: 1rem" data-toggle="tooltip" {% if dir.disabled %}
|
||||||
title="Enable directory on-the-fly alias creation" {% else %} title="Disable directory on-the-fly alias creation" {% endif %}>
|
title="Enable directory on-the-fly alias creation" {% else %} title="Disable directory on-the-fly alias creation" {% endif %}>
|
||||||
{{ toggle_dir_form.directory_enabled( class="custom-switch-input", checked=(not dir.disabled) ) }}
|
{{ toggle_dir_form.directory_enabled(class="custom-switch-input", checked=(not dir.disabled) ) }}
|
||||||
<span class="custom-switch-indicator"></span>
|
<span class="custom-switch-indicator"></span>
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
@ -91,11 +89,11 @@
|
|||||||
data-toggle="tooltip"
|
data-toggle="tooltip"
|
||||||
title="Aliases created with this directory are automatically owned by these mailboxes"></i>
|
title="Aliases created with this directory are automatically owned by these mailboxes"></i>
|
||||||
<br />
|
<br />
|
||||||
{% set dir_mailboxes=dir.mailboxes %}
|
{% set dir_mailboxes = dir.mailboxes %}
|
||||||
<form method="post" class="mt-2">
|
<form method="post" class="mt-2">
|
||||||
{{ update_dir_form.csrf_token }}
|
{{ update_dir_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="update">
|
<input type="hidden" name="form-name" value="update">
|
||||||
{{ update_dir_form.directory_id( type="hidden", value=dir.id) }}
|
{{ update_dir_form.directory_id(type="hidden", value=dir.id) }}
|
||||||
<select data-width="100%"
|
<select data-width="100%"
|
||||||
required
|
required
|
||||||
class="mailbox-select"
|
class="mailbox-select"
|
||||||
@ -103,10 +101,7 @@
|
|||||||
name="mailbox_ids">
|
name="mailbox_ids">
|
||||||
{% for mailbox in mailboxes %}
|
{% for mailbox in mailboxes %}
|
||||||
|
|
||||||
<option value="{{ mailbox.id }}"
|
<option value="{{ mailbox.id }}" {% if mailbox in dir_mailboxes %}selected{% endif %}>{{ mailbox.email }}</option>
|
||||||
{% if mailbox in dir_mailboxes %} selected{% endif %}>
|
|
||||||
{{ mailbox.email }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button class="mt-2 btn btn-outline-primary btn-sm">Update</button>
|
<button class="mt-2 btn btn-outline-primary btn-sm">Update</button>
|
||||||
@ -119,7 +114,7 @@
|
|||||||
<form method="post">
|
<form method="post">
|
||||||
{{ delete_dir_form.csrf_token }}
|
{{ delete_dir_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="delete">
|
<input type="hidden" name="form-name" value="delete">
|
||||||
{{ delete_dir_form.directory_id( type="hidden", value=dir.id) }}
|
{{ delete_dir_form.directory_id(type="hidden", value=dir.id) }}
|
||||||
<span class="card-link btn btn-link float-right text-danger delete-dir">Delete</span>
|
<span class="card-link btn btn-link float-right text-danger delete-dir">Delete</span>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -129,7 +124,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="row {% if current_user.directory_quota <= 0 %} disabled-content{% endif %}">
|
<div class="row {% if current_user.directory_quota <= 0 %}disabled-content{% endif %}">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@ -156,10 +151,7 @@
|
|||||||
<select data-width="100%" class="mailbox-select" multiple name="mailbox_ids">
|
<select data-width="100%" class="mailbox-select" multiple name="mailbox_ids">
|
||||||
{% for mailbox in mailboxes %}
|
{% for mailbox in mailboxes %}
|
||||||
|
|
||||||
<option value="{{ mailbox.id }}"
|
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}selected{% endif %}>{{ mailbox.email }}</option>
|
||||||
{% if mailbox.id == current_user.default_mailbox_id %} selected{% endif %}>
|
|
||||||
{{ mailbox.email }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button id="btn-create-directory" class="btn btn-primary mt-2">Create</button>
|
<button id="btn-create-directory" class="btn btn-primary mt-2">Create</button>
|
||||||
|
@ -13,7 +13,8 @@
|
|||||||
|
|
||||||
<div class="alert alert-warning mt-3">Rules are ineffective when catch-all is enabled.</div>
|
<div class="alert alert-warning mt-3">Rules are ineffective when catch-all is enabled.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="{% if custom_domain.catch_all %} disabled-content{% endif %}">
|
<div class="{% if custom_domain.catch_all %}
|
||||||
|
disabled-content{% endif %}">
|
||||||
<div class="mt-3 mb-2">
|
<div class="mt-3 mb-2">
|
||||||
For a greater control than a simple catch-all, you can define a set of <b>rules</b> to auto create aliases.
|
For a greater control than a simple catch-all, you can define a set of <b>rules</b> to auto create aliases.
|
||||||
<br />
|
<br />
|
||||||
@ -60,8 +61,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Regex</label>
|
<label>Regex</label>
|
||||||
{{ new_auto_create_rule_form.regex(class="form-control",
|
{{ new_auto_create_rule_form.regex(class="form-control",
|
||||||
placeholder="prefix.*"
|
placeholder="prefix.*") }}
|
||||||
) }}
|
|
||||||
{{ render_field_errors(new_auto_create_rule_form.regex) }}
|
{{ render_field_errors(new_auto_create_rule_form.regex) }}
|
||||||
<div class="small-text">
|
<div class="small-text">
|
||||||
For example, if you want aliases that starts with <b>prefix</b> to be automatically created, you can set
|
For example, if you want aliases that starts with <b>prefix</b> to be automatically created, you can set
|
||||||
@ -95,10 +95,7 @@
|
|||||||
name="mailbox_ids">
|
name="mailbox_ids">
|
||||||
{% for mailbox in mailboxes %}
|
{% for mailbox in mailboxes %}
|
||||||
|
|
||||||
<option value="{{ mailbox.id }}"
|
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}selected{% endif %}>{{ mailbox.email }}</option>
|
||||||
{% if mailbox.id == current_user.default_mailbox_id %} selected{% endif %}>
|
|
||||||
{{ mailbox.email }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -128,9 +125,7 @@
|
|||||||
{% if auto_create_test_result %}
|
{% if auto_create_test_result %}
|
||||||
|
|
||||||
<div class="alert {% if auto_create_test_passed %}
|
<div class="alert {% if auto_create_test_passed %}
|
||||||
alert-success {% else %} alert-warning {% endif %}">
|
alert-success {% else %} alert-warning {% endif %}">{{ auto_create_test_result }}</div>
|
||||||
{{ auto_create_test_result }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -38,7 +38,7 @@
|
|||||||
Value: <em data-toggle="tooltip"
|
Value: <em data-toggle="tooltip"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
class="clipboard"
|
class="clipboard"
|
||||||
data-clipboard-text="{{ custom_domain.get_ownership_dns_txt_value() }}">{{ custom_domain.get_ownership_dns_txt_value() }}</em>
|
data-clipboard-text="{{ ownership_record }}">{{ ownership_record }}</em>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="#ownership-form">
|
<form method="post" action="#ownership-form">
|
||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
@ -63,8 +63,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<hr />
|
<hr />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="{% if not custom_domain.ownership_verified %} disabled-content{% endif %}"
|
<div class="{% if not custom_domain.ownership_verified %}
|
||||||
id="dns-setup">
|
disabled-content{% endif %}" id="dns-setup">
|
||||||
{% if not custom_domain.ownership_verified %}
|
{% if not custom_domain.ownership_verified %}
|
||||||
|
|
||||||
<div class="alert alert-warning">A domain ownership must be verified first.</div>
|
<div class="alert alert-warning">A domain ownership must be verified first.</div>
|
||||||
@ -177,9 +177,7 @@
|
|||||||
<em data-toggle="tooltip"
|
<em data-toggle="tooltip"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
class="clipboard"
|
class="clipboard"
|
||||||
data-clipboard-text="{{ spf_record }}">
|
data-clipboard-text="{{ spf_record }}">{{ spf_record }}</em>
|
||||||
{{ spf_record }}
|
|
||||||
</em>
|
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="#spf-form">
|
<form method="post" action="#spf-form">
|
||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
@ -238,10 +236,8 @@
|
|||||||
Setting up DKIM is highly recommended to reduce the chance your emails ending up in the recipient's Spam
|
Setting up DKIM is highly recommended to reduce the chance your emails ending up in the recipient's Spam
|
||||||
folder.
|
folder.
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">Add the following CNAME DNS records to your domain.</div>
|
||||||
Add the following CNAME DNS records to your domain.
|
{% for dkim_prefix, dkim_cname_value in dkim_records.items() %}
|
||||||
</div>
|
|
||||||
{% for dkim_prefix, dkim_cname_value in dkim_records %}
|
|
||||||
|
|
||||||
<div class="mb-2 p-3 dns-record">
|
<div class="mb-2 p-3 dns-record">
|
||||||
Record: CNAME
|
Record: CNAME
|
||||||
@ -256,9 +252,7 @@
|
|||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
class="clipboard"
|
class="clipboard"
|
||||||
data-clipboard-text="{{ dkim_cname_value }}."
|
data-clipboard-text="{{ dkim_cname_value }}."
|
||||||
style="overflow-wrap: break-word">
|
style="overflow-wrap: break-word">{{ dkim_cname_value }}.</em>
|
||||||
{{ dkim_cname_value }}.
|
|
||||||
</em>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
@ -282,21 +276,15 @@
|
|||||||
<input type="hidden" name="form-name" value="check-dkim">
|
<input type="hidden" name="form-name" value="check-dkim">
|
||||||
{% if custom_domain.dkim_verified %}
|
{% if custom_domain.dkim_verified %}
|
||||||
|
|
||||||
<button type="submit" class="btn btn-outline-primary">
|
<button type="submit" class="btn btn-outline-primary">Re-verify</button>
|
||||||
Re-verify
|
|
||||||
</button>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">Verify</button>
|
||||||
Verify
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
{% if not dkim_ok %}
|
{% if not dkim_ok %}
|
||||||
|
|
||||||
<div class="text-danger mt-4">
|
<div class="text-danger mt-4">
|
||||||
<p>
|
<p>Your DNS is not correctly set.</p>
|
||||||
Your DNS is not correctly set.
|
|
||||||
</p>
|
|
||||||
<ul>
|
<ul>
|
||||||
{% for custom_record, retrieved_cname in dkim_errors.items() %}
|
{% for custom_record, retrieved_cname in dkim_errors.items() %}
|
||||||
|
|
||||||
@ -312,9 +300,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if custom_domain.dkim_verified %}
|
{% if custom_domain.dkim_verified %}
|
||||||
|
|
||||||
<div class="text-danger mt-4">
|
<div class="text-danger mt-4">DKIM is still enabled. Please update your DKIM settings with all CNAME records</div>
|
||||||
DKIM is still enabled. Please update your DKIM settings with all CNAME records
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -330,24 +316,20 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="cursor"
|
<span class="cursor"
|
||||||
data-toggle="tooltip"
|
data-toggle="tooltip"
|
||||||
data-original-title="DMARC Not Verified">🚫 </span>
|
data-original-title="DMARC Not Verified">🚫</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
DMARC
|
DMARC
|
||||||
<a href="https://en.wikipedia.org/wiki/DMARC"
|
<a href="https://en.wikipedia.org/wiki/DMARC"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer">(Wikipedia↗)</a>
|
||||||
(Wikipedia↗)
|
|
||||||
</a>
|
|
||||||
is designed to protect the domain from unauthorized use, commonly known as email spoofing.
|
is designed to protect the domain from unauthorized use, commonly known as email spoofing.
|
||||||
<br />
|
<br />
|
||||||
Built around SPF and DKIM, a DMARC policy tells the receiving mail server what to do if
|
Built around SPF and DKIM, a DMARC policy tells the receiving mail server what to do if
|
||||||
neither of those authentication methods passes.
|
neither of those authentication methods passes.
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">Add the following TXT DNS record to your domain.</div>
|
||||||
Add the following TXT DNS record to your domain.
|
|
||||||
</div>
|
|
||||||
<div class="mb-2 p-3 dns-record">
|
<div class="mb-2 p-3 dns-record">
|
||||||
Record: TXT
|
Record: TXT
|
||||||
<br />
|
<br />
|
||||||
@ -360,9 +342,7 @@
|
|||||||
<em data-toggle="tooltip"
|
<em data-toggle="tooltip"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
class="clipboard"
|
class="clipboard"
|
||||||
data-clipboard-text="{{ dmarc_record }}">
|
data-clipboard-text="{{ dmarc_record }}">{{ dmarc_record }}</em>
|
||||||
{{ dmarc_record }}
|
|
||||||
</em>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
Some DNS registrar might require a full record path, in this case please use
|
Some DNS registrar might require a full record path, in this case please use
|
||||||
@ -377,13 +357,9 @@
|
|||||||
<input type="hidden" name="form-name" value="check-dmarc">
|
<input type="hidden" name="form-name" value="check-dmarc">
|
||||||
{% if custom_domain.dmarc_verified %}
|
{% if custom_domain.dmarc_verified %}
|
||||||
|
|
||||||
<button type="submit" class="btn btn-outline-primary">
|
<button type="submit" class="btn btn-outline-primary">Re-verify</button>
|
||||||
Re-verify
|
|
||||||
</button>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">Verify</button>
|
||||||
Verify
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
{% if not dmarc_ok %}
|
{% if not dmarc_ok %}
|
||||||
|
@ -34,13 +34,14 @@
|
|||||||
.
|
.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="{% if not custom_domain.catch_all %} disabled-content{% endif %}">
|
<div class="{% if not custom_domain.catch_all %}
|
||||||
|
disabled-content{% endif %}">
|
||||||
<div>
|
<div>
|
||||||
Auto-created aliases are automatically owned by the following mailboxes
|
Auto-created aliases are automatically owned by the following mailboxes
|
||||||
<i class="fe fe-corner-right-down"></i>
|
<i class="fe fe-corner-right-down"></i>
|
||||||
.
|
.
|
||||||
</div>
|
</div>
|
||||||
{% set domain_mailboxes=custom_domain.mailboxes %}
|
{% set domain_mailboxes = custom_domain.mailboxes %}
|
||||||
<form method="post" class="mt-2">
|
<form method="post" class="mt-2">
|
||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="update">
|
<input type="hidden" name="form-name" value="update">
|
||||||
@ -54,10 +55,7 @@
|
|||||||
name="mailbox_ids">
|
name="mailbox_ids">
|
||||||
{% for mailbox in mailboxes %}
|
{% for mailbox in mailboxes %}
|
||||||
|
|
||||||
<option value="{{ mailbox.id }}"
|
<option value="{{ mailbox.id }}" {% if mailbox in domain_mailboxes %}selected{% endif %}>{{ mailbox.email }}</option>
|
||||||
{% if mailbox in domain_mailboxes %} selected{% endif %}>
|
|
||||||
{{ mailbox.email }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
@ -21,8 +21,8 @@
|
|||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
<p>Alternatively you can use your Proton credentials to ensure it's you.</p>
|
<p>Alternatively you can use your Proton credentials to ensure it's you.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-primary btn-block mt-2 proton-button w-25"
|
<a class="btn btn-primary btn-block mt-2 proton-button"
|
||||||
href="{{ url_for('auth.proton_login', next=next) }}">
|
href="{{ url_for('auth.proton_login', next=next) }}" style="max-width: 400px">
|
||||||
<img class="mr-2" src="/static/images/proton.svg" />
|
<img class="mr-2" src="/static/images/proton.svg" />
|
||||||
Authenticate with Proton
|
Authenticate with Proton
|
||||||
</a>
|
</a>
|
||||||
|
@ -45,7 +45,7 @@
|
|||||||
<td>Link a New Key</td>
|
<td>Link a New Key</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<a href="{{ url_for('dashboard.fido_setup') }}">
|
<a href="{{ url_for("dashboard.fido_setup") }}">
|
||||||
<button class="btn btn-outline-success">Link</button>
|
<button class="btn btn-outline-success">Link</button>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
@ -61,8 +61,7 @@
|
|||||||
class="btn btn-success dropdown-toggle btn-group-border-left"
|
class="btn btn-success dropdown-toggle btn-group-border-left"
|
||||||
data-toggle="dropdown"
|
data-toggle="dropdown"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-expanded="false">
|
aria-expanded="false"></button>
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu dropdown-menu-right border-left"
|
<div class="dropdown-menu dropdown-menu-right border-left"
|
||||||
aria-labelledby="btnGroupDrop1">
|
aria-labelledby="btnGroupDrop1">
|
||||||
<div>
|
<div>
|
||||||
@ -125,7 +124,9 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="subheader">Aliases</div>
|
<div class="subheader">Aliases</div>
|
||||||
<div class="text-muted"
|
<div class="text-muted"
|
||||||
style="order: 2; margin-left: auto; font-size: .8rem">All time</div>
|
style="order: 2;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: .8rem">All time</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h1 m-0">{{ stats.nb_alias }}</div>
|
<div class="h1 m-0">{{ stats.nb_alias }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -137,7 +138,9 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="subheader">Forwarded</div>
|
<div class="subheader">Forwarded</div>
|
||||||
<div class="text-muted"
|
<div class="text-muted"
|
||||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
style="order: 2;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: .8rem">Last 14 days</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h1 m-0">{{ stats.nb_forward }}</div>
|
<div class="h1 m-0">{{ stats.nb_forward }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -149,7 +152,9 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="subheader">Replies/Sent</div>
|
<div class="subheader">Replies/Sent</div>
|
||||||
<div class="text-muted"
|
<div class="text-muted"
|
||||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
style="order: 2;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: .8rem">Last 14 days</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h1 m-0">{{ stats.nb_reply }}</div>
|
<div class="h1 m-0">{{ stats.nb_reply }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -161,7 +166,9 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="subheader">Blocked</div>
|
<div class="subheader">Blocked</div>
|
||||||
<div class="text-muted"
|
<div class="text-muted"
|
||||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
style="order: 2;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: .8rem">Last 14 days</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h1 m-0">{{ stats.nb_block }}</div>
|
<div class="h1 m-0">{{ stats.nb_block }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -177,52 +184,28 @@
|
|||||||
<select name="sort"
|
<select name="sort"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
class="form-control mr-3 shadow">
|
class="form-control mr-3 shadow">
|
||||||
<option value="" {% if sort == "" %} selected{% endif %}>
|
<option value="" {% if sort == "" %}selected{% endif %}>Sort by most recent activity</option>
|
||||||
Sort by most recent activity
|
<option value="old2new" {% if sort == "old2new" %}selected{% endif %}>Alias Old-Recent</option>
|
||||||
</option>
|
<option value="new2old" {% if sort == "new2old" %}selected{% endif %}>Alias Recent-Old</option>
|
||||||
<option value="old2new" {% if sort == "old2new" %} selected{% endif %}>
|
<option value="a2z" {% if sort == "a2z" %}selected{% endif %}>Alias A-Z</option>
|
||||||
Alias Old-Recent
|
<option value="z2a" {% if sort == "z2a" %}selected{% endif %}>Alias Z-A</option>
|
||||||
</option>
|
|
||||||
<option value="new2old" {% if sort == "new2old" %} selected{% endif %}>
|
|
||||||
Alias Recent-Old
|
|
||||||
</option>
|
|
||||||
<option value="a2z" {% if sort == "a2z" %} selected{% endif %}>
|
|
||||||
Alias A-Z
|
|
||||||
</option>
|
|
||||||
<option value="z2a" {% if sort == "z2a" %} selected{% endif %}>
|
|
||||||
Alias Z-A
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select name="filter"
|
<select name="filter"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
class="form-control mr-3 shadow"
|
class="form-control mr-3 shadow"
|
||||||
style="max-width: 200px">
|
style="max-width: 200px">
|
||||||
<option value="" {% if filter == "" %} selected{% endif %}>
|
<option value="" {% if filter == "" %}selected{% endif %}>All Aliases</option>
|
||||||
All Aliases
|
<option value="pinned" {% if filter == "pinned" %}selected{% endif %}>Pinned Aliases</option>
|
||||||
</option>
|
<option value="enabled" {% if filter == "enabled" %}selected{% endif %}>Only Enabled Aliases</option>
|
||||||
<option value="pinned" {% if filter == "pinned" %} selected{% endif %}>
|
<option value="disabled" {% if filter == "disabled" %}selected{% endif %}>Only Disabled Aliases</option>
|
||||||
Pinned Aliases
|
<option value="hibp" {% if filter == "hibp" %}selected{% endif %}>Only Aliases Found In Data Breaches</option>
|
||||||
</option>
|
|
||||||
<option value="enabled" {% if filter == "enabled" %} selected{% endif %}>
|
|
||||||
Only Enabled Aliases
|
|
||||||
</option>
|
|
||||||
<option value="disabled" {% if filter == "disabled" %} selected{% endif %}>
|
|
||||||
Only Disabled Aliases
|
|
||||||
</option>
|
|
||||||
<option value="hibp" {% if filter == "hibp" %} selected{% endif %}>
|
|
||||||
Only Aliases Found In Data Breaches
|
|
||||||
</option>
|
|
||||||
{% for mailbox in current_user.mailboxes() %}
|
{% for mailbox in current_user.mailboxes() %}
|
||||||
|
|
||||||
<option value="mailbox:{{ mailbox.id }}" {% if filter == "mailbox:" ~ mailbox.id %}
|
<option value="mailbox:{{ mailbox.id }}" {% if filter == "mailbox:" ~ mailbox.id %}selected{% endif %}>{{ mailbox.email }}'s aliases</option>
|
||||||
selected {% endif %}>
|
|
||||||
{{ mailbox.email }}'s aliases
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for directory in current_user.directories %}
|
{% for directory in current_user.directories %}
|
||||||
|
|
||||||
<option value="directory:{{ directory.id }}" {% if filter == "directory:" ~ directory.id %}
|
<option value="directory:{{ directory.id }}" {% if filter == "directory:" ~ directory.id %}selected{% endif %}>
|
||||||
selected {% endif %}>
|
|
||||||
Directory <b>{{ directory.name }}</b> aliases
|
Directory <b>{{ directory.name }}</b> aliases
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -237,7 +220,7 @@
|
|||||||
<div style="margin-left: auto">
|
<div style="margin-left: auto">
|
||||||
{% if query or sort or filter %}
|
{% if query or sort or filter %}
|
||||||
|
|
||||||
<a href="{{ url_for('dashboard.index') }}"
|
<a href="{{ url_for("dashboard.index") }}"
|
||||||
class="btn btn-outline-secondary">Reset</a>
|
class="btn btn-outline-secondary">Reset</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -251,10 +234,11 @@
|
|||||||
|
|
||||||
{% set alias = alias_info.alias %}
|
{% set alias = alias_info.alias %}
|
||||||
<div class="col-12 col-lg-6" id="alias-container-{{ alias.id }}">
|
<div class="col-12 col-lg-6" id="alias-container-{{ alias.id }}">
|
||||||
<div class="card p-4 shadow-sm {% if alias.id == highlight_alias_id %} highlight-row{% endif %} ">
|
<div class="card p-4 shadow-sm {% if alias.id == highlight_alias_id %}highlight-row{% endif %} ">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<span class="{% if alias.id == highlight_alias_id %} highlighted{% endif %} clipboard cursor mb-0" {% if loop.index ==1 %}
|
<span class="{% if alias.id == highlight_alias_id %}
|
||||||
|
highlighted{% endif %} clipboard cursor mb-0" {% if loop.index ==1 %}
|
||||||
data-intro="This is your first <em>alias</em>.
|
data-intro="This is your first <em>alias</em>.
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
@ -358,7 +342,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex mb-2">
|
<div class="d-flex mb-2">
|
||||||
<div class="flex-grow-1 mr-2">
|
<div class="flex-grow-1 mr-2">
|
||||||
<textarea id="note-{{ alias.id }}" name="note" class="form-control" style="font-size: 12px" rows="2" placeholder="e.g. where the alias is used or why is it created" onchange="handleNoteChange({{ alias.id }}, '{{ alias.email }}')" onfocus="handleNoteFocus({{ alias.id }})" onblur="handleNoteBlur({{ alias.id }})">{{ alias.note or "" }}</textarea>
|
<textarea id="note-{{ alias.id }}"
|
||||||
|
name="note"
|
||||||
|
class="form-control"
|
||||||
|
style="font-size: 12px"
|
||||||
|
rows="2"
|
||||||
|
placeholder="e.g. where the alias is used or why is it created"
|
||||||
|
onchange="handleNoteChange({{ alias.id }}, '{{ alias.email }}')"
|
||||||
|
onfocus="handleNoteFocus({{ alias.id }})"
|
||||||
|
onblur="handleNoteBlur({{ alias.id }})">{{ alias.note or "" }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Send Email && More button -->
|
<!-- Send Email && More button -->
|
||||||
@ -399,27 +391,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- END Send Email && More button -->
|
<!-- END Send Email && More button -->
|
||||||
<!-- Collapse section -->
|
<!-- Collapse section -->
|
||||||
<div class="{% if not current_user.expand_alias_info %} collapse{% endif %} mt-2"
|
<div class="{% if not current_user.expand_alias_info %}
|
||||||
id="alias-{{ alias.id }}">
|
collapse{% endif %} mt-2" id="alias-{{ alias.id }}">
|
||||||
{% if alias_info.latest_email_log != None %}
|
{% if alias_info.latest_email_log != None %}
|
||||||
|
|
||||||
<div style="font-size: 12px">
|
<div style="font-size: 12px">Alias created {{ alias.created_at | dt }}</div>
|
||||||
Alias created {{ alias.created_at | dt }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="alias-activity">{{ alias_info.nb_forward }}</span> forwarded,
|
<span class="alias-activity">{{ alias_info.nb_forward }}</span> forwarded,
|
||||||
<span class="alias-activity">{{ alias_info.nb_blocked }}</span> blocked,
|
<span class="alias-activity">{{ alias_info.nb_blocked }}</span> blocked,
|
||||||
<span class="alias-activity">{{ alias_info.nb_reply }}</span> sent
|
<span class="alias-activity">{{ alias_info.nb_reply }}</span> sent
|
||||||
in the last 14 days
|
in the last 14 days
|
||||||
<a href="{{ url_for('dashboard.alias_log', alias_id=alias.id) }}"
|
<a href="{{ url_for('dashboard.alias_log', alias_id=alias.id) }}"
|
||||||
class="btn btn-sm btn-link">
|
class="btn btn-sm btn-link">See All →</a>
|
||||||
See All →
|
|
||||||
</a>
|
|
||||||
{% if mailboxes|length > 1 %}
|
{% if mailboxes|length > 1 %}
|
||||||
|
|
||||||
<div class="small-text">
|
<div class="small-text">Current mailbox</div>
|
||||||
Current mailbox
|
|
||||||
</div>
|
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<div class="flex-grow-1 mr-2">
|
<div class="flex-grow-1 mr-2">
|
||||||
<select required
|
<select required
|
||||||
@ -431,10 +417,7 @@
|
|||||||
onchange="handleMailboxChange({{ alias.id }}, '{{ alias.email }}')">
|
onchange="handleMailboxChange({{ alias.id }}, '{{ alias.email }}')">
|
||||||
{% for mailbox in mailboxes %}
|
{% for mailbox in mailboxes %}
|
||||||
|
|
||||||
<option value="{{ mailbox.id }}" {% if alias_info.contain_mailbox(mailbox.id) %}
|
<option value="{{ mailbox.id }}" {% if alias_info.contain_mailbox(mailbox.id) %}selected{% endif %}>{{ mailbox.email }}</option>
|
||||||
selected {% endif %}>
|
|
||||||
{{ mailbox.email }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -502,11 +485,7 @@
|
|||||||
<input type="hidden" name="form-name" value="delete-alias">
|
<input type="hidden" name="form-name" value="delete-alias">
|
||||||
<input type="hidden" name="alias-id" value="{{ alias.id }}">
|
<input type="hidden" name="alias-id" value="{{ alias.id }}">
|
||||||
<input type="hidden" name="alias" class="alias" value="{{ alias.email }}">
|
<input type="hidden" name="alias" class="alias" value="{{ alias.email }}">
|
||||||
<span class="btn btn-link btn-sm float-right text-danger"
|
<span class="btn btn-link btn-sm float-right text-danger" onclick="confirmDeleteAlias.call(this)" {% if alias.custom_domain %}data-custom-domain-trash-url="{{ alias.custom_domain.get_trash_url() }}"{% endif %} data-alias="{{ alias.id }}" data-alias-email="{{ alias.email }}">
|
||||||
onclick="confirmDeleteAlias.call(this)"
|
|
||||||
{% if alias.custom_domain %} data-custom-domain-trash-url="{{ alias.custom_domain.get_trash_url() }}"{% endif %}
|
|
||||||
data-alias="{{ alias.id }}"
|
|
||||||
data-alias-email="{{ alias.email }}">
|
|
||||||
Delete <i class="dropdown-icon fe fe-trash-2 text-danger"></i>
|
Delete <i class="dropdown-icon fe fe-trash-2 text-danger"></i>
|
||||||
</span>
|
</span>
|
||||||
</form>
|
</form>
|
||||||
@ -527,14 +506,12 @@
|
|||||||
<nav aria-label="Alias navigation">
|
<nav aria-label="Alias navigation">
|
||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
<li class="page-item mr-1">
|
<li class="page-item mr-1">
|
||||||
<a class="btn btn-outline-primary {% if page == 0 %}disabled{% endif %}"
|
<a class="btn btn-outline-primary {% if page == 0 %}disabled{% endif %}" href="{{ url_for('dashboard.index', page=page-1, query=query, sort=sort, filter=filter) }}">
|
||||||
href="{{ url_for('dashboard.index', page=page-1, query=query, sort=sort, filter=filter) }}">
|
|
||||||
Previous
|
Previous
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="btn btn-outline-primary {% if last_page %}disabled{% endif %}"
|
<a class="btn btn-outline-primary {% if last_page %}disabled{% endif %}" href="{{ url_for('dashboard.index', page=page+1, query=query, sort=sort, filter=filter) }}">
|
||||||
href="{{ url_for('dashboard.index', page=page+1, query=query, sort=sort, filter=filter) }}">
|
|
||||||
Next
|
Next
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -22,21 +22,19 @@
|
|||||||
|
|
||||||
<div class="alert alert-danger" role="alert">This feature is only available in premium plan.</div>
|
<div class="alert alert-danger" role="alert">This feature is only available in premium plan.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="alert alert-primary collapse {% if mailboxes|length == 1 %} show{% endif %}"
|
<div class="alert alert-primary collapse {% if mailboxes|length == 1 %}show{% endif %}" id="howtouse" role="alert">
|
||||||
id="howtouse"
|
|
||||||
role="alert">
|
|
||||||
A <em>mailbox</em> is just another personal email address. When creating a new alias, you could choose
|
A <em>mailbox</em> is just another personal email address. When creating a new alias, you could choose
|
||||||
the
|
the
|
||||||
mailbox that <em>owns</em> this alias, i.e:
|
mailbox that <em>owns</em> this alias, i.e:
|
||||||
<br/>
|
<br />
|
||||||
- all emails sent to this alias will be forwarded to this mailbox
|
- all emails sent to this alias will be forwarded to this mailbox
|
||||||
<br/>
|
<br />
|
||||||
- from this mailbox, you can reply/send emails from the alias.
|
- from this mailbox, you can reply/send emails from the alias.
|
||||||
<br/>
|
<br />
|
||||||
<br/>
|
<br />
|
||||||
When you signed up, a mailbox is automatically created with your email <b>{{ current_user.email }}</b>
|
When you signed up, a mailbox is automatically created with your email <b>{{ current_user.email }}</b>
|
||||||
<br/>
|
<br />
|
||||||
<br/>
|
<br />
|
||||||
The mailbox doesn't have to be your email: it can be your friend's email
|
The mailbox doesn't have to be your email: it can be your friend's email
|
||||||
if you want to create aliases for your buddy.
|
if you want to create aliases for your buddy.
|
||||||
</div>
|
</div>
|
||||||
@ -75,9 +73,9 @@
|
|||||||
</h5>
|
</h5>
|
||||||
<h6 class="card-subtitle mb-2 text-muted">
|
<h6 class="card-subtitle mb-2 text-muted">
|
||||||
Created {{ mailbox.created_at | dt }}
|
Created {{ mailbox.created_at | dt }}
|
||||||
<br/>
|
<br />
|
||||||
<span class="font-weight-bold">{{ mailbox.nb_alias() }}</span> aliases.
|
<span class="font-weight-bold">{{ mailbox.nb_alias() }}</span> aliases.
|
||||||
<br/>
|
<br />
|
||||||
</h6>
|
</h6>
|
||||||
<a href="{{ url_for('dashboard.mailbox_detail_route', mailbox_id=mailbox.id) }}">Edit
|
<a href="{{ url_for('dashboard.mailbox_detail_route', mailbox_id=mailbox.id) }}">Edit
|
||||||
➡</a>
|
➡</a>
|
||||||
@ -92,9 +90,7 @@
|
|||||||
<input type="hidden" name="form-name" value="set-default">
|
<input type="hidden" name="form-name" value="set-default">
|
||||||
<input type="hidden" class="mailbox" value="{{ mailbox.email }}">
|
<input type="hidden" class="mailbox" value="{{ mailbox.email }}">
|
||||||
<input type="hidden" name="mailbox_id" value="{{ mailbox.id }}">
|
<input type="hidden" name="mailbox_id" value="{{ mailbox.id }}">
|
||||||
<button class="card-link btn btn-link {% if mailbox.id == current_user.default_mailbox_id %} disabled{% endif %}">
|
<button class="card-link btn btn-link {% if mailbox.id == current_user.default_mailbox_id %}disabled{% endif %}">Set As Default Mailbox</button>
|
||||||
Set As Default Mailbox
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -105,22 +101,16 @@
|
|||||||
<input type="hidden" class="mailbox" value="{{ mailbox.email }}">
|
<input type="hidden" class="mailbox" value="{{ mailbox.email }}">
|
||||||
<input type="hidden" name="mailbox_id" value="{{ mailbox.id }}">
|
<input type="hidden" name="mailbox_id" value="{{ mailbox.id }}">
|
||||||
<select hidden name="transfer_mailbox_id" value="">
|
<select hidden name="transfer_mailbox_id" value="">
|
||||||
<option value="-1">
|
<option value="-1">Delete my aliases</option>
|
||||||
Delete my aliases
|
|
||||||
</option>
|
|
||||||
{% for mailbox_opt in mailboxes %}
|
{% for mailbox_opt in mailboxes %}
|
||||||
|
|
||||||
{% if mailbox_opt.verified and mailbox_opt.id != mailbox.id %}
|
{% if mailbox_opt.verified and mailbox_opt.id != mailbox.id %}
|
||||||
|
|
||||||
<option value="{{ mailbox_opt.id }}">
|
<option value="{{ mailbox_opt.id }}">{{ mailbox_opt.email }}</option>
|
||||||
{{ mailbox_opt.email }}
|
|
||||||
</option>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<span class="card-link btn btn-link text-danger float-right delete-mailbox {% if mailbox.id == current_user.default_mailbox_id %} disabled{% endif %}">
|
<span class="card-link btn btn-link text-danger float-right delete-mailbox {% if mailbox.id == current_user.default_mailbox_id %}disabled{% endif %}">Delete</span>
|
||||||
Delete
|
|
||||||
</span>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -60,9 +60,7 @@
|
|||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<span class="text-danger">Pending change: {{ pending_email }}</span>
|
<span class="text-danger">Pending change: {{ pending_email }}</span>
|
||||||
<a href="{{ url_for('dashboard.cancel_mailbox_change_route', mailbox_id=mailbox.id) }}"
|
<a href="{{ url_for('dashboard.cancel_mailbox_change_route', mailbox_id=mailbox.id) }}"
|
||||||
class="btn btn-secondary btn-sm">
|
class="btn btn-secondary btn-sm">Cancel mailbox change</a>
|
||||||
Cancel mailbox change
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -123,13 +121,15 @@
|
|||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">PGP Public Key</label>
|
<label class="form-label">PGP Public Key</label>
|
||||||
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="(Drag and drop or paste your pgp public key here) -----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
|
<textarea name="pgp"
|
||||||
|
{% if not current_user.is_premium() %}disabled{% endif %}
|
||||||
|
class="form-control"
|
||||||
|
rows="10"
|
||||||
|
id="pgp-public-key"
|
||||||
|
placeholder="(Drag and drop or paste your pgp public key here) -----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="form-name" value="pgp">
|
<input type="hidden" name="form-name" value="pgp">
|
||||||
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
|
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}disabled{% endif %} value="save">Save</button>
|
||||||
disabled {% endif %} value="save">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
{% if mailbox.pgp_finger_print %}
|
{% if mailbox.pgp_finger_print %}
|
||||||
|
|
||||||
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
|
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
Mailbox <b>{{ mailbox.email }}</b> verified, you can now start creating alias with it
|
Mailbox <b>{{ mailbox.email }}</b> verified, you can now start creating alias with it
|
||||||
</div>
|
</div>
|
||||||
<div class="mx-auto">
|
<div class="mx-auto">
|
||||||
<a href="{{ url_for('dashboard.index') }}" class="btn btn-primary">Go To Home Page</a>
|
<a href="{{ url_for("dashboard.index") }}" class="btn btn-primary">Go To Home Page</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -17,13 +17,9 @@
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-height: 100px;
|
max-height: 100px;
|
||||||
text-overflow: ellipsis;">
|
text-overflow: ellipsis">{{ notification.message | safe }}</div>
|
||||||
{{ notification.message | safe }}
|
|
||||||
</div>
|
|
||||||
<a href="{{ url_for('dashboard.notification_route', notification_id=notification.id) }}"
|
<a href="{{ url_for('dashboard.notification_route', notification_id=notification.id) }}"
|
||||||
class="mt-2 btn btn-outline-primary">
|
class="mt-2 btn btn-outline-primary">More ➡</a>
|
||||||
More ➡
|
|
||||||
</a>
|
|
||||||
<div class="small text-muted mt-2">{{ notification.created_at | dt }}</div>
|
<div class="small text-muted mt-2">{{ notification.created_at | dt }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -36,16 +32,12 @@
|
|||||||
<nav aria-label="Notification navigation">
|
<nav aria-label="Notification navigation">
|
||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
<li class="page-item mr-1">
|
<li class="page-item mr-1">
|
||||||
<a class="btn btn-outline-primary {% if page == 0 %}disabled{% endif %}"
|
<a class="btn btn-outline-primary {% if page == 0 %}disabled{% endif %}" href="{{ url_for('dashboard.notifications_route', page=page-1) }}">
|
||||||
href="{{ url_for('dashboard.notifications_route', page=page-1) }}">
|
|
||||||
Previous
|
Previous
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="btn btn-outline-primary {% if last_page %}disabled{% endif %}"
|
<a class="btn btn-outline-primary {% if last_page %}disabled{% endif %}" href="{{ url_for('dashboard.notifications_route', page=page+1) }}">Next</a>
|
||||||
href="{{ url_for('dashboard.notifications_route', page=page+1) }}">
|
|
||||||
Next
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
if (window.Paddle === undefined) {
|
if (window.Paddle === undefined) {
|
||||||
console.log("cannot load Paddle from CDN");
|
console.log("cannot load Paddle from CDN");
|
||||||
// split string to avoid djlint incorrectly formatting the file
|
// split string to avoid djlint incorrectly formatting the file
|
||||||
document.write('<' + 'script src="/static/vendor/paddle.js"><\/script' + '>');
|
document.write('<' + 'script src="/static/vendor/paddle.js"><\/script ' + '>');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
@ -144,9 +144,7 @@
|
|||||||
{% set sub = current_user.get_paddle_subscription() %}
|
{% set sub = current_user.get_paddle_subscription() %}
|
||||||
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
|
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
disabled>
|
disabled>Current plan</button>
|
||||||
Current plan
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
@ -172,9 +170,7 @@
|
|||||||
<div class="h3 my-3">$4 / month</div>
|
<div class="h3 my-3">$4 / month</div>
|
||||||
<div class="text-center mt-4 mb-6">
|
<div class="text-center mt-4 mb-6">
|
||||||
<button class="btn btn-primary btn-lg w-100"
|
<button class="btn btn-primary btn-lg w-100"
|
||||||
onclick="upgradePaddle({{ PADDLE_MONTHLY_PRODUCT_ID }})">
|
onclick="upgradePaddle({{ PADDLE_MONTHLY_PRODUCT_ID }})">Upgrade to Premium</button>
|
||||||
Upgrade to Premium
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
@ -287,9 +283,7 @@
|
|||||||
{% set sub = current_user.get_paddle_subscription() %}
|
{% set sub = current_user.get_paddle_subscription() %}
|
||||||
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
|
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
disabled>
|
disabled>Current plan</button>
|
||||||
Current plan
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
@ -315,9 +309,7 @@
|
|||||||
<div class="h3 my-3">$30 / year</div>
|
<div class="h3 my-3">$30 / year</div>
|
||||||
<div class="text-center mt-4 mb-6">
|
<div class="text-center mt-4 mb-6">
|
||||||
<button class="btn btn-primary btn-lg w-100"
|
<button class="btn btn-primary btn-lg w-100"
|
||||||
onclick="upgradePaddle({{ PADDLE_YEARLY_PRODUCT_ID }})">
|
onclick="upgradePaddle({{ PADDLE_YEARLY_PRODUCT_ID }})">Upgrade to Premium</button>
|
||||||
Upgrade to Premium
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
@ -447,18 +439,10 @@
|
|||||||
We use <a href="https://paddle.com" target="_blank" rel="noopener noreferrer">Paddle <i class="fe fe-external-link"></i></a> by default for handling payments via credit cards and PayPal. Paddle currently supports the following payment methods:
|
We use <a href="https://paddle.com" target="_blank" rel="noopener noreferrer">Paddle <i class="fe fe-external-link"></i></a> by default for handling payments via credit cards and PayPal. Paddle currently supports the following payment methods:
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>Cards (including Mastercard, Visa, Maestro, American Express, Discover, Diners Club, JCB, UnionPay, and Mada)</li>
|
||||||
Cards (including Mastercard, Visa, Maestro, American Express, Discover, Diners Club, JCB, UnionPay, and Mada)
|
<li>PayPal</li>
|
||||||
</li>
|
<li>Apple Pay</li>
|
||||||
<li>
|
<li>Wire Transfers (ACH/SEPA/BACS)</li>
|
||||||
PayPal
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Apple Pay
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Wire Transfers (ACH/SEPA/BACS)
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
More information can be found on
|
More information can be found on
|
||||||
@ -482,7 +466,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="d-flex justify-content-center">
|
<div class="d-flex justify-content-center">
|
||||||
<a class="btn btn-outline-primary text-center"
|
<a class="btn btn-outline-primary text-center"
|
||||||
href="{{ url_for('dashboard.coinbase_checkout_route') }}"
|
href="{{ url_for("dashboard.coinbase_checkout_route") }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer">
|
||||||
Upgrade to Premium - cryptocurrency
|
Upgrade to Premium - cryptocurrency
|
||||||
@ -520,7 +504,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>
|
<p>
|
||||||
To redeem or buy a coupon, please go to the
|
To redeem or buy a coupon, please go to the
|
||||||
<a href="{{ url_for('dashboard.coupon_route') }}">coupon page</a>. The coupon code can be used by you or given to someone as a gift.
|
<a href="{{ url_for("dashboard.coupon_route") }}">coupon page</a>. The coupon code can be used by you or given to someone as a gift.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -554,18 +538,12 @@
|
|||||||
sending emails. Concretely:
|
sending emails. Concretely:
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>All aliases/domains/directories/mailboxes you have created are kept and continue working normally.</li>
|
||||||
All aliases/domains/directories/mailboxes you have created are kept and continue working normally.
|
<li>You cannot create new aliases if you exceed the free plan limit, i.e. have more than 10 aliases.</li>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
You cannot create new aliases if you exceed the free plan limit, i.e. have more than 10 aliases.
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
As features like catch-all or directory allow you to create aliases on-the-fly, those aliases cannot be automatically created if you have more than 10 aliases.
|
As features like catch-all or directory allow you to create aliases on-the-fly, those aliases cannot be automatically created if you have more than 10 aliases.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>You cannot add new domain, directory or mailbox.</li>
|
||||||
You cannot add new domain, directory or mailbox.
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
For example, if you have 100 aliases by the time your subscription ends, these 100 aliases will continue receiving and sending emails normally. You cannot however create new aliases.
|
For example, if you have 100 aliases by the time your subscription ends, these 100 aliases will continue receiving and sending emails normally. You cannot however create new aliases.
|
||||||
@ -630,19 +608,11 @@
|
|||||||
aria-labelledby="pricing-faq-question-discounts"
|
aria-labelledby="pricing-faq-question-discounts"
|
||||||
data-parent="#pricing-faq">
|
data-parent="#pricing-faq">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>
|
<p>We offer important discounts or free premium for:</p>
|
||||||
We offer important discounts or free premium for:
|
|
||||||
</p>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>students, professors or technical staffs working at an educational institute</li>
|
||||||
students, professors or technical staffs working at an educational institute
|
<li>activists, dissidents or journalists</li>
|
||||||
</li>
|
<li>charity organizations</li>
|
||||||
<li>
|
|
||||||
activists, dissidents or journalists
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
charity organizations
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
Please send us an email at <a href="mailto:support@simplelogin.zendesk.com" target="_blank">support@simplelogin.zendesk.com</a> for more info.
|
Please send us an email at <a href="mailto:support@simplelogin.zendesk.com" target="_blank">support@simplelogin.zendesk.com</a> for more info.
|
||||||
@ -677,9 +647,7 @@
|
|||||||
aria-labelledby="pricing-faq-question-refund"
|
aria-labelledby="pricing-faq-question-refund"
|
||||||
data-parent="#pricing-faq">
|
data-parent="#pricing-faq">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>
|
<p>No we don't have a refund policy because SimpleLogin has a trial period where you can try all premium features.</p>
|
||||||
No we don't have a refund policy because SimpleLogin has a trial period where you can try all premium features.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -34,7 +34,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% for referral in referrals %}
|
{% for referral in referrals %}
|
||||||
|
|
||||||
<div class="card p-4 shadow-sm {% if referral.id == highlight_id %} highlight-row{% endif %}">
|
<div class="card p-4 shadow-sm {% if referral.id == highlight_id %}highlight-row{% endif %}">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="form-name" value="update">
|
<input type="hidden" name="form-name" value="update">
|
||||||
<input type="hidden" name="referral-id" value="{{ referral.id }}">
|
<input type="hidden" name="referral-id" value="{{ referral.id }}">
|
||||||
@ -102,9 +102,7 @@
|
|||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
class="clipboard"
|
class="clipboard"
|
||||||
data-clipboard-text="{{ '?slref=' + referral.code }}"
|
data-clipboard-text="{{ '?slref=' + referral.code }}"
|
||||||
style="overflow-wrap: break-word">
|
style="overflow-wrap: break-word">?slref={{ referral.code }}</em>
|
||||||
?slref={{ referral.code }}
|
|
||||||
</em>
|
|
||||||
to any link on SimpleLogin website.
|
to any link on SimpleLogin website.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -48,7 +48,7 @@
|
|||||||
{% set refused_email = email_log.refused_email %}
|
{% set refused_email = email_log.refused_email %}
|
||||||
{% set contact = email_log.contact %}
|
{% set contact = email_log.contact %}
|
||||||
{% set alias = contact.alias %}
|
{% set alias = contact.alias %}
|
||||||
<div class="card p-4 shadow-sm {% if email_log.id == highlight_id %} highlight-row{% endif %}">
|
<div class="card p-4 shadow-sm {% if email_log.id == highlight_id %}highlight-row{% endif %}">
|
||||||
<div class="small-text">
|
<div class="small-text">
|
||||||
Sent {{ refused_email.created_at | dt }}
|
Sent {{ refused_email.created_at | dt }}
|
||||||
{% if email_log.bounced %}
|
{% if email_log.bounced %}
|
||||||
@ -70,9 +70,7 @@
|
|||||||
To: {{ alias.email }}
|
To: {{ alias.email }}
|
||||||
<a href='{{ url_for("dashboard.index", highlight_alias_id=alias.id) }}'
|
<a href='{{ url_for("dashboard.index", highlight_alias_id=alias.id) }}'
|
||||||
class="text-danger small-text"
|
class="text-danger small-text"
|
||||||
style="text-decoration: underline">
|
style="text-decoration: underline">Disable Alias</a>
|
||||||
Disable Alias
|
|
||||||
</a>
|
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if refused_email.deleted %}
|
{% if refused_email.deleted %}
|
||||||
|
@ -35,7 +35,7 @@
|
|||||||
<div>
|
<div>
|
||||||
{% if paddle_sub.cancelled %}(Cancelled){% endif %}
|
{% if paddle_sub.cancelled %}(Cancelled){% endif %}
|
||||||
{{ paddle_sub.plan_name() }} plan subscribed via Paddle.
|
{{ paddle_sub.plan_name() }} plan subscribed via Paddle.
|
||||||
<a href="{{ url_for('dashboard.billing') }}">Manage Subscription ➡</a>
|
<a href="{{ url_for("dashboard.billing") }}">Manage Subscription ➡</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if manual_sub and manual_sub.is_active() %}
|
{% if manual_sub and manual_sub.is_active() %}
|
||||||
@ -48,7 +48,7 @@
|
|||||||
<br />
|
<br />
|
||||||
To gain additional features and support SimpleLogin you can upgrade to a Premium plan.
|
To gain additional features and support SimpleLogin you can upgrade to a Premium plan.
|
||||||
<br />
|
<br />
|
||||||
<a href="{{ url_for('dashboard.pricing') }}"
|
<a href="{{ url_for("dashboard.pricing") }}"
|
||||||
class="btn btn-sm btn-outline-primary">Upgrade</a>
|
class="btn btn-sm btn-outline-primary">Upgrade</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -61,7 +61,7 @@
|
|||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
If you want to subscribe via the Web instead, please make sure to cancel your subscription
|
If you want to subscribe via the Web instead, please make sure to cancel your subscription
|
||||||
on Apple first.
|
on Apple first.
|
||||||
<a href="{{ url_for('dashboard.pricing') }}">
|
<a href="{{ url_for("dashboard.pricing") }}">
|
||||||
Upgrade <i class="fa fa-arrow-right" aria-hidden="true"></i>
|
Upgrade <i class="fa fa-arrow-right" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -72,7 +72,7 @@
|
|||||||
<div>
|
<div>
|
||||||
Yearly plan subscribed with cryptocurrency which expires on
|
Yearly plan subscribed with cryptocurrency which expires on
|
||||||
{{ coinbase_sub.end_at.format("YYYY-MM-DD") }}.
|
{{ coinbase_sub.end_at.format("YYYY-MM-DD") }}.
|
||||||
<a href="{{ url_for('dashboard.coinbase_checkout_route') }}"
|
<a href="{{ url_for("dashboard.coinbase_checkout_route") }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer">
|
||||||
Extend Subscription <i class="fe fe-external-link"></i>
|
Extend Subscription <i class="fe fe-external-link"></i>
|
||||||
@ -97,11 +97,7 @@
|
|||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="notification-preference">
|
<input type="hidden" name="form-name" value="notification-preference">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox"
|
<input type="checkbox" id="notification" name="notification" {% if current_user.notification %}checked{% endif %} class="form-check-input">
|
||||||
id="notification"
|
|
||||||
name="notification"
|
|
||||||
{% if current_user.notification %} checked{% endif %}
|
|
||||||
class="form-check-input">
|
|
||||||
<label for="notification">I want to be emailed when new features are released.</label>
|
<label for="notification">I want to be emailed when new features are released.</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-primary">Submit</button>
|
<button type="submit" class="btn btn-outline-primary">Submit</button>
|
||||||
@ -172,7 +168,7 @@
|
|||||||
<a class="btn btn-primary mt-2 proton-button"
|
<a class="btn btn-primary mt-2 proton-button"
|
||||||
href="{{ url_for("auth.proton_login", action="link") }}">
|
href="{{ url_for("auth.proton_login", action="link") }}">
|
||||||
<img class="mr-2"
|
<img class="mr-2"
|
||||||
src="{{ url_for('static', filename='images/proton.svg') }}"/>
|
src="{{ url_for('static', filename='images/proton.svg') }}" />
|
||||||
Connect with Proton
|
Connect with Proton
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -189,13 +185,11 @@
|
|||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="change-alias-generator">
|
<input type="hidden" name="form-name" value="change-alias-generator">
|
||||||
<select class="form-control mr-sm-2" name="alias-generator-scheme">
|
<select class="form-control mr-sm-2" name="alias-generator-scheme">
|
||||||
<option value="{{ AliasGeneratorEnum.word.value }}"
|
<option value="{{ AliasGeneratorEnum.word.value }}" {% if current_user.alias_generator == AliasGeneratorEnum.word.value %}selected{% endif %}>
|
||||||
{% if current_user.alias_generator == AliasGeneratorEnum.word.value %} selected{% endif %}>
|
|
||||||
Based on
|
Based on
|
||||||
Random {{ AliasGeneratorEnum.word.name.capitalize() }}
|
Random {{ AliasGeneratorEnum.word.name.capitalize() }}
|
||||||
</option>
|
</option>
|
||||||
<option value="{{ AliasGeneratorEnum.uuid.value }}"
|
<option value="{{ AliasGeneratorEnum.uuid.value }}" {% if current_user.alias_generator == AliasGeneratorEnum.uuid.value %}selected{% endif %}>
|
||||||
{% if current_user.alias_generator == AliasGeneratorEnum.uuid.value %} selected{% endif %}>
|
|
||||||
Based
|
Based
|
||||||
on {{ AliasGeneratorEnum.uuid.name.upper() }}
|
on {{ AliasGeneratorEnum.uuid.name.upper() }}
|
||||||
</option>
|
</option>
|
||||||
@ -209,14 +203,11 @@
|
|||||||
name="form-name"
|
name="form-name"
|
||||||
value="change-random-alias-default-domain">
|
value="change-random-alias-default-domain">
|
||||||
<select class="form-control mr-sm-2" name="random-alias-default-domain">
|
<select class="form-control mr-sm-2" name="random-alias-default-domain">
|
||||||
<option value="">
|
<option value="">Not Selected</option>
|
||||||
Not Selected
|
|
||||||
</option>
|
|
||||||
{% for is_public, domain in current_user.available_domains_for_random_alias() %}
|
{% for is_public, domain in current_user.available_domains_for_random_alias() %}
|
||||||
|
|
||||||
<option value="{{ domain }}" {% if current_user.default_alias_custom_domain_id or current_user.default_alias_public_domain_id %}
|
<option value="{{ domain }}" {% if current_user.default_alias_custom_domain_id or current_user.default_alias_public_domain_id %}
|
||||||
{% if current_user.default_random_alias_domain() == domain %}
|
{% if current_user.default_random_alias_domain() == domain %}selected{% endif %} {% endif %}>
|
||||||
selected {% endif %} {% endif %}>
|
|
||||||
{{ domain }} (
|
{{ domain }} (
|
||||||
{% if is_public %}
|
{% if is_public %}
|
||||||
|
|
||||||
@ -235,14 +226,8 @@
|
|||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="random-alias-suffix">
|
<input type="hidden" name="form-name" value="random-alias-suffix">
|
||||||
<select class="form-control mr-sm-2" name="random-alias-suffix-generator">
|
<select class="form-control mr-sm-2" name="random-alias-suffix-generator">
|
||||||
<option value="0"
|
<option value="0" {% if current_user.random_alias_suffix==0 %}selected{% endif %}>Random word from our dictionary</option>
|
||||||
{% if current_user.random_alias_suffix==0 %} selected{% endif %}>
|
<option value="1" {% if current_user.random_alias_suffix==1 %}selected{% endif %}>Random combination of {{ ALIAS_RAND_SUFFIX_LENGTH }} letter and digits</option>
|
||||||
Random word from our dictionary
|
|
||||||
</option>
|
|
||||||
<option value="1"
|
|
||||||
{% if current_user.random_alias_suffix==1 %} selected{% endif %}>
|
|
||||||
Random combination of {{ ALIAS_RAND_SUFFIX_LENGTH }} letter and digits
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-outline-primary">Update</button>
|
<button class="btn btn-outline-primary">Update</button>
|
||||||
</form>
|
</form>
|
||||||
@ -258,7 +243,7 @@
|
|||||||
|
|
||||||
<div class="alert alert-info" role="alert">
|
<div class="alert alert-info" role="alert">
|
||||||
This feature is only available on Premium plan.
|
This feature is only available on Premium plan.
|
||||||
<a href="{{ url_for('dashboard.pricing') }}"
|
<a href="{{ url_for("dashboard.pricing") }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer">
|
||||||
Upgrade<i class="fe fe-external-link"></i>
|
Upgrade<i class="fe fe-external-link"></i>
|
||||||
@ -273,11 +258,7 @@
|
|||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="enable_data_breach_check">
|
<input type="hidden" name="form-name" value="enable_data_breach_check">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox"
|
<input type="checkbox" id="enable_data_breach_check" name="enable_data_breach_check" {% if current_user.enable_data_breach_check %}checked{% endif %} class="form-check-input">
|
||||||
id="enable_data_breach_check"
|
|
||||||
name="enable_data_breach_check"
|
|
||||||
{% if current_user.enable_data_breach_check %} checked{% endif %}
|
|
||||||
class="form-check-input">
|
|
||||||
<label for="enable_data_breach_check">Enable data breach monitoring</label>
|
<label for="enable_data_breach_check">Enable data breach monitoring</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-primary">Update</button>
|
<button type="submit" class="btn btn-outline-primary">Update</button>
|
||||||
@ -300,30 +281,13 @@
|
|||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="change-sender-format">
|
<input type="hidden" name="form-name" value="change-sender-format">
|
||||||
<select class="form-control mr-sm-2" name="sender-format">
|
<select class="form-control mr-sm-2" name="sender-format">
|
||||||
<option value="{{ SenderFormatEnum.AT.value }}"
|
<option value="{{ SenderFormatEnum.AT.value }}" {% if current_user.sender_format == SenderFormatEnum.AT.value %}selected{% endif %}>John Wick - john at wick.com</option>
|
||||||
{% if current_user.sender_format == SenderFormatEnum.AT.value %} selected{% endif %}>
|
<option value="{{ SenderFormatEnum.A.value }}" {% if current_user.sender_format == SenderFormatEnum.A.value %}selected{% endif %}>John Wick - john(a)wick.com</option>
|
||||||
John Wick - john at wick.com
|
<option value="{{ SenderFormatEnum.NAME_ONLY.value }}" {% if current_user.sender_format == SenderFormatEnum.NAME_ONLY.value %}selected{% endif %}>John Wick</option>
|
||||||
</option>
|
<option value="{{ SenderFormatEnum.AT_ONLY.value }}" {% if current_user.sender_format == SenderFormatEnum.AT_ONLY.value %}selected{% endif %}>john at wick.com</option>
|
||||||
<option value="{{ SenderFormatEnum.A.value }}"
|
<option value="{{ SenderFormatEnum.NO_NAME.value }}" {% if current_user.sender_format == SenderFormatEnum.NO_NAME.value %}selected{% endif %}>No Name (i.e. only reverse-alias)</option>
|
||||||
{% if current_user.sender_format == SenderFormatEnum.A.value %} selected{% endif %}>
|
|
||||||
John Wick - john(a)wick.com
|
|
||||||
</option>
|
|
||||||
<option value="{{ SenderFormatEnum.NAME_ONLY.value }}"
|
|
||||||
{% if current_user.sender_format == SenderFormatEnum.NAME_ONLY.value %} selected{% endif %}>
|
|
||||||
John Wick
|
|
||||||
</option>
|
|
||||||
<option value="{{ SenderFormatEnum.AT_ONLY.value }}"
|
|
||||||
{% if current_user.sender_format == SenderFormatEnum.AT_ONLY.value %} selected{% endif %}>
|
|
||||||
john at wick.com
|
|
||||||
</option>
|
|
||||||
<option value="{{ SenderFormatEnum.NO_NAME.value }}"
|
|
||||||
{% if current_user.sender_format == SenderFormatEnum.NO_NAME.value %} selected{% endif %}>
|
|
||||||
No Name (i.e. only reverse-alias)
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-outline-primary mt-3">
|
<button class="btn btn-outline-primary mt-3">Update</button>
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -333,9 +297,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
Reverse Alias Replacement
|
Reverse Alias Replacement
|
||||||
<div class="badge badge-warning">
|
<div class="badge badge-warning">Experimental</div>
|
||||||
Experimental
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
When replying to a forwarded email, the <b>reverse-alias</b> can be automatically included
|
When replying to a forwarded email, the <b>reverse-alias</b> can be automatically included
|
||||||
@ -347,18 +309,10 @@
|
|||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="replace-ra">
|
<input type="hidden" name="form-name" value="replace-ra">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox"
|
<input type="checkbox" id="replace-ra" name="replace-ra" {% if current_user.replace_reverse_alias %}checked{% endif %} class="form-check-input">
|
||||||
id="replace-ra"
|
<label for="replace-ra">Enable replacing reverse alias</label>
|
||||||
name="replace-ra"
|
|
||||||
{% if current_user.replace_reverse_alias %} checked{% endif %}
|
|
||||||
class="form-check-input">
|
|
||||||
<label for="replace-ra">
|
|
||||||
Enable replacing reverse alias
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-primary">
|
<button type="submit" class="btn btn-outline-primary">Update</button>
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -366,9 +320,7 @@
|
|||||||
<!-- Sender included in reverse-alias -->
|
<!-- Sender included in reverse-alias -->
|
||||||
<div class="card" id="sender-in-ra">
|
<div class="card" id="sender-in-ra">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">
|
<div class="card-title">Include sender address in reverse-alias</div>
|
||||||
Include sender address in reverse-alias
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
If this option is enabled, new reverse-alias will include the sender address (e.g. <span class="italic">sender_domain_com_gibberish@simplelogin.co</span>)
|
If this option is enabled, new reverse-alias will include the sender address (e.g. <span class="italic">sender_domain_com_gibberish@simplelogin.co</span>)
|
||||||
so you can quickly know the sender.
|
so you can quickly know the sender.
|
||||||
@ -385,13 +337,9 @@
|
|||||||
{% if current_user.include_sender_in_reverse_alias is none or current_user.include_sender_in_reverse_alias %}
|
{% if current_user.include_sender_in_reverse_alias is none or current_user.include_sender_in_reverse_alias %}
|
||||||
|
|
||||||
checked {% endif %} class="form-check-input">
|
checked {% endif %} class="form-check-input">
|
||||||
<label for="include-sender-ra">
|
<label for="include-sender-ra">Include sender address in reverse-alias</label>
|
||||||
Include sender address in reverse-alias
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-primary">
|
<button type="submit" class="btn btn-outline-primary">Update</button>
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -399,9 +347,7 @@
|
|||||||
<!-- Always expand alias info -->
|
<!-- Always expand alias info -->
|
||||||
<div class="card" id="expand-alias-info-section">
|
<div class="card" id="expand-alias-info-section">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">
|
<div class="card-title">Always expand alias info</div>
|
||||||
Always expand alias info
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
By default, additional alias info is shown after clicking on the "More" button.
|
By default, additional alias info is shown after clicking on the "More" button.
|
||||||
<br />
|
<br />
|
||||||
@ -412,18 +358,10 @@
|
|||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="expand-alias-info">
|
<input type="hidden" name="form-name" value="expand-alias-info">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox"
|
<input type="checkbox" id="expand-alias-info" name="enable" {% if current_user.expand_alias_info %}checked{% endif %} class="form-check-input">
|
||||||
id="expand-alias-info"
|
<label for="expand-alias-info">Automatically expand alias info</label>
|
||||||
name="enable"
|
|
||||||
{% if current_user.expand_alias_info %} checked{% endif %}
|
|
||||||
class="form-check-input">
|
|
||||||
<label for="expand-alias-info">
|
|
||||||
Automatically expand alias info
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-primary">
|
<button type="submit" class="btn btn-outline-primary">Update</button>
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -431,9 +369,7 @@
|
|||||||
<!-- Include website address in alias -->
|
<!-- Include website address in alias -->
|
||||||
<div class="card" id="include_website_in_one_click_alias">
|
<div class="card" id="include_website_in_one_click_alias">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">
|
<div class="card-title">Include website address in one-click alias creation on browser extension</div>
|
||||||
Include website address in one-click alias creation on browser extension
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
If enabled, the website name will be used as alias prefix
|
If enabled, the website name will be used as alias prefix
|
||||||
when you create an alias via SimpleLogin browser extension via the email input field
|
when you create an alias via SimpleLogin browser extension via the email input field
|
||||||
@ -448,18 +384,10 @@
|
|||||||
name="form-name"
|
name="form-name"
|
||||||
value="include_website_in_one_click_alias">
|
value="include_website_in_one_click_alias">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox"
|
<input type="checkbox" id="include-website-in-alias" name="enable" {% if current_user.include_website_in_one_click_alias %}checked{% endif %} class="form-check-input">
|
||||||
id="include-website-in-alias"
|
<label for="include-website-in-alias">Include website address in alias</label>
|
||||||
name="enable"
|
|
||||||
{% if current_user.include_website_in_one_click_alias %} checked{% endif %}
|
|
||||||
class="form-check-input">
|
|
||||||
<label for="include-website-in-alias">
|
|
||||||
Include website address in alias
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-primary">
|
<button type="submit" class="btn btn-outline-primary">Update</button>
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -479,7 +407,7 @@
|
|||||||
{# <input type="hidden" name="form-name" value="ignore-loop-email">#}
|
{# <input type="hidden" name="form-name" value="ignore-loop-email">#}
|
||||||
{# <div class="form-check">#}
|
{# <div class="form-check">#}
|
||||||
{# <input type="checkbox" id="ignore-loop-email" name="enable"#}
|
{# <input type="checkbox" id="ignore-loop-email" name="enable"#}
|
||||||
{# {% if current_user.ignore_loop_email %} checked{% endif %} class="form-check-input">#}
|
{# {% if current_user.ignore_loop_email %}checked{% endif %} class="form-check-input">#}
|
||||||
{# <label for="ignore-loop-email">Ignore Loop Emails</label>#}
|
{# <label for="ignore-loop-email">Ignore Loop Emails</label>#}
|
||||||
{# </div>#}
|
{# </div>#}
|
||||||
{# <button type="submit" class="btn btn-outline-primary">Update</button>#}
|
{# <button type="submit" class="btn btn-outline-primary">Update</button>#}
|
||||||
@ -490,14 +418,10 @@
|
|||||||
<!-- One-click subscribe -->
|
<!-- One-click subscribe -->
|
||||||
<div class="card" id="one-click-unsubscribe-section">
|
<div class="card" id="one-click-unsubscribe-section">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">
|
<div class="card-title">One-click unsubscribe</div>
|
||||||
One-click unsubscribe
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
On email clients that support the
|
On email clients that support the
|
||||||
<a href="https://simplelogin.io/docs/getting-started/one-click-unsubscribe/">
|
<a href="https://simplelogin.io/docs/getting-started/one-click-unsubscribe/">One-click unsubscribe</a>
|
||||||
One-click unsubscribe
|
|
||||||
</a>
|
|
||||||
button, clicking on it will allow you to do one of these actions:
|
button, clicking on it will allow you to do one of these actions:
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
@ -519,30 +443,20 @@
|
|||||||
<input type="hidden" name="form-name" value="one-click-unsubscribe">
|
<input type="hidden" name="form-name" value="one-click-unsubscribe">
|
||||||
<select class="form-control mr-sm-2" name="unsubscribe-behaviour">
|
<select class="form-control mr-sm-2" name="unsubscribe-behaviour">
|
||||||
<option value="{{ UnsubscribeBehaviourEnum.PreserveOriginal.name }}" {% if current_user.unsub_behaviour.value == UnsubscribeBehaviourEnum.PreserveOriginal.value %}
|
<option value="{{ UnsubscribeBehaviourEnum.PreserveOriginal.name }}" {% if current_user.unsub_behaviour.value == UnsubscribeBehaviourEnum.PreserveOriginal.value %}
|
||||||
selected="selected" {% endif %}>
|
selected="selected" {% endif %}>Preserve the original unsubscribe policy</option>
|
||||||
Preserve the original unsubscribe policy
|
|
||||||
</option>
|
|
||||||
<option value="{{ UnsubscribeBehaviourEnum.DisableAlias.name }}" {% if current_user.unsub_behaviour.value == UnsubscribeBehaviourEnum.DisableAlias.value %}
|
<option value="{{ UnsubscribeBehaviourEnum.DisableAlias.name }}" {% if current_user.unsub_behaviour.value == UnsubscribeBehaviourEnum.DisableAlias.value %}
|
||||||
selected="selected" {% endif %}>
|
selected="selected" {% endif %}>Disable the alias that received the email</option>
|
||||||
Disable the alias that received the email
|
|
||||||
</option>
|
|
||||||
<option value="{{ UnsubscribeBehaviourEnum.BlockContact.name }}" {% if current_user.unsub_behaviour.value == UnsubscribeBehaviourEnum.BlockContact.value %}
|
<option value="{{ UnsubscribeBehaviourEnum.BlockContact.name }}" {% if current_user.unsub_behaviour.value == UnsubscribeBehaviourEnum.BlockContact.value %}
|
||||||
selected="selected" {% endif %}>
|
selected="selected" {% endif %}>Block the sender that sent the original email</option>
|
||||||
Block the sender that sent the original email
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="btn btn-outline-primary">
|
<button type="submit" class="btn btn-outline-primary">Update</button>
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- END One-click subscribe -->
|
<!-- END One-click subscribe -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">
|
<div class="card-title">Quarantine & Bounces</div>
|
||||||
Quarantine & Bounces
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
When an email is refused (or bounced) by your mailbox provider or flagged by
|
When an email is refused (or bounced) by your mailbox provider or flagged by
|
||||||
<a href="https://simplelogin.io/docs/getting-started/anti-phishing/">SimpleLogin anti-phishing program</a>,
|
<a href="https://simplelogin.io/docs/getting-started/anti-phishing/">SimpleLogin anti-phishing program</a>,
|
||||||
@ -552,17 +466,13 @@
|
|||||||
<br />
|
<br />
|
||||||
This is an exceptional case where SimpleLogin temporarily stores the email.
|
This is an exceptional case where SimpleLogin temporarily stores the email.
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ url_for('dashboard.refused_email_route') }}"
|
<a href="{{ url_for("dashboard.refused_email_route") }}"
|
||||||
class="btn btn-outline-primary">
|
class="btn btn-outline-primary">See quarantine & bounce emails</a>
|
||||||
See quarantine & bounce emails
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" id="blocked-behaviour">
|
<div class="card" id="blocked-behaviour">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">
|
<div class="card-title">Disabled alias/Blocked contact</div>
|
||||||
Disabled alias/Blocked contact
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
When an email is sent to a <b>disabled</b> alias or sent from a <b>blocked</b> contact, you can decide what
|
When an email is sent to a <b>disabled</b> alias or sent from a <b>blocked</b> contact, you can decide what
|
||||||
response the sender should see.
|
response the sender should see.
|
||||||
@ -578,26 +488,20 @@
|
|||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="change-blocked-behaviour">
|
<input type="hidden" name="form-name" value="change-blocked-behaviour">
|
||||||
<select class="form-control mr-sm-2" name="blocked-behaviour">
|
<select class="form-control mr-sm-2" name="blocked-behaviour">
|
||||||
<option value="{{ BlockBehaviourEnum.return_2xx.value }}"
|
<option value="{{ BlockBehaviourEnum.return_2xx.value }}" {% if current_user.block_behaviour.value == BlockBehaviourEnum.return_2xx.value %}selected="selected"{% endif %}>
|
||||||
{% if current_user.block_behaviour.value == BlockBehaviourEnum.return_2xx.value %} selected="selected"{% endif %}>
|
|
||||||
Ignore (the sender will see the email as delivered, but you won't receive anything).
|
Ignore (the sender will see the email as delivered, but you won't receive anything).
|
||||||
</option>
|
</option>
|
||||||
<option value="{{ BlockBehaviourEnum.return_5xx.value }}"
|
<option value="{{ BlockBehaviourEnum.return_5xx.value }}" {% if current_user.block_behaviour.value == BlockBehaviourEnum.return_5xx.value %}selected="selected"{% endif %}>
|
||||||
{% if current_user.block_behaviour.value == BlockBehaviourEnum.return_5xx.value %} selected="selected"{% endif %}>
|
|
||||||
Reject (the sender will be told that your alias does not exist).
|
Reject (the sender will be told that your alias does not exist).
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-outline-primary">
|
<button class="btn btn-outline-primary">Update</button>
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" id="sender-header">
|
<div class="card" id="sender-header">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">
|
<div class="card-title">Include original sender in email headers</div>
|
||||||
Include original sender in email headers
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
SimpleLogin forwards emails to your mailbox from the <b>reverse-alias</b> and not from the <b>original</b>
|
SimpleLogin forwards emails to your mailbox from the <b>reverse-alias</b> and not from the <b>original</b>
|
||||||
sender address.
|
sender address.
|
||||||
@ -612,34 +516,24 @@
|
|||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
<input type="hidden" name="form-name" value="sender-header">
|
<input type="hidden" name="form-name" value="sender-header">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox"
|
<input type="checkbox" id="include-sender-header" name="enable" {% if current_user.include_header_email_header %}checked{% endif %} class="form-check-input">
|
||||||
id="include-sender-header"
|
<label for="include-sender-header">Include sender address in email headers</label>
|
||||||
name="enable"
|
|
||||||
{% if current_user.include_header_email_header %} checked{% endif %}
|
|
||||||
class="form-check-input">
|
|
||||||
<label for="include-sender-header">
|
|
||||||
Include sender address in email headers
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-primary">
|
<button type="submit" class="btn btn-outline-primary">Update</button>
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Alias import/export -->
|
<!-- Alias import/export -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">
|
<div class="card-title">Alias import/export</div>
|
||||||
Alias import/export
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
You can import your aliases created on other platforms into SimpleLogin.
|
You can import your aliases created on other platforms into SimpleLogin.
|
||||||
You can also export your aliases to a readable csv format for a future batch import.
|
You can also export your aliases to a readable csv format for a future batch import.
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ url_for('dashboard.batch_import_route') }}"
|
<a href="{{ url_for("dashboard.batch_import_route") }}"
|
||||||
class="btn btn-outline-primary">Batch Import</a>
|
class="btn btn-outline-primary">Batch Import</a>
|
||||||
<a href="{{ url_for('dashboard.alias_export_route') }}"
|
<a href="{{ url_for("dashboard.alias_export_route") }}"
|
||||||
class="btn btn-outline-secondary">Export Aliases</a>
|
class="btn btn-outline-secondary">Export Aliases</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -23,16 +23,14 @@
|
|||||||
|
|
||||||
<div class="alert alert-danger" role="alert">
|
<div class="alert alert-danger" role="alert">
|
||||||
This feature is only available on Premium plan.
|
This feature is only available on Premium plan.
|
||||||
<a href="{{ url_for('dashboard.pricing') }}"
|
<a href="{{ url_for("dashboard.pricing") }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer">
|
||||||
Upgrade<i class="fe fe-external-link"></i>
|
Upgrade<i class="fe fe-external-link"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="alert alert-primary collapse {% if not subdomains %} show{% endif %}"
|
<div class="alert alert-primary collapse {% if not subdomains %}show{% endif %}" id="howtouse" role="alert">
|
||||||
id="howtouse"
|
|
||||||
role="alert">
|
|
||||||
You can use subdomain to quickly create email aliases without opening SimpleLogin app.
|
You can use subdomain to quickly create email aliases without opening SimpleLogin app.
|
||||||
<br />
|
<br />
|
||||||
Handy when you need to quickly give out an email address, for example on a phone call, in a meeting or just
|
Handy when you need to quickly give out an email address, for example on a phone call, in a meeting or just
|
||||||
@ -65,8 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="row {% if current_user.subdomain_quota <= 0 %} disabled-content{% endif %}"
|
<div class="row {% if current_user.subdomain_quota <= 0 %}disabled-content{% endif %}" id="new-subdomain">
|
||||||
id="new-subdomain">
|
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@ -88,12 +85,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Root domain</label>
|
<label>Root domain</label>
|
||||||
<select name="domain" v-model="domain" class="form-control">
|
<select name="domain" v-model="domain" class="form-control">
|
||||||
{% for sl_domain in sl_domains %}
|
{% for sl_domain in sl_domains %}<option value="{{ sl_domain.domain }}">{{ sl_domain.domain }}</option>{% endfor %}
|
||||||
|
|
||||||
<option value="{{ sl_domain.domain }}">
|
|
||||||
{{ sl_domain.domain }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary">Create</button>
|
<button class="btn btn-primary">Create</button>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{% extends "default.html" %}
|
{% extends "default.html" %}
|
||||||
|
|
||||||
{% set active_page = 'dashboard' %}
|
{% set active_page = "dashboard" %}
|
||||||
{% block title %}Support{% endblock %}
|
{% block title %}Support{% endblock %}
|
||||||
{% block default_content %}
|
{% block default_content %}
|
||||||
|
|
||||||
@ -10,7 +10,7 @@
|
|||||||
<div class="card-title mb-3">Report a problem</div>
|
<div class="card-title mb-3">Report a problem</div>
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
If an email cannot be delivered to your mailbox, please check
|
If an email cannot be delivered to your mailbox, please check
|
||||||
<a href="{{ url_for('dashboard.notifications_route') }}">
|
<a href="{{ url_for("dashboard.notifications_route") }}">
|
||||||
your
|
your
|
||||||
notifications
|
notifications
|
||||||
</a>
|
</a>
|
||||||
@ -18,7 +18,7 @@
|
|||||||
<br />
|
<br />
|
||||||
For generic questions, i.e. not related to your account, we recommend to post the question on
|
For generic questions, i.e. not related to your account, we recommend to post the question on
|
||||||
our
|
our
|
||||||
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a> or <a href="https://forum.simplelogin.io/">our official forum</a>
|
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a> or <a href="https://github.com/simple-login/app/discussions">forum</a>
|
||||||
where our community can help answer the question
|
where our community can help answer the question
|
||||||
and other people with the same question can find the answer there.
|
and other people with the same question can find the answer there.
|
||||||
</div>
|
</div>
|
||||||
@ -28,7 +28,12 @@
|
|||||||
<form id="supportZendeskForm" method="post" enctype="multipart/form-data">
|
<form id="supportZendeskForm" method="post" enctype="multipart/form-data">
|
||||||
<div class="mt-4 mb-5">
|
<div class="mt-4 mb-5">
|
||||||
<label for="issueDescription" class="form-label font-weight-bold">What happened?</label>
|
<label for="issueDescription" class="form-label font-weight-bold">What happened?</label>
|
||||||
<textarea class="form-control" required name="ticket_content" id="issueDescription" rows="3" placeholder="Please provide as much information as possible. For example which alias(es), mailbox(es) are affected, if this is a persistent issue...">{{- ticket_content or '' -}}</textarea>
|
<textarea class="form-control"
|
||||||
|
required
|
||||||
|
name="ticket_content"
|
||||||
|
id="issueDescription"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Please provide as much information as possible. For example which alias(es), mailbox(es) are affected, if this is a persistent issue...">{{- ticket_content or '' -}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 font-weight-bold">Attach files to support request</div>
|
<div class="mt-5 font-weight-bold">Attach files to support request</div>
|
||||||
<div class="text-muted">Only images, text and emails are accepted</div>
|
<div class="text-muted">Only images, text and emails are accepted</div>
|
||||||
@ -57,9 +62,7 @@
|
|||||||
<button class="btn btn-outline-primary"
|
<button class="btn btn-outline-primary"
|
||||||
type="button"
|
type="button"
|
||||||
@click="generateRandomAlias"
|
@click="generateRandomAlias"
|
||||||
id="button-addon2">
|
id="button-addon2">Generate a random alias</button>
|
||||||
Generate a random alias
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
|
@ -66,7 +66,7 @@
|
|||||||
{# {{ approval_form.description(#}
|
{# {{ approval_form.description(#}
|
||||||
{# class="form-control", rows="10",#}
|
{# class="form-control", rows="10",#}
|
||||||
{# placeholder="This information is used for approving your application. Please give us as much info as you can, for example where you plan to use SimpleLogin, for which community, etc."#}
|
{# placeholder="This information is used for approving your application. Please give us as much info as you can, for example where you plan to use SimpleLogin, for which community, etc."#}
|
||||||
{# ) }}#}
|
{#) }}#}
|
||||||
{# {{ render_field_errors(approval_form.description) }}#}
|
{# {{ render_field_errors(approval_form.description) }}#}
|
||||||
{# </div>#}
|
{# </div>#}
|
||||||
{##}
|
{##}
|
||||||
|
@ -7,14 +7,9 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">OpenID Connect Discovery Document</label>
|
<label class="form-label">OpenID Connect Discovery Document</label>
|
||||||
<div class="input-group mt-2">
|
<div class="input-group mt-2">
|
||||||
<input type="text"
|
<input type="text" disabled value="{{ URL + "/.well-known/openid-configuration" }}" class="form-control">
|
||||||
disabled
|
|
||||||
value="{{ URL + "/.well-known/openid-configuration" }}"
|
|
||||||
class="form-control">
|
|
||||||
<span class="input-group-append">
|
<span class="input-group-append">
|
||||||
<button data-clipboard-text="{{ URL + "/.well-known/openid-configuration" }}"
|
<button data-clipboard-text="{{ URL + "/.well-known/openid-configuration" }}" class="clipboard btn btn-primary" type="button">
|
||||||
class="clipboard btn btn-primary"
|
|
||||||
type="button">
|
|
||||||
<i class="fe fe-clipboard"></i>
|
<i class="fe fe-clipboard"></i>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@ -23,14 +18,9 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Authorization endpoint</label>
|
<label class="form-label">Authorization endpoint</label>
|
||||||
<div class="input-group mt-2">
|
<div class="input-group mt-2">
|
||||||
<input type="text"
|
<input type="text" disabled value="{{ URL + "/oauth2/authorize" }}" class="form-control">
|
||||||
disabled
|
|
||||||
value="{{ URL + "/oauth2/authorize" }}"
|
|
||||||
class="form-control">
|
|
||||||
<span class="input-group-append">
|
<span class="input-group-append">
|
||||||
<button data-clipboard-text="{{ URL + "/oauth2/authorize" }}"
|
<button data-clipboard-text="{{ URL + "/oauth2/authorize" }}" class="clipboard btn btn-primary" type="button">
|
||||||
class="clipboard btn btn-primary"
|
|
||||||
type="button">
|
|
||||||
<i class="fe fe-clipboard"></i>
|
<i class="fe fe-clipboard"></i>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@ -39,14 +29,9 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Token endpoint</label>
|
<label class="form-label">Token endpoint</label>
|
||||||
<div class="input-group mt-2">
|
<div class="input-group mt-2">
|
||||||
<input type="text"
|
<input type="text" disabled value="{{ URL + "/oauth2/token" }}" class="form-control">
|
||||||
disabled
|
|
||||||
value="{{ URL + "/oauth2/token" }}"
|
|
||||||
class="form-control">
|
|
||||||
<span class="input-group-append">
|
<span class="input-group-append">
|
||||||
<button data-clipboard-text="{{ URL + "/oauth2/token" }}"
|
<button data-clipboard-text="{{ URL + "/oauth2/token" }}" class="clipboard btn btn-primary" type="button">
|
||||||
class="clipboard btn btn-primary"
|
|
||||||
type="button">
|
|
||||||
<i class="fe fe-clipboard"></i>
|
<i class="fe fe-clipboard"></i>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@ -55,14 +40,9 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">UserInfo endpoint</label>
|
<label class="form-label">UserInfo endpoint</label>
|
||||||
<div class="input-group mt-2">
|
<div class="input-group mt-2">
|
||||||
<input type="text"
|
<input type="text" disabled value="{{ URL + "/oauth2/userinfo" }}" class="form-control">
|
||||||
disabled
|
|
||||||
value="{{ URL + "/oauth2/userinfo" }}"
|
|
||||||
class="form-control">
|
|
||||||
<span class="input-group-append">
|
<span class="input-group-append">
|
||||||
<button data-clipboard-text="{{ URL + "/oauth2/userinfo" }}"
|
<button data-clipboard-text="{{ URL + "/oauth2/userinfo" }}" class="clipboard btn btn-primary" type="button">
|
||||||
class="clipboard btn btn-primary"
|
|
||||||
type="button">
|
|
||||||
<i class="fe fe-clipboard"></i>
|
<i class="fe fe-clipboard"></i>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<h1 class="h2">Referral</h1>
|
<h1 class="h2">Referral</h1>
|
||||||
<div>
|
<div>
|
||||||
If you are in the
|
If you are in the
|
||||||
<a href="{{ url_for('dashboard.referral_route') }}">referral</a>
|
<a href="{{ url_for("dashboard.referral_route") }}">referral</a>
|
||||||
program, you can attach a
|
program, you can attach a
|
||||||
referral to this website.
|
referral to this website.
|
||||||
Any SimpleLogin sign up thanks to the SIWSL on your website will be counted towards this referral.
|
Any SimpleLogin sign up thanks to the SIWSL on your website will be counted towards this referral.
|
||||||
@ -17,17 +17,9 @@
|
|||||||
<select class="form-control" name="referral-id" id="client-select">
|
<select class="form-control" name="referral-id" id="client-select">
|
||||||
{% for referral in current_user.referrals %}
|
{% for referral in current_user.referrals %}
|
||||||
|
|
||||||
<option value="{{ referral.id }}"
|
<option value="{{ referral.id }}" {% if client.referral_id == referral.id %}selected{% endif %}>{{ referral.name }}</option>
|
||||||
{% if client.referral_id == referral.id %} selected{% endif %}>
|
|
||||||
{{ referral.name }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if client.referral_id is none %}
|
{% if client.referral_id is none %}<option value="" selected>No referral selected</option>{% endif %}
|
||||||
|
|
||||||
<option value="" selected>
|
|
||||||
No referral selected
|
|
||||||
</option>
|
|
||||||
{% endif %}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<input type="submit" class="btn btn-primary" value="Update">
|
<input type="submit" class="btn btn-primary" value="Update">
|
||||||
|
@ -18,9 +18,7 @@
|
|||||||
How to use <i class="fe fe-chevrons-down"></i>
|
How to use <i class="fe fe-chevrons-down"></i>
|
||||||
</a>
|
</a>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="alert alert-primary collapse {% if not clients %} show{% endif %}"
|
<div class="alert alert-primary collapse {% if not clients %}show{% endif %}" id="howtouse" role="alert">
|
||||||
id="howtouse"
|
|
||||||
role="alert">
|
|
||||||
If you want to integrate SIWSL into your website,
|
If you want to integrate SIWSL into your website,
|
||||||
this page is for you.
|
this page is for you.
|
||||||
<br />
|
<br />
|
||||||
@ -28,14 +26,9 @@
|
|||||||
If you are using a CMS or any system that supports a OpenID Connect plugin, you can just point
|
If you are using a CMS or any system that supports a OpenID Connect plugin, you can just point
|
||||||
it to SimpleLogin OpenID Configuration endpoint 👇
|
it to SimpleLogin OpenID Configuration endpoint 👇
|
||||||
<div class="input-group mt-2">
|
<div class="input-group mt-2">
|
||||||
<input type="text"
|
<input type="text" disabled value="{{ URL + "/.well-known/openid-configuration" }}" class="form-control">
|
||||||
disabled
|
|
||||||
value="{{ URL + "/.well-known/openid-configuration" }}"
|
|
||||||
class="form-control">
|
|
||||||
<span class="input-group-append">
|
<span class="input-group-append">
|
||||||
<button data-clipboard-text="{{ URL + "/.well-known/openid-configuration" }}"
|
<button data-clipboard-text="{{ URL + "/.well-known/openid-configuration" }}" class="clipboard btn btn-primary" type="button">
|
||||||
class="clipboard btn btn-primary"
|
|
||||||
type="button">
|
|
||||||
<i class="fe fe-clipboard"></i>
|
<i class="fe fe-clipboard"></i>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@ -46,7 +39,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="btn-group" role="group" aria-label="Basic example">
|
<div class="btn-group" role="group" aria-label="Basic example">
|
||||||
<a href="{{ url_for('developer.new_client') }}" class="btn btn-primary">New website</a>
|
<a href="{{ url_for("developer.new_client") }}" class="btn btn-primary">New website</a>
|
||||||
<a href="https://simplelogin.io/docs/siwsl/app/"
|
<a href="https://simplelogin.io/docs/siwsl/app/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
@ -2,14 +2,14 @@
|
|||||||
<p style="font-size: 16px;
|
<p style="font-size: 16px;
|
||||||
line-height: 1.625;
|
line-height: 1.625;
|
||||||
color: #51545E;
|
color: #51545E;
|
||||||
margin: .4em 0 1.1875em;">{{ text }}</p>
|
margin: .4em 0 1.1875em">{{ text }}</p>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
<!-- To be used instead of render_text, much better! -->
|
<!-- To be used instead of render_text, much better! -->
|
||||||
{% macro text() %}
|
{% macro text() %}
|
||||||
<p style="font-size: 16px;
|
<p style="font-size: 16px;
|
||||||
line-height: 1.625;
|
line-height: 1.625;
|
||||||
color: #51545E;
|
color: #51545E;
|
||||||
margin: .4em 0 1.1875em;">{{ caller() }}</p>
|
margin: .4em 0 1.1875em">{{ caller() }}</p>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
{% macro render_button(button_text, link) %}
|
{% macro render_button(button_text, link) %}
|
||||||
<!-- Action -->
|
<!-- Action -->
|
||||||
@ -25,12 +25,12 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 30px auto;
|
margin: 30px auto;
|
||||||
padding: 0;">
|
padding: 0">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"
|
<td align="center"
|
||||||
style="word-break: break-word;
|
style="word-break: break-word;
|
||||||
font-family: Helvetica, Arial, sans-serif;
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
font-size: 16px;">
|
font-size: 16px">
|
||||||
<!-- Border based button
|
<!-- Border based button
|
||||||
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
||||||
<table width="100%"
|
<table width="100%"
|
||||||
@ -42,7 +42,7 @@ https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
|||||||
<td align="center"
|
<td align="center"
|
||||||
style="word-break: break-word;
|
style="word-break: break-word;
|
||||||
font-family: Helvetica, Arial, sans-serif;
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
font-size: 16px;">
|
font-size: 16px">
|
||||||
<a href="{{ link }}"
|
<a href="{{ link }}"
|
||||||
class="f-fallback button"
|
class="f-fallback button"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -57,9 +57,7 @@ https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
||||||
-webkit-text-size-adjust: none;
|
-webkit-text-size-adjust: none;
|
||||||
box-sizing: border-box;">
|
box-sizing: border-box">{{ button_text }}</a>
|
||||||
{{ button_text }}
|
|
||||||
</a>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@ -75,25 +73,23 @@ https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
|||||||
padding-top: 25px;
|
padding-top: 25px;
|
||||||
border-top-width: 1px;
|
border-top-width: 1px;
|
||||||
border-top-color: #EAEAEC;
|
border-top-color: #EAEAEC;
|
||||||
border-top-style: solid;">
|
border-top-style: solid">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="word-break: break-word;
|
<td style="word-break: break-word;
|
||||||
font-family: Helvetica, Arial, sans-serif;
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
font-size: 16px;">
|
font-size: 16px">
|
||||||
<p class="f-fallback sub"
|
<p class="f-fallback sub"
|
||||||
style="font-size: 13px;
|
style="font-size: 13px;
|
||||||
line-height: 1.625;
|
line-height: 1.625;
|
||||||
color: #51545E;
|
color: #51545E;
|
||||||
margin: .4em 0 1.1875em;">
|
margin: .4em 0 1.1875em">
|
||||||
If you’re having trouble with the button above, copy and paste the URL below into your web browser.
|
If you’re having trouble with the button above, copy and paste the URL below into your web browser.
|
||||||
</p>
|
</p>
|
||||||
<p class="f-fallback sub"
|
<p class="f-fallback sub"
|
||||||
style="font-size: 13px;
|
style="font-size: 13px;
|
||||||
line-height: 1.625;
|
line-height: 1.625;
|
||||||
color: #51545E;
|
color: #51545E;
|
||||||
margin: .4em 0 1.1875em;">
|
margin: .4em 0 1.1875em">{{ link }}</p>
|
||||||
{{ link }}
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@ -104,14 +100,14 @@ https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
|||||||
cellpadding="0"
|
cellpadding="0"
|
||||||
cellspacing="0"
|
cellspacing="0"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
style="margin: 0 0 21px;">
|
style="margin: 0 0 21px">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="attributes_content"
|
<td class="attributes_content"
|
||||||
style="word-break: break-word;
|
style="word-break: break-word;
|
||||||
font-family: Helvetica, Arial, sans-serif;
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
background-color: #F4F4F7;
|
background-color: #F4F4F7;
|
||||||
padding: 16px;"
|
padding: 16px"
|
||||||
bgcolor="#F4F4F7">
|
bgcolor="#F4F4F7">
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
{% for part in parts %}
|
{% for part in parts %}
|
||||||
@ -121,7 +117,7 @@ https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
|||||||
style="word-break: break-word;
|
style="word-break: break-word;
|
||||||
font-family: Helvetica, Arial, sans-serif;
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 0;">
|
padding: 0">
|
||||||
<div class="f-fallback">
|
<div class="f-fallback">
|
||||||
{{ part }}
|
{{ part }}
|
||||||
<br />
|
<br />
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %}
|
{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %}
|
||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml"
|
<html xmlns="http://www.w3.org/1999/xhtml"
|
||||||
xmlns:v="urn:schemas-microsoft-com:vml"
|
xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
@ -8,7 +8,7 @@
|
|||||||
<!--[if gte mso 15]>
|
<!--[if gte mso 15]>
|
||||||
<xml>
|
<xml>
|
||||||
<o:OfficeDocumentSettings>
|
<o:OfficeDocumentSettings>
|
||||||
<o:AllowPNG/>
|
<o:AllowPNG />
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
</o:OfficeDocumentSettings>
|
</o:OfficeDocumentSettings>
|
||||||
</xml>
|
</xml>
|
||||||
@ -504,7 +504,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
-ms-text-size-adjust: 100%;
|
-ms-text-size-adjust: 100%;
|
||||||
-webkit-text-size-adjust: 100%;">
|
-webkit-text-size-adjust: 100%">
|
||||||
<!--[if !gte mso 9]><!----><span class="mcnPreviewText"
|
<!--[if !gte mso 9]><!----><span class="mcnPreviewText"
|
||||||
style="display:none;
|
style="display:none;
|
||||||
font-size:0px;
|
font-size:0px;
|
||||||
@ -514,7 +514,7 @@
|
|||||||
opacity:0;
|
opacity:0;
|
||||||
overflow:hidden;
|
overflow:hidden;
|
||||||
visibility:hidden;
|
visibility:hidden;
|
||||||
mso-hide:all;"></span><!--<![endif]-->
|
mso-hide:all"></span><!--<![endif]-->
|
||||||
<center>
|
<center>
|
||||||
<table align="center"
|
<table align="center"
|
||||||
border="0"
|
border="0"
|
||||||
@ -531,7 +531,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;">
|
width: 100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"
|
<td align="center"
|
||||||
valign="top"
|
valign="top"
|
||||||
@ -542,7 +542,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
width: 100%;">
|
width: 100%">
|
||||||
<!-- BEGIN TEMPLATE // -->
|
<!-- BEGIN TEMPLATE // -->
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600" style="width:600px;"><tr><td align="center" valign="top" width="600" style="width:600px;">
|
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600" style="width:600px;"><tr><td align="center" valign="top" width="600" style="width:600px;">
|
||||||
@ -557,13 +557,13 @@
|
|||||||
mso-table-rspace: 0pt;
|
mso-table-rspace: 0pt;
|
||||||
-ms-text-size-adjust: 100%;
|
-ms-text-size-adjust: 100%;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
max-width: 600px !important;">
|
max-width: 600px !important">
|
||||||
<tr>
|
<tr>
|
||||||
<td valign="top"
|
<td valign="top"
|
||||||
id="templateHeader"
|
id="templateHeader"
|
||||||
style="mso-line-height-rule: exactly;
|
style="mso-line-height-rule: exactly;
|
||||||
-ms-text-size-adjust: 100%;
|
-ms-text-size-adjust: 100%;
|
||||||
-webkit-text-size-adjust: 100%;">
|
-webkit-text-size-adjust: 100%">
|
||||||
<table border="0"
|
<table border="0"
|
||||||
cellpadding="0"
|
cellpadding="0"
|
||||||
cellspacing="0"
|
cellspacing="0"
|
||||||
@ -574,14 +574,14 @@
|
|||||||
mso-table-lspace: 0pt;
|
mso-table-lspace: 0pt;
|
||||||
mso-table-rspace: 0pt;
|
mso-table-rspace: 0pt;
|
||||||
-ms-text-size-adjust: 100%;
|
-ms-text-size-adjust: 100%;
|
||||||
-webkit-text-size-adjust: 100%;">
|
-webkit-text-size-adjust: 100%">
|
||||||
<tbody class="mcnImageBlockOuter">
|
<tbody class="mcnImageBlockOuter">
|
||||||
<tr>
|
<tr>
|
||||||
<td valign="top"
|
<td valign="top"
|
||||||
style="padding: 16px;
|
style="padding: 16px;
|
||||||
mso-line-height-rule: exactly;
|
mso-line-height-rule: exactly;
|
||||||
-ms-text-size-adjust: 100%;
|
-ms-text-size-adjust: 100%;
|
||||||
-webkit-text-size-adjust: 100%;"
|
-webkit-text-size-adjust: 100%"
|
||||||
class="mcnImageBlockInner">
|
class="mcnImageBlockInner">
|
||||||
<table align="left"
|
<table align="left"
|
||||||
width="100%"
|
width="100%"
|
||||||
@ -594,7 +594,7 @@
|
|||||||
mso-table-lspace: 0pt;
|
mso-table-lspace: 0pt;
|
||||||
mso-table-rspace: 0pt;
|
mso-table-rspace: 0pt;
|
||||||
-ms-text-size-adjust: 100%;
|
-ms-text-size-adjust: 100%;
|
||||||
-webkit-text-size-adjust: 100%;">
|
-webkit-text-size-adjust: 100%">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mcnImageContent"
|
<td class="mcnImageContent"
|
||||||
@ -603,13 +603,22 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
mso-line-height-rule: exactly;
|
mso-line-height-rule: exactly;
|
||||||
-ms-text-size-adjust: 100%;
|
-ms-text-size-adjust: 100%;
|
||||||
-webkit-text-size-adjust: 100%;">
|
-webkit-text-size-adjust: 100%">
|
||||||
<a href="https://proton.me/" target="_blank" style="">
|
<a href="https://proton.me/" target="_blank" style="">
|
||||||
<img align="center"
|
<img align="center"
|
||||||
alt="Proton"
|
alt="Proton"
|
||||||
src="{{ URL }}/static/logo-proton.png"
|
src="{{ URL }}/static/logo-proton.png"
|
||||||
width="190"
|
width="190"
|
||||||
style="width:35.4477%; max-width: 380px; padding-bottom: 0; display: inline !important; vertical-align: bottom; border: 0; height: auto; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; ">
|
style="width:35.4477%;
|
||||||
|
max-width: 380px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
display: inline !important;
|
||||||
|
vertical-align: bottom;
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic">
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -624,7 +633,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td valign="top"
|
<td valign="top"
|
||||||
id="templateBody"
|
id="templateBody"
|
||||||
style="mso-line-height-rule: exactly; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; ">
|
style="mso-line-height-rule: exactly;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%">
|
||||||
{% block greeting %}{% endblock %}
|
{% block greeting %}{% endblock %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
<!-- Sub copy -->
|
<!-- Sub copy -->
|
||||||
|
@ -423,7 +423,7 @@
|
|||||||
.f-fallback {
|
.f-fallback {
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<![endif]-->
|
<![endif]-->
|
||||||
<style type="text/css" rel="stylesheet" media="all">
|
<style type="text/css" rel="stylesheet" media="all">
|
||||||
body {
|
body {
|
||||||
@ -449,7 +449,7 @@
|
|||||||
font-family: Helvetica, Arial, sans-serif;
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
background-color: #F2F4F6;
|
background-color: #F2F4F6;
|
||||||
color: #51545E;
|
color: #51545E;
|
||||||
margin: 0;"
|
margin: 0"
|
||||||
bgcolor="#F2F4F6">
|
bgcolor="#F2F4F6">
|
||||||
<span class="preheader"
|
<span class="preheader"
|
||||||
style="display: none !important;
|
style="display: none !important;
|
||||||
@ -460,7 +460,7 @@
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
max-width: 0;
|
max-width: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
overflow: hidden;">{{ pre_header }}</span>
|
overflow: hidden">{{ pre_header }}</span>
|
||||||
<table class="email-wrapper"
|
<table class="email-wrapper"
|
||||||
width="100%"
|
width="100%"
|
||||||
cellpadding="0"
|
cellpadding="0"
|
||||||
@ -472,13 +472,13 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
background-color: #F2F4F6;
|
background-color: #F2F4F6;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;"
|
padding: 0"
|
||||||
bgcolor="#F2F4F6">
|
bgcolor="#F2F4F6">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"
|
<td align="center"
|
||||||
style="word-break: break-word;
|
style="word-break: break-word;
|
||||||
font-family: Helvetica, Arial, sans-serif;
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
font-size: 16px;">
|
font-size: 16px">
|
||||||
<table class="email-content"
|
<table class="email-content"
|
||||||
width="100%"
|
width="100%"
|
||||||
cellpadding="0"
|
cellpadding="0"
|
||||||
@ -489,14 +489,14 @@
|
|||||||
-premailer-cellpadding: 0;
|
-premailer-cellpadding: 0;
|
||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;">
|
padding: 0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="email-masthead"
|
<td class="email-masthead"
|
||||||
style="word-break: break-word;
|
style="word-break: break-word;
|
||||||
font-family: Helvetica, Arial, sans-serif;
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 25px 0;"
|
padding: 25px 0"
|
||||||
align="center">
|
align="center">
|
||||||
<a href="{{ LANDING_PAGE_URL }}"
|
<a href="{{ LANDING_PAGE_URL }}"
|
||||||
class="f-fallback email-masthead_name"
|
class="f-fallback email-masthead_name"
|
||||||
@ -504,7 +504,7 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
text-shadow: 0 1px 0 white;">
|
text-shadow: 0 1px 0 white">
|
||||||
{% block logo %}<img src="{{ URL }}/static/logo.png" style="width: 150px; margin: auto">{% endblock %}
|
{% block logo %}<img src="{{ URL }}/static/logo.png" style="width: 150px; margin: auto">{% endblock %}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@ -523,7 +523,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
-premailer-width: 100%;
|
-premailer-width: 100%;
|
||||||
-premailer-cellpadding: 0;
|
-premailer-cellpadding: 0;
|
||||||
-premailer-cellspacing: 0;">
|
-premailer-cellspacing: 0">
|
||||||
<table class="email-body_inner"
|
<table class="email-body_inner"
|
||||||
align="center"
|
align="center"
|
||||||
width="750"
|
width="750"
|
||||||
@ -536,7 +536,7 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0;"
|
padding: 0"
|
||||||
bgcolor="#FFFFFF">
|
bgcolor="#FFFFFF">
|
||||||
<!-- Body content -->
|
<!-- Body content -->
|
||||||
<tr>
|
<tr>
|
||||||
@ -544,7 +544,7 @@
|
|||||||
style="word-break: break-word;
|
style="word-break: break-word;
|
||||||
font-family: Helvetica, Arial, sans-serif;
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 30px;">
|
padding: 30px">
|
||||||
<div class="f-fallback">
|
<div class="f-fallback">
|
||||||
{% block greeting %}{% endblock %}
|
{% block greeting %}{% endblock %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
@ -559,7 +559,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="word-break: break-word;
|
<td style="word-break: break-word;
|
||||||
font-family: Helvetica, Arial, sans-serif;
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
font-size: 16px;">
|
font-size: 16px">
|
||||||
<table class="email-footer"
|
<table class="email-footer"
|
||||||
align="center"
|
align="center"
|
||||||
width="750"
|
width="750"
|
||||||
@ -572,20 +572,20 @@
|
|||||||
-premailer-cellspacing: 0;
|
-premailer-cellspacing: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0;">
|
padding: 0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="content-cell"
|
<td class="content-cell"
|
||||||
align="center"
|
align="center"
|
||||||
style="word-break: break-word;
|
style="word-break: break-word;
|
||||||
font-family: Helvetica, Arial, sans-serif;
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 30px;">
|
padding: 30px">
|
||||||
<p class="f-fallback sub align-center"
|
<p class="f-fallback sub align-center"
|
||||||
style="font-size: 13px;
|
style="font-size: 13px;
|
||||||
line-height: 1.625;
|
line-height: 1.625;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #A8AAAF;
|
color: #A8AAAF;
|
||||||
margin: .4em 0 1.1875em;"
|
margin: .4em 0 1.1875em"
|
||||||
align="center">
|
align="center">
|
||||||
© {{ YEAR }} SimpleLogin - a Proton product. All rights reserved.
|
© {{ YEAR }} SimpleLogin - a Proton product. All rights reserved.
|
||||||
<br />
|
<br />
|
||||||
@ -597,7 +597,7 @@
|
|||||||
style="font-size: 13px;
|
style="font-size: 13px;
|
||||||
line-height: 1.625;
|
line-height: 1.625;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: .4em 0 1.1875em;">
|
margin: .4em 0 1.1875em">
|
||||||
<a href="{{ unsubscribe_oneclick }}">Unsubscribe from our newsletter</a>
|
<a href="{{ unsubscribe_oneclick }}">Unsubscribe from our newsletter</a>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -606,7 +606,7 @@
|
|||||||
line-height: 1.625;
|
line-height: 1.625;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #A8AAAF;
|
color: #A8AAAF;
|
||||||
margin: .4em 0 1.1875em;"
|
margin: .4em 0 1.1875em"
|
||||||
align="center">
|
align="center">
|
||||||
<a href="https://app.simplelogin.io/dashboard/support">Do you have a question?</a>
|
<a href="https://app.simplelogin.io/dashboard/support">Do you have a question?</a>
|
||||||
</p>
|
</p>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user