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,
|
||||
E203,
|
||||
# Ignore "f-string is missing placeholders"
|
||||
F541
|
||||
F541,
|
||||
# allow bare "except"
|
||||
E722
|
||||
exclude =
|
||||
.git,
|
||||
__pycache__,
|
||||
|
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
@ -29,8 +29,7 @@ jobs:
|
||||
# optional (defaults to `postgres`)
|
||||
POSTGRES_USER: test
|
||||
ports:
|
||||
# maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
- 15432:5432
|
||||
# set health checks to wait until postgres has started
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
@ -64,6 +63,7 @@ jobs:
|
||||
poetry run black --check .
|
||||
flake8
|
||||
|
||||
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
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! 🎉👍
|
||||
|
||||
The project uses Flask and requires Python3.7+.
|
||||
|
||||
## 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.
|
||||
The project uses Flask, Python3.7+ and requires Postgres 12+ as dependency.
|
||||
|
||||
## 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:
|
||||
|
||||
- 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).
|
||||
|
||||
## Run code locally
|
||||
## Install dependencies
|
||||
|
||||
The project uses
|
||||
The project requires:
|
||||
- Python 3.7+ and [poetry](https://python-poetry.org/) to manage dependencies
|
||||
- Node v10 for front-end.
|
||||
- Postgres 12+
|
||||
|
||||
First, install all dependencies by running the following command.
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
## Run the code locally
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
```
|
||||
john@wick.com / password
|
||||
```
|
||||
then open http://localhost:7777, you should be able to login with `john@wick.com / password` account.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
sh new_migration.sh
|
||||
sh scripts/new-migration.sh
|
||||
```
|
||||
|
||||
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
|
||||
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:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
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
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
v=spf1 mx -all
|
||||
v=spf1 mx ~all
|
||||
```
|
||||
|
||||
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.actions import action
|
||||
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.models import User, ManualSubscription
|
||||
@ -122,6 +122,21 @@ class UserAdmin(SLModelView):
|
||||
|
||||
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):
|
||||
query = User.query.filter(User.id.in_(ids))
|
||||
@ -209,6 +224,12 @@ class ClientAdmin(SLModelView):
|
||||
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):
|
||||
column_searchable_list = ["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 email_validator import validate_email, EmailNotValidError
|
||||
from sqlalchemy.exc import IntegrityError, DataError
|
||||
|
||||
from app.config import BOUNCE_PREFIX_FOR_REPLY_PHASE
|
||||
@ -10,6 +11,7 @@ from app.email_utils import (
|
||||
send_cannot_create_domain_alias,
|
||||
can_create_directory_for_address,
|
||||
send_cannot_create_directory_alias_disabled,
|
||||
get_email_local_part,
|
||||
)
|
||||
from app.errors import AliasInTrashError
|
||||
from app.extensions import db
|
||||
@ -25,18 +27,23 @@ from app.models import (
|
||||
Mailbox,
|
||||
EmailLog,
|
||||
Contact,
|
||||
AutoCreateRule,
|
||||
)
|
||||
|
||||
|
||||
def try_auto_create(address: str) -> Optional[Alias]:
|
||||
"""Try to auto-create the alias using directory or catch-all domain"""
|
||||
if address.startswith(f"{BOUNCE_PREFIX_FOR_REPLY_PHASE}+"):
|
||||
LOG.exception(
|
||||
"alias %s can't start with %s", address, BOUNCE_PREFIX_FOR_REPLY_PHASE
|
||||
)
|
||||
LOG.e("alias %s can't start with %s", address, BOUNCE_PREFIX_FOR_REPLY_PHASE)
|
||||
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:
|
||||
alias = try_auto_create_directory(address)
|
||||
|
||||
@ -68,16 +75,14 @@ def try_auto_create_directory(address: str) -> Optional[Alias]:
|
||||
if not directory:
|
||||
return None
|
||||
|
||||
dir_user: User = directory.user
|
||||
user: User = directory.user
|
||||
|
||||
if not dir_user.can_create_new_alias():
|
||||
send_cannot_create_directory_alias(dir_user, address, directory_name)
|
||||
if not user.can_create_new_alias():
|
||||
send_cannot_create_directory_alias(user, address, directory_name)
|
||||
return None
|
||||
|
||||
if directory.disabled:
|
||||
send_cannot_create_directory_alias_disabled(
|
||||
dir_user, address, directory_name
|
||||
)
|
||||
send_cannot_create_directory_alias_disabled(user, address, directory_name)
|
||||
return None
|
||||
|
||||
try:
|
||||
@ -90,6 +95,7 @@ def try_auto_create_directory(address: str) -> Optional[Alias]:
|
||||
user_id=directory.user_id,
|
||||
directory_id=directory.id,
|
||||
mailbox_id=mailboxes[0].id,
|
||||
note=f"Created by directory {directory.name}",
|
||||
)
|
||||
db.session.flush()
|
||||
for i in range(1, len(mailboxes)):
|
||||
@ -101,22 +107,22 @@ def try_auto_create_directory(address: str) -> Optional[Alias]:
|
||||
db.session.commit()
|
||||
return alias
|
||||
except AliasInTrashError:
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"Alias %s was deleted before, cannot auto-create using directory %s, user %s",
|
||||
address,
|
||||
directory_name,
|
||||
dir_user,
|
||||
user,
|
||||
)
|
||||
return None
|
||||
except IntegrityError:
|
||||
LOG.warning("Alias %s already exists", address)
|
||||
LOG.w("Alias %s already exists", address)
|
||||
db.session.rollback()
|
||||
alias = Alias.get_by(email=address)
|
||||
return alias
|
||||
|
||||
|
||||
def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]:
|
||||
"""Try to create an alias with catch-all domain"""
|
||||
def try_auto_create_via_domain(address: str) -> Optional[Alias]:
|
||||
"""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
|
||||
# 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:
|
||||
return None
|
||||
|
||||
# custom_domain exists
|
||||
if not custom_domain.catch_all:
|
||||
if not custom_domain.catch_all and len(custom_domain.auto_create_rules) == 0:
|
||||
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
|
||||
|
||||
if not domain_user.can_create_new_alias():
|
||||
@ -139,13 +165,13 @@ def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]:
|
||||
|
||||
try:
|
||||
LOG.d("create alias %s for domain %s", address, custom_domain)
|
||||
mailboxes = custom_domain.mailboxes
|
||||
alias = Alias.create(
|
||||
email=address,
|
||||
user_id=custom_domain.user_id,
|
||||
custom_domain_id=custom_domain.id,
|
||||
automatic_creation=True,
|
||||
mailbox_id=mailboxes[0].id,
|
||||
note=alias_note,
|
||||
)
|
||||
db.session.flush()
|
||||
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()
|
||||
return alias
|
||||
except AliasInTrashError:
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"Alias %s was deleted before, cannot auto-create using domain catch-all %s, user %s",
|
||||
address,
|
||||
custom_domain,
|
||||
@ -164,12 +190,12 @@ def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]:
|
||||
)
|
||||
return None
|
||||
except IntegrityError:
|
||||
LOG.warning("Alias %s already exists", address)
|
||||
LOG.w("Alias %s already exists", address)
|
||||
db.session.rollback()
|
||||
alias = Alias.get_by(email=address)
|
||||
return alias
|
||||
except DataError:
|
||||
LOG.warning("Cannot create alias %s", address)
|
||||
LOG.w("Cannot create alias %s", address)
|
||||
db.session.rollback()
|
||||
return None
|
||||
|
||||
@ -184,7 +210,7 @@ def delete_alias(alias: Alias, user: User):
|
||||
if not DomainDeletedAlias.get_by(
|
||||
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(
|
||||
DomainDeletedAlias(
|
||||
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.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
|
||||
@ -129,119 +137,46 @@ def get_alias_infos_with_pagination(user, page_id=0, query=None) -> [AliasInfo]:
|
||||
|
||||
|
||||
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]:
|
||||
# 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()
|
||||
)
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
q = construct_alias_query(user)
|
||||
|
||||
if query:
|
||||
q = (
|
||||
# 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_(
|
||||
Alias.email.ilike(f"%{query}%"),
|
||||
# can't use match() here as it uses to_tsquery that expected a tsquery input
|
||||
# Alias.ts_vector.match(query),
|
||||
Alias.ts_vector.op("@@")(func.plainto_tsquery(query)),
|
||||
Alias.name.ilike(f"%{query}%"),
|
||||
Mailbox.email.ilike(f"%{query}%"),
|
||||
)
|
||||
q = q.filter(
|
||||
or_(
|
||||
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
|
||||
# Alias.ts_vector.match(query),
|
||||
Alias.ts_vector.op("@@")(func.plainto_tsquery("english", query)),
|
||||
Alias.name.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":
|
||||
q = q.filter(Alias.enabled)
|
||||
elif alias_filter == "disabled":
|
||||
q = q.filter(Alias.enabled.is_(False))
|
||||
|
||||
q = q.order_by(Alias.pinned.desc())
|
||||
elif alias_filter == "pinned":
|
||||
q = q.filter(Alias.pinned)
|
||||
elif alias_filter == "hibp":
|
||||
q = q.filter(Alias.hibp_breaches.any())
|
||||
|
||||
if sort == "old2new":
|
||||
q = q.order_by(Alias.created_at)
|
||||
@ -251,10 +186,16 @@ def get_alias_infos_with_pagination_v3(
|
||||
q = q.order_by(Alias.email)
|
||||
elif sort == "z2a":
|
||||
q = q.order_by(Alias.email.desc())
|
||||
elif alias_filter == "hibp":
|
||||
q = q.filter(Alias.hibp_breaches.any())
|
||||
else:
|
||||
# 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 = 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))
|
||||
|
||||
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 jsonify
|
||||
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.email_utils import (
|
||||
parseaddr_unicode,
|
||||
is_valid_email,
|
||||
generate_reply_email,
|
||||
)
|
||||
from app.extensions import db
|
||||
@ -400,10 +400,11 @@ def create_contact_route(alias_id):
|
||||
if not contact_addr:
|
||||
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):
|
||||
return jsonify(error=f"invalid contact email {contact_email}"), 400
|
||||
contact_name, contact_email = full_address.display_name, full_address.address
|
||||
|
||||
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
|
||||
|
||||
"""
|
||||
LOG.debug("request for /apple/process_payment")
|
||||
LOG.d("request for /apple/process_payment")
|
||||
user = g.user
|
||||
data = request.get_json()
|
||||
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",
|
||||
# "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()
|
||||
if not (
|
||||
data
|
||||
@ -282,7 +282,7 @@ def apple_update_notification():
|
||||
db.session.commit()
|
||||
return jsonify(ok=True), 200
|
||||
else:
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"No existing AppleSub for original_transaction_id %s",
|
||||
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}
|
||||
)
|
||||
except RequestException:
|
||||
LOG.warning("cannot call Apple server %s", _PROD_URL)
|
||||
LOG.w("cannot call Apple server %s", _PROD_URL)
|
||||
return None
|
||||
|
||||
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
|
||||
|
||||
if r.json() == {"status": 21007}:
|
||||
# try sandbox_url
|
||||
LOG.warning("Use the sandbox url instead")
|
||||
LOG.w("Use the sandbox url instead")
|
||||
r = requests.post(
|
||||
_SANDBOX_URL,
|
||||
json={"receipt-data": receipt_data, "password": password},
|
||||
@ -472,7 +472,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
||||
# }
|
||||
|
||||
if data["status"] != 0:
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"verifyReceipt status !=0, probably invalid receipt. User %s",
|
||||
user,
|
||||
)
|
||||
@ -499,7 +499,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
||||
# }
|
||||
transactions = data["receipt"]["in_app"]
|
||||
if not transactions:
|
||||
LOG.warning("Empty transactions in data %s", data)
|
||||
LOG.w("Empty transactions in data %s", data)
|
||||
return None
|
||||
|
||||
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:
|
||||
# the same original_transaction_id has been used on another account
|
||||
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
|
||||
|
||||
LOG.d(
|
||||
|
@ -96,7 +96,7 @@ def auth_register():
|
||||
if not password or len(password) < 8:
|
||||
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)
|
||||
db.session.flush()
|
||||
|
||||
@ -166,7 +166,7 @@ def auth_activate():
|
||||
|
||||
return jsonify(error="Wrong email or code"), 400
|
||||
|
||||
LOG.debug("activate user %s", user)
|
||||
LOG.d("activate user %s", user)
|
||||
user.activated = True
|
||||
AccountActivation.delete(account_activation.id)
|
||||
db.session.commit()
|
||||
|
@ -69,10 +69,10 @@ def new_custom_alias_v2():
|
||||
try:
|
||||
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
||||
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
|
||||
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
|
||||
|
||||
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
|
||||
@ -184,10 +184,10 @@ def new_custom_alias_v3():
|
||||
try:
|
||||
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
||||
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
|
||||
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
|
||||
|
||||
if not verify_prefix_suffix(user, alias_prefix, alias_suffix):
|
||||
|
@ -48,7 +48,7 @@ def new_random_alias():
|
||||
elif mode == "uuid":
|
||||
scheme = AliasGeneratorEnum.uuid.value
|
||||
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)
|
||||
db.session.commit()
|
||||
|
@ -87,7 +87,7 @@ def update_setting():
|
||||
|
||||
# sanity check
|
||||
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
|
||||
else:
|
||||
user.default_alias_custom_domain_id = custom_domain.id
|
||||
|
@ -12,7 +12,7 @@
|
||||
<form class="card" method="post">
|
||||
{{ form.csrf_token }}
|
||||
<div class="card-body p-6">
|
||||
<div class="card-title">Forgot password</div>
|
||||
<h1 class="card-title">Forgot password</h1>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email address</label>
|
||||
|
@ -16,7 +16,7 @@
|
||||
<form class="card" style="border-radius: 2%" method="post">
|
||||
{{ form.csrf_token }}
|
||||
<div class="card-body p-6">
|
||||
<div class="card-title">Welcome back!</div>
|
||||
<h1 class="card-title">Welcome back!</h1>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email address</label>
|
||||
{{ form.email(class="form-control", type="email", autofocus="true") }}
|
||||
|
@ -8,7 +8,7 @@
|
||||
<form class="card" style="border-radius: 2%" method="post">
|
||||
{{ form.csrf_token }}
|
||||
<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">
|
||||
<label class="form-label">Email address</label>
|
||||
|
@ -57,8 +57,8 @@ def activate():
|
||||
# The activation link contains the original page, for ex authorize page
|
||||
if "next" in request.args:
|
||||
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)
|
||||
else:
|
||||
LOG.debug("redirect user to dashboard")
|
||||
LOG.d("redirect user to dashboard")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
@ -115,7 +115,7 @@ def facebook_callback():
|
||||
# The activation link contains the original page, for ex authorize page
|
||||
if "facebook_next_url" in session:
|
||||
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 :)
|
||||
session.pop("facebook_next_url", None)
|
||||
|
@ -95,7 +95,7 @@ def fido():
|
||||
)
|
||||
new_sign_count = webauthn_assertion_response.verify()
|
||||
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")
|
||||
# Trigger rate limiter
|
||||
g.deduct_limit = True
|
||||
|
@ -75,7 +75,7 @@ def github_callback():
|
||||
break
|
||||
|
||||
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(
|
||||
"Cannot get a valid email from Github, please another way to login/sign up",
|
||||
"error",
|
||||
|
@ -101,7 +101,7 @@ def google_callback():
|
||||
# The activation link contains the original page, for ex authorize page
|
||||
if "google_next_url" in session:
|
||||
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 :)
|
||||
session.pop("google_next_url", None)
|
||||
|
@ -25,7 +25,7 @@ def login():
|
||||
|
||||
if current_user.is_authenticated:
|
||||
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)
|
||||
else:
|
||||
LOG.d("user is already authenticated, redirect to dashboard")
|
||||
|
@ -29,15 +29,15 @@ def after_login(user, next_url):
|
||||
else:
|
||||
return redirect(url_for("auth.mfa"))
|
||||
else:
|
||||
LOG.debug("log user %s in", user)
|
||||
LOG.d("log user %s in", user)
|
||||
login_user(user)
|
||||
|
||||
# User comes to login page from another page
|
||||
if next_url:
|
||||
LOG.debug("redirect user to %s", next_url)
|
||||
LOG.d("redirect user to %s", next_url)
|
||||
return redirect(next_url)
|
||||
else:
|
||||
LOG.debug("redirect user to dashboard")
|
||||
LOG.d("redirect user to dashboard")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
|
||||
|
@ -58,10 +58,10 @@ def recovery_route():
|
||||
|
||||
# User comes to login page from another page
|
||||
if next_url:
|
||||
LOG.debug("redirect user to %s", next_url)
|
||||
LOG.d("redirect user to %s", next_url)
|
||||
return redirect(next_url)
|
||||
else:
|
||||
LOG.debug("redirect user to dashboard")
|
||||
LOG.d("redirect user to dashboard")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
else:
|
||||
# Trigger rate limiter
|
||||
|
@ -53,7 +53,7 @@ def register():
|
||||
# 'challenge_ts': '2020-07-23T10:03:25',
|
||||
# 'hostname': '127.0.0.1'}
|
||||
if not hcaptcha_res["success"]:
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"User put wrong captcha %s %s",
|
||||
form.email.data,
|
||||
hcaptcha_res,
|
||||
@ -74,7 +74,7 @@ def register():
|
||||
if personal_email_already_used(email):
|
||||
flash(f"Email {email} already used", "error")
|
||||
else:
|
||||
LOG.debug("create user %s", email)
|
||||
LOG.d("create user %s", email)
|
||||
user = User.create(
|
||||
email=email,
|
||||
name="",
|
||||
|
@ -45,7 +45,6 @@ if config_file:
|
||||
else:
|
||||
load_dotenv()
|
||||
|
||||
RESET_DB = "RESET_DB" 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
|
||||
@ -158,7 +157,6 @@ DISABLE_ALIAS_SUFFIX = "DISABLE_ALIAS_SUFFIX" in os.environ
|
||||
UNSUBSCRIBER = os.environ.get("UNSUBSCRIBER")
|
||||
|
||||
DKIM_SELECTOR = b"dkim"
|
||||
DKIM_HEADERS = [b"from", b"to"]
|
||||
DKIM_PRIVATE_KEY = None
|
||||
|
||||
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_DIRECTORY_DISABLED_ALIAS_CREATION = "alert_directory_disabled_alias_creation"
|
||||
|
||||
ALERT_HOTMAIL_COMPLAINT = "alert_hotmail_complaint"
|
||||
ALERT_YAHOO_COMPLAINT = "alert_yahoo_complaint"
|
||||
|
||||
# <<<<< END ALERT EMAIL >>>>
|
||||
|
||||
# Disable onboarding emails
|
||||
@ -394,3 +395,11 @@ except Exception:
|
||||
HIBP_API_KEYS = sl_getenv("HIBP_API_KEYS", list) or []
|
||||
|
||||
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="card">
|
||||
<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">
|
||||
{% if api_key.last_used %}
|
||||
Last used: {{ api_key.last_used | dt }} <br>
|
||||
|
@ -25,15 +25,16 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<form method="post" data-parsley-validate>
|
||||
<div class="row mb-2">
|
||||
<div class="col-sm-6 mb-1 p-1" style="min-width: 4em">
|
||||
<input name="prefix" class="form-control"
|
||||
id="prefix"
|
||||
type="text"
|
||||
pattern="[0-9a-z-_.]{1,}"
|
||||
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"
|
||||
data-bouncer-message="Only lowercase letters, dots, numbers, dashes (-) and underscores (_) are currently supported."
|
||||
placeholder="Alias prefix, for example newsletter.com-123_xyz"
|
||||
autofocus required>
|
||||
|
||||
@ -69,7 +70,7 @@
|
||||
<div class="row mb-2">
|
||||
<div class="col p-1">
|
||||
<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 %}
|
||||
<option value="{{ mailbox.id }}" {% if mailbox.id == current_user.default_mailbox_id %}
|
||||
selected {% endif %}>
|
||||
@ -94,7 +95,7 @@
|
||||
|
||||
<div class="row">
|
||||
<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>
|
||||
</form>
|
||||
@ -105,9 +106,6 @@
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
// init bouncer
|
||||
new Bouncer('form');
|
||||
|
||||
$('.mailbox-select').multipleSelect();
|
||||
|
||||
// Ctrl-enter submit the form
|
||||
@ -117,7 +115,7 @@
|
||||
}
|
||||
})
|
||||
|
||||
$("#submit").on("click", async function () {
|
||||
$("#create").on("click", async function () {
|
||||
let that = $(this);
|
||||
let mailbox_ids = $(`#mailboxes`).val();
|
||||
let prefix = $('#prefix').val();
|
||||
|
@ -21,7 +21,7 @@
|
||||
{% if not current_user.is_premium() %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
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>
|
||||
</a>
|
||||
</div>
|
||||
@ -42,51 +42,27 @@
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}">{{ custom_domain.domain }}</a>
|
||||
{% if custom_domain.verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="Domain Verified">✅</span>
|
||||
{% if custom_domain.ownership_verified and not custom_domain.verified %}
|
||||
<a href="{{ url_for('dashboard.domain_detail_dns', custom_domain_id=custom_domain.id,
|
||||
_anchor='dns-setup') }}" class="btn btn-info btn-sm">
|
||||
Ownership verified. Setup the DNS
|
||||
</a>
|
||||
{% elif custom_domain.ownership_verified and custom_domain.verified %}
|
||||
<span class="badge badge-success">Domain ready</span>
|
||||
|
||||
<!-- custom_domain.ownership_verified is False -->
|
||||
{% 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>
|
||||
<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>
|
||||
{% endif %}
|
||||
</h5>
|
||||
|
||||
<h6 class="card-subtitle mb-4 text-muted">
|
||||
Created {{ custom_domain.created_at | dt }} <br>
|
||||
<span class="font-weight-bold">{{ custom_domain.nb_alias() }}</span> aliases.
|
||||
<br><br>
|
||||
|
||||
<b>Mailboxes:</b>
|
||||
<i class="fe fe-info" data-toggle="tooltip"
|
||||
title="Aliases created with this domain are automatically owned by these mailboxes">
|
||||
</i>
|
||||
<br>
|
||||
|
||||
{% set domain_mailboxes=custom_domain.mailboxes %}
|
||||
<form method="post" class="mt-2">
|
||||
<input type="hidden" name="form-name" value="update">
|
||||
<input type="hidden" name="domain-id" value="{{ custom_domain.id }}">
|
||||
|
||||
<div class="d-flex">
|
||||
<div class="flex-grow-1 mr-2">
|
||||
<select data-width="100%" required
|
||||
class="mailbox-select" multiple name="mailbox_ids">
|
||||
{% for mailbox in mailboxes %}
|
||||
<option value="{{ mailbox.id }}" {% if mailbox in domain_mailboxes %}
|
||||
selected {% endif %}>
|
||||
{{ mailbox.email }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-outline-primary btn-sm">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</h6>
|
||||
|
||||
<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) }}
|
||||
{{ render_field_errors(new_custom_domain_form.domain) }}
|
||||
<div class="small-text">
|
||||
Please use full path domain, for ex <em>my-subdomain.my-domain.com</em>
|
||||
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 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>
|
||||
</form>
|
||||
</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> <br>
|
||||
</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>.
|
||||
|
||||
|
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' }}">
|
||||
<span class="icon mr-3"><i class="fe fe-trash"></i></span>Deleted Alias
|
||||
</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 class="col-lg-9">
|
||||
<div class="card">
|
||||
<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 %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
@ -13,303 +13,359 @@
|
||||
<div class="">Please follow the steps below to set up your domain.</div>
|
||||
|
||||
<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
|
||||
minute or in our experience).
|
||||
DNS changes could take up to 24 hours to update.
|
||||
</div>
|
||||
|
||||
<div id="mx-form">
|
||||
<div class="font-weight-bold">1. MX record
|
||||
{% if not custom_domain.ownership_verified %}
|
||||
<div id="ownership-form">
|
||||
<div class="font-weight-bold">Domain ownership verification
|
||||
|
||||
{% if custom_domain.verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="MX Record Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="MX Record Not Verified">🚫 </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-2">Add the following MX DNS record to your domain. <br>
|
||||
Please note that there's a point (<em>.</em>) at the end target addresses.
|
||||
This is to make sure the <i>absolute</i> address is used.
|
||||
<br>
|
||||
Also some domain registrars (Namecheap, CloudFlare, etc) might use <em>@</em> for the root domain.
|
||||
</div>
|
||||
|
||||
{% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}
|
||||
<div class="mb-3 p-3 dns-record">
|
||||
Record: MX <br>
|
||||
Domain: {{ custom_domain.domain }} or
|
||||
<em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="@">@</em> <br>
|
||||
Priority: {{ priority }} <br>
|
||||
Target: <em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="{{ email_server }}">{{ email_server }}</em>
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
|
||||
<form method="post" action="#mx-form">
|
||||
<input type="hidden" name="form-name" value="check-mx">
|
||||
{% if custom_domain.verified %}
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
Re-verify
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Verify
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% if not custom_domain.ownership_verified %}
|
||||
|
||||
{% if not mx_ok %}
|
||||
<div class="text-danger mt-4">
|
||||
Your DNS is not correctly set. The MX record we obtain is:
|
||||
<div class="mb-3 p-3 dns-record">
|
||||
{% if not mx_errors %}
|
||||
(Empty)
|
||||
{% endif %}
|
||||
{% for r in mx_errors %}
|
||||
{{ r }} <br>
|
||||
{% endfor %}
|
||||
<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>
|
||||
{% if custom_domain.verified %}
|
||||
<div class="alert alert-danger">
|
||||
Without the MX record set up correctly, you can miss emails sent to your aliases.
|
||||
Please update the MX record ASAP.
|
||||
|
||||
<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>
|
||||
|
||||
<hr>
|
||||
<div id="mx-form">
|
||||
<div class="font-weight-bold">1. MX record
|
||||
|
||||
<div id="spf-form">
|
||||
<div class="font-weight-bold">2. SPF (Optional)
|
||||
{% if custom_domain.spf_verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="SPF Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="SPF Not Verified">🚫 </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
SPF <a href="https://en.wikipedia.org/wiki/Sender_Policy_Framework" target="_blank"
|
||||
rel="noopener">(Wikipedia↗)</a> is an email
|
||||
authentication method
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div class="mb-2">Add the following TXT DNS record to your domain.</div>
|
||||
|
||||
<div class="mb-2 p-3 dns-record">
|
||||
Record: TXT <br>
|
||||
Domain: {{ custom_domain.domain }} or
|
||||
<em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="@">@</em> <br>
|
||||
Value:
|
||||
<em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="{{ spf_record }}">
|
||||
{{ spf_record }}
|
||||
</em>
|
||||
</div>
|
||||
|
||||
<form method="post" action="#spf-form">
|
||||
<input type="hidden" name="form-name" value="check-spf">
|
||||
{% if custom_domain.spf_verified %}
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
Re-verify
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Verify
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if not spf_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 spf_errors %}
|
||||
(Empty)
|
||||
{% endif %}
|
||||
|
||||
{% for r in spf_errors %}
|
||||
{{ r }} <br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if custom_domain.spf_verified %}
|
||||
Without SPF setup, emails you sent from your alias might end up in Spam/Junk folder.
|
||||
{% if custom_domain.verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="MX Record Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="MX Record Not Verified">🚫 </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="mb-2">Add the following MX DNS record to your domain. <br>
|
||||
Please note that there's a point (<em>.</em>) at the end target addresses.
|
||||
This is to make sure the <i>absolute</i> address is used.
|
||||
<br>
|
||||
Also some domain registrars (Namecheap, CloudFlare, etc) might use <em>@</em> for the root domain.
|
||||
</div>
|
||||
|
||||
<div id="dkim-form">
|
||||
<div class="font-weight-bold">3. DKIM (Optional)
|
||||
{% if custom_domain.dkim_verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="SPF Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="DKIM Not Verified">🚫 </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}
|
||||
<div class="mb-3 p-3 dns-record">
|
||||
Record: MX <br>
|
||||
Domain: {{ custom_domain.domain }} or
|
||||
<b>@</b> <br>
|
||||
Priority: {{ priority }} <br>
|
||||
Target: <em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="{{ email_server }}">{{ email_server }}</em>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div>
|
||||
DKIM <a href="https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail" target="_blank" rel="noopener">(Wikipedia↗)</a>
|
||||
is an
|
||||
email
|
||||
authentication method
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div class="mb-2">Add the following CNAME DNS record to your domain.</div>
|
||||
|
||||
<div class="mb-2 p-3 dns-record">
|
||||
Record: CNAME <br>
|
||||
Domain: <em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="dkim._domainkey">dkim._domainkey</em> <br>
|
||||
Value:
|
||||
<em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="{{ dkim_cname + '.' }}" style="overflow-wrap: break-word">
|
||||
{{ dkim_cname }}.
|
||||
</em>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
Some DNS registrar might require a full record path, in this case please use
|
||||
<i>dkim._domainkey.{{ custom_domain.domain }}</i> as domain value instead. <br>
|
||||
If you are using a subdomain, e.g. <i>subdomain.domain.com</i>,
|
||||
you need to use <i>dkim._domainkey.subdomain</i> as domain value instead.
|
||||
<br>
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
If you are using CloudFlare, please make sure to <b>not</b> select the Proxy option. <br><br>
|
||||
<img src="/static/images/cloudflare-proxy.png" class="w-100">
|
||||
</div>
|
||||
|
||||
<form method="post" action="#dkim-form">
|
||||
<input type="hidden" name="form-name" value="check-dkim">
|
||||
{% if custom_domain.dkim_verified %}
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
Re-verify
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Verify
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if not dkim_ok %}
|
||||
<div class="text-danger mt-4">
|
||||
Your DNS is not correctly set.
|
||||
{% if dkim_errors %}
|
||||
The CNAME record we obtain for
|
||||
<em>dkim._domainkey.{{ custom_domain.domain }}</em> is:
|
||||
<form method="post" action="#mx-form">
|
||||
<input type="hidden" name="form-name" value="check-mx">
|
||||
{% if custom_domain.verified %}
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
Re-verify
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Verify
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if not mx_ok %}
|
||||
<div class="text-danger mt-4">
|
||||
Your DNS is not correctly set. The MX record we obtain is:
|
||||
<div class="mb-3 p-3 dns-record">
|
||||
{% for r in dkim_errors %}
|
||||
{% if not mx_errors %}
|
||||
(Empty)
|
||||
{% endif %}
|
||||
{% for r in mx_errors %}
|
||||
{{ r }} <br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if custom_domain.verified %}
|
||||
<div class="alert alert-danger">
|
||||
Without the MX record set up correctly, you can miss emails sent to your aliases.
|
||||
Please update the MX record ASAP.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if custom_domain.dkim_verified %}
|
||||
Without DKIM setup, emails you sent from your alias might end up in Spam/Junk folder.
|
||||
<hr>
|
||||
|
||||
<div id="spf-form">
|
||||
<div class="font-weight-bold">2. SPF (Optional)
|
||||
{% if custom_domain.spf_verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="SPF Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="SPF Not Verified">🚫 </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div>
|
||||
SPF <a href="https://en.wikipedia.org/wiki/Sender_Policy_Framework" target="_blank"
|
||||
rel="noopener">(Wikipedia↗)</a> is an email
|
||||
authentication method
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div id="dmarc-form">
|
||||
<div class="font-weight-bold">4. DMARC (Optional)
|
||||
{% if custom_domain.dmarc_verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="DMARC Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="DMARC Not Verified">🚫 </span>
|
||||
<div class="mb-2">Add the following TXT DNS record to your domain.</div>
|
||||
|
||||
<div class="mb-2 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="{{ spf_record }}">
|
||||
{{ spf_record }}
|
||||
</em>
|
||||
</div>
|
||||
|
||||
<form method="post" action="#spf-form">
|
||||
<input type="hidden" name="form-name" value="check-spf">
|
||||
{% if custom_domain.spf_verified %}
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
Re-verify
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Verify
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if not spf_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 spf_errors %}
|
||||
(Empty)
|
||||
{% endif %}
|
||||
|
||||
{% for r in spf_errors %}
|
||||
{{ r }} <br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if custom_domain.spf_verified %}
|
||||
Without SPF setup, emails you sent from your alias might end up in Spam/Junk folder.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
DMARC <a href="https://en.wikipedia.org/wiki/DMARC" target="_blank" rel="noopener">(Wikipedia↗)</a>
|
||||
is designed to protect the domain from unauthorized use, commonly known as email spoofing. <br>
|
||||
Built around SPF and DKIM, a DMARC policy tells the receiving mail server what to do if
|
||||
neither of those authentication methods passes.
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div class="mb-2">Add the following TXT DNS record to your domain.</div>
|
||||
<div id="dkim-form">
|
||||
<div class="font-weight-bold">3. DKIM (Optional)
|
||||
{% if custom_domain.dkim_verified %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="SPF Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="DKIM Not Verified">🚫 </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-2 p-3 dns-record">
|
||||
Record: TXT <br>
|
||||
Domain: <em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="_dmarc">_dmarc</em> <br>
|
||||
Value:
|
||||
<em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="{{ dmarc_record }}">
|
||||
{{ dmarc_record }}
|
||||
</em>
|
||||
</div>
|
||||
<div>
|
||||
DKIM <a href="https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail" target="_blank" rel="noopener">(Wikipedia↗)</a>
|
||||
is an
|
||||
email
|
||||
authentication method
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
Some DNS registrar might require a full record path, in this case please use
|
||||
<i>_dmarc.{{ custom_domain.domain }}</i> as domain value instead. <br>
|
||||
If you are using a subdomain, e.g. <i>subdomain.domain.com</i>,
|
||||
you need to use <i>_dmarc.subdomain</i> as domain value instead.
|
||||
<br>
|
||||
</div>
|
||||
<div class="mb-2">Add the following CNAME DNS record to your domain.</div>
|
||||
|
||||
<form method="post" action="#dmarc-form">
|
||||
<input type="hidden" name="form-name" value="check-dmarc">
|
||||
{% if custom_domain.dmarc_verified %}
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
Re-verify
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Verify
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
<div class="mb-2 p-3 dns-record">
|
||||
Record: CNAME <br>
|
||||
Domain: <em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="dkim._domainkey">dkim._domainkey</em> <br>
|
||||
Value:
|
||||
<em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="{{ dkim_cname + '.' }}" style="overflow-wrap: break-word">
|
||||
{{ dkim_cname }}.
|
||||
</em>
|
||||
</div>
|
||||
|
||||
{% if not dmarc_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" style="background-color: #eee">
|
||||
{% if not dmarc_errors %}
|
||||
(Empty)
|
||||
<div class="alert alert-info">
|
||||
Some DNS registrar might require a full record path, in this case please use
|
||||
<i>dkim._domainkey.{{ custom_domain.domain }}</i> as domain value instead. <br>
|
||||
If you are using a subdomain, e.g. <i>subdomain.domain.com</i>,
|
||||
you need to use <i>dkim._domainkey.subdomain</i> as domain value instead.
|
||||
<br>
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
If you are using CloudFlare, please make sure to <b>not</b> select the Proxy option. <br><br>
|
||||
<img src="/static/images/cloudflare-proxy.png" class="w-100">
|
||||
</div>
|
||||
|
||||
<form method="post" action="#dkim-form">
|
||||
<input type="hidden" name="form-name" value="check-dkim">
|
||||
{% if custom_domain.dkim_verified %}
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
Re-verify
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Verify
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if not dkim_ok %}
|
||||
<div class="text-danger mt-4">
|
||||
Your DNS is not correctly set.
|
||||
{% if dkim_errors %}
|
||||
The CNAME record we obtain for
|
||||
<em>dkim._domainkey.{{ custom_domain.domain }}</em> is:
|
||||
|
||||
<div class="mb-3 p-3 dns-record">
|
||||
{% for r in dkim_errors %}
|
||||
{{ r }} <br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for r in dmarc_errors %}
|
||||
{{ r }} <br>
|
||||
{% endfor %}
|
||||
{% if custom_domain.dkim_verified %}
|
||||
Without DKIM setup, emails you sent from your alias might end up in Spam/Junk folder.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="dmarc-form">
|
||||
<div class="font-weight-bold">4. DMARC (Optional)
|
||||
{% if custom_domain.dmarc_verified %}
|
||||
Without DMARC setup, emails sent from your alias might end up in the Spam/Junk folder.
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="DMARC Verified">✅</span>
|
||||
{% else %}
|
||||
<span class="cursor" data-toggle="tooltip" data-original-title="DMARC Not Verified">🚫 </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
DMARC <a href="https://en.wikipedia.org/wiki/DMARC" target="_blank" rel="noopener">(Wikipedia↗)</a>
|
||||
is designed to protect the domain from unauthorized use, commonly known as email spoofing. <br>
|
||||
Built around SPF and DKIM, a DMARC policy tells the receiving mail server what to do if
|
||||
neither of those authentication methods passes.
|
||||
</div>
|
||||
|
||||
<div class="mb-2">Add the following TXT DNS record to your domain.</div>
|
||||
|
||||
<div class="mb-2 p-3 dns-record">
|
||||
Record: TXT <br>
|
||||
Domain: <em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="_dmarc">_dmarc</em> <br>
|
||||
Value:
|
||||
<em data-toggle="tooltip"
|
||||
title="Click to copy"
|
||||
class="clipboard"
|
||||
data-clipboard-text="{{ dmarc_record }}">
|
||||
{{ dmarc_record }}
|
||||
</em>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
Some DNS registrar might require a full record path, in this case please use
|
||||
<i>_dmarc.{{ custom_domain.domain }}</i> as domain value instead. <br>
|
||||
If you are using a subdomain, e.g. <i>subdomain.domain.com</i>,
|
||||
you need to use <i>_dmarc.subdomain</i> as domain value instead.
|
||||
<br>
|
||||
</div>
|
||||
|
||||
<form method="post" action="#dmarc-form">
|
||||
<input type="hidden" name="form-name" value="check-dmarc">
|
||||
{% if custom_domain.dmarc_verified %}
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
Re-verify
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Verify
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if not dmarc_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" style="background-color: #eee">
|
||||
{% if not dmarc_errors %}
|
||||
(Empty)
|
||||
{% endif %}
|
||||
|
||||
{% for r in dmarc_errors %}
|
||||
{{ r }} <br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if custom_domain.dmarc_verified %}
|
||||
Without DMARC setup, emails sent from your alias might end up in the Spam/Junk folder.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
@ -7,39 +7,18 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block domain_detail_content %}
|
||||
<h1 class="h3"> {{ custom_domain.domain }}
|
||||
{% 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>
|
||||
<h1 class="h2 mb-1"> {{ custom_domain.domain }} </h1>
|
||||
|
||||
<div class="small-text">Created {{ custom_domain.created_at | dt }}</div>
|
||||
|
||||
{{ nb_alias }} aliases
|
||||
<div class="small-text">Created {{ custom_domain.created_at | dt }}. {{ nb_alias }} aliases</div>
|
||||
|
||||
<hr>
|
||||
<div>Catch All</div>
|
||||
<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>
|
||||
<h3 class="mb-1">Auto create/on the fly alias </h3>
|
||||
|
||||
|
||||
<div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="switch-catch-all">
|
||||
|
||||
<label class="custom-switch cursor mt-2 pl-0"
|
||||
data-toggle="tooltip"
|
||||
{% if custom_domain.catch_all %}
|
||||
@ -52,38 +31,81 @@
|
||||
{{ "checked" if custom_domain.catch_all else "" }}>
|
||||
|
||||
<span class="custom-switch-indicator"></span>
|
||||
<spam class="ml-2">
|
||||
Catch All
|
||||
</spam>
|
||||
|
||||
</label>
|
||||
</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>
|
||||
|
||||
|
||||
<hr>
|
||||
<div>Default Alias Name</div>
|
||||
<div class="small-text">
|
||||
This name will be used as the default alias name when you send
|
||||
or reply from an alias, unless overwritten by the alias specific name.
|
||||
<h3 class="mb-1">Default Display Name</h3>
|
||||
<div class="">
|
||||
Default display name for aliases created with <b>{{ custom_domain.domain }}</b>
|
||||
unless overwritten by the alias display name.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form method="post">
|
||||
<form method="post" class="form-inline">
|
||||
<input type="hidden" name="form-name" value="set-name">
|
||||
<div class="form-group">
|
||||
<input class="form-control"
|
||||
<input class="form-control mr-2"
|
||||
value="{{ custom_domain.name or "" }}"
|
||||
name="alias-name"
|
||||
placeholder="Alias name">
|
||||
placeholder="Alias Display Name">
|
||||
</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 %}
|
||||
<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 %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div>Random Prefix Generation</div>
|
||||
<div class="small-text">
|
||||
A random prefix can be generated for this domain for usage in the New Alias
|
||||
feature.
|
||||
<h3 class="mb-1">Random Prefix Generation</h3>
|
||||
<div class="">
|
||||
Add a random prefix for this domain when creating a new alias.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -106,20 +128,22 @@
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h3 class="mb-0">Delete Domain</h3>
|
||||
<div class="small-text mb-3">Please note that this operation is irreversible.
|
||||
All aliases associated with this domain will be also deleted.
|
||||
<h3 class="mb-1">Delete Domain</h3>
|
||||
<div class="mb-3">This operation is <b>irreversible</b>.
|
||||
All aliases associated with this domain will be deleted.
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<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>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$('.mailbox-select').multipleSelect();
|
||||
|
||||
$(".custom-switch-input").change(function (e) {
|
||||
$(this).closest("form").submit();
|
||||
});
|
||||
@ -149,3 +173,4 @@
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -92,124 +92,154 @@
|
||||
</div>
|
||||
<!-- END Global Stats -->
|
||||
|
||||
|
||||
<div class="row mb-3">
|
||||
|
||||
<div class="col-lg-6 pt-1" style="max-width: 25em">
|
||||
<div class="btn-group" role="group">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="create-custom-email">
|
||||
<button data-toggle="tooltip"
|
||||
title="Create a custom alias"
|
||||
class="btn btn-primary mr-2"><i class="fa fa-plus"></i> New Custom Alias
|
||||
</button>
|
||||
</form>
|
||||
<div class="btn-group" role="group">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="create-random-email">
|
||||
<button data-toggle="tooltip"
|
||||
title="Create a totally random alias"
|
||||
class="btn btn-success"><i class="fa fa-random"></i> Random Alias
|
||||
</button>
|
||||
</form>
|
||||
<button id="btnGroupDrop1" type="button" class="btn btn-success dropdown-toggle btn-group-border-left"
|
||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right border-left" aria-labelledby="btnGroupDrop1">
|
||||
<div class="">
|
||||
<!-- Controls: buttons & search -->
|
||||
<div id="filter-app">
|
||||
<div class="row mb-3">
|
||||
<div class="col d-flex">
|
||||
<div>
|
||||
<div class="btn-group" role="group">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="create-custom-email">
|
||||
<button data-toggle="tooltip"
|
||||
title="Create a custom alias"
|
||||
class="btn btn-primary mr-2"><i class="fa fa-plus"></i> New Custom Alias
|
||||
</button>
|
||||
</form>
|
||||
<div class="btn-group" role="group">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="create-random-email">
|
||||
<input type="hidden" name="generator_scheme" value="{{ AliasGeneratorEnum.word.value }}">
|
||||
<button class="dropdown-item">By Random Words</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="create-random-email">
|
||||
<input type="hidden" name="generator_scheme" value="{{ AliasGeneratorEnum.uuid.value }}">
|
||||
<button class="dropdown-item">By UUID</button>
|
||||
<button data-toggle="tooltip"
|
||||
title="Create a totally random alias"
|
||||
class="btn btn-success"><i class="fa fa-random"></i> Random Alias
|
||||
</button>
|
||||
</form>
|
||||
<button id="btnGroupDrop1" type="button" class="btn btn-success dropdown-toggle btn-group-border-left"
|
||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right border-left" aria-labelledby="btnGroupDrop1">
|
||||
<div class="">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="create-random-email">
|
||||
<input type="hidden" name="generator_scheme" value="{{ AliasGeneratorEnum.word.value }}">
|
||||
<button class="dropdown-item">By Random Words</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="">
|
||||
<form method="post">
|
||||
<input type="hidden" name="form-name" value="create-random-email">
|
||||
<input type="hidden" name="generator_scheme" value="{{ AliasGeneratorEnum.uuid.value }}">
|
||||
<button class="dropdown-item">By UUID</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="" style="margin-left: auto">
|
||||
<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 id="filter-app" class="col-lg-auto pt-1 flex-grow-1">
|
||||
<div class="float-right d-flex">
|
||||
<div class="row mb-2" v-if="showFilter" id="filter-control">
|
||||
<!-- Filter Control -->
|
||||
<div class="col d-flex">
|
||||
<form method="get" class="form-inline">
|
||||
<select name="sort"
|
||||
onchange="this.form.submit()"
|
||||
class="form-control mr-3 shadow">
|
||||
<option value="" {% if sort == "" %} selected {% endif %}>
|
||||
Sort by most recent activity
|
||||
</option>
|
||||
<option value="old2new" {% if sort == "old2new" %} selected {% endif %}>
|
||||
Alias Old-Recent
|
||||
</option>
|
||||
<option value="new2old" {% if sort == "new2old" %} selected {% endif %}>
|
||||
Alias Recent-Old
|
||||
</option>
|
||||
<option value="a2z" {% if sort == "a2z" %} selected {% endif %}>
|
||||
Alias A-Z
|
||||
</option>
|
||||
<option value="z2a" {% if sort == "z2a" %} selected {% endif %}>
|
||||
Alias Z-A
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Filter Control -->
|
||||
<div v-if="showFilter" id="filter-control">
|
||||
<form method="get" class="form-inline">
|
||||
<select name="sort"
|
||||
onchange="this.form.submit()"
|
||||
class="form-control mr-3 shadow">
|
||||
<option value="" {% if sort == "" %} selected {% endif %}>
|
||||
Sort by most recent activity
|
||||
<select name="filter"
|
||||
onchange="this.form.submit()"
|
||||
class="form-control mr-3 shadow"
|
||||
style="max-width: 200px">
|
||||
<option value="" {% if filter == "" %} selected {% endif %}>
|
||||
All Aliases
|
||||
</option>
|
||||
<option value="pinned" {% if filter == "pinned" %} selected {% endif %}>
|
||||
Pinned Aliases
|
||||
</option>
|
||||
<option value="enabled" {% if filter == "enabled" %} selected {% endif %}>
|
||||
Only Enabled Aliases
|
||||
</option>
|
||||
<option value="disabled" {% if filter == "disabled" %} selected {% endif %}>
|
||||
Only Disabled Aliases
|
||||
</option>
|
||||
<option value="hibp" {% if filter == "hibp" %} selected {% endif %}>
|
||||
Only Aliases Found In Data Breaches
|
||||
</option>
|
||||
{% for mailbox in current_user.mailboxes() %}
|
||||
<option value="mailbox:{{ mailbox.id }}" {% if filter == "mailbox:" ~ mailbox.id %}
|
||||
selected {% endif %}>
|
||||
{{ mailbox.email }}'s aliases
|
||||
</option>
|
||||
<option value="old2new" {% if sort == "old2new" %} selected {% endif %}>
|
||||
Alias Old-Recent
|
||||
</option>
|
||||
<option value="new2old" {% if sort == "new2old" %} selected {% endif %}>
|
||||
Alias Recent-Old
|
||||
</option>
|
||||
<option value="a2z" {% if sort == "a2z" %} selected {% endif %}>
|
||||
Alias A-Z
|
||||
</option>
|
||||
<option value="z2a" {% if sort == "z2a" %} selected {% endif %}>
|
||||
Alias Z-A
|
||||
</option>
|
||||
</select>
|
||||
{% endfor %}
|
||||
|
||||
<select name="filter"
|
||||
onchange="this.form.submit()"
|
||||
class="form-control mr-3 shadow"
|
||||
style="max-width: 200px">
|
||||
<option value="" {% if filter == "" %} selected {% endif %}>
|
||||
All Aliases
|
||||
{% 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>
|
||||
<option value="enabled" {% if filter == "enabled" %} selected {% endif %}>
|
||||
Only Enabled Aliases
|
||||
</option>
|
||||
<option value="disabled" {% if filter == "disabled" %} selected {% endif %}>
|
||||
Only Disabled Aliases
|
||||
</option>
|
||||
<option value="hibp" {% if filter == "hibp" %} selected {% endif %}>
|
||||
Only Aliases Found In Data Breaches
|
||||
</option>
|
||||
</select>
|
||||
{% endfor %}
|
||||
|
||||
<input type="search" name="query" placeholder="Enter to search for alias"
|
||||
class="form-control shadow mr-2"
|
||||
style="max-width: 15em"
|
||||
value="{{ query }}">
|
||||
</select>
|
||||
|
||||
{% if query or sort or filter %}
|
||||
<a href="{{ url_for('dashboard.index') }}"
|
||||
class="btn btn-light">Reset</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
<input type="search" name="query" placeholder="Enter to search for alias"
|
||||
class="form-control shadow mr-2"
|
||||
style="max-width: 15em"
|
||||
value="{{ query }}">
|
||||
|
||||
<div class="btn-group">
|
||||
<a v-if="!showFilter" @click="toggleFilter()" class="btn btn-outline-secondary">
|
||||
<i class="fe fe-chevrons-left"></i> Filters
|
||||
</a>
|
||||
|
||||
</form>
|
||||
|
||||
<div style="margin-left: auto">
|
||||
|
||||
{% if query or sort or filter %}
|
||||
<a href="{{ url_for('dashboard.index') }}"
|
||||
class="btn btn-outline-secondary">Reset</a>
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END Controls: buttons & search -->
|
||||
|
||||
<!-- Alias list -->
|
||||
<div class="row">
|
||||
{% for alias_info in alias_infos %}
|
||||
{% set alias = alias_info.alias %}
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card p-4 shadow-sm {% if alias.id == highlight_alias_id %} highlight-row {% endif %} ">
|
||||
<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 %} "
|
||||
{% if highlight_alias_id and alias.id != highlight_alias_id %}
|
||||
style="opacity: 0.6"
|
||||
{% endif %}
|
||||
>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
@ -233,8 +263,8 @@
|
||||
{% endif %}
|
||||
|
||||
{% if alias.pinned %}
|
||||
<span class="fa fa-heart" data-toggle="tooltip"
|
||||
title="This alias added to favorite"></span>
|
||||
<span class="fa fa-thumb-tack" data-toggle="tooltip"
|
||||
title="This alias is pinned"></span>
|
||||
{% endif %}
|
||||
|
||||
{% if alias.hibp_breaches | length > 0 %}
|
||||
@ -299,7 +329,7 @@
|
||||
{{ email_log.created_at | dt }}
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
|
||||
</div>
|
||||
@ -307,6 +337,26 @@
|
||||
</div>
|
||||
<!-- 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 -->
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
@ -384,28 +434,9 @@
|
||||
</div>
|
||||
{% 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"
|
||||
title="Alias name is used when you send or reply from alias">
|
||||
Alias name
|
||||
title="When sending an email from this alias, the email will have 'Display Name <{{ alias.email }}>' as sender.">
|
||||
Display name
|
||||
<i class="fe fe-help-circle"></i>
|
||||
</div>
|
||||
|
||||
@ -444,8 +475,8 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="small-text mt-2" data-toogle="tooltip"
|
||||
title="Add alias to favorite so it's always pinned on top">
|
||||
Add to favorite
|
||||
title="it's always pinned on top">
|
||||
Pin this alias
|
||||
<i class="fe fe-help-circle"></i>
|
||||
</div>
|
||||
<div>
|
||||
@ -495,6 +526,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<!-- END Alias list -->
|
||||
|
||||
<!-- Only show pagination control if there are previous/next page -->
|
||||
{% if page > 0 or not last_page %}
|
||||
|
@ -49,8 +49,15 @@
|
||||
{% endif %}
|
||||
|
||||
{% 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") }}).
|
||||
<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() %}
|
||||
You are on the Premium plan which expires {{ coinbase_sub.end_at | dt }}
|
||||
({{ coinbase_sub.end_at.format("YYYY-MM-DD") }}).
|
||||
@ -283,14 +290,6 @@
|
||||
<input type="hidden" name="form-name" value="change-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 }}"
|
||||
{% if current_user.sender_format == SenderFormatEnum.AT.value %} selected {% endif %}>
|
||||
John Wick - john at wick.com
|
||||
@ -301,13 +300,6 @@
|
||||
John Wick - john(a)wick.com
|
||||
</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>
|
||||
|
||||
<button class="btn btn-outline-primary mt-3">Update</button>
|
||||
@ -358,8 +350,9 @@
|
||||
<input type="hidden" name="form-name" value="sender-in-ra">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="include-sender-ra" name="enable"
|
||||
{# 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">
|
||||
{# 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">
|
||||
<label for="include-sender-ra">Include sender address in reverse-alias</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary">Update</button>
|
||||
@ -389,6 +382,29 @@
|
||||
</div>
|
||||
<!-- 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-body">
|
||||
<div class="card-title">Quarantine</div>
|
||||
|
@ -11,14 +11,13 @@ from wtforms import StringField, validators, ValidationError
|
||||
from app.config import PAGE_LIMIT
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.email_utils import (
|
||||
parseaddr_unicode,
|
||||
is_valid_email,
|
||||
generate_reply_email,
|
||||
parse_full_address,
|
||||
)
|
||||
from app.extensions import db
|
||||
from app.log import LOG
|
||||
from app.models import Alias, Contact, EmailLog
|
||||
from app.utils import sanitize_email
|
||||
|
||||
|
||||
def email_validator():
|
||||
@ -182,8 +181,7 @@ def alias_contact_manager(alias_id):
|
||||
contact_addr = new_contact_form.email.data.strip()
|
||||
|
||||
try:
|
||||
contact_name, contact_email = parseaddr_unicode(contact_addr)
|
||||
contact_email = sanitize_email(contact_email)
|
||||
contact_name, contact_email = parse_full_address(contact_addr)
|
||||
except Exception:
|
||||
flash(f"{contact_addr} is invalid", "error")
|
||||
return redirect(
|
||||
|
@ -31,7 +31,7 @@ def batch_import_route():
|
||||
|
||||
bi = BatchImport.create(user_id=current_user.id, file_id=file.id)
|
||||
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
|
||||
Job.create(
|
||||
|
@ -21,7 +21,7 @@ def billing():
|
||||
|
||||
if request.method == "POST":
|
||||
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)
|
||||
|
||||
if success:
|
||||
@ -37,7 +37,7 @@ def billing():
|
||||
|
||||
return redirect(url_for("dashboard.billing"))
|
||||
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(
|
||||
current_user, sub.subscription_id, PADDLE_MONTHLY_PRODUCT_ID
|
||||
)
|
||||
@ -58,7 +58,7 @@ def billing():
|
||||
|
||||
return redirect(url_for("dashboard.billing"))
|
||||
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(
|
||||
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.email_utils import send_email
|
||||
from app.extensions import db
|
||||
from app.models import ManualSubscription, Coupon
|
||||
from app.models import (
|
||||
ManualSubscription,
|
||||
Coupon,
|
||||
Subscription,
|
||||
AppleSubscription,
|
||||
CoinbaseSubscription,
|
||||
)
|
||||
|
||||
|
||||
class CouponForm(FlaskForm):
|
||||
@ -18,20 +24,29 @@ class CouponForm(FlaskForm):
|
||||
@dashboard_bp.route("/coupon", methods=["GET", "POST"])
|
||||
@login_required
|
||||
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)
|
||||
if current_user._lifetime_or_active_subscription():
|
||||
manual_sub: ManualSubscription = ManualSubscription.get_by(
|
||||
user_id=current_user.id
|
||||
)
|
||||
can_use_coupon = True
|
||||
|
||||
# user has an non-manual subscription
|
||||
if not manual_sub or not manual_sub.is_active():
|
||||
flash("You already have another subscription.", "warning")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
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
|
||||
)
|
||||
if coinbase_subscription and coinbase_subscription.is_active():
|
||||
can_use_coupon = False
|
||||
|
||||
if not can_use_coupon:
|
||||
flash("You already have another subscription.", "warning")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
coupon_form = CouponForm()
|
||||
|
||||
@ -63,7 +78,7 @@ def coupon_route():
|
||||
user_id=current_user.id,
|
||||
end_at=arrow.now().shift(years=coupon.nb_year, days=1),
|
||||
comment="using coupon code",
|
||||
is_giveaway=False,
|
||||
is_giveaway=coupon.is_giveaway,
|
||||
commit=True,
|
||||
)
|
||||
flash(
|
||||
@ -72,9 +87,13 @@ def coupon_route():
|
||||
)
|
||||
|
||||
# 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(
|
||||
ADMIN_EMAIL,
|
||||
subject=f"User {current_user} applies the coupon",
|
||||
subject=subject,
|
||||
plaintext="",
|
||||
html="",
|
||||
)
|
||||
|
@ -249,11 +249,11 @@ def custom_alias():
|
||||
signed_alias_suffix_decoded
|
||||
)
|
||||
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")
|
||||
return redirect(url_for("dashboard.custom_alias"))
|
||||
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")
|
||||
return redirect(url_for("dashboard.custom_alias"))
|
||||
|
||||
@ -281,7 +281,7 @@ def custom_alias():
|
||||
)
|
||||
else:
|
||||
# should never happen as user can only choose their domains
|
||||
LOG.exception(
|
||||
LOG.e(
|
||||
"Deleted Alias %s does not belong to user %s",
|
||||
domain_deleted_alias,
|
||||
)
|
||||
@ -309,7 +309,7 @@ def custom_alias():
|
||||
)
|
||||
db.session.flush()
|
||||
except IntegrityError:
|
||||
LOG.warning("Alias %s already exists", full_alias)
|
||||
LOG.w("Alias %s already exists", full_alias)
|
||||
db.session.rollback()
|
||||
flash("Unknown error, please retry", "error")
|
||||
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
|
||||
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
|
||||
|
||||
# SimpleLogin domain case:
|
||||
@ -365,17 +365,17 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
|
||||
):
|
||||
|
||||
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
|
||||
|
||||
else:
|
||||
if alias_domain not in user_custom_domains:
|
||||
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
|
||||
|
||||
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 True
|
||||
|
@ -88,43 +88,6 @@ def custom_domain():
|
||||
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(
|
||||
"dashboard/custom_domain.html",
|
||||
|
@ -1,7 +1,10 @@
|
||||
import re2 as re
|
||||
from threading import Thread
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
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.dashboard.base import dashboard_bp
|
||||
@ -14,29 +17,65 @@ from app.dns_utils import (
|
||||
from app.email_utils import send_email
|
||||
from app.extensions import db
|
||||
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"])
|
||||
@login_required
|
||||
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:
|
||||
flash("You cannot see this page", "warning")
|
||||
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
|
||||
dkim_cname = f"dkim._domainkey.{EMAIL_DOMAIN}"
|
||||
|
||||
dmarc_record = "v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s"
|
||||
|
||||
mx_ok = spf_ok = dkim_ok = dmarc_ok = True
|
||||
mx_errors = spf_errors = dkim_errors = dmarc_errors = []
|
||||
mx_ok = spf_ok = dkim_ok = dmarc_ok = ownership_ok = True
|
||||
mx_errors = spf_errors = dkim_errors = dmarc_errors = ownership_errors = []
|
||||
|
||||
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)
|
||||
|
||||
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"])
|
||||
@login_required
|
||||
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:
|
||||
flash("You cannot see this page", "warning")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
@ -191,6 +232,47 @@ def domain_detail(custom_domain_id):
|
||||
return redirect(
|
||||
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":
|
||||
name = custom_domain.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())
|
||||
|
||||
|
||||
def delete_domain(custom_domain_id: CustomDomain):
|
||||
def delete_domain(custom_domain_id: int):
|
||||
from server import create_light_app
|
||||
|
||||
with create_light_app().app_context():
|
||||
@ -288,3 +370,167 @@ def domain_detail_trash(custom_domain_id):
|
||||
domain_deleted_aliases=domain_deleted_aliases,
|
||||
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
|
||||
next_url = request.args.get("next")
|
||||
if next_url:
|
||||
LOG.debug("redirect user to %s", next_url)
|
||||
LOG.d("redirect user to %s", next_url)
|
||||
return redirect(next_url)
|
||||
else:
|
||||
LOG.debug("redirect user to dashboard")
|
||||
LOG.d("redirect user to dashboard")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
else:
|
||||
flash("Incorrect password", "warning")
|
||||
|
@ -55,7 +55,7 @@ def fido_setup():
|
||||
try:
|
||||
fido_credential = fido_reg_response.verify()
|
||||
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")
|
||||
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 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.dashboard.base import dashboard_bp
|
||||
from app.extensions import db, limiter
|
||||
@ -69,7 +69,7 @@ def index():
|
||||
try:
|
||||
highlight_alias_id = int(request.args.get("highlight_alias_id"))
|
||||
except ValueError:
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"highlight_alias_id must be a number, received %s",
|
||||
request.args.get("highlight_alias_id"),
|
||||
)
|
||||
@ -150,11 +150,29 @@ def index():
|
||||
|
||||
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(
|
||||
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
|
||||
|
||||
# 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(
|
||||
"dashboard/index.html",
|
||||
alias_infos=alias_infos,
|
||||
|
@ -1,3 +1,5 @@
|
||||
from threading import Thread
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
@ -49,10 +51,13 @@ def mailbox_route():
|
||||
flash("You cannot delete default mailbox", "error")
|
||||
return redirect(url_for("dashboard.mailbox_route"))
|
||||
|
||||
email = mailbox.email
|
||||
Mailbox.delete(mailbox_id)
|
||||
db.session.commit()
|
||||
flash(f"Mailbox {email} has been deleted", "success")
|
||||
LOG.d("Schedule deleting %s", mailbox)
|
||||
Thread(target=delete_mailbox, args=(mailbox.id,)).start()
|
||||
flash(
|
||||
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"))
|
||||
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):
|
||||
s = Signer(MAILBOX_SECRET)
|
||||
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")
|
||||
def mailbox_confirm_change_route():
|
||||
s = Signer(MAILBOX_SECRET)
|
||||
mailbox_id = request.args.get("mailbox_id")
|
||||
signed_mailbox_id = request.args.get("mailbox_id")
|
||||
|
||||
try:
|
||||
r_id = int(s.unsign(mailbox_id))
|
||||
mailbox_id = int(s.unsign(signed_mailbox_id))
|
||||
except Exception:
|
||||
flash("Invalid link", "error")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
else:
|
||||
mailbox = Mailbox.get(r_id)
|
||||
mailbox = Mailbox.get(mailbox_id)
|
||||
|
||||
# new_email can be None if user cancels change in the meantime
|
||||
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.new_email = None
|
||||
|
||||
|
@ -12,6 +12,7 @@ from app.config import (
|
||||
)
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.log import LOG
|
||||
from app.models import AppleSubscription
|
||||
|
||||
|
||||
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
|
||||
@ -21,6 +22,10 @@ def pricing():
|
||||
flash("You are already a premium user", "warning")
|
||||
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(
|
||||
"dashboard/pricing.html",
|
||||
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_login import login_required, current_user
|
||||
|
@ -15,7 +15,7 @@ def refused_email_route():
|
||||
try:
|
||||
highlight_id = int(highlight_id)
|
||||
except ValueError:
|
||||
LOG.warning("Cannot parse highlight_id %s", highlight_id)
|
||||
LOG.w("Cannot parse highlight_id %s", highlight_id)
|
||||
highlight_id = None
|
||||
|
||||
email_logs: [EmailLog] = (
|
||||
|
@ -105,7 +105,7 @@ def setting():
|
||||
other_email_change: EmailChange = EmailChange.get_by(
|
||||
new_email=new_email
|
||||
)
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"Another user has a pending %s with the same email address. Current user:%s",
|
||||
other_email_change,
|
||||
current_user,
|
||||
@ -193,7 +193,7 @@ def setting():
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
|
||||
# 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(
|
||||
name=JOB_DELETE_ACCOUNT,
|
||||
payload={"user_id": current_user.id},
|
||||
@ -236,7 +236,7 @@ def setting():
|
||||
custom_domain.user_id != current_user.id
|
||||
or not custom_domain.verified
|
||||
):
|
||||
LOG.exception(
|
||||
LOG.e(
|
||||
"%s cannot use domain %s", current_user, default_domain
|
||||
)
|
||||
else:
|
||||
@ -300,6 +300,15 @@ def setting():
|
||||
db.session.commit()
|
||||
flash("Your preference has been updated", "success")
|
||||
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":
|
||||
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)
|
||||
else:
|
||||
# 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
|
||||
|
@ -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
|
||||
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
|
||||
# E401 = "421 SL E401 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 "
|
||||
"at a rate that prevents additional messages from being delivered."
|
||||
)
|
||||
E523 = "550 SL E523 Unknown error"
|
||||
|
@ -1,26 +1,33 @@
|
||||
import base64
|
||||
import email
|
||||
import enum
|
||||
import os
|
||||
import quopri
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from email.errors import HeaderParseError
|
||||
from email.header import decode_header
|
||||
import uuid
|
||||
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.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import make_msgid, formatdate, parseaddr
|
||||
from smtplib import SMTP, SMTPServerDisconnected
|
||||
from typing import Tuple, List, Optional
|
||||
from email.utils import make_msgid, formatdate
|
||||
from smtplib import SMTP, SMTPServerDisconnected, SMTPException
|
||||
from typing import Tuple, List, Optional, Union
|
||||
|
||||
import arrow
|
||||
import dkim
|
||||
import re2 as re
|
||||
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 sqlalchemy import func
|
||||
from validate_email import validate_email
|
||||
|
||||
from app.config import (
|
||||
SUPPORT_EMAIL,
|
||||
@ -29,7 +36,6 @@ from app.config import (
|
||||
NOT_SEND_EMAIL,
|
||||
DKIM_SELECTOR,
|
||||
DKIM_PRIVATE_KEY,
|
||||
DKIM_HEADERS,
|
||||
ALIAS_DOMAINS,
|
||||
SUPPORT_NAME,
|
||||
POSTFIX_SUBMISSION_TLS,
|
||||
@ -44,6 +50,8 @@ from app.config import (
|
||||
TRANSACTIONAL_BOUNCE_EMAIL,
|
||||
ALERT_SPF,
|
||||
POSTFIX_PORT_FORWARD,
|
||||
TEMP_DIR,
|
||||
ALIAS_AUTOMATIC_DISABLE,
|
||||
)
|
||||
from app.dns_utils import get_mx_domains
|
||||
from app.extensions import db
|
||||
@ -290,9 +298,7 @@ def send_email(
|
||||
|
||||
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)
|
||||
smtp.sendmail(TRANSACTIONAL_BOUNCE_EMAIL.format(transaction.id), to_email, msg_raw)
|
||||
@ -307,6 +313,7 @@ def send_email_with_rate_control(
|
||||
html=None,
|
||||
max_nb_alert=MAX_ALERT_24H,
|
||||
nb_day=1,
|
||||
ignore_smtp_error=False,
|
||||
) -> bool:
|
||||
"""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
|
||||
@ -322,7 +329,7 @@ def send_email_with_rate_control(
|
||||
)
|
||||
|
||||
if nb_alert >= max_nb_alert:
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"%s emails were sent to %s in the last %s days, alert type %s",
|
||||
nb_alert,
|
||||
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)
|
||||
db.session.commit()
|
||||
send_email(to_email, subject, plaintext, html)
|
||||
|
||||
if ignore_smtp_error:
|
||||
try:
|
||||
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
|
||||
|
||||
|
||||
@ -358,7 +373,7 @@ def send_email_at_most_times(
|
||||
).count()
|
||||
|
||||
if nb_alert >= max_times:
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"%s emails were sent to %s alert type %s",
|
||||
nb_alert,
|
||||
to_email,
|
||||
@ -376,8 +391,12 @@ def get_email_local_part(address) -> str:
|
||||
"""
|
||||
Get the local part from email
|
||||
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):
|
||||
@ -389,7 +408,39 @@ def get_email_domain_part(address):
|
||||
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):
|
||||
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")
|
||||
|
||||
# Specify headers in "byte" form
|
||||
@ -400,7 +451,7 @@ def add_dkim_signature(msg: Message, email_domain: str):
|
||||
DKIM_SELECTOR,
|
||||
email_domain.encode(),
|
||||
DKIM_PRIVATE_KEY.encode(),
|
||||
include_headers=DKIM_HEADERS,
|
||||
include_headers=dkim_headers,
|
||||
)
|
||||
sig = sig.decode()
|
||||
|
||||
@ -448,19 +499,19 @@ def delete_all_headers_except(msg: Message, headers: [str]):
|
||||
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"""
|
||||
# not allow creating directory with premium domain
|
||||
for domain in ALIAS_DOMAINS:
|
||||
if address.endswith("@" + domain):
|
||||
if email_address.endswith("@" + domain):
|
||||
return True
|
||||
|
||||
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"""
|
||||
domain = get_email_domain_part(address)
|
||||
domain = get_email_domain_part(email_address)
|
||||
if SLDomain.get_by(domain=domain):
|
||||
return True
|
||||
|
||||
@ -470,7 +521,7 @@ def is_valid_alias_address_domain(address) -> bool:
|
||||
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.
|
||||
Use the email domain as criteria. A domain can be used if it is not:
|
||||
- one of ALIAS_DOMAINS
|
||||
@ -478,7 +529,13 @@ def email_can_be_used_as_mailbox(email: str) -> bool:
|
||||
- one of custom domains
|
||||
- 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:
|
||||
return False
|
||||
|
||||
@ -531,9 +588,9 @@ def get_mx_domain_list(domain) -> [str]:
|
||||
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"""
|
||||
if User.get_by(email=email):
|
||||
if User.get_by(email=email_address):
|
||||
return True
|
||||
|
||||
return False
|
||||
@ -566,6 +623,30 @@ def get_orig_message_from_bounce(msg: Message) -> Message:
|
||||
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:
|
||||
"""using regex to get header value from bounce message
|
||||
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=") :])
|
||||
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 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:
|
||||
return ""
|
||||
|
||||
decoded_string, charset = decode_header(header)[0]
|
||||
if charset is not None:
|
||||
try:
|
||||
return decoded_string.decode(charset)
|
||||
except UnicodeDecodeError:
|
||||
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:
|
||||
if charset is not None:
|
||||
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")
|
||||
ret = ""
|
||||
for to_decoded_str, charset in decode_header(header):
|
||||
if charset is None:
|
||||
if type(to_decoded_str) is bytes:
|
||||
decoded_str = to_decoded_str.decode()
|
||||
else:
|
||||
name = decoded_string
|
||||
decoded_str = to_decoded_str
|
||||
else:
|
||||
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:
|
||||
name = name.decode()
|
||||
return name, email
|
||||
return ret
|
||||
|
||||
|
||||
def copy(msg: Message) -> Message:
|
||||
"""return a copy of message"""
|
||||
try:
|
||||
# prefer the unicode way
|
||||
return email.message_from_string(msg.as_string())
|
||||
except (UnicodeEncodeError, KeyError, LookupError):
|
||||
LOG.warning("as_string() fails, try to_bytes")
|
||||
return email.message_from_bytes(to_bytes(msg))
|
||||
return deepcopy(msg)
|
||||
except Exception:
|
||||
LOG.w("deepcopy fails, try string parsing")
|
||||
try:
|
||||
return message_from_string(msg.as_string())
|
||||
except (UnicodeEncodeError, KeyError, LookupError):
|
||||
LOG.w("as_string() fails, try bytes parsing")
|
||||
return message_from_bytes(to_bytes(msg))
|
||||
|
||||
|
||||
def to_bytes(msg: Message):
|
||||
@ -709,17 +766,15 @@ def to_bytes(msg: Message):
|
||||
try:
|
||||
return msg.as_bytes()
|
||||
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:
|
||||
return msg.as_bytes(policy=email.policy.SMTP)
|
||||
return msg.as_bytes(policy=policy.SMTP)
|
||||
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:
|
||||
return msg.as_bytes(policy=email.policy.SMTPUTF8)
|
||||
return msg.as_bytes(policy=policy.SMTPUTF8)
|
||||
except UnicodeEncodeError:
|
||||
LOG.warning(
|
||||
"as_bytes fails with SMTPUTF8 policy, try converting to string"
|
||||
)
|
||||
LOG.w("as_bytes fails with SMTPUTF8 policy, try converting to string")
|
||||
msg_string = msg.as_string()
|
||||
try:
|
||||
return msg_string.encode()
|
||||
@ -740,10 +795,16 @@ def should_add_dkim_signature(domain: str) -> bool:
|
||||
|
||||
|
||||
def is_valid_email(email_address: str) -> bool:
|
||||
"""Used to check whether an email address is valid"""
|
||||
return validate_email(
|
||||
email_address=email_address, check_mx=False, use_blacklist=False
|
||||
)
|
||||
"""
|
||||
Used to check whether an email address is valid
|
||||
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):
|
||||
@ -773,7 +834,7 @@ def get_encoding(msg: Message) -> EmailEncoding:
|
||||
if cte in ("amazonses.com",):
|
||||
return EmailEncoding.NO
|
||||
|
||||
LOG.exception("Unknown encoding %s", cte)
|
||||
LOG.e("Unknown encoding %s", cte)
|
||||
|
||||
return EmailEncoding.NO
|
||||
|
||||
@ -870,6 +931,7 @@ def replace(msg: Message, old, new) -> Message:
|
||||
or content_type == "text/calendar"
|
||||
or content_type == "text/directory"
|
||||
or content_type == "text/csv"
|
||||
or content_type == "text/x-python-script"
|
||||
):
|
||||
LOG.d("not applicable for %s", content_type)
|
||||
return msg
|
||||
@ -898,7 +960,7 @@ def replace(msg: Message, old, new) -> Message:
|
||||
clone_msg.set_payload(new_parts)
|
||||
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
|
||||
|
||||
|
||||
@ -974,7 +1036,10 @@ def should_disable(alias: Alias) -> bool:
|
||||
"""Disable an alias if it has too many bounces recently"""
|
||||
# Bypass the bounce rule
|
||||
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
|
||||
|
||||
yesterday = arrow.now().shift(days=-1)
|
||||
@ -1008,7 +1073,7 @@ def should_disable(alias: Alias) -> bool:
|
||||
.count()
|
||||
)
|
||||
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, "
|
||||
"disable alias %s",
|
||||
alias,
|
||||
@ -1088,7 +1153,7 @@ def spf_pass(
|
||||
try:
|
||||
r = spf.check2(i=ip, s=envelope.mail_from, h=None)
|
||||
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:
|
||||
# TODO: Handle temperr case (e.g. dns timeout)
|
||||
# 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 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
|
||||
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)
|
||||
|
||||
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"])
|
||||
note = row["note"]
|
||||
except KeyError:
|
||||
LOG.warning("Cannot parse row %s", row)
|
||||
LOG.w("Cannot parse row %s", row)
|
||||
continue
|
||||
|
||||
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 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
|
||||
|
||||
if (
|
||||
|
@ -62,7 +62,7 @@ def verify_id_token(id_token) -> bool:
|
||||
try:
|
||||
jwt.JWT(key=_key, jwt=id_token)
|
||||
except Exception:
|
||||
LOG.exception("id token not verified")
|
||||
LOG.e("id token not verified")
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
@ -42,7 +42,7 @@ def _get_console_handler():
|
||||
return console_handler
|
||||
|
||||
|
||||
def _get_logger(name):
|
||||
def _get_logger(name) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
213
app/models.py
213
app/models.py
@ -5,10 +5,13 @@ from email.utils import formataddr
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
import arrow
|
||||
import sqlalchemy as sa
|
||||
from arrow import Arrow
|
||||
from flanker.addresslib import address
|
||||
from flask import url_for
|
||||
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_utils import ArrowType
|
||||
|
||||
@ -39,9 +42,6 @@ from app.utils import (
|
||||
random_word,
|
||||
)
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
|
||||
|
||||
class TSVector(sa.types.TypeDecorator):
|
||||
impl = TSVECTOR
|
||||
@ -83,12 +83,17 @@ class ModelMixin(object):
|
||||
def create(cls, **kw):
|
||||
# whether should call db.session.commit
|
||||
commit = kw.pop("commit", False)
|
||||
flush = kw.pop("flush", False)
|
||||
|
||||
r = cls(**kw)
|
||||
db.session.add(r)
|
||||
|
||||
if commit:
|
||||
db.session.commit()
|
||||
|
||||
if flush:
|
||||
db.session.flush()
|
||||
|
||||
return r
|
||||
|
||||
def save(self):
|
||||
@ -160,9 +165,7 @@ class PlanEnum(EnumE):
|
||||
# Specify the format for sender address
|
||||
class SenderFormatEnum(EnumE):
|
||||
AT = 0 # John Wick - john at wick.com
|
||||
VIA = 1 # john@wick.com via SimpleLogin
|
||||
A = 2 # John Wick - john(a)wick.com
|
||||
FULL = 3 # John Wick - john@wick.com
|
||||
|
||||
|
||||
class AliasGeneratorEnum(EnumE):
|
||||
@ -336,6 +339,12 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
||||
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
|
||||
def create(cls, email, name="", password=None, **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
|
||||
|
||||
def _lifetime_or_active_subscription(self) -> bool:
|
||||
def lifetime_or_active_subscription(self) -> bool:
|
||||
"""True if user has lifetime licence or active subscription"""
|
||||
if self.lifetime:
|
||||
return True
|
||||
@ -435,7 +444,7 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
||||
|
||||
def in_trial(self):
|
||||
"""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
|
||||
|
||||
if self.trial_end and arrow.now() < self.trial_end:
|
||||
@ -444,7 +453,7 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
||||
return False
|
||||
|
||||
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
|
||||
sub: Subscription = self.get_subscription()
|
||||
if sub and sub.cancelled:
|
||||
@ -468,10 +477,6 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
||||
if sub and not sub.cancelled:
|
||||
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)
|
||||
# user who has giveaway premium can decide to upgrade
|
||||
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
|
||||
- active subscription
|
||||
"""
|
||||
if self._lifetime_or_active_subscription():
|
||||
if self.lifetime_or_active_subscription():
|
||||
return True
|
||||
|
||||
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()
|
||||
if sub:
|
||||
if sub.cancelled:
|
||||
return f"Cancelled Paddle Subscription {sub.subscription_id}"
|
||||
return f"Cancelled Paddle Subscription {sub.subscription_id} {sub.plan_name()}"
|
||||
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)
|
||||
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
|
||||
- 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
|
||||
else:
|
||||
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 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 custom_domain.domain
|
||||
@ -695,11 +700,11 @@ class User(db.Model, ModelMixin, UserMixin, PasswordOracle):
|
||||
sl_domain = SLDomain.get(self.default_alias_public_domain_id)
|
||||
# sanity check
|
||||
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
|
||||
|
||||
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",
|
||||
self,
|
||||
sl_domain,
|
||||
@ -864,13 +869,11 @@ def generate_oauth_client_id(client_name) -> str:
|
||||
|
||||
# check that the client does not exist yet
|
||||
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
|
||||
|
||||
# Rerun the function
|
||||
LOG.warning(
|
||||
"client_id %s already exists, generate a new client_id", oauth_client_id
|
||||
)
|
||||
LOG.w("client_id %s already exists, generate a new client_id", oauth_client_id)
|
||||
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(
|
||||
email=random_email
|
||||
):
|
||||
LOG.debug("generate email %s", random_email)
|
||||
LOG.d("generate email %s", random_email)
|
||||
return random_email
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@ -1136,6 +1139,13 @@ class Alias(db.Model, ModelMixin):
|
||||
|
||||
__table_args__ = (
|
||||
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])
|
||||
@ -1234,7 +1244,7 @@ class Alias(db.Model, ModelMixin):
|
||||
elif 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():
|
||||
LOG.warning("%s not premium, cannot use %s", user, sl_domain)
|
||||
LOG.w("%s not premium, cannot use %s", user, sl_domain)
|
||||
else:
|
||||
random_email = generate_email(
|
||||
scheme=scheme, in_hex=in_hex, alias_domain=sl_domain.domain
|
||||
@ -1349,7 +1359,7 @@ class ClientUser(db.Model, ModelMixin):
|
||||
elif scope == Scope.EMAIL:
|
||||
# Use generated email
|
||||
if self.alias_id:
|
||||
LOG.debug(
|
||||
LOG.d(
|
||||
"Use gen email for user %s, client %s", self.user, self.client
|
||||
)
|
||||
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
|
||||
# the envelope mail_from
|
||||
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
|
||||
invalid_email = db.Column(
|
||||
@ -1443,12 +1451,10 @@ class Contact(db.Model, ModelMixin):
|
||||
# if no name, try to parse it from website_from
|
||||
if not name and self.website_from:
|
||||
try:
|
||||
from app.email_utils import parseaddr_unicode
|
||||
|
||||
name, _ = parseaddr_unicode(self.website_from)
|
||||
name = address.parse(self.website_from).display_name
|
||||
except Exception:
|
||||
# Skip if website_from is wrongly formatted
|
||||
LOG.warning(
|
||||
LOG.e(
|
||||
"Cannot parse contact %s website_from %s", self, self.website_from
|
||||
)
|
||||
name = ""
|
||||
@ -1477,26 +1483,19 @@ class Contact(db.Model, ModelMixin):
|
||||
`new_email` is a special reply address
|
||||
"""
|
||||
user = self.user
|
||||
if (
|
||||
not user
|
||||
or not SenderFormatEnum.has_value(user.sender_format)
|
||||
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()
|
||||
elif user.sender_format == SenderFormatEnum.A.value:
|
||||
formatted_email = self.website_email.replace("@", "(a)").strip()
|
||||
elif user.sender_format == SenderFormatEnum.FULL.value:
|
||||
formatted_email = self.website_email.strip()
|
||||
sender_format = user.sender_format if user else SenderFormatEnum.AT.value
|
||||
|
||||
# Prefix name to formatted email if available
|
||||
new_name = (
|
||||
(self.name + " - " + formatted_email)
|
||||
if self.name and self.name != self.website_email.strip()
|
||||
else formatted_email
|
||||
)
|
||||
if sender_format == SenderFormatEnum.AT.value:
|
||||
formatted_email = self.website_email.replace("@", " at ").strip()
|
||||
else:
|
||||
formatted_email = self.website_email.replace("@", "(a)").strip()
|
||||
|
||||
# Prefix name to formatted email if available
|
||||
new_name = (
|
||||
(self.name + " - " + formatted_email)
|
||||
if self.name and self.name != self.website_email.strip()
|
||||
else formatted_email
|
||||
)
|
||||
|
||||
new_addr = formataddr((new_name, self.reply_email)).strip()
|
||||
return new_addr.strip()
|
||||
@ -1757,25 +1756,19 @@ class ApiKey(db.Model, ModelMixin):
|
||||
|
||||
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), 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)
|
||||
times = db.Column(db.Integer, default=0, nullable=False)
|
||||
|
||||
user = db.relationship(User)
|
||||
|
||||
@classmethod
|
||||
def create(cls, user_id, name):
|
||||
# generate unique code
|
||||
found = False
|
||||
while not found:
|
||||
code = random_string(60)
|
||||
def create(cls, user_id, name=None, **kwargs):
|
||||
code = random_string(60)
|
||||
if cls.get_by(code=code):
|
||||
code = str(uuid.uuid4())
|
||||
|
||||
if not cls.get_by(code=code):
|
||||
found = True
|
||||
|
||||
a = cls(user_id=user_id, code=code, name=name)
|
||||
db.session.add(a)
|
||||
return a
|
||||
return super().create(user_id=user_id, name=name, code=code, **kwargs)
|
||||
|
||||
|
||||
class CustomDomain(db.Model, ModelMixin):
|
||||
@ -1785,6 +1778,7 @@ class CustomDomain(db.Model, ModelMixin):
|
||||
# default name to use when user replies/sends from alias
|
||||
name = db.Column(db.String(128), nullable=True, default=None)
|
||||
|
||||
# mx verified
|
||||
verified = db.Column(db.Boolean, nullable=False, default=False)
|
||||
dkim_verified = db.Column(
|
||||
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
|
||||
)
|
||||
|
||||
# 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])
|
||||
|
||||
@property
|
||||
@ -1828,10 +1842,71 @@ class CustomDomain(db.Model, ModelMixin):
|
||||
def get_trash_url(self):
|
||||
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):
|
||||
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):
|
||||
"""Store all deleted alias for a domain"""
|
||||
|
||||
@ -1876,6 +1951,10 @@ class Coupon(db.Model, ModelMixin):
|
||||
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):
|
||||
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
|
||||
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(
|
||||
"Mailbox", secondary="directory_mailbox", lazy="joined"
|
||||
@ -2330,7 +2409,7 @@ class TransactionalEmail(db.Model, ModelMixin):
|
||||
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):
|
||||
|
@ -33,7 +33,7 @@
|
||||
|
||||
{% 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 %}
|
||||
<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.
|
||||
@ -101,10 +101,11 @@
|
||||
<div class="col-sm-6 pr-1 mb-1" style="min-width: 5em">
|
||||
<input name="prefix" class="form-control"
|
||||
type="text"
|
||||
pattern="[0-9a-z-_]{1,}"
|
||||
maxlength="40"
|
||||
data-bouncer-message="Only lowercase letters, dots, numbers, dashes (-) and underscores (_) are currently supported."
|
||||
placeholder="email alias"
|
||||
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."
|
||||
placeholder="Alias prefix, for example newsletter.com-123_xyz"
|
||||
autofocus>
|
||||
</div>
|
||||
|
||||
@ -124,10 +125,6 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<small class="text-muted">
|
||||
Alias can use letter, number, dash and cannot be empty
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -202,10 +199,3 @@
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
// init bouncer
|
||||
new Bouncer('form');
|
||||
</script>
|
||||
{% endblock %}
|
@ -102,7 +102,7 @@ def authorize():
|
||||
)
|
||||
user_info = {}
|
||||
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()
|
||||
else:
|
||||
suggested_email, other_emails = current_user.suggested_emails(
|
||||
@ -131,11 +131,11 @@ def authorize():
|
||||
)
|
||||
else: # POST - user allows or denies
|
||||
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}"
|
||||
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)
|
||||
|
||||
# user has already allowed this client, user cannot change information
|
||||
@ -167,11 +167,11 @@ def authorize():
|
||||
try:
|
||||
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
|
||||
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")
|
||||
return redirect(request.url)
|
||||
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")
|
||||
return redirect(request.url)
|
||||
|
||||
@ -189,7 +189,7 @@ def authorize():
|
||||
or DeletedAlias.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")
|
||||
return redirect(request.url)
|
||||
else:
|
||||
@ -255,9 +255,7 @@ def authorize():
|
||||
if state:
|
||||
redirect_args["state"] = state
|
||||
else:
|
||||
LOG.warning(
|
||||
"more security reason, state should be added. client %s", client
|
||||
)
|
||||
LOG.w("more security reason, state should be added. client %s", client)
|
||||
|
||||
if scope:
|
||||
redirect_args["scope"] = scope
|
||||
@ -339,7 +337,7 @@ def generate_access_token() -> str:
|
||||
return access_token
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
|
@ -56,9 +56,7 @@ def token():
|
||||
if auth_code.client_id != client.id:
|
||||
return jsonify(error="are you sure this code belongs to you?"), 400
|
||||
|
||||
LOG.debug(
|
||||
"Create Oauth token for user %s, client %s", auth_code.user, auth_code.client
|
||||
)
|
||||
LOG.d("Create Oauth token for user %s, client %s", auth_code.user, auth_code.client)
|
||||
|
||||
# Create token
|
||||
oauth_token = OauthToken.create(
|
||||
|
@ -72,9 +72,7 @@ def cancel_subscription(subscription_id: str) -> bool:
|
||||
)
|
||||
res = r.json()
|
||||
if not res["success"]:
|
||||
LOG.exception(
|
||||
f"cannot cancel subscription {subscription_id}, paddle response: {res}"
|
||||
)
|
||||
LOG.e(f"cannot cancel subscription {subscription_id}, paddle response: {res}")
|
||||
|
||||
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"
|
||||
except KeyError:
|
||||
LOG.exception(
|
||||
LOG.e(
|
||||
f"cannot change subscription {subscription_id} to {plan_id}, paddle response: {res}"
|
||||
)
|
||||
return False, ""
|
||||
|
@ -43,7 +43,7 @@ def load_public_key_and_check(public_key: str) -> str:
|
||||
try:
|
||||
encrypt_file(dummy_data, fingerprint)
|
||||
except Exception as e:
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"Cannot encrypt using the imported key %s %s", fingerprint, public_key
|
||||
)
|
||||
# remove the fingerprint
|
||||
@ -55,7 +55,7 @@ def load_public_key_and_check(public_key: str) -> str:
|
||||
|
||||
def hard_exit():
|
||||
pid = os.getpid()
|
||||
LOG.warning("kill pid %s", pid)
|
||||
LOG.w("kill pid %s", pid)
|
||||
os.kill(pid, 9)
|
||||
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
https://github.com/petermat/spamassassin_client
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import re2 as re
|
||||
import select
|
||||
import socket
|
||||
from io import BytesIO
|
||||
@ -115,7 +115,7 @@ class SpamAssassin(object):
|
||||
"description": " ".join(wordlist[1:]),
|
||||
}
|
||||
except ValueError:
|
||||
LOG.warning("Cannot parse %s %s", wordlist[0], wordlist)
|
||||
LOG.w("Cannot parse %s %s", wordlist[0], wordlist)
|
||||
|
||||
headers = (
|
||||
headers.decode("utf-8")
|
||||
|
14
app/utils.py
14
app/utils.py
@ -1,6 +1,8 @@
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
import urllib.parse
|
||||
from functools import wraps
|
||||
|
||||
from unidecode import unidecode
|
||||
|
||||
@ -73,3 +75,15 @@ def sanitize_email(email_address: str) -> str:
|
||||
def query2str(query):
|
||||
"""Useful utility method to print out a SQLAlchemy query"""
|
||||
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()
|
||||
):
|
||||
user = sub.user
|
||||
|
||||
if user.lifetime:
|
||||
continue
|
||||
|
||||
LOG.d(f"Send subscription ending soon email to user {user}")
|
||||
|
||||
send_email(
|
||||
@ -151,7 +155,7 @@ def notify_manual_sub_end():
|
||||
|
||||
if need_reminder:
|
||||
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(
|
||||
user.email,
|
||||
f"Your subscription will end soon",
|
||||
@ -185,7 +189,7 @@ def notify_manual_sub_end():
|
||||
|
||||
if need_reminder:
|
||||
user = coinbase_subscription.user
|
||||
LOG.debug(
|
||||
LOG.d(
|
||||
"Remind user %s that their coinbase subscription is ending soon", user
|
||||
)
|
||||
send_email(
|
||||
@ -443,9 +447,7 @@ def migrate_domain_trash():
|
||||
if not SLDomain.get_by(domain=alias_domain):
|
||||
custom_domain = CustomDomain.get_by(domain=alias_domain)
|
||||
if custom_domain:
|
||||
LOG.exception(
|
||||
"move %s to domain %s trash", deleted_alias, custom_domain
|
||||
)
|
||||
LOG.e("move %s to domain %s trash", deleted_alias, custom_domain)
|
||||
db.session.add(
|
||||
DomainDeletedAlias(
|
||||
user_id=custom_domain.user_id,
|
||||
@ -470,7 +472,7 @@ def set_custom_domain_for_alias():
|
||||
alias_domain = get_email_domain_part(alias.email)
|
||||
custom_domain = CustomDomain.get_by(domain=alias_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
|
||||
else: # phantom domain
|
||||
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),
|
||||
)
|
||||
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"issue with mailbox %s domain. #alias %s, nb email log %s",
|
||||
mailbox,
|
||||
mailbox.nb_alias(),
|
||||
@ -546,40 +548,40 @@ def sanity_check():
|
||||
|
||||
for user in User.filter_by(activated=True).all():
|
||||
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():
|
||||
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:
|
||||
alias.name = alias.name.replace("\n", "")
|
||||
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")
|
||||
for contact in Contact.query.all():
|
||||
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 (
|
||||
sanitize_email(contact.website_email) != contact.website_email
|
||||
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):
|
||||
LOG.exception("%s invalid email", contact)
|
||||
LOG.e("%s invalid email", contact)
|
||||
contact.invalid_email = True
|
||||
db.session.commit()
|
||||
|
||||
for mailbox in Mailbox.query.all():
|
||||
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():
|
||||
if normalize_reply_email(contact.reply_email) != contact.reply_email:
|
||||
LOG.exception(
|
||||
LOG.e(
|
||||
"Contact %s reply email is not normalized %s",
|
||||
contact,
|
||||
contact.reply_email,
|
||||
@ -587,7 +589,7 @@ def sanity_check():
|
||||
|
||||
for domain in CustomDomain.query.all():
|
||||
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()
|
||||
set_custom_domain_for_alias()
|
||||
@ -605,7 +607,7 @@ def check_custom_domain():
|
||||
|
||||
if sorted(mx_domains) != sorted(EMAIL_SERVERS_WITH_PRIORITY):
|
||||
user = custom_domain.user
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"The MX record is not correctly set for %s %s %s",
|
||||
custom_domain,
|
||||
user,
|
||||
@ -617,9 +619,7 @@ def check_custom_domain():
|
||||
# send alert if fail for 5 consecutive days
|
||||
if custom_domain.nb_failed_checks > 5:
|
||||
domain_dns_url = f"{URL}/dashboard/domains/{custom_domain.id}/dns"
|
||||
LOG.warning(
|
||||
"Alert domain MX check fails %s about %s", user, custom_domain
|
||||
)
|
||||
LOG.w("Alert domain MX check fails %s about %s", user, custom_domain)
|
||||
send_email_with_rate_control(
|
||||
user,
|
||||
AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN,
|
||||
@ -728,7 +728,7 @@ async def check_hibp():
|
||||
LOG.d("Checking HIBP API for aliases in breaches")
|
||||
|
||||
if len(HIBP_API_KEYS) == 0:
|
||||
LOG.exception("No HIBP API keys")
|
||||
LOG.e("No HIBP API keys")
|
||||
return
|
||||
|
||||
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 email
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from email import encoders
|
||||
@ -47,6 +48,9 @@ from typing import List, Tuple, Optional
|
||||
import newrelic.agent
|
||||
from aiosmtpd.controller import Controller
|
||||
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 app import pgp_utils, s3
|
||||
@ -76,6 +80,10 @@ from app.config import (
|
||||
ENABLE_SPAM_ASSASSIN,
|
||||
BOUNCE_PREFIX_FOR_REPLY_PHASE,
|
||||
NEWRELIC_CONFIG_PATH,
|
||||
POSTMASTER,
|
||||
ALERT_HOTMAIL_COMPLAINT,
|
||||
ALERT_YAHOO_COMPLAINT,
|
||||
TEMP_DIR,
|
||||
)
|
||||
from app.email import status
|
||||
from app.email.rate_limit import rate_limited
|
||||
@ -90,7 +98,6 @@ from app.email_utils import (
|
||||
delete_all_headers_except,
|
||||
get_spam_info,
|
||||
get_orig_message_from_spamassassin_report,
|
||||
parseaddr_unicode,
|
||||
send_email_with_rate_control,
|
||||
get_email_domain_part,
|
||||
copy,
|
||||
@ -112,6 +119,9 @@ from app.email_utils import (
|
||||
sanitize_header,
|
||||
get_queue_id,
|
||||
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.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_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):
|
||||
# From header is wrongly formatted, try with mail_from
|
||||
if mail_from and mail_from != "<>":
|
||||
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,
|
||||
mail_from,
|
||||
)
|
||||
_, contact_email = parseaddr_unicode(mail_from)
|
||||
contact_email = mail_from
|
||||
|
||||
if not is_valid_email(contact_email):
|
||||
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)
|
||||
|
||||
if contact_name and "\x00" in contact_name:
|
||||
LOG.w("issue with contact name %s", contact_name)
|
||||
contact_name = ""
|
||||
|
||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
||||
if contact:
|
||||
if contact.name != contact_name:
|
||||
@ -219,16 +237,6 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
||||
)
|
||||
contact.mail_from = mail_from
|
||||
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:
|
||||
LOG.d(
|
||||
"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,
|
||||
name=contact_name,
|
||||
mail_from=mail_from,
|
||||
from_header=from_header,
|
||||
reply_email=generate_reply_email(contact_email, alias.user)
|
||||
if is_valid_email(contact_email)
|
||||
else NOREPLY,
|
||||
@ -267,25 +274,26 @@ def get_or_create_reply_to_contact(
|
||||
"""
|
||||
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(
|
||||
"invalid reply-to address %s. Parse from %s",
|
||||
address,
|
||||
contact_address,
|
||||
reply_to_header,
|
||||
)
|
||||
return None
|
||||
|
||||
address = sanitize_email(address)
|
||||
|
||||
contact = Contact.get_by(alias_id=alias.id, website_email=address)
|
||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_address)
|
||||
if contact:
|
||||
return contact
|
||||
else:
|
||||
LOG.d(
|
||||
"create contact %s for alias %s via reply-to header",
|
||||
address,
|
||||
contact_address,
|
||||
alias,
|
||||
reply_to_header,
|
||||
)
|
||||
@ -294,15 +302,15 @@ def get_or_create_reply_to_contact(
|
||||
contact = Contact.create(
|
||||
user_id=alias.user_id,
|
||||
alias_id=alias.id,
|
||||
website_email=address,
|
||||
name=name,
|
||||
reply_email=generate_reply_email(address, alias.user),
|
||||
website_email=contact_address,
|
||||
name=contact_name,
|
||||
reply_email=generate_reply_email(contact_address, alias.user),
|
||||
)
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
LOG.w("Contact %s %s already exist", alias, address)
|
||||
LOG.w("Contact %s %s already exist", alias, contact_address)
|
||||
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
|
||||
|
||||
@ -314,37 +322,43 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
||||
new_addrs: [str] = []
|
||||
headers = msg.get_all(header, [])
|
||||
# headers can be an array of Header, convert it to string here
|
||||
headers = [str(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)
|
||||
headers = [get_header_unicode(h) for h in headers]
|
||||
|
||||
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
|
||||
if contact_email == alias.email:
|
||||
new_addrs.append(addr)
|
||||
new_addrs.append(full_address.full_spec())
|
||||
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)
|
||||
continue
|
||||
|
||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
||||
if contact:
|
||||
# update the contact name if needed
|
||||
if contact.name != contact_name:
|
||||
if contact.name != full_address.display_name:
|
||||
LOG.d(
|
||||
"Update contact %s name %s to %s",
|
||||
contact,
|
||||
contact.name,
|
||||
contact_name,
|
||||
full_address.display_name,
|
||||
)
|
||||
contact.name = contact_name
|
||||
contact.name = full_address.display_name
|
||||
db.session.commit()
|
||||
else:
|
||||
LOG.debug(
|
||||
LOG.d(
|
||||
"create contact for alias %s and email %s, header %s",
|
||||
alias,
|
||||
contact_email,
|
||||
@ -356,10 +370,9 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
||||
user_id=alias.user_id,
|
||||
alias_id=alias.id,
|
||||
website_email=contact_email,
|
||||
name=contact_name,
|
||||
name=full_address.display_name,
|
||||
reply_email=generate_reply_email(contact_email, alias.user),
|
||||
is_cc=header.lower() == "cc",
|
||||
from_header=addr,
|
||||
)
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
@ -485,7 +498,7 @@ def sign_msg(msg: Message) -> Message:
|
||||
try:
|
||||
signature.set_payload(sign_data(to_bytes(msg).replace(b"\n", b"\r\n")))
|
||||
except Exception:
|
||||
LOG.exception("Cannot sign, try using pgpy")
|
||||
LOG.e("Cannot sign, try using pgpy")
|
||||
signature.set_payload(
|
||||
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
|
||||
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:
|
||||
LOG.d("alias %s not exist. Try to see if it can be created on the fly", address)
|
||||
alias = try_auto_create(address)
|
||||
LOG.d(
|
||||
"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:
|
||||
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):
|
||||
return [(True, status.E207)]
|
||||
else:
|
||||
@ -555,21 +571,22 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
|
||||
else:
|
||||
return [(False, status.E504)]
|
||||
|
||||
# mail_from = envelope.mail_from
|
||||
# for mb in alias.mailboxes:
|
||||
# # email send from a mailbox to alias
|
||||
# if mb.email == mail_from:
|
||||
# LOG.w("cycle email sent from %s to %s", mb, alias)
|
||||
# handle_email_sent_to_ourself(alias, mb, msg, user)
|
||||
# return [(True, "250 Message accepted for delivery")]
|
||||
if user.ignore_loop_email:
|
||||
mail_from = envelope.mail_from
|
||||
for mb in alias.mailboxes:
|
||||
# email sent from a mailbox to its alias
|
||||
if mb.email == mail_from:
|
||||
LOG.w("cycle email sent from %s to %s", mb, alias)
|
||||
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)
|
||||
contact = get_or_create_contact(from_header, envelope.mail_from, alias)
|
||||
|
||||
reply_to_contact = None
|
||||
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)
|
||||
# ignore when reply-to = alias
|
||||
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
|
||||
# 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):
|
||||
LOG.exception("%s domain isn't known", alias)
|
||||
LOG.e("%s domain isn't known", alias)
|
||||
return False, status.E503
|
||||
|
||||
user = alias.user
|
||||
mail_from = envelope.mail_from
|
||||
|
||||
if user.disabled:
|
||||
LOG.exception(
|
||||
LOG.e(
|
||||
"User %s disabled, disable sending emails from %s to %s",
|
||||
user,
|
||||
alias,
|
||||
@ -1089,9 +1106,9 @@ def get_mailbox_from_mail_from(mail_from: str, alias) -> Optional[Mailbox]:
|
||||
if mailbox.email == mail_from:
|
||||
return mailbox
|
||||
|
||||
for address in mailbox.authorized_addresses:
|
||||
if address.email == mail_from:
|
||||
LOG.debug(
|
||||
for addr in mailbox.authorized_addresses:
|
||||
if addr.email == mail_from:
|
||||
LOG.d(
|
||||
"Found an authorized address for %s %s %s", alias, mailbox, address
|
||||
)
|
||||
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
|
||||
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
|
||||
|
||||
Bounce.create(email=mailbox.email, commit=True)
|
||||
|
||||
LOG.debug(
|
||||
LOG.d(
|
||||
"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,
|
||||
),
|
||||
max_nb_alert=10,
|
||||
# smtp error can happen if user mailbox is unreachable, that might explain the bounce
|
||||
ignore_smtp_error=True,
|
||||
)
|
||||
else:
|
||||
LOG.w(
|
||||
@ -1276,9 +1295,90 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
|
||||
mailbox_email=mailbox.email,
|
||||
),
|
||||
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):
|
||||
"""
|
||||
Handle reply phase bounce
|
||||
@ -1289,9 +1389,7 @@ def handle_bounce_reply_phase(envelope, msg: Message, email_log: EmailLog):
|
||||
user = alias.user
|
||||
mailbox = email_log.mailbox or alias.mailbox
|
||||
|
||||
LOG.debug(
|
||||
"Handle reply bounce %s -> %s -> %s.%s", mailbox, alias, contact, email_log
|
||||
)
|
||||
LOG.d("Handle reply bounce %s -> %s -> %s.%s", mailbox, alias, contact, email_log)
|
||||
|
||||
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"""
|
||||
user = User.get(user_id)
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
user.notification = False
|
||||
@ -1638,7 +1736,7 @@ def handle(envelope: Envelope) -> str:
|
||||
if postfix_queue_id:
|
||||
set_message_id(postfix_queue_id)
|
||||
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):
|
||||
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)
|
||||
if contact:
|
||||
LOG.e(
|
||||
"email can't be sent from a reverse-alias alias:%s, contact email:%s",
|
||||
contact.alias,
|
||||
LOG.w(
|
||||
"email can't be sent from a reverse-alias:%s, contact email:%s, %s, %s",
|
||||
contact.reply_email,
|
||||
contact.website_email,
|
||||
contact.alias,
|
||||
contact.user,
|
||||
)
|
||||
return status.E203
|
||||
|
||||
@ -1687,6 +1787,28 @@ def handle(envelope: Envelope) -> str:
|
||||
handle_transactional_bounce(envelope, rcpt_tos[0])
|
||||
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
|
||||
if (
|
||||
len(rcpt_tos) == 1
|
||||
@ -1721,8 +1843,45 @@ def handle(envelope: Envelope) -> str:
|
||||
)
|
||||
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):
|
||||
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):
|
||||
return status.E207
|
||||
else:
|
||||
@ -1821,6 +1980,9 @@ class MailHandler:
|
||||
newrelic.agent.record_custom_metric(
|
||||
"Custom/email_handler_time", elapsed, newrelic_app
|
||||
)
|
||||
newrelic.agent.record_custom_metric(
|
||||
"Custom/number_incoming_email", 1, newrelic_app
|
||||
)
|
||||
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
|
||||
# DKIM_PRIVATE_KEY_PATH=local_data/dkim.key
|
||||
|
||||
# delete and recreate the sqlite database, for local development
|
||||
RESET_DB=true
|
||||
|
||||
# DB Connection
|
||||
# Local SQLite database
|
||||
DB_URI=sqlite:///db.sqlite
|
||||
# Postgres
|
||||
# DB_URI=postgresql://myuser:mypassword@sl-db:5432/simplelogin
|
||||
DB_URI=postgresql://myuser:mypassword@localhost:35432/simplelogin
|
||||
|
||||
FLASK_SECRET=secret
|
||||
|
||||
@ -178,3 +172,9 @@ DISABLE_ONBOARDING=true
|
||||
|
||||
# NewRelic Config File Path
|
||||
# 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
|
||||
if fingerprint != mailbox.pgp_finger_print:
|
||||
LOG.exception(
|
||||
"fingerprint %s different for mailbox %s", fingerprint, mailbox
|
||||
)
|
||||
LOG.e("fingerprint %s different for mailbox %s", fingerprint, mailbox)
|
||||
mailbox.pgp_finger_print = fingerprint
|
||||
db.session.commit()
|
||||
|
||||
@ -27,9 +25,7 @@ def load_pgp_public_keys():
|
||||
|
||||
# sanity check
|
||||
if fingerprint != contact.pgp_finger_print:
|
||||
LOG.exception(
|
||||
"fingerprint %s different for contact %s", fingerprint, contact
|
||||
)
|
||||
LOG.e("fingerprint %s different for contact %s", fingerprint, contact)
|
||||
contact.pgp_finger_print = fingerprint
|
||||
|
||||
db.session.commit()
|
||||
@ -42,14 +38,14 @@ def add_sl_domains():
|
||||
if SLDomain.get_by(domain=alias_domain):
|
||||
LOG.d("%s is already a SL domain", alias_domain)
|
||||
else:
|
||||
LOG.info("Add %s to SL domain", alias_domain)
|
||||
LOG.i("Add %s to SL domain", alias_domain)
|
||||
SLDomain.create(domain=alias_domain)
|
||||
|
||||
for premium_domain in PREMIUM_ALIAS_DOMAINS:
|
||||
if SLDomain.get_by(domain=premium_domain):
|
||||
LOG.d("%s is already a SL domain", premium_domain)
|
||||
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)
|
||||
|
||||
db.session.commit()
|
||||
|
@ -159,7 +159,7 @@ if __name__ == "__main__":
|
||||
continue
|
||||
|
||||
user_email = user.email
|
||||
LOG.warning("Delete user %s", user)
|
||||
LOG.w("Delete user %s", user)
|
||||
User.delete(user.id)
|
||||
db.session.commit()
|
||||
|
||||
@ -170,6 +170,6 @@ if __name__ == "__main__":
|
||||
render("transactional/account-delete.html"),
|
||||
)
|
||||
else:
|
||||
LOG.exception("Unknown job name %s", job.name)
|
||||
LOG.e("Unknown job name %s", job.name)
|
||||
|
||||
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
|
||||
_nb_failed = 0
|
||||
|
||||
LOG.exception(
|
||||
LOG.e(
|
||||
"Too many emails in incoming & active queue %s %s",
|
||||
incoming_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]]
|
||||
name = "aiosmtpd"
|
||||
version = "1.2"
|
||||
version = "1.4.2"
|
||||
description = "aiosmtpd - asyncio based SMTP server"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
python-versions = "~=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
atpublic = "*"
|
||||
attrs = "*"
|
||||
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
|
||||
|
||||
[[package]]
|
||||
name = "aiosmtplib"
|
||||
@ -396,11 +398,11 @@ trio = ["trio (>=0.14.0)", "sniffio (>=1.1)"]
|
||||
|
||||
[[package]]
|
||||
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."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
|
||||
|
||||
[package.dependencies]
|
||||
dnspython = ">=1.15.0"
|
||||
@ -421,7 +423,7 @@ requests = "*"
|
||||
name = "filelock"
|
||||
version = "3.0.12"
|
||||
description = "A platform independent file lock."
|
||||
category = "main"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
@ -439,6 +441,30 @@ mccabe = ">=0.6.0,<0.7.0"
|
||||
pycodestyle = ">=2.6.0a1,<2.7.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]]
|
||||
name = "flask"
|
||||
version = "1.1.2"
|
||||
@ -563,7 +589,7 @@ simplejson = "*"
|
||||
|
||||
[[package]]
|
||||
name = "flask-sqlalchemy"
|
||||
version = "2.4.4"
|
||||
version = "2.5.1"
|
||||
description = "Adds SQLAlchemy support to your Flask application."
|
||||
category = "main"
|
||||
optional = false
|
||||
@ -1058,6 +1084,14 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
|
||||
[[package]]
|
||||
name = "ply"
|
||||
version = "3.11"
|
||||
description = "Python Lex & Yacc"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "2.7.1"
|
||||
@ -1132,19 +1166,6 @@ category = "dev"
|
||||
optional = false
|
||||
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]]
|
||||
name = "pyasn1"
|
||||
version = "0.4.8"
|
||||
@ -1236,6 +1257,18 @@ category = "dev"
|
||||
optional = false
|
||||
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]]
|
||||
name = "pyreadline"
|
||||
version = "2.1"
|
||||
@ -1348,7 +1381,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
name = "regex"
|
||||
version = "2020.9.27"
|
||||
description = "Alternative regular expression module, to replace re."
|
||||
category = "dev"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
@ -1533,6 +1566,14 @@ python-versions = "*"
|
||||
python-dateutil = ">=2.6.0"
|
||||
"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]]
|
||||
name = "toml"
|
||||
version = "0.10.1"
|
||||
@ -1653,6 +1694,18 @@ future = ">=0.17.1"
|
||||
pyOpenSSL = ">=16.0.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]]
|
||||
name = "werkzeug"
|
||||
version = "1.0.1"
|
||||
@ -1751,7 +1804,7 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.7"
|
||||
content-hash = "6a909880eb2c4afcd161158bd606edde3839aedbfca57a751c6b0c6e5306d9dc"
|
||||
content-hash = "7c789c1e89d6b2859ee06adcc2b1caf93c759097624059461d1d613da1888e8e"
|
||||
|
||||
[metadata.files]
|
||||
aiohttp = [
|
||||
@ -1769,7 +1822,8 @@ aiohttp = [
|
||||
{file = "aiohttp-3.6.2.tar.gz", hash = "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326"},
|
||||
]
|
||||
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 = [
|
||||
{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"},
|
||||
]
|
||||
email-validator = [
|
||||
{file = "email_validator-1.1.1-py2.py3-none-any.whl", hash = "sha256:5f246ae8d81ce3000eade06595b7bb55a4cf350d559e890182a1466a21f25067"},
|
||||
{file = "email_validator-1.1.1.tar.gz", hash = "sha256:63094045c3e802c3d3d575b18b004a531c36243ca8d1cec785ff6bfcb04185bb"},
|
||||
{file = "email_validator-1.1.3-py2.py3-none-any.whl", hash = "sha256:5675c8ceb7106a37e40e2698a57c056756bf3f272cfa8682a4f87ebd95d8440b"},
|
||||
{file = "email_validator-1.1.3.tar.gz", hash = "sha256:aa237a65f6f4da067119b7df3f13e89c25c051327b2b5b66dc075f33d62480d7"},
|
||||
]
|
||||
facebook-sdk = [
|
||||
{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.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"},
|
||||
]
|
||||
flanker = [
|
||||
{file = "flanker-0.9.11.tar.gz", hash = "sha256:974418e5b498fd3bcb3859c22e22d26495257f9cf98b744c17f2335aca86e001"},
|
||||
]
|
||||
flask = [
|
||||
{file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"},
|
||||
{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"},
|
||||
]
|
||||
flask-sqlalchemy = [
|
||||
{file = "Flask-SQLAlchemy-2.4.4.tar.gz", hash = "sha256:bfc7150eaf809b1c283879302f04c42791136060c6eeb12c0c6674fb1291fae5"},
|
||||
{file = "Flask_SQLAlchemy-2.4.4-py2.py3-none-any.whl", hash = "sha256:05b31d2034dd3f2a685cbbae4cfc4ed906b2a733cff7964ada450fd5e462b84e"},
|
||||
{file = "Flask-SQLAlchemy-2.5.1.tar.gz", hash = "sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912"},
|
||||
{file = "Flask_SQLAlchemy-2.5.1-py2.py3-none-any.whl", hash = "sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390"},
|
||||
]
|
||||
flask-wtf = [
|
||||
{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.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 = [
|
||||
{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"},
|
||||
@ -2378,9 +2439,6 @@ py = [
|
||||
{file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"},
|
||||
{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 = [
|
||||
{file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"},
|
||||
{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.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 = [
|
||||
{file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"},
|
||||
{file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"},
|
||||
@ -2691,6 +2768,15 @@ sqlalchemy-utils = [
|
||||
strictyaml = [
|
||||
{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 = [
|
||||
{file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
|
||||
{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.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 = [
|
||||
{file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"},
|
||||
{file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"},
|
||||
|
@ -74,10 +74,11 @@ gevent = "^20.9.0"
|
||||
aiospamc = "^0.6.1"
|
||||
email_validator = "^1.1.1"
|
||||
PGPy = "^0.5.3"
|
||||
py3-validate-email = "^0.2.10"
|
||||
coinbase-commerce = "^1.0.1"
|
||||
requests = "^2.25.1"
|
||||
newrelic = "^6.4.4"
|
||||
flanker = "^0.9.11"
|
||||
pyre2 = "^0.3.6"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
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,
|
||||
PayoutAdmin,
|
||||
CouponAdmin,
|
||||
CustomDomainAdmin,
|
||||
)
|
||||
from app.api.base import api_bp
|
||||
from app.auth.base import auth_bp
|
||||
@ -49,7 +50,6 @@ from app.config import (
|
||||
URL,
|
||||
SHA1,
|
||||
PADDLE_MONTHLY_PRODUCT_ID,
|
||||
RESET_DB,
|
||||
FLASK_PROFILER_PATH,
|
||||
FLASK_PROFILER_PASSWORD,
|
||||
SENTRY_FRONT_END_DSN,
|
||||
@ -205,13 +205,6 @@ def create_app() -> Flask:
|
||||
|
||||
def 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
|
||||
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=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
|
||||
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("/_debug_toolbar")
|
||||
):
|
||||
LOG.debug(
|
||||
LOG.d(
|
||||
"%s %s %s %s %s, takes %s",
|
||||
request.remote_addr,
|
||||
request.method,
|
||||
@ -565,7 +563,7 @@ def setup_error_page(app):
|
||||
|
||||
@app.errorhandler(429)
|
||||
def rate_limited(e):
|
||||
LOG.warning(
|
||||
LOG.w(
|
||||
"Client hit rate limit on path %s, user:%s",
|
||||
request.path,
|
||||
get_current_user(),
|
||||
@ -591,7 +589,7 @@ def setup_error_page(app):
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def error_handler(e):
|
||||
LOG.exception(e)
|
||||
LOG.e(e)
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify(error="Internal error"), 500
|
||||
else:
|
||||
@ -628,19 +626,18 @@ def jinja2_filter(app):
|
||||
STATUS_PAGE_URL=STATUS_PAGE_URL,
|
||||
SUPPORT_EMAIL=SUPPORT_EMAIL,
|
||||
PGP_SIGNER=PGP_SIGNER,
|
||||
canonical_url=f"{URL}{request.path}",
|
||||
)
|
||||
|
||||
|
||||
def setup_paddle_callback(app: Flask):
|
||||
@app.route("/paddle", methods=["GET", "POST"])
|
||||
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
|
||||
if not paddle_utils.verify_incoming_request(dict(request.form)):
|
||||
LOG.exception(
|
||||
"request not coming from paddle. Request data:%s", dict(request.form)
|
||||
)
|
||||
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
|
||||
return "KO", 400
|
||||
|
||||
if (
|
||||
@ -659,7 +656,7 @@ def setup_paddle_callback(app: Flask):
|
||||
elif subscription_plan_id in PADDLE_YEARLY_PRODUCT_IDS:
|
||||
plan = PlanEnum.yearly
|
||||
else:
|
||||
LOG.exception(
|
||||
LOG.e(
|
||||
"Unknown subscription_plan_id %s %s",
|
||||
subscription_plan_id,
|
||||
request.form,
|
||||
@ -696,13 +693,13 @@ def setup_paddle_callback(app: Flask):
|
||||
# in case user cancels a plan and subscribes a new plan
|
||||
sub.cancelled = False
|
||||
|
||||
LOG.debug("User %s upgrades!", user)
|
||||
LOG.d("User %s upgrades!", user)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
elif request.form.get("alert_name") == "subscription_payment_succeeded":
|
||||
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)
|
||||
# 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)
|
||||
if sub:
|
||||
# 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",
|
||||
subscription_id,
|
||||
sub.user,
|
||||
@ -751,7 +748,7 @@ def setup_paddle_callback(app: Flask):
|
||||
|
||||
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
|
||||
if sub:
|
||||
LOG.debug(
|
||||
LOG.d(
|
||||
"Update subscription %s %s on %s, next bill date %s",
|
||||
subscription_id,
|
||||
sub.user,
|
||||
@ -797,7 +794,7 @@ def setup_coinbase_commerce(app):
|
||||
request_data, request_sig, COINBASE_WEBHOOK_SECRET
|
||||
)
|
||||
except (WebhookInvalidPayload, SignatureVerificationError) as e:
|
||||
LOG.exception("Invalid Coinbase webhook")
|
||||
LOG.e("Invalid Coinbase webhook")
|
||||
return str(e), 400
|
||||
|
||||
LOG.d("Coinbase event %s", event)
|
||||
@ -816,7 +813,7 @@ def handle_coinbase_event(event) -> bool:
|
||||
code = event["data"]["code"]
|
||||
user = User.get(user_id)
|
||||
if not user:
|
||||
LOG.exception("User not found %s", user_id)
|
||||
LOG.e("User not found %s", user_id)
|
||||
return False
|
||||
|
||||
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
|
||||
@ -886,6 +883,7 @@ def init_admin(app):
|
||||
admin.add_view(CouponAdmin(Coupon, db.session))
|
||||
admin.add_view(ManualSubscriptionAdmin(ManualSubscription, 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(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)
|
||||
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):
|
||||
@app.route("/dnt")
|
||||
@ -951,15 +958,6 @@ def local_main():
|
||||
app.debug = True
|
||||
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)
|
||||
|
||||
# uncomment to run https locally
|
||||
|
10
shell.py
10
shell.py
@ -23,7 +23,7 @@ from server import create_app
|
||||
|
||||
def create_db():
|
||||
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 all tables
|
||||
@ -56,7 +56,7 @@ def send_mailbox_newsletter():
|
||||
)
|
||||
sleep(1)
|
||||
except Exception:
|
||||
LOG.warning("Cannot send to user %s", user)
|
||||
LOG.w("Cannot send to user %s", user)
|
||||
|
||||
|
||||
def send_pgp_newsletter():
|
||||
@ -72,7 +72,7 @@ def send_pgp_newsletter():
|
||||
)
|
||||
sleep(1)
|
||||
except Exception:
|
||||
LOG.warning("Cannot send to user %s", user)
|
||||
LOG.w("Cannot send to user %s", user)
|
||||
|
||||
|
||||
def send_mobile_newsletter():
|
||||
@ -89,7 +89,7 @@ def send_mobile_newsletter():
|
||||
render("com/newsletter/mobile-darkmode.html", user=user),
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning("Cannot send to user %s", user)
|
||||
LOG.w("Cannot send to user %s", user)
|
||||
|
||||
if count % 5 == 0:
|
||||
# sleep every 5 sends to avoid hitting email limits
|
||||
@ -125,7 +125,7 @@ def disable_mailbox(mailbox_id):
|
||||
email_msg.replace("\n", "<br>"),
|
||||
)
|
||||
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):
|
||||
|
@ -139,9 +139,9 @@ $(".pin-alias").change(async function () {
|
||||
|
||||
if (res.ok) {
|
||||
if (newValue) {
|
||||
toastr.success(`${alias} is added to favorite`);
|
||||
toastr.success(`${alias} is pinned`);
|
||||
} else {
|
||||
toastr.info(`${alias} is removed from favorite`);
|
||||
toastr.info(`${alias} is unpinned`);
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
toastr.success(`Note Saved`);
|
||||
toastr.success(`Saved`);
|
||||
} else {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// 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",
|
||||
"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": {
|
||||
"version": "2.9.3",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.16.1",
|
||||
"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",
|
||||
"bootbox": "^5.5.2",
|
||||
"font-awesome": "^4.7.0",
|
||||
"formbouncerjs": "^1.4.6",
|
||||
"intro.js": "^2.9.3",
|
||||
"multiple-select": "^1.5.2",
|
||||
"parsleyjs": "^2.9.2",
|
||||
"qrious": "^4.0.2",
|
||||
"toastr": "^2.1.4",
|
||||
"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="shortcut icon" type="image/x-icon" href="/static/favicon.ico"/>
|
||||
<link rel="canonical" href="{{ canonical_url }}">
|
||||
|
||||
<title>
|
||||
{% block title %}{% endblock %} | SimpleLogin
|
||||
@ -77,8 +78,9 @@
|
||||
<script
|
||||
src="{{ url_for('static', filename='node_modules/multiple-select/dist/multiple-select.min.js') }}"></script>
|
||||
|
||||
<!-- bouncer library -->
|
||||
<script src="{{ url_for('static', filename='node_modules/formbouncerjs/dist/bouncer.js') }}"></script>
|
||||
<!-- Parseley library -->
|
||||
<script src="{{ url_for('static', filename='node_modules/parsleyjs/dist/parsley.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='node_modules/parsleyjs/dist/i18n/en.js') }}"></script>
|
||||
|
||||
|
||||
{% 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(
|
||||
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.json["error"] == "invalid contact email with space@gmail.com"
|
||||
assert r.json["error"] == "invalid contact email @gmail.com"
|
||||
|
||||
|
||||
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