Compare commits

...

316 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
Son Nguyen Kim 69d5de8d41
fix paddle refund (#1213) 2022-08-02 12:43:48 +02:00
Son Nguyen Kim d72226aa19
show proton sub info on admin (#1207) 2022-08-01 20:49:05 +02:00
Son Nguyen Kim abe0e0fc46
fix memory error, deleted user when sending newsletter (#1199) 2022-08-01 20:38:13 +02:00
Carlos Quintana a04152a37f
Do not allow SVG image uploads (#1198) 2022-07-29 08:52:51 +02:00
Adrià Casajús 54466389c5
Update Dockerfile to use python 3.10 (#1195)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-27 18:07:16 +02:00
Adrià Casajús 25fde11a86
Refactor alias suffix (#1194)
* Extract suffix generation and validation to a module

* Updated tests

* Make custom alias use signed suffixes

* Added the signature check to the module

* Fix invalid route

* Move more suffix related stuff

* Fix tests

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-27 17:40:22 +02:00
Adrià Casajús bd044304f0
Added rate limit to resend activation email (#1192)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-26 14:57:26 +02:00
Adrià Casajús f4c5198055
Remove ResetCodes after email change (#1191)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-26 14:43:31 +02:00
Son Nguyen Kim 97805173cb
remove envs used for proton beta link (#1189)
* remove envs used for proton beta link

* remove is_connect_with_proton_enabled()
2022-07-26 12:38:18 +02:00
Son Nguyen Kim c3c0b045db
not blur out other aliases when an alias is highlighted (#1190) 2022-07-26 11:14:33 +02:00
Carlos Quintana 827e3a1acb
Implement mode for Login with Proton (#1186) 2022-07-26 09:55:24 +02:00
Son Nguyen Kim 4f4a098b9b
update wording for proton (#1187)
* update wording for proton

* improve wording
2022-07-25 18:10:30 +02:00
Son Nguyen Kim 125538748d
command send newsletter (#1184) 2022-07-25 11:16:40 +02:00
Son Nguyen Kim 6322e03996
admin can manage newsletter and test sending it (#1177)
* admin can manage newsletter and test sending it

* add comments

* comment

* doc

* not userID not specified, send the newsletter to current user

* automatically match textarea height to content when editing newsletter

* increase text height and limit img size to 100% in email template

* admin can send newsletter to a specific address
2022-07-22 11:24:53 +02:00
Carlos Quintana 7db3ec246e
Mitigate open redirect with OAuth (#1176)
* Mitigate open redirect with OAuth

* Fix tests
2022-07-21 14:23:08 +02:00
Adrià Casajús 598d912f2e
Set ordering to semver (#1175)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-20 12:21:53 +02:00
Adrià Casajús 3fa9db9bb7
Change default unsub behaviour to disable alias by default (#1174)
* Change default unsub behaviour to disable alias by default

* Alter default valut for unsub_behaviouur

* Added comments to the migration

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-20 11:57:34 +02:00
Adrià Casajús 06c1c7f2f7
Restrict the number of free alias for new free users (#1155)
* Restrict the number of free alias for new free users

* Fix test

* Make flag reverse

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-20 11:09:22 +02:00
Son Nguyen Kim 8773ed199a
improve wording (#1168)
* improve wording

* improve wording
2022-07-19 18:58:41 +02:00
Adrià Casajús f3d47a1eaa
Allow users to keep the original unsub behaviour (#1148)
* Feature: Preserve original unsubscribe request

* Updated tests

* Updated settings

* PR comments

* reduced prefix length

* Include migrate users for new unsub behaviour

* PR comments

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-19 17:25:21 +02:00
Son Nguyen Kim 750b6f9038
distinguish between bounce and quarantine (#1167)
* distinguish between bounce and quarantine

- improve wording
- show bounce or quarantine badge

* prettify
2022-07-19 16:00:02 +02:00
Son Nguyen Kim c5773af6a8
remove 15 hardcoding (#1164) 2022-07-19 15:09:46 +02:00
Adrià Casajús afb2ab3758
Allow to configure mem storage from config (#1166)
* Allow to configure memory storage from config

* format

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-19 08:25:59 +02:00
Son Nguyen Kim 36547bd82d
Update wording (#1163)
* rename file

* update wording when adding mailbox

* rename
2022-07-17 15:02:17 +02:00
Adrià Casajús 2837350204
Limit amount of imports (#1161)
* Limit amount of imports

* Review suggestions

* Format

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-16 18:17:15 +02:00
Adrià Casajús bcd4383e05
Sanitize the highlight contact id (#1160)
* Sanitize also parameter

* Formatting

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-15 17:48:42 +02:00
Adrià Casajús 67be5ba050
Enforce int params in routes (#1159)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-15 17:10:00 +02:00
Son Nguyen Kim f367acbeaf
Add next bill date on admin UI (#1154)
* add subscription next bill date on admin

* small refactor: remove unused param
2022-07-12 18:17:39 +02:00
Son Nguyen Kim b742f58829
add a bit of spacing to email template that uses "call" a lot (#1153)
* add a bit of spacing to email template that uses "call" a lot

* apply djlint
2022-07-11 12:06:15 +02:00
Adrià Casajús 2bc088cad7
Disable telegran notificaiton (#1152)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-11 10:27:05 +02:00
Adrià Casajús f75bdd006a
Fix: Allow internal link independent of enable log in with proton (#1151)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-11 09:41:20 +02:00
Son Nguyen Kim 288f086a55
small rename (#1149) 2022-07-08 09:05:38 +02:00
Adrià Casajús 82d0f44cab
Fix: Check if required session headers exist (#1145)
* Check session keys exist

* Update message

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-05 22:26:48 +02:00
Son Nguyen Kim 6aeb710ca0
add nb_proton_user, nb_proton_premium to daily metric email (#1144) 2022-07-05 18:00:28 +02:00
Adrià Casajús 494005eaa5
Fix: Add weird encodings to the list (#1146)
(cherry picked from commit cfed4061e7bf3e34c52518b905065055acb8858e)

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-05 12:19:14 +02:00
Son Nguyen Kim 8fffe72910
fix refund callback (#1143)
fix https://sentry.io/organizations/simplelogin/issues/3370469626/?alert_rule_id=2478639&alert_timestamp=1656988438946&alert_type=email&environment=production&project=1868546&referrer=alert_email
2022-07-05 10:14:30 +02:00
Carlos Quintana 38d305da23
Bypass 2FA if Login with Proton (#1142)
* Bypass 2FA if Login with Proton

* Fix formatting of template
2022-07-04 16:24:49 +02:00
Adrià Casajús c2bb6488e4
Allow to login with proton to enter sudo mode (#1141)
* Allow to login with proton to enter sudo mode

* Updated wording

* lint

* Only enabled if the user has the account linked

* Add exit-sudo route for tests

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-04 16:09:36 +02:00
Adrià Casajús 046748c443
Update pre-commit (#1138)
* Update pre-commit

* Upgrade djlint, remove flake8 and add pylint

* Reformat with new djlint version

* Run pre-commit on CI

* Use only python3.10 on CI

* Reformat files with pre-commit

* Run pre-commit against all files

* Reformat

* Added global excludes

* Added pre-commit to the contributing file

* Set python 3.9 as default

* Set language version to python3

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
Co-authored-by: Carlos Quintana <carlos.quintana@proton.ch>
2022-07-04 16:01:04 +02:00
Carlos Quintana e2f9ea4ae1
Capture exception on Login with Proton (#1140) 2022-07-04 15:40:17 +02:00
Son Nguyen Kim 6d86e64d65
show msg on /internal/integrations/proton (#1139)
* show msg on /internal/integrations/proton

* highlight the connect with Proton section

* djlint
2022-07-04 15:39:12 +02:00
Son Nguyen Kim 2f9301eb97
add 14 days mention and use same stats design for alias activity page (#1136)
* add 14 days mention and use same stats design for alias activity page

* djlint
2022-07-04 11:52:34 +02:00
Adrià Casajús 38c9138cdb
Fix: When logging with parter create accounts with lowercase emails (#1137)
* Fix: When logging with parter create accounts with lowercase emails

* Sanitize emails instead of just lowercase them

* linting

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-04 11:51:43 +02:00
Son Nguyen Kim 66a2152ea3
Compute Proton metrics (#1135)
* compute nb_proton_premium

* compute nb_proton_user
2022-07-04 11:40:29 +02:00
Son Nguyen Kim 02b39f98b7
fix cron job (#1134) 2022-07-04 11:05:42 +02:00
Son Nguyen Kim 8799691f99
allow admin to disable spoofing check on an alias (#1133) 2022-07-04 11:05:13 +02:00
Adrià Casajús aabcc8e72a
Feature: Add delete account route for the api (#1132)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-02 16:45:58 +02:00
Adrià Casajús 88dd07e48d
Feature: Use new job status to retry killed jobs (#1130)
* Feature: Use new job status to retry killed jobs

* Set attermpts and time via config

* Update timing condition

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-07-01 11:14:53 +02:00
Son Nguyen Kim 93968d00b6
update wording (#1131) 2022-06-30 19:19:22 +02:00
Adrià Casajús 8b89a428e0
Fix: clear next in the session before triggering a login (#1129)
* Fix: clear next in the session before triggering a login

* Format

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-06-30 15:41:50 +02:00
Adrià Casajús 21feced342
Refactor unsubscribe handling (#1090)
* Refactor unsubscribe email handling

* MR comments

* Moved all unsub logic to the encoder

* remove unused

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-06-30 11:40:01 +02:00
Adrià Casajús c85ed7d29e
Fix: Always treat references header as a string (#1127)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-06-29 19:48:22 +02:00
Son Nguyen Kim 44ddd95730
fix coupon page (#1126) 2022-06-29 18:21:49 +02:00
Carlos Quintana d06470a3c6
Activate users created with account link (#1124) 2022-06-29 16:55:20 +02:00
Carlos Quintana 9abb8aa47f
Validate user uploaded image (#1123)
* Validate user uploaded image

* Fix test/data path detection
2022-06-29 15:04:55 +02:00
Carlos Quintana cb7868bdca
Add djlint (#1122)
* Add DJlint configuration

* Initial reformat for djlint

* Add template linting to CI

* Add explanation for HTML template checks in CONTRIBUTING.md
2022-06-29 11:28:26 +02:00
Son Nguyen Kim f6a7ee981a
do not send double subscription email (#1118)
* do not send double subscription email

* remove unused import

* remove unused test
2022-06-28 17:51:44 +02:00
Son Nguyen Kim 90b767169b
update welcome proton user email (#1119) 2022-06-28 17:29:05 +02:00
Son Nguyen Kim 75c710a6ab
small refactoring (#1120) 2022-06-28 17:21:23 +02:00
Adrià Casajús aac493ad2f
Update docs and error message for sudo route (#1117)
* Update docs and error message for sudo route

* Fix

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-06-28 14:40:06 +02:00
Carlos Quintana 07b7f40371
Fix prompt user to upgrade to proton account (#1116) 2022-06-28 12:36:21 +02:00
Son Nguyen Kim 89062edc06
show cancel status in "Current plan" section (#1114)
* show cancel status in "Current plan" section

* do not show upgrade button for canceled paddle sub
2022-06-28 11:58:04 +02:00
Carlos Quintana dd0598a4dd
Send welcome email when user created by login with proton (#1115)
* Send welcome email when user created by login with proton

* Add dedicated test to user.created_by_partner
2022-06-28 11:57:21 +02:00
Adrià Casajús 5fa41d6ccf
Add state management to job (#1113)
* Add state management to job

* Add migration

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-06-28 09:22:48 +02:00
Carlos Quintana 686f4f3f68
Always check redirect_uri for oauth (#1111)
* Always check redirect_uri for oauth

* Fix OAuth tests
2022-06-27 13:20:18 +02:00
Carlos Quintana f58c4a9a50
Show premium subscription managed by partner (#1112) 2022-06-27 13:17:30 +02:00
Adrià Casajús de31e6d072
Allow to set sudo mode for api requests (#1108)
* Allow to set sudo mode for api requests

* Rebase migration on top of master

* PR comments

* Added missing migration

* Removed unused import

* Apply suggestions from code review

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-06-23 14:26:36 +02:00
Adrià Casajús 9cc9d38dce
Propose upgrade proton account for proton partner users without paid mail plan (#1106)
* Propose upgrade proton account for proton partner users without paid mail plan

* Reformat js

* Initial display via jinja

* tweak ui: add a ---OR--- separator

* use collapse to show SL upgrade option

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
Co-authored-by: Son <nguyenkims@users.noreply.github.com>
2022-06-23 12:26:02 +02:00
Son Nguyen Kim 09cec0cdec
allow to hide some public domains and set their order (#1107) 2022-06-22 18:21:19 +02:00
Adrià Casajús db6ec2dbe6
Fix: Missing renamed methods (#1105)
Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-06-22 15:34:57 +02:00
Adrià Casajús 99ce10a1bc
Send email to users with a subscription and a partner plan upgrade (#1101)
* Send email to users with a subscription and a partner plan upgrade

* Update double-subscription-partner.html

* Update double-subscription-partner.txt.jinja2

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
Co-authored-by: Son Nguyen Kim <nguyenkims@users.noreply.github.com>
2022-06-20 14:34:20 +02:00
Adrià Casajús fbb59a1531
Send welcome mail to proton created users (#1099)
* Send welcome mail to proton created users

* Skip import

* Use new logo

Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
2022-06-20 11:36:16 +02:00
Carlos Quintana fb1e14e509
Add Proton logo to sign up page (#1104) 2022-06-20 09:13:19 +02:00
Carlos Quintana 1798d411a4
Fix hover color in login with proton button (#1100) 2022-06-17 15:35:36 +02:00
Carlos Quintana 5ee5e386e5
Allow to create users from partner (#1095)
* Allow to create users from partner

* Fix tests

* Update tests/test_account_linking.py

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

* Fix lint

Co-authored-by: Adrià Casajús <acasajus@users.noreply.github.com>
2022-06-16 10:25:50 +02:00
Carlos Quintana ba6c5f93ac
Add extension_redirect endpoint (#1093)
* Add extension_redirect endpoint

* Add test for extension_redirect
2022-06-16 09:56:00 +02:00
Carlos Quintana 332fcb27d9
Fix double backslash open redirect (#1096) 2022-06-16 09:55:08 +02:00
Carlos Quintana 58990ec762
Hide proton integration behind cookie (#1092)
* Hide proton integration behind cookie

* Make cookie name configurable via config
2022-06-15 15:42:41 +02:00
Carlos Quintana b4e3c39329
Add Proton logo to buttons (#1091) 2022-06-15 12:06:11 +02:00
Carlos Quintana 3b47e79fae
Emit events on proton actions (#1089)
* Emit events on proton actions

* Update app/account_linking.py

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

* Update app/account_linking.py

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

Co-authored-by: Adrià Casajús <acasajus@users.noreply.github.com>
2022-06-15 08:24:11 +02:00
Carlos Quintana cf5ff6fa23
Allow extra headers on proton connection (#1087) 2022-06-14 10:29:18 +02:00
428 changed files with 26580 additions and 334499 deletions

View file

@ -7,12 +7,12 @@ assignees: ''
---
Please note that this is only for bug report.
Please note that this is only for bug report.
For help on your account, please reach out to us at hi[at]simplelogin.io. Please make sure to check out [our FAQ](https://simplelogin.io/faq/) that contains frequently asked questions.
For feature request, you can use our [forum](https://github.com/simple-login/app/discussions/categories/feature-request).
For feature request, you can use our [forum](https://github.com/simple-login/app/discussions/categories/feature-request).
For self-hosted question/issue, please ask in [self-hosted forum](https://github.com/simple-login/app/discussions/categories/self-hosting-question)

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}}",
"empty_template": "- no changes",
"categories": [
@ -18,6 +18,6 @@
],
"ignore_labels": ["ignore"],
"tag_resolver": {
"method": "sort"
"method": "semver"
}
}
}

View file

@ -1,15 +1,44 @@
name: Run tests & Publish to Docker Registry
name: Test and lint
on:
on:
push:
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:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: [3.7, "3.10"]
python-version: ["3.10"]
# service containers to run with `postgres-job`
services:
@ -38,27 +67,16 @@ jobs:
--health-retries 5
steps:
- name: Check out repository
uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Check out repo
uses: actions/checkout@v3
- name: Install poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
run: pipx install poetry
- name: Run caching
id: cached-poetry-dependencies
uses: actions/cache@v2
- uses: actions/setup-python@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
python-version: ${{ matrix.python-version }}
cache: 'poetry'
- name: Install OS dependencies
if: ${{ matrix.python-version }} == '3.10'
@ -68,15 +86,13 @@ jobs:
- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root
- name: Install library
run: poetry install --no-interaction
- name: Check formatting & linting
run: |
poetry run black --check .
poetry run flake8
- name: Start Redis v6
uses: superchargejs/redis-github-action@1.1.0
with:
redis-version: 6
- name: Run db migration
run: |
@ -101,7 +117,7 @@ jobs:
build:
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'))
steps:
@ -119,7 +135,19 @@ jobs:
# We need to checkout the repository in order for the "Create Sentry release" to work
- 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
run: |
@ -133,19 +161,13 @@ jobs:
push: true
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
uses: appleboy/telegram-action@master
with:
to: ${{ secrets.TELEGRAM_TO }}
token: ${{ secrets.TELEGRAM_TOKEN }}
args: Docker image pushed on ${{ github.ref }}
#- name: Send Telegram message
# uses: appleboy/telegram-action@master
# with:
# to: ${{ secrets.TELEGRAM_TO }}
# token: ${{ secrets.TELEGRAM_TOKEN }}
# args: Docker image pushed on ${{ github.ref }}
# If we have generated a tag, generate the changelog, send a notification to slack and create the GitHub release
- name: Build Changelog
@ -213,4 +235,4 @@ jobs:
release_name: ${{ github.ref }}
body: ${{ steps.build_changelog.outputs.changelog }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View file

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

View file

@ -1,10 +1,25 @@
exclude: "(migrations|static/node_modules|static/assets|static/vendor)"
default_language_version:
python: python3
repos:
- repo: https://github.com/psf/black
rev: 22.1.0
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.2.0
hooks:
- id: black
language_version: python3.7
- repo: https://github.com/pycqa/flake8
rev: 4.0.1
- id: check-yaml
- id: trailing-whitespace
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.3.0
hooks:
- id: flake8
- id: djlint-jinja
files: '.*\.html'
entry: djlint --reformat
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.1.5
hooks:
# Run the linter.
- id: ruff
args: [ --fix ]
# Run the formatter.
- id: ruff-format

227
.pylintrc Normal file
View file

@ -0,0 +1,227 @@
[MASTER]
extension-pkg-allow-list=re2
fail-under=7.0
ignore=CVS
ignore-paths=migrations
ignore-patterns=^\.#
jobs=0
[MESSAGES CONTROL]
disable=missing-function-docstring,
missing-module-docstring,
duplicate-code,
#import-error,
missing-class-docstring,
useless-object-inheritance,
use-dict-literal,
logging-format-interpolation,
consider-using-f-string,
unnecessary-comprehension,
inconsistent-return-statements,
wrong-import-order,
line-too-long,
invalid-name,
global-statement,
no-else-return,
unspecified-encoding,
logging-fstring-interpolation,
too-few-public-methods,
bare-except,
fixme,
unnecessary-pass,
f-string-without-interpolation,
super-init-not-called,
unused-argument,
ungrouped-imports,
too-many-locals,
consider-using-with,
too-many-statements,
consider-using-set-comprehension,
unidiomatic-typecheck,
useless-else-on-loop,
too-many-return-statements,
broad-except,
protected-access,
consider-using-enumerate,
too-many-nested-blocks,
too-many-branches,
simplifiable-if-expression,
possibly-unused-variable,
pointless-string-statement,
wrong-import-position,
redefined-outer-name,
raise-missing-from,
logging-too-few-args,
redefined-builtin,
too-many-arguments,
import-outside-toplevel,
redefined-argument-from-local,
logging-too-many-args,
too-many-instance-attributes,
unreachable,
no-name-in-module,
no-member,
consider-using-ternary,
too-many-lines,
arguments-differ,
too-many-public-methods,
unused-variable,
consider-using-dict-items,
consider-using-in,
reimported,
too-many-boolean-expressions,
cyclic-import,
not-callable, # (paddle_utils.py) verifier.verify cannot be called (although it can)
abstract-method, # (models.py)
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
#class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style. If left empty, inline iteration names will be checked
# with the set naming style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
#variable-rgx=
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[FORMAT]
max-line-length=88
single-line-if-stmt=yes

View file

@ -117,7 +117,7 @@ Add SUPPORT_NAME param to set a support email name.
## [1.0.1] - 2020-01-28
Simplify config file.
Simplify config file.
## [1.0.0] - 2020-01-22

View file

@ -1,9 +1,9 @@
Thanks for taking the time to contribute! 🎉👍
Before working on a new feature, please get in touch with us at dev[at]simplelogin.io to avoid duplication.
We can also discuss the best way to implement it.
Before working on a new feature, please get in touch with us at dev[at]simplelogin.io to avoid duplication.
We can also discuss the best way to implement it.
The project uses Flask, Python3.7+ and requires Postgres 12+ as dependency.
The project uses Flask, Python3.7+ and requires Postgres 12+ as dependency.
## General Architecture
@ -34,7 +34,7 @@ poetry install
On Mac, sometimes you might need to install some other packages via `brew`:
```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:
@ -43,15 +43,27 @@ You also need to install `gpg` tool, on Mac it can be done with:
brew install gnupg
```
If you see the `pyre2` package in the error message, you might need to install its dependencies with `brew`.
If you see the `pyre2` package in the error message, you might need to install its dependencies with `brew`.
More info on https://github.com/andreasvc/pyre2
```bash
brew install -s re2 pybind11
```
## Linting and static analysis
We use pre-commit to run all our linting and static analysis checks. Please run
```bash
poetry run pre-commit install
```
To install it in your development environment.
## Run tests
For most tests, you will need to have ``redis`` installed and started on your machine (listening on port 6379).
```bash
sh scripts/run-test.sh
```
@ -70,10 +82,16 @@ To run the code locally, please create a local setting file based on `example.en
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:
```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:
@ -145,7 +163,17 @@ The code is also checked with `flake8`, make sure to run `flake8` before creatin
poetry run flake8
```
Nice to have: as we haven't found a good enough HTML code formatter, please reformat any HTML code with PyCharm.
For HTML templates, we use `djlint`. Before creating a pull request, please run
```bash
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
@ -184,4 +212,11 @@ python email_handler.py
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,10 +2,10 @@
FROM node:10.17.0-alpine AS npm
WORKDIR /code
COPY ./static/package*.json /code/static/
RUN cd /code/static && npm install
RUN cd /code/static && npm ci
# Main image
FROM python:3.7
FROM python:3.10
# Keeps Python from generating .pyc files in the container
ENV PYTHONDONTWRITEBYTECODE 1
@ -13,7 +13,7 @@ ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Add poetry to PATH
ENV PATH="${PATH}:/root/.poetry/bin"
ENV PATH="${PATH}:/root/.local/bin"
WORKDIR /code
@ -23,15 +23,15 @@ COPY poetry.lock pyproject.toml ./
# Install and setup poetry
RUN pip install -U pip \
&& apt-get update \
&& apt install -y curl netcat gcc python3-dev gnupg git libre2-dev \
&& curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python - \
&& apt install -y curl netcat-traditional gcc python3-dev gnupg git libre2-dev cmake ninja-build\
&& curl -sSL https://install.python-poetry.org | python3 - \
# Remove curl and netcat from the image
&& apt-get purge -y curl netcat \
&& apt-get purge -y curl netcat-traditional \
# Run poetry
&& poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi --no-root \
# Clear apt cache \
&& apt-get purge -y libre2-dev \
&& apt-get purge -y libre2-dev cmake ninja-build\
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

View file

@ -15,8 +15,8 @@
<img src="https://img.shields.io/github/license/simple-login/app">
</a>
<a href="https://twitter.com/simple_login">
<img src="https://img.shields.io/twitter/follow/simple_login?style=social">
<a href="https://twitter.com/simplelogin">
<img src="https://img.shields.io/twitter/follow/simplelogin?style=social">
</a>
</p>
@ -29,12 +29,12 @@
---
Your email address is your **online identity**. When you use the same email address everywhere, you can be easily tracked.
More information on https://simplelogin.io
Your email address is your **online identity**. When you use the same email address everywhere, you can be easily tracked.
More information on https://simplelogin.io
This README contains instructions on how to self host SimpleLogin.
Once you have your own SimpleLogin instance running, you can change the `API URL` in SimpleLogin's Chrome/Firefox extension, Android/iOS app to your server.
Once you have your own SimpleLogin instance running, you can change the `API URL` in SimpleLogin's Chrome/Firefox extension, Android/iOS app to your server.
SimpleLogin roadmap is at https://github.com/simple-login/app/projects/1 and our forum at https://github.com/simple-login/app/discussions, feel free to submit new ideas or vote on features.
@ -334,6 +334,12 @@ smtpd_recipient_restrictions =
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.
Make sure that the database config is correctly set, replace `mydomain.com` with your domain, update 'myuser' and 'mypassword' with your postgres credentials.
@ -374,10 +380,10 @@ sudo systemctl restart postfix
To run SimpleLogin, you need a config file at `$(pwd)/simplelogin.env`. Below is an example that you can use right away, make sure to
- replace `mydomain.com` by your domain,
- set `FLASK_SECRET` to a secret string,
- set `FLASK_SECRET` to a secret string,
- update 'myuser' and 'mypassword' with your database credentials used in previous step.
All possible parameters can be found in [config example](example.env). Some are optional and are commented out by default.
All possible parameters can be found in [config example](example.env). Some are optional and are commented out by default.
Some have "dummy" values, fill them up if you want to enable these features (Paddle, AWS, etc).
```.env

View file

@ -2,13 +2,13 @@
## Supported Versions
We only add security updates to the latest MAJOR.MINOR version of the project. No security updates are backported to previous versions.
We only add security updates to the latest MAJOR.MINOR version of the project. No security updates are backported to previous versions.
If you want be up to date on security patches, make sure your SimpleLogin image is up to date.
## Reporting a Vulnerability
If you've found a security vulnerability, you can disclose it responsibly by sending a summary to security@simplelogin.io.
We will review the potential threat and fix it as fast as we can.
If you've found a security vulnerability, you can disclose it responsibly by sending a summary to security@simplelogin.io.
We will review the potential threat and fix it as fast as we can.
We are incredibly thankful for people who disclose vulnerabilities, unfortunately we do not have a bounty program in place yet.

View file

@ -1,13 +1,28 @@
from abc import ABC, abstractmethod
from arrow import Arrow
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from arrow import Arrow
from newrelic import agent
from sqlalchemy import or_
from app.db import Session
from app.errors import AccountAlreadyLinkedToAnotherPartnerException
from app.email_utils import send_welcome_email
from app.utils import sanitize_email, canonicalize_email
from app.errors import (
AccountAlreadyLinkedToAnotherPartnerException,
AccountIsUsingAliasAsEmail,
AccountAlreadyLinkedToAnotherUserException,
)
from app.log import LOG
from app.models import PartnerSubscription, Partner, PartnerUser, User
from app.models import (
PartnerSubscription,
Partner,
PartnerUser,
User,
Alias,
)
from app.utils import random_string
@ -28,6 +43,7 @@ class PartnerLinkRequest:
email: str
external_user_id: str
plan: SLPlan
from_partner: bool
@dataclass
@ -44,6 +60,7 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
f"Deleting partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
)
PartnerSubscription.delete(sub.id)
agent.record_custom_event("PlanChange", {"plan": "free"})
else:
if sub is None:
LOG.i(
@ -53,11 +70,16 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
partner_user_id=partner_user.id,
end_at=plan.expiration,
)
agent.record_custom_event("PlanChange", {"plan": "premium", "type": "new"})
else:
LOG.i(
f"Updating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
)
sub.end_at = plan.expiration
if sub.end_at != plan.expiration:
LOG.i(
f"Updating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
)
agent.record_custom_event(
"PlanChange", {"plan": "premium", "type": "extension"}
)
sub.end_at = plan.expiration
Session.commit()
@ -110,10 +132,13 @@ class ClientMergeStrategy(ABC):
class NewUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult:
# Will create a new SL User with a random password
canonical_email = canonicalize_email(self.link_request.email)
new_user = User.create(
email=self.link_request.email,
email=canonical_email,
name=self.link_request.name,
password=random_string(20),
activated=True,
from_partner=self.link_request.from_partner,
)
partner_user = PartnerUser.create(
user_id=new_user.id,
@ -130,15 +155,19 @@ class NewUserStrategy(ClientMergeStrategy):
)
Session.commit()
if not new_user.created_by_partner:
send_welcome_email(new_user)
agent.record_custom_event("PartnerUserCreation", {"partner": self.partner.name})
return LinkResult(
user=new_user,
strategy=self.__class__.__name__,
)
class ExistingUnlinedUserStrategy(ClientMergeStrategy):
class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult:
partner_user = ensure_partner_user_exists_for_user(
self.link_request, self.user, self.partner
)
@ -152,7 +181,7 @@ class ExistingUnlinedUserStrategy(ClientMergeStrategy):
class LinkedWithAnotherPartnerUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult:
raise AccountAlreadyLinkedToAnotherPartnerException()
raise AccountAlreadyLinkedToAnotherUserException()
def get_login_strategy(
@ -166,20 +195,40 @@ def get_login_strategy(
if other_partner_user is not None:
return LinkedWithAnotherPartnerUserStrategy(link_request, user, partner)
# There is a SimpleLogin user with the partner_user's e-mail
return ExistingUnlinedUserStrategy(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(
link_request: PartnerLinkRequest, partner: Partner
) -> LinkResult:
# Sanitize email just in case
link_request.email = sanitize_email(link_request.email)
# Try to find a SimpleLogin user registered with that partner user id
partner_user = PartnerUser.get_by(
partner_id=partner.id, external_user_id=link_request.external_user_id
)
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
# 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
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()
else:
# We found the SL user registered with that partner user id
@ -195,11 +244,14 @@ def process_login_case(
def link_user(
link_request: PartnerLinkRequest, current_user: User, partner: Partner
) -> LinkResult:
# Sanitize email just in case
link_request.email = sanitize_email(link_request.email)
partner_user = ensure_partner_user_exists_for_user(
link_request, current_user, partner
)
set_plan_for_partner_user(partner_user, link_request.plan)
agent.record_custom_event("AccountLinked", {"partner": partner.name})
Session.commit()
return LinkResult(
user=current_user,
@ -237,6 +289,8 @@ def process_link_case(
current_user: User,
partner: Partner,
) -> LinkResult:
# Sanitize email just in case
link_request.email = sanitize_email(link_request.email)
# Try to find a SimpleLogin user linked with this Partner account
partner_user = PartnerUser.get_by(
partner_id=partner.id, external_user_id=link_request.external_user_id
@ -246,7 +300,7 @@ def process_link_case(
return link_user(link_request, current_user, partner)
# 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
set_plan_for_partner_user(partner_user, link_request.plan)
# It's the same user. No need to do anything
@ -255,5 +309,4 @@ def process_link_case(
strategy="Link",
)
else:
return switch_already_linked_user(link_request, partner_user, current_user)

View file

@ -24,12 +24,17 @@ from app.models import (
ProviderComplaintState,
Phase,
ProviderComplaint,
Alias,
Newsletter,
PADDLE_SUBSCRIPTION_GRACE_DAYS,
)
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
class SLModelView(sqla.ModelView):
column_default_sort = ("id", True)
column_display_pk = True
page_size = 100
can_edit = False
can_create = False
@ -89,6 +94,10 @@ class SLAdminIndexView(AdminIndexView):
return redirect("/admin/user")
def _user_upgrade_channel_formatter(view, context, model, name):
return Markup(model.upgrade_channel)
class UserAdmin(SLModelView):
column_searchable_list = ["email", "id"]
column_exclude_list = [
@ -106,6 +115,38 @@ class UserAdmin(SLModelView):
ret.insert(0, "upgrade_channel")
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(
"education_upgrade",
"Education upgrade",
@ -196,6 +237,36 @@ class UserAdmin(SLModelView):
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(
# "login_as",
# "Login as this user",
@ -219,7 +290,7 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
flash(f"user {user} already has a lifetime license", "warning")
continue
sub: Subscription = user.get_subscription()
sub: Subscription = user.get_paddle_subscription()
if sub and not sub.cancelled:
flash(
f"user {user} already has a Paddle license, they have to cancel it first",
@ -269,6 +340,26 @@ class AliasAdmin(SLModelView):
column_searchable_list = ["id", "user.email", "email", "mailbox.email"]
column_filters = ["id", "user.email", "email", "mailbox.email"]
@action(
"disable_email_spoofing_check",
"Disable email spoofing protection",
"Disable email spoofing protection?",
)
def disable_email_spoofing_check_for(self, ids):
for alias in Alias.filter(Alias.id.in_(ids)):
if alias.disable_email_spoofing_check:
flash(
f"Email spoofing protection is already disabled on {alias.email}",
"warning",
)
else:
alias.disable_email_spoofing_check = True
flash(
f"Email spoofing protection is disabled on {alias.email}", "success"
)
Session.commit()
class MailboxAdmin(SLModelView):
column_searchable_list = ["id", "user.email", "email"]
@ -448,3 +539,120 @@ class ProviderComplaintAdmin(SLModelView):
)
},
)
def _newsletter_plain_text_formatter(view, context, model: Newsletter, name):
# to display newsletter plain_text with linebreaks in the list view
return Markup(model.plain_text.replace("\n", "<br>"))
def _newsletter_html_formatter(view, context, model: Newsletter, name):
# to display newsletter html with linebreaks in the list view
return Markup(model.html.replace("\n", "<br>"))
class NewsletterAdmin(SLModelView):
list_template = "admin/model/newsletter-list.html"
edit_template = "admin/model/newsletter-edit.html"
edit_modal = False
can_edit = True
can_create = True
column_formatters = {
"plain_text": _newsletter_plain_text_formatter,
"html": _newsletter_html_formatter,
}
@action(
"send_newsletter_to_user",
"Send this newsletter to myself or the specified userID",
)
def send_newsletter_to_user(self, newsletter_ids):
user_id = request.form["user_id"]
if user_id:
user = User.get(user_id)
if not user:
flash(f"No such user with ID {user_id}", "error")
return
else:
flash("use the current user", "info")
user = current_user
for newsletter_id in newsletter_ids:
newsletter = Newsletter.get(newsletter_id)
sent, error_msg = send_newsletter_to_user(newsletter, user)
if sent:
flash(f"{newsletter} sent to {user}", "success")
else:
flash(error_msg, "error")
@action(
"send_newsletter_to_address",
"Send this newsletter to a specific address",
)
def send_newsletter_to_address(self, newsletter_ids):
to_address = request.form["to_address"]
if not to_address:
flash("to_address missing", "error")
return
for newsletter_id in newsletter_ids:
newsletter = Newsletter.get(newsletter_id)
# use the current_user for rendering email
sent, error_msg = send_newsletter_to_address(
newsletter, current_user, to_address
)
if sent:
flash(
f"{newsletter} sent to {to_address} with {current_user} context",
"success",
)
else:
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):
column_searchable_list = ["id"]
column_filters = ["id", "user.email", "newsletter.subject"]
column_exclude_list = ["created_at", "updated_at", "id"]
can_edit = 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

190
app/alias_suffix.py Normal file
View file

@ -0,0 +1,190 @@
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from typing import Optional
import itsdangerous
from app import config
from app.log import LOG
from app.models import User, AliasOptions, SLDomain
signer = itsdangerous.TimestampSigner(config.CUSTOM_ALIAS_SECRET)
@dataclass
class AliasSuffix:
# whether this is a custom domain
is_custom: bool
# Suffix
suffix: str
# Suffix signature
signed_suffix: str
# whether this is a premium SL domain. Not apply to custom domain
is_premium: bool
# can be either Custom or SL domain
domain: str
# if custom domain, whether the custom domain has MX verified, i.e. can receive emails
mx_verified: bool = True
def serialize(self):
return json.dumps(asdict(self))
@classmethod
def deserialize(cls, data: str) -> AliasSuffix:
return AliasSuffix(**json.loads(data))
def check_suffix_signature(signed_suffix: str) -> Optional[str]:
# hypothesis: user will click on the button in the 600 secs
try:
return signer.unsign(signed_suffix, max_age=600).decode()
except itsdangerous.BadSignature:
return None
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"""
if not alias_prefix or not alias_suffix: # should be caught on frontend
return False
user_custom_domains = [cd.domain for cd in user.verified_custom_domains()]
# make sure alias_suffix is either .random_word@simplelogin.co or @my-domain.com
alias_suffix = alias_suffix.strip()
# alias_domain_prefix is either a .random_word or ""
alias_domain_prefix, alias_domain = alias_suffix.split("@", 1)
# alias_domain must be either one of user custom domains or built-in 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)
return False
# SimpleLogin domain case:
# 1) alias_suffix must start with "." and
# 2) alias_domain_prefix must come from the word list
if (
alias_domain in user.available_sl_domains(alias_options=alias_options)
and alias_domain not in user_custom_domains
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
and not config.DISABLE_ALIAS_SUFFIX
):
if not alias_domain_prefix.startswith("."):
LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix)
return False
else:
if alias_domain not in user_custom_domains:
if not config.DISABLE_ALIAS_SUFFIX:
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False
if alias_domain not in user.available_sl_domains(
alias_options=alias_options
):
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False
return True
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.
"""
user_custom_domains = user.verified_custom_domains()
alias_suffixes: [AliasSuffix] = []
# put custom domain first
# for each user domain, generate both the domain and a random suffix version
for custom_domain in user_custom_domains:
if custom_domain.random_prefix_generation:
suffix = (
f".{user.get_random_alias_suffix(custom_domain)}@{custom_domain.domain}"
)
alias_suffix = AliasSuffix(
is_custom=True,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=False,
domain=custom_domain.domain,
mx_verified=custom_domain.verified,
)
if user.default_alias_custom_domain_id == custom_domain.id:
alias_suffixes.insert(0, alias_suffix)
else:
alias_suffixes.append(alias_suffix)
suffix = f"@{custom_domain.domain}"
alias_suffix = AliasSuffix(
is_custom=True,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=False,
domain=custom_domain.domain,
mx_verified=custom_domain.verified,
)
# put the default domain to top
# only if random_prefix_generation isn't enabled
if (
user.default_alias_custom_domain_id == custom_domain.id
and not custom_domain.random_prefix_generation
):
alias_suffixes.insert(0, alias_suffix)
else:
alias_suffixes.append(alias_suffix)
# then SimpleLogin domain
sl_domains = user.get_sl_domains(alias_options=alias_options)
default_domain_found = False
for sl_domain in sl_domains:
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,
)
# No default or this is not the default
if (
user.default_alias_public_domain_id is None
or user.default_alias_public_domain_id != sl_domain.id
):
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

View file

@ -1,8 +1,11 @@
import csv
from io import StringIO
import re
from typing import Optional, Tuple
from email_validator import validate_email, EmailNotValidError
from sqlalchemy.exc import IntegrityError, DataError
from flask import make_response
from app.config import (
BOUNCE_PREFIX_FOR_REPLY_PHASE,
@ -18,6 +21,8 @@ from app.email_utils import (
send_cannot_create_directory_alias_disabled,
get_email_local_part,
send_cannot_create_domain_alias,
send_email,
render,
)
from app.errors import AliasInTrashError
from app.log import LOG
@ -33,6 +38,8 @@ from app.models import (
EmailLog,
Contact,
AutoCreateRule,
AliasUsedOn,
ClientUser,
)
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(
address, notify_user=notify_user
)
if DomainDeletedAlias.get_by(email=address):
return None
if domain_and_rule:
return domain_and_rule[0].user
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
if not user.can_create_new_alias():
LOG.d(f"{user} can't create new custom-domain alias {address}")
if notify_user:
send_cannot_create_domain_alias(custom_domain.user, address, alias_domain)
return None
@ -146,6 +156,7 @@ def check_if_alias_can_be_auto_created_for_a_directory(
return None
if not user.can_create_new_alias():
LOG.d(f"{user} can't create new directory alias {address}")
if notify_user:
send_cannot_create_directory_alias(user, address, directory_name)
return None
@ -362,3 +373,88 @@ def check_alias_prefix(alias_prefix) -> bool:
return False
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

@ -13,4 +13,25 @@ from .views import (
setting,
export,
phone,
sudo,
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

@ -1,4 +1,5 @@
from functools import wraps
from typing import Tuple, Optional
import arrow
from flask import Blueprint, request, jsonify, g
@ -9,30 +10,58 @@ from app.models import ApiKey
api_bp = Blueprint(name="api", import_name=__name__, url_prefix="/api")
SUDO_MODE_MINUTES_VALID = 5
def authorize_request() -> Optional[Tuple[str, int]]:
api_code = request.headers.get("Authentication")
api_key = ApiKey.get_by(code=api_code)
if not api_key:
if current_user.is_authenticated:
g.user = current_user
else:
return jsonify(error="Wrong api key"), 401
else:
# Update api key stats
api_key.last_used = arrow.now()
api_key.times += 1
Session.commit()
g.user = api_key.user
if g.user.disabled:
return jsonify(error="Disabled account"), 403
g.api_key = api_key
return None
def check_sudo_mode_is_active(api_key: ApiKey) -> bool:
return api_key.sudo_mode_at and g.api_key.sudo_mode_at >= arrow.now().shift(
minutes=-SUDO_MODE_MINUTES_VALID
)
def require_api_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
api_code = request.headers.get("Authentication")
api_key = ApiKey.get_by(code=api_code)
if not api_key:
# if user is authenticated, the request is authorized
if current_user.is_authenticated:
g.user = current_user
else:
return jsonify(error="Wrong api key"), 401
else:
# Update api key stats
api_key.last_used = arrow.now()
api_key.times += 1
Session.commit()
g.user = api_key.user
if g.user.disabled:
return jsonify(error="Disabled account"), 403
error_return = authorize_request()
if error_return:
return error_return
return f(*args, **kwargs)
return decorated
def require_api_sudo(f):
@wraps(f)
def decorated(*args, **kwargs):
error_return = authorize_request()
if error_return:
return error_return
if not check_sudo_mode_is_active(g.api_key):
return jsonify(error="Need sudo"), 440
return f(*args, **kwargs)
return decorated

View file

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

View file

@ -2,10 +2,8 @@ import tldextract
from flask import jsonify, request, g
from sqlalchemy import desc
from app.alias_suffix import get_alias_suffixes
from app.api.base import api_bp, require_api_auth
from app.dashboard.views.custom_alias import (
get_available_suffixes,
)
from app.db import Session
from app.log import LOG
from app.models import AliasUsedOn, Alias, User
@ -68,7 +66,7 @@ def options_v4():
prefix_suggestion = convert_to_id(prefix_suggestion)
ret["prefix_suggestion"] = prefix_suggestion
suffixes = get_available_suffixes(user)
suffixes = get_alias_suffixes(user)
# custom domain should be put first
ret["suffixes"] = list([suffix.suffix, suffix.signed_suffix] for suffix in suffixes)
@ -139,7 +137,7 @@ def options_v5():
prefix_suggestion = convert_to_id(prefix_suggestion)
ret["prefix_suggestion"] = prefix_suggestion
suffixes = get_available_suffixes(user)
suffixes = get_alias_suffixes(user)
# custom domain should be put first
ret["suffixes"] = [

View file

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

View file

@ -23,7 +23,7 @@ from app.events.auth_event import LoginEvent, RegisterEvent
from app.extensions import limiter
from app.log import LOG
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"])
@ -49,11 +49,13 @@ def auth_login():
if not data:
return jsonify(error="request body cannot be empty"), 400
email = sanitize_email(data.get("email"))
password = data.get("password")
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):
LoginEvent(LoginEvent.ActionType.failed, LoginEvent.Source.api).send()
@ -61,6 +63,11 @@ def auth_login():
elif user.disabled:
LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send()
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:
LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send()
return jsonify(error="Account not activated"), 422
@ -89,7 +96,8 @@ def auth_register():
if not data:
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")
if DISABLE_REGISTRATION:
@ -110,7 +118,7 @@ def auth_register():
return jsonify(error="password too long"), 400
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()
# create activation code
@ -148,9 +156,10 @@ def auth_activate():
return jsonify(error="request body cannot be empty"), 400
email = sanitize_email(data.get("email"))
canonical_email = canonicalize_email(data.get("email"))
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
if not user or user.activated:
@ -196,7 +205,9 @@ def auth_reactivate():
return jsonify(error="request body cannot be empty"), 400
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
if not user or user.activated:
@ -351,7 +362,7 @@ def auth_payload(user, device) -> dict:
@api_bp.route("/auth/forgot_password", methods=["POST"])
@limiter.limit("10/minute")
@limiter.limit("2/minute")
def forgot_password():
"""
User forgot password
@ -367,8 +378,9 @@ def forgot_password():
return jsonify(error="request body must contain email"), 400
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:
send_reset_password_email(user)

View file

@ -55,7 +55,7 @@ def auth_mfa():
)
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")
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 jsonify
from flask import make_response
from app.api.base import api_bp, require_api_auth
from app.models import Alias, Client, CustomDomain
from app.alias_utils import alias_export_csv
@api_bp.route("/export/data", methods=["GET"])
@ -49,24 +46,4 @@ def export_aliases():
Importable CSV file
"""
user = 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
return alias_export_csv(g.user)

View file

@ -13,8 +13,8 @@ from app.db import Session
from app.email_utils import (
mailbox_already_used,
email_can_be_used_as_mailbox,
is_valid_email,
)
from app.email_validation import is_valid_email
from app.log import LOG
from app.models import Mailbox, Job
from app.utils import sanitize_email
@ -45,7 +45,7 @@ def create_mailbox():
mailbox_email = sanitize_email(request.get_json().get("email"))
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):
return jsonify(error=f"{mailbox_email} invalid"), 400
@ -71,13 +71,16 @@ def create_mailbox():
)
@api_bp.route("/mailboxes/<mailbox_id>", methods=["DELETE"])
@api_bp.route("/mailboxes/<int:mailbox_id>", methods=["DELETE"])
@require_api_auth
def delete_mailbox(mailbox_id):
"""
Delete mailbox
Input:
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:
200 if deleted successfully
@ -91,11 +94,36 @@ def delete_mailbox(mailbox_id):
if mailbox.id == user.default_mailbox_id:
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
LOG.w("schedule delete mailbox job for %s", mailbox)
Job.create(
name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": mailbox.id},
payload={
"mailbox_id": mailbox.id,
"transfer_mailbox_id": transfer_mailbox_id,
},
run_at=arrow.now(),
commit=True,
)
@ -103,7 +131,7 @@ def delete_mailbox(mailbox_id):
return jsonify(deleted=True), 200
@api_bp.route("/mailboxes/<mailbox_id>", methods=["PUT"])
@api_bp.route("/mailboxes/<int:mailbox_id>", methods=["PUT"])
@require_api_auth
def update_mailbox(mailbox_id):
"""

View file

@ -1,7 +1,8 @@
from flask import g
from flask import jsonify, request
from itsdangerous import SignatureExpired
from app import parallel_limiter
from app.alias_suffix import check_suffix_signature, verify_prefix_suffix
from app.alias_utils import check_alias_prefix
from app.api.base import api_bp, require_api_auth
from app.api.serializer import (
@ -9,7 +10,6 @@ from app.api.serializer import (
get_alias_info_v2,
)
from app.config import MAX_NB_EMAIL_FREE_PLAN, ALIAS_LIMIT
from app.dashboard.views.custom_alias import verify_prefix_suffix, signer
from app.db import Session
from app.extensions import limiter
from app.log import LOG
@ -28,6 +28,7 @@ from app.utils import convert_to_id
@api_bp.route("/v2/alias/custom/new", methods=["POST"])
@limiter.limit(ALIAS_LIMIT)
@require_api_auth
@parallel_limiter.lock(name="alias_creation")
def new_custom_alias_v2():
"""
Create a new custom alias
@ -65,12 +66,11 @@ def new_custom_alias_v2():
note = data.get("note")
alias_prefix = convert_to_id(alias_prefix)
# hypothesis: user will click on the button in the 600 secs
try:
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
except SignatureExpired:
LOG.w("Alias creation time expired for %s", user)
return jsonify(error="Alias creation time is expired, please retry"), 412
alias_suffix = check_suffix_signature(signed_suffix)
if not alias_suffix:
LOG.w("Alias creation time expired for %s", user)
return jsonify(error="Alias creation time is expired, please retry"), 412
except Exception:
LOG.w("Alias suffix is tampered, user %s", user)
return jsonify(error="Tampered suffix"), 400
@ -115,6 +115,7 @@ def new_custom_alias_v2():
@api_bp.route("/v3/alias/custom/new", methods=["POST"])
@limiter.limit(ALIAS_LIMIT)
@require_api_auth
@parallel_limiter.lock(name="alias_creation")
def new_custom_alias_v3():
"""
Create a new custom alias
@ -149,7 +150,7 @@ def new_custom_alias_v3():
if not data:
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
alias_prefix = data.get("alias_prefix", "").strip().lower().replace(" ", "")
@ -167,7 +168,7 @@ def new_custom_alias_v3():
return jsonify(error="alias prefix invalid format or too long"), 400
# 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
mailboxes = []
for mailbox_id in mailbox_ids:
@ -181,10 +182,10 @@ def new_custom_alias_v3():
# hypothesis: user will click on the button in the 600 secs
try:
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
except SignatureExpired:
LOG.w("Alias creation time expired for %s", user)
return jsonify(error="Alias creation time is expired, please retry"), 412
alias_suffix = check_suffix_signature(signed_suffix)
if not alias_suffix:
LOG.w("Alias creation time expired for %s", user)
return jsonify(error="Alias creation time is expired, please retry"), 412
except Exception:
LOG.w("Alias suffix is tampered, user %s", user)
return jsonify(error="Tampered suffix"), 400

View file

@ -2,13 +2,14 @@ import tldextract
from flask import g
from flask import jsonify, request
from app import parallel_limiter
from app.alias_suffix import get_alias_suffixes
from app.api.base import api_bp, require_api_auth
from app.api.serializer import (
get_alias_info_v2,
serialize_alias_info_v2,
)
from app.config import MAX_NB_EMAIL_FREE_PLAN, ALIAS_LIMIT
from app.dashboard.views.custom_alias import get_available_suffixes
from app.db import Session
from app.errors import AliasInTrashError
from app.extensions import limiter
@ -20,6 +21,7 @@ from app.utils import convert_to_id
@api_bp.route("/alias/random/new", methods=["POST"])
@limiter.limit(ALIAS_LIMIT)
@require_api_auth
@parallel_limiter.lock(name="alias_creation")
def new_random_alias():
"""
Create a new random alias
@ -57,7 +59,7 @@ def new_random_alias():
prefix_suggestion = ext.domain
prefix_suggestion = convert_to_id(prefix_suggestion)
suffixes = get_available_suffixes(user)
suffixes = get_alias_suffixes(user)
# use the first suffix
suggested_alias = prefix_suggestion + suffixes[0].suffix
@ -105,8 +107,9 @@ def new_random_alias():
Session.commit()
if hostname and not AliasUsedOn.get_by(alias_id=alias.id, hostname=hostname):
AliasUsedOn.create(alias_id=alias.id, hostname=hostname, user_id=alias.user_id)
Session.commit()
AliasUsedOn.create(
alias_id=alias.id, hostname=hostname, user_id=alias.user_id, commit=True
)
return (
jsonify(alias=alias.email, **serialize_alias_info_v2(get_alias_info_v2(alias))),

View file

@ -60,7 +60,7 @@ def get_notifications():
)
@api_bp.route("/notifications/<notification_id>/read", methods=["POST"])
@api_bp.route("/notifications/<int:notification_id>/read", methods=["POST"])
@require_api_auth
def mark_as_read(notification_id):
"""

View file

@ -9,7 +9,7 @@ from app.models import (
)
@api_bp.route("/phone/reservations/<reservation_id>", methods=["GET", "POST"])
@api_bp.route("/phone/reservations/<int:reservation_id>", methods=["GET", "POST"])
@require_api_auth
def phone_messages(reservation_id):
"""

View file

@ -12,6 +12,7 @@ from app.models import (
SenderFormatEnum,
AliasSuffixEnum,
)
from app.proton.utils import perform_proton_account_unlink
def setting_to_dict(user: User):
@ -137,3 +138,11 @@ def get_available_domains_for_random_alias_v2():
]
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})

27
app/api/views/sudo.py Normal file
View file

@ -0,0 +1,27 @@
from flask import jsonify, g, request
from sqlalchemy_utils.types.arrow import arrow
from app.api.base import api_bp, require_api_auth
from app.db import Session
@api_bp.route("/sudo", methods=["PATCH"])
@require_api_auth
def enter_sudo():
"""
Enter sudo mode
Input
- password: user password to validate request to enter sudo mode
"""
user = g.user
data = request.get_json() or {}
if "password" not in data:
return jsonify(error="Invalid password"), 403
if not user.check_password(data["password"]):
return jsonify(error="Invalid password"), 403
g.api_key.sudo_mode_at = arrow.now()
Session.commit()
return jsonify(ok=True)

46
app/api/views/user.py Normal file
View file

@ -0,0 +1,46 @@
from flask import jsonify, g
from sqlalchemy_utils.types.arrow import arrow
from app.api.base import api_bp, require_api_sudo, require_api_auth
from app import config
from app.extensions import limiter
from app.log import LOG
from app.models import Job, ApiToCookieToken
@api_bp.route("/user", methods=["DELETE"])
@require_api_sudo
def delete_user():
"""
Delete the user. Requires sudo mode.
"""
# Schedule delete account job
LOG.w("schedule delete account job for %s", g.user)
Job.create(
name=config.JOB_DELETE_ACCOUNT,
payload={"user_id": g.user.id},
run_at=arrow.now(),
commit=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,25 +1,42 @@
import base64
import dataclasses
from io import BytesIO
from typing import Optional
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.config import SESSION_COOKIE_NAME
from app.dashboard.views.index import get_stats
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
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:
ret = {
"name": user.name or "",
"is_premium": user.is_premium(),
"email": user.email,
"in_trial": user.in_trial(),
"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:
ret["profile_picture_url"] = user.profile_picture.get_url()
else:
@ -33,6 +50,14 @@ def user_to_dict(user: User) -> dict:
def user_info():
"""
Return user info given the api-key
Output as json
- name
- is_premium
- email
- in_trial
- max_alias_free
- is_connected_with_proton
"""
user = g.user
@ -46,7 +71,6 @@ def update_user_info():
Input
- profile_picture (optional): base64 of the profile picture. Set to null to remove the profile picture
- name (optional)
"""
user = g.user
data = request.get_json() or {}
@ -109,8 +133,27 @@ def logout():
Output:
- 200
"""
logout_user()
logout_session()
response = make_response(jsonify(msg="User is logged out"), 200)
response.delete_cookie(SESSION_COOKIE_NAME)
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,
social,
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

@ -3,7 +3,7 @@ from flask_login import login_user
from app.auth.base import auth_bp
from app.db import Session
from app.models import EmailChange
from app.models import EmailChange, ResetPasswordCode
@auth_bp.route("/change_email", methods=["GET", "POST"])
@ -25,6 +25,7 @@ def change_email():
user.email = email_change.new_email
EmailChange.delete(email_change.id)
ResetPasswordCode.filter_by(user_id=user.id).delete()
Session.commit()
flash("Your new email has been updated", "success")

View file

@ -1,5 +1,6 @@
import json
import secrets
from time import time
import webauthn
from flask import (
@ -61,7 +62,7 @@ def fido():
browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
if browser and not browser.is_expired() and browser.user_id == user.id:
login_user(user)
flash(f"Welcome back!", "success")
flash("Welcome back!", "success")
# Redirect user to correct page
return redirect(next_url or url_for("dashboard.index"))
else:
@ -107,8 +108,9 @@ def fido():
Session.commit()
del session[MFA_USER_ID]
session["sudo_time"] = int(time())
login_user(user)
flash(f"Welcome back!", "success")
flash("Welcome back!", "success")
# Redirect user to correct page
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 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.log import LOG
from app.models import User
from app.utils import sanitize_email
from app.utils import sanitize_email, canonicalize_email
class ForgotPasswordForm(FlaskForm):
@ -16,7 +16,7 @@ class ForgotPasswordForm(FlaskForm):
@auth_bp.route("/forgot_password", methods=["GET", "POST"])
@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():
form = ForgotPasswordForm(request.form)
@ -25,16 +25,17 @@ def forgot_password():
# Trigger rate limiter
g.deduct_limit = True
email = sanitize_email(form.email.data)
flash(
"If your email is correct, you are going to receive an email to reset your password",
"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:
LOG.d("Send forgot password email to %s", user)
send_reset_password_email(user)
return redirect(url_for("auth.forgot_password"))
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.log import LOG
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):
@ -38,7 +38,9 @@ def login():
show_resend_activation = False
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):
# Trigger rate limiter
@ -52,6 +54,12 @@ def login():
"error",
)
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:
show_resend_activation = True
flash(

View file

@ -1,3 +1,4 @@
from time import time
from typing import Optional
from flask import session, redirect, url_for, request
@ -8,37 +9,40 @@ from app.log import LOG
from app.models import Referral
def after_login(user, next_url):
def after_login(user, next_url, login_from_proton: bool = False):
"""
Redirect to the correct page after login.
If the user is logged in with Proton, do not look at fido nor otp
If user enables MFA: redirect user to MFA page
Otherwise redirect to dashboard page if no next_url
"""
if user.fido_enabled():
# Use the same session for FIDO so that we can easily
# switch between these two 2FA option
session[MFA_USER_ID] = user.id
if next_url:
return redirect(url_for("auth.fido", next=next_url))
else:
return redirect(url_for("auth.fido"))
elif user.enable_otp:
session[MFA_USER_ID] = user.id
if next_url:
return redirect(url_for("auth.mfa", next=next_url))
else:
return redirect(url_for("auth.mfa"))
else:
LOG.d("log user %s in", user)
login_user(user)
if not login_from_proton:
if user.fido_enabled():
# Use the same session for FIDO so that we can easily
# switch between these two 2FA option
session[MFA_USER_ID] = user.id
if next_url:
return redirect(url_for("auth.fido", next=next_url))
else:
return redirect(url_for("auth.fido"))
elif user.enable_otp:
session[MFA_USER_ID] = user.id
if next_url:
return redirect(url_for("auth.mfa", next=next_url))
else:
return redirect(url_for("auth.mfa"))
# User comes to login page from another page
if next_url:
LOG.d("redirect user to %s", next_url)
return redirect(next_url)
else:
LOG.d("redirect user to dashboard")
return redirect(url_for("dashboard.index"))
LOG.d("log user %s in", user)
login_user(user)
session["sudo_time"] = int(time())
# User comes to login page from another page
if next_url:
LOG.d("redirect user to %s", next_url)
return redirect(next_url)
else:
LOG.d("redirect user to dashboard")
return redirect(url_for("dashboard.index"))
# name of the cookie that stores the referral code

View file

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

View file

@ -55,7 +55,7 @@ def mfa():
browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
if browser and not browser.is_expired() and browser.user_id == user.id:
login_user(user)
flash(f"Welcome back!", "success")
flash("Welcome back!", "success")
# Redirect user to correct page
return redirect(next_url or url_for("dashboard.index"))
else:
@ -67,13 +67,13 @@ def mfa():
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]
user.last_otp = token
Session.commit()
login_user(user)
flash(f"Welcome back!", "success")
flash("Welcome back!", "success")
# Redirect user to correct page
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_login import current_user
from requests_oauthlib import OAuth2Session
from typing import Optional
from app.auth.base import auth_bp
from app.auth.views.login_utils import after_login
@ -10,16 +11,20 @@ from app.config import (
PROTON_BASE_URL,
PROTON_CLIENT_ID,
PROTON_CLIENT_SECRET,
PROTON_EXTRA_HEADER_NAME,
PROTON_EXTRA_HEADER_VALUE,
PROTON_VALIDATE_CERTS,
URL,
)
from app.log import LOG
from app.models import ApiKey, User
from app.proton.proton_client import HttpProtonClient, convert_access_token
from app.proton.proton_callback_handler import (
ProtonCallbackHandler,
Action,
get_proton_partner,
)
from app.utils import sanitize_next_url
from app.proton.utils import get_proton_partner
from app.utils import sanitize_next_url, sanitize_scheme
_authorization_base_url = PROTON_BASE_URL + "/oauth/authorize"
_token_url = PROTON_BASE_URL + "/oauth/token"
@ -28,19 +33,35 @@ _token_url = PROTON_BASE_URL + "/oauth/token"
# when served behind nginx, the redirect_uri is localhost... and not the real url
_redirect_uri = URL + "/auth/proton/callback"
SESSION_ACTION_KEY = "oauth_action"
SESSION_STATE_KEY = "oauth_state"
DEFAULT_SCHEME = "auth.simplelogin"
def extract_action() -> Action:
def get_api_key_for_user(user: User) -> str:
ak = ApiKey.create(
user_id=user.id,
name="Created via Login with Proton on mobile app",
commit=True,
)
return ak.code
def extract_action() -> Optional[Action]:
action = request.args.get("action")
if action is not None:
if action == "link":
return Action.Link
elif action == "login":
return Action.Login
else:
raise Exception(f"Unknown action: {action}")
LOG.w(f"Unknown action received: {action}")
return None
return Action.Login
def get_action_from_state() -> Action:
oauth_action = session["oauth_action"]
oauth_action = session[SESSION_ACTION_KEY]
if oauth_action == Action.Login.value:
return Action.Login
elif oauth_action == Action.Link.value:
@ -53,20 +74,44 @@ def proton_login():
if PROTON_CLIENT_ID is None or PROTON_CLIENT_SECRET is None:
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"))
if next_url:
session["oauth_next"] = next_url
elif "oauth_next" in session:
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")
if mode == "apikey":
session["oauth_mode"] = "apikey"
else:
session["oauth_mode"] = "session"
proton = OAuth2Session(PROTON_CLIENT_ID, redirect_uri=_redirect_uri)
authorization_url, state = proton.authorization_url(_authorization_base_url)
# State is used to prevent CSRF, keep this for later.
session["oauth_state"] = state
session["oauth_action"] = extract_action().value
session[SESSION_STATE_KEY] = state
session[SESSION_ACTION_KEY] = action.value
return redirect(authorization_url)
@auth_bp.route("/proton/callback")
def proton_callback():
if SESSION_STATE_KEY not in session or SESSION_STATE_KEY not in session:
flash("Invalid state, please retry", "error")
return redirect(url_for("auth.login"))
if PROTON_CLIENT_ID is None or PROTON_CLIENT_SECRET is None:
return redirect(url_for("auth.login"))
@ -77,7 +122,7 @@ def proton_callback():
proton = OAuth2Session(
PROTON_CLIENT_ID,
state=session["oauth_state"],
state=session[SESSION_STATE_KEY],
redirect_uri=_redirect_uri,
)
@ -89,14 +134,26 @@ def proton_callback():
return response
proton.register_compliance_hook("access_token_response", check_status_code)
token = proton.fetch_token(
_token_url,
client_secret=PROTON_CLIENT_SECRET,
authorization_response=request.url,
verify=PROTON_VALIDATE_CERTS,
method="GET",
include_client_id=True,
)
headers = None
if PROTON_EXTRA_HEADER_NAME and PROTON_EXTRA_HEADER_VALUE:
headers = {PROTON_EXTRA_HEADER_NAME: PROTON_EXTRA_HEADER_VALUE}
try:
token = proton.fetch_token(
_token_url,
client_secret=PROTON_CLIENT_SECRET,
authorization_response=request.url,
verify=PROTON_VALIDATE_CERTS,
method="GET",
include_client_id=True,
headers=headers,
)
except Exception as e:
LOG.warning(f"Error fetching Proton token: {e}")
flash("There was an error in the login process", "error")
return redirect(url_for("auth.login"))
credentials = convert_access_token(token["access_token"])
action = get_action_from_state()
@ -106,6 +163,7 @@ def proton_callback():
handler = ProtonCallbackHandler(proton_client)
proton_partner = get_proton_partner()
next_url = session.get("oauth_next")
if action == Action.Login:
res = handler.handle_login(proton_partner)
elif action == Action.Link:
@ -116,11 +174,17 @@ def proton_callback():
if res.flash_message is not None:
flash(res.flash_message, res.flash_category)
oauth_scheme = session.get("oauth_scheme")
if session.get("oauth_mode", "session") == "apikey":
apikey = get_api_key_for_user(res.user)
scheme = oauth_scheme or DEFAULT_SCHEME
return redirect(f"{scheme}:///login?apikey={apikey}")
if res.redirect_to_login:
return redirect(url_for("auth.login"))
if res.redirect:
return after_login(res.user, res.redirect)
if next_url and next_url[0] == "/" and oauth_scheme:
next_url = f"{oauth_scheme}://{next_url}"
next_url = session.get("oauth_next")
return after_login(res.user, next_url)
redirect_url = next_url or res.redirect
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():
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.used:
@ -53,7 +53,7 @@ def recovery_route():
del session[MFA_USER_ID]
login_user(user)
flash(f"Welcome back!", "success")
flash("Welcome back!", "success")
recovery_code.used = True
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.log import LOG
from app.models import User, ActivationCode
from app.utils import random_string, encode_url, sanitize_email
from app.models import User, ActivationCode, DailyMetric
from app.utils import random_string, encode_url, sanitize_email, canonicalize_email
class RegisterForm(FlaskForm):
@ -70,19 +70,22 @@ def register():
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):
flash("You cannot use this email address as your personal inbox.", "error")
RegisterEvent(RegisterEvent.ActionType.email_in_use).send()
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")
RegisterEvent(RegisterEvent.ActionType.email_in_use).send()
else:
LOG.d("create user %s", email)
user = User.create(
email=email,
name="",
name=form.email.data,
password=form.password.data,
referral=get_referral(),
)
@ -91,6 +94,8 @@ def register():
try:
send_activation_email(user, next_url)
RegisterEvent(RegisterEvent.ActionType.success).send()
DailyMetric.get_or_create_today_metric().nb_new_web_non_proton_user += 1
Session.commit()
except Exception:
flash("Invalid email, are you sure the email is correct?", "error")
RegisterEvent(RegisterEvent.ActionType.invalid_email).send()

View file

@ -4,9 +4,10 @@ from wtforms import StringField, validators
from app.auth.base import auth_bp
from app.auth.views.register import send_activation_email
from app.extensions import limiter
from app.log import LOG
from app.models import User
from app.utils import sanitize_email
from app.utils import sanitize_email, canonicalize_email
class ResendActivationForm(FlaskForm):
@ -14,11 +15,14 @@ class ResendActivationForm(FlaskForm):
@auth_bp.route("/resend_activation", methods=["GET", "POST"])
@limiter.limit("10/hour")
def resend_activation():
form = ResendActivationForm(request.form)
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:
flash("There is no such email", "warning")

View file

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

View file

@ -8,7 +8,6 @@ from urllib.parse import urlparse
from dotenv import load_dotenv
ROOT_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
@ -97,6 +96,8 @@ except Exception:
print("MAX_NB_EMAIL_FREE_PLAN is not set, use 5 as default value")
MAX_NB_EMAIL_FREE_PLAN = 5
MAX_NB_EMAIL_OLD_FREE_PLAN = int(os.environ.get("MAX_NB_EMAIL_OLD_FREE_PLAN", 15))
# maximum number of directory a premium user can create
MAX_NB_DIRECTORY = 50
MAX_NB_SUBDOMAIN = 5
@ -110,13 +111,16 @@ POSTFIX_SERVER = os.environ.get("POSTFIX_SERVER", "240.0.0.1")
DISABLE_REGISTRATION = "DISABLE_REGISTRATION" in os.environ
# 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
# Useful when calling Postfix from an external network
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"]
OTHER_ALIAS_DOMAINS = sl_getenv("OTHER_ALIAS_DOMAINS", list)
@ -159,6 +163,7 @@ if "DKIM_PRIVATE_KEY_PATH" in os.environ:
# Database
DB_URI = os.environ["DB_URI"]
DB_CONN_NAME = os.environ.get("DB_CONN_NAME", "webapp")
# Flask secret
FLASK_SECRET = os.environ["FLASK_SECRET"]
@ -167,6 +172,7 @@ if not FLASK_SECRET:
SESSION_COOKIE_NAME = "slapp"
MAILBOX_SECRET = FLASK_SECRET + "mailbox"
CUSTOM_ALIAS_SECRET = FLASK_SECRET + "custom_alias"
UNSUBSCRIBE_SECRET = FLASK_SECRET + "unsub"
# AWS
AWS_REGION = os.environ.get("AWS_REGION") or "eu-west-3"
@ -244,6 +250,8 @@ PROTON_BASE_URL = os.environ.get(
)
PROTON_VALIDATE_CERTS = "PROTON_VALIDATE_CERTS" in os.environ
CONNECT_WITH_PROTON = "CONNECT_WITH_PROTON" in os.environ
PROTON_EXTRA_HEADER_NAME = os.environ.get("PROTON_EXTRA_HEADER_NAME")
PROTON_EXTRA_HEADER_VALUE = os.environ.get("PROTON_EXTRA_HEADER_VALUE")
# in seconds
AVATAR_URL_EXPIRATION = 3600 * 24 * 7 # 1h*24h/d*7d=1week
@ -264,6 +272,7 @@ JOB_DELETE_ACCOUNT = "delete-account"
JOB_DELETE_MAILBOX = "delete-mailbox"
JOB_DELETE_DOMAIN = "delete-domain"
JOB_SEND_USER_REPORT = "send-user-report"
JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1"
# for pagination
PAGE_LIMIT = 20
@ -347,6 +356,9 @@ ALERT_COMPLAINT_TRANSACTIONAL_PHASE = "alert_complaint_transactional_phase"
ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc"
ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER = "alert_dual_sub_with_partner"
ALERT_WARN_MULTIPLE_SUBSCRIPTIONS = "alert_multiple_subscription"
# <<<<< END ALERT EMAIL >>>>
# Disable onboarding emails
@ -480,3 +492,50 @@ DISABLE_CREATE_CONTACTS_FOR_FREE_USERS = False
PARTNER_API_TOKEN_SECRET = os.environ.get("PARTNER_API_TOKEN_SECRET") or (
FLASK_SECRET + "partnerapitoken"
)
JOB_MAX_ATTEMPTS = 5
JOB_TAKEN_RETRY_WAIT_MINS = 30
# MEM_STORE
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,
billing,
alias_log,
alias_export,
unsubscribe,
api_key,
custom_domain,
@ -23,7 +24,6 @@ from .views import (
mailbox_detail,
refused_email,
referral,
recovery_code,
contact_detail,
setup_done,
batch_import,
@ -33,3 +33,39 @@ from .views import (
notification,
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
# 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.db import Session
from app.email_utils import (
is_valid_email,
generate_reply_email,
parse_full_address,
)
from app.email_validation import is_valid_email
from app.errors import (
CannotCreateContactForReverseAlias,
ErrContactErrorUpgradeNeeded,
@ -25,7 +25,7 @@ from app.errors import (
)
from app.log import LOG
from app.models import Alias, Contact, EmailLog, User
from app.utils import sanitize_email
from app.utils import sanitize_email, CSRFValidationForm
def email_validator():
@ -90,7 +90,7 @@ def create_contact(user: User, alias: Alias, contact_address: str) -> Contact:
alias_id=alias.id,
website_email=contact_email,
name=contact_name,
reply_email=generate_reply_email(contact_email, user),
reply_email=generate_reply_email(contact_email, alias),
)
LOG.d(
@ -229,12 +229,17 @@ def delete_contact(alias: Alias, contact_id: int):
flash(f"Reverse-alias for {delete_contact_email} has been deleted", "success")
@dashboard_bp.route("/alias_contact_manager/<alias_id>/", methods=["GET", "POST"])
@dashboard_bp.route("/alias_contact_manager/<int:alias_id>/", methods=["GET", "POST"])
@login_required
@parallel_limiter.lock(name="contact_creation")
def alias_contact_manager(alias_id):
highlight_contact_id = None
if request.args.get("highlight_contact_id"):
highlight_contact_id = int(request.args.get("highlight_contact_id"))
try:
highlight_contact_id = int(request.args.get("highlight_contact_id"))
except ValueError:
flash("Invalid contact id", "error")
return redirect(url_for("dashboard.index"))
alias = Alias.get(alias_id)
@ -254,8 +259,12 @@ def alias_contact_manager(alias_id):
return redirect(url_for("dashboard.index"))
new_contact_form = NewContactForm()
csrf_form = CSRFValidationForm()
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 new_contact_form.validate():
contact_address = new_contact_form.email.data.strip()
@ -319,4 +328,5 @@ def alias_contact_manager(alias_id):
query=query,
nb_contact=nb_contact,
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,
)
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

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 app import config
from app.alias_utils import transfer_alias
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.email_utils import send_email, render
from app.extensions import limiter
from app.log import LOG
from app.models import (
Alias,
Contact,
AliasUsedOn,
AliasMailbox,
User,
ClientUser,
)
from app.models import Mailbox
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()
from app.utils import CSRFValidationForm
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"))
alias_transfer_url = None
csrf_form = CSRFValidationForm()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
# generate a new transfer_token
if request.form.get("form-name") == "create":
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,
link_active=alias.transfer_token_expiration is not None
and alias.transfer_token_expiration > arrow.utcnow(),
csrf_form=csrf_form,
)
@ -208,7 +154,13 @@ def alias_transfer_receive_route():
mailboxes,
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")
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 wtforms import StringField, validators
from app import config
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.extensions import limiter
from app.models import ApiKey
from app.utils import CSRFValidationForm
class NewApiKeyForm(FlaskForm):
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"])
@login_required
@sudo_required
@limiter.limit("10/hour")
def api_key():
api_keys = (
ApiKey.filter(ApiKey.user_id == current_user.id)
@ -23,9 +51,13 @@ def api_key():
.all()
)
csrf_form = CSRFValidationForm()
new_api_key_form = NewApiKeyForm()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "delete":
api_key_id = request.form.get("api-key-id")
@ -45,6 +77,7 @@ def api_key():
elif request.form.get("form-name") == "create":
if new_api_key_form.validate():
clean_up_unused_or_old_api_keys(current_user.id)
new_api_key = ApiKey.create(
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 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_login import login_required, current_user
from sqlalchemy.orm import joinedload
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.models import (
ClientUser,
)
@ -17,6 +12,10 @@ from app.models import (
@dashboard_bp.route("/app", methods=["GET", "POST"])
@login_required
def app_route():
"""
List of apps that user has used via the "Sign in with SimpleLogin"
"""
client_users = (
ClientUser.filter_by(user_id=current_user.id)
.options(joinedload(ClientUser.client))

View file

@ -8,7 +8,7 @@ from app.dashboard.base import dashboard_bp
from app.db import Session
from app.log import LOG
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"])
@ -25,9 +25,27 @@ def batch_import_route():
)
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 not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if len(batch_imports) > 10:
flash(
"You have too many imports already. Wait until some get cleaned up",
"error",
)
return render_template(
"dashboard/batch_import.html",
batch_imports=batch_imports,
csrf_form=csrf_form,
)
alias_file = request.files["alias-file"]
file_path = random_string(20) + ".csv"
@ -55,4 +73,6 @@ def 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

@ -13,7 +13,7 @@ from app.paddle_utils import cancel_subscription, change_plan
@login_required
def billing():
# sanity check: make sure this page is only for user who has paddle subscription
sub: Subscription = current_user.get_subscription()
sub: Subscription = current_user.get_paddle_subscription()
if not sub:
flash("You don't have any active subscription", "warning")

View file

@ -1,5 +1,7 @@
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.dashboard.base import dashboard_bp
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
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"])
@login_required
def contact_detail_route(contact_id):
@ -16,33 +26,41 @@ def contact_detail_route(contact_id):
return redirect(url_for("dashboard.index"))
alias = contact.alias
pgp_form = PGPContactForm()
if request.method == "POST":
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():
flash("Only premium plan can add PGP Key", "warning")
return redirect(
url_for("dashboard.contact_detail_route", contact_id=contact_id)
)
contact.pgp_public_key = request.form.get("pgp")
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")
if not pgp_form.pgp.data:
flash("Invalid pgp key")
else:
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 request.form.get("action") == "remove":
contact.pgp_public_key = pgp_form.pgp.data
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:
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
contact.pgp_public_key = None
contact.pgp_finger_print = None
@ -53,5 +71,5 @@ def contact_detail_route(contact_id):
)
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 wtforms import StringField, validators
from app import parallel_limiter
from app.config import PADDLE_VENDOR_ID, PADDLE_COUPON_ID
from app.dashboard.base import dashboard_bp
from app.db import Session
@ -24,6 +25,7 @@ class CouponForm(FlaskForm):
@dashboard_bp.route("/coupon", methods=["GET", "POST"])
@login_required
@parallel_limiter.lock()
def coupon_route():
coupon_form = CouponForm()
@ -40,7 +42,7 @@ def coupon_route():
if current_user.lifetime:
can_use_coupon = False
sub: Subscription = current_user.get_subscription()
sub: Subscription = current_user.get_paddle_subscription()
if sub:
can_use_coupon = False
@ -66,9 +68,14 @@ def coupon_route():
)
return redirect(request.url)
coupon.used_by_user_id = current_user.id
coupon.used = True
Session.commit()
updated = (
Session.query(Coupon)
.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(
user_id=current_user.id
@ -93,7 +100,7 @@ def coupon_route():
commit=True,
)
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",
)

View file

@ -1,16 +1,16 @@
import json
from dataclasses import dataclass, asdict
from email_validator import validate_email, EmailNotValidError
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from itsdangerous import TimestampSigner, SignatureExpired
from sqlalchemy.exc import IntegrityError
from app import parallel_limiter
from app.alias_suffix import (
get_alias_suffixes,
check_suffix_signature,
verify_prefix_suffix,
)
from app.alias_utils import check_alias_prefix
from app.config import (
DISABLE_ALIAS_SUFFIX,
CUSTOM_ALIAS_SECRET,
ALIAS_LIMIT,
)
from app.dashboard.base import dashboard_bp
@ -19,180 +19,17 @@ from app.extensions import limiter
from app.log import LOG
from app.models import (
Alias,
CustomDomain,
DeletedAlias,
Mailbox,
User,
AliasMailbox,
DomainDeletedAlias,
)
signer = TimestampSigner(CUSTOM_ALIAS_SECRET)
@dataclass
class SuffixInfo:
"""
Alias suffix info
WARNING: should use AliasSuffix instead
"""
# whether this is a custom domain
is_custom: bool
suffix: str
signed_suffix: str
# whether this is a premium SL domain. Not apply to custom domain
is_premium: bool
def get_available_suffixes(user: User) -> [SuffixInfo]:
"""
WARNING: should use get_alias_suffixes() instead
"""
user_custom_domains = user.verified_custom_domains()
suffixes: [SuffixInfo] = []
# put custom domain first
# for each user domain, generate both the domain and a random suffix version
for custom_domain in user_custom_domains:
if custom_domain.random_prefix_generation:
suffix = "." + user.get_random_alias_suffix() + "@" + custom_domain.domain
suffix_info = SuffixInfo(True, suffix, signer.sign(suffix).decode(), False)
if user.default_alias_custom_domain_id == custom_domain.id:
suffixes.insert(0, suffix_info)
else:
suffixes.append(suffix_info)
suffix = "@" + custom_domain.domain
suffix_info = SuffixInfo(True, suffix, signer.sign(suffix).decode(), False)
# put the default domain to top
# only if random_prefix_generation isn't enabled
if (
user.default_alias_custom_domain_id == custom_domain.id
and not custom_domain.random_prefix_generation
):
suffixes.insert(0, suffix_info)
else:
suffixes.append(suffix_info)
# then SimpleLogin domain
for sl_domain in user.get_sl_domains():
suffix = (
("" if DISABLE_ALIAS_SUFFIX else "." + user.get_random_alias_suffix())
+ "@"
+ sl_domain.domain
)
suffix_info = SuffixInfo(
False, suffix, signer.sign(suffix).decode(), sl_domain.premium_only
)
# put the default domain to top
if user.default_alias_public_domain_id == sl_domain.id:
suffixes.insert(0, suffix_info)
else:
suffixes.append(suffix_info)
return suffixes
@dataclass
class AliasSuffix:
# whether this is a custom domain
is_custom: bool
suffix: str
# whether this is a premium SL domain. Not apply to custom domain
is_premium: bool
# can be either Custom or SL domain
domain: str
# if custom domain, whether the custom domain has MX verified, i.e. can receive emails
mx_verified: bool = True
def serialize(self):
return json.dumps(asdict(self))
@classmethod
def deserialize(cls, data: str) -> "AliasSuffix":
return AliasSuffix(**json.loads(data))
def get_alias_suffixes(user: User) -> [AliasSuffix]:
"""
Similar to as get_available_suffixes() but also return custom domain that doesn't have MX set up.
"""
user_custom_domains = CustomDomain.filter_by(
user_id=user.id, ownership_verified=True
).all()
alias_suffixes: [AliasSuffix] = []
# put custom domain first
# for each user domain, generate both the domain and a random suffix version
for custom_domain in user_custom_domains:
if custom_domain.random_prefix_generation:
suffix = "." + user.get_random_alias_suffix() + "@" + custom_domain.domain
alias_suffix = AliasSuffix(
is_custom=True,
suffix=suffix,
is_premium=False,
domain=custom_domain.domain,
mx_verified=custom_domain.verified,
)
if user.default_alias_custom_domain_id == custom_domain.id:
alias_suffixes.insert(0, alias_suffix)
else:
alias_suffixes.append(alias_suffix)
suffix = "@" + custom_domain.domain
alias_suffix = AliasSuffix(
is_custom=True,
suffix=suffix,
is_premium=False,
domain=custom_domain.domain,
mx_verified=custom_domain.verified,
)
# put the default domain to top
# only if random_prefix_generation isn't enabled
if (
user.default_alias_custom_domain_id == custom_domain.id
and not custom_domain.random_prefix_generation
):
alias_suffixes.insert(0, alias_suffix)
else:
alias_suffixes.append(alias_suffix)
# then SimpleLogin domain
for sl_domain in user.get_sl_domains():
suffix = (
("" if DISABLE_ALIAS_SUFFIX else "." + user.get_random_alias_suffix())
+ "@"
+ sl_domain.domain
)
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
is_premium=sl_domain.premium_only,
domain=sl_domain.domain,
mx_verified=True,
)
# put the default domain to top
if user.default_alias_public_domain_id == sl_domain.id:
alias_suffixes.insert(0, alias_suffix)
else:
alias_suffixes.append(alias_suffix)
return alias_suffixes
@dashboard_bp.route("/custom_alias", methods=["GET", "POST"])
@limiter.limit(ALIAS_LIMIT, methods=["POST"])
@login_required
@parallel_limiter.lock(name="alias_creation")
def custom_alias():
# check if user has not exceeded the alias quota
if not current_user.can_create_new_alias():
@ -211,11 +48,6 @@ def custom_alias():
at_least_a_premium_domain = True
break
alias_suffixes_with_signature = [
(alias_suffix, signer.sign(alias_suffix.serialize()).decode())
for alias_suffix in alias_suffixes
]
mailboxes = current_user.mailboxes()
if request.method == "POST":
@ -249,25 +81,19 @@ def custom_alias():
flash("At least one mailbox must be selected", "error")
return redirect(request.url)
# hypothesis: user will click on the button in the 600 secs
try:
signed_alias_suffix_decoded = signer.unsign(
signed_alias_suffix, max_age=600
).decode()
alias_suffix: AliasSuffix = AliasSuffix.deserialize(
signed_alias_suffix_decoded
)
except SignatureExpired:
LOG.w("Alias creation time expired for %s", current_user)
flash("Alias creation time is expired, please retry", "warning")
return redirect(request.url)
suffix = check_suffix_signature(signed_alias_suffix)
if not suffix:
LOG.w("Alias creation time expired for %s", current_user)
flash("Alias creation time is expired, please retry", "warning")
return redirect(request.url)
except Exception:
LOG.w("Alias suffix is tampered, user %s", current_user)
flash("Unknown error, refresh the page", "error")
return redirect(request.url)
if verify_prefix_suffix(current_user, alias_prefix, alias_suffix.suffix):
full_alias = alias_prefix + alias_suffix.suffix
if verify_prefix_suffix(current_user, alias_prefix, suffix):
full_alias = alias_prefix + suffix
if ".." in full_alias:
flash("Your alias can't contain 2 consecutive dots (..)", "error")
@ -294,18 +120,11 @@ def custom_alias():
email=full_alias
)
custom_domain = domain_deleted_alias.domain
if domain_deleted_alias.user_id == current_user.id:
flash(
f"You have deleted this alias before. You can restore it on "
f"{custom_domain.domain} 'Deleted Alias' page",
"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,
)
flash(
f"You have deleted this alias before. You can restore it on "
f"{custom_domain.domain} 'Deleted Alias' page",
"error",
)
elif DeletedAlias.get_by(email=full_alias):
flash(general_error_msg, "error")
@ -342,51 +161,7 @@ def custom_alias():
return render_template(
"dashboard/custom_alias.html",
user_custom_domains=user_custom_domains,
alias_suffixes_with_signature=alias_suffixes_with_signature,
alias_suffixes=alias_suffixes,
at_least_a_premium_domain=at_least_a_premium_domain,
mailboxes=mailboxes,
)
def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
"""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
return False
user_custom_domains = [cd.domain for cd in user.verified_custom_domains()]
# make sure alias_suffix is either .random_word@simplelogin.co or @my-domain.com
alias_suffix = alias_suffix.strip()
# alias_domain_prefix is either a .random_word or ""
alias_domain_prefix, alias_domain = alias_suffix.split("@", 1)
# alias_domain must be either one of user custom domains or built-in domains
if alias_domain not in user.available_alias_domains():
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False
# SimpleLogin domain case:
# 1) alias_suffix must start with "." and
# 2) alias_domain_prefix must come from the word list
if (
alias_domain in user.available_sl_domains()
and alias_domain not in user_custom_domains
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
and not DISABLE_ALIAS_SUFFIX
):
if not alias_domain_prefix.startswith("."):
LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix)
return False
else:
if alias_domain not in user_custom_domains:
if not DISABLE_ALIAS_SUFFIX:
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False
if alias_domain not in user.available_sl_domains():
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False
return True

View file

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

View file

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

View file

@ -1,8 +1,15 @@
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from wtforms import (
StringField,
validators,
SelectMultipleField,
BooleanField,
IntegerField,
)
from app import parallel_limiter
from app.config import (
EMAIL_DOMAIN,
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"])
@login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def directory():
dirs = (
Directory.filter_by(user_id=current_user.id)
@ -33,54 +57,68 @@ def directory():
mailboxes = current_user.mailboxes()
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.form.get("form-name") == "delete":
dir_id = request.form.get("dir-id")
dir = Directory.get(dir_id)
if not delete_dir_form.validate():
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")
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")
return redirect(url_for("dashboard.directory"))
name = dir.name
Directory.delete(dir_id)
name = dir_obj.name
Directory.delete(dir_obj.id)
Session.commit()
flash(f"Directory {name} has been deleted", "success")
return redirect(url_for("dashboard.directory"))
if request.form.get("form-name") == "toggle-directory":
dir_id = request.form.get("dir-id")
dir = Directory.get(dir_id)
if not toggle_dir_form.validate():
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")
return redirect(url_for("dashboard.directory"))
if request.form.get("dir-status") == "on":
dir.disabled = False
flash(f"On-the-fly is enabled for {dir.name}", "success")
if toggle_dir_form.directory_enabled.data:
dir_obj.disabled = False
flash(f"On-the-fly is enabled for {dir_obj.name}", "success")
else:
dir.disabled = True
flash(f"On-the-fly is disabled for {dir.name}", "warning")
dir_obj.disabled = True
flash(f"On-the-fly is disabled for {dir_obj.name}", "warning")
Session.commit()
return redirect(url_for("dashboard.directory"))
elif request.form.get("form-name") == "update":
dir_id = request.form.get("dir-id")
dir = Directory.get(dir_id)
if not update_dir_form.validate():
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")
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
mailboxes = []
for mailbox_id in mailbox_ids:
@ -99,14 +137,14 @@ def directory():
return redirect(url_for("dashboard.directory"))
# 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()
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()
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"))
elif request.form.get("form-name") == "create":
@ -181,6 +219,9 @@ def directory():
return render_template(
"dashboard/directory.html",
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,
mailboxes=mailboxes,
EMAIL_DOMAIN=EMAIL_DOMAIN,

View file

@ -7,13 +7,13 @@ from flask_wtf import FlaskForm
from wtforms import StringField, validators, IntegerField
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.db import Session
from app.dns_utils import (
get_mx_domains,
get_spf_domain,
get_txt_record,
get_cname_record,
is_mx_equivalent,
)
from app.log import LOG
@ -28,7 +28,7 @@ from app.models import (
Job,
)
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"])
@ -46,8 +46,8 @@ def domain_detail_dns(custom_domain_id):
spf_record = f"v=spf1 include:{EMAIL_DOMAIN} ~all"
# hardcode the DKIM selector here
dkim_cname = f"dkim._domainkey.{EMAIL_DOMAIN}"
domain_validator = CustomDomainValidation(EMAIL_DOMAIN)
csrf_form = CSRFValidationForm()
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 = []
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":
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)
elif request.form.get("form-name") == "check-dkim":
dkim_record = get_cname_record("dkim._domainkey." + custom_domain.domain)
if dkim_record == dkim_cname:
dkim_errors = domain_validator.validate_dkim_records(custom_domain)
if len(dkim_errors) == 0:
flash("DKIM is setup correctly.", "success")
custom_domain.dkim_verified = True
Session.commit()
return redirect(
url_for(
"dashboard.domain_detail_dns", custom_domain_id=custom_domain.id
)
)
else:
custom_domain.dkim_verified = False
Session.commit()
flash("DKIM: the CNAME record is not correctly set", "warning")
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":
txt_records = get_txt_record("_dmarc." + custom_domain.domain)
@ -164,6 +161,7 @@ def domain_detail_dns(custom_domain_id):
return render_template(
"dashboard/domain_detail/dns.html",
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
dkim_records=domain_validator.get_dkim_records(),
**locals(),
)
@ -171,6 +169,7 @@ def domain_detail_dns(custom_domain_id):
@dashboard_bp.route("/domains/<int:custom_domain_id>/info", methods=["GET", "POST"])
@login_required
def domain_detail(custom_domain_id):
csrf_form = CSRFValidationForm()
custom_domain: CustomDomain = CustomDomain.get(custom_domain_id)
mailboxes = current_user.mailboxes()
@ -179,6 +178,9 @@ def domain_detail(custom_domain_id):
return redirect(url_for("dashboard.index"))
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":
custom_domain.catch_all = not custom_domain.catch_all
Session.commit()
@ -307,12 +309,16 @@ def domain_detail(custom_domain_id):
@dashboard_bp.route("/domains/<int:custom_domain_id>/trash", methods=["GET", "POST"])
@login_required
def domain_detail_trash(custom_domain_id):
csrf_form = CSRFValidationForm()
custom_domain = CustomDomain.get(custom_domain_id)
if not custom_domain or custom_domain.user_id != current_user.id:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
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":
DomainDeletedAlias.filter_by(domain_id=custom_domain.id).delete()
Session.commit()
@ -356,6 +362,7 @@ def domain_detail_trash(custom_domain_id):
"dashboard/domain_detail/trash.html",
domain_deleted_aliases=domain_deleted_aliases,
custom_domain=custom_domain,
csrf_form=csrf_form,
)

View file

@ -6,8 +6,12 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import PasswordField, validators
from app.config import CONNECT_WITH_PROTON
from app.dashboard.base import dashboard_bp
from app.extensions import limiter
from app.log import LOG
from app.models import PartnerUser
from app.proton.utils import get_proton_partner
from app.utils import sanitize_next_url
_SUDO_GAP = 900
@ -18,6 +22,7 @@ class LoginForm(FlaskForm):
@dashboard_bp.route("/enter_sudo", methods=["GET", "POST"])
@limiter.limit("3/minute")
@login_required
def enter_sudo():
password_check_form = LoginForm()
@ -39,8 +44,18 @@ def enter_sudo():
else:
flash("Incorrect password", "warning")
proton_enabled = CONNECT_WITH_PROTON
if proton_enabled:
# Only for users that have the account linked
partner_user = PartnerUser.get_by(user_id=current_user.id)
if not partner_user or partner_user.partner_id != get_proton_partner().id:
proton_enabled = False
return render_template(
"dashboard/enter_sudo.html", password_check_form=password_check_form
"dashboard/enter_sudo.html",
password_check_form=password_check_form,
next=request.args.get("next"),
connect_with_proton=proton_enabled,
)

View file

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

View file

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

View file

@ -23,7 +23,7 @@ def lifetime_licence():
# user needs to cancel active subscription first
# to avoid being charged
sub = current_user.get_subscription()
sub = current_user.get_paddle_subscription()
if sub and not sub.cancelled:
flash("Please cancel your current subscription first", "warning")
return redirect(url_for("dashboard.index"))

View file

@ -1,11 +1,16 @@
import base64
import binascii
import json
import arrow
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from itsdangerous import Signer
from wtforms import validators
from itsdangerous import TimestampSigner
from wtforms import validators, IntegerField
from wtforms.fields.html5 import EmailField
from app import parallel_limiter
from app.config import MAILBOX_SECRET, URL, JOB_DELETE_MAILBOX
from app.dashboard.base import dashboard_bp
from app.db import Session
@ -14,10 +19,11 @@ from app.email_utils import (
mailbox_already_used,
render,
send_email,
is_valid_email,
)
from app.email_validation import is_valid_email
from app.log import LOG
from app.models import Mailbox, Job
from app.utils import CSRFValidationForm
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"])
@login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def mailbox_route():
mailboxes = (
Mailbox.filter_by(user_id=current_user.id)
@ -36,25 +50,57 @@ def mailbox_route():
)
new_mailbox_form = NewMailboxForm()
csrf_form = CSRFValidationForm()
delete_mailbox_form = DeleteMailboxForm()
if request.method == "POST":
if request.form.get("form-name") == "delete":
mailbox_id = request.form.get("mailbox-id")
mailbox = Mailbox.get(mailbox_id)
if not delete_mailbox_form.validate():
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:
flash("Unknown error. Refresh the page", "warning")
flash("Invalid mailbox. Refresh the page", "warning")
return redirect(url_for("dashboard.mailbox_route"))
if mailbox.id == current_user.default_mailbox_id:
flash("You cannot delete default mailbox", "error")
return redirect(url_for("dashboard.mailbox_route"))
transfer_mailbox_id = delete_mailbox_form.transfer_mailbox_id.data
if transfer_mailbox_id and transfer_mailbox_id > 0:
transfer_mailbox = Mailbox.get(transfer_mailbox_id)
if not transfer_mailbox or transfer_mailbox.user_id != current_user.id:
flash(
"You must transfer the aliases to a mailbox you own.", "error"
)
return redirect(url_for("dashboard.mailbox_route"))
if transfer_mailbox.id == mailbox.id:
flash(
"You can not transfer the aliases to the mailbox you want to delete.",
"error",
)
return redirect(url_for("dashboard.mailbox_route"))
if not transfer_mailbox.verified:
flash("Your new mailbox is not verified", "error")
return redirect(url_for("dashboard.mailbox_route"))
# Schedule delete account job
LOG.w("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(
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(),
commit=True,
)
@ -67,7 +113,10 @@ def mailbox_route():
return redirect(url_for("dashboard.mailbox_route"))
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)
if not mailbox or mailbox.user_id != current_user.id:
@ -119,7 +168,8 @@ def mailbox_route():
return redirect(
url_for(
"dashboard.mailbox_detail_route", mailbox_id=new_mailbox.id
"dashboard.mailbox_detail_route",
mailbox_id=new_mailbox.id,
)
)
@ -127,46 +177,24 @@ def mailbox_route():
"dashboard/mailbox.html",
mailboxes=mailboxes,
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):
s = Signer(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode()
s = TimestampSigner(MAILBOX_SECRET)
encoded_data = json.dumps([mailbox.id, mailbox.email]).encode("utf-8")
b64_data = base64.urlsafe_b64encode(encoded_data)
mailbox_id_signed = s.sign(b64_data).decode()
verification_url = (
URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}"
)
send_email(
mailbox.email,
f"Please confirm your email {mailbox.email}",
f"Please confirm your mailbox {mailbox.email}",
render(
"transactional/verify-mailbox.txt",
"transactional/verify-mailbox.txt.jinja2",
user=user,
link=verification_url,
mailbox_email=mailbox.email,
@ -182,23 +210,35 @@ def send_verification_email(user, mailbox):
@dashboard_bp.route("/mailbox_verify")
def mailbox_verify():
s = Signer(MAILBOX_SECRET)
mailbox_id = request.args.get("mailbox_id")
s = TimestampSigner(MAILBOX_SECRET)
mailbox_verify_request = request.args.get("mailbox_id")
try:
r_id = int(s.unsign(mailbox_id))
mailbox_raw_data = s.unsign(mailbox_verify_request, max_age=900)
except Exception:
flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route"))
else:
mailbox = Mailbox.get(r_id)
if not mailbox:
flash("Invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
try:
decoded_data = base64.urlsafe_b64decode(mailbox_raw_data)
except binascii.Error:
flash("Invalid link. Please delete and re-add your mailbox", "error")
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
Session.commit()
mailbox.verified = True
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 email_validator import validate_email, EmailNotValidError
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from itsdangerous import Signer
from itsdangerous import TimestampSigner
from wtforms import validators
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 Mailbox
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):
@ -29,12 +30,13 @@ class ChangeEmailForm(FlaskForm):
@dashboard_bp.route("/mailbox/<int:mailbox_id>/", methods=["GET", "POST"])
@login_required
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:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
change_email_form = ChangeEmailForm()
csrf_form = CSRFValidationForm()
if mailbox.new_email:
pending_email = mailbox.new_email
@ -42,6 +44,9 @@ def mailbox_detail_route(mailbox_id):
pending_email = None
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if (
request.form.get("form-name") == "update-email"
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":
address = sanitize_email(request.form.get("email"))
if AuthorizedAddress.get_by(mailbox_id=mailbox.id, email=address):
flash(f"{address} already added", "error")
try:
validate_email(
address, check_deliverability=False, allow_smtputf8=False
).domain
except EmailNotValidError:
flash(f"invalid {address}", "error")
else:
AuthorizedAddress.create(
user_id=current_user.id,
mailbox_id=mailbox.id,
email=address,
commit=True,
)
flash(f"{address} added as authorized address", "success")
if AuthorizedAddress.get_by(mailbox_id=mailbox.id, email=address):
flash(f"{address} already added", "error")
else:
AuthorizedAddress.create(
user_id=current_user.id,
mailbox_id=mailbox.id,
email=address,
commit=True,
)
flash(f"{address} added as authorized address", "success")
return redirect(
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)
)
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")
try:
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":
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")
Session.commit()
flash("Generic subject for PGP-encrypted email is enabled", "success")
flash("Generic subject is enabled", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
elif request.form.get("action") == "remove":
mailbox.generic_subject = None
Session.commit()
flash("Generic subject for PGP-encrypted email is disabled", "success")
flash("Generic subject is disabled", "success")
return redirect(
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):
s = Signer(MAILBOX_SECRET)
s = TimestampSigner(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode()
verification_url = (
f"{URL}/dashboard/mailbox/confirm_change?mailbox_id={mailbox_id_signed}"
@ -208,7 +220,7 @@ def verify_mailbox_change(user, mailbox, new_email):
new_email,
"Confirm mailbox change on SimpleLogin",
render(
"transactional/verify-mailbox-change.txt",
"transactional/verify-mailbox-change.txt.jinja2",
user=user,
link=verification_url,
mailbox_email=mailbox.email,
@ -250,11 +262,11 @@ def cancel_mailbox_change_route(mailbox_id):
@dashboard_bp.route("/mailbox/confirm_change")
def mailbox_confirm_change_route():
s = Signer(MAILBOX_SECRET)
s = TimestampSigner(MAILBOX_SECRET)
signed_mailbox_id = request.args.get("mailbox_id")
try:
mailbox_id = int(s.unsign(signed_mailbox_id))
mailbox_id = int(s.unsign(signed_mailbox_id, max_age=900))
except Exception:
flash("Invalid link", "error")
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.db import Session
from app.models import RecoveryCode
from app.utils import CSRFValidationForm
@dashboard_bp.route("/mfa_cancel", methods=["GET", "POST"])
@ -15,8 +16,13 @@ def mfa_cancel():
flash("you don't have MFA enabled", "warning")
return redirect(url_for("dashboard.index"))
csrf_form = CSRFValidationForm()
# user cancels TOTP
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
current_user.enable_otp = False
current_user.otp_secret = None
Session.commit()
@ -28,4 +34,4 @@ def mfa_cancel():
flash("TOTP is now disabled", "warning")
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.db import Session
from app.log import LOG
from app.models import RecoveryCode
class OtpTokenForm(FlaskForm):
@ -39,8 +40,10 @@ def mfa_setup():
current_user.last_otp = token
Session.commit()
flash("MFA has been activated", "success")
return redirect(url_for("dashboard.recovery_code_route"))
recovery_codes = RecoveryCode.generate(current_user)
return render_template(
"dashboard/recovery_code.html", recovery_codes=recovery_codes
)
else:
flash("Incorrect token", "warning")

View file

@ -19,7 +19,10 @@ from app.models import (
Subscription,
ManualSubscription,
CoinbaseSubscription,
PartnerUser,
PartnerSubscription,
)
from app.proton.utils import get_proton_partner
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
@ -29,9 +32,9 @@ def pricing():
flash("You already have a lifetime subscription", "error")
return redirect(url_for("dashboard.index"))
sub: Subscription = current_user.get_subscription()
paddle_sub: Subscription = current_user.get_paddle_subscription()
# user who has canceled can re-subscribe
if sub and not sub.cancelled:
if paddle_sub and not paddle_sub.cancelled:
flash("You already have an active subscription", "error")
return redirect(url_for("dashboard.index"))
@ -49,6 +52,18 @@ def pricing():
if apple_sub and apple_sub.is_valid():
flash("Please make sure to cancel your subscription on Apple first", "warning")
proton_upgrade = False
partner_user = PartnerUser.get_by(user_id=current_user.id)
if partner_user:
partner_sub = PartnerSubscription.get_by(partner_user_id=partner_user.id)
if partner_sub and partner_sub.is_active():
flash(
f"You already have a subscription provided by {partner_user.partner.name}",
"error",
)
return redirect(url_for("dashboard.index"))
proton_upgrade = partner_user.partner_id == get_proton_partner().id
return render_template(
"dashboard/pricing.html",
PADDLE_VENDOR_ID=PADDLE_VENDOR_ID,
@ -58,14 +73,16 @@ def pricing():
manual_sub=manual_sub,
coinbase_sub=coinbase_sub,
now=now,
proton_upgrade=proton_upgrade,
)
@dashboard_bp.route("/subscription_success")
@login_required
def subscription_success():
flash("Thanks so much for supporting SimpleLogin!", "success")
return redirect(url_for("dashboard.index"))
return render_template(
"dashboard/thank-you.html",
)
@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

@ -1,4 +1,5 @@
from io import BytesIO
from typing import Optional, Tuple
import arrow
from flask import (
@ -11,7 +12,6 @@ from flask import (
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from flask_wtf.file import FileField
from typing import Optional
from wtforms import StringField, validators
from wtforms.fields.html5 import EmailField
@ -29,6 +29,8 @@ from app.email_utils import (
personal_email_already_used,
)
from app.errors import ProtonPartnerNotSetUp
from app.extensions import limiter
from app.image_validation import detect_image_format, ImageFormat
from app.jobs.export_user_data_job import ExportUserDataJob
from app.log import LOG
from app.models import (
@ -48,9 +50,15 @@ from app.models import (
CoinbaseSubscription,
AppleSubscription,
PartnerUser,
PartnerSubscription,
UnsubscribeBehaviourEnum,
)
from app.proton.utils import get_proton_partner, perform_proton_account_unlink
from app.utils import (
random_string,
CSRFValidationForm,
canonicalize_email,
)
from app.proton.proton_callback_handler import get_proton_partner
from app.utils import random_string, sanitize_email
class SettingForm(FlaskForm):
@ -84,12 +92,25 @@ def get_proton_linked_account() -> Optional[str]:
return proton_linked_account.partner_email
def get_partner_subscription_and_name(
user_id: int,
) -> Optional[Tuple[PartnerSubscription, str]]:
partner_sub = PartnerSubscription.find_by_user_id(user_id)
if not partner_sub or not partner_sub.is_active():
return None
partner = partner_sub.partner_user.partner
return (partner_sub, partner.name)
@dashboard_bp.route("/setting", methods=["GET", "POST"])
@login_required
@limiter.limit("5/minute", methods=["POST"])
def setting():
form = SettingForm()
promo_form = PromoCodeForm()
change_email_form = ChangeEmailForm()
csrf_form = CSRFValidationForm()
email_change = EmailChange.get_by(user_id=current_user.id)
if email_change:
@ -98,16 +119,15 @@ def setting():
pending_email = None
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 change_email_form.validate():
# whether user can proceed with the email update
new_email_valid = True
if (
sanitize_email(change_email_form.email.data) != current_user.email
and not pending_email
):
new_email = sanitize_email(change_email_form.email.data)
new_email = canonicalize_email(change_email_form.email.data)
if new_email != current_user.email and not pending_email:
# check if this email is not already used
if personal_email_already_used(new_email) or Alias.get_by(
email=new_email
@ -169,12 +189,28 @@ def setting():
profile_updated = True
if form.profile_picture.data:
image_contents = form.profile_picture.data.read()
if detect_image_format(image_contents) == ImageFormat.Unknown:
flash(
"This image format is not supported",
"error",
)
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 = File.create(user_id=current_user.id, path=file_path)
s3.upload_from_bytesio(
file_path, BytesIO(form.profile_picture.data.read())
)
s3.upload_from_bytesio(file_path, BytesIO(image_contents))
Session.flush()
LOG.d("upload file %s to s3", file)
@ -309,11 +345,16 @@ def setting():
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "one-click-unsubscribe":
choose = request.form.get("enable")
if choose == "on":
current_user.one_click_unsubscribe_block_sender = True
choose = request.form.get("unsubscribe-behaviour")
if choose == UnsubscribeBehaviourEnum.PreserveOriginal.name:
current_user.unsub_behaviour = UnsubscribeBehaviourEnum.PreserveOriginal
elif choose == UnsubscribeBehaviourEnum.DisableAlias.name:
current_user.unsub_behaviour = UnsubscribeBehaviourEnum.DisableAlias
elif choose == UnsubscribeBehaviourEnum.BlockContact.name:
current_user.unsub_behaviour = UnsubscribeBehaviourEnum.BlockContact
else:
current_user.one_click_unsubscribe_block_sender = False
flash("There was an error. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
@ -358,10 +399,19 @@ def setting():
manual_sub = ManualSubscription.get_by(user_id=current_user.id)
apple_sub = AppleSubscription.get_by(user_id=current_user.id)
coinbase_sub = CoinbaseSubscription.get_by(user_id=current_user.id)
paddle_sub = current_user.get_paddle_subscription()
partner_sub = None
partner_name = None
partner_sub_name = get_partner_subscription_and_name(current_user.id)
if partner_sub_name:
partner_sub, partner_name = partner_sub_name
proton_linked_account = get_proton_linked_account()
return render_template(
"dashboard/setting.html",
csrf_form=csrf_form,
form=form,
PlanEnum=PlanEnum,
SenderFormatEnum=SenderFormatEnum,
@ -370,8 +420,12 @@ def setting():
change_email_form=change_email_form,
pending_email=pending_email,
AliasGeneratorEnum=AliasGeneratorEnum,
UnsubscribeBehaviourEnum=UnsubscribeBehaviourEnum,
manual_sub=manual_sub,
partner_sub=partner_sub,
partner_name=partner_name,
apple_sub=apple_sub,
paddle_sub=paddle_sub,
coinbase_sub=coinbase_sub,
FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
ALIAS_RAND_SUFFIX_LENGTH=ALIAS_RANDOM_SUFFIX_LENGTH,
@ -406,8 +460,13 @@ def send_change_email_confirmation(user: User, email_change: EmailChange):
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])
@limiter.limit("5/hour")
@login_required
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)
if email_change:
# extend email change expiration
@ -427,6 +486,10 @@ def resend_email_change():
@dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"])
@login_required
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)
if email_change:
EmailChange.delete(email_change.id)
@ -440,14 +503,14 @@ def cancel_email_change():
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
def unlink_proton_account():
partner_user = PartnerUser.get_by(
user_id=current_user.id, partner_id=get_proton_partner().id
)
if partner_user is not None:
PartnerUser.delete(partner_user.id)
Session.commit()
csrf_form = CSRFValidationForm()
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting"))
perform_proton_account_unlink(current_user)
flash("Your Proton account has been unlinked", "success")
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_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.dashboard.base import dashboard_bp
from app.errors import SubdomainInTrashError
@ -13,8 +16,18 @@ from app.models import CustomDomain, Mailbox, SLDomain
_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"])
@login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def subdomain_route():
if not current_user.subdomain_is_available():
flash("Unknown error, redirect to the home page", "error")
@ -26,9 +39,13 @@ def subdomain_route():
).all()
errors = {}
new_subdomain_form = NewSubdomainForm()
if request.method == "POST":
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():
flash("Only premium plan can add subdomain", "warning")
return redirect(request.url)
@ -39,8 +56,8 @@ def subdomain_route():
)
return redirect(request.url)
subdomain = request.form.get("subdomain").lower().strip()
domain = request.form.get("domain").lower().strip()
subdomain = new_subdomain_form.subdomain.data.lower().strip()
domain = new_subdomain_form.domain.data.lower().strip()
if len(subdomain) < 3:
flash("Subdomain must have at least 3 characters", "error")
@ -108,4 +125,5 @@ def subdomain_route():
sl_domains=sl_domains,
errors=errors,
subdomains=subdomains,
new_subdomain_form=new_subdomain_form,
)

View file

@ -9,10 +9,12 @@ from flask import redirect, url_for, flash, request, render_template
from flask_login import login_required, current_user
from app.dashboard.base import dashboard_bp
from app.handler.unsubscribe_encoder import UnsubscribeAction
from app.handler.unsubscribe_handler import UnsubscribeHandler
from app.models import Alias, Contact
@dashboard_bp.route("/unsubscribe/<alias_id>", methods=["GET", "POST"])
@dashboard_bp.route("/unsubscribe/<int:alias_id>", methods=["GET", "POST"])
@login_required
def unsubscribe(alias_id):
alias = Alias.get(alias_id)
@ -38,7 +40,7 @@ def unsubscribe(alias_id):
return render_template("dashboard/unsubscribe.html", alias=alias.email)
@dashboard_bp.route("/block_contact/<contact_id>", methods=["GET", "POST"])
@dashboard_bp.route("/block_contact/<int:contact_id>", methods=["GET", "POST"])
@login_required
def block_contact(contact_id):
contact = Contact.get(contact_id)
@ -68,3 +70,43 @@ def block_contact(contact_id):
)
else: # ask user confirmation
return render_template("dashboard/block_contact.html", contact=contact)
@dashboard_bp.route("/unsubscribe/encoded/<encoded_request>", methods=["GET"])
@login_required
def encoded_unsubscribe(encoded_request: str):
unsub_data = UnsubscribeHandler().handle_unsubscribe_from_request(
current_user, encoded_request
)
if not unsub_data:
flash("Invalid unsubscribe request", "error")
return redirect(url_for("dashboard.index"))
if unsub_data.action == UnsubscribeAction.DisableAlias:
alias = Alias.get(unsub_data.data)
flash(f"Alias {alias.email} has been blocked", "success")
return redirect(url_for("dashboard.index", highlight_alias_id=alias.id))
if unsub_data.action == UnsubscribeAction.DisableContact:
contact = Contact.get(unsub_data.data)
flash(f"Emails sent from {contact.website_email} are now blocked", "success")
return redirect(
url_for(
"dashboard.alias_contact_manager",
alias_id=contact.alias_id,
highlight_contact_id=contact.id,
)
)
if unsub_data.action == UnsubscribeAction.UnsubscribeNewsletter:
flash("You've unsubscribed from the newsletter", "success")
return redirect(
url_for(
"dashboard.index",
)
)
if unsub_data.action == UnsubscribeAction.OriginalUnsubscribeMailto:
flash("The original unsubscribe request has been forwarded", "success")
return redirect(
url_for(
"dashboard.index",
)
)
return redirect(url_for("dashboard.index"))

View file

@ -3,9 +3,12 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session
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()
Session = scoped_session(sessionmaker(bind=connection))

View file

@ -1 +1,3 @@
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(
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",
)

View file

@ -1 +1,3 @@
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)]:
"""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.
"""
try:
@ -50,7 +50,7 @@ def get_mx_domains(hostname) -> [(int, str)]:
ret.append((int(parts[0]), parts[1]))
return ret
return sorted(ret, key=lambda prio_domain: prio_domain[0])
_include_spf = "include:"

View file

@ -20,6 +20,7 @@ X_SPAM_STATUS = "X-Spam-Status"
LIST_UNSUBSCRIBE = "List-Unsubscribe"
LIST_UNSUBSCRIBE_POST = "List-Unsubscribe-Post"
RETURN_PATH = "Return-Path"
AUTHENTICATION_RESULTS = "Authentication-Results"
# headers used to DKIM sign in order of preference
DKIM_HEADERS = [
@ -32,6 +33,7 @@ DKIM_HEADERS = [
SL_DIRECTION = "X-SimpleLogin-Type"
SL_EMAIL_LOG_ID = "X-SimpleLogin-EmailLog-ID"
SL_ENVELOPE_FROM = "X-SimpleLogin-Envelope-From"
SL_ORIGINAL_FROM = "X-SimpleLogin-Original-From"
SL_ENVELOPE_TO = "X-SimpleLogin-Envelope-To"
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"
E404 = "421 SL E404 Unexpected error - Retry later"
E405 = "421 SL E405 Mailbox domain problem - Retry later"
E406 = "421 SL E406 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
# region 5** errors
@ -64,4 +60,5 @@ E522 = (
)
E523 = "550 SL E523 Unknown error"
E524 = "550 SL E524 Wrong use of reverse-alias"
E525 = "550 SL E525 Alias loop"
# endregion

View file

@ -14,7 +14,7 @@ from email.header import decode_header, Header
from email.message import Message, EmailMessage
from email.mime.multipart import MIMEMultipart
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 typing import Tuple, List, Optional, Union
@ -34,30 +34,7 @@ from flanker.addresslib.address import EmailAddress
from jinja2 import Environment, FileSystemLoader
from sqlalchemy import func
from app.config import (
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 import config
from app.db import Session
from app.dns_utils import get_mx_domains
from app.email import headers
@ -77,6 +54,7 @@ from app.models import (
IgnoreBounceSender,
InvalidMailboxDomain,
VerpType,
available_sl_email,
)
from app.utils import (
random_string,
@ -91,31 +69,31 @@ VERP_HMAC_ALGO = "sha3-224"
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))
template = env.get_template(template_name)
return template.render(
MAX_NB_EMAIL_FREE_PLAN=MAX_NB_EMAIL_FREE_PLAN,
URL=URL,
LANDING_PAGE_URL=LANDING_PAGE_URL,
MAX_NB_EMAIL_FREE_PLAN=config.MAX_NB_EMAIL_FREE_PLAN,
URL=config.URL,
LANDING_PAGE_URL=config.LANDING_PAGE_URL,
YEAR=arrow.now().year,
**kwargs,
)
def send_welcome_email(user):
to_email, unsubscribe_link, via_email = user.get_communication_email()
if not to_email:
comm_email, unsubscribe_link, via_email = user.get_communication_email()
if not comm_email:
return
# 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(
to_email,
f"Welcome to SimpleLogin",
comm_email,
"Welcome to SimpleLogin",
render("com/welcome.txt", user=user, alias=alias),
render("com/welcome.html", user=user, alias=alias),
unsubscribe_link,
@ -126,7 +104,7 @@ def send_welcome_email(user):
def send_trial_end_soon_email(user):
send_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.html", user=user),
ignore_smtp_error=True,
@ -136,7 +114,7 @@ def send_trial_end_soon_email(user):
def send_activation_email(email, activation_link):
send_email(
email,
f"Just one more step to join SimpleLogin",
"Just one more step to join SimpleLogin",
render(
"transactional/activation.txt",
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):
send_email_with_rate_control(
user,
ALERT_INVALID_TOTP_LOGIN,
config.ALERT_INVALID_TOTP_LOGIN,
user.email,
"Unsuccessful attempt to login to your SimpleLogin account",
render(
@ -245,7 +223,7 @@ def send_cannot_create_directory_alias_disabled(user, alias_address, directory_n
"""
send_email_with_rate_control(
user,
ALERT_DIRECTORY_DISABLED_ALIAS_CREATION,
config.ALERT_DIRECTORY_DISABLED_ALIAS_CREATION,
user.email,
f"Alias {alias_address} cannot be created",
render(
@ -297,8 +275,9 @@ def send_email(
LOG.d("send email to %s, subject '%s'", to_email, subject)
from_name = from_name or NOREPLY
from_addr = from_addr or NOREPLY
from_name = from_name or config.NOREPLY
from_addr = from_addr or config.NOREPLY
from_domain = get_email_domain_part(from_addr)
if html:
msg = MIMEMultipart("alternative")
@ -313,13 +292,14 @@ def send_email(
msg[headers.FROM] = f'"{from_name}" <{from_addr}>'
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
date_header = formatdate()
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:
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)
sl_sendmail(
generate_verp_email(VerpType.transactional, transaction.id),
generate_verp_email(VerpType.transactional, transaction.id, from_domain),
to_email,
msg,
retries=retries,
@ -351,7 +331,7 @@ def send_email_with_rate_control(
subject,
plaintext,
html=None,
max_nb_alert=MAX_ALERT_24H,
max_nb_alert=config.MAX_ALERT_24H,
nb_day=1,
ignore_smtp_error=False,
retries=0,
@ -448,7 +428,7 @@ def get_email_domain_part(address):
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")
msg[headers.SL_WANT_SIGNING] = "yes"
return
@ -463,9 +443,9 @@ def add_dkim_signature(msg: Message, email_domain: str):
continue
# 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"
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())
LOG.w("email saved to %s", file_name)
@ -480,12 +460,12 @@ def add_dkim_signature_with_header(
# Specify headers in "byte" form
# Generate message signature
if DKIM_PRIVATE_KEY:
if config.DKIM_PRIVATE_KEY:
sig = dkim.sign(
message_to_bytes(msg),
DKIM_SELECTOR,
config.DKIM_SELECTOR,
email_domain.encode(),
DKIM_PRIVATE_KEY.encode(),
config.DKIM_PRIVATE_KEY.encode(),
include_headers=dkim_headers,
)
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:
"""return True if an email ends with one of the alias domains provided by SimpleLogin"""
# not allow creating directory with premium domain
for domain in ALIAS_DOMAINS:
for domain in config.ALIAS_DOMAINS:
if email_address.endswith("@" + domain):
return True
@ -594,7 +574,7 @@ def email_can_be_used_as_mailbox(email_address: str) -> bool:
mx_domains = get_mx_domain_list(domain)
# 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)
return False
@ -788,7 +768,7 @@ def get_header_unicode(header: Union[str, Header]) -> str:
ret = ""
for to_decoded_str, charset in decode_header(header):
if charset is None:
if type(to_decoded_str) is bytes:
if isinstance(to_decoded_str, bytes):
decoded_str = to_decoded_str.decode()
else:
decoded_str = to_decoded_str
@ -825,13 +805,13 @@ def to_bytes(msg: Message):
for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]:
try:
return msg.as_bytes(policy=generator_policy)
except:
except Exception:
LOG.w("as_bytes() fails with %s policy", policy, exc_info=True)
msg_string = msg.as_string()
try:
return msg_string.encode()
except:
except Exception:
LOG.w("as_string().encode() fails", exc_info=True)
return msg_string.encode(errors="replace")
@ -848,19 +828,6 @@ def should_add_dkim_signature(domain: str) -> bool:
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):
BASE64 = "base64"
QUOTED = "quoted-printable"
@ -874,8 +841,24 @@ def get_encoding(msg: Message) -> EmailEncoding:
- base64
- 7bit: default if unknown or empty
"""
cte = str(msg.get(headers.CONTENT_TRANSFER_ENCODING, "")).lower().strip()
if cte in ("", "7bit", "7-bit", "8bit", "binary", "8bit;", "utf-8"):
cte = (
str(msg.get(headers.CONTENT_TRANSFER_ENCODING, ""))
.lower()
.strip()
.strip('"')
.strip("'")
)
if cte in (
"",
"7bit",
"7-bit",
"7bits",
"8bit",
"8bits",
"binary",
"8bit;",
"utf-8",
):
return EmailEncoding.NO
if cte == "base64":
@ -915,22 +898,25 @@ def decode_text(text: str, encoding: EmailEncoding = EmailEncoding.NO) -> str:
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()
if content_type == "text/plain":
encoding = get_encoding(msg)
payload = msg.get_payload()
if type(payload) is str:
if isinstance(payload, str):
clone_msg = copy(msg)
new_payload = f"""{text_header}
---
------------------------------
{decode_text(payload, encoding)}"""
clone_msg.set_payload(encode_text(new_payload, encoding))
return clone_msg
elif content_type == "text/html":
encoding = get_encoding(msg)
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;
-premailer-cellspacing: 0; margin: 0; padding: 0;">
<tr>
@ -952,6 +938,8 @@ def add_header(msg: Message, text_header, html_header) -> Message:
for part in msg.get_payload():
if isinstance(part, Message):
new_parts.append(add_header(part, text_header, html_header))
elif isinstance(part, str):
new_parts.append(MIMEText(part))
else:
new_parts.append(part)
clone_msg = copy(msg)
@ -960,7 +948,14 @@ def add_header(msg: Message, text_header, html_header) -> Message:
elif content_type in ("multipart/mixed", "multipart/signed"):
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)
for ix, part in enumerate(parts):
if ix == 0:
@ -976,7 +971,11 @@ def add_header(msg: Message, text_header, html_header) -> Message:
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()
if (
@ -996,7 +995,7 @@ def replace(msg: Message, old, new) -> Message:
if content_type in ("text/plain", "text/html"):
encoding = get_encoding(msg)
payload = msg.get_payload()
if type(payload) is str:
if isinstance(payload, str):
if encoding == EmailEncoding.QUOTED:
LOG.d("handle quoted-printable replace %s -> %s", old, new)
# first decode the payload
@ -1041,7 +1040,7 @@ def replace(msg: Message, old, new) -> Message:
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
"""
@ -1052,6 +1051,7 @@ def generate_reply_email(contact_email: str, user: User) -> str:
include_sender_in_reverse_alias = False
user = alias.user
# user has set this option explicitly
if user.include_sender_in_reverse_alias is not None:
include_sender_in_reverse_alias = user.include_sender_in_reverse_alias
@ -1066,22 +1066,28 @@ def generate_reply_email(contact_email: str, user: User) -> str:
contact_email = contact_email.replace(".", "_")
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
for _ in range(1000):
if include_sender_in_reverse_alias and contact_email:
random_length = random.randint(5, 10)
reply_email = (
# do not use the ra+ anymore
# f"ra+{contact_email}+{random_string(random_length)}@{EMAIL_DOMAIN}"
f"{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)}@{reply_domain}"
)
else:
random_length = random.randint(20, 50)
# do not use the ra+ anymore
# reply_email = f"ra+{random_string(random_length)}@{EMAIL_DOMAIN}"
reply_email = f"{random_string(random_length)}@{EMAIL_DOMAIN}"
# reply_email = f"ra+{random_string(random_length)}@{config.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
raise Exception("Cannot generate reply email")
@ -1092,31 +1098,11 @@ def is_reverse_alias(address: str) -> bool:
if Contact.get_by(reply_email=address):
return True
return address.endswith(f"@{EMAIL_DOMAIN}") and (
return address.endswith(f"@{config.EMAIL_DOMAIN}") and (
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):
"""
Return whether an alias should be disabled and if yes, the reason why
@ -1126,7 +1112,7 @@ def should_disable(alias: Alias) -> (bool, str):
LOG.w("%s cannot be disabled", alias)
return False, ""
if not ALIAS_AUTOMATIC_DISABLE:
if not config.ALIAS_AUTOMATIC_DISABLE:
return False, ""
yesterday = arrow.now().shift(days=-1)
@ -1241,14 +1227,14 @@ def spf_pass(
subject = get_header_unicode(msg[headers.SUBJECT])
send_email_with_rate_control(
user,
ALERT_SPF,
config.ALERT_SPF,
mailbox.email,
f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address",
render(
"transactional/spf-fail.txt",
alias=alias.email,
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,
subject=subject,
time=arrow.now(),
@ -1256,7 +1242,7 @@ def spf_pass(
render(
"transactional/spf-fail.html",
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,
subject=subject,
time=arrow.now(),
@ -1279,11 +1265,11 @@ def spf_pass(
@cached(cache=TTLCache(maxsize=2, ttl=20))
def get_smtp_server():
LOG.d("get a smtp server")
if POSTFIX_SUBMISSION_TLS:
smtp = SMTP(POSTFIX_SERVER, 587)
if config.POSTFIX_SUBMISSION_TLS:
smtp = SMTP(config.POSTFIX_SERVER, 587)
smtp.starttls()
else:
smtp = SMTP(POSTFIX_SERVER, POSTFIX_PORT)
smtp = SMTP(config.POSTFIX_SERVER, config.POSTFIX_PORT)
return smtp
@ -1355,12 +1341,12 @@ def save_email_for_debugging(msg: Message, file_name_prefix=None) -> str:
"""Save email for debugging to temporary location
Return the file path
"""
if TEMP_DIR:
if config.TEMP_DIR:
file_name = str(uuid.uuid4()) + ".eml"
if file_name_prefix:
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())
LOG.d("email saved to %s", file_name)
@ -1373,12 +1359,12 @@ def save_envelope_for_debugging(envelope: Envelope, file_name_prefix=None) -> st
"""Save envelope for debugging to temporary location
Return the file path
"""
if TEMP_DIR:
if config.TEMP_DIR:
file_name = str(uuid.uuid4()) + ".eml"
if file_name_prefix:
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)
LOG.d("envelope saved to %s", file_name)
@ -1404,12 +1390,15 @@ def generate_verp_email(
# Signing without itsdangereous because it uses base64 that includes +/= symbols and lower and upper case letters.
# We need to encode in base32
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]
encoded_payload = base64.b32encode(json_payload).rstrip(b"=").decode("utf-8")
encoded_signature = base64.b32encode(payload_hmac).rstrip(b"=").decode("utf-8")
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()
@ -1422,7 +1411,7 @@ def get_verp_info_from_email(email: str) -> Optional[Tuple[VerpType, int]]:
return None
username = email[:idx]
fields = username.split(".")
if len(fields) != 3 or fields[0] != VERP_PREFIX:
if len(fields) != 3 or fields[0] != config.VERP_PREFIX:
return None
try:
padding = (8 - (len(fields[1]) % 8)) % 8
@ -1434,7 +1423,7 @@ def get_verp_info_from_email(email: str) -> Optional[Tuple[VerpType, int]]:
except binascii.Error:
return None
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]
if expected_signature != signature:
return None
@ -1442,6 +1431,13 @@ def get_verp_info_from_email(email: str) -> Optional[Tuple[VerpType, int]]:
# verp type, object_id, time
if len(data) != 3:
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 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"""
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):
@ -84,6 +84,14 @@ class ErrAddressInvalid(SLException):
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):
"""raised when a contact already exists"""
@ -108,3 +116,15 @@ class AccountAlreadyLinkedToAnotherPartnerException(LinkException):
class AccountAlreadyLinkedToAnotherUserException(LinkException):
def __init__(self):
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
disabled_login = 2
not_activated = 3
scheduled_to_be_deleted = 4
class Source(EnumE):
web = 0

View file

@ -1,12 +1,31 @@
from flask_limiter import Limiter
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.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
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

View file

@ -5,7 +5,7 @@ from typing import Optional, Tuple
from aiosmtpd.handlers import Message
from aiosmtpd.smtp import Envelope
from app import s3
from app import s3, config
from app.config import (
DMARC_CHECK_ENABLED,
ALERT_QUARANTINE_DMARC,
@ -34,6 +34,37 @@ def apply_dmarc_policy_for_forward_phase(
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:
LOG.w(
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(
msg,
f"""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/
""",
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>
""",
warning_plain_text,
warning_html,
)
return changed_msg, None
@ -133,6 +157,7 @@ def apply_dmarc_policy_for_reply_phase(
DmarcCheckResult.soft_fail,
):
return None
LOG.w(
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]}"

View file

@ -3,7 +3,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from io import BytesIO
from mailbox import Message
from typing import Optional
from typing import Optional, Union
from app import s3
from app.config import (
@ -189,7 +189,7 @@ def handle_yahoo_complaint(message: Message) -> bool:
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)
@ -221,7 +221,7 @@ def handle_complaint(message: Message, origin: ProviderComplaintOrigin) -> bool:
return True
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
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)
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
if not alias:
@ -245,16 +245,22 @@ def handle_complaint(message: Message, origin: ProviderComplaintOrigin) -> bool:
def report_complaint_to_user_in_reply_phase(
alias: Alias,
alias: Union[Alias, DomainDeletedAlias],
to_address: str,
origin: ProviderComplaintOrigin,
msg_info: OriginalMessageInformation,
):
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(
alias.user,
f"{ALERT_COMPLAINT_REPLY_PHASE}_{origin.name()}",
msg_info.mailbox_address or alias.mailbox.email,
mailbox_email,
f"Abuse report from {capitalized_name}",
render(
"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(
alias: Alias, origin: ProviderComplaintOrigin, msg_info: OriginalMessageInformation
alias: Union[Alias, DomainDeletedAlias],
origin: ProviderComplaintOrigin,
msg_info: OriginalMessageInformation,
):
capitalized_name = origin.name().capitalize()
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(
user,
f"{ALERT_COMPLAINT_FORWARD_PHASE}_{origin.name()}",

View file

@ -4,6 +4,7 @@ from typing import Dict, Optional
import newrelic.agent
from app.email import headers
from app.log import LOG
from app.models import EnumE, Phase
from email.message import Message
@ -55,6 +56,7 @@ class SpamdResult:
self.phase: Phase = phase
self.dmarc: DmarcCheckResult = DmarcCheckResult.not_available
self.spf: SPFCheckResult = SPFCheckResult.not_available
self.rspamd_score = -1
def set_dmarc_result(self, dmarc_result: DmarcCheckResult):
self.dmarc = dmarc_result
@ -85,6 +87,7 @@ class SpamdResult:
spam_entries = [
entry.strip() for entry in str(spam_result_header[-1]).split("\n")
]
for entry_pos in range(len(spam_entries)):
sep = spam_entries[entry_pos].find("(")
if sep > -1:
@ -101,6 +104,17 @@ class SpamdResult:
spamd_result.set_spf_result(spf_result)
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)
return spamd_result

View file

@ -0,0 +1,150 @@
import base64
import enum
import hashlib
import json
from dataclasses import dataclass
from typing import Optional, Union
import itsdangerous
from app import config
from app.log import LOG
UNSUB_PREFIX = "un"
class UnsubscribeAction(enum.Enum):
UnsubscribeNewsletter = 1
DisableAlias = 2
DisableContact = 3
OriginalUnsubscribeMailto = 4
@dataclass
class UnsubscribeOriginalData:
alias_id: int
recipient: str
subject: str
@dataclass
class UnsubscribeData:
action: UnsubscribeAction
data: Union[UnsubscribeOriginalData, int]
@dataclass
class UnsubscribeLink:
link: str
via_email: bool
class UnsubscribeEncoder:
@staticmethod
def encode(
action: UnsubscribeAction,
data: Union[int, UnsubscribeOriginalData],
force_web: bool = False,
) -> UnsubscribeLink:
if config.UNSUBSCRIBER and not force_web:
return UnsubscribeLink(UnsubscribeEncoder.encode_mailto(action, data), True)
return UnsubscribeLink(UnsubscribeEncoder.encode_url(action, data), False)
@classmethod
def encode_subject(
cls, action: UnsubscribeAction, data: Union[int, UnsubscribeOriginalData]
) -> str:
if action != UnsubscribeAction.OriginalUnsubscribeMailto and not isinstance(
data, int
):
raise ValueError(f"Data has to be an int for an action of type {action}")
if action == UnsubscribeAction.OriginalUnsubscribeMailto:
if type(data) is not UnsubscribeOriginalData:
raise ValueError(
f"Data has to be an UnsubscribeOriginalData for an action of type {action}"
)
# Initial 0 is the version number. If we need to add support for extra use-cases we can bump up this number
data = (0, data.alias_id, data.recipient, data.subject)
payload = (action.value, data)
serialized_data = (
base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
.rstrip(b"=")
.decode("utf-8")
)
signed_data = cls._get_signer().sign(serialized_data).decode("utf-8")
encoded_request = f"{UNSUB_PREFIX}.{signed_data}"
if len(encoded_request) > 512:
LOG.w("Encoded request is longer than 512 chars")
return encoded_request
@staticmethod
def encode_mailto(
action: UnsubscribeAction, data: Union[int, UnsubscribeOriginalData]
) -> str:
subject = UnsubscribeEncoder.encode_subject(action, data)
return f"mailto:{config.UNSUBSCRIBER}?subject={subject}"
@staticmethod
def encode_url(
action: UnsubscribeAction, data: Union[int, UnsubscribeOriginalData]
) -> str:
if action == UnsubscribeAction.DisableAlias:
return f"{config.URL}/dashboard/unsubscribe/{data}"
if action == UnsubscribeAction.DisableContact:
return f"{config.URL}/dashboard/block_contact/{data}"
if action in (
UnsubscribeAction.UnsubscribeNewsletter,
UnsubscribeAction.OriginalUnsubscribeMailto,
):
encoded = UnsubscribeEncoder.encode_subject(action, data)
return f"{config.URL}/dashboard/unsubscribe/encoded?data={encoded}"
@staticmethod
def _get_signer() -> itsdangerous.Signer:
return itsdangerous.Signer(
config.UNSUBSCRIBE_SECRET, digest_method=hashlib.sha3_224
)
@classmethod
def decode_subject(cls, data: str) -> Optional[UnsubscribeData]:
if data.find(UNSUB_PREFIX) == -1:
try:
# subject has the format {alias.id}=
if data.endswith("="):
alias_id = int(data[:-1])
return UnsubscribeData(UnsubscribeAction.DisableAlias, alias_id)
# {contact.id}_
elif data.endswith("_"):
contact_id = int(data[:-1])
return UnsubscribeData(UnsubscribeAction.DisableContact, contact_id)
# {user.id}*
elif data.endswith("*"):
user_id = int(data[:-1])
return UnsubscribeData(
UnsubscribeAction.UnsubscribeNewsletter, user_id
)
else:
# some email providers might strip off the = suffix
alias_id = int(data)
return UnsubscribeData(UnsubscribeAction.DisableAlias, alias_id)
except ValueError:
return None
signer = cls._get_signer()
try:
verified_data = signer.unsign(data[len(UNSUB_PREFIX) + 1 :])
except itsdangerous.BadSignature:
return None
try:
padded_data = verified_data + (b"=" * (-len(verified_data) % 4))
payload = json.loads(base64.urlsafe_b64decode(padded_data))
except ValueError:
return None
action = UnsubscribeAction(payload[0])
action_data = payload[1]
if action == UnsubscribeAction.OriginalUnsubscribeMailto:
# Skip version number in action_data[0] for now it's always 0
action_data = UnsubscribeOriginalData(
action_data[1], action_data[2], action_data[3]
)
return UnsubscribeData(action, action_data)

View file

@ -0,0 +1,106 @@
import urllib
from email.header import Header
from email.message import Message
from app.email import headers
from app.email_utils import add_or_replace_header, delete_header
from app.handler.unsubscribe_encoder import (
UnsubscribeEncoder,
UnsubscribeAction,
UnsubscribeData,
UnsubscribeOriginalData,
)
from app.log import LOG
from app.models import Alias, Contact, UnsubscribeBehaviourEnum
class UnsubscribeGenerator:
def _generate_header_with_original_behaviour(
self, alias: Alias, message: Message
) -> Message:
"""
Generate a header that will encode the original unsub request. To do so
1. Look if there's an original List_Unsubscribe headers, otherwise do nothing
2. Header has the form <method1>, <method2>, .. where each method is either
- mailto:s@b.c?subject=something
- http(s)://somewhere.com
3. Check if there are http unsub requests in the header. If there are, reserve them and remove all mailto
methods to avoid leaking the real mailbox. We forward the message with only http(s) methods.
4. If there aren't neither https nor mailto methods, strip the header from the message and that's it.
It could happen if the header is malformed.
5. Encode in our unsub request the first original mail and subject to unsub, and use that as our unsub header.
"""
unsubscribe_data = message[headers.LIST_UNSUBSCRIBE]
if not unsubscribe_data:
LOG.info("Email has no unsubscribe header")
return message
if isinstance(unsubscribe_data, Header):
unsubscribe_data = str(unsubscribe_data.encode())
raw_methods = [method.strip() for method in unsubscribe_data.split(",")]
mailto_unsubs = None
other_unsubs = []
for raw_method in raw_methods:
start = raw_method.find("<")
end = raw_method.rfind(">")
if start == -1 or end == -1 or start >= end:
continue
method = raw_method[start + 1 : end]
url_data = urllib.parse.urlparse(method)
if url_data.scheme == "mailto":
query_data = urllib.parse.parse_qs(url_data.query)
mailto_unsubs = (url_data.path, query_data.get("subject", [""])[0])
LOG.debug(f"Unsub is mailto to {mailto_unsubs}")
else:
LOG.debug(f"Unsub has {url_data.scheme} scheme")
other_unsubs.append(method)
# If there are non mailto unsubscribe methods, use those in the header
if other_unsubs:
add_or_replace_header(
message,
headers.LIST_UNSUBSCRIBE,
", ".join([f"<{method}>" for method in other_unsubs]),
)
add_or_replace_header(
message, headers.LIST_UNSUBSCRIBE_POST, "List-Unsubscribe=One-Click"
)
LOG.debug(f"Adding click unsub methods to header {other_unsubs}")
return message
elif not mailto_unsubs:
LOG.debug("No unsubs. Deleting all unsub headers")
delete_header(message, headers.LIST_UNSUBSCRIBE)
delete_header(message, headers.LIST_UNSUBSCRIBE_POST)
return message
unsub_data = UnsubscribeData(
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(
self, message: Message, unsub: UnsubscribeData
) -> Message:
unsub_link = UnsubscribeEncoder.encode(unsub.action, unsub.data)
add_or_replace_header(message, headers.LIST_UNSUBSCRIBE, f"<{unsub_link.link}>")
if not unsub_link.via_email:
add_or_replace_header(
message, headers.LIST_UNSUBSCRIBE_POST, "List-Unsubscribe=One-Click"
)
return message
def add_header_to_message(
self, alias: Alias, contact: Contact, message: Message
) -> Message:
"""
Add List-Unsubscribe header based on the user preference.
"""
unsub_behaviour = alias.user.unsub_behaviour
if unsub_behaviour == UnsubscribeBehaviourEnum.PreserveOriginal:
return self._generate_header_with_original_behaviour(alias, message)
elif unsub_behaviour == UnsubscribeBehaviourEnum.DisableAlias:
unsub = UnsubscribeData(UnsubscribeAction.DisableAlias, alias.id)
return self._add_unsubscribe_header(message, unsub)
else:
unsub = UnsubscribeData(UnsubscribeAction.DisableContact, contact.id)
return self._add_unsubscribe_header(message, unsub)

View file

@ -0,0 +1,250 @@
from email.message import Message, EmailMessage
from email.utils import make_msgid, formatdate
from typing import Optional
from aiosmtpd.smtp import Envelope
from app import config
from app.db import Session
from app.email import headers, status
from app.email_utils import (
send_email,
render,
get_email_domain_part,
add_dkim_signature,
generate_verp_email,
)
from app.handler.unsubscribe_encoder import (
UnsubscribeData,
UnsubscribeEncoder,
UnsubscribeAction,
UnsubscribeOriginalData,
)
from app.log import LOG
from app.mail_sender import sl_sendmail
from app.models import (
Alias,
Contact,
User,
Mailbox,
TransactionalEmail,
VerpType,
)
from app.utils import sanitize_email
class UnsubscribeHandler:
def _extract_unsub_info_from_message(
self, message: Message
) -> Optional[UnsubscribeData]:
header_value = message[headers.SUBJECT]
if not header_value:
return None
return UnsubscribeEncoder.decode_subject(header_value)
def handle_unsubscribe_from_message(self, envelope: Envelope, msg: Message) -> str:
unsub_data = self._extract_unsub_info_from_message(msg)
if not unsub_data:
LOG.w("Wrong format subject %s", msg[headers.SUBJECT])
return status.E507
mailbox = Mailbox.get_by(email=envelope.mail_from)
if not mailbox:
LOG.w("Unknown mailbox %s", envelope.mail_from)
return status.E507
if unsub_data.action == UnsubscribeAction.DisableAlias:
return self._disable_alias(unsub_data.data, mailbox.user, mailbox)
elif unsub_data.action == UnsubscribeAction.DisableContact:
return self._disable_contact(unsub_data.data, mailbox.user, mailbox)
elif unsub_data.action == UnsubscribeAction.UnsubscribeNewsletter:
return self._unsubscribe_user_from_newsletter(unsub_data.data, mailbox.user)
elif unsub_data.action == UnsubscribeAction.OriginalUnsubscribeMailto:
return self._unsubscribe_original_behaviour(unsub_data.data, mailbox.user)
else:
raise Exception(f"Unknown unsubscribe action {unsub_data.action}")
def handle_unsubscribe_from_request(
self, user: User, unsub_request: str
) -> Optional[UnsubscribeData]:
unsub_data = UnsubscribeEncoder.decode_subject(unsub_request)
if not unsub_data:
LOG.w("Wrong request %s", unsub_request)
return None
if unsub_data.action == UnsubscribeAction.DisableAlias:
response_code = self._disable_alias(unsub_data.data, user)
elif unsub_data.action == UnsubscribeAction.DisableContact:
response_code = self._disable_contact(unsub_data.data, user)
elif unsub_data.action == UnsubscribeAction.UnsubscribeNewsletter:
response_code = self._unsubscribe_user_from_newsletter(
unsub_data.data, user
)
elif unsub_data.action == UnsubscribeAction.OriginalUnsubscribeMailto:
response_code = self._unsubscribe_original_behaviour(unsub_data.data, user)
else:
raise Exception(f"Unknown unsubscribe action {unsub_data.action}")
if response_code == status.E202:
return unsub_data
return None
def _disable_alias(
self, alias_id: int, user: User, mailbox: Optional[Mailbox] = None
) -> str:
alias = Alias.get(alias_id)
if not alias:
return status.E508
if alias.user_id != user.id:
LOG.w("Alias doesn't belong to user")
return status.E508
# Only alias's owning mailbox can send the unsubscribe request
if mailbox and not self._check_email_is_authorized_for_alias(
mailbox.email, alias
):
return status.E509
alias.enabled = False
Session.commit()
enable_alias_url = config.URL + f"/dashboard/?highlight_alias_id={alias.id}"
for mailbox in alias.mailboxes:
send_email(
mailbox.email,
f"Alias {alias.email} has been disabled successfully",
render(
"transactional/unsubscribe-disable-alias.txt",
user=alias.user,
alias=alias.email,
enable_alias_url=enable_alias_url,
),
render(
"transactional/unsubscribe-disable-alias.html",
user=alias.user,
alias=alias.email,
enable_alias_url=enable_alias_url,
),
)
return status.E202
def _disable_contact(
self, contact_id: int, user: User, mailbox: Optional[Mailbox] = None
) -> str:
contact = Contact.get(contact_id)
if not contact:
return status.E508
if contact.user_id != user.id:
LOG.w("Contact doesn't belong to user")
return status.E508
# Only contact's owning mailbox can send the unsubscribe request
if mailbox and not self._check_email_is_authorized_for_alias(
mailbox.email, contact.alias
):
return status.E509
alias = contact.alias
contact.block_forward = True
Session.commit()
unblock_contact_url = (
config.URL
+ f"/dashboard/alias_contact_manager/{alias.id}?highlight_contact_id={contact.id}"
)
for mailbox in alias.mailboxes:
send_email(
mailbox.email,
f"Emails from {contact.website_email} to {alias.email} are now blocked",
render(
"transactional/unsubscribe-block-contact.txt.jinja2",
user=alias.user,
alias=alias,
contact=contact,
unblock_contact_url=unblock_contact_url,
),
)
return status.E202
def _unsubscribe_user_from_newsletter(
self, user_id: int, request_user: User
) -> str:
"""return the SMTP status"""
user = User.get(user_id)
if not user:
LOG.w("No such user %s", user_id)
return status.E510
if user.id != request_user.id:
LOG.w("Unauthorized unsubscribe user from", request_user)
return status.E511
user.notification = False
Session.commit()
send_email(
user.email,
"You have been unsubscribed from SimpleLogin newsletter",
render(
"transactional/unsubscribe-newsletter.txt",
user=user,
),
render(
"transactional/unsubscribe-newsletter.html",
user=user,
),
)
return status.E202
def _check_email_is_authorized_for_alias(
self, email_address: str, alias: Alias
) -> bool:
"""return if the email_address is authorized to unsubscribe from an alias or block a contact
Usually the mail_from=mailbox.email but it can also be one of the authorized address
"""
for mailbox in alias.mailboxes:
if mailbox.email == email_address:
return True
for authorized_address in mailbox.authorized_addresses:
if authorized_address.email == email_address:
LOG.d(
"Found an authorized address for %s %s %s",
alias,
mailbox,
authorized_address,
)
return True
LOG.d(
"%s cannot disable alias %s. Alias authorized addresses:%s",
email_address,
alias,
alias.authorized_addresses,
)
return False
def _unsubscribe_original_behaviour(
self, original_unsub_data: UnsubscribeOriginalData, user: User
) -> str:
alias = Alias.get(original_unsub_data.alias_id)
if not alias:
return status.E508
if alias.user_id != user.id:
return status.E509
email_domain = get_email_domain_part(alias.email)
to_email = sanitize_email(original_unsub_data.recipient)
msg = EmailMessage()
msg[headers.TO] = to_email
msg[headers.SUBJECT] = original_unsub_data.subject
msg[headers.FROM] = alias.email
msg[headers.MESSAGE_ID] = make_msgid(domain=email_domain)
msg[headers.DATE] = formatdate()
msg[headers.CONTENT_TYPE] = "text/plain"
msg[headers.MIME_VERSION] = "1.0"
msg.set_payload("")
add_dkim_signature(msg, email_domain)
transaction = TransactionalEmail.create(email=to_email, commit=True)
sl_sendmail(
generate_verp_email(
VerpType.transactional, transaction.id, sender_domain=email_domain
),
to_email,
msg,
retries=3,
ignore_smtp_error=True,
)
return status.E202

25
app/image_validation.py Normal file
View file

@ -0,0 +1,25 @@
from enum import Enum
class ImageFormat(Enum):
Png = 1
Jpg = 2
Webp = 3
Unknown = 9
magic_numbers = {
ImageFormat.Png: bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
ImageFormat.Jpg: bytes([0xFF, 0xD8, 0xFF, 0xE0]),
ImageFormat.Webp: bytes([0x52, 0x49, 0x46, 0x46]),
}
def detect_image_format(image: bytes) -> ImageFormat:
# Detect image based on magic number
for fmt, header in magic_numbers.items():
if image.startswith(header):
return fmt
# We don't know the type
return ImageFormat.Unknown

View file

@ -15,7 +15,7 @@ from app.models import (
Mailbox,
User,
)
from app.utils import sanitize_email
from app.utils import sanitize_email, canonicalize_email
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)
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)
@ -69,7 +69,7 @@ def import_from_csv(batch_import: BatchImport, user: User, lines):
if "mailboxes" in row:
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)
if not mailbox or not mailbox.verified or mailbox.user_id != user.id:

4
app/internal/__init__.py Normal file
View file

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

8
app/internal/base.py Normal file
View file

@ -0,0 +1,8 @@
from flask import Blueprint
internal_bp = Blueprint(
name="internal",
import_name=__name__,
url_prefix="/internal",
template_folder="templates",
)

10
app/internal/exit_sudo.py Normal file
View file

@ -0,0 +1,10 @@
from flask import session, redirect, url_for, flash
from app.internal.base import internal_bp
@internal_bp.route("/exit-sudo-mode")
def exit_sudo_mode():
session["sudo_time"] = 0
flash("Exited sudo mode", "info")
return redirect(url_for("dashboard.index"))

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