Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
357f0cca57 | |||
8ce90e27f7 | |||
3ecc8d36f9 | |||
14f4829fab | |||
63ac89e952 | |||
8896f00124 | |||
d313c94f77 | |||
39fcf2e48f | |||
41a5a65f51 | |||
1d0c7ec4a0 | |||
4de5b8eb6d | |||
0942f5eba3 | |||
dae6f64482 | |||
e7f0f81d85 | |||
e82190f227 | |||
9002bbad09 | |||
f51d31f431 | |||
c67b97fe32 | |||
bd414b1fc7 |
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
|
||||||
|
|
||||||
|
@ -26,10 +26,15 @@ from app.email_utils import (
|
|||||||
)
|
)
|
||||||
from app.errors import AliasInTrashError
|
from app.errors import AliasInTrashError
|
||||||
from app.events.event_dispatcher import EventDispatcher
|
from app.events.event_dispatcher import EventDispatcher
|
||||||
from app.events.generated.event_pb2 import AliasDeleted, AliasStatusChange, EventContent
|
from app.events.generated.event_pb2 import (
|
||||||
|
AliasDeleted,
|
||||||
|
AliasStatusChanged,
|
||||||
|
EventContent,
|
||||||
|
)
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Alias,
|
Alias,
|
||||||
|
AliasDeleteReason,
|
||||||
CustomDomain,
|
CustomDomain,
|
||||||
Directory,
|
Directory,
|
||||||
User,
|
User,
|
||||||
@ -58,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
|
||||||
@ -88,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
|
||||||
@ -103,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)
|
||||||
|
|
||||||
@ -116,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")
|
||||||
|
|
||||||
@ -143,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)]
|
||||||
@ -150,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
|
||||||
@ -158,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
|
||||||
@ -305,7 +329,12 @@ def try_auto_create_via_domain(address: str) -> Optional[Alias]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def delete_alias(alias: Alias, user: User):
|
def delete_alias(
|
||||||
|
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
|
||||||
Should be used instead of Alias.delete, DomainDeletedAlias.create, DeletedAlias.create
|
Should be used instead of Alias.delete, DomainDeletedAlias.create, DeletedAlias.create
|
||||||
@ -320,6 +349,7 @@ def delete_alias(alias: Alias, user: User):
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
email=alias.email,
|
email=alias.email,
|
||||||
domain_id=alias.custom_domain_id,
|
domain_id=alias.custom_domain_id,
|
||||||
|
reason=reason,
|
||||||
)
|
)
|
||||||
Session.add(domain_deleted_alias)
|
Session.add(domain_deleted_alias)
|
||||||
Session.commit()
|
Session.commit()
|
||||||
@ -328,17 +358,24 @@ def delete_alias(alias: Alias, user: User):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if not DeletedAlias.get_by(email=alias.email):
|
if not DeletedAlias.get_by(email=alias.email):
|
||||||
deleted_alias = DeletedAlias(email=alias.email)
|
deleted_alias = DeletedAlias(email=alias.email, reason=reason)
|
||||||
Session.add(deleted_alias)
|
Session.add(deleted_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(alias_id=alias_id, alias_email=alias_email)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
if commit:
|
||||||
|
Session.commit()
|
||||||
|
|
||||||
|
|
||||||
def aliases_for_mailbox(mailbox: Mailbox) -> [Alias]:
|
def aliases_for_mailbox(mailbox: Mailbox) -> [Alias]:
|
||||||
@ -449,10 +486,12 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
|
|||||||
f"Alias {alias.email} has been received",
|
f"Alias {alias.email} has been received",
|
||||||
render(
|
render(
|
||||||
"transactional/alias-transferred.txt",
|
"transactional/alias-transferred.txt",
|
||||||
|
user=old_user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/alias-transferred.html",
|
"transactional/alias-transferred.html",
|
||||||
|
user=old_user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -468,9 +507,10 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
|
|||||||
|
|
||||||
|
|
||||||
def change_alias_status(alias: Alias, enabled: bool, commit: bool = False):
|
def change_alias_status(alias: Alias, enabled: bool, commit: bool = False):
|
||||||
|
LOG.i(f"Changing alias {alias} enabled to {enabled}")
|
||||||
alias.enabled = enabled
|
alias.enabled = enabled
|
||||||
|
|
||||||
event = AliasStatusChange(
|
event = AliasStatusChanged(
|
||||||
alias_id=alias.id, alias_email=alias.email, enabled=enabled
|
alias_id=alias.id, alias_email=alias.email, enabled=enabled
|
||||||
)
|
)
|
||||||
EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event))
|
EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event))
|
||||||
|
@ -19,6 +19,9 @@ def authorize_request() -> Optional[Tuple[str, int]]:
|
|||||||
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
|
# 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
|
||||||
|
@ -25,7 +25,8 @@ from app.errors import (
|
|||||||
ErrAddressInvalid,
|
ErrAddressInvalid,
|
||||||
)
|
)
|
||||||
from app.extensions import limiter
|
from app.extensions import limiter
|
||||||
from app.models import Alias, Contact, Mailbox, AliasMailbox
|
from app.log import LOG
|
||||||
|
from app.models import Alias, Contact, Mailbox, AliasMailbox, AliasDeleteReason
|
||||||
|
|
||||||
|
|
||||||
@deprecated
|
@deprecated
|
||||||
@ -160,7 +161,7 @@ def delete_alias(alias_id):
|
|||||||
if not alias or alias.user_id != user.id:
|
if not alias or alias.user_id != user.id:
|
||||||
return jsonify(error="Forbidden"), 403
|
return jsonify(error="Forbidden"), 403
|
||||||
|
|
||||||
alias_utils.delete_alias(alias, user)
|
alias_utils.delete_alias(alias, user, AliasDeleteReason.ManualAction)
|
||||||
|
|
||||||
return jsonify(deleted=True), 200
|
return jsonify(deleted=True), 200
|
||||||
|
|
||||||
@ -185,6 +186,7 @@ def toggle_alias(alias_id):
|
|||||||
return jsonify(error="Forbidden"), 403
|
return jsonify(error="Forbidden"), 403
|
||||||
|
|
||||||
alias_utils.change_alias_status(alias, enabled=not alias.enabled)
|
alias_utils.change_alias_status(alias, enabled=not alias.enabled)
|
||||||
|
LOG.i(f"User {user} changed alias {alias} enabled status to {alias.enabled}")
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
return jsonify(enabled=alias.enabled), 200
|
return jsonify(enabled=alias.enabled), 200
|
||||||
|
@ -129,8 +129,8 @@ def auth_register():
|
|||||||
send_email(
|
send_email(
|
||||||
email,
|
email,
|
||||||
"Just one more step to join SimpleLogin",
|
"Just one more step to join SimpleLogin",
|
||||||
render("transactional/code-activation.txt.jinja2", code=code),
|
render("transactional/code-activation.txt.jinja2", user=user, code=code),
|
||||||
render("transactional/code-activation.html", code=code),
|
render("transactional/code-activation.html", user=user, code=code),
|
||||||
)
|
)
|
||||||
|
|
||||||
RegisterEvent(RegisterEvent.ActionType.success, RegisterEvent.Source.api).send()
|
RegisterEvent(RegisterEvent.ActionType.success, RegisterEvent.Source.api).send()
|
||||||
@ -226,8 +226,8 @@ def auth_reactivate():
|
|||||||
send_email(
|
send_email(
|
||||||
email,
|
email,
|
||||||
"Just one more step to join SimpleLogin",
|
"Just one more step to join SimpleLogin",
|
||||||
render("transactional/code-activation.txt.jinja2", code=code),
|
render("transactional/code-activation.txt.jinja2", user=user, code=code),
|
||||||
render("transactional/code-activation.html", code=code),
|
render("transactional/code-activation.html", user=user, code=code),
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify(msg="User needs to confirm their account"), 200
|
return jsonify(msg="User needs to confirm their account"), 200
|
||||||
|
@ -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,31 +40,15 @@ 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:
|
||||||
|
return jsonify(error=e.msg), 400
|
||||||
|
|
||||||
if not is_valid_email(mailbox_email):
|
return (
|
||||||
return jsonify(error=f"{mailbox_email} invalid"), 400
|
jsonify(mailbox_to_dict(new_mailbox)),
|
||||||
elif mailbox_already_used(mailbox_email, user):
|
201,
|
||||||
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 (
|
|
||||||
jsonify(mailbox_to_dict(new_mailbox)),
|
|
||||||
201,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route("/mailboxes/<int:mailbox_id>", methods=["DELETE"])
|
@api_bp.route("/mailboxes/<int:mailbox_id>", methods=["DELETE"])
|
||||||
@ -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,17 +79,18 @@ 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
|
Session.flush()
|
||||||
|
if file:
|
||||||
|
File.delete(file.id)
|
||||||
|
s3.delete(file.path)
|
||||||
Session.flush()
|
Session.flush()
|
||||||
if file:
|
|
||||||
File.delete(file.id)
|
|
||||||
s3.delete(file.path)
|
|
||||||
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()
|
||||||
|
|
||||||
@ -125,4 +126,4 @@ def send_activation_email(user, next_url):
|
|||||||
LOG.d("redirect user to %s after activation", next_url)
|
LOG.d("redirect user to %s after activation", next_url)
|
||||||
activation_link = activation_link + "&next=" + encode_url(next_url)
|
activation_link = activation_link + "&next=" + encode_url(next_url)
|
||||||
|
|
||||||
email_utils.send_activation_email(user.email, activation_link)
|
email_utils.send_activation_email(user, activation_link)
|
||||||
|
@ -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
|
||||||
@ -120,7 +120,7 @@ if POSTFIX_SUBMISSION_TLS:
|
|||||||
else:
|
else:
|
||||||
default_postfix_port = 25
|
default_postfix_port = 25
|
||||||
POSTFIX_PORT = int(os.environ.get("POSTFIX_PORT", default_postfix_port))
|
POSTFIX_PORT = int(os.environ.get("POSTFIX_PORT", default_postfix_port))
|
||||||
POSTFIX_TIMEOUT = os.environ.get("POSTFIX_TIMEOUT", 3)
|
POSTFIX_TIMEOUT = int(os.environ.get("POSTFIX_TIMEOUT", 3))
|
||||||
|
|
||||||
# ["domain1.com", "domain2.com"]
|
# ["domain1.com", "domain2.com"]
|
||||||
OTHER_ALIAS_DOMAINS = sl_getenv("OTHER_ALIAS_DOMAINS", list)
|
OTHER_ALIAS_DOMAINS = sl_getenv("OTHER_ALIAS_DOMAINS", list)
|
||||||
@ -281,6 +281,7 @@ JOB_DELETE_MAILBOX = "delete-mailbox"
|
|||||||
JOB_DELETE_DOMAIN = "delete-domain"
|
JOB_DELETE_DOMAIN = "delete-domain"
|
||||||
JOB_SEND_USER_REPORT = "send-user-report"
|
JOB_SEND_USER_REPORT = "send-user-report"
|
||||||
JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1"
|
JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1"
|
||||||
|
JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events"
|
||||||
|
|
||||||
# for pagination
|
# for pagination
|
||||||
PAGE_LIMIT = 20
|
PAGE_LIMIT = 20
|
||||||
@ -587,3 +588,24 @@ 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)
|
||||||
|
1
app/app/constants.py
Normal file
1
app/app/constants.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
HEADER_ALLOW_API_COOKIES = "X-Sl-Allowcookies"
|
@ -169,7 +169,7 @@ def send_reset_password_email(user):
|
|||||||
|
|
||||||
reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}"
|
reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}"
|
||||||
|
|
||||||
email_utils.send_reset_password_email(user.email, reset_password_link)
|
email_utils.send_reset_password_email(user, reset_password_link)
|
||||||
|
|
||||||
|
|
||||||
def send_change_email_confirmation(user: User, email_change: EmailChange):
|
def send_change_email_confirmation(user: User, email_change: EmailChange):
|
||||||
@ -179,7 +179,7 @@ def send_change_email_confirmation(user: User, email_change: EmailChange):
|
|||||||
|
|
||||||
link = f"{URL}/auth/change_email?code={email_change.code}"
|
link = f"{URL}/auth/change_email?code={email_change.code}"
|
||||||
|
|
||||||
email_utils.send_change_email(email_change.new_email, user.email, link)
|
email_utils.send_change_email(user, email_change.new_email, link)
|
||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])
|
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])
|
||||||
|
@ -12,6 +12,7 @@ from app.extensions import limiter
|
|||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Alias,
|
Alias,
|
||||||
|
AliasDeleteReason,
|
||||||
AliasGeneratorEnum,
|
AliasGeneratorEnum,
|
||||||
User,
|
User,
|
||||||
EmailLog,
|
EmailLog,
|
||||||
@ -143,7 +144,9 @@ def index():
|
|||||||
if request.form.get("form-name") == "delete-alias":
|
if request.form.get("form-name") == "delete-alias":
|
||||||
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, current_user)
|
alias_utils.delete_alias(
|
||||||
|
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":
|
||||||
alias_utils.change_alias_status(alias, enabled=False)
|
alias_utils.change_alias_status(alias, enabled=False)
|
||||||
|
@ -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,120 +50,61 @@ 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,
|
||||||
|
delete_mailbox_form.transfer_mailbox_id.data,
|
||||||
|
)
|
||||||
|
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 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"
|
|
||||||
)
|
|
||||||
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)
|
||||||
mailbox_id = request.form.get("mailbox_id")
|
try:
|
||||||
mailbox = Mailbox.get(mailbox_id)
|
mailbox_id = request.form.get("mailbox_id")
|
||||||
|
mailbox = user_settings.set_default_mailbox(current_user, mailbox_id)
|
||||||
if not mailbox or mailbox.user_id != current_user.id:
|
except user_settings.CannotSetMailbox as e:
|
||||||
flash("Unknown error. Refresh the page", "warning")
|
flash(e.msg, "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():
|
flash(
|
||||||
mailbox_email = (
|
f"You are going to receive an email to confirm {mailbox.email}.",
|
||||||
new_mailbox_form.email.data.lower().strip().replace(" ", "")
|
"success",
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"dashboard.mailbox_detail_route",
|
||||||
|
mailbox_id=mailbox.id,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
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(
|
|
||||||
f"You are going to receive an email to confirm {mailbox_email}.",
|
|
||||||
"success",
|
|
||||||
)
|
|
||||||
|
|
||||||
return redirect(
|
|
||||||
url_for(
|
|
||||||
"dashboard.mailbox_detail_route",
|
|
||||||
mailbox_id=new_mailbox.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard/mailbox.html",
|
"dashboard/mailbox.html",
|
||||||
@ -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,38 +164,11 @@ 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():
|
return redirect(url_for("dashboard.setting"))
|
||||||
flash("You cannot use this domain", "error")
|
|
||||||
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")
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -33,6 +33,7 @@ from flanker.addresslib import address
|
|||||||
from flanker.addresslib.address import EmailAddress
|
from flanker.addresslib.address import EmailAddress
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
@ -68,17 +69,27 @@ VERP_TIME_START = 1640995200
|
|||||||
VERP_HMAC_ALGO = "sha3-224"
|
VERP_HMAC_ALGO = "sha3-224"
|
||||||
|
|
||||||
|
|
||||||
def render(template_name, **kwargs) -> str:
|
def render(template_name: str, user: Optional[User], **kwargs) -> str:
|
||||||
templates_dir = os.path.join(config.ROOT_DIR, "templates", "emails")
|
templates_dir = os.path.join(config.ROOT_DIR, "templates", "emails")
|
||||||
env = Environment(loader=FileSystemLoader(templates_dir))
|
env = Environment(loader=FileSystemLoader(templates_dir))
|
||||||
|
|
||||||
template = env.get_template(template_name)
|
template = env.get_template(template_name)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
if current_user and current_user.is_authenticated:
|
||||||
|
user = current_user
|
||||||
|
|
||||||
|
use_partner_template = False
|
||||||
|
if user:
|
||||||
|
use_partner_template = user.has_used_alias_from_partner()
|
||||||
|
kwargs["user"] = user
|
||||||
|
|
||||||
return template.render(
|
return template.render(
|
||||||
MAX_NB_EMAIL_FREE_PLAN=config.MAX_NB_EMAIL_FREE_PLAN,
|
MAX_NB_EMAIL_FREE_PLAN=config.MAX_NB_EMAIL_FREE_PLAN,
|
||||||
URL=config.URL,
|
URL=config.URL,
|
||||||
LANDING_PAGE_URL=config.LANDING_PAGE_URL,
|
LANDING_PAGE_URL=config.LANDING_PAGE_URL,
|
||||||
YEAR=arrow.now().year,
|
YEAR=arrow.now().year,
|
||||||
|
USE_PARTNER_TEMPLATE=use_partner_template,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -111,53 +122,59 @@ def send_trial_end_soon_email(user):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_activation_email(email, activation_link):
|
def send_activation_email(user: User, activation_link):
|
||||||
send_email(
|
send_email(
|
||||||
email,
|
user.email,
|
||||||
"Just one more step to join SimpleLogin",
|
"Just one more step to join SimpleLogin",
|
||||||
render(
|
render(
|
||||||
"transactional/activation.txt",
|
"transactional/activation.txt",
|
||||||
|
user=user,
|
||||||
activation_link=activation_link,
|
activation_link=activation_link,
|
||||||
email=email,
|
email=user.email,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/activation.html",
|
"transactional/activation.html",
|
||||||
|
user=user,
|
||||||
activation_link=activation_link,
|
activation_link=activation_link,
|
||||||
email=email,
|
email=user.email,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_reset_password_email(email, reset_password_link):
|
def send_reset_password_email(user: User, reset_password_link):
|
||||||
send_email(
|
send_email(
|
||||||
email,
|
user.email,
|
||||||
"Reset your password on SimpleLogin",
|
"Reset your password on SimpleLogin",
|
||||||
render(
|
render(
|
||||||
"transactional/reset-password.txt",
|
"transactional/reset-password.txt",
|
||||||
|
user=user,
|
||||||
reset_password_link=reset_password_link,
|
reset_password_link=reset_password_link,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/reset-password.html",
|
"transactional/reset-password.html",
|
||||||
|
user=user,
|
||||||
reset_password_link=reset_password_link,
|
reset_password_link=reset_password_link,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_change_email(new_email, current_email, link):
|
def send_change_email(user: User, new_email, link):
|
||||||
send_email(
|
send_email(
|
||||||
new_email,
|
new_email,
|
||||||
"Confirm email update on SimpleLogin",
|
"Confirm email update on SimpleLogin",
|
||||||
render(
|
render(
|
||||||
"transactional/change-email.txt",
|
"transactional/change-email.txt",
|
||||||
|
user=user,
|
||||||
link=link,
|
link=link,
|
||||||
new_email=new_email,
|
new_email=new_email,
|
||||||
current_email=current_email,
|
current_email=user.email,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/change-email.html",
|
"transactional/change-email.html",
|
||||||
|
user=user,
|
||||||
link=link,
|
link=link,
|
||||||
new_email=new_email,
|
new_email=new_email,
|
||||||
current_email=current_email,
|
current_email=user.email,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -170,28 +187,32 @@ def send_invalid_totp_login_email(user, totp_type):
|
|||||||
"Unsuccessful attempt to login to your SimpleLogin account",
|
"Unsuccessful attempt to login to your SimpleLogin account",
|
||||||
render(
|
render(
|
||||||
"transactional/invalid-totp-login.txt",
|
"transactional/invalid-totp-login.txt",
|
||||||
|
user=user,
|
||||||
type=totp_type,
|
type=totp_type,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/invalid-totp-login.html",
|
"transactional/invalid-totp-login.html",
|
||||||
|
user=user,
|
||||||
type=totp_type,
|
type=totp_type,
|
||||||
),
|
),
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_test_email_alias(email, name):
|
def send_test_email_alias(user: User, email: str):
|
||||||
send_email(
|
send_email(
|
||||||
email,
|
email,
|
||||||
f"This email is sent to {email}",
|
f"This email is sent to {email}",
|
||||||
render(
|
render(
|
||||||
"transactional/test-email.txt",
|
"transactional/test-email.txt",
|
||||||
name=name,
|
user=user,
|
||||||
|
name=user.name,
|
||||||
alias=email,
|
alias=email,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/test-email.html",
|
"transactional/test-email.html",
|
||||||
name=name,
|
user=user,
|
||||||
|
name=user.name,
|
||||||
alias=email,
|
alias=email,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -206,11 +227,13 @@ def send_cannot_create_directory_alias(user, alias_address, directory_name):
|
|||||||
f"Alias {alias_address} cannot be created",
|
f"Alias {alias_address} cannot be created",
|
||||||
render(
|
render(
|
||||||
"transactional/cannot-create-alias-directory.txt",
|
"transactional/cannot-create-alias-directory.txt",
|
||||||
|
user=user,
|
||||||
alias=alias_address,
|
alias=alias_address,
|
||||||
directory=directory_name,
|
directory=directory_name,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/cannot-create-alias-directory.html",
|
"transactional/cannot-create-alias-directory.html",
|
||||||
|
user=user,
|
||||||
alias=alias_address,
|
alias=alias_address,
|
||||||
directory=directory_name,
|
directory=directory_name,
|
||||||
),
|
),
|
||||||
@ -228,11 +251,13 @@ def send_cannot_create_directory_alias_disabled(user, alias_address, directory_n
|
|||||||
f"Alias {alias_address} cannot be created",
|
f"Alias {alias_address} cannot be created",
|
||||||
render(
|
render(
|
||||||
"transactional/cannot-create-alias-directory-disabled.txt",
|
"transactional/cannot-create-alias-directory-disabled.txt",
|
||||||
|
user=user,
|
||||||
alias=alias_address,
|
alias=alias_address,
|
||||||
directory=directory_name,
|
directory=directory_name,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/cannot-create-alias-directory-disabled.html",
|
"transactional/cannot-create-alias-directory-disabled.html",
|
||||||
|
user=user,
|
||||||
alias=alias_address,
|
alias=alias_address,
|
||||||
directory=directory_name,
|
directory=directory_name,
|
||||||
),
|
),
|
||||||
@ -248,11 +273,13 @@ def send_cannot_create_domain_alias(user, alias, domain):
|
|||||||
f"Alias {alias} cannot be created",
|
f"Alias {alias} cannot be created",
|
||||||
render(
|
render(
|
||||||
"transactional/cannot-create-alias-domain.txt",
|
"transactional/cannot-create-alias-domain.txt",
|
||||||
|
user=user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/cannot-create-alias-domain.html",
|
"transactional/cannot-create-alias-domain.html",
|
||||||
|
user=user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
),
|
),
|
||||||
@ -521,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
|
||||||
|
|
||||||
|
|
||||||
@ -919,10 +948,20 @@ def decode_text(text: str, encoding: EmailEncoding = EmailEncoding.NO) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def add_header(msg: Message, text_header, html_header=None) -> Message:
|
def add_header(
|
||||||
|
msg: Message, text_header, html_header=None, subject_prefix=None
|
||||||
|
) -> Message:
|
||||||
if not html_header:
|
if not html_header:
|
||||||
html_header = text_header.replace("\n", "<br>")
|
html_header = text_header.replace("\n", "<br>")
|
||||||
|
|
||||||
|
if subject_prefix is not None:
|
||||||
|
subject = msg[headers.SUBJECT]
|
||||||
|
if not subject:
|
||||||
|
msg.add_header(headers.SUBJECT, subject_prefix)
|
||||||
|
else:
|
||||||
|
subject = f"{subject_prefix} {subject}"
|
||||||
|
msg.replace_header(headers.SUBJECT, subject)
|
||||||
|
|
||||||
content_type = msg.get_content_type().lower()
|
content_type = msg.get_content_type().lower()
|
||||||
if content_type == "text/plain":
|
if content_type == "text/plain":
|
||||||
encoding = get_encoding(msg)
|
encoding = get_encoding(msg)
|
||||||
@ -1253,6 +1292,7 @@ def spf_pass(
|
|||||||
f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address",
|
f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address",
|
||||||
render(
|
render(
|
||||||
"transactional/spf-fail.txt",
|
"transactional/spf-fail.txt",
|
||||||
|
user=user,
|
||||||
alias=alias.email,
|
alias=alias.email,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf",
|
mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf",
|
||||||
@ -1262,6 +1302,7 @@ def spf_pass(
|
|||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/spf-fail.html",
|
"transactional/spf-fail.html",
|
||||||
|
user=user,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf",
|
mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf",
|
||||||
to_email=contact_email,
|
to_email=contact_email,
|
||||||
|
@ -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,49 @@ 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
|
return
|
||||||
|
|
||||||
|
if config.EVENT_WEBHOOK_ENABLED_USER_IDS is not None:
|
||||||
|
if user.id not in config.EVENT_WEBHOOK_ENABLED_USER_IDS:
|
||||||
|
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(
|
||||||
@ -53,6 +84,8 @@ class EventDispatcher:
|
|||||||
|
|
||||||
serialized = event.SerializeToString()
|
serialized = event.SerializeToString()
|
||||||
dispatcher.send(serialized)
|
dispatcher.send(serialized)
|
||||||
|
newrelic.agent.record_custom_metric("Custom/events_stored", 1)
|
||||||
|
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]:
|
||||||
|
@ -1,12 +1,22 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||||
|
# NO CHECKED-IN PROTOBUF GENCODE
|
||||||
# source: event.proto
|
# source: event.proto
|
||||||
# Protobuf Python Version: 5.26.1
|
# Protobuf Python Version: 5.27.0
|
||||||
"""Generated protocol buffer code."""
|
"""Generated protocol buffer code."""
|
||||||
from google.protobuf import descriptor as _descriptor
|
from google.protobuf import descriptor as _descriptor
|
||||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||||
|
from google.protobuf import runtime_version as _runtime_version
|
||||||
from google.protobuf import symbol_database as _symbol_database
|
from google.protobuf import symbol_database as _symbol_database
|
||||||
from google.protobuf.internal import builder as _builder
|
from google.protobuf.internal import builder as _builder
|
||||||
|
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||||
|
_runtime_version.Domain.PUBLIC,
|
||||||
|
5,
|
||||||
|
27,
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
'event.proto'
|
||||||
|
)
|
||||||
# @@protoc_insertion_point(imports)
|
# @@protoc_insertion_point(imports)
|
||||||
|
|
||||||
_sym_db = _symbol_database.Default()
|
_sym_db = _symbol_database.Default()
|
||||||
@ -14,25 +24,27 @@ _sym_db = _symbol_database.Default()
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\x12simplelogin_events\"\'\n\x0eUserPlanChange\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\"K\n\x11\x41liasStatusChange\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\"\xce\x02\n\x0c\x45ventContent\x12>\n\x10user_plan_change\x18\x01 \x01(\x0b\x32\".simplelogin_events.UserPlanChangeH\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\x44\n\x13\x61lias_status_change\x18\x04 \x01(\x0b\x32%.simplelogin_events.AliasStatusChangeH\x00\x12\x39\n\ralias_deleted\x18\x05 \x01(\x0b\x32 .simplelogin_events.AliasDeletedH\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\"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')
|
||||||
|
|
||||||
_globals = globals()
|
_globals = globals()
|
||||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'event_pb2', _globals)
|
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'event_pb2', _globals)
|
||||||
if not _descriptor._USE_C_DESCRIPTORS:
|
if not _descriptor._USE_C_DESCRIPTORS:
|
||||||
DESCRIPTOR._loaded_options = None
|
DESCRIPTOR._loaded_options = None
|
||||||
_globals['_USERPLANCHANGE']._serialized_start=35
|
_globals['_USERPLANCHANGED']._serialized_start=35
|
||||||
_globals['_USERPLANCHANGE']._serialized_end=74
|
_globals['_USERPLANCHANGED']._serialized_end=75
|
||||||
_globals['_USERDELETED']._serialized_start=76
|
_globals['_USERDELETED']._serialized_start=77
|
||||||
_globals['_USERDELETED']._serialized_end=89
|
_globals['_USERDELETED']._serialized_end=90
|
||||||
_globals['_ALIASCREATED']._serialized_start=91
|
_globals['_ALIASCREATED']._serialized_start=92
|
||||||
_globals['_ALIASCREATED']._serialized_end=181
|
_globals['_ALIASCREATED']._serialized_end=182
|
||||||
_globals['_ALIASSTATUSCHANGE']._serialized_start=183
|
_globals['_ALIASSTATUSCHANGED']._serialized_start=184
|
||||||
_globals['_ALIASSTATUSCHANGE']._serialized_end=258
|
_globals['_ALIASSTATUSCHANGED']._serialized_end=260
|
||||||
_globals['_ALIASDELETED']._serialized_start=260
|
_globals['_ALIASDELETED']._serialized_start=262
|
||||||
_globals['_ALIASDELETED']._serialized_end=313
|
_globals['_ALIASDELETED']._serialized_end=315
|
||||||
_globals['_EVENTCONTENT']._serialized_start=316
|
_globals['_ALIASCREATEDLIST']._serialized_start=317
|
||||||
_globals['_EVENTCONTENT']._serialized_end=650
|
_globals['_ALIASCREATEDLIST']._serialized_end=385
|
||||||
_globals['_EVENT']._serialized_start=652
|
_globals['_EVENTCONTENT']._serialized_start=388
|
||||||
_globals['_EVENT']._serialized_end=773
|
_globals['_EVENTCONTENT']._serialized_end=791
|
||||||
|
_globals['_EVENT']._serialized_start=793
|
||||||
|
_globals['_EVENT']._serialized_end=914
|
||||||
# @@protoc_insertion_point(module_scope)
|
# @@protoc_insertion_point(module_scope)
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
|
from google.protobuf.internal import containers as _containers
|
||||||
from google.protobuf import descriptor as _descriptor
|
from google.protobuf import descriptor as _descriptor
|
||||||
from google.protobuf import message as _message
|
from google.protobuf import message as _message
|
||||||
from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||||
|
|
||||||
DESCRIPTOR: _descriptor.FileDescriptor
|
DESCRIPTOR: _descriptor.FileDescriptor
|
||||||
|
|
||||||
class UserPlanChange(_message.Message):
|
class UserPlanChanged(_message.Message):
|
||||||
__slots__ = ("plan_end_time",)
|
__slots__ = ("plan_end_time",)
|
||||||
PLAN_END_TIME_FIELD_NUMBER: _ClassVar[int]
|
PLAN_END_TIME_FIELD_NUMBER: _ClassVar[int]
|
||||||
plan_end_time: int
|
plan_end_time: int
|
||||||
@ -26,7 +27,7 @@ class AliasCreated(_message.Message):
|
|||||||
enabled: bool
|
enabled: bool
|
||||||
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ..., alias_note: _Optional[str] = ..., enabled: bool = ...) -> None: ...
|
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ..., alias_note: _Optional[str] = ..., enabled: bool = ...) -> None: ...
|
||||||
|
|
||||||
class AliasStatusChange(_message.Message):
|
class AliasStatusChanged(_message.Message):
|
||||||
__slots__ = ("alias_id", "alias_email", "enabled")
|
__slots__ = ("alias_id", "alias_email", "enabled")
|
||||||
ALIAS_ID_FIELD_NUMBER: _ClassVar[int]
|
ALIAS_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
ALIAS_EMAIL_FIELD_NUMBER: _ClassVar[int]
|
ALIAS_EMAIL_FIELD_NUMBER: _ClassVar[int]
|
||||||
@ -44,19 +45,27 @@ class AliasDeleted(_message.Message):
|
|||||||
alias_email: str
|
alias_email: str
|
||||||
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ...) -> None: ...
|
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ...) -> None: ...
|
||||||
|
|
||||||
|
class AliasCreatedList(_message.Message):
|
||||||
|
__slots__ = ("events",)
|
||||||
|
EVENTS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
events: _containers.RepeatedCompositeFieldContainer[AliasCreated]
|
||||||
|
def __init__(self, events: _Optional[_Iterable[_Union[AliasCreated, _Mapping]]] = ...) -> None: ...
|
||||||
|
|
||||||
class EventContent(_message.Message):
|
class EventContent(_message.Message):
|
||||||
__slots__ = ("user_plan_change", "user_deleted", "alias_created", "alias_status_change", "alias_deleted")
|
__slots__ = ("user_plan_change", "user_deleted", "alias_created", "alias_status_change", "alias_deleted", "alias_create_list")
|
||||||
USER_PLAN_CHANGE_FIELD_NUMBER: _ClassVar[int]
|
USER_PLAN_CHANGE_FIELD_NUMBER: _ClassVar[int]
|
||||||
USER_DELETED_FIELD_NUMBER: _ClassVar[int]
|
USER_DELETED_FIELD_NUMBER: _ClassVar[int]
|
||||||
ALIAS_CREATED_FIELD_NUMBER: _ClassVar[int]
|
ALIAS_CREATED_FIELD_NUMBER: _ClassVar[int]
|
||||||
ALIAS_STATUS_CHANGE_FIELD_NUMBER: _ClassVar[int]
|
ALIAS_STATUS_CHANGE_FIELD_NUMBER: _ClassVar[int]
|
||||||
ALIAS_DELETED_FIELD_NUMBER: _ClassVar[int]
|
ALIAS_DELETED_FIELD_NUMBER: _ClassVar[int]
|
||||||
user_plan_change: UserPlanChange
|
ALIAS_CREATE_LIST_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
user_plan_change: UserPlanChanged
|
||||||
user_deleted: UserDeleted
|
user_deleted: UserDeleted
|
||||||
alias_created: AliasCreated
|
alias_created: AliasCreated
|
||||||
alias_status_change: AliasStatusChange
|
alias_status_change: AliasStatusChanged
|
||||||
alias_deleted: AliasDeleted
|
alias_deleted: AliasDeleted
|
||||||
def __init__(self, user_plan_change: _Optional[_Union[UserPlanChange, _Mapping]] = ..., user_deleted: _Optional[_Union[UserDeleted, _Mapping]] = ..., alias_created: _Optional[_Union[AliasCreated, _Mapping]] = ..., alias_status_change: _Optional[_Union[AliasStatusChange, _Mapping]] = ..., alias_deleted: _Optional[_Union[AliasDeleted, _Mapping]] = ...) -> None: ...
|
alias_create_list: AliasCreatedList
|
||||||
|
def __init__(self, user_plan_change: _Optional[_Union[UserPlanChanged, _Mapping]] = ..., user_deleted: _Optional[_Union[UserDeleted, _Mapping]] = ..., alias_created: _Optional[_Union[AliasCreated, _Mapping]] = ..., alias_status_change: _Optional[_Union[AliasStatusChanged, _Mapping]] = ..., alias_deleted: _Optional[_Union[AliasDeleted, _Mapping]] = ..., alias_create_list: _Optional[_Union[AliasCreatedList, _Mapping]] = ...) -> None: ...
|
||||||
|
|
||||||
class Event(_message.Message):
|
class Event(_message.Message):
|
||||||
__slots__ = ("user_id", "external_user_id", "partner_id", "content")
|
__slots__ = ("user_id", "external_user_id", "partner_id", "content")
|
||||||
|
@ -64,6 +64,7 @@ More info on https://simplelogin.io/docs/getting-started/anti-phishing/
|
|||||||
msg,
|
msg,
|
||||||
warning_plain_text,
|
warning_plain_text,
|
||||||
warning_html,
|
warning_html,
|
||||||
|
subject_prefix="[Possible phishing attempt]",
|
||||||
)
|
)
|
||||||
return changed_msg, None
|
return changed_msg, None
|
||||||
|
|
||||||
@ -76,6 +77,7 @@ More info on https://simplelogin.io/docs/getting-started/anti-phishing/
|
|||||||
msg,
|
msg,
|
||||||
warning_plain_text,
|
warning_plain_text,
|
||||||
warning_html,
|
warning_html,
|
||||||
|
subject_prefix="[Possible phishing attempt]",
|
||||||
)
|
)
|
||||||
return changed_msg, None
|
return changed_msg, None
|
||||||
|
|
||||||
@ -104,12 +106,14 @@ More info on https://simplelogin.io/docs/getting-started/anti-phishing/
|
|||||||
f"An email sent to {alias.email} has been quarantined",
|
f"An email sent to {alias.email} has been quarantined",
|
||||||
render(
|
render(
|
||||||
"transactional/message-quarantine-dmarc.txt.jinja2",
|
"transactional/message-quarantine-dmarc.txt.jinja2",
|
||||||
|
user=user,
|
||||||
from_header=from_header,
|
from_header=from_header,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
refused_email_url=email_log.get_dashboard_url(),
|
refused_email_url=email_log.get_dashboard_url(),
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/message-quarantine-dmarc.html",
|
"transactional/message-quarantine-dmarc.html",
|
||||||
|
user=user,
|
||||||
from_header=from_header,
|
from_header=from_header,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
refused_email_url=email_log.get_dashboard_url(),
|
refused_email_url=email_log.get_dashboard_url(),
|
||||||
@ -174,12 +178,14 @@ def apply_dmarc_policy_for_reply_phase(
|
|||||||
f"Attempt to send an email to your contact {contact_recipient.email} from {envelope.mail_from}",
|
f"Attempt to send an email to your contact {contact_recipient.email} from {envelope.mail_from}",
|
||||||
render(
|
render(
|
||||||
"transactional/spoof-reply.txt.jinja2",
|
"transactional/spoof-reply.txt.jinja2",
|
||||||
|
user=alias_from.user,
|
||||||
contact=contact_recipient,
|
contact=contact_recipient,
|
||||||
alias=alias_from,
|
alias=alias_from,
|
||||||
sender=envelope.mail_from,
|
sender=envelope.mail_from,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/spoof-reply.html",
|
"transactional/spoof-reply.html",
|
||||||
|
user=alias_from.user,
|
||||||
contact=contact_recipient,
|
contact=contact_recipient,
|
||||||
alias=alias_from,
|
alias=alias_from,
|
||||||
sender=envelope.mail_from,
|
sender=envelope.mail_from,
|
||||||
|
@ -319,11 +319,13 @@ def report_complaint_to_user_in_forward_phase(
|
|||||||
f"Abuse report from {capitalized_name}",
|
f"Abuse report from {capitalized_name}",
|
||||||
render(
|
render(
|
||||||
"transactional/provider-complaint-forward-phase.txt.jinja2",
|
"transactional/provider-complaint-forward-phase.txt.jinja2",
|
||||||
|
user=user,
|
||||||
email=mailbox_email,
|
email=mailbox_email,
|
||||||
provider=capitalized_name,
|
provider=capitalized_name,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/provider-complaint-forward-phase.html",
|
"transactional/provider-complaint-forward-phase.html",
|
||||||
|
user=user,
|
||||||
email=mailbox_email,
|
email=mailbox_email,
|
||||||
provider=capitalized_name,
|
provider=capitalized_name,
|
||||||
),
|
),
|
||||||
|
@ -102,6 +102,7 @@ class UnsubscribeHandler:
|
|||||||
mailbox.email, alias
|
mailbox.email, alias
|
||||||
):
|
):
|
||||||
return status.E509
|
return status.E509
|
||||||
|
LOG.i(f"User disabled alias {alias} via unsubscribe header")
|
||||||
alias_utils.change_alias_status(alias, enabled=False)
|
alias_utils.change_alias_status(alias, enabled=False)
|
||||||
Session.commit()
|
Session.commit()
|
||||||
enable_alias_url = config.URL + f"/dashboard/?highlight_alias_id={alias.id}"
|
enable_alias_url = config.URL + f"/dashboard/?highlight_alias_id={alias.id}"
|
||||||
|
51
app/app/jobs/event_jobs.py
Normal file
51
app/app/jobs/event_jobs.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import newrelic.agent
|
||||||
|
|
||||||
|
from app.events.event_dispatcher import EventDispatcher, Dispatcher
|
||||||
|
from app.events.generated.event_pb2 import EventContent, AliasCreated, AliasCreatedList
|
||||||
|
from app.log import LOG
|
||||||
|
from app.models import User, Alias
|
||||||
|
|
||||||
|
|
||||||
|
def send_alias_creation_events_for_user(
|
||||||
|
user: User, dispatcher: Dispatcher, chunk_size=50
|
||||||
|
):
|
||||||
|
if user.disabled:
|
||||||
|
LOG.i("User {user} is disabled. Skipping sending events for that user")
|
||||||
|
return
|
||||||
|
chunk_size = min(chunk_size, 50)
|
||||||
|
event_list = []
|
||||||
|
LOG.i("Sending alias create events for user {user}")
|
||||||
|
for alias in (
|
||||||
|
Alias.yield_per_query(chunk_size)
|
||||||
|
.filter_by(user_id=user.id)
|
||||||
|
.order_by(Alias.id.asc())
|
||||||
|
):
|
||||||
|
event_list.append(
|
||||||
|
AliasCreated(
|
||||||
|
alias_id=alias.id,
|
||||||
|
alias_email=alias.email,
|
||||||
|
alias_note=alias.note,
|
||||||
|
enabled=alias.enabled,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(event_list) >= chunk_size:
|
||||||
|
LOG.i(f"Sending {len(event_list)} alias create event for {user}")
|
||||||
|
EventDispatcher.send_event(
|
||||||
|
user,
|
||||||
|
EventContent(alias_create_list=AliasCreatedList(events=event_list)),
|
||||||
|
dispatcher=dispatcher,
|
||||||
|
)
|
||||||
|
newrelic.agent.record_custom_metric(
|
||||||
|
"Custom/event_alias_created_event", len(event_list)
|
||||||
|
)
|
||||||
|
event_list = []
|
||||||
|
if len(event_list) > 0:
|
||||||
|
LOG.i(f"Sending {len(event_list)} alias create event for {user}")
|
||||||
|
EventDispatcher.send_event(
|
||||||
|
user,
|
||||||
|
EventContent(alias_create_list=AliasCreatedList(events=event_list)),
|
||||||
|
dispatcher=dispatcher,
|
||||||
|
)
|
||||||
|
newrelic.agent.record_custom_metric(
|
||||||
|
"Custom/event_alias_created_event", len(event_list)
|
||||||
|
)
|
@ -137,7 +137,9 @@ class ExportUserDataJob:
|
|||||||
msg[headers.SUBJECT] = "Your SimpleLogin data"
|
msg[headers.SUBJECT] = "Your SimpleLogin data"
|
||||||
msg[headers.FROM] = f'"SimpleLogin (noreply)" <{config.NOREPLY}>'
|
msg[headers.FROM] = f'"SimpleLogin (noreply)" <{config.NOREPLY}>'
|
||||||
msg[headers.TO] = to_email
|
msg[headers.TO] = to_email
|
||||||
msg.attach(MIMEText(render("transactional/user-report.html"), "html"))
|
msg.attach(
|
||||||
|
MIMEText(render("transactional/user-report.html", user=self._user), "html")
|
||||||
|
)
|
||||||
attachment = MIMEApplication(zipped_contents.read())
|
attachment = MIMEApplication(zipped_contents.read())
|
||||||
attachment.add_header(
|
attachment.add_header(
|
||||||
"Content-Disposition", "attachment", filename="user_report.zip"
|
"Content-Disposition", "attachment", filename="user_report.zip"
|
||||||
|
@ -76,7 +76,6 @@ class SendRequest:
|
|||||||
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
|
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
|
||||||
self.save_request_to_file(file_path)
|
self.save_request_to_file(file_path)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def save_request_to_failed_dir(self, prefix: str = "DeliveryRetryFail"):
|
def save_request_to_failed_dir(self, prefix: str = "DeliveryRetryFail"):
|
||||||
file_name = (
|
file_name = (
|
||||||
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
|
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
@ -263,6 +263,15 @@ class UnsubscribeBehaviourEnum(EnumE):
|
|||||||
PreserveOriginal = 2
|
PreserveOriginal = 2
|
||||||
|
|
||||||
|
|
||||||
|
class AliasDeleteReason(EnumE):
|
||||||
|
Unspecified = 0
|
||||||
|
UserHasBeenDeleted = 1
|
||||||
|
ManualAction = 2
|
||||||
|
DirectoryDeleted = 3
|
||||||
|
MailboxDeleted = 4
|
||||||
|
CustomDomainDeleted = 5
|
||||||
|
|
||||||
|
|
||||||
class IntEnumType(sa.types.TypeDecorator):
|
class IntEnumType(sa.types.TypeDecorator):
|
||||||
impl = sa.Integer
|
impl = sa.Integer
|
||||||
|
|
||||||
@ -330,6 +339,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
FLAG_FREE_DISABLE_CREATE_ALIAS = 1 << 0
|
FLAG_FREE_DISABLE_CREATE_ALIAS = 1 << 0
|
||||||
FLAG_CREATED_FROM_PARTNER = 1 << 1
|
FLAG_CREATED_FROM_PARTNER = 1 << 1
|
||||||
FLAG_FREE_OLD_ALIAS_LIMIT = 1 << 2
|
FLAG_FREE_OLD_ALIAS_LIMIT = 1 << 2
|
||||||
|
FLAG_CREATED_ALIAS_FROM_PARTNER = 1 << 3
|
||||||
|
|
||||||
email = sa.Column(sa.String(256), unique=True, nullable=False)
|
email = sa.Column(sa.String(256), unique=True, nullable=False)
|
||||||
|
|
||||||
@ -666,6 +676,12 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
user: User = cls.get(obj_id)
|
user: User = cls.get(obj_id)
|
||||||
EventDispatcher.send_event(user, EventContent(user_deleted=UserDeleted()))
|
EventDispatcher.send_event(user, EventContent(user_deleted=UserDeleted()))
|
||||||
|
|
||||||
|
# Manually delete all aliases for the user that is about to be deleted
|
||||||
|
from app.alias_utils import delete_alias
|
||||||
|
|
||||||
|
for alias in Alias.filter_by(user_id=user.id):
|
||||||
|
delete_alias(alias, user, AliasDeleteReason.UserHasBeenDeleted)
|
||||||
|
|
||||||
res = super(User, cls).delete(obj_id)
|
res = super(User, cls).delete(obj_id)
|
||||||
if commit:
|
if commit:
|
||||||
Session.commit()
|
Session.commit()
|
||||||
@ -969,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))
|
||||||
@ -1112,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)
|
||||||
@ -1153,6 +1172,13 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
return True
|
return True
|
||||||
return not config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS
|
return not config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS
|
||||||
|
|
||||||
|
def has_used_alias_from_partner(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.flags
|
||||||
|
& (User.FLAG_CREATED_ALIAS_FROM_PARTNER | User.FLAG_CREATED_FROM_PARTNER)
|
||||||
|
> 0
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<User {self.id} {self.name} {self.email}>"
|
return f"<User {self.id} {self.name} {self.email}>"
|
||||||
|
|
||||||
@ -1634,6 +1660,18 @@ 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
|
||||||
|
|
||||||
|
if (
|
||||||
|
new_alias.flags & cls.FLAG_PARTNER_CREATED > 0
|
||||||
|
and new_alias.user.flags & User.FLAG_CREATED_ALIAS_FROM_PARTNER == 0
|
||||||
|
):
|
||||||
|
user.flags = user.flags | User.FLAG_CREATED_ALIAS_FROM_PARTNER
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
Session.commit()
|
||||||
|
|
||||||
|
if flush:
|
||||||
|
Session.flush()
|
||||||
|
|
||||||
# Internal import to avoid global import cycles
|
# Internal import to avoid global import cycles
|
||||||
from app.events.event_dispatcher import EventDispatcher
|
from app.events.event_dispatcher import EventDispatcher
|
||||||
from app.events.generated.event_pb2 import AliasCreated, EventContent
|
from app.events.generated.event_pb2 import AliasCreated, EventContent
|
||||||
@ -1646,12 +1684,6 @@ class Alias(Base, ModelMixin):
|
|||||||
)
|
)
|
||||||
EventDispatcher.send_event(user, EventContent(alias_created=event))
|
EventDispatcher.send_event(user, EventContent(alias_created=event))
|
||||||
|
|
||||||
if commit:
|
|
||||||
Session.commit()
|
|
||||||
|
|
||||||
if flush:
|
|
||||||
Session.flush()
|
|
||||||
|
|
||||||
return new_alias
|
return new_alias
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -2247,6 +2279,12 @@ class DeletedAlias(Base, ModelMixin):
|
|||||||
__tablename__ = "deleted_alias"
|
__tablename__ = "deleted_alias"
|
||||||
|
|
||||||
email = sa.Column(sa.String(256), unique=True, nullable=False)
|
email = sa.Column(sa.String(256), unique=True, nullable=False)
|
||||||
|
reason = sa.Column(
|
||||||
|
IntEnumType(AliasDeleteReason),
|
||||||
|
nullable=False,
|
||||||
|
default=AliasDeleteReason.Unspecified,
|
||||||
|
server_default=str(AliasDeleteReason.Unspecified.value),
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, **kw):
|
def create(cls, **kw):
|
||||||
@ -2434,6 +2472,13 @@ class CustomDomain(Base, ModelMixin):
|
|||||||
if obj.is_sl_subdomain:
|
if obj.is_sl_subdomain:
|
||||||
DeletedSubdomain.create(domain=obj.domain)
|
DeletedSubdomain.create(domain=obj.domain)
|
||||||
|
|
||||||
|
from app import alias_utils
|
||||||
|
|
||||||
|
for alias in Alias.filter_by(custom_domain_id=obj_id):
|
||||||
|
alias_utils.delete_alias(
|
||||||
|
alias, obj.user, AliasDeleteReason.CustomDomainDeleted
|
||||||
|
)
|
||||||
|
|
||||||
return super(CustomDomain, cls).delete(obj_id)
|
return super(CustomDomain, cls).delete(obj_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -2441,7 +2486,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):
|
||||||
@ -2506,6 +2551,12 @@ class DomainDeletedAlias(Base, ModelMixin):
|
|||||||
|
|
||||||
domain = orm.relationship(CustomDomain)
|
domain = orm.relationship(CustomDomain)
|
||||||
user = orm.relationship(User, foreign_keys=[user_id])
|
user = orm.relationship(User, foreign_keys=[user_id])
|
||||||
|
reason = sa.Column(
|
||||||
|
IntEnumType(AliasDeleteReason),
|
||||||
|
nullable=False,
|
||||||
|
default=AliasDeleteReason.Unspecified,
|
||||||
|
server_default=str(AliasDeleteReason.Unspecified.value),
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, **kw):
|
def create(cls, **kw):
|
||||||
@ -2597,7 +2648,7 @@ class Directory(Base, ModelMixin):
|
|||||||
for alias in Alias.filter_by(directory_id=obj_id):
|
for alias in Alias.filter_by(directory_id=obj_id):
|
||||||
from app import alias_utils
|
from app import alias_utils
|
||||||
|
|
||||||
alias_utils.delete_alias(alias, user)
|
alias_utils.delete_alias(alias, user, AliasDeleteReason.DirectoryDeleted)
|
||||||
|
|
||||||
DeletedDirectory.create(name=obj.name)
|
DeletedDirectory.create(name=obj.name)
|
||||||
cls.filter(cls.id == obj_id).delete()
|
cls.filter(cls.id == obj_id).delete()
|
||||||
@ -2725,7 +2776,7 @@ class Mailbox(Base, ModelMixin):
|
|||||||
from app import alias_utils
|
from app import alias_utils
|
||||||
|
|
||||||
# only put aliases that have mailbox as a single mailbox into trash
|
# only put aliases that have mailbox as a single mailbox into trash
|
||||||
alias_utils.delete_alias(alias, user)
|
alias_utils.delete_alias(alias, user, AliasDeleteReason.MailboxDeleted)
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
cls.filter(cls.id == obj_id).delete()
|
cls.filter(cls.id == obj_id).delete()
|
||||||
@ -2753,6 +2804,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"""
|
||||||
|
|
||||||
@ -2971,11 +3032,7 @@ class RecoveryCode(Base, ModelMixin):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def find_by_user_code(cls, user: User, code: str):
|
def find_by_user_code(cls, user: User, code: str):
|
||||||
hashed_code = cls._hash_code(code)
|
hashed_code = cls._hash_code(code)
|
||||||
# TODO: Only return hashed codes once there aren't unhashed codes in the db.
|
return cls.get_by(user_id=user.id, code=hashed_code)
|
||||||
found_code = cls.get_by(user_id=user.id, code=hashed_code)
|
|
||||||
if found_code:
|
|
||||||
return found_code
|
|
||||||
return cls.get_by(user_id=user.id, code=code)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def empty(cls, user):
|
def empty(cls, user):
|
||||||
@ -3070,7 +3127,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):
|
||||||
@ -3440,6 +3497,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
|
||||||
@ -3685,6 +3743,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"),
|
||||||
@ -3706,7 +3765,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(
|
||||||
(
|
(
|
||||||
@ -3719,6 +3778,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)
|
||||||
|
@ -20,7 +20,7 @@ def final():
|
|||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
alias = Alias.get_by(email=form.email.data)
|
alias = Alias.get_by(email=form.email.data)
|
||||||
if alias and alias.user_id == current_user.id:
|
if alias and alias.user_id == current_user.id:
|
||||||
send_test_email_alias(alias.email, current_user.name)
|
send_test_email_alias(current_user, alias.email)
|
||||||
flash("An email is sent to your alias", "success")
|
flash("An email is sent to your alias", "success")
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
|
@ -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")
|
||||||
|
@ -27,6 +27,7 @@ def failed_payment(sub: Subscription, subscription_id: str):
|
|||||||
"SimpleLogin - your subscription has failed to be renewed",
|
"SimpleLogin - your subscription has failed to be renewed",
|
||||||
render(
|
render(
|
||||||
"transactional/subscription-cancel.txt",
|
"transactional/subscription-cancel.txt",
|
||||||
|
user=user,
|
||||||
end_date=arrow.arrow.datetime.utcnow(),
|
end_date=arrow.arrow.datetime.utcnow(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -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 @@ from newrelic import agent
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
|
from app.log import LOG
|
||||||
from app.errors import ProtonPartnerNotSetUp
|
from app.errors import ProtonPartnerNotSetUp
|
||||||
from app.models import Partner, PartnerUser, User
|
from app.models import Partner, PartnerUser, User
|
||||||
|
|
||||||
@ -30,6 +31,7 @@ def perform_proton_account_unlink(current_user: User):
|
|||||||
user_id=current_user.id, partner_id=proton_partner.id
|
user_id=current_user.id, partner_id=proton_partner.id
|
||||||
)
|
)
|
||||||
if partner_user is not None:
|
if partner_user is not None:
|
||||||
|
LOG.info(f"User {current_user} has unlinked the account from {partner_user}")
|
||||||
PartnerUser.delete(partner_user.id)
|
PartnerUser.delete(partner_user.id)
|
||||||
Session.commit()
|
Session.commit()
|
||||||
agent.record_custom_event("AccountUnlinked", {"partner": proton_partner.name})
|
agent.record_custom_event("AccountUnlinked", {"partner": proton_partner.name})
|
||||||
|
@ -87,6 +87,7 @@ class RedisSessionStore(SessionInterface):
|
|||||||
httponly = self.get_cookie_httponly(app)
|
httponly = self.get_cookie_httponly(app)
|
||||||
secure = self.get_cookie_secure(app)
|
secure = self.get_cookie_secure(app)
|
||||||
expires = self.get_expiration_time(app, session)
|
expires = self.get_expiration_time(app, session)
|
||||||
|
samesite = self.get_cookie_samesite(app)
|
||||||
val = pickle.dumps(dict(session))
|
val = pickle.dumps(dict(session))
|
||||||
ttl = int(app.permanent_session_lifetime.total_seconds())
|
ttl = int(app.permanent_session_lifetime.total_seconds())
|
||||||
# Only 5 minutes for non-authenticated sessions.
|
# Only 5 minutes for non-authenticated sessions.
|
||||||
@ -109,6 +110,7 @@ class RedisSessionStore(SessionInterface):
|
|||||||
domain=domain,
|
domain=domain,
|
||||||
path=path,
|
path=path,
|
||||||
secure=secure,
|
secure=secure,
|
||||||
|
samesite=samesite,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -2,8 +2,9 @@ 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, UserPlanChange
|
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
|
||||||
@ -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 = UserPlanChange(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
|
@ -266,11 +266,13 @@ def notify_manual_sub_end():
|
|||||||
"Your SimpleLogin subscription will end soon",
|
"Your SimpleLogin subscription will end soon",
|
||||||
render(
|
render(
|
||||||
"transactional/coinbase/reminder-subscription.txt",
|
"transactional/coinbase/reminder-subscription.txt",
|
||||||
|
user=user,
|
||||||
coinbase_subscription=coinbase_subscription,
|
coinbase_subscription=coinbase_subscription,
|
||||||
extend_subscription_url=extend_subscription_url,
|
extend_subscription_url=extend_subscription_url,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/coinbase/reminder-subscription.html",
|
"transactional/coinbase/reminder-subscription.html",
|
||||||
|
user=user,
|
||||||
coinbase_subscription=coinbase_subscription,
|
coinbase_subscription=coinbase_subscription,
|
||||||
extend_subscription_url=extend_subscription_url,
|
extend_subscription_url=extend_subscription_url,
|
||||||
),
|
),
|
||||||
@ -826,10 +828,12 @@ def check_mailbox_valid_domain():
|
|||||||
f"Mailbox {mailbox.email} is disabled",
|
f"Mailbox {mailbox.email} is disabled",
|
||||||
render(
|
render(
|
||||||
"transactional/disable-mailbox-warning.txt.jinja2",
|
"transactional/disable-mailbox-warning.txt.jinja2",
|
||||||
|
user=mailbox.user,
|
||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/disable-mailbox-warning.html",
|
"transactional/disable-mailbox-warning.html",
|
||||||
|
user=mailbox.user,
|
||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
),
|
),
|
||||||
retries=3,
|
retries=3,
|
||||||
@ -884,6 +888,7 @@ def check_mailbox_valid_pgp_keys():
|
|||||||
f"Mailbox {mailbox.email}'s PGP Key is invalid",
|
f"Mailbox {mailbox.email}'s PGP Key is invalid",
|
||||||
render(
|
render(
|
||||||
"transactional/invalid-mailbox-pgp-key.txt.jinja2",
|
"transactional/invalid-mailbox-pgp-key.txt.jinja2",
|
||||||
|
user=mailbox.user,
|
||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
),
|
),
|
||||||
retries=3,
|
retries=3,
|
||||||
@ -924,6 +929,7 @@ def check_single_custom_domain(custom_domain):
|
|||||||
f"Please update {custom_domain.domain} DNS on SimpleLogin",
|
f"Please update {custom_domain.domain} DNS on SimpleLogin",
|
||||||
render(
|
render(
|
||||||
"transactional/custom-domain-dns-issue.txt.jinja2",
|
"transactional/custom-domain-dns-issue.txt.jinja2",
|
||||||
|
user=user,
|
||||||
custom_domain=custom_domain,
|
custom_domain=custom_domain,
|
||||||
domain_dns_url=domain_dns_url,
|
domain_dns_url=domain_dns_url,
|
||||||
),
|
),
|
||||||
|
@ -235,13 +235,14 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
|||||||
contact.mail_from = mail_from
|
contact.mail_from = mail_from
|
||||||
Session.commit()
|
Session.commit()
|
||||||
else:
|
else:
|
||||||
|
alias_id = alias.id
|
||||||
try:
|
try:
|
||||||
contact_email_for_reply = (
|
contact_email_for_reply = (
|
||||||
contact_email if is_valid_email(contact_email) else ""
|
contact_email if is_valid_email(contact_email) else ""
|
||||||
)
|
)
|
||||||
contact = Contact.create(
|
contact = Contact.create(
|
||||||
user_id=alias.user_id,
|
user_id=alias.user_id,
|
||||||
alias_id=alias.id,
|
alias_id=alias_id,
|
||||||
website_email=contact_email,
|
website_email=contact_email,
|
||||||
name=contact_name,
|
name=contact_name,
|
||||||
mail_from=mail_from,
|
mail_from=mail_from,
|
||||||
@ -261,9 +262,10 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
|||||||
|
|
||||||
Session.commit()
|
Session.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
LOG.w(f"Contact with email {contact_email} for alias {alias} already exist")
|
LOG.info(
|
||||||
Session.rollback()
|
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)
|
)
|
||||||
|
contact = Contact.get_by(alias_id=alias_id, website_email=contact_email)
|
||||||
|
|
||||||
return contact
|
return contact
|
||||||
|
|
||||||
@ -601,12 +603,14 @@ def handle_email_sent_to_ourself(alias, from_addr: str, msg: Message, user):
|
|||||||
f"Email sent to {alias.email} from its own mailbox {from_addr}",
|
f"Email sent to {alias.email} from its own mailbox {from_addr}",
|
||||||
render(
|
render(
|
||||||
"transactional/cycle-email.txt.jinja2",
|
"transactional/cycle-email.txt.jinja2",
|
||||||
|
user=user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
from_addr=from_addr,
|
from_addr=from_addr,
|
||||||
refused_email_url=refused_email_url,
|
refused_email_url=refused_email_url,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/cycle-email.html",
|
"transactional/cycle-email.html",
|
||||||
|
user=user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
from_addr=from_addr,
|
from_addr=from_addr,
|
||||||
refused_email_url=refused_email_url,
|
refused_email_url=refused_email_url,
|
||||||
@ -660,6 +664,9 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
|
|||||||
from_header = get_header_unicode(msg[headers.FROM])
|
from_header = get_header_unicode(msg[headers.FROM])
|
||||||
LOG.d("Create or get contact for from_header:%s", from_header)
|
LOG.d("Create or get contact for from_header:%s", from_header)
|
||||||
contact = get_or_create_contact(from_header, envelope.mail_from, alias)
|
contact = get_or_create_contact(from_header, envelope.mail_from, alias)
|
||||||
|
alias = (
|
||||||
|
contact.alias
|
||||||
|
) # In case the Session was closed in the get_or_create we re-fetch the alias
|
||||||
|
|
||||||
reply_to_contact = None
|
reply_to_contact = None
|
||||||
if msg[headers.REPLY_TO]:
|
if msg[headers.REPLY_TO]:
|
||||||
@ -728,12 +735,14 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
|
|||||||
f"Your mailbox {mailbox.email} is an alias",
|
f"Your mailbox {mailbox.email} is an alias",
|
||||||
render(
|
render(
|
||||||
"transactional/mailbox-invalid.txt.jinja2",
|
"transactional/mailbox-invalid.txt.jinja2",
|
||||||
|
user=mailbox.user,
|
||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
mailbox_url=mailbox_url,
|
mailbox_url=mailbox_url,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/mailbox-invalid.html",
|
"transactional/mailbox-invalid.html",
|
||||||
|
user=mailbox.user,
|
||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
mailbox_url=mailbox_url,
|
mailbox_url=mailbox_url,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
@ -786,12 +795,14 @@ def forward_email_to_mailbox(
|
|||||||
f"Your mailbox {mailbox.email} and alias {alias.email} use the same domain",
|
f"Your mailbox {mailbox.email} and alias {alias.email} use the same domain",
|
||||||
render(
|
render(
|
||||||
"transactional/mailbox-invalid.txt.jinja2",
|
"transactional/mailbox-invalid.txt.jinja2",
|
||||||
|
user=mailbox.user,
|
||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
mailbox_url=mailbox_url,
|
mailbox_url=mailbox_url,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/mailbox-invalid.html",
|
"transactional/mailbox-invalid.html",
|
||||||
|
user=mailbox.user,
|
||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
mailbox_url=mailbox_url,
|
mailbox_url=mailbox_url,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
@ -805,7 +816,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]),
|
||||||
@ -1276,6 +1287,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
|
|||||||
f"Email sent to {contact.email} contains non reverse-alias addresses",
|
f"Email sent to {contact.email} contains non reverse-alias addresses",
|
||||||
render(
|
render(
|
||||||
"transactional/non-reverse-alias-reply-phase.txt.jinja2",
|
"transactional/non-reverse-alias-reply-phase.txt.jinja2",
|
||||||
|
user=alias.user,
|
||||||
destination=contact.email,
|
destination=contact.email,
|
||||||
alias=alias.email,
|
alias=alias.email,
|
||||||
subject=msg[headers.SUBJECT],
|
subject=msg[headers.SUBJECT],
|
||||||
@ -1497,6 +1509,7 @@ def handle_unknown_mailbox(
|
|||||||
f"Attempt to use your alias {alias.email} from {envelope.mail_from}",
|
f"Attempt to use your alias {alias.email} from {envelope.mail_from}",
|
||||||
render(
|
render(
|
||||||
"transactional/reply-must-use-personal-email.txt",
|
"transactional/reply-must-use-personal-email.txt",
|
||||||
|
user=user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
sender=envelope.mail_from,
|
sender=envelope.mail_from,
|
||||||
authorize_address_link=authorize_address_link,
|
authorize_address_link=authorize_address_link,
|
||||||
@ -1504,6 +1517,7 @@ def handle_unknown_mailbox(
|
|||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/reply-must-use-personal-email.html",
|
"transactional/reply-must-use-personal-email.html",
|
||||||
|
user=user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
sender=envelope.mail_from,
|
sender=envelope.mail_from,
|
||||||
authorize_address_link=authorize_address_link,
|
authorize_address_link=authorize_address_link,
|
||||||
@ -1604,12 +1618,14 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
|
|||||||
f"Alias {alias.email} has been disabled due to multiple bounces",
|
f"Alias {alias.email} has been disabled due to multiple bounces",
|
||||||
render(
|
render(
|
||||||
"transactional/bounce/automatic-disable-alias.txt",
|
"transactional/bounce/automatic-disable-alias.txt",
|
||||||
|
user=alias.user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
refused_email_url=refused_email_url,
|
refused_email_url=refused_email_url,
|
||||||
mailbox_email=mailbox.email,
|
mailbox_email=mailbox.email,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/bounce/automatic-disable-alias.html",
|
"transactional/bounce/automatic-disable-alias.html",
|
||||||
|
user=alias.user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
refused_email_url=refused_email_url,
|
refused_email_url=refused_email_url,
|
||||||
mailbox_email=mailbox.email,
|
mailbox_email=mailbox.email,
|
||||||
@ -1648,6 +1664,7 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
|
|||||||
f"An email sent to {alias.email} cannot be delivered to your mailbox",
|
f"An email sent to {alias.email} cannot be delivered to your mailbox",
|
||||||
render(
|
render(
|
||||||
"transactional/bounce/bounced-email.txt.jinja2",
|
"transactional/bounce/bounced-email.txt.jinja2",
|
||||||
|
user=alias.user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
website_email=contact.website_email,
|
website_email=contact.website_email,
|
||||||
disable_alias_link=disable_alias_link,
|
disable_alias_link=disable_alias_link,
|
||||||
@ -1657,6 +1674,7 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
|
|||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/bounce/bounced-email.html",
|
"transactional/bounce/bounced-email.html",
|
||||||
|
user=alias.user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
website_email=contact.website_email,
|
website_email=contact.website_email,
|
||||||
disable_alias_link=disable_alias_link,
|
disable_alias_link=disable_alias_link,
|
||||||
@ -1749,12 +1767,14 @@ def handle_bounce_reply_phase(envelope, msg: Message, email_log: EmailLog):
|
|||||||
f"Email cannot be sent to { contact.email } from your alias { alias.email }",
|
f"Email cannot be sent to { contact.email } from your alias { alias.email }",
|
||||||
render(
|
render(
|
||||||
"transactional/bounce/bounce-email-reply-phase.txt",
|
"transactional/bounce/bounce-email-reply-phase.txt",
|
||||||
|
user=user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
contact=contact,
|
contact=contact,
|
||||||
refused_email_url=refused_email_url,
|
refused_email_url=refused_email_url,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/bounce/bounce-email-reply-phase.html",
|
"transactional/bounce/bounce-email-reply-phase.html",
|
||||||
|
user=user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
contact=contact,
|
contact=contact,
|
||||||
refused_email_url=refused_email_url,
|
refused_email_url=refused_email_url,
|
||||||
@ -1817,6 +1837,7 @@ def handle_spam(
|
|||||||
f"Email from {alias.email} to {contact.website_email} is detected as spam",
|
f"Email from {alias.email} to {contact.website_email} is detected as spam",
|
||||||
render(
|
render(
|
||||||
"transactional/spam-email-reply-phase.txt",
|
"transactional/spam-email-reply-phase.txt",
|
||||||
|
user=user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
website_email=contact.website_email,
|
website_email=contact.website_email,
|
||||||
disable_alias_link=disable_alias_link,
|
disable_alias_link=disable_alias_link,
|
||||||
@ -1824,6 +1845,7 @@ def handle_spam(
|
|||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/spam-email-reply-phase.html",
|
"transactional/spam-email-reply-phase.html",
|
||||||
|
user=user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
website_email=contact.website_email,
|
website_email=contact.website_email,
|
||||||
disable_alias_link=disable_alias_link,
|
disable_alias_link=disable_alias_link,
|
||||||
@ -1846,6 +1868,7 @@ def handle_spam(
|
|||||||
f"Email from {contact.website_email} to {alias.email} is detected as spam",
|
f"Email from {contact.website_email} to {alias.email} is detected as spam",
|
||||||
render(
|
render(
|
||||||
"transactional/spam-email.txt",
|
"transactional/spam-email.txt",
|
||||||
|
user=user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
website_email=contact.website_email,
|
website_email=contact.website_email,
|
||||||
disable_alias_link=disable_alias_link,
|
disable_alias_link=disable_alias_link,
|
||||||
@ -1853,6 +1876,7 @@ def handle_spam(
|
|||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/spam-email.html",
|
"transactional/spam-email.html",
|
||||||
|
user=user,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
website_email=contact.website_email,
|
website_email=contact.website_email,
|
||||||
disable_alias_link=disable_alias_link,
|
disable_alias_link=disable_alias_link,
|
||||||
@ -2009,7 +2033,7 @@ def send_no_reply_response(mail_from: str, msg: Message):
|
|||||||
ALERT_TO_NOREPLY,
|
ALERT_TO_NOREPLY,
|
||||||
mailbox.user.email,
|
mailbox.user.email,
|
||||||
"Auto: {}".format(msg[headers.SUBJECT] or "No subject"),
|
"Auto: {}".format(msg[headers.SUBJECT] or "No subject"),
|
||||||
render("transactional/noreply.text.jinja2"),
|
render("transactional/noreply.text.jinja2", user=mailbox.user),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -2091,6 +2115,7 @@ def handle(envelope: Envelope, msg: Message) -> str:
|
|||||||
"SimpleLogin shouldn't be used with another email forwarding system",
|
"SimpleLogin shouldn't be used with another email forwarding system",
|
||||||
render(
|
render(
|
||||||
"transactional/email-sent-from-reverse-alias.txt.jinja2",
|
"transactional/email-sent-from-reverse-alias.txt.jinja2",
|
||||||
|
user=user,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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 = 100
|
||||||
|
|
||||||
|
|
||||||
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,7 @@ 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("event_sent", {"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)
|
||||||
|
@ -14,7 +14,9 @@ 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.export_user_data_job import ExportUserDataJob
|
from app.jobs.export_user_data_job import ExportUserDataJob
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState
|
from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState
|
||||||
@ -197,13 +199,18 @@ def process_job(job: Job):
|
|||||||
onboarding_mailbox(user)
|
onboarding_mailbox(user)
|
||||||
elif job.name == config.JOB_ONBOARDING_4:
|
elif job.name == config.JOB_ONBOARDING_4:
|
||||||
user_id = job.payload.get("user_id")
|
user_id = job.payload.get("user_id")
|
||||||
user = User.get(user_id)
|
user: User = User.get(user_id)
|
||||||
|
|
||||||
# user might delete their account in the meantime
|
# user might delete their account in the meantime
|
||||||
# or disable the notification
|
# or disable the notification
|
||||||
if user and user.notification and user.activated:
|
if user and user.notification and user.activated:
|
||||||
LOG.d("send onboarding pgp email to user %s", user)
|
# if user only has 1 mailbox which is Proton then do not send PGP onboarding email
|
||||||
onboarding_pgp(user)
|
mailboxes = user.mailboxes()
|
||||||
|
if len(mailboxes) == 1 and mailboxes[0].is_proton():
|
||||||
|
LOG.d("Do not send onboarding PGP email to Proton mailbox")
|
||||||
|
else:
|
||||||
|
LOG.d("send onboarding pgp email to user %s", user)
|
||||||
|
onboarding_pgp(user)
|
||||||
|
|
||||||
elif job.name == config.JOB_BATCH_IMPORT:
|
elif job.name == config.JOB_BATCH_IMPORT:
|
||||||
batch_import_id = job.payload.get("batch_import_id")
|
batch_import_id = job.payload.get("batch_import_id")
|
||||||
@ -219,16 +226,15 @@ def process_job(job: Job):
|
|||||||
|
|
||||||
user_email = user.email
|
user_email = user.email
|
||||||
LOG.w("Delete user %s", user)
|
LOG.w("Delete user %s", user)
|
||||||
User.delete(user.id)
|
|
||||||
Session.commit()
|
|
||||||
|
|
||||||
send_email(
|
send_email(
|
||||||
user_email,
|
user_email,
|
||||||
"Your SimpleLogin account has been deleted",
|
"Your SimpleLogin account has been deleted",
|
||||||
render("transactional/account-delete.txt"),
|
render("transactional/account-delete.txt", user=user),
|
||||||
render("transactional/account-delete.html"),
|
render("transactional/account-delete.html", user=user),
|
||||||
retries=3,
|
retries=3,
|
||||||
)
|
)
|
||||||
|
User.delete(user.id)
|
||||||
|
Session.commit()
|
||||||
elif job.name == config.JOB_DELETE_MAILBOX:
|
elif job.name == config.JOB_DELETE_MAILBOX:
|
||||||
delete_mailbox_job(job)
|
delete_mailbox_job(job)
|
||||||
|
|
||||||
@ -264,8 +270,16 @@ SimpleLogin team.
|
|||||||
user_id = job.payload.get("user_id")
|
user_id = job.payload.get("user_id")
|
||||||
user = User.get(user_id)
|
user = User.get(user_id)
|
||||||
if user and user.activated:
|
if user and user.activated:
|
||||||
LOG.d("send proton welcome email to user %s", user)
|
LOG.d("Send proton welcome email to user %s", user)
|
||||||
welcome_proton(user)
|
welcome_proton(user)
|
||||||
|
elif job.name == config.JOB_SEND_ALIAS_CREATION_EVENTS:
|
||||||
|
user_id = job.payload.get("user_id")
|
||||||
|
user = User.get(user_id)
|
||||||
|
if user and user.activated:
|
||||||
|
LOG.d(f"Sending alias creation events for {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
|
||||||
|
31
app/migrations/versions/2024_070516_d608b8e48082_.py
Normal file
31
app/migrations/versions/2024_070516_d608b8e48082_.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: d608b8e48082
|
||||||
|
Revises: 06a9a7133445
|
||||||
|
Create Date: 2024-07-05 16:56:04.220173
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'd608b8e48082'
|
||||||
|
down_revision = '06a9a7133445'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('deleted_alias', sa.Column('reason', sa.Integer(), default=0, server_default='0', nullable=False))
|
||||||
|
op.add_column('domain_deleted_alias', sa.Column('reason', sa.Integer(), default=0, server_default='0', nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('domain_deleted_alias', 'reason')
|
||||||
|
op.drop_column('deleted_alias', 'reason')
|
||||||
|
# ### end Alembic commands ###
|
@ -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 ###
|
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("")
|
@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.models import Alias, User
|
||||||
|
from app.db import Session
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="Backfill alias", description="Backfill user flags for partner alias created"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-s", "--start_user_id", default=0, type=int, help="Initial user_id"
|
||||||
|
)
|
||||||
|
parser.add_argument("-e", "--end_user_id", default=0, type=int, help="Last user_id")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
user_id_start = args.start_user_id
|
||||||
|
max_user_id = args.end_user_id
|
||||||
|
if max_user_id == 0:
|
||||||
|
max_user_id = Session.query(func.max(User.id)).scalar()
|
||||||
|
|
||||||
|
print(f"Checking user {user_id_start} to {max_user_id}")
|
||||||
|
step = 1000
|
||||||
|
el_query = "SELECT user_id, count(id) from alias where user_id>=:start AND user_id < :end AND flags & :alias_flag > 0 GROUP BY user_id"
|
||||||
|
user_update_query = "UPDATE users set flags = flags | :user_flag where id = :user_id"
|
||||||
|
updated = 0
|
||||||
|
start_time = time.time()
|
||||||
|
for batch_start in range(user_id_start, max_user_id, step):
|
||||||
|
rows = Session.execute(
|
||||||
|
el_query,
|
||||||
|
{
|
||||||
|
"start": batch_start,
|
||||||
|
"end": batch_start + step,
|
||||||
|
"alias_flag": Alias.FLAG_PARTNER_CREATED,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
if row[1] > 0:
|
||||||
|
Session.execute(
|
||||||
|
user_update_query,
|
||||||
|
{"user_id": row[0], "user_flag": User.FLAG_CREATED_ALIAS_FROM_PARTNER},
|
||||||
|
)
|
||||||
|
Session.commit()
|
||||||
|
updated += 1
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
time_per_alias = elapsed / (updated + 1)
|
||||||
|
last_batch_id = batch_start + step
|
||||||
|
remaining = max_user_id - last_batch_id
|
||||||
|
time_remaining = (max_user_id - last_batch_id) * time_per_alias
|
||||||
|
hours_remaining = time_remaining / 3600.0
|
||||||
|
print(
|
||||||
|
f"\rUser {batch_start}/{max_user_id} {updated} {hours_remaining:.2f}hrs remaining"
|
||||||
|
)
|
||||||
|
print("")
|
420
app/poetry.lock
generated
420
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"
|
||||||
@ -2150,24 +2165,22 @@ wcwidth = "*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "protobuf"
|
name = "protobuf"
|
||||||
version = "4.24.3"
|
version = "5.27.1"
|
||||||
description = ""
|
description = ""
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "protobuf-4.24.3-cp310-abi3-win32.whl", hash = "sha256:20651f11b6adc70c0f29efbe8f4a94a74caf61b6200472a9aea6e19898f9fcf4"},
|
{file = "protobuf-5.27.1-cp310-abi3-win32.whl", hash = "sha256:3adc15ec0ff35c5b2d0992f9345b04a540c1e73bfee3ff1643db43cc1d734333"},
|
||||||
{file = "protobuf-4.24.3-cp310-abi3-win_amd64.whl", hash = "sha256:3d42e9e4796a811478c783ef63dc85b5a104b44aaaca85d4864d5b886e4b05e3"},
|
{file = "protobuf-5.27.1-cp310-abi3-win_amd64.whl", hash = "sha256:25236b69ab4ce1bec413fd4b68a15ef8141794427e0b4dc173e9d5d9dffc3bcd"},
|
||||||
{file = "protobuf-4.24.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:6e514e8af0045be2b56e56ae1bb14f43ce7ffa0f68b1c793670ccbe2c4fc7d2b"},
|
{file = "protobuf-5.27.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4e38fc29d7df32e01a41cf118b5a968b1efd46b9c41ff515234e794011c78b17"},
|
||||||
{file = "protobuf-4.24.3-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:ba53c2f04798a326774f0e53b9c759eaef4f6a568ea7072ec6629851c8435959"},
|
{file = "protobuf-5.27.1-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:917ed03c3eb8a2d51c3496359f5b53b4e4b7e40edfbdd3d3f34336e0eef6825a"},
|
||||||
{file = "protobuf-4.24.3-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:f6ccbcf027761a2978c1406070c3788f6de4a4b2cc20800cc03d52df716ad675"},
|
{file = "protobuf-5.27.1-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:ee52874a9e69a30271649be88ecbe69d374232e8fd0b4e4b0aaaa87f429f1631"},
|
||||||
{file = "protobuf-4.24.3-cp37-cp37m-win32.whl", hash = "sha256:1b182c7181a2891e8f7f3a1b5242e4ec54d1f42582485a896e4de81aa17540c2"},
|
{file = "protobuf-5.27.1-cp38-cp38-win32.whl", hash = "sha256:7a97b9c5aed86b9ca289eb5148df6c208ab5bb6906930590961e08f097258107"},
|
||||||
{file = "protobuf-4.24.3-cp37-cp37m-win_amd64.whl", hash = "sha256:b0271a701e6782880d65a308ba42bc43874dabd1a0a0f41f72d2dac3b57f8e76"},
|
{file = "protobuf-5.27.1-cp38-cp38-win_amd64.whl", hash = "sha256:f6abd0f69968792da7460d3c2cfa7d94fd74e1c21df321eb6345b963f9ec3d8d"},
|
||||||
{file = "protobuf-4.24.3-cp38-cp38-win32.whl", hash = "sha256:e29d79c913f17a60cf17c626f1041e5288e9885c8579832580209de8b75f2a52"},
|
{file = "protobuf-5.27.1-cp39-cp39-win32.whl", hash = "sha256:dfddb7537f789002cc4eb00752c92e67885badcc7005566f2c5de9d969d3282d"},
|
||||||
{file = "protobuf-4.24.3-cp38-cp38-win_amd64.whl", hash = "sha256:067f750169bc644da2e1ef18c785e85071b7c296f14ac53e0900e605da588719"},
|
{file = "protobuf-5.27.1-cp39-cp39-win_amd64.whl", hash = "sha256:39309898b912ca6febb0084ea912e976482834f401be35840a008da12d189340"},
|
||||||
{file = "protobuf-4.24.3-cp39-cp39-win32.whl", hash = "sha256:2da777d34b4f4f7613cdf85c70eb9a90b1fbef9d36ae4a0ccfe014b0b07906f1"},
|
{file = "protobuf-5.27.1-py3-none-any.whl", hash = "sha256:4ac7249a1530a2ed50e24201d6630125ced04b30619262f06224616e0030b6cf"},
|
||||||
{file = "protobuf-4.24.3-cp39-cp39-win_amd64.whl", hash = "sha256:f631bb982c5478e0c1c70eab383af74a84be66945ebf5dd6b06fc90079668d0b"},
|
{file = "protobuf-5.27.1.tar.gz", hash = "sha256:df5e5b8e39b7d1c25b186ffdf9f44f40f810bbcc9d2b71d9d3156fee5a9adf15"},
|
||||||
{file = "protobuf-4.24.3-py3-none-any.whl", hash = "sha256:f6f8dc65625dadaad0c8545319c2e2f0424fede988368893ca3844261342c11a"},
|
|
||||||
{file = "protobuf-4.24.3.tar.gz", hash = "sha256:12e9ad2ec079b833176d2921be2cb24281fa591f0b119b208b788adc48c2561d"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2667,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]]
|
||||||
@ -3132,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"
|
||||||
@ -3290,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"
|
||||||
@ -3607,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"
|
||||||
@ -3700,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"
|
||||||
|
@ -2,7 +2,7 @@ syntax = "proto3";
|
|||||||
|
|
||||||
package simplelogin_events;
|
package simplelogin_events;
|
||||||
|
|
||||||
message UserPlanChange {
|
message UserPlanChanged {
|
||||||
uint32 plan_end_time = 1;
|
uint32 plan_end_time = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ message AliasCreated {
|
|||||||
bool enabled = 4;
|
bool enabled = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AliasStatusChange {
|
message AliasStatusChanged {
|
||||||
uint32 alias_id = 1;
|
uint32 alias_id = 1;
|
||||||
string alias_email = 2;
|
string alias_email = 2;
|
||||||
bool enabled = 3;
|
bool enabled = 3;
|
||||||
@ -27,13 +27,18 @@ message AliasDeleted {
|
|||||||
string alias_email = 2;
|
string alias_email = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message AliasCreatedList {
|
||||||
|
repeated AliasCreated events = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message EventContent {
|
message EventContent {
|
||||||
oneof content {
|
oneof content {
|
||||||
UserPlanChange user_plan_change = 1;
|
UserPlanChanged user_plan_change = 1;
|
||||||
UserDeleted user_deleted = 2;
|
UserDeleted user_deleted = 2;
|
||||||
AliasCreated alias_created = 3;
|
AliasCreated alias_created = 3;
|
||||||
AliasStatusChange alias_status_change = 4;
|
AliasStatusChanged alias_status_change = 4;
|
||||||
AliasDeleted alias_deleted = 5;
|
AliasDeleted alias_deleted = 5;
|
||||||
|
AliasCreatedList alias_create_list = 6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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"]
|
||||||
|
@ -12,10 +12,10 @@ docker run -p 25432:5432 --name ${container_name} -e POSTGRES_PASSWORD=postgres
|
|||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
# upgrade the DB to the latest stage and
|
# upgrade the DB to the latest stage and
|
||||||
env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl poetry run alembic upgrade head
|
env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl rye run alembic upgrade head
|
||||||
|
|
||||||
# generate the migration script.
|
# generate the migration script.
|
||||||
env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl poetry run alembic revision --autogenerate $@
|
env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl rye run alembic revision --autogenerate $@
|
||||||
|
|
||||||
# remove the db
|
# remove the db
|
||||||
docker rm -f ${container_name}
|
docker rm -f ${container_name}
|
||||||
|
@ -29,7 +29,7 @@ from sentry_sdk.integrations.flask import FlaskIntegration
|
|||||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
from app import paddle_utils, config, paddle_callback
|
from app import paddle_utils, config, paddle_callback, constants
|
||||||
from app.admin_model import (
|
from app.admin_model import (
|
||||||
SLAdminIndexView,
|
SLAdminIndexView,
|
||||||
UserAdmin,
|
UserAdmin,
|
||||||
@ -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
|
||||||
@ -430,6 +437,7 @@ def jinja2_filter(app):
|
|||||||
PAGE_LIMIT=PAGE_LIMIT,
|
PAGE_LIMIT=PAGE_LIMIT,
|
||||||
ZENDESK_ENABLED=ZENDESK_ENABLED,
|
ZENDESK_ENABLED=ZENDESK_ENABLED,
|
||||||
MAX_NB_EMAIL_FREE_PLAN=MAX_NB_EMAIL_FREE_PLAN,
|
MAX_NB_EMAIL_FREE_PLAN=MAX_NB_EMAIL_FREE_PLAN,
|
||||||
|
HEADER_ALLOW_API_COOKIES=constants.HEADER_ALLOW_API_COOKIES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -542,6 +550,7 @@ def setup_paddle_callback(app: Flask):
|
|||||||
"SimpleLogin - your subscription is canceled",
|
"SimpleLogin - your subscription is canceled",
|
||||||
render(
|
render(
|
||||||
"transactional/subscription-cancel.txt",
|
"transactional/subscription-cancel.txt",
|
||||||
|
user=user,
|
||||||
end_date=request.form.get("cancellation_effective_date"),
|
end_date=request.form.get("cancellation_effective_date"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -701,7 +710,12 @@ def setup_coinbase_commerce(app):
|
|||||||
|
|
||||||
|
|
||||||
def handle_coinbase_event(event) -> bool:
|
def handle_coinbase_event(event) -> bool:
|
||||||
user_id = int(event["data"]["metadata"]["user_id"])
|
server_user_id = event["data"]["metadata"]["user_id"]
|
||||||
|
try:
|
||||||
|
user_id = int(server_user_id)
|
||||||
|
except ValueError:
|
||||||
|
user_id = int(float(server_user_id))
|
||||||
|
|
||||||
code = event["data"]["code"]
|
code = event["data"]["code"]
|
||||||
user = User.get(user_id)
|
user = User.get(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
@ -722,10 +736,12 @@ def handle_coinbase_event(event) -> bool:
|
|||||||
"Your SimpleLogin account has been upgraded",
|
"Your SimpleLogin account has been upgraded",
|
||||||
render(
|
render(
|
||||||
"transactional/coinbase/new-subscription.txt",
|
"transactional/coinbase/new-subscription.txt",
|
||||||
|
user=user,
|
||||||
coinbase_subscription=coinbase_subscription,
|
coinbase_subscription=coinbase_subscription,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/coinbase/new-subscription.html",
|
"transactional/coinbase/new-subscription.html",
|
||||||
|
user=user,
|
||||||
coinbase_subscription=coinbase_subscription,
|
coinbase_subscription=coinbase_subscription,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -746,10 +762,12 @@ def handle_coinbase_event(event) -> bool:
|
|||||||
"Your SimpleLogin account has been extended",
|
"Your SimpleLogin account has been extended",
|
||||||
render(
|
render(
|
||||||
"transactional/coinbase/extend-subscription.txt",
|
"transactional/coinbase/extend-subscription.txt",
|
||||||
|
user=user,
|
||||||
coinbase_subscription=coinbase_subscription,
|
coinbase_subscription=coinbase_subscription,
|
||||||
),
|
),
|
||||||
render(
|
render(
|
||||||
"transactional/coinbase/extend-subscription.html",
|
"transactional/coinbase/extend-subscription.html",
|
||||||
|
user=user,
|
||||||
coinbase_subscription=coinbase_subscription,
|
coinbase_subscription=coinbase_subscription,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -769,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))
|
||||||
|
@ -51,14 +51,19 @@ $(".enable-disable-alias").change(async function () {
|
|||||||
await disableAlias(aliasId, alias);
|
await disableAlias(aliasId, alias);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getHeaders() {
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Sl-Allowcookies": 'allow',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function disableAlias(aliasId, alias) {
|
async function disableAlias(aliasId, alias) {
|
||||||
let oldValue;
|
let oldValue;
|
||||||
try {
|
try {
|
||||||
let res = await fetch(`/api/aliases/${aliasId}/toggle`, {
|
let res = await fetch(`/api/aliases/${aliasId}/toggle`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: getHeaders()
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -94,9 +99,7 @@ $(".enable-disable-pgp").change(async function (e) {
|
|||||||
try {
|
try {
|
||||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: getHeaders(),
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
disable_pgp: oldValue,
|
disable_pgp: oldValue,
|
||||||
}),
|
}),
|
||||||
@ -129,9 +132,7 @@ $(".pin-alias").change(async function () {
|
|||||||
try {
|
try {
|
||||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: getHeaders(),
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pinned: newValue,
|
pinned: newValue,
|
||||||
}),
|
}),
|
||||||
@ -161,9 +162,7 @@ async function handleNoteChange(aliasId, aliasEmail) {
|
|||||||
try {
|
try {
|
||||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: getHeaders(),
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
note: note,
|
note: note,
|
||||||
}),
|
}),
|
||||||
@ -200,9 +199,7 @@ async function handleMailboxChange(aliasId, aliasEmail) {
|
|||||||
try {
|
try {
|
||||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: getHeaders(),
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mailbox_ids: mailbox_ids,
|
mailbox_ids: mailbox_ids,
|
||||||
}),
|
}),
|
||||||
@ -225,9 +222,7 @@ async function handleDisplayNameChange(aliasId, aliasEmail) {
|
|||||||
try {
|
try {
|
||||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: getHeaders(),
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: name,
|
name: name,
|
||||||
}),
|
}),
|
||||||
|
BIN
app/static/logo-proton.png
vendored
Normal file
BIN
app/static/logo-proton.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
@ -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>
|
||||||
@ -264,6 +254,7 @@
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
@ -139,8 +134,8 @@
|
|||||||
<h2 class="h4 mb-1">New Directory</h2>
|
<h2 class="h4 mb-1">New Directory</h2>
|
||||||
<div class="small-text mb-4">You can create up to {{ current_user.directory_quota }} directories.</div>
|
<div class="small-text mb-4">You can create up to {{ current_user.directory_quota }} directories.</div>
|
||||||
{{ new_dir_form.name(class="form-control", placeholder="my-directory",
|
{{ new_dir_form.name(class="form-control", placeholder="my-directory",
|
||||||
pattern="[0-9a-z-_]{3,}",
|
pattern="[0-9a-z-_]{3,}",
|
||||||
title="Only letter, number, dash (-), underscore (_) can be used. Directory name must be at least 3 characters.") }}
|
title="Only letter, number, dash (-), underscore (_) can be used. Directory name must be at least 3 characters.") }}
|
||||||
{{ render_field_errors(new_dir_form.name) }}
|
{{ render_field_errors(new_dir_form.name) }}
|
||||||
<div class="small-text">
|
<div class="small-text">
|
||||||
Directory name must be at least 3 characters.
|
Directory name must be at least 3 characters.
|
||||||
@ -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>
|
||||||
|
@ -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,9 +236,7 @@
|
|||||||
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.
|
|
||||||
</div>
|
|
||||||
{% for dkim_prefix, dkim_cname_value in dkim_records %}
|
{% 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">
|
||||||
@ -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,10 +300,8 @@
|
|||||||
</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
|
{% endif %}
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
@ -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 %}
|
||||||
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user