mirror of
https://github.com/simple-login/app.git
synced 2024-09-21 01:11:29 +02:00
Merge branch 'simple-login:master' into master
This commit is contained in:
commit
71b7c18ae8
4
.flake8
4
.flake8
@ -6,7 +6,9 @@ ignore =
|
|||||||
W503,
|
W503,
|
||||||
E203,
|
E203,
|
||||||
# Ignore "f-string is missing placeholders"
|
# Ignore "f-string is missing placeholders"
|
||||||
F541
|
F541,
|
||||||
|
# allow bare "except"
|
||||||
|
E722
|
||||||
exclude =
|
exclude =
|
||||||
.git,
|
.git,
|
||||||
__pycache__,
|
__pycache__,
|
||||||
|
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
@ -29,8 +29,7 @@ jobs:
|
|||||||
# optional (defaults to `postgres`)
|
# optional (defaults to `postgres`)
|
||||||
POSTGRES_USER: test
|
POSTGRES_USER: test
|
||||||
ports:
|
ports:
|
||||||
# maps tcp port 5432 on service container to the host
|
- 15432:5432
|
||||||
- 5432:5432
|
|
||||||
# set health checks to wait until postgres has started
|
# set health checks to wait until postgres has started
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
@ -64,6 +63,7 @@ jobs:
|
|||||||
poetry run black --check .
|
poetry run black --check .
|
||||||
flake8
|
flake8
|
||||||
|
|
||||||
|
|
||||||
- name: Test with pytest
|
- name: Test with pytest
|
||||||
run: |
|
run: |
|
||||||
pytest --cov=. --cov-report=term:skip-covered --cov-report=html:htmlcov --cov-fail-under=60
|
pytest --cov=. --cov-report=term:skip-covered --cov-report=html:htmlcov --cov-fail-under=60
|
||||||
|
@ -1,21 +1,6 @@
|
|||||||
Thanks for taking the time to contribute! 🎉👍
|
Thanks for taking the time to contribute! 🎉👍
|
||||||
|
|
||||||
The project uses Flask and requires Python3.7+.
|
The project uses Flask, Python3.7+ and requires Postgres 12+ as dependency.
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
If you have Docker installed, run the following command to start SimpleLogin local server:
|
|
||||||
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --name sl -it --rm \
|
|
||||||
-e RESET_DB=true \
|
|
||||||
-e CONFIG=/code/example.env \
|
|
||||||
-p 7777:7777 \
|
|
||||||
simplelogin/app:3.4.0 python server.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open http://localhost:7777, you should be able to login with `john@wick.com/password` account.
|
|
||||||
|
|
||||||
## General Architecture
|
## General Architecture
|
||||||
|
|
||||||
@ -25,15 +10,16 @@ Then open http://localhost:7777, you should be able to login with `john@wick.com
|
|||||||
|
|
||||||
SimpleLogin backend consists of 2 main components:
|
SimpleLogin backend consists of 2 main components:
|
||||||
|
|
||||||
- the `webapp` used by several clients: web UI (the dashboard), browser extension (Chrome & Firefox for now), OAuth clients (apps that integrate "Login with SimpleLogin" button) and mobile app (work in progress).
|
- the `webapp` used by several clients: the web app, the browser extensions (Chrome & Firefox for now), OAuth clients (apps that integrate "Sign in with SimpleLogin" button) and mobile apps.
|
||||||
|
|
||||||
- the `email handler`: implements the email forwarding (i.e. alias receiving email) and email sending (i.e. alias sending email).
|
- the `email handler`: implements the email forwarding (i.e. alias receiving email) and email sending (i.e. alias sending email).
|
||||||
|
|
||||||
## Run code locally
|
## Install dependencies
|
||||||
|
|
||||||
The project uses
|
The project requires:
|
||||||
- Python 3.7+ and [poetry](https://python-poetry.org/) to manage dependencies
|
- Python 3.7+ and [poetry](https://python-poetry.org/) to manage dependencies
|
||||||
- Node v10 for front-end.
|
- Node v10 for front-end.
|
||||||
|
- Postgres 12+
|
||||||
|
|
||||||
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.
|
||||||
@ -42,29 +28,25 @@ Feel free to use `virtualenv` or similar tools to isolate development environmen
|
|||||||
poetry install
|
poetry install
|
||||||
```
|
```
|
||||||
|
|
||||||
On Mac, sometimes you might need to install some other packages like
|
On Mac, sometimes you might need to install some other packages via `brew`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install pkg-config libffi openssl postgresql
|
brew install pkg-config libffi openssl postgresql
|
||||||
```
|
```
|
||||||
|
|
||||||
You also need to install `gpg`, on Mac it can be done with:
|
You also need to install `gpg` tool, on Mac it can be done with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install gnupg
|
brew install gnupg
|
||||||
```
|
```
|
||||||
|
|
||||||
Then make sure all tests pass. You need to run a local postgres database to run tests, it can be run with docker with:
|
## Run tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -e POSTGRES_PASSWORD=test -e POSTGRES_USER=test -e POSTGRES_DB=test -p 5432:5432 postgres:13
|
sh scripts/run-test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
then run all tests
|
## Run the code locally
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
Install npm packages
|
Install npm packages
|
||||||
|
|
||||||
@ -78,17 +60,19 @@ To run the code locally, please create a local setting file based on `example.en
|
|||||||
cp example.env .env
|
cp example.env .env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run the postgres database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -e POSTGRES_PASSWORD=mypassword -e POSTGRES_USER=myuser -e POSTGRES_DB=simplelogin -p 35432:5432 postgres:13
|
||||||
|
```
|
||||||
|
|
||||||
To run the server:
|
To run the server:
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 server.py
|
flask db upgrade && flask dummy-data && python3 server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
then open http://localhost:7777, you should be able to login with the following account
|
then open http://localhost:7777, you should be able to login with `john@wick.com / password` account.
|
||||||
|
|
||||||
```
|
|
||||||
john@wick.com / password
|
|
||||||
```
|
|
||||||
|
|
||||||
You might need to change the `.env` file for developing certain features. This file is ignored by git.
|
You might need to change the `.env` file for developing certain features. This file is ignored by git.
|
||||||
|
|
||||||
@ -101,7 +85,7 @@ Whenever the model changes, a new migration has to be created.
|
|||||||
If you have Docker installed, you can create the migration by the following script:
|
If you have Docker installed, you can create the migration by the following script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sh new_migration.sh
|
sh scripts/new-migration.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Make sure to review the migration script before committing it.
|
Make sure to review the migration script before committing it.
|
||||||
|
674
LICENSE
674
LICENSE
@ -1,21 +1,661 @@
|
|||||||
MIT License
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (c) 2020 SimpleLogin
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Preamble
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
copies or substantial portions of the Software.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
The licenses for most software and other practical works are designed
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
share and change all versions of a program--to make sure it remains free
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
software for all its users.
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
@ -161,7 +161,7 @@ Similar to DKIM, setting up SPF is highly recommended.
|
|||||||
Add a TXT record for `mydomain.com.` with the value:
|
Add a TXT record for `mydomain.com.` with the value:
|
||||||
|
|
||||||
```
|
```
|
||||||
v=spf1 mx -all
|
v=spf1 mx ~all
|
||||||
```
|
```
|
||||||
|
|
||||||
What it means is only your server can send email with `@mydomain.com` domain.
|
What it means is only your server can send email with `@mydomain.com` domain.
|
||||||
|
@ -3,7 +3,7 @@ from flask import redirect, url_for, request, flash
|
|||||||
from flask_admin import expose, AdminIndexView
|
from flask_admin import expose, AdminIndexView
|
||||||
from flask_admin.actions import action
|
from flask_admin.actions import action
|
||||||
from flask_admin.contrib import sqla
|
from flask_admin.contrib import sqla
|
||||||
from flask_login import current_user
|
from flask_login import current_user, login_user
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import User, ManualSubscription
|
from app.models import User, ManualSubscription
|
||||||
@ -122,6 +122,21 @@ class UserAdmin(SLModelView):
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
@action(
|
||||||
|
"login_as",
|
||||||
|
"Login as this user",
|
||||||
|
"Login as this user?",
|
||||||
|
)
|
||||||
|
def login_as(self, ids):
|
||||||
|
if len(ids) != 1:
|
||||||
|
flash("only 1 user can be selected", "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
for user in User.query.filter(User.id.in_(ids)):
|
||||||
|
login_user(user)
|
||||||
|
flash(f"Login as user {user}", "success")
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
|
def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
|
||||||
query = User.query.filter(User.id.in_(ids))
|
query = User.query.filter(User.id.in_(ids))
|
||||||
@ -209,6 +224,12 @@ class ClientAdmin(SLModelView):
|
|||||||
can_edit = True
|
can_edit = True
|
||||||
|
|
||||||
|
|
||||||
|
class CustomDomainAdmin(SLModelView):
|
||||||
|
column_searchable_list = ["domain", "user.email", "user.id"]
|
||||||
|
column_exclude_list = ["ownership_txt_token"]
|
||||||
|
can_edit = False
|
||||||
|
|
||||||
|
|
||||||
class ReferralAdmin(SLModelView):
|
class ReferralAdmin(SLModelView):
|
||||||
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"]
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import re
|
import re2 as re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from email_validator import validate_email, EmailNotValidError
|
||||||
from sqlalchemy.exc import IntegrityError, DataError
|
from sqlalchemy.exc import IntegrityError, DataError
|
||||||
|
|
||||||
from app.config import BOUNCE_PREFIX_FOR_REPLY_PHASE
|
from app.config import BOUNCE_PREFIX_FOR_REPLY_PHASE
|
||||||
@ -10,6 +11,7 @@ from app.email_utils import (
|
|||||||
send_cannot_create_domain_alias,
|
send_cannot_create_domain_alias,
|
||||||
can_create_directory_for_address,
|
can_create_directory_for_address,
|
||||||
send_cannot_create_directory_alias_disabled,
|
send_cannot_create_directory_alias_disabled,
|
||||||
|
get_email_local_part,
|
||||||
)
|
)
|
||||||
from app.errors import AliasInTrashError
|
from app.errors import AliasInTrashError
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@ -25,18 +27,23 @@ from app.models import (
|
|||||||
Mailbox,
|
Mailbox,
|
||||||
EmailLog,
|
EmailLog,
|
||||||
Contact,
|
Contact,
|
||||||
|
AutoCreateRule,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def try_auto_create(address: str) -> Optional[Alias]:
|
def try_auto_create(address: str) -> Optional[Alias]:
|
||||||
"""Try to auto-create the alias using directory or catch-all domain"""
|
"""Try to auto-create the alias using directory or catch-all domain"""
|
||||||
if address.startswith(f"{BOUNCE_PREFIX_FOR_REPLY_PHASE}+"):
|
if address.startswith(f"{BOUNCE_PREFIX_FOR_REPLY_PHASE}+"):
|
||||||
LOG.exception(
|
LOG.e("alias %s can't start with %s", address, BOUNCE_PREFIX_FOR_REPLY_PHASE)
|
||||||
"alias %s can't start with %s", address, BOUNCE_PREFIX_FOR_REPLY_PHASE
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
alias = try_auto_create_catch_all_domain(address)
|
try:
|
||||||
|
# NOT allow unicode for now
|
||||||
|
validate_email(address, check_deliverability=False, allow_smtputf8=False)
|
||||||
|
except EmailNotValidError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
alias = try_auto_create_via_domain(address)
|
||||||
if not alias:
|
if not alias:
|
||||||
alias = try_auto_create_directory(address)
|
alias = try_auto_create_directory(address)
|
||||||
|
|
||||||
@ -68,16 +75,14 @@ def try_auto_create_directory(address: str) -> Optional[Alias]:
|
|||||||
if not directory:
|
if not directory:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
dir_user: User = directory.user
|
user: User = directory.user
|
||||||
|
|
||||||
if not dir_user.can_create_new_alias():
|
if not user.can_create_new_alias():
|
||||||
send_cannot_create_directory_alias(dir_user, address, directory_name)
|
send_cannot_create_directory_alias(user, address, directory_name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if directory.disabled:
|
if directory.disabled:
|
||||||
send_cannot_create_directory_alias_disabled(
|
send_cannot_create_directory_alias_disabled(user, address, directory_name)
|
||||||
dir_user, address, directory_name
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -90,6 +95,7 @@ def try_auto_create_directory(address: str) -> Optional[Alias]:
|
|||||||
user_id=directory.user_id,
|
user_id=directory.user_id,
|
||||||
directory_id=directory.id,
|
directory_id=directory.id,
|
||||||
mailbox_id=mailboxes[0].id,
|
mailbox_id=mailboxes[0].id,
|
||||||
|
note=f"Created by directory {directory.name}",
|
||||||
)
|
)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
for i in range(1, len(mailboxes)):
|
for i in range(1, len(mailboxes)):
|
||||||
@ -101,22 +107,22 @@ def try_auto_create_directory(address: str) -> Optional[Alias]:
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return alias
|
return alias
|
||||||
except AliasInTrashError:
|
except AliasInTrashError:
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"Alias %s was deleted before, cannot auto-create using directory %s, user %s",
|
"Alias %s was deleted before, cannot auto-create using directory %s, user %s",
|
||||||
address,
|
address,
|
||||||
directory_name,
|
directory_name,
|
||||||
dir_user,
|
user,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
LOG.warning("Alias %s already exists", address)
|
LOG.w("Alias %s already exists", address)
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
alias = Alias.get_by(email=address)
|
alias = Alias.get_by(email=address)
|
||||||
return alias
|
return alias
|
||||||
|
|
||||||
|
|
||||||
def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]:
|
def try_auto_create_via_domain(address: str) -> Optional[Alias]:
|
||||||
"""Try to create an alias with catch-all domain"""
|
"""Try to create an alias with catch-all or auto-create rules on custom domain"""
|
||||||
|
|
||||||
# try to create alias on-the-fly with custom-domain catch-all feature
|
# try to create alias on-the-fly with custom-domain catch-all feature
|
||||||
# check if alias is custom-domain alias and if the custom-domain has catch-all enabled
|
# check if alias is custom-domain alias and if the custom-domain has catch-all enabled
|
||||||
@ -126,11 +132,31 @@ def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]:
|
|||||||
if not custom_domain:
|
if not custom_domain:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# custom_domain exists
|
if not custom_domain.catch_all and len(custom_domain.auto_create_rules) == 0:
|
||||||
if not custom_domain.catch_all:
|
|
||||||
return None
|
return None
|
||||||
|
elif not custom_domain.catch_all and len(custom_domain.auto_create_rules) > 0:
|
||||||
|
local = get_email_local_part(address)
|
||||||
|
|
||||||
|
for rule in custom_domain.auto_create_rules:
|
||||||
|
rule: AutoCreateRule
|
||||||
|
regex = re.compile(rule.regex)
|
||||||
|
if re.fullmatch(regex, local):
|
||||||
|
LOG.d(
|
||||||
|
"%s passes %s on %s",
|
||||||
|
address,
|
||||||
|
rule.regex,
|
||||||
|
custom_domain,
|
||||||
|
)
|
||||||
|
alias_note = f"Created by rule {rule.order} with regex {rule.regex}"
|
||||||
|
mailboxes = rule.mailboxes
|
||||||
|
break
|
||||||
|
else: # no rule passes
|
||||||
|
LOG.d("no rule passed to create %s", local)
|
||||||
|
return
|
||||||
|
else: # catch-all is enabled
|
||||||
|
mailboxes = custom_domain.mailboxes
|
||||||
|
alias_note = "Created by catch-all option"
|
||||||
|
|
||||||
# custom_domain has catch-all enabled
|
|
||||||
domain_user: User = custom_domain.user
|
domain_user: User = custom_domain.user
|
||||||
|
|
||||||
if not domain_user.can_create_new_alias():
|
if not domain_user.can_create_new_alias():
|
||||||
@ -139,13 +165,13 @@ def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
LOG.d("create alias %s for domain %s", address, custom_domain)
|
LOG.d("create alias %s for domain %s", address, custom_domain)
|
||||||
mailboxes = custom_domain.mailboxes
|
|
||||||
alias = Alias.create(
|
alias = Alias.create(
|
||||||
email=address,
|
email=address,
|
||||||
user_id=custom_domain.user_id,
|
user_id=custom_domain.user_id,
|
||||||
custom_domain_id=custom_domain.id,
|
custom_domain_id=custom_domain.id,
|
||||||
automatic_creation=True,
|
automatic_creation=True,
|
||||||
mailbox_id=mailboxes[0].id,
|
mailbox_id=mailboxes[0].id,
|
||||||
|
note=alias_note,
|
||||||
)
|
)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
for i in range(1, len(mailboxes)):
|
for i in range(1, len(mailboxes)):
|
||||||
@ -156,7 +182,7 @@ def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]:
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return alias
|
return alias
|
||||||
except AliasInTrashError:
|
except AliasInTrashError:
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"Alias %s was deleted before, cannot auto-create using domain catch-all %s, user %s",
|
"Alias %s was deleted before, cannot auto-create using domain catch-all %s, user %s",
|
||||||
address,
|
address,
|
||||||
custom_domain,
|
custom_domain,
|
||||||
@ -164,12 +190,12 @@ def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
LOG.warning("Alias %s already exists", address)
|
LOG.w("Alias %s already exists", address)
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
alias = Alias.get_by(email=address)
|
alias = Alias.get_by(email=address)
|
||||||
return alias
|
return alias
|
||||||
except DataError:
|
except DataError:
|
||||||
LOG.warning("Cannot create alias %s", address)
|
LOG.w("Cannot create alias %s", address)
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -184,7 +210,7 @@ def delete_alias(alias: Alias, user: User):
|
|||||||
if not DomainDeletedAlias.get_by(
|
if not DomainDeletedAlias.get_by(
|
||||||
email=alias.email, domain_id=alias.custom_domain_id
|
email=alias.email, domain_id=alias.custom_domain_id
|
||||||
):
|
):
|
||||||
LOG.debug("add %s to domain %s trash", alias, alias.custom_domain_id)
|
LOG.d("add %s to domain %s trash", alias, alias.custom_domain_id)
|
||||||
db.session.add(
|
db.session.add(
|
||||||
DomainDeletedAlias(
|
DomainDeletedAlias(
|
||||||
user_id=user.id, email=alias.email, domain_id=alias.custom_domain_id
|
user_id=user.id, email=alias.email, domain_id=alias.custom_domain_id
|
||||||
|
@ -7,7 +7,15 @@ from sqlalchemy.orm import joinedload
|
|||||||
|
|
||||||
from app.config import PAGE_LIMIT
|
from app.config import PAGE_LIMIT
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import Alias, Contact, EmailLog, Mailbox, AliasMailbox, CustomDomain
|
from app.models import (
|
||||||
|
Alias,
|
||||||
|
Contact,
|
||||||
|
EmailLog,
|
||||||
|
Mailbox,
|
||||||
|
AliasMailbox,
|
||||||
|
CustomDomain,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -129,119 +137,46 @@ def get_alias_infos_with_pagination(user, page_id=0, query=None) -> [AliasInfo]:
|
|||||||
|
|
||||||
|
|
||||||
def get_alias_infos_with_pagination_v3(
|
def get_alias_infos_with_pagination_v3(
|
||||||
user, page_id=0, query=None, sort=None, alias_filter=None
|
user,
|
||||||
|
page_id=0,
|
||||||
|
query=None,
|
||||||
|
sort=None,
|
||||||
|
alias_filter=None,
|
||||||
|
mailbox_id=None,
|
||||||
|
directory_id=None,
|
||||||
) -> [AliasInfo]:
|
) -> [AliasInfo]:
|
||||||
# subquery on alias annotated with nb_reply, nb_blocked, nb_forward, max_created_at, latest_email_log_created_at
|
q = construct_alias_query(user)
|
||||||
alias_activity_subquery = (
|
|
||||||
db.session.query(
|
|
||||||
Alias.id,
|
|
||||||
func.sum(case([(EmailLog.is_reply, 1)], else_=0)).label("nb_reply"),
|
|
||||||
func.sum(
|
|
||||||
case(
|
|
||||||
[(and_(EmailLog.is_reply.is_(False), EmailLog.blocked), 1)],
|
|
||||||
else_=0,
|
|
||||||
)
|
|
||||||
).label("nb_blocked"),
|
|
||||||
func.sum(
|
|
||||||
case(
|
|
||||||
[
|
|
||||||
(
|
|
||||||
and_(
|
|
||||||
EmailLog.is_reply.is_(False),
|
|
||||||
EmailLog.blocked.is_(False),
|
|
||||||
),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
else_=0,
|
|
||||||
)
|
|
||||||
).label("nb_forward"),
|
|
||||||
func.max(EmailLog.created_at).label("latest_email_log_created_at"),
|
|
||||||
)
|
|
||||||
.join(EmailLog, Alias.id == EmailLog.alias_id, isouter=True)
|
|
||||||
.filter(Alias.user_id == user.id)
|
|
||||||
.group_by(Alias.id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
alias_contact_subquery = (
|
|
||||||
db.session.query(Alias.id, func.max(Contact.id).label("max_contact_id"))
|
|
||||||
.join(Contact, Alias.id == Contact.alias_id, isouter=True)
|
|
||||||
.filter(Alias.user_id == user.id)
|
|
||||||
.group_by(Alias.id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
latest_activity = case(
|
|
||||||
[
|
|
||||||
(Alias.created_at > EmailLog.created_at, Alias.created_at),
|
|
||||||
(Alias.created_at < EmailLog.created_at, EmailLog.created_at),
|
|
||||||
],
|
|
||||||
else_=Alias.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
q = (
|
|
||||||
db.session.query(
|
|
||||||
Alias,
|
|
||||||
Contact,
|
|
||||||
EmailLog,
|
|
||||||
CustomDomain,
|
|
||||||
alias_activity_subquery.c.nb_reply,
|
|
||||||
alias_activity_subquery.c.nb_blocked,
|
|
||||||
alias_activity_subquery.c.nb_forward,
|
|
||||||
)
|
|
||||||
.options(joinedload(Alias.hibp_breaches))
|
|
||||||
.join(Contact, Alias.id == Contact.alias_id, isouter=True)
|
|
||||||
.join(CustomDomain, Alias.custom_domain_id == CustomDomain.id, isouter=True)
|
|
||||||
.join(EmailLog, Contact.id == EmailLog.contact_id, isouter=True)
|
|
||||||
.filter(Alias.id == alias_activity_subquery.c.id)
|
|
||||||
.filter(Alias.id == alias_contact_subquery.c.id)
|
|
||||||
.filter(
|
|
||||||
or_(
|
|
||||||
EmailLog.created_at
|
|
||||||
== alias_activity_subquery.c.latest_email_log_created_at,
|
|
||||||
and_(
|
|
||||||
# no email log yet for this alias
|
|
||||||
alias_activity_subquery.c.latest_email_log_created_at.is_(None),
|
|
||||||
# to make sure only 1 contact is returned in this case
|
|
||||||
or_(
|
|
||||||
Contact.id == alias_contact_subquery.c.max_contact_id,
|
|
||||||
alias_contact_subquery.c.max_contact_id.is_(None),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if query:
|
if query:
|
||||||
q = (
|
q = q.filter(
|
||||||
# to find mailbox whose email match the query
|
|
||||||
q.join(AliasMailbox, Alias.id == AliasMailbox.alias_id, isouter=True)
|
|
||||||
.join(
|
|
||||||
Mailbox,
|
|
||||||
or_(
|
|
||||||
Mailbox.id == Alias.mailbox_id,
|
|
||||||
Mailbox.id == AliasMailbox.mailbox_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
or_(
|
or_(
|
||||||
Alias.email.ilike(f"%{query}%"),
|
Alias.email.ilike(f"%{query}%"),
|
||||||
|
Alias.note.ilike(f"%{query}%"),
|
||||||
# can't use match() here as it uses to_tsquery that expected a tsquery input
|
# can't use match() here as it uses to_tsquery that expected a tsquery input
|
||||||
# Alias.ts_vector.match(query),
|
# Alias.ts_vector.match(query),
|
||||||
Alias.ts_vector.op("@@")(func.plainto_tsquery(query)),
|
Alias.ts_vector.op("@@")(func.plainto_tsquery("english", query)),
|
||||||
Alias.name.ilike(f"%{query}%"),
|
Alias.name.ilike(f"%{query}%"),
|
||||||
Mailbox.email.ilike(f"%{query}%"),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if mailbox_id:
|
||||||
|
q = q.join(
|
||||||
|
AliasMailbox, Alias.id == AliasMailbox.alias_id, isouter=True
|
||||||
|
).filter(
|
||||||
|
or_(Alias.mailbox_id == mailbox_id, AliasMailbox.mailbox_id == mailbox_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if directory_id:
|
||||||
|
q = q.filter(Alias.directory_id == directory_id)
|
||||||
|
|
||||||
if alias_filter == "enabled":
|
if alias_filter == "enabled":
|
||||||
q = q.filter(Alias.enabled)
|
q = q.filter(Alias.enabled)
|
||||||
elif alias_filter == "disabled":
|
elif alias_filter == "disabled":
|
||||||
q = q.filter(Alias.enabled.is_(False))
|
q = q.filter(Alias.enabled.is_(False))
|
||||||
|
elif alias_filter == "pinned":
|
||||||
q = q.order_by(Alias.pinned.desc())
|
q = q.filter(Alias.pinned)
|
||||||
|
elif alias_filter == "hibp":
|
||||||
|
q = q.filter(Alias.hibp_breaches.any())
|
||||||
|
|
||||||
if sort == "old2new":
|
if sort == "old2new":
|
||||||
q = q.order_by(Alias.created_at)
|
q = q.order_by(Alias.created_at)
|
||||||
@ -251,10 +186,16 @@ def get_alias_infos_with_pagination_v3(
|
|||||||
q = q.order_by(Alias.email)
|
q = q.order_by(Alias.email)
|
||||||
elif sort == "z2a":
|
elif sort == "z2a":
|
||||||
q = q.order_by(Alias.email.desc())
|
q = q.order_by(Alias.email.desc())
|
||||||
elif alias_filter == "hibp":
|
|
||||||
q = q.filter(Alias.hibp_breaches.any())
|
|
||||||
else:
|
else:
|
||||||
# default sorting
|
# default sorting
|
||||||
|
latest_activity = case(
|
||||||
|
[
|
||||||
|
(Alias.created_at > EmailLog.created_at, Alias.created_at),
|
||||||
|
(Alias.created_at < EmailLog.created_at, EmailLog.created_at),
|
||||||
|
],
|
||||||
|
else_=Alias.created_at,
|
||||||
|
)
|
||||||
|
q = q.order_by(Alias.pinned.desc())
|
||||||
q = q.order_by(latest_activity.desc())
|
q = q.order_by(latest_activity.desc())
|
||||||
|
|
||||||
q = list(q.limit(PAGE_LIMIT).offset(page_id * PAGE_LIMIT))
|
q = list(q.limit(PAGE_LIMIT).offset(page_id * PAGE_LIMIT))
|
||||||
@ -367,3 +308,98 @@ def get_alias_contacts(alias, page_id: int) -> [dict]:
|
|||||||
res.append(serialize_contact(fe))
|
res.append(serialize_contact(fe))
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def get_alias_info_v3(user: User, alias_id: int) -> AliasInfo:
|
||||||
|
# use the same query construction in get_alias_infos_with_pagination_v3
|
||||||
|
q = construct_alias_query(user)
|
||||||
|
q = q.filter(Alias.id == alias_id)
|
||||||
|
|
||||||
|
for alias, contact, email_log, custom_domain, nb_reply, nb_blocked, nb_forward in q:
|
||||||
|
return AliasInfo(
|
||||||
|
alias=alias,
|
||||||
|
mailbox=alias.mailbox,
|
||||||
|
mailboxes=alias.mailboxes,
|
||||||
|
nb_forward=nb_forward,
|
||||||
|
nb_blocked=nb_blocked,
|
||||||
|
nb_reply=nb_reply,
|
||||||
|
latest_email_log=email_log,
|
||||||
|
latest_contact=contact,
|
||||||
|
custom_domain=custom_domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def construct_alias_query(user: User):
|
||||||
|
# subquery on alias annotated with nb_reply, nb_blocked, nb_forward, max_created_at, latest_email_log_created_at
|
||||||
|
alias_activity_subquery = (
|
||||||
|
db.session.query(
|
||||||
|
Alias.id,
|
||||||
|
func.sum(case([(EmailLog.is_reply, 1)], else_=0)).label("nb_reply"),
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
[(and_(EmailLog.is_reply.is_(False), EmailLog.blocked), 1)],
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
).label("nb_blocked"),
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
and_(
|
||||||
|
EmailLog.is_reply.is_(False),
|
||||||
|
EmailLog.blocked.is_(False),
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
).label("nb_forward"),
|
||||||
|
func.max(EmailLog.created_at).label("latest_email_log_created_at"),
|
||||||
|
)
|
||||||
|
.join(EmailLog, Alias.id == EmailLog.alias_id, isouter=True)
|
||||||
|
.filter(Alias.user_id == user.id)
|
||||||
|
.group_by(Alias.id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
alias_contact_subquery = (
|
||||||
|
db.session.query(Alias.id, func.max(Contact.id).label("max_contact_id"))
|
||||||
|
.join(Contact, Alias.id == Contact.alias_id, isouter=True)
|
||||||
|
.filter(Alias.user_id == user.id)
|
||||||
|
.group_by(Alias.id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
db.session.query(
|
||||||
|
Alias,
|
||||||
|
Contact,
|
||||||
|
EmailLog,
|
||||||
|
CustomDomain,
|
||||||
|
alias_activity_subquery.c.nb_reply,
|
||||||
|
alias_activity_subquery.c.nb_blocked,
|
||||||
|
alias_activity_subquery.c.nb_forward,
|
||||||
|
)
|
||||||
|
.options(joinedload(Alias.hibp_breaches))
|
||||||
|
.join(Contact, Alias.id == Contact.alias_id, isouter=True)
|
||||||
|
.join(CustomDomain, Alias.custom_domain_id == CustomDomain.id, isouter=True)
|
||||||
|
.join(EmailLog, Contact.id == EmailLog.contact_id, isouter=True)
|
||||||
|
.filter(Alias.id == alias_activity_subquery.c.id)
|
||||||
|
.filter(Alias.id == alias_contact_subquery.c.id)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
EmailLog.created_at
|
||||||
|
== alias_activity_subquery.c.latest_email_log_created_at,
|
||||||
|
and_(
|
||||||
|
# no email log yet for this alias
|
||||||
|
alias_activity_subquery.c.latest_email_log_created_at.is_(None),
|
||||||
|
# to make sure only 1 contact is returned in this case
|
||||||
|
or_(
|
||||||
|
Contact.id == alias_contact_subquery.c.max_contact_id,
|
||||||
|
alias_contact_subquery.c.max_contact_id.is_(None),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from flanker.addresslib import address
|
||||||
|
from flanker.addresslib.address import EmailAddress
|
||||||
from flask import g
|
from flask import g
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
from flask import request
|
from flask import request
|
||||||
@ -16,8 +18,6 @@ from app.api.serializer import (
|
|||||||
)
|
)
|
||||||
from app.dashboard.views.alias_log import get_alias_log
|
from app.dashboard.views.alias_log import get_alias_log
|
||||||
from app.email_utils import (
|
from app.email_utils import (
|
||||||
parseaddr_unicode,
|
|
||||||
is_valid_email,
|
|
||||||
generate_reply_email,
|
generate_reply_email,
|
||||||
)
|
)
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@ -400,10 +400,11 @@ def create_contact_route(alias_id):
|
|||||||
if not contact_addr:
|
if not contact_addr:
|
||||||
return jsonify(error="Contact cannot be empty"), 400
|
return jsonify(error="Contact cannot be empty"), 400
|
||||||
|
|
||||||
contact_name, contact_email = parseaddr_unicode(contact_addr)
|
full_address: EmailAddress = address.parse(contact_addr)
|
||||||
|
if not full_address:
|
||||||
|
return jsonify(error=f"invalid contact email {contact_addr}"), 400
|
||||||
|
|
||||||
if not is_valid_email(contact_email):
|
contact_name, contact_email = full_address.display_name, full_address.address
|
||||||
return jsonify(error=f"invalid contact email {contact_email}"), 400
|
|
||||||
|
|
||||||
contact_email = sanitize_email(contact_email)
|
contact_email = sanitize_email(contact_email)
|
||||||
|
|
||||||
|
@ -36,7 +36,7 @@ def apple_process_payment():
|
|||||||
200 of the payment is successful, i.e. user is upgraded to premium
|
200 of the payment is successful, i.e. user is upgraded to premium
|
||||||
|
|
||||||
"""
|
"""
|
||||||
LOG.debug("request for /apple/process_payment")
|
LOG.d("request for /apple/process_payment")
|
||||||
user = g.user
|
user = g.user
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
receipt_data = data.get("receipt_data")
|
receipt_data = data.get("receipt_data")
|
||||||
@ -229,7 +229,7 @@ def apple_update_notification():
|
|||||||
# "auto_renew_product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
|
# "auto_renew_product_id": "io.simplelogin.ios_app.subscription.premium.yearly",
|
||||||
# "notification_type": "DID_CHANGE_RENEWAL_STATUS",
|
# "notification_type": "DID_CHANGE_RENEWAL_STATUS",
|
||||||
# }
|
# }
|
||||||
LOG.debug("request for /api/apple/update_notification")
|
LOG.d("request for /api/apple/update_notification")
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not (
|
if not (
|
||||||
data
|
data
|
||||||
@ -282,7 +282,7 @@ def apple_update_notification():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify(ok=True), 200
|
return jsonify(ok=True), 200
|
||||||
else:
|
else:
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"No existing AppleSub for original_transaction_id %s",
|
"No existing AppleSub for original_transaction_id %s",
|
||||||
original_transaction_id,
|
original_transaction_id,
|
||||||
)
|
)
|
||||||
@ -305,16 +305,16 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
|||||||
_PROD_URL, json={"receipt-data": receipt_data, "password": password}
|
_PROD_URL, json={"receipt-data": receipt_data, "password": password}
|
||||||
)
|
)
|
||||||
except RequestException:
|
except RequestException:
|
||||||
LOG.warning("cannot call Apple server %s", _PROD_URL)
|
LOG.w("cannot call Apple server %s", _PROD_URL)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if r.status_code >= 500:
|
if r.status_code >= 500:
|
||||||
LOG.warning("Apple server error, response:%s %s", r, r.content)
|
LOG.w("Apple server error, response:%s %s", r, r.content)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if r.json() == {"status": 21007}:
|
if r.json() == {"status": 21007}:
|
||||||
# try sandbox_url
|
# try sandbox_url
|
||||||
LOG.warning("Use the sandbox url instead")
|
LOG.w("Use the sandbox url instead")
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
_SANDBOX_URL,
|
_SANDBOX_URL,
|
||||||
json={"receipt-data": receipt_data, "password": password},
|
json={"receipt-data": receipt_data, "password": password},
|
||||||
@ -472,7 +472,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
|||||||
# }
|
# }
|
||||||
|
|
||||||
if data["status"] != 0:
|
if data["status"] != 0:
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"verifyReceipt status !=0, probably invalid receipt. User %s",
|
"verifyReceipt status !=0, probably invalid receipt. User %s",
|
||||||
user,
|
user,
|
||||||
)
|
)
|
||||||
@ -499,7 +499,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
|||||||
# }
|
# }
|
||||||
transactions = data["receipt"]["in_app"]
|
transactions = data["receipt"]["in_app"]
|
||||||
if not transactions:
|
if not transactions:
|
||||||
LOG.warning("Empty transactions in data %s", data)
|
LOG.w("Empty transactions in data %s", data)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
latest_transaction = max(transactions, key=lambda t: int(t["expires_date_ms"]))
|
latest_transaction = max(transactions, key=lambda t: int(t["expires_date_ms"]))
|
||||||
@ -527,7 +527,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
|||||||
else:
|
else:
|
||||||
# the same original_transaction_id has been used on another account
|
# the same original_transaction_id has been used on another account
|
||||||
if AppleSubscription.get_by(original_transaction_id=original_transaction_id):
|
if AppleSubscription.get_by(original_transaction_id=original_transaction_id):
|
||||||
LOG.exception("Same Apple Sub has been used before, current user %s", user)
|
LOG.e("Same Apple Sub has been used before, current user %s", user)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
LOG.d(
|
LOG.d(
|
||||||
|
@ -96,7 +96,7 @@ def auth_register():
|
|||||||
if not password or len(password) < 8:
|
if not password or len(password) < 8:
|
||||||
return jsonify(error="password too short"), 400
|
return jsonify(error="password too short"), 400
|
||||||
|
|
||||||
LOG.debug("create user %s", email)
|
LOG.d("create user %s", email)
|
||||||
user = User.create(email=email, name="", password=password)
|
user = User.create(email=email, name="", password=password)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
@ -166,7 +166,7 @@ def auth_activate():
|
|||||||
|
|
||||||
return jsonify(error="Wrong email or code"), 400
|
return jsonify(error="Wrong email or code"), 400
|
||||||
|
|
||||||
LOG.debug("activate user %s", user)
|
LOG.d("activate user %s", user)
|
||||||
user.activated = True
|
user.activated = True
|
||||||
AccountActivation.delete(account_activation.id)
|
AccountActivation.delete(account_activation.id)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
@ -69,10 +69,10 @@ def new_custom_alias_v2():
|
|||||||
try:
|
try:
|
||||||
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
||||||
except SignatureExpired:
|
except SignatureExpired:
|
||||||
LOG.warning("Alias creation time expired for %s", user)
|
LOG.w("Alias creation time expired for %s", user)
|
||||||
return jsonify(error="Alias creation time is expired, please retry"), 412
|
return jsonify(error="Alias creation time is expired, please retry"), 412
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.warning("Alias suffix is tampered, user %s", user)
|
LOG.w("Alias suffix is tampered, user %s", user)
|
||||||
return jsonify(error="Tampered suffix"), 400
|
return jsonify(error="Tampered suffix"), 400
|
||||||
|
|
||||||
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
|
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
|
||||||
@ -184,10 +184,10 @@ def new_custom_alias_v3():
|
|||||||
try:
|
try:
|
||||||
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
||||||
except SignatureExpired:
|
except SignatureExpired:
|
||||||
LOG.warning("Alias creation time expired for %s", user)
|
LOG.w("Alias creation time expired for %s", user)
|
||||||
return jsonify(error="Alias creation time is expired, please retry"), 412
|
return jsonify(error="Alias creation time is expired, please retry"), 412
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.warning("Alias suffix is tampered, user %s", user)
|
LOG.w("Alias suffix is tampered, user %s", user)
|
||||||
return jsonify(error="Tampered suffix"), 400
|
return jsonify(error="Tampered suffix"), 400
|
||||||
|
|
||||||
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
|
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
|
||||||
|
@ -48,7 +48,7 @@ def new_random_alias():
|
|||||||
elif mode == "uuid":
|
elif mode == "uuid":
|
||||||
scheme = AliasGeneratorEnum.uuid.value
|
scheme = AliasGeneratorEnum.uuid.value
|
||||||
else:
|
else:
|
||||||
return jsonify(error=f"{mode} must be either word or alias"), 400
|
return jsonify(error=f"{mode} must be either word or uuid"), 400
|
||||||
|
|
||||||
alias = Alias.create_new_random(user=user, scheme=scheme, note=note)
|
alias = Alias.create_new_random(user=user, scheme=scheme, note=note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
@ -87,7 +87,7 @@ def update_setting():
|
|||||||
|
|
||||||
# sanity check
|
# sanity check
|
||||||
if custom_domain.user_id != user.id or not custom_domain.verified:
|
if custom_domain.user_id != user.id or not custom_domain.verified:
|
||||||
LOG.exception("%s cannot use domain %s", user, default_domain)
|
LOG.e("%s cannot use domain %s", user, default_domain)
|
||||||
return jsonify(error="invalid domain"), 400
|
return jsonify(error="invalid domain"), 400
|
||||||
else:
|
else:
|
||||||
user.default_alias_custom_domain_id = custom_domain.id
|
user.default_alias_custom_domain_id = custom_domain.id
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
<form class="card" method="post">
|
<form class="card" method="post">
|
||||||
{{ form.csrf_token }}
|
{{ form.csrf_token }}
|
||||||
<div class="card-body p-6">
|
<div class="card-body p-6">
|
||||||
<div class="card-title">Forgot password</div>
|
<h1 class="card-title">Forgot password</h1>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Email address</label>
|
<label class="form-label">Email address</label>
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
<form class="card" style="border-radius: 2%" method="post">
|
<form class="card" style="border-radius: 2%" method="post">
|
||||||
{{ form.csrf_token }}
|
{{ form.csrf_token }}
|
||||||
<div class="card-body p-6">
|
<div class="card-body p-6">
|
||||||
<div class="card-title">Welcome back!</div>
|
<h1 class="card-title">Welcome back!</h1>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Email address</label>
|
<label class="form-label">Email address</label>
|
||||||
{{ form.email(class="form-control", type="email", autofocus="true") }}
|
{{ form.email(class="form-control", type="email", autofocus="true") }}
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
<form class="card" style="border-radius: 2%" method="post">
|
<form class="card" style="border-radius: 2%" method="post">
|
||||||
{{ form.csrf_token }}
|
{{ form.csrf_token }}
|
||||||
<div class="card-body p-6">
|
<div class="card-body p-6">
|
||||||
<div class="card-title">Create new account</div>
|
<h1 class="card-title">Create new account</h1>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Email address</label>
|
<label class="form-label">Email address</label>
|
||||||
|
@ -57,8 +57,8 @@ def activate():
|
|||||||
# The activation link contains the original page, for ex authorize page
|
# The activation link contains the original page, for ex authorize page
|
||||||
if "next" in request.args:
|
if "next" in request.args:
|
||||||
next_url = request.args.get("next")
|
next_url = request.args.get("next")
|
||||||
LOG.debug("redirect user to %s", next_url)
|
LOG.d("redirect user to %s", next_url)
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
else:
|
else:
|
||||||
LOG.debug("redirect user to dashboard")
|
LOG.d("redirect user to dashboard")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
|
@ -115,7 +115,7 @@ def facebook_callback():
|
|||||||
# The activation link contains the original page, for ex authorize page
|
# The activation link contains the original page, for ex authorize page
|
||||||
if "facebook_next_url" in session:
|
if "facebook_next_url" in session:
|
||||||
next_url = session["facebook_next_url"]
|
next_url = session["facebook_next_url"]
|
||||||
LOG.debug("redirect user to %s", next_url)
|
LOG.d("redirect user to %s", next_url)
|
||||||
|
|
||||||
# reset the next_url to avoid user getting redirected at each login :)
|
# reset the next_url to avoid user getting redirected at each login :)
|
||||||
session.pop("facebook_next_url", None)
|
session.pop("facebook_next_url", None)
|
||||||
|
@ -95,7 +95,7 @@ def fido():
|
|||||||
)
|
)
|
||||||
new_sign_count = webauthn_assertion_response.verify()
|
new_sign_count = webauthn_assertion_response.verify()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.warning(f"An error occurred in WebAuthn verification process: {e}")
|
LOG.w(f"An error occurred in WebAuthn verification process: {e}")
|
||||||
flash("Key verification failed.", "warning")
|
flash("Key verification failed.", "warning")
|
||||||
# Trigger rate limiter
|
# Trigger rate limiter
|
||||||
g.deduct_limit = True
|
g.deduct_limit = True
|
||||||
|
@ -75,7 +75,7 @@ def github_callback():
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not email:
|
if not email:
|
||||||
LOG.exception(f"cannot get email for github user {github_user_data} {emails}")
|
LOG.e(f"cannot get email for github user {github_user_data} {emails}")
|
||||||
flash(
|
flash(
|
||||||
"Cannot get a valid email from Github, please another way to login/sign up",
|
"Cannot get a valid email from Github, please another way to login/sign up",
|
||||||
"error",
|
"error",
|
||||||
|
@ -101,7 +101,7 @@ def google_callback():
|
|||||||
# The activation link contains the original page, for ex authorize page
|
# The activation link contains the original page, for ex authorize page
|
||||||
if "google_next_url" in session:
|
if "google_next_url" in session:
|
||||||
next_url = session["google_next_url"]
|
next_url = session["google_next_url"]
|
||||||
LOG.debug("redirect user to %s", next_url)
|
LOG.d("redirect user to %s", next_url)
|
||||||
|
|
||||||
# reset the next_url to avoid user getting redirected at each login :)
|
# reset the next_url to avoid user getting redirected at each login :)
|
||||||
session.pop("google_next_url", None)
|
session.pop("google_next_url", None)
|
||||||
|
@ -25,7 +25,7 @@ def login():
|
|||||||
|
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
if next_url:
|
if next_url:
|
||||||
LOG.debug("user is already authenticated, redirect to %s", next_url)
|
LOG.d("user is already authenticated, redirect to %s", next_url)
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
else:
|
else:
|
||||||
LOG.d("user is already authenticated, redirect to dashboard")
|
LOG.d("user is already authenticated, redirect to dashboard")
|
||||||
|
@ -29,15 +29,15 @@ def after_login(user, next_url):
|
|||||||
else:
|
else:
|
||||||
return redirect(url_for("auth.mfa"))
|
return redirect(url_for("auth.mfa"))
|
||||||
else:
|
else:
|
||||||
LOG.debug("log user %s in", user)
|
LOG.d("log user %s in", user)
|
||||||
login_user(user)
|
login_user(user)
|
||||||
|
|
||||||
# User comes to login page from another page
|
# User comes to login page from another page
|
||||||
if next_url:
|
if next_url:
|
||||||
LOG.debug("redirect user to %s", next_url)
|
LOG.d("redirect user to %s", next_url)
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
else:
|
else:
|
||||||
LOG.debug("redirect user to dashboard")
|
LOG.d("redirect user to dashboard")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@ -58,10 +58,10 @@ def recovery_route():
|
|||||||
|
|
||||||
# User comes to login page from another page
|
# User comes to login page from another page
|
||||||
if next_url:
|
if next_url:
|
||||||
LOG.debug("redirect user to %s", next_url)
|
LOG.d("redirect user to %s", next_url)
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
else:
|
else:
|
||||||
LOG.debug("redirect user to dashboard")
|
LOG.d("redirect user to dashboard")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
else:
|
else:
|
||||||
# Trigger rate limiter
|
# Trigger rate limiter
|
||||||
|
@ -53,7 +53,7 @@ def register():
|
|||||||
# 'challenge_ts': '2020-07-23T10:03:25',
|
# 'challenge_ts': '2020-07-23T10:03:25',
|
||||||
# 'hostname': '127.0.0.1'}
|
# 'hostname': '127.0.0.1'}
|
||||||
if not hcaptcha_res["success"]:
|
if not hcaptcha_res["success"]:
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"User put wrong captcha %s %s",
|
"User put wrong captcha %s %s",
|
||||||
form.email.data,
|
form.email.data,
|
||||||
hcaptcha_res,
|
hcaptcha_res,
|
||||||
@ -74,7 +74,7 @@ def register():
|
|||||||
if personal_email_already_used(email):
|
if personal_email_already_used(email):
|
||||||
flash(f"Email {email} already used", "error")
|
flash(f"Email {email} already used", "error")
|
||||||
else:
|
else:
|
||||||
LOG.debug("create user %s", email)
|
LOG.d("create user %s", email)
|
||||||
user = User.create(
|
user = User.create(
|
||||||
email=email,
|
email=email,
|
||||||
name="",
|
name="",
|
||||||
|
@ -45,7 +45,6 @@ if config_file:
|
|||||||
else:
|
else:
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
RESET_DB = "RESET_DB" in os.environ
|
|
||||||
COLOR_LOG = "COLOR_LOG" in os.environ
|
COLOR_LOG = "COLOR_LOG" in os.environ
|
||||||
|
|
||||||
# Allow user to have 1 year of premium: set the expiration_date to 1 year more
|
# Allow user to have 1 year of premium: set the expiration_date to 1 year more
|
||||||
@ -158,7 +157,6 @@ DISABLE_ALIAS_SUFFIX = "DISABLE_ALIAS_SUFFIX" in os.environ
|
|||||||
UNSUBSCRIBER = os.environ.get("UNSUBSCRIBER")
|
UNSUBSCRIBER = os.environ.get("UNSUBSCRIBER")
|
||||||
|
|
||||||
DKIM_SELECTOR = b"dkim"
|
DKIM_SELECTOR = b"dkim"
|
||||||
DKIM_HEADERS = [b"from", b"to"]
|
|
||||||
DKIM_PRIVATE_KEY = None
|
DKIM_PRIVATE_KEY = None
|
||||||
|
|
||||||
if "DKIM_PRIVATE_KEY_PATH" in os.environ:
|
if "DKIM_PRIVATE_KEY_PATH" in os.environ:
|
||||||
@ -334,6 +332,9 @@ AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN = "custom_domain_mx_record_issue"
|
|||||||
# alert when a new alias is about to be created on a disabled directory
|
# alert when a new alias is about to be created on a disabled directory
|
||||||
ALERT_DIRECTORY_DISABLED_ALIAS_CREATION = "alert_directory_disabled_alias_creation"
|
ALERT_DIRECTORY_DISABLED_ALIAS_CREATION = "alert_directory_disabled_alias_creation"
|
||||||
|
|
||||||
|
ALERT_HOTMAIL_COMPLAINT = "alert_hotmail_complaint"
|
||||||
|
ALERT_YAHOO_COMPLAINT = "alert_yahoo_complaint"
|
||||||
|
|
||||||
# <<<<< END ALERT EMAIL >>>>
|
# <<<<< END ALERT EMAIL >>>>
|
||||||
|
|
||||||
# Disable onboarding emails
|
# Disable onboarding emails
|
||||||
@ -394,3 +395,11 @@ except Exception:
|
|||||||
HIBP_API_KEYS = sl_getenv("HIBP_API_KEYS", list) or []
|
HIBP_API_KEYS = sl_getenv("HIBP_API_KEYS", list) or []
|
||||||
|
|
||||||
NEWRELIC_CONFIG_PATH = os.environ.get("NEWRELIC_CONFIG_PATH")
|
NEWRELIC_CONFIG_PATH = os.environ.get("NEWRELIC_CONFIG_PATH")
|
||||||
|
|
||||||
|
POSTMASTER = os.environ.get("POSTMASTER")
|
||||||
|
|
||||||
|
# store temporary files, especially for debugging
|
||||||
|
TEMP_DIR = os.environ.get("TEMP_DIR")
|
||||||
|
|
||||||
|
# enable the alias automation disable: an alias can be automatically disabled if it has too many bounces
|
||||||
|
ALIAS_AUTOMATIC_DISABLE = "ALIAS_AUTOMATIC_DISABLE" in os.environ
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">{{ api_key.name }}</h5>
|
<h5 class="card-title">{{ api_key.name or "N/A" }}</h5>
|
||||||
<h6 class="card-subtitle mb-2 text-muted">
|
<h6 class="card-subtitle mb-2 text-muted">
|
||||||
{% if api_key.last_used %}
|
{% if api_key.last_used %}
|
||||||
Last used: {{ api_key.last_used | dt }} <br>
|
Last used: {{ api_key.last_used | dt }} <br>
|
||||||
|
@ -25,15 +25,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" data-parsley-validate>
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="col-sm-6 mb-1 p-1" style="min-width: 4em">
|
<div class="col-sm-6 mb-1 p-1" style="min-width: 4em">
|
||||||
<input name="prefix" class="form-control"
|
<input name="prefix" class="form-control"
|
||||||
id="prefix"
|
id="prefix"
|
||||||
type="text"
|
type="text"
|
||||||
pattern="[0-9a-z-_.]{1,}"
|
data-parsley-pattern="[0-9a-z-_.]{1,}"
|
||||||
|
data-parsley-trigger="change"
|
||||||
|
data-parsley-error-message="Only lowercase letters, dots, numbers, dashes (-) and underscores (_) are currently supported."
|
||||||
maxlength="40"
|
maxlength="40"
|
||||||
data-bouncer-message="Only lowercase letters, dots, numbers, dashes (-) and underscores (_) are currently supported."
|
|
||||||
placeholder="Alias prefix, for example newsletter.com-123_xyz"
|
placeholder="Alias prefix, for example newsletter.com-123_xyz"
|
||||||
autofocus required>
|
autofocus required>
|
||||||
|
|
||||||
@ -69,7 +70,7 @@
|
|||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="col p-1">
|
<div class="col p-1">
|
||||||
<select data-width="100%"
|
<select data-width="100%"
|
||||||
class="mailbox-select" id="mailboxes" multiple name="mailboxes">
|
class="mailbox-select" id="mailboxes" multiple name="mailboxes" required>
|
||||||
{% for mailbox in mailboxes %}
|
{% for mailbox in mailboxes %}
|
||||||
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}
|
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}
|
||||||
selected {% endif %}>
|
selected {% endif %}>
|
||||||
@ -94,7 +95,7 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col p-1">
|
<div class="col p-1">
|
||||||
<button type="button" id="submit" class="btn btn-primary mt-1">Create</button>
|
<button type="submit" id="create" class="btn btn-primary mt-1">Create</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -105,9 +106,6 @@
|
|||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script>
|
<script>
|
||||||
// init bouncer
|
|
||||||
new Bouncer('form');
|
|
||||||
|
|
||||||
$('.mailbox-select').multipleSelect();
|
$('.mailbox-select').multipleSelect();
|
||||||
|
|
||||||
// Ctrl-enter submit the form
|
// Ctrl-enter submit the form
|
||||||
@ -117,7 +115,7 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
$("#submit").on("click", async function () {
|
$("#create").on("click", async function () {
|
||||||
let that = $(this);
|
let that = $(this);
|
||||||
let mailbox_ids = $(`#mailboxes`).val();
|
let mailbox_ids = $(`#mailboxes`).val();
|
||||||
let prefix = $('#prefix').val();
|
let prefix = $('#prefix').val();
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
{% if not current_user.is_premium() %}
|
{% if not current_user.is_premium() %}
|
||||||
<div class="alert alert-danger" role="alert">
|
<div class="alert alert-danger" role="alert">
|
||||||
This feature is only available on Premium plan.
|
This feature is only available on Premium plan.
|
||||||
<a href="{{URL}}/dashboard/pricing" target="_blank" rel="noopener">
|
<a href="{{ URL }}/dashboard/pricing" target="_blank" rel="noopener">
|
||||||
Upgrade<i class="fe fe-external-link"></i>
|
Upgrade<i class="fe fe-external-link"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -42,51 +42,27 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">
|
<h5 class="card-title">
|
||||||
<a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}">{{ custom_domain.domain }}</a>
|
<a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}">{{ custom_domain.domain }}</a>
|
||||||
{% if custom_domain.verified %}
|
{% if custom_domain.ownership_verified and not custom_domain.verified %}
|
||||||
<span class="cursor" data-toggle="tooltip" data-original-title="Domain Verified">✅</span>
|
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id,
|
||||||
{% else %}
|
_anchor='dns-setup') }}" class="btn btn-info btn-sm">
|
||||||
<span class="cursor" data-toggle="tooltip" data-original-title="DNS Setup Needed">
|
Ownership verified. Setup the DNS
|
||||||
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id) }}"
|
</a>
|
||||||
class="text-decoration-none">🚫
|
{% elif custom_domain.ownership_verified and custom_domain.verified %}
|
||||||
|
<span class="badge badge-success">Domain ready</span>
|
||||||
|
|
||||||
|
<!-- custom_domain.ownership_verified is False -->
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id,
|
||||||
|
_anchor='ownership-form') }}" class="btn btn-warning btn-sm" role="button">
|
||||||
|
Verify domain ownership
|
||||||
</a>
|
</a>
|
||||||
</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
<h6 class="card-subtitle mb-4 text-muted">
|
<h6 class="card-subtitle mb-4 text-muted">
|
||||||
Created {{ custom_domain.created_at | dt }} <br>
|
Created {{ custom_domain.created_at | dt }} <br>
|
||||||
<span class="font-weight-bold">{{ custom_domain.nb_alias() }}</span> aliases.
|
<span class="font-weight-bold">{{ custom_domain.nb_alias() }}</span> aliases.
|
||||||
<br><br>
|
|
||||||
|
|
||||||
<b>Mailboxes:</b>
|
|
||||||
<i class="fe fe-info" data-toggle="tooltip"
|
|
||||||
title="Aliases created with this domain are automatically owned by these mailboxes">
|
|
||||||
</i>
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
{% set domain_mailboxes=custom_domain.mailboxes %}
|
|
||||||
<form method="post" class="mt-2">
|
|
||||||
<input type="hidden" name="form-name" value="update">
|
|
||||||
<input type="hidden" name="domain-id" value="{{ custom_domain.id }}">
|
|
||||||
|
|
||||||
<div class="d-flex">
|
|
||||||
<div class="flex-grow-1 mr-2">
|
|
||||||
<select data-width="100%" required
|
|
||||||
class="mailbox-select" multiple name="mailbox_ids">
|
|
||||||
{% for mailbox in mailboxes %}
|
|
||||||
<option value="{{ mailbox.id }}" {% if mailbox in domain_mailboxes %}
|
|
||||||
selected {% endif %}>
|
|
||||||
{{ mailbox.email }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button class="btn btn-outline-primary btn-sm">Update</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</h6>
|
</h6>
|
||||||
|
|
||||||
<a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}" class="mt-3">
|
<a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}" class="mt-3">
|
||||||
@ -112,25 +88,10 @@
|
|||||||
{{ new_custom_domain_form.domain(class="form-control", placeholder="my-domain.com", maxlength=128) }}
|
{{ new_custom_domain_form.domain(class="form-control", placeholder="my-domain.com", maxlength=128) }}
|
||||||
{{ render_field_errors(new_custom_domain_form.domain) }}
|
{{ render_field_errors(new_custom_domain_form.domain) }}
|
||||||
<div class="small-text">
|
<div class="small-text">
|
||||||
Please use full path domain, for ex <em>my-subdomain.my-domain.com</em>
|
Please use full path domain, for example <b>my-domain.com</b>
|
||||||
|
or <b>my-subdomain.my-domain.com</b> if you are using a subdomain.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 small-text alert alert-info">
|
|
||||||
By default, aliases created with your domain are "owned" by your default
|
|
||||||
mailbox <b>{{ current_user.default_mailbox.email }}</b>. <br>
|
|
||||||
This below option allow you to choose the mailbox(es) that a new alias automatically belongs to.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<select data-width="100%"
|
|
||||||
class="mailbox-select" multiple name="mailbox_ids">
|
|
||||||
{% for mailbox in mailboxes %}
|
|
||||||
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}
|
|
||||||
selected {% endif %}>
|
|
||||||
{{ mailbox.email }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button class="btn btn-primary mt-2">Create</button>
|
<button class="btn btn-primary mt-2">Create</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
<em>my_directory+<b>anything</b>@{{ FIRST_ALIAS_DOMAIN }}</em> or <br>
|
<em>my_directory+<b>anything</b>@{{ FIRST_ALIAS_DOMAIN }}</em> or <br>
|
||||||
<em>my_directory#<b>anything</b>@{{ FIRST_ALIAS_DOMAIN }}</em> <br>
|
<em>my_directory#<b>anything</b>@{{ FIRST_ALIAS_DOMAIN }}</em> <br>
|
||||||
</div>
|
</div>
|
||||||
<em><b>anything</b></em> is any string composed of lowercase character. <br>
|
<em><b>anything</b></em> is any string composed of lowercase characters. <br>
|
||||||
|
|
||||||
You can find more info on directory on our <a href="https://simplelogin.io/blog/alias-directory/">blog post</a>.
|
You can find more info on directory on our <a href="https://simplelogin.io/blog/alias-directory/">blog post</a>.
|
||||||
|
|
||||||
|
171
app/dashboard/templates/dashboard/domain_detail/auto-create.html
Normal file
171
app/dashboard/templates/dashboard/domain_detail/auto-create.html
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
{% extends 'dashboard/domain_detail/base.html' %}
|
||||||
|
|
||||||
|
{% set domain_detail_page = "auto_create" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{{ custom_domain.domain }} Auto Create Rules
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block domain_detail_content %}
|
||||||
|
<h1 class="h2 mb-1"> {{ custom_domain.domain }} auto create alias rules </h1>
|
||||||
|
<div>
|
||||||
|
<span class="badge badge-info">Advanced</span>
|
||||||
|
<span class="badge badge-warning">Beta</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% if custom_domain.catch_all %}
|
||||||
|
<div class="alert alert-warning mt-3">
|
||||||
|
Rules are ineffective when catch-all is enabled.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="{% if custom_domain.catch_all %} disabled-content {% endif %}">
|
||||||
|
<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. <br>
|
||||||
|
A rule is based on a regular expression (<b>regex</b>): if an alias matches the expression, it'll be automatically
|
||||||
|
created.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Only the local part of the alias (i.e. <b>@{{ custom_domain.domain }}</b> is ignored) during the
|
||||||
|
regex test.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
When there are several rules, rules will be evaluated by their order.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if custom_domain.auto_create_rules | length > 0 %}
|
||||||
|
<div class="mt-2" id="rule-list">
|
||||||
|
{% for auto_create_rule in custom_domain.auto_create_rules %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
Order: <b>{{ auto_create_rule.order }}</b> <br>
|
||||||
|
<input readonly value="{{ auto_create_rule.regex }}" class="form-control">
|
||||||
|
New alias will belong to
|
||||||
|
{% for mailbox in auto_create_rule.mailboxes %}
|
||||||
|
<b>{{ mailbox.email }}</b>
|
||||||
|
{% if not loop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<form method="post" class="mt-2">
|
||||||
|
<input type="hidden" name="form-name" value="delete-auto-create-rule">
|
||||||
|
<input type="hidden" name="rule-id" value="{{ auto_create_rule.id }}">
|
||||||
|
<button class="btn btn-outline-danger btn-sm float-right">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-2" id="new-rule">
|
||||||
|
<hr>
|
||||||
|
<h3>New rule </h3>
|
||||||
|
|
||||||
|
<form method="post" action="#rule-list" data-parsley-validate>
|
||||||
|
<input type="hidden" name="form-name" value="create-auto-create-rule">
|
||||||
|
{{ new_auto_create_rule_form.csrf_token }}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Regex</label>
|
||||||
|
|
||||||
|
{{ new_auto_create_rule_form.regex(class="form-control",
|
||||||
|
placeholder="prefix\..*"
|
||||||
|
) }}
|
||||||
|
{{ render_field_errors(new_auto_create_rule_form.regex) }}
|
||||||
|
|
||||||
|
<div class="small-text">
|
||||||
|
For example, if you want aliases that starts with <b>prefix.</b> to be automatically created, you can set
|
||||||
|
the
|
||||||
|
regex to <em data-toggle="tooltip"
|
||||||
|
title="Click to copy"
|
||||||
|
class="clipboard"
|
||||||
|
data-clipboard-text="prefix\..*">prefix\..*</em>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
If you want aliases that ends with <b>.suffix</b> to be automatically created, you can use the regex
|
||||||
|
<em data-toggle="tooltip"
|
||||||
|
title="Click to copy"
|
||||||
|
class="clipboard"
|
||||||
|
data-clipboard-text=".*\.suffix">.*\.suffix</em>
|
||||||
|
<br>
|
||||||
|
To test out regex, we recommend using regex tester tool like
|
||||||
|
<a href="https://regex101.com" target="_blank">https://regex101.com↗</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Order</label>
|
||||||
|
{{ new_auto_create_rule_form.order(class="form-control", placeholder="10", min=1, value=1, type="number") }}
|
||||||
|
{{ render_field_errors(new_auto_create_rule_form.order) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="flex-grow-1 mr-2">
|
||||||
|
<select data-width="100%" required
|
||||||
|
class="mailbox-select" multiple name="mailbox_ids">
|
||||||
|
{% for mailbox in mailboxes %}
|
||||||
|
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}
|
||||||
|
selected {% endif %}>
|
||||||
|
{{ mailbox.email }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary mt-2">Create</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="debug-zone">
|
||||||
|
<hr>
|
||||||
|
<h3>Debug Zone</h3>
|
||||||
|
<p>You can test whether an alias will be automatically created given the rules above </p>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No worries, no alias will be created during the test :)
|
||||||
|
</div>
|
||||||
|
<form method="post" action="#debug-zone">
|
||||||
|
<input type="hidden" name="form-name" value="test-auto-create-rule">
|
||||||
|
{{ auto_create_test_form.csrf_token }}
|
||||||
|
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="form-group d-flex">
|
||||||
|
{{ auto_create_test_form.local(class="form-control", type="text", placeholder="local", value=auto_create_test_local) }}
|
||||||
|
{{ render_field_errors(auto_create_test_form.local) }}
|
||||||
|
<b class="pt-2 ml-1">@{{ custom_domain.domain }}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button class="btn btn-outline-primary">Test</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if auto_create_test_result %}
|
||||||
|
<div class="alert {% if auto_create_test_passed %} alert-success {% else %} alert-warning {% endif %}">
|
||||||
|
{{ auto_create_test_result }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
$('.mailbox-select').multipleSelect();
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -20,13 +20,19 @@
|
|||||||
class="list-group-item list-group-item-action {{ 'active' if domain_detail_page == 'trash' }}">
|
class="list-group-item list-group-item-action {{ 'active' if domain_detail_page == 'trash' }}">
|
||||||
<span class="icon mr-3"><i class="fe fe-trash"></i></span>Deleted Alias
|
<span class="icon mr-3"><i class="fe fe-trash"></i></span>Deleted Alias
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ url_for('dashboard.domain_detail_auto_create', custom_domain_id=custom_domain.id) }}"
|
||||||
|
class="list-group-item list-group-item-action {{ 'active' if domain_detail_page == 'auto_create' }}">
|
||||||
|
<span class="icon mr-3"><i class="fe fe-layers"></i></span>Auto Create
|
||||||
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-9">
|
<div class="col-lg-9">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="text-wrap p-lg-6">
|
<div class="text-wrap p-lg-6 domain_detail_content">
|
||||||
{% block domain_detail_content %}
|
{% block domain_detail_content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -13,10 +13,69 @@
|
|||||||
<div class="">Please follow the steps below to set up your domain.</div>
|
<div class="">Please follow the steps below to set up your domain.</div>
|
||||||
|
|
||||||
<div class="small-text mb-5">
|
<div class="small-text mb-5">
|
||||||
DNS changes could take up to 24 hours to propagate. In practice, it's a lot faster though (~1
|
DNS changes could take up to 24 hours to update.
|
||||||
minute or in our experience).
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not custom_domain.ownership_verified %}
|
||||||
|
<div id="ownership-form">
|
||||||
|
<div class="font-weight-bold">Domain ownership verification
|
||||||
|
|
||||||
|
{% if custom_domain.ownership_verified %}
|
||||||
|
<span class="cursor" data-toggle="tooltip" data-original-title="Domain Ownership Verified">✅</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="cursor" data-toggle="tooltip" data-original-title="Domain Ownership Required">🚫 </span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not custom_domain.ownership_verified %}
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
To verify ownership of the domain, please add the following TXT record.
|
||||||
|
Some domain registrars (Namecheap, CloudFlare, etc) might use <em>@</em> for the root domain.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 p-3 dns-record">
|
||||||
|
Record: TXT <br>
|
||||||
|
Domain: {{ custom_domain.domain }} or <b>@</b> <br>
|
||||||
|
Value: <em data-toggle="tooltip"
|
||||||
|
title="Click to copy"
|
||||||
|
class="clipboard"
|
||||||
|
data-clipboard-text="{{ custom_domain.get_ownership_dns_txt_value() }}">{{ custom_domain.get_ownership_dns_txt_value() }}</em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="#ownership-form">
|
||||||
|
<input type="hidden" name="form-name" value="check-ownership">
|
||||||
|
<button type="submit" class="btn btn-primary"> Verify</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if not ownership_ok %}
|
||||||
|
<div class="text-danger mt-4">
|
||||||
|
Your DNS is not correctly set. The TXT record we obtain is:
|
||||||
|
<div class="mb-3 p-3 dns-record">
|
||||||
|
{% if not ownership_errors %}
|
||||||
|
(Empty)
|
||||||
|
{% endif %}
|
||||||
|
{% for r in ownership_errors %}
|
||||||
|
{{ r }} <br>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<hr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="{% if not custom_domain.ownership_verified %} disabled-content {% endif %}"
|
||||||
|
id="dns-setup">
|
||||||
|
|
||||||
|
{% if not custom_domain.ownership_verified %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
A domain ownership must be verified first.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div id="mx-form">
|
<div id="mx-form">
|
||||||
<div class="font-weight-bold">1. MX record
|
<div class="font-weight-bold">1. MX record
|
||||||
|
|
||||||
@ -38,10 +97,7 @@
|
|||||||
<div class="mb-3 p-3 dns-record">
|
<div class="mb-3 p-3 dns-record">
|
||||||
Record: MX <br>
|
Record: MX <br>
|
||||||
Domain: {{ custom_domain.domain }} or
|
Domain: {{ custom_domain.domain }} or
|
||||||
<em data-toggle="tooltip"
|
<b>@</b> <br>
|
||||||
title="Click to copy"
|
|
||||||
class="clipboard"
|
|
||||||
data-clipboard-text="@">@</em> <br>
|
|
||||||
Priority: {{ priority }} <br>
|
Priority: {{ priority }} <br>
|
||||||
Target: <em data-toggle="tooltip"
|
Target: <em data-toggle="tooltip"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
@ -100,7 +156,8 @@
|
|||||||
rel="noopener">(Wikipedia↗)</a> is an email
|
rel="noopener">(Wikipedia↗)</a> is an email
|
||||||
authentication method
|
authentication method
|
||||||
designed to detect forging sender addresses during the delivery of the email. <br>
|
designed to detect forging sender addresses during the delivery of the email. <br>
|
||||||
Setting up SPF is highly recommended to reduce the chance your emails ending up in the recipient's Spam folder.
|
Setting up SPF is highly recommended to reduce the chance your emails ending up in the recipient's Spam
|
||||||
|
folder.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-2">Add the following TXT DNS record to your domain.</div>
|
<div class="mb-2">Add the following TXT DNS record to your domain.</div>
|
||||||
@ -108,10 +165,7 @@
|
|||||||
<div class="mb-2 p-3 dns-record">
|
<div class="mb-2 p-3 dns-record">
|
||||||
Record: TXT <br>
|
Record: TXT <br>
|
||||||
Domain: {{ custom_domain.domain }} or
|
Domain: {{ custom_domain.domain }} or
|
||||||
<em data-toggle="tooltip"
|
<b>@</b> <br>
|
||||||
title="Click to copy"
|
|
||||||
class="clipboard"
|
|
||||||
data-clipboard-text="@">@</em> <br>
|
|
||||||
Value:
|
Value:
|
||||||
<em data-toggle="tooltip"
|
<em data-toggle="tooltip"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
@ -170,7 +224,8 @@
|
|||||||
email
|
email
|
||||||
authentication method
|
authentication method
|
||||||
designed to avoid email spoofing. <br>
|
designed to avoid email spoofing. <br>
|
||||||
Setting up DKIM is highly recommended to reduce the chance your emails ending up in the recipient's Spam folder.
|
Setting up DKIM is highly recommended to reduce the chance your emails ending up in the recipient's Spam
|
||||||
|
folder.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-2">Add the following CNAME DNS record to your domain.</div>
|
<div class="mb-2">Add the following CNAME DNS record to your domain.</div>
|
||||||
@ -311,6 +366,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -7,39 +7,18 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block domain_detail_content %}
|
{% block domain_detail_content %}
|
||||||
<h1 class="h3"> {{ custom_domain.domain }}
|
<h1 class="h2 mb-1"> {{ custom_domain.domain }} </h1>
|
||||||
{% if custom_domain.verified %}
|
|
||||||
<span class="cursor" data-toggle="tooltip" data-original-title="DNS Setup OK">✅</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="cursor" data-toggle="tooltip" data-original-title="DNS Setup Needed">
|
|
||||||
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id) }}"
|
|
||||||
class="text-decoration-none">🚫
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div class="small-text">Created {{ custom_domain.created_at | dt }}</div>
|
<div class="small-text">Created {{ custom_domain.created_at | dt }}. {{ nb_alias }} aliases</div>
|
||||||
|
|
||||||
{{ nb_alias }} aliases
|
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
<div>Catch All</div>
|
<h3 class="mb-1">Auto create/on the fly alias </h3>
|
||||||
<div class="small-text">
|
|
||||||
This feature allows you to create aliases <b>on the fly</b>.
|
|
||||||
Simply use <em>anything@{{ custom_domain.domain }}</em>
|
|
||||||
next time you need an email address. <br>
|
|
||||||
The alias will be created the first time it receives an email
|
|
||||||
and automatically belong to <b>{{ custom_domain.domain }}</b> mailboxes (
|
|
||||||
{% for mailbox in custom_domain.mailboxes %}
|
|
||||||
<b>{{ mailbox.email }}</b>
|
|
||||||
{% if not loop.last %},{% endif %}
|
|
||||||
{% endfor %})
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="form-name" value="switch-catch-all">
|
<input type="hidden" name="form-name" value="switch-catch-all">
|
||||||
|
|
||||||
<label class="custom-switch cursor mt-2 pl-0"
|
<label class="custom-switch cursor mt-2 pl-0"
|
||||||
data-toggle="tooltip"
|
data-toggle="tooltip"
|
||||||
{% if custom_domain.catch_all %}
|
{% if custom_domain.catch_all %}
|
||||||
@ -52,38 +31,81 @@
|
|||||||
{{ "checked" if custom_domain.catch_all else "" }}>
|
{{ "checked" if custom_domain.catch_all else "" }}>
|
||||||
|
|
||||||
<span class="custom-switch-indicator"></span>
|
<span class="custom-switch-indicator"></span>
|
||||||
|
<spam class="ml-2">
|
||||||
|
Catch All
|
||||||
|
</spam>
|
||||||
|
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
|
<div class="">
|
||||||
|
Simply use <b>anything@{{ custom_domain.domain }}</b>
|
||||||
|
next time you need an alias: it'll be <b>automatically</b>
|
||||||
|
created the first time it receives an email.
|
||||||
|
To have more fine-grained control, you can also define
|
||||||
|
<a href="{{ url_for('dashboard.domain_detail_auto_create', custom_domain_id=custom_domain.id) }}">auto create
|
||||||
|
rules
|
||||||
|
<i class="fe fe-chevrons-right"></i></a>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="{% if not custom_domain.catch_all %} disabled-content {% endif %}">
|
||||||
|
<div>Auto-created aliases are automatically owned by the following mailboxes
|
||||||
|
<i class="fe fe-corner-right-down"></i></a>.
|
||||||
|
</div>
|
||||||
|
{% set domain_mailboxes=custom_domain.mailboxes %}
|
||||||
|
<form method="post" class="mt-2">
|
||||||
|
<input type="hidden" name="form-name" value="update">
|
||||||
|
<input type="hidden" name="domain-id" value="{{ custom_domain.id }}">
|
||||||
|
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="flex-grow-1 mr-2">
|
||||||
|
<select data-width="100%" required
|
||||||
|
class="mailbox-select" multiple name="mailbox_ids">
|
||||||
|
{% for mailbox in mailboxes %}
|
||||||
|
<option value="{{ mailbox.id }}" {% if mailbox in domain_mailboxes %}
|
||||||
|
selected {% endif %}>
|
||||||
|
{{ mailbox.email }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-primary btn-sm">Update</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
<div>Default Alias Name</div>
|
<h3 class="mb-1">Default Display Name</h3>
|
||||||
<div class="small-text">
|
<div class="">
|
||||||
This name will be used as the default alias name when you send
|
Default display name for aliases created with <b>{{ custom_domain.domain }}</b>
|
||||||
or reply from an alias, unless overwritten by the alias specific name.
|
unless overwritten by the alias display name.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<form method="post">
|
<form method="post" class="form-inline">
|
||||||
<input type="hidden" name="form-name" value="set-name">
|
<input type="hidden" name="form-name" value="set-name">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input class="form-control"
|
<input class="form-control mr-2"
|
||||||
value="{{ custom_domain.name or "" }}"
|
value="{{ custom_domain.name or "" }}"
|
||||||
name="alias-name"
|
name="alias-name"
|
||||||
placeholder="Alias name">
|
placeholder="Alias Display Name">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" name="action" value="save">Save</button>
|
<button class="btn btn-outline-primary" name="action" value="save">Save</button>
|
||||||
{% if custom_domain.name %}
|
{% if custom_domain.name %}
|
||||||
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
|
<button class="btn btn-outline-danger float-right ml-2" name="action" value="remove">Remove</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
<div>Random Prefix Generation</div>
|
<h3 class="mb-1">Random Prefix Generation</h3>
|
||||||
<div class="small-text">
|
<div class="">
|
||||||
A random prefix can be generated for this domain for usage in the New Alias
|
Add a random prefix for this domain when creating a new alias.
|
||||||
feature.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -106,20 +128,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
<h3 class="mb-0">Delete Domain</h3>
|
<h3 class="mb-1">Delete Domain</h3>
|
||||||
<div class="small-text mb-3">Please note that this operation is irreversible.
|
<div class="mb-3">This operation is <b>irreversible</b>.
|
||||||
All aliases associated with this domain will be also deleted.
|
All aliases associated with this domain will be deleted.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="form-name" value="delete">
|
<input type="hidden" name="form-name" value="delete">
|
||||||
<span class="delete-custom-domain btn btn-outline-danger">Delete domain</span>
|
<span class="delete-custom-domain btn btn-danger">Delete domain</span>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script>
|
<script>
|
||||||
|
$('.mailbox-select').multipleSelect();
|
||||||
|
|
||||||
$(".custom-switch-input").change(function (e) {
|
$(".custom-switch-input").change(function (e) {
|
||||||
$(this).closest("form").submit();
|
$(this).closest("form").submit();
|
||||||
});
|
});
|
||||||
@ -149,3 +173,4 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -92,10 +92,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- END Global Stats -->
|
<!-- END Global Stats -->
|
||||||
|
|
||||||
|
<!-- Controls: buttons & search -->
|
||||||
|
<div id="filter-app">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
|
<div class="col d-flex">
|
||||||
<div class="col-lg-6 pt-1" style="max-width: 25em">
|
<div>
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="form-name" value="create-custom-email">
|
<input type="hidden" name="form-name" value="create-custom-email">
|
||||||
@ -135,11 +136,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="filter-app" class="col-lg-auto pt-1 flex-grow-1">
|
<div class="" style="margin-left: auto">
|
||||||
<div class="float-right d-flex">
|
<div class="btn-group">
|
||||||
|
<a v-if="!showFilter" @click="toggleFilter()" class="btn btn-outline-secondary">
|
||||||
|
<i class="fe fe-chevrons-down"></i> Filters
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-2" v-if="showFilter" id="filter-control">
|
||||||
<!-- Filter Control -->
|
<!-- Filter Control -->
|
||||||
<div v-if="showFilter" id="filter-control">
|
<div class="col d-flex">
|
||||||
<form method="get" class="form-inline">
|
<form method="get" class="form-inline">
|
||||||
<select name="sort"
|
<select name="sort"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
@ -168,6 +177,9 @@
|
|||||||
<option value="" {% if filter == "" %} selected {% endif %}>
|
<option value="" {% if filter == "" %} selected {% endif %}>
|
||||||
All Aliases
|
All Aliases
|
||||||
</option>
|
</option>
|
||||||
|
<option value="pinned" {% if filter == "pinned" %} selected {% endif %}>
|
||||||
|
Pinned Aliases
|
||||||
|
</option>
|
||||||
<option value="enabled" {% if filter == "enabled" %} selected {% endif %}>
|
<option value="enabled" {% if filter == "enabled" %} selected {% endif %}>
|
||||||
Only Enabled Aliases
|
Only Enabled Aliases
|
||||||
</option>
|
</option>
|
||||||
@ -177,6 +189,20 @@
|
|||||||
<option value="hibp" {% if filter == "hibp" %} selected {% endif %}>
|
<option value="hibp" {% if filter == "hibp" %} selected {% endif %}>
|
||||||
Only Aliases Found In Data Breaches
|
Only Aliases Found In Data Breaches
|
||||||
</option>
|
</option>
|
||||||
|
{% for mailbox in current_user.mailboxes() %}
|
||||||
|
<option value="mailbox:{{ mailbox.id }}" {% if filter == "mailbox:" ~ mailbox.id %}
|
||||||
|
selected {% endif %}>
|
||||||
|
{{ mailbox.email }}'s aliases
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for directory in current_user.directories %}
|
||||||
|
<option value="directory:{{ directory.id }}" {% if filter == "directory:" ~ directory.id %}
|
||||||
|
selected {% endif %}>
|
||||||
|
Directory <b>{{ directory.name }}</b> aliases
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input type="search" name="query" placeholder="Enter to search for alias"
|
<input type="search" name="query" placeholder="Enter to search for alias"
|
||||||
@ -184,32 +210,36 @@
|
|||||||
style="max-width: 15em"
|
style="max-width: 15em"
|
||||||
value="{{ query }}">
|
value="{{ query }}">
|
||||||
|
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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-light">Reset</a>
|
class="btn btn-outline-secondary">Reset</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group">
|
|
||||||
<a v-if="!showFilter" @click="toggleFilter()" class="btn btn-outline-secondary">
|
|
||||||
<i class="fe fe-chevrons-left"></i> Filters
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a v-if="showFilter" @click="toggleFilter()" class="btn btn-outline-secondary">
|
<a v-if="showFilter" @click="toggleFilter()" class="btn btn-outline-secondary">
|
||||||
<i class="fe fe-chevrons-right"></i>
|
<i class="fe fe-chevrons-up"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- END Controls: buttons & search -->
|
||||||
|
|
||||||
|
<!-- Alias list -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% for alias_info in alias_infos %}
|
{% for alias_info in alias_infos %}
|
||||||
{% set alias = alias_info.alias %}
|
{% set alias = alias_info.alias %}
|
||||||
|
|
||||||
<div class="col-12 col-lg-6">
|
<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 %} "
|
||||||
|
{% if highlight_alias_id and alias.id != highlight_alias_id %}
|
||||||
|
style="opacity: 0.6"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
@ -233,8 +263,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if alias.pinned %}
|
{% if alias.pinned %}
|
||||||
<span class="fa fa-heart" data-toggle="tooltip"
|
<span class="fa fa-thumb-tack" data-toggle="tooltip"
|
||||||
title="This alias added to favorite"></span>
|
title="This alias is pinned"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if alias.hibp_breaches | length > 0 %}
|
{% if alias.hibp_breaches | length > 0 %}
|
||||||
@ -299,7 +329,7 @@
|
|||||||
{{ email_log.created_at | dt }}
|
{{ email_log.created_at | dt }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
No Activity in the last 14 days. Alias created {{ alias.created_at | dt }}
|
No emails received/sent in the last 14 days. Created {{ alias.created_at | dt }}.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -307,6 +337,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- END Email Activity -->
|
<!-- END Email Activity -->
|
||||||
|
|
||||||
|
<div class="small-text mt-1">Alias description</div>
|
||||||
|
<div class="d-flex mb-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">{{ alias.note or "" }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="">
|
||||||
|
<a data-alias="{{ alias.id }}"
|
||||||
|
class="save-note btn btn-sm btn-outline-success w-100">
|
||||||
|
Save
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Send Email && More button -->
|
<!-- Send Email && More button -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@ -384,28 +434,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="small-text mt-2">Alias Note</div>
|
|
||||||
<div class="d-flex">
|
|
||||||
<div class="flex-grow-1 mr-2">
|
|
||||||
<textarea
|
|
||||||
id="note-{{ alias.id }}"
|
|
||||||
name="note"
|
|
||||||
class="form-control"
|
|
||||||
rows="2"
|
|
||||||
placeholder="e.g. where the alias is used or why is it created">{{ alias.note or "" }}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="">
|
|
||||||
<a data-alias="{{ alias.id }}"
|
|
||||||
class="save-note btn btn-sm btn-outline-success w-100">
|
|
||||||
Save
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="small-text mt-2" data-toogle="tooltip"
|
<div class="small-text mt-2" data-toogle="tooltip"
|
||||||
title="Alias name is used when you send or reply from alias">
|
title="When sending an email from this alias, the email will have 'Display Name <{{ alias.email }}>' as sender.">
|
||||||
Alias name
|
Display name
|
||||||
<i class="fe fe-help-circle"></i>
|
<i class="fe fe-help-circle"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -444,8 +475,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="small-text mt-2" data-toogle="tooltip"
|
<div class="small-text mt-2" data-toogle="tooltip"
|
||||||
title="Add alias to favorite so it's always pinned on top">
|
title="it's always pinned on top">
|
||||||
Add to favorite
|
Pin this alias
|
||||||
<i class="fe fe-help-circle"></i>
|
<i class="fe fe-help-circle"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -495,6 +526,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- END Alias list -->
|
||||||
|
|
||||||
<!-- Only show pagination control if there are previous/next page -->
|
<!-- Only show pagination control if there are previous/next page -->
|
||||||
{% if page > 0 or not last_page %}
|
{% if page > 0 or not last_page %}
|
||||||
|
@ -49,8 +49,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% elif apple_sub and apple_sub.is_valid() %}
|
{% elif apple_sub and apple_sub.is_valid() %}
|
||||||
You are on the Premium plan which expires {{ apple_sub.expires_date | dt }}
|
You are on the Premium plan (subscribed via Apple) which expires {{ apple_sub.expires_date | dt }}
|
||||||
({{ apple_sub.expires_date.format("YYYY-MM-DD") }}).
|
({{ apple_sub.expires_date.format("YYYY-MM-DD") }}).
|
||||||
|
<div class="alert alert-info">
|
||||||
|
If you want to subscribe via the Web instead, please make sure to cancel your subscription
|
||||||
|
on Apple first.
|
||||||
|
<a href="{{ url_for('dashboard.pricing') }}"
|
||||||
|
class="">Upgrade <i class="fa fa-arrow-right" aria-hidden="true"></i></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% elif coinbase_sub and coinbase_sub.is_active() %}
|
{% elif coinbase_sub and coinbase_sub.is_active() %}
|
||||||
You are on the Premium plan which expires {{ coinbase_sub.end_at | dt }}
|
You are on the Premium plan which expires {{ coinbase_sub.end_at | dt }}
|
||||||
({{ coinbase_sub.end_at.format("YYYY-MM-DD") }}).
|
({{ coinbase_sub.end_at.format("YYYY-MM-DD") }}).
|
||||||
@ -283,14 +290,6 @@
|
|||||||
<input type="hidden" name="form-name" value="change-sender-format">
|
<input type="hidden" name="form-name" value="change-sender-format">
|
||||||
|
|
||||||
<select class="form-control mr-sm-2" name="sender-format">
|
<select class="form-control mr-sm-2" name="sender-format">
|
||||||
{# Only show this for compatibility reason #}
|
|
||||||
{% if current_user.sender_format == SenderFormatEnum.VIA.value %}
|
|
||||||
<option value="{{ SenderFormatEnum.VIA.value }}"
|
|
||||||
{% if current_user.sender_format == SenderFormatEnum.VIA.value %} selected {% endif %}>
|
|
||||||
john@wick.com via SimpleLogin (Not recommended)
|
|
||||||
</option>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<option value="{{ SenderFormatEnum.AT.value }}"
|
<option value="{{ SenderFormatEnum.AT.value }}"
|
||||||
{% if current_user.sender_format == SenderFormatEnum.AT.value %} selected {% endif %}>
|
{% if current_user.sender_format == SenderFormatEnum.AT.value %} selected {% endif %}>
|
||||||
John Wick - john at wick.com
|
John Wick - john at wick.com
|
||||||
@ -301,13 +300,6 @@
|
|||||||
John Wick - john(a)wick.com
|
John Wick - john(a)wick.com
|
||||||
</option>
|
</option>
|
||||||
|
|
||||||
{# Only show this for compatibility reason #}
|
|
||||||
{% if current_user.sender_format == SenderFormatEnum.FULL.value %}
|
|
||||||
<option value="{{ SenderFormatEnum.FULL.value }}"
|
|
||||||
{% if current_user.sender_format == SenderFormatEnum.FULL.value %} selected {% endif %}>
|
|
||||||
John Wick - john@wick.com (Not recommended)
|
|
||||||
</option>
|
|
||||||
{% endif %}
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button class="btn btn-outline-primary mt-3">Update</button>
|
<button class="btn btn-outline-primary mt-3">Update</button>
|
||||||
@ -358,8 +350,9 @@
|
|||||||
<input type="hidden" name="form-name" value="sender-in-ra">
|
<input type="hidden" name="form-name" value="sender-in-ra">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" id="include-sender-ra" name="enable"
|
<input type="checkbox" id="include-sender-ra" name="enable"
|
||||||
{# todo: remove current_user.include_sender_in_reverse_alias is none condition #}
|
{# todo: remove current_user.include_sender_in_reverse_alias is none condition #}
|
||||||
{% if current_user.include_sender_in_reverse_alias is none or current_user.include_sender_in_reverse_alias %} checked {% endif %} class="form-check-input">
|
{% if current_user.include_sender_in_reverse_alias is none or current_user.include_sender_in_reverse_alias %}
|
||||||
|
checked {% endif %} class="form-check-input">
|
||||||
<label for="include-sender-ra">Include sender address in reverse-alias</label>
|
<label for="include-sender-ra">Include sender address in reverse-alias</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-primary">Update</button>
|
<button type="submit" class="btn btn-outline-primary">Update</button>
|
||||||
@ -389,6 +382,29 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- END Always expand alias info -->
|
<!-- END Always expand alias info -->
|
||||||
|
|
||||||
|
<!-- Ignore Loop Email -->
|
||||||
|
<div class="card" id="ignore-loop-email-section">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title">Ignore Loop Emails</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
On some email clients, "Reply All" automatically includes your alias that
|
||||||
|
would send the same email to your mailbox.
|
||||||
|
<br>
|
||||||
|
You can disable these "loop" emails by enabling this option.
|
||||||
|
</div>
|
||||||
|
<form method="post" action="#ignore-loop-email-section">
|
||||||
|
<input type="hidden" name="form-name" value="ignore-loop-email">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="ignore-loop-email" name="enable"
|
||||||
|
{% if current_user.ignore_loop_email %} checked {% endif %} class="form-check-input">
|
||||||
|
<label for="ignore-loop-email">Ignore Loop Emails</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-outline-primary">Update</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- END Ignore Loop Email -->
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">Quarantine</div>
|
<div class="card-title">Quarantine</div>
|
||||||
|
@ -11,14 +11,13 @@ from wtforms import StringField, validators, ValidationError
|
|||||||
from app.config import PAGE_LIMIT
|
from app.config import PAGE_LIMIT
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.email_utils import (
|
from app.email_utils import (
|
||||||
parseaddr_unicode,
|
|
||||||
is_valid_email,
|
is_valid_email,
|
||||||
generate_reply_email,
|
generate_reply_email,
|
||||||
|
parse_full_address,
|
||||||
)
|
)
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import Alias, Contact, EmailLog
|
from app.models import Alias, Contact, EmailLog
|
||||||
from app.utils import sanitize_email
|
|
||||||
|
|
||||||
|
|
||||||
def email_validator():
|
def email_validator():
|
||||||
@ -182,8 +181,7 @@ def alias_contact_manager(alias_id):
|
|||||||
contact_addr = new_contact_form.email.data.strip()
|
contact_addr = new_contact_form.email.data.strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
contact_name, contact_email = parseaddr_unicode(contact_addr)
|
contact_name, contact_email = parse_full_address(contact_addr)
|
||||||
contact_email = sanitize_email(contact_email)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
flash(f"{contact_addr} is invalid", "error")
|
flash(f"{contact_addr} is invalid", "error")
|
||||||
return redirect(
|
return redirect(
|
||||||
|
@ -31,7 +31,7 @@ def batch_import_route():
|
|||||||
|
|
||||||
bi = BatchImport.create(user_id=current_user.id, file_id=file.id)
|
bi = BatchImport.create(user_id=current_user.id, file_id=file.id)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
LOG.debug("Add a batch import job %s for %s", bi, current_user)
|
LOG.d("Add a batch import job %s for %s", bi, current_user)
|
||||||
|
|
||||||
# Schedule batch import job
|
# Schedule batch import job
|
||||||
Job.create(
|
Job.create(
|
||||||
|
@ -21,7 +21,7 @@ def billing():
|
|||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if request.form.get("form-name") == "cancel":
|
if request.form.get("form-name") == "cancel":
|
||||||
LOG.warning(f"User {current_user} cancels their subscription")
|
LOG.w(f"User {current_user} cancels their subscription")
|
||||||
success = cancel_subscription(sub.subscription_id)
|
success = cancel_subscription(sub.subscription_id)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@ -37,7 +37,7 @@ def billing():
|
|||||||
|
|
||||||
return redirect(url_for("dashboard.billing"))
|
return redirect(url_for("dashboard.billing"))
|
||||||
elif request.form.get("form-name") == "change-monthly":
|
elif request.form.get("form-name") == "change-monthly":
|
||||||
LOG.debug(f"User {current_user} changes to monthly plan")
|
LOG.d(f"User {current_user} changes to monthly plan")
|
||||||
success, msg = change_plan(
|
success, msg = change_plan(
|
||||||
current_user, sub.subscription_id, PADDLE_MONTHLY_PRODUCT_ID
|
current_user, sub.subscription_id, PADDLE_MONTHLY_PRODUCT_ID
|
||||||
)
|
)
|
||||||
@ -58,7 +58,7 @@ def billing():
|
|||||||
|
|
||||||
return redirect(url_for("dashboard.billing"))
|
return redirect(url_for("dashboard.billing"))
|
||||||
elif request.form.get("form-name") == "change-yearly":
|
elif request.form.get("form-name") == "change-yearly":
|
||||||
LOG.debug(f"User {current_user} changes to yearly plan")
|
LOG.d(f"User {current_user} changes to yearly plan")
|
||||||
success, msg = change_plan(
|
success, msg = change_plan(
|
||||||
current_user, sub.subscription_id, PADDLE_YEARLY_PRODUCT_ID
|
current_user, sub.subscription_id, PADDLE_YEARLY_PRODUCT_ID
|
||||||
)
|
)
|
||||||
|
@ -8,7 +8,13 @@ from app.config import ADMIN_EMAIL
|
|||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.email_utils import send_email
|
from app.email_utils import send_email
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import ManualSubscription, Coupon
|
from app.models import (
|
||||||
|
ManualSubscription,
|
||||||
|
Coupon,
|
||||||
|
Subscription,
|
||||||
|
AppleSubscription,
|
||||||
|
CoinbaseSubscription,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CouponForm(FlaskForm):
|
class CouponForm(FlaskForm):
|
||||||
@ -18,18 +24,27 @@ class CouponForm(FlaskForm):
|
|||||||
@dashboard_bp.route("/coupon", methods=["GET", "POST"])
|
@dashboard_bp.route("/coupon", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def coupon_route():
|
def coupon_route():
|
||||||
if current_user.lifetime:
|
|
||||||
flash("You already have a lifetime licence", "warning")
|
|
||||||
return redirect(url_for("dashboard.index"))
|
|
||||||
|
|
||||||
# handle case user already has an active subscription via another channel (Paddle, Apple, etc)
|
# handle case user already has an active subscription via another channel (Paddle, Apple, etc)
|
||||||
if current_user._lifetime_or_active_subscription():
|
can_use_coupon = True
|
||||||
manual_sub: ManualSubscription = ManualSubscription.get_by(
|
|
||||||
|
if current_user.lifetime:
|
||||||
|
can_use_coupon = False
|
||||||
|
|
||||||
|
sub: Subscription = current_user.get_subscription()
|
||||||
|
if sub:
|
||||||
|
can_use_coupon = False
|
||||||
|
|
||||||
|
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=current_user.id)
|
||||||
|
if apple_sub and apple_sub.is_valid():
|
||||||
|
can_use_coupon = False
|
||||||
|
|
||||||
|
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
|
||||||
user_id=current_user.id
|
user_id=current_user.id
|
||||||
)
|
)
|
||||||
|
if coinbase_subscription and coinbase_subscription.is_active():
|
||||||
|
can_use_coupon = False
|
||||||
|
|
||||||
# user has an non-manual subscription
|
if not can_use_coupon:
|
||||||
if not manual_sub or not manual_sub.is_active():
|
|
||||||
flash("You already have another subscription.", "warning")
|
flash("You already have another subscription.", "warning")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
@ -63,7 +78,7 @@ def coupon_route():
|
|||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
end_at=arrow.now().shift(years=coupon.nb_year, days=1),
|
end_at=arrow.now().shift(years=coupon.nb_year, days=1),
|
||||||
comment="using coupon code",
|
comment="using coupon code",
|
||||||
is_giveaway=False,
|
is_giveaway=coupon.is_giveaway,
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
@ -72,9 +87,13 @@ def coupon_route():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# notify admin
|
# notify admin
|
||||||
|
if coupon.is_giveaway:
|
||||||
|
subject = f"User {current_user} applies a (free) coupon"
|
||||||
|
else:
|
||||||
|
subject = f"User {current_user} applies a (paid) coupon"
|
||||||
send_email(
|
send_email(
|
||||||
ADMIN_EMAIL,
|
ADMIN_EMAIL,
|
||||||
subject=f"User {current_user} applies the coupon",
|
subject=subject,
|
||||||
plaintext="",
|
plaintext="",
|
||||||
html="",
|
html="",
|
||||||
)
|
)
|
||||||
|
@ -249,11 +249,11 @@ def custom_alias():
|
|||||||
signed_alias_suffix_decoded
|
signed_alias_suffix_decoded
|
||||||
)
|
)
|
||||||
except SignatureExpired:
|
except SignatureExpired:
|
||||||
LOG.warning("Alias creation time expired for %s", current_user)
|
LOG.w("Alias creation time expired for %s", current_user)
|
||||||
flash("Alias creation time is expired, please retry", "warning")
|
flash("Alias creation time is expired, please retry", "warning")
|
||||||
return redirect(url_for("dashboard.custom_alias"))
|
return redirect(url_for("dashboard.custom_alias"))
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.warning("Alias suffix is tampered, user %s", current_user)
|
LOG.w("Alias suffix is tampered, user %s", current_user)
|
||||||
flash("Unknown error, refresh the page", "error")
|
flash("Unknown error, refresh the page", "error")
|
||||||
return redirect(url_for("dashboard.custom_alias"))
|
return redirect(url_for("dashboard.custom_alias"))
|
||||||
|
|
||||||
@ -281,7 +281,7 @@ def custom_alias():
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# should never happen as user can only choose their domains
|
# should never happen as user can only choose their domains
|
||||||
LOG.exception(
|
LOG.e(
|
||||||
"Deleted Alias %s does not belong to user %s",
|
"Deleted Alias %s does not belong to user %s",
|
||||||
domain_deleted_alias,
|
domain_deleted_alias,
|
||||||
)
|
)
|
||||||
@ -309,7 +309,7 @@ def custom_alias():
|
|||||||
)
|
)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
LOG.warning("Alias %s already exists", full_alias)
|
LOG.w("Alias %s already exists", full_alias)
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
flash("Unknown error, please retry", "error")
|
flash("Unknown error, please retry", "error")
|
||||||
return redirect(url_for("dashboard.custom_alias"))
|
return redirect(url_for("dashboard.custom_alias"))
|
||||||
@ -351,7 +351,7 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
|
|||||||
|
|
||||||
# alias_domain must be either one of user custom domains or built-in domains
|
# alias_domain must be either one of user custom domains or built-in domains
|
||||||
if alias_domain not in user.available_alias_domains():
|
if alias_domain not in user.available_alias_domains():
|
||||||
LOG.exception("wrong alias suffix %s, user %s", alias_suffix, user)
|
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# SimpleLogin domain case:
|
# SimpleLogin domain case:
|
||||||
@ -365,17 +365,17 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
|
|||||||
):
|
):
|
||||||
|
|
||||||
if not alias_domain_prefix.startswith("."):
|
if not alias_domain_prefix.startswith("."):
|
||||||
LOG.exception("User %s submits a wrong alias suffix %s", user, alias_suffix)
|
LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if alias_domain not in user_custom_domains:
|
if alias_domain not in user_custom_domains:
|
||||||
if not DISABLE_ALIAS_SUFFIX:
|
if not DISABLE_ALIAS_SUFFIX:
|
||||||
LOG.exception("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 user.available_sl_domains():
|
||||||
LOG.exception("wrong alias suffix %s, user %s", alias_suffix, user)
|
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
@ -88,43 +88,6 @@ def custom_domain():
|
|||||||
custom_domain_id=new_custom_domain.id,
|
custom_domain_id=new_custom_domain.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif request.form.get("form-name") == "update":
|
|
||||||
domain_id = request.form.get("domain-id")
|
|
||||||
domain = CustomDomain.get(domain_id)
|
|
||||||
|
|
||||||
if not domain or domain.user_id != current_user.id:
|
|
||||||
flash("Unknown error. Refresh the page", "warning")
|
|
||||||
return redirect(url_for("dashboard.custom_domain"))
|
|
||||||
|
|
||||||
mailbox_ids = request.form.getlist("mailbox_ids")
|
|
||||||
# check if mailbox is not tempered with
|
|
||||||
mailboxes = []
|
|
||||||
for mailbox_id in mailbox_ids:
|
|
||||||
mailbox = Mailbox.get(mailbox_id)
|
|
||||||
if (
|
|
||||||
not mailbox
|
|
||||||
or mailbox.user_id != current_user.id
|
|
||||||
or not mailbox.verified
|
|
||||||
):
|
|
||||||
flash("Something went wrong, please retry", "warning")
|
|
||||||
return redirect(url_for("dashboard.custom_domain"))
|
|
||||||
mailboxes.append(mailbox)
|
|
||||||
|
|
||||||
if not mailboxes:
|
|
||||||
flash("You must select at least 1 mailbox", "warning")
|
|
||||||
return redirect(url_for("dashboard.custom_domain"))
|
|
||||||
|
|
||||||
# first remove all existing domain-mailboxes links
|
|
||||||
DomainMailbox.query.filter_by(domain_id=domain.id).delete()
|
|
||||||
db.session.flush()
|
|
||||||
|
|
||||||
for mailbox in mailboxes:
|
|
||||||
DomainMailbox.create(domain_id=domain.id, mailbox_id=mailbox.id)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
flash(f"Domain {domain.domain} has been updated", "success")
|
|
||||||
|
|
||||||
return redirect(url_for("dashboard.custom_domain"))
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard/custom_domain.html",
|
"dashboard/custom_domain.html",
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
|
import re2 as re
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
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 wtforms import StringField, validators, IntegerField
|
||||||
|
|
||||||
from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_DOMAIN
|
from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_DOMAIN
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
@ -14,29 +17,65 @@ from app.dns_utils import (
|
|||||||
from app.email_utils import send_email
|
from app.email_utils import send_email
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import CustomDomain, Alias, DomainDeletedAlias
|
from app.models import (
|
||||||
|
CustomDomain,
|
||||||
|
Alias,
|
||||||
|
DomainDeletedAlias,
|
||||||
|
Mailbox,
|
||||||
|
DomainMailbox,
|
||||||
|
AutoCreateRule,
|
||||||
|
AutoCreateRuleMailbox,
|
||||||
|
)
|
||||||
|
from app.utils import random_string
|
||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route("/domains/<int:custom_domain_id>/dns", methods=["GET", "POST"])
|
@dashboard_bp.route("/domains/<int:custom_domain_id>/dns", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def domain_detail_dns(custom_domain_id):
|
def domain_detail_dns(custom_domain_id):
|
||||||
custom_domain = CustomDomain.get(custom_domain_id)
|
custom_domain: CustomDomain = CustomDomain.get(custom_domain_id)
|
||||||
if not custom_domain or custom_domain.user_id != current_user.id:
|
if not custom_domain or custom_domain.user_id != current_user.id:
|
||||||
flash("You cannot see this page", "warning")
|
flash("You cannot see this page", "warning")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
spf_record = f"v=spf1 include:{EMAIL_DOMAIN} -all"
|
# generate a domain ownership txt token if needed
|
||||||
|
if not custom_domain.ownership_verified and not custom_domain.ownership_txt_token:
|
||||||
|
custom_domain.ownership_txt_token = random_string(30)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
spf_record = f"v=spf1 include:{EMAIL_DOMAIN} ~all"
|
||||||
|
|
||||||
# hardcode the DKIM selector here
|
# hardcode the DKIM selector here
|
||||||
dkim_cname = f"dkim._domainkey.{EMAIL_DOMAIN}"
|
dkim_cname = f"dkim._domainkey.{EMAIL_DOMAIN}"
|
||||||
|
|
||||||
dmarc_record = "v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s"
|
dmarc_record = "v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s"
|
||||||
|
|
||||||
mx_ok = spf_ok = dkim_ok = dmarc_ok = True
|
mx_ok = spf_ok = dkim_ok = dmarc_ok = ownership_ok = True
|
||||||
mx_errors = spf_errors = dkim_errors = dmarc_errors = []
|
mx_errors = spf_errors = dkim_errors = dmarc_errors = ownership_errors = []
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if request.form.get("form-name") == "check-mx":
|
if request.form.get("form-name") == "check-ownership":
|
||||||
|
txt_records = get_txt_record(custom_domain.domain)
|
||||||
|
|
||||||
|
if custom_domain.get_ownership_dns_txt_value() in txt_records:
|
||||||
|
flash(
|
||||||
|
"Domain ownership is verified. Please proceed to the other records setup",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
custom_domain.ownership_verified = True
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"dashboard.domain_detail_dns",
|
||||||
|
custom_domain_id=custom_domain.id,
|
||||||
|
_anchor="dns-setup",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash("We can't find the needed TXT record", "error")
|
||||||
|
ownership_ok = False
|
||||||
|
ownership_errors = txt_records
|
||||||
|
|
||||||
|
elif request.form.get("form-name") == "check-mx":
|
||||||
mx_domains = get_mx_domains(custom_domain.domain)
|
mx_domains = get_mx_domains(custom_domain.domain)
|
||||||
|
|
||||||
if sorted(mx_domains) != sorted(EMAIL_SERVERS_WITH_PRIORITY):
|
if sorted(mx_domains) != sorted(EMAIL_SERVERS_WITH_PRIORITY):
|
||||||
@ -130,7 +169,9 @@ def domain_detail_dns(custom_domain_id):
|
|||||||
@dashboard_bp.route("/domains/<int:custom_domain_id>/info", methods=["GET", "POST"])
|
@dashboard_bp.route("/domains/<int:custom_domain_id>/info", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def domain_detail(custom_domain_id):
|
def domain_detail(custom_domain_id):
|
||||||
custom_domain = CustomDomain.get(custom_domain_id)
|
custom_domain: CustomDomain = CustomDomain.get(custom_domain_id)
|
||||||
|
mailboxes = current_user.mailboxes()
|
||||||
|
|
||||||
if not custom_domain or custom_domain.user_id != current_user.id:
|
if not custom_domain or custom_domain.user_id != current_user.id:
|
||||||
flash("You cannot see this page", "warning")
|
flash("You cannot see this page", "warning")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
@ -191,6 +232,47 @@ def domain_detail(custom_domain_id):
|
|||||||
return redirect(
|
return redirect(
|
||||||
url_for("dashboard.domain_detail", custom_domain_id=custom_domain.id)
|
url_for("dashboard.domain_detail", custom_domain_id=custom_domain.id)
|
||||||
)
|
)
|
||||||
|
elif request.form.get("form-name") == "update":
|
||||||
|
mailbox_ids = request.form.getlist("mailbox_ids")
|
||||||
|
# check if mailbox is not tempered with
|
||||||
|
mailboxes = []
|
||||||
|
for mailbox_id in mailbox_ids:
|
||||||
|
mailbox = Mailbox.get(mailbox_id)
|
||||||
|
if (
|
||||||
|
not mailbox
|
||||||
|
or mailbox.user_id != current_user.id
|
||||||
|
or not mailbox.verified
|
||||||
|
):
|
||||||
|
flash("Something went wrong, please retry", "warning")
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"dashboard.domain_detail", custom_domain_id=custom_domain.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mailboxes.append(mailbox)
|
||||||
|
|
||||||
|
if not mailboxes:
|
||||||
|
flash("You must select at least 1 mailbox", "warning")
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"dashboard.domain_detail", custom_domain_id=custom_domain.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# first remove all existing domain-mailboxes links
|
||||||
|
DomainMailbox.query.filter_by(domain_id=custom_domain.id).delete()
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
for mailbox in mailboxes:
|
||||||
|
DomainMailbox.create(domain_id=custom_domain.id, mailbox_id=mailbox.id)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"{custom_domain.domain} mailboxes has been updated", "success")
|
||||||
|
|
||||||
|
return redirect(
|
||||||
|
url_for("dashboard.domain_detail", custom_domain_id=custom_domain.id)
|
||||||
|
)
|
||||||
|
|
||||||
elif request.form.get("form-name") == "delete":
|
elif request.form.get("form-name") == "delete":
|
||||||
name = custom_domain.domain
|
name = custom_domain.domain
|
||||||
LOG.d("Schedule deleting %s", custom_domain)
|
LOG.d("Schedule deleting %s", custom_domain)
|
||||||
@ -208,7 +290,7 @@ def domain_detail(custom_domain_id):
|
|||||||
return render_template("dashboard/domain_detail/info.html", **locals())
|
return render_template("dashboard/domain_detail/info.html", **locals())
|
||||||
|
|
||||||
|
|
||||||
def delete_domain(custom_domain_id: CustomDomain):
|
def delete_domain(custom_domain_id: int):
|
||||||
from server import create_light_app
|
from server import create_light_app
|
||||||
|
|
||||||
with create_light_app().app_context():
|
with create_light_app().app_context():
|
||||||
@ -288,3 +370,167 @@ def domain_detail_trash(custom_domain_id):
|
|||||||
domain_deleted_aliases=domain_deleted_aliases,
|
domain_deleted_aliases=domain_deleted_aliases,
|
||||||
custom_domain=custom_domain,
|
custom_domain=custom_domain,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoCreateRuleForm(FlaskForm):
|
||||||
|
regex = StringField(
|
||||||
|
"regex", validators=[validators.DataRequired(), validators.Length(max=128)]
|
||||||
|
)
|
||||||
|
|
||||||
|
order = IntegerField(
|
||||||
|
"order",
|
||||||
|
validators=[validators.DataRequired(), validators.NumberRange(min=0, max=100)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoCreateTestForm(FlaskForm):
|
||||||
|
local = StringField(
|
||||||
|
"local part", validators=[validators.DataRequired(), validators.Length(max=128)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dashboard_bp.route(
|
||||||
|
"/domains/<int:custom_domain_id>/auto-create", methods=["GET", "POST"]
|
||||||
|
)
|
||||||
|
@login_required
|
||||||
|
def domain_detail_auto_create(custom_domain_id):
|
||||||
|
custom_domain: CustomDomain = CustomDomain.get(custom_domain_id)
|
||||||
|
mailboxes = current_user.mailboxes()
|
||||||
|
new_auto_create_rule_form = AutoCreateRuleForm()
|
||||||
|
|
||||||
|
auto_create_test_form = AutoCreateTestForm()
|
||||||
|
auto_create_test_local, auto_create_test_result, auto_create_test_passed = (
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not custom_domain or custom_domain.user_id != current_user.id:
|
||||||
|
flash("You cannot see this page", "warning")
|
||||||
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if request.form.get("form-name") == "create-auto-create-rule":
|
||||||
|
if new_auto_create_rule_form.validate():
|
||||||
|
# make sure order isn't used before
|
||||||
|
for auto_create_rule in custom_domain.auto_create_rules:
|
||||||
|
auto_create_rule: AutoCreateRule
|
||||||
|
if auto_create_rule.order == int(
|
||||||
|
new_auto_create_rule_form.order.data
|
||||||
|
):
|
||||||
|
flash(
|
||||||
|
"Another rule with the same order already exists", "error"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
mailbox_ids = request.form.getlist("mailbox_ids")
|
||||||
|
# check if mailbox is not tempered with
|
||||||
|
mailboxes = []
|
||||||
|
for mailbox_id in mailbox_ids:
|
||||||
|
mailbox = Mailbox.get(mailbox_id)
|
||||||
|
if (
|
||||||
|
not mailbox
|
||||||
|
or mailbox.user_id != current_user.id
|
||||||
|
or not mailbox.verified
|
||||||
|
):
|
||||||
|
flash("Something went wrong, please retry", "warning")
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"dashboard.domain_detail_auto_create",
|
||||||
|
custom_domain_id=custom_domain.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mailboxes.append(mailbox)
|
||||||
|
|
||||||
|
if not mailboxes:
|
||||||
|
flash("You must select at least 1 mailbox", "warning")
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"dashboard.domain_detail_auto_create",
|
||||||
|
custom_domain_id=custom_domain.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
re.compile(new_auto_create_rule_form.regex.data)
|
||||||
|
except:
|
||||||
|
flash(
|
||||||
|
f"Invalid regex {new_auto_create_rule_form.regex.data}",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"dashboard.domain_detail_auto_create",
|
||||||
|
custom_domain_id=custom_domain.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rule = AutoCreateRule.create(
|
||||||
|
custom_domain_id=custom_domain.id,
|
||||||
|
order=int(new_auto_create_rule_form.order.data),
|
||||||
|
regex=new_auto_create_rule_form.regex.data,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for mailbox in mailboxes:
|
||||||
|
AutoCreateRuleMailbox.create(
|
||||||
|
auto_create_rule_id=rule.id, mailbox_id=mailbox.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash("New auto create rule has been created", "success")
|
||||||
|
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"dashboard.domain_detail_auto_create",
|
||||||
|
custom_domain_id=custom_domain.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif request.form.get("form-name") == "delete-auto-create-rule":
|
||||||
|
rule_id = request.form.get("rule-id")
|
||||||
|
rule: AutoCreateRule = AutoCreateRule.get(int(rule_id))
|
||||||
|
|
||||||
|
if not rule or rule.custom_domain_id != custom_domain.id:
|
||||||
|
flash("Something wrong, please retry", "error")
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"dashboard.domain_detail_auto_create",
|
||||||
|
custom_domain_id=custom_domain.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rule_order = rule.order
|
||||||
|
AutoCreateRule.delete(rule_id)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Rule #{rule_order} has been deleted", "success")
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"dashboard.domain_detail_auto_create",
|
||||||
|
custom_domain_id=custom_domain.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif request.form.get("form-name") == "test-auto-create-rule":
|
||||||
|
if auto_create_test_form.validate():
|
||||||
|
local = auto_create_test_form.local.data
|
||||||
|
auto_create_test_local = local
|
||||||
|
|
||||||
|
for rule in custom_domain.auto_create_rules:
|
||||||
|
rule: AutoCreateRule
|
||||||
|
regex = re.compile(rule.regex)
|
||||||
|
if re.fullmatch(regex, local):
|
||||||
|
auto_create_test_result = (
|
||||||
|
f"{local}@{custom_domain.domain} passes rule #{rule.order}"
|
||||||
|
)
|
||||||
|
auto_create_test_passed = True
|
||||||
|
break
|
||||||
|
else: # no rule passes
|
||||||
|
auto_create_test_result = (
|
||||||
|
f"{local}@{custom_domain.domain} doesn't pass any rule"
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"dashboard/domain_detail/auto-create.html", **locals()
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template("dashboard/domain_detail/auto-create.html", **locals())
|
||||||
|
@ -30,10 +30,10 @@ def enter_sudo():
|
|||||||
# User comes to sudo page from another page
|
# User comes to sudo page from another page
|
||||||
next_url = request.args.get("next")
|
next_url = request.args.get("next")
|
||||||
if next_url:
|
if next_url:
|
||||||
LOG.debug("redirect user to %s", next_url)
|
LOG.d("redirect user to %s", next_url)
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
else:
|
else:
|
||||||
LOG.debug("redirect user to dashboard")
|
LOG.d("redirect user to dashboard")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
else:
|
else:
|
||||||
flash("Incorrect password", "warning")
|
flash("Incorrect password", "warning")
|
||||||
|
@ -55,7 +55,7 @@ def fido_setup():
|
|||||||
try:
|
try:
|
||||||
fido_credential = fido_reg_response.verify()
|
fido_credential = fido_reg_response.verify()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.warning(f"An error occurred in WebAuthn registration process: {e}")
|
LOG.w(f"An error occurred in WebAuthn registration process: {e}")
|
||||||
flash("Key registration failed.", "warning")
|
flash("Key registration failed.", "warning")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ 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 app import alias_utils
|
from app import alias_utils
|
||||||
from app.api.serializer import get_alias_infos_with_pagination_v3
|
from app.api.serializer import get_alias_infos_with_pagination_v3, get_alias_info_v3
|
||||||
from app.config import PAGE_LIMIT, ALIAS_LIMIT
|
from app.config import PAGE_LIMIT, ALIAS_LIMIT
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.extensions import db, limiter
|
from app.extensions import db, limiter
|
||||||
@ -69,7 +69,7 @@ def index():
|
|||||||
try:
|
try:
|
||||||
highlight_alias_id = int(request.args.get("highlight_alias_id"))
|
highlight_alias_id = int(request.args.get("highlight_alias_id"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"highlight_alias_id must be a number, received %s",
|
"highlight_alias_id must be a number, received %s",
|
||||||
request.args.get("highlight_alias_id"),
|
request.args.get("highlight_alias_id"),
|
||||||
)
|
)
|
||||||
@ -150,11 +150,29 @@ def index():
|
|||||||
|
|
||||||
stats = get_stats(current_user)
|
stats = get_stats(current_user)
|
||||||
|
|
||||||
|
mailbox_id = None
|
||||||
|
if alias_filter and alias_filter.startswith("mailbox:"):
|
||||||
|
mailbox_id = int(alias_filter[len("mailbox:") :])
|
||||||
|
|
||||||
|
directory_id = None
|
||||||
|
if alias_filter and alias_filter.startswith("directory:"):
|
||||||
|
directory_id = int(alias_filter[len("directory:") :])
|
||||||
|
|
||||||
alias_infos = get_alias_infos_with_pagination_v3(
|
alias_infos = get_alias_infos_with_pagination_v3(
|
||||||
current_user, page, query, sort, alias_filter
|
current_user, page, query, sort, alias_filter, mailbox_id, directory_id
|
||||||
)
|
)
|
||||||
last_page = len(alias_infos) < PAGE_LIMIT
|
last_page = len(alias_infos) < PAGE_LIMIT
|
||||||
|
|
||||||
|
# add highlighted alias in case it's not included
|
||||||
|
if highlight_alias_id and highlight_alias_id not in [
|
||||||
|
alias_info.alias.id for alias_info in alias_infos
|
||||||
|
]:
|
||||||
|
highlight_alias_info = get_alias_info_v3(
|
||||||
|
current_user, alias_id=highlight_alias_id
|
||||||
|
)
|
||||||
|
if highlight_alias_info:
|
||||||
|
alias_infos.insert(0, highlight_alias_info)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard/index.html",
|
"dashboard/index.html",
|
||||||
alias_infos=alias_infos,
|
alias_infos=alias_infos,
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from threading import Thread
|
||||||
|
|
||||||
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
|
||||||
@ -49,10 +51,13 @@ def mailbox_route():
|
|||||||
flash("You cannot delete default mailbox", "error")
|
flash("You cannot delete default mailbox", "error")
|
||||||
return redirect(url_for("dashboard.mailbox_route"))
|
return redirect(url_for("dashboard.mailbox_route"))
|
||||||
|
|
||||||
email = mailbox.email
|
LOG.d("Schedule deleting %s", mailbox)
|
||||||
Mailbox.delete(mailbox_id)
|
Thread(target=delete_mailbox, args=(mailbox.id,)).start()
|
||||||
db.session.commit()
|
flash(
|
||||||
flash(f"Mailbox {email} has been deleted", "success")
|
f"Mailbox {mailbox.email} scheduled for deletion."
|
||||||
|
f"You will receive a confirmation email when the deletion is finished",
|
||||||
|
"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":
|
||||||
@ -119,6 +124,32 @@ def mailbox_route():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_mailbox(mailbox_id: int):
|
||||||
|
from server import create_light_app
|
||||||
|
|
||||||
|
with create_light_app().app_context():
|
||||||
|
mailbox = Mailbox.get(mailbox_id)
|
||||||
|
if not mailbox:
|
||||||
|
return
|
||||||
|
|
||||||
|
mailbox_email = mailbox.email
|
||||||
|
user = mailbox.user
|
||||||
|
|
||||||
|
Mailbox.delete(mailbox_id)
|
||||||
|
db.session.commit()
|
||||||
|
LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email)
|
||||||
|
|
||||||
|
send_email(
|
||||||
|
user.email,
|
||||||
|
f"Your mailbox {mailbox_email} has been deleted",
|
||||||
|
f"""Mailbox {mailbox_email} along with its aliases are deleted successfully.
|
||||||
|
|
||||||
|
Regards,
|
||||||
|
SimpleLogin team.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_verification_email(user, mailbox):
|
def send_verification_email(user, mailbox):
|
||||||
s = Signer(MAILBOX_SECRET)
|
s = Signer(MAILBOX_SECRET)
|
||||||
mailbox_id_signed = s.sign(str(mailbox.id)).decode()
|
mailbox_id_signed = s.sign(str(mailbox.id)).decode()
|
||||||
|
@ -251,18 +251,24 @@ def cancel_mailbox_change_route(mailbox_id):
|
|||||||
@dashboard_bp.route("/mailbox/confirm_change")
|
@dashboard_bp.route("/mailbox/confirm_change")
|
||||||
def mailbox_confirm_change_route():
|
def mailbox_confirm_change_route():
|
||||||
s = Signer(MAILBOX_SECRET)
|
s = Signer(MAILBOX_SECRET)
|
||||||
mailbox_id = request.args.get("mailbox_id")
|
signed_mailbox_id = request.args.get("mailbox_id")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r_id = int(s.unsign(mailbox_id))
|
mailbox_id = int(s.unsign(signed_mailbox_id))
|
||||||
except Exception:
|
except Exception:
|
||||||
flash("Invalid link", "error")
|
flash("Invalid link", "error")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
else:
|
else:
|
||||||
mailbox = Mailbox.get(r_id)
|
mailbox = Mailbox.get(mailbox_id)
|
||||||
|
|
||||||
# new_email can be None if user cancels change in the meantime
|
# new_email can be None if user cancels change in the meantime
|
||||||
if mailbox and mailbox.new_email:
|
if mailbox and mailbox.new_email:
|
||||||
|
if Mailbox.get_by(email=mailbox.new_email):
|
||||||
|
flash(f"{mailbox.new_email} is already used", "error")
|
||||||
|
return redirect(
|
||||||
|
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id)
|
||||||
|
)
|
||||||
|
|
||||||
mailbox.email = mailbox.new_email
|
mailbox.email = mailbox.new_email
|
||||||
mailbox.new_email = None
|
mailbox.new_email = None
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ from app.config import (
|
|||||||
)
|
)
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
|
from app.models import AppleSubscription
|
||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
|
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
|
||||||
@ -21,6 +22,10 @@ def pricing():
|
|||||||
flash("You are already a premium user", "warning")
|
flash("You are already a premium user", "warning")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
|
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=current_user.id)
|
||||||
|
if apple_sub and apple_sub.is_valid():
|
||||||
|
flash("Please make sure to cancel your subscription on Apple first", "warning")
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard/pricing.html",
|
"dashboard/pricing.html",
|
||||||
PADDLE_VENDOR_ID=PADDLE_VENDOR_ID,
|
PADDLE_VENDOR_ID=PADDLE_VENDOR_ID,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import re
|
import re2 as re
|
||||||
|
|
||||||
from flask import render_template, request, flash, redirect, url_for
|
from flask import render_template, request, flash, redirect, url_for
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
|
@ -15,7 +15,7 @@ def refused_email_route():
|
|||||||
try:
|
try:
|
||||||
highlight_id = int(highlight_id)
|
highlight_id = int(highlight_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
LOG.warning("Cannot parse highlight_id %s", highlight_id)
|
LOG.w("Cannot parse highlight_id %s", highlight_id)
|
||||||
highlight_id = None
|
highlight_id = None
|
||||||
|
|
||||||
email_logs: [EmailLog] = (
|
email_logs: [EmailLog] = (
|
||||||
|
@ -105,7 +105,7 @@ def setting():
|
|||||||
other_email_change: EmailChange = EmailChange.get_by(
|
other_email_change: EmailChange = EmailChange.get_by(
|
||||||
new_email=new_email
|
new_email=new_email
|
||||||
)
|
)
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"Another user has a pending %s with the same email address. Current user:%s",
|
"Another user has a pending %s with the same email address. Current user:%s",
|
||||||
other_email_change,
|
other_email_change,
|
||||||
current_user,
|
current_user,
|
||||||
@ -193,7 +193,7 @@ def setting():
|
|||||||
return redirect(url_for("dashboard.setting"))
|
return redirect(url_for("dashboard.setting"))
|
||||||
|
|
||||||
# Schedule delete account job
|
# Schedule delete account job
|
||||||
LOG.warning("schedule delete account job for %s", current_user)
|
LOG.w("schedule delete account job for %s", current_user)
|
||||||
Job.create(
|
Job.create(
|
||||||
name=JOB_DELETE_ACCOUNT,
|
name=JOB_DELETE_ACCOUNT,
|
||||||
payload={"user_id": current_user.id},
|
payload={"user_id": current_user.id},
|
||||||
@ -236,7 +236,7 @@ def setting():
|
|||||||
custom_domain.user_id != current_user.id
|
custom_domain.user_id != current_user.id
|
||||||
or not custom_domain.verified
|
or not custom_domain.verified
|
||||||
):
|
):
|
||||||
LOG.exception(
|
LOG.e(
|
||||||
"%s cannot use domain %s", current_user, default_domain
|
"%s cannot use domain %s", current_user, default_domain
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -300,6 +300,15 @@ def setting():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Your preference has been updated", "success")
|
flash("Your preference has been updated", "success")
|
||||||
return redirect(url_for("dashboard.setting"))
|
return redirect(url_for("dashboard.setting"))
|
||||||
|
elif request.form.get("form-name") == "ignore-loop-email":
|
||||||
|
choose = request.form.get("enable")
|
||||||
|
if choose == "on":
|
||||||
|
current_user.ignore_loop_email = True
|
||||||
|
else:
|
||||||
|
current_user.ignore_loop_email = False
|
||||||
|
db.session.commit()
|
||||||
|
flash("Your preference has been updated", "success")
|
||||||
|
return redirect(url_for("dashboard.setting"))
|
||||||
|
|
||||||
elif request.form.get("form-name") == "export-data":
|
elif request.form.get("form-name") == "export-data":
|
||||||
return redirect(url_for("api.export_data"))
|
return redirect(url_for("api.export_data"))
|
||||||
|
@ -59,5 +59,5 @@ def get_spam_score(
|
|||||||
return get_spam_score(message, email_log, can_retry=False)
|
return get_spam_score(message, email_log, can_retry=False)
|
||||||
else:
|
else:
|
||||||
# return a negative score so the message is always considered as ham
|
# return a negative score so the message is always considered as ham
|
||||||
LOG.exception("SpamAssassin exception, ignore spam check")
|
LOG.e("SpamAssassin exception, ignore spam check")
|
||||||
return -999, None
|
return -999, None
|
||||||
|
@ -11,6 +11,12 @@ E206 = "250 SL E206 Out of office"
|
|||||||
# if mail_from is a IgnoreBounceSender, no need to send back a bounce report
|
# if mail_from is a IgnoreBounceSender, no need to send back a bounce report
|
||||||
E207 = "250 SL E207 No bounce report"
|
E207 = "250 SL E207 No bounce report"
|
||||||
|
|
||||||
|
E208 = "250 SL E208 Hotmail complaint handled"
|
||||||
|
|
||||||
|
E209 = "250 SL E209 Email Loop"
|
||||||
|
|
||||||
|
E210 = "250 SL E210 Yahoo complaint handled"
|
||||||
|
|
||||||
# 4** errors
|
# 4** errors
|
||||||
# E401 = "421 SL E401 Retry later"
|
# E401 = "421 SL E401 Retry later"
|
||||||
E402 = "421 SL E402 Encryption failed - Retry later"
|
E402 = "421 SL E402 Encryption failed - Retry later"
|
||||||
@ -45,3 +51,4 @@ E522 = (
|
|||||||
"550 SL E522 The user you are trying to contact is receiving mail "
|
"550 SL E522 The user you are trying to contact is receiving mail "
|
||||||
"at a rate that prevents additional messages from being delivered."
|
"at a rate that prevents additional messages from being delivered."
|
||||||
)
|
)
|
||||||
|
E523 = "550 SL E523 Unknown error"
|
||||||
|
@ -1,26 +1,33 @@
|
|||||||
import base64
|
import base64
|
||||||
import email
|
|
||||||
import enum
|
import enum
|
||||||
import os
|
import os
|
||||||
import quopri
|
import quopri
|
||||||
import random
|
import random
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
from email.errors import HeaderParseError
|
import uuid
|
||||||
from email.header import decode_header
|
from copy import deepcopy
|
||||||
|
from email import policy, message_from_bytes, message_from_string
|
||||||
|
from email.header import decode_header, Header
|
||||||
from email.message import Message
|
from email.message import Message
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.utils import make_msgid, formatdate, parseaddr
|
from email.utils import make_msgid, formatdate
|
||||||
from smtplib import SMTP, SMTPServerDisconnected
|
from smtplib import SMTP, SMTPServerDisconnected, SMTPException
|
||||||
from typing import Tuple, List, Optional
|
from typing import Tuple, List, Optional, Union
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
import dkim
|
import dkim
|
||||||
|
import re2 as re
|
||||||
import spf
|
import spf
|
||||||
|
from email_validator import (
|
||||||
|
validate_email,
|
||||||
|
EmailNotValidError,
|
||||||
|
ValidatedEmail,
|
||||||
|
)
|
||||||
|
from flanker.addresslib import address
|
||||||
|
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 validate_email import validate_email
|
|
||||||
|
|
||||||
from app.config import (
|
from app.config import (
|
||||||
SUPPORT_EMAIL,
|
SUPPORT_EMAIL,
|
||||||
@ -29,7 +36,6 @@ from app.config import (
|
|||||||
NOT_SEND_EMAIL,
|
NOT_SEND_EMAIL,
|
||||||
DKIM_SELECTOR,
|
DKIM_SELECTOR,
|
||||||
DKIM_PRIVATE_KEY,
|
DKIM_PRIVATE_KEY,
|
||||||
DKIM_HEADERS,
|
|
||||||
ALIAS_DOMAINS,
|
ALIAS_DOMAINS,
|
||||||
SUPPORT_NAME,
|
SUPPORT_NAME,
|
||||||
POSTFIX_SUBMISSION_TLS,
|
POSTFIX_SUBMISSION_TLS,
|
||||||
@ -44,6 +50,8 @@ from app.config import (
|
|||||||
TRANSACTIONAL_BOUNCE_EMAIL,
|
TRANSACTIONAL_BOUNCE_EMAIL,
|
||||||
ALERT_SPF,
|
ALERT_SPF,
|
||||||
POSTFIX_PORT_FORWARD,
|
POSTFIX_PORT_FORWARD,
|
||||||
|
TEMP_DIR,
|
||||||
|
ALIAS_AUTOMATIC_DISABLE,
|
||||||
)
|
)
|
||||||
from app.dns_utils import get_mx_domains
|
from app.dns_utils import get_mx_domains
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@ -290,8 +298,6 @@ def send_email(
|
|||||||
|
|
||||||
msg_raw = to_bytes(msg)
|
msg_raw = to_bytes(msg)
|
||||||
|
|
||||||
transaction = TransactionalEmail.get_by(email=to_email)
|
|
||||||
if not transaction:
|
|
||||||
transaction = TransactionalEmail.create(email=to_email, commit=True)
|
transaction = TransactionalEmail.create(email=to_email, commit=True)
|
||||||
|
|
||||||
# use a different envelope sender for each transactional email (aka VERP)
|
# use a different envelope sender for each transactional email (aka VERP)
|
||||||
@ -307,6 +313,7 @@ def send_email_with_rate_control(
|
|||||||
html=None,
|
html=None,
|
||||||
max_nb_alert=MAX_ALERT_24H,
|
max_nb_alert=MAX_ALERT_24H,
|
||||||
nb_day=1,
|
nb_day=1,
|
||||||
|
ignore_smtp_error=False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Same as send_email with rate control over alert_type.
|
"""Same as send_email with rate control over alert_type.
|
||||||
Make sure no more than `max_nb_alert` emails are sent over the period of `nb_day` days
|
Make sure no more than `max_nb_alert` emails are sent over the period of `nb_day` days
|
||||||
@ -322,7 +329,7 @@ def send_email_with_rate_control(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if nb_alert >= max_nb_alert:
|
if nb_alert >= max_nb_alert:
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"%s emails were sent to %s in the last %s days, alert type %s",
|
"%s emails were sent to %s in the last %s days, alert type %s",
|
||||||
nb_alert,
|
nb_alert,
|
||||||
to_email,
|
to_email,
|
||||||
@ -333,7 +340,15 @@ def send_email_with_rate_control(
|
|||||||
|
|
||||||
SentAlert.create(user_id=user.id, alert_type=alert_type, to_email=to_email)
|
SentAlert.create(user_id=user.id, alert_type=alert_type, to_email=to_email)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
if ignore_smtp_error:
|
||||||
|
try:
|
||||||
send_email(to_email, subject, plaintext, html)
|
send_email(to_email, subject, plaintext, html)
|
||||||
|
except SMTPException:
|
||||||
|
LOG.w("Cannot send email to %s, subject %s", to_email, subject)
|
||||||
|
else:
|
||||||
|
send_email(to_email, subject, plaintext, html)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@ -358,7 +373,7 @@ def send_email_at_most_times(
|
|||||||
).count()
|
).count()
|
||||||
|
|
||||||
if nb_alert >= max_times:
|
if nb_alert >= max_times:
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"%s emails were sent to %s alert type %s",
|
"%s emails were sent to %s alert type %s",
|
||||||
nb_alert,
|
nb_alert,
|
||||||
to_email,
|
to_email,
|
||||||
@ -376,8 +391,12 @@ def get_email_local_part(address) -> str:
|
|||||||
"""
|
"""
|
||||||
Get the local part from email
|
Get the local part from email
|
||||||
ab@cd.com -> ab
|
ab@cd.com -> ab
|
||||||
|
Convert the local part to lowercase
|
||||||
"""
|
"""
|
||||||
return address[: address.find("@")]
|
r: ValidatedEmail = validate_email(
|
||||||
|
address, check_deliverability=False, allow_smtputf8=False
|
||||||
|
)
|
||||||
|
return r.local_part.lower()
|
||||||
|
|
||||||
|
|
||||||
def get_email_domain_part(address):
|
def get_email_domain_part(address):
|
||||||
@ -389,7 +408,39 @@ def get_email_domain_part(address):
|
|||||||
return address[address.find("@") + 1 :]
|
return address[address.find("@") + 1 :]
|
||||||
|
|
||||||
|
|
||||||
|
# headers used to DKIM sign in order of preference
|
||||||
|
_DKIM_HEADERS = [
|
||||||
|
[b"Message-ID", b"Date", b"Subject", b"From", b"To"],
|
||||||
|
[b"From", b"To"],
|
||||||
|
[b"Message-ID", b"Date"],
|
||||||
|
[b"From"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def add_dkim_signature(msg: Message, email_domain: str):
|
def add_dkim_signature(msg: Message, email_domain: str):
|
||||||
|
for dkim_headers in _DKIM_HEADERS:
|
||||||
|
try:
|
||||||
|
add_dkim_signature_with_header(msg, email_domain, dkim_headers)
|
||||||
|
return
|
||||||
|
except dkim.DKIMException:
|
||||||
|
LOG.w("DKIM fail with %s", dkim_headers, exc_info=True)
|
||||||
|
# try with another headers
|
||||||
|
continue
|
||||||
|
|
||||||
|
# To investigate why some emails can't be DKIM signed. todo: remove
|
||||||
|
if TEMP_DIR:
|
||||||
|
file_name = str(uuid.uuid4()) + ".eml"
|
||||||
|
with open(os.path.join(TEMP_DIR, file_name), "wb") as f:
|
||||||
|
f.write(msg.as_bytes())
|
||||||
|
|
||||||
|
LOG.w("email saved to %s", file_name)
|
||||||
|
|
||||||
|
raise Exception("Cannot create DKIM signature")
|
||||||
|
|
||||||
|
|
||||||
|
def add_dkim_signature_with_header(
|
||||||
|
msg: Message, email_domain: str, dkim_headers: [bytes]
|
||||||
|
):
|
||||||
delete_header(msg, "DKIM-Signature")
|
delete_header(msg, "DKIM-Signature")
|
||||||
|
|
||||||
# Specify headers in "byte" form
|
# Specify headers in "byte" form
|
||||||
@ -400,7 +451,7 @@ def add_dkim_signature(msg: Message, email_domain: str):
|
|||||||
DKIM_SELECTOR,
|
DKIM_SELECTOR,
|
||||||
email_domain.encode(),
|
email_domain.encode(),
|
||||||
DKIM_PRIVATE_KEY.encode(),
|
DKIM_PRIVATE_KEY.encode(),
|
||||||
include_headers=DKIM_HEADERS,
|
include_headers=dkim_headers,
|
||||||
)
|
)
|
||||||
sig = sig.decode()
|
sig = sig.decode()
|
||||||
|
|
||||||
@ -448,19 +499,19 @@ def delete_all_headers_except(msg: Message, headers: [str]):
|
|||||||
del msg._headers[i]
|
del msg._headers[i]
|
||||||
|
|
||||||
|
|
||||||
def can_create_directory_for_address(address: str) -> bool:
|
def can_create_directory_for_address(email_address: str) -> bool:
|
||||||
"""return True if an email ends with one of the alias domains provided by SimpleLogin"""
|
"""return True if an email ends with one of the alias domains provided by SimpleLogin"""
|
||||||
# not allow creating directory with premium domain
|
# not allow creating directory with premium domain
|
||||||
for domain in ALIAS_DOMAINS:
|
for domain in ALIAS_DOMAINS:
|
||||||
if address.endswith("@" + domain):
|
if email_address.endswith("@" + domain):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def is_valid_alias_address_domain(address) -> bool:
|
def is_valid_alias_address_domain(email_address) -> bool:
|
||||||
"""Return whether an address domain might a domain handled by SimpleLogin"""
|
"""Return whether an address domain might a domain handled by SimpleLogin"""
|
||||||
domain = get_email_domain_part(address)
|
domain = get_email_domain_part(email_address)
|
||||||
if SLDomain.get_by(domain=domain):
|
if SLDomain.get_by(domain=domain):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -470,7 +521,7 @@ def is_valid_alias_address_domain(address) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def email_can_be_used_as_mailbox(email: str) -> bool:
|
def email_can_be_used_as_mailbox(email_address: str) -> bool:
|
||||||
"""Return True if an email can be used as a personal email.
|
"""Return True if an email can be used as a personal email.
|
||||||
Use the email domain as criteria. A domain can be used if it is not:
|
Use the email domain as criteria. A domain can be used if it is not:
|
||||||
- one of ALIAS_DOMAINS
|
- one of ALIAS_DOMAINS
|
||||||
@ -478,7 +529,13 @@ def email_can_be_used_as_mailbox(email: str) -> bool:
|
|||||||
- one of custom domains
|
- one of custom domains
|
||||||
- a disposable domain
|
- a disposable domain
|
||||||
"""
|
"""
|
||||||
domain = get_email_domain_part(email)
|
try:
|
||||||
|
domain = validate_email(
|
||||||
|
email_address, check_deliverability=False, allow_smtputf8=False
|
||||||
|
).domain
|
||||||
|
except EmailNotValidError:
|
||||||
|
return False
|
||||||
|
|
||||||
if not domain:
|
if not domain:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -531,9 +588,9 @@ def get_mx_domain_list(domain) -> [str]:
|
|||||||
return [d[:-1] for _, d in priority_domains]
|
return [d[:-1] for _, d in priority_domains]
|
||||||
|
|
||||||
|
|
||||||
def personal_email_already_used(email: str) -> bool:
|
def personal_email_already_used(email_address: str) -> bool:
|
||||||
"""test if an email can be used as user email"""
|
"""test if an email can be used as user email"""
|
||||||
if User.get_by(email=email):
|
if User.get_by(email=email_address):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@ -566,6 +623,30 @@ def get_orig_message_from_bounce(msg: Message) -> Message:
|
|||||||
return part
|
return part
|
||||||
|
|
||||||
|
|
||||||
|
def get_orig_message_from_hotmail_complaint(msg: Message) -> Message:
|
||||||
|
i = 0
|
||||||
|
for part in msg.walk():
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# 1st part is the container
|
||||||
|
# 2nd part is the empty body
|
||||||
|
# 3rd is original message
|
||||||
|
if i == 3:
|
||||||
|
return part
|
||||||
|
|
||||||
|
|
||||||
|
def get_orig_message_from_yahoo_complaint(msg: Message) -> Message:
|
||||||
|
i = 0
|
||||||
|
for part in msg.walk():
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# 1st part is the container
|
||||||
|
# 2nd part is the empty body
|
||||||
|
# 6th is original message
|
||||||
|
if i == 6:
|
||||||
|
return part
|
||||||
|
|
||||||
|
|
||||||
def get_header_from_bounce(msg: Message, header: str) -> str:
|
def get_header_from_bounce(msg: Message, header: str) -> str:
|
||||||
"""using regex to get header value from bounce message
|
"""using regex to get header value from bounce message
|
||||||
get_orig_message_from_bounce is better. This should be the last option
|
get_orig_message_from_bounce is better. This should be the last option
|
||||||
@ -631,77 +712,53 @@ def get_spam_from_header(spam_status_header, max_score=None) -> (bool, str):
|
|||||||
)
|
)
|
||||||
score = float(score_section[len("score=") :])
|
score = float(score_section[len("score=") :])
|
||||||
if score >= max_score:
|
if score >= max_score:
|
||||||
LOG.warning("Spam score %s exceeds %s", score, max_score)
|
LOG.w("Spam score %s exceeds %s", score, max_score)
|
||||||
return True, spam_status_header
|
return True, spam_status_header
|
||||||
|
|
||||||
return spamassassin_answer.lower() == "yes", spam_status_header
|
return spamassassin_answer.lower() == "yes", spam_status_header
|
||||||
|
|
||||||
|
|
||||||
def get_header_unicode(header: str) -> str:
|
def get_header_unicode(header: Union[str, Header]) -> str:
|
||||||
|
"""
|
||||||
|
Convert a header to unicode
|
||||||
|
Should be used to handle headers like From:, To:, CC:, Subject:
|
||||||
|
"""
|
||||||
if header is None:
|
if header is None:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
decoded_string, charset = decode_header(header)[0]
|
ret = ""
|
||||||
if charset is not None:
|
for to_decoded_str, charset in decode_header(header):
|
||||||
try:
|
if charset is None:
|
||||||
return decoded_string.decode(charset)
|
if type(to_decoded_str) is bytes:
|
||||||
except UnicodeDecodeError:
|
decoded_str = to_decoded_str.decode()
|
||||||
LOG.warning("Cannot decode header %s", header)
|
|
||||||
except LookupError: # charset is unknown
|
|
||||||
LOG.warning("Cannot decode %s with %s, use utf-8", decoded_string, charset)
|
|
||||||
try:
|
|
||||||
return decoded_string.decode("utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
LOG.warning("Cannot UTF-8 decode %s", decoded_string)
|
|
||||||
return decoded_string.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
return header
|
|
||||||
|
|
||||||
|
|
||||||
def parseaddr_unicode(addr) -> (str, str):
|
|
||||||
"""Like parseaddr() but return name in unicode instead of in RFC 2047 format
|
|
||||||
Should be used instead of parseaddr()
|
|
||||||
'=?UTF-8?B?TmjGoW4gTmd1eeG7hW4=?= <abcd@gmail.com>' -> ('Nhơn Nguyễn', "abcd@gmail.com")
|
|
||||||
"""
|
|
||||||
# sometimes linebreaks are present in addr
|
|
||||||
addr = addr.replace("\n", "").strip()
|
|
||||||
name, email = parseaddr(addr)
|
|
||||||
# email can have whitespace so we can't remove whitespace here
|
|
||||||
email = email.strip().lower()
|
|
||||||
if name:
|
|
||||||
name = name.strip()
|
|
||||||
try:
|
|
||||||
decoded_string, charset = decode_header(name)[0]
|
|
||||||
except HeaderParseError: # fail in case
|
|
||||||
LOG.warning("Can't decode name %s", name)
|
|
||||||
else:
|
else:
|
||||||
if charset is not None:
|
decoded_str = to_decoded_str
|
||||||
try:
|
|
||||||
name = decoded_string.decode(charset)
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
LOG.warning("Cannot decode addr name %s", name)
|
|
||||||
name = ""
|
|
||||||
except LookupError: # charset is unknown
|
|
||||||
LOG.warning(
|
|
||||||
"Cannot decode %s with %s, use utf-8", decoded_string, charset
|
|
||||||
)
|
|
||||||
name = decoded_string.decode("utf-8")
|
|
||||||
else:
|
else:
|
||||||
name = decoded_string
|
try:
|
||||||
|
decoded_str = to_decoded_str.decode(charset)
|
||||||
|
except (LookupError, UnicodeDecodeError): # charset is unknown
|
||||||
|
LOG.w("Cannot decode %s with %s, try utf-8", to_decoded_str, charset)
|
||||||
|
try:
|
||||||
|
decoded_str = to_decoded_str.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
LOG.w("Cannot UTF-8 decode %s", to_decoded_str)
|
||||||
|
decoded_str = to_decoded_str.decode("utf-8", errors="replace")
|
||||||
|
ret += decoded_str
|
||||||
|
|
||||||
if type(name) == bytes:
|
return ret
|
||||||
name = name.decode()
|
|
||||||
return name, email
|
|
||||||
|
|
||||||
|
|
||||||
def copy(msg: Message) -> Message:
|
def copy(msg: Message) -> Message:
|
||||||
"""return a copy of message"""
|
"""return a copy of message"""
|
||||||
try:
|
try:
|
||||||
# prefer the unicode way
|
return deepcopy(msg)
|
||||||
return email.message_from_string(msg.as_string())
|
except Exception:
|
||||||
|
LOG.w("deepcopy fails, try string parsing")
|
||||||
|
try:
|
||||||
|
return message_from_string(msg.as_string())
|
||||||
except (UnicodeEncodeError, KeyError, LookupError):
|
except (UnicodeEncodeError, KeyError, LookupError):
|
||||||
LOG.warning("as_string() fails, try to_bytes")
|
LOG.w("as_string() fails, try bytes parsing")
|
||||||
return email.message_from_bytes(to_bytes(msg))
|
return message_from_bytes(to_bytes(msg))
|
||||||
|
|
||||||
|
|
||||||
def to_bytes(msg: Message):
|
def to_bytes(msg: Message):
|
||||||
@ -709,17 +766,15 @@ def to_bytes(msg: Message):
|
|||||||
try:
|
try:
|
||||||
return msg.as_bytes()
|
return msg.as_bytes()
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
LOG.warning("as_bytes fails with default policy, try SMTP policy")
|
LOG.w("as_bytes fails with default policy, try SMTP policy")
|
||||||
try:
|
try:
|
||||||
return msg.as_bytes(policy=email.policy.SMTP)
|
return msg.as_bytes(policy=policy.SMTP)
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
LOG.warning("as_bytes fails with SMTP policy, try SMTPUTF8 policy")
|
LOG.w("as_bytes fails with SMTP policy, try SMTPUTF8 policy")
|
||||||
try:
|
try:
|
||||||
return msg.as_bytes(policy=email.policy.SMTPUTF8)
|
return msg.as_bytes(policy=policy.SMTPUTF8)
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
LOG.warning(
|
LOG.w("as_bytes fails with SMTPUTF8 policy, try converting to string")
|
||||||
"as_bytes fails with SMTPUTF8 policy, try converting to string"
|
|
||||||
)
|
|
||||||
msg_string = msg.as_string()
|
msg_string = msg.as_string()
|
||||||
try:
|
try:
|
||||||
return msg_string.encode()
|
return msg_string.encode()
|
||||||
@ -740,10 +795,16 @@ def should_add_dkim_signature(domain: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def is_valid_email(email_address: str) -> bool:
|
def is_valid_email(email_address: str) -> bool:
|
||||||
"""Used to check whether an email address is valid"""
|
"""
|
||||||
return validate_email(
|
Used to check whether an email address is valid
|
||||||
email_address=email_address, check_mx=False, use_blacklist=False
|
NOT run MX check.
|
||||||
)
|
NOT allow unicode.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validate_email(email_address, check_deliverability=False, allow_smtputf8=False)
|
||||||
|
return True
|
||||||
|
except EmailNotValidError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class EmailEncoding(enum.Enum):
|
class EmailEncoding(enum.Enum):
|
||||||
@ -773,7 +834,7 @@ def get_encoding(msg: Message) -> EmailEncoding:
|
|||||||
if cte in ("amazonses.com",):
|
if cte in ("amazonses.com",):
|
||||||
return EmailEncoding.NO
|
return EmailEncoding.NO
|
||||||
|
|
||||||
LOG.exception("Unknown encoding %s", cte)
|
LOG.e("Unknown encoding %s", cte)
|
||||||
|
|
||||||
return EmailEncoding.NO
|
return EmailEncoding.NO
|
||||||
|
|
||||||
@ -870,6 +931,7 @@ def replace(msg: Message, old, new) -> Message:
|
|||||||
or content_type == "text/calendar"
|
or content_type == "text/calendar"
|
||||||
or content_type == "text/directory"
|
or content_type == "text/directory"
|
||||||
or content_type == "text/csv"
|
or content_type == "text/csv"
|
||||||
|
or content_type == "text/x-python-script"
|
||||||
):
|
):
|
||||||
LOG.d("not applicable for %s", content_type)
|
LOG.d("not applicable for %s", content_type)
|
||||||
return msg
|
return msg
|
||||||
@ -898,7 +960,7 @@ def replace(msg: Message, old, new) -> Message:
|
|||||||
clone_msg.set_payload(new_parts)
|
clone_msg.set_payload(new_parts)
|
||||||
return clone_msg
|
return clone_msg
|
||||||
|
|
||||||
LOG.exception("Cannot replace text for %s", msg.get_content_type())
|
LOG.w("Cannot replace text for %s", msg.get_content_type())
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
@ -974,7 +1036,10 @@ def should_disable(alias: Alias) -> bool:
|
|||||||
"""Disable an alias if it has too many bounces recently"""
|
"""Disable an alias if it has too many bounces recently"""
|
||||||
# Bypass the bounce rule
|
# Bypass the bounce rule
|
||||||
if alias.cannot_be_disabled:
|
if alias.cannot_be_disabled:
|
||||||
LOG.warning("%s cannot be disabled", alias)
|
LOG.w("%s cannot be disabled", alias)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not ALIAS_AUTOMATIC_DISABLE:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
yesterday = arrow.now().shift(days=-1)
|
yesterday = arrow.now().shift(days=-1)
|
||||||
@ -1008,7 +1073,7 @@ def should_disable(alias: Alias) -> bool:
|
|||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
if nb_bounced_7d_1d > 1:
|
if nb_bounced_7d_1d > 1:
|
||||||
LOG.debug(
|
LOG.d(
|
||||||
"more than 5 bounces in the last 24h and more than 1 bounces in the last 7 days, "
|
"more than 5 bounces in the last 24h and more than 1 bounces in the last 7 days, "
|
||||||
"disable alias %s",
|
"disable alias %s",
|
||||||
alias,
|
alias,
|
||||||
@ -1088,7 +1153,7 @@ def spf_pass(
|
|||||||
try:
|
try:
|
||||||
r = spf.check2(i=ip, s=envelope.mail_from, h=None)
|
r = spf.check2(i=ip, s=envelope.mail_from, h=None)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.exception("SPF error, mailbox %s, ip %s", mailbox.email, ip)
|
LOG.e("SPF error, mailbox %s, ip %s", mailbox.email, ip)
|
||||||
else:
|
else:
|
||||||
# TODO: Handle temperr case (e.g. dns timeout)
|
# TODO: Handle temperr case (e.g. dns timeout)
|
||||||
# only an absolute pass, or no SPF policy at all is 'valid'
|
# only an absolute pass, or no SPF policy at all is 'valid'
|
||||||
@ -1222,3 +1287,17 @@ def should_ignore_bounce(mail_from: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_full_address(full_address) -> (str, str):
|
||||||
|
"""
|
||||||
|
parse the email address full format and return the display name and address
|
||||||
|
For ex: ab <cd@xy.com> -> (ab, cd@xy.com)
|
||||||
|
'=?UTF-8?B?TmjGoW4gTmd1eeG7hW4=?= <abcd@gmail.com>' -> ('Nhơn Nguyễn', "abcd@gmail.com")
|
||||||
|
|
||||||
|
If the parsing fails, raise ValueError
|
||||||
|
"""
|
||||||
|
full_address: EmailAddress = address.parse(full_address)
|
||||||
|
if full_address is None:
|
||||||
|
raise ValueError
|
||||||
|
return full_address.display_name, full_address.address
|
||||||
|
@ -25,7 +25,7 @@ def handle_batch_import(batch_import: BatchImport):
|
|||||||
batch_import.processed = True
|
batch_import.processed = True
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
LOG.debug("Start batch import for %s %s", batch_import, user)
|
LOG.d("Start batch import for %s %s", batch_import, user)
|
||||||
file_url = s3.get_url(batch_import.file.path)
|
file_url = s3.get_url(batch_import.file.path)
|
||||||
|
|
||||||
LOG.d("Download file %s from %s", batch_import.file, file_url)
|
LOG.d("Download file %s from %s", batch_import.file, file_url)
|
||||||
@ -43,7 +43,7 @@ def import_from_csv(batch_import: BatchImport, user: User, lines):
|
|||||||
full_alias = sanitize_email(row["alias"])
|
full_alias = sanitize_email(row["alias"])
|
||||||
note = row["note"]
|
note = row["note"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
LOG.warning("Cannot parse row %s", row)
|
LOG.w("Cannot parse row %s", row)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
alias_domain = get_email_domain_part(full_alias)
|
alias_domain = get_email_domain_part(full_alias)
|
||||||
@ -54,7 +54,7 @@ def import_from_csv(batch_import: BatchImport, user: User, lines):
|
|||||||
or not custom_domain.verified
|
or not custom_domain.verified
|
||||||
or custom_domain.user_id != user.id
|
or custom_domain.user_id != user.id
|
||||||
):
|
):
|
||||||
LOG.debug("domain %s can't be used %s", alias_domain, user)
|
LOG.d("domain %s can't be used %s", alias_domain, user)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -62,7 +62,7 @@ def verify_id_token(id_token) -> bool:
|
|||||||
try:
|
try:
|
||||||
jwt.JWT(key=_key, jwt=id_token)
|
jwt.JWT(key=_key, jwt=id_token)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.exception("id token not verified")
|
LOG.e("id token not verified")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
return True
|
return True
|
||||||
|
@ -42,7 +42,7 @@ def _get_console_handler():
|
|||||||
return console_handler
|
return console_handler
|
||||||
|
|
||||||
|
|
||||||
def _get_logger(name):
|
def _get_logger(name) -> logging.Logger:
|
||||||
logger = logging.getLogger(name)
|
logger = logging.getLogger(name)
|
||||||
|
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
|
195
app/models.py
195
app/models.py
@ -5,10 +5,13 @@ from email.utils import formataddr
|
|||||||
from typing import List, Tuple, Optional
|
from typing import List, Tuple, Optional
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
|
import sqlalchemy as sa
|
||||||
from arrow import Arrow
|
from arrow import Arrow
|
||||||
|
from flanker.addresslib import address
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
from sqlalchemy import text, desc, CheckConstraint, Index
|
from sqlalchemy import text, desc, CheckConstraint, Index, Column
|
||||||
|
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||||
from sqlalchemy.orm import deferred
|
from sqlalchemy.orm import deferred
|
||||||
from sqlalchemy_utils import ArrowType
|
from sqlalchemy_utils import ArrowType
|
||||||
|
|
||||||
@ -39,9 +42,6 @@ from app.utils import (
|
|||||||
random_word,
|
random_word,
|
||||||
)
|
)
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
|
||||||
|
|
||||||
|
|
||||||
class TSVector(sa.types.TypeDecorator):
|
class TSVector(sa.types.TypeDecorator):
|
||||||
impl = TSVECTOR
|
impl = TSVECTOR
|
||||||
@ -83,12 +83,17 @@ class ModelMixin(object):
|
|||||||
def create(cls, **kw):
|
def create(cls, **kw):
|
||||||
# whether should call db.session.commit
|
# whether should call db.session.commit
|
||||||
commit = kw.pop("commit", False)
|
commit = kw.pop("commit", False)
|
||||||
|
flush = kw.pop("flush", False)
|
||||||
|
|
||||||
r = cls(**kw)
|
r = cls(**kw)
|
||||||
db.session.add(r)
|
db.session.add(r)
|
||||||
|
|
||||||
if commit:
|
if commit:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
if flush:
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
@ -160,9 +165,7 @@ class PlanEnum(EnumE):
|
|||||||
# Specify the format for sender address
|
# Specify the format for sender address
|
||||||
class SenderFormatEnum(EnumE):
|
class SenderFormatEnum(EnumE):
|
||||||
AT = 0 # John Wick - john at wick.com
|
AT = 0 # John Wick - john at wick.com
|
||||||
VIA = 1 # john@wick.com via SimpleLogin
|
|
||||||
A = 2 # John Wick - john(a)wick.com
|
A = 2 # John Wick - john(a)wick.com
|
||||||
FULL = 3 # John Wick - john@wick.com
|
|
||||||
|
|
||||||
|
|
||||||
class AliasGeneratorEnum(EnumE):
|
class AliasGeneratorEnum(EnumE):
|
||||||
@ -336,6 +339,12 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
db.Boolean, default=False, nullable=False, server_default="0"
|
db.Boolean, default=False, nullable=False, server_default="0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ignore emails send from a mailbox to its alias. This can happen when replying all to a forwarded email
|
||||||
|
# can automatically re-includes the alias
|
||||||
|
ignore_loop_email = db.Column(
|
||||||
|
db.Boolean, default=False, nullable=False, server_default="0"
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, email, name="", password=None, **kwargs):
|
def create(cls, email, name="", password=None, **kwargs):
|
||||||
user: User = super(User, cls).create(email=email, name=name, **kwargs)
|
user: User = super(User, cls).create(email=email, name=name, **kwargs)
|
||||||
@ -386,7 +395,7 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def _lifetime_or_active_subscription(self) -> bool:
|
def lifetime_or_active_subscription(self) -> bool:
|
||||||
"""True if user has lifetime licence or active subscription"""
|
"""True if user has lifetime licence or active subscription"""
|
||||||
if self.lifetime:
|
if self.lifetime:
|
||||||
return True
|
return True
|
||||||
@ -435,7 +444,7 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
|
|
||||||
def in_trial(self):
|
def in_trial(self):
|
||||||
"""return True if user does not have lifetime licence or an active subscription AND is in trial period"""
|
"""return True if user does not have lifetime licence or an active subscription AND is in trial period"""
|
||||||
if self._lifetime_or_active_subscription():
|
if self.lifetime_or_active_subscription():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.trial_end and arrow.now() < self.trial_end:
|
if self.trial_end and arrow.now() < self.trial_end:
|
||||||
@ -444,7 +453,7 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def should_show_upgrade_button(self):
|
def should_show_upgrade_button(self):
|
||||||
if self._lifetime_or_active_subscription():
|
if self.lifetime_or_active_subscription():
|
||||||
# user who has canceled can also re-subscribe
|
# user who has canceled can also re-subscribe
|
||||||
sub: Subscription = self.get_subscription()
|
sub: Subscription = self.get_subscription()
|
||||||
if sub and sub.cancelled:
|
if sub and sub.cancelled:
|
||||||
@ -468,10 +477,6 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
if sub and not sub.cancelled:
|
if sub and not sub.cancelled:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id)
|
|
||||||
if apple_sub and apple_sub.is_valid():
|
|
||||||
return False
|
|
||||||
|
|
||||||
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
|
manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=self.id)
|
||||||
# user who has giveaway premium can decide to upgrade
|
# user who has giveaway premium can decide to upgrade
|
||||||
if manual_sub and manual_sub.is_active() and not manual_sub.is_giveaway:
|
if manual_sub and manual_sub.is_active() and not manual_sub.is_giveaway:
|
||||||
@ -490,7 +495,7 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
- in trial period or
|
- in trial period or
|
||||||
- active subscription
|
- active subscription
|
||||||
"""
|
"""
|
||||||
if self._lifetime_or_active_subscription():
|
if self.lifetime_or_active_subscription():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if self.trial_end and arrow.now() < self.trial_end:
|
if self.trial_end and arrow.now() < self.trial_end:
|
||||||
@ -506,9 +511,9 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
sub: Subscription = self.get_subscription()
|
sub: Subscription = self.get_subscription()
|
||||||
if sub:
|
if sub:
|
||||||
if sub.cancelled:
|
if sub.cancelled:
|
||||||
return f"Cancelled Paddle Subscription {sub.subscription_id}"
|
return f"Cancelled Paddle Subscription {sub.subscription_id} {sub.plan_name()}"
|
||||||
else:
|
else:
|
||||||
return f"Active Paddle Subscription {sub.subscription_id}"
|
return f"Active Paddle Subscription {sub.subscription_id} {sub.plan_name()}"
|
||||||
|
|
||||||
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id)
|
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id)
|
||||||
if apple_sub and apple_sub.is_valid():
|
if apple_sub and apple_sub.is_valid():
|
||||||
@ -582,7 +587,7 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
Whether user can create a new alias. User can't create a new alias if
|
Whether user can create a new alias. User can't create a new alias if
|
||||||
- has more than 15 aliases in the free plan, *even in the free trial*
|
- has more than 15 aliases in the free plan, *even in the free trial*
|
||||||
"""
|
"""
|
||||||
if self._lifetime_or_active_subscription():
|
if self.lifetime_or_active_subscription():
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
return Alias.filter_by(user_id=self.id).count() < MAX_NB_EMAIL_FREE_PLAN
|
return Alias.filter_by(user_id=self.id).count() < MAX_NB_EMAIL_FREE_PLAN
|
||||||
@ -686,7 +691,7 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
or not custom_domain.verified
|
or not custom_domain.verified
|
||||||
or custom_domain.user_id != self.id
|
or custom_domain.user_id != self.id
|
||||||
):
|
):
|
||||||
LOG.warning("Problem with %s default random alias domain", self)
|
LOG.w("Problem with %s default random alias domain", self)
|
||||||
return FIRST_ALIAS_DOMAIN
|
return FIRST_ALIAS_DOMAIN
|
||||||
|
|
||||||
return custom_domain.domain
|
return custom_domain.domain
|
||||||
@ -695,11 +700,11 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
sl_domain = SLDomain.get(self.default_alias_public_domain_id)
|
sl_domain = SLDomain.get(self.default_alias_public_domain_id)
|
||||||
# sanity check
|
# sanity check
|
||||||
if not sl_domain:
|
if not sl_domain:
|
||||||
LOG.exception("Problem with %s public random alias domain", self)
|
LOG.e("Problem with %s public random alias domain", self)
|
||||||
return FIRST_ALIAS_DOMAIN
|
return FIRST_ALIAS_DOMAIN
|
||||||
|
|
||||||
if sl_domain.premium_only and not self.is_premium():
|
if sl_domain.premium_only and not self.is_premium():
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"%s is not premium and cannot use %s. Reset default random alias domain setting",
|
"%s is not premium and cannot use %s. Reset default random alias domain setting",
|
||||||
self,
|
self,
|
||||||
sl_domain,
|
sl_domain,
|
||||||
@ -864,13 +869,11 @@ def generate_oauth_client_id(client_name) -> str:
|
|||||||
|
|
||||||
# check that the client does not exist yet
|
# check that the client does not exist yet
|
||||||
if not Client.get_by(oauth_client_id=oauth_client_id):
|
if not Client.get_by(oauth_client_id=oauth_client_id):
|
||||||
LOG.debug("generate oauth_client_id %s", oauth_client_id)
|
LOG.d("generate oauth_client_id %s", oauth_client_id)
|
||||||
return oauth_client_id
|
return oauth_client_id
|
||||||
|
|
||||||
# Rerun the function
|
# Rerun the function
|
||||||
LOG.warning(
|
LOG.w("client_id %s already exists, generate a new client_id", oauth_client_id)
|
||||||
"client_id %s already exists, generate a new client_id", oauth_client_id
|
|
||||||
)
|
|
||||||
return generate_oauth_client_id(client_name)
|
return generate_oauth_client_id(client_name)
|
||||||
|
|
||||||
|
|
||||||
@ -1042,11 +1045,11 @@ def generate_email(
|
|||||||
if not Alias.get_by(email=random_email) and not DeletedAlias.get_by(
|
if not Alias.get_by(email=random_email) and not DeletedAlias.get_by(
|
||||||
email=random_email
|
email=random_email
|
||||||
):
|
):
|
||||||
LOG.debug("generate email %s", random_email)
|
LOG.d("generate email %s", random_email)
|
||||||
return random_email
|
return random_email
|
||||||
|
|
||||||
# Rerun the function
|
# Rerun the function
|
||||||
LOG.warning("email %s already exists, generate a new email", random_email)
|
LOG.w("email %s already exists, generate a new email", random_email)
|
||||||
return generate_email(scheme=scheme, in_hex=in_hex)
|
return generate_email(scheme=scheme, in_hex=in_hex)
|
||||||
|
|
||||||
|
|
||||||
@ -1136,6 +1139,13 @@ class Alias(db.Model, ModelMixin):
|
|||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_video___ts_vector__", ts_vector, postgresql_using="gin"),
|
Index("ix_video___ts_vector__", ts_vector, postgresql_using="gin"),
|
||||||
|
# index on note column using pg_trgm
|
||||||
|
Index(
|
||||||
|
"note_pg_trgm_index",
|
||||||
|
"note",
|
||||||
|
postgresql_ops={"note": "gin_trgm_ops"},
|
||||||
|
postgresql_using="gin",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
user = db.relationship(User, foreign_keys=[user_id])
|
user = db.relationship(User, foreign_keys=[user_id])
|
||||||
@ -1234,7 +1244,7 @@ class Alias(db.Model, ModelMixin):
|
|||||||
elif user.default_alias_public_domain_id:
|
elif user.default_alias_public_domain_id:
|
||||||
sl_domain: SLDomain = SLDomain.get(user.default_alias_public_domain_id)
|
sl_domain: SLDomain = SLDomain.get(user.default_alias_public_domain_id)
|
||||||
if sl_domain.premium_only and not user.is_premium():
|
if sl_domain.premium_only and not user.is_premium():
|
||||||
LOG.warning("%s not premium, cannot use %s", user, sl_domain)
|
LOG.w("%s not premium, cannot use %s", user, sl_domain)
|
||||||
else:
|
else:
|
||||||
random_email = generate_email(
|
random_email = generate_email(
|
||||||
scheme=scheme, in_hex=in_hex, alias_domain=sl_domain.domain
|
scheme=scheme, in_hex=in_hex, alias_domain=sl_domain.domain
|
||||||
@ -1349,7 +1359,7 @@ class ClientUser(db.Model, ModelMixin):
|
|||||||
elif scope == Scope.EMAIL:
|
elif scope == Scope.EMAIL:
|
||||||
# Use generated email
|
# Use generated email
|
||||||
if self.alias_id:
|
if self.alias_id:
|
||||||
LOG.debug(
|
LOG.d(
|
||||||
"Use gen email for user %s, client %s", self.user, self.client
|
"Use gen email for user %s, client %s", self.user, self.client
|
||||||
)
|
)
|
||||||
res[Scope.EMAIL.value] = self.alias.email
|
res[Scope.EMAIL.value] = self.alias.email
|
||||||
@ -1407,8 +1417,6 @@ class Contact(db.Model, ModelMixin):
|
|||||||
# to investigate why the website_email is sometimes not correctly parsed
|
# to investigate why the website_email is sometimes not correctly parsed
|
||||||
# the envelope mail_from
|
# the envelope mail_from
|
||||||
mail_from = db.Column(db.Text, nullable=True, default=None)
|
mail_from = db.Column(db.Text, nullable=True, default=None)
|
||||||
# the message["From"] header
|
|
||||||
from_header = db.Column(db.Text, nullable=True, default=None)
|
|
||||||
|
|
||||||
# a contact can have an empty email address, in this case it can't receive emails
|
# a contact can have an empty email address, in this case it can't receive emails
|
||||||
invalid_email = db.Column(
|
invalid_email = db.Column(
|
||||||
@ -1443,12 +1451,10 @@ class Contact(db.Model, ModelMixin):
|
|||||||
# if no name, try to parse it from website_from
|
# if no name, try to parse it from website_from
|
||||||
if not name and self.website_from:
|
if not name and self.website_from:
|
||||||
try:
|
try:
|
||||||
from app.email_utils import parseaddr_unicode
|
name = address.parse(self.website_from).display_name
|
||||||
|
|
||||||
name, _ = parseaddr_unicode(self.website_from)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Skip if website_from is wrongly formatted
|
# Skip if website_from is wrongly formatted
|
||||||
LOG.warning(
|
LOG.e(
|
||||||
"Cannot parse contact %s website_from %s", self, self.website_from
|
"Cannot parse contact %s website_from %s", self, self.website_from
|
||||||
)
|
)
|
||||||
name = ""
|
name = ""
|
||||||
@ -1477,19 +1483,12 @@ class Contact(db.Model, ModelMixin):
|
|||||||
`new_email` is a special reply address
|
`new_email` is a special reply address
|
||||||
"""
|
"""
|
||||||
user = self.user
|
user = self.user
|
||||||
if (
|
sender_format = user.sender_format if user else SenderFormatEnum.AT.value
|
||||||
not user
|
|
||||||
or not SenderFormatEnum.has_value(user.sender_format)
|
if sender_format == SenderFormatEnum.AT.value:
|
||||||
or user.sender_format == SenderFormatEnum.VIA.value
|
|
||||||
):
|
|
||||||
new_name = f"{self.website_email} via SimpleLogin"
|
|
||||||
else:
|
|
||||||
if user.sender_format == SenderFormatEnum.AT.value:
|
|
||||||
formatted_email = self.website_email.replace("@", " at ").strip()
|
formatted_email = self.website_email.replace("@", " at ").strip()
|
||||||
elif user.sender_format == SenderFormatEnum.A.value:
|
else:
|
||||||
formatted_email = self.website_email.replace("@", "(a)").strip()
|
formatted_email = self.website_email.replace("@", "(a)").strip()
|
||||||
elif user.sender_format == SenderFormatEnum.FULL.value:
|
|
||||||
formatted_email = self.website_email.strip()
|
|
||||||
|
|
||||||
# Prefix name to formatted email if available
|
# Prefix name to formatted email if available
|
||||||
new_name = (
|
new_name = (
|
||||||
@ -1757,25 +1756,19 @@ class ApiKey(db.Model, ModelMixin):
|
|||||||
|
|
||||||
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||||
code = db.Column(db.String(128), unique=True, nullable=False)
|
code = db.Column(db.String(128), unique=True, nullable=False)
|
||||||
name = db.Column(db.String(128), nullable=False)
|
name = db.Column(db.String(128), nullable=True)
|
||||||
last_used = db.Column(ArrowType, default=None)
|
last_used = db.Column(ArrowType, default=None)
|
||||||
times = db.Column(db.Integer, default=0, nullable=False)
|
times = db.Column(db.Integer, default=0, nullable=False)
|
||||||
|
|
||||||
user = db.relationship(User)
|
user = db.relationship(User)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, user_id, name):
|
def create(cls, user_id, name=None, **kwargs):
|
||||||
# generate unique code
|
|
||||||
found = False
|
|
||||||
while not found:
|
|
||||||
code = random_string(60)
|
code = random_string(60)
|
||||||
|
if cls.get_by(code=code):
|
||||||
|
code = str(uuid.uuid4())
|
||||||
|
|
||||||
if not cls.get_by(code=code):
|
return super().create(user_id=user_id, name=name, code=code, **kwargs)
|
||||||
found = True
|
|
||||||
|
|
||||||
a = cls(user_id=user_id, code=code, name=name)
|
|
||||||
db.session.add(a)
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
class CustomDomain(db.Model, ModelMixin):
|
class CustomDomain(db.Model, ModelMixin):
|
||||||
@ -1785,6 +1778,7 @@ class CustomDomain(db.Model, ModelMixin):
|
|||||||
# default name to use when user replies/sends from alias
|
# default name to use when user replies/sends from alias
|
||||||
name = db.Column(db.String(128), nullable=True, default=None)
|
name = db.Column(db.String(128), nullable=True, default=None)
|
||||||
|
|
||||||
|
# mx verified
|
||||||
verified = db.Column(db.Boolean, nullable=False, default=False)
|
verified = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
dkim_verified = db.Column(
|
dkim_verified = db.Column(
|
||||||
db.Boolean, nullable=False, default=False, server_default="0"
|
db.Boolean, nullable=False, default=False, server_default="0"
|
||||||
@ -1813,6 +1807,26 @@ class CustomDomain(db.Model, ModelMixin):
|
|||||||
db.Integer, default=0, server_default="0", nullable=False
|
db.Integer, default=0, server_default="0", nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# only domain has the ownership verified can go the next DNS step
|
||||||
|
# MX verified domains before this change don't have to do the TXT check
|
||||||
|
# and therefore have ownership_verified=True
|
||||||
|
ownership_verified = db.Column(
|
||||||
|
db.Boolean, nullable=False, default=False, server_default="0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# randomly generated TXT value for verifying domain ownership
|
||||||
|
# the TXT record should be sl-verification=txt_token
|
||||||
|
ownership_txt_token = db.Column(db.String(128), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_unique_domain", # Index name
|
||||||
|
"domain", # Columns which are part of the index
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=Column("ownership_verified"),
|
||||||
|
), # The condition
|
||||||
|
)
|
||||||
|
|
||||||
user = db.relationship(User, foreign_keys=[user_id])
|
user = db.relationship(User, foreign_keys=[user_id])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -1828,10 +1842,71 @@ class CustomDomain(db.Model, ModelMixin):
|
|||||||
def get_trash_url(self):
|
def get_trash_url(self):
|
||||||
return URL + f"/dashboard/domains/{self.id}/trash"
|
return URL + f"/dashboard/domains/{self.id}/trash"
|
||||||
|
|
||||||
|
def get_ownership_dns_txt_value(self):
|
||||||
|
return f"sl-verification={self.ownership_txt_token}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, **kw):
|
||||||
|
domain: CustomDomain = super(CustomDomain, cls).create(**kw)
|
||||||
|
|
||||||
|
# generate a domain ownership txt token
|
||||||
|
if not domain.ownership_txt_token:
|
||||||
|
domain.ownership_txt_token = random_string(30)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return domain
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auto_create_rules(self):
|
||||||
|
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.domain}>"
|
||||||
|
|
||||||
|
|
||||||
|
class AutoCreateRule(db.Model, ModelMixin):
|
||||||
|
"""Alias auto creation rule for custom domain"""
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint(
|
||||||
|
"custom_domain_id", "order", name="uq_auto_create_rule_order"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
custom_domain_id = db.Column(
|
||||||
|
db.ForeignKey(CustomDomain.id, ondelete="cascade"), nullable=False
|
||||||
|
)
|
||||||
|
# an alias is auto created if it matches the regex
|
||||||
|
regex = db.Column(db.String(512), nullable=False)
|
||||||
|
|
||||||
|
# the order in which rules are evaluated in case there are multiple rules
|
||||||
|
order = db.Column(db.Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
custom_domain = db.relationship(CustomDomain, backref="_auto_create_rules")
|
||||||
|
|
||||||
|
mailboxes = db.relationship(
|
||||||
|
"Mailbox", secondary="auto_create_rule__mailbox", lazy="joined"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoCreateRuleMailbox(db.Model, ModelMixin):
|
||||||
|
"""store auto create rule - mailbox association"""
|
||||||
|
|
||||||
|
__tablename__ = "auto_create_rule__mailbox"
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint(
|
||||||
|
"auto_create_rule_id", "mailbox_id", name="uq_auto_create_rule_mailbox"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
auto_create_rule_id = db.Column(
|
||||||
|
db.ForeignKey(AutoCreateRule.id, ondelete="cascade"), nullable=False
|
||||||
|
)
|
||||||
|
mailbox_id = db.Column(
|
||||||
|
db.ForeignKey("mailbox.id", ondelete="cascade"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DomainDeletedAlias(db.Model, ModelMixin):
|
class DomainDeletedAlias(db.Model, ModelMixin):
|
||||||
"""Store all deleted alias for a domain"""
|
"""Store all deleted alias for a domain"""
|
||||||
|
|
||||||
@ -1876,6 +1951,10 @@ class Coupon(db.Model, ModelMixin):
|
|||||||
db.ForeignKey(User.id, ondelete="cascade"), nullable=True
|
db.ForeignKey(User.id, ondelete="cascade"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_giveaway = db.Column(
|
||||||
|
db.Boolean, default=False, nullable=False, server_default="0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Directory(db.Model, ModelMixin):
|
class Directory(db.Model, ModelMixin):
|
||||||
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||||
@ -1883,7 +1962,7 @@ class Directory(db.Model, ModelMixin):
|
|||||||
# when a directory is disabled, new alias can't be created on the fly
|
# when a directory is disabled, new alias can't be created on the fly
|
||||||
disabled = db.Column(db.Boolean, default=False, nullable=False, server_default="0")
|
disabled = db.Column(db.Boolean, default=False, nullable=False, server_default="0")
|
||||||
|
|
||||||
user = db.relationship(User)
|
user = db.relationship(User, backref="directories")
|
||||||
|
|
||||||
_mailboxes = db.relationship(
|
_mailboxes = db.relationship(
|
||||||
"Mailbox", secondary="directory_mailbox", lazy="joined"
|
"Mailbox", secondary="directory_mailbox", lazy="joined"
|
||||||
@ -2330,7 +2409,7 @@ class TransactionalEmail(db.Model, ModelMixin):
|
|||||||
Deleted after 7 days
|
Deleted after 7 days
|
||||||
"""
|
"""
|
||||||
|
|
||||||
email = db.Column(db.String(256), nullable=False, unique=True)
|
email = db.Column(db.String(256), nullable=False, unique=False)
|
||||||
|
|
||||||
|
|
||||||
class Payout(db.Model, ModelMixin):
|
class Payout(db.Model, ModelMixin):
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
{% block single_content %}
|
{% block single_content %}
|
||||||
|
|
||||||
<form class="card" method="post" style="max-width: 40rem; margin: auto; border-radius: 2%">
|
<form class="card" method="post" data-parsley-validate style="max-width: 40rem; margin: auto; border-radius: 2%">
|
||||||
{% if not client.approved %}
|
{% if not client.approved %}
|
||||||
<div class="alert alert-warning" style="border-bottom: 3px solid;">
|
<div class="alert alert-warning" style="border-bottom: 3px solid;">
|
||||||
<b>{{ client.name }}</b> is in Dev Mode and isn't approved (yet) by SimpleLogin.
|
<b>{{ client.name }}</b> is in Dev Mode and isn't approved (yet) by SimpleLogin.
|
||||||
@ -101,10 +101,11 @@
|
|||||||
<div class="col-sm-6 pr-1 mb-1" style="min-width: 5em">
|
<div class="col-sm-6 pr-1 mb-1" style="min-width: 5em">
|
||||||
<input name="prefix" class="form-control"
|
<input name="prefix" class="form-control"
|
||||||
type="text"
|
type="text"
|
||||||
pattern="[0-9a-z-_]{1,}"
|
|
||||||
maxlength="40"
|
maxlength="40"
|
||||||
data-bouncer-message="Only lowercase letters, dots, numbers, dashes (-) and underscores (_) are currently supported."
|
data-parsley-pattern="[0-9a-z-_.]{1,}"
|
||||||
placeholder="email alias"
|
data-parsley-trigger="change"
|
||||||
|
data-parsley-error-message="Only lowercase letters, dots, numbers, dashes (-) and underscores (_) are currently supported."
|
||||||
|
placeholder="Alias prefix, for example newsletter.com-123_xyz"
|
||||||
autofocus>
|
autofocus>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -124,10 +125,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<small class="text-muted">
|
|
||||||
Alias can use letter, number, dash and cannot be empty
|
|
||||||
</small>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -202,10 +199,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block script %}
|
|
||||||
<script>
|
|
||||||
// init bouncer
|
|
||||||
new Bouncer('form');
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
@ -102,7 +102,7 @@ def authorize():
|
|||||||
)
|
)
|
||||||
user_info = {}
|
user_info = {}
|
||||||
if client_user:
|
if client_user:
|
||||||
LOG.debug("user %s has already allowed client %s", current_user, client)
|
LOG.d("user %s has already allowed client %s", current_user, client)
|
||||||
user_info = client_user.get_user_info()
|
user_info = client_user.get_user_info()
|
||||||
else:
|
else:
|
||||||
suggested_email, other_emails = current_user.suggested_emails(
|
suggested_email, other_emails = current_user.suggested_emails(
|
||||||
@ -131,11 +131,11 @@ def authorize():
|
|||||||
)
|
)
|
||||||
else: # POST - user allows or denies
|
else: # POST - user allows or denies
|
||||||
if request.form.get("button") == "deny":
|
if request.form.get("button") == "deny":
|
||||||
LOG.debug("User %s denies Client %s", current_user, client)
|
LOG.d("User %s denies Client %s", current_user, client)
|
||||||
final_redirect_uri = f"{redirect_uri}?error=deny&state={state}"
|
final_redirect_uri = f"{redirect_uri}?error=deny&state={state}"
|
||||||
return redirect(final_redirect_uri)
|
return redirect(final_redirect_uri)
|
||||||
|
|
||||||
LOG.debug("User %s allows Client %s", current_user, client)
|
LOG.d("User %s allows Client %s", current_user, client)
|
||||||
client_user = ClientUser.get_by(client_id=client.id, user_id=current_user.id)
|
client_user = ClientUser.get_by(client_id=client.id, user_id=current_user.id)
|
||||||
|
|
||||||
# user has already allowed this client, user cannot change information
|
# user has already allowed this client, user cannot change information
|
||||||
@ -167,11 +167,11 @@ def authorize():
|
|||||||
try:
|
try:
|
||||||
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
||||||
except SignatureExpired:
|
except SignatureExpired:
|
||||||
LOG.warning("Alias creation time expired for %s", current_user)
|
LOG.w("Alias creation time expired for %s", current_user)
|
||||||
flash("Alias creation time is expired, please retry", "warning")
|
flash("Alias creation time is expired, please retry", "warning")
|
||||||
return redirect(request.url)
|
return redirect(request.url)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.warning("Alias suffix is tampered, user %s", current_user)
|
LOG.w("Alias suffix is tampered, user %s", current_user)
|
||||||
flash("Unknown error, refresh the page", "error")
|
flash("Unknown error, refresh the page", "error")
|
||||||
return redirect(request.url)
|
return redirect(request.url)
|
||||||
|
|
||||||
@ -189,7 +189,7 @@ def authorize():
|
|||||||
or DeletedAlias.get_by(email=full_alias)
|
or DeletedAlias.get_by(email=full_alias)
|
||||||
or DomainDeletedAlias.get_by(email=full_alias)
|
or DomainDeletedAlias.get_by(email=full_alias)
|
||||||
):
|
):
|
||||||
LOG.exception("alias %s already used, very rare!", full_alias)
|
LOG.e("alias %s already used, very rare!", full_alias)
|
||||||
flash(f"Alias {full_alias} already used", "error")
|
flash(f"Alias {full_alias} already used", "error")
|
||||||
return redirect(request.url)
|
return redirect(request.url)
|
||||||
else:
|
else:
|
||||||
@ -255,9 +255,7 @@ def authorize():
|
|||||||
if state:
|
if state:
|
||||||
redirect_args["state"] = state
|
redirect_args["state"] = state
|
||||||
else:
|
else:
|
||||||
LOG.warning(
|
LOG.w("more security reason, state should be added. client %s", client)
|
||||||
"more security reason, state should be added. client %s", client
|
|
||||||
)
|
|
||||||
|
|
||||||
if scope:
|
if scope:
|
||||||
redirect_args["scope"] = scope
|
redirect_args["scope"] = scope
|
||||||
@ -339,7 +337,7 @@ def generate_access_token() -> str:
|
|||||||
return access_token
|
return access_token
|
||||||
|
|
||||||
# Rerun the function
|
# Rerun the function
|
||||||
LOG.warning("access token already exists, generate a new one")
|
LOG.w("access token already exists, generate a new one")
|
||||||
return generate_access_token()
|
return generate_access_token()
|
||||||
|
|
||||||
|
|
||||||
|
@ -56,9 +56,7 @@ def token():
|
|||||||
if auth_code.client_id != client.id:
|
if auth_code.client_id != client.id:
|
||||||
return jsonify(error="are you sure this code belongs to you?"), 400
|
return jsonify(error="are you sure this code belongs to you?"), 400
|
||||||
|
|
||||||
LOG.debug(
|
LOG.d("Create Oauth token for user %s, client %s", auth_code.user, auth_code.client)
|
||||||
"Create Oauth token for user %s, client %s", auth_code.user, auth_code.client
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create token
|
# Create token
|
||||||
oauth_token = OauthToken.create(
|
oauth_token = OauthToken.create(
|
||||||
|
@ -72,9 +72,7 @@ def cancel_subscription(subscription_id: str) -> bool:
|
|||||||
)
|
)
|
||||||
res = r.json()
|
res = r.json()
|
||||||
if not res["success"]:
|
if not res["success"]:
|
||||||
LOG.exception(
|
LOG.e(f"cannot cancel subscription {subscription_id}, paddle response: {res}")
|
||||||
f"cannot cancel subscription {subscription_id}, paddle response: {res}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return res["success"]
|
return res["success"]
|
||||||
|
|
||||||
@ -102,7 +100,7 @@ def change_plan(user: User, subscription_id: str, plan_id) -> (bool, str):
|
|||||||
)
|
)
|
||||||
return False, "Your card cannot be charged"
|
return False, "Your card cannot be charged"
|
||||||
except KeyError:
|
except KeyError:
|
||||||
LOG.exception(
|
LOG.e(
|
||||||
f"cannot change subscription {subscription_id} to {plan_id}, paddle response: {res}"
|
f"cannot change subscription {subscription_id} to {plan_id}, paddle response: {res}"
|
||||||
)
|
)
|
||||||
return False, ""
|
return False, ""
|
||||||
|
@ -43,7 +43,7 @@ def load_public_key_and_check(public_key: str) -> str:
|
|||||||
try:
|
try:
|
||||||
encrypt_file(dummy_data, fingerprint)
|
encrypt_file(dummy_data, fingerprint)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"Cannot encrypt using the imported key %s %s", fingerprint, public_key
|
"Cannot encrypt using the imported key %s %s", fingerprint, public_key
|
||||||
)
|
)
|
||||||
# remove the fingerprint
|
# remove the fingerprint
|
||||||
@ -55,7 +55,7 @@ def load_public_key_and_check(public_key: str) -> str:
|
|||||||
|
|
||||||
def hard_exit():
|
def hard_exit():
|
||||||
pid = os.getpid()
|
pid = os.getpid()
|
||||||
LOG.warning("kill pid %s", pid)
|
LOG.w("kill pid %s", pid)
|
||||||
os.kill(pid, 9)
|
os.kill(pid, 9)
|
||||||
|
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
https://github.com/petermat/spamassassin_client
|
https://github.com/petermat/spamassassin_client
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re2 as re
|
||||||
import select
|
import select
|
||||||
import socket
|
import socket
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@ -115,7 +115,7 @@ class SpamAssassin(object):
|
|||||||
"description": " ".join(wordlist[1:]),
|
"description": " ".join(wordlist[1:]),
|
||||||
}
|
}
|
||||||
except ValueError:
|
except ValueError:
|
||||||
LOG.warning("Cannot parse %s %s", wordlist[0], wordlist)
|
LOG.w("Cannot parse %s %s", wordlist[0], wordlist)
|
||||||
|
|
||||||
headers = (
|
headers = (
|
||||||
headers.decode("utf-8")
|
headers.decode("utf-8")
|
||||||
|
14
app/utils.py
14
app/utils.py
@ -1,6 +1,8 @@
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
from unidecode import unidecode
|
from unidecode import unidecode
|
||||||
|
|
||||||
@ -73,3 +75,15 @@ def sanitize_email(email_address: str) -> str:
|
|||||||
def query2str(query):
|
def query2str(query):
|
||||||
"""Useful utility method to print out a SQLAlchemy query"""
|
"""Useful utility method to print out a SQLAlchemy query"""
|
||||||
return query.statement.compile(compile_kwargs={"literal_binds": True})
|
return query.statement.compile(compile_kwargs={"literal_binds": True})
|
||||||
|
|
||||||
|
|
||||||
|
def debug_info(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrap(*args, **kwargs):
|
||||||
|
start = time.time()
|
||||||
|
LOG.d("start %s %s %s", func.__name__, args, kwargs)
|
||||||
|
ret = func(*args, **kwargs)
|
||||||
|
LOG.d("finish %s. Takes %s seconds", func.__name__, time.time() - start)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
return wrap
|
||||||
|
42
cron.py
42
cron.py
@ -123,6 +123,10 @@ def notify_premium_end():
|
|||||||
>= arrow.now().shift(days=2).date()
|
>= arrow.now().shift(days=2).date()
|
||||||
):
|
):
|
||||||
user = sub.user
|
user = sub.user
|
||||||
|
|
||||||
|
if user.lifetime:
|
||||||
|
continue
|
||||||
|
|
||||||
LOG.d(f"Send subscription ending soon email to user {user}")
|
LOG.d(f"Send subscription ending soon email to user {user}")
|
||||||
|
|
||||||
send_email(
|
send_email(
|
||||||
@ -151,7 +155,7 @@ def notify_manual_sub_end():
|
|||||||
|
|
||||||
if need_reminder:
|
if need_reminder:
|
||||||
user = manual_sub.user
|
user = manual_sub.user
|
||||||
LOG.debug("Remind user %s that their manual sub is ending soon", user)
|
LOG.d("Remind user %s that their manual sub is ending soon", user)
|
||||||
send_email(
|
send_email(
|
||||||
user.email,
|
user.email,
|
||||||
f"Your subscription will end soon",
|
f"Your subscription will end soon",
|
||||||
@ -185,7 +189,7 @@ def notify_manual_sub_end():
|
|||||||
|
|
||||||
if need_reminder:
|
if need_reminder:
|
||||||
user = coinbase_subscription.user
|
user = coinbase_subscription.user
|
||||||
LOG.debug(
|
LOG.d(
|
||||||
"Remind user %s that their coinbase subscription is ending soon", user
|
"Remind user %s that their coinbase subscription is ending soon", user
|
||||||
)
|
)
|
||||||
send_email(
|
send_email(
|
||||||
@ -443,9 +447,7 @@ def migrate_domain_trash():
|
|||||||
if not SLDomain.get_by(domain=alias_domain):
|
if not SLDomain.get_by(domain=alias_domain):
|
||||||
custom_domain = CustomDomain.get_by(domain=alias_domain)
|
custom_domain = CustomDomain.get_by(domain=alias_domain)
|
||||||
if custom_domain:
|
if custom_domain:
|
||||||
LOG.exception(
|
LOG.e("move %s to domain %s trash", deleted_alias, custom_domain)
|
||||||
"move %s to domain %s trash", deleted_alias, custom_domain
|
|
||||||
)
|
|
||||||
db.session.add(
|
db.session.add(
|
||||||
DomainDeletedAlias(
|
DomainDeletedAlias(
|
||||||
user_id=custom_domain.user_id,
|
user_id=custom_domain.user_id,
|
||||||
@ -470,7 +472,7 @@ def set_custom_domain_for_alias():
|
|||||||
alias_domain = get_email_domain_part(alias.email)
|
alias_domain = get_email_domain_part(alias.email)
|
||||||
custom_domain = CustomDomain.get_by(domain=alias_domain)
|
custom_domain = CustomDomain.get_by(domain=alias_domain)
|
||||||
if custom_domain:
|
if custom_domain:
|
||||||
LOG.exception("set %s for %s", custom_domain, alias)
|
LOG.e("set %s for %s", custom_domain, alias)
|
||||||
alias.custom_domain_id = custom_domain.id
|
alias.custom_domain_id = custom_domain.id
|
||||||
else: # phantom domain
|
else: # phantom domain
|
||||||
LOG.d("phantom domain %s %s %s", alias.user, alias, alias.enabled)
|
LOG.d("phantom domain %s %s %s", alias.user, alias, alias.enabled)
|
||||||
@ -533,7 +535,7 @@ def sanity_check():
|
|||||||
render("transactional/disable-mailbox.html", mailbox=mailbox),
|
render("transactional/disable-mailbox.html", mailbox=mailbox),
|
||||||
)
|
)
|
||||||
|
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"issue with mailbox %s domain. #alias %s, nb email log %s",
|
"issue with mailbox %s domain. #alias %s, nb email log %s",
|
||||||
mailbox,
|
mailbox,
|
||||||
mailbox.nb_alias(),
|
mailbox.nb_alias(),
|
||||||
@ -546,40 +548,40 @@ def sanity_check():
|
|||||||
|
|
||||||
for user in User.filter_by(activated=True).all():
|
for user in User.filter_by(activated=True).all():
|
||||||
if sanitize_email(user.email) != user.email:
|
if sanitize_email(user.email) != user.email:
|
||||||
LOG.exception("%s does not have sanitized email", user)
|
LOG.e("%s does not have sanitized email", user)
|
||||||
|
|
||||||
for alias in Alias.query.all():
|
for alias in Alias.query.all():
|
||||||
if sanitize_email(alias.email) != alias.email:
|
if sanitize_email(alias.email) != alias.email:
|
||||||
LOG.exception("Alias %s email not sanitized", alias)
|
LOG.e("Alias %s email not sanitized", alias)
|
||||||
|
|
||||||
if alias.name and "\n" in alias.name:
|
if alias.name and "\n" in alias.name:
|
||||||
alias.name = alias.name.replace("\n", "")
|
alias.name = alias.name.replace("\n", "")
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
LOG.exception("Alias %s name contains linebreak %s", alias, alias.name)
|
LOG.e("Alias %s name contains linebreak %s", alias, alias.name)
|
||||||
|
|
||||||
contact_email_sanity_date = arrow.get("2021-01-12")
|
contact_email_sanity_date = arrow.get("2021-01-12")
|
||||||
for contact in Contact.query.all():
|
for contact in Contact.query.all():
|
||||||
if sanitize_email(contact.reply_email) != contact.reply_email:
|
if sanitize_email(contact.reply_email) != contact.reply_email:
|
||||||
LOG.exception("Contact %s reply-email not sanitized", contact)
|
LOG.e("Contact %s reply-email not sanitized", contact)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
sanitize_email(contact.website_email) != contact.website_email
|
sanitize_email(contact.website_email) != contact.website_email
|
||||||
and contact.created_at > contact_email_sanity_date
|
and contact.created_at > contact_email_sanity_date
|
||||||
):
|
):
|
||||||
LOG.exception("Contact %s website-email not sanitized", contact)
|
LOG.e("Contact %s website-email not sanitized", contact)
|
||||||
|
|
||||||
if not contact.invalid_email and not is_valid_email(contact.website_email):
|
if not contact.invalid_email and not is_valid_email(contact.website_email):
|
||||||
LOG.exception("%s invalid email", contact)
|
LOG.e("%s invalid email", contact)
|
||||||
contact.invalid_email = True
|
contact.invalid_email = True
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
for mailbox in Mailbox.query.all():
|
for mailbox in Mailbox.query.all():
|
||||||
if sanitize_email(mailbox.email) != mailbox.email:
|
if sanitize_email(mailbox.email) != mailbox.email:
|
||||||
LOG.exception("Mailbox %s address not sanitized", mailbox)
|
LOG.e("Mailbox %s address not sanitized", mailbox)
|
||||||
|
|
||||||
for contact in Contact.query.all():
|
for contact in Contact.query.all():
|
||||||
if normalize_reply_email(contact.reply_email) != contact.reply_email:
|
if normalize_reply_email(contact.reply_email) != contact.reply_email:
|
||||||
LOG.exception(
|
LOG.e(
|
||||||
"Contact %s reply email is not normalized %s",
|
"Contact %s reply email is not normalized %s",
|
||||||
contact,
|
contact,
|
||||||
contact.reply_email,
|
contact.reply_email,
|
||||||
@ -587,7 +589,7 @@ def sanity_check():
|
|||||||
|
|
||||||
for domain in CustomDomain.query.all():
|
for domain in CustomDomain.query.all():
|
||||||
if domain.name and "\n" in domain.name:
|
if domain.name and "\n" in domain.name:
|
||||||
LOG.exception("Domain %s name contain linebreak %s", domain, domain.name)
|
LOG.e("Domain %s name contain linebreak %s", domain, domain.name)
|
||||||
|
|
||||||
migrate_domain_trash()
|
migrate_domain_trash()
|
||||||
set_custom_domain_for_alias()
|
set_custom_domain_for_alias()
|
||||||
@ -605,7 +607,7 @@ def check_custom_domain():
|
|||||||
|
|
||||||
if sorted(mx_domains) != sorted(EMAIL_SERVERS_WITH_PRIORITY):
|
if sorted(mx_domains) != sorted(EMAIL_SERVERS_WITH_PRIORITY):
|
||||||
user = custom_domain.user
|
user = custom_domain.user
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"The MX record is not correctly set for %s %s %s",
|
"The MX record is not correctly set for %s %s %s",
|
||||||
custom_domain,
|
custom_domain,
|
||||||
user,
|
user,
|
||||||
@ -617,9 +619,7 @@ def check_custom_domain():
|
|||||||
# send alert if fail for 5 consecutive days
|
# send alert if fail for 5 consecutive days
|
||||||
if custom_domain.nb_failed_checks > 5:
|
if custom_domain.nb_failed_checks > 5:
|
||||||
domain_dns_url = f"{URL}/dashboard/domains/{custom_domain.id}/dns"
|
domain_dns_url = f"{URL}/dashboard/domains/{custom_domain.id}/dns"
|
||||||
LOG.warning(
|
LOG.w("Alert domain MX check fails %s about %s", user, custom_domain)
|
||||||
"Alert domain MX check fails %s about %s", user, custom_domain
|
|
||||||
)
|
|
||||||
send_email_with_rate_control(
|
send_email_with_rate_control(
|
||||||
user,
|
user,
|
||||||
AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN,
|
AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN,
|
||||||
@ -728,7 +728,7 @@ async def check_hibp():
|
|||||||
LOG.d("Checking HIBP API for aliases in breaches")
|
LOG.d("Checking HIBP API for aliases in breaches")
|
||||||
|
|
||||||
if len(HIBP_API_KEYS) == 0:
|
if len(HIBP_API_KEYS) == 0:
|
||||||
LOG.exception("No HIBP API keys")
|
LOG.e("No HIBP API keys")
|
||||||
return
|
return
|
||||||
|
|
||||||
LOG.d("Updating list of known breaches")
|
LOG.d("Updating list of known breaches")
|
||||||
|
306
email_handler.py
306
email_handler.py
@ -32,6 +32,7 @@ It should contain the following info:
|
|||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import email
|
import email
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from email import encoders
|
from email import encoders
|
||||||
@ -47,6 +48,9 @@ from typing import List, Tuple, Optional
|
|||||||
import newrelic.agent
|
import newrelic.agent
|
||||||
from aiosmtpd.controller import Controller
|
from aiosmtpd.controller import Controller
|
||||||
from aiosmtpd.smtp import Envelope
|
from aiosmtpd.smtp import Envelope
|
||||||
|
from email_validator import validate_email, EmailNotValidError
|
||||||
|
from flanker.addresslib import address
|
||||||
|
from flanker.addresslib.address import EmailAddress
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from app import pgp_utils, s3
|
from app import pgp_utils, s3
|
||||||
@ -76,6 +80,10 @@ from app.config import (
|
|||||||
ENABLE_SPAM_ASSASSIN,
|
ENABLE_SPAM_ASSASSIN,
|
||||||
BOUNCE_PREFIX_FOR_REPLY_PHASE,
|
BOUNCE_PREFIX_FOR_REPLY_PHASE,
|
||||||
NEWRELIC_CONFIG_PATH,
|
NEWRELIC_CONFIG_PATH,
|
||||||
|
POSTMASTER,
|
||||||
|
ALERT_HOTMAIL_COMPLAINT,
|
||||||
|
ALERT_YAHOO_COMPLAINT,
|
||||||
|
TEMP_DIR,
|
||||||
)
|
)
|
||||||
from app.email import status
|
from app.email import status
|
||||||
from app.email.rate_limit import rate_limited
|
from app.email.rate_limit import rate_limited
|
||||||
@ -90,7 +98,6 @@ from app.email_utils import (
|
|||||||
delete_all_headers_except,
|
delete_all_headers_except,
|
||||||
get_spam_info,
|
get_spam_info,
|
||||||
get_orig_message_from_spamassassin_report,
|
get_orig_message_from_spamassassin_report,
|
||||||
parseaddr_unicode,
|
|
||||||
send_email_with_rate_control,
|
send_email_with_rate_control,
|
||||||
get_email_domain_part,
|
get_email_domain_part,
|
||||||
copy,
|
copy,
|
||||||
@ -112,6 +119,9 @@ from app.email_utils import (
|
|||||||
sanitize_header,
|
sanitize_header,
|
||||||
get_queue_id,
|
get_queue_id,
|
||||||
should_ignore_bounce,
|
should_ignore_bounce,
|
||||||
|
get_orig_message_from_hotmail_complaint,
|
||||||
|
parse_full_address,
|
||||||
|
get_orig_message_from_yahoo_complaint,
|
||||||
)
|
)
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.log import LOG, set_message_id
|
from app.log import LOG, set_message_id
|
||||||
@ -174,16 +184,20 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
|||||||
"""
|
"""
|
||||||
contact_from_header is the RFC 2047 format FROM header
|
contact_from_header is the RFC 2047 format FROM header
|
||||||
"""
|
"""
|
||||||
contact_name, contact_email = parseaddr_unicode(from_header)
|
try:
|
||||||
|
contact_name, contact_email = parse_full_address(from_header)
|
||||||
|
except ValueError:
|
||||||
|
contact_name, contact_email = "", ""
|
||||||
|
|
||||||
if not is_valid_email(contact_email):
|
if not is_valid_email(contact_email):
|
||||||
# From header is wrongly formatted, try with mail_from
|
# From header is wrongly formatted, try with mail_from
|
||||||
if mail_from and mail_from != "<>":
|
if mail_from and mail_from != "<>":
|
||||||
LOG.w(
|
LOG.w(
|
||||||
"Cannot parse email from from_header %s, parse from mail_from %s",
|
"Cannot parse email from from_header %s, use mail_from %s",
|
||||||
from_header,
|
from_header,
|
||||||
mail_from,
|
mail_from,
|
||||||
)
|
)
|
||||||
_, contact_email = parseaddr_unicode(mail_from)
|
contact_email = mail_from
|
||||||
|
|
||||||
if not is_valid_email(contact_email):
|
if not is_valid_email(contact_email):
|
||||||
LOG.w(
|
LOG.w(
|
||||||
@ -197,6 +211,10 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
|||||||
|
|
||||||
contact_email = sanitize_email(contact_email)
|
contact_email = sanitize_email(contact_email)
|
||||||
|
|
||||||
|
if contact_name and "\x00" in contact_name:
|
||||||
|
LOG.w("issue with contact name %s", contact_name)
|
||||||
|
contact_name = ""
|
||||||
|
|
||||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
||||||
if contact:
|
if contact:
|
||||||
if contact.name != contact_name:
|
if contact.name != contact_name:
|
||||||
@ -219,16 +237,6 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
|||||||
)
|
)
|
||||||
contact.mail_from = mail_from
|
contact.mail_from = mail_from
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
if not contact.from_header and from_header:
|
|
||||||
LOG.d(
|
|
||||||
"Set contact from_header %s: %s to %s",
|
|
||||||
contact,
|
|
||||||
contact.from_header,
|
|
||||||
from_header,
|
|
||||||
)
|
|
||||||
contact.from_header = from_header
|
|
||||||
db.session.commit()
|
|
||||||
else:
|
else:
|
||||||
LOG.d(
|
LOG.d(
|
||||||
"create contact %s for alias %s",
|
"create contact %s for alias %s",
|
||||||
@ -243,7 +251,6 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
|||||||
website_email=contact_email,
|
website_email=contact_email,
|
||||||
name=contact_name,
|
name=contact_name,
|
||||||
mail_from=mail_from,
|
mail_from=mail_from,
|
||||||
from_header=from_header,
|
|
||||||
reply_email=generate_reply_email(contact_email, alias.user)
|
reply_email=generate_reply_email(contact_email, alias.user)
|
||||||
if is_valid_email(contact_email)
|
if is_valid_email(contact_email)
|
||||||
else NOREPLY,
|
else NOREPLY,
|
||||||
@ -267,25 +274,26 @@ def get_or_create_reply_to_contact(
|
|||||||
"""
|
"""
|
||||||
Get or create the contact for the Reply-To header
|
Get or create the contact for the Reply-To header
|
||||||
"""
|
"""
|
||||||
name, address = parseaddr_unicode(reply_to_header)
|
try:
|
||||||
|
contact_name, contact_address = parse_full_address(reply_to_header)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
if not is_valid_email(address):
|
if not is_valid_email(contact_address):
|
||||||
LOG.w(
|
LOG.w(
|
||||||
"invalid reply-to address %s. Parse from %s",
|
"invalid reply-to address %s. Parse from %s",
|
||||||
address,
|
contact_address,
|
||||||
reply_to_header,
|
reply_to_header,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
address = sanitize_email(address)
|
contact = Contact.get_by(alias_id=alias.id, website_email=contact_address)
|
||||||
|
|
||||||
contact = Contact.get_by(alias_id=alias.id, website_email=address)
|
|
||||||
if contact:
|
if contact:
|
||||||
return contact
|
return contact
|
||||||
else:
|
else:
|
||||||
LOG.d(
|
LOG.d(
|
||||||
"create contact %s for alias %s via reply-to header",
|
"create contact %s for alias %s via reply-to header",
|
||||||
address,
|
contact_address,
|
||||||
alias,
|
alias,
|
||||||
reply_to_header,
|
reply_to_header,
|
||||||
)
|
)
|
||||||
@ -294,15 +302,15 @@ def get_or_create_reply_to_contact(
|
|||||||
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=address,
|
website_email=contact_address,
|
||||||
name=name,
|
name=contact_name,
|
||||||
reply_email=generate_reply_email(address, alias.user),
|
reply_email=generate_reply_email(contact_address, alias.user),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
LOG.w("Contact %s %s already exist", alias, address)
|
LOG.w("Contact %s %s already exist", alias, contact_address)
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
contact = Contact.get_by(alias_id=alias.id, website_email=address)
|
contact = Contact.get_by(alias_id=alias.id, website_email=contact_address)
|
||||||
|
|
||||||
return contact
|
return contact
|
||||||
|
|
||||||
@ -314,37 +322,43 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
|||||||
new_addrs: [str] = []
|
new_addrs: [str] = []
|
||||||
headers = msg.get_all(header, [])
|
headers = msg.get_all(header, [])
|
||||||
# headers can be an array of Header, convert it to string here
|
# headers can be an array of Header, convert it to string here
|
||||||
headers = [str(h) for h in headers]
|
headers = [get_header_unicode(h) for h in headers]
|
||||||
for contact_name, contact_email in getaddresses(headers):
|
|
||||||
# convert back to original then parse again to make sure contact_name is unicode
|
|
||||||
addr = formataddr((contact_name, contact_email))
|
|
||||||
contact_name, _ = parseaddr_unicode(addr)
|
|
||||||
|
|
||||||
contact_email = sanitize_email(contact_email)
|
full_addresses: [EmailAddress] = []
|
||||||
|
for h in headers:
|
||||||
|
full_addresses += address.parse_list(h)
|
||||||
|
|
||||||
|
for full_address in full_addresses:
|
||||||
|
contact_email = sanitize_email(full_address.address)
|
||||||
|
|
||||||
# no transformation when alias is already in the header
|
# no transformation when alias is already in the header
|
||||||
if contact_email == alias.email:
|
if contact_email == alias.email:
|
||||||
new_addrs.append(addr)
|
new_addrs.append(full_address.full_spec())
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not is_valid_email(contact_email):
|
try:
|
||||||
|
# NOT allow unicode for contact address
|
||||||
|
validate_email(
|
||||||
|
contact_email, check_deliverability=False, allow_smtputf8=False
|
||||||
|
)
|
||||||
|
except EmailNotValidError:
|
||||||
LOG.w("invalid contact email %s. %s. Skip", contact_email, headers)
|
LOG.w("invalid contact email %s. %s. Skip", contact_email, headers)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
||||||
if contact:
|
if contact:
|
||||||
# update the contact name if needed
|
# update the contact name if needed
|
||||||
if contact.name != contact_name:
|
if contact.name != full_address.display_name:
|
||||||
LOG.d(
|
LOG.d(
|
||||||
"Update contact %s name %s to %s",
|
"Update contact %s name %s to %s",
|
||||||
contact,
|
contact,
|
||||||
contact.name,
|
contact.name,
|
||||||
contact_name,
|
full_address.display_name,
|
||||||
)
|
)
|
||||||
contact.name = contact_name
|
contact.name = full_address.display_name
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
else:
|
else:
|
||||||
LOG.debug(
|
LOG.d(
|
||||||
"create contact for alias %s and email %s, header %s",
|
"create contact for alias %s and email %s, header %s",
|
||||||
alias,
|
alias,
|
||||||
contact_email,
|
contact_email,
|
||||||
@ -356,10 +370,9 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
|||||||
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=full_address.display_name,
|
||||||
reply_email=generate_reply_email(contact_email, alias.user),
|
reply_email=generate_reply_email(contact_email, alias.user),
|
||||||
is_cc=header.lower() == "cc",
|
is_cc=header.lower() == "cc",
|
||||||
from_header=addr,
|
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
@ -485,7 +498,7 @@ def sign_msg(msg: Message) -> Message:
|
|||||||
try:
|
try:
|
||||||
signature.set_payload(sign_data(to_bytes(msg).replace(b"\n", b"\r\n")))
|
signature.set_payload(sign_data(to_bytes(msg).replace(b"\n", b"\r\n")))
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.exception("Cannot sign, try using pgpy")
|
LOG.e("Cannot sign, try using pgpy")
|
||||||
signature.set_payload(
|
signature.set_payload(
|
||||||
sign_data_with_pgpy(to_bytes(msg).replace(b"\n", b"\r\n"))
|
sign_data_with_pgpy(to_bytes(msg).replace(b"\n", b"\r\n"))
|
||||||
)
|
)
|
||||||
@ -533,14 +546,17 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
|
|||||||
is_success indicates whether an email has been delivered and
|
is_success indicates whether an email has been delivered and
|
||||||
smtp_status is the SMTP Status ("250 Message accepted", "550 Non-existent email address", etc)
|
smtp_status is the SMTP Status ("250 Message accepted", "550 Non-existent email address", etc)
|
||||||
"""
|
"""
|
||||||
address = rcpt_to # alias@SL
|
alias_address = rcpt_to # alias@SL
|
||||||
|
|
||||||
alias = Alias.get_by(email=address)
|
alias = Alias.get_by(email=alias_address)
|
||||||
if not alias:
|
if not alias:
|
||||||
LOG.d("alias %s not exist. Try to see if it can be created on the fly", address)
|
LOG.d(
|
||||||
alias = try_auto_create(address)
|
"alias %s not exist. Try to see if it can be created on the fly",
|
||||||
|
alias_address,
|
||||||
|
)
|
||||||
|
alias = try_auto_create(alias_address)
|
||||||
if not alias:
|
if not alias:
|
||||||
LOG.d("alias %s cannot be created on-the-fly, return 550", address)
|
LOG.d("alias %s cannot be created on-the-fly, return 550", alias_address)
|
||||||
if should_ignore_bounce(envelope.mail_from):
|
if should_ignore_bounce(envelope.mail_from):
|
||||||
return [(True, status.E207)]
|
return [(True, status.E207)]
|
||||||
else:
|
else:
|
||||||
@ -555,21 +571,22 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
|
|||||||
else:
|
else:
|
||||||
return [(False, status.E504)]
|
return [(False, status.E504)]
|
||||||
|
|
||||||
# mail_from = envelope.mail_from
|
if user.ignore_loop_email:
|
||||||
# for mb in alias.mailboxes:
|
mail_from = envelope.mail_from
|
||||||
# # email send from a mailbox to alias
|
for mb in alias.mailboxes:
|
||||||
# if mb.email == mail_from:
|
# email sent from a mailbox to its alias
|
||||||
# LOG.w("cycle email sent from %s to %s", mb, alias)
|
if mb.email == mail_from:
|
||||||
# handle_email_sent_to_ourself(alias, mb, msg, user)
|
LOG.w("cycle email sent from %s to %s", mb, alias)
|
||||||
# return [(True, "250 Message accepted for delivery")]
|
handle_email_sent_to_ourself(alias, mb, msg, user)
|
||||||
|
return [(True, status.E209)]
|
||||||
|
|
||||||
from_header = str(msg["From"])
|
from_header = get_header_unicode(msg["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)
|
||||||
|
|
||||||
reply_to_contact = None
|
reply_to_contact = None
|
||||||
if msg["Reply-To"]:
|
if msg["Reply-To"]:
|
||||||
reply_to = str(msg["Reply-To"])
|
reply_to = get_header_unicode(msg["Reply-To"])
|
||||||
LOG.d("Create or get contact for from_header:%s", reply_to)
|
LOG.d("Create or get contact for from_header:%s", reply_to)
|
||||||
# ignore when reply-to = alias
|
# ignore when reply-to = alias
|
||||||
if reply_to == alias.email:
|
if reply_to == alias.email:
|
||||||
@ -868,14 +885,14 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
|
|||||||
# Sanity check: verify alias domain is managed by SimpleLogin
|
# Sanity check: verify alias domain is managed by SimpleLogin
|
||||||
# scenario: a user have removed a domain but due to a bug, the aliases are still there
|
# scenario: a user have removed a domain but due to a bug, the aliases are still there
|
||||||
if not is_valid_alias_address_domain(alias.email):
|
if not is_valid_alias_address_domain(alias.email):
|
||||||
LOG.exception("%s domain isn't known", alias)
|
LOG.e("%s domain isn't known", alias)
|
||||||
return False, status.E503
|
return False, status.E503
|
||||||
|
|
||||||
user = alias.user
|
user = alias.user
|
||||||
mail_from = envelope.mail_from
|
mail_from = envelope.mail_from
|
||||||
|
|
||||||
if user.disabled:
|
if user.disabled:
|
||||||
LOG.exception(
|
LOG.e(
|
||||||
"User %s disabled, disable sending emails from %s to %s",
|
"User %s disabled, disable sending emails from %s to %s",
|
||||||
user,
|
user,
|
||||||
alias,
|
alias,
|
||||||
@ -1089,9 +1106,9 @@ def get_mailbox_from_mail_from(mail_from: str, alias) -> Optional[Mailbox]:
|
|||||||
if mailbox.email == mail_from:
|
if mailbox.email == mail_from:
|
||||||
return mailbox
|
return mailbox
|
||||||
|
|
||||||
for address in mailbox.authorized_addresses:
|
for addr in mailbox.authorized_addresses:
|
||||||
if address.email == mail_from:
|
if addr.email == mail_from:
|
||||||
LOG.debug(
|
LOG.d(
|
||||||
"Found an authorized address for %s %s %s", alias, mailbox, address
|
"Found an authorized address for %s %s %s", alias, mailbox, address
|
||||||
)
|
)
|
||||||
return mailbox
|
return mailbox
|
||||||
@ -1169,12 +1186,12 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
|
|||||||
|
|
||||||
# email_log.mailbox should be set during the forward phase
|
# email_log.mailbox should be set during the forward phase
|
||||||
if not mailbox:
|
if not mailbox:
|
||||||
LOG.exception("Use %s default mailbox %s", alias, alias.mailbox)
|
LOG.e("Use %s default mailbox %s", alias, alias.mailbox)
|
||||||
mailbox = alias.mailbox
|
mailbox = alias.mailbox
|
||||||
|
|
||||||
Bounce.create(email=mailbox.email, commit=True)
|
Bounce.create(email=mailbox.email, commit=True)
|
||||||
|
|
||||||
LOG.debug(
|
LOG.d(
|
||||||
"Handle forward bounce %s -> %s -> %s. %s", contact, alias, mailbox, email_log
|
"Handle forward bounce %s -> %s -> %s. %s", contact, alias, mailbox, email_log
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1249,6 +1266,8 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
|
|||||||
mailbox_email=mailbox.email,
|
mailbox_email=mailbox.email,
|
||||||
),
|
),
|
||||||
max_nb_alert=10,
|
max_nb_alert=10,
|
||||||
|
# smtp error can happen if user mailbox is unreachable, that might explain the bounce
|
||||||
|
ignore_smtp_error=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
LOG.w(
|
LOG.w(
|
||||||
@ -1276,9 +1295,90 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
|
|||||||
mailbox_email=mailbox.email,
|
mailbox_email=mailbox.email,
|
||||||
),
|
),
|
||||||
max_nb_alert=10,
|
max_nb_alert=10,
|
||||||
|
ignore_smtp_error=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_hotmail_complaint(msg: Message) -> bool:
|
||||||
|
"""
|
||||||
|
Handle hotmail complaint sent to postmaster
|
||||||
|
Return True if the complaint can be handled, False otherwise
|
||||||
|
"""
|
||||||
|
orig_msg = get_orig_message_from_hotmail_complaint(msg)
|
||||||
|
to_header = orig_msg["To"]
|
||||||
|
if not to_header:
|
||||||
|
LOG.e("cannot find the alias")
|
||||||
|
return False
|
||||||
|
|
||||||
|
_, alias_address = parse_full_address(get_header_unicode(to_header))
|
||||||
|
alias = Alias.get_by(email=alias_address)
|
||||||
|
|
||||||
|
if not alias:
|
||||||
|
LOG.w("No alias for %s", alias_address)
|
||||||
|
return False
|
||||||
|
|
||||||
|
user = alias.user
|
||||||
|
LOG.w("Handle hotmail complaint for %s %s %s", alias, user, alias.mailboxes)
|
||||||
|
|
||||||
|
send_email_with_rate_control(
|
||||||
|
user,
|
||||||
|
ALERT_HOTMAIL_COMPLAINT,
|
||||||
|
user.email,
|
||||||
|
f"Hotmail abuse report",
|
||||||
|
render(
|
||||||
|
"transactional/hotmail-complaint.txt.jinja2",
|
||||||
|
alias=alias,
|
||||||
|
),
|
||||||
|
render(
|
||||||
|
"transactional/hotmail-complaint.html",
|
||||||
|
alias=alias,
|
||||||
|
),
|
||||||
|
max_nb_alert=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def handle_yahoo_complaint(msg: Message) -> bool:
|
||||||
|
"""
|
||||||
|
Handle yahoo complaint sent to postmaster
|
||||||
|
Return True if the complaint can be handled, False otherwise
|
||||||
|
"""
|
||||||
|
orig_msg = get_orig_message_from_yahoo_complaint(msg)
|
||||||
|
to_header = orig_msg["To"]
|
||||||
|
if not to_header:
|
||||||
|
LOG.e("cannot find the alias")
|
||||||
|
return False
|
||||||
|
|
||||||
|
_, alias_address = parse_full_address(get_header_unicode(to_header))
|
||||||
|
alias = Alias.get_by(email=alias_address)
|
||||||
|
|
||||||
|
if not alias:
|
||||||
|
LOG.w("No alias for %s", alias_address)
|
||||||
|
return False
|
||||||
|
|
||||||
|
user = alias.user
|
||||||
|
LOG.w("Handle yahoo complaint for %s %s %s", alias, user, alias.mailboxes)
|
||||||
|
|
||||||
|
send_email_with_rate_control(
|
||||||
|
user,
|
||||||
|
ALERT_YAHOO_COMPLAINT,
|
||||||
|
user.email,
|
||||||
|
f"Yahoo abuse report",
|
||||||
|
render(
|
||||||
|
"transactional/yahoo-complaint.txt.jinja2",
|
||||||
|
alias=alias,
|
||||||
|
),
|
||||||
|
render(
|
||||||
|
"transactional/yahoo-complaint.html",
|
||||||
|
alias=alias,
|
||||||
|
),
|
||||||
|
max_nb_alert=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def handle_bounce_reply_phase(envelope, msg: Message, email_log: EmailLog):
|
def handle_bounce_reply_phase(envelope, msg: Message, email_log: EmailLog):
|
||||||
"""
|
"""
|
||||||
Handle reply phase bounce
|
Handle reply phase bounce
|
||||||
@ -1289,9 +1389,7 @@ def handle_bounce_reply_phase(envelope, msg: Message, email_log: EmailLog):
|
|||||||
user = alias.user
|
user = alias.user
|
||||||
mailbox = email_log.mailbox or alias.mailbox
|
mailbox = email_log.mailbox or alias.mailbox
|
||||||
|
|
||||||
LOG.debug(
|
LOG.d("Handle reply bounce %s -> %s -> %s.%s", mailbox, alias, contact, email_log)
|
||||||
"Handle reply bounce %s -> %s -> %s.%s", mailbox, alias, contact, email_log
|
|
||||||
)
|
|
||||||
|
|
||||||
Bounce.create(email=sanitize_email(contact.website_email), commit=True)
|
Bounce.create(email=sanitize_email(contact.website_email), commit=True)
|
||||||
|
|
||||||
@ -1512,11 +1610,11 @@ def handle_unsubscribe_user(user_id: int, mail_from: str) -> str:
|
|||||||
"""return the SMTP status"""
|
"""return the SMTP status"""
|
||||||
user = User.get(user_id)
|
user = User.get(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
LOG.exception("No such user %s %s", user_id, mail_from)
|
LOG.w("No such user %s %s", user_id, mail_from)
|
||||||
return status.E510
|
return status.E510
|
||||||
|
|
||||||
if mail_from != user.email:
|
if mail_from != user.email:
|
||||||
LOG.exception("Unauthorized mail_from %s %s", user, mail_from)
|
LOG.e("Unauthorized mail_from %s %s", user, mail_from)
|
||||||
return status.E511
|
return status.E511
|
||||||
|
|
||||||
user.notification = False
|
user.notification = False
|
||||||
@ -1638,7 +1736,7 @@ def handle(envelope: Envelope) -> str:
|
|||||||
if postfix_queue_id:
|
if postfix_queue_id:
|
||||||
set_message_id(postfix_queue_id)
|
set_message_id(postfix_queue_id)
|
||||||
else:
|
else:
|
||||||
LOG.w("Cannot parse Postfix queue ID from %s", msg["Received"])
|
LOG.d("Cannot parse Postfix queue ID from %s", msg["Received"])
|
||||||
|
|
||||||
if should_ignore(mail_from, rcpt_tos):
|
if should_ignore(mail_from, rcpt_tos):
|
||||||
LOG.w("Ignore email mail_from=%s rcpt_to=%s", mail_from, rcpt_tos)
|
LOG.w("Ignore email mail_from=%s rcpt_to=%s", mail_from, rcpt_tos)
|
||||||
@ -1665,10 +1763,12 @@ def handle(envelope: Envelope) -> str:
|
|||||||
|
|
||||||
contact = Contact.get_by(reply_email=mail_from)
|
contact = Contact.get_by(reply_email=mail_from)
|
||||||
if contact:
|
if contact:
|
||||||
LOG.e(
|
LOG.w(
|
||||||
"email can't be sent from a reverse-alias alias:%s, contact email:%s",
|
"email can't be sent from a reverse-alias:%s, contact email:%s, %s, %s",
|
||||||
contact.alias,
|
contact.reply_email,
|
||||||
contact.website_email,
|
contact.website_email,
|
||||||
|
contact.alias,
|
||||||
|
contact.user,
|
||||||
)
|
)
|
||||||
return status.E203
|
return status.E203
|
||||||
|
|
||||||
@ -1687,6 +1787,28 @@ def handle(envelope: Envelope) -> str:
|
|||||||
handle_transactional_bounce(envelope, rcpt_tos[0])
|
handle_transactional_bounce(envelope, rcpt_tos[0])
|
||||||
return status.E205
|
return status.E205
|
||||||
|
|
||||||
|
if (
|
||||||
|
len(rcpt_tos) == 1
|
||||||
|
and mail_from == "staff@hotmail.com"
|
||||||
|
and rcpt_tos[0] == POSTMASTER
|
||||||
|
):
|
||||||
|
LOG.w("Handle hotmail complaint")
|
||||||
|
|
||||||
|
# if the complaint cannot be handled, forward it normally
|
||||||
|
if handle_hotmail_complaint(msg):
|
||||||
|
return status.E208
|
||||||
|
|
||||||
|
if (
|
||||||
|
len(rcpt_tos) == 1
|
||||||
|
and mail_from == "feedback@arf.mail.yahoo.com"
|
||||||
|
and rcpt_tos[0] == POSTMASTER
|
||||||
|
):
|
||||||
|
LOG.w("Handle yahoo complaint")
|
||||||
|
|
||||||
|
# if the complaint cannot be handled, forward it normally
|
||||||
|
if handle_yahoo_complaint(msg):
|
||||||
|
return status.E210
|
||||||
|
|
||||||
# Handle bounce
|
# Handle bounce
|
||||||
if (
|
if (
|
||||||
len(rcpt_tos) == 1
|
len(rcpt_tos) == 1
|
||||||
@ -1721,8 +1843,45 @@ def handle(envelope: Envelope) -> str:
|
|||||||
)
|
)
|
||||||
return handle_bounce(envelope, email_log, msg)
|
return handle_bounce(envelope, email_log, msg)
|
||||||
|
|
||||||
|
# case where From: header is a reverse alias which should never happen
|
||||||
|
from_header = get_header_unicode(msg["From"])
|
||||||
|
if from_header:
|
||||||
|
try:
|
||||||
|
_, from_header_address = parse_full_address(from_header)
|
||||||
|
except ValueError:
|
||||||
|
LOG.d("cannot parse the From header %s", from_header)
|
||||||
|
else:
|
||||||
|
if is_reply_email(from_header_address):
|
||||||
|
LOG.e("email sent from a reverse alias %s", from_header_address)
|
||||||
|
# get more info for debug
|
||||||
|
contact = Contact.get_by(reply_email=from_header_address)
|
||||||
|
if contact:
|
||||||
|
LOG.d("%s %s %s", contact.user, contact.alias, contact)
|
||||||
|
|
||||||
|
# To investigate. todo: remove
|
||||||
|
if TEMP_DIR:
|
||||||
|
file_name = str(uuid.uuid4()) + ".eml"
|
||||||
|
with open(os.path.join(TEMP_DIR, file_name), "wb") as f:
|
||||||
|
f.write(msg.as_bytes())
|
||||||
|
|
||||||
|
LOG.d("email saved to %s", file_name)
|
||||||
|
return status.E523
|
||||||
|
|
||||||
if rate_limited(mail_from, rcpt_tos):
|
if rate_limited(mail_from, rcpt_tos):
|
||||||
LOG.w("Rate Limiting applied for mail_from:%s rcpt_tos:%s", mail_from, rcpt_tos)
|
LOG.w("Rate Limiting applied for mail_from:%s rcpt_tos:%s", mail_from, rcpt_tos)
|
||||||
|
|
||||||
|
# add more logging info. TODO: remove
|
||||||
|
if len(rcpt_tos) == 1:
|
||||||
|
alias = Alias.get_by(email=rcpt_tos[0])
|
||||||
|
if alias:
|
||||||
|
LOG.w(
|
||||||
|
"total number email log on %s, %s is %s, %s",
|
||||||
|
alias,
|
||||||
|
alias.user,
|
||||||
|
EmailLog.query.filter(EmailLog.alias_id == alias.id).count(),
|
||||||
|
EmailLog.query.filter(EmailLog.user_id == alias.user_id).count(),
|
||||||
|
)
|
||||||
|
|
||||||
if should_ignore_bounce(envelope.mail_from):
|
if should_ignore_bounce(envelope.mail_from):
|
||||||
return status.E207
|
return status.E207
|
||||||
else:
|
else:
|
||||||
@ -1821,6 +1980,9 @@ class MailHandler:
|
|||||||
newrelic.agent.record_custom_metric(
|
newrelic.agent.record_custom_metric(
|
||||||
"Custom/email_handler_time", elapsed, newrelic_app
|
"Custom/email_handler_time", elapsed, newrelic_app
|
||||||
)
|
)
|
||||||
|
newrelic.agent.record_custom_metric(
|
||||||
|
"Custom/number_incoming_email", 1, newrelic_app
|
||||||
|
)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
14
example.env
14
example.env
@ -74,14 +74,8 @@ EMAIL_SERVERS_WITH_PRIORITY=[(10, "email.hostname.")]
|
|||||||
# the DKIM private key used to compute DKIM-Signature
|
# the DKIM private key used to compute DKIM-Signature
|
||||||
# DKIM_PRIVATE_KEY_PATH=local_data/dkim.key
|
# DKIM_PRIVATE_KEY_PATH=local_data/dkim.key
|
||||||
|
|
||||||
# delete and recreate the sqlite database, for local development
|
|
||||||
RESET_DB=true
|
|
||||||
|
|
||||||
# DB Connection
|
# DB Connection
|
||||||
# Local SQLite database
|
DB_URI=postgresql://myuser:mypassword@localhost:35432/simplelogin
|
||||||
DB_URI=sqlite:///db.sqlite
|
|
||||||
# Postgres
|
|
||||||
# DB_URI=postgresql://myuser:mypassword@sl-db:5432/simplelogin
|
|
||||||
|
|
||||||
FLASK_SECRET=secret
|
FLASK_SECRET=secret
|
||||||
|
|
||||||
@ -178,3 +172,9 @@ DISABLE_ONBOARDING=true
|
|||||||
|
|
||||||
# NewRelic Config File Path
|
# NewRelic Config File Path
|
||||||
# NEWRELIC_CONFIG_PATH = /path/newrelic.ini
|
# NEWRELIC_CONFIG_PATH = /path/newrelic.ini
|
||||||
|
|
||||||
|
# POSTMASTER = postmaster@example.com
|
||||||
|
|
||||||
|
# TEMP_DIR = /tmp
|
||||||
|
|
||||||
|
#ALIAS_AUTOMATIC_DISABLE=true
|
12
init_app.py
12
init_app.py
@ -15,9 +15,7 @@ def load_pgp_public_keys():
|
|||||||
|
|
||||||
# sanity check
|
# sanity check
|
||||||
if fingerprint != mailbox.pgp_finger_print:
|
if fingerprint != mailbox.pgp_finger_print:
|
||||||
LOG.exception(
|
LOG.e("fingerprint %s different for mailbox %s", fingerprint, mailbox)
|
||||||
"fingerprint %s different for mailbox %s", fingerprint, mailbox
|
|
||||||
)
|
|
||||||
mailbox.pgp_finger_print = fingerprint
|
mailbox.pgp_finger_print = fingerprint
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@ -27,9 +25,7 @@ def load_pgp_public_keys():
|
|||||||
|
|
||||||
# sanity check
|
# sanity check
|
||||||
if fingerprint != contact.pgp_finger_print:
|
if fingerprint != contact.pgp_finger_print:
|
||||||
LOG.exception(
|
LOG.e("fingerprint %s different for contact %s", fingerprint, contact)
|
||||||
"fingerprint %s different for contact %s", fingerprint, contact
|
|
||||||
)
|
|
||||||
contact.pgp_finger_print = fingerprint
|
contact.pgp_finger_print = fingerprint
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@ -42,14 +38,14 @@ def add_sl_domains():
|
|||||||
if SLDomain.get_by(domain=alias_domain):
|
if SLDomain.get_by(domain=alias_domain):
|
||||||
LOG.d("%s is already a SL domain", alias_domain)
|
LOG.d("%s is already a SL domain", alias_domain)
|
||||||
else:
|
else:
|
||||||
LOG.info("Add %s to SL domain", alias_domain)
|
LOG.i("Add %s to SL domain", alias_domain)
|
||||||
SLDomain.create(domain=alias_domain)
|
SLDomain.create(domain=alias_domain)
|
||||||
|
|
||||||
for premium_domain in PREMIUM_ALIAS_DOMAINS:
|
for premium_domain in PREMIUM_ALIAS_DOMAINS:
|
||||||
if SLDomain.get_by(domain=premium_domain):
|
if SLDomain.get_by(domain=premium_domain):
|
||||||
LOG.d("%s is already a SL domain", premium_domain)
|
LOG.d("%s is already a SL domain", premium_domain)
|
||||||
else:
|
else:
|
||||||
LOG.info("Add %s to SL domain", premium_domain)
|
LOG.i("Add %s to SL domain", premium_domain)
|
||||||
SLDomain.create(domain=premium_domain, premium_only=True)
|
SLDomain.create(domain=premium_domain, premium_only=True)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
@ -159,7 +159,7 @@ if __name__ == "__main__":
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
user_email = user.email
|
user_email = user.email
|
||||||
LOG.warning("Delete user %s", user)
|
LOG.w("Delete user %s", user)
|
||||||
User.delete(user.id)
|
User.delete(user.id)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@ -170,6 +170,6 @@ if __name__ == "__main__":
|
|||||||
render("transactional/account-delete.html"),
|
render("transactional/account-delete.html"),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
LOG.exception("Unknown job name %s", job.name)
|
LOG.e("Unknown job name %s", job.name)
|
||||||
|
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
|
36
migrations/versions/2021_081718_d4392342465f_.py
Normal file
36
migrations/versions/2021_081718_d4392342465f_.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: d4392342465f
|
||||||
|
Revises: 9014cca7097c
|
||||||
|
Create Date: 2021-08-17 18:53:27.134187
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'd4392342465f'
|
||||||
|
down_revision = '9014cca7097c'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('custom_domain', sa.Column('ownership_txt_token', sa.String(length=128), nullable=True))
|
||||||
|
op.add_column('custom_domain', sa.Column('ownership_verified', sa.Boolean(), server_default='0', nullable=False))
|
||||||
|
op.create_index('ix_unique_domain', 'custom_domain', ['domain'], unique=True,
|
||||||
|
postgresql_where=sa.text('ownership_verified'))
|
||||||
|
|
||||||
|
# set ownership_verified=True for domain that has verified=True
|
||||||
|
op.execute('UPDATE custom_domain SET ownership_verified = true WHERE verified = true')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('ix_unique_domain', table_name='custom_domain')
|
||||||
|
op.drop_column('custom_domain', 'ownership_verified')
|
||||||
|
op.drop_column('custom_domain', 'ownership_txt_token')
|
||||||
|
# ### end Alembic commands ###
|
39
migrations/versions/2021_082012_424808e1fe49_.py
Normal file
39
migrations/versions/2021_082012_424808e1fe49_.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 424808e1fe49
|
||||||
|
Revises: d4392342465f
|
||||||
|
Create Date: 2021-08-20 12:11:57.901994
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from psycopg2 import errors
|
||||||
|
from psycopg2.errorcodes import DUPLICATE_OBJECT
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '424808e1fe49'
|
||||||
|
down_revision = 'd4392342465f'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
try:
|
||||||
|
op.execute('CREATE EXTENSION pg_trgm')
|
||||||
|
# thanks to https://stackoverflow.com/a/58743364/1428034 !
|
||||||
|
except sa.exc.ProgrammingError as e:
|
||||||
|
if isinstance(e.orig, errors.lookup(DUPLICATE_OBJECT)):
|
||||||
|
print(">>> pg_trgm already loaded, ignore")
|
||||||
|
op.execute("Rollback")
|
||||||
|
|
||||||
|
op.create_index('note_pg_trgm_index', 'alias', ['note'], unique=False, postgresql_ops={'note': 'gin_trgm_ops'}, postgresql_using='gin')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('note_pg_trgm_index', table_name='alias')
|
||||||
|
op.execute('DROP EXTENSION pg_trgm')
|
||||||
|
# ### end Alembic commands ###
|
29
migrations/versions/2021_090715_916a5257d18c_.py
Normal file
29
migrations/versions/2021_090715_916a5257d18c_.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 916a5257d18c
|
||||||
|
Revises: 424808e1fe49
|
||||||
|
Create Date: 2021-09-07 15:35:35.430202
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '916a5257d18c'
|
||||||
|
down_revision = '424808e1fe49'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('coupon', sa.Column('is_giveaway', sa.Boolean(), server_default='0', nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('coupon', 'is_giveaway')
|
||||||
|
# ### end Alembic commands ###
|
29
migrations/versions/2021_091018_4d3f91ddf3e9_.py
Normal file
29
migrations/versions/2021_091018_4d3f91ddf3e9_.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 4d3f91ddf3e9
|
||||||
|
Revises: 916a5257d18c
|
||||||
|
Create Date: 2021-09-10 18:12:21.374836
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '4d3f91ddf3e9'
|
||||||
|
down_revision = '916a5257d18c'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('users', sa.Column('ignore_loop_email', sa.Boolean(), server_default='0', nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('users', 'ignore_loop_email')
|
||||||
|
# ### end Alembic commands ###
|
29
migrations/versions/2021_091716_d8c55e79da54_.py
Normal file
29
migrations/versions/2021_091716_d8c55e79da54_.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: d8c55e79da54
|
||||||
|
Revises: 4d3f91ddf3e9
|
||||||
|
Create Date: 2021-09-17 16:30:23.299011
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'd8c55e79da54'
|
||||||
|
down_revision = '4d3f91ddf3e9'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('custom_domain', sa.Column('auto_create_regex', sa.String(length=512), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('custom_domain', 'auto_create_regex')
|
||||||
|
# ### end Alembic commands ###
|
29
migrations/versions/2021_092018_7a105bfc0cd0_.py
Normal file
29
migrations/versions/2021_092018_7a105bfc0cd0_.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 7a105bfc0cd0
|
||||||
|
Revises: cf1e8c1bc737
|
||||||
|
Create Date: 2021-09-20 18:41:43.017908
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '7a105bfc0cd0'
|
||||||
|
down_revision = 'cf1e8c1bc737'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('custom_domain', 'auto_create_regex')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('custom_domain', sa.Column('auto_create_regex', sa.VARCHAR(length=512), autoincrement=False, nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
51
migrations/versions/2021_092018_cf1e8c1bc737_.py
Normal file
51
migrations/versions/2021_092018_cf1e8c1bc737_.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: cf1e8c1bc737
|
||||||
|
Revises: d8c55e79da54
|
||||||
|
Create Date: 2021-09-20 18:14:17.798925
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'cf1e8c1bc737'
|
||||||
|
down_revision = 'd8c55e79da54'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('auto_create_rule',
|
||||||
|
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('custom_domain_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('regex', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['custom_domain_id'], ['custom_domain.id'], ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('custom_domain_id', 'order', name='uq_auto_create_rule_order')
|
||||||
|
)
|
||||||
|
op.create_table('auto_create_rule__mailbox',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
|
||||||
|
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
|
||||||
|
sa.Column('auto_create_rule_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('mailbox_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['auto_create_rule_id'], ['auto_create_rule.id'], ondelete='cascade'),
|
||||||
|
sa.ForeignKeyConstraint(['mailbox_id'], ['mailbox.id'], ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('auto_create_rule_id', 'mailbox_id', name='uq_auto_create_rule_mailbox')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('auto_create_rule__mailbox')
|
||||||
|
op.drop_table('auto_create_rule')
|
||||||
|
# ### end Alembic commands ###
|
33
migrations/versions/2021_092111_b8b4f9598240_.py
Normal file
33
migrations/versions/2021_092111_b8b4f9598240_.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: b8b4f9598240
|
||||||
|
Revises: bc75acacc98e
|
||||||
|
Create Date: 2021-09-21 11:22:24.285286
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b8b4f9598240'
|
||||||
|
down_revision = 'bc75acacc98e'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('api_key', 'name',
|
||||||
|
existing_type=sa.VARCHAR(length=128),
|
||||||
|
nullable=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('api_key', 'name',
|
||||||
|
existing_type=sa.VARCHAR(length=128),
|
||||||
|
nullable=False)
|
||||||
|
# ### end Alembic commands ###
|
29
migrations/versions/2021_092111_bc75acacc98e_.py
Normal file
29
migrations/versions/2021_092111_bc75acacc98e_.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: bc75acacc98e
|
||||||
|
Revises: 7a105bfc0cd0
|
||||||
|
Create Date: 2021-09-21 11:15:15.573629
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'bc75acacc98e'
|
||||||
|
down_revision = '7a105bfc0cd0'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint('transactional_email_email_key', 'transactional_email', type_='unique')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_unique_constraint('transactional_email_email_key', 'transactional_email', ['email'])
|
||||||
|
# ### end Alembic commands ###
|
29
migrations/versions/2021_092712_5ee767807344_.py
Normal file
29
migrations/versions/2021_092712_5ee767807344_.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 5ee767807344
|
||||||
|
Revises: b8b4f9598240
|
||||||
|
Create Date: 2021-09-27 12:19:14.398709
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '5ee767807344'
|
||||||
|
down_revision = 'b8b4f9598240'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('contact', 'from_header')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('contact', sa.Column('from_header', sa.TEXT(), autoincrement=False, nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
@ -43,7 +43,7 @@ def get_stats():
|
|||||||
# reset
|
# reset
|
||||||
_nb_failed = 0
|
_nb_failed = 0
|
||||||
|
|
||||||
LOG.exception(
|
LOG.e(
|
||||||
"Too many emails in incoming & active queue %s %s",
|
"Too many emails in incoming & active queue %s %s",
|
||||||
incoming_queue,
|
incoming_queue,
|
||||||
active_queue,
|
active_queue,
|
||||||
|
@ -1,16 +0,0 @@
|
|||||||
# Generate a new migration script using Docker
|
|
||||||
# To run it:
|
|
||||||
# sh new_migration.sh
|
|
||||||
|
|
||||||
# create a postgres database for SimpleLogin
|
|
||||||
docker rm -f sl-db
|
|
||||||
docker run -p 15432:5432 --name sl-db -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=sl -d postgres
|
|
||||||
|
|
||||||
# run run `flask db upgrade` to upgrade the DB to the latest stage and
|
|
||||||
env DB_URI=postgresql://postgres:postgres@127.0.0.1:15432/sl flask db upgrade
|
|
||||||
|
|
||||||
# finally `flask db migrate` to generate the migration script.
|
|
||||||
env DB_URI=postgresql://postgres:postgres@127.0.0.1:15432/sl flask db migrate
|
|
||||||
|
|
||||||
# remove the db
|
|
||||||
docker rm -f sl-db
|
|
148
poetry.lock
generated
148
poetry.lock
generated
@ -18,14 +18,16 @@ speedups = ["aiodns", "brotlipy", "cchardet"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiosmtpd"
|
name = "aiosmtpd"
|
||||||
version = "1.2"
|
version = "1.4.2"
|
||||||
description = "aiosmtpd - asyncio based SMTP server"
|
description = "aiosmtpd - asyncio based SMTP server"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "~=3.6"
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
atpublic = "*"
|
atpublic = "*"
|
||||||
|
attrs = "*"
|
||||||
|
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiosmtplib"
|
name = "aiosmtplib"
|
||||||
@ -396,11 +398,11 @@ trio = ["trio (>=0.14.0)", "sniffio (>=1.1)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "email-validator"
|
name = "email-validator"
|
||||||
version = "1.1.1"
|
version = "1.1.3"
|
||||||
description = "A robust email syntax and deliverability validation library for Python 2.x/3.x."
|
description = "A robust email syntax and deliverability validation library for Python 2.x/3.x."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
dnspython = ">=1.15.0"
|
dnspython = ">=1.15.0"
|
||||||
@ -421,7 +423,7 @@ requests = "*"
|
|||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.0.12"
|
version = "3.0.12"
|
||||||
description = "A platform independent file lock."
|
description = "A platform independent file lock."
|
||||||
category = "main"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
|
|
||||||
@ -439,6 +441,30 @@ mccabe = ">=0.6.0,<0.7.0"
|
|||||||
pycodestyle = ">=2.6.0a1,<2.7.0"
|
pycodestyle = ">=2.6.0a1,<2.7.0"
|
||||||
pyflakes = ">=2.2.0,<2.3.0"
|
pyflakes = ">=2.2.0,<2.3.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flanker"
|
||||||
|
version = "0.9.11"
|
||||||
|
description = "Mailgun Parsing Tools"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
attrs = "*"
|
||||||
|
chardet = ">=1.0.1"
|
||||||
|
cryptography = ">=0.5"
|
||||||
|
idna = ">=2.5"
|
||||||
|
ply = ">=3.10"
|
||||||
|
regex = ">=0.1.20110315"
|
||||||
|
six = "*"
|
||||||
|
tld = "*"
|
||||||
|
WebOb = ">=0.9.8"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
cchardet = ["cchardet (>=0.3.5)"]
|
||||||
|
tests = ["coverage", "coveralls", "mock", "nose"]
|
||||||
|
validator = ["dnsq (>=1.1.6)", "redis (>=2.7.1)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flask"
|
name = "flask"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@ -563,7 +589,7 @@ simplejson = "*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flask-sqlalchemy"
|
name = "flask-sqlalchemy"
|
||||||
version = "2.4.4"
|
version = "2.5.1"
|
||||||
description = "Adds SQLAlchemy support to your Flask application."
|
description = "Adds SQLAlchemy support to your Flask application."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -1058,6 +1084,14 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
dev = ["pre-commit", "tox"]
|
dev = ["pre-commit", "tox"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ply"
|
||||||
|
version = "3.11"
|
||||||
|
description = "Python Lex & Yacc"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pre-commit"
|
name = "pre-commit"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
@ -1132,19 +1166,6 @@ category = "dev"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "py3-validate-email"
|
|
||||||
version = "0.2.10"
|
|
||||||
description = "Email validator with regex, blacklisted domains and SMTP checking."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
dnspython = ">=2.0,<3.0"
|
|
||||||
filelock = ">=3.0,<4.0"
|
|
||||||
idna = ">=2.10,<3.0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyasn1"
|
name = "pyasn1"
|
||||||
version = "0.4.8"
|
version = "0.4.8"
|
||||||
@ -1236,6 +1257,18 @@ category = "dev"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyre2"
|
||||||
|
version = "0.3.6"
|
||||||
|
description = "Python wrapper for Google\\'s RE2 using Cython"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
perf = ["regex"]
|
||||||
|
test = ["pytest"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyreadline"
|
name = "pyreadline"
|
||||||
version = "2.1"
|
version = "2.1"
|
||||||
@ -1348,7 +1381,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
|||||||
name = "regex"
|
name = "regex"
|
||||||
version = "2020.9.27"
|
version = "2020.9.27"
|
||||||
description = "Alternative regular expression module, to replace re."
|
description = "Alternative regular expression module, to replace re."
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
|
|
||||||
@ -1533,6 +1566,14 @@ python-versions = "*"
|
|||||||
python-dateutil = ">=2.6.0"
|
python-dateutil = ">=2.6.0"
|
||||||
"ruamel.yaml" = ">=0.14.2"
|
"ruamel.yaml" = ">=0.14.2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tld"
|
||||||
|
version = "0.12.6"
|
||||||
|
description = "Extract the top-level domain (TLD) from the URL given."
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=2.7, <4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@ -1653,6 +1694,18 @@ future = ">=0.17.1"
|
|||||||
pyOpenSSL = ">=16.0.0"
|
pyOpenSSL = ">=16.0.0"
|
||||||
six = ">=1.11.0"
|
six = ">=1.11.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webob"
|
||||||
|
version = "1.8.7"
|
||||||
|
description = "WSGI request and response object"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
docs = ["Sphinx (>=1.7.5)", "pylons-sphinx-themes"]
|
||||||
|
testing = ["pytest (>=3.1.0)", "coverage", "pytest-cov", "pytest-xdist"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@ -1751,7 +1804,7 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.7"
|
python-versions = "^3.7"
|
||||||
content-hash = "6a909880eb2c4afcd161158bd606edde3839aedbfca57a751c6b0c6e5306d9dc"
|
content-hash = "7c789c1e89d6b2859ee06adcc2b1caf93c759097624059461d1d613da1888e8e"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
aiohttp = [
|
aiohttp = [
|
||||||
@ -1769,7 +1822,8 @@ aiohttp = [
|
|||||||
{file = "aiohttp-3.6.2.tar.gz", hash = "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326"},
|
{file = "aiohttp-3.6.2.tar.gz", hash = "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326"},
|
||||||
]
|
]
|
||||||
aiosmtpd = [
|
aiosmtpd = [
|
||||||
{file = "aiosmtpd-1.2.tar.gz", hash = "sha256:b7ea7ee663f3b8514d3224d55c4e8827148277b124ea862a0bbfca1bc899aef5"},
|
{file = "aiosmtpd-1.4.2-py3-none-any.whl", hash = "sha256:314f70b74cb8474882cef396b186fbfad8660c7b52be5c1937f3c31df14232a4"},
|
||||||
|
{file = "aiosmtpd-1.4.2.tar.gz", hash = "sha256:aa891d010d2097274189078c6ce2a59a167f3fb2e974e028b572a61e92e1549c"},
|
||||||
]
|
]
|
||||||
aiosmtplib = [
|
aiosmtplib = [
|
||||||
{file = "aiosmtplib-1.1.4-py3-none-any.whl", hash = "sha256:93e53edac183f1a608bc34464efeef23902e59e949017b1682014f59ecdcd37d"},
|
{file = "aiosmtplib-1.1.4-py3-none-any.whl", hash = "sha256:93e53edac183f1a608bc34464efeef23902e59e949017b1682014f59ecdcd37d"},
|
||||||
@ -1989,8 +2043,8 @@ dnspython = [
|
|||||||
{file = "dnspython-2.0.0.zip", hash = "sha256:044af09374469c3a39eeea1a146e8cac27daec951f1f1f157b1962fc7cb9d1b7"},
|
{file = "dnspython-2.0.0.zip", hash = "sha256:044af09374469c3a39eeea1a146e8cac27daec951f1f1f157b1962fc7cb9d1b7"},
|
||||||
]
|
]
|
||||||
email-validator = [
|
email-validator = [
|
||||||
{file = "email_validator-1.1.1-py2.py3-none-any.whl", hash = "sha256:5f246ae8d81ce3000eade06595b7bb55a4cf350d559e890182a1466a21f25067"},
|
{file = "email_validator-1.1.3-py2.py3-none-any.whl", hash = "sha256:5675c8ceb7106a37e40e2698a57c056756bf3f272cfa8682a4f87ebd95d8440b"},
|
||||||
{file = "email_validator-1.1.1.tar.gz", hash = "sha256:63094045c3e802c3d3d575b18b004a531c36243ca8d1cec785ff6bfcb04185bb"},
|
{file = "email_validator-1.1.3.tar.gz", hash = "sha256:aa237a65f6f4da067119b7df3f13e89c25c051327b2b5b66dc075f33d62480d7"},
|
||||||
]
|
]
|
||||||
facebook-sdk = [
|
facebook-sdk = [
|
||||||
{file = "facebook-sdk-3.1.0.tar.gz", hash = "sha256:cabcd2e69ea3d9f042919c99b353df7aa1e2be86d040121f6e9f5e63c1cf0f8d"},
|
{file = "facebook-sdk-3.1.0.tar.gz", hash = "sha256:cabcd2e69ea3d9f042919c99b353df7aa1e2be86d040121f6e9f5e63c1cf0f8d"},
|
||||||
@ -2004,6 +2058,9 @@ flake8 = [
|
|||||||
{file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"},
|
{file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"},
|
||||||
{file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"},
|
{file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"},
|
||||||
]
|
]
|
||||||
|
flanker = [
|
||||||
|
{file = "flanker-0.9.11.tar.gz", hash = "sha256:974418e5b498fd3bcb3859c22e22d26495257f9cf98b744c17f2335aca86e001"},
|
||||||
|
]
|
||||||
flask = [
|
flask = [
|
||||||
{file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"},
|
{file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"},
|
||||||
{file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"},
|
{file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"},
|
||||||
@ -2041,8 +2098,8 @@ flask-profiler = [
|
|||||||
{file = "flask_profiler-1.8.1.tar.gz", hash = "sha256:fc9f2875a4f22223ddc04ffacd75792854162c4cdbef165598a51f898521ac51"},
|
{file = "flask_profiler-1.8.1.tar.gz", hash = "sha256:fc9f2875a4f22223ddc04ffacd75792854162c4cdbef165598a51f898521ac51"},
|
||||||
]
|
]
|
||||||
flask-sqlalchemy = [
|
flask-sqlalchemy = [
|
||||||
{file = "Flask-SQLAlchemy-2.4.4.tar.gz", hash = "sha256:bfc7150eaf809b1c283879302f04c42791136060c6eeb12c0c6674fb1291fae5"},
|
{file = "Flask-SQLAlchemy-2.5.1.tar.gz", hash = "sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912"},
|
||||||
{file = "Flask_SQLAlchemy-2.4.4-py2.py3-none-any.whl", hash = "sha256:05b31d2034dd3f2a685cbbae4cfc4ed906b2a733cff7964ada450fd5e462b84e"},
|
{file = "Flask_SQLAlchemy-2.5.1-py2.py3-none-any.whl", hash = "sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390"},
|
||||||
]
|
]
|
||||||
flask-wtf = [
|
flask-wtf = [
|
||||||
{file = "Flask-WTF-0.14.3.tar.gz", hash = "sha256:d417e3a0008b5ba583da1763e4db0f55a1269d9dd91dcc3eb3c026d3c5dbd720"},
|
{file = "Flask-WTF-0.14.3.tar.gz", hash = "sha256:d417e3a0008b5ba583da1763e4db0f55a1269d9dd91dcc3eb3c026d3c5dbd720"},
|
||||||
@ -2295,6 +2352,10 @@ pluggy = [
|
|||||||
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
|
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
|
||||||
{file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
|
{file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
|
||||||
]
|
]
|
||||||
|
ply = [
|
||||||
|
{file = "ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce"},
|
||||||
|
{file = "ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3"},
|
||||||
|
]
|
||||||
pre-commit = [
|
pre-commit = [
|
||||||
{file = "pre_commit-2.7.1-py2.py3-none-any.whl", hash = "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a"},
|
{file = "pre_commit-2.7.1-py2.py3-none-any.whl", hash = "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a"},
|
||||||
{file = "pre_commit-2.7.1.tar.gz", hash = "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70"},
|
{file = "pre_commit-2.7.1.tar.gz", hash = "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70"},
|
||||||
@ -2378,9 +2439,6 @@ py = [
|
|||||||
{file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"},
|
{file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"},
|
||||||
{file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"},
|
{file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"},
|
||||||
]
|
]
|
||||||
py3-validate-email = [
|
|
||||||
{file = "py3-validate-email-0.2.10.tar.gz", hash = "sha256:3bbb264b49c0ae09afdb2738956f00b0e8dd7e079e2d079b2e9b6688de474d28"},
|
|
||||||
]
|
|
||||||
pyasn1 = [
|
pyasn1 = [
|
||||||
{file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"},
|
{file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"},
|
||||||
{file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"},
|
{file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"},
|
||||||
@ -2476,6 +2534,25 @@ pyparsing = [
|
|||||||
{file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
|
{file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
|
||||||
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
|
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
|
||||||
]
|
]
|
||||||
|
pyre2 = [
|
||||||
|
{file = "pyre2-0.3.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6d8e550899886ee01f1b8149ba1c336e1c749cec2e33414815a76fb5649cdf67"},
|
||||||
|
{file = "pyre2-0.3.6-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:310d5c98495114692940ffa020aaeef1341427755b6ca5a17c63092060ed93dc"},
|
||||||
|
{file = "pyre2-0.3.6-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:97de5d4cf7d8b9be7dbe0dc0941c4a6c1395fc598722d9644adc55427d3dd083"},
|
||||||
|
{file = "pyre2-0.3.6-cp36-cp36m-win_amd64.whl", hash = "sha256:18cd5d76973ee57232a5d851489c202105e4752aee6dcbd38742c0475f3f1c4e"},
|
||||||
|
{file = "pyre2-0.3.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:225784d7bd905bc3e87d4bbcc6ac4087ccea8905dd657273fd71bfb113e50e82"},
|
||||||
|
{file = "pyre2-0.3.6-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:c3b45f789374d0f95866330fcd34bb6b93705e8f5c276d9d70d318a227ba5954"},
|
||||||
|
{file = "pyre2-0.3.6-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f3467dd9a4c8100f6406bc6277d945a13b7fd7c4426d2415564de1324b5db94f"},
|
||||||
|
{file = "pyre2-0.3.6-cp37-cp37m-win_amd64.whl", hash = "sha256:608558276d3539002ad6300d0b0a2b0941577fdea009715ff4d31052e05cb409"},
|
||||||
|
{file = "pyre2-0.3.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ebe92a3222f2f6f176eeb3859638734e4f9a82d5940ad7d6f0c1288153c70ce2"},
|
||||||
|
{file = "pyre2-0.3.6-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:961020835a3b805eed51a082e5effdccb51979c4efef2a17f17122967cb4749a"},
|
||||||
|
{file = "pyre2-0.3.6-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:cc180989186f05b75020b53c79059c338e9e1940d325fc945c84aab2b5c57525"},
|
||||||
|
{file = "pyre2-0.3.6-cp38-cp38-win_amd64.whl", hash = "sha256:7c398942c3467fe23b2dd4a11dd78da8aee774d0b481e84b1b208819ee724cca"},
|
||||||
|
{file = "pyre2-0.3.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:617c4d75b41b34afe7590e144efad1c564a8b49a1e0827872afc2243b24beada"},
|
||||||
|
{file = "pyre2-0.3.6-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:f3ae7b087abcbc4b910d535c2fb877ef452b61d2514a63fd15b8b020b51fe4b5"},
|
||||||
|
{file = "pyre2-0.3.6-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:b87e9aeee74376210bd82c8328eb007b93378f3cd61fa6176161c3b9037e8474"},
|
||||||
|
{file = "pyre2-0.3.6-cp39-cp39-win_amd64.whl", hash = "sha256:1efec117f2543b38adcbe038a2ae156eb91b6ed8a73c998c3752a766d6241075"},
|
||||||
|
{file = "pyre2-0.3.6.tar.gz", hash = "sha256:6fe972c0cadec49a5a055690e5aa29f8aebaed0fa9b7d8d3530e33719b61f91c"},
|
||||||
|
]
|
||||||
pyreadline = [
|
pyreadline = [
|
||||||
{file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"},
|
{file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"},
|
||||||
{file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"},
|
{file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"},
|
||||||
@ -2691,6 +2768,15 @@ sqlalchemy-utils = [
|
|||||||
strictyaml = [
|
strictyaml = [
|
||||||
{file = "strictyaml-1.1.0.tar.gz", hash = "sha256:6b07dbd4f77ab023ed4167c43ffc1b9f9354fb6075cc6ff3b91fefcbb80342ca"},
|
{file = "strictyaml-1.1.0.tar.gz", hash = "sha256:6b07dbd4f77ab023ed4167c43ffc1b9f9354fb6075cc6ff3b91fefcbb80342ca"},
|
||||||
]
|
]
|
||||||
|
tld = [
|
||||||
|
{file = "tld-0.12.6-py27-none-any.whl", hash = "sha256:ef5b162d6fa295822dacd4fe4df1b62d8df2550795a97399a8905821b58d3702"},
|
||||||
|
{file = "tld-0.12.6-py35-none-any.whl", hash = "sha256:826bbe61dccc8d63144b51caef83e1373fbaac6f9ada46fca7846021f5d36fef"},
|
||||||
|
{file = "tld-0.12.6-py36-none-any.whl", hash = "sha256:843844e4256c943983d86366b5af3ac9cd1c9a0b6465f04d9f70e3b4c1a7989f"},
|
||||||
|
{file = "tld-0.12.6-py37-none-any.whl", hash = "sha256:a92ac6b84917e7d9e934434b8d37e9be534598f138fbb86b3c0d5426f2621890"},
|
||||||
|
{file = "tld-0.12.6-py38-none-any.whl", hash = "sha256:266106ad9035f54cd5cce5f823911a51f697e7c58cb45bfbd6c53b4c2976ece2"},
|
||||||
|
{file = "tld-0.12.6-py39-none-any.whl", hash = "sha256:b6650f2d5392a49760064bc55d73ce3397a378ef24ded96efb516c6b8ec68c26"},
|
||||||
|
{file = "tld-0.12.6.tar.gz", hash = "sha256:69fed19d26bb3f715366fb4af66fdeace896c55c052b00e8aaba3a7b63f3e7f0"},
|
||||||
|
]
|
||||||
toml = [
|
toml = [
|
||||||
{file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
|
{file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
|
||||||
{file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
|
{file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
|
||||||
@ -2755,6 +2841,10 @@ webauthn = [
|
|||||||
{file = "webauthn-0.4.7-py2.py3-none-any.whl", hash = "sha256:238391b2e2cc60fb51a2cd2d2d6be149920b9af6184651353d9f95856617a9e7"},
|
{file = "webauthn-0.4.7-py2.py3-none-any.whl", hash = "sha256:238391b2e2cc60fb51a2cd2d2d6be149920b9af6184651353d9f95856617a9e7"},
|
||||||
{file = "webauthn-0.4.7.tar.gz", hash = "sha256:8ad9072ff1d6169f3be30d4dc8733ea563dd266962397bc58b40f674a6af74ac"},
|
{file = "webauthn-0.4.7.tar.gz", hash = "sha256:8ad9072ff1d6169f3be30d4dc8733ea563dd266962397bc58b40f674a6af74ac"},
|
||||||
]
|
]
|
||||||
|
webob = [
|
||||||
|
{file = "WebOb-1.8.7-py2.py3-none-any.whl", hash = "sha256:73aae30359291c14fa3b956f8b5ca31960e420c28c1bec002547fb04928cf89b"},
|
||||||
|
{file = "WebOb-1.8.7.tar.gz", hash = "sha256:b64ef5141be559cfade448f044fa45c2260351edcb6a8ef6b7e00c7dcef0c323"},
|
||||||
|
]
|
||||||
werkzeug = [
|
werkzeug = [
|
||||||
{file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"},
|
{file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"},
|
||||||
{file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"},
|
{file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"},
|
||||||
|
@ -74,10 +74,11 @@ gevent = "^20.9.0"
|
|||||||
aiospamc = "^0.6.1"
|
aiospamc = "^0.6.1"
|
||||||
email_validator = "^1.1.1"
|
email_validator = "^1.1.1"
|
||||||
PGPy = "^0.5.3"
|
PGPy = "^0.5.3"
|
||||||
py3-validate-email = "^0.2.10"
|
|
||||||
coinbase-commerce = "^1.0.1"
|
coinbase-commerce = "^1.0.1"
|
||||||
requests = "^2.25.1"
|
requests = "^2.25.1"
|
||||||
newrelic = "^6.4.4"
|
newrelic = "^6.4.4"
|
||||||
|
flanker = "^0.9.11"
|
||||||
|
pyre2 = "^0.3.6"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^6.1.0"
|
pytest = "^6.1.0"
|
||||||
|
16
scripts/new-migration.sh
Normal file
16
scripts/new-migration.sh
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Generate a new migration script using Docker
|
||||||
|
# To run it:
|
||||||
|
# sh scripts/new-migration.sh
|
||||||
|
|
||||||
|
# create a postgres database for SimpleLogin
|
||||||
|
docker rm -f sl-db
|
||||||
|
docker run -p 25432:5432 --name sl-db -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=sl -d postgres:13
|
||||||
|
|
||||||
|
# run run `flask db upgrade` to upgrade the DB to the latest stage and
|
||||||
|
env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl poetry run flask db upgrade
|
||||||
|
|
||||||
|
# finally `flask db migrate` to generate the migration script.
|
||||||
|
env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl poetry run flask db migrate
|
||||||
|
|
||||||
|
# remove the db
|
||||||
|
docker rm -f sl-db
|
7
scripts/run-test.sh
Normal file
7
scripts/run-test.sh
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Run tests
|
||||||
|
|
||||||
|
docker run -d --name sl-test-db -e POSTGRES_PASSWORD=test -e POSTGRES_USER=test -e POSTGRES_DB=test -p 15432:5432 postgres:13
|
||||||
|
|
||||||
|
poetry run pytest
|
||||||
|
|
||||||
|
docker rm -f sl-test-db
|
60
server.py
60
server.py
@ -39,6 +39,7 @@ from app.admin_model import (
|
|||||||
ReferralAdmin,
|
ReferralAdmin,
|
||||||
PayoutAdmin,
|
PayoutAdmin,
|
||||||
CouponAdmin,
|
CouponAdmin,
|
||||||
|
CustomDomainAdmin,
|
||||||
)
|
)
|
||||||
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
|
||||||
@ -49,7 +50,6 @@ from app.config import (
|
|||||||
URL,
|
URL,
|
||||||
SHA1,
|
SHA1,
|
||||||
PADDLE_MONTHLY_PRODUCT_ID,
|
PADDLE_MONTHLY_PRODUCT_ID,
|
||||||
RESET_DB,
|
|
||||||
FLASK_PROFILER_PATH,
|
FLASK_PROFILER_PATH,
|
||||||
FLASK_PROFILER_PASSWORD,
|
FLASK_PROFILER_PASSWORD,
|
||||||
SENTRY_FRONT_END_DSN,
|
SENTRY_FRONT_END_DSN,
|
||||||
@ -205,13 +205,6 @@ def create_app() -> Flask:
|
|||||||
|
|
||||||
def fake_data():
|
def fake_data():
|
||||||
LOG.d("create fake data")
|
LOG.d("create fake data")
|
||||||
# Remove db if exist
|
|
||||||
if os.path.exists("db.sqlite"):
|
|
||||||
LOG.d("remove existing db file")
|
|
||||||
os.remove("db.sqlite")
|
|
||||||
|
|
||||||
# Create all tables
|
|
||||||
db.create_all()
|
|
||||||
|
|
||||||
# Create a user
|
# Create a user
|
||||||
user = User.create(
|
user = User.create(
|
||||||
@ -438,6 +431,11 @@ def fake_data():
|
|||||||
AliasHibp.create(hibp_id=hibp1.id, alias_id=breached_alias1.id)
|
AliasHibp.create(hibp_id=hibp1.id, alias_id=breached_alias1.id)
|
||||||
AliasHibp.create(hibp_id=hibp2.id, alias_id=breached_alias2.id)
|
AliasHibp.create(hibp_id=hibp2.id, alias_id=breached_alias2.id)
|
||||||
|
|
||||||
|
# old domain will have ownership_verified=True
|
||||||
|
CustomDomain.create(
|
||||||
|
user_id=user.id, domain="old.com", verified=True, ownership_verified=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
@ -487,7 +485,7 @@ def set_index_page(app):
|
|||||||
and not request.path.startswith("/admin/static")
|
and not request.path.startswith("/admin/static")
|
||||||
and not request.path.startswith("/_debug_toolbar")
|
and not request.path.startswith("/_debug_toolbar")
|
||||||
):
|
):
|
||||||
LOG.debug(
|
LOG.d(
|
||||||
"%s %s %s %s %s, takes %s",
|
"%s %s %s %s %s, takes %s",
|
||||||
request.remote_addr,
|
request.remote_addr,
|
||||||
request.method,
|
request.method,
|
||||||
@ -565,7 +563,7 @@ def setup_error_page(app):
|
|||||||
|
|
||||||
@app.errorhandler(429)
|
@app.errorhandler(429)
|
||||||
def rate_limited(e):
|
def rate_limited(e):
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"Client hit rate limit on path %s, user:%s",
|
"Client hit rate limit on path %s, user:%s",
|
||||||
request.path,
|
request.path,
|
||||||
get_current_user(),
|
get_current_user(),
|
||||||
@ -591,7 +589,7 @@ def setup_error_page(app):
|
|||||||
|
|
||||||
@app.errorhandler(Exception)
|
@app.errorhandler(Exception)
|
||||||
def error_handler(e):
|
def error_handler(e):
|
||||||
LOG.exception(e)
|
LOG.e(e)
|
||||||
if request.path.startswith("/api/"):
|
if request.path.startswith("/api/"):
|
||||||
return jsonify(error="Internal error"), 500
|
return jsonify(error="Internal error"), 500
|
||||||
else:
|
else:
|
||||||
@ -628,19 +626,18 @@ def jinja2_filter(app):
|
|||||||
STATUS_PAGE_URL=STATUS_PAGE_URL,
|
STATUS_PAGE_URL=STATUS_PAGE_URL,
|
||||||
SUPPORT_EMAIL=SUPPORT_EMAIL,
|
SUPPORT_EMAIL=SUPPORT_EMAIL,
|
||||||
PGP_SIGNER=PGP_SIGNER,
|
PGP_SIGNER=PGP_SIGNER,
|
||||||
|
canonical_url=f"{URL}{request.path}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def setup_paddle_callback(app: Flask):
|
def setup_paddle_callback(app: Flask):
|
||||||
@app.route("/paddle", methods=["GET", "POST"])
|
@app.route("/paddle", methods=["GET", "POST"])
|
||||||
def paddle():
|
def paddle():
|
||||||
LOG.debug(f"paddle callback {request.form.get('alert_name')} {request.form}")
|
LOG.d(f"paddle callback {request.form.get('alert_name')} {request.form}")
|
||||||
|
|
||||||
# make sure the request comes from Paddle
|
# make sure the request comes from Paddle
|
||||||
if not paddle_utils.verify_incoming_request(dict(request.form)):
|
if not paddle_utils.verify_incoming_request(dict(request.form)):
|
||||||
LOG.exception(
|
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
|
||||||
"request not coming from paddle. Request data:%s", dict(request.form)
|
|
||||||
)
|
|
||||||
return "KO", 400
|
return "KO", 400
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -659,7 +656,7 @@ def setup_paddle_callback(app: Flask):
|
|||||||
elif subscription_plan_id in PADDLE_YEARLY_PRODUCT_IDS:
|
elif subscription_plan_id in PADDLE_YEARLY_PRODUCT_IDS:
|
||||||
plan = PlanEnum.yearly
|
plan = PlanEnum.yearly
|
||||||
else:
|
else:
|
||||||
LOG.exception(
|
LOG.e(
|
||||||
"Unknown subscription_plan_id %s %s",
|
"Unknown subscription_plan_id %s %s",
|
||||||
subscription_plan_id,
|
subscription_plan_id,
|
||||||
request.form,
|
request.form,
|
||||||
@ -696,13 +693,13 @@ def setup_paddle_callback(app: Flask):
|
|||||||
# in case user cancels a plan and subscribes a new plan
|
# in case user cancels a plan and subscribes a new plan
|
||||||
sub.cancelled = False
|
sub.cancelled = False
|
||||||
|
|
||||||
LOG.debug("User %s upgrades!", user)
|
LOG.d("User %s upgrades!", user)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
elif request.form.get("alert_name") == "subscription_payment_succeeded":
|
elif request.form.get("alert_name") == "subscription_payment_succeeded":
|
||||||
subscription_id = request.form.get("subscription_id")
|
subscription_id = request.form.get("subscription_id")
|
||||||
LOG.debug("Update subscription %s", subscription_id)
|
LOG.d("Update subscription %s", subscription_id)
|
||||||
|
|
||||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||||
# when user subscribes, the "subscription_payment_succeeded" can arrive BEFORE "subscription_created"
|
# when user subscribes, the "subscription_payment_succeeded" can arrive BEFORE "subscription_created"
|
||||||
@ -721,7 +718,7 @@ def setup_paddle_callback(app: Flask):
|
|||||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||||
if sub:
|
if sub:
|
||||||
# cancellation_effective_date should be the same as next_bill_date
|
# cancellation_effective_date should be the same as next_bill_date
|
||||||
LOG.warning(
|
LOG.w(
|
||||||
"Cancel subscription %s %s on %s, next bill date %s",
|
"Cancel subscription %s %s on %s, next bill date %s",
|
||||||
subscription_id,
|
subscription_id,
|
||||||
sub.user,
|
sub.user,
|
||||||
@ -751,7 +748,7 @@ def setup_paddle_callback(app: Flask):
|
|||||||
|
|
||||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||||
if sub:
|
if sub:
|
||||||
LOG.debug(
|
LOG.d(
|
||||||
"Update subscription %s %s on %s, next bill date %s",
|
"Update subscription %s %s on %s, next bill date %s",
|
||||||
subscription_id,
|
subscription_id,
|
||||||
sub.user,
|
sub.user,
|
||||||
@ -797,7 +794,7 @@ def setup_coinbase_commerce(app):
|
|||||||
request_data, request_sig, COINBASE_WEBHOOK_SECRET
|
request_data, request_sig, COINBASE_WEBHOOK_SECRET
|
||||||
)
|
)
|
||||||
except (WebhookInvalidPayload, SignatureVerificationError) as e:
|
except (WebhookInvalidPayload, SignatureVerificationError) as e:
|
||||||
LOG.exception("Invalid Coinbase webhook")
|
LOG.e("Invalid Coinbase webhook")
|
||||||
return str(e), 400
|
return str(e), 400
|
||||||
|
|
||||||
LOG.d("Coinbase event %s", event)
|
LOG.d("Coinbase event %s", event)
|
||||||
@ -816,7 +813,7 @@ def handle_coinbase_event(event) -> bool:
|
|||||||
code = event["data"]["code"]
|
code = event["data"]["code"]
|
||||||
user = User.get(user_id)
|
user = User.get(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
LOG.exception("User not found %s", user_id)
|
LOG.e("User not found %s", user_id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
|
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
|
||||||
@ -886,6 +883,7 @@ def init_admin(app):
|
|||||||
admin.add_view(CouponAdmin(Coupon, db.session))
|
admin.add_view(CouponAdmin(Coupon, db.session))
|
||||||
admin.add_view(ManualSubscriptionAdmin(ManualSubscription, db.session))
|
admin.add_view(ManualSubscriptionAdmin(ManualSubscription, db.session))
|
||||||
admin.add_view(ClientAdmin(Client, db.session))
|
admin.add_view(ClientAdmin(Client, db.session))
|
||||||
|
admin.add_view(CustomDomainAdmin(CustomDomain, db.session))
|
||||||
admin.add_view(ReferralAdmin(Referral, db.session))
|
admin.add_view(ReferralAdmin(Referral, db.session))
|
||||||
admin.add_view(PayoutAdmin(Payout, db.session))
|
admin.add_view(PayoutAdmin(Payout, db.session))
|
||||||
|
|
||||||
@ -919,6 +917,15 @@ def register_custom_commands(app):
|
|||||||
LOG.d("finish trunk %s, update %s email logs", trunk, nb_update)
|
LOG.d("finish trunk %s, update %s email logs", trunk, nb_update)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
@app.cli.command("dummy-data")
|
||||||
|
def dummy_data():
|
||||||
|
from init_app import add_sl_domains
|
||||||
|
|
||||||
|
LOG.w("reset db, add fake data")
|
||||||
|
with app.app_context():
|
||||||
|
fake_data()
|
||||||
|
add_sl_domains()
|
||||||
|
|
||||||
|
|
||||||
def setup_do_not_track(app):
|
def setup_do_not_track(app):
|
||||||
@app.route("/dnt")
|
@app.route("/dnt")
|
||||||
@ -951,15 +958,6 @@ def local_main():
|
|||||||
app.debug = True
|
app.debug = True
|
||||||
DebugToolbarExtension(app)
|
DebugToolbarExtension(app)
|
||||||
|
|
||||||
# warning: only used in local
|
|
||||||
if RESET_DB:
|
|
||||||
from init_app import add_sl_domains
|
|
||||||
|
|
||||||
LOG.warning("reset db, add fake data")
|
|
||||||
with app.app_context():
|
|
||||||
fake_data()
|
|
||||||
add_sl_domains()
|
|
||||||
|
|
||||||
app.run(debug=True, port=7777)
|
app.run(debug=True, port=7777)
|
||||||
|
|
||||||
# uncomment to run https locally
|
# uncomment to run https locally
|
||||||
|
10
shell.py
10
shell.py
@ -23,7 +23,7 @@ from server import create_app
|
|||||||
|
|
||||||
def create_db():
|
def create_db():
|
||||||
if not database_exists(DB_URI):
|
if not database_exists(DB_URI):
|
||||||
LOG.debug("db not exist, create database")
|
LOG.d("db not exist, create database")
|
||||||
create_database(DB_URI)
|
create_database(DB_URI)
|
||||||
|
|
||||||
# Create all tables
|
# Create all tables
|
||||||
@ -56,7 +56,7 @@ def send_mailbox_newsletter():
|
|||||||
)
|
)
|
||||||
sleep(1)
|
sleep(1)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.warning("Cannot send to user %s", user)
|
LOG.w("Cannot send to user %s", user)
|
||||||
|
|
||||||
|
|
||||||
def send_pgp_newsletter():
|
def send_pgp_newsletter():
|
||||||
@ -72,7 +72,7 @@ def send_pgp_newsletter():
|
|||||||
)
|
)
|
||||||
sleep(1)
|
sleep(1)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.warning("Cannot send to user %s", user)
|
LOG.w("Cannot send to user %s", user)
|
||||||
|
|
||||||
|
|
||||||
def send_mobile_newsletter():
|
def send_mobile_newsletter():
|
||||||
@ -89,7 +89,7 @@ def send_mobile_newsletter():
|
|||||||
render("com/newsletter/mobile-darkmode.html", user=user),
|
render("com/newsletter/mobile-darkmode.html", user=user),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.warning("Cannot send to user %s", user)
|
LOG.w("Cannot send to user %s", user)
|
||||||
|
|
||||||
if count % 5 == 0:
|
if count % 5 == 0:
|
||||||
# sleep every 5 sends to avoid hitting email limits
|
# sleep every 5 sends to avoid hitting email limits
|
||||||
@ -125,7 +125,7 @@ def disable_mailbox(mailbox_id):
|
|||||||
email_msg.replace("\n", "<br>"),
|
email_msg.replace("\n", "<br>"),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.exception("Cannot send disable mailbox email to %s", mailbox.user)
|
LOG.e("Cannot send disable mailbox email to %s", mailbox.user)
|
||||||
|
|
||||||
|
|
||||||
def send_onboarding_emails(user):
|
def send_onboarding_emails(user):
|
||||||
|
@ -139,9 +139,9 @@ $(".pin-alias").change(async function () {
|
|||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
toastr.success(`${alias} is added to favorite`);
|
toastr.success(`${alias} is pinned`);
|
||||||
} else {
|
} else {
|
||||||
toastr.info(`${alias} is removed from favorite`);
|
toastr.info(`${alias} is unpinned`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||||
@ -172,7 +172,7 @@ $(".save-note").on("click", async function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toastr.success(`Note Saved`);
|
toastr.success(`Saved`);
|
||||||
} else {
|
} else {
|
||||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||||
// reset to the original value
|
// reset to the original value
|
||||||
|
13
static/package-lock.json
generated
vendored
13
static/package-lock.json
generated
vendored
@ -81,11 +81,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz",
|
||||||
"integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM="
|
"integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM="
|
||||||
},
|
},
|
||||||
"formbouncerjs": {
|
|
||||||
"version": "1.4.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/formbouncerjs/-/formbouncerjs-1.4.6.tgz",
|
|
||||||
"integrity": "sha512-uQL/ZFs5w79RPtgtPbmL8HTKrsEK2q7Fph/uBqe4sFNXIszcrVmHcJ3aj5sB4OTsFbV6igQX9819f/f5t5Wjgg=="
|
|
||||||
},
|
|
||||||
"intro.js": {
|
"intro.js": {
|
||||||
"version": "2.9.3",
|
"version": "2.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/intro.js/-/intro.js-2.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/intro.js/-/intro.js-2.9.3.tgz",
|
||||||
@ -101,6 +96,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/multiple-select/-/multiple-select-1.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/multiple-select/-/multiple-select-1.5.2.tgz",
|
||||||
"integrity": "sha512-sTNNRrjnTtB1b1+HTKcjQ/mjWY7Gvigo9F3C/3oTQCTFEpYzwaRYFPRAOu2SogfA1hEfyJTXjyS1VAbanJMsmA=="
|
"integrity": "sha512-sTNNRrjnTtB1b1+HTKcjQ/mjWY7Gvigo9F3C/3oTQCTFEpYzwaRYFPRAOu2SogfA1hEfyJTXjyS1VAbanJMsmA=="
|
||||||
},
|
},
|
||||||
|
"parsleyjs": {
|
||||||
|
"version": "2.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parsleyjs/-/parsleyjs-2.9.2.tgz",
|
||||||
|
"integrity": "sha512-DKS2XXTjEUZ1BJWUzgXAr+550kFBZrom2WYweubqdV7WzdNC1hjOajZDfeBPoAZMkXumJPlB3v37IKatbiW8zQ==",
|
||||||
|
"requires": {
|
||||||
|
"jquery": ">=1.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"popper.js": {
|
"popper.js": {
|
||||||
"version": "1.16.1",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
||||||
|
2
static/package.json
vendored
2
static/package.json
vendored
@ -19,9 +19,9 @@
|
|||||||
"@sentry/browser": "^5.30.0",
|
"@sentry/browser": "^5.30.0",
|
||||||
"bootbox": "^5.5.2",
|
"bootbox": "^5.5.2",
|
||||||
"font-awesome": "^4.7.0",
|
"font-awesome": "^4.7.0",
|
||||||
"formbouncerjs": "^1.4.6",
|
|
||||||
"intro.js": "^2.9.3",
|
"intro.js": "^2.9.3",
|
||||||
"multiple-select": "^1.5.2",
|
"multiple-select": "^1.5.2",
|
||||||
|
"parsleyjs": "^2.9.2",
|
||||||
"qrious": "^4.0.2",
|
"qrious": "^4.0.2",
|
||||||
"toastr": "^2.1.4",
|
"toastr": "^2.1.4",
|
||||||
"vue": "^2.6.14"
|
"vue": "^2.6.14"
|
||||||
|
45
static/style.css
vendored
45
static/style.css
vendored
@ -125,3 +125,48 @@ em {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.disabled-content {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parsley CSS */
|
||||||
|
input.parsley-success,
|
||||||
|
select.parsley-success,
|
||||||
|
textarea.parsley-success {
|
||||||
|
color: #468847;
|
||||||
|
background-color: #DFF0D8;
|
||||||
|
border: 1px solid #D6E9C6;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.parsley-error,
|
||||||
|
select.parsley-error,
|
||||||
|
textarea.parsley-error {
|
||||||
|
color: #B94A48;
|
||||||
|
background-color: #F2DEDE;
|
||||||
|
border: 1px solid #EED3D7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parsley-errors-list {
|
||||||
|
margin: 2px 0 3px;
|
||||||
|
padding: 0;
|
||||||
|
list-style-type: none;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 0.9em;
|
||||||
|
opacity: 0;
|
||||||
|
color: #B94A48;
|
||||||
|
|
||||||
|
transition: all .3s ease-in;
|
||||||
|
-o-transition: all .3s ease-in;
|
||||||
|
-moz-transition: all .3s ease-in;
|
||||||
|
-webkit-transition: all .3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parsley-errors-list.filled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
/* END Parsley CSS */
|
||||||
|
|
||||||
|
.domain_detail_content {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
<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 }}">
|
||||||
|
|
||||||
<title>
|
<title>
|
||||||
{% block title %}{% endblock %} | SimpleLogin
|
{% block title %}{% endblock %} | SimpleLogin
|
||||||
@ -77,8 +78,9 @@
|
|||||||
<script
|
<script
|
||||||
src="{{ url_for('static', filename='node_modules/multiple-select/dist/multiple-select.min.js') }}"></script>
|
src="{{ url_for('static', filename='node_modules/multiple-select/dist/multiple-select.min.js') }}"></script>
|
||||||
|
|
||||||
<!-- bouncer library -->
|
<!-- Parseley library -->
|
||||||
<script src="{{ url_for('static', filename='node_modules/formbouncerjs/dist/bouncer.js') }}"></script>
|
<script src="{{ url_for('static', filename='node_modules/parsleyjs/dist/parsley.min.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='node_modules/parsleyjs/dist/i18n/en.js') }}"></script>
|
||||||
|
|
||||||
|
|
||||||
{% if PLAUSIBLE_HOST and PLAUSIBLE_DOMAIN %}
|
{% if PLAUSIBLE_HOST and PLAUSIBLE_DOMAIN %}
|
||||||
|
38
templates/emails/transactional/hotmail-complaint.html
Normal file
38
templates/emails/transactional/hotmail-complaint.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% call text() %}
|
||||||
|
This is SimpleLogin team. <br>
|
||||||
|
Hotmail has informed us about an email sent to <b>{{ alias.email }}</b> that might have been considered as spam,
|
||||||
|
either by you or by Hotmail spam filter.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
Please note that explicitly marking a SimpleLogin's forwarded email as Spam affects SimpleLogin email delivery,
|
||||||
|
has a negative effect for all users and
|
||||||
|
is a violation of our terms and condition.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
If that’s the case, please disable the alias instead if you don't want to receive the emails sent to this alias.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
If somehow Hotmail considers a forwarded email as Spam, it will help us if you can move the email out of the Spam
|
||||||
|
folder.
|
||||||
|
You can also set up a filter to avoid this from happening in the future using this guide at
|
||||||
|
https://simplelogin.io/help/
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
Looking to hear back from you.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
Best, <br/>
|
||||||
|
SimpleLogin Team.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
19
templates/emails/transactional/hotmail-complaint.txt.jinja2
Normal file
19
templates/emails/transactional/hotmail-complaint.txt.jinja2
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
Hi,
|
||||||
|
|
||||||
|
This is SimpleLogin team.
|
||||||
|
|
||||||
|
Hotmail has informed us about an email sent to {{ alias.email }} that might have been considered as spam,
|
||||||
|
either by you or by Hotmail spam filter.
|
||||||
|
|
||||||
|
Please note that explicitly marking a SimpleLogin's forwarded email as Spam affects SimpleLogin email delivery,
|
||||||
|
has a negative effect for all users and is a violation of our terms and condition.
|
||||||
|
|
||||||
|
If that’s the case, please disable the alias instead if you don't want to receive the emails sent to this alias.
|
||||||
|
|
||||||
|
If somehow Hotmail considers a forwarded email as Spam, it will help us if you can move the email out of the Spam folder.
|
||||||
|
You can also set up a filter to avoid this from happening in the future using this guide at https://simplelogin.io/help/
|
||||||
|
|
||||||
|
Looking to hear back from you.
|
||||||
|
|
||||||
|
Best,
|
||||||
|
SimpleLogin Team.
|
34
templates/emails/transactional/yahoo-complaint.html
Normal file
34
templates/emails/transactional/yahoo-complaint.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% call text() %}
|
||||||
|
This is SimpleLogin team. <br>
|
||||||
|
Yahoo has informed us about an email sent to <b>{{ alias.email }}</b> that might have been marked as spam.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
Please note that explicitly marking a SimpleLogin's forwarded email as Spam affects SimpleLogin email delivery,
|
||||||
|
has a negative effect for all users and
|
||||||
|
is a violation of our terms and condition.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
If that’s the case, please disable the alias instead if you don't want to receive the emails sent to this alias.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
If SimpleLogin isn’t useful for you, please know that you can simply delete your account on the Settings page.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
Looking to hear back from you.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call text() %}
|
||||||
|
Best, <br/>
|
||||||
|
SimpleLogin Team.
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
17
templates/emails/transactional/yahoo-complaint.txt.jinja2
Normal file
17
templates/emails/transactional/yahoo-complaint.txt.jinja2
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
Hi,
|
||||||
|
|
||||||
|
This is SimpleLogin team.
|
||||||
|
|
||||||
|
Yahoo has informed us about an email sent to {{ alias.email }} that might have been marked as spam.
|
||||||
|
|
||||||
|
Please note that explicitly marking a SimpleLogin's forwarded email as Spam affects SimpleLogin email delivery,
|
||||||
|
has a negative effect for all users and is a violation of our terms and condition.
|
||||||
|
|
||||||
|
If that’s the case, please disable the alias instead if you don't want to receive the emails sent to this alias.
|
||||||
|
|
||||||
|
If SimpleLogin isn’t useful for you, please know that you can simply delete your account on the Settings page.
|
||||||
|
|
||||||
|
Looking to hear back from you.
|
||||||
|
|
||||||
|
Best,
|
||||||
|
SimpleLogin Team.
|
@ -577,11 +577,11 @@ def test_create_contact_route_invalid_contact_email(flask_client):
|
|||||||
|
|
||||||
r = flask_client.post(
|
r = flask_client.post(
|
||||||
url_for("api.create_contact_route", alias_id=alias.id),
|
url_for("api.create_contact_route", alias_id=alias.id),
|
||||||
json={"contact": "with space@gmail.com"},
|
json={"contact": "@gmail.com"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert r.json["error"] == "invalid contact email with space@gmail.com"
|
assert r.json["error"] == "invalid contact email @gmail.com"
|
||||||
|
|
||||||
|
|
||||||
def test_delete_contact(flask_client):
|
def test_delete_contact(flask_client):
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user