def encrypt_file(data: BytesIO, fingerprint: str) -> str: LOG.d("encrypt for %s", fingerprint) mem_usage = memory_usage(-1, interval=1, timeout=1)[0] LOG.d("mem_usage %s", mem_usage) # todo if mem_usage > 300: LOG.error("Force exit") hard_exit() r = gpg.encrypt_file(data, fingerprint, always_trust=True) if not r.ok: # maybe the fingerprint is not loaded on this host, try to load it mailbox = Mailbox.get_by(pgp_finger_print=fingerprint) if mailbox: LOG.d("(re-)load public key for %s", mailbox) load_public_key(mailbox.pgp_public_key) LOG.d("retry to encrypt") data.seek(0) r = gpg.encrypt_file(data, fingerprint, always_trust=True) if not r.ok: raise PGPException(f"Cannot encrypt, status: {r.status}") return str(r)
def delete_alias(alias: Alias, user: User): Alias.delete(alias.id) db.session.commit() # save deleted alias to either global or domain trash if alias.custom_domain_id: try: DomainDeletedAlias.create(user_id=user.id, email=alias.email, domain_id=alias.custom_domain_id) db.session.commit() except IntegrityError: LOG.error( "alias %s domain %s has been added before to DeletedAlias", alias.email, alias.custom_domain_id, ) db.session.rollback() else: try: DeletedAlias.create(email=alias.email) db.session.commit() except IntegrityError: LOG.error("alias %s has been added before to DeletedAlias", alias.email) db.session.rollback()
def send_email_with_rate_control( user: User, alert_type: str, to_email: str, subject, plaintext, html=None, bounced_email: Optional[Message] = None, max_alert_24h=MAX_ALERT_24H, ) -> bool: """Same as send_email with rate control over alert_type. For now no more than _MAX_ALERT_24h alert can be sent in the last 24h Return true if the email is sent, otherwise False """ to_email = to_email.lower().strip() one_day_ago = arrow.now().shift(days=-1) nb_alert = (SentAlert.query.filter_by( alert_type=alert_type, to_email=to_email).filter(SentAlert.created_at > one_day_ago).count()) if nb_alert >= max_alert_24h: LOG.error( "%s emails were sent to %s in the last 24h, 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, bounced_email) return True
def load_pgp_public_keys(): """Load PGP public key to keyring""" for mailbox in Mailbox.query.filter(Mailbox.pgp_public_key != None).all(): LOG.d("Load PGP key for mailbox %s", mailbox) fingerprint = load_public_key(mailbox.pgp_public_key) # sanity check if fingerprint != mailbox.pgp_finger_print: LOG.error("fingerprint %s different for mailbox %s", fingerprint, mailbox) mailbox.pgp_finger_print = fingerprint db.session.commit() for contact in Contact.query.filter(Contact.pgp_public_key != None).all(): LOG.d("Load PGP key for %s", contact) fingerprint = load_public_key(contact.pgp_public_key) # sanity check if fingerprint != contact.pgp_finger_print: LOG.error("fingerprint %s different for contact %s", fingerprint, contact) contact.pgp_finger_print = fingerprint db.session.commit() LOG.d("Finish load_pgp_public_keys")
def next_bill_date(self) -> str: sub: Subscription = self.get_subscription() if sub: return sub.next_bill_date.strftime("%Y-%m-%d") LOG.error( f"next_bill_date() should be called only on user with active subscription. User {self}" ) return ""
def check_zendesk_response_status(response_code: int) -> bool: if response_code != 201: if response_code in (401, 422): LOG.error("Could not authenticate to Zendesk") else: LOG.error("Problem with the Zendesk request. Status {}".format( response_code)) return False return True
def get_or_create_contact(contact_from_header: str, mail_from: str, alias: Alias) -> Contact: """ contact_from_header is the RFC 2047 format FROM header """ # contact_from_header can be None, use mail_from in this case instead contact_from_header = contact_from_header or mail_from # force convert header to string, sometimes contact_from_header is Header object contact_from_header = str(contact_from_header) contact_name, contact_email = parseaddr_unicode(contact_from_header) if not contact_email: # From header is wrongly formatted, try with mail_from LOG.warning("From header is empty, parse mail_from %s %s", mail_from, alias) contact_name, contact_email = parseaddr_unicode(mail_from) if not contact_email: LOG.error( "Cannot parse contact from from_header:%s, mail_from:%s", contact_from_header, mail_from, ) contact = Contact.get_by(alias_id=alias.id, website_email=contact_email) if contact: if contact.name != contact_name: LOG.d( "Update contact %s name %s to %s", contact, contact.name, contact_name, ) contact.name = contact_name db.session.commit() else: LOG.debug( "create contact for alias %s and contact %s", alias, contact_from_header, ) reply_email = generate_reply_email() contact = Contact.create( user_id=alias.user_id, alias_id=alias.id, website_email=contact_email, name=contact_name, reply_email=reply_email, ) db.session.commit() return contact
def delete(cls, obj_id): # Put all aliases belonging to this domain to global trash try: for alias in Alias.query.filter_by(custom_domain_id=obj_id): DeletedAlias.create(email=alias.email) db.session.commit() except IntegrityError: LOG.error("Some aliases have been added before to DeletedAlias") db.session.rollback() cls.query.filter(cls.id == obj_id).delete() db.session.commit()
def custom_alias(): # check if user has not exceeded the alias quota if not current_user.can_create_new_alias(): # notify admin LOG.error("user %s tries to create custom alias", current_user) flash("ony premium user can choose custom alias", "warning") return redirect(url_for("dashboard.index")) user_custom_domains = [cd.domain for cd in current_user.verified_custom_domains()] # List of (is_custom_domain, alias-suffix) suffixes = [] # put custom domain first for alias_domain in user_custom_domains: suffixes.append((True, "@" + alias_domain)) # then default domain for domain in ALIAS_DOMAINS: suffixes.append( ( False, ("" if DISABLE_ALIAS_SUFFIX else "." + random_word()) + "@" + domain, ) ) if request.method == "POST": alias_prefix = request.form.get("prefix") alias_suffix = request.form.get("suffix") if verify_prefix_suffix( current_user, alias_prefix, alias_suffix, user_custom_domains ): full_alias = alias_prefix + alias_suffix if GenEmail.get_by(email=full_alias): LOG.d("full alias already used %s", full_alias) flash( f"Alias {full_alias} already exists, please choose another one", "warning", ) else: gen_email = GenEmail.create(user_id=current_user.id, email=full_alias) db.session.commit() flash(f"Alias {full_alias} has been created", "success") session[HIGHLIGHT_GEN_EMAIL_ID] = gen_email.id return redirect(url_for("dashboard.index")) # only happen if the request has been "hacked" else: flash("something went wrong", "warning") return render_template("dashboard/custom_alias.html", **locals())
def delete(cls, obj_id): # Put all aliases belonging to this directory to global trash try: for alias in Alias.query.filter_by(directory_id=obj_id): DeletedAlias.create(email=alias.email) db.session.commit() # this can happen when a previously deleted alias is re-created via catch-all or directory feature except IntegrityError: LOG.error("Some aliases have been added before to DeletedAlias") db.session.rollback() cls.query.filter(cls.id == obj_id).delete() db.session.commit()
def delete_alias(alias: Alias, user: User): email = alias.email Alias.delete(alias.id) db.session.commit() # try to save deleted alias try: DeletedAlias.create(email=email) db.session.commit() # this can happen when a previously deleted alias is re-created via catch-all or directory feature except IntegrityError: LOG.error("alias %s has been added before to DeletedAlias", email) db.session.rollback()
def verify_prefix_suffix(user, alias_prefix, alias_suffix) -> bool: """verify if user could create an alias with the given prefix and suffix""" if not alias_prefix or not alias_suffix: # should be caught on frontend return False user_custom_domains = [cd.domain for cd in user.verified_custom_domains()] alias_prefix = alias_prefix.strip() alias_prefix = convert_to_id(alias_prefix) # make sure alias_suffix is either [email protected] or @my-domain.com alias_suffix = alias_suffix.strip() if alias_suffix.startswith("@"): alias_domain = alias_suffix[1:] # alias_domain can be either custom_domain or if DISABLE_ALIAS_SUFFIX, one of the default ALIAS_DOMAINS if DISABLE_ALIAS_SUFFIX: if ( alias_domain not in user_custom_domains and alias_domain not in ALIAS_DOMAINS ): LOG.error("wrong alias suffix %s, user %s", alias_suffix, user) return False else: if alias_domain not in user_custom_domains: LOG.error("wrong alias suffix %s, user %s", alias_suffix, user) return False else: if not alias_suffix.startswith("."): LOG.error("User %s submits a wrong alias suffix %s", user, alias_suffix) return False full_alias = alias_prefix + alias_suffix if not email_belongs_to_alias_domains(full_alias): LOG.error( "Alias suffix should end with one of the alias domains %s", user, alias_suffix, ) return False random_word_part = alias_suffix[1 : alias_suffix.find("@")] if not word_exist(random_word_part): LOG.error( "alias suffix %s needs to start with a random word, user %s", alias_suffix, user, ) return False return True
def load_pgp_public_keys(app): """Load PGP public key to keyring""" with app.app_context(): for mailbox in Mailbox.query.filter( Mailbox.pgp_public_key != None).all(): LOG.d("Load PGP key for mailbox %s", mailbox) fingerprint = load_public_key(mailbox.pgp_public_key) # sanity check if fingerprint != mailbox.pgp_finger_print: LOG.error("fingerprint %s different for mailbox %s", fingerprint, mailbox) mailbox.pgp_finger_print = fingerprint db.session.commit()
def cancel_subscription(subscription_id: int) -> bool: r = requests.post( "https://vendors.paddle.com/api/2.0/subscription/users_cancel", data={ "vendor_id": PADDLE_VENDOR_ID, "vendor_auth_code": PADDLE_AUTH_CODE, "subscription_id": subscription_id, }, ) res = r.json() if not res["success"]: LOG.error( f"cannot cancel subscription {subscription_id}, paddle response: {res}" ) return res["success"]
def verify_prefix_suffix(user, alias_prefix, alias_suffix, user_custom_domains) -> bool: """verify if user could create an alias with the given prefix and suffix""" alias_prefix = alias_prefix.strip() alias_prefix = convert_to_id(alias_prefix) if not alias_prefix: # should be caught on frontend return False # make sure alias_suffix is either [email protected] or @my-domain.com alias_suffix = alias_suffix.strip() if alias_suffix.startswith("@"): alias_domain = alias_suffix[1:] # alias_domain can be either custom_domain or if DISABLE_ALIAS_SUFFIX, EMAIL_DOMAIN if DISABLE_ALIAS_SUFFIX: if alias_domain not in user_custom_domains and alias_domain != EMAIL_DOMAIN: LOG.error("wrong alias suffix %s, user %s", alias_suffix, user) return False else: if alias_domain not in user_custom_domains: LOG.error("wrong alias suffix %s, user %s", alias_suffix, user) return False else: if not alias_suffix.startswith("."): LOG.error("User %s submits a wrong alias suffix %s", user, alias_suffix) return False if not alias_suffix.endswith(EMAIL_DOMAIN): LOG.error( "Alias suffix should end with default alias domain %s", user, alias_suffix, ) return False random_word_part = alias_suffix[1 : alias_suffix.find("@")] if not word_exist(random_word_part): LOG.error( "alias suffix %s needs to start with a random word, user %s", alias_suffix, user, ) return False return True
def delete(cls, obj_id): # Put all aliases belonging to this mailbox to global trash try: for alias in Alias.query.filter_by(mailbox_id=obj_id): # special handling for alias that has several mailboxes and has mailbox_id=obj_id if len(alias.mailboxes) > 1: # use the first mailbox found in alias._mailboxes first_mb = alias._mailboxes[0] alias.mailbox_id = first_mb.id alias._mailboxes.remove(first_mb) else: # only put aliases that have mailbox as a single mailbox into trash DeletedAlias.create(email=alias.email) db.session.commit() # this can happen when a previously deleted alias is re-created via catch-all or directory feature except IntegrityError: LOG.error("Some aliases have been added before to DeletedAlias") db.session.rollback() cls.query.filter(cls.id == obj_id).delete() db.session.commit()
def sanity_check(): """ #TODO: investigate why DNS sometimes not working Different sanity checks - detect if there's mailbox that's using a invalid domain """ for mailbox in Mailbox.filter_by(verified=True).all(): # hack to not query DNS too often sleep(1) if not email_domain_can_be_used_as_mailbox(mailbox.email): mailbox.nb_failed_checks += 1 # alert if too much fail if mailbox.nb_failed_checks > 10: log_func = LOG.error else: log_func = LOG.warning log_func( "issue with mailbox %s domain. #alias %s, nb email log %s", mailbox, mailbox.nb_alias(), mailbox.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 user.email.lower() != user.email: LOG.error("%s does not have lowercase email", user) for mailbox in Mailbox.filter_by(verified=True).all(): if mailbox.email.lower() != mailbox.email: LOG.error("%s does not have lowercase email", mailbox) LOG.d("Finish sanity check")
def handle_reply(self, envelope, smtp: SMTP, msg: Message) -> str: reply_email = envelope.rcpt_tos[0].lower() # reply_email must end with EMAIL_DOMAIN if not reply_email.endswith(EMAIL_DOMAIN): LOG.error(f"Reply email {reply_email} has wrong domain") return "550 wrong reply email" forward_email = ForwardEmail.get_by(reply_email=reply_email) alias: str = forward_email.gen_email.email alias_domain = alias[alias.find("@") + 1:] # alias must end with one of the ALIAS_DOMAINS or custom-domain if not email_belongs_to_alias_domains(alias): if not CustomDomain.get_by(domain=alias_domain): return "550 alias unknown by SimpleLogin" user_email = forward_email.gen_email.user.email if envelope.mail_from.lower() != user_email.lower(): LOG.error( f"Reply email can only be used by user email. Actual mail_from: %s. msg from header: %s, User email %s. reply_email %s", envelope.mail_from, msg["From"], user_email, reply_email, ) send_reply_alias_must_use_personal_email( forward_email.gen_email.user, forward_email.gen_email.email, envelope.mail_from, ) send_email( envelope.mail_from, f"Your email ({envelope.mail_from}) is not allowed to send email to {reply_email}", "", "", ) return "550 ignored" delete_header(msg, "DKIM-Signature") # the email comes from alias msg.replace_header("From", alias) # some email providers like ProtonMail adds automatically the Reply-To field # make sure to delete it delete_header(msg, "Reply-To") msg.replace_header("To", forward_email.website_email) # add List-Unsubscribe header unsubscribe_link = f"{URL}/dashboard/unsubscribe/{forward_email.gen_email_id}" add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>") add_or_replace_header(msg, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click") # Received-SPF is injected by postfix-policyd-spf-python can reveal user original email delete_header(msg, "Received-SPF") LOG.d( "send email from %s to %s, mail_options:%s,rcpt_options:%s", alias, forward_email.website_email, envelope.mail_options, envelope.rcpt_options, ) if alias_domain in ALIAS_DOMAINS: add_dkim_signature(msg, alias_domain) # add DKIM-Signature for custom-domain alias else: custom_domain: CustomDomain = CustomDomain.get_by( domain=alias_domain) if custom_domain.dkim_verified: add_dkim_signature(msg, alias_domain) msg_raw = msg.as_string().encode() smtp.sendmail( alias, forward_email.website_email, msg_raw, envelope.mail_options, envelope.rcpt_options, ) ForwardEmailLog.create(forward_id=forward_email.id, is_reply=True) db.session.commit() return "250 Message accepted for delivery"
def handle_forward(self, envelope, smtp: SMTP, msg: Message) -> str: """return *status_code message*""" alias = envelope.rcpt_tos[0].lower() # alias@SL gen_email = GenEmail.get_by(email=alias) if not gen_email: LOG.d( "alias %s not exist. Try to see if it can be created on the fly", alias) # try to see if alias could be created on-the-fly on_the_fly = False # check if alias belongs to a directory, ie having directory/anything@EMAIL_DOMAIN format if email_belongs_to_alias_domains(alias): if "/" in alias or "+" in alias or "#" in alias: if "/" in alias: sep = "/" elif "+" in alias: sep = "+" else: sep = "#" directory_name = alias[:alias.find(sep)] LOG.d("directory_name %s", directory_name) directory = Directory.get_by(name=directory_name) # Only premium user can use the directory feature if directory: dir_user = directory.user if dir_user.is_premium(): LOG.d("create alias %s for directory %s", alias, directory) on_the_fly = True gen_email = GenEmail.create( email=alias, user_id=directory.user_id, directory_id=directory.id, ) db.session.commit() else: LOG.error( "User %s is not premium anymore and cannot create alias with directory", dir_user, ) send_cannot_create_directory_alias( dir_user, alias, directory_name) # try to create alias on-the-fly with custom-domain catch-all feature # check if alias is custom-domain alias and if the custom-domain has catch-all enabled if not on_the_fly: alias_domain = get_email_domain_part(alias) custom_domain = CustomDomain.get_by(domain=alias_domain) # Only premium user can continue using the catch-all feature if custom_domain and custom_domain.catch_all: domain_user = custom_domain.user if domain_user.is_premium(): LOG.d("create alias %s for domain %s", alias, custom_domain) on_the_fly = True gen_email = GenEmail.create( email=alias, user_id=custom_domain.user_id, custom_domain_id=custom_domain.id, automatic_creation=True, ) db.session.commit() else: LOG.error( "User %s is not premium anymore and cannot create alias with domain %s", domain_user, alias_domain, ) send_cannot_create_domain_alias( domain_user, alias, alias_domain) if not on_the_fly: LOG.d("alias %s cannot be created on-the-fly, return 510", alias) return "510 Email not exist" user_email = gen_email.user.email website_email = get_email_part(msg["From"]) forward_email = ForwardEmail.get_by(gen_email_id=gen_email.id, website_email=website_email) if not forward_email: LOG.debug( "create forward email for alias %s and website email %s", alias, website_email, ) # generate a reply_email, make sure it is unique # not use while to avoid infinite loop for _ in range(1000): reply_email = f"reply+{random_string(30)}@{EMAIL_DOMAIN}" if not ForwardEmail.get_by(reply_email=reply_email): break forward_email = ForwardEmail.create( gen_email_id=gen_email.id, website_email=website_email, website_from=msg["From"], reply_email=reply_email, ) db.session.commit() forward_log = ForwardEmailLog.create(forward_id=forward_email.id) if gen_email.enabled: # add custom header add_or_replace_header(msg, "X-SimpleLogin-Type", "Forward") # remove reply-to header if present delete_header(msg, "Reply-To") # change the from header so the sender comes from @SL # so it can pass DMARC check # replace the email part in from: header from_header = (get_email_name(msg["From"]) + " - " + website_email.replace("@", " at ") + f" <{forward_email.reply_email}>") msg.replace_header("From", from_header) LOG.d("new from header:%s", from_header) # add List-Unsubscribe header unsubscribe_link = f"{URL}/dashboard/unsubscribe/{gen_email.id}" add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>") add_or_replace_header(msg, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click") add_dkim_signature(msg, EMAIL_DOMAIN) LOG.d( "Forward mail from %s to %s, mail_options %s, rcpt_options %s ", website_email, user_email, envelope.mail_options, envelope.rcpt_options, ) # smtp.send_message has UnicodeEncodeErroremail issue # encode message raw directly instead msg_raw = msg.as_string().encode() smtp.sendmail( forward_email.reply_email, user_email, msg_raw, envelope.mail_options, envelope.rcpt_options, ) else: LOG.d("%s is disabled, do not forward", gen_email) forward_log.blocked = True db.session.commit() return "250 Message accepted for delivery"
def custom_alias(): # check if user has not exceeded the alias quota if not current_user.can_create_new_alias(): # notify admin LOG.error("user %s tries to create custom alias", current_user) flash( "You have reached free plan limit, please upgrade to create new aliases", "warning", ) return redirect(url_for("dashboard.index")) user_custom_domains = [cd.domain for cd in current_user.verified_custom_domains()] # List of (is_custom_domain, alias-suffix) suffixes = [] # put custom domain first for alias_domain in user_custom_domains: suffixes.append((True, "@" + alias_domain)) # then default domain for domain in ALIAS_DOMAINS: suffixes.append( ( False, ("" if DISABLE_ALIAS_SUFFIX else "." + random_word()) + "@" + domain, ) ) if request.method == "POST": alias_prefix = request.form.get("prefix") alias_suffix = request.form.get("suffix") alias_note = request.form.get("note") if verify_prefix_suffix( current_user, alias_prefix, alias_suffix, user_custom_domains ): full_alias = alias_prefix + alias_suffix if GenEmail.get_by(email=full_alias) or DeletedAlias.get_by( email=full_alias ): LOG.d("full alias already used %s", full_alias) flash( f"Alias {full_alias} already exists, please choose another one", "warning", ) else: gen_email = GenEmail.create( user_id=current_user.id, email=full_alias, note=alias_note ) # get the custom_domain_id if alias is created with a custom domain alias_domain = get_email_domain_part(full_alias) custom_domain = CustomDomain.get_by(domain=alias_domain) if custom_domain: gen_email.custom_domain_id = custom_domain.id db.session.commit() flash(f"Alias {full_alias} has been created", "success") return redirect( url_for("dashboard.index", highlight_gen_email_id=gen_email.id) ) # only happen if the request has been "hacked" else: flash("something went wrong", "warning") return render_template("dashboard/custom_alias.html", **locals())
def paddle(): LOG.debug( "paddle callback %s %s %s %s %s", request.form.get("alert_name"), request.form.get("email"), request.form.get("customer_name"), request.form.get("subscription_id"), request.form.get("subscription_plan_id"), ) # make sure the request comes from Paddle if not paddle_utils.verify_incoming_request(dict(request.form)): LOG.error("request not coming from paddle. Request data:%s", dict(request.form)) return "KO", 400 if (request.form.get("alert_name") == "subscription_created" ): # new user subscribes user_email = request.form.get("email") user = User.get_by(email=user_email) if (int(request.form.get("subscription_plan_id")) == PADDLE_MONTHLY_PRODUCT_ID): plan = PlanEnum.monthly else: plan = PlanEnum.yearly sub = Subscription.get_by(user_id=user.id) if not sub: LOG.d("create a new sub") Subscription.create( user_id=user.id, cancel_url=request.form.get("cancel_url"), update_url=request.form.get("update_url"), subscription_id=request.form.get("subscription_id"), event_time=arrow.now(), next_bill_date=arrow.get( request.form.get("next_bill_date"), "YYYY-MM-DD").date(), plan=plan, ) else: LOG.d("update existing sub %s", sub) sub.cancel_url = request.form.get("cancel_url") sub.update_url = request.form.get("update_url") sub.subscription_id = request.form.get("subscription_id") sub.event_time = arrow.now() sub.next_bill_date = arrow.get( request.form.get("next_bill_date"), "YYYY-MM-DD").date() sub.plan = plan LOG.debug("User %s upgrades!", user) db.session.commit() elif request.form.get("alert_name") == "subscription_updated": subscription_id = request.form.get("subscription_id") LOG.debug("Update subscription %s", subscription_id) sub: Subscription = Subscription.get_by( subscription_id=subscription_id) sub.event_time = arrow.now() sub.next_bill_date = arrow.get(request.form.get("next_bill_date"), "YYYY-MM-DD").date() db.session.commit() elif request.form.get("alert_name") == "subscription_cancelled": subscription_id = request.form.get("subscription_id") LOG.error("Cancel subscription %s", subscription_id) sub: Subscription = Subscription.get_by( subscription_id=subscription_id) if sub: sub.cancelled = True db.session.commit() return "OK"
if __name__ == "__main__": while True: # run a job 1h earlier or later is not a big deal ... min_dt = arrow.now().shift(hours=-1) max_dt = arrow.now().shift(hours=1) app = new_app() with app.app_context(): for job in Job.query.filter(Job.taken == False, Job.run_at > min_dt, Job.run_at <= max_dt).all(): LOG.d("Take job %s", job) # mark the job as taken, whether it will be executed successfully or not job.taken = True db.session.commit() if job.name == JOB_ONBOARDING_1: user_id = job.payload.get("user_id") user = User.get(user_id) LOG.d("run onboarding_1 for user %s", user) onboarding_1(user) else: LOG.error("Unknown job name %s", job.name) time.sleep(10)
def spf_pass( ip: str, envelope, mailbox: Mailbox, user: User, alias: Alias, contact_email: str, msg: Message, ) -> bool: if ip: LOG.d("Enforce SPF") try: r = spf.check2(i=ip, s=envelope.mail_from.lower(), h=None) except Exception: LOG.error("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.error( "SPF fail for mailbox %s, reason %s, failed IP %s", mailbox.email, r[0], ip, ) 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", name=user.name, alias=alias.email, ip=ip, mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf", to_email=contact_email, subject=msg["Subject"], time=arrow.now(), ), render( "transactional/spf-fail.html", name=user.name, alias=alias.email, ip=ip, mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf", to_email=contact_email, subject=msg["Subject"], time=arrow.now(), ), ) return False else: LOG.warning( "Could not find %s header %s -> %s", _IP_HEADER, mailbox.email, contact_email, ) return True
def authorize(): """ Redirected from client when user clicks on "Login with Server". This is a GET request with the following field in url - client_id - (optional) state - response_type: must be code """ oauth_client_id = request.args.get("client_id") state = request.args.get("state") scope = request.args.get("scope") redirect_uri = request.args.get("redirect_uri") response_mode = request.args.get("response_mode") nonce = request.args.get("nonce") try: response_types: [ResponseType] = get_response_types(request) except ValueError: return ( "response_type must be code, token, id_token or certain combination of these." " Please see /.well-known/openid-configuration to see what response_type are supported ", 400, ) if set(response_types) not in SUPPORTED_OPENID_FLOWS: return ( f"SimpleLogin only support the following OIDC flows: {SUPPORTED_OPENID_FLOWS_STR}", 400, ) if not redirect_uri: LOG.d("no redirect uri") return "redirect_uri must be set", 400 client = Client.get_by(oauth_client_id=oauth_client_id) if not client: final_redirect_uri = ( f"{redirect_uri}?error=invalid_client_id&client_id={oauth_client_id}" ) return redirect(final_redirect_uri) # check if redirect_uri is valid # allow localhost by default hostname, scheme = get_host_name_and_scheme(redirect_uri) if hostname != "localhost" and hostname != "127.0.0.1": # support custom scheme for mobile app if scheme == "http": final_redirect_uri = f"{redirect_uri}?error=http_not_allowed" return redirect(final_redirect_uri) if not RedirectUri.get_by(client_id=client.id, uri=redirect_uri): final_redirect_uri = f"{redirect_uri}?error=unknown_redirect_uri" return redirect(final_redirect_uri) # redirect from client website if request.method == "GET": if current_user.is_authenticated: suggested_email, other_emails, email_suffix = None, [], None suggested_name, other_names = None, [] # user has already allowed this client client_user: ClientUser = ClientUser.get_by( client_id=client.id, user_id=current_user.id) user_info = {} if client_user: LOG.debug("user %s has already allowed client %s", current_user, client) user_info = client_user.get_user_info() else: suggested_email, other_emails = current_user.suggested_emails( client.name) suggested_name, other_names = current_user.suggested_names() user_custom_domains = [ cd.domain for cd in current_user.verified_custom_domains() ] # List of (is_custom_domain, alias-suffix, time-signed alias-suffix) suffixes = available_suffixes(current_user) return render_template( "oauth/authorize.html", Scope=Scope, EMAIL_DOMAIN=EMAIL_DOMAIN, **locals(), ) else: # after user logs in, redirect user back to this page return render_template( "oauth/authorize_nonlogin_user.html", client=client, next=request.url, Scope=Scope, ) else: # POST - user allows or denies if request.form.get("button") == "deny": LOG.debug("User %s denies Client %s", current_user, client) final_redirect_uri = f"{redirect_uri}?error=deny&state={state}" return redirect(final_redirect_uri) LOG.debug("User %s allows Client %s", current_user, client) client_user = ClientUser.get_by(client_id=client.id, user_id=current_user.id) # user has already allowed this client, user cannot change information if client_user: LOG.d("user %s has already allowed client %s", current_user, client) else: alias_prefix = request.form.get("prefix") signed_suffix = request.form.get("suffix") alias = None # user creates a new alias, not using suggested alias if alias_prefix: # should never happen as this is checked on the front-end if not current_user.can_create_new_alias(): raise Exception( f"User {current_user} cannot create custom email") # hypothesis: user will click on the button in the 600 secs try: alias_suffix = signer.unsign(signed_suffix, max_age=600).decode() except SignatureExpired: LOG.error("Alias creation time expired for %s", current_user) flash("Alias creation time is expired, please retry", "warning") return redirect(request.url) except Exception: LOG.error("Alias suffix is tampered, user %s", current_user) flash("Unknown error, refresh the page", "error") return redirect(request.url) user_custom_domains = [ cd.domain for cd in current_user.verified_custom_domains() ] from app.dashboard.views.custom_alias import verify_prefix_suffix if verify_prefix_suffix(current_user, alias_prefix, alias_suffix): full_alias = alias_prefix + alias_suffix if Alias.get_by(email=full_alias) or DeletedAlias.get_by( email=full_alias): LOG.error("alias %s already used, very rare!", full_alias) flash(f"Alias {full_alias} already used", "error") return redirect(request.url) else: alias = Alias.create( user_id=current_user.id, email=full_alias, mailbox_id=current_user.default_mailbox_id, ) # get the custom_domain_id if alias is created with a custom domain if alias_suffix.startswith("@"): alias_domain = alias_suffix[1:] domain = CustomDomain.get_by(domain=alias_domain) alias.custom_domain_id = domain.id db.session.flush() flash(f"Alias {full_alias} has been created", "success") # only happen if the request has been "hacked" else: flash("something went wrong", "warning") return redirect(request.url) # User chooses one of the suggestions else: chosen_email = request.form.get("suggested-email") # todo: add some checks on chosen_email if chosen_email != current_user.email: alias = Alias.get_by(email=chosen_email) if not alias: alias = Alias.create( email=chosen_email, user_id=current_user.id, mailbox_id=current_user.default_mailbox_id, ) db.session.flush() suggested_name = request.form.get("suggested-name") custom_name = request.form.get("custom-name") use_default_avatar = request.form.get("avatar-choice") == "default" client_user = ClientUser.create(client_id=client.id, user_id=current_user.id) if alias: client_user.alias_id = alias.id if custom_name: client_user.name = custom_name elif suggested_name != current_user.name: client_user.name = suggested_name if use_default_avatar: # use default avatar LOG.d("use default avatar for user %s client %s", current_user, client) client_user.default_avatar = True db.session.flush() LOG.d("create client-user for client %s, user %s", client, current_user) redirect_args = {} if state: redirect_args["state"] = state else: LOG.warning( "more security reason, state should be added. client %s", client) if scope: redirect_args["scope"] = scope auth_code = None if ResponseType.CODE in response_types: # Create authorization code auth_code = AuthorizationCode.create( client_id=client.id, user_id=current_user.id, code=random_string(), scope=scope, redirect_uri=redirect_uri, response_type=response_types_to_str(response_types), ) db.session.add(auth_code) redirect_args["code"] = auth_code.code oauth_token = None if ResponseType.TOKEN in response_types: # create access-token oauth_token = OauthToken.create( client_id=client.id, user_id=current_user.id, scope=scope, redirect_uri=redirect_uri, access_token=generate_access_token(), response_type=response_types_to_str(response_types), ) db.session.add(oauth_token) redirect_args["access_token"] = oauth_token.access_token if ResponseType.ID_TOKEN in response_types: redirect_args["id_token"] = make_id_token( client_user, nonce, oauth_token.access_token if oauth_token else None, auth_code.code if auth_code else None, ) db.session.commit() # should all params appended the url using fragment (#) or query fragment = False if response_mode and response_mode == "fragment": fragment = True # if response_types contain "token" => implicit flow => should use fragment # except if client sets explicitly response_mode if not response_mode: if ResponseType.TOKEN in response_types: fragment = True # construct redirect_uri with redirect_args return redirect(construct_url(redirect_uri, redirect_args, fragment))
def custom_alias(): # check if user has the right to create custom alias if not current_user.can_create_new_alias(): # notify admin LOG.error("user %s tries to create custom alias", current_user) flash("ony premium user can choose custom alias", "warning") return redirect(url_for("dashboard.index")) error = "" if request.method == "POST": if request.form.get("form-name") == "non-custom-domain-name": email_prefix = request.form.get("email-prefix") email_prefix = convert_to_id(email_prefix) email_suffix = request.form.get("email-suffix") if not email_prefix: error = "alias prefix cannot be empty" else: full_email = f"{email_prefix}.{email_suffix}@{EMAIL_DOMAIN}" # check if email already exists if GenEmail.get_by(email=full_email) or DeletedAlias.get_by( email=full_email): error = "email already chosen, please choose another one" else: # create the new alias LOG.d("create custom alias %s for user %s", full_email, current_user) gen_email = GenEmail.create(email=full_email, user_id=current_user.id) db.session.commit() flash(f"Alias {full_email} has been created", "success") session[HIGHLIGHT_GEN_EMAIL_ID] = gen_email.id return redirect(url_for("dashboard.index")) elif request.form.get("form-name") == "custom-domain-name": custom_domain_id = request.form.get("custom-domain-id") email = request.form.get("email") custom_domain = CustomDomain.get(custom_domain_id) if not custom_domain: flash("Unknown error. Refresh the page", "warning") return redirect(url_for("dashboard.custom_alias")) elif custom_domain.user_id != current_user.id: flash("Unknown error. Refresh the page", "warning") return redirect(url_for("dashboard.custom_alias")) elif not custom_domain.verified: flash("Unknown error. Refresh the page", "warning") return redirect(url_for("dashboard.custom_alias")) full_email = f"{email}@{custom_domain.domain}" if GenEmail.get_by(email=full_email): error = f"{full_email} already exist, please choose another one" else: LOG.d( "create custom alias %s for custom domain %s", full_email, custom_domain.domain, ) gen_email = GenEmail.create( email=full_email, user_id=current_user.id, custom_domain_id=custom_domain.id, ) db.session.commit() flash(f"Alias {full_email} has been created", "success") session[HIGHLIGHT_GEN_EMAIL_ID] = gen_email.id return redirect(url_for("dashboard.index")) email_suffix = random_word() return render_template( "dashboard/custom_alias.html", error=error, email_suffix=email_suffix, EMAIL_DOMAIN=EMAIL_DOMAIN, custom_domains=current_user.verified_custom_domains(), )
def custom_alias(): # check if user has not exceeded the alias quota if not current_user.can_create_new_alias(): # notify admin LOG.error("user %s tries to create custom alias", current_user) flash( "You have reached free plan limit, please upgrade to create new aliases", "warning", ) return redirect(url_for("dashboard.index")) user_custom_domains = [ cd.domain for cd in current_user.verified_custom_domains() ] # List of (is_custom_domain, alias-suffix, time-signed alias-suffix) suffixes = available_suffixes(current_user) mailboxes = current_user.mailboxes() if request.method == "POST": alias_prefix = request.form.get("prefix").strip().lower() signed_suffix = request.form.get("suffix") mailbox_ids = request.form.getlist("mailboxes") alias_note = request.form.get("note") # check if mailbox is not tempered with mailboxes = [] for mailbox_id in mailbox_ids: mailbox = Mailbox.get(mailbox_id) if (not mailbox or mailbox.user_id != current_user.id or not mailbox.verified): flash("Something went wrong, please retry", "warning") return redirect(url_for("dashboard.custom_alias")) mailboxes.append(mailbox) if not mailboxes: flash("At least one mailbox must be selected", "error") return redirect(url_for("dashboard.custom_alias")) # hypothesis: user will click on the button in the 600 secs try: alias_suffix = signer.unsign(signed_suffix, max_age=600).decode() except SignatureExpired: LOG.warning("Alias creation time expired for %s", current_user) flash("Alias creation time is expired, please retry", "warning") return redirect(url_for("dashboard.custom_alias")) except Exception: LOG.error("Alias suffix is tampered, user %s", current_user) flash("Unknown error, refresh the page", "error") return redirect(url_for("dashboard.custom_alias")) if verify_prefix_suffix(current_user, alias_prefix, alias_suffix): full_alias = alias_prefix + alias_suffix if (Alias.get_by(email=full_alias) or DeletedAlias.get_by(email=full_alias) or DomainDeletedAlias.get_by(email=full_alias)): LOG.d("full alias already used %s", full_alias) flash( f"Alias {full_alias} already exists, please choose another one", "warning", ) else: custom_domain_id = None # get the custom_domain_id if alias is created with a custom domain if alias_suffix.startswith("@"): alias_domain = alias_suffix[1:] domain = CustomDomain.get_by(domain=alias_domain) # check if the alias is currently in the domain trash if domain and DomainDeletedAlias.get_by( domain_id=domain.id, email=full_alias): flash( f"Alias {full_alias} is currently in the {domain.domain} trash. " f"Please remove it from the trash in order to re-create it.", "warning", ) return redirect(url_for("dashboard.custom_alias")) if domain: custom_domain_id = domain.id alias = Alias.create( user_id=current_user.id, email=full_alias, note=alias_note, mailbox_id=mailboxes[0].id, custom_domain_id=custom_domain_id, ) db.session.flush() for i in range(1, len(mailboxes)): AliasMailbox.create( alias_id=alias.id, mailbox_id=mailboxes[i].id, ) db.session.commit() flash(f"Alias {full_alias} has been created", "success") return redirect( url_for("dashboard.index", highlight_alias_id=alias.id)) # only happen if the request has been "hacked" else: flash("something went wrong", "warning") return render_template( "dashboard/custom_alias.html", user_custom_domains=user_custom_domains, suffixes=suffixes, mailboxes=mailboxes, )
def handle_bounce(contact: Contact, alias: Alias, msg: Message, user: User): address = alias.email email_log: EmailLog = EmailLog.create(contact_id=contact.id, bounced=True, user_id=contact.user_id) db.session.commit() nb_bounced = EmailLog.filter_by(contact_id=contact.id, bounced=True).count() disable_alias_link = f"{URL}/dashboard/unsubscribe/{alias.id}" # <<< Store the bounced email >>> # generate a name for the email random_name = str(uuid.uuid4()) full_report_path = f"refused-emails/full-{random_name}.eml" s3.upload_email_from_bytesio(full_report_path, BytesIO(msg.as_bytes()), random_name) orig_msg = get_orig_message_from_bounce(msg) if not orig_msg: LOG.error( "Cannot parse original message from bounce message %s %s %s %s", alias, user, contact, full_report_path, ) return file_path = f"refused-emails/{random_name}.eml" s3.upload_email_from_bytesio(file_path, BytesIO(orig_msg.as_bytes()), random_name) # <<< END Store the bounced email >>> mailbox_id = int(orig_msg[_MAILBOX_ID_HEADER]) mailbox = Mailbox.get(mailbox_id) if not mailbox or mailbox.user_id != user.id: LOG.error( "Tampered message mailbox_id %s, %s, %s, %s %s", mailbox_id, user, alias, contact, full_report_path, ) return refused_email = RefusedEmail.create(path=file_path, full_report_path=full_report_path, user_id=user.id) db.session.flush() email_log.refused_email_id = refused_email.id email_log.bounced_mailbox_id = mailbox.id db.session.commit() LOG.d("Create refused email %s", refused_email) refused_email_url = (URL + f"/dashboard/refused_email?highlight_id=" + str(email_log.id)) # inform user if this is the first bounced email if nb_bounced == 1: LOG.d( "Inform user %s about bounced email sent by %s to alias %s", user, contact.website_email, address, ) send_email_with_rate_control( user, ALERT_BOUNCE_EMAIL, # use user mail here as only user is authenticated to see the refused email user.email, f"Email from {contact.website_email} to {address} cannot be delivered to your inbox", render( "transactional/bounced-email.txt", name=user.name, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), render( "transactional/bounced-email.html", name=user.name, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), # cannot include bounce email as it can contain spammy text # bounced_email=msg, ) # disable the alias the second time email is bounced elif nb_bounced >= 2: LOG.d( "Bounce happens again with alias %s from %s. Disable alias now ", address, contact.website_email, ) alias.enabled = False db.session.commit() send_email_with_rate_control( user, ALERT_BOUNCE_EMAIL, # use user mail here as only user is authenticated to see the refused email user.email, f"Alias {address} has been disabled due to second undelivered email from {contact.website_email}", render( "transactional/automatic-disable-alias.txt", name=user.name, alias=alias, website_email=contact.website_email, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), render( "transactional/automatic-disable-alias.html", name=user.name, alias=alias, website_email=contact.website_email, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), # cannot include bounce email as it can contain spammy text # bounced_email=msg, )
def handle_reply(self, envelope, smtp: SMTP, msg: EmailMessage) -> str: reply_email = envelope.rcpt_tos[0] # reply_email must end with EMAIL_DOMAIN if not reply_email.endswith(EMAIL_DOMAIN): LOG.error(f"Reply email {reply_email} has wrong domain") return "550 wrong reply email" forward_email = ForwardEmail.get_by(reply_email=reply_email) alias: str = forward_email.gen_email.email # alias must end with EMAIL_DOMAIN or custom-domain alias_domain = alias[alias.find("@") + 1 :] if alias_domain != EMAIL_DOMAIN: if not CustomDomain.get_by(domain=alias_domain): return "550 alias unknown by SimpleLogin" user_email = forward_email.gen_email.user.email if envelope.mail_from != user_email: LOG.error( f"Reply email can only be used by user email. Actual mail_from: %s. User email %s", envelope.mail_from, user_email, ) send_email( envelope.mail_from, f"Your email ({envelope.mail_from}) is not allowed to send email to {reply_email}", "", "", ) return "250 ignored" # remove DKIM-Signature if msg["DKIM-Signature"]: LOG.d("Remove DKIM-Signature %s", msg["DKIM-Signature"]) del msg["DKIM-Signature"] # email seems to come from alias msg.replace_header("From", alias) msg.replace_header("To", forward_email.website_email) # add List-Unsubscribe header unsubscribe_link = f"{URL}/dashboard/unsubscribe/{forward_email.gen_email_id}" add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>") add_or_replace_header( msg, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click" ) LOG.d( "send email from %s to %s, mail_options:%s,rcpt_options:%s", alias, forward_email.website_email, envelope.mail_options, envelope.rcpt_options, ) if alias_domain == EMAIL_DOMAIN: add_dkim_signature(msg, EMAIL_DOMAIN) # add DKIM-Signature for non-custom-domain alias else: custom_domain: CustomDomain = CustomDomain.get_by(domain=alias_domain) if custom_domain.dkim_verified: add_dkim_signature(msg, alias_domain) msg_raw = msg.as_string().encode() smtp.sendmail( alias, forward_email.website_email, msg_raw, envelope.mail_options, envelope.rcpt_options, ) ForwardEmailLog.create(forward_id=forward_email.id, is_reply=True) db.session.commit() return "250 Message accepted for delivery"
def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]: """Call verifyReceipt endpoint and create/update AppleSubscription table Call the production URL for verifyReceipt first, and proceed to verify with the sandbox URL if receive a 21007 status code. Return AppleSubscription object if success https://developer.apple.com/documentation/appstorereceipts/verifyreceipt """ LOG.d("start verify_receipt") r = requests.post(_PROD_URL, json={ "receipt-data": receipt_data, "password": password }) if r.json() == {"status": 21007}: # try sandbox_url LOG.warning("Use the sandbox url instead") r = requests.post( _SANDBOX_URL, json={ "receipt-data": receipt_data, "password": password }, ) data = r.json() # data has the following format # { # "status": 0, # "environment": "Sandbox", # "receipt": { # "receipt_type": "ProductionSandbox", # "adam_id": 0, # "app_item_id": 0, # "bundle_id": "io.simplelogin.ios-app", # "application_version": "2", # "download_id": 0, # "version_external_identifier": 0, # "receipt_creation_date": "2020-04-18 16:36:34 Etc/GMT", # "receipt_creation_date_ms": "1587227794000", # "receipt_creation_date_pst": "2020-04-18 09:36:34 America/Los_Angeles", # "request_date": "2020-04-18 16:46:36 Etc/GMT", # "request_date_ms": "1587228396496", # "request_date_pst": "2020-04-18 09:46:36 America/Los_Angeles", # "original_purchase_date": "2013-08-01 07:00:00 Etc/GMT", # "original_purchase_date_ms": "1375340400000", # "original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles", # "original_application_version": "1.0", # "in_app": [ # { # "quantity": "1", # "product_id": "io.simplelogin.ios_app.subscription.premium.monthly", # "transaction_id": "1000000653584474", # "original_transaction_id": "1000000653584474", # "purchase_date": "2020-04-18 16:27:42 Etc/GMT", # "purchase_date_ms": "1587227262000", # "purchase_date_pst": "2020-04-18 09:27:42 America/Los_Angeles", # "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT", # "original_purchase_date_ms": "1587227264000", # "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles", # "expires_date": "2020-04-18 16:32:42 Etc/GMT", # "expires_date_ms": "1587227562000", # "expires_date_pst": "2020-04-18 09:32:42 America/Los_Angeles", # "web_order_line_item_id": "1000000051847459", # "is_trial_period": "false", # "is_in_intro_offer_period": "false", # }, # { # "quantity": "1", # "product_id": "io.simplelogin.ios_app.subscription.premium.monthly", # "transaction_id": "1000000653584861", # "original_transaction_id": "1000000653584474", # "purchase_date": "2020-04-18 16:32:42 Etc/GMT", # "purchase_date_ms": "1587227562000", # "purchase_date_pst": "2020-04-18 09:32:42 America/Los_Angeles", # "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT", # "original_purchase_date_ms": "1587227264000", # "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles", # "expires_date": "2020-04-18 16:37:42 Etc/GMT", # "expires_date_ms": "1587227862000", # "expires_date_pst": "2020-04-18 09:37:42 America/Los_Angeles", # "web_order_line_item_id": "1000000051847461", # "is_trial_period": "false", # "is_in_intro_offer_period": "false", # }, # ], # }, # "latest_receipt_info": [ # { # "quantity": "1", # "product_id": "io.simplelogin.ios_app.subscription.premium.monthly", # "transaction_id": "1000000653584474", # "original_transaction_id": "1000000653584474", # "purchase_date": "2020-04-18 16:27:42 Etc/GMT", # "purchase_date_ms": "1587227262000", # "purchase_date_pst": "2020-04-18 09:27:42 America/Los_Angeles", # "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT", # "original_purchase_date_ms": "1587227264000", # "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles", # "expires_date": "2020-04-18 16:32:42 Etc/GMT", # "expires_date_ms": "1587227562000", # "expires_date_pst": "2020-04-18 09:32:42 America/Los_Angeles", # "web_order_line_item_id": "1000000051847459", # "is_trial_period": "false", # "is_in_intro_offer_period": "false", # "subscription_group_identifier": "20624274", # }, # { # "quantity": "1", # "product_id": "io.simplelogin.ios_app.subscription.premium.monthly", # "transaction_id": "1000000653584861", # "original_transaction_id": "1000000653584474", # "purchase_date": "2020-04-18 16:32:42 Etc/GMT", # "purchase_date_ms": "1587227562000", # "purchase_date_pst": "2020-04-18 09:32:42 America/Los_Angeles", # "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT", # "original_purchase_date_ms": "1587227264000", # "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles", # "expires_date": "2020-04-18 16:37:42 Etc/GMT", # "expires_date_ms": "1587227862000", # "expires_date_pst": "2020-04-18 09:37:42 America/Los_Angeles", # "web_order_line_item_id": "1000000051847461", # "is_trial_period": "false", # "is_in_intro_offer_period": "false", # "subscription_group_identifier": "20624274", # }, # { # "quantity": "1", # "product_id": "io.simplelogin.ios_app.subscription.premium.monthly", # "transaction_id": "1000000653585235", # "original_transaction_id": "1000000653584474", # "purchase_date": "2020-04-18 16:38:16 Etc/GMT", # "purchase_date_ms": "1587227896000", # "purchase_date_pst": "2020-04-18 09:38:16 America/Los_Angeles", # "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT", # "original_purchase_date_ms": "1587227264000", # "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles", # "expires_date": "2020-04-18 16:43:16 Etc/GMT", # "expires_date_ms": "1587228196000", # "expires_date_pst": "2020-04-18 09:43:16 America/Los_Angeles", # "web_order_line_item_id": "1000000051847500", # "is_trial_period": "false", # "is_in_intro_offer_period": "false", # "subscription_group_identifier": "20624274", # }, # { # "quantity": "1", # "product_id": "io.simplelogin.ios_app.subscription.premium.monthly", # "transaction_id": "1000000653585760", # "original_transaction_id": "1000000653584474", # "purchase_date": "2020-04-18 16:44:25 Etc/GMT", # "purchase_date_ms": "1587228265000", # "purchase_date_pst": "2020-04-18 09:44:25 America/Los_Angeles", # "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT", # "original_purchase_date_ms": "1587227264000", # "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles", # "expires_date": "2020-04-18 16:49:25 Etc/GMT", # "expires_date_ms": "1587228565000", # "expires_date_pst": "2020-04-18 09:49:25 America/Los_Angeles", # "web_order_line_item_id": "1000000051847566", # "is_trial_period": "false", # "is_in_intro_offer_period": "false", # "subscription_group_identifier": "20624274", # }, # ], # "latest_receipt": "very long string", # "pending_renewal_info": [ # { # "auto_renew_product_id": "io.simplelogin.ios_app.subscription.premium.monthly", # "original_transaction_id": "1000000653584474", # "product_id": "io.simplelogin.ios_app.subscription.premium.monthly", # "auto_renew_status": "1", # } # ], # } if data["status"] != 0: LOG.warning( "verifyReceipt status !=0, probably invalid receipt. User %s", user, ) return None # each item in data["receipt"]["in_app"] has the following format # { # "quantity": "1", # "product_id": "io.simplelogin.ios_app.subscription.premium.monthly", # "transaction_id": "1000000653584474", # "original_transaction_id": "1000000653584474", # "purchase_date": "2020-04-18 16:27:42 Etc/GMT", # "purchase_date_ms": "1587227262000", # "purchase_date_pst": "2020-04-18 09:27:42 America/Los_Angeles", # "original_purchase_date": "2020-04-18 16:27:44 Etc/GMT", # "original_purchase_date_ms": "1587227264000", # "original_purchase_date_pst": "2020-04-18 09:27:44 America/Los_Angeles", # "expires_date": "2020-04-18 16:32:42 Etc/GMT", # "expires_date_ms": "1587227562000", # "expires_date_pst": "2020-04-18 09:32:42 America/Los_Angeles", # "web_order_line_item_id": "1000000051847459", # "is_trial_period": "false", # "is_in_intro_offer_period": "false", # } transactions = data["receipt"]["in_app"] if not transactions: LOG.warning("Empty transactions in data %s", data) return None latest_transaction = max(transactions, key=lambda t: int(t["expires_date_ms"])) original_transaction_id = latest_transaction["original_transaction_id"] expires_date = arrow.get(int(latest_transaction["expires_date_ms"]) / 1000) plan = (PlanEnum.monthly if latest_transaction["product_id"] == _MONTHLY_PRODUCT_ID else PlanEnum.yearly) apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=user.id) if apple_sub: LOG.d( "Update AppleSubscription for user %s, expired at %s, plan %s", user, expires_date, plan, ) apple_sub.receipt_data = receipt_data apple_sub.expires_date = expires_date apple_sub.original_transaction_id = original_transaction_id apple_sub.plan = plan else: # the same original_transaction_id has been used on another account if AppleSubscription.get_by( original_transaction_id=original_transaction_id): LOG.error("Same Apple Sub has been used before, current user %s", user) return None LOG.d( "Create new AppleSubscription for user %s, expired at %s, plan %s", user, expires_date, plan, ) apple_sub = AppleSubscription.create( user_id=user.id, receipt_data=receipt_data, expires_date=expires_date, original_transaction_id=original_transaction_id, plan=plan, ) db.session.commit() return apple_sub
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.error( 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 = email.strip().lower() 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") db.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)