def import_from_csv(batch_import: BatchImport, user: User, lines): reader = csv.DictReader(lines) for row in reader: try: full_alias = sanitize_email(row["alias"]) note = row["note"] except KeyError: LOG.warning("Cannot parse row %s", row) continue alias_domain = get_email_domain_part(full_alias) custom_domain = CustomDomain.get_by(domain=alias_domain) if (not custom_domain or not custom_domain.verified or custom_domain.user_id != user.id): LOG.debug("domain %s can't be used %s", alias_domain, user) continue if (Alias.get_by(email=full_alias) or DeletedAlias.get_by(email=full_alias) or DomainDeletedAlias.get_by(email=full_alias)): LOG.d("alias already used %s", full_alias) continue mailboxes = [] if "mailboxes" in row: for mailbox_email in row["mailboxes"].split(): mailbox_email = sanitize_email(mailbox_email) mailbox = Mailbox.get_by(email=mailbox_email) if not mailbox or not mailbox.verified or mailbox.user_id != user.id: LOG.d("mailbox %s can't be used %s", mailbox, user) continue mailboxes.append(mailbox.id) if len(mailboxes) == 0: mailboxes = [user.default_mailbox_id] alias = Alias.create( user_id=user.id, email=full_alias, note=note, mailbox_id=mailboxes[0], custom_domain_id=custom_domain.id, batch_import_id=batch_import.id, ) db.session.commit() db.session.flush() LOG.d("Create %s", alias) for i in range(1, len(mailboxes)): alias_mailbox = AliasMailbox.create( alias_id=alias.id, mailbox_id=mailboxes[i], ) db.session.commit() LOG.d("Create %s", alias_mailbox)
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, ) -> 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.query.filter_by( alert_type=alert_type, to_email=to_email).filter(SentAlert.created_at > min_dt).count()) if nb_alert >= max_nb_alert: LOG.warning( "%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) db.session.commit() send_email(to_email, subject, plaintext, html) return True
def resend_activation(): form = ResendActivationForm(request.form) if form.validate_on_submit(): user = User.filter_by(email=sanitize_email(form.email.data)).first() if not user: flash("There is no such email", "warning") return render_template("auth/resend_activation.html", form=form) if user.activated: flash("Your account was already activated, please login", "success") return redirect(url_for("auth.login")) # user is not activated LOG.d("user %s is not activated", user) flash( "An activation email has been sent to you. Please check your inbox/spam folder.", "warning", ) send_activation_email(user, request.args.get("next")) return render_template("auth/register_waiting_activation.html") return render_template("auth/resend_activation.html", form=form)
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.query.filter_by( alert_type=alert_type, to_email=to_email ).count() if nb_alert >= max_times: LOG.warning( "%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) db.session.commit() send_email(to_email, subject, plaintext, html) return True
def get_email_domain_part(address): """ Get the domain part from email [email protected] -> cd.com """ address = sanitize_email(address) return address[address.find("@") + 1 :]
def create_mailbox(): """ Create a new mailbox. User needs to verify the mailbox via an activation email. Input: email: in body Output: the new mailbox dict """ user = g.user mailbox_email = sanitize_email(request.get_json().get("email")) if not is_valid_email(mailbox_email): return jsonify(error=f"{mailbox_email} invalid"), 400 elif mailbox_already_used(mailbox_email, user): return jsonify(error=f"{mailbox_email} already used"), 400 elif not email_can_be_used_as_mailbox(mailbox_email): return ( jsonify( error= f"{mailbox_email} cannot be used. Please note a mailbox cannot " f"be a disposable email address"), 400, ) else: new_mailbox = Mailbox.create(email=mailbox_email, user_id=user.id) db.session.commit() send_verification_email(user, new_mailbox) return ( jsonify(mailbox_to_dict(new_mailbox)), 201, )
def create_contact_route(alias_id): """ Create contact for an alias Input: alias_id: in url contact: in body Output: 201 if success 409 if contact already added """ data = request.get_json() if not data: return jsonify(error="request body cannot be empty"), 400 user = g.user alias: Alias = Alias.get(alias_id) if alias.user_id != user.id: return jsonify(error="Forbidden"), 403 contact_addr = data.get("contact") if not contact_addr: return jsonify(error="Contact cannot be empty"), 400 full_address: EmailAddress = address.parse(contact_addr) if not full_address: return jsonify(error=f"invalid contact email {contact_addr}"), 400 contact_name, contact_email = full_address.display_name, full_address.address contact_email = sanitize_email(contact_email, not_lower=True) # already been added contact = Contact.get_by(alias_id=alias.id, website_email=contact_email) if contact: return jsonify(**serialize_contact(contact, existed=True)), 200 try: contact = Contact.create( user_id=alias.user_id, alias_id=alias.id, website_email=contact_email, name=contact_name, reply_email=generate_reply_email(contact_email, user), ) except CannotCreateContactForReverseAlias: return jsonify( error="You can't create contact for a reverse alias"), 400 LOG.d("create reverse-alias for %s %s", contact_addr, alias) Session.commit() return jsonify(**serialize_contact(contact)), 201
def auth_activate(): """ User enters the activation code to confirm their account. Input: email code Output: 200: user account is now activated, user can login now 400: wrong email, code 410: wrong code too many times """ data = request.get_json() if not data: return jsonify(error="request body cannot be empty"), 400 email = sanitize_email(data.get("email")) code = data.get("code") user = User.get_by(email=email) # do not use a different message to avoid exposing existing email if not user or user.activated: # Trigger rate limiter g.deduct_limit = True return jsonify(error="Wrong email or code"), 400 account_activation = AccountActivation.get_by(user_id=user.id) if not account_activation: # Trigger rate limiter g.deduct_limit = True return jsonify(error="Wrong email or code"), 400 if account_activation.code != code: # decrement nb tries account_activation.tries -= 1 db.session.commit() # Trigger rate limiter g.deduct_limit = True if account_activation.tries == 0: AccountActivation.delete(account_activation.id) db.session.commit() return jsonify(error="Too many wrong tries"), 410 return jsonify(error="Wrong email or code"), 400 LOG.debug("activate user %s", user) user.activated = True AccountActivation.delete(account_activation.id) db.session.commit() return jsonify(msg="Account is activated, user can login now"), 200
def create_contact_route(alias_id): """ Create contact for an alias Input: alias_id: in url contact: in body Output: 201 if success 409 if contact already added """ data = request.get_json() if not data: return jsonify(error="request body cannot be empty"), 400 user = g.user alias: Alias = Alias.get(alias_id) if alias.user_id != user.id: return jsonify(error="Forbidden"), 403 contact_addr = data.get("contact") if not contact_addr: return jsonify(error="Contact cannot be empty"), 400 contact_name, contact_email = parseaddr_unicode(contact_addr) if not is_valid_email(contact_email): return jsonify(error=f"invalid contact email {contact_email}"), 400 contact_email = sanitize_email(contact_email) # already been added contact = Contact.get_by(alias_id=alias.id, website_email=contact_email) if contact: return jsonify(**serialize_contact(contact, existed=True)), 200 contact = Contact.create( user_id=alias.user_id, alias_id=alias.id, website_email=contact_email, name=contact_name, reply_email=generate_reply_email(contact_email, user), ) LOG.d("create reverse-alias for %s %s", contact_addr, alias) db.session.commit() return jsonify(**serialize_contact(contact)), 201
def auth_google(): """ Authenticate user with Facebook Input: google_token: Google access token device: to create an ApiKey associated with this device Output: 200 and user info containing: { name: "John Wick", mfa_enabled: true, mfa_key: "a long string", api_key: "a long string" } """ data = request.get_json() if not data: return jsonify(error="request body cannot be empty"), 400 google_token = data.get("google_token") device = data.get("device") cred = google.oauth2.credentials.Credentials(token=google_token) build = googleapiclient.discovery.build("oauth2", "v2", credentials=cred) user_info = build.userinfo().get().execute() email = sanitize_email(user_info.get("email")) user = User.get_by(email=email) if not user: if DISABLE_REGISTRATION: return jsonify(error="registration is closed"), 400 if not email_can_be_used_as_mailbox(email) or personal_email_already_used( email ): return jsonify(error=f"cannot use {email} as personal inbox"), 400 LOG.d("create Google user with %s", user_info) user = User.create(email=email, name="", activated=True) db.session.commit() email_utils.send_welcome_email(user) if not SocialAuth.get_by(user_id=user.id, social="google"): SocialAuth.create(user_id=user.id, social="google") db.session.commit() return jsonify(**auth_payload(user, device)), 200
def sanitize_alias_address_name(): count = 0 # using Alias.all() will take all the memory for alias in Alias.yield_per_query(): if count % 1000 == 0: LOG.d("process %s", count) count += 1 if sanitize_email(alias.email) != alias.email: LOG.e("Alias %s email not sanitized", alias) if alias.name and "\n" in alias.name: alias.name = alias.name.replace("\n", "") Session.commit() LOG.e("Alias %s name contains linebreak %s", alias, alias.name)
def auth_facebook(): """ Authenticate user with Facebook Input: facebook_token: facebook access token device: to create an ApiKey associated with this device Output: 200 and user info containing: { name: "John Wick", mfa_enabled: true, mfa_key: "a long string", api_key: "a long string" } """ data = request.get_json() if not data: return jsonify(error="request body cannot be empty"), 400 facebook_token = data.get("facebook_token") device = data.get("device") graph = facebook.GraphAPI(access_token=facebook_token) user_info = graph.get_object("me", fields="email,name") email = sanitize_email(user_info.get("email")) user = User.get_by(email=email) if not user: if DISABLE_REGISTRATION: return jsonify(error="registration is closed"), 400 if not email_can_be_used_as_mailbox(email) or personal_email_already_used( email ): return jsonify(error=f"cannot use {email} as personal inbox"), 400 LOG.d("create facebook user with %s", user_info) user = User.create(email=email, name=user_info["name"], activated=True) db.session.commit() email_utils.send_welcome_email(user) if not SocialAuth.get_by(user_id=user.id, social="facebook"): SocialAuth.create(user_id=user.id, social="facebook") db.session.commit() return jsonify(**auth_payload(user, device)), 200
def handle_batch_import(batch_import: BatchImport): user = batch_import.user batch_import.processed = True db.session.commit() LOG.debug("Start batch import for %s %s", batch_import, user) file_url = s3.get_url(batch_import.file.path) LOG.d("Download file %s from %s", batch_import.file, file_url) r = requests.get(file_url) lines = [line.decode() for line in r.iter_lines()] reader = csv.DictReader(lines) for row in reader: try: full_alias = sanitize_email(row["alias"]) note = row["note"] except KeyError: LOG.warning("Cannot parse row %s", row) continue alias_domain = get_email_domain_part(full_alias) custom_domain = CustomDomain.get_by(domain=alias_domain) if (not custom_domain or not custom_domain.verified or custom_domain.user_id != user.id): LOG.debug("domain %s can't be used %s", alias_domain, user) continue if (Alias.get_by(email=full_alias) or DeletedAlias.get_by(email=full_alias) or DomainDeletedAlias.get_by(email=full_alias)): LOG.d("alias already used %s", full_alias) continue alias = Alias.create( user_id=user.id, email=full_alias, note=note, mailbox_id=user.default_mailbox_id, custom_domain_id=custom_domain.id, batch_import_id=batch_import.id, ) db.session.commit() LOG.d("Create %s", alias)
def auth_register(): """ User signs up - will need to activate their account with an activation code. Input: email password Output: 200: user needs to confirm their account """ data = request.get_json() if not data: return jsonify(error="request body cannot be empty"), 400 email = sanitize_email(data.get("email")) password = data.get("password") if DISABLE_REGISTRATION: return jsonify(error="registration is closed"), 400 if not email_can_be_used_as_mailbox(email) or personal_email_already_used( email): return jsonify(error=f"cannot use {email} as personal inbox"), 400 if not password or len(password) < 8: return jsonify(error="password too short"), 400 if len(password) > 100: return jsonify(error="password too long"), 400 LOG.d("create user %s", email) user = User.create(email=email, name="", password=password) Session.flush() # create activation code code = "".join([str(random.randint(0, 9)) for _ in range(6)]) AccountActivation.create(user_id=user.id, code=code) Session.commit() send_email( email, "Just one more step to join SimpleLogin", render("transactional/code-activation.txt.jinja2", code=code), render("transactional/code-activation.html", code=code), ) return jsonify(msg="User needs to confirm their account"), 200
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, 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: 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: send_email(to_email, subject, plaintext, html, retries=retries) except SMTPException: LOG.w("Cannot send email to %s, subject %s", to_email, subject) else: send_email(to_email, subject, plaintext, html, retries=retries) return True
def login(): next_url = request.args.get("next") if current_user.is_authenticated: if next_url: LOG.debug("user is already authenticated, redirect to %s", next_url) return redirect(next_url) else: LOG.d("user is already authenticated, redirect to dashboard") return redirect(url_for("dashboard.index")) form = LoginForm(request.form) show_resend_activation = False if form.validate_on_submit(): user = User.filter_by(email=sanitize_email(form.email.data)).first() if not user or not user.check_password(form.password.data): # Trigger rate limiter g.deduct_limit = True form.password.data = None flash("Email or password incorrect", "error") elif user.disabled: flash( "Your account is disabled. Please contact SimpleLogin team to re-enable your account.", "error", ) elif not user.activated: show_resend_activation = True flash( "Please check your inbox for the activation email. You can also have this email re-sent", "error", ) else: return after_login(user, next_url) return render_template( "auth/login.html", form=form, next_url=next_url, show_resend_activation=show_resend_activation, )
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 chosen an 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: # control char: 4 chars (ra+, +) # random suffix: max 10 chars # maximum: 64 # make sure contact_email can be ascii-encoded contact_email = convert_to_id(contact_email) 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 = ( f"ra+{contact_email}+{random_string(random_length)}@{EMAIL_DOMAIN}" ) else: random_length = random.randint(20, 50) reply_email = f"ra+{random_string(random_length)}@{EMAIL_DOMAIN}" if not Contact.get_by(reply_email=reply_email): return reply_email raise Exception("Cannot generate reply email")
def auth_login(): """ Authenticate user Input: email password device: to create an ApiKey associated with this device Output: 200 and user info containing: { name: "John Wick", mfa_enabled: true, mfa_key: "a long string", api_key: "a long string" } """ data = request.get_json() if not data: return jsonify(error="request body cannot be empty"), 400 email = sanitize_email(data.get("email")) password = data.get("password") device = data.get("device") user = User.filter_by(email=email).first() if not user or not user.check_password(password): # Trigger rate limiter g.deduct_limit = True return jsonify(error="Email or password incorrect"), 400 elif user.disabled: return jsonify(error="Account disabled"), 400 elif not user.activated: return jsonify(error="Account not activated"), 400 elif user.fido_enabled(): # allow user who has TOTP enabled to continue using the mobile app if not user.enable_otp: return jsonify( error="Currently we don't support FIDO on mobile yet"), 403 return jsonify(**auth_payload(user, device)), 200
def forgot_password(): form = ForgotPasswordForm(request.form) if form.validate_on_submit(): email = sanitize_email(form.email.data) flash( "If your email is correct, you are going to receive an email to reset your password", "success", ) user = User.get_by(email=email) if user: send_reset_password_email(user) return redirect(url_for("auth.forgot_password")) # Trigger rate limiter g.deduct_limit = True return render_template("auth/forgot_password.html", form=form)
def forgot_password(): """ User forgot password Input: email Output: 200 and a reset password email is sent to user 400 if email not exist """ data = request.get_json() if not data or not data.get("email"): return jsonify(error="request body must contain email"), 400 email = sanitize_email(data.get("email")) user = User.get_by(email=email) if user: send_reset_password_email(user) return jsonify(ok=True)
def auth_reactivate(): """ User asks for another activation code Input: email Output: 200: user is going to receive an email for activate their account """ data = request.get_json() if not data: return jsonify(error="request body cannot be empty"), 400 email = sanitize_email(data.get("email")) user = User.get_by(email=email) # do not use a different message to avoid exposing existing email if not user or user.activated: return jsonify(error="Something went wrong"), 400 account_activation = AccountActivation.get_by(user_id=user.id) if account_activation: AccountActivation.delete(account_activation.id) db.session.commit() # create activation code code = "".join([str(random.randint(0, 9)) for _ in range(6)]) AccountActivation.create(user_id=user.id, code=code) db.session.commit() send_email( email, "Just one more step to join SimpleLogin", render("transactional/code-activation.txt", code=code), render("transactional/code-activation.html", code=code), ) return jsonify(msg="User needs to confirm their account"), 200
def setting(): form = SettingForm() promo_form = PromoCodeForm() change_email_form = ChangeEmailForm() email_change = EmailChange.get_by(user_id=current_user.id) if email_change: pending_email = email_change.new_email else: pending_email = None if request.method == "POST": if request.form.get("form-name") == "update-email": if change_email_form.validate(): # whether user can proceed with the email update new_email_valid = True if (sanitize_email(change_email_form.email.data) != current_user.email and not pending_email): new_email = sanitize_email(change_email_form.email.data) # check if this email is not already used if personal_email_already_used(new_email) or Alias.get_by( email=new_email): flash(f"Email {new_email} already used", "error") new_email_valid = False elif not email_can_be_used_as_mailbox(new_email): flash( "You cannot use this email address as your personal inbox.", "error", ) new_email_valid = False # a pending email change with the same email exists from another user elif EmailChange.get_by(new_email=new_email): other_email_change: EmailChange = EmailChange.get_by( new_email=new_email) LOG.warning( "Another user has a pending %s with the same email address. Current user:%s", other_email_change, current_user, ) if other_email_change.is_expired(): LOG.d("delete the expired email change %s", other_email_change) EmailChange.delete(other_email_change.id) db.session.commit() else: flash( "You cannot use this email address as your personal inbox.", "error", ) new_email_valid = False if new_email_valid: email_change = EmailChange.create( user_id=current_user.id, code=random_string( 60), # todo: make sure the code is unique new_email=new_email, ) db.session.commit() send_change_email_confirmation(current_user, email_change) flash( "A confirmation email is on the way, please check your inbox", "success", ) return redirect(url_for("dashboard.setting")) if request.form.get("form-name") == "update-profile": if form.validate(): profile_updated = False # update user info if form.name.data != current_user.name: current_user.name = form.name.data db.session.commit() profile_updated = True if form.profile_picture.data: file_path = random_string(30) file = File.create(user_id=current_user.id, path=file_path) s3.upload_from_bytesio( file_path, BytesIO(form.profile_picture.data.read())) db.session.flush() LOG.d("upload file %s to s3", file) current_user.profile_picture_id = file.id db.session.commit() profile_updated = True if profile_updated: flash("Your profile has been updated", "success") return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "change-password": flash( "You are going to receive an email containing instructions to change your password", "success", ) send_reset_password_email(current_user) return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "notification-preference": choose = request.form.get("notification") if choose == "on": current_user.notification = True else: current_user.notification = False db.session.commit() flash("Your notification preference has been updated", "success") return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "delete-account": LOG.warning("Delete account %s", current_user) User.delete(current_user.id) db.session.commit() flash("Your account has been deleted", "success") logout_user() return redirect(url_for("auth.register")) elif request.form.get("form-name") == "change-alias-generator": scheme = int(request.form.get("alias-generator-scheme")) if AliasGeneratorEnum.has_value(scheme): current_user.alias_generator = scheme db.session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) elif request.form.get( "form-name") == "change-random-alias-default-domain": default_domain = request.form.get("random-alias-default-domain") if default_domain: sl_domain: SLDomain = SLDomain.get_by(domain=default_domain) if sl_domain: if sl_domain.premium_only and not current_user.is_premium( ): flash("You cannot use this domain", "error") return redirect(url_for("dashboard.setting")) current_user.default_alias_public_domain_id = sl_domain.id current_user.default_alias_custom_domain_id = None else: custom_domain = CustomDomain.get_by(domain=default_domain) if custom_domain: # sanity check if (custom_domain.user_id != current_user.id or not custom_domain.verified): LOG.exception("%s cannot use domain %s", current_user, default_domain) else: current_user.default_alias_custom_domain_id = ( custom_domain.id) current_user.default_alias_public_domain_id = None else: current_user.default_alias_custom_domain_id = None current_user.default_alias_public_domain_id = None db.session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "change-sender-format": sender_format = int(request.form.get("sender-format")) if SenderFormatEnum.has_value(sender_format): current_user.sender_format = sender_format current_user.sender_format_updated_at = arrow.now() db.session.commit() flash("Your sender format preference has been updated", "success") db.session.commit() return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "replace-ra": choose = request.form.get("replace-ra") if choose == "on": current_user.replace_reverse_alias = True else: current_user.replace_reverse_alias = False db.session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "sender-in-ra": choose = request.form.get("enable") if choose == "on": current_user.include_sender_in_reverse_alias = True else: current_user.include_sender_in_reverse_alias = False db.session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "export-data": data = { "email": current_user.email, "name": current_user.name, "aliases": [], "apps": [], "custom_domains": [], } for alias in Alias.filter_by( user_id=current_user.id).all(): # type: Alias data["aliases"].append( dict(email=alias.email, enabled=alias.enabled)) for custom_domain in CustomDomain.filter_by( user_id=current_user.id).all(): data["custom_domains"].append(custom_domain.domain) for app in Client.filter_by( user_id=current_user.id): # type: Client data["apps"].append( dict(name=app.name, home_url=app.home_url, published=app.published)) return Response( json.dumps(data), mimetype="text/json", headers={ "Content-Disposition": "attachment;filename=data.json" }, ) elif request.form.get("form-name") == "export-alias": data = [["alias", "note", "enabled"]] for alias in Alias.filter_by( user_id=current_user.id).all(): # type: Alias data.append([alias.email, alias.note, alias.enabled]) si = StringIO() cw = csv.writer(si) cw.writerows(data) output = make_response(si.getvalue()) output.headers[ "Content-Disposition"] = "attachment; filename=aliases.csv" output.headers["Content-type"] = "text/csv" return output manual_sub = ManualSubscription.get_by(user_id=current_user.id) coinbase_sub = CoinbaseSubscription.get_by(user_id=current_user.id) return render_template( "dashboard/setting.html", form=form, PlanEnum=PlanEnum, SenderFormatEnum=SenderFormatEnum, promo_form=promo_form, change_email_form=change_email_form, pending_email=pending_email, AliasGeneratorEnum=AliasGeneratorEnum, manual_sub=manual_sub, coinbase_sub=coinbase_sub, FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN, )
def update_mailbox(mailbox_id): """ Update mailbox Input: mailbox_id: in url (optional) default: in body. Set a mailbox as the default mailbox. (optional) email: in body. Change a mailbox email. (optional) cancel_email_change: in body. Cancel mailbox email change. Output: 200 if updated successfully """ user = g.user mailbox = Mailbox.get(mailbox_id) if not mailbox or mailbox.user_id != user.id: return jsonify(error="Forbidden"), 403 data = request.get_json() or {} changed = False if "default" in data: is_default = data.get("default") if is_default: if not mailbox.verified: return ( jsonify( error= "Unverified mailbox cannot be used as default mailbox" ), 400, ) user.default_mailbox_id = mailbox.id changed = True if "email" in data: new_email = sanitize_email(data.get("email")) if mailbox_already_used(new_email, user): return jsonify(error=f"{new_email} already used"), 400 elif not email_can_be_used_as_mailbox(new_email): return ( jsonify( error= f"{new_email} cannot be used. Please note a mailbox cannot " f"be a disposable email address"), 400, ) try: verify_mailbox_change(user, mailbox, new_email) except SMTPRecipientsRefused: return jsonify( error=f"Incorrect mailbox, please recheck {new_email}"), 400 else: mailbox.new_email = new_email changed = True if "cancel_email_change" in data: cancel_email_change = data.get("cancel_email_change") if cancel_email_change: mailbox.new_email = None changed = True if changed: Session.commit() return jsonify(updated=True), 200
def github_callback(): # user clicks on cancel if "error" in request.args: flash("Please use another sign in method then", "warning") return redirect("/") github = OAuth2Session( GITHUB_CLIENT_ID, state=session["oauth_state"], scope=["user:email"], redirect_uri=_redirect_uri, ) github.fetch_token( _token_url, client_secret=GITHUB_CLIENT_SECRET, authorization_response=request.url, ) # a dict with "name", "login" github_user_data = github.get("https://api.github.com/user").json() # return list of emails # { # 'email': '*****@*****.**', # 'primary': False, # 'verified': True, # 'visibility': None # } emails = github.get("https://api.github.com/user/emails").json() # only take the primary email email = None for e in emails: if e.get("verified") and e.get("primary"): email = e.get("email") break if not email: LOG.e(f"cannot get email for github user {github_user_data} {emails}") flash( "Cannot get a valid email from Github, please another way to login/sign up", "error", ) return redirect(url_for("auth.login")) email = sanitize_email(email) user = User.get_by(email=email) if not user: flash( "Sorry you cannot sign up via Github, please use email/password sign-up instead", "error", ) return redirect(url_for("auth.register")) if not SocialAuth.get_by(user_id=user.id, social="github"): SocialAuth.create(user_id=user.id, social="github") Session.commit() # The activation link contains the original page, for ex authorize page next_url = request.args.get("next") if request.args else None return after_login(user, next_url)
def google_callback(): # user clicks on cancel if "error" in request.args: flash("please use another sign in method then", "warning") return redirect("/") google = OAuth2Session( GOOGLE_CLIENT_ID, # some how Google Login fails with oauth_state KeyError # state=session["oauth_state"], scope=_scope, redirect_uri=_redirect_uri, ) google.fetch_token( _token_url, client_secret=GOOGLE_CLIENT_SECRET, authorization_response=request.url, ) # Fetch a protected resource, i.e. user profile # { # "email": "*****@*****.**", # "family_name": "First name", # "given_name": "Last name", # "id": "1234", # "locale": "en", # "name": "First Last", # "picture": "http://profile.jpg", # "verified_email": true # } google_user_data = google.get( "https://www.googleapis.com/oauth2/v1/userinfo").json() email = sanitize_email(google_user_data["email"]) user = User.get_by(email=email) picture_url = google_user_data.get("picture") if user: if picture_url and not user.profile_picture_id: LOG.d("set user profile picture to %s", picture_url) file = create_file_from_url(user, picture_url) user.profile_picture_id = file.id db.session.commit() else: flash( "Sorry you cannot sign up via Google, please use email/password sign-up instead", "error", ) return redirect(url_for("auth.register")) next_url = None # The activation link contains the original page, for ex authorize page if "google_next_url" in session: next_url = session["google_next_url"] LOG.debug("redirect user to %s", next_url) # reset the next_url to avoid user getting redirected at each login :) session.pop("google_next_url", None) if not SocialAuth.get_by(user_id=user.id, social="google"): SocialAuth.create(user_id=user.id, social="google") db.session.commit() return after_login(user, next_url)
def sanity_check(): """ #TODO: investigate why DNS sometimes not working Different sanity checks - detect if there's mailbox that's using a invalid domain """ mailbox_ids = ( db.session.query(Mailbox.id) .filter(Mailbox.verified.is_(True), Mailbox.disabled.is_(False)) .all() ) mailbox_ids = [e[0] for e in mailbox_ids] # iterate over id instead of mailbox directly # as a mailbox can be deleted during the sleep time for mailbox_id in mailbox_ids: mailbox = Mailbox.get(mailbox_id) # a mailbox has been deleted if not mailbox: continue # hack to not query DNS too often sleep(1) if not email_can_be_used_as_mailbox(mailbox.email): mailbox.nb_failed_checks += 1 nb_email_log = nb_email_log_for_mailbox(mailbox) # send a warning if mailbox.nb_failed_checks == 5: if mailbox.user.email != mailbox.email: send_email( mailbox.user.email, f"Mailbox {mailbox.email} is disabled", render( "transactional/disable-mailbox-warning.txt", mailbox=mailbox ), render( "transactional/disable-mailbox-warning.html", mailbox=mailbox, ), ) # alert if too much fail and nb_email_log > 100 if mailbox.nb_failed_checks > 10 and nb_email_log > 100: mailbox.disabled = True if mailbox.user.email != mailbox.email: send_email( mailbox.user.email, f"Mailbox {mailbox.email} is disabled", render("transactional/disable-mailbox.txt", mailbox=mailbox), render("transactional/disable-mailbox.html", mailbox=mailbox), ) LOG.warning( "issue with mailbox %s domain. #alias %s, nb email log %s", mailbox, mailbox.nb_alias(), nb_email_log, ) else: # reset nb check mailbox.nb_failed_checks = 0 db.session.commit() for user in User.filter_by(activated=True).all(): if sanitize_email(user.email) != user.email: LOG.exception("%s does not have sanitized email", user) for alias in Alias.query.all(): if sanitize_email(alias.email) != alias.email: LOG.exception("Alias %s email not sanitized", alias) if alias.name and "\n" in alias.name: alias.name = alias.name.replace("\n", "") db.session.commit() LOG.exception("Alias %s name contains linebreak %s", alias, alias.name) contact_email_sanity_date = arrow.get("2021-01-12") for contact in Contact.query.all(): if sanitize_email(contact.reply_email) != contact.reply_email: LOG.exception("Contact %s reply-email not sanitized", contact) if ( sanitize_email(contact.website_email) != contact.website_email and contact.created_at > contact_email_sanity_date ): LOG.exception("Contact %s website-email not sanitized", contact) if not contact.invalid_email and not is_valid_email(contact.website_email): LOG.exception("%s invalid email", contact) contact.invalid_email = True db.session.commit() for mailbox in Mailbox.query.all(): if sanitize_email(mailbox.email) != mailbox.email: LOG.exception("Mailbox %s address not sanitized", mailbox) for contact in Contact.query.all(): if normalize_reply_email(contact.reply_email) != contact.reply_email: LOG.exception( "Contact %s reply email is not normalized %s", contact, contact.reply_email, ) for domain in CustomDomain.query.all(): if domain.name and "\n" in domain.name: LOG.exception("Domain %s name contain linebreak %s", domain, domain.name) migrate_domain_trash() set_custom_domain_for_alias() LOG.d("Finish sanity check")
def setting(): form = SettingForm() promo_form = PromoCodeForm() change_email_form = ChangeEmailForm() email_change = EmailChange.get_by(user_id=current_user.id) if email_change: pending_email = email_change.new_email else: pending_email = None if request.method == "POST": if request.form.get("form-name") == "update-email": if change_email_form.validate(): # whether user can proceed with the email update new_email_valid = True if (sanitize_email(change_email_form.email.data) != current_user.email and not pending_email): new_email = sanitize_email(change_email_form.email.data) # check if this email is not already used if personal_email_already_used(new_email) or Alias.get_by( email=new_email): flash(f"Email {new_email} already used", "error") new_email_valid = False elif not email_can_be_used_as_mailbox(new_email): flash( "You cannot use this email address as your personal inbox.", "error", ) new_email_valid = False # a pending email change with the same email exists from another user elif EmailChange.get_by(new_email=new_email): other_email_change: EmailChange = EmailChange.get_by( new_email=new_email) LOG.warning( "Another user has a pending %s with the same email address. Current user:%s", other_email_change, current_user, ) if other_email_change.is_expired(): LOG.d("delete the expired email change %s", other_email_change) EmailChange.delete(other_email_change.id) db.session.commit() else: flash( "You cannot use this email address as your personal inbox.", "error", ) new_email_valid = False if new_email_valid: email_change = EmailChange.create( user_id=current_user.id, code=random_string( 60), # todo: make sure the code is unique new_email=new_email, ) db.session.commit() send_change_email_confirmation(current_user, email_change) flash( "A confirmation email is on the way, please check your inbox", "success", ) return redirect(url_for("dashboard.setting")) if request.form.get("form-name") == "update-profile": if form.validate(): profile_updated = False # update user info if form.name.data != current_user.name: current_user.name = form.name.data db.session.commit() profile_updated = True if form.profile_picture.data: file_path = random_string(30) file = File.create(user_id=current_user.id, path=file_path) s3.upload_from_bytesio( file_path, BytesIO(form.profile_picture.data.read())) db.session.flush() LOG.d("upload file %s to s3", file) current_user.profile_picture_id = file.id db.session.commit() profile_updated = True if profile_updated: flash("Your profile has been updated", "success") return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "change-password": flash( "You are going to receive an email containing instructions to change your password", "success", ) send_reset_password_email(current_user) return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "notification-preference": choose = request.form.get("notification") if choose == "on": current_user.notification = True else: current_user.notification = False db.session.commit() flash("Your notification preference has been updated", "success") return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "delete-account": # Schedule delete account job LOG.warning("schedule delete account job for %s", current_user) Job.create( name=JOB_DELETE_ACCOUNT, payload={"user_id": current_user.id}, run_at=arrow.now(), commit=True, ) flash( "Your account deletion has been scheduled. " "You'll receive an email when the deletion is finished", "success", ) return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "change-alias-generator": scheme = int(request.form.get("alias-generator-scheme")) if AliasGeneratorEnum.has_value(scheme): current_user.alias_generator = scheme db.session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) elif request.form.get( "form-name") == "change-random-alias-default-domain": default_domain = request.form.get("random-alias-default-domain") if default_domain: sl_domain: SLDomain = SLDomain.get_by(domain=default_domain) if sl_domain: if sl_domain.premium_only and not current_user.is_premium( ): flash("You cannot use this domain", "error") return redirect(url_for("dashboard.setting")) current_user.default_alias_public_domain_id = sl_domain.id current_user.default_alias_custom_domain_id = None else: custom_domain = CustomDomain.get_by(domain=default_domain) if custom_domain: # sanity check if (custom_domain.user_id != current_user.id or not custom_domain.verified): LOG.exception("%s cannot use domain %s", current_user, default_domain) else: current_user.default_alias_custom_domain_id = ( custom_domain.id) current_user.default_alias_public_domain_id = None else: current_user.default_alias_custom_domain_id = None current_user.default_alias_public_domain_id = None db.session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "change-sender-format": sender_format = int(request.form.get("sender-format")) if SenderFormatEnum.has_value(sender_format): current_user.sender_format = sender_format current_user.sender_format_updated_at = arrow.now() db.session.commit() flash("Your sender format preference has been updated", "success") db.session.commit() return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "replace-ra": choose = request.form.get("replace-ra") if choose == "on": current_user.replace_reverse_alias = True else: current_user.replace_reverse_alias = False db.session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "sender-in-ra": choose = request.form.get("enable") if choose == "on": current_user.include_sender_in_reverse_alias = True else: current_user.include_sender_in_reverse_alias = False db.session.commit() flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) elif request.form.get("form-name") == "export-data": return redirect(url_for("api.export_data")) elif request.form.get("form-name") == "export-alias": return redirect(url_for("api.export_aliases")) manual_sub = ManualSubscription.get_by(user_id=current_user.id) apple_sub = AppleSubscription.get_by(user_id=current_user.id) coinbase_sub = CoinbaseSubscription.get_by(user_id=current_user.id) return render_template( "dashboard/setting.html", form=form, PlanEnum=PlanEnum, SenderFormatEnum=SenderFormatEnum, promo_form=promo_form, change_email_form=change_email_form, pending_email=pending_email, AliasGeneratorEnum=AliasGeneratorEnum, manual_sub=manual_sub, apple_sub=apple_sub, coinbase_sub=coinbase_sub, FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN, )
def alias_contact_manager(alias_id): highlight_contact_id = None if request.args.get("highlight_contact_id"): highlight_contact_id = int(request.args.get("highlight_contact_id")) alias = Alias.get(alias_id) page = 0 if request.args.get("page"): page = int(request.args.get("page")) query = request.args.get("query") or "" # sanity check if not alias: flash("You do not have access to this page", "warning") return redirect(url_for("dashboard.index")) if alias.user_id != current_user.id: flash("You do not have access to this page", "warning") return redirect(url_for("dashboard.index")) new_contact_form = NewContactForm() if request.method == "POST": if request.form.get("form-name") == "create": if new_contact_form.validate(): contact_addr = new_contact_form.email.data.strip() try: contact_name, contact_email = parseaddr_unicode( contact_addr) contact_email = sanitize_email(contact_email) except Exception: flash(f"{contact_addr} is invalid", "error") return redirect( url_for( "dashboard.alias_contact_manager", alias_id=alias_id, )) if not is_valid_email(contact_email): flash(f"{contact_email} is invalid", "error") return redirect( url_for( "dashboard.alias_contact_manager", alias_id=alias_id, )) contact = Contact.get_by(alias_id=alias.id, website_email=contact_email) # already been added if contact: flash(f"{contact_email} is already added", "error") return redirect( url_for( "dashboard.alias_contact_manager", alias_id=alias_id, highlight_contact_id=contact.id, )) contact = Contact.create( user_id=alias.user_id, alias_id=alias.id, website_email=contact_email, name=contact_name, reply_email=generate_reply_email(contact_email, current_user), ) LOG.d("create reverse-alias for %s", contact_addr) db.session.commit() flash(f"Reverse alias for {contact_addr} is created", "success") return redirect( url_for( "dashboard.alias_contact_manager", alias_id=alias_id, highlight_contact_id=contact.id, )) elif request.form.get("form-name") == "delete": contact_id = request.form.get("contact-id") contact = Contact.get(contact_id) if not contact: flash("Unknown error. Refresh the page", "warning") return redirect( url_for("dashboard.alias_contact_manager", alias_id=alias_id)) elif contact.alias_id != alias.id: flash("You cannot delete reverse-alias", "warning") return redirect( url_for("dashboard.alias_contact_manager", alias_id=alias_id)) delete_contact_email = contact.website_email Contact.delete(contact_id) db.session.commit() flash(f"Reverse-alias for {delete_contact_email} has been deleted", "success") return redirect( url_for("dashboard.alias_contact_manager", alias_id=alias_id)) elif request.form.get("form-name") == "search": query = request.form.get("query") return redirect( url_for( "dashboard.alias_contact_manager", alias_id=alias_id, query=query, highlight_contact_id=highlight_contact_id, )) contact_infos = get_contact_infos(alias, page, query=query) last_page = len(contact_infos) < PAGE_LIMIT # if highlighted contact isn't included, fetch it # make sure highlighted contact is at array start contact_ids = [contact_info.contact.id for contact_info in contact_infos] if highlight_contact_id and highlight_contact_id not in contact_ids: contact_infos = (get_contact_infos( alias, contact_id=highlight_contact_id, query=query) + contact_infos) return render_template( "dashboard/alias_contact_manager.html", contact_infos=contact_infos, alias=alias, new_contact_form=new_contact_form, highlight_contact_id=highlight_contact_id, page=page, last_page=last_page, query=query, )
def register(): if current_user.is_authenticated: LOG.d("user is already authenticated, redirect to dashboard") flash("You are already logged in", "warning") return redirect(url_for("dashboard.index")) if config.DISABLE_REGISTRATION: flash("Registration is closed", "error") return redirect(url_for("auth.login")) form = RegisterForm(request.form) next_url = request.args.get("next") if form.validate_on_submit(): # only check if hcaptcha is enabled if HCAPTCHA_SECRET: # check with hCaptcha token = request.form.get("h-captcha-response") params = {"secret": HCAPTCHA_SECRET, "response": token} hcaptcha_res = requests.post("https://hcaptcha.com/siteverify", data=params).json() # return something like # {'success': True, # 'challenge_ts': '2020-07-23T10:03:25', # 'hostname': '127.0.0.1'} if not hcaptcha_res["success"]: LOG.warning( "User put wrong captcha %s %s", form.email.data, hcaptcha_res, ) flash("Wrong Captcha", "error") return render_template( "auth/register.html", form=form, next_url=next_url, HCAPTCHA_SITEKEY=HCAPTCHA_SITEKEY, ) email = sanitize_email(form.email.data) if not email_can_be_used_as_mailbox(email): flash("You cannot use this email address as your personal inbox.", "error") else: if personal_email_already_used(email): flash(f"Email {email} already used", "error") else: LOG.debug("create user %s", email) user = User.create( email=email, name="", password=form.password.data, referral=get_referral(), ) db.session.commit() try: send_activation_email(user, next_url) except Exception: flash("Invalid email, are you sure the email is correct?", "error") return redirect(url_for("auth.register")) return render_template("auth/register_waiting_activation.html") return render_template( "auth/register.html", form=form, next_url=next_url, HCAPTCHA_SITEKEY=HCAPTCHA_SITEKEY, )
def send_email( to_email, subject, plaintext, html=None, unsubscribe_link=None, unsubscribe_via_email=False, ): to_email = sanitize_email(to_email) if NOT_SEND_EMAIL: LOG.d( "send email with subject '%s' to '%s', plaintext: %s", subject, to_email, plaintext, ) return LOG.d("send email to %s, subject %s", to_email, subject) if POSTFIX_SUBMISSION_TLS: smtp = SMTP(POSTFIX_SERVER, 587) smtp.starttls() else: smtp = SMTP(POSTFIX_SERVER, POSTFIX_PORT or 25) msg = MIMEMultipart("alternative") msg.attach(MIMEText(plaintext)) if not html: LOG.d("Use plaintext as html") html = plaintext.replace("\n", "<br>") msg.attach(MIMEText(html, "html")) msg["Subject"] = subject msg["From"] = f"{SUPPORT_NAME} <{SUPPORT_EMAIL}>" msg["To"] = to_email msg_id_header = make_msgid() msg["Message-ID"] = msg_id_header date_header = formatdate() msg["Date"] = date_header if unsubscribe_link: add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>") if not unsubscribe_via_email: add_or_replace_header( msg, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click" ) # add DKIM email_domain = SUPPORT_EMAIL[SUPPORT_EMAIL.find("@") + 1 :] add_dkim_signature(msg, email_domain) msg_raw = to_bytes(msg) transaction = TransactionalEmail.get_by(email=to_email) if not transaction: transaction = TransactionalEmail.create(email=to_email, commit=True) # use a different envelope sender for each transactional email (aka VERP) smtp.sendmail(TRANSACTIONAL_BOUNCE_EMAIL.format(transaction.id), to_email, msg_raw)