Compare commits

...
Sign in to create a new pull request.

235 commits

Author SHA1 Message Date
Adrià Casajús
f81f8ca032
Further limit the index endpoint (#1950) 2023-11-21 17:44:33 +01:00
Adrià Casajús
31896ff262
Replace black and flake8 with ruff (#1943) 2023-11-21 16:42:18 +01:00
Adrià Casajús
45575261dc
Rate limit index endpoint (#1948) 2023-11-21 14:42:24 +01:00
Adrià Casajús
627ad302d2
Creating account via partner also canonicalizes email (#1939) 2023-11-08 09:58:01 +01:00
Son NK
08862a35c3 fix image size 2023-11-07 14:33:46 +01:00
Son Nguyen Kim
75dd3cf925
admin can clone newsletter (#1938)
* admin can clone newsletter

- remove unique constraint on newsletter subject
- admin can clone newsletter

* update coupon image

---------

Co-authored-by: Son NK <son@simplelogin.io>
2023-11-07 14:16:03 +01:00
Son Nguyen Kim
a097e33abe
black friday 2023 (#1937)
Co-authored-by: Son NK <son@simplelogin.io>
2023-11-07 13:53:28 +01:00
Adrià Casajús
e5cc8b9628
Update dockerfile to account for new build changes in yacron (#1936) 2023-11-07 11:09:55 +01:00
Hulk667i
d149686296
remove sensitive words (#1935) 2023-11-07 10:44:35 +01:00
Adrià Casajús
babf4b058a
Remove potentially conflictive words (#1932) 2023-11-02 17:33:03 +01:00
UserBob6
eb8f8caeb8
fix https://github.com/simple-login/app/issues/1925 (#1926)
Remove sensitive words
2023-10-16 22:19:20 +02:00
UserBob6
70fc9c383a
Remove sensitive words from words.txt https://github.com/simple-login/app/issues/1905 (#1921) 2023-10-16 21:39:53 +02:00
Adrià Casajús
b68f074783
Add index on message_id for foreign key (#1906)
* Add index on message_id for foreign key

* Revert cron changes
2023-10-05 10:55:29 +02:00
Adrià Casajús
73a0addf27
Remove bad word from wordlist 2023-10-03 12:04:50 +02:00
Adrià Casajús
e6bcf81726
Delete old email_log entries in batches to avoid table lock (#1902)
* Delete old email_log entries in batches to avoid table lock

* Avoid nested join

* Commiting after the batch delete

* Added statement count print

* Rename var
2023-10-02 10:50:02 +02:00
Adrià Casajús
7600038813
Update dependencies (#1901)
* Update dependencies

* Update python version

* update workflow to use python 3.10

* Install OS deps
2023-09-29 17:26:40 +02:00
Adrià Casajús
c19b62b878
Add index on created_at for EmailLog (#1898)
(cherry picked from commit ea46ca0af5f6912d17cf7c656f00257cdee191d1)
2023-09-28 18:26:40 +02:00
Jack Wright
4fe79bdd42
Update dns.html to amend DKIM configuration instructions (#1884)
When I was configuring my subdomain-based alias, I was wondering why it would not verify, even after waiting a day. But after playing a bit of whack-a-mole with my DNS settings, the proposed changes worked for me.
2023-09-26 12:21:23 +02:00
Son Nguyen Kim
fd1744470b
allow BCC (#1894)
Co-authored-by: Son NK <son@simplelogin.io>
2023-09-26 10:00:33 +02:00
Adrià Casajús
989a577db6
Allow to get premium partner domains without premium sl domains (#1880)
* Allow to get premium partner domains without premium sl domains

* Set condition on domains
2023-09-13 18:12:47 +02:00
Adrià Casajús
373c30e53b
Schedule deletion of users (#1872)
* Accounts to be scheduled to be deleted cannot receive emails or login

* Create model and create migration for user

* Add test for the cron function

* Move logic to one place

* Use the class name to call the static delete method
2023-09-10 22:11:50 +02:00
Son Nguyen Kim
ff3dbdaad2
add proton.ch to the is_proton check (#1863)
Co-authored-by: Son NK <son@simplelogin.io>
2023-09-04 21:21:39 +02:00
Adrià Casajús
7ec7e06c2b
Move alias transfer util outside the views to make it importable (#1855) 2023-08-31 13:42:44 +02:00
Adrià Casajús
ef90423a35
Fix: Use proper error when linking external partner accounts 2023-08-30 13:49:47 +02:00
Adrià Casajús
c04f5102d6
Fix: Handle email headers as strings if the are Header type (#1850) 2023-08-29 12:37:26 +02:00
Son Nguyen Kim
5714403976
Can use generic subject without pgp (#1847)
* improve wording for hide my subject option

* can use generic subject on a non-pgp mailbox

---------

Co-authored-by: Son NK <son@simplelogin.io>
2023-08-24 22:47:31 +02:00
Carlos Quintana
40ff4604c8
fix: handle Proton account not validated case (#1842) 2023-08-18 15:59:46 +02:00
mlec
66d26a1193
fix(core): Open mailto: links in a new tab when the default email client is set to a web mail (#1721)
* fix(build): Update docker image of Node to v20

- Open "mailto:" links in a new tab if using browser

* feat(dockerfile): revert node to v10.17.0
2023-08-15 16:03:04 +02:00
D-Bao
9b1e4f73ca
Update pricing page text (#1843) 2023-08-15 15:58:15 +02:00
Son Nguyen Kim
0435c745fd
disable the PGP section if the mailbox is proton and not has PGP enabled (#1841)
* disable the PGP section if the mailbox is proton and not has PGP enabled

* fix format

---------

Co-authored-by: Son NK <son@simplelogin.io>
2023-08-09 09:56:53 +02:00
Adrià Casajús
366631ee93
Limit length of contact names (#1837)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-08-04 16:17:45 +02:00
Adrià Casajús
4bf925fe6f
Revert contact creation (#1836)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-08-04 14:01:21 +02:00
Carlos Quintana
0e82801512
chore: add upcloud monitoring (#1835)
* chore: add upcloud monitoring

* Added db_role to new_relic metrics

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-08-04 12:19:00 +02:00
Adrià Casajús
9ab3695d36
Fix: Do not lowercase by default contact emails (#1834)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-08-04 10:36:13 +02:00
Son Nguyen Kim
06b7e05e61
raise exception when signature is empty (#1833)
Co-authored-by: Son NK <son@simplelogin.io>
2023-08-03 16:59:36 +02:00
Son Nguyen Kim
6c7e9e69dc
add logging in case of empty signature (#1832)
Co-authored-by: Son NK <son@simplelogin.io>
2023-08-03 10:22:02 +02:00
Adrià Casajús
6e4f6fe540
Sanitize alias, contacts, mailboxes and users before creating them (#1829)
* Sanitize alias, contacts, mailboxes and users before creating them

* Updated comments and moved crons to run when load is low

* Run the stats at the same time as previously

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-08-03 10:20:25 +02:00
Adrià Casajús
f2dad4c28c
Cron improvements (#1826)
* Yield on big queries and check the trial is active in the query directly

* Eagerly load the hibp aliases to check

* Updated trial condition

* Also yield referral

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-07-31 15:14:13 +02:00
Adrià Casajús
e9e863807c
Add missing indexes (#1824)
* Rate limit the sudo route

* Add missing indexes

* Updated index

* Update index creation to run with concurrent

* With autocommit block

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-07-29 10:03:31 +02:00
Adrià Casajús
c4003b07ac
Rate limit the sudo route (#1823)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-07-26 12:56:06 +02:00
Adrià Casajús
d8943cf126
Fix: Allow to create more than one api key if the user has more than one (#1822)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-07-25 17:15:18 +02:00
Maxime Labelle
2eec918543
Documented CAA (#1804)
* Documented CAA

* Fixed bold typo

* Clarified CAA configuration

* Highlighted bash syntax
2023-07-24 21:51:17 +02:00
Maxime Labelle
4d9b8f9a4b
Documented MTA-STS and TLSRPT (#1806) 2023-07-20 18:26:06 +02:00
Efren
81d5ef0783
Fix typo in placeholder text of form on support page (#1808)
"are" was missing an e
2023-07-20 18:15:27 +02:00
Adrià Casajús
04d92b7f23
Fix: Use MIMEText for text contents (#1801)
* Fix: For badly formatted messages use MIMEText

* Fix: For badly formatted messages use MIMEText

* fix test

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-07-11 16:48:01 +02:00
Adrià Casajús
cb900ed057
Fix: For badly formatted messages use MIMEText (#1800)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-07-11 16:23:37 +02:00
Adrià Casajús
516072fd99
Fix: save retries to disk (#1799)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-07-11 10:59:24 +02:00
Son Nguyen Kim
2351330732
mention about proton mail during signup (#1796)
* mention about proton mail during signup

* format

* trim whitespaces

---------

Co-authored-by: Son NK <son@sons-macbook-air-2.home>
Co-authored-by: Son NK <son@Sons-MacBook-Air-2.local>
2023-07-10 14:41:52 +02:00
Adrià Casajús
e2dbf8d48d
Avoid sending long encoded subject to sentry (#1798)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-07-10 14:41:42 +02:00
Adrià Casajús
d62bff8e46
Add rate limit and maximum amount of api keys (#1788)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-29 17:21:00 +02:00
Adrià Casajús
fc205157a8
Preserve also contact name in Original-From (#1787)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-29 16:21:22 +02:00
Adrià Casajús
ac9d550069
Fix: delete_header has no return value (#1786)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-27 14:42:52 +02:00
Adrià Casajús
daec781ffc
Fix unsubscribe header manipulation (#1785)
* Added debug statements to find out unsubscribe issues

* Add List-Unsubscribe headers to preserve list

* Cleanup debug messages

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-27 11:18:44 +02:00
Son Nguyen Kim
501c625ddf
set default alias suffix to word (#1765)
Co-authored-by: Son NK <son@Sons-MacBook-Air-2.local>
2023-06-27 11:07:02 +02:00
Adrià Casajús
d3aae31d45
Preserve original from header in X-SimpleLogin-Original-From (#1784)
* Preserve original from in the headers

* Update the settings page

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-23 12:43:06 +02:00
Adrià Casajús
8512093bfc
Update dockerfile to use netcat-traditional (#1782)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-22 10:58:00 +02:00
Adrià Casajús
76b05e0d64
Preserve original sender and authentication results if the original email is preserved in the alias (#1780)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-22 10:40:32 +02:00
Adrià Casajús
40663358d8
Add Object.freeze to prevent proto injections (#1781)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-21 18:57:54 +02:00
Adrià Casajús
f046b2270c
Fix: send also mailbox email to verify so that mailbox changes are not allowed (#1777)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-21 18:56:22 +02:00
Adrià Casajús
03c67ead44
Do not show the default domain twice (#1772)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-21 18:56:03 +02:00
Adrià Casajús
37ffe4d5fe
Fix: Always include default domain in the list of domains (#1768)
* Fix: Always include default domain in the list of domains

* Add premium test

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-06 15:55:10 +02:00
Adrià Casajús
689ef3a579
Check if the domain has a deleted alias (#1764)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-01 17:33:58 +02:00
Adrià Casajús
495d544505
Only retry n times each message (#1759)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-01 10:59:02 +02:00
Adrià Casajús
a539428607
Fix: If default domain is premium for free users do not offer it as an option (#1763)
* Fix: If default domain is premium for free users do not offer it as an option

* Refactored into simpler logic

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-06-01 10:46:25 +02:00
Adrià Casajús
8c7e9f7fb3
Fix: only send subscription notification if there is a valid subscription (#1762)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-31 18:20:18 +02:00
Adrià Casajús
9d9e5fcab6
Fix: If the default domain is hidden do not return it (#1761)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-31 17:59:49 +02:00
Adrià Casajús
ff33392398
Fix: use incorrect model to access profile picture path (#1760)
(cherry picked from commit e875f1dd40fe726f6e83aaa833f65eb9e10f7e94)

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-31 17:21:30 +02:00
Adrià Casajús
85964f283e
Add timeout to any outbound connection (#1756)
* Add timeout to any outbound connection

* Change log message to error

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-31 14:19:43 +02:00
Carlos Quintana
d30183bbda
fix: remove user password from export user data (#1758) 2023-05-31 09:40:20 +02:00
Adrià Casajús
ed66c7306b
Fix typo (#1755)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-29 17:50:41 +02:00
Adrià Casajús
07bb658310
Show the default domain for creating aliases even if it's not requested by a partner (#1754)
* Show the default domain in the suffixes even if it's not allowed

* Simplify logic

* Reformat

* Simplified logic

* Remove unused function

* Added test to validate suffixes

* Ensure we catch prefixes in test

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-29 16:40:04 +02:00
Adrià Casajús
e43a2dd34d
Have subscription callback whenever a subscription changes (#1748)
* Have subscription callback whenever a subscription changes

* Fixed tests

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-26 15:33:55 +02:00
Adrià Casajús
3de83f2f05
Add toggle to check if a user is premium without the partner subscription (#1739)
* Add toggle to check if a user is premium without the partner subscription

* fix test

* Parter created users do not have a newsletter alias id

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-15 12:34:58 +02:00
Adrià Casajús
e4d4317988
Various fixes (#1733)
* Reset all password tokens on password reset

* Added csrf validation on email change request and validation

* Return the same wether is a valid email or not

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-10 15:31:30 +02:00
Adrià Casajús
da2cedd254
Update package-lock.json to fix build error (#1732)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-10 11:18:45 +02:00
Adrià Casajús
e343b27fa6
Update package-lock.json (#1728)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-09 18:10:13 +02:00
Adrià Casajús
6dfb6bb3e4
Revert "Add code verification for creating mailboxes (#1725)" (#1727)
This reverts commit a5e7da10dd.

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-09 18:04:04 +02:00
Adrià Casajús
a5e7da10dd
Add code verification for creating mailboxes (#1725)
* Add code verification for creating mailboxes

* Added validation checks

* Use exceptions

* Added delete to the mailbox utils

* Fix test

* Update package.lock

* Fix delete error

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-09 16:35:58 +02:00
Adrià Casajús
5ddbca05b2
Check users aren't using an alias as their link email address for partner links (#1724)
(cherry picked from commit 93e24cb4239b812d46f119a982edd12de2406802)

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-08 18:47:10 +02:00
Faisal Misle
6c33e0d986
documentation clarification (#1717) 2023-05-03 19:56:22 +02:00
Adrià Casajús
7cb7b48845
Ensure coupons are only used once (#1718)
* Ensure coupons are only used once

* Update test to handle redirect

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-05-03 16:18:46 +02:00
Son Nguyen Kim
6276ad4419
Stats endpoint (#1716)
* update some dependencies: newrelic, gevent

that isn't compatible with python 3.11 on mac

* update package-lock using npm 9.6.4 and node 20.0

* Add GET /api/stats

* update pytest

---------

Co-authored-by: Son Nguyen Kim <son@Sons-MacBook-Air-2.local>
2023-05-03 10:15:47 +02:00
Son Nguyen Kim
66c3a07c92
Update dep (#1715)
* update some dependencies: newrelic, gevent

that isn't compatible with python 3.11 on mac

* update package-lock using npm 9.6.4 and node 20.0

---------

Co-authored-by: Son Nguyen Kim <son@Sons-MacBook-Air-2.local>
2023-05-02 23:01:55 +02:00
D-Bao
23a4e46885
add option to show/hide stats in aliases page (#1697) 2023-04-22 21:16:03 +02:00
Adrià Casajús
52e6f5e2d2
Fix: Allow contacts created with a domain to be delivered even if the domain cannot be used any more for contact creation (#1704)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-04-21 21:07:58 +02:00
Son Nguyen Kim
59c189957f
fix the E501 check (#1702) 2023-04-20 12:43:43 +02:00
Adrià Casajús
bec8cb2292
Alias domain as contact domain (#1689)
* Use the alias domain for contacts

* Check there are not duplicate emails

* Check also in trash

* Use helper

* Set VERP for the forward phase to the contact domain

* Add pgp_fingerprint as index for contacts

* Removed check trash

* Only use reply domains for sl domains

* Configure via db wether the domain can be used as a reverse_domain

* Fix: typo

* reverse logic

* fix migration

* fix test

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
Co-authored-by: Son <nguyenkims@users.noreply.github.com>
2023-04-20 12:14:53 +02:00
Adrià Casajús
7f23533c64
Fix sever typo (#1701)
* Fix: typo

* Limit the name to 100 chars

* Fix migration

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-04-20 11:06:59 +02:00
Adrià Casajús
62fecf1190
Add end_at index to PartnerSubscription (#1696)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-04-15 20:49:59 +02:00
Adrià Casajús
9d8116e535
Add migrations to create indexes (#1694)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-04-14 19:10:21 +02:00
Adrià Casajús
796c0c5aa1
Add alias indexes in the tables that refer to alias to speedup the alias deletion process (#1693)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-04-14 19:08:52 +02:00
Adrià Casajús
5a56b46650
Add pgp_fingerprint as index for contacts (#1692)
(cherry picked from commit 350d246d32)

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-04-14 18:29:06 +02:00
D-Bao
e3ae9bc6d5
Replace save/update buttons with an auto save feature (#1685)
* replace save/update buttons with auto save feature

* minor css improvement
2023-04-11 22:52:44 +02:00
Son Nguyen Kim
ec666aee87
minor wording change (#1684) 2023-04-07 09:24:06 +02:00
D-Bao
2230e0b925
Redesign new pricing page (#1680)
* redesign new pricing page

* add FAQ section

* reformatting using djlint

* fix djlint formatting

* minor Indentation adjustment
2023-04-07 09:22:57 +02:00
Adrià Casajús
71fd5e2241
Reduce rate limit on password forgot route (#1683)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-04-06 15:55:37 +02:00
Adrià Casajús
97cbff5dc9
Fix: add missing alias options (#1682)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-04-06 12:42:18 +02:00
Adrià Casajús
b6f79ea3a6
Refactor alias options and add it to more methods (#1681)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-04-06 11:07:13 +02:00
Adrià Casajús
43b91cd197
Create Partner only domains (#1665)
* Add Partner only domains

* Add hidden domain to the test and revert to default domains after the tests

* Send what to show in each call

* Fix: Pass none instead of false

* Removed flag from partnerusr

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-04-04 15:21:51 +02:00
Son Nguyen Kim
03e5083d97
use {word1}_{word2}{digits} as random alias address instead of {word1}{word2}{digits} (#1673) 2023-04-04 08:46:29 +02:00
Son Nguyen Kim
1f9d784382
Use a shorter suffix in case of custom domain (#1670) 2023-03-28 22:33:28 +02:00
dependabot[bot]
c09b5bc526
Bump redis from 4.3.4 to 4.5.3 (#1668)
Bumps [redis](https://github.com/redis/redis-py) from 4.3.4 to 4.5.3.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v4.3.4...v4.5.3)

---
updated-dependencies:
- dependency-name: redis
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-28 14:37:15 +02:00
Son Nguyen Kim
eba4ee8c2c
remove unnecessary plausible calls (#1664) 2023-03-27 10:48:41 +02:00
D-Bao
1c65094da8
Fix drag and drop to upload PGP public key not working on Firefox and Chromium (but working on Safari) (#1658)
* Fix pgp file drag and drop only worked on Safari

* Minor UI improvement of pgp public key text area

* add dashed outline only during dragover event
2023-03-27 10:48:27 +02:00
Carlos Quintana
2a014f0e4b
chore: add example to domain detail with subdomain (#1663)
* chore: add example to domain detail with subdomain

* Update templates/dashboard/domain_detail/dns.html

---------

Co-authored-by: Adrià Casajús <acasajus@users.noreply.github.com>
2023-03-24 10:33:00 +01:00
Son Nguyen Kim
b081b6a16a
track "visit pricing" and "upgraded" event (#1662) 2023-03-23 21:11:05 +01:00
Son Nguyen Kim
66039c526b
use PreserveOriginal as default (#1652) 2023-03-22 15:47:40 +01:00
Adrià Casajús
f722cae8d6
Add multiple registration warning message (#1653)
* Add multiple registration warning message

* Add alert

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-03-22 15:35:33 +01:00
Son Nguyen Kim
b6286e3c1b
Fix recreate alias from trash (#1641)
* no need to check for a deleted alias that belongs to user domain

* fix config.SAVE_UNSENT_DIR not set
2023-03-17 15:39:59 +01:00
Son Nguyen Kim
26d5fd400c
change docs.simplelogin.to to https://simplelogin.io/docs/siwsl/app/ (#1640)
* change docs.simplelogin.to to https://simplelogin.io/docs/siwsl/app/

* fix url
2023-03-17 15:39:47 +01:00
Son Nguyen Kim
b470ab3396
reset transfer token (#1638) 2023-03-17 11:47:11 +01:00
Adrià Casajús
66388e72e0
Feat: Use only sfw words with a number suffix (#1625)
* Feat: Use only sfw words with a number suffix

* Updated also custom aliases to have a number suffix

* do not use _ as separator

* use _ as separator for words-based suffix

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
Co-authored-by: Son <nguyenkims@users.noreply.github.com>
2023-03-13 19:55:16 +01:00
Adrià Casajús
432fb3fcf7
Fix: Send different exception for users with an alias as email (#1630)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-03-13 14:57:00 +01:00
Adrià Casajús
44e0dd8635
Break using an alias as a mailbox loop in the email_handler.py (#1624)
* Do not allow to use email alias as account email when linking

* Add missing status

* Remove TODO

* Also break contact as email loop

* Better test names

* Allow a reverse alias to send an email to an alias

* Ident fix

* Removed invalid test

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-03-13 13:01:00 +01:00
Adrià Casajús
2ec1208eb7
Remove dangerous words (#1620)
* Remove invalid words from word generation list

* Remove more dangerous words

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-03-08 09:09:30 +00:00
Adrià Casajús
87efe6b059
Remove invalid words from word generation list (#1615)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-03-03 17:30:53 +01:00
guzlewski
6a60a4951e
Add sender do message body (#1609) 2023-02-28 18:58:54 +01:00
Carlos Quintana
b3ce5c8901
chore: add noopener noreferrer to every target _blank (#1608) 2023-02-27 13:15:25 +01:00
Adrià Casajús
3fcb37f246
Reformat base64 encoded messages to shorter lines (#1575)
* Reformat base64 encoded messages to shorter lines

* Remove storing debug versions

* Add  example test email

* Update linelength to 76

* Revert changes in pre-commit

---------

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-02-21 15:28:06 +01:00
Son Nguyen Kim
62ba2844f3
add admin page for InvalidMailboxDomain (#1573)
* add admin page for InvalidMailboxDomain

* show creation and modification date for InvalidMailboxDomain
2023-02-15 10:38:18 +01:00
Carlos Quintana
9143a0f6bc
fix: ensure contact name fits within db limits (#1568) 2023-02-10 10:07:43 +01:00
Adrià Casajús
48ae859e1b
Fix: Set the smtp default port in config to allow connect to port 25 with TLS (#1564)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-02-06 16:53:10 +01:00
Adrià Casajús
0a197313ea
Fix: allow receive email from non-canonical sources (#1545)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-01-25 13:17:20 +01:00
Son Nguyen Kim
b487b01442
Fix format (#1554)
* after deleting an alias, user should stay on the same page

* fix email indentation
2023-01-25 13:16:29 +01:00
Son Nguyen Kim
170082e2c1
after deleting an alias, user should stay on the same page (#1546)
* after deleting an alias, user should stay on the same page

* Fix delete alias mlec (#1547)

* Specify how to create the certificates if they don't exist in readme (#1533)

* Remove id= from get 🩹

* Add flash message level 🩹

* Rename transfer_mailbox back to new_mailbox in the create-mailbox part 🩹

Co-authored-by: rubencm <rubencm@gmail.com>

* Fix delete alias mlec (#1552)

* Specify how to create the certificates if they don't exist in readme (#1533)

* Remove id= from get 🩹

* Add flash message level 🩹

* Rename transfer_mailbox back to new_mailbox in the create-mailbox part 🩹

* Linting files to pass test 🎨

Co-authored-by: rubencm <rubencm@gmail.com>

Co-authored-by: mlec <42201667+mlec1@users.noreply.github.com>
Co-authored-by: rubencm <rubencm@gmail.com>
2023-01-25 13:16:10 +01:00
rubencm
51916a8c8a
Specify how to create the certificates if they don't exist in readme (#1533) 2023-01-17 20:09:41 +01:00
Adrià Casajús
4f2b624cc7
Set the proper link for unsub newsletter (#1503)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-01-17 11:56:00 +01:00
Adrià Casajús
81eb56e213
Tranfer aliases to a new mailbox when deleting mailboxes (#1534)
* Set up npm clean install instead of npm install in order to keep the version of npm packages 🎨

* Add option to transfer the alias to a new mailbox when a mailbox is deleted

* Moved alias transfer to job

* Lint

* Update forms

* Revert dockerfile change

Co-authored-by: ewen <ewen.coppens@a1.digital>
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-01-17 11:55:34 +01:00
Adrià Casajús
650a74ac00
Fix: Use npm ci instead of install to prevent install different versions (#1543)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-01-17 09:57:38 +01:00
Adrian Schnell
e6cdabd46e
Update docu for /api/alias/random/new (#1515)
the param `mode` has to be passed in the query
2023-01-12 15:55:07 +01:00
Adrià Casajús
d874acfe2c
Fix: Add CSRF validation to api key management page (#1523)
* Fix: Add CSRF validation to api key management page

* Added csrf to subdomain creation

* Added CSRF to totp cancel

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-01-12 12:34:47 +01:00
Adrià Casajús
0ab53ad49a
Fix: Use timed signers to avoid leaving permanent links (#1524)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-01-12 12:34:14 +01:00
Adrià Casajús
92de307c75
Added parallel limiting to creating custom domains, directories, mailboxes and subdomains (#1525)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-01-11 22:08:52 +01:00
Adrià Casajús
38c93e7f85
Fix: typo in the message (#1522)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2023-01-11 22:05:31 +01:00
Carlos Quintana
f2a840016b
chore: allow redis to support rediss (#1526) 2023-01-11 16:25:35 +01:00
Son Nguyen Kim
54997a8978
Manual sub reminder (#1519)
* use support page to renew sub

* remove other payment options
2023-01-11 14:29:41 +01:00
Son Nguyen Kim
be6bc7088e
use SL.com instead of SL.co in the example (#1506)
* use SL.com instead of SL.co in the example

* reduce the admin page size to speed up loading

* Revert "reduce the admin page size to speed up loading"

This reverts commit d7550ab153.
2022-12-28 09:37:50 +01:00
Adrià Casajús
ca0cbd911f
Remove bad words from the word list (#1500)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-21 07:56:50 +01:00
Adrià Casajús
0284719dbb
Fix: Remove * from a param (typo) (#1498)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-19 11:45:04 +01:00
Adrià Casajús
9378b8a17d
Fix: Return email in the get_communication_email always and search for the alias when needed (#1497)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-19 09:23:53 +01:00
Adrià Casajús
3f84a63e6d
Extend validity of totp tokens for up to a minute. (#1494)
* Feat: Allow TOTP for up to one minute in the future and in the past

* Feat: Allow TOTP for up to one minute in the future and in the past

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-16 17:54:46 +01:00
Adrià Casajús
5e48d86efa
Canonicalize emails from google and proton before registering users (#1493)
* Revert "Revert "Use canonical email when registering users (#1458)" (#1474)"

This reverts commit c8ab1c747e.

* Only canonicalize gmail and proton

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-14 11:50:36 +01:00
Adrià Casajús
9dcf063337
Rate limit changing user settings (#1491)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-13 18:48:44 +01:00
Adrià Casajús
73c0429cad
Fix: Set oneclick link for unsubscribe of the newsletter for tx emails (#1465)
* Feat: Add unsub oneclick to the base transactional email template

* Format

* Removed unused

* Format

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-13 16:59:14 +01:00
Adrià Casajús
21e9fce3ba
Set the admin view to show 100 entries by default (#1490)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-13 16:58:27 +01:00
Adrià Casajús
c8ab1c747e
Revert "Use canonical email when registering users (#1458)" (#1474)
* Revert "Use canonical email when registering users (#1458)"

This reverts commit f728b0175a.

* missing chang

* typo
2022-12-08 10:57:46 +01:00
Adrià Casajús
8636659ca9
Update docs to the same port as the reset script + remove pre-commit pylint (#1464)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-02 17:31:31 +01:00
Adrià Casajús
7e360bcbd9
Fix: Add mising csrf validation for contact pgp key modification (#1463)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-02 15:13:38 +01:00
Adrià Casajús
327b672f24
Set the user name on creation to the original email (#1462)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-01 13:07:36 +01:00
Adrià Casajús
12b18dd8b1
Revert BlackFriday banners (#1461)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-01 09:25:24 +01:00
Adrià Casajús
0996378537
Revert "Keep the dirty email after registering (#1459)" (#1460)
This reverts commit 0664e3b80c.

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-12-01 09:19:15 +01:00
Adrià Casajús
0664e3b80c
Keep the dirty email after registering (#1459)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-30 18:38:48 +01:00
Adrià Casajús
f728b0175a
Use canonical email when registering users (#1458)
* Use canonical email for registration, check both when checking if user exists

* Fix test

* Set pagesize to 100

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-30 17:19:55 +01:00
Adrià Casajús
53ef99562c
Add unsub link to newsletters (#1455)
* Add unsub link to newsletters

* Remove debug statement

* Updated unsub link

* Update unsub style

* Format

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-28 15:08:46 +01:00
Adrià Casajús
363a9932f1
Allow sentry to fail (#1454)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-28 12:40:06 +01:00
Adrià Casajús
b6ec4a9ac7
Update github checkout actions to v3 (#1451)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-28 11:13:27 +01:00
Adrià Casajús
3c36f37a12
Feat: Add enable/disable options in the admin panel (#1450)
* Feat: Add enable/disable options in the admin panel

* Fix duplicate method

* Black format

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-28 10:39:18 +01:00
Adrià Casajús
478b1386cd
Updated checkout action to full checkout the repo (#1436)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-24 10:37:04 +01:00
Spitfireap
b849d1cfa7
Simpler csv export (#1383)
* Export alias in csv

* reformating

* template

* Improved contributing script and doc

* Updated test

* removed csv export from GDPR export archive

* added test for new route

* fix trailing space

* moved test to new utils file
2022-11-23 13:51:08 +01:00
Adrià Casajús
0fbe576c44
Fix: Also replace source mailbox to alias when replacing stuff in the reply phase (#1432)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-18 14:30:19 +01:00
Son Nguyen Kim
d2360d1a99
update black friday wording (#1430) 2022-11-16 15:49:22 +01:00
Son Nguyen Kim
420bc56fc8
fix test (#1429) 2022-11-16 13:58:30 +01:00
Son Nguyen Kim
b3e9232956
show black friday banner (#1428)
* show black friday banner

* djlint
2022-11-16 13:43:09 +01:00
Son Nguyen Kim
989358af34
Fix empty authorized address (#1423)
* not allow empty authorized address

* check authorized address before adding

* use github for flake8

* fix test
2022-11-15 16:04:31 +01:00
Son Nguyen Kim
390b96b991
remove the code which is never called (#1407)
* remove the code which is never called

* fix comment

* no need to run ci for python 3.9
2022-11-15 10:07:06 +01:00
Adrià Casajús
4661972f97
Fix: When re-sending emails if they trigger exceptions move out of failed dir (#1411)
* Fix: When re-sending emails if they trigger exceptions move out of failed dir

* Use proper timeout

* Lint

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-10 13:24:46 +01:00
Son Nguyen Kim
25743da161
Dmarc transactional (#1402)
* make sure transactional email use the same domain for header from and envelope from

* fix import
2022-11-04 14:22:28 +01:00
Adrià Casajús
5bbf6a2654
Fix: Only override postfix port when enabling TLS if the port is set to be 25 (#1401)
* Fix: Only override postfix port when enabling TLS if the port is set to be 25

* Add connection timeout

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-04 11:13:19 +01:00
Adrià Casajús
dace2b1233
Fix: Do not re-re-deliver unsent mails on failure to re-deliver (#1397)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-03 17:48:09 +01:00
Adrià Casajús
afe2de4167
Fix: Create crontab for all hosts (#1396)
* Fix: Create crontab for all hosts

* Typo

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-02 18:13:24 +01:00
Adrià Casajús
efc7760ecb
Use newer github actions to install and cache poetry (#1395)
* Use newer github actions to install and cache poetry

* Update setup-python action to v4

* Parallel execution

* Build depends on lint

* Added missing req deps

* Install in all

* Remove unused

* No need to lint on all python versions

* Remove matrix deps

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-02 17:59:36 +01:00
Adrià Casajús
90d60217a4
Feat: Re-deliver mails (#1394)
* Feat: Send undelivered emails

* Add cron job

* Added to the crontab

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-02 15:51:14 +01:00
Adrià Casajús
3bc976c322
Feature: Add app name to each db connection (#1393)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-11-02 15:41:48 +01:00
Son Nguyen Kim
36d1626972
Notify another mailbox about an email sent by a mailbox to a reverse alias (#1381)
* Notify another mailbox about an email sent by a mailbox to a reverse alias

* keep reverse alias in CC and To header

* use alias as From to hint that the email is sent from the alias

* keep original subject, improve wording

* only add DKIM if custom domain has DKIM enabled
2022-10-30 19:59:42 +01:00
Adrià Casajús
6d8fba0320
Added too many exceptions test (#1378)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-10-27 14:04:03 +02:00
Son Nguyen Kim
02f42821c5
fix 21004 error (#1380) 2022-10-27 14:03:11 +02:00
Adrià Casajús
a5056b3fcc
Fix: Use source ip if user is not authenticated (#1379)
* Fix: Use source ip if user is not authenticated

* Fix lint

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-10-27 13:37:45 +02:00
Adrià Casajús
f6463a5adc
Change: Do not sleep on exclusive zones (#1375)
* Change: Do not sleep on exclusive zones

* Update test

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-10-27 10:40:33 +02:00
Adrià Casajús
7f9ce5641f
Feat: Added parallel limiter to prevent sqlalchemy transaction issues (#1360)
* Feat: Added parallel limiter to prevent sqlalchemy transaction issues

* Remove logs

* Moved initialization to its own file

* Throw exception

* Added test

* Add redis to gh actions

* Added v6 to the name

* Removed debug prints

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-10-27 10:07:02 +02:00
Adrià Casajús
d324e2fa79
Fix: Add csrf verification to directory updates (#1358)
* Fix: Add csrf verification to directory updates

* Update templates/dashboard/directory.html

* Added csrf for delete account form

* Fix tests

* Added CSRF check for settings page

* Added csrf to batch import

* Added CSRF to alias dashboard and alias transfer

* Added csrf to contact manager

* Added csrf to mailbox

* Added csrf for mailbox detail

* Added csrf to domain detail

* Lint

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-10-27 10:04:47 +02:00
Son Nguyen Kim
2f769b38ad
Apple in app fix (#1369)
* error log if issue with apple sub

* use the right secret when polling apple sub
2022-10-25 19:45:53 +02:00
Son Nguyen Kim
87047b3250
use /p.outbound.js and /p/api/event on app.sl.io (#1366) 2022-10-24 18:18:22 +02:00
Adrià Casajús
300f8c959e
Fix: Add words.txt to local data (#1365)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-10-24 17:55:59 +02:00
Son Nguyen Kim
8c73ff3c16
plausible: use https://simplelogin.io/p.outbound.js (#1364) 2022-10-24 16:44:01 +02:00
Son Nguyen Kim
9b452641a8
rename analytics.js to an.js (#1363) 2022-10-24 15:47:02 +02:00
Son Nguyen Kim
35470613d3
add DailyMetric and Metric as admin page, remove EmailLog admin page (#1352) 2022-10-15 19:10:39 +02:00
Son Nguyen Kim
c71824c68e
Init daily metric (#1351)
* Add DailyMetric model

* increment nb_new_web_non_proton_user

* fix test

* fix test
2022-10-14 17:35:34 +02:00
Son Nguyen Kim
1fc75203f2
Improve test: disable rate limit during test and avoid conflicts between tests (#1349)
* disable rate limit during test, avoid conflict between tests

* fix test
2022-10-14 16:37:49 +02:00
Son Nguyen Kim
3a4dac15f0
Plausible roll up (#1350)
* enable plausible roll-up, use everything.simplelogin.com

* versionning analytics.js to avoid caching

* allow plausible custom event

* send "Complete registration" event when user finishes signup

* remove blank lines
2022-10-14 10:38:43 +02:00
Son Nguyen Kim
7b24cdd98a
Revert "remove deduct_limit as it has no effect (#1347)" (#1348)
This reverts commit 851ba0a99a.
2022-10-13 22:00:45 +02:00
Son Nguyen Kim
851ba0a99a
remove deduct_limit as it has no effect (#1347)
* remove deduct_limit as it has no effect

- disable rate limit during test
- randomize data in test
- support non-empty db in test

* fix more test
2022-10-13 18:55:22 +02:00
Son Nguyen Kim
3be75a1bd9
fix copy to clipboard (#1346) 2022-10-13 17:29:01 +02:00
Adrià Casajús
72277211bb
For unauthenticated sessions only store them in redis for 5m (#1345)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-10-13 15:55:08 +02:00
Adrià Casajús
d5ca316e41
Have custom domains set up multiple dkim records to be able to rotate keys (#1334)
* Have custom domains set up multiple dkim records to be able to rotate keys

* Apply suggestions from code review

* Some PR comments

* Keep dkim enabled if it is already

* Format

* PR updates

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-10-11 07:17:37 +02:00
Son Nguyen Kim
f3bfc6e6a1
djlint (#1342) 2022-10-10 10:25:53 +02:00
mfmw123
21ce5c8e10
Corrections & consistent footer (#1338)
* Corrections and consistent footer

- _Downloads_ instead of _Features_
- Made _open source_ a link
- Deleted _-_ in the _open source_
- Added comparisons to be consistent with the main page
- Fixed GitHub spelling

* fix styling

Co-authored-by: Son Nguyen Kim <nguyenkims@users.noreply.github.com>
2022-10-10 10:17:12 +02:00
Son Nguyen Kim
1c5a547cd0
do not quarantine an email if fails DMARC but has a small rspamd score (#1337)
* do not quarantine an email if fails DMARC but has a small rspamd score

* use 0 when cannot parse rspamd score

* use -1 as default value
2022-10-10 10:13:07 +02:00
Son Nguyen Kim
5088604bb8
Replace reverse alias (#1335)
* replace any reverse alias by real address for all contacts

* improve logging

* fix comment

* Request contacts in batches of 100 to avoid loading the db

* Fix typo

* Added tests for the contact replacement

* Increase batch size to 1k

* Revert and use only reply_email and website_email

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-10-10 10:00:19 +02:00
Son Nguyen Kim
4ff158950d
use Proton Mail instead of Protonmail (#1336) 2022-10-06 17:43:01 +02:00
Son Nguyen Kim
d159a51de4
update logo white (#1331) 2022-10-04 18:07:00 +02:00
Son Nguyen Kim
002897182e
use logo with Proton mention (#1330) 2022-10-04 11:14:23 +02:00
Adrià Casajús
faeddc365c
Display recovery codes for mfa only once (#1317)
* Recovery codes can only be shown after adding a 2FA code and cannot be seen afterwards

* Added recovery codes fix

* Updated models and script

* Formatting

* Format

* Added base code

* Updated wording

* Set the config by default

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-10-03 12:32:45 +02:00
Adrià Casajús
faaff7e9b9
Handle failed payments subscriptions in paddle (#1327)
* Handle failed payments subscriptions in paddle

* Added tests

* Remove unused import

* Remove unused import

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-09-30 17:51:06 +02:00
Son Nguyen Kim
d415974e3b
Handle undisclosed recipients header (#1314)
* remove TO header if it's set to "undisclosed-recipients:;"

more info on https://www.rfc-editor.org/rfc/rfc4356.txt

* remove unnecessary indentation character in plain text email
2022-09-27 09:43:58 +02:00
Carlos Quintana
fa50c23a43
Allow RedisSessionStore to connect to sentinel (#1307)
* Allow RedisSessionStore to connect to sentinel

* Reuse flask_limiter redis storage

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-09-23 10:23:07 +02:00
Son Nguyen Kim
3900742d1f
Add proton mention (#1306)
* do not add mime-version header if already present

* mention proton in footer

* update email template
2022-09-22 15:15:22 +02:00
Son Nguyen Kim
72a130e225
do not add mime-version header if already present (#1302) 2022-09-22 13:46:32 +02:00
Adrià Casajús
b5aff490ef
Store session in redis if redis is enabled (#1288)
* Store sesions in redis to prevent saving old cookies

* Format

* Rename sid to session_id

* Logout session completely

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-09-21 11:11:17 +02:00
Son Nguyen Kim
2760b149ff
change twitter handle to simplelogin instead of simple_login (#1286) 2022-09-14 17:37:41 +02:00
Adrià Casajús
9c86e1a820
Fix: Use email directly for DomainDeletedAlias (#1273)
* Fix: Use email directly for DomainDeletedAlias

* Add handling for reply phase

* Use the first mailbox of the domain for deleted domain aliase

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-09-08 14:54:32 +02:00
Son Nguyen Kim
753a28e886
handle case msg is string in replace() (#1271)
should fix https://sentry.io/organizations/simplelogin/issues/3563106404/?alert_rule_id=2478639&alert_timestamp=1662404226476&alert_type=email&environment=production&project=1868546&referrer=alert_email
2022-09-07 10:22:11 +02:00
Carlos Quintana
f47661c3d2
Add uncategorized PRs to changelog (#1270) 2022-09-05 16:43:18 +02:00
Son Nguyen Kim
6595d34276
shouldn't count processed batch import (#1268) 2022-09-05 15:38:12 +02:00
Son Nguyen Kim
192d03fd68
make sure sl_formataddr always return str (#1269) 2022-09-05 15:38:04 +02:00
Son Nguyen Kim
313a928070
Create sl_formataddr to handle unicode for built-in formataddr (#1265)
* Create sl_formataddr to handle unicode for built-in formataddr

* fix circular import
2022-09-05 08:40:24 +02:00
PurpleSn0w
48127914c2
Fix: Spelling (#1259)
* Fix: Spelling

* Fix: Spelling

Co-authored-by: Hugh <inbox.xmrjn@simplelogin.co>
2022-09-02 11:58:26 +02:00
Son Nguyen Kim
cea139b7d5
Improve handling when pgp key is invalid (#1264)
* remove unused email statuses

* add more logging

* use text_header if html_header not set

* improve email

* add a header about PGP failure when forward emails can't be encrypted

* remove unused email status
2022-09-02 11:47:04 +02:00
Son Nguyen Kim
25773448c2
admin can go directly to paddle (#1263) 2022-09-02 10:39:53 +02:00
Son Nguyen Kim
96e6753c95
fix dockerfile (#1262) 2022-09-01 16:40:39 +02:00
Son Nguyen Kim
2b389cbe53
use the recommended way to install poetry (#1261) 2022-09-01 15:28:33 +02:00
Son Nguyen Kim
ae2cbf98e2
Handle invalid pgp key (#1260)
* check invalid mailbox pgp key

* check if public key is valid before trying with pgpy

* fix query

* remove unused code
2022-09-01 15:10:11 +02:00
Son Nguyen Kim
f69c9583fb
fix proton partner error when self host (#1255)
* fix proton partner error when self host

* fix test

* fix test

* remove a@b.c
2022-09-01 14:59:16 +02:00
Son Nguyen Kim
72256d935c
do not notify lifetime user about coinbase sub (#1254) 2022-08-30 22:41:08 +02:00
Son Nguyen Kim
fd00100141
fix grammar mistake (#1248) 2022-08-26 16:47:25 +02:00
Son Nguyen Kim
9eacd980ef
include_sender_in_reverse_alias set to true for new users (#1244) 2022-08-23 11:24:49 +02:00
Son Nguyen Kim
b299a305b5
Fix quarantine (#1241)
* add more logging

* fix quarantine email incorrect deleted_at
2022-08-18 14:47:05 +02:00
Carlos Quintana
ba06852dc2
Do not crash if action is unknown (#1231) 2022-08-12 15:02:00 +02:00
Carlos Quintana
7eb44a5947
Fixes for connect with proton on mobile (#1230)
* Fixes for connect with proton on mobile

* Added a test

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-08-12 13:17:21 +02:00
Thanh-Nhon NGUYEN
7476bdde4b
Fix typo in hyperlink to GET /api/user/cookie_token (#1227) 2022-08-12 11:58:31 +02:00
Carlos Quintana
596dd0b1ee
Support next with Proton Link (#1226)
* Support next with Proton Link

* Add support for double next

* Fix bug on account relink
2022-08-11 10:38:44 +02:00
Adrià Casajús
3a75686898
Generate a web session from an api key (#1224)
* Create a token to exchange for a cookie

* Added Route to exchange token for cookie

* add missing migration



Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-08-10 18:48:32 +02:00
Carlos Quintana
a9549c11d7
Rate limiting depending on user authenticated status (#1221)
* Rate limiting depending on user authenticated status

* Update app/extensions.py

Co-authored-by: Adrià Casajús <acasajus@users.noreply.github.com>

* Add rate_limiting tests

Co-authored-by: Adrià Casajús <acasajus@users.noreply.github.com>
2022-08-09 14:57:21 +02:00
Son Nguyen Kim
a88a8ff2be
add more logging (#1223) 2022-08-09 10:01:55 +02:00
Son Nguyen Kim
6c6deedf47
Stop paddle sub (#1216)
* admin can stop a paddle sub

* show admin menu if user is admin
2022-08-04 09:20:07 +02:00
melbv
f340c9c9ea
DB port correction (#1214)
Correction of the port assigned to PostGresql from '35432' to '5432'
2022-08-03 16:04:03 +02:00
275 changed files with 18978 additions and 329125 deletions

View file

@ -1,5 +1,5 @@
{ {
"template": "${{CHANGELOG}}", "template": "${{CHANGELOG}}\n\n<details>\n<summary>Uncategorized</summary>\n\n${{UNCATEGORIZED}}\n</details>",
"pr_template": "- ${{TITLE}} #${{NUMBER}}", "pr_template": "- ${{TITLE}} #${{NUMBER}}",
"empty_template": "- no changes", "empty_template": "- no changes",
"categories": [ "categories": [
@ -20,4 +20,4 @@
"tag_resolver": { "tag_resolver": {
"method": "semver" "method": "semver"
} }
} }

View file

@ -1,15 +1,44 @@
name: Run tests & Publish to Docker Registry name: Test and lint
on: on:
push: push:
jobs: jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v3
- name: Install poetry
run: pipx install poetry
- uses: actions/setup-python@v4
with:
python-version: '3.10'
cache: 'poetry'
- name: Install OS dependencies
if: ${{ matrix.python-version }} == '3.10'
run: |
sudo apt update
sudo apt install -y libre2-dev libpq-dev
- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction
- name: Check formatting & linting
run: |
poetry run pre-commit run --all-files
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
max-parallel: 4 max-parallel: 4
matrix: matrix:
python-version: ["3.9", "3.10"] python-version: ["3.10"]
# service containers to run with `postgres-job` # service containers to run with `postgres-job`
services: services:
@ -38,27 +67,16 @@ jobs:
--health-retries 5 --health-retries 5
steps: steps:
- name: Check out repository - name: Check out repo
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install poetry - name: Install poetry
uses: snok/install-poetry@v1 run: pipx install poetry
with:
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
- name: Run caching - uses: actions/setup-python@v4
id: cached-poetry-dependencies
uses: actions/cache@v2
with: with:
path: .venv python-version: ${{ matrix.python-version }}
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} cache: 'poetry'
- name: Install OS dependencies - name: Install OS dependencies
if: ${{ matrix.python-version }} == '3.10' if: ${{ matrix.python-version }} == '3.10'
@ -68,14 +86,13 @@ jobs:
- name: Install dependencies - name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root
- name: Install library
run: poetry install --no-interaction run: poetry install --no-interaction
- name: Check formatting & linting
run: | - name: Start Redis v6
poetry run pre-commit run --all-files uses: superchargejs/redis-github-action@1.1.0
with:
redis-version: 6
- name: Run db migration - name: Run db migration
run: | run: |
@ -100,7 +117,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: ['test'] needs: ['test', 'lint']
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')) if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v'))
steps: steps:
@ -118,7 +135,19 @@ jobs:
# We need to checkout the repository in order for the "Create Sentry release" to work # We need to checkout the repository in order for the "Create Sentry release" to work
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Create Sentry release
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
ignore_missing: true
ignore_empty: true
- name: Prepare version file - name: Prepare version file
run: | run: |
@ -132,12 +161,6 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
- name: Create Sentry release
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
#- name: Send Telegram message #- name: Send Telegram message
# uses: appleboy/telegram-action@master # uses: appleboy/telegram-action@master

1
.gitignore vendored
View file

@ -15,3 +15,4 @@ venv/
.coverage .coverage
htmlcov htmlcov
adhoc adhoc
.env.*

View file

@ -7,21 +7,19 @@ repos:
hooks: hooks:
- id: check-yaml - id: check-yaml
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 22.3.0
hooks:
- id: black
- repo: https://gitlab.com/pycqa/flake8
rev: 3.9.2
hooks:
- id: flake8
- repo: https://github.com/Riverside-Healthcare/djLint - repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.3.0 rev: v1.3.0
hooks: hooks:
- id: djlint-jinja - id: djlint-jinja
files: '.*\.html' files: '.*\.html'
entry: djlint --reformat entry: djlint --reformat
- repo: https://github.com/PyCQA/pylint - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v2.14.4 # Ruff version.
rev: v0.1.5
hooks: hooks:
- id: pylint # Run the linter.
- id: ruff
args: [ --fix ]
# Run the formatter.
- id: ruff-format

View file

@ -34,7 +34,7 @@ poetry install
On Mac, sometimes you might need to install some other packages via `brew`: On Mac, sometimes you might need to install some other packages via `brew`:
```bash ```bash
brew install pkg-config libffi openssl postgresql brew install pkg-config libffi openssl postgresql@13
``` ```
You also need to install `gpg` tool, on Mac it can be done with: You also need to install `gpg` tool, on Mac it can be done with:
@ -62,6 +62,8 @@ To install it in your development environment.
## Run tests ## Run tests
For most tests, you will need to have ``redis`` installed and started on your machine (listening on port 6379).
```bash ```bash
sh scripts/run-test.sh sh scripts/run-test.sh
``` ```
@ -80,10 +82,16 @@ To run the code locally, please create a local setting file based on `example.en
cp example.env .env cp example.env .env
``` ```
You need to edit your .env to reflect the postgres exposed port, edit the `DB_URI` to:
```
DB_URI=postgresql://myuser:mypassword@localhost:35432/simplelogin
```
Run the postgres database: Run the postgres database:
```bash ```bash
docker run -e POSTGRES_PASSWORD=mypassword -e POSTGRES_USER=myuser -e POSTGRES_DB=simplelogin -p 35432:5432 postgres:13 docker run -e POSTGRES_PASSWORD=mypassword -e POSTGRES_USER=myuser -e POSTGRES_DB=simplelogin -p 15432:5432 postgres:13
``` ```
To run the server: To run the server:
@ -161,6 +169,12 @@ For HTML templates, we use `djlint`. Before creating a pull request, please run
poetry run djlint --check templates poetry run djlint --check templates
``` ```
If some files aren't properly formatted, you can format all files with
```bash
poetry run djlint --reformat .
```
## Test sending email ## Test sending email
[swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`. [swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`.
@ -198,4 +212,11 @@ python email_handler.py
swaks --to e1@sl.local --from hey@google.com --server 127.0.0.1:20381 swaks --to e1@sl.local --from hey@google.com --server 127.0.0.1:20381
``` ```
Now open http://localhost:1080/ (or http://localhost:1080/ for MailHog), you should see the forwarded email. Now open http://localhost:1080/ (or http://localhost:1080/ for MailHog), you should see the forwarded email.
## Job runner
Some features require a job handler (such as GDPR data export). To test such feature you need to run the job_runner
```bash
python job_runner.py
```

View file

@ -2,7 +2,7 @@
FROM node:10.17.0-alpine AS npm FROM node:10.17.0-alpine AS npm
WORKDIR /code WORKDIR /code
COPY ./static/package*.json /code/static/ COPY ./static/package*.json /code/static/
RUN cd /code/static && npm install RUN cd /code/static && npm ci
# Main image # Main image
FROM python:3.10 FROM python:3.10
@ -13,7 +13,7 @@ ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
# Add poetry to PATH # Add poetry to PATH
ENV PATH="${PATH}:/root/.poetry/bin" ENV PATH="${PATH}:/root/.local/bin"
WORKDIR /code WORKDIR /code
@ -23,15 +23,15 @@ COPY poetry.lock pyproject.toml ./
# Install and setup poetry # Install and setup poetry
RUN pip install -U pip \ RUN pip install -U pip \
&& apt-get update \ && apt-get update \
&& apt install -y curl netcat gcc python3-dev gnupg git libre2-dev \ && apt install -y curl netcat-traditional gcc python3-dev gnupg git libre2-dev cmake ninja-build\
&& curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python - \ && curl -sSL https://install.python-poetry.org | python3 - \
# Remove curl and netcat from the image # Remove curl and netcat from the image
&& apt-get purge -y curl netcat \ && apt-get purge -y curl netcat-traditional \
# Run poetry # Run poetry
&& poetry config virtualenvs.create false \ && poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi --no-root \ && poetry install --no-interaction --no-ansi --no-root \
# Clear apt cache \ # Clear apt cache \
&& apt-get purge -y libre2-dev \ && apt-get purge -y libre2-dev cmake ninja-build\
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View file

@ -15,8 +15,8 @@
<img src="https://img.shields.io/github/license/simple-login/app"> <img src="https://img.shields.io/github/license/simple-login/app">
</a> </a>
<a href="https://twitter.com/simple_login"> <a href="https://twitter.com/simplelogin">
<img src="https://img.shields.io/twitter/follow/simple_login?style=social"> <img src="https://img.shields.io/twitter/follow/simplelogin?style=social">
</a> </a>
</p> </p>
@ -334,6 +334,12 @@ smtpd_recipient_restrictions =
permit permit
``` ```
Check that the ssl certificates `/etc/ssl/certs/ssl-cert-snakeoil.pem` and `/etc/ssl/private/ssl-cert-snakeoil.key` exist. Depending on the linux distribution you are using they may or may not be present. If they are not, you will need to generate them with this command:
```bash
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout /etc/ssl/private/ssl-cert-snakeoil.key -out /etc/ssl/certs/ssl-cert-snakeoil.pem
```
Create the `/etc/postfix/pgsql-relay-domains.cf` file with the following content. Create the `/etc/postfix/pgsql-relay-domains.cf` file with the following content.
Make sure that the database config is correctly set, replace `mydomain.com` with your domain, update 'myuser' and 'mypassword' with your postgres credentials. Make sure that the database config is correctly set, replace `mydomain.com` with your domain, update 'myuser' and 'mypassword' with your postgres credentials.

View file

@ -5,17 +5,23 @@ from typing import Optional
from arrow import Arrow from arrow import Arrow
from newrelic import agent from newrelic import agent
from sqlalchemy import or_
from app.db import Session from app.db import Session
from app.email_utils import send_welcome_email from app.email_utils import send_welcome_email
from app.utils import sanitize_email from app.utils import sanitize_email, canonicalize_email
from app.errors import AccountAlreadyLinkedToAnotherPartnerException from app.errors import (
AccountAlreadyLinkedToAnotherPartnerException,
AccountIsUsingAliasAsEmail,
AccountAlreadyLinkedToAnotherUserException,
)
from app.log import LOG from app.log import LOG
from app.models import ( from app.models import (
PartnerSubscription, PartnerSubscription,
Partner, Partner,
PartnerUser, PartnerUser,
User, User,
Alias,
) )
from app.utils import random_string from app.utils import random_string
@ -126,8 +132,9 @@ class ClientMergeStrategy(ABC):
class NewUserStrategy(ClientMergeStrategy): class NewUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult: def process(self) -> LinkResult:
# Will create a new SL User with a random password # Will create a new SL User with a random password
canonical_email = canonicalize_email(self.link_request.email)
new_user = User.create( new_user = User.create(
email=self.link_request.email, email=canonical_email,
name=self.link_request.name, name=self.link_request.name,
password=random_string(20), password=random_string(20),
activated=True, activated=True,
@ -161,7 +168,6 @@ class NewUserStrategy(ClientMergeStrategy):
class ExistingUnlinkedUserStrategy(ClientMergeStrategy): class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult: def process(self) -> LinkResult:
partner_user = ensure_partner_user_exists_for_user( partner_user = ensure_partner_user_exists_for_user(
self.link_request, self.user, self.partner self.link_request, self.user, self.partner
) )
@ -175,7 +181,7 @@ class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
class LinkedWithAnotherPartnerUserStrategy(ClientMergeStrategy): class LinkedWithAnotherPartnerUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult: def process(self) -> LinkResult:
raise AccountAlreadyLinkedToAnotherPartnerException() raise AccountAlreadyLinkedToAnotherUserException()
def get_login_strategy( def get_login_strategy(
@ -192,6 +198,12 @@ def get_login_strategy(
return ExistingUnlinkedUserStrategy(link_request, user, partner) return ExistingUnlinkedUserStrategy(link_request, user, partner)
def check_alias(email: str) -> bool:
alias = Alias.get_by(email=email)
if alias is not None:
raise AccountIsUsingAliasAsEmail()
def process_login_case( def process_login_case(
link_request: PartnerLinkRequest, partner: Partner link_request: PartnerLinkRequest, partner: Partner
) -> LinkResult: ) -> LinkResult:
@ -202,9 +214,21 @@ def process_login_case(
partner_id=partner.id, external_user_id=link_request.external_user_id partner_id=partner.id, external_user_id=link_request.external_user_id
) )
if partner_user is None: if partner_user is None:
canonical_email = canonicalize_email(link_request.email)
# We didn't find any SimpleLogin user registered with that partner user id # We didn't find any SimpleLogin user registered with that partner user id
# Make sure they aren't using an alias as their link email
check_alias(link_request.email)
check_alias(canonical_email)
# Try to find it using the partner's e-mail address # Try to find it using the partner's e-mail address
user = User.get_by(email=link_request.email) users = User.filter(
or_(User.email == link_request.email, User.email == canonical_email)
).all()
if len(users) > 1:
user = [user for user in users if user.email == canonical_email][0]
elif len(users) == 1:
user = users[0]
else:
user = None
return get_login_strategy(link_request, user, partner).process() return get_login_strategy(link_request, user, partner).process()
else: else:
# We found the SL user registered with that partner user id # We found the SL user registered with that partner user id
@ -276,7 +300,7 @@ def process_link_case(
return link_user(link_request, current_user, partner) return link_user(link_request, current_user, partner)
# There is a SL user registered with the partner. Check if is the current one # There is a SL user registered with the partner. Check if is the current one
if partner_user.id == current_user.id: if partner_user.user_id == current_user.id:
# Update plan # Update plan
set_plan_for_partner_user(partner_user, link_request.plan) set_plan_for_partner_user(partner_user, link_request.plan)
# It's the same user. No need to do anything # It's the same user. No need to do anything
@ -285,5 +309,4 @@ def process_link_case(
strategy="Link", strategy="Link",
) )
else: else:
return switch_already_linked_user(link_request, partner_user, current_user) return switch_already_linked_user(link_request, partner_user, current_user)

View file

@ -26,6 +26,7 @@ from app.models import (
ProviderComplaint, ProviderComplaint,
Alias, Alias,
Newsletter, Newsletter,
PADDLE_SUBSCRIPTION_GRACE_DAYS,
) )
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
@ -33,6 +34,7 @@ from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_add
class SLModelView(sqla.ModelView): class SLModelView(sqla.ModelView):
column_default_sort = ("id", True) column_default_sort = ("id", True)
column_display_pk = True column_display_pk = True
page_size = 100
can_edit = False can_edit = False
can_create = False can_create = False
@ -92,6 +94,10 @@ class SLAdminIndexView(AdminIndexView):
return redirect("/admin/user") return redirect("/admin/user")
def _user_upgrade_channel_formatter(view, context, model, name):
return Markup(model.upgrade_channel)
class UserAdmin(SLModelView): class UserAdmin(SLModelView):
column_searchable_list = ["email", "id"] column_searchable_list = ["email", "id"]
column_exclude_list = [ column_exclude_list = [
@ -109,6 +115,38 @@ class UserAdmin(SLModelView):
ret.insert(0, "upgrade_channel") ret.insert(0, "upgrade_channel")
return ret return ret
column_formatters = {
"upgrade_channel": _user_upgrade_channel_formatter,
}
@action(
"disable_user",
"Disable user",
"Are you sure you want to disable the selected users?",
)
def action_disable_user(self, ids):
for user in User.filter(User.id.in_(ids)):
user.disabled = True
flash(f"Disabled user {user.id}")
AdminAuditLog.disable_user(current_user.id, user.id)
Session.commit()
@action(
"enable_user",
"Enable user",
"Are you sure you want to enable the selected users?",
)
def action_enable_user(self, ids):
for user in User.filter(User.id.in_(ids)):
user.disabled = False
flash(f"Enabled user {user.id}")
AdminAuditLog.enable_user(current_user.id, user.id)
Session.commit()
@action( @action(
"education_upgrade", "education_upgrade",
"Education upgrade", "Education upgrade",
@ -199,6 +237,36 @@ class UserAdmin(SLModelView):
Session.commit() Session.commit()
@action(
"stop_paddle_sub",
"Stop user Paddle subscription",
"This will stop the current user Paddle subscription so if user doesn't have Proton sub, they will lose all SL benefits immediately",
)
def stop_paddle_sub(self, ids):
for user in User.filter(User.id.in_(ids)):
sub: Subscription = user.get_paddle_subscription()
if not sub:
flash(f"No Paddle sub for {user}", "warning")
continue
flash(f"{user} sub will end now, instead of {sub.next_bill_date}", "info")
sub.next_bill_date = (
arrow.now().shift(days=-PADDLE_SUBSCRIPTION_GRACE_DAYS).date()
)
Session.commit()
@action(
"clear_delete_on",
"Remove scheduled deletion of user",
"This will remove the scheduled deletion for this users",
)
def clean_delete_on(self, ids):
for user in User.filter(User.id.in_(ids)):
user.delete_on = None
Session.commit()
# @action( # @action(
# "login_as", # "login_as",
# "Login as this user", # "Login as this user",
@ -543,6 +611,26 @@ class NewsletterAdmin(SLModelView):
else: else:
flash(error_msg, "error") flash(error_msg, "error")
@action(
"clone_newsletter",
"Clone this newsletter",
)
def clone_newsletter(self, newsletter_ids):
if len(newsletter_ids) != 1:
flash("you can only select 1 newsletter", "error")
return
newsletter_id = newsletter_ids[0]
newsletter: Newsletter = Newsletter.get(newsletter_id)
new_newsletter = Newsletter.create(
subject=newsletter.subject,
html=newsletter.html,
plain_text=newsletter.plain_text,
commit=True,
)
flash(f"Newsletter {new_newsletter.subject} has been cloned", "success")
class NewsletterUserAdmin(SLModelView): class NewsletterUserAdmin(SLModelView):
column_searchable_list = ["id"] column_searchable_list = ["id"]
@ -551,3 +639,20 @@ class NewsletterUserAdmin(SLModelView):
can_edit = False can_edit = False
can_create = False can_create = False
class DailyMetricAdmin(SLModelView):
column_exclude_list = ["created_at", "updated_at", "id"]
can_export = True
class MetricAdmin(SLModelView):
column_exclude_list = ["created_at", "updated_at", "id"]
can_export = True
class InvalidMailboxDomainAdmin(SLModelView):
can_create = True
can_delete = True

View file

@ -6,8 +6,7 @@ from typing import Optional
import itsdangerous import itsdangerous
from app import config from app import config
from app.log import LOG from app.log import LOG
from app.models import User from app.models import User, AliasOptions, SLDomain
signer = itsdangerous.TimestampSigner(config.CUSTOM_ALIAS_SECRET) signer = itsdangerous.TimestampSigner(config.CUSTOM_ALIAS_SECRET)
@ -43,7 +42,9 @@ def check_suffix_signature(signed_suffix: str) -> Optional[str]:
return None return None
def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool: def verify_prefix_suffix(
user: User, alias_prefix, alias_suffix, alias_options: Optional[AliasOptions] = None
) -> bool:
"""verify if user could create an alias with the given prefix and suffix""" """verify if user could create an alias with the given prefix and suffix"""
if not alias_prefix or not alias_suffix: # should be caught on frontend if not alias_prefix or not alias_suffix: # should be caught on frontend
return False return False
@ -56,7 +57,7 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
alias_domain_prefix, alias_domain = alias_suffix.split("@", 1) alias_domain_prefix, alias_domain = alias_suffix.split("@", 1)
# alias_domain must be either one of user custom domains or built-in domains # alias_domain must be either one of user custom domains or built-in domains
if alias_domain not in user.available_alias_domains(): if alias_domain not in user.available_alias_domains(alias_options=alias_options):
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False return False
@ -64,12 +65,11 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
# 1) alias_suffix must start with "." and # 1) alias_suffix must start with "." and
# 2) alias_domain_prefix must come from the word list # 2) alias_domain_prefix must come from the word list
if ( if (
alias_domain in user.available_sl_domains() alias_domain in user.available_sl_domains(alias_options=alias_options)
and alias_domain not in user_custom_domains and alias_domain not in user_custom_domains
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty # when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
and not config.DISABLE_ALIAS_SUFFIX and not config.DISABLE_ALIAS_SUFFIX
): ):
if not alias_domain_prefix.startswith("."): if not alias_domain_prefix.startswith("."):
LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix) LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix)
return False return False
@ -80,14 +80,18 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False return False
if alias_domain not in user.available_sl_domains(): if alias_domain not in user.available_sl_domains(
alias_options=alias_options
):
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False return False
return True return True
def get_alias_suffixes(user: User) -> [AliasSuffix]: def get_alias_suffixes(
user: User, alias_options: Optional[AliasOptions] = None
) -> [AliasSuffix]:
""" """
Similar to as get_available_suffixes() but also return custom domain that doesn't have MX set up. Similar to as get_available_suffixes() but also return custom domain that doesn't have MX set up.
""" """
@ -99,7 +103,9 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
# for each user domain, generate both the domain and a random suffix version # for each user domain, generate both the domain and a random suffix version
for custom_domain in user_custom_domains: for custom_domain in user_custom_domains:
if custom_domain.random_prefix_generation: if custom_domain.random_prefix_generation:
suffix = "." + user.get_random_alias_suffix() + "@" + custom_domain.domain suffix = (
f".{user.get_random_alias_suffix(custom_domain)}@{custom_domain.domain}"
)
alias_suffix = AliasSuffix( alias_suffix = AliasSuffix(
is_custom=True, is_custom=True,
suffix=suffix, suffix=suffix,
@ -113,7 +119,7 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
else: else:
alias_suffixes.append(alias_suffix) alias_suffixes.append(alias_suffix)
suffix = "@" + custom_domain.domain suffix = f"@{custom_domain.domain}"
alias_suffix = AliasSuffix( alias_suffix = AliasSuffix(
is_custom=True, is_custom=True,
suffix=suffix, suffix=suffix,
@ -134,16 +140,13 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
alias_suffixes.append(alias_suffix) alias_suffixes.append(alias_suffix)
# then SimpleLogin domain # then SimpleLogin domain
for sl_domain in user.get_sl_domains(): sl_domains = user.get_sl_domains(alias_options=alias_options)
suffix = ( default_domain_found = False
( for sl_domain in sl_domains:
"" prefix = (
if config.DISABLE_ALIAS_SUFFIX "" if config.DISABLE_ALIAS_SUFFIX else f".{user.get_random_alias_suffix()}"
else "." + user.get_random_alias_suffix()
)
+ "@"
+ sl_domain.domain
) )
suffix = f"{prefix}@{sl_domain.domain}"
alias_suffix = AliasSuffix( alias_suffix = AliasSuffix(
is_custom=False, is_custom=False,
suffix=suffix, suffix=suffix,
@ -152,11 +155,36 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
domain=sl_domain.domain, domain=sl_domain.domain,
mx_verified=True, mx_verified=True,
) )
# No default or this is not the default
# put the default domain to top if (
if user.default_alias_public_domain_id == sl_domain.id: user.default_alias_public_domain_id is None
alias_suffixes.insert(0, alias_suffix) or user.default_alias_public_domain_id != sl_domain.id
else: ):
alias_suffixes.append(alias_suffix) alias_suffixes.append(alias_suffix)
else:
default_domain_found = True
alias_suffixes.insert(0, alias_suffix)
if not default_domain_found:
domain_conditions = {"id": user.default_alias_public_domain_id, "hidden": False}
if not user.is_premium():
domain_conditions["premium_only"] = False
sl_domain = SLDomain.get_by(**domain_conditions)
if sl_domain:
prefix = (
""
if config.DISABLE_ALIAS_SUFFIX
else f".{user.get_random_alias_suffix()}"
)
suffix = f"{prefix}@{sl_domain.domain}"
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=sl_domain.premium_only,
domain=sl_domain.domain,
mx_verified=True,
)
alias_suffixes.insert(0, alias_suffix)
return alias_suffixes return alias_suffixes

View file

@ -1,8 +1,11 @@
import csv
from io import StringIO
import re import re
from typing import Optional, Tuple from typing import Optional, Tuple
from email_validator import validate_email, EmailNotValidError from email_validator import validate_email, EmailNotValidError
from sqlalchemy.exc import IntegrityError, DataError from sqlalchemy.exc import IntegrityError, DataError
from flask import make_response
from app.config import ( from app.config import (
BOUNCE_PREFIX_FOR_REPLY_PHASE, BOUNCE_PREFIX_FOR_REPLY_PHASE,
@ -18,6 +21,8 @@ from app.email_utils import (
send_cannot_create_directory_alias_disabled, send_cannot_create_directory_alias_disabled,
get_email_local_part, get_email_local_part,
send_cannot_create_domain_alias, send_cannot_create_domain_alias,
send_email,
render,
) )
from app.errors import AliasInTrashError from app.errors import AliasInTrashError
from app.log import LOG from app.log import LOG
@ -33,6 +38,8 @@ from app.models import (
EmailLog, EmailLog,
Contact, Contact,
AutoCreateRule, AutoCreateRule,
AliasUsedOn,
ClientUser,
) )
from app.regex_utils import regex_match from app.regex_utils import regex_match
@ -54,6 +61,8 @@ def get_user_if_alias_would_auto_create(
domain_and_rule = check_if_alias_can_be_auto_created_for_custom_domain( domain_and_rule = check_if_alias_can_be_auto_created_for_custom_domain(
address, notify_user=notify_user address, notify_user=notify_user
) )
if DomainDeletedAlias.get_by(email=address):
return None
if domain_and_rule: if domain_and_rule:
return domain_and_rule[0].user return domain_and_rule[0].user
directory = check_if_alias_can_be_auto_created_for_a_directory( directory = check_if_alias_can_be_auto_created_for_a_directory(
@ -85,6 +94,7 @@ def check_if_alias_can_be_auto_created_for_custom_domain(
return None return None
if not user.can_create_new_alias(): if not user.can_create_new_alias():
LOG.d(f"{user} can't create new custom-domain alias {address}")
if notify_user: if notify_user:
send_cannot_create_domain_alias(custom_domain.user, address, alias_domain) send_cannot_create_domain_alias(custom_domain.user, address, alias_domain)
return None return None
@ -146,6 +156,7 @@ def check_if_alias_can_be_auto_created_for_a_directory(
return None return None
if not user.can_create_new_alias(): if not user.can_create_new_alias():
LOG.d(f"{user} can't create new directory alias {address}")
if notify_user: if notify_user:
send_cannot_create_directory_alias(user, address, directory_name) send_cannot_create_directory_alias(user, address, directory_name)
return None return None
@ -362,3 +373,88 @@ def check_alias_prefix(alias_prefix) -> bool:
return False return False
return True return True
def alias_export_csv(user, csv_direct_export=False):
"""
Get user aliases as importable CSV file
Output:
Importable CSV file
"""
data = [["alias", "note", "enabled", "mailboxes"]]
for alias in Alias.filter_by(user_id=user.id).all(): # type: Alias
# Always put the main mailbox first
# It is seen a primary while importing
alias_mailboxes = alias.mailboxes
alias_mailboxes.insert(
0, alias_mailboxes.pop(alias_mailboxes.index(alias.mailbox))
)
mailboxes = " ".join([mailbox.email for mailbox in alias_mailboxes])
data.append([alias.email, alias.note, alias.enabled, mailboxes])
si = StringIO()
cw = csv.writer(si)
cw.writerows(data)
if csv_direct_export:
return si.getvalue()
output = make_response(si.getvalue())
output.headers["Content-Disposition"] = "attachment; filename=aliases.csv"
output.headers["Content-type"] = "text/csv"
return output
def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
# cannot transfer alias which is used for receiving newsletter
if User.get_by(newsletter_alias_id=alias.id):
raise Exception("Cannot transfer alias that's used to receive newsletter")
# update user_id
Session.query(Contact).filter(Contact.alias_id == alias.id).update(
{"user_id": new_user.id}
)
Session.query(AliasUsedOn).filter(AliasUsedOn.alias_id == alias.id).update(
{"user_id": new_user.id}
)
Session.query(ClientUser).filter(ClientUser.alias_id == alias.id).update(
{"user_id": new_user.id}
)
# remove existing mailboxes from the alias
Session.query(AliasMailbox).filter(AliasMailbox.alias_id == alias.id).delete()
# set mailboxes
alias.mailbox_id = new_mailboxes.pop().id
for mb in new_mailboxes:
AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id)
# alias has never been transferred before
if not alias.original_owner_id:
alias.original_owner_id = alias.user_id
# inform previous owner
old_user = alias.user
send_email(
old_user.email,
f"Alias {alias.email} has been received",
render(
"transactional/alias-transferred.txt",
alias=alias,
),
render(
"transactional/alias-transferred.html",
alias=alias,
),
)
# now the alias belongs to the new user
alias.user_id = new_user.id
# set some fields back to default
alias.disable_pgp = False
alias.pinned = False
Session.commit()

View file

@ -16,3 +16,22 @@ from .views import (
sudo, sudo,
user, user,
) )
__all__ = [
"alias_options",
"new_custom_alias",
"custom_domain",
"new_random_alias",
"user_info",
"auth",
"auth_mfa",
"alias",
"apple",
"mailbox",
"notification",
"setting",
"export",
"phone",
"sudo",
"user",
]

View file

@ -24,6 +24,7 @@ from app.errors import (
ErrContactAlreadyExists, ErrContactAlreadyExists,
ErrAddressInvalid, ErrAddressInvalid,
) )
from app.extensions import limiter
from app.models import Alias, Contact, Mailbox, AliasMailbox from app.models import Alias, Contact, Mailbox, AliasMailbox
@ -71,6 +72,9 @@ def get_aliases():
@api_bp.route("/v2/aliases", methods=["GET", "POST"]) @api_bp.route("/v2/aliases", methods=["GET", "POST"])
@limiter.limit(
"5/minute",
)
@require_api_auth @require_api_auth
def get_aliases_v2(): def get_aliases_v2():
""" """

View file

@ -9,6 +9,7 @@ from requests import RequestException
from app.api.base import api_bp, require_api_auth from app.api.base import api_bp, require_api_auth
from app.config import APPLE_API_SECRET, MACAPP_APPLE_API_SECRET from app.config import APPLE_API_SECRET, MACAPP_APPLE_API_SECRET
from app.subscription_webhook import execute_subscription_webhook
from app.db import Session from app.db import Session
from app.log import LOG from app.log import LOG
from app.models import PlanEnum, AppleSubscription from app.models import PlanEnum, AppleSubscription
@ -40,15 +41,17 @@ def apple_process_payment():
LOG.d("request for /apple/process_payment from %s", user) LOG.d("request for /apple/process_payment from %s", user)
data = request.get_json() data = request.get_json()
receipt_data = data.get("receipt_data") receipt_data = data.get("receipt_data")
is_macapp = "is_macapp" in data is_macapp = "is_macapp" in data and data["is_macapp"] is True
if is_macapp: if is_macapp:
LOG.d("Use Macapp secret")
password = MACAPP_APPLE_API_SECRET password = MACAPP_APPLE_API_SECRET
else: else:
password = APPLE_API_SECRET password = APPLE_API_SECRET
apple_sub = verify_receipt(receipt_data, user, password) apple_sub = verify_receipt(receipt_data, user, password)
if apple_sub: if apple_sub:
execute_subscription_webhook(user)
return jsonify(ok=True), 200 return jsonify(ok=True), 200
return jsonify(error="Processing failed"), 400 return jsonify(error="Processing failed"), 400
@ -281,6 +284,7 @@ def apple_update_notification():
apple_sub.plan = plan apple_sub.plan = plan
apple_sub.product_id = transaction["product_id"] apple_sub.product_id = transaction["product_id"]
Session.commit() Session.commit()
execute_subscription_webhook(user)
return jsonify(ok=True), 200 return jsonify(ok=True), 200
else: else:
LOG.w( LOG.w(
@ -474,7 +478,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
# } # }
if data["status"] != 0: if data["status"] != 0:
LOG.w( LOG.e(
"verifyReceipt status !=0, probably invalid receipt. User %s, data %s", "verifyReceipt status !=0, probably invalid receipt. User %s, data %s",
user, user,
data, data,
@ -521,9 +525,10 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
if apple_sub: if apple_sub:
LOG.d( LOG.d(
"Update AppleSubscription for user %s, expired at %s, plan %s", "Update AppleSubscription for user %s, expired at %s (%s), plan %s",
user, user,
expires_date, expires_date,
expires_date.humanize(),
plan, plan,
) )
apple_sub.receipt_data = receipt_data apple_sub.receipt_data = receipt_data
@ -552,6 +557,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
product_id=latest_transaction["product_id"], product_id=latest_transaction["product_id"],
) )
execute_subscription_webhook(user)
Session.commit() Session.commit()
return apple_sub return apple_sub

View file

@ -23,7 +23,7 @@ from app.events.auth_event import LoginEvent, RegisterEvent
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import User, ApiKey, SocialAuth, AccountActivation from app.models import User, ApiKey, SocialAuth, AccountActivation
from app.utils import sanitize_email from app.utils import sanitize_email, canonicalize_email
@api_bp.route("/auth/login", methods=["POST"]) @api_bp.route("/auth/login", methods=["POST"])
@ -49,11 +49,13 @@ def auth_login():
if not data: if not data:
return jsonify(error="request body cannot be empty"), 400 return jsonify(error="request body cannot be empty"), 400
email = sanitize_email(data.get("email"))
password = data.get("password") password = data.get("password")
device = data.get("device") device = data.get("device")
user = User.filter_by(email=email).first() email = sanitize_email(data.get("email"))
canonical_email = canonicalize_email(data.get("email"))
user = User.get_by(email=email) or User.get_by(email=canonical_email)
if not user or not user.check_password(password): if not user or not user.check_password(password):
LoginEvent(LoginEvent.ActionType.failed, LoginEvent.Source.api).send() LoginEvent(LoginEvent.ActionType.failed, LoginEvent.Source.api).send()
@ -61,6 +63,11 @@ def auth_login():
elif user.disabled: elif user.disabled:
LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send() LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send()
return jsonify(error="Account disabled"), 400 return jsonify(error="Account disabled"), 400
elif user.delete_on is not None:
LoginEvent(
LoginEvent.ActionType.scheduled_to_be_deleted, LoginEvent.Source.api
).send()
return jsonify(error="Account scheduled for deletion"), 400
elif not user.activated: elif not user.activated:
LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send() LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send()
return jsonify(error="Account not activated"), 422 return jsonify(error="Account not activated"), 422
@ -89,7 +96,8 @@ def auth_register():
if not data: if not data:
return jsonify(error="request body cannot be empty"), 400 return jsonify(error="request body cannot be empty"), 400
email = sanitize_email(data.get("email")) dirty_email = data.get("email")
email = canonicalize_email(dirty_email)
password = data.get("password") password = data.get("password")
if DISABLE_REGISTRATION: if DISABLE_REGISTRATION:
@ -110,7 +118,7 @@ def auth_register():
return jsonify(error="password too long"), 400 return jsonify(error="password too long"), 400
LOG.d("create user %s", email) LOG.d("create user %s", email)
user = User.create(email=email, name="", password=password) user = User.create(email=email, name=dirty_email, password=password)
Session.flush() Session.flush()
# create activation code # create activation code
@ -148,9 +156,10 @@ def auth_activate():
return jsonify(error="request body cannot be empty"), 400 return jsonify(error="request body cannot be empty"), 400
email = sanitize_email(data.get("email")) email = sanitize_email(data.get("email"))
canonical_email = canonicalize_email(data.get("email"))
code = data.get("code") code = data.get("code")
user = User.get_by(email=email) user = User.get_by(email=email) or User.get_by(email=canonical_email)
# do not use a different message to avoid exposing existing email # do not use a different message to avoid exposing existing email
if not user or user.activated: if not user or user.activated:
@ -196,7 +205,9 @@ def auth_reactivate():
return jsonify(error="request body cannot be empty"), 400 return jsonify(error="request body cannot be empty"), 400
email = sanitize_email(data.get("email")) email = sanitize_email(data.get("email"))
user = User.get_by(email=email) canonical_email = canonicalize_email(data.get("email"))
user = User.get_by(email=email) or User.get_by(email=canonical_email)
# do not use a different message to avoid exposing existing email # do not use a different message to avoid exposing existing email
if not user or user.activated: if not user or user.activated:
@ -351,7 +362,7 @@ def auth_payload(user, device) -> dict:
@api_bp.route("/auth/forgot_password", methods=["POST"]) @api_bp.route("/auth/forgot_password", methods=["POST"])
@limiter.limit("10/minute") @limiter.limit("2/minute")
def forgot_password(): def forgot_password():
""" """
User forgot password User forgot password
@ -367,8 +378,9 @@ def forgot_password():
return jsonify(error="request body must contain email"), 400 return jsonify(error="request body must contain email"), 400
email = sanitize_email(data.get("email")) email = sanitize_email(data.get("email"))
canonical_email = canonicalize_email(data.get("email"))
user = User.get_by(email=email) user = User.get_by(email=email) or User.get_by(email=canonical_email)
if user: if user:
send_reset_password_email(user) send_reset_password_email(user)

View file

@ -55,7 +55,7 @@ def auth_mfa():
) )
totp = pyotp.TOTP(user.otp_secret) totp = pyotp.TOTP(user.otp_secret)
if not totp.verify(mfa_token): if not totp.verify(mfa_token, valid_window=2):
send_invalid_totp_login_email(user, "TOTP") send_invalid_totp_login_email(user, "TOTP")
return jsonify(error="Wrong TOTP Token"), 400 return jsonify(error="Wrong TOTP Token"), 400

View file

@ -1,12 +1,9 @@
import csv
from io import StringIO
from flask import g from flask import g
from flask import jsonify from flask import jsonify
from flask import make_response
from app.api.base import api_bp, require_api_auth from app.api.base import api_bp, require_api_auth
from app.models import Alias, Client, CustomDomain from app.models import Alias, Client, CustomDomain
from app.alias_utils import alias_export_csv
@api_bp.route("/export/data", methods=["GET"]) @api_bp.route("/export/data", methods=["GET"])
@ -49,24 +46,4 @@ def export_aliases():
Importable CSV file Importable CSV file
""" """
user = g.user return alias_export_csv(g.user)
data = [["alias", "note", "enabled", "mailboxes"]]
for alias in Alias.filter_by(user_id=user.id).all(): # type: Alias
# Always put the main mailbox first
# It is seen a primary while importing
alias_mailboxes = alias.mailboxes
alias_mailboxes.insert(
0, alias_mailboxes.pop(alias_mailboxes.index(alias.mailbox))
)
mailboxes = " ".join([mailbox.email for mailbox in alias_mailboxes])
data.append([alias.email, alias.note, alias.enabled, mailboxes])
si = StringIO()
cw = csv.writer(si)
cw.writerows(data)
output = make_response(si.getvalue())
output.headers["Content-Disposition"] = "attachment; filename=aliases.csv"
output.headers["Content-type"] = "text/csv"
return output

View file

@ -13,8 +13,8 @@ from app.db import Session
from app.email_utils import ( from app.email_utils import (
mailbox_already_used, mailbox_already_used,
email_can_be_used_as_mailbox, email_can_be_used_as_mailbox,
is_valid_email,
) )
from app.email_validation import is_valid_email
from app.log import LOG from app.log import LOG
from app.models import Mailbox, Job from app.models import Mailbox, Job
from app.utils import sanitize_email from app.utils import sanitize_email
@ -45,7 +45,7 @@ def create_mailbox():
mailbox_email = sanitize_email(request.get_json().get("email")) mailbox_email = sanitize_email(request.get_json().get("email"))
if not user.is_premium(): if not user.is_premium():
return jsonify(error=f"Only premium plan can add additional mailbox"), 400 return jsonify(error="Only premium plan can add additional mailbox"), 400
if not is_valid_email(mailbox_email): if not is_valid_email(mailbox_email):
return jsonify(error=f"{mailbox_email} invalid"), 400 return jsonify(error=f"{mailbox_email} invalid"), 400
@ -78,6 +78,9 @@ def delete_mailbox(mailbox_id):
Delete mailbox Delete mailbox
Input: Input:
mailbox_id: in url mailbox_id: in url
(optional) transfer_aliases_to: in body. Id of the new mailbox for the aliases.
If omitted or the value is set to -1,
the aliases of the mailbox will be deleted too.
Output: Output:
200 if deleted successfully 200 if deleted successfully
@ -91,11 +94,36 @@ def delete_mailbox(mailbox_id):
if mailbox.id == user.default_mailbox_id: if mailbox.id == user.default_mailbox_id:
return jsonify(error="You cannot delete the default mailbox"), 400 return jsonify(error="You cannot delete the default mailbox"), 400
data = request.get_json() or {}
transfer_mailbox_id = data.get("transfer_aliases_to")
if transfer_mailbox_id and int(transfer_mailbox_id) >= 0:
transfer_mailbox = Mailbox.get(transfer_mailbox_id)
if not transfer_mailbox or transfer_mailbox.user_id != user.id:
return (
jsonify(error="You must transfer the aliases to a mailbox you own."),
403,
)
if transfer_mailbox_id == mailbox_id:
return (
jsonify(
error="You can not transfer the aliases to the mailbox you want to delete."
),
400,
)
if not transfer_mailbox.verified:
return jsonify(error="Your new mailbox is not verified"), 400
# Schedule delete account job # Schedule delete account job
LOG.w("schedule delete mailbox job for %s", mailbox) LOG.w("schedule delete mailbox job for %s", mailbox)
Job.create( Job.create(
name=JOB_DELETE_MAILBOX, name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": mailbox.id}, payload={
"mailbox_id": mailbox.id,
"transfer_mailbox_id": transfer_mailbox_id,
},
run_at=arrow.now(), run_at=arrow.now(),
commit=True, commit=True,
) )

View file

@ -1,6 +1,7 @@
from flask import g from flask import g
from flask import jsonify, request from flask import jsonify, request
from app import parallel_limiter
from app.alias_suffix import check_suffix_signature, verify_prefix_suffix from app.alias_suffix import check_suffix_signature, verify_prefix_suffix
from app.alias_utils import check_alias_prefix from app.alias_utils import check_alias_prefix
from app.api.base import api_bp, require_api_auth from app.api.base import api_bp, require_api_auth
@ -27,6 +28,7 @@ from app.utils import convert_to_id
@api_bp.route("/v2/alias/custom/new", methods=["POST"]) @api_bp.route("/v2/alias/custom/new", methods=["POST"])
@limiter.limit(ALIAS_LIMIT) @limiter.limit(ALIAS_LIMIT)
@require_api_auth @require_api_auth
@parallel_limiter.lock(name="alias_creation")
def new_custom_alias_v2(): def new_custom_alias_v2():
""" """
Create a new custom alias Create a new custom alias
@ -113,6 +115,7 @@ def new_custom_alias_v2():
@api_bp.route("/v3/alias/custom/new", methods=["POST"]) @api_bp.route("/v3/alias/custom/new", methods=["POST"])
@limiter.limit(ALIAS_LIMIT) @limiter.limit(ALIAS_LIMIT)
@require_api_auth @require_api_auth
@parallel_limiter.lock(name="alias_creation")
def new_custom_alias_v3(): def new_custom_alias_v3():
""" """
Create a new custom alias Create a new custom alias
@ -147,7 +150,7 @@ def new_custom_alias_v3():
if not data: if not data:
return jsonify(error="request body cannot be empty"), 400 return jsonify(error="request body cannot be empty"), 400
if type(data) is not dict: if not isinstance(data, dict):
return jsonify(error="request body does not follow the required format"), 400 return jsonify(error="request body does not follow the required format"), 400
alias_prefix = data.get("alias_prefix", "").strip().lower().replace(" ", "") alias_prefix = data.get("alias_prefix", "").strip().lower().replace(" ", "")
@ -165,7 +168,7 @@ def new_custom_alias_v3():
return jsonify(error="alias prefix invalid format or too long"), 400 return jsonify(error="alias prefix invalid format or too long"), 400
# check if mailbox is not tempered with # check if mailbox is not tempered with
if type(mailbox_ids) is not list: if not isinstance(mailbox_ids, list):
return jsonify(error="mailbox_ids must be an array of id"), 400 return jsonify(error="mailbox_ids must be an array of id"), 400
mailboxes = [] mailboxes = []
for mailbox_id in mailbox_ids: for mailbox_id in mailbox_ids:

View file

@ -2,6 +2,7 @@ import tldextract
from flask import g from flask import g
from flask import jsonify, request from flask import jsonify, request
from app import parallel_limiter
from app.alias_suffix import get_alias_suffixes from app.alias_suffix import get_alias_suffixes
from app.api.base import api_bp, require_api_auth from app.api.base import api_bp, require_api_auth
from app.api.serializer import ( from app.api.serializer import (
@ -20,6 +21,7 @@ from app.utils import convert_to_id
@api_bp.route("/alias/random/new", methods=["POST"]) @api_bp.route("/alias/random/new", methods=["POST"])
@limiter.limit(ALIAS_LIMIT) @limiter.limit(ALIAS_LIMIT)
@require_api_auth @require_api_auth
@parallel_limiter.lock(name="alias_creation")
def new_random_alias(): def new_random_alias():
""" """
Create a new random alias Create a new random alias

View file

@ -12,6 +12,7 @@ from app.models import (
SenderFormatEnum, SenderFormatEnum,
AliasSuffixEnum, AliasSuffixEnum,
) )
from app.proton.utils import perform_proton_account_unlink
def setting_to_dict(user: User): def setting_to_dict(user: User):
@ -137,3 +138,11 @@ def get_available_domains_for_random_alias_v2():
] ]
return jsonify(ret) return jsonify(ret)
@api_bp.route("/setting/unlink_proton_account", methods=["DELETE"])
@require_api_auth
def unlink_proton_account():
user = g.user
perform_proton_account_unlink(user)
return jsonify({"ok": True})

View file

@ -1,10 +1,11 @@
from flask import jsonify, g from flask import jsonify, g
from sqlalchemy_utils.types.arrow import arrow from sqlalchemy_utils.types.arrow import arrow
from app.api.base import api_bp, require_api_sudo from app.api.base import api_bp, require_api_sudo, require_api_auth
from app import config from app import config
from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import Job from app.models import Job, ApiToCookieToken
@api_bp.route("/user", methods=["DELETE"]) @api_bp.route("/user", methods=["DELETE"])
@ -23,3 +24,23 @@ def delete_user():
commit=True, commit=True,
) )
return jsonify(ok=True) return jsonify(ok=True)
@api_bp.route("/user/cookie_token", methods=["GET"])
@require_api_auth
@limiter.limit("5/minute")
def get_api_session_token():
"""
Get a temporary token to exchange it for a cookie based session
Output:
200 and a temporary random token
{
token: "asdli3ldq39h9hd3",
}
"""
token = ApiToCookieToken.create(
user=g.user,
api_key_id=g.api_key.id,
commit=True,
)
return jsonify({"token": token.code})

View file

@ -1,17 +1,29 @@
import base64 import base64
import dataclasses
from io import BytesIO from io import BytesIO
from typing import Optional
from flask import jsonify, g, request, make_response from flask import jsonify, g, request, make_response
from flask_login import logout_user
from app import s3 from app import s3, config
from app.api.base import api_bp, require_api_auth from app.api.base import api_bp, require_api_auth
from app.config import SESSION_COOKIE_NAME from app.config import SESSION_COOKIE_NAME
from app.dashboard.views.index import get_stats
from app.db import Session from app.db import Session
from app.models import ApiKey, File, User from app.models import ApiKey, File, PartnerUser, User
from app.proton.utils import get_proton_partner
from app.session import logout_session
from app.utils import random_string from app.utils import random_string
def get_connected_proton_address(user: User) -> Optional[str]:
proton_partner = get_proton_partner()
partner_user = PartnerUser.get_by(user_id=user.id, partner_id=proton_partner.id)
if partner_user is None:
return None
return partner_user.partner_email
def user_to_dict(user: User) -> dict: def user_to_dict(user: User) -> dict:
ret = { ret = {
"name": user.name or "", "name": user.name or "",
@ -19,8 +31,12 @@ def user_to_dict(user: User) -> dict:
"email": user.email, "email": user.email,
"in_trial": user.in_trial(), "in_trial": user.in_trial(),
"max_alias_free_plan": user.max_alias_for_free_account(), "max_alias_free_plan": user.max_alias_for_free_account(),
"connected_proton_address": None,
} }
if config.CONNECT_WITH_PROTON:
ret["connected_proton_address"] = get_connected_proton_address(user)
if user.profile_picture_id: if user.profile_picture_id:
ret["profile_picture_url"] = user.profile_picture.get_url() ret["profile_picture_url"] = user.profile_picture.get_url()
else: else:
@ -41,6 +57,7 @@ def user_info():
- email - email
- in_trial - in_trial
- max_alias_free - max_alias_free
- is_connected_with_proton
""" """
user = g.user user = g.user
@ -116,8 +133,27 @@ def logout():
Output: Output:
- 200 - 200
""" """
logout_user() logout_session()
response = make_response(jsonify(msg="User is logged out"), 200) response = make_response(jsonify(msg="User is logged out"), 200)
response.delete_cookie(SESSION_COOKIE_NAME) response.delete_cookie(SESSION_COOKIE_NAME)
return response return response
@api_bp.route("/stats")
@require_api_auth
def user_stats():
"""
Return stats
Output as json
- nb_alias
- nb_forward
- nb_reply
- nb_block
"""
user = g.user
stats = get_stats(user)
return jsonify(dataclasses.asdict(stats))

View file

@ -15,4 +15,25 @@ from .views import (
fido, fido,
social, social,
recovery, recovery,
api_to_cookie,
) )
__all__ = [
"login",
"logout",
"register",
"activate",
"resend_activation",
"reset_password",
"forgot_password",
"github",
"google",
"facebook",
"proton",
"change_email",
"mfa",
"fido",
"social",
"recovery",
"api_to_cookie",
]

View file

@ -0,0 +1,30 @@
import arrow
from flask import redirect, url_for, request, flash
from flask_login import login_user
from app.auth.base import auth_bp
from app.models import ApiToCookieToken
from app.utils import sanitize_next_url
@auth_bp.route("/api_to_cookie", methods=["GET"])
def api_to_cookie():
code = request.args.get("token")
if not code:
flash("Missing token", "error")
return redirect(url_for("auth.login"))
token = ApiToCookieToken.get_by(code=code)
if not token or token.created_at < arrow.now().shift(minutes=-5):
flash("Missing token", "error")
return redirect(url_for("auth.login"))
user = token.user
ApiToCookieToken.delete(token.id, commit=True)
login_user(user)
next_url = sanitize_next_url(request.args.get("next"))
if next_url:
return redirect(next_url)
else:
return redirect(url_for("dashboard.index"))

View file

@ -62,7 +62,7 @@ def fido():
browser = MfaBrowser.get_by(token=request.cookies.get("mfa")) browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
if browser and not browser.is_expired() and browser.user_id == user.id: if browser and not browser.is_expired() and browser.user_id == user.id:
login_user(user) login_user(user)
flash(f"Welcome back!", "success") flash("Welcome back!", "success")
# Redirect user to correct page # Redirect user to correct page
return redirect(next_url or url_for("dashboard.index")) return redirect(next_url or url_for("dashboard.index"))
else: else:
@ -110,7 +110,7 @@ def fido():
session["sudo_time"] = int(time()) session["sudo_time"] = int(time())
login_user(user) login_user(user)
flash(f"Welcome back!", "success") flash("Welcome back!", "success")
# Redirect user to correct page # Redirect user to correct page
response = make_response(redirect(next_url or url_for("dashboard.index"))) response = make_response(redirect(next_url or url_for("dashboard.index")))

View file

@ -1,4 +1,4 @@
from flask import request, render_template, redirect, url_for, flash, g from flask import request, render_template, flash, g
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, validators from wtforms import StringField, validators
@ -7,7 +7,7 @@ from app.dashboard.views.setting import send_reset_password_email
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import User from app.models import User
from app.utils import sanitize_email from app.utils import sanitize_email, canonicalize_email
class ForgotPasswordForm(FlaskForm): class ForgotPasswordForm(FlaskForm):
@ -16,7 +16,7 @@ class ForgotPasswordForm(FlaskForm):
@auth_bp.route("/forgot_password", methods=["GET", "POST"]) @auth_bp.route("/forgot_password", methods=["GET", "POST"])
@limiter.limit( @limiter.limit(
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit "10/hour", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
) )
def forgot_password(): def forgot_password():
form = ForgotPasswordForm(request.form) form = ForgotPasswordForm(request.form)
@ -25,16 +25,17 @@ def forgot_password():
# Trigger rate limiter # Trigger rate limiter
g.deduct_limit = True g.deduct_limit = True
email = sanitize_email(form.email.data)
flash( flash(
"If your email is correct, you are going to receive an email to reset your password", "If your email is correct, you are going to receive an email to reset your password",
"success", "success",
) )
user = User.get_by(email=email)
email = sanitize_email(form.email.data)
canonical_email = canonicalize_email(email)
user = User.get_by(email=email) or User.get_by(email=canonical_email)
if user: if user:
LOG.d("Send forgot password email to %s", user) LOG.d("Send forgot password email to %s", user)
send_reset_password_email(user) send_reset_password_email(user)
return redirect(url_for("auth.forgot_password"))
return render_template("auth/forgot_password.html", form=form) return render_template("auth/forgot_password.html", form=form)

View file

@ -10,7 +10,7 @@ from app.events.auth_event import LoginEvent
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import User from app.models import User
from app.utils import sanitize_email, sanitize_next_url from app.utils import sanitize_email, sanitize_next_url, canonicalize_email
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
@ -38,7 +38,9 @@ def login():
show_resend_activation = False show_resend_activation = False
if form.validate_on_submit(): if form.validate_on_submit():
user = User.filter_by(email=sanitize_email(form.email.data)).first() email = sanitize_email(form.email.data)
canonical_email = canonicalize_email(email)
user = User.get_by(email=email) or User.get_by(email=canonical_email)
if not user or not user.check_password(form.password.data): if not user or not user.check_password(form.password.data):
# Trigger rate limiter # Trigger rate limiter
@ -52,6 +54,12 @@ def login():
"error", "error",
) )
LoginEvent(LoginEvent.ActionType.disabled_login).send() LoginEvent(LoginEvent.ActionType.disabled_login).send()
elif user.delete_on is not None:
flash(
f"Your account is scheduled to be deleted on {user.delete_on}",
"error",
)
LoginEvent(LoginEvent.ActionType.scheduled_to_be_deleted).send()
elif not user.activated: elif not user.activated:
show_resend_activation = True show_resend_activation = True
flash( flash(

View file

@ -1,13 +1,13 @@
from flask import redirect, url_for, flash, make_response from flask import redirect, url_for, flash, make_response
from flask_login import logout_user
from app.auth.base import auth_bp from app.auth.base import auth_bp
from app.config import SESSION_COOKIE_NAME from app.config import SESSION_COOKIE_NAME
from app.session import logout_session
@auth_bp.route("/logout") @auth_bp.route("/logout")
def logout(): def logout():
logout_user() logout_session()
flash("You are logged out", "success") flash("You are logged out", "success")
response = make_response(redirect(url_for("auth.login"))) response = make_response(redirect(url_for("auth.login")))
response.delete_cookie(SESSION_COOKIE_NAME) response.delete_cookie(SESSION_COOKIE_NAME)

View file

@ -55,7 +55,7 @@ def mfa():
browser = MfaBrowser.get_by(token=request.cookies.get("mfa")) browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
if browser and not browser.is_expired() and browser.user_id == user.id: if browser and not browser.is_expired() and browser.user_id == user.id:
login_user(user) login_user(user)
flash(f"Welcome back!", "success") flash("Welcome back!", "success")
# Redirect user to correct page # Redirect user to correct page
return redirect(next_url or url_for("dashboard.index")) return redirect(next_url or url_for("dashboard.index"))
else: else:
@ -67,13 +67,13 @@ def mfa():
token = otp_token_form.token.data.replace(" ", "") token = otp_token_form.token.data.replace(" ", "")
if totp.verify(token) and user.last_otp != token: if totp.verify(token, valid_window=2) and user.last_otp != token:
del session[MFA_USER_ID] del session[MFA_USER_ID]
user.last_otp = token user.last_otp = token
Session.commit() Session.commit()
login_user(user) login_user(user)
flash(f"Welcome back!", "success") flash("Welcome back!", "success")
# Redirect user to correct page # Redirect user to correct page
response = make_response(redirect(next_url or url_for("dashboard.index"))) response = make_response(redirect(next_url or url_for("dashboard.index")))

View file

@ -3,6 +3,7 @@ from flask import request, session, redirect, flash, url_for
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
from flask_login import current_user from flask_login import current_user
from requests_oauthlib import OAuth2Session from requests_oauthlib import OAuth2Session
from typing import Optional
from app.auth.base import auth_bp from app.auth.base import auth_bp
from app.auth.views.login_utils import after_login from app.auth.views.login_utils import after_login
@ -23,7 +24,7 @@ from app.proton.proton_callback_handler import (
Action, Action,
) )
from app.proton.utils import get_proton_partner from app.proton.utils import get_proton_partner
from app.utils import sanitize_next_url from app.utils import sanitize_next_url, sanitize_scheme
_authorization_base_url = PROTON_BASE_URL + "/oauth/authorize" _authorization_base_url = PROTON_BASE_URL + "/oauth/authorize"
_token_url = PROTON_BASE_URL + "/oauth/token" _token_url = PROTON_BASE_URL + "/oauth/token"
@ -34,6 +35,7 @@ _redirect_uri = URL + "/auth/proton/callback"
SESSION_ACTION_KEY = "oauth_action" SESSION_ACTION_KEY = "oauth_action"
SESSION_STATE_KEY = "oauth_state" SESSION_STATE_KEY = "oauth_state"
DEFAULT_SCHEME = "auth.simplelogin"
def get_api_key_for_user(user: User) -> str: def get_api_key_for_user(user: User) -> str:
@ -45,13 +47,16 @@ def get_api_key_for_user(user: User) -> str:
return ak.code return ak.code
def extract_action() -> Action: def extract_action() -> Optional[Action]:
action = request.args.get("action") action = request.args.get("action")
if action is not None: if action is not None:
if action == "link": if action == "link":
return Action.Link return Action.Link
elif action == "login":
return Action.Login
else: else:
raise Exception(f"Unknown action: {action}") LOG.w(f"Unknown action received: {action}")
return None
return Action.Login return Action.Login
@ -69,12 +74,24 @@ def proton_login():
if PROTON_CLIENT_ID is None or PROTON_CLIENT_SECRET is None: if PROTON_CLIENT_ID is None or PROTON_CLIENT_SECRET is None:
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
action = extract_action()
if action is None:
return redirect(url_for("auth.login"))
if action == Action.Link and not current_user.is_authenticated:
return redirect(url_for("auth.login"))
next_url = sanitize_next_url(request.args.get("next")) next_url = sanitize_next_url(request.args.get("next"))
if next_url: if next_url:
session["oauth_next"] = next_url session["oauth_next"] = next_url
elif "oauth_next" in session: elif "oauth_next" in session:
del session["oauth_next"] del session["oauth_next"]
scheme = sanitize_scheme(request.args.get("scheme"))
if scheme:
session["oauth_scheme"] = scheme
elif "oauth_scheme" in session:
del session["oauth_scheme"]
mode = request.args.get("mode", "session") mode = request.args.get("mode", "session")
if mode == "apikey": if mode == "apikey":
session["oauth_mode"] = "apikey" session["oauth_mode"] = "apikey"
@ -86,7 +103,7 @@ def proton_login():
# State is used to prevent CSRF, keep this for later. # State is used to prevent CSRF, keep this for later.
session[SESSION_STATE_KEY] = state session[SESSION_STATE_KEY] = state
session[SESSION_ACTION_KEY] = extract_action().value session[SESSION_ACTION_KEY] = action.value
return redirect(authorization_url) return redirect(authorization_url)
@ -146,6 +163,7 @@ def proton_callback():
handler = ProtonCallbackHandler(proton_client) handler = ProtonCallbackHandler(proton_client)
proton_partner = get_proton_partner() proton_partner = get_proton_partner()
next_url = session.get("oauth_next")
if action == Action.Login: if action == Action.Login:
res = handler.handle_login(proton_partner) res = handler.handle_login(proton_partner)
elif action == Action.Link: elif action == Action.Link:
@ -156,15 +174,17 @@ def proton_callback():
if res.flash_message is not None: if res.flash_message is not None:
flash(res.flash_message, res.flash_category) flash(res.flash_message, res.flash_category)
oauth_scheme = session.get("oauth_scheme")
if session.get("oauth_mode", "session") == "apikey": if session.get("oauth_mode", "session") == "apikey":
apikey = get_api_key_for_user(res.user) apikey = get_api_key_for_user(res.user)
return redirect(f"auth.simplelogin://callback?apikey={apikey}") scheme = oauth_scheme or DEFAULT_SCHEME
return redirect(f"{scheme}:///login?apikey={apikey}")
if res.redirect_to_login: if res.redirect_to_login:
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
if res.redirect: if next_url and next_url[0] == "/" and oauth_scheme:
return after_login(res.user, res.redirect, login_from_proton=True) next_url = f"{oauth_scheme}://{next_url}"
next_url = session.get("oauth_next") redirect_url = next_url or res.redirect
return after_login(res.user, next_url, login_from_proton=True) return after_login(res.user, redirect_url, login_from_proton=True)

View file

@ -42,7 +42,7 @@ def recovery_route():
if recovery_form.validate_on_submit(): if recovery_form.validate_on_submit():
code = recovery_form.code.data code = recovery_form.code.data
recovery_code = RecoveryCode.get_by(user_id=user.id, code=code) recovery_code = RecoveryCode.find_by_user_code(user, code)
if recovery_code: if recovery_code:
if recovery_code.used: if recovery_code.used:
@ -53,7 +53,7 @@ def recovery_route():
del session[MFA_USER_ID] del session[MFA_USER_ID]
login_user(user) login_user(user)
flash(f"Welcome back!", "success") flash("Welcome back!", "success")
recovery_code.used = True recovery_code.used = True
recovery_code.used_at = arrow.now() recovery_code.used_at = arrow.now()

View file

@ -16,8 +16,8 @@ from app.email_utils import (
) )
from app.events.auth_event import RegisterEvent from app.events.auth_event import RegisterEvent
from app.log import LOG from app.log import LOG
from app.models import User, ActivationCode from app.models import User, ActivationCode, DailyMetric
from app.utils import random_string, encode_url, sanitize_email from app.utils import random_string, encode_url, sanitize_email, canonicalize_email
class RegisterForm(FlaskForm): class RegisterForm(FlaskForm):
@ -70,19 +70,22 @@ def register():
HCAPTCHA_SITEKEY=HCAPTCHA_SITEKEY, HCAPTCHA_SITEKEY=HCAPTCHA_SITEKEY,
) )
email = sanitize_email(form.email.data) email = canonicalize_email(form.email.data)
if not email_can_be_used_as_mailbox(email): if not email_can_be_used_as_mailbox(email):
flash("You cannot use this email address as your personal inbox.", "error") flash("You cannot use this email address as your personal inbox.", "error")
RegisterEvent(RegisterEvent.ActionType.email_in_use).send() RegisterEvent(RegisterEvent.ActionType.email_in_use).send()
else: else:
if personal_email_already_used(email): sanitized_email = sanitize_email(form.email.data)
if personal_email_already_used(email) or personal_email_already_used(
sanitized_email
):
flash(f"Email {email} already used", "error") flash(f"Email {email} already used", "error")
RegisterEvent(RegisterEvent.ActionType.email_in_use).send() RegisterEvent(RegisterEvent.ActionType.email_in_use).send()
else: else:
LOG.d("create user %s", email) LOG.d("create user %s", email)
user = User.create( user = User.create(
email=email, email=email,
name="", name=form.email.data,
password=form.password.data, password=form.password.data,
referral=get_referral(), referral=get_referral(),
) )
@ -91,6 +94,8 @@ def register():
try: try:
send_activation_email(user, next_url) send_activation_email(user, next_url)
RegisterEvent(RegisterEvent.ActionType.success).send() RegisterEvent(RegisterEvent.ActionType.success).send()
DailyMetric.get_or_create_today_metric().nb_new_web_non_proton_user += 1
Session.commit()
except Exception: except Exception:
flash("Invalid email, are you sure the email is correct?", "error") flash("Invalid email, are you sure the email is correct?", "error")
RegisterEvent(RegisterEvent.ActionType.invalid_email).send() RegisterEvent(RegisterEvent.ActionType.invalid_email).send()

View file

@ -7,7 +7,7 @@ from app.auth.views.register import send_activation_email
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import User from app.models import User
from app.utils import sanitize_email from app.utils import sanitize_email, canonicalize_email
class ResendActivationForm(FlaskForm): class ResendActivationForm(FlaskForm):
@ -20,7 +20,9 @@ def resend_activation():
form = ResendActivationForm(request.form) form = ResendActivationForm(request.form)
if form.validate_on_submit(): if form.validate_on_submit():
user = User.filter_by(email=sanitize_email(form.email.data)).first() email = sanitize_email(form.email.data)
canonical_email = canonicalize_email(email)
user = User.get_by(email=email) or User.get_by(email=canonical_email)
if not user: if not user:
flash("There is no such email", "warning") flash("There is no such email", "warning")

View file

@ -60,8 +60,8 @@ def reset_password():
# this can be served to activate user too # this can be served to activate user too
user.activated = True user.activated = True
# remove the reset password code # remove all reset password codes
ResetPasswordCode.delete(reset_password_code.id) ResetPasswordCode.filter_by(user_id=user.id).delete()
# change the alternative_id to log user out on other browsers # change the alternative_id to log user out on other browsers
user.alternative_id = str(uuid.uuid4()) user.alternative_id = str(uuid.uuid4())

View file

@ -8,7 +8,6 @@ from urllib.parse import urlparse
from dotenv import load_dotenv from dotenv import load_dotenv
ROOT_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) ROOT_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
@ -112,13 +111,16 @@ POSTFIX_SERVER = os.environ.get("POSTFIX_SERVER", "240.0.0.1")
DISABLE_REGISTRATION = "DISABLE_REGISTRATION" in os.environ DISABLE_REGISTRATION = "DISABLE_REGISTRATION" in os.environ
# allow using a different postfix port, useful when developing locally # allow using a different postfix port, useful when developing locally
POSTFIX_PORT = 25
if "POSTFIX_PORT" in os.environ:
POSTFIX_PORT = int(os.environ["POSTFIX_PORT"])
# Use port 587 instead of 25 when sending emails through Postfix # Use port 587 instead of 25 when sending emails through Postfix
# Useful when calling Postfix from an external network # Useful when calling Postfix from an external network
POSTFIX_SUBMISSION_TLS = "POSTFIX_SUBMISSION_TLS" in os.environ POSTFIX_SUBMISSION_TLS = "POSTFIX_SUBMISSION_TLS" in os.environ
if POSTFIX_SUBMISSION_TLS:
default_postfix_port = 587
else:
default_postfix_port = 25
POSTFIX_PORT = int(os.environ.get("POSTFIX_PORT", default_postfix_port))
POSTFIX_TIMEOUT = os.environ.get("POSTFIX_TIMEOUT", 3)
# ["domain1.com", "domain2.com"] # ["domain1.com", "domain2.com"]
OTHER_ALIAS_DOMAINS = sl_getenv("OTHER_ALIAS_DOMAINS", list) OTHER_ALIAS_DOMAINS = sl_getenv("OTHER_ALIAS_DOMAINS", list)
@ -161,6 +163,7 @@ if "DKIM_PRIVATE_KEY_PATH" in os.environ:
# Database # Database
DB_URI = os.environ["DB_URI"] DB_URI = os.environ["DB_URI"]
DB_CONN_NAME = os.environ.get("DB_CONN_NAME", "webapp")
# Flask secret # Flask secret
FLASK_SECRET = os.environ["FLASK_SECRET"] FLASK_SECRET = os.environ["FLASK_SECRET"]
@ -354,6 +357,7 @@ ALERT_COMPLAINT_TRANSACTIONAL_PHASE = "alert_complaint_transactional_phase"
ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc" ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc"
ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER = "alert_dual_sub_with_partner" ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER = "alert_dual_sub_with_partner"
ALERT_WARN_MULTIPLE_SUBSCRIPTIONS = "alert_multiple_subscription"
# <<<<< END ALERT EMAIL >>>> # <<<<< END ALERT EMAIL >>>>
@ -494,3 +498,44 @@ JOB_TAKEN_RETRY_WAIT_MINS = 30
# MEM_STORE # MEM_STORE
MEM_STORE_URI = os.environ.get("MEM_STORE_URI", None) MEM_STORE_URI = os.environ.get("MEM_STORE_URI", None)
# Recovery codes hash salt
RECOVERY_CODE_HMAC_SECRET = os.environ.get("RECOVERY_CODE_HMAC_SECRET") or (
FLASK_SECRET + "generatearandomtoken"
)
if not RECOVERY_CODE_HMAC_SECRET or len(RECOVERY_CODE_HMAC_SECRET) < 16:
raise RuntimeError(
"Please define RECOVERY_CODE_HMAC_SECRET in your configuration with a random string at least 16 chars long"
)
# the minimum rspamd spam score above which emails that fail DMARC should be quarantined
if "MIN_RSPAMD_SCORE_FOR_FAILED_DMARC" in os.environ:
MIN_RSPAMD_SCORE_FOR_FAILED_DMARC = float(
os.environ["MIN_RSPAMD_SCORE_FOR_FAILED_DMARC"]
)
else:
MIN_RSPAMD_SCORE_FOR_FAILED_DMARC = None
# run over all reverse alias for an alias and replace them with sender address
ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT = (
"ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT" in os.environ
)
if ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT:
# max number of reverse alias that can be replaced
MAX_NB_REVERSE_ALIAS_REPLACEMENT = int(
os.environ["MAX_NB_REVERSE_ALIAS_REPLACEMENT"]
)
# Only used for tests
SKIP_MX_LOOKUP_ON_CHECK = False
DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ
SUBSCRIPTION_CHANGE_WEBHOOK = os.environ.get("SUBSCRIPTION_CHANGE_WEBHOOK", None)
MAX_API_KEYS = int(os.environ.get("MAX_API_KEYS", 30))
UPCLOUD_USERNAME = os.environ.get("UPCLOUD_USERNAME", None)
UPCLOUD_PASSWORD = os.environ.get("UPCLOUD_PASSWORD", None)
UPCLOUD_DB_ID = os.environ.get("UPCLOUD_DB_ID", None)

View file

@ -0,0 +1,37 @@
from app.db import Session
from app.dns_utils import get_cname_record
from app.models import CustomDomain
class CustomDomainValidation:
def __init__(self, dkim_domain: str):
self.dkim_domain = dkim_domain
self._dkim_records = {
(f"{key}._domainkey", f"{key}._domainkey.{self.dkim_domain}")
for key in ("dkim", "dkim02", "dkim03")
}
def get_dkim_records(self) -> {str: str}:
"""
Get a list of dkim records to set up. It will be
"""
return self._dkim_records
def validate_dkim_records(self, custom_domain: CustomDomain) -> dict[str, str]:
"""
Check if dkim records are properly set for this custom domain.
Returns empty list if all records are ok. Other-wise return the records that aren't properly configured
"""
invalid_records = {}
for prefix, expected_record in self.get_dkim_records():
custom_record = f"{prefix}.{custom_domain.domain}"
dkim_record = get_cname_record(custom_record)
if dkim_record != expected_record:
invalid_records[custom_record] = dkim_record or "empty"
# HACK: If dkim is enabled, don't disable it to give users time to update their CNAMES
if custom_domain.dkim_verified:
return invalid_records
custom_domain.dkim_verified = len(invalid_records) == 0
Session.commit()
return invalid_records

View file

@ -6,6 +6,7 @@ from .views import (
subdomain, subdomain,
billing, billing,
alias_log, alias_log,
alias_export,
unsubscribe, unsubscribe,
api_key, api_key,
custom_domain, custom_domain,
@ -23,7 +24,6 @@ from .views import (
mailbox_detail, mailbox_detail,
refused_email, refused_email,
referral, referral,
recovery_code,
contact_detail, contact_detail,
setup_done, setup_done,
batch_import, batch_import,
@ -33,3 +33,39 @@ from .views import (
notification, notification,
support, support,
) )
__all__ = [
"index",
"pricing",
"setting",
"custom_alias",
"subdomain",
"billing",
"alias_log",
"alias_export",
"unsubscribe",
"api_key",
"custom_domain",
"alias_contact_manager",
"enter_sudo",
"mfa_setup",
"mfa_cancel",
"fido_setup",
"coupon",
"fido_manage",
"domain_detail",
"lifetime_licence",
"directory",
"mailbox",
"mailbox_detail",
"refused_email",
"referral",
"contact_detail",
"setup_done",
"batch_import",
"alias_transfer",
"app",
"delete_account",
"notification",
"support",
]

View file

@ -9,14 +9,14 @@ from sqlalchemy import and_, func, case
from wtforms import StringField, validators, ValidationError from wtforms import StringField, validators, ValidationError
# Need to import directly from config to allow modification from the tests # Need to import directly from config to allow modification from the tests
from app import config from app import config, parallel_limiter
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
from app.email_utils import ( from app.email_utils import (
is_valid_email,
generate_reply_email, generate_reply_email,
parse_full_address, parse_full_address,
) )
from app.email_validation import is_valid_email
from app.errors import ( from app.errors import (
CannotCreateContactForReverseAlias, CannotCreateContactForReverseAlias,
ErrContactErrorUpgradeNeeded, ErrContactErrorUpgradeNeeded,
@ -25,7 +25,7 @@ from app.errors import (
) )
from app.log import LOG from app.log import LOG
from app.models import Alias, Contact, EmailLog, User from app.models import Alias, Contact, EmailLog, User
from app.utils import sanitize_email from app.utils import sanitize_email, CSRFValidationForm
def email_validator(): def email_validator():
@ -90,7 +90,7 @@ def create_contact(user: User, alias: Alias, contact_address: str) -> Contact:
alias_id=alias.id, alias_id=alias.id,
website_email=contact_email, website_email=contact_email,
name=contact_name, name=contact_name,
reply_email=generate_reply_email(contact_email, user), reply_email=generate_reply_email(contact_email, alias),
) )
LOG.d( LOG.d(
@ -231,6 +231,7 @@ def delete_contact(alias: Alias, contact_id: int):
@dashboard_bp.route("/alias_contact_manager/<int:alias_id>/", methods=["GET", "POST"]) @dashboard_bp.route("/alias_contact_manager/<int:alias_id>/", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(name="contact_creation")
def alias_contact_manager(alias_id): def alias_contact_manager(alias_id):
highlight_contact_id = None highlight_contact_id = None
if request.args.get("highlight_contact_id"): if request.args.get("highlight_contact_id"):
@ -258,8 +259,12 @@ def alias_contact_manager(alias_id):
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
new_contact_form = NewContactForm() new_contact_form = NewContactForm()
csrf_form = CSRFValidationForm()
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "create": if request.form.get("form-name") == "create":
if new_contact_form.validate(): if new_contact_form.validate():
contact_address = new_contact_form.email.data.strip() contact_address = new_contact_form.email.data.strip()
@ -323,4 +328,5 @@ def alias_contact_manager(alias_id):
query=query, query=query,
nb_contact=nb_contact, nb_contact=nb_contact,
can_create_contacts=user_can_create_contacts(current_user), can_create_contacts=user_can_create_contacts(current_user),
csrf_form=csrf_form,
) )

View file

@ -0,0 +1,9 @@
from app.dashboard.base import dashboard_bp
from flask_login import login_required, current_user
from app.alias_utils import alias_export_csv
@dashboard_bp.route("/alias_export", methods=["GET"])
@login_required
def alias_export_route():
return alias_export_csv(current_user)

View file

@ -87,6 +87,6 @@ def get_alias_log(alias: Alias, page_id=0) -> [AliasLog]:
contact=contact, contact=contact,
) )
logs.append(al) logs.append(al)
logs = sorted(logs, key=lambda l: l.when, reverse=True) logs = sorted(logs, key=lambda log: log.when, reverse=True)
return logs return logs

View file

@ -7,76 +7,17 @@ from flask import render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import config from app import config
from app.alias_utils import transfer_alias
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session from app.db import Session
from app.email_utils import send_email, render
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import ( from app.models import (
Alias, Alias,
Contact,
AliasUsedOn,
AliasMailbox,
User,
ClientUser,
) )
from app.models import Mailbox from app.models import Mailbox
from app.utils import CSRFValidationForm
def transfer(alias, new_user, new_mailboxes: [Mailbox]):
# cannot transfer alias which is used for receiving newsletter
if User.get_by(newsletter_alias_id=alias.id):
raise Exception("Cannot transfer alias that's used to receive newsletter")
# update user_id
Session.query(Contact).filter(Contact.alias_id == alias.id).update(
{"user_id": new_user.id}
)
Session.query(AliasUsedOn).filter(AliasUsedOn.alias_id == alias.id).update(
{"user_id": new_user.id}
)
Session.query(ClientUser).filter(ClientUser.alias_id == alias.id).update(
{"user_id": new_user.id}
)
# remove existing mailboxes from the alias
Session.query(AliasMailbox).filter(AliasMailbox.alias_id == alias.id).delete()
# set mailboxes
alias.mailbox_id = new_mailboxes.pop().id
for mb in new_mailboxes:
AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id)
# alias has never been transferred before
if not alias.original_owner_id:
alias.original_owner_id = alias.user_id
# inform previous owner
old_user = alias.user
send_email(
old_user.email,
f"Alias {alias.email} has been received",
render(
"transactional/alias-transferred.txt",
alias=alias,
),
render(
"transactional/alias-transferred.html",
alias=alias,
),
)
# now the alias belongs to the new user
alias.user_id = new_user.id
# set some fields back to default
alias.disable_pgp = False
alias.pinned = False
Session.commit()
def hmac_alias_transfer_token(transfer_token: str) -> str: def hmac_alias_transfer_token(transfer_token: str) -> str:
@ -105,8 +46,12 @@ def alias_transfer_send_route(alias_id):
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
alias_transfer_url = None alias_transfer_url = None
csrf_form = CSRFValidationForm()
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
# generate a new transfer_token # generate a new transfer_token
if request.form.get("form-name") == "create": if request.form.get("form-name") == "create":
transfer_token = f"{alias.id}.{secrets.token_urlsafe(32)}" transfer_token = f"{alias.id}.{secrets.token_urlsafe(32)}"
@ -133,6 +78,7 @@ def alias_transfer_send_route(alias_id):
alias_transfer_url=alias_transfer_url, alias_transfer_url=alias_transfer_url,
link_active=alias.transfer_token_expiration is not None link_active=alias.transfer_token_expiration is not None
and alias.transfer_token_expiration > arrow.utcnow(), and alias.transfer_token_expiration > arrow.utcnow(),
csrf_form=csrf_form,
) )
@ -208,7 +154,13 @@ def alias_transfer_receive_route():
mailboxes, mailboxes,
token, token,
) )
transfer(alias, current_user, mailboxes) transfer_alias(alias, current_user, mailboxes)
# reset transfer token
alias.transfer_token = None
alias.transfer_token_expiration = None
Session.commit()
flash(f"You are now owner of {alias.email}", "success") flash(f"You are now owner of {alias.email}", "success")
return redirect(url_for("dashboard.index", highlight_alias_id=alias.id)) return redirect(url_for("dashboard.index", highlight_alias_id=alias.id))

View file

@ -3,19 +3,47 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, validators from wtforms import StringField, validators
from app import config
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session from app.db import Session
from app.extensions import limiter
from app.models import ApiKey from app.models import ApiKey
from app.utils import CSRFValidationForm
class NewApiKeyForm(FlaskForm): class NewApiKeyForm(FlaskForm):
name = StringField("Name", validators=[validators.DataRequired()]) name = StringField("Name", validators=[validators.DataRequired()])
def clean_up_unused_or_old_api_keys(user_id: int):
total_keys = ApiKey.filter_by(user_id=user_id).count()
if total_keys <= config.MAX_API_KEYS:
return
# Remove oldest unused
for api_key in (
ApiKey.filter_by(user_id=user_id, last_used=None)
.order_by(ApiKey.created_at.asc())
.all()
):
Session.delete(api_key)
total_keys -= 1
if total_keys <= config.MAX_API_KEYS:
return
# Clean up oldest used
for api_key in (
ApiKey.filter_by(user_id=user_id).order_by(ApiKey.last_used.asc()).all()
):
Session.delete(api_key)
total_keys -= 1
if total_keys <= config.MAX_API_KEYS:
return
@dashboard_bp.route("/api_key", methods=["GET", "POST"]) @dashboard_bp.route("/api_key", methods=["GET", "POST"])
@login_required @login_required
@sudo_required @sudo_required
@limiter.limit("10/hour")
def api_key(): def api_key():
api_keys = ( api_keys = (
ApiKey.filter(ApiKey.user_id == current_user.id) ApiKey.filter(ApiKey.user_id == current_user.id)
@ -23,9 +51,13 @@ def api_key():
.all() .all()
) )
csrf_form = CSRFValidationForm()
new_api_key_form = NewApiKeyForm() new_api_key_form = NewApiKeyForm()
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "delete": if request.form.get("form-name") == "delete":
api_key_id = request.form.get("api-key-id") api_key_id = request.form.get("api-key-id")
@ -45,6 +77,7 @@ def api_key():
elif request.form.get("form-name") == "create": elif request.form.get("form-name") == "create":
if new_api_key_form.validate(): if new_api_key_form.validate():
clean_up_unused_or_old_api_keys(current_user.id)
new_api_key = ApiKey.create( new_api_key = ApiKey.create(
name=new_api_key_form.name.data, user_id=current_user.id name=new_api_key_form.name.data, user_id=current_user.id
) )
@ -62,5 +95,8 @@ def api_key():
return redirect(url_for("dashboard.api_key")) return redirect(url_for("dashboard.api_key"))
return render_template( return render_template(
"dashboard/api_key.html", api_keys=api_keys, new_api_key_form=new_api_key_form "dashboard/api_key.html",
api_keys=api_keys,
new_api_key_form=new_api_key_form,
csrf_form=csrf_form,
) )

View file

@ -1,14 +1,9 @@
from app.db import Session
"""
List of apps that user has used via the "Sign in with SimpleLogin"
"""
from flask import render_template, request, flash, redirect from flask import render_template, request, flash, redirect
from flask_login import login_required, current_user from flask_login import login_required, current_user
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session
from app.models import ( from app.models import (
ClientUser, ClientUser,
) )
@ -17,6 +12,10 @@ from app.models import (
@dashboard_bp.route("/app", methods=["GET", "POST"]) @dashboard_bp.route("/app", methods=["GET", "POST"])
@login_required @login_required
def app_route(): def app_route():
"""
List of apps that user has used via the "Sign in with SimpleLogin"
"""
client_users = ( client_users = (
ClientUser.filter_by(user_id=current_user.id) ClientUser.filter_by(user_id=current_user.id)
.options(joinedload(ClientUser.client)) .options(joinedload(ClientUser.client))

View file

@ -8,7 +8,7 @@ from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
from app.log import LOG from app.log import LOG
from app.models import File, BatchImport, Job from app.models import File, BatchImport, Job
from app.utils import random_string from app.utils import random_string, CSRFValidationForm
@dashboard_bp.route("/batch_import", methods=["GET", "POST"]) @dashboard_bp.route("/batch_import", methods=["GET", "POST"])
@ -25,16 +25,25 @@ def batch_import_route():
) )
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
batch_imports = BatchImport.filter_by(user_id=current_user.id).all() batch_imports = BatchImport.filter_by(
user_id=current_user.id, processed=False
).all()
csrf_form = CSRFValidationForm()
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if len(batch_imports) > 10: if len(batch_imports) > 10:
flash( flash(
"You have too many imports already. Wait until some get cleaned up", "You have too many imports already. Wait until some get cleaned up",
"error", "error",
) )
return render_template( return render_template(
"dashboard/batch_import.html", batch_imports=batch_imports "dashboard/batch_import.html",
batch_imports=batch_imports,
csrf_form=csrf_form,
) )
alias_file = request.files["alias-file"] alias_file = request.files["alias-file"]
@ -64,4 +73,6 @@ def batch_import_route():
return redirect(url_for("dashboard.batch_import_route")) return redirect(url_for("dashboard.batch_import_route"))
return render_template("dashboard/batch_import.html", batch_imports=batch_imports) return render_template(
"dashboard/batch_import.html", batch_imports=batch_imports, csrf_form=csrf_form
)

View file

@ -1,5 +1,7 @@
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
@ -7,6 +9,14 @@ from app.models import Contact
from app.pgp_utils import PGPException, load_public_key_and_check from app.pgp_utils import PGPException, load_public_key_and_check
class PGPContactForm(FlaskForm):
action = StringField(
"action",
validators=[validators.DataRequired(), validators.AnyOf(("save", "remove"))],
)
pgp = StringField("pgp", validators=[validators.Optional()])
@dashboard_bp.route("/contact/<int:contact_id>/", methods=["GET", "POST"]) @dashboard_bp.route("/contact/<int:contact_id>/", methods=["GET", "POST"])
@login_required @login_required
def contact_detail_route(contact_id): def contact_detail_route(contact_id):
@ -16,33 +26,41 @@ def contact_detail_route(contact_id):
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
alias = contact.alias alias = contact.alias
pgp_form = PGPContactForm()
if request.method == "POST": if request.method == "POST":
if request.form.get("form-name") == "pgp": if request.form.get("form-name") == "pgp":
if request.form.get("action") == "save": if not pgp_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if pgp_form.action.data == "save":
if not current_user.is_premium(): if not current_user.is_premium():
flash("Only premium plan can add PGP Key", "warning") flash("Only premium plan can add PGP Key", "warning")
return redirect( return redirect(
url_for("dashboard.contact_detail_route", contact_id=contact_id) url_for("dashboard.contact_detail_route", contact_id=contact_id)
) )
if not pgp_form.pgp.data:
contact.pgp_public_key = request.form.get("pgp") flash("Invalid pgp key")
try:
contact.pgp_finger_print = load_public_key_and_check(
contact.pgp_public_key
)
except PGPException:
flash("Cannot add the public key, please verify it", "error")
else: else:
Session.commit() contact.pgp_public_key = pgp_form.pgp.data
flash( try:
f"PGP public key for {contact.email} is saved successfully", contact.pgp_finger_print = load_public_key_and_check(
"success", contact.pgp_public_key
) )
return redirect( except PGPException:
url_for("dashboard.contact_detail_route", contact_id=contact_id) flash("Cannot add the public key, please verify it", "error")
) else:
elif request.form.get("action") == "remove": Session.commit()
flash(
f"PGP public key for {contact.email} is saved successfully",
"success",
)
return redirect(
url_for(
"dashboard.contact_detail_route", contact_id=contact_id
)
)
elif pgp_form.action.data == "remove":
# Free user can decide to remove contact PGP key # Free user can decide to remove contact PGP key
contact.pgp_public_key = None contact.pgp_public_key = None
contact.pgp_finger_print = None contact.pgp_finger_print = None
@ -53,5 +71,5 @@ def contact_detail_route(contact_id):
) )
return render_template( return render_template(
"dashboard/contact_detail.html", contact=contact, alias=alias "dashboard/contact_detail.html", contact=contact, alias=alias, pgp_form=pgp_form
) )

View file

@ -4,6 +4,7 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, validators from wtforms import StringField, validators
from app import parallel_limiter
from app.config import PADDLE_VENDOR_ID, PADDLE_COUPON_ID from app.config import PADDLE_VENDOR_ID, PADDLE_COUPON_ID
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
@ -24,6 +25,7 @@ class CouponForm(FlaskForm):
@dashboard_bp.route("/coupon", methods=["GET", "POST"]) @dashboard_bp.route("/coupon", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock()
def coupon_route(): def coupon_route():
coupon_form = CouponForm() coupon_form = CouponForm()
@ -66,9 +68,14 @@ def coupon_route():
) )
return redirect(request.url) return redirect(request.url)
coupon.used_by_user_id = current_user.id updated = (
coupon.used = True Session.query(Coupon)
Session.commit() .filter_by(code=code, used=False)
.update({"used_by_user_id": current_user.id, "used": True})
)
if updated != 1:
flash("Coupon is not valid", "error")
return redirect(request.url)
manual_sub: ManualSubscription = ManualSubscription.get_by( manual_sub: ManualSubscription = ManualSubscription.get_by(
user_id=current_user.id user_id=current_user.id
@ -93,7 +100,7 @@ def coupon_route():
commit=True, commit=True,
) )
flash( flash(
f"Your account has been upgraded to Premium, thanks for your support!", "Your account has been upgraded to Premium, thanks for your support!",
"success", "success",
) )

View file

@ -3,6 +3,7 @@ from flask import render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user from flask_login import login_required, current_user
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from app import parallel_limiter
from app.alias_suffix import ( from app.alias_suffix import (
get_alias_suffixes, get_alias_suffixes,
check_suffix_signature, check_suffix_signature,
@ -28,6 +29,7 @@ from app.models import (
@dashboard_bp.route("/custom_alias", methods=["GET", "POST"]) @dashboard_bp.route("/custom_alias", methods=["GET", "POST"])
@limiter.limit(ALIAS_LIMIT, methods=["POST"]) @limiter.limit(ALIAS_LIMIT, methods=["POST"])
@login_required @login_required
@parallel_limiter.lock(name="alias_creation")
def custom_alias(): def custom_alias():
# check if user has not exceeded the alias quota # check if user has not exceeded the alias quota
if not current_user.can_create_new_alias(): if not current_user.can_create_new_alias():
@ -118,18 +120,11 @@ def custom_alias():
email=full_alias email=full_alias
) )
custom_domain = domain_deleted_alias.domain custom_domain = domain_deleted_alias.domain
if domain_deleted_alias.user_id == current_user.id: flash(
flash( f"You have deleted this alias before. You can restore it on "
f"You have deleted this alias before. You can restore it on " f"{custom_domain.domain} 'Deleted Alias' page",
f"{custom_domain.domain} 'Deleted Alias' page", "error",
"error", )
)
else:
# should never happen as user can only choose their domains
LOG.e(
"Deleted Alias %s does not belong to user %s",
domain_deleted_alias,
)
elif DeletedAlias.get_by(email=full_alias): elif DeletedAlias.get_by(email=full_alias):
flash(general_error_msg, "error") flash(general_error_msg, "error")

View file

@ -3,6 +3,7 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, validators from wtforms import StringField, validators
from app import parallel_limiter
from app.config import EMAIL_SERVERS_WITH_PRIORITY from app.config import EMAIL_SERVERS_WITH_PRIORITY
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
@ -19,6 +20,7 @@ class NewCustomDomainForm(FlaskForm):
@dashboard_bp.route("/custom_domain", methods=["GET", "POST"]) @dashboard_bp.route("/custom_domain", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def custom_domain(): def custom_domain():
custom_domains = CustomDomain.filter_by( custom_domains = CustomDomain.filter_by(
user_id=current_user.id, is_sl_subdomain=False user_id=current_user.id, is_sl_subdomain=False

View file

@ -1,6 +1,7 @@
import arrow import arrow
from flask import flash, redirect, url_for, request, render_template from flask import flash, redirect, url_for, request, render_template
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from app.config import JOB_DELETE_ACCOUNT from app.config import JOB_DELETE_ACCOUNT
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
@ -9,11 +10,21 @@ from app.log import LOG
from app.models import Subscription, Job from app.models import Subscription, Job
class DeleteDirForm(FlaskForm):
pass
@dashboard_bp.route("/delete_account", methods=["GET", "POST"]) @dashboard_bp.route("/delete_account", methods=["GET", "POST"])
@login_required @login_required
@sudo_required @sudo_required
def delete_account(): def delete_account():
delete_form = DeleteDirForm()
if request.method == "POST" and request.form.get("form-name") == "delete-account": if request.method == "POST" and request.form.get("form-name") == "delete-account":
if not delete_form.validate():
flash("Invalid request", "warning")
return render_template(
"dashboard/delete_account.html", delete_form=delete_form
)
sub: Subscription = current_user.get_paddle_subscription() sub: Subscription = current_user.get_paddle_subscription()
# user who has canceled can also re-subscribe # user who has canceled can also re-subscribe
if sub and not sub.cancelled: if sub and not sub.cancelled:
@ -36,6 +47,4 @@ def delete_account():
) )
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
return render_template( return render_template("dashboard/delete_account.html", delete_form=delete_form)
"dashboard/delete_account.html",
)

View file

@ -1,8 +1,15 @@
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, validators from wtforms import (
StringField,
validators,
SelectMultipleField,
BooleanField,
IntegerField,
)
from app import parallel_limiter
from app.config import ( from app.config import (
EMAIL_DOMAIN, EMAIL_DOMAIN,
ALIAS_DOMAINS, ALIAS_DOMAINS,
@ -21,8 +28,25 @@ class NewDirForm(FlaskForm):
) )
class ToggleDirForm(FlaskForm):
directory_id = IntegerField(validators=[validators.DataRequired()])
directory_enabled = BooleanField(validators=[])
class UpdateDirForm(FlaskForm):
directory_id = IntegerField(validators=[validators.DataRequired()])
mailbox_ids = SelectMultipleField(
validators=[validators.DataRequired()], validate_choice=False, choices=[]
)
class DeleteDirForm(FlaskForm):
directory_id = IntegerField(validators=[validators.DataRequired()])
@dashboard_bp.route("/directory", methods=["GET", "POST"]) @dashboard_bp.route("/directory", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def directory(): def directory():
dirs = ( dirs = (
Directory.filter_by(user_id=current_user.id) Directory.filter_by(user_id=current_user.id)
@ -33,54 +57,68 @@ def directory():
mailboxes = current_user.mailboxes() mailboxes = current_user.mailboxes()
new_dir_form = NewDirForm() new_dir_form = NewDirForm()
toggle_dir_form = ToggleDirForm()
update_dir_form = UpdateDirForm()
update_dir_form.mailbox_ids.choices = [
(str(mailbox.id), str(mailbox.id)) for mailbox in mailboxes
]
delete_dir_form = DeleteDirForm()
if request.method == "POST": if request.method == "POST":
if request.form.get("form-name") == "delete": if request.form.get("form-name") == "delete":
dir_id = request.form.get("dir-id") if not delete_dir_form.validate():
dir = Directory.get(dir_id) flash("Invalid request", "warning")
return redirect(url_for("dashboard.directory"))
dir_obj = Directory.get(delete_dir_form.directory_id.data)
if not dir: if not dir_obj:
flash("Unknown error. Refresh the page", "warning") flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
elif dir.user_id != current_user.id: elif dir_obj.user_id != current_user.id:
flash("You cannot delete this directory", "warning") flash("You cannot delete this directory", "warning")
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
name = dir.name name = dir_obj.name
Directory.delete(dir_id) Directory.delete(dir_obj.id)
Session.commit() Session.commit()
flash(f"Directory {name} has been deleted", "success") flash(f"Directory {name} has been deleted", "success")
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
if request.form.get("form-name") == "toggle-directory": if request.form.get("form-name") == "toggle-directory":
dir_id = request.form.get("dir-id") if not toggle_dir_form.validate():
dir = Directory.get(dir_id) flash("Invalid request", "warning")
return redirect(url_for("dashboard.directory"))
dir_id = toggle_dir_form.directory_id.data
dir_obj = Directory.get(dir_id)
if not dir or dir.user_id != current_user.id: if not dir_obj or dir_obj.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning") flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
if request.form.get("dir-status") == "on": if toggle_dir_form.directory_enabled.data:
dir.disabled = False dir_obj.disabled = False
flash(f"On-the-fly is enabled for {dir.name}", "success") flash(f"On-the-fly is enabled for {dir_obj.name}", "success")
else: else:
dir.disabled = True dir_obj.disabled = True
flash(f"On-the-fly is disabled for {dir.name}", "warning") flash(f"On-the-fly is disabled for {dir_obj.name}", "warning")
Session.commit() Session.commit()
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
elif request.form.get("form-name") == "update": elif request.form.get("form-name") == "update":
dir_id = request.form.get("dir-id") if not update_dir_form.validate():
dir = Directory.get(dir_id) flash("Invalid request", "warning")
return redirect(url_for("dashboard.directory"))
dir_id = update_dir_form.directory_id.data
dir_obj = Directory.get(dir_id)
if not dir or dir.user_id != current_user.id: if not dir_obj or dir_obj.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning") flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
mailbox_ids = request.form.getlist("mailbox_ids") mailbox_ids = update_dir_form.mailbox_ids.data
# check if mailbox is not tempered with # check if mailbox is not tempered with
mailboxes = [] mailboxes = []
for mailbox_id in mailbox_ids: for mailbox_id in mailbox_ids:
@ -99,14 +137,14 @@ def directory():
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
# first remove all existing directory-mailboxes links # first remove all existing directory-mailboxes links
DirectoryMailbox.filter_by(directory_id=dir.id).delete() DirectoryMailbox.filter_by(directory_id=dir_obj.id).delete()
Session.flush() Session.flush()
for mailbox in mailboxes: for mailbox in mailboxes:
DirectoryMailbox.create(directory_id=dir.id, mailbox_id=mailbox.id) DirectoryMailbox.create(directory_id=dir_obj.id, mailbox_id=mailbox.id)
Session.commit() Session.commit()
flash(f"Directory {dir.name} has been updated", "success") flash(f"Directory {dir_obj.name} has been updated", "success")
return redirect(url_for("dashboard.directory")) return redirect(url_for("dashboard.directory"))
elif request.form.get("form-name") == "create": elif request.form.get("form-name") == "create":
@ -181,6 +219,9 @@ def directory():
return render_template( return render_template(
"dashboard/directory.html", "dashboard/directory.html",
dirs=dirs, dirs=dirs,
toggle_dir_form=toggle_dir_form,
update_dir_form=update_dir_form,
delete_dir_form=delete_dir_form,
new_dir_form=new_dir_form, new_dir_form=new_dir_form,
mailboxes=mailboxes, mailboxes=mailboxes,
EMAIL_DOMAIN=EMAIL_DOMAIN, EMAIL_DOMAIN=EMAIL_DOMAIN,

View file

@ -7,13 +7,13 @@ from flask_wtf import FlaskForm
from wtforms import StringField, validators, IntegerField from wtforms import StringField, validators, IntegerField
from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_DOMAIN, JOB_DELETE_DOMAIN from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_DOMAIN, JOB_DELETE_DOMAIN
from app.custom_domain_validation import CustomDomainValidation
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
from app.dns_utils import ( from app.dns_utils import (
get_mx_domains, get_mx_domains,
get_spf_domain, get_spf_domain,
get_txt_record, get_txt_record,
get_cname_record,
is_mx_equivalent, is_mx_equivalent,
) )
from app.log import LOG from app.log import LOG
@ -28,7 +28,7 @@ from app.models import (
Job, Job,
) )
from app.regex_utils import regex_match from app.regex_utils import regex_match
from app.utils import random_string from app.utils import random_string, CSRFValidationForm
@dashboard_bp.route("/domains/<int:custom_domain_id>/dns", methods=["GET", "POST"]) @dashboard_bp.route("/domains/<int:custom_domain_id>/dns", methods=["GET", "POST"])
@ -46,8 +46,8 @@ def domain_detail_dns(custom_domain_id):
spf_record = f"v=spf1 include:{EMAIL_DOMAIN} ~all" spf_record = f"v=spf1 include:{EMAIL_DOMAIN} ~all"
# hardcode the DKIM selector here domain_validator = CustomDomainValidation(EMAIL_DOMAIN)
dkim_cname = f"dkim._domainkey.{EMAIL_DOMAIN}" csrf_form = CSRFValidationForm()
dmarc_record = "v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s" dmarc_record = "v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s"
@ -55,6 +55,9 @@ def domain_detail_dns(custom_domain_id):
mx_errors = spf_errors = dkim_errors = dmarc_errors = ownership_errors = [] mx_errors = spf_errors = dkim_errors = dmarc_errors = ownership_errors = []
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "check-ownership": if request.form.get("form-name") == "check-ownership":
txt_records = get_txt_record(custom_domain.domain) txt_records = get_txt_record(custom_domain.domain)
@ -122,23 +125,17 @@ def domain_detail_dns(custom_domain_id):
spf_errors = get_txt_record(custom_domain.domain) spf_errors = get_txt_record(custom_domain.domain)
elif request.form.get("form-name") == "check-dkim": elif request.form.get("form-name") == "check-dkim":
dkim_record = get_cname_record("dkim._domainkey." + custom_domain.domain) dkim_errors = domain_validator.validate_dkim_records(custom_domain)
if dkim_record == dkim_cname: if len(dkim_errors) == 0:
flash("DKIM is setup correctly.", "success") flash("DKIM is setup correctly.", "success")
custom_domain.dkim_verified = True
Session.commit()
return redirect( return redirect(
url_for( url_for(
"dashboard.domain_detail_dns", custom_domain_id=custom_domain.id "dashboard.domain_detail_dns", custom_domain_id=custom_domain.id
) )
) )
else: else:
custom_domain.dkim_verified = False
Session.commit()
flash("DKIM: the CNAME record is not correctly set", "warning")
dkim_ok = False dkim_ok = False
dkim_errors = [dkim_record or "[Empty]"] flash("DKIM: the CNAME record is not correctly set", "warning")
elif request.form.get("form-name") == "check-dmarc": elif request.form.get("form-name") == "check-dmarc":
txt_records = get_txt_record("_dmarc." + custom_domain.domain) txt_records = get_txt_record("_dmarc." + custom_domain.domain)
@ -164,6 +161,7 @@ def domain_detail_dns(custom_domain_id):
return render_template( return render_template(
"dashboard/domain_detail/dns.html", "dashboard/domain_detail/dns.html",
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY, EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
dkim_records=domain_validator.get_dkim_records(),
**locals(), **locals(),
) )
@ -171,6 +169,7 @@ def domain_detail_dns(custom_domain_id):
@dashboard_bp.route("/domains/<int:custom_domain_id>/info", methods=["GET", "POST"]) @dashboard_bp.route("/domains/<int:custom_domain_id>/info", methods=["GET", "POST"])
@login_required @login_required
def domain_detail(custom_domain_id): def domain_detail(custom_domain_id):
csrf_form = CSRFValidationForm()
custom_domain: CustomDomain = CustomDomain.get(custom_domain_id) custom_domain: CustomDomain = CustomDomain.get(custom_domain_id)
mailboxes = current_user.mailboxes() mailboxes = current_user.mailboxes()
@ -179,6 +178,9 @@ def domain_detail(custom_domain_id):
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "switch-catch-all": if request.form.get("form-name") == "switch-catch-all":
custom_domain.catch_all = not custom_domain.catch_all custom_domain.catch_all = not custom_domain.catch_all
Session.commit() Session.commit()
@ -307,12 +309,16 @@ def domain_detail(custom_domain_id):
@dashboard_bp.route("/domains/<int:custom_domain_id>/trash", methods=["GET", "POST"]) @dashboard_bp.route("/domains/<int:custom_domain_id>/trash", methods=["GET", "POST"])
@login_required @login_required
def domain_detail_trash(custom_domain_id): def domain_detail_trash(custom_domain_id):
csrf_form = CSRFValidationForm()
custom_domain = CustomDomain.get(custom_domain_id) custom_domain = CustomDomain.get(custom_domain_id)
if not custom_domain or custom_domain.user_id != current_user.id: if not custom_domain or custom_domain.user_id != current_user.id:
flash("You cannot see this page", "warning") flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "empty-all": if request.form.get("form-name") == "empty-all":
DomainDeletedAlias.filter_by(domain_id=custom_domain.id).delete() DomainDeletedAlias.filter_by(domain_id=custom_domain.id).delete()
Session.commit() Session.commit()
@ -356,6 +362,7 @@ def domain_detail_trash(custom_domain_id):
"dashboard/domain_detail/trash.html", "dashboard/domain_detail/trash.html",
domain_deleted_aliases=domain_deleted_aliases, domain_deleted_aliases=domain_deleted_aliases,
custom_domain=custom_domain, custom_domain=custom_domain,
csrf_form=csrf_form,
) )

View file

@ -8,6 +8,7 @@ from wtforms import PasswordField, validators
from app.config import CONNECT_WITH_PROTON from app.config import CONNECT_WITH_PROTON
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import PartnerUser from app.models import PartnerUser
from app.proton.utils import get_proton_partner from app.proton.utils import get_proton_partner
@ -21,6 +22,7 @@ class LoginForm(FlaskForm):
@dashboard_bp.route("/enter_sudo", methods=["GET", "POST"]) @dashboard_bp.route("/enter_sudo", methods=["GET", "POST"])
@limiter.limit("3/minute")
@login_required @login_required
def enter_sudo(): def enter_sudo():
password_check_form = LoginForm() password_check_form = LoginForm()

View file

@ -78,10 +78,10 @@ def fido_setup():
) )
flash("Security key has been activated", "success") flash("Security key has been activated", "success")
if not RecoveryCode.filter_by(user_id=current_user.id).all(): recovery_codes = RecoveryCode.generate(current_user)
return redirect(url_for("dashboard.recovery_code_route")) return render_template(
else: "dashboard/recovery_code.html", recovery_codes=recovery_codes
return redirect(url_for("dashboard.fido_manage")) )
# Prepare information for key registration process # Prepare information for key registration process
fido_uuid = ( fido_uuid = (

View file

@ -3,7 +3,7 @@ from dataclasses import dataclass
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import alias_utils from app import alias_utils, parallel_limiter
from app.api.serializer import get_alias_infos_with_pagination_v3, get_alias_info_v3 from app.api.serializer import get_alias_infos_with_pagination_v3, get_alias_info_v3
from app.config import ALIAS_LIMIT, PAGE_LIMIT from app.config import ALIAS_LIMIT, PAGE_LIMIT
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
@ -17,6 +17,7 @@ from app.models import (
EmailLog, EmailLog,
Contact, Contact,
) )
from app.utils import CSRFValidationForm
@dataclass @dataclass
@ -56,7 +57,15 @@ def get_stats(user: User) -> Stats:
methods=["POST"], methods=["POST"],
exempt_when=lambda: request.form.get("form-name") != "create-random-email", exempt_when=lambda: request.form.get("form-name") != "create-random-email",
) )
@limiter.limit(
"5/minute",
methods=["GET"],
)
@login_required @login_required
@parallel_limiter.lock(
name="alias_creation",
only_when=lambda: request.form.get("form-name") == "create-random-email",
)
def index(): def index():
query = request.args.get("query") or "" query = request.args.get("query") or ""
sort = request.args.get("sort") or "" sort = request.args.get("sort") or ""
@ -75,8 +84,12 @@ def index():
"highlight_alias_id must be a number, received %s", "highlight_alias_id must be a number, received %s",
request.args.get("highlight_alias_id"), request.args.get("highlight_alias_id"),
) )
csrf_form = CSRFValidationForm()
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "create-custom-email": if request.form.get("form-name") == "create-custom-email":
if current_user.can_create_new_alias(): if current_user.can_create_new_alias():
return redirect(url_for("dashboard.custom_alias")) return redirect(url_for("dashboard.custom_alias"))
@ -141,7 +154,13 @@ def index():
flash(f"Alias {alias.email} has been disabled", "success") flash(f"Alias {alias.email} has been disabled", "success")
return redirect( return redirect(
url_for("dashboard.index", query=query, sort=sort, filter=alias_filter) url_for(
"dashboard.index",
query=query,
sort=sort,
filter=alias_filter,
page=page,
)
) )
mailboxes = current_user.mailboxes() mailboxes = current_user.mailboxes()
@ -204,6 +223,7 @@ def index():
sort=sort, sort=sort,
filter=alias_filter, filter=alias_filter,
stats=stats, stats=stats,
csrf_form=csrf_form,
) )

View file

@ -1,11 +1,16 @@
import base64
import binascii
import json
import arrow import arrow
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from itsdangerous import Signer from itsdangerous import TimestampSigner
from wtforms import validators from wtforms import validators, IntegerField
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField
from app import parallel_limiter
from app.config import MAILBOX_SECRET, URL, JOB_DELETE_MAILBOX from app.config import MAILBOX_SECRET, URL, JOB_DELETE_MAILBOX
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
@ -14,10 +19,11 @@ from app.email_utils import (
mailbox_already_used, mailbox_already_used,
render, render,
send_email, send_email,
is_valid_email,
) )
from app.email_validation import is_valid_email
from app.log import LOG from app.log import LOG
from app.models import Mailbox, Job from app.models import Mailbox, Job
from app.utils import CSRFValidationForm
class NewMailboxForm(FlaskForm): class NewMailboxForm(FlaskForm):
@ -26,8 +32,16 @@ class NewMailboxForm(FlaskForm):
) )
class DeleteMailboxForm(FlaskForm):
mailbox_id = IntegerField(
validators=[validators.DataRequired()],
)
transfer_mailbox_id = IntegerField()
@dashboard_bp.route("/mailbox", methods=["GET", "POST"]) @dashboard_bp.route("/mailbox", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def mailbox_route(): def mailbox_route():
mailboxes = ( mailboxes = (
Mailbox.filter_by(user_id=current_user.id) Mailbox.filter_by(user_id=current_user.id)
@ -36,25 +50,57 @@ def mailbox_route():
) )
new_mailbox_form = NewMailboxForm() new_mailbox_form = NewMailboxForm()
csrf_form = CSRFValidationForm()
delete_mailbox_form = DeleteMailboxForm()
if request.method == "POST": if request.method == "POST":
if request.form.get("form-name") == "delete": if request.form.get("form-name") == "delete":
mailbox_id = request.form.get("mailbox-id") if not delete_mailbox_form.validate():
mailbox = Mailbox.get(mailbox_id) flash("Invalid request", "warning")
return redirect(request.url)
mailbox = Mailbox.get(delete_mailbox_form.mailbox_id.data)
if not mailbox or mailbox.user_id != current_user.id: if not mailbox or mailbox.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning") flash("Invalid mailbox. Refresh the page", "warning")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
if mailbox.id == current_user.default_mailbox_id: if mailbox.id == current_user.default_mailbox_id:
flash("You cannot delete default mailbox", "error") flash("You cannot delete default mailbox", "error")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
transfer_mailbox_id = delete_mailbox_form.transfer_mailbox_id.data
if transfer_mailbox_id and transfer_mailbox_id > 0:
transfer_mailbox = Mailbox.get(transfer_mailbox_id)
if not transfer_mailbox or transfer_mailbox.user_id != current_user.id:
flash(
"You must transfer the aliases to a mailbox you own.", "error"
)
return redirect(url_for("dashboard.mailbox_route"))
if transfer_mailbox.id == mailbox.id:
flash(
"You can not transfer the aliases to the mailbox you want to delete.",
"error",
)
return redirect(url_for("dashboard.mailbox_route"))
if not transfer_mailbox.verified:
flash("Your new mailbox is not verified", "error")
return redirect(url_for("dashboard.mailbox_route"))
# Schedule delete account job # Schedule delete account job
LOG.w("schedule delete mailbox job for %s", mailbox) LOG.w(
f"schedule delete mailbox job for {mailbox.id} with transfer to mailbox {transfer_mailbox_id}"
)
Job.create( Job.create(
name=JOB_DELETE_MAILBOX, name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": mailbox.id}, payload={
"mailbox_id": mailbox.id,
"transfer_mailbox_id": transfer_mailbox_id
if transfer_mailbox_id > 0
else None,
},
run_at=arrow.now(), run_at=arrow.now(),
commit=True, commit=True,
) )
@ -67,7 +113,10 @@ def mailbox_route():
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
if request.form.get("form-name") == "set-default": if request.form.get("form-name") == "set-default":
mailbox_id = request.form.get("mailbox-id") if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
mailbox_id = request.form.get("mailbox_id")
mailbox = Mailbox.get(mailbox_id) mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != current_user.id: if not mailbox or mailbox.user_id != current_user.id:
@ -119,7 +168,8 @@ def mailbox_route():
return redirect( return redirect(
url_for( url_for(
"dashboard.mailbox_detail_route", mailbox_id=new_mailbox.id "dashboard.mailbox_detail_route",
mailbox_id=new_mailbox.id,
) )
) )
@ -127,38 +177,16 @@ def mailbox_route():
"dashboard/mailbox.html", "dashboard/mailbox.html",
mailboxes=mailboxes, mailboxes=mailboxes,
new_mailbox_form=new_mailbox_form, new_mailbox_form=new_mailbox_form,
delete_mailbox_form=delete_mailbox_form,
csrf_form=csrf_form,
) )
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)
Session.commit()
LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email)
send_email(
user.email,
f"Your mailbox {mailbox_email} has been deleted",
f"""Mailbox {mailbox_email} along with its aliases are deleted successfully.
Regards,
SimpleLogin team.
""",
)
def send_verification_email(user, mailbox): def send_verification_email(user, mailbox):
s = Signer(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode() encoded_data = json.dumps([mailbox.id, mailbox.email]).encode("utf-8")
b64_data = base64.urlsafe_b64encode(encoded_data)
mailbox_id_signed = s.sign(b64_data).decode()
verification_url = ( verification_url = (
URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}" URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}"
) )
@ -182,23 +210,35 @@ def send_verification_email(user, mailbox):
@dashboard_bp.route("/mailbox_verify") @dashboard_bp.route("/mailbox_verify")
def mailbox_verify(): def mailbox_verify():
s = Signer(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
mailbox_id = request.args.get("mailbox_id") mailbox_verify_request = request.args.get("mailbox_id")
try: try:
r_id = int(s.unsign(mailbox_id)) mailbox_raw_data = s.unsign(mailbox_verify_request, max_age=900)
except Exception: except Exception:
flash("Invalid link. Please delete and re-add your mailbox", "error") flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
else: try:
mailbox = Mailbox.get(r_id) decoded_data = base64.urlsafe_b64decode(mailbox_raw_data)
if not mailbox: except binascii.Error:
flash("Invalid link", "error") flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
mailbox_data = json.loads(decoded_data)
if not isinstance(mailbox_data, list) or len(mailbox_data) != 2:
flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route"))
mailbox_id = mailbox_data[0]
mailbox = Mailbox.get(mailbox_id)
if not mailbox:
flash("Invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
mailbox_email = mailbox_data[1]
if mailbox_email != mailbox.email:
flash("Invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
mailbox.verified = True mailbox.verified = True
Session.commit() Session.commit()
LOG.d("Mailbox %s is verified", mailbox) LOG.d("Mailbox %s is verified", mailbox)
return render_template("dashboard/mailbox_validation.html", mailbox=mailbox) return render_template("dashboard/mailbox_validation.html", mailbox=mailbox)

View file

@ -1,9 +1,10 @@
from smtplib import SMTPRecipientsRefused from smtplib import SMTPRecipientsRefused
from email_validator import validate_email, EmailNotValidError
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from itsdangerous import Signer from itsdangerous import TimestampSigner
from wtforms import validators from wtforms import validators
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField
@ -17,7 +18,7 @@ from app.log import LOG
from app.models import Alias, AuthorizedAddress from app.models import Alias, AuthorizedAddress
from app.models import Mailbox from app.models import Mailbox
from app.pgp_utils import PGPException, load_public_key_and_check from app.pgp_utils import PGPException, load_public_key_and_check
from app.utils import sanitize_email from app.utils import sanitize_email, CSRFValidationForm
class ChangeEmailForm(FlaskForm): class ChangeEmailForm(FlaskForm):
@ -29,12 +30,13 @@ class ChangeEmailForm(FlaskForm):
@dashboard_bp.route("/mailbox/<int:mailbox_id>/", methods=["GET", "POST"]) @dashboard_bp.route("/mailbox/<int:mailbox_id>/", methods=["GET", "POST"])
@login_required @login_required
def mailbox_detail_route(mailbox_id): def mailbox_detail_route(mailbox_id):
mailbox = Mailbox.get(mailbox_id) mailbox: Mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != current_user.id: if not mailbox or mailbox.user_id != current_user.id:
flash("You cannot see this page", "warning") flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
change_email_form = ChangeEmailForm() change_email_form = ChangeEmailForm()
csrf_form = CSRFValidationForm()
if mailbox.new_email: if mailbox.new_email:
pending_email = mailbox.new_email pending_email = mailbox.new_email
@ -42,6 +44,9 @@ def mailbox_detail_route(mailbox_id):
pending_email = None pending_email = None
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if ( if (
request.form.get("form-name") == "update-email" request.form.get("form-name") == "update-email"
and change_email_form.validate_on_submit() and change_email_form.validate_on_submit()
@ -94,16 +99,23 @@ def mailbox_detail_route(mailbox_id):
) )
elif request.form.get("form-name") == "add-authorized-address": elif request.form.get("form-name") == "add-authorized-address":
address = sanitize_email(request.form.get("email")) address = sanitize_email(request.form.get("email"))
if AuthorizedAddress.get_by(mailbox_id=mailbox.id, email=address): try:
flash(f"{address} already added", "error") validate_email(
address, check_deliverability=False, allow_smtputf8=False
).domain
except EmailNotValidError:
flash(f"invalid {address}", "error")
else: else:
AuthorizedAddress.create( if AuthorizedAddress.get_by(mailbox_id=mailbox.id, email=address):
user_id=current_user.id, flash(f"{address} already added", "error")
mailbox_id=mailbox.id, else:
email=address, AuthorizedAddress.create(
commit=True, user_id=current_user.id,
) mailbox_id=mailbox.id,
flash(f"{address} added as authorized address", "success") email=address,
commit=True,
)
flash(f"{address} added as authorized address", "success")
return redirect( return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
@ -132,6 +144,15 @@ def mailbox_detail_route(mailbox_id):
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
) )
if mailbox.is_proton():
flash(
"Enabling PGP for a Proton Mail mailbox is redundant and does not add any security benefit",
"info",
)
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
mailbox.pgp_public_key = request.form.get("pgp") mailbox.pgp_public_key = request.form.get("pgp")
try: try:
mailbox.pgp_finger_print = load_public_key_and_check( mailbox.pgp_finger_print = load_public_key_and_check(
@ -170,25 +191,16 @@ def mailbox_detail_route(mailbox_id):
) )
elif request.form.get("form-name") == "generic-subject": elif request.form.get("form-name") == "generic-subject":
if request.form.get("action") == "save": if request.form.get("action") == "save":
if not mailbox.pgp_enabled():
flash(
"Generic subject can only be used on PGP-enabled mailbox",
"error",
)
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
mailbox.generic_subject = request.form.get("generic-subject") mailbox.generic_subject = request.form.get("generic-subject")
Session.commit() Session.commit()
flash("Generic subject for PGP-encrypted email is enabled", "success") flash("Generic subject is enabled", "success")
return redirect( return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
) )
elif request.form.get("action") == "remove": elif request.form.get("action") == "remove":
mailbox.generic_subject = None mailbox.generic_subject = None
Session.commit() Session.commit()
flash("Generic subject for PGP-encrypted email is disabled", "success") flash("Generic subject is disabled", "success")
return redirect( return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
) )
@ -198,7 +210,7 @@ def mailbox_detail_route(mailbox_id):
def verify_mailbox_change(user, mailbox, new_email): def verify_mailbox_change(user, mailbox, new_email):
s = Signer(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode() mailbox_id_signed = s.sign(str(mailbox.id)).decode()
verification_url = ( verification_url = (
f"{URL}/dashboard/mailbox/confirm_change?mailbox_id={mailbox_id_signed}" f"{URL}/dashboard/mailbox/confirm_change?mailbox_id={mailbox_id_signed}"
@ -250,11 +262,11 @@ def cancel_mailbox_change_route(mailbox_id):
@dashboard_bp.route("/mailbox/confirm_change") @dashboard_bp.route("/mailbox/confirm_change")
def mailbox_confirm_change_route(): def mailbox_confirm_change_route():
s = Signer(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
signed_mailbox_id = request.args.get("mailbox_id") signed_mailbox_id = request.args.get("mailbox_id")
try: try:
mailbox_id = int(s.unsign(signed_mailbox_id)) mailbox_id = int(s.unsign(signed_mailbox_id, max_age=900))
except Exception: except Exception:
flash("Invalid link", "error") flash("Invalid link", "error")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))

View file

@ -5,6 +5,7 @@ from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session from app.db import Session
from app.models import RecoveryCode from app.models import RecoveryCode
from app.utils import CSRFValidationForm
@dashboard_bp.route("/mfa_cancel", methods=["GET", "POST"]) @dashboard_bp.route("/mfa_cancel", methods=["GET", "POST"])
@ -15,8 +16,13 @@ def mfa_cancel():
flash("you don't have MFA enabled", "warning") flash("you don't have MFA enabled", "warning")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
csrf_form = CSRFValidationForm()
# user cancels TOTP # user cancels TOTP
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
current_user.enable_otp = False current_user.enable_otp = False
current_user.otp_secret = None current_user.otp_secret = None
Session.commit() Session.commit()
@ -28,4 +34,4 @@ def mfa_cancel():
flash("TOTP is now disabled", "warning") flash("TOTP is now disabled", "warning")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
return render_template("dashboard/mfa_cancel.html") return render_template("dashboard/mfa_cancel.html", csrf_form=csrf_form)

View file

@ -8,6 +8,7 @@ from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session from app.db import Session
from app.log import LOG from app.log import LOG
from app.models import RecoveryCode
class OtpTokenForm(FlaskForm): class OtpTokenForm(FlaskForm):
@ -39,8 +40,10 @@ def mfa_setup():
current_user.last_otp = token current_user.last_otp = token
Session.commit() Session.commit()
flash("MFA has been activated", "success") flash("MFA has been activated", "success")
recovery_codes = RecoveryCode.generate(current_user)
return redirect(url_for("dashboard.recovery_code_route")) return render_template(
"dashboard/recovery_code.html", recovery_codes=recovery_codes
)
else: else:
flash("Incorrect token", "warning") flash("Incorrect token", "warning")

View file

@ -80,8 +80,9 @@ def pricing():
@dashboard_bp.route("/subscription_success") @dashboard_bp.route("/subscription_success")
@login_required @login_required
def subscription_success(): def subscription_success():
flash("Thanks so much for supporting SimpleLogin!", "success") return render_template(
return redirect(url_for("dashboard.index")) "dashboard/thank-you.html",
)
@dashboard_bp.route("/coinbase_checkout") @dashboard_bp.route("/coinbase_checkout")

View file

@ -1,30 +0,0 @@
from flask import render_template, flash, redirect, url_for, request
from flask_login import login_required, current_user
from app.dashboard.base import dashboard_bp
from app.log import LOG
from app.models import RecoveryCode
@dashboard_bp.route("/recovery_code", methods=["GET", "POST"])
@login_required
def recovery_code_route():
if not current_user.two_factor_authentication_enabled():
flash("you need to enable either TOTP or WebAuthn", "warning")
return redirect(url_for("dashboard.index"))
recovery_codes = RecoveryCode.filter_by(user_id=current_user.id).all()
if request.method == "GET" and not recovery_codes:
# user arrives at this page for the first time
LOG.d("%s has no recovery keys, generate", current_user)
RecoveryCode.generate(current_user)
recovery_codes = RecoveryCode.filter_by(user_id=current_user.id).all()
if request.method == "POST":
RecoveryCode.generate(current_user)
flash("New recovery codes generated", "success")
return redirect(url_for("dashboard.recovery_code_route"))
return render_template(
"dashboard/recovery_code.html", recovery_codes=recovery_codes
)

View file

@ -12,7 +12,6 @@ from flask import (
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.file import FileField from flask_wtf.file import FileField
from newrelic import agent
from wtforms import StringField, validators from wtforms import StringField, validators
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField
@ -30,6 +29,7 @@ from app.email_utils import (
personal_email_already_used, personal_email_already_used,
) )
from app.errors import ProtonPartnerNotSetUp from app.errors import ProtonPartnerNotSetUp
from app.extensions import limiter
from app.image_validation import detect_image_format, ImageFormat from app.image_validation import detect_image_format, ImageFormat
from app.jobs.export_user_data_job import ExportUserDataJob from app.jobs.export_user_data_job import ExportUserDataJob
from app.log import LOG from app.log import LOG
@ -53,8 +53,12 @@ from app.models import (
PartnerSubscription, PartnerSubscription,
UnsubscribeBehaviourEnum, UnsubscribeBehaviourEnum,
) )
from app.proton.utils import get_proton_partner from app.proton.utils import get_proton_partner, perform_proton_account_unlink
from app.utils import random_string, sanitize_email from app.utils import (
random_string,
CSRFValidationForm,
canonicalize_email,
)
class SettingForm(FlaskForm): class SettingForm(FlaskForm):
@ -101,10 +105,12 @@ def get_partner_subscription_and_name(
@dashboard_bp.route("/setting", methods=["GET", "POST"]) @dashboard_bp.route("/setting", methods=["GET", "POST"])
@login_required @login_required
@limiter.limit("5/minute", methods=["POST"])
def setting(): def setting():
form = SettingForm() form = SettingForm()
promo_form = PromoCodeForm() promo_form = PromoCodeForm()
change_email_form = ChangeEmailForm() change_email_form = ChangeEmailForm()
csrf_form = CSRFValidationForm()
email_change = EmailChange.get_by(user_id=current_user.id) email_change = EmailChange.get_by(user_id=current_user.id)
if email_change: if email_change:
@ -113,16 +119,15 @@ def setting():
pending_email = None pending_email = None
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting"))
if request.form.get("form-name") == "update-email": if request.form.get("form-name") == "update-email":
if change_email_form.validate(): if change_email_form.validate():
# whether user can proceed with the email update # whether user can proceed with the email update
new_email_valid = True new_email_valid = True
if ( new_email = canonicalize_email(change_email_form.email.data)
sanitize_email(change_email_form.email.data) != current_user.email if new_email != current_user.email and not pending_email:
and not pending_email
):
new_email = sanitize_email(change_email_form.email.data)
# check if this email is not already used # check if this email is not already used
if personal_email_already_used(new_email) or Alias.get_by( if personal_email_already_used(new_email) or Alias.get_by(
email=new_email email=new_email
@ -192,6 +197,16 @@ def setting():
) )
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
if current_user.profile_picture_id is not None:
current_profile_file = File.get_by(
id=current_user.profile_picture_id
)
if (
current_profile_file is not None
and current_profile_file.user_id == current_user.id
):
s3.delete(current_profile_file.path)
file_path = random_string(30) file_path = random_string(30)
file = File.create(user_id=current_user.id, path=file_path) file = File.create(user_id=current_user.id, path=file_path)
@ -396,6 +411,7 @@ def setting():
return render_template( return render_template(
"dashboard/setting.html", "dashboard/setting.html",
csrf_form=csrf_form,
form=form, form=form,
PlanEnum=PlanEnum, PlanEnum=PlanEnum,
SenderFormatEnum=SenderFormatEnum, SenderFormatEnum=SenderFormatEnum,
@ -444,8 +460,13 @@ def send_change_email_confirmation(user: User, email_change: EmailChange):
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"]) @dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])
@limiter.limit("5/hour")
@login_required @login_required
def resend_email_change(): def resend_email_change():
form = CSRFValidationForm()
if not form.validate():
flash("Invalid request. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
email_change = EmailChange.get_by(user_id=current_user.id) email_change = EmailChange.get_by(user_id=current_user.id)
if email_change: if email_change:
# extend email change expiration # extend email change expiration
@ -465,6 +486,10 @@ def resend_email_change():
@dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"]) @dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"])
@login_required @login_required
def cancel_email_change(): def cancel_email_change():
form = CSRFValidationForm()
if not form.validate():
flash("Invalid request. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
email_change = EmailChange.get_by(user_id=current_user.id) email_change = EmailChange.get_by(user_id=current_user.id)
if email_change: if email_change:
EmailChange.delete(email_change.id) EmailChange.delete(email_change.id)
@ -478,16 +503,14 @@ def cancel_email_change():
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
@dashboard_bp.route("/unlink_proton_account", methods=["GET", "POST"]) @dashboard_bp.route("/unlink_proton_account", methods=["POST"])
@login_required @login_required
def unlink_proton_account(): def unlink_proton_account():
proton_partner = get_proton_partner() csrf_form = CSRFValidationForm()
partner_user = PartnerUser.get_by( if not csrf_form.validate():
user_id=current_user.id, partner_id=proton_partner.id flash("Invalid request", "warning")
) return redirect(url_for("dashboard.setting"))
if partner_user is not None:
PartnerUser.delete(partner_user.id) perform_proton_account_unlink(current_user)
Session.commit()
flash("Your Proton account has been unlinked", "success") flash("Your Proton account has been unlinked", "success")
agent.record_custom_event("AccountUnlinked", {"partner": proton_partner.name})
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))

View file

@ -2,7 +2,10 @@ import re
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app import parallel_limiter
from app.config import MAX_NB_SUBDOMAIN from app.config import MAX_NB_SUBDOMAIN
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.errors import SubdomainInTrashError from app.errors import SubdomainInTrashError
@ -13,8 +16,18 @@ from app.models import CustomDomain, Mailbox, SLDomain
_SUBDOMAIN_PATTERN = r"[0-9a-z-]{1,}" _SUBDOMAIN_PATTERN = r"[0-9a-z-]{1,}"
class NewSubdomainForm(FlaskForm):
domain = StringField(
"domain", validators=[validators.DataRequired(), validators.Length(max=64)]
)
subdomain = StringField(
"subdomain", validators=[validators.DataRequired(), validators.Length(max=64)]
)
@dashboard_bp.route("/subdomain", methods=["GET", "POST"]) @dashboard_bp.route("/subdomain", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def subdomain_route(): def subdomain_route():
if not current_user.subdomain_is_available(): if not current_user.subdomain_is_available():
flash("Unknown error, redirect to the home page", "error") flash("Unknown error, redirect to the home page", "error")
@ -26,9 +39,13 @@ def subdomain_route():
).all() ).all()
errors = {} errors = {}
new_subdomain_form = NewSubdomainForm()
if request.method == "POST": if request.method == "POST":
if request.form.get("form-name") == "create": if request.form.get("form-name") == "create":
if not new_subdomain_form.validate():
flash("Invalid new subdomain", "warning")
return redirect(url_for("dashboard.subdomain_route"))
if not current_user.is_premium(): if not current_user.is_premium():
flash("Only premium plan can add subdomain", "warning") flash("Only premium plan can add subdomain", "warning")
return redirect(request.url) return redirect(request.url)
@ -39,8 +56,8 @@ def subdomain_route():
) )
return redirect(request.url) return redirect(request.url)
subdomain = request.form.get("subdomain").lower().strip() subdomain = new_subdomain_form.subdomain.data.lower().strip()
domain = request.form.get("domain").lower().strip() domain = new_subdomain_form.domain.data.lower().strip()
if len(subdomain) < 3: if len(subdomain) < 3:
flash("Subdomain must have at least 3 characters", "error") flash("Subdomain must have at least 3 characters", "error")
@ -108,4 +125,5 @@ def subdomain_route():
sl_domains=sl_domains, sl_domains=sl_domains,
errors=errors, errors=errors,
subdomains=subdomains, subdomains=subdomains,
new_subdomain_form=new_subdomain_form,
) )

View file

@ -75,12 +75,11 @@ def block_contact(contact_id):
@dashboard_bp.route("/unsubscribe/encoded/<encoded_request>", methods=["GET"]) @dashboard_bp.route("/unsubscribe/encoded/<encoded_request>", methods=["GET"])
@login_required @login_required
def encoded_unsubscribe(encoded_request: str): def encoded_unsubscribe(encoded_request: str):
unsub_data = UnsubscribeHandler().handle_unsubscribe_from_request( unsub_data = UnsubscribeHandler().handle_unsubscribe_from_request(
current_user, encoded_request current_user, encoded_request
) )
if not unsub_data: if not unsub_data:
flash(f"Invalid unsubscribe request", "error") flash("Invalid unsubscribe request", "error")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
if unsub_data.action == UnsubscribeAction.DisableAlias: if unsub_data.action == UnsubscribeAction.DisableAlias:
alias = Alias.get(unsub_data.data) alias = Alias.get(unsub_data.data)
@ -97,14 +96,14 @@ def encoded_unsubscribe(encoded_request: str):
) )
) )
if unsub_data.action == UnsubscribeAction.UnsubscribeNewsletter: if unsub_data.action == UnsubscribeAction.UnsubscribeNewsletter:
flash(f"You've unsubscribed from the newsletter", "success") flash("You've unsubscribed from the newsletter", "success")
return redirect( return redirect(
url_for( url_for(
"dashboard.index", "dashboard.index",
) )
) )
if unsub_data.action == UnsubscribeAction.OriginalUnsubscribeMailto: if unsub_data.action == UnsubscribeAction.OriginalUnsubscribeMailto:
flash(f"The original unsubscribe request has been forwarded", "success") flash("The original unsubscribe request has been forwarded", "success")
return redirect( return redirect(
url_for( url_for(
"dashboard.index", "dashboard.index",

View file

@ -3,9 +3,12 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session from sqlalchemy.orm import scoped_session
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from app.config import DB_URI from app import config
engine = create_engine(DB_URI)
engine = create_engine(
config.DB_URI, connect_args={"application_name": config.DB_CONN_NAME}
)
connection = engine.connect() connection = engine.connect()
Session = scoped_session(sessionmaker(bind=connection)) Session = scoped_session(sessionmaker(bind=connection))

View file

@ -1 +1,3 @@
from .views import index, new_client, client_detail from .views import index, new_client, client_detail
__all__ = ["index", "new_client", "client_detail"]

View file

@ -87,7 +87,7 @@ def client_detail(client_id):
) )
flash( flash(
f"Thanks for submitting, we are informed and will come back to you asap!", "Thanks for submitting, we are informed and will come back to you asap!",
"success", "success",
) )

View file

@ -1 +1,3 @@
from .views import index from .views import index
__all__ = ["index"]

View file

@ -34,7 +34,7 @@ def get_cname_record(hostname) -> Optional[str]:
def get_mx_domains(hostname) -> [(int, str)]: def get_mx_domains(hostname) -> [(int, str)]:
"""return list of (priority, domain name). """return list of (priority, domain name) sorted by priority (lowest priority first)
domain name ends with a "." at the end. domain name ends with a "." at the end.
""" """
try: try:
@ -50,7 +50,7 @@ def get_mx_domains(hostname) -> [(int, str)]:
ret.append((int(parts[0]), parts[1])) ret.append((int(parts[0]), parts[1]))
return ret return sorted(ret, key=lambda prio_domain: prio_domain[0])
_include_spf = "include:" _include_spf = "include:"

View file

@ -20,6 +20,7 @@ X_SPAM_STATUS = "X-Spam-Status"
LIST_UNSUBSCRIBE = "List-Unsubscribe" LIST_UNSUBSCRIBE = "List-Unsubscribe"
LIST_UNSUBSCRIBE_POST = "List-Unsubscribe-Post" LIST_UNSUBSCRIBE_POST = "List-Unsubscribe-Post"
RETURN_PATH = "Return-Path" RETURN_PATH = "Return-Path"
AUTHENTICATION_RESULTS = "Authentication-Results"
# headers used to DKIM sign in order of preference # headers used to DKIM sign in order of preference
DKIM_HEADERS = [ DKIM_HEADERS = [
@ -32,6 +33,7 @@ DKIM_HEADERS = [
SL_DIRECTION = "X-SimpleLogin-Type" SL_DIRECTION = "X-SimpleLogin-Type"
SL_EMAIL_LOG_ID = "X-SimpleLogin-EmailLog-ID" SL_EMAIL_LOG_ID = "X-SimpleLogin-EmailLog-ID"
SL_ENVELOPE_FROM = "X-SimpleLogin-Envelope-From" SL_ENVELOPE_FROM = "X-SimpleLogin-Envelope-From"
SL_ORIGINAL_FROM = "X-SimpleLogin-Original-From"
SL_ENVELOPE_TO = "X-SimpleLogin-Envelope-To" SL_ENVELOPE_TO = "X-SimpleLogin-Envelope-To"
SL_CLIENT_IP = "X-SimpleLogin-Client-IP" SL_CLIENT_IP = "X-SimpleLogin-Client-IP"

View file

@ -31,11 +31,7 @@ E402 = "421 SL E402 Encryption failed - Retry later"
# E403 = "421 SL E403 Retry later" # E403 = "421 SL E403 Retry later"
E404 = "421 SL E404 Unexpected error - Retry later" E404 = "421 SL E404 Unexpected error - Retry later"
E405 = "421 SL E405 Mailbox domain problem - Retry later" E405 = "421 SL E405 Mailbox domain problem - Retry later"
E406 = "421 SL E406 Retry later"
E407 = "421 SL E407 Retry later" E407 = "421 SL E407 Retry later"
E408 = "421 SL E408 Retry later"
E409 = "421 SL E409 Retry later"
E410 = "421 SL E410 Retry later"
# endregion # endregion
# region 5** errors # region 5** errors
@ -64,4 +60,5 @@ E522 = (
) )
E523 = "550 SL E523 Unknown error" E523 = "550 SL E523 Unknown error"
E524 = "550 SL E524 Wrong use of reverse-alias" E524 = "550 SL E524 Wrong use of reverse-alias"
E525 = "550 SL E525 Alias loop"
# endregion # endregion

View file

@ -14,7 +14,7 @@ from email.header import decode_header, Header
from email.message import Message, EmailMessage from email.message import Message, EmailMessage
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import make_msgid, formatdate from email.utils import make_msgid, formatdate, formataddr
from smtplib import SMTP, SMTPException from smtplib import SMTP, SMTPException
from typing import Tuple, List, Optional, Union from typing import Tuple, List, Optional, Union
@ -34,30 +34,7 @@ from flanker.addresslib.address import EmailAddress
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from sqlalchemy import func from sqlalchemy import func
from app.config import ( from app import config
ROOT_DIR,
POSTFIX_SERVER,
DKIM_SELECTOR,
DKIM_PRIVATE_KEY,
ALIAS_DOMAINS,
POSTFIX_SUBMISSION_TLS,
MAX_NB_EMAIL_FREE_PLAN,
MAX_ALERT_24H,
POSTFIX_PORT,
URL,
LANDING_PAGE_URL,
EMAIL_DOMAIN,
ALERT_DIRECTORY_DISABLED_ALIAS_CREATION,
ALERT_SPF,
ALERT_INVALID_TOTP_LOGIN,
TEMP_DIR,
ALIAS_AUTOMATIC_DISABLE,
RSPAMD_SIGN_DKIM,
NOREPLY,
VERP_PREFIX,
VERP_MESSAGE_LIFETIME,
VERP_EMAIL_SECRET,
)
from app.db import Session from app.db import Session
from app.dns_utils import get_mx_domains from app.dns_utils import get_mx_domains
from app.email import headers from app.email import headers
@ -77,6 +54,7 @@ from app.models import (
IgnoreBounceSender, IgnoreBounceSender,
InvalidMailboxDomain, InvalidMailboxDomain,
VerpType, VerpType,
available_sl_email,
) )
from app.utils import ( from app.utils import (
random_string, random_string,
@ -91,31 +69,31 @@ VERP_HMAC_ALGO = "sha3-224"
def render(template_name, **kwargs) -> str: def render(template_name, **kwargs) -> str:
templates_dir = os.path.join(ROOT_DIR, "templates", "emails") templates_dir = os.path.join(config.ROOT_DIR, "templates", "emails")
env = Environment(loader=FileSystemLoader(templates_dir)) env = Environment(loader=FileSystemLoader(templates_dir))
template = env.get_template(template_name) template = env.get_template(template_name)
return template.render( return template.render(
MAX_NB_EMAIL_FREE_PLAN=MAX_NB_EMAIL_FREE_PLAN, MAX_NB_EMAIL_FREE_PLAN=config.MAX_NB_EMAIL_FREE_PLAN,
URL=URL, URL=config.URL,
LANDING_PAGE_URL=LANDING_PAGE_URL, LANDING_PAGE_URL=config.LANDING_PAGE_URL,
YEAR=arrow.now().year, YEAR=arrow.now().year,
**kwargs, **kwargs,
) )
def send_welcome_email(user): def send_welcome_email(user):
to_email, unsubscribe_link, via_email = user.get_communication_email() comm_email, unsubscribe_link, via_email = user.get_communication_email()
if not to_email: if not comm_email:
return return
# whether this email is sent to an alias # whether this email is sent to an alias
alias = to_email if to_email != user.email else None alias = comm_email if comm_email != user.email else None
send_email( send_email(
to_email, comm_email,
f"Welcome to SimpleLogin", "Welcome to SimpleLogin",
render("com/welcome.txt", user=user, alias=alias), render("com/welcome.txt", user=user, alias=alias),
render("com/welcome.html", user=user, alias=alias), render("com/welcome.html", user=user, alias=alias),
unsubscribe_link, unsubscribe_link,
@ -126,7 +104,7 @@ def send_welcome_email(user):
def send_trial_end_soon_email(user): def send_trial_end_soon_email(user):
send_email( send_email(
user.email, user.email,
f"Your trial will end soon", "Your trial will end soon",
render("transactional/trial-end.txt.jinja2", user=user), render("transactional/trial-end.txt.jinja2", user=user),
render("transactional/trial-end.html", user=user), render("transactional/trial-end.html", user=user),
ignore_smtp_error=True, ignore_smtp_error=True,
@ -136,7 +114,7 @@ def send_trial_end_soon_email(user):
def send_activation_email(email, activation_link): def send_activation_email(email, activation_link):
send_email( send_email(
email, email,
f"Just one more step to join SimpleLogin", "Just one more step to join SimpleLogin",
render( render(
"transactional/activation.txt", "transactional/activation.txt",
activation_link=activation_link, activation_link=activation_link,
@ -187,7 +165,7 @@ def send_change_email(new_email, current_email, link):
def send_invalid_totp_login_email(user, totp_type): def send_invalid_totp_login_email(user, totp_type):
send_email_with_rate_control( send_email_with_rate_control(
user, user,
ALERT_INVALID_TOTP_LOGIN, config.ALERT_INVALID_TOTP_LOGIN,
user.email, user.email,
"Unsuccessful attempt to login to your SimpleLogin account", "Unsuccessful attempt to login to your SimpleLogin account",
render( render(
@ -245,7 +223,7 @@ def send_cannot_create_directory_alias_disabled(user, alias_address, directory_n
""" """
send_email_with_rate_control( send_email_with_rate_control(
user, user,
ALERT_DIRECTORY_DISABLED_ALIAS_CREATION, config.ALERT_DIRECTORY_DISABLED_ALIAS_CREATION,
user.email, user.email,
f"Alias {alias_address} cannot be created", f"Alias {alias_address} cannot be created",
render( render(
@ -297,8 +275,9 @@ def send_email(
LOG.d("send email to %s, subject '%s'", to_email, subject) LOG.d("send email to %s, subject '%s'", to_email, subject)
from_name = from_name or NOREPLY from_name = from_name or config.NOREPLY
from_addr = from_addr or NOREPLY from_addr = from_addr or config.NOREPLY
from_domain = get_email_domain_part(from_addr)
if html: if html:
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
@ -313,13 +292,14 @@ def send_email(
msg[headers.FROM] = f'"{from_name}" <{from_addr}>' msg[headers.FROM] = f'"{from_name}" <{from_addr}>'
msg[headers.TO] = to_email msg[headers.TO] = to_email
msg_id_header = make_msgid(domain=EMAIL_DOMAIN) msg_id_header = make_msgid(domain=config.EMAIL_DOMAIN)
msg[headers.MESSAGE_ID] = msg_id_header msg[headers.MESSAGE_ID] = msg_id_header
date_header = formatdate() date_header = formatdate()
msg[headers.DATE] = date_header msg[headers.DATE] = date_header
msg[headers.MIME_VERSION] = "1.0" if headers.MIME_VERSION not in msg:
msg[headers.MIME_VERSION] = "1.0"
if unsubscribe_link: if unsubscribe_link:
add_or_replace_header(msg, headers.LIST_UNSUBSCRIBE, f"<{unsubscribe_link}>") add_or_replace_header(msg, headers.LIST_UNSUBSCRIBE, f"<{unsubscribe_link}>")
@ -336,7 +316,7 @@ def send_email(
# use a different envelope sender for each transactional email (aka VERP) # use a different envelope sender for each transactional email (aka VERP)
sl_sendmail( sl_sendmail(
generate_verp_email(VerpType.transactional, transaction.id), generate_verp_email(VerpType.transactional, transaction.id, from_domain),
to_email, to_email,
msg, msg,
retries=retries, retries=retries,
@ -351,7 +331,7 @@ def send_email_with_rate_control(
subject, subject,
plaintext, plaintext,
html=None, html=None,
max_nb_alert=MAX_ALERT_24H, max_nb_alert=config.MAX_ALERT_24H,
nb_day=1, nb_day=1,
ignore_smtp_error=False, ignore_smtp_error=False,
retries=0, retries=0,
@ -448,7 +428,7 @@ def get_email_domain_part(address):
def add_dkim_signature(msg: Message, email_domain: str): def add_dkim_signature(msg: Message, email_domain: str):
if RSPAMD_SIGN_DKIM: if config.RSPAMD_SIGN_DKIM:
LOG.d("DKIM signature will be added by rspamd") LOG.d("DKIM signature will be added by rspamd")
msg[headers.SL_WANT_SIGNING] = "yes" msg[headers.SL_WANT_SIGNING] = "yes"
return return
@ -463,9 +443,9 @@ def add_dkim_signature(msg: Message, email_domain: str):
continue continue
# To investigate why some emails can't be DKIM signed. todo: remove # To investigate why some emails can't be DKIM signed. todo: remove
if TEMP_DIR: if config.TEMP_DIR:
file_name = str(uuid.uuid4()) + ".eml" file_name = str(uuid.uuid4()) + ".eml"
with open(os.path.join(TEMP_DIR, file_name), "wb") as f: with open(os.path.join(config.TEMP_DIR, file_name), "wb") as f:
f.write(msg.as_bytes()) f.write(msg.as_bytes())
LOG.w("email saved to %s", file_name) LOG.w("email saved to %s", file_name)
@ -480,12 +460,12 @@ def add_dkim_signature_with_header(
# Specify headers in "byte" form # Specify headers in "byte" form
# Generate message signature # Generate message signature
if DKIM_PRIVATE_KEY: if config.DKIM_PRIVATE_KEY:
sig = dkim.sign( sig = dkim.sign(
message_to_bytes(msg), message_to_bytes(msg),
DKIM_SELECTOR, config.DKIM_SELECTOR,
email_domain.encode(), email_domain.encode(),
DKIM_PRIVATE_KEY.encode(), config.DKIM_PRIVATE_KEY.encode(),
include_headers=dkim_headers, include_headers=dkim_headers,
) )
sig = sig.decode() sig = sig.decode()
@ -537,7 +517,7 @@ def delete_all_headers_except(msg: Message, headers: [str]):
def can_create_directory_for_address(email_address: str) -> bool: def can_create_directory_for_address(email_address: str) -> bool:
"""return True if an email ends with one of the alias domains provided by SimpleLogin""" """return True if an email ends with one of the alias domains provided by SimpleLogin"""
# not allow creating directory with premium domain # not allow creating directory with premium domain
for domain in ALIAS_DOMAINS: for domain in config.ALIAS_DOMAINS:
if email_address.endswith("@" + domain): if email_address.endswith("@" + domain):
return True return True
@ -594,7 +574,7 @@ def email_can_be_used_as_mailbox(email_address: str) -> bool:
mx_domains = get_mx_domain_list(domain) mx_domains = get_mx_domain_list(domain)
# if no MX record, email is not valid # if no MX record, email is not valid
if not mx_domains: if not config.SKIP_MX_LOOKUP_ON_CHECK and not mx_domains:
LOG.d("No MX record for domain %s", domain) LOG.d("No MX record for domain %s", domain)
return False return False
@ -788,7 +768,7 @@ def get_header_unicode(header: Union[str, Header]) -> str:
ret = "" ret = ""
for to_decoded_str, charset in decode_header(header): for to_decoded_str, charset in decode_header(header):
if charset is None: if charset is None:
if type(to_decoded_str) is bytes: if isinstance(to_decoded_str, bytes):
decoded_str = to_decoded_str.decode() decoded_str = to_decoded_str.decode()
else: else:
decoded_str = to_decoded_str decoded_str = to_decoded_str
@ -825,13 +805,13 @@ def to_bytes(msg: Message):
for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]: for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]:
try: try:
return msg.as_bytes(policy=generator_policy) return msg.as_bytes(policy=generator_policy)
except: except Exception:
LOG.w("as_bytes() fails with %s policy", policy, exc_info=True) LOG.w("as_bytes() fails with %s policy", policy, exc_info=True)
msg_string = msg.as_string() msg_string = msg.as_string()
try: try:
return msg_string.encode() return msg_string.encode()
except: except Exception:
LOG.w("as_string().encode() fails", exc_info=True) LOG.w("as_string().encode() fails", exc_info=True)
return msg_string.encode(errors="replace") return msg_string.encode(errors="replace")
@ -848,19 +828,6 @@ def should_add_dkim_signature(domain: str) -> bool:
return False return False
def is_valid_email(email_address: str) -> bool:
"""
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): class EmailEncoding(enum.Enum):
BASE64 = "base64" BASE64 = "base64"
QUOTED = "quoted-printable" QUOTED = "quoted-printable"
@ -931,22 +898,25 @@ def decode_text(text: str, encoding: EmailEncoding = EmailEncoding.NO) -> str:
return text return text
def add_header(msg: Message, text_header, html_header) -> Message: def add_header(msg: Message, text_header, html_header=None) -> Message:
if not html_header:
html_header = text_header.replace("\n", "<br>")
content_type = msg.get_content_type().lower() content_type = msg.get_content_type().lower()
if content_type == "text/plain": if content_type == "text/plain":
encoding = get_encoding(msg) encoding = get_encoding(msg)
payload = msg.get_payload() payload = msg.get_payload()
if type(payload) is str: if isinstance(payload, str):
clone_msg = copy(msg) clone_msg = copy(msg)
new_payload = f"""{text_header} new_payload = f"""{text_header}
--- ------------------------------
{decode_text(payload, encoding)}""" {decode_text(payload, encoding)}"""
clone_msg.set_payload(encode_text(new_payload, encoding)) clone_msg.set_payload(encode_text(new_payload, encoding))
return clone_msg return clone_msg
elif content_type == "text/html": elif content_type == "text/html":
encoding = get_encoding(msg) encoding = get_encoding(msg)
payload = msg.get_payload() payload = msg.get_payload()
if type(payload) is str: if isinstance(payload, str):
new_payload = f"""<table width="100%" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; new_payload = f"""<table width="100%" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; margin: 0; padding: 0;"> -premailer-cellspacing: 0; margin: 0; padding: 0;">
<tr> <tr>
@ -968,6 +938,8 @@ def add_header(msg: Message, text_header, html_header) -> Message:
for part in msg.get_payload(): for part in msg.get_payload():
if isinstance(part, Message): if isinstance(part, Message):
new_parts.append(add_header(part, text_header, html_header)) new_parts.append(add_header(part, text_header, html_header))
elif isinstance(part, str):
new_parts.append(MIMEText(part))
else: else:
new_parts.append(part) new_parts.append(part)
clone_msg = copy(msg) clone_msg = copy(msg)
@ -976,7 +948,14 @@ def add_header(msg: Message, text_header, html_header) -> Message:
elif content_type in ("multipart/mixed", "multipart/signed"): elif content_type in ("multipart/mixed", "multipart/signed"):
new_parts = [] new_parts = []
parts = list(msg.get_payload()) payload = msg.get_payload()
if isinstance(payload, str):
# The message is badly formatted inject as new
new_parts = [MIMEText(text_header, "plain"), MIMEText(payload, "plain")]
clone_msg = copy(msg)
clone_msg.set_payload(new_parts)
return clone_msg
parts = list(payload)
LOG.d("only add header for the first part for %s", content_type) LOG.d("only add header for the first part for %s", content_type)
for ix, part in enumerate(parts): for ix, part in enumerate(parts):
if ix == 0: if ix == 0:
@ -992,7 +971,11 @@ def add_header(msg: Message, text_header, html_header) -> Message:
return msg return msg
def replace(msg: Message, old, new) -> Message: def replace(msg: Union[Message, str], old, new) -> Union[Message, str]:
if isinstance(msg, str):
msg = msg.replace(old, new)
return msg
content_type = msg.get_content_type() content_type = msg.get_content_type()
if ( if (
@ -1012,7 +995,7 @@ def replace(msg: Message, old, new) -> Message:
if content_type in ("text/plain", "text/html"): if content_type in ("text/plain", "text/html"):
encoding = get_encoding(msg) encoding = get_encoding(msg)
payload = msg.get_payload() payload = msg.get_payload()
if type(payload) is str: if isinstance(payload, str):
if encoding == EmailEncoding.QUOTED: if encoding == EmailEncoding.QUOTED:
LOG.d("handle quoted-printable replace %s -> %s", old, new) LOG.d("handle quoted-printable replace %s -> %s", old, new)
# first decode the payload # first decode the payload
@ -1057,7 +1040,7 @@ def replace(msg: Message, old, new) -> Message:
return msg return msg
def generate_reply_email(contact_email: str, user: User) -> str: def generate_reply_email(contact_email: str, alias: Alias) -> str:
""" """
generate a reply_email (aka reverse-alias), make sure it isn't used by any contact generate a reply_email (aka reverse-alias), make sure it isn't used by any contact
""" """
@ -1068,6 +1051,7 @@ def generate_reply_email(contact_email: str, user: User) -> str:
include_sender_in_reverse_alias = False include_sender_in_reverse_alias = False
user = alias.user
# user has set this option explicitly # user has set this option explicitly
if user.include_sender_in_reverse_alias is not None: if user.include_sender_in_reverse_alias is not None:
include_sender_in_reverse_alias = user.include_sender_in_reverse_alias include_sender_in_reverse_alias = user.include_sender_in_reverse_alias
@ -1082,22 +1066,28 @@ def generate_reply_email(contact_email: str, user: User) -> str:
contact_email = contact_email.replace(".", "_") contact_email = contact_email.replace(".", "_")
contact_email = convert_to_alphanumeric(contact_email) contact_email = convert_to_alphanumeric(contact_email)
reply_domain = config.EMAIL_DOMAIN
alias_domain = get_email_domain_part(alias.email)
sl_domain = SLDomain.get_by(domain=alias_domain)
if sl_domain and sl_domain.use_as_reverse_alias:
reply_domain = alias_domain
# not use while to avoid infinite loop # not use while to avoid infinite loop
for _ in range(1000): for _ in range(1000):
if include_sender_in_reverse_alias and contact_email: if include_sender_in_reverse_alias and contact_email:
random_length = random.randint(5, 10) random_length = random.randint(5, 10)
reply_email = ( reply_email = (
# do not use the ra+ anymore # do not use the ra+ anymore
# f"ra+{contact_email}+{random_string(random_length)}@{EMAIL_DOMAIN}" # f"ra+{contact_email}+{random_string(random_length)}@{config.EMAIL_DOMAIN}"
f"{contact_email}_{random_string(random_length)}@{EMAIL_DOMAIN}" f"{contact_email}_{random_string(random_length)}@{reply_domain}"
) )
else: else:
random_length = random.randint(20, 50) random_length = random.randint(20, 50)
# do not use the ra+ anymore # do not use the ra+ anymore
# reply_email = f"ra+{random_string(random_length)}@{EMAIL_DOMAIN}" # reply_email = f"ra+{random_string(random_length)}@{config.EMAIL_DOMAIN}"
reply_email = f"{random_string(random_length)}@{EMAIL_DOMAIN}" reply_email = f"{random_string(random_length)}@{reply_domain}"
if not Contact.get_by(reply_email=reply_email): if available_sl_email(reply_email):
return reply_email return reply_email
raise Exception("Cannot generate reply email") raise Exception("Cannot generate reply email")
@ -1108,31 +1098,11 @@ def is_reverse_alias(address: str) -> bool:
if Contact.get_by(reply_email=address): if Contact.get_by(reply_email=address):
return True return True
return address.endswith(f"@{EMAIL_DOMAIN}") and ( return address.endswith(f"@{config.EMAIL_DOMAIN}") and (
address.startswith("reply+") or address.startswith("ra+") address.startswith("reply+") or address.startswith("ra+")
) )
# allow also + and @ that are present in a reply address
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.+@"
def normalize_reply_email(reply_email: str) -> str:
"""Handle the case where reply email contains *strange* char that was wrongly generated in the past"""
if not reply_email.isascii():
reply_email = convert_to_id(reply_email)
ret = []
# drop all control characters like shift, separator, etc
for c in reply_email:
if c not in _ALLOWED_CHARS:
ret.append("_")
else:
ret.append(c)
return "".join(ret)
def should_disable(alias: Alias) -> (bool, str): def should_disable(alias: Alias) -> (bool, str):
""" """
Return whether an alias should be disabled and if yes, the reason why Return whether an alias should be disabled and if yes, the reason why
@ -1142,7 +1112,7 @@ def should_disable(alias: Alias) -> (bool, str):
LOG.w("%s cannot be disabled", alias) LOG.w("%s cannot be disabled", alias)
return False, "" return False, ""
if not ALIAS_AUTOMATIC_DISABLE: if not config.ALIAS_AUTOMATIC_DISABLE:
return False, "" return False, ""
yesterday = arrow.now().shift(days=-1) yesterday = arrow.now().shift(days=-1)
@ -1257,14 +1227,14 @@ def spf_pass(
subject = get_header_unicode(msg[headers.SUBJECT]) subject = get_header_unicode(msg[headers.SUBJECT])
send_email_with_rate_control( send_email_with_rate_control(
user, user,
ALERT_SPF, config.ALERT_SPF,
mailbox.email, mailbox.email,
f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address", f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address",
render( render(
"transactional/spf-fail.txt", "transactional/spf-fail.txt",
alias=alias.email, alias=alias.email,
ip=ip, ip=ip,
mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf", mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf",
to_email=contact_email, to_email=contact_email,
subject=subject, subject=subject,
time=arrow.now(), time=arrow.now(),
@ -1272,7 +1242,7 @@ def spf_pass(
render( render(
"transactional/spf-fail.html", "transactional/spf-fail.html",
ip=ip, ip=ip,
mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf", mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf",
to_email=contact_email, to_email=contact_email,
subject=subject, subject=subject,
time=arrow.now(), time=arrow.now(),
@ -1295,11 +1265,11 @@ def spf_pass(
@cached(cache=TTLCache(maxsize=2, ttl=20)) @cached(cache=TTLCache(maxsize=2, ttl=20))
def get_smtp_server(): def get_smtp_server():
LOG.d("get a smtp server") LOG.d("get a smtp server")
if POSTFIX_SUBMISSION_TLS: if config.POSTFIX_SUBMISSION_TLS:
smtp = SMTP(POSTFIX_SERVER, 587) smtp = SMTP(config.POSTFIX_SERVER, 587)
smtp.starttls() smtp.starttls()
else: else:
smtp = SMTP(POSTFIX_SERVER, POSTFIX_PORT) smtp = SMTP(config.POSTFIX_SERVER, config.POSTFIX_PORT)
return smtp return smtp
@ -1371,12 +1341,12 @@ def save_email_for_debugging(msg: Message, file_name_prefix=None) -> str:
"""Save email for debugging to temporary location """Save email for debugging to temporary location
Return the file path Return the file path
""" """
if TEMP_DIR: if config.TEMP_DIR:
file_name = str(uuid.uuid4()) + ".eml" file_name = str(uuid.uuid4()) + ".eml"
if file_name_prefix: if file_name_prefix:
file_name = "{}-{}".format(file_name_prefix, file_name) file_name = "{}-{}".format(file_name_prefix, file_name)
with open(os.path.join(TEMP_DIR, file_name), "wb") as f: with open(os.path.join(config.TEMP_DIR, file_name), "wb") as f:
f.write(msg.as_bytes()) f.write(msg.as_bytes())
LOG.d("email saved to %s", file_name) LOG.d("email saved to %s", file_name)
@ -1389,12 +1359,12 @@ def save_envelope_for_debugging(envelope: Envelope, file_name_prefix=None) -> st
"""Save envelope for debugging to temporary location """Save envelope for debugging to temporary location
Return the file path Return the file path
""" """
if TEMP_DIR: if config.TEMP_DIR:
file_name = str(uuid.uuid4()) + ".eml" file_name = str(uuid.uuid4()) + ".eml"
if file_name_prefix: if file_name_prefix:
file_name = "{}-{}".format(file_name_prefix, file_name) file_name = "{}-{}".format(file_name_prefix, file_name)
with open(os.path.join(TEMP_DIR, file_name), "wb") as f: with open(os.path.join(config.TEMP_DIR, file_name), "wb") as f:
f.write(envelope.original_content) f.write(envelope.original_content)
LOG.d("envelope saved to %s", file_name) LOG.d("envelope saved to %s", file_name)
@ -1420,12 +1390,15 @@ def generate_verp_email(
# Signing without itsdangereous because it uses base64 that includes +/= symbols and lower and upper case letters. # Signing without itsdangereous because it uses base64 that includes +/= symbols and lower and upper case letters.
# We need to encode in base32 # We need to encode in base32
payload_hmac = hmac.new( payload_hmac = hmac.new(
VERP_EMAIL_SECRET.encode("utf-8"), json_payload, VERP_HMAC_ALGO config.VERP_EMAIL_SECRET.encode("utf-8"), json_payload, VERP_HMAC_ALGO
).digest()[:8] ).digest()[:8]
encoded_payload = base64.b32encode(json_payload).rstrip(b"=").decode("utf-8") encoded_payload = base64.b32encode(json_payload).rstrip(b"=").decode("utf-8")
encoded_signature = base64.b32encode(payload_hmac).rstrip(b"=").decode("utf-8") encoded_signature = base64.b32encode(payload_hmac).rstrip(b"=").decode("utf-8")
return "{}.{}.{}@{}".format( return "{}.{}.{}@{}".format(
VERP_PREFIX, encoded_payload, encoded_signature, sender_domain or EMAIL_DOMAIN config.VERP_PREFIX,
encoded_payload,
encoded_signature,
sender_domain or config.EMAIL_DOMAIN,
).lower() ).lower()
@ -1438,7 +1411,7 @@ def get_verp_info_from_email(email: str) -> Optional[Tuple[VerpType, int]]:
return None return None
username = email[:idx] username = email[:idx]
fields = username.split(".") fields = username.split(".")
if len(fields) != 3 or fields[0] != VERP_PREFIX: if len(fields) != 3 or fields[0] != config.VERP_PREFIX:
return None return None
try: try:
padding = (8 - (len(fields[1]) % 8)) % 8 padding = (8 - (len(fields[1]) % 8)) % 8
@ -1450,7 +1423,7 @@ def get_verp_info_from_email(email: str) -> Optional[Tuple[VerpType, int]]:
except binascii.Error: except binascii.Error:
return None return None
expected_signature = hmac.new( expected_signature = hmac.new(
VERP_EMAIL_SECRET.encode("utf-8"), payload, VERP_HMAC_ALGO config.VERP_EMAIL_SECRET.encode("utf-8"), payload, VERP_HMAC_ALGO
).digest()[:8] ).digest()[:8]
if expected_signature != signature: if expected_signature != signature:
return None return None
@ -1458,6 +1431,13 @@ def get_verp_info_from_email(email: str) -> Optional[Tuple[VerpType, int]]:
# verp type, object_id, time # verp type, object_id, time
if len(data) != 3: if len(data) != 3:
return None return None
if data[2] > (time.time() + VERP_MESSAGE_LIFETIME - VERP_TIME_START) / 60: if data[2] > (time.time() + config.VERP_MESSAGE_LIFETIME - VERP_TIME_START) / 60:
return None return None
return VerpType(data[0]), data[1] return VerpType(data[0]), data[1]
def sl_formataddr(name_address_tuple: Tuple[str, str]):
"""Same as formataddr but use utf-8 encoding by default and always return str (and never Header)"""
name, addr = name_address_tuple
# formataddr can return Header, make sure to convert to str
return str(formataddr((name, Header(addr, "utf-8"))))

38
app/email_validation.py Normal file
View file

@ -0,0 +1,38 @@
from email_validator import (
validate_email,
EmailNotValidError,
)
from app.utils import convert_to_id
# allow also + and @ that are present in a reply address
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.+@"
def is_valid_email(email_address: str) -> bool:
"""
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
def normalize_reply_email(reply_email: str) -> str:
"""Handle the case where reply email contains *strange* char that was wrongly generated in the past"""
if not reply_email.isascii():
reply_email = convert_to_id(reply_email)
ret = []
# drop all control characters like shift, separator, etc
for c in reply_email:
if c not in _ALLOWED_CHARS:
ret.append("_")
else:
ret.append(c)
return "".join(ret)

View file

@ -71,7 +71,7 @@ class ErrContactErrorUpgradeNeeded(SLException):
"""raised when user cannot create a contact because the plan doesn't allow it""" """raised when user cannot create a contact because the plan doesn't allow it"""
def error_for_user(self) -> str: def error_for_user(self) -> str:
return f"Please upgrade to premium to create reverse-alias" return "Please upgrade to premium to create reverse-alias"
class ErrAddressInvalid(SLException): class ErrAddressInvalid(SLException):
@ -84,6 +84,14 @@ class ErrAddressInvalid(SLException):
return f"{self.address} is not a valid email address" return f"{self.address} is not a valid email address"
class InvalidContactEmailError(SLException):
def __init__(self, website_email: str): # noqa: F821
self.website_email = website_email
def error_for_user(self) -> str:
return f"Cannot create contact with invalid email {self.website_email}"
class ErrContactAlreadyExists(SLException): class ErrContactAlreadyExists(SLException):
"""raised when a contact already exists""" """raised when a contact already exists"""
@ -108,3 +116,15 @@ class AccountAlreadyLinkedToAnotherPartnerException(LinkException):
class AccountAlreadyLinkedToAnotherUserException(LinkException): class AccountAlreadyLinkedToAnotherUserException(LinkException):
def __init__(self): def __init__(self):
super().__init__("This account is linked to another user") super().__init__("This account is linked to another user")
class AccountIsUsingAliasAsEmail(LinkException):
def __init__(self):
super().__init__("Your account has an alias as it's email address")
class ProtonAccountNotVerified(LinkException):
def __init__(self):
super().__init__(
"The Proton account you are trying to use has not been verified"
)

View file

@ -9,6 +9,7 @@ class LoginEvent:
failed = 1 failed = 1
disabled_login = 2 disabled_login = 2
not_activated = 3 not_activated = 3
scheduled_to_be_deleted = 4
class Source(EnumE): class Source(EnumE):
web = 0 web = 0

View file

@ -1,12 +1,31 @@
from flask_limiter import Limiter from flask_limiter import Limiter
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
from flask_login import LoginManager from flask_login import current_user, LoginManager
from app import config
login_manager = LoginManager() login_manager = LoginManager()
login_manager.session_protection = "strong" login_manager.session_protection = "strong"
# We want to rate limit based on:
# - If the user is not logged in: request source IP
# - If the user is logged in: user_id
def __key_func():
if current_user.is_authenticated:
return f"userid:{current_user.id}"
else:
ip_addr = get_remote_address()
return f"ip:{ip_addr}"
# Setup rate limit facility # Setup rate limit facility
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(key_func=__key_func)
@limiter.request_filter
def disable_rate_limit():
return config.DISABLE_RATE_LIMIT
# @limiter.request_filter # @limiter.request_filter

View file

@ -5,7 +5,7 @@ from typing import Optional, Tuple
from aiosmtpd.handlers import Message from aiosmtpd.handlers import Message
from aiosmtpd.smtp import Envelope from aiosmtpd.smtp import Envelope
from app import s3 from app import s3, config
from app.config import ( from app.config import (
DMARC_CHECK_ENABLED, DMARC_CHECK_ENABLED,
ALERT_QUARANTINE_DMARC, ALERT_QUARANTINE_DMARC,
@ -34,6 +34,37 @@ def apply_dmarc_policy_for_forward_phase(
from_header = get_header_unicode(msg[headers.FROM]) from_header = get_header_unicode(msg[headers.FROM])
warning_plain_text = """This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content.
More info on https://simplelogin.io/docs/getting-started/anti-phishing/
"""
warning_html = """
<p style="color:red">
This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content.
More info on <a href="https://simplelogin.io/docs/getting-started/anti-phishing/">anti-phishing measure</a>
</p>
"""
# do not quarantine an email if fails DMARC but has a small rspamd score
if (
config.MIN_RSPAMD_SCORE_FOR_FAILED_DMARC is not None
and spam_result.rspamd_score < config.MIN_RSPAMD_SCORE_FOR_FAILED_DMARC
and spam_result.dmarc
in (
DmarcCheckResult.quarantine,
DmarcCheckResult.reject,
)
):
LOG.w(
f"email fails DMARC but has a small rspamd score, from contact {contact.email} to alias {alias.email}."
f"mail_from:{envelope.mail_from}, from_header: {from_header}"
)
changed_msg = add_header(
msg,
warning_plain_text,
warning_html,
)
return changed_msg, None
if spam_result.dmarc == DmarcCheckResult.soft_fail: if spam_result.dmarc == DmarcCheckResult.soft_fail:
LOG.w( LOG.w(
f"dmarc forward: soft_fail from contact {contact.email} to alias {alias.email}." f"dmarc forward: soft_fail from contact {contact.email} to alias {alias.email}."
@ -41,15 +72,8 @@ def apply_dmarc_policy_for_forward_phase(
) )
changed_msg = add_header( changed_msg = add_header(
msg, msg,
f"""This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content. warning_plain_text,
More info on https://simplelogin.io/docs/getting-started/anti-phishing/ warning_html,
""",
f"""
<p style="color:red">
This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content.
More info on <a href="https://simplelogin.io/docs/getting-started/anti-phishing/">anti-phishing measure</a>
</p>
""",
) )
return changed_msg, None return changed_msg, None
@ -133,6 +157,7 @@ def apply_dmarc_policy_for_reply_phase(
DmarcCheckResult.soft_fail, DmarcCheckResult.soft_fail,
): ):
return None return None
LOG.w( LOG.w(
f"dmarc reply: Put email from {alias_from.email} to {contact_recipient} into quarantine. {spam_result.event_data()}, " f"dmarc reply: Put email from {alias_from.email} to {contact_recipient} into quarantine. {spam_result.event_data()}, "
f"mail_from:{envelope.mail_from}, from_header: {msg[headers.FROM]}" f"mail_from:{envelope.mail_from}, from_header: {msg[headers.FROM]}"

View file

@ -3,7 +3,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from io import BytesIO from io import BytesIO
from mailbox import Message from mailbox import Message
from typing import Optional from typing import Optional, Union
from app import s3 from app import s3
from app.config import ( from app.config import (
@ -189,7 +189,7 @@ def handle_yahoo_complaint(message: Message) -> bool:
return handle_complaint(message, ProviderComplaintYahoo()) return handle_complaint(message, ProviderComplaintYahoo())
def find_alias_with_address(address: str) -> Optional[Alias]: def find_alias_with_address(address: str) -> Optional[Union[Alias, DomainDeletedAlias]]:
return Alias.get_by(email=address) or DomainDeletedAlias.get_by(email=address) return Alias.get_by(email=address) or DomainDeletedAlias.get_by(email=address)
@ -221,7 +221,7 @@ def handle_complaint(message: Message, origin: ProviderComplaintOrigin) -> bool:
return True return True
if is_deleted_alias(msg_info.sender_address): if is_deleted_alias(msg_info.sender_address):
LOG.i(f"Complaint is for deleted alias. Do nothing") LOG.i("Complaint is for deleted alias. Do nothing")
return True return True
contact = Contact.get_by(reply_email=msg_info.sender_address) contact = Contact.get_by(reply_email=msg_info.sender_address)
@ -231,7 +231,7 @@ def handle_complaint(message: Message, origin: ProviderComplaintOrigin) -> bool:
alias = find_alias_with_address(msg_info.rcpt_address) alias = find_alias_with_address(msg_info.rcpt_address)
if is_deleted_alias(msg_info.rcpt_address): if is_deleted_alias(msg_info.rcpt_address):
LOG.i(f"Complaint is for deleted alias. Do nothing") LOG.i("Complaint is for deleted alias. Do nothing")
return True return True
if not alias: if not alias:
@ -245,16 +245,22 @@ def handle_complaint(message: Message, origin: ProviderComplaintOrigin) -> bool:
def report_complaint_to_user_in_reply_phase( def report_complaint_to_user_in_reply_phase(
alias: Alias, alias: Union[Alias, DomainDeletedAlias],
to_address: str, to_address: str,
origin: ProviderComplaintOrigin, origin: ProviderComplaintOrigin,
msg_info: OriginalMessageInformation, msg_info: OriginalMessageInformation,
): ):
capitalized_name = origin.name().capitalize() capitalized_name = origin.name().capitalize()
mailbox_email = msg_info.mailbox_address
if not mailbox_email:
if type(alias) is Alias:
mailbox_email = alias.mailbox.email
else:
mailbox_email = alias.domain.mailboxes[0].email
send_email_with_rate_control( send_email_with_rate_control(
alias.user, alias.user,
f"{ALERT_COMPLAINT_REPLY_PHASE}_{origin.name()}", f"{ALERT_COMPLAINT_REPLY_PHASE}_{origin.name()}",
msg_info.mailbox_address or alias.mailbox.email, mailbox_email,
f"Abuse report from {capitalized_name}", f"Abuse report from {capitalized_name}",
render( render(
"transactional/provider-complaint-reply-phase.txt.jinja2", "transactional/provider-complaint-reply-phase.txt.jinja2",
@ -293,11 +299,19 @@ def report_complaint_to_user_in_transactional_phase(
def report_complaint_to_user_in_forward_phase( def report_complaint_to_user_in_forward_phase(
alias: Alias, origin: ProviderComplaintOrigin, msg_info: OriginalMessageInformation alias: Union[Alias, DomainDeletedAlias],
origin: ProviderComplaintOrigin,
msg_info: OriginalMessageInformation,
): ):
capitalized_name = origin.name().capitalize() capitalized_name = origin.name().capitalize()
user = alias.user user = alias.user
mailbox_email = msg_info.mailbox_address or alias.mailbox.email
mailbox_email = msg_info.mailbox_address
if not mailbox_email:
if type(alias) is Alias:
mailbox_email = alias.mailbox.email
else:
mailbox_email = alias.domain.mailboxes[0].email
send_email_with_rate_control( send_email_with_rate_control(
user, user,
f"{ALERT_COMPLAINT_FORWARD_PHASE}_{origin.name()}", f"{ALERT_COMPLAINT_FORWARD_PHASE}_{origin.name()}",

View file

@ -4,6 +4,7 @@ from typing import Dict, Optional
import newrelic.agent import newrelic.agent
from app.email import headers from app.email import headers
from app.log import LOG
from app.models import EnumE, Phase from app.models import EnumE, Phase
from email.message import Message from email.message import Message
@ -55,6 +56,7 @@ class SpamdResult:
self.phase: Phase = phase self.phase: Phase = phase
self.dmarc: DmarcCheckResult = DmarcCheckResult.not_available self.dmarc: DmarcCheckResult = DmarcCheckResult.not_available
self.spf: SPFCheckResult = SPFCheckResult.not_available self.spf: SPFCheckResult = SPFCheckResult.not_available
self.rspamd_score = -1
def set_dmarc_result(self, dmarc_result: DmarcCheckResult): def set_dmarc_result(self, dmarc_result: DmarcCheckResult):
self.dmarc = dmarc_result self.dmarc = dmarc_result
@ -85,6 +87,7 @@ class SpamdResult:
spam_entries = [ spam_entries = [
entry.strip() for entry in str(spam_result_header[-1]).split("\n") entry.strip() for entry in str(spam_result_header[-1]).split("\n")
] ]
for entry_pos in range(len(spam_entries)): for entry_pos in range(len(spam_entries)):
sep = spam_entries[entry_pos].find("(") sep = spam_entries[entry_pos].find("(")
if sep > -1: if sep > -1:
@ -101,6 +104,17 @@ class SpamdResult:
spamd_result.set_spf_result(spf_result) spamd_result.set_spf_result(spf_result)
break break
# parse the rspamd score
try:
score_line = spam_entries[0] # e.g. "default: False [2.30 / 13.00];"
spamd_result.rspamd_score = float(
score_line[(score_line.find("[") + 1) : score_line.find("]")]
.split("/")[0]
.strip()
)
except (IndexError, ValueError):
LOG.e("cannot parse rspamd score")
cls._store_in_message(spamd_result, msg) cls._store_in_message(spamd_result, msg)
return spamd_result return spamd_result

View file

@ -42,9 +42,11 @@ class UnsubscribeLink:
class UnsubscribeEncoder: class UnsubscribeEncoder:
@staticmethod @staticmethod
def encode( def encode(
action: UnsubscribeAction, data: Union[int, UnsubscribeOriginalData] action: UnsubscribeAction,
data: Union[int, UnsubscribeOriginalData],
force_web: bool = False,
) -> UnsubscribeLink: ) -> UnsubscribeLink:
if config.UNSUBSCRIBER: if config.UNSUBSCRIBER and not force_web:
return UnsubscribeLink(UnsubscribeEncoder.encode_mailto(action, data), True) return UnsubscribeLink(UnsubscribeEncoder.encode_mailto(action, data), True)
return UnsubscribeLink(UnsubscribeEncoder.encode_url(action, data), False) return UnsubscribeLink(UnsubscribeEncoder.encode_url(action, data), False)
@ -52,9 +54,8 @@ class UnsubscribeEncoder:
def encode_subject( def encode_subject(
cls, action: UnsubscribeAction, data: Union[int, UnsubscribeOriginalData] cls, action: UnsubscribeAction, data: Union[int, UnsubscribeOriginalData]
) -> str: ) -> str:
if ( if action != UnsubscribeAction.OriginalUnsubscribeMailto and not isinstance(
action != UnsubscribeAction.OriginalUnsubscribeMailto data, int
and type(data) is not int
): ):
raise ValueError(f"Data has to be an int for an action of type {action}") raise ValueError(f"Data has to be an int for an action of type {action}")
if action == UnsubscribeAction.OriginalUnsubscribeMailto: if action == UnsubscribeAction.OriginalUnsubscribeMailto:
@ -72,8 +73,8 @@ class UnsubscribeEncoder:
) )
signed_data = cls._get_signer().sign(serialized_data).decode("utf-8") signed_data = cls._get_signer().sign(serialized_data).decode("utf-8")
encoded_request = f"{UNSUB_PREFIX}.{signed_data}" encoded_request = f"{UNSUB_PREFIX}.{signed_data}"
if len(encoded_request) > 256: if len(encoded_request) > 512:
LOG.e("Encoded request is longer than 256 chars") LOG.w("Encoded request is longer than 512 chars")
return encoded_request return encoded_request
@staticmethod @staticmethod

View file

@ -1,4 +1,5 @@
import urllib import urllib
from email.header import Header
from email.message import Message from email.message import Message
from app.email import headers from app.email import headers
@ -9,6 +10,7 @@ from app.handler.unsubscribe_encoder import (
UnsubscribeData, UnsubscribeData,
UnsubscribeOriginalData, UnsubscribeOriginalData,
) )
from app.log import LOG
from app.models import Alias, Contact, UnsubscribeBehaviourEnum from app.models import Alias, Contact, UnsubscribeBehaviourEnum
@ -30,7 +32,10 @@ class UnsubscribeGenerator:
""" """
unsubscribe_data = message[headers.LIST_UNSUBSCRIBE] unsubscribe_data = message[headers.LIST_UNSUBSCRIBE]
if not unsubscribe_data: if not unsubscribe_data:
LOG.info("Email has no unsubscribe header")
return message return message
if isinstance(unsubscribe_data, Header):
unsubscribe_data = str(unsubscribe_data.encode())
raw_methods = [method.strip() for method in unsubscribe_data.split(",")] raw_methods = [method.strip() for method in unsubscribe_data.split(",")]
mailto_unsubs = None mailto_unsubs = None
other_unsubs = [] other_unsubs = []
@ -44,7 +49,9 @@ class UnsubscribeGenerator:
if url_data.scheme == "mailto": if url_data.scheme == "mailto":
query_data = urllib.parse.parse_qs(url_data.query) query_data = urllib.parse.parse_qs(url_data.query)
mailto_unsubs = (url_data.path, query_data.get("subject", [""])[0]) mailto_unsubs = (url_data.path, query_data.get("subject", [""])[0])
LOG.debug(f"Unsub is mailto to {mailto_unsubs}")
else: else:
LOG.debug(f"Unsub has {url_data.scheme} scheme")
other_unsubs.append(method) other_unsubs.append(method)
# If there are non mailto unsubscribe methods, use those in the header # If there are non mailto unsubscribe methods, use those in the header
if other_unsubs: if other_unsubs:
@ -56,18 +63,19 @@ class UnsubscribeGenerator:
add_or_replace_header( add_or_replace_header(
message, headers.LIST_UNSUBSCRIBE_POST, "List-Unsubscribe=One-Click" message, headers.LIST_UNSUBSCRIBE_POST, "List-Unsubscribe=One-Click"
) )
LOG.debug(f"Adding click unsub methods to header {other_unsubs}")
return message return message
if not mailto_unsubs: elif not mailto_unsubs:
message = delete_header(message, headers.LIST_UNSUBSCRIBE) LOG.debug("No unsubs. Deleting all unsub headers")
message = delete_header(message, headers.LIST_UNSUBSCRIBE_POST) delete_header(message, headers.LIST_UNSUBSCRIBE)
delete_header(message, headers.LIST_UNSUBSCRIBE_POST)
return message return message
return self._add_unsubscribe_header( unsub_data = UnsubscribeData(
message, UnsubscribeAction.OriginalUnsubscribeMailto,
UnsubscribeData( UnsubscribeOriginalData(alias.id, mailto_unsubs[0], mailto_unsubs[1]),
UnsubscribeAction.OriginalUnsubscribeMailto,
UnsubscribeOriginalData(alias.id, mailto_unsubs[0], mailto_unsubs[1]),
),
) )
LOG.debug(f"Adding unsub data {unsub_data}")
return self._add_unsubscribe_header(message, unsub_data)
def _add_unsubscribe_header( def _add_unsubscribe_header(
self, message: Message, unsub: UnsubscribeData self, message: Message, unsub: UnsubscribeData

View file

@ -49,7 +49,7 @@ class UnsubscribeHandler:
return status.E507 return status.E507
mailbox = Mailbox.get_by(email=envelope.mail_from) mailbox = Mailbox.get_by(email=envelope.mail_from)
if not mailbox: if not mailbox:
LOG.w("Unknown mailbox %s", msg[headers.SUBJECT]) LOG.w("Unknown mailbox %s", envelope.mail_from)
return status.E507 return status.E507
if unsub_data.action == UnsubscribeAction.DisableAlias: if unsub_data.action == UnsubscribeAction.DisableAlias:

View file

@ -15,7 +15,7 @@ from app.models import (
Mailbox, Mailbox,
User, User,
) )
from app.utils import sanitize_email from app.utils import sanitize_email, canonicalize_email
from .log import LOG from .log import LOG
@ -30,7 +30,7 @@ def handle_batch_import(batch_import: BatchImport):
LOG.d("Download file %s from %s", batch_import.file, file_url) LOG.d("Download file %s from %s", batch_import.file, file_url)
r = requests.get(file_url) r = requests.get(file_url)
lines = [line.decode() for line in r.iter_lines()] lines = [line.decode("utf-8") for line in r.iter_lines()]
import_from_csv(batch_import, user, lines) import_from_csv(batch_import, user, lines)
@ -69,7 +69,7 @@ def import_from_csv(batch_import: BatchImport, user: User, lines):
if "mailboxes" in row: if "mailboxes" in row:
for mailbox_email in row["mailboxes"].split(): for mailbox_email in row["mailboxes"].split():
mailbox_email = sanitize_email(mailbox_email) mailbox_email = canonicalize_email(mailbox_email)
mailbox = Mailbox.get_by(email=mailbox_email) mailbox = Mailbox.get_by(email=mailbox_email)
if not mailbox or not mailbox.verified or mailbox.user_id != user.id: if not mailbox or not mailbox.verified or mailbox.user_id != user.id:

View file

@ -1,2 +1,4 @@
from .integrations import set_enable_proton_cookie from .integrations import set_enable_proton_cookie
from .exit_sudo import exit_sudo_mode from .exit_sudo import exit_sudo_mode
__all__ = ["set_enable_proton_cookie", "exit_sudo_mode"]

View file

@ -14,7 +14,12 @@ import sqlalchemy
from app import config from app import config
from app.db import Session from app.db import Session
from app.email import headers from app.email import headers
from app.email_utils import generate_verp_email, render, add_dkim_signature from app.email_utils import (
generate_verp_email,
render,
add_dkim_signature,
get_email_domain_part,
)
from app.mail_sender import sl_sendmail from app.mail_sender import sl_sendmail
from app.models import ( from app.models import (
Alias, Alias,
@ -34,9 +39,8 @@ from app.models import (
class ExportUserDataJob: class ExportUserDataJob:
REMOVE_FIELDS = { REMOVE_FIELDS = {
"User": ("otp_secret",), "User": ("otp_secret", "password"),
"Alias": ("ts_vector", "transfer_token", "hibp_last_check"), "Alias": ("ts_vector", "transfer_token", "hibp_last_check"),
"CustomDomain": ("ownership_txt_token",), "CustomDomain": ("ownership_txt_token",),
} }
@ -147,7 +151,11 @@ class ExportUserDataJob:
transaction = TransactionalEmail.create(email=to_email, commit=True) transaction = TransactionalEmail.create(email=to_email, commit=True)
sl_sendmail( sl_sendmail(
generate_verp_email(VerpType.transactional, transaction.id), generate_verp_email(
VerpType.transactional,
transaction.id,
get_email_domain_part(config.NOREPLY),
),
to_email, to_email,
msg, msg,
ignore_smtp_error=False, ignore_smtp_error=False,

View file

@ -6,8 +6,8 @@ import os
import time import time
import uuid import uuid
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from email.message import Message
from functools import wraps from functools import wraps
from mailbox import Message
from smtplib import SMTP, SMTPException from smtplib import SMTP, SMTPException
from typing import Optional, Dict, List, Callable from typing import Optional, Dict, List, Callable
@ -17,11 +17,13 @@ from attr import dataclass
from app import config from app import config
from app.email import headers from app.email import headers
from app.log import LOG from app.log import LOG
from app.message_utils import message_to_bytes from app.message_utils import message_to_bytes, message_format_base64_parts
@dataclass @dataclass
class SendRequest: class SendRequest:
SAVE_EXTENSION = "sendrequest"
envelope_from: str envelope_from: str
envelope_to: str envelope_to: str
msg: Message msg: Message
@ -29,6 +31,7 @@ class SendRequest:
rcpt_options: Dict = {} rcpt_options: Dict = {}
is_forward: bool = False is_forward: bool = False
ignore_smtp_errors: bool = False ignore_smtp_errors: bool = False
retries: int = 0
def to_bytes(self) -> bytes: def to_bytes(self) -> bytes:
if not config.SAVE_UNSENT_DIR: if not config.SAVE_UNSENT_DIR:
@ -42,6 +45,7 @@ class SendRequest:
"mail_options": self.mail_options, "mail_options": self.mail_options,
"rcpt_options": self.rcpt_options, "rcpt_options": self.rcpt_options,
"is_forward": self.is_forward, "is_forward": self.is_forward,
"retries": self.retries,
} }
return json.dumps(data).encode("utf-8") return json.dumps(data).encode("utf-8")
@ -62,8 +66,33 @@ class SendRequest:
mail_options=decoded_data["mail_options"], mail_options=decoded_data["mail_options"],
rcpt_options=decoded_data["rcpt_options"], rcpt_options=decoded_data["rcpt_options"],
is_forward=decoded_data["is_forward"], is_forward=decoded_data["is_forward"],
retries=decoded_data.get("retries", 1),
) )
def save_request_to_unsent_dir(self, prefix: str = "DeliveryFail"):
file_name = (
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
)
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
self.save_request_to_file(file_path)
@staticmethod
def save_request_to_failed_dir(self, prefix: str = "DeliveryRetryFail"):
file_name = (
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
)
dir_name = os.path.join(config.SAVE_UNSENT_DIR, "failed")
if not os.path.isdir(dir_name):
os.makedirs(dir_name)
file_path = os.path.join(dir_name, file_name)
self.save_request_to_file(file_path)
def save_request_to_file(self, file_path: str):
file_contents = self.to_bytes()
with open(file_path, "wb") as fd:
fd.write(file_contents)
LOG.i(f"Saved unsent message {file_path}")
class MailSender: class MailSender:
def __init__(self): def __init__(self):
@ -95,7 +124,7 @@ class MailSender:
def enable_background_pool(self, max_workers=10): def enable_background_pool(self, max_workers=10):
self._pool = ThreadPoolExecutor(max_workers=max_workers) self._pool = ThreadPoolExecutor(max_workers=max_workers)
def send(self, send_request: SendRequest, retries: int = 2): def send(self, send_request: SendRequest, retries: int = 2) -> bool:
"""replace smtp.sendmail""" """replace smtp.sendmail"""
if self._store_emails: if self._store_emails:
self._emails_sent.append(send_request) self._emails_sent.append(send_request)
@ -106,21 +135,21 @@ class MailSender:
send_request.msg[headers.FROM], send_request.msg[headers.FROM],
send_request.msg[headers.TO], send_request.msg[headers.TO],
) )
return return True
if not self._pool: if not self._pool:
self._send_to_smtp(send_request, retries) return self._send_to_smtp(send_request, retries)
else: else:
self._pool.submit(self._send_to_smtp, (send_request, retries)) self._pool.submit(self._send_to_smtp, (send_request, retries))
return True
def _send_to_smtp(self, send_request: SendRequest, retries: int): def _send_to_smtp(self, send_request: SendRequest, retries: int) -> bool:
try: try:
start = time.time() start = time.time()
if config.POSTFIX_SUBMISSION_TLS: with SMTP(
smtp_port = 587 config.POSTFIX_SERVER,
else: config.POSTFIX_PORT,
smtp_port = config.POSTFIX_PORT timeout=config.POSTFIX_TIMEOUT,
) as smtp:
with SMTP(config.POSTFIX_SERVER, smtp_port) as smtp:
if config.POSTFIX_SUBMISSION_TLS: if config.POSTFIX_SUBMISSION_TLS:
smtp.starttls() smtp.starttls()
@ -151,35 +180,94 @@ class MailSender:
newrelic.agent.record_custom_metric( newrelic.agent.record_custom_metric(
"Custom/smtp_sending_time", time.time() - start "Custom/smtp_sending_time", time.time() - start
) )
return True
except ( except (
SMTPException, SMTPException,
ConnectionRefusedError, ConnectionRefusedError,
TimeoutError, TimeoutError,
) as e: ) as e:
if retries > 0: if retries > 0:
time.sleep(0.3 * send_request.retries) time.sleep(0.3 * retries)
self._send_to_smtp(send_request, retries - 1) return self._send_to_smtp(send_request, retries - 1)
else: else:
if send_request.ignore_smtp_errors: if send_request.ignore_smtp_errors:
LOG.e(f"Ignore smtp error {e}") LOG.e(f"Ignore smtp error {e}")
return return False
LOG.e( LOG.e(
f"Could not send message to smtp server {config.POSTFIX_SERVER}:{smtp_port}" f"Could not send message to smtp server {config.POSTFIX_SERVER}:{config.POSTFIX_PORT}"
) )
self._save_request_to_unsent_dir(send_request) if config.SAVE_UNSENT_DIR:
send_request.save_request_to_unsent_dir()
def _save_request_to_unsent_dir(self, send_request: SendRequest): return False
file_name = f"DeliveryFail-{int(time.time())}-{uuid.uuid4()}.eml"
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
file_contents = send_request.to_bytes()
with open(file_path, "wb") as fd:
fd.write(file_contents)
LOG.i(f"Saved unsent message {file_path}")
mail_sender = MailSender() mail_sender = MailSender()
def save_request_to_failed_dir(exception_name: str, send_request: SendRequest):
file_name = f"{exception_name}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
failed_file_dir = os.path.join(config.SAVE_UNSENT_DIR, "failed")
try:
os.makedirs(failed_file_dir)
except FileExistsError:
pass
file_path = os.path.join(failed_file_dir, file_name)
file_contents = send_request.to_bytes()
with open(file_path, "wb") as fd:
fd.write(file_contents)
return file_path
def load_unsent_mails_from_fs_and_resend():
if not config.SAVE_UNSENT_DIR:
return
for filename in os.listdir(config.SAVE_UNSENT_DIR):
(_, extension) = os.path.splitext(filename)
if extension[1:] != SendRequest.SAVE_EXTENSION:
LOG.i(f"Skipping {filename} does not have the proper extension")
continue
full_file_path = os.path.join(config.SAVE_UNSENT_DIR, filename)
if not os.path.isfile(full_file_path):
LOG.i(f"Skipping {filename} as it's not a file")
continue
LOG.i(f"Trying to re-deliver email {filename}")
try:
send_request = SendRequest.load_from_file(full_file_path)
send_request.retries += 1
except Exception as e:
LOG.e(f"Cannot load {filename}. Error {e}")
continue
try:
send_request.ignore_smtp_errors = True
if mail_sender.send(send_request, 2):
os.unlink(full_file_path)
newrelic.agent.record_custom_event(
"DeliverUnsentEmail", {"delivered": "true"}
)
else:
if send_request.retries > 2:
os.unlink(full_file_path)
send_request.save_request_to_failed_dir()
else:
send_request.save_request_to_file(full_file_path)
newrelic.agent.record_custom_event(
"DeliverUnsentEmail", {"delivered": "false"}
)
except Exception as e:
# Unlink original file to avoid re-doing the same
os.unlink(full_file_path)
LOG.e(
"email sending failed with error:%s "
"envelope %s -> %s, mail %s -> %s saved to %s",
e,
send_request.envelope_from,
send_request.envelope_to,
send_request.msg[headers.FROM],
send_request.msg[headers.TO],
save_request_to_failed_dir(e.__class__.__name__, send_request),
)
def sl_sendmail( def sl_sendmail(
envelope_from: str, envelope_from: str,
envelope_to: str, envelope_to: str,
@ -193,7 +281,7 @@ def sl_sendmail(
send_request = SendRequest( send_request = SendRequest(
envelope_from, envelope_from,
envelope_to, envelope_to,
msg, message_format_base64_parts(msg),
mail_options, mail_options,
rcpt_options, rcpt_options,
is_forward, is_forward,

View file

@ -1,21 +1,42 @@
import re
from email import policy from email import policy
from email.message import Message from email.message import Message
from app.email import headers
from app.log import LOG from app.log import LOG
# Spam assassin might flag as spam with a different line length
BASE64_LINELENGTH = 76
def message_to_bytes(msg: Message) -> bytes: def message_to_bytes(msg: Message) -> bytes:
"""replace Message.as_bytes() method by trying different policies""" """replace Message.as_bytes() method by trying different policies"""
for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]: for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]:
try: try:
return msg.as_bytes(policy=generator_policy) return msg.as_bytes(policy=generator_policy)
except: except Exception:
LOG.w("as_bytes() fails with %s policy", policy, exc_info=True) LOG.w("as_bytes() fails with %s policy", policy, exc_info=True)
msg_string = msg.as_string() msg_string = msg.as_string()
try: try:
return msg_string.encode() return msg_string.encode()
except: except Exception:
LOG.w("as_string().encode() fails", exc_info=True) LOG.w("as_string().encode() fails", exc_info=True)
return msg_string.encode(errors="replace") return msg_string.encode(errors="replace")
def message_format_base64_parts(msg: Message) -> Message:
for part in msg.walk():
if part.get(
headers.CONTENT_TRANSFER_ENCODING
) == "base64" and part.get_content_type() in ("text/plain", "text/html"):
# Remove line breaks
body = re.sub("[\r\n]", "", part.get_payload())
# Split in 80 column lines
chunks = [
body[i : i + BASE64_LINELENGTH]
for i in range(0, len(body), BASE64_LINELENGTH)
]
part.set_payload("\r\n".join(chunks))
return msg

View file

@ -1,13 +1,14 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import dataclasses
import enum import enum
import hashlib import hashlib
import hmac import hmac
import os import os
import random import random
import secrets
import uuid import uuid
from email.utils import formataddr
from typing import List, Tuple, Optional, Union from typing import List, Tuple, Optional, Union
import arrow import arrow
@ -18,7 +19,7 @@ from flanker.addresslib import address
from flask import url_for from flask import url_for
from flask_login import UserMixin from flask_login import UserMixin
from jinja2 import FileSystemLoader, Environment from jinja2 import FileSystemLoader, Environment
from sqlalchemy import orm from sqlalchemy import orm, or_
from sqlalchemy import text, desc, CheckConstraint, Index, Column from sqlalchemy import text, desc, CheckConstraint, Index, Column
from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
@ -26,9 +27,11 @@ from sqlalchemy.orm import deferred
from sqlalchemy.sql import and_ from sqlalchemy.sql import and_
from sqlalchemy_utils import ArrowType from sqlalchemy_utils import ArrowType
from app import s3
from app import config from app import config
from app import s3
from app.db import Session from app.db import Session
from app.dns_utils import get_mx_domains
from app.errors import ( from app.errors import (
AliasInTrashError, AliasInTrashError,
DirectoryInTrashError, DirectoryInTrashError,
@ -44,7 +47,6 @@ from app.utils import (
random_string, random_string,
random_words, random_words,
sanitize_email, sanitize_email,
random_word,
) )
Base = declarative_base() Base = declarative_base()
@ -231,6 +233,8 @@ class AuditLogActionEnum(EnumE):
logged_as_user = 6 logged_as_user = 6
extend_subscription = 7 extend_subscription = 7
download_provider_complaint = 8 download_provider_complaint = 8
disable_user = 9
enable_user = 10
class Phase(EnumE): class Phase(EnumE):
@ -272,6 +276,13 @@ class IntEnumType(sa.types.TypeDecorator):
return self._enum_type(enum_value) return self._enum_type(enum_value)
@dataclasses.dataclass
class AliasOptions:
show_sl_domains: bool = True
show_partner_domains: Optional[Partner] = None
show_partner_premium: Optional[bool] = None
class Hibp(Base, ModelMixin): class Hibp(Base, ModelMixin):
__tablename__ = "hibp" __tablename__ = "hibp"
name = sa.Column(sa.String(), nullable=False, unique=True, index=True) name = sa.Column(sa.String(), nullable=False, unique=True, index=True)
@ -290,7 +301,9 @@ class HibpNotifiedAlias(Base, ModelMixin):
""" """
__tablename__ = "hibp_notified_alias" __tablename__ = "hibp_notified_alias"
alias_id = sa.Column(sa.ForeignKey("alias.id", ondelete="cascade"), nullable=False) alias_id = sa.Column(
sa.ForeignKey("alias.id", ondelete="cascade"), nullable=False, index=True
)
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=False) user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=False)
notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False) notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False)
@ -331,7 +344,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
sa.Boolean, default=True, nullable=False, server_default="1" sa.Boolean, default=True, nullable=False, server_default="1"
) )
activated = sa.Column(sa.Boolean, default=False, nullable=False) activated = sa.Column(sa.Boolean, default=False, nullable=False, index=True)
# an account can be disabled if having harmful behavior # an account can be disabled if having harmful behavior
disabled = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0") disabled = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0")
@ -401,7 +414,10 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
) )
referral_id = sa.Column( referral_id = sa.Column(
sa.ForeignKey("referral.id", ondelete="SET NULL"), nullable=True, default=None sa.ForeignKey("referral.id", ondelete="SET NULL"),
nullable=True,
default=None,
index=True,
) )
referral = orm.relationship("Referral", foreign_keys=[referral_id]) referral = orm.relationship("Referral", foreign_keys=[referral_id])
@ -418,12 +434,15 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
# newsletter is sent to this address # newsletter is sent to this address
newsletter_alias_id = sa.Column( newsletter_alias_id = sa.Column(
sa.ForeignKey("alias.id", ondelete="SET NULL"), nullable=True, default=None sa.ForeignKey("alias.id", ondelete="SET NULL"),
nullable=True,
default=None,
index=True,
) )
# whether to include the sender address in reverse-alias # whether to include the sender address in reverse-alias
include_sender_in_reverse_alias = sa.Column( include_sender_in_reverse_alias = sa.Column(
sa.Boolean, default=False, nullable=False, server_default="0" sa.Boolean, default=True, nullable=False, server_default="0"
) )
# whether to use random string or random word as suffix # whether to use random string or random word as suffix
@ -432,7 +451,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
random_alias_suffix = sa.Column( random_alias_suffix = sa.Column(
sa.Integer, sa.Integer,
nullable=False, nullable=False,
default=AliasSuffixEnum.random_string.value, default=AliasSuffixEnum.word.value,
server_default=str(AliasSuffixEnum.random_string.value), server_default=str(AliasSuffixEnum.random_string.value),
) )
@ -501,9 +520,8 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
server_default=BlockBehaviourEnum.return_2xx.name, server_default=BlockBehaviourEnum.return_2xx.name,
) )
# to keep existing behavior, the server default is TRUE whereas for new user, the default value is FALSE
include_header_email_header = sa.Column( include_header_email_header = sa.Column(
sa.Boolean, default=False, nullable=False, server_default="1" sa.Boolean, default=True, nullable=False, server_default="1"
) )
# bitwise flags. Allow for future expansion # bitwise flags. Allow for future expansion
@ -517,11 +535,21 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
# Keep original unsub behaviour # Keep original unsub behaviour
unsub_behaviour = sa.Column( unsub_behaviour = sa.Column(
IntEnumType(UnsubscribeBehaviourEnum), IntEnumType(UnsubscribeBehaviourEnum),
default=UnsubscribeBehaviourEnum.DisableAlias, default=UnsubscribeBehaviourEnum.PreserveOriginal,
server_default=str(UnsubscribeBehaviourEnum.DisableAlias.value), server_default=str(UnsubscribeBehaviourEnum.DisableAlias.value),
nullable=False, nullable=False,
) )
# Trigger hard deletion of the account at this time
delete_on = sa.Column(ArrowType, default=None)
__table_args__ = (
sa.Index(
"ix_users_activated_trial_end_lifetime", activated, trial_end, lifetime
),
sa.Index("ix_users_delete_on", delete_on),
)
@property @property
def directory_quota(self): def directory_quota(self):
return min( return min(
@ -556,7 +584,8 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
@classmethod @classmethod
def create(cls, email, name="", password=None, from_partner=False, **kwargs): def create(cls, email, name="", password=None, from_partner=False, **kwargs):
user: User = super(User, cls).create(email=email, name=name, **kwargs) email = sanitize_email(email)
user: User = super(User, cls).create(email=email, name=name[:100], **kwargs)
if password: if password:
user.set_password(password) user.set_password(password)
@ -567,19 +596,6 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
Session.flush() Session.flush()
user.default_mailbox_id = mb.id user.default_mailbox_id = mb.id
# create a first alias mail to show user how to use when they login
alias = Alias.create_new(
user,
prefix="simplelogin-newsletter",
mailbox_id=mb.id,
note="This is your first alias. It's used to receive SimpleLogin communications "
"like new features announcements, newsletters.",
)
Session.flush()
user.newsletter_alias_id = alias.id
Session.flush()
# generate an alternative_id if needed # generate an alternative_id if needed
if "alternative_id" not in kwargs: if "alternative_id" not in kwargs:
user.alternative_id = str(uuid.uuid4()) user.alternative_id = str(uuid.uuid4())
@ -598,6 +614,19 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
Session.flush() Session.flush()
return user return user
# create a first alias mail to show user how to use when they login
alias = Alias.create_new(
user,
prefix="simplelogin-newsletter",
mailbox_id=mb.id,
note="This is your first alias. It's used to receive SimpleLogin communications "
"like new features announcements, newsletters.",
)
Session.flush()
user.newsletter_alias_id = alias.id
Session.flush()
if config.DISABLE_ONBOARDING: if config.DISABLE_ONBOARDING:
LOG.d("Disable onboarding emails") LOG.d("Disable onboarding emails")
return user return user
@ -623,7 +652,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return user return user
def get_active_subscription( def get_active_subscription(
self, self, include_partner_subscription: bool = True
) -> Optional[ ) -> Optional[
Union[ Union[
Subscription Subscription
@ -651,19 +680,40 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
if coinbase_subscription and coinbase_subscription.is_active(): if coinbase_subscription and coinbase_subscription.is_active():
return coinbase_subscription return coinbase_subscription
partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(self.id) if include_partner_subscription:
if partner_sub and partner_sub.is_active(): partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(
return partner_sub self.id
)
if partner_sub and partner_sub.is_active():
return partner_sub
return None return None
def get_active_subscription_end(
self, include_partner_subscription: bool = True
) -> Optional[arrow.Arrow]:
sub = self.get_active_subscription(
include_partner_subscription=include_partner_subscription
)
if isinstance(sub, Subscription):
return arrow.get(sub.next_bill_date)
if isinstance(sub, AppleSubscription):
return sub.expires_date
if isinstance(sub, ManualSubscription):
return sub.end_at
if isinstance(sub, CoinbaseSubscription):
return sub.end_at
return None
# region Billing # region Billing
def lifetime_or_active_subscription(self) -> bool: def lifetime_or_active_subscription(
self, include_partner_subscription: bool = True
) -> bool:
"""True if user has lifetime licence or active subscription""" """True if user has lifetime licence or active subscription"""
if self.lifetime: if self.lifetime:
return True return True
return self.get_active_subscription() is not None return self.get_active_subscription(include_partner_subscription) is not None
def is_paid(self) -> bool: def is_paid(self) -> bool:
"""same as _lifetime_or_active_subscription but not include free manual subscription""" """same as _lifetime_or_active_subscription but not include free manual subscription"""
@ -692,14 +742,14 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return True return True
def is_premium(self) -> bool: def is_premium(self, include_partner_subscription: bool = True) -> bool:
""" """
user is premium if they: user is premium if they:
- have a lifetime deal or - have a lifetime deal or
- in trial period or - in trial period or
- active subscription - active subscription
""" """
if self.lifetime_or_active_subscription(): if self.lifetime_or_active_subscription(include_partner_subscription):
return True return True
if self.trial_end and arrow.now() < self.trial_end: if self.trial_end and arrow.now() < self.trial_end:
@ -719,11 +769,11 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
if sub: if sub:
if sub.cancelled: if sub.cancelled:
channels.append( channels.append(
f"Cancelled Paddle Subscription {sub.subscription_id} {sub.plan_name()} ends at {sub.next_bill_date}" f"""Cancelled Paddle Subscription <a href="https://vendors.paddle.com/subscriptions/customers/manage/{sub.subscription_id}">{sub.subscription_id}</a> {sub.plan_name()} ends at {sub.next_bill_date}"""
) )
else: else:
channels.append( channels.append(
f"Active Paddle Subscription {sub.subscription_id} {sub.plan_name()}, renews at {sub.next_bill_date}" f"""Active Paddle Subscription <a href="https://vendors.paddle.com/subscriptions/customers/manage/{sub.subscription_id}">{sub.subscription_id}</a> {sub.plan_name()}, renews at {sub.next_bill_date}"""
) )
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id) apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=self.id)
@ -788,6 +838,17 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
< self.max_alias_for_free_account() < self.max_alias_for_free_account()
) )
def can_send_or_receive(self) -> bool:
if self.disabled:
LOG.i(f"User {self} is disabled. Cannot receive or send emails")
return False
if self.delete_on is not None:
LOG.i(
f"User {self} is scheduled to be deleted. Cannot receive or send emails"
)
return False
return True
def profile_picture_url(self): def profile_picture_url(self):
if self.profile_picture_id: if self.profile_picture_id:
return self.profile_picture.get_url() return self.profile_picture.get_url()
@ -866,14 +927,16 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
def custom_domains(self): def custom_domains(self):
return CustomDomain.filter_by(user_id=self.id, verified=True).all() return CustomDomain.filter_by(user_id=self.id, verified=True).all()
def available_domains_for_random_alias(self) -> List[Tuple[bool, str]]: def available_domains_for_random_alias(
self, alias_options: Optional[AliasOptions] = None
) -> List[Tuple[bool, str]]:
"""Return available domains for user to create random aliases """Return available domains for user to create random aliases
Each result record contains: Each result record contains:
- whether the domain belongs to SimpleLogin - whether the domain belongs to SimpleLogin
- the domain - the domain
""" """
res = [] res = []
for domain in self.available_sl_domains(): for domain in self.available_sl_domains(alias_options=alias_options):
res.append((True, domain)) res.append((True, domain))
for custom_domain in self.verified_custom_domains(): for custom_domain in self.verified_custom_domains():
@ -943,7 +1006,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return alias.email, unsub.link, unsub.via_email return alias.email, unsub.link, unsub.via_email
# alias disabled -> user doesn't want to receive newsletter # alias disabled -> user doesn't want to receive newsletter
else: else:
return None, None, False return None, "", False
else: else:
# do not handle http POST unsubscribe # do not handle http POST unsubscribe
if config.UNSUBSCRIBER: if config.UNSUBSCRIBER:
@ -956,32 +1019,67 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
True, True,
) )
return None, None, False return None, "", False
def available_sl_domains(self) -> [str]: def available_sl_domains(
self, alias_options: Optional[AliasOptions] = None
) -> [str]:
""" """
Return all SimpleLogin domains that user can use when creating a new alias, including: Return all SimpleLogin domains that user can use when creating a new alias, including:
- SimpleLogin public domains, available for all users (ALIAS_DOMAIN) - SimpleLogin public domains, available for all users (ALIAS_DOMAIN)
- SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN) - SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN)
""" """
return [sl_domain.domain for sl_domain in self.get_sl_domains()] return [
sl_domain.domain
for sl_domain in self.get_sl_domains(alias_options=alias_options)
]
def get_sl_domains(self) -> List["SLDomain"]: def get_sl_domains(
query = SLDomain.filter_by(hidden=False).order_by(SLDomain.order) self, alias_options: Optional[AliasOptions] = None
) -> list["SLDomain"]:
if alias_options is None:
alias_options = AliasOptions()
top_conds = [SLDomain.hidden == False] # noqa: E712
or_conds = [] # noqa:E711
if self.default_alias_public_domain_id is not None:
default_domain_conds = [SLDomain.id == self.default_alias_public_domain_id]
if not self.is_premium():
default_domain_conds.append(
SLDomain.premium_only == False # noqa: E712
)
or_conds.append(and_(*default_domain_conds).self_group())
if alias_options.show_partner_domains is not None:
partner_user = PartnerUser.filter_by(
user_id=self.id, partner_id=alias_options.show_partner_domains.id
).first()
if partner_user is not None:
partner_domain_cond = [SLDomain.partner_id == partner_user.partner_id]
if alias_options.show_partner_premium is None:
alias_options.show_partner_premium = self.is_premium()
if not alias_options.show_partner_premium:
partner_domain_cond.append(
SLDomain.premium_only == False # noqa: E712
)
or_conds.append(and_(*partner_domain_cond).self_group())
if alias_options.show_sl_domains:
sl_conds = [SLDomain.partner_id == None] # noqa: E711
if not self.is_premium():
sl_conds.append(SLDomain.premium_only == False) # noqa: E712
or_conds.append(and_(*sl_conds).self_group())
top_conds.append(or_(*or_conds))
query = Session.query(SLDomain).filter(*top_conds).order_by(SLDomain.order)
return query.all()
if self.is_premium(): def available_alias_domains(
return query.all() self, alias_options: Optional[AliasOptions] = None
else: ) -> [str]:
return query.filter_by(premium_only=False).all()
def available_alias_domains(self) -> [str]:
"""return all domains that user can use when creating a new alias, including: """return all domains that user can use when creating a new alias, including:
- SimpleLogin public domains, available for all users (ALIAS_DOMAIN) - SimpleLogin public domains, available for all users (ALIAS_DOMAIN)
- SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN) - SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN)
- Verified custom domains - Verified custom domains
""" """
domains = self.available_sl_domains() domains = self.available_sl_domains(alias_options=alias_options)
for custom_domain in self.verified_custom_domains(): for custom_domain in self.verified_custom_domains():
domains.append(custom_domain.domain) domains.append(custom_domain.domain)
@ -999,16 +1097,21 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
> 0 > 0
) )
def get_random_alias_suffix(self): def get_random_alias_suffix(self, custom_domain: Optional["CustomDomain"] = None):
"""Get random suffix for an alias based on user's preference. """Get random suffix for an alias based on user's preference.
Use a shorter suffix in case of custom domain
Returns: Returns:
str: the random suffix generated str: the random suffix generated
""" """
if self.random_alias_suffix == AliasSuffixEnum.random_string.value: if self.random_alias_suffix == AliasSuffixEnum.random_string.value:
return random_string(config.ALIAS_RANDOM_SUFFIX_LENGTH, include_digits=True) return random_string(config.ALIAS_RANDOM_SUFFIX_LENGTH, include_digits=True)
return random_word()
if custom_domain is None:
return random_words(1, 3)
return random_words(1)
def __repr__(self): def __repr__(self):
return f"<User {self.id} {self.name} {self.email}>" return f"<User {self.id} {self.name} {self.email}>"
@ -1253,34 +1356,48 @@ class OauthToken(Base, ModelMixin):
return self.expired < arrow.now() return self.expired < arrow.now()
def generate_email( def available_sl_email(email: str) -> bool:
if (
Alias.get_by(email=email)
or Contact.get_by(reply_email=email)
or DeletedAlias.get_by(email=email)
):
return False
return True
def generate_random_alias_email(
scheme: int = AliasGeneratorEnum.word.value, scheme: int = AliasGeneratorEnum.word.value,
in_hex: bool = False, in_hex: bool = False,
alias_domain=config.FIRST_ALIAS_DOMAIN, alias_domain: str = config.FIRST_ALIAS_DOMAIN,
retries: int = 10,
) -> str: ) -> str:
"""generate an email address that does not exist before """generate an email address that does not exist before
:param alias_domain: the domain used to generate the alias. :param alias_domain: the domain used to generate the alias.
:param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated :param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated
:param retries: int, How many times we can try to generate an alias in case of collision
:type in_hex: bool, if the generate scheme is uuid, is hex favorable? :type in_hex: bool, if the generate scheme is uuid, is hex favorable?
""" """
if retries <= 0:
raise Exception("Cannot generate alias after many retries")
if scheme == AliasGeneratorEnum.uuid.value: if scheme == AliasGeneratorEnum.uuid.value:
name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__() name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__()
random_email = name + "@" + alias_domain random_email = name + "@" + alias_domain
else: else:
random_email = random_words() + "@" + alias_domain random_email = random_words(2, 3) + "@" + alias_domain
random_email = random_email.lower().strip() random_email = random_email.lower().strip()
# check that the client does not exist yet # check that the client does not exist yet
if not Alias.get_by(email=random_email) and not DeletedAlias.get_by( if available_sl_email(random_email):
email=random_email
):
LOG.d("generate email %s", random_email) LOG.d("generate email %s", random_email)
return random_email return random_email
# Rerun the function # Rerun the function
LOG.w("email %s already exists, generate a new email", random_email) LOG.w("email %s already exists, generate a new email", random_email)
return generate_email(scheme=scheme, in_hex=in_hex) return generate_random_alias_email(
scheme=scheme, in_hex=in_hex, retries=retries - 1
)
class Alias(Base, ModelMixin): class Alias(Base, ModelMixin):
@ -1362,7 +1479,7 @@ class Alias(Base, ModelMixin):
) )
# have I been pwned # have I been pwned
hibp_last_check = sa.Column(ArrowType, default=None) hibp_last_check = sa.Column(ArrowType, default=None, index=True)
hibp_breaches = orm.relationship("Hibp", secondary="alias_hibp") hibp_breaches = orm.relationship("Hibp", secondary="alias_hibp")
# to use Postgres full text search. Only applied on "note" column for now # to use Postgres full text search. Only applied on "note" column for now
@ -1457,6 +1574,7 @@ class Alias(Base, ModelMixin):
new_alias.custom_domain_id = custom_domain.id new_alias.custom_domain_id = custom_domain.id
Session.add(new_alias) Session.add(new_alias)
DailyMetric.get_or_create_today_metric().nb_alias += 1
if commit: if commit:
Session.commit() Session.commit()
@ -1478,7 +1596,7 @@ class Alias(Base, ModelMixin):
suffix = user.get_random_alias_suffix() suffix = user.get_random_alias_suffix()
email = f"{prefix}.{suffix}@{config.FIRST_ALIAS_DOMAIN}" email = f"{prefix}.{suffix}@{config.FIRST_ALIAS_DOMAIN}"
if not cls.get_by(email=email) and not DeletedAlias.get_by(email=email): if available_sl_email(email):
break break
return Alias.create( return Alias.create(
@ -1507,7 +1625,7 @@ class Alias(Base, ModelMixin):
if user.default_alias_custom_domain_id: if user.default_alias_custom_domain_id:
custom_domain = CustomDomain.get(user.default_alias_custom_domain_id) custom_domain = CustomDomain.get(user.default_alias_custom_domain_id)
random_email = generate_email( random_email = generate_random_alias_email(
scheme=scheme, in_hex=in_hex, alias_domain=custom_domain.domain scheme=scheme, in_hex=in_hex, alias_domain=custom_domain.domain
) )
elif user.default_alias_public_domain_id: elif user.default_alias_public_domain_id:
@ -1515,12 +1633,12 @@ class Alias(Base, ModelMixin):
if sl_domain.premium_only and not user.is_premium(): if sl_domain.premium_only and not user.is_premium():
LOG.w("%s not premium, cannot use %s", user, sl_domain) LOG.w("%s not premium, cannot use %s", user, sl_domain)
else: else:
random_email = generate_email( random_email = generate_random_alias_email(
scheme=scheme, in_hex=in_hex, alias_domain=sl_domain.domain scheme=scheme, in_hex=in_hex, alias_domain=sl_domain.domain
) )
if not random_email: if not random_email:
random_email = generate_email(scheme=scheme, in_hex=in_hex) random_email = generate_random_alias_email(scheme=scheme, in_hex=in_hex)
alias = Alias.create( alias = Alias.create(
user_id=user.id, user_id=user.id,
@ -1554,7 +1672,9 @@ class ClientUser(Base, ModelMixin):
client_id = sa.Column(sa.ForeignKey(Client.id, ondelete="cascade"), nullable=False) client_id = sa.Column(sa.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
# Null means client has access to user original email # Null means client has access to user original email
alias_id = sa.Column(sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=True) alias_id = sa.Column(
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=True, index=True
)
# user can decide to send to client another name # user can decide to send to client another name
name = sa.Column( name = sa.Column(
@ -1638,6 +1758,8 @@ class Contact(Base, ModelMixin):
Store configuration of sender (website-email) and alias. Store configuration of sender (website-email) and alias.
""" """
MAX_NAME_LENGTH = 512
__tablename__ = "contact" __tablename__ = "contact"
__table_args__ = ( __table_args__ = (
@ -1671,7 +1793,7 @@ class Contact(Base, ModelMixin):
is_cc = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0") is_cc = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0")
pgp_public_key = sa.Column(sa.Text, nullable=True) pgp_public_key = sa.Column(sa.Text, nullable=True)
pgp_finger_print = sa.Column(sa.String(512), nullable=True) pgp_finger_print = sa.Column(sa.String(512), nullable=True, index=True)
alias = orm.relationship(Alias, backref="contacts") alias = orm.relationship(Alias, backref="contacts")
user = orm.relationship(User) user = orm.relationship(User)
@ -1806,7 +1928,9 @@ class Contact(Base, ModelMixin):
else formatted_email else formatted_email
) )
new_addr = formataddr((new_name, self.reply_email)).strip() from app.email_utils import sl_formataddr
new_addr = sl_formataddr((new_name, self.reply_email)).strip()
return new_addr.strip() return new_addr.strip()
def last_reply(self) -> "EmailLog": def last_reply(self) -> "EmailLog":
@ -1823,6 +1947,7 @@ class Contact(Base, ModelMixin):
class EmailLog(Base, ModelMixin): class EmailLog(Base, ModelMixin):
__tablename__ = "email_log" __tablename__ = "email_log"
__table_args__ = (Index("ix_email_log_created_at", "created_at"),)
user_id = sa.Column( user_id = sa.Column(
sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True
@ -2080,7 +2205,9 @@ class AliasUsedOn(Base, ModelMixin):
sa.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"), sa.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"),
) )
alias_id = sa.Column(sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False) alias_id = sa.Column(
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False, index=True
)
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False) user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
alias = orm.relationship(Alias) alias = orm.relationship(Alias)
@ -2199,6 +2326,7 @@ class CustomDomain(Base, ModelMixin):
@classmethod @classmethod
def create(cls, **kwargs): def create(cls, **kwargs):
domain = kwargs.get("domain") domain = kwargs.get("domain")
kwargs["domain"] = domain.replace("\n", "")
if DeletedSubdomain.get_by(domain=domain): if DeletedSubdomain.get_by(domain=domain):
raise SubdomainInTrashError raise SubdomainInTrashError
@ -2466,6 +2594,28 @@ class Mailbox(Base, ModelMixin):
+ Alias.filter_by(mailbox_id=self.id).count() + Alias.filter_by(mailbox_id=self.id).count()
) )
def is_proton(self) -> bool:
if (
self.email.endswith("@proton.me")
or self.email.endswith("@protonmail.com")
or self.email.endswith("@protonmail.ch")
or self.email.endswith("@proton.ch")
or self.email.endswith("@pm.me")
):
return True
from app.email_utils import get_email_local_part
mx_domains: [(int, str)] = get_mx_domains(get_email_local_part(self.email))
# Proton is the first domain
if mx_domains and mx_domains[0][1] in (
"mail.protonmail.ch.",
"mailsec.protonmail.ch.",
):
return True
return False
@classmethod @classmethod
def delete(cls, obj_id): def delete(cls, obj_id):
mailbox: Mailbox = cls.get(obj_id) mailbox: Mailbox = cls.get(obj_id)
@ -2498,6 +2648,12 @@ class Mailbox(Base, ModelMixin):
return ret return ret
@classmethod
def create(cls, **kw):
if "email" in kw:
kw["email"] = sanitize_email(kw["email"])
return super().create(**kw)
def __repr__(self): def __repr__(self):
return f"<Mailbox {self.id} {self.email}>" return f"<Mailbox {self.id} {self.email}>"
@ -2681,12 +2837,21 @@ class RecoveryCode(Base, ModelMixin):
__table_args__ = (sa.UniqueConstraint("user_id", "code", name="uq_recovery_code"),) __table_args__ = (sa.UniqueConstraint("user_id", "code", name="uq_recovery_code"),)
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False) user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
code = sa.Column(sa.String(16), nullable=False) code = sa.Column(sa.String(64), nullable=False)
used = sa.Column(sa.Boolean, nullable=False, default=False) used = sa.Column(sa.Boolean, nullable=False, default=False)
used_at = sa.Column(ArrowType, nullable=True, default=None) used_at = sa.Column(ArrowType, nullable=True, default=None)
user = orm.relationship(User) user = orm.relationship(User)
@classmethod
def _hash_code(cls, code: str) -> str:
code_hmac = hmac.new(
config.RECOVERY_CODE_HMAC_SECRET.encode("utf-8"),
code.encode("utf-8"),
"sha3_224",
)
return base64.urlsafe_b64encode(code_hmac.digest()).decode("utf-8").rstrip("=")
@classmethod @classmethod
def generate(cls, user): def generate(cls, user):
"""generate recovery codes for user""" """generate recovery codes for user"""
@ -2695,14 +2860,27 @@ class RecoveryCode(Base, ModelMixin):
Session.flush() Session.flush()
nb_code = 0 nb_code = 0
raw_codes = []
while nb_code < _NB_RECOVERY_CODE: while nb_code < _NB_RECOVERY_CODE:
code = random_string(_RECOVERY_CODE_LENGTH) raw_code = random_string(_RECOVERY_CODE_LENGTH)
if not cls.get_by(user_id=user.id, code=code): encoded_code = cls._hash_code(raw_code)
cls.create(user_id=user.id, code=code) if not cls.get_by(user_id=user.id, code=encoded_code):
cls.create(user_id=user.id, code=encoded_code)
raw_codes.append(raw_code)
nb_code += 1 nb_code += 1
LOG.d("Create recovery codes for %s", user) LOG.d("Create recovery codes for %s", user)
Session.commit() Session.commit()
return raw_codes
@classmethod
def find_by_user_code(cls, user: User, code: str):
hashed_code = cls._hash_code(code)
# TODO: Only return hashed codes once there aren't unhashed codes in the db.
found_code = cls.get_by(user_id=user.id, code=hashed_code)
if found_code:
return found_code
return cls.get_by(user_id=user.id, code=code)
@classmethod @classmethod
def empty(cls, user): def empty(cls, user):
@ -2735,6 +2913,31 @@ class Notification(Base, ModelMixin):
) )
class Partner(Base, ModelMixin):
__tablename__ = "partner"
name = sa.Column(sa.String(128), unique=True, nullable=False)
contact_email = sa.Column(sa.String(128), unique=True, nullable=False)
@staticmethod
def find_by_token(token: str) -> Optional[Partner]:
hmaced = PartnerApiToken.hmac_token(token)
res = (
Session.query(Partner, PartnerApiToken)
.filter(
and_(
PartnerApiToken.token == hmaced,
Partner.id == PartnerApiToken.partner_id,
)
)
.first()
)
if res:
partner, partner_api_token = res
return partner
return None
class SLDomain(Base, ModelMixin): class SLDomain(Base, ModelMixin):
"""SimpleLogin domains""" """SimpleLogin domains"""
@ -2752,12 +2955,23 @@ class SLDomain(Base, ModelMixin):
sa.Boolean, nullable=False, default=False, server_default="0" sa.Boolean, nullable=False, default=False, server_default="0"
) )
partner_id = sa.Column(
sa.ForeignKey(Partner.id, ondelete="cascade"),
nullable=True,
default=None,
server_default="NULL",
)
# if enabled, do not show this domain when user creates a custom alias # if enabled, do not show this domain when user creates a custom alias
hidden = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0") hidden = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0")
# the order in which the domains are shown when user creates a custom alias # the order in which the domains are shown when user creates a custom alias
order = sa.Column(sa.Integer, nullable=False, default=0, server_default="0") order = sa.Column(sa.Integer, nullable=False, default=0, server_default="0")
use_as_reverse_alias = sa.Column(
sa.Boolean, nullable=False, default=False, server_default="0"
)
def __repr__(self): def __repr__(self):
return f"<SLDomain {self.domain} {'Premium' if self.premium_only else 'Free'}" return f"<SLDomain {self.domain} {'Premium' if self.premium_only else 'Free'}"
@ -2778,6 +2992,8 @@ class Monitoring(Base, ModelMixin):
active_queue = sa.Column(sa.Integer, nullable=False) active_queue = sa.Column(sa.Integer, nullable=False)
deferred_queue = sa.Column(sa.Integer, nullable=False) deferred_queue = sa.Column(sa.Integer, nullable=False)
__table_args__ = (Index("ix_monitoring_created_at", "created_at"),)
class BatchImport(Base, ModelMixin): class BatchImport(Base, ModelMixin):
__tablename__ = "batch_import" __tablename__ = "batch_import"
@ -2868,6 +3084,34 @@ class Metric2(Base, ModelMixin):
nb_app = sa.Column(sa.Float, nullable=True) nb_app = sa.Column(sa.Float, nullable=True)
class DailyMetric(Base, ModelMixin):
"""
For storing daily event-based metrics.
The difference between DailyEventMetric and Metric2 is Metric2 stores the total
whereas DailyEventMetric is reset for a new day
"""
__tablename__ = "daily_metric"
date = sa.Column(sa.Date, nullable=False, unique=True)
# users who sign up via web without using "Login with Proton"
nb_new_web_non_proton_user = sa.Column(
sa.Integer, nullable=False, server_default="0", default=0
)
nb_alias = sa.Column(sa.Integer, nullable=False, server_default="0", default=0)
@staticmethod
def get_or_create_today_metric() -> DailyMetric:
today = arrow.utcnow().date()
daily_metric = DailyMetric.get_by(date=today)
if not daily_metric:
daily_metric = DailyMetric.create(
date=today, nb_new_web_non_proton_user=0, nb_alias=0
)
return daily_metric
class Bounce(Base, ModelMixin): class Bounce(Base, ModelMixin):
"""Record all bounces. Deleted after 7 days""" """Record all bounces. Deleted after 7 days"""
@ -2875,6 +3119,8 @@ class Bounce(Base, ModelMixin):
email = sa.Column(sa.String(256), nullable=False, index=True) email = sa.Column(sa.String(256), nullable=False, index=True)
info = sa.Column(sa.Text, nullable=True) info = sa.Column(sa.Text, nullable=True)
__table_args__ = (sa.Index("ix_bounce_created_at", "created_at"),)
class TransactionalEmail(Base, ModelMixin): class TransactionalEmail(Base, ModelMixin):
"""Storing all email addresses that receive transactional emails, including account email and mailboxes. """Storing all email addresses that receive transactional emails, including account email and mailboxes.
@ -2884,6 +3130,8 @@ class TransactionalEmail(Base, ModelMixin):
__tablename__ = "transactional_email" __tablename__ = "transactional_email"
email = sa.Column(sa.String(256), nullable=False, unique=False) email = sa.Column(sa.String(256), nullable=False, unique=False)
__table_args__ = (sa.Index("ix_transactional_email_created_at", "created_at"),)
class Payout(Base, ModelMixin): class Payout(Base, ModelMixin):
"""Referral payouts""" """Referral payouts"""
@ -2936,7 +3184,7 @@ class MessageIDMatching(Base, ModelMixin):
# to track what email_log that has created this matching # to track what email_log that has created this matching
email_log_id = sa.Column( email_log_id = sa.Column(
sa.ForeignKey("email_log.id", ondelete="cascade"), nullable=True sa.ForeignKey("email_log.id", ondelete="cascade"), nullable=True, index=True
) )
email_log = orm.relationship("EmailLog") email_log = orm.relationship("EmailLog")
@ -3125,6 +3373,26 @@ class AdminAuditLog(Base):
data={}, data={},
) )
@classmethod
def disable_user(cls, admin_user_id: int, user_id: int):
cls.create(
admin_user_id=admin_user_id,
action=AuditLogActionEnum.disable_user.value,
model="User",
model_id=user_id,
data={},
)
@classmethod
def enable_user(cls, admin_user_id: int, user_id: int):
cls.create(
admin_user_id=admin_user_id,
action=AuditLogActionEnum.enable_user.value,
model="User",
model_id=user_id,
data={},
)
class ProviderComplaintState(EnumE): class ProviderComplaintState(EnumE):
new = 0 new = 0
@ -3150,31 +3418,6 @@ class ProviderComplaint(Base, ModelMixin):
refused_email = orm.relationship(RefusedEmail, foreign_keys=[refused_email_id]) refused_email = orm.relationship(RefusedEmail, foreign_keys=[refused_email_id])
class Partner(Base, ModelMixin):
__tablename__ = "partner"
name = sa.Column(sa.String(128), unique=True, nullable=False)
contact_email = sa.Column(sa.String(128), unique=True, nullable=False)
@staticmethod
def find_by_token(token: str) -> Optional[Partner]:
hmaced = PartnerApiToken.hmac_token(token)
res = (
Session.query(Partner, PartnerApiToken)
.filter(
and_(
PartnerApiToken.token == hmaced,
Partner.id == PartnerApiToken.partner_id,
)
)
.first()
)
if res:
partner, partner_api_token = res
return partner
return None
class PartnerApiToken(Base, ModelMixin): class PartnerApiToken(Base, ModelMixin):
__tablename__ = "partner_api_token" __tablename__ = "partner_api_token"
@ -3244,7 +3487,7 @@ class PartnerSubscription(Base, ModelMixin):
) )
# when the partner subscription ends # when the partner subscription ends
end_at = sa.Column(ArrowType, nullable=False) end_at = sa.Column(ArrowType, nullable=False, index=True)
partner_user = orm.relationship(PartnerUser) partner_user = orm.relationship(PartnerUser)
@ -3274,7 +3517,7 @@ class PartnerSubscription(Base, ModelMixin):
class Newsletter(Base, ModelMixin): class Newsletter(Base, ModelMixin):
__tablename__ = "newsletter" __tablename__ = "newsletter"
subject = sa.Column(sa.String(), nullable=False, unique=True, index=True) subject = sa.Column(sa.String(), nullable=False, index=True)
html = sa.Column(sa.Text) html = sa.Column(sa.Text)
plain_text = sa.Column(sa.Text) plain_text = sa.Column(sa.Text)
@ -3296,3 +3539,19 @@ class NewsletterUser(Base, ModelMixin):
user = orm.relationship(User) user = orm.relationship(User)
newsletter = orm.relationship(Newsletter) newsletter = orm.relationship(Newsletter)
class ApiToCookieToken(Base, ModelMixin):
__tablename__ = "api_cookie_token"
code = sa.Column(sa.String(128), unique=True, nullable=False)
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
api_key_id = sa.Column(sa.ForeignKey(ApiKey.id, ondelete="cascade"), nullable=False)
user = orm.relationship(User)
api_key = orm.relationship(ApiKey)
@classmethod
def create(cls, **kwargs):
code = secrets.token_urlsafe(32)
return super().create(code=code, **kwargs)

View file

@ -1 +1,3 @@
from . import views from . import views
__all__ = ["views"]

View file

@ -4,8 +4,9 @@ from jinja2 import Environment, FileSystemLoader
from app.config import ROOT_DIR, URL from app.config import ROOT_DIR, URL
from app.email_utils import send_email from app.email_utils import send_email
from app.handler.unsubscribe_encoder import UnsubscribeEncoder, UnsubscribeAction
from app.log import LOG from app.log import LOG
from app.models import NewsletterUser from app.models import NewsletterUser, Alias
def send_newsletter_to_user(newsletter, user) -> (bool, str): def send_newsletter_to_user(newsletter, user) -> (bool, str):
@ -16,12 +17,25 @@ def send_newsletter_to_user(newsletter, user) -> (bool, str):
html_template = env.from_string(newsletter.html) html_template = env.from_string(newsletter.html)
text_template = env.from_string(newsletter.plain_text) text_template = env.from_string(newsletter.plain_text)
to_email, unsubscribe_link, via_email = user.get_communication_email() comm_email, unsubscribe_link, via_email = user.get_communication_email()
if not to_email: if not comm_email:
return False, f"{user} not subscribed to newsletter" return False, f"{user} not subscribed to newsletter"
comm_alias = Alias.get_by(email=comm_email)
comm_alias_id = -1
if comm_alias:
comm_alias_id = comm_alias.id
unsubscribe_oneclick = unsubscribe_link
if via_email and comm_alias_id > -1:
unsubscribe_oneclick = UnsubscribeEncoder.encode(
UnsubscribeAction.DisableAlias,
comm_alias_id,
force_web=True,
).link
send_email( send_email(
to_email, comm_email,
newsletter.subject, newsletter.subject,
text_template.render( text_template.render(
user=user, user=user,
@ -30,7 +44,10 @@ def send_newsletter_to_user(newsletter, user) -> (bool, str):
html_template.render( html_template.render(
user=user, user=user,
URL=URL, URL=URL,
unsubscribe_oneclick=unsubscribe_oneclick,
), ),
unsubscribe_link=unsubscribe_link,
unsubscribe_via_email=via_email,
) )
NewsletterUser.create(newsletter_id=newsletter.id, user_id=user.id, commit=True) NewsletterUser.create(newsletter_id=newsletter.id, user_id=user.id, commit=True)

View file

@ -1 +1,3 @@
from .views import authorize, token, user_info from .views import authorize, token, user_info
__all__ = ["authorize", "token", "user_info"]

View file

@ -64,7 +64,7 @@ def _split_arg(arg_input: Union[str, list]) -> Set[str]:
- the response_type/scope passed as a list ?scope=scope_1&scope=scope_2 - the response_type/scope passed as a list ?scope=scope_1&scope=scope_2
""" """
res = set() res = set()
if type(arg_input) is str: if isinstance(arg_input, str):
if " " in arg_input: if " " in arg_input:
for x in arg_input.split(" "): for x in arg_input.split(" "):
if x: if x:

View file

@ -5,3 +5,11 @@ from .views import (
account_activated, account_activated,
extension_redirect, extension_redirect,
) )
__all__ = [
"index",
"final",
"setup_done",
"account_activated",
"extension_redirect",
]

32
app/paddle_callback.py Normal file
View file

@ -0,0 +1,32 @@
import arrow
from app.db import Session
from app.email_utils import send_email, render
from app.log import LOG
from app.models import Subscription
from app import paddle_utils
def failed_payment(sub: Subscription, subscription_id: str):
LOG.w(
"Subscription failed payment %s for %s (sub %s)",
subscription_id,
sub.user,
sub.id,
)
sub.cancelled = True
Session.commit()
user = sub.user
paddle_utils.cancel_subscription(subscription_id)
send_email(
user.email,
"SimpleLogin - your subscription has failed to be renewed",
render(
"transactional/subscription-cancel.txt",
end_date=arrow.arrow.datetime.utcnow(),
),
)

73
app/parallel_limiter.py Normal file
View file

@ -0,0 +1,73 @@
import uuid
from datetime import timedelta
from functools import wraps
from typing import Callable, Any, Optional
from flask import request
from flask_login import current_user
from limits.storage import RedisStorage
from werkzeug import exceptions
lock_redis: Optional[RedisStorage] = None
def set_redis_concurrent_lock(redis: RedisStorage):
global lock_redis
lock_redis = redis
class _InnerLock:
def __init__(
self,
lock_suffix: Optional[str] = None,
max_wait_secs: int = 5,
only_when: Optional[Callable[..., bool]] = None,
):
self.lock_suffix = lock_suffix
self.max_wait_secs = max_wait_secs
self.only_when = only_when
def acquire_lock(self, lock_name: str, lock_value: str):
if not lock_redis.storage.set(
lock_name, lock_value, ex=timedelta(seconds=self.max_wait_secs), nx=True
):
raise exceptions.TooManyRequests()
def release_lock(self, lock_name: str, lock_value: str):
current_lock_value = lock_redis.storage.get(lock_name)
if current_lock_value == lock_value.encode("utf-8"):
lock_redis.storage.delete(lock_name)
def __call__(self, f: Callable[..., Any]):
if self.lock_suffix is None:
lock_suffix = f.__name__
else:
lock_suffix = self.lock_suffix
@wraps(f)
def decorated(*args, **kwargs):
if self.only_when and not self.only_when():
return f(*args, **kwargs)
if not lock_redis:
return f(*args, **kwargs)
lock_value = str(uuid.uuid4())[:10]
if "id" in dir(current_user):
lock_name = f"cl:{current_user.id}:{lock_suffix}"
else:
lock_name = f"cl:{request.remote_addr}:{lock_suffix}"
self.acquire_lock(lock_name, lock_value)
try:
return f(*args, **kwargs)
finally:
self.release_lock(lock_name, lock_value)
return decorated
def lock(
name: Optional[str] = None,
max_wait_secs: int = 5,
only_when: Optional[Callable[..., bool]] = None,
):
return _InnerLock(name, max_wait_secs, only_when)

View file

@ -5,3 +5,11 @@ from .views import (
provider1_callback, provider1_callback,
provider2_callback, provider2_callback,
) )
__all__ = [
"index",
"phone_reservation",
"twilio_callback",
"provider1_callback",
"provider2_callback",
]

View file

@ -64,7 +64,9 @@ class ProtonCallbackHandler:
) )
def handle_link( def handle_link(
self, current_user: Optional[User], partner: Partner self,
current_user: Optional[User],
partner: Partner,
) -> ProtonCallbackResult: ) -> ProtonCallbackResult:
if current_user is None: if current_user is None:
raise Exception("Cannot link account with current_user being None") raise Exception("Cannot link account with current_user being None")

View file

@ -7,11 +7,12 @@ from typing import Optional
from app.account_linking import SLPlan, SLPlanType from app.account_linking import SLPlan, SLPlanType
from app.config import PROTON_EXTRA_HEADER_NAME, PROTON_EXTRA_HEADER_VALUE from app.config import PROTON_EXTRA_HEADER_NAME, PROTON_EXTRA_HEADER_VALUE
from app.errors import ProtonAccountNotVerified
from app.log import LOG from app.log import LOG
_APP_VERSION = "OauthClient_1.0.0" _APP_VERSION = "OauthClient_1.0.0"
PROTON_ERROR_CODE_NOT_EXISTS = 2501 PROTON_ERROR_CODE_HV_NEEDED = 9001
PLAN_FREE = 1 PLAN_FREE = 1
PLAN_PREMIUM = 2 PLAN_PREMIUM = 2
@ -57,6 +58,15 @@ def convert_access_token(access_token_response: str) -> AccessCredentials:
) )
def handle_response_not_ok(status: int, body: dict, text: str) -> Exception:
if status == HTTPStatus.UNPROCESSABLE_ENTITY:
res_code = body.get("Code")
if res_code == PROTON_ERROR_CODE_HV_NEEDED:
return ProtonAccountNotVerified()
return Exception(f"Unexpected status code. Wanted 200 and got {status}: " + text)
class ProtonClient(ABC): class ProtonClient(ABC):
@abstractmethod @abstractmethod
def get_user(self) -> Optional[UserInformation]: def get_user(self) -> Optional[UserInformation]:
@ -124,11 +134,11 @@ class HttpProtonClient(ProtonClient):
@staticmethod @staticmethod
def __validate_response(res: Response) -> dict: def __validate_response(res: Response) -> dict:
status = res.status_code status = res.status_code
if status != HTTPStatus.OK:
raise Exception(
f"Unexpected status code. Wanted 200 and got {status}: " + res.text
)
as_json = res.json() as_json = res.json()
if status != HTTPStatus.OK:
raise HttpProtonClient.__handle_response_not_ok(
status=status, body=as_json, text=res.text
)
res_code = as_json.get("Code") res_code = as_json.get("Code")
if not res_code or res_code != 1000: if not res_code or res_code != 1000:
raise Exception( raise Exception(

View file

@ -1,8 +1,9 @@
from newrelic import agent
from typing import Optional from typing import Optional
from app.db import Session from app.db import Session
from app.errors import ProtonPartnerNotSetUp from app.errors import ProtonPartnerNotSetUp
from app.models import Partner from app.models import Partner, PartnerUser, User
PROTON_PARTNER_NAME = "Proton" PROTON_PARTNER_NAME = "Proton"
_PROTON_PARTNER: Optional[Partner] = None _PROTON_PARTNER: Optional[Partner] = None
@ -21,3 +22,14 @@ def get_proton_partner() -> Partner:
def is_proton_partner(partner: Partner) -> bool: def is_proton_partner(partner: Partner) -> bool:
return partner.name == PROTON_PARTNER_NAME return partner.name == PROTON_PARTNER_NAME
def perform_proton_account_unlink(current_user: User):
proton_partner = get_proton_partner()
partner_user = PartnerUser.get_by(
user_id=current_user.id, partner_id=proton_partner.id
)
if partner_user is not None:
PartnerUser.delete(partner_user.id)
Session.commit()
agent.record_custom_event("AccountUnlinked", {"partner": proton_partner.name})

22
app/redis_services.py Normal file
View file

@ -0,0 +1,22 @@
import flask
import limits.storage
from app.parallel_limiter import set_redis_concurrent_lock
from app.session import RedisSessionStore
def initialize_redis_services(app: flask.Flask, redis_url: str):
if redis_url.startswith("redis://") or redis_url.startswith("rediss://"):
storage = limits.storage.RedisStorage(redis_url)
app.session_interface = RedisSessionStore(storage.storage, storage.storage, app)
set_redis_concurrent_lock(storage)
elif redis_url.startswith("redis+sentinel://"):
storage = limits.storage.RedisSentinelStorage(redis_url)
app.session_interface = RedisSessionStore(
storage.storage, storage.storage_slave, app
)
set_redis_concurrent_lock(storage)
else:
raise RuntimeError(
f"Tried to set_redis_session with an invalid redis url: ${redis_url}"
)

Some files were not shown because too many files have changed in this diff Show more