app-MAIL-temp/app/email_utils.py

1439 lines
44 KiB
Python
Raw Normal View History

2020-11-26 17:01:05 +01:00
import base64
2020-11-30 10:48:16 +01:00
import enum
2019-11-16 15:19:43 +01:00
import os
2020-11-26 17:01:05 +01:00
import quopri
import random
import time
import uuid
from copy import deepcopy
2021-09-21 14:11:59 +02:00
from email import policy, message_from_bytes, message_from_string
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
2021-12-16 15:06:26 +01:00
from smtplib import SMTP, SMTPServerDisconnected, SMTPException, SMTPRecipientsRefused
from typing import Tuple, List, Optional, Union
create BaseForm to enable CSRF register page redirect user to dashboard if they are logged in enable csrf for login page Set models more strict bootstrap developer page add helper method to ModelMixin, remove CRUDMixin display list of clients on developer index, add copy client-secret to clipboard using clipboardjs add toastr and use jquery non slim display a toast when user copies the client-secret create new client, generate client-id using unidecode client detail page: can edit client add delete client implement /oauth/authorize and /oauth/allow-deny implement /oauth/token add /oauth/user_info endpoint handle scopes: wip take into account scope: display scope, return user data according to scope create virtual-domain, gen email, client_user model WIP create authorize_nonlogin_user page user can choose to generate a new email no need to interfere with root logger log for before and after request if user has already allowed a client: generate a auth-code and redirect user to client get_user_info takes into account gen email display list of clients that have user has authorised use yk-client domain instead of localhost as cookie depends on the domain name use wtforms instead of flask_wtf Dockerfile delete virtual domain EMAIL_DOMAIN can come from env var bind to host 0.0.0.0 fix signup error: use session as default csrf_context rename yourkey to simplelogin add python-dotenv, ipython, sqlalchemy_utils create DB_URI, FLASK_SECRET. Load config from CONFIG file if exist add shortcuts to logging create shell add psycopg2 do not add local data in Dockerfile add drop_db into shell add shell.prepare_db() fix prepare_db setup sentry copy assets from tabler/dist add icon downloaded from https://commons.wikimedia.org/wiki/File:Simpleicons_Interface_key-tool-1.svg integrate tabler - login and register page add favicon template: default, header. Use gravatar for user avatar url use default template for dashboard, developer page use another icon add clipboard and notie prettify dashboard add notie css add fake gen email and client-user prettify list client page, use notie for toast add email, name scope to new client display client scope in client list prettify new-client, client-detail add sentry-sdk and blinker add arrow, add dt jinja filter, prettify logout, dashboard comment "last used" in dashboard for now prettify date display add copy email to clipboard to dashboard use "users" as table name for User as "user" is reserved key in postgres call prepare_db() when creating new db error page 400, 401, 403, 404 prettify authorize_login_user create already_authorize.html for user who has already authorized a client user can generate new email display all other generated emails add ENV variable, only reset DB when ENV=local fix: not return other users gen emails display nb users for each client refactor shell: remove prepare_db() add sendgrid add /favicon.ico route add new config: URL, SUPPORT_EMAIL, SENDGRID_API_KEY user needs to activate their account before login create copy button on dashboard client can have multiple redirect uris, in client detail can add/remove redirect-uri, use redirect_uri passed in /authorize refactor: move get_user_info into ClientUser model dashboard: display all apps, all generated emails add "id" into user_info add trigger email button invalidate the session at each new version by changing the secret centralize Client creation into Client.create_new user can enable/disable email forwarding setup auto dismiss alert: just add .alert-auto-dismiss move name down in register form add shell.add_real_data move blueprint template to its own package prettify authorize page for non-authenticated user update readme, return error if not redirect_uri add flask-wtf, use psycopg2-binary use flask-wtf FlaskForm instead of Form rename email -> email_utils add AWS_REGION, BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY to config add s3 module add File model, add Client.icon_id handle client icon update can create client with icon display client icon in client list page add Client.home_url take into account Client.home_url add boto3 register: ask name first only show "trigger test email" if email forwarding is enabled display gen email in alphabetical order, client in client.name alphabetical order better error page the modal does not get close when user clicks outside of modal add Client.published column discover page that displays all published Client add missing bootstrap.bundle.min.js.map developer can publish/unpublish their app in discover use notie for display flash message create hotmail account fix missing jquery add footer, add global jinja2 variable strengthen model: use nullable=False whenever possible, rename client_id to oauth_client_id, client_secret to oauth_client_secret add flask-migrate init migrate 1st migrate version fix rename client_id -> oauth_client_id prettify UI use flask_migrate.upgrade() instead of db.create_all() make sure requirejs.config is called for all page enable sentry for js, use uppercase for global jinja2 variables add flask-admin add User.is_admin column setup flask admin, only accessible to admin user fix migration: add server_default replace session[redirect_after_login] by "next" request args add pyproject.toml: ignore migrations/ in black add register waiting_activation_email page better email wording add pytest add get_host_name_and_scheme and tests example fail test fix test fix client-id display add flask-cors /user_info supports cors, add /me as /user_info synonym return client in /me support implicit flow no need to use with "app.app_context()" add watchtower to requirement add param ENABLE_CLOUDWATCH, CLOUDWATCH_LOG_GROUP, CLOUDWATCH_LOG_STREAM add cloudwatch logger if cloudwatch is enabled add 500 error page add help text for list of used client display list of app/website that an email has been used click on client name brings to client detail page create style.css to add additional style, append its url with the current sha1 to avoid cache POC on how to send email using postfix add sqlalchemy-utils use arrow instead of datetime add new params STRIPE_API, STRIPE_YEARLY_SKU, STRIPE_MONTHLY_PLAN show full error in local add plan, plan_expiration to User, need to create enum directly in migration script, cf https://github.com/sqlalchemy/alembic/issues/67 reformat all html files: use space instead of tab new user will have trial plan for 15 days add new param MAX_NB_EMAIL_FREE_PLAN only user with enough quota can create new email if user cannot create new gen email, pick randomly one from existing gen emails. Use flush instead of commit rename STRIPE_YEARLY_SKU -> STRIPE_YEARLY_PLAN open client page in discover in a new tab add stripe not logging /static call: disable flask logging, replace by after_request add param STRIPE_SECRET_KEY add 3 columns stripe_customer_id, stripe_card_token, stripe_subscription_id user can upgrade their pricing add setting page as coming-soon add GenEmail, ClientUser to admin ignore /admin/static logging add more fake data add ondelete="cascade" whenever possible rename plan_expiration -> trial_expiration reset migration: delete old migrations, create new one rename test_send_email -> poc_send_email to avoid the file being called by pytest add new param LYRA_ANALYTICS_ID, add lyra analytics add how to create new migration into readme add drift to base.html notify admin when new user signs up or pays subscription log exception in case of 500 use sendgrid to notify admin add alias /userinfo to user_info endpoint add change_password to shell add info on how payment is handled invite user to retry if card not working remove drift and add "contact us" link move poc_send_email into poc/ support getting client-id, client-secret from form-data in addition to basic auth client-id, client-secret is passed in form-data by passport-oauth2 for ex add jwtRS256 private and public key add jwk-jws-jwt poc add new param OPENID_PRIVATE_KEY_PATH, OPENID_PRIVATE_KEY_PATH add scope, redirect_url to AuthorizationCode and OauthToken take into scope when creating oauth-token, authorization-code add jwcrypto add jose_utils: make_id_token and verify_id_token add &scope to redirect uri add "email_verified": True into user_info fix user not activated add /oauth2 as alias for /oauth handle case where scope and state are empty remove threaded=False Use Email Alias as wording remove help text user can re-send activation email add "expired" into ActivationCode Handle the case activation code is expired reformat: use form.validate_on_submit instead of request.method == post && form.validate use error text instead of flash() display client oauth-id and oauth-secret on client detail page not display oauth-secret on client listing fix expiration check improve page title, footer add /jwks and /.well-known/openid-configuration init properly tests, fix blueprint conflict bug in flask-admin create oauth_models module rename Scope -> ScopeE to distinguish with Scope DB model set app.url_map.strict_slashes = False use ScopeE instead of SCOPE_NAME, ... support access_token passed as args in /userinfo merge /allow-deny into /authorize improve wording take into account the case response_type=code and openid is in scope take into account response_type=id_token, id_token token, id_token code make sure to use in-memory db in test fix scope can be null allow cross_origin for /.well-known/openid-configuration and /jwks fix footer link center authorize form rename trial_expiration to plan_expiration move stripe init to create_app() use real email to be able to receive email notification add user.profile_picture_id column use user profile picture and fallback to gravatar use nguyenkims+local@gm to distinguish with staging handle plan cancel, reactivation, user profile update fix can_create_new_email create cron.py that set plan to free when expired add crontab.yml add yacron use notify_admin instead of LOG.error add ResetPasswordCode model user can change password in setting increase display time for notie add forgot_password page If login error: redirect to this page upon success login. hide discover tab add column user.is_developer only show developer menu to developer comment out the publish button set local user to developer make sure only developer can access /developer blueprint User is invited to upgrade if they are in free plan or their trial ends soon not sending email when in local mode create Partner model create become partner page use normal error handling on local fix migration add "import sqlalchemy_utils" into migration template small refactoring on setting page handle promo code. TODO: add migration file add migration for user.promo_codes move email alias on top of apps in dashboard add introjs move encode_url to utils create GenEmail.create_new_gen_email create a first alias mail to show user how to use when they login show intro when user visits the website the first time fix register
2019-07-02 09:20:12 +02:00
import arrow
import dkim
import newrelic.agent
2021-09-21 14:11:59 +02:00
import re2 as re
import spf
from cachetools import cached, TTLCache
2021-09-18 18:48:31 +02:00
from email_validator import (
validate_email,
EmailNotValidError,
ValidatedEmail,
)
from flanker.addresslib import address
from flanker.addresslib.address import EmailAddress
2019-11-16 15:19:43 +01:00
from jinja2 import Environment, FileSystemLoader
from sqlalchemy import func
create BaseForm to enable CSRF register page redirect user to dashboard if they are logged in enable csrf for login page Set models more strict bootstrap developer page add helper method to ModelMixin, remove CRUDMixin display list of clients on developer index, add copy client-secret to clipboard using clipboardjs add toastr and use jquery non slim display a toast when user copies the client-secret create new client, generate client-id using unidecode client detail page: can edit client add delete client implement /oauth/authorize and /oauth/allow-deny implement /oauth/token add /oauth/user_info endpoint handle scopes: wip take into account scope: display scope, return user data according to scope create virtual-domain, gen email, client_user model WIP create authorize_nonlogin_user page user can choose to generate a new email no need to interfere with root logger log for before and after request if user has already allowed a client: generate a auth-code and redirect user to client get_user_info takes into account gen email display list of clients that have user has authorised use yk-client domain instead of localhost as cookie depends on the domain name use wtforms instead of flask_wtf Dockerfile delete virtual domain EMAIL_DOMAIN can come from env var bind to host 0.0.0.0 fix signup error: use session as default csrf_context rename yourkey to simplelogin add python-dotenv, ipython, sqlalchemy_utils create DB_URI, FLASK_SECRET. Load config from CONFIG file if exist add shortcuts to logging create shell add psycopg2 do not add local data in Dockerfile add drop_db into shell add shell.prepare_db() fix prepare_db setup sentry copy assets from tabler/dist add icon downloaded from https://commons.wikimedia.org/wiki/File:Simpleicons_Interface_key-tool-1.svg integrate tabler - login and register page add favicon template: default, header. Use gravatar for user avatar url use default template for dashboard, developer page use another icon add clipboard and notie prettify dashboard add notie css add fake gen email and client-user prettify list client page, use notie for toast add email, name scope to new client display client scope in client list prettify new-client, client-detail add sentry-sdk and blinker add arrow, add dt jinja filter, prettify logout, dashboard comment "last used" in dashboard for now prettify date display add copy email to clipboard to dashboard use "users" as table name for User as "user" is reserved key in postgres call prepare_db() when creating new db error page 400, 401, 403, 404 prettify authorize_login_user create already_authorize.html for user who has already authorized a client user can generate new email display all other generated emails add ENV variable, only reset DB when ENV=local fix: not return other users gen emails display nb users for each client refactor shell: remove prepare_db() add sendgrid add /favicon.ico route add new config: URL, SUPPORT_EMAIL, SENDGRID_API_KEY user needs to activate their account before login create copy button on dashboard client can have multiple redirect uris, in client detail can add/remove redirect-uri, use redirect_uri passed in /authorize refactor: move get_user_info into ClientUser model dashboard: display all apps, all generated emails add "id" into user_info add trigger email button invalidate the session at each new version by changing the secret centralize Client creation into Client.create_new user can enable/disable email forwarding setup auto dismiss alert: just add .alert-auto-dismiss move name down in register form add shell.add_real_data move blueprint template to its own package prettify authorize page for non-authenticated user update readme, return error if not redirect_uri add flask-wtf, use psycopg2-binary use flask-wtf FlaskForm instead of Form rename email -> email_utils add AWS_REGION, BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY to config add s3 module add File model, add Client.icon_id handle client icon update can create client with icon display client icon in client list page add Client.home_url take into account Client.home_url add boto3 register: ask name first only show "trigger test email" if email forwarding is enabled display gen email in alphabetical order, client in client.name alphabetical order better error page the modal does not get close when user clicks outside of modal add Client.published column discover page that displays all published Client add missing bootstrap.bundle.min.js.map developer can publish/unpublish their app in discover use notie for display flash message create hotmail account fix missing jquery add footer, add global jinja2 variable strengthen model: use nullable=False whenever possible, rename client_id to oauth_client_id, client_secret to oauth_client_secret add flask-migrate init migrate 1st migrate version fix rename client_id -> oauth_client_id prettify UI use flask_migrate.upgrade() instead of db.create_all() make sure requirejs.config is called for all page enable sentry for js, use uppercase for global jinja2 variables add flask-admin add User.is_admin column setup flask admin, only accessible to admin user fix migration: add server_default replace session[redirect_after_login] by "next" request args add pyproject.toml: ignore migrations/ in black add register waiting_activation_email page better email wording add pytest add get_host_name_and_scheme and tests example fail test fix test fix client-id display add flask-cors /user_info supports cors, add /me as /user_info synonym return client in /me support implicit flow no need to use with "app.app_context()" add watchtower to requirement add param ENABLE_CLOUDWATCH, CLOUDWATCH_LOG_GROUP, CLOUDWATCH_LOG_STREAM add cloudwatch logger if cloudwatch is enabled add 500 error page add help text for list of used client display list of app/website that an email has been used click on client name brings to client detail page create style.css to add additional style, append its url with the current sha1 to avoid cache POC on how to send email using postfix add sqlalchemy-utils use arrow instead of datetime add new params STRIPE_API, STRIPE_YEARLY_SKU, STRIPE_MONTHLY_PLAN show full error in local add plan, plan_expiration to User, need to create enum directly in migration script, cf https://github.com/sqlalchemy/alembic/issues/67 reformat all html files: use space instead of tab new user will have trial plan for 15 days add new param MAX_NB_EMAIL_FREE_PLAN only user with enough quota can create new email if user cannot create new gen email, pick randomly one from existing gen emails. Use flush instead of commit rename STRIPE_YEARLY_SKU -> STRIPE_YEARLY_PLAN open client page in discover in a new tab add stripe not logging /static call: disable flask logging, replace by after_request add param STRIPE_SECRET_KEY add 3 columns stripe_customer_id, stripe_card_token, stripe_subscription_id user can upgrade their pricing add setting page as coming-soon add GenEmail, ClientUser to admin ignore /admin/static logging add more fake data add ondelete="cascade" whenever possible rename plan_expiration -> trial_expiration reset migration: delete old migrations, create new one rename test_send_email -> poc_send_email to avoid the file being called by pytest add new param LYRA_ANALYTICS_ID, add lyra analytics add how to create new migration into readme add drift to base.html notify admin when new user signs up or pays subscription log exception in case of 500 use sendgrid to notify admin add alias /userinfo to user_info endpoint add change_password to shell add info on how payment is handled invite user to retry if card not working remove drift and add "contact us" link move poc_send_email into poc/ support getting client-id, client-secret from form-data in addition to basic auth client-id, client-secret is passed in form-data by passport-oauth2 for ex add jwtRS256 private and public key add jwk-jws-jwt poc add new param OPENID_PRIVATE_KEY_PATH, OPENID_PRIVATE_KEY_PATH add scope, redirect_url to AuthorizationCode and OauthToken take into scope when creating oauth-token, authorization-code add jwcrypto add jose_utils: make_id_token and verify_id_token add &scope to redirect uri add "email_verified": True into user_info fix user not activated add /oauth2 as alias for /oauth handle case where scope and state are empty remove threaded=False Use Email Alias as wording remove help text user can re-send activation email add "expired" into ActivationCode Handle the case activation code is expired reformat: use form.validate_on_submit instead of request.method == post && form.validate use error text instead of flash() display client oauth-id and oauth-secret on client detail page not display oauth-secret on client listing fix expiration check improve page title, footer add /jwks and /.well-known/openid-configuration init properly tests, fix blueprint conflict bug in flask-admin create oauth_models module rename Scope -> ScopeE to distinguish with Scope DB model set app.url_map.strict_slashes = False use ScopeE instead of SCOPE_NAME, ... support access_token passed as args in /userinfo merge /allow-deny into /authorize improve wording take into account the case response_type=code and openid is in scope take into account response_type=id_token, id_token token, id_token code make sure to use in-memory db in test fix scope can be null allow cross_origin for /.well-known/openid-configuration and /jwks fix footer link center authorize form rename trial_expiration to plan_expiration move stripe init to create_app() use real email to be able to receive email notification add user.profile_picture_id column use user profile picture and fallback to gravatar use nguyenkims+local@gm to distinguish with staging handle plan cancel, reactivation, user profile update fix can_create_new_email create cron.py that set plan to free when expired add crontab.yml add yacron use notify_admin instead of LOG.error add ResetPasswordCode model user can change password in setting increase display time for notie add forgot_password page If login error: redirect to this page upon success login. hide discover tab add column user.is_developer only show developer menu to developer comment out the publish button set local user to developer make sure only developer can access /developer blueprint User is invited to upgrade if they are in free plan or their trial ends soon not sending email when in local mode create Partner model create become partner page use normal error handling on local fix migration add "import sqlalchemy_utils" into migration template small refactoring on setting page handle promo code. TODO: add migration file add migration for user.promo_codes move email alias on top of apps in dashboard add introjs move encode_url to utils create GenEmail.create_new_gen_email create a first alias mail to show user how to use when they login show intro when user visits the website the first time fix register
2019-07-02 09:20:12 +02:00
from app.config import (
ROOT_DIR,
POSTFIX_SERVER,
NOT_SEND_EMAIL,
DKIM_SELECTOR,
DKIM_PRIVATE_KEY,
ALIAS_DOMAINS,
POSTFIX_SUBMISSION_TLS,
MAX_NB_EMAIL_FREE_PLAN,
MAX_ALERT_24H,
POSTFIX_PORT,
2020-08-14 16:23:29 +02:00
URL,
2020-08-25 13:03:34 +02:00
LANDING_PAGE_URL,
EMAIL_DOMAIN,
ALERT_DIRECTORY_DISABLED_ALIAS_CREATION,
TRANSACTIONAL_BOUNCE_EMAIL,
ALERT_SPF,
ALERT_INVALID_TOTP_LOGIN,
TEMP_DIR,
ALIAS_AUTOMATIC_DISABLE,
RSPAMD_SIGN_DKIM,
2022-02-17 13:18:52 +01:00
NOREPLY,
)
from app.db import Session
from app.dns_utils import get_mx_domains
2021-10-11 12:10:18 +02:00
from app.email import headers
from app.log import LOG
2020-12-16 18:22:57 +01:00
from app.models import (
Mailbox,
User,
SentAlert,
CustomDomain,
SLDomain,
Contact,
Alias,
EmailLog,
TransactionalEmail,
IgnoreBounceSender,
InvalidMailboxDomain,
2020-12-16 18:22:57 +01:00
)
from app.utils import (
random_string,
convert_to_id,
convert_to_alphanumeric,
sanitize_email,
)
create BaseForm to enable CSRF register page redirect user to dashboard if they are logged in enable csrf for login page Set models more strict bootstrap developer page add helper method to ModelMixin, remove CRUDMixin display list of clients on developer index, add copy client-secret to clipboard using clipboardjs add toastr and use jquery non slim display a toast when user copies the client-secret create new client, generate client-id using unidecode client detail page: can edit client add delete client implement /oauth/authorize and /oauth/allow-deny implement /oauth/token add /oauth/user_info endpoint handle scopes: wip take into account scope: display scope, return user data according to scope create virtual-domain, gen email, client_user model WIP create authorize_nonlogin_user page user can choose to generate a new email no need to interfere with root logger log for before and after request if user has already allowed a client: generate a auth-code and redirect user to client get_user_info takes into account gen email display list of clients that have user has authorised use yk-client domain instead of localhost as cookie depends on the domain name use wtforms instead of flask_wtf Dockerfile delete virtual domain EMAIL_DOMAIN can come from env var bind to host 0.0.0.0 fix signup error: use session as default csrf_context rename yourkey to simplelogin add python-dotenv, ipython, sqlalchemy_utils create DB_URI, FLASK_SECRET. Load config from CONFIG file if exist add shortcuts to logging create shell add psycopg2 do not add local data in Dockerfile add drop_db into shell add shell.prepare_db() fix prepare_db setup sentry copy assets from tabler/dist add icon downloaded from https://commons.wikimedia.org/wiki/File:Simpleicons_Interface_key-tool-1.svg integrate tabler - login and register page add favicon template: default, header. Use gravatar for user avatar url use default template for dashboard, developer page use another icon add clipboard and notie prettify dashboard add notie css add fake gen email and client-user prettify list client page, use notie for toast add email, name scope to new client display client scope in client list prettify new-client, client-detail add sentry-sdk and blinker add arrow, add dt jinja filter, prettify logout, dashboard comment "last used" in dashboard for now prettify date display add copy email to clipboard to dashboard use "users" as table name for User as "user" is reserved key in postgres call prepare_db() when creating new db error page 400, 401, 403, 404 prettify authorize_login_user create already_authorize.html for user who has already authorized a client user can generate new email display all other generated emails add ENV variable, only reset DB when ENV=local fix: not return other users gen emails display nb users for each client refactor shell: remove prepare_db() add sendgrid add /favicon.ico route add new config: URL, SUPPORT_EMAIL, SENDGRID_API_KEY user needs to activate their account before login create copy button on dashboard client can have multiple redirect uris, in client detail can add/remove redirect-uri, use redirect_uri passed in /authorize refactor: move get_user_info into ClientUser model dashboard: display all apps, all generated emails add "id" into user_info add trigger email button invalidate the session at each new version by changing the secret centralize Client creation into Client.create_new user can enable/disable email forwarding setup auto dismiss alert: just add .alert-auto-dismiss move name down in register form add shell.add_real_data move blueprint template to its own package prettify authorize page for non-authenticated user update readme, return error if not redirect_uri add flask-wtf, use psycopg2-binary use flask-wtf FlaskForm instead of Form rename email -> email_utils add AWS_REGION, BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY to config add s3 module add File model, add Client.icon_id handle client icon update can create client with icon display client icon in client list page add Client.home_url take into account Client.home_url add boto3 register: ask name first only show "trigger test email" if email forwarding is enabled display gen email in alphabetical order, client in client.name alphabetical order better error page the modal does not get close when user clicks outside of modal add Client.published column discover page that displays all published Client add missing bootstrap.bundle.min.js.map developer can publish/unpublish their app in discover use notie for display flash message create hotmail account fix missing jquery add footer, add global jinja2 variable strengthen model: use nullable=False whenever possible, rename client_id to oauth_client_id, client_secret to oauth_client_secret add flask-migrate init migrate 1st migrate version fix rename client_id -> oauth_client_id prettify UI use flask_migrate.upgrade() instead of db.create_all() make sure requirejs.config is called for all page enable sentry for js, use uppercase for global jinja2 variables add flask-admin add User.is_admin column setup flask admin, only accessible to admin user fix migration: add server_default replace session[redirect_after_login] by "next" request args add pyproject.toml: ignore migrations/ in black add register waiting_activation_email page better email wording add pytest add get_host_name_and_scheme and tests example fail test fix test fix client-id display add flask-cors /user_info supports cors, add /me as /user_info synonym return client in /me support implicit flow no need to use with "app.app_context()" add watchtower to requirement add param ENABLE_CLOUDWATCH, CLOUDWATCH_LOG_GROUP, CLOUDWATCH_LOG_STREAM add cloudwatch logger if cloudwatch is enabled add 500 error page add help text for list of used client display list of app/website that an email has been used click on client name brings to client detail page create style.css to add additional style, append its url with the current sha1 to avoid cache POC on how to send email using postfix add sqlalchemy-utils use arrow instead of datetime add new params STRIPE_API, STRIPE_YEARLY_SKU, STRIPE_MONTHLY_PLAN show full error in local add plan, plan_expiration to User, need to create enum directly in migration script, cf https://github.com/sqlalchemy/alembic/issues/67 reformat all html files: use space instead of tab new user will have trial plan for 15 days add new param MAX_NB_EMAIL_FREE_PLAN only user with enough quota can create new email if user cannot create new gen email, pick randomly one from existing gen emails. Use flush instead of commit rename STRIPE_YEARLY_SKU -> STRIPE_YEARLY_PLAN open client page in discover in a new tab add stripe not logging /static call: disable flask logging, replace by after_request add param STRIPE_SECRET_KEY add 3 columns stripe_customer_id, stripe_card_token, stripe_subscription_id user can upgrade their pricing add setting page as coming-soon add GenEmail, ClientUser to admin ignore /admin/static logging add more fake data add ondelete="cascade" whenever possible rename plan_expiration -> trial_expiration reset migration: delete old migrations, create new one rename test_send_email -> poc_send_email to avoid the file being called by pytest add new param LYRA_ANALYTICS_ID, add lyra analytics add how to create new migration into readme add drift to base.html notify admin when new user signs up or pays subscription log exception in case of 500 use sendgrid to notify admin add alias /userinfo to user_info endpoint add change_password to shell add info on how payment is handled invite user to retry if card not working remove drift and add "contact us" link move poc_send_email into poc/ support getting client-id, client-secret from form-data in addition to basic auth client-id, client-secret is passed in form-data by passport-oauth2 for ex add jwtRS256 private and public key add jwk-jws-jwt poc add new param OPENID_PRIVATE_KEY_PATH, OPENID_PRIVATE_KEY_PATH add scope, redirect_url to AuthorizationCode and OauthToken take into scope when creating oauth-token, authorization-code add jwcrypto add jose_utils: make_id_token and verify_id_token add &scope to redirect uri add "email_verified": True into user_info fix user not activated add /oauth2 as alias for /oauth handle case where scope and state are empty remove threaded=False Use Email Alias as wording remove help text user can re-send activation email add "expired" into ActivationCode Handle the case activation code is expired reformat: use form.validate_on_submit instead of request.method == post && form.validate use error text instead of flash() display client oauth-id and oauth-secret on client detail page not display oauth-secret on client listing fix expiration check improve page title, footer add /jwks and /.well-known/openid-configuration init properly tests, fix blueprint conflict bug in flask-admin create oauth_models module rename Scope -> ScopeE to distinguish with Scope DB model set app.url_map.strict_slashes = False use ScopeE instead of SCOPE_NAME, ... support access_token passed as args in /userinfo merge /allow-deny into /authorize improve wording take into account the case response_type=code and openid is in scope take into account response_type=id_token, id_token token, id_token code make sure to use in-memory db in test fix scope can be null allow cross_origin for /.well-known/openid-configuration and /jwks fix footer link center authorize form rename trial_expiration to plan_expiration move stripe init to create_app() use real email to be able to receive email notification add user.profile_picture_id column use user profile picture and fallback to gravatar use nguyenkims+local@gm to distinguish with staging handle plan cancel, reactivation, user profile update fix can_create_new_email create cron.py that set plan to free when expired add crontab.yml add yacron use notify_admin instead of LOG.error add ResetPasswordCode model user can change password in setting increase display time for notie add forgot_password page If login error: redirect to this page upon success login. hide discover tab add column user.is_developer only show developer menu to developer comment out the publish button set local user to developer make sure only developer can access /developer blueprint User is invited to upgrade if they are in free plan or their trial ends soon not sending email when in local mode create Partner model create become partner page use normal error handling on local fix migration add "import sqlalchemy_utils" into migration template small refactoring on setting page handle promo code. TODO: add migration file add migration for user.promo_codes move email alias on top of apps in dashboard add introjs move encode_url to utils create GenEmail.create_new_gen_email create a first alias mail to show user how to use when they login show intro when user visits the website the first time fix register
2019-07-02 09:20:12 +02:00
2020-02-03 07:00:58 +01:00
def render(template_name, **kwargs) -> str:
2019-11-16 15:19:43 +01:00
templates_dir = os.path.join(ROOT_DIR, "templates", "emails")
env = Environment(loader=FileSystemLoader(templates_dir))
create BaseForm to enable CSRF register page redirect user to dashboard if they are logged in enable csrf for login page Set models more strict bootstrap developer page add helper method to ModelMixin, remove CRUDMixin display list of clients on developer index, add copy client-secret to clipboard using clipboardjs add toastr and use jquery non slim display a toast when user copies the client-secret create new client, generate client-id using unidecode client detail page: can edit client add delete client implement /oauth/authorize and /oauth/allow-deny implement /oauth/token add /oauth/user_info endpoint handle scopes: wip take into account scope: display scope, return user data according to scope create virtual-domain, gen email, client_user model WIP create authorize_nonlogin_user page user can choose to generate a new email no need to interfere with root logger log for before and after request if user has already allowed a client: generate a auth-code and redirect user to client get_user_info takes into account gen email display list of clients that have user has authorised use yk-client domain instead of localhost as cookie depends on the domain name use wtforms instead of flask_wtf Dockerfile delete virtual domain EMAIL_DOMAIN can come from env var bind to host 0.0.0.0 fix signup error: use session as default csrf_context rename yourkey to simplelogin add python-dotenv, ipython, sqlalchemy_utils create DB_URI, FLASK_SECRET. Load config from CONFIG file if exist add shortcuts to logging create shell add psycopg2 do not add local data in Dockerfile add drop_db into shell add shell.prepare_db() fix prepare_db setup sentry copy assets from tabler/dist add icon downloaded from https://commons.wikimedia.org/wiki/File:Simpleicons_Interface_key-tool-1.svg integrate tabler - login and register page add favicon template: default, header. Use gravatar for user avatar url use default template for dashboard, developer page use another icon add clipboard and notie prettify dashboard add notie css add fake gen email and client-user prettify list client page, use notie for toast add email, name scope to new client display client scope in client list prettify new-client, client-detail add sentry-sdk and blinker add arrow, add dt jinja filter, prettify logout, dashboard comment "last used" in dashboard for now prettify date display add copy email to clipboard to dashboard use "users" as table name for User as "user" is reserved key in postgres call prepare_db() when creating new db error page 400, 401, 403, 404 prettify authorize_login_user create already_authorize.html for user who has already authorized a client user can generate new email display all other generated emails add ENV variable, only reset DB when ENV=local fix: not return other users gen emails display nb users for each client refactor shell: remove prepare_db() add sendgrid add /favicon.ico route add new config: URL, SUPPORT_EMAIL, SENDGRID_API_KEY user needs to activate their account before login create copy button on dashboard client can have multiple redirect uris, in client detail can add/remove redirect-uri, use redirect_uri passed in /authorize refactor: move get_user_info into ClientUser model dashboard: display all apps, all generated emails add "id" into user_info add trigger email button invalidate the session at each new version by changing the secret centralize Client creation into Client.create_new user can enable/disable email forwarding setup auto dismiss alert: just add .alert-auto-dismiss move name down in register form add shell.add_real_data move blueprint template to its own package prettify authorize page for non-authenticated user update readme, return error if not redirect_uri add flask-wtf, use psycopg2-binary use flask-wtf FlaskForm instead of Form rename email -> email_utils add AWS_REGION, BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY to config add s3 module add File model, add Client.icon_id handle client icon update can create client with icon display client icon in client list page add Client.home_url take into account Client.home_url add boto3 register: ask name first only show "trigger test email" if email forwarding is enabled display gen email in alphabetical order, client in client.name alphabetical order better error page the modal does not get close when user clicks outside of modal add Client.published column discover page that displays all published Client add missing bootstrap.bundle.min.js.map developer can publish/unpublish their app in discover use notie for display flash message create hotmail account fix missing jquery add footer, add global jinja2 variable strengthen model: use nullable=False whenever possible, rename client_id to oauth_client_id, client_secret to oauth_client_secret add flask-migrate init migrate 1st migrate version fix rename client_id -> oauth_client_id prettify UI use flask_migrate.upgrade() instead of db.create_all() make sure requirejs.config is called for all page enable sentry for js, use uppercase for global jinja2 variables add flask-admin add User.is_admin column setup flask admin, only accessible to admin user fix migration: add server_default replace session[redirect_after_login] by "next" request args add pyproject.toml: ignore migrations/ in black add register waiting_activation_email page better email wording add pytest add get_host_name_and_scheme and tests example fail test fix test fix client-id display add flask-cors /user_info supports cors, add /me as /user_info synonym return client in /me support implicit flow no need to use with "app.app_context()" add watchtower to requirement add param ENABLE_CLOUDWATCH, CLOUDWATCH_LOG_GROUP, CLOUDWATCH_LOG_STREAM add cloudwatch logger if cloudwatch is enabled add 500 error page add help text for list of used client display list of app/website that an email has been used click on client name brings to client detail page create style.css to add additional style, append its url with the current sha1 to avoid cache POC on how to send email using postfix add sqlalchemy-utils use arrow instead of datetime add new params STRIPE_API, STRIPE_YEARLY_SKU, STRIPE_MONTHLY_PLAN show full error in local add plan, plan_expiration to User, need to create enum directly in migration script, cf https://github.com/sqlalchemy/alembic/issues/67 reformat all html files: use space instead of tab new user will have trial plan for 15 days add new param MAX_NB_EMAIL_FREE_PLAN only user with enough quota can create new email if user cannot create new gen email, pick randomly one from existing gen emails. Use flush instead of commit rename STRIPE_YEARLY_SKU -> STRIPE_YEARLY_PLAN open client page in discover in a new tab add stripe not logging /static call: disable flask logging, replace by after_request add param STRIPE_SECRET_KEY add 3 columns stripe_customer_id, stripe_card_token, stripe_subscription_id user can upgrade their pricing add setting page as coming-soon add GenEmail, ClientUser to admin ignore /admin/static logging add more fake data add ondelete="cascade" whenever possible rename plan_expiration -> trial_expiration reset migration: delete old migrations, create new one rename test_send_email -> poc_send_email to avoid the file being called by pytest add new param LYRA_ANALYTICS_ID, add lyra analytics add how to create new migration into readme add drift to base.html notify admin when new user signs up or pays subscription log exception in case of 500 use sendgrid to notify admin add alias /userinfo to user_info endpoint add change_password to shell add info on how payment is handled invite user to retry if card not working remove drift and add "contact us" link move poc_send_email into poc/ support getting client-id, client-secret from form-data in addition to basic auth client-id, client-secret is passed in form-data by passport-oauth2 for ex add jwtRS256 private and public key add jwk-jws-jwt poc add new param OPENID_PRIVATE_KEY_PATH, OPENID_PRIVATE_KEY_PATH add scope, redirect_url to AuthorizationCode and OauthToken take into scope when creating oauth-token, authorization-code add jwcrypto add jose_utils: make_id_token and verify_id_token add &scope to redirect uri add "email_verified": True into user_info fix user not activated add /oauth2 as alias for /oauth handle case where scope and state are empty remove threaded=False Use Email Alias as wording remove help text user can re-send activation email add "expired" into ActivationCode Handle the case activation code is expired reformat: use form.validate_on_submit instead of request.method == post && form.validate use error text instead of flash() display client oauth-id and oauth-secret on client detail page not display oauth-secret on client listing fix expiration check improve page title, footer add /jwks and /.well-known/openid-configuration init properly tests, fix blueprint conflict bug in flask-admin create oauth_models module rename Scope -> ScopeE to distinguish with Scope DB model set app.url_map.strict_slashes = False use ScopeE instead of SCOPE_NAME, ... support access_token passed as args in /userinfo merge /allow-deny into /authorize improve wording take into account the case response_type=code and openid is in scope take into account response_type=id_token, id_token token, id_token code make sure to use in-memory db in test fix scope can be null allow cross_origin for /.well-known/openid-configuration and /jwks fix footer link center authorize form rename trial_expiration to plan_expiration move stripe init to create_app() use real email to be able to receive email notification add user.profile_picture_id column use user profile picture and fallback to gravatar use nguyenkims+local@gm to distinguish with staging handle plan cancel, reactivation, user profile update fix can_create_new_email create cron.py that set plan to free when expired add crontab.yml add yacron use notify_admin instead of LOG.error add ResetPasswordCode model user can change password in setting increase display time for notie add forgot_password page If login error: redirect to this page upon success login. hide discover tab add column user.is_developer only show developer menu to developer comment out the publish button set local user to developer make sure only developer can access /developer blueprint User is invited to upgrade if they are in free plan or their trial ends soon not sending email when in local mode create Partner model create become partner page use normal error handling on local fix migration add "import sqlalchemy_utils" into migration template small refactoring on setting page handle promo code. TODO: add migration file add migration for user.promo_codes move email alias on top of apps in dashboard add introjs move encode_url to utils create GenEmail.create_new_gen_email create a first alias mail to show user how to use when they login show intro when user visits the website the first time fix register
2019-07-02 09:20:12 +02:00
2019-11-16 15:19:43 +01:00
template = env.get_template(template_name)
2020-08-14 16:30:43 +02:00
return template.render(
2020-08-25 13:03:34 +02:00
MAX_NB_EMAIL_FREE_PLAN=MAX_NB_EMAIL_FREE_PLAN,
URL=URL,
LANDING_PAGE_URL=LANDING_PAGE_URL,
2021-07-30 17:05:16 +02:00
YEAR=arrow.now().year,
2020-08-25 13:03:34 +02:00
**kwargs,
2020-08-14 16:50:59 +02:00
)
2019-11-16 15:19:43 +01:00
def send_welcome_email(user):
2020-10-22 10:44:05 +02:00
to_email, unsubscribe_link, via_email = user.get_communication_email()
2020-09-12 14:34:32 +02:00
if not to_email:
return
# whether this email is sent to an alias
alias = to_email if to_email != user.email else None
2019-12-16 18:36:59 +01:00
send_email(
2020-09-12 14:34:32 +02:00
to_email,
f"Welcome to SimpleLogin",
2021-03-23 18:47:16 +01:00
render("com/welcome.txt", user=user, alias=alias),
render("com/welcome.html", user=user, alias=alias),
2020-10-22 10:44:05 +02:00
unsubscribe_link,
via_email,
2019-11-16 15:19:43 +01:00
)
def send_trial_end_soon_email(user):
send_email(
user.email,
f"Your trial will end soon",
2021-11-05 18:33:14 +01:00
render("transactional/trial-end.txt.jinja2", user=user),
render("transactional/trial-end.html", user=user),
ignore_smtp_error=True,
)
def send_activation_email(email, activation_link):
2019-12-16 18:36:59 +01:00
send_email(
2019-11-16 15:19:43 +01:00
email,
f"Just one more step to join SimpleLogin",
2020-02-03 07:00:58 +01:00
render(
2020-02-02 15:49:38 +01:00
"transactional/activation.txt",
activation_link=activation_link,
email=email,
),
2020-02-03 07:00:58 +01:00
render(
2020-02-02 15:49:38 +01:00
"transactional/activation.html",
activation_link=activation_link,
email=email,
),
create BaseForm to enable CSRF register page redirect user to dashboard if they are logged in enable csrf for login page Set models more strict bootstrap developer page add helper method to ModelMixin, remove CRUDMixin display list of clients on developer index, add copy client-secret to clipboard using clipboardjs add toastr and use jquery non slim display a toast when user copies the client-secret create new client, generate client-id using unidecode client detail page: can edit client add delete client implement /oauth/authorize and /oauth/allow-deny implement /oauth/token add /oauth/user_info endpoint handle scopes: wip take into account scope: display scope, return user data according to scope create virtual-domain, gen email, client_user model WIP create authorize_nonlogin_user page user can choose to generate a new email no need to interfere with root logger log for before and after request if user has already allowed a client: generate a auth-code and redirect user to client get_user_info takes into account gen email display list of clients that have user has authorised use yk-client domain instead of localhost as cookie depends on the domain name use wtforms instead of flask_wtf Dockerfile delete virtual domain EMAIL_DOMAIN can come from env var bind to host 0.0.0.0 fix signup error: use session as default csrf_context rename yourkey to simplelogin add python-dotenv, ipython, sqlalchemy_utils create DB_URI, FLASK_SECRET. Load config from CONFIG file if exist add shortcuts to logging create shell add psycopg2 do not add local data in Dockerfile add drop_db into shell add shell.prepare_db() fix prepare_db setup sentry copy assets from tabler/dist add icon downloaded from https://commons.wikimedia.org/wiki/File:Simpleicons_Interface_key-tool-1.svg integrate tabler - login and register page add favicon template: default, header. Use gravatar for user avatar url use default template for dashboard, developer page use another icon add clipboard and notie prettify dashboard add notie css add fake gen email and client-user prettify list client page, use notie for toast add email, name scope to new client display client scope in client list prettify new-client, client-detail add sentry-sdk and blinker add arrow, add dt jinja filter, prettify logout, dashboard comment "last used" in dashboard for now prettify date display add copy email to clipboard to dashboard use "users" as table name for User as "user" is reserved key in postgres call prepare_db() when creating new db error page 400, 401, 403, 404 prettify authorize_login_user create already_authorize.html for user who has already authorized a client user can generate new email display all other generated emails add ENV variable, only reset DB when ENV=local fix: not return other users gen emails display nb users for each client refactor shell: remove prepare_db() add sendgrid add /favicon.ico route add new config: URL, SUPPORT_EMAIL, SENDGRID_API_KEY user needs to activate their account before login create copy button on dashboard client can have multiple redirect uris, in client detail can add/remove redirect-uri, use redirect_uri passed in /authorize refactor: move get_user_info into ClientUser model dashboard: display all apps, all generated emails add "id" into user_info add trigger email button invalidate the session at each new version by changing the secret centralize Client creation into Client.create_new user can enable/disable email forwarding setup auto dismiss alert: just add .alert-auto-dismiss move name down in register form add shell.add_real_data move blueprint template to its own package prettify authorize page for non-authenticated user update readme, return error if not redirect_uri add flask-wtf, use psycopg2-binary use flask-wtf FlaskForm instead of Form rename email -> email_utils add AWS_REGION, BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY to config add s3 module add File model, add Client.icon_id handle client icon update can create client with icon display client icon in client list page add Client.home_url take into account Client.home_url add boto3 register: ask name first only show "trigger test email" if email forwarding is enabled display gen email in alphabetical order, client in client.name alphabetical order better error page the modal does not get close when user clicks outside of modal add Client.published column discover page that displays all published Client add missing bootstrap.bundle.min.js.map developer can publish/unpublish their app in discover use notie for display flash message create hotmail account fix missing jquery add footer, add global jinja2 variable strengthen model: use nullable=False whenever possible, rename client_id to oauth_client_id, client_secret to oauth_client_secret add flask-migrate init migrate 1st migrate version fix rename client_id -> oauth_client_id prettify UI use flask_migrate.upgrade() instead of db.create_all() make sure requirejs.config is called for all page enable sentry for js, use uppercase for global jinja2 variables add flask-admin add User.is_admin column setup flask admin, only accessible to admin user fix migration: add server_default replace session[redirect_after_login] by "next" request args add pyproject.toml: ignore migrations/ in black add register waiting_activation_email page better email wording add pytest add get_host_name_and_scheme and tests example fail test fix test fix client-id display add flask-cors /user_info supports cors, add /me as /user_info synonym return client in /me support implicit flow no need to use with "app.app_context()" add watchtower to requirement add param ENABLE_CLOUDWATCH, CLOUDWATCH_LOG_GROUP, CLOUDWATCH_LOG_STREAM add cloudwatch logger if cloudwatch is enabled add 500 error page add help text for list of used client display list of app/website that an email has been used click on client name brings to client detail page create style.css to add additional style, append its url with the current sha1 to avoid cache POC on how to send email using postfix add sqlalchemy-utils use arrow instead of datetime add new params STRIPE_API, STRIPE_YEARLY_SKU, STRIPE_MONTHLY_PLAN show full error in local add plan, plan_expiration to User, need to create enum directly in migration script, cf https://github.com/sqlalchemy/alembic/issues/67 reformat all html files: use space instead of tab new user will have trial plan for 15 days add new param MAX_NB_EMAIL_FREE_PLAN only user with enough quota can create new email if user cannot create new gen email, pick randomly one from existing gen emails. Use flush instead of commit rename STRIPE_YEARLY_SKU -> STRIPE_YEARLY_PLAN open client page in discover in a new tab add stripe not logging /static call: disable flask logging, replace by after_request add param STRIPE_SECRET_KEY add 3 columns stripe_customer_id, stripe_card_token, stripe_subscription_id user can upgrade their pricing add setting page as coming-soon add GenEmail, ClientUser to admin ignore /admin/static logging add more fake data add ondelete="cascade" whenever possible rename plan_expiration -> trial_expiration reset migration: delete old migrations, create new one rename test_send_email -> poc_send_email to avoid the file being called by pytest add new param LYRA_ANALYTICS_ID, add lyra analytics add how to create new migration into readme add drift to base.html notify admin when new user signs up or pays subscription log exception in case of 500 use sendgrid to notify admin add alias /userinfo to user_info endpoint add change_password to shell add info on how payment is handled invite user to retry if card not working remove drift and add "contact us" link move poc_send_email into poc/ support getting client-id, client-secret from form-data in addition to basic auth client-id, client-secret is passed in form-data by passport-oauth2 for ex add jwtRS256 private and public key add jwk-jws-jwt poc add new param OPENID_PRIVATE_KEY_PATH, OPENID_PRIVATE_KEY_PATH add scope, redirect_url to AuthorizationCode and OauthToken take into scope when creating oauth-token, authorization-code add jwcrypto add jose_utils: make_id_token and verify_id_token add &scope to redirect uri add "email_verified": True into user_info fix user not activated add /oauth2 as alias for /oauth handle case where scope and state are empty remove threaded=False Use Email Alias as wording remove help text user can re-send activation email add "expired" into ActivationCode Handle the case activation code is expired reformat: use form.validate_on_submit instead of request.method == post && form.validate use error text instead of flash() display client oauth-id and oauth-secret on client detail page not display oauth-secret on client listing fix expiration check improve page title, footer add /jwks and /.well-known/openid-configuration init properly tests, fix blueprint conflict bug in flask-admin create oauth_models module rename Scope -> ScopeE to distinguish with Scope DB model set app.url_map.strict_slashes = False use ScopeE instead of SCOPE_NAME, ... support access_token passed as args in /userinfo merge /allow-deny into /authorize improve wording take into account the case response_type=code and openid is in scope take into account response_type=id_token, id_token token, id_token code make sure to use in-memory db in test fix scope can be null allow cross_origin for /.well-known/openid-configuration and /jwks fix footer link center authorize form rename trial_expiration to plan_expiration move stripe init to create_app() use real email to be able to receive email notification add user.profile_picture_id column use user profile picture and fallback to gravatar use nguyenkims+local@gm to distinguish with staging handle plan cancel, reactivation, user profile update fix can_create_new_email create cron.py that set plan to free when expired add crontab.yml add yacron use notify_admin instead of LOG.error add ResetPasswordCode model user can change password in setting increase display time for notie add forgot_password page If login error: redirect to this page upon success login. hide discover tab add column user.is_developer only show developer menu to developer comment out the publish button set local user to developer make sure only developer can access /developer blueprint User is invited to upgrade if they are in free plan or their trial ends soon not sending email when in local mode create Partner model create become partner page use normal error handling on local fix migration add "import sqlalchemy_utils" into migration template small refactoring on setting page handle promo code. TODO: add migration file add migration for user.promo_codes move email alias on top of apps in dashboard add introjs move encode_url to utils create GenEmail.create_new_gen_email create a first alias mail to show user how to use when they login show intro when user visits the website the first time fix register
2019-07-02 09:20:12 +02:00
)
create BaseForm to enable CSRF register page redirect user to dashboard if they are logged in enable csrf for login page Set models more strict bootstrap developer page add helper method to ModelMixin, remove CRUDMixin display list of clients on developer index, add copy client-secret to clipboard using clipboardjs add toastr and use jquery non slim display a toast when user copies the client-secret create new client, generate client-id using unidecode client detail page: can edit client add delete client implement /oauth/authorize and /oauth/allow-deny implement /oauth/token add /oauth/user_info endpoint handle scopes: wip take into account scope: display scope, return user data according to scope create virtual-domain, gen email, client_user model WIP create authorize_nonlogin_user page user can choose to generate a new email no need to interfere with root logger log for before and after request if user has already allowed a client: generate a auth-code and redirect user to client get_user_info takes into account gen email display list of clients that have user has authorised use yk-client domain instead of localhost as cookie depends on the domain name use wtforms instead of flask_wtf Dockerfile delete virtual domain EMAIL_DOMAIN can come from env var bind to host 0.0.0.0 fix signup error: use session as default csrf_context rename yourkey to simplelogin add python-dotenv, ipython, sqlalchemy_utils create DB_URI, FLASK_SECRET. Load config from CONFIG file if exist add shortcuts to logging create shell add psycopg2 do not add local data in Dockerfile add drop_db into shell add shell.prepare_db() fix prepare_db setup sentry copy assets from tabler/dist add icon downloaded from https://commons.wikimedia.org/wiki/File:Simpleicons_Interface_key-tool-1.svg integrate tabler - login and register page add favicon template: default, header. Use gravatar for user avatar url use default template for dashboard, developer page use another icon add clipboard and notie prettify dashboard add notie css add fake gen email and client-user prettify list client page, use notie for toast add email, name scope to new client display client scope in client list prettify new-client, client-detail add sentry-sdk and blinker add arrow, add dt jinja filter, prettify logout, dashboard comment "last used" in dashboard for now prettify date display add copy email to clipboard to dashboard use "users" as table name for User as "user" is reserved key in postgres call prepare_db() when creating new db error page 400, 401, 403, 404 prettify authorize_login_user create already_authorize.html for user who has already authorized a client user can generate new email display all other generated emails add ENV variable, only reset DB when ENV=local fix: not return other users gen emails display nb users for each client refactor shell: remove prepare_db() add sendgrid add /favicon.ico route add new config: URL, SUPPORT_EMAIL, SENDGRID_API_KEY user needs to activate their account before login create copy button on dashboard client can have multiple redirect uris, in client detail can add/remove redirect-uri, use redirect_uri passed in /authorize refactor: move get_user_info into ClientUser model dashboard: display all apps, all generated emails add "id" into user_info add trigger email button invalidate the session at each new version by changing the secret centralize Client creation into Client.create_new user can enable/disable email forwarding setup auto dismiss alert: just add .alert-auto-dismiss move name down in register form add shell.add_real_data move blueprint template to its own package prettify authorize page for non-authenticated user update readme, return error if not redirect_uri add flask-wtf, use psycopg2-binary use flask-wtf FlaskForm instead of Form rename email -> email_utils add AWS_REGION, BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY to config add s3 module add File model, add Client.icon_id handle client icon update can create client with icon display client icon in client list page add Client.home_url take into account Client.home_url add boto3 register: ask name first only show "trigger test email" if email forwarding is enabled display gen email in alphabetical order, client in client.name alphabetical order better error page the modal does not get close when user clicks outside of modal add Client.published column discover page that displays all published Client add missing bootstrap.bundle.min.js.map developer can publish/unpublish their app in discover use notie for display flash message create hotmail account fix missing jquery add footer, add global jinja2 variable strengthen model: use nullable=False whenever possible, rename client_id to oauth_client_id, client_secret to oauth_client_secret add flask-migrate init migrate 1st migrate version fix rename client_id -> oauth_client_id prettify UI use flask_migrate.upgrade() instead of db.create_all() make sure requirejs.config is called for all page enable sentry for js, use uppercase for global jinja2 variables add flask-admin add User.is_admin column setup flask admin, only accessible to admin user fix migration: add server_default replace session[redirect_after_login] by "next" request args add pyproject.toml: ignore migrations/ in black add register waiting_activation_email page better email wording add pytest add get_host_name_and_scheme and tests example fail test fix test fix client-id display add flask-cors /user_info supports cors, add /me as /user_info synonym return client in /me support implicit flow no need to use with "app.app_context()" add watchtower to requirement add param ENABLE_CLOUDWATCH, CLOUDWATCH_LOG_GROUP, CLOUDWATCH_LOG_STREAM add cloudwatch logger if cloudwatch is enabled add 500 error page add help text for list of used client display list of app/website that an email has been used click on client name brings to client detail page create style.css to add additional style, append its url with the current sha1 to avoid cache POC on how to send email using postfix add sqlalchemy-utils use arrow instead of datetime add new params STRIPE_API, STRIPE_YEARLY_SKU, STRIPE_MONTHLY_PLAN show full error in local add plan, plan_expiration to User, need to create enum directly in migration script, cf https://github.com/sqlalchemy/alembic/issues/67 reformat all html files: use space instead of tab new user will have trial plan for 15 days add new param MAX_NB_EMAIL_FREE_PLAN only user with enough quota can create new email if user cannot create new gen email, pick randomly one from existing gen emails. Use flush instead of commit rename STRIPE_YEARLY_SKU -> STRIPE_YEARLY_PLAN open client page in discover in a new tab add stripe not logging /static call: disable flask logging, replace by after_request add param STRIPE_SECRET_KEY add 3 columns stripe_customer_id, stripe_card_token, stripe_subscription_id user can upgrade their pricing add setting page as coming-soon add GenEmail, ClientUser to admin ignore /admin/static logging add more fake data add ondelete="cascade" whenever possible rename plan_expiration -> trial_expiration reset migration: delete old migrations, create new one rename test_send_email -> poc_send_email to avoid the file being called by pytest add new param LYRA_ANALYTICS_ID, add lyra analytics add how to create new migration into readme add drift to base.html notify admin when new user signs up or pays subscription log exception in case of 500 use sendgrid to notify admin add alias /userinfo to user_info endpoint add change_password to shell add info on how payment is handled invite user to retry if card not working remove drift and add "contact us" link move poc_send_email into poc/ support getting client-id, client-secret from form-data in addition to basic auth client-id, client-secret is passed in form-data by passport-oauth2 for ex add jwtRS256 private and public key add jwk-jws-jwt poc add new param OPENID_PRIVATE_KEY_PATH, OPENID_PRIVATE_KEY_PATH add scope, redirect_url to AuthorizationCode and OauthToken take into scope when creating oauth-token, authorization-code add jwcrypto add jose_utils: make_id_token and verify_id_token add &scope to redirect uri add "email_verified": True into user_info fix user not activated add /oauth2 as alias for /oauth handle case where scope and state are empty remove threaded=False Use Email Alias as wording remove help text user can re-send activation email add "expired" into ActivationCode Handle the case activation code is expired reformat: use form.validate_on_submit instead of request.method == post && form.validate use error text instead of flash() display client oauth-id and oauth-secret on client detail page not display oauth-secret on client listing fix expiration check improve page title, footer add /jwks and /.well-known/openid-configuration init properly tests, fix blueprint conflict bug in flask-admin create oauth_models module rename Scope -> ScopeE to distinguish with Scope DB model set app.url_map.strict_slashes = False use ScopeE instead of SCOPE_NAME, ... support access_token passed as args in /userinfo merge /allow-deny into /authorize improve wording take into account the case response_type=code and openid is in scope take into account response_type=id_token, id_token token, id_token code make sure to use in-memory db in test fix scope can be null allow cross_origin for /.well-known/openid-configuration and /jwks fix footer link center authorize form rename trial_expiration to plan_expiration move stripe init to create_app() use real email to be able to receive email notification add user.profile_picture_id column use user profile picture and fallback to gravatar use nguyenkims+local@gm to distinguish with staging handle plan cancel, reactivation, user profile update fix can_create_new_email create cron.py that set plan to free when expired add crontab.yml add yacron use notify_admin instead of LOG.error add ResetPasswordCode model user can change password in setting increase display time for notie add forgot_password page If login error: redirect to this page upon success login. hide discover tab add column user.is_developer only show developer menu to developer comment out the publish button set local user to developer make sure only developer can access /developer blueprint User is invited to upgrade if they are in free plan or their trial ends soon not sending email when in local mode create Partner model create become partner page use normal error handling on local fix migration add "import sqlalchemy_utils" into migration template small refactoring on setting page handle promo code. TODO: add migration file add migration for user.promo_codes move email alias on top of apps in dashboard add introjs move encode_url to utils create GenEmail.create_new_gen_email create a first alias mail to show user how to use when they login show intro when user visits the website the first time fix register
2019-07-02 09:20:12 +02:00
def send_reset_password_email(email, reset_password_link):
2019-12-16 18:36:59 +01:00
send_email(
2019-11-16 15:19:43 +01:00
email,
2020-12-06 18:01:55 +01:00
"Reset your password on SimpleLogin",
2020-02-03 07:00:58 +01:00
render(
2020-02-02 15:49:38 +01:00
"transactional/reset-password.txt",
reset_password_link=reset_password_link,
2019-11-16 15:19:43 +01:00
),
2020-02-03 07:00:58 +01:00
render(
2020-02-02 15:49:38 +01:00
"transactional/reset-password.html",
reset_password_link=reset_password_link,
2019-11-16 15:19:43 +01:00
),
)
create BaseForm to enable CSRF register page redirect user to dashboard if they are logged in enable csrf for login page Set models more strict bootstrap developer page add helper method to ModelMixin, remove CRUDMixin display list of clients on developer index, add copy client-secret to clipboard using clipboardjs add toastr and use jquery non slim display a toast when user copies the client-secret create new client, generate client-id using unidecode client detail page: can edit client add delete client implement /oauth/authorize and /oauth/allow-deny implement /oauth/token add /oauth/user_info endpoint handle scopes: wip take into account scope: display scope, return user data according to scope create virtual-domain, gen email, client_user model WIP create authorize_nonlogin_user page user can choose to generate a new email no need to interfere with root logger log for before and after request if user has already allowed a client: generate a auth-code and redirect user to client get_user_info takes into account gen email display list of clients that have user has authorised use yk-client domain instead of localhost as cookie depends on the domain name use wtforms instead of flask_wtf Dockerfile delete virtual domain EMAIL_DOMAIN can come from env var bind to host 0.0.0.0 fix signup error: use session as default csrf_context rename yourkey to simplelogin add python-dotenv, ipython, sqlalchemy_utils create DB_URI, FLASK_SECRET. Load config from CONFIG file if exist add shortcuts to logging create shell add psycopg2 do not add local data in Dockerfile add drop_db into shell add shell.prepare_db() fix prepare_db setup sentry copy assets from tabler/dist add icon downloaded from https://commons.wikimedia.org/wiki/File:Simpleicons_Interface_key-tool-1.svg integrate tabler - login and register page add favicon template: default, header. Use gravatar for user avatar url use default template for dashboard, developer page use another icon add clipboard and notie prettify dashboard add notie css add fake gen email and client-user prettify list client page, use notie for toast add email, name scope to new client display client scope in client list prettify new-client, client-detail add sentry-sdk and blinker add arrow, add dt jinja filter, prettify logout, dashboard comment "last used" in dashboard for now prettify date display add copy email to clipboard to dashboard use "users" as table name for User as "user" is reserved key in postgres call prepare_db() when creating new db error page 400, 401, 403, 404 prettify authorize_login_user create already_authorize.html for user who has already authorized a client user can generate new email display all other generated emails add ENV variable, only reset DB when ENV=local fix: not return other users gen emails display nb users for each client refactor shell: remove prepare_db() add sendgrid add /favicon.ico route add new config: URL, SUPPORT_EMAIL, SENDGRID_API_KEY user needs to activate their account before login create copy button on dashboard client can have multiple redirect uris, in client detail can add/remove redirect-uri, use redirect_uri passed in /authorize refactor: move get_user_info into ClientUser model dashboard: display all apps, all generated emails add "id" into user_info add trigger email button invalidate the session at each new version by changing the secret centralize Client creation into Client.create_new user can enable/disable email forwarding setup auto dismiss alert: just add .alert-auto-dismiss move name down in register form add shell.add_real_data move blueprint template to its own package prettify authorize page for non-authenticated user update readme, return error if not redirect_uri add flask-wtf, use psycopg2-binary use flask-wtf FlaskForm instead of Form rename email -> email_utils add AWS_REGION, BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY to config add s3 module add File model, add Client.icon_id handle client icon update can create client with icon display client icon in client list page add Client.home_url take into account Client.home_url add boto3 register: ask name first only show "trigger test email" if email forwarding is enabled display gen email in alphabetical order, client in client.name alphabetical order better error page the modal does not get close when user clicks outside of modal add Client.published column discover page that displays all published Client add missing bootstrap.bundle.min.js.map developer can publish/unpublish their app in discover use notie for display flash message create hotmail account fix missing jquery add footer, add global jinja2 variable strengthen model: use nullable=False whenever possible, rename client_id to oauth_client_id, client_secret to oauth_client_secret add flask-migrate init migrate 1st migrate version fix rename client_id -> oauth_client_id prettify UI use flask_migrate.upgrade() instead of db.create_all() make sure requirejs.config is called for all page enable sentry for js, use uppercase for global jinja2 variables add flask-admin add User.is_admin column setup flask admin, only accessible to admin user fix migration: add server_default replace session[redirect_after_login] by "next" request args add pyproject.toml: ignore migrations/ in black add register waiting_activation_email page better email wording add pytest add get_host_name_and_scheme and tests example fail test fix test fix client-id display add flask-cors /user_info supports cors, add /me as /user_info synonym return client in /me support implicit flow no need to use with "app.app_context()" add watchtower to requirement add param ENABLE_CLOUDWATCH, CLOUDWATCH_LOG_GROUP, CLOUDWATCH_LOG_STREAM add cloudwatch logger if cloudwatch is enabled add 500 error page add help text for list of used client display list of app/website that an email has been used click on client name brings to client detail page create style.css to add additional style, append its url with the current sha1 to avoid cache POC on how to send email using postfix add sqlalchemy-utils use arrow instead of datetime add new params STRIPE_API, STRIPE_YEARLY_SKU, STRIPE_MONTHLY_PLAN show full error in local add plan, plan_expiration to User, need to create enum directly in migration script, cf https://github.com/sqlalchemy/alembic/issues/67 reformat all html files: use space instead of tab new user will have trial plan for 15 days add new param MAX_NB_EMAIL_FREE_PLAN only user with enough quota can create new email if user cannot create new gen email, pick randomly one from existing gen emails. Use flush instead of commit rename STRIPE_YEARLY_SKU -> STRIPE_YEARLY_PLAN open client page in discover in a new tab add stripe not logging /static call: disable flask logging, replace by after_request add param STRIPE_SECRET_KEY add 3 columns stripe_customer_id, stripe_card_token, stripe_subscription_id user can upgrade their pricing add setting page as coming-soon add GenEmail, ClientUser to admin ignore /admin/static logging add more fake data add ondelete="cascade" whenever possible rename plan_expiration -> trial_expiration reset migration: delete old migrations, create new one rename test_send_email -> poc_send_email to avoid the file being called by pytest add new param LYRA_ANALYTICS_ID, add lyra analytics add how to create new migration into readme add drift to base.html notify admin when new user signs up or pays subscription log exception in case of 500 use sendgrid to notify admin add alias /userinfo to user_info endpoint add change_password to shell add info on how payment is handled invite user to retry if card not working remove drift and add "contact us" link move poc_send_email into poc/ support getting client-id, client-secret from form-data in addition to basic auth client-id, client-secret is passed in form-data by passport-oauth2 for ex add jwtRS256 private and public key add jwk-jws-jwt poc add new param OPENID_PRIVATE_KEY_PATH, OPENID_PRIVATE_KEY_PATH add scope, redirect_url to AuthorizationCode and OauthToken take into scope when creating oauth-token, authorization-code add jwcrypto add jose_utils: make_id_token and verify_id_token add &scope to redirect uri add "email_verified": True into user_info fix user not activated add /oauth2 as alias for /oauth handle case where scope and state are empty remove threaded=False Use Email Alias as wording remove help text user can re-send activation email add "expired" into ActivationCode Handle the case activation code is expired reformat: use form.validate_on_submit instead of request.method == post && form.validate use error text instead of flash() display client oauth-id and oauth-secret on client detail page not display oauth-secret on client listing fix expiration check improve page title, footer add /jwks and /.well-known/openid-configuration init properly tests, fix blueprint conflict bug in flask-admin create oauth_models module rename Scope -> ScopeE to distinguish with Scope DB model set app.url_map.strict_slashes = False use ScopeE instead of SCOPE_NAME, ... support access_token passed as args in /userinfo merge /allow-deny into /authorize improve wording take into account the case response_type=code and openid is in scope take into account response_type=id_token, id_token token, id_token code make sure to use in-memory db in test fix scope can be null allow cross_origin for /.well-known/openid-configuration and /jwks fix footer link center authorize form rename trial_expiration to plan_expiration move stripe init to create_app() use real email to be able to receive email notification add user.profile_picture_id column use user profile picture and fallback to gravatar use nguyenkims+local@gm to distinguish with staging handle plan cancel, reactivation, user profile update fix can_create_new_email create cron.py that set plan to free when expired add crontab.yml add yacron use notify_admin instead of LOG.error add ResetPasswordCode model user can change password in setting increase display time for notie add forgot_password page If login error: redirect to this page upon success login. hide discover tab add column user.is_developer only show developer menu to developer comment out the publish button set local user to developer make sure only developer can access /developer blueprint User is invited to upgrade if they are in free plan or their trial ends soon not sending email when in local mode create Partner model create become partner page use normal error handling on local fix migration add "import sqlalchemy_utils" into migration template small refactoring on setting page handle promo code. TODO: add migration file add migration for user.promo_codes move email alias on top of apps in dashboard add introjs move encode_url to utils create GenEmail.create_new_gen_email create a first alias mail to show user how to use when they login show intro when user visits the website the first time fix register
2019-07-02 09:20:12 +02:00
2019-11-16 15:19:43 +01:00
def send_change_email(new_email, current_email, link):
2019-12-16 18:36:59 +01:00
send_email(
2019-11-18 19:34:53 +01:00
new_email,
2020-12-06 18:01:55 +01:00
"Confirm email update on SimpleLogin",
2020-02-03 07:00:58 +01:00
render(
"transactional/change-email.txt",
2019-11-18 19:34:53 +01:00
link=link,
new_email=new_email,
current_email=current_email,
),
2020-02-03 07:00:58 +01:00
render(
"transactional/change-email.html",
2019-11-18 19:34:53 +01:00
link=link,
new_email=new_email,
current_email=current_email,
),
)
def send_invalid_totp_login_email(user, totp_type):
send_email_with_rate_control(
user,
ALERT_INVALID_TOTP_LOGIN,
user.email,
2022-01-20 19:24:28 +01:00
"Unsuccessful attempt to login to your SimpleLogin account",
render(
"transactional/invalid-totp-login.txt",
type=totp_type,
),
render(
"transactional/invalid-totp-login.html",
type=totp_type,
),
1,
)
2019-11-16 15:26:50 +01:00
def send_test_email_alias(email, name):
2019-12-16 18:36:59 +01:00
send_email(
2019-11-16 15:26:50 +01:00
email,
f"This email is sent to {email}",
2020-02-03 07:00:58 +01:00
render("transactional/test-email.txt", name=name, alias=email),
render("transactional/test-email.html", name=name, alias=email),
2019-11-16 15:26:50 +01:00
)
2020-12-07 10:50:42 +01:00
def send_cannot_create_directory_alias(user, alias_address, directory_name):
"""when user cancels their subscription, they cannot create alias on the fly.
If this happens, send them an email to notify
"""
send_email(
user.email,
2020-12-07 10:50:42 +01:00
f"Alias {alias_address} cannot be created",
2020-02-03 07:00:58 +01:00
render(
"transactional/cannot-create-alias-directory.txt",
2020-12-07 10:50:42 +01:00
alias=alias_address,
directory=directory_name,
),
2020-02-03 07:00:58 +01:00
render(
"transactional/cannot-create-alias-directory.html",
2020-12-07 10:50:42 +01:00
alias=alias_address,
directory=directory_name,
),
)
def send_cannot_create_directory_alias_disabled(user, alias_address, directory_name):
"""when the directory is disabled, new alias can't be created on-the-fly.
Send user an email to notify of an attempt
"""
send_email_with_rate_control(
user,
ALERT_DIRECTORY_DISABLED_ALIAS_CREATION,
user.email,
f"Alias {alias_address} cannot be created",
render(
"transactional/cannot-create-alias-directory-disabled.txt",
alias=alias_address,
directory=directory_name,
),
render(
"transactional/cannot-create-alias-directory-disabled.html",
alias=alias_address,
directory=directory_name,
),
)
def send_cannot_create_domain_alias(user, alias, domain):
"""when user cancels their subscription, they cannot create alias on the fly with custom domain.
If this happens, send them an email to notify
"""
send_email(
user.email,
f"Alias {alias} cannot be created",
2020-02-03 07:00:58 +01:00
render(
2020-02-02 15:49:38 +01:00
"transactional/cannot-create-alias-domain.txt",
alias=alias,
domain=domain,
),
2020-02-03 07:00:58 +01:00
render(
"transactional/cannot-create-alias-domain.html",
2020-01-16 22:27:45 +01:00
alias=alias,
domain=domain,
),
)
2020-10-22 10:44:05 +02:00
def send_email(
to_email,
subject,
plaintext,
html=None,
unsubscribe_link=None,
unsubscribe_via_email=False,
2021-12-28 16:42:01 +01:00
retries=0, # by default no retry if sending fails
ignore_smtp_error=False,
2020-10-22 10:44:05 +02:00
):
to_email = sanitize_email(to_email)
2019-12-09 21:21:40 +01:00
if NOT_SEND_EMAIL:
LOG.d(
2021-04-01 10:52:51 +02:00
"send email with subject '%s' to '%s', plaintext: %s, html: %s",
2019-12-09 21:21:40 +01:00
subject,
to_email,
plaintext,
2021-04-01 10:52:51 +02:00
html,
2019-12-09 21:21:40 +01:00
)
return
2022-01-26 15:22:37 +01:00
LOG.d("send email to %s, subject '%s'", to_email, subject)
2020-02-23 08:38:53 +01:00
if html:
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(plaintext))
msg.attach(MIMEText(html, "html"))
else:
msg = EmailMessage()
msg.set_payload(plaintext)
msg[headers.CONTENT_TYPE] = "text/plain"
2021-10-11 12:10:18 +02:00
msg[headers.SUBJECT] = subject
2022-02-21 12:30:26 +01:00
msg[headers.FROM] = f"{NOREPLY} <{NOREPLY}>"
2021-10-11 12:10:18 +02:00
msg[headers.TO] = to_email
2019-11-16 15:19:43 +01:00
msg_id_header = make_msgid()
2021-10-11 12:10:18 +02:00
msg[headers.MESSAGE_ID] = msg_id_header
date_header = formatdate()
2021-10-11 12:10:18 +02:00
msg[headers.DATE] = date_header
2020-10-22 10:44:05 +02:00
if unsubscribe_link:
add_or_replace_header(msg, headers.LIST_UNSUBSCRIBE, f"<{unsubscribe_link}>")
2020-10-22 10:44:05 +02:00
if not unsubscribe_via_email:
add_or_replace_header(
msg, headers.LIST_UNSUBSCRIBE_POST, "List-Unsubscribe=One-Click"
2020-10-22 10:44:05 +02:00
)
# add DKIM
2022-02-17 13:18:52 +01:00
email_domain = NOREPLY[NOREPLY.find("@") + 1 :]
add_dkim_signature(msg, email_domain)
transaction = TransactionalEmail.create(email=to_email, commit=True)
# use a different envelope sender for each transactional email (aka VERP)
sl_sendmail(
TRANSACTIONAL_BOUNCE_EMAIL.format(transaction.id),
to_email,
msg,
2021-12-28 16:42:01 +01:00
retries=retries,
ignore_smtp_error=ignore_smtp_error,
)
def send_email_with_rate_control(
user: User,
alert_type: str,
to_email: str,
subject,
plaintext,
html=None,
max_nb_alert=MAX_ALERT_24H,
nb_day=1,
ignore_smtp_error=False,
2021-12-28 16:42:01 +01:00
retries=0,
) -> bool:
"""Same as send_email with rate control over alert_type.
Make sure no more than `max_nb_alert` emails are sent over the period of `nb_day` days
Return true if the email is sent, otherwise False
"""
to_email = sanitize_email(to_email)
min_dt = arrow.now().shift(days=-1 * nb_day)
nb_alert = (
SentAlert.filter_by(alert_type=alert_type, to_email=to_email)
.filter(SentAlert.created_at > min_dt)
.count()
)
if nb_alert >= max_nb_alert:
2021-09-08 11:29:55 +02:00
LOG.w(
"%s emails were sent to %s in the last %s days, alert type %s",
nb_alert,
to_email,
nb_day,
alert_type,
)
return False
SentAlert.create(user_id=user.id, alert_type=alert_type, to_email=to_email)
Session.commit()
if ignore_smtp_error:
try:
2021-12-28 16:42:01 +01:00
send_email(to_email, subject, plaintext, html, retries=retries)
except SMTPException:
LOG.w("Cannot send email to %s, subject %s", to_email, subject)
else:
2021-12-28 16:42:01 +01:00
send_email(to_email, subject, plaintext, html, retries=retries)
return True
2020-08-27 10:43:30 +02:00
def send_email_at_most_times(
user: User,
alert_type: str,
to_email: str,
subject,
plaintext,
html=None,
max_times=1,
) -> bool:
"""Same as send_email with rate control over alert_type.
Sent at most `max_times`
This is used to inform users about a warning.
Return true if the email is sent, otherwise False
"""
to_email = sanitize_email(to_email)
nb_alert = SentAlert.filter_by(alert_type=alert_type, to_email=to_email).count()
2020-08-27 10:43:30 +02:00
if nb_alert >= max_times:
2021-09-08 11:29:55 +02:00
LOG.w(
2020-08-27 10:43:30 +02:00
"%s emails were sent to %s alert type %s",
nb_alert,
to_email,
alert_type,
)
return False
SentAlert.create(user_id=user.id, alert_type=alert_type, to_email=to_email)
Session.commit()
2020-08-27 10:43:30 +02:00
send_email(to_email, subject, plaintext, html)
return True
def get_email_local_part(address) -> str:
"""
Get the local part from email
ab@cd.com -> ab
Convert the local part to lowercase
"""
r: ValidatedEmail = validate_email(
address, check_deliverability=False, allow_smtputf8=False
)
return r.local_part.lower()
def get_email_domain_part(address):
"""
Get the domain part from email
ab@cd.com -> cd.com
"""
address = sanitize_email(address)
return address[address.find("@") + 1 :]
2020-01-07 19:47:26 +01:00
def add_dkim_signature(msg: Message, email_domain: str):
if RSPAMD_SIGN_DKIM:
LOG.d("DKIM signature will be added by rspamd")
msg[headers.SL_WANT_SIGNING] = "yes"
return
2021-10-11 12:10:18 +02:00
for dkim_headers in headers.DKIM_HEADERS:
2021-09-13 16:04:32 +02:00
try:
add_dkim_signature_with_header(msg, email_domain, dkim_headers)
return
except dkim.DKIMException:
2021-09-14 09:13:39 +02:00
LOG.w("DKIM fail with %s", dkim_headers, exc_info=True)
2021-09-13 16:04:32 +02:00
# try with another headers
continue
# To investigate why some emails can't be DKIM signed. todo: remove
if TEMP_DIR:
file_name = str(uuid.uuid4()) + ".eml"
with open(os.path.join(TEMP_DIR, file_name), "wb") as f:
f.write(msg.as_bytes())
LOG.w("email saved to %s", file_name)
2021-09-13 16:04:32 +02:00
raise Exception("Cannot create DKIM signature")
def add_dkim_signature_with_header(
msg: Message, email_domain: str, dkim_headers: [bytes]
):
delete_header(msg, "DKIM-Signature")
2019-12-17 18:06:17 +01:00
# Specify headers in "byte" form
# Generate message signature
2021-03-08 15:18:03 +01:00
if DKIM_PRIVATE_KEY:
sig = dkim.sign(
to_bytes(msg),
DKIM_SELECTOR,
email_domain.encode(),
DKIM_PRIVATE_KEY.encode(),
2021-09-13 16:04:32 +02:00
include_headers=dkim_headers,
2021-03-08 15:18:03 +01:00
)
sig = sig.decode()
2021-03-08 15:18:03 +01:00
# remove linebreaks from sig
sig = sig.replace("\n", " ").replace("\r", "")
2021-10-11 12:10:18 +02:00
msg[headers.DKIM_SIGNATURE] = sig[len("DKIM-Signature: ") :]
def add_or_replace_header(msg: Message, header: str, value: str):
"""
Remove all occurrences of `header` and add `header` with `value`.
"""
delete_header(msg, header)
msg[header] = value
2020-01-07 19:49:26 +01:00
def delete_header(msg: Message, header: str):
"""a header can appear several times in message."""
# inspired from https://stackoverflow.com/a/47903323/1428034
for i in reversed(range(len(msg._headers))):
header_name = msg._headers[i][0].lower()
if header_name == header.lower():
del msg._headers[i]
2021-03-17 10:59:13 +01:00
def sanitize_header(msg: Message, header: str):
"""remove trailing space and remove linebreak from a header"""
for i in reversed(range(len(msg._headers))):
header_name = msg._headers[i][0].lower()
if header_name == header.lower():
# msg._headers[i] is a tuple like ('From', 'hey@google.com')
if msg._headers[i][1]:
msg._headers[i] = (
msg._headers[i][0],
msg._headers[i][1].strip().replace("\n", " "),
)
2020-03-14 22:24:02 +01:00
def delete_all_headers_except(msg: Message, headers: [str]):
headers = [h.lower() for h in headers]
for i in reversed(range(len(msg._headers))):
header_name = msg._headers[i][0].lower()
if header_name not in headers:
del msg._headers[i]
2021-09-21 14:09:24 +02:00
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"""
2020-10-15 16:21:31 +02:00
# not allow creating directory with premium domain
for domain in ALIAS_DOMAINS:
2021-09-21 14:09:24 +02:00
if email_address.endswith("@" + domain):
return True
return False
2020-01-25 16:40:30 +01:00
2021-09-21 14:11:59 +02:00
def is_valid_alias_address_domain(email_address) -> bool:
2020-10-15 16:21:31 +02:00
"""Return whether an address domain might a domain handled by SimpleLogin"""
2021-09-21 14:11:59 +02:00
domain = get_email_domain_part(email_address)
2020-10-15 16:51:07 +02:00
if SLDomain.get_by(domain=domain):
2020-10-15 16:21:31 +02:00
return True
if CustomDomain.get_by(domain=domain, verified=True):
return True
return False
2021-09-21 14:11:59 +02:00
def email_can_be_used_as_mailbox(email_address: str) -> bool:
2020-10-15 16:21:31 +02:00
"""Return True if an email can be used as a personal email.
Use the email domain as criteria. A domain can be used if it is not:
2020-01-25 16:40:30 +01:00
- one of ALIAS_DOMAINS
2020-10-15 16:21:31 +02:00
- one of PREMIUM_ALIAS_DOMAINS
2020-01-25 16:40:30 +01:00
- one of custom domains
2020-10-15 16:21:31 +02:00
- a disposable domain
2020-01-25 16:40:30 +01:00
"""
try:
domain = validate_email(
email_address, check_deliverability=False, allow_smtputf8=False
).domain
except EmailNotValidError:
2022-01-10 15:58:22 +01:00
LOG.d("%s is invalid email address", email_address)
return False
2021-09-18 18:48:31 +02:00
2020-01-25 16:40:30 +01:00
if not domain:
2022-01-10 15:58:22 +01:00
LOG.d("no valid domain associated to %s", email_address)
2020-01-25 16:40:30 +01:00
return False
2020-10-15 16:51:07 +02:00
if SLDomain.get_by(domain=domain):
2022-01-10 15:58:22 +01:00
LOG.d("%s is a SL domain", email_address)
2020-10-15 16:21:31 +02:00
return False
2020-01-25 16:40:30 +01:00
from app.models import CustomDomain
if CustomDomain.get_by(domain=domain, verified=True):
LOG.d("domain %s is a SimpleLogin custom domain", domain)
2020-01-25 16:40:30 +01:00
return False
if is_invalid_mailbox_domain(domain):
LOG.d("Domain %s is invalid mailbox domain", domain)
return False
# check if email MX domain is disposable
mx_domains = get_mx_domain_list(domain)
2020-04-27 23:08:34 +02:00
# if no MX record, email is not valid
if not mx_domains:
LOG.d("No MX record for domain %s", domain)
2020-04-27 23:08:34 +02:00
return False
for mx_domain in mx_domains:
if is_invalid_mailbox_domain(mx_domain):
LOG.d("MX Domain %s %s is invalid mailbox domain", mx_domain, domain)
return False
return True
def is_invalid_mailbox_domain(domain):
"""
Whether a domain is invalid mailbox domain
Also return True if `domain` is a subdomain of an invalid mailbox domain
"""
parts = domain.split(".")
for i in range(0, len(parts) - 1):
parent_domain = ".".join(parts[i:])
if InvalidMailboxDomain.get_by(domain=parent_domain):
return True
return False
def get_mx_domain_list(domain) -> [str]:
"""return list of MX domains for a given email.
domain name ends *without* a dot (".") at the end.
"""
priority_domains = get_mx_domains(domain)
return [d[:-1] for _, d in priority_domains]
2021-09-21 14:11:59 +02:00
def personal_email_already_used(email_address: str) -> bool:
2020-08-27 10:20:48 +02:00
"""test if an email can be used as user email"""
2021-09-21 14:11:59 +02:00
if User.get_by(email=email_address):
return True
return False
def mailbox_already_used(email: str, user) -> bool:
if Mailbox.get_by(email=email, user_id=user.id):
return True
# support the case user wants to re-add their real email as mailbox
# can happen when user changes their root email and wants to add this new email as mailbox
if email == user.email:
return False
return False
2021-10-14 15:10:16 +02:00
def get_orig_message_from_bounce(bounce_report: Message) -> Optional[Message]:
"""parse the original email from Bounce"""
i = 0
2021-10-14 15:10:16 +02:00
for part in bounce_report.walk():
i += 1
2021-10-14 15:10:16 +02:00
# 1st part is the container (bounce report)
# 2nd part is the report from our own Postfix
# 3rd is report from other mailbox
# 4th is the container of the original message
# ...
# 7th is original message
if i == 7:
return part
2021-10-14 15:10:16 +02:00
def get_mailbox_bounce_info(bounce_report: Message) -> Optional[Message]:
"""
Return the bounce info from the bounce report
An example of bounce info:
Final-Recipient: rfc822; not-existing@gmail.com
Original-Recipient: rfc822;not-existing@gmail.com
Action: failed
Status: 5.1.1
Remote-MTA: dns; gmail-smtp-in.l.google.com
Diagnostic-Code: smtp;
550-5.1.1 The email account that you tried to reach does
not exist. Please try 550-5.1.1 double-checking the recipient's email
address for typos or 550-5.1.1 unnecessary spaces. Learn more at 550 5.1.1
https://support.google.com/mail/?p=NoSuchUser z127si6173191wmc.132 - gsmtp
"""
i = 0
for part in bounce_report.walk():
i += 1
# 1st part is the container (bounce report)
# 2nd part is the report from our own Postfix
# 3rd is report from other mailbox
# 4th is the container of the original message
# 5th is a child of 3rd that contains more info about the bounce
if i == 5:
if not part["content-transfer-encoding"]:
2021-10-18 09:55:10 +02:00
LOG.w("add missing content-transfer-encoding header")
part["content-transfer-encoding"] = "7bit"
try:
part.as_bytes().decode()
except UnicodeDecodeError:
LOG.w("cannot use this bounce report")
return
else:
return part
2021-10-14 15:10:16 +02:00
def get_orig_message_from_hotmail_complaint(msg: Message) -> Optional[Message]:
2021-09-06 19:44:18 +02:00
i = 0
for part in msg.walk():
i += 1
# 1st part is the container
# 2nd part is the empty body
# 3rd is original message
if i == 3:
return part
2021-10-14 15:10:16 +02:00
def get_orig_message_from_yahoo_complaint(msg: Message) -> Optional[Message]:
2021-09-13 19:49:40 +02:00
i = 0
for part in msg.walk():
i += 1
# 1st part is the container
# 2nd part is the empty body
# 6th is original message
if i == 6:
return part
2020-08-24 10:17:22 +02:00
def get_header_from_bounce(msg: Message, header: str) -> str:
"""using regex to get header value from bounce message
get_orig_message_from_bounce is better. This should be the last option
"""
msg_str = str(msg)
exp = re.compile(f"{header}.*\n")
r = re.search(exp, msg_str)
if r:
# substr should be something like 'HEADER: 1234'
substr = msg_str[r.start() : r.end()].strip()
parts = substr.split(":")
return parts[1].strip()
return None
def get_orig_message_from_spamassassin_report(msg: Message) -> Message:
"""parse the original email from Spamassassin report"""
i = 0
for part in msg.walk():
i += 1
# the original message is the 4th part
# 1st part is the root part, multipart/report
# 2nd is text/plain, SpamAssassin part
# 3rd is the original message in message/rfc822 content type
# 4th is original message
if i == 4:
return part
2020-07-23 11:11:43 +02:00
def get_spam_info(msg: Message, max_score=None) -> (bool, str):
"""parse SpamAssassin header to detect whether a message is classified as spam.
2020-08-27 10:20:48 +02:00
Return (is spam, spam status detail)
The header format is
```X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
DKIM_VALID_AU,RCVD_IN_DNSWL_BLOCKED,RCVD_IN_MSPIKE_H2,SPF_PASS,
URIBL_BLOCKED autolearn=unavailable autolearn_force=no version=3.4.2```
"""
2021-10-11 12:10:18 +02:00
spamassassin_status = msg[headers.X_SPAM_STATUS]
if not spamassassin_status:
return False, ""
2020-07-23 11:11:43 +02:00
return get_spam_from_header(spamassassin_status, max_score=max_score)
def get_spam_from_header(spam_status_header, max_score=None) -> (bool, str):
"""get spam info from X-Spam-Status header
2020-08-27 10:20:48 +02:00
Return (is spam, spam status detail).
The spam_status_header has the following format
```No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
DKIM_VALID_AU,RCVD_IN_DNSWL_BLOCKED,RCVD_IN_MSPIKE_H2,SPF_PASS,
URIBL_BLOCKED autolearn=unavailable autolearn_force=no version=3.4.2```
2020-07-23 11:11:43 +02:00
"""
# yes or no
2020-07-23 11:11:43 +02:00
spamassassin_answer = spam_status_header[: spam_status_header.find(",")]
if max_score:
# spam score
# get the score section "score=-0.1"
score_section = (
spam_status_header[spam_status_header.find(",") + 1 :].strip().split(" ")[0]
)
score = float(score_section[len("score=") :])
if score >= max_score:
2021-09-08 11:29:55 +02:00
LOG.w("Spam score %s exceeds %s", score, max_score)
2020-07-23 11:11:43 +02:00
return True, spam_status_header
2020-07-23 11:11:43 +02:00
return spamassassin_answer.lower() == "yes", spam_status_header
2020-04-05 12:07:40 +02:00
def get_header_unicode(header: Union[str, Header]) -> str:
"""
Convert a header to unicode
Should be used to handle headers like From:, To:, CC:, Subject:
"""
if header is None:
return ""
ret = ""
for to_decoded_str, charset in decode_header(header):
if charset is None:
if type(to_decoded_str) is bytes:
decoded_str = to_decoded_str.decode()
else:
decoded_str = to_decoded_str
else:
try:
decoded_str = to_decoded_str.decode(charset)
except (LookupError, UnicodeDecodeError): # charset is unknown
LOG.w("Cannot decode %s with %s, try utf-8", to_decoded_str, charset)
try:
decoded_str = to_decoded_str.decode("utf-8")
except UnicodeDecodeError:
LOG.w("Cannot UTF-8 decode %s", to_decoded_str)
decoded_str = to_decoded_str.decode("utf-8", errors="replace")
ret += decoded_str
return ret
2020-11-09 21:16:50 +01:00
2020-06-12 00:02:07 +02:00
def copy(msg: Message) -> Message:
"""return a copy of message"""
2020-11-09 20:58:39 +01:00
try:
return deepcopy(msg)
2021-08-20 16:09:22 +02:00
except Exception:
2021-09-08 11:29:55 +02:00
LOG.w("deepcopy fails, try string parsing")
try:
2021-09-21 14:11:59 +02:00
return message_from_string(msg.as_string())
2022-02-06 21:37:43 +01:00
except (UnicodeEncodeError, LookupError):
2021-09-08 11:29:55 +02:00
LOG.w("as_string() fails, try bytes parsing")
2021-09-21 14:11:59 +02:00
return message_from_bytes(to_bytes(msg))
def to_bytes(msg: Message):
"""replace Message.as_bytes() method by trying different policies"""
try:
return msg.as_bytes()
except UnicodeEncodeError:
2021-09-08 11:29:55 +02:00
LOG.w("as_bytes fails with default policy, try SMTP policy")
try:
2021-09-21 14:11:59 +02:00
return msg.as_bytes(policy=policy.SMTP)
except UnicodeEncodeError:
2021-09-08 11:29:55 +02:00
LOG.w("as_bytes fails with SMTP policy, try SMTPUTF8 policy")
try:
2021-09-21 14:11:59 +02:00
return msg.as_bytes(policy=policy.SMTPUTF8)
except UnicodeEncodeError:
2021-09-08 11:29:55 +02:00
LOG.w("as_bytes fails with SMTPUTF8 policy, try converting to string")
2021-03-24 10:08:11 +01:00
msg_string = msg.as_string()
try:
return msg_string.encode()
except UnicodeEncodeError as e:
LOG.w("can't encode msg, err:%s", e)
return msg_string.encode(errors="replace")
def should_add_dkim_signature(domain: str) -> bool:
2020-10-15 16:51:07 +02:00
if SLDomain.get_by(domain=domain):
return True
custom_domain: CustomDomain = CustomDomain.get_by(domain=domain)
if custom_domain.dkim_verified:
return True
return False
2020-11-03 11:09:37 +01:00
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
2020-11-07 13:00:12 +01:00
2020-11-30 10:48:16 +01:00
class EmailEncoding(enum.Enum):
BASE64 = "base64"
QUOTED = "quoted-printable"
NO = "no-encoding"
def get_encoding(msg: Message) -> EmailEncoding:
2020-11-26 17:01:05 +01:00
"""
Return the message encoding, possible values:
- quoted-printable
2020-11-26 17:22:17 +01:00
- base64
- 7bit: default if unknown or empty
2020-11-26 17:01:05 +01:00
"""
2021-10-24 10:40:05 +02:00
cte = str(msg.get(headers.CONTENT_TRANSFER_ENCODING, "")).lower().strip()
if cte in ("", "7bit", "8bit", "binary", "8bit;", "utf-8"):
2020-11-30 10:48:16 +01:00
return EmailEncoding.NO
if cte == "base64":
return EmailEncoding.BASE64
2020-11-26 17:01:05 +01:00
2020-11-30 10:48:16 +01:00
if cte == "quoted-printable":
return EmailEncoding.QUOTED
2020-11-26 17:01:05 +01:00
2021-01-31 11:52:55 +01:00
# some email services use unknown encoding
if cte in ("amazonses.com",):
return EmailEncoding.NO
2021-09-08 11:29:55 +02:00
LOG.e("Unknown encoding %s", cte)
2020-11-26 17:01:05 +01:00
2020-11-30 10:48:16 +01:00
return EmailEncoding.NO
2020-11-26 17:01:05 +01:00
2020-11-30 10:48:16 +01:00
def encode_text(text: str, encoding: EmailEncoding = EmailEncoding.NO) -> str:
if encoding == EmailEncoding.QUOTED:
2020-11-26 17:01:05 +01:00
encoded = quopri.encodestring(text.encode("utf-8"))
return str(encoded, "utf-8")
2020-11-30 10:48:16 +01:00
elif encoding == EmailEncoding.BASE64:
2020-11-26 17:01:05 +01:00
encoded = base64.b64encode(text.encode("utf-8"))
return str(encoded, "utf-8")
2020-11-26 17:22:17 +01:00
else: # 7bit - no encoding
return text
2020-11-26 17:01:05 +01:00
2020-12-18 10:43:06 +01:00
def decode_text(text: str, encoding: EmailEncoding = EmailEncoding.NO) -> str:
if encoding == EmailEncoding.QUOTED:
decoded = quopri.decodestring(text.encode("utf-8"))
return decoded.decode(errors="ignore")
2020-12-18 10:43:06 +01:00
elif encoding == EmailEncoding.BASE64:
decoded = base64.b64decode(text.encode("utf-8"))
return decoded.decode(errors="ignore")
2020-12-18 10:43:06 +01:00
else: # 7bit - no encoding
return text
2020-11-07 13:00:12 +01:00
def add_header(msg: Message, text_header, html_header) -> Message:
content_type = msg.get_content_type().lower()
if content_type == "text/plain":
encoding = get_encoding(msg)
2020-11-07 13:00:12 +01:00
payload = msg.get_payload()
if type(payload) is str:
clone_msg = copy(msg)
2020-12-18 10:44:42 +01:00
new_payload = f"""{text_header}
---
{decode_text(payload, encoding)}"""
clone_msg.set_payload(encode_text(new_payload, encoding))
2020-11-07 13:00:12 +01:00
return clone_msg
elif content_type == "text/html":
encoding = get_encoding(msg)
2020-11-07 13:00:12 +01:00
payload = msg.get_payload()
if type(payload) is str:
2020-12-18 10:44:42 +01:00
new_payload = f"""<table width="100%" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; margin: 0; padding: 0;">
2020-11-07 13:00:12 +01:00
<tr>
<td style="border-bottom:1px dashed #5675E2; padding: 10px 0px">{html_header}</td>
</tr>
<tr>
2020-12-18 10:44:42 +01:00
<td>
{decode_text(payload, encoding)}
</td>
2020-11-07 13:00:12 +01:00
</tr>
</table>
2020-12-18 10:44:42 +01:00
"""
2020-11-07 13:00:12 +01:00
clone_msg = copy(msg)
2020-12-18 10:44:42 +01:00
clone_msg.set_payload(encode_text(new_payload, encoding))
2020-11-07 13:00:12 +01:00
return clone_msg
elif content_type in ("multipart/alternative", "multipart/related"):
2020-11-07 13:00:12 +01:00
new_parts = []
for part in msg.get_payload():
new_parts.append(add_header(part, text_header, html_header))
clone_msg = copy(msg)
clone_msg.set_payload(new_parts)
return clone_msg
elif content_type in ("multipart/mixed", "multipart/signed"):
2020-12-31 15:11:46 +01:00
new_parts = []
parts = list(msg.get_payload())
LOG.d("only add header for the first part for %s", content_type)
2020-12-31 15:11:46 +01:00
for ix, part in enumerate(parts):
if ix == 0:
new_parts.append(add_header(part, text_header, html_header))
else:
new_parts.append(part)
clone_msg = copy(msg)
clone_msg.set_payload(new_parts)
return clone_msg
LOG.d("No header added for %s", content_type)
2020-11-07 13:00:12 +01:00
return msg
2020-11-30 15:15:13 +01:00
def replace(msg: Message, old, new) -> Message:
content_type = msg.get_content_type()
if (
content_type.startswith("image/")
or content_type.startswith("video/")
or content_type.startswith("audio/")
or content_type == "multipart/signed"
2020-12-04 11:33:49 +01:00
or content_type.startswith("application/")
2020-12-18 16:07:32 +01:00
or content_type == "text/calendar"
2020-12-30 09:48:58 +01:00
or content_type == "text/directory"
2020-12-31 18:03:42 +01:00
or content_type == "text/csv"
or content_type == "text/x-python-script"
):
2020-12-04 11:33:49 +01:00
LOG.d("not applicable for %s", content_type)
return msg
if content_type in ("text/plain", "text/html"):
2020-11-30 15:15:13 +01:00
encoding = get_encoding(msg)
payload = msg.get_payload()
if type(payload) is str:
if encoding == EmailEncoding.QUOTED:
LOG.d("handle quoted-printable replace %s -> %s", old, new)
# first decode the payload
2021-11-01 10:11:36 +01:00
try:
new_payload = quopri.decodestring(payload).decode("utf-8")
except UnicodeDecodeError:
LOG.w("cannot decode payload:%s", payload)
return msg
# then replace the old text
new_payload = new_payload.replace(old, new)
clone_msg = copy(msg)
clone_msg.set_payload(quopri.encodestring(new_payload.encode()))
return clone_msg
else:
clone_msg = copy(msg)
new_payload = payload.replace(
encode_text(old, encoding), encode_text(new, encoding)
)
clone_msg.set_payload(new_payload)
return clone_msg
2020-11-30 15:15:13 +01:00
elif content_type in (
"multipart/alternative",
"multipart/related",
"multipart/mixed",
2021-01-16 10:55:14 +01:00
"message/rfc822",
):
2020-11-30 15:15:13 +01:00
new_parts = []
for part in msg.get_payload():
new_parts.append(replace(part, old, new))
clone_msg = copy(msg)
clone_msg.set_payload(new_parts)
return clone_msg
LOG.w("Cannot replace text for %s", msg.get_content_type())
2020-11-30 15:15:13 +01:00
return msg
def generate_reply_email(contact_email: str, user: User) -> str:
"""
generate a reply_email (aka reverse-alias), make sure it isn't used by any contact
"""
# shorten email to avoid exceeding the 64 characters
# from https://tools.ietf.org/html/rfc5321#section-4.5.3
# "The maximum total length of a user name or other local-part is 64
# octets."
include_sender_in_reverse_alias = False
# 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
if include_sender_in_reverse_alias and contact_email:
# make sure contact_email can be ascii-encoded
contact_email = convert_to_id(contact_email)
2021-01-11 12:27:02 +01:00
contact_email = sanitize_email(contact_email)
contact_email = contact_email[:45]
contact_email = contact_email.replace("@", ".at.")
contact_email = convert_to_alphanumeric(contact_email)
# 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}"
)
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}"
if not Contact.get_by(reply_email=reply_email):
return reply_email
raise Exception("Cannot generate reply email")
2020-11-16 19:22:19 +01:00
def is_reverse_alias(address: str) -> bool:
# to take into account the new reverse-alias that doesn't start with "ra+"
if Contact.get_by(reply_email=address):
return True
2021-10-19 14:03:51 +02:00
return address.endswith(f"@{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)
2020-12-16 18:22:57 +01:00
def should_disable(alias: Alias) -> bool:
"""Disable an alias if it has too many bounces recently"""
# Bypass the bounce rule
if alias.cannot_be_disabled:
2021-09-08 11:29:55 +02:00
LOG.w("%s cannot be disabled", alias)
return False
if not ALIAS_AUTOMATIC_DISABLE:
return False
yesterday = arrow.now().shift(days=-1)
nb_bounced_last_24h = (
Session.query(EmailLog)
.filter(
EmailLog.bounced.is_(True),
EmailLog.is_reply.is_(False),
EmailLog.created_at > yesterday,
)
2021-07-14 12:25:44 +02:00
.filter(EmailLog.alias_id == alias.id)
.count()
)
# if more than 12 bounces in 24h -> disable alias
if nb_bounced_last_24h > 12:
LOG.d("more than 12 bounces in the last 24h, disable alias %s", alias)
return True
# if more than 5 bounces but has bounces last week -> disable alias
elif nb_bounced_last_24h > 5:
one_week_ago = arrow.now().shift(days=-8)
nb_bounced_7d_1d = (
Session.query(EmailLog)
.filter(
EmailLog.bounced.is_(True),
EmailLog.is_reply.is_(False),
EmailLog.created_at > one_week_ago,
EmailLog.created_at < yesterday,
)
2021-07-14 12:25:44 +02:00
.filter(EmailLog.alias_id == alias.id)
.count()
)
if nb_bounced_7d_1d > 1:
2021-09-08 11:29:55 +02:00
LOG.d(
"more than 5 bounces in the last 24h and more than 1 bounces in the last 7 days, "
"disable alias %s",
alias,
)
return True
else:
# alias level
# if bounces at least 9 days in the last 10 days -> disable alias
query = (
Session.query(
func.date(EmailLog.created_at).label("date"),
func.count(EmailLog.id).label("count"),
)
2021-07-14 12:25:44 +02:00
.filter(EmailLog.alias_id == alias.id)
.filter(
EmailLog.created_at > arrow.now().shift(days=-10),
EmailLog.bounced.is_(True),
EmailLog.is_reply.is_(False),
)
.group_by("date")
)
if query.count() >= 9:
LOG.d(
"Bounces every day for at least 9 days in the last 10 days, disable alias %s",
alias,
)
return True
# account level
query = (
Session.query(
func.date(EmailLog.created_at).label("date"),
func.count(EmailLog.id).label("count"),
)
.filter(EmailLog.user_id == alias.user_id)
.filter(
EmailLog.created_at > arrow.now().shift(days=-10),
EmailLog.bounced.is_(True),
EmailLog.is_reply.is_(False),
)
.group_by("date")
)
# if an account has more than 10 bounces every day for at least 4 days in the last 10 days, disable alias
date_bounces: List[Tuple[arrow.Arrow, int]] = list(query)
if len(date_bounces) > 4:
if all([v > 10 for _, v in date_bounces]):
LOG.d(
"+10 bounces for +4 days in the last 10 days on %s, disable alias %s",
alias.user,
alias,
)
return True
return False
2021-07-13 17:06:58 +02:00
def parse_id_from_bounce(email_address: str) -> int:
return int(email_address[email_address.find("+") : email_address.rfind("+")])
2021-03-17 10:16:09 +01:00
_IP_HEADER = "X-SimpleLogin-Client-IP"
def spf_pass(
envelope,
mailbox: Mailbox,
user: User,
alias: Alias,
contact_email: str,
msg: Message,
) -> bool:
2021-03-17 10:16:09 +01:00
ip = msg[_IP_HEADER]
if ip:
LOG.d("Enforce SPF on %s %s", ip, envelope.mail_from)
try:
r = spf.check2(i=ip, s=envelope.mail_from, h=None)
except Exception:
2021-09-08 11:29:55 +02:00
LOG.e("SPF error, mailbox %s, ip %s", mailbox.email, ip)
else:
# TODO: Handle temperr case (e.g. dns timeout)
# only an absolute pass, or no SPF policy at all is 'valid'
if r[0] not in ["pass", "none"]:
LOG.w(
"SPF fail for mailbox %s, reason %s, failed IP %s",
mailbox.email,
r[0],
ip,
)
2021-10-11 12:10:18 +02:00
subject = get_header_unicode(msg[headers.SUBJECT])
send_email_with_rate_control(
user,
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",
to_email=contact_email,
subject=subject,
time=arrow.now(),
),
render(
"transactional/spf-fail.html",
ip=ip,
mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf",
to_email=contact_email,
subject=subject,
time=arrow.now(),
),
)
return False
else:
LOG.w(
"Could not find %s header %s -> %s",
_IP_HEADER,
mailbox.email,
contact_email,
)
return True
# cache the smtp server for 20 seconds
@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)
smtp.starttls()
else:
smtp = SMTP(POSTFIX_SERVER, POSTFIX_PORT)
return smtp
def sl_sendmail(
from_addr,
to_addr,
msg: Message,
mail_options=(),
rcpt_options=(),
is_forward: bool = False,
retries=2,
ignore_smtp_error=False,
):
"""replace smtp.sendmail"""
if NOT_SEND_EMAIL:
LOG.d(
"send email with subject '%s', from '%s' to '%s'",
2021-10-11 12:10:18 +02:00
msg[headers.SUBJECT],
msg[headers.FROM],
msg[headers.TO],
)
return
try:
start = time.time()
2021-12-29 17:09:24 +01:00
if POSTFIX_SUBMISSION_TLS:
smtp_port = 587
else:
smtp_port = POSTFIX_PORT
2021-12-29 17:09:24 +01:00
with SMTP(POSTFIX_SERVER, smtp_port) as smtp:
if POSTFIX_SUBMISSION_TLS:
smtp.starttls()
elapsed = time.time() - start
LOG.d("getting a smtp connection takes seconds %s", elapsed)
newrelic.agent.record_custom_metric("Custom/smtp_connection_time", elapsed)
2021-12-29 17:09:24 +01:00
# smtp.send_message has UnicodeEncodeError
# encode message raw directly instead
LOG.d(
"Sendmail mail_from:%s, rcpt_to:%s, header_from:%s, header_to:%s, header_cc:%s",
from_addr,
to_addr,
msg[headers.FROM],
msg[headers.TO],
msg[headers.CC],
)
smtp.sendmail(
from_addr,
to_addr,
to_bytes(msg),
mail_options,
rcpt_options,
)
2021-12-16 15:06:26 +01:00
except (SMTPServerDisconnected, SMTPRecipientsRefused) as e:
if retries > 0:
2021-12-16 15:06:26 +01:00
LOG.w(
2021-12-16 15:09:15 +01:00
"SMTPServerDisconnected or SMTPRecipientsRefused error %s, retry",
e,
2021-12-16 15:06:26 +01:00
exc_info=True,
)
time.sleep(0.3 * retries)
sl_sendmail(
from_addr,
to_addr,
msg,
mail_options,
rcpt_options,
is_forward,
retries=retries - 1,
)
else:
if ignore_smtp_error:
LOG.w("Ignore smtp error %s", e)
else:
raise
2021-06-04 17:15:59 +02:00
def get_queue_id(msg: Message) -> Optional[str]:
"""Get the Postfix queue-id from a message"""
2021-10-11 12:10:18 +02:00
received_header = str(msg[headers.RECEIVED])
2021-06-04 17:15:59 +02:00
if not received_header:
return
# received_header looks like 'from mail-wr1-x434.google.com (mail-wr1-x434.google.com [IPv6:2a00:1450:4864:20::434])\r\n\t(using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits))\r\n\t(No client certificate requested)\r\n\tby mx1.simplelogin.co (Postfix) with ESMTPS id 4FxQmw1DXdz2vK2\r\n\tfor <jglfdjgld@alias.com>; Fri, 4 Jun 2021 14:55:43 +0000 (UTC)'
search_result = re.search("with ESMTPS id [0-9a-zA-Z]{1,}", received_header)
if not search_result:
return
# the "with ESMTPS id 4FxQmw1DXdz2vK2" part
with_esmtps = received_header[search_result.start() : search_result.end()]
return with_esmtps[len("with ESMTPS id ") :]
def should_ignore_bounce(mail_from: str) -> bool:
if IgnoreBounceSender.get_by(mail_from=mail_from):
LOG.w("do not send back bounce report to %s", mail_from)
return True
return False
def parse_full_address(full_address) -> (str, str):
"""
parse the email address full format and return the display name and address
For ex: ab <cd@xy.com> -> (ab, cd@xy.com)
'=?UTF-8?B?TmjGoW4gTmd1eeG7hW4=?= <abcd@gmail.com>' -> ('Nhơn Nguyễn', "abcd@gmail.com")
If the parsing fails, raise ValueError
"""
full_address: EmailAddress = address.parse(full_address)
if full_address is None:
raise ValueError
# address.parse can also parse a URL and return UrlAddress
if type(full_address) is not EmailAddress:
raise ValueError
return full_address.display_name, full_address.address
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:
file_name = str(uuid.uuid4()) + ".eml"
if file_name_prefix:
file_name = file_name_prefix + file_name
with open(os.path.join(TEMP_DIR, file_name), "wb") as f:
f.write(msg.as_bytes())
LOG.d("email saved to %s", file_name)
return file_name
return ""