def get_spam_score(message: Message, email_log: EmailLog, can_retry=True) -> (float, dict): """ Return the spam score and spam report """ LOG.d("get spam score for %s", email_log) sa_input = to_bytes(message) # Spamassassin requires to have an ending linebreak if not sa_input.endswith(b"\n"): LOG.d("add linebreak to spamassassin input") sa_input += b"\n" try: # wait for at max 300s which is the default spamd timeout-child sa = SpamAssassin(sa_input, host=SPAMASSASSIN_HOST, timeout=300) return sa.get_score(), sa.get_report_json() except Exception: if can_retry: LOG.w("SpamAssassin exception, retry") time.sleep(3) return get_spam_score(message, email_log, can_retry=False) else: # return a negative score so the message is always considered as ham LOG.e("SpamAssassin exception, ignore spam check") return -999, None
def try_auto_create(address: str) -> Optional[Alias]: """Try to auto-create the alias using directory or catch-all domain""" # VERP for reply phase is {BOUNCE_PREFIX_FOR_REPLY_PHASE}+{email_log.id}+@{alias_domain} if address.startswith( f"{BOUNCE_PREFIX_FOR_REPLY_PHASE}+") and "+@" in address: LOG.e("alias %s can't start with %s", address, BOUNCE_PREFIX_FOR_REPLY_PHASE) return None # VERP for forward phase is BOUNCE_PREFIX + email_log.id + BOUNCE_SUFFIX if address.startswith(BOUNCE_PREFIX) and address.endswith(BOUNCE_SUFFIX): LOG.e("alias %s can't start with %s", address, BOUNCE_PREFIX) return None try: # NOT allow unicode for now validate_email(address, check_deliverability=False, allow_smtputf8=False) except EmailNotValidError: return None alias = try_auto_create_via_domain(address) if not alias: alias = try_auto_create_directory(address) return alias
def paddle_coupon(): LOG.d(f"paddle coupon callback %s", request.form) if not paddle_utils.verify_incoming_request(dict(request.form)): LOG.e("request not coming from paddle. Request data:%s", dict(request.form)) return "KO", 400 product_id = request.form.get("p_product_id") if product_id != PADDLE_COUPON_ID: LOG.e("product_id %s not match with %s", product_id, PADDLE_COUPON_ID) return "KO", 400 email = request.form.get("email") LOG.d("Paddle coupon request for %s", email) coupon = Coupon.create( code=random_string(30), comment="For 1-year coupon", expires_date=arrow.now().shift(years=1, days=-1), commit=True, ) return ( f"Your 1-year coupon is <b>{coupon.code}</b> <br> " f"It's valid until <b>{coupon.expires_date.date().isoformat()}</b>" )
def change_plan(user: User, subscription_id: str, plan_id) -> (bool, str): """return whether the operation is successful and an optional error message""" r = requests.post( "https://vendors.paddle.com/api/2.0/subscription/users/update", data={ "vendor_id": PADDLE_VENDOR_ID, "vendor_auth_code": PADDLE_AUTH_CODE, "subscription_id": subscription_id, "plan_id": plan_id, }, ) res = r.json() if not res["success"]: try: # "unable to complete the resubscription because we could not charge the customer for the resubscription" if res["error"]["code"] == 147: LOG.w( "could not charge the customer for the resubscription error %s,%s", subscription_id, user, ) return False, "Your card cannot be charged" except KeyError: LOG.e( f"cannot change subscription {subscription_id} to {plan_id}, paddle response: {res}" ) return False, "" return False, "" return res["success"], ""
def verify_id_token(id_token) -> bool: try: jwt.JWT(key=_key, jwt=id_token) except Exception: LOG.e("id token not verified") return False else: return True
def handle_coinbase_event(event) -> bool: user_id = int(event["data"]["metadata"]["user_id"]) code = event["data"]["code"] user = User.get(user_id) if not user: LOG.e("User not found %s", user_id) return False coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by( user_id=user_id) if not coinbase_subscription: LOG.d("Create a coinbase subscription for %s", user) coinbase_subscription = CoinbaseSubscription.create( user_id=user_id, end_at=arrow.now().shift(years=1), code=code, commit=True) send_email( user.email, "Your SimpleLogin account has been upgraded", render( "transactional/coinbase/new-subscription.txt", coinbase_subscription=coinbase_subscription, ), render( "transactional/coinbase/new-subscription.html", coinbase_subscription=coinbase_subscription, ), ) else: if coinbase_subscription.code != code: LOG.d("Update code from %s to %s", coinbase_subscription.code, code) coinbase_subscription.code = code if coinbase_subscription.is_active(): coinbase_subscription.end_at = coinbase_subscription.end_at.shift( years=1) else: # already expired subscription coinbase_subscription.end_at = arrow.now().shift(years=1) Session.commit() send_email( user.email, "Your SimpleLogin account has been extended", render( "transactional/coinbase/extend-subscription.txt", coinbase_subscription=coinbase_subscription, ), render( "transactional/coinbase/extend-subscription.html", coinbase_subscription=coinbase_subscription, ), ) return True
def cancel_subscription(subscription_id: str) -> 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.e(f"cannot cancel subscription {subscription_id}, paddle response: {res}") return res["success"]
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)
async def check_hibp(): """ Check all aliases on the HIBP (Have I Been Pwned) API """ LOG.d("Checking HIBP API for aliases in breaches") if len(HIBP_API_KEYS) == 0: LOG.e("No HIBP API keys") return LOG.d("Updating list of known breaches") r = requests.get("https://haveibeenpwned.com/api/v3/breaches") for entry in r.json(): hibp_entry = Hibp.get_or_create(name=entry["Name"]) hibp_entry.date = arrow.get(entry["BreachDate"]) hibp_entry.description = entry["Description"] Session.commit() LOG.d("Updated list of known breaches") LOG.d("Preparing list of aliases to check") queue = asyncio.Queue() max_date = arrow.now().shift(days=-HIBP_SCAN_INTERVAL_DAYS) for alias in (Alias.filter( or_(Alias.hibp_last_check.is_(None), Alias.hibp_last_check < max_date)).filter( Alias.enabled).order_by( Alias.hibp_last_check.asc()).all()): await queue.put(alias.id) LOG.d("Need to check about %s aliases", queue.qsize()) # Start one checking process per API key # Each checking process will take one alias from the queue, get the info # and then sleep for 1.5 seconds (due to HIBP API request limits) checkers = [] for i in range(len(HIBP_API_KEYS)): checker = asyncio.create_task(_hibp_check( HIBP_API_KEYS[i], queue, )) checkers.append(checker) # Wait until all checking processes are done for checker in checkers: await checker LOG.d("Done checking HIBP API for aliases in breaches")
def provider2_sms(): encoded = request.headers.get(PHONE_PROVIDER_2_HEADER) try: jwt.decode(encoded, key=PHONE_PROVIDER_2_SECRET, algorithms="HS256") except (InvalidSignatureError, DecodeError): LOG.e( "Unauthenticated callback %s %s %s %s", request.headers, request.method, request.args, request.json, ) return "not ok", 400 # request.json should be a dict where # msisdn is the sender # receiver is the receiver # For ex: # {'id': 2042489247, 'msisdn': 33612345678, 'country_code': 'FR', 'country_prefix': 33, 'receiver': 33687654321, # 'message': 'Test 1', 'senttime': 1641401781, 'webhook_label': 'Hagekar', 'sender': None, # 'mcc': None, 'mnc': None, 'validity_period': None, 'encoding': 'UTF8', 'udh': None, 'payload': None} to_number: str = str(request.json.get("receiver")) if not to_number.startswith("+"): to_number = "+" + to_number from_number = str(request.json.get("msisdn")) if not from_number.startswith("+"): from_number = "+" + from_number body = request.json.get("message") LOG.d("%s->%s:%s", from_number, to_number, body) phone_number = PhoneNumber.get_by(number=to_number) if phone_number: PhoneMessage.create( number_id=phone_number.id, from_number=from_number, body=body, commit=True, ) else: LOG.e("Unknown phone number %s %s", to_number, request.json) return "not ok", 200 return "ok", 200
def set_custom_domain_for_alias(): """Go through all aliases and make sure custom_domain is correctly set""" sl_domains = [sl_domain.domain for sl_domain in SLDomain.all()] for alias in Alias.yield_per_query().filter( Alias.custom_domain_id.is_(None)): if (not any( alias.email.endswith(f"@{sl_domain}") for sl_domain in sl_domains) and not alias.custom_domain_id): alias_domain = get_email_domain_part(alias.email) custom_domain = CustomDomain.get_by(domain=alias_domain) if custom_domain: LOG.e("set %s for %s", custom_domain, alias) alias.custom_domain_id = custom_domain.id else: # phantom domain LOG.d("phantom domain %s %s %s", alias.user, alias, alias.enabled) Session.commit()
def twilio_sms(): LOG.d("%s %s %s", request.args, request.form, request.data) resp = MessagingResponse() to_number = request.form.get("To") from_number = request.form.get("From") body = request.form.get("Body") LOG.d("%s->%s:%s", from_number, to_number, body) phone_number = PhoneNumber.get_by(number=to_number) if phone_number: PhoneMessage.create( number_id=phone_number.id, from_number=from_number, body=body, commit=True, ) else: LOG.e("Unknown phone number %s %s", to_number, request.form) return str(resp)
async def get_spam_score_async(message: Message) -> float: sa_input = to_bytes(message) # Spamassassin requires to have an ending linebreak if not sa_input.endswith(b"\n"): LOG.d("add linebreak to spamassassin input") sa_input += b"\n" try: # wait for at max 300s which is the default spamd timeout-child response = await asyncio.wait_for(aiospamc.check( sa_input, host=SPAMASSASSIN_HOST), timeout=300) return response.headers["Spam"].score except asyncio.TimeoutError: LOG.e("SpamAssassin timeout") # return a negative score so the message is always considered as ham return -999 except Exception: LOG.e("SpamAssassin exception") return -999
def coinbase_webhook(): # event payload request_data = request.data.decode("utf-8") # webhook signature request_sig = request.headers.get("X-CC-Webhook-Signature", None) try: # signature verification and event object construction event = Webhook.construct_event(request_data, request_sig, COINBASE_WEBHOOK_SECRET) except (WebhookInvalidPayload, SignatureVerificationError) as e: LOG.e("Invalid Coinbase webhook") return str(e), 400 LOG.d("Coinbase event %s", event) if event["type"] == "charge:confirmed": if handle_coinbase_event(event): return "success", 200 else: return "error", 400 return "success", 200
def load_pgp_public_keys(): """Load PGP public key to keyring""" for mailbox in Mailbox.filter(Mailbox.pgp_public_key.isnot(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.e("fingerprint %s different for mailbox %s", fingerprint, mailbox) mailbox.pgp_finger_print = fingerprint Session.commit() for contact in Contact.filter(Contact.pgp_public_key.isnot(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.e("fingerprint %s different for contact %s", fingerprint, contact) contact.pgp_finger_print = fingerprint Session.commit() LOG.d("Finish load_pgp_public_keys")
def get_encoding(msg: Message) -> EmailEncoding: """ Return the message encoding, possible values: - quoted-printable - base64 - 7bit: default if unknown or empty """ cte = str(msg.get(headers.CONTENT_TRANSFER_ENCODING, "")).lower().strip() if cte in ("", "7bit", "8bit", "binary", "8bit;", "utf-8"): return EmailEncoding.NO if cte == "base64": return EmailEncoding.BASE64 if cte == "quoted-printable": return EmailEncoding.QUOTED # some email services use unknown encoding if cte in ("amazonses.com", ): return EmailEncoding.NO LOG.e("Unknown encoding %s", cte) return EmailEncoding.NO
def provider1_sms(): if request.headers.get(PHONE_PROVIDER_1_HEADER) != PHONE_PROVIDER_1_SECRET: LOG.e( "Unauthenticated callback %s %s %s %s", request.headers, request.method, request.args, request.data, ) return "not ok", 200 # request.form should be a dict that contains message_id, number, text, sim_card_number. # "number" is the contact number and "sim_card_number" the virtual number # The "reception_date" is in local time and shouldn't be used # For ex: # ImmutableMultiDict([('message_id', 'sms_0000000000000000000000'), ('number', '+33600112233'), # ('text', 'Lorem Ipsum is simply dummy text ...'), ('sim_card_number', '12345'), # ('reception_date', '2022-01-04 14:42:51')]) to_number = request.form.get("sim_card_number") from_number = request.form.get("number") body = request.form.get("text") LOG.d("%s->%s:%s", from_number, to_number, body) phone_number = PhoneNumber.get_by(number=to_number) if phone_number: PhoneMessage.create( number_id=phone_number.id, from_number=from_number, body=body, commit=True, ) else: LOG.e("Unknown phone number %s %s", to_number, request.form) return "not ok", 200 return "ok", 200
def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool: """verify if user could create an alias with the given prefix and suffix""" if not alias_prefix or not alias_suffix: # should be caught on frontend return False user_custom_domains = [cd.domain for cd in user.verified_custom_domains()] # make sure alias_suffix is either [email protected] or @my-domain.com alias_suffix = alias_suffix.strip() # alias_domain_prefix is either a .random_word or "" alias_domain_prefix, alias_domain = alias_suffix.split("@", 1) # alias_domain must be either one of user custom domains or built-in domains if alias_domain not in user.available_alias_domains(): LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) return False # SimpleLogin domain case: # 1) alias_suffix must start with "." and # 2) alias_domain_prefix must come from the word list if (alias_domain in user.available_sl_domains() and alias_domain not in user_custom_domains # when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty and not DISABLE_ALIAS_SUFFIX): if not alias_domain_prefix.startswith("."): LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix) return False else: if alias_domain not in user_custom_domains: if not DISABLE_ALIAS_SUFFIX: LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) return False if alias_domain not in user.available_sl_domains(): LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) return False return True
def subdomain_route(): if not current_user.subdomain_is_available(): flash("Unknown error, redirect to the home page", "error") return redirect(url_for("dashboard.index")) sl_domains = SLDomain.filter_by(can_use_subdomain=True).all() subdomains = CustomDomain.filter_by( user_id=current_user.id, is_sl_subdomain=True ).all() errors = {} if request.method == "POST": if request.form.get("form-name") == "create": if not current_user.is_premium(): flash("Only premium plan can add subdomain", "warning") return redirect(request.url) if current_user.subdomain_quota <= 0: flash( f"You can't create more than {MAX_NB_SUBDOMAIN} subdomains", "error" ) return redirect(request.url) subdomain = request.form.get("subdomain").lower().strip() domain = request.form.get("domain").lower().strip() if len(subdomain) < 3: flash("Subdomain must have at least 3 characters", "error") return redirect(request.url) if re.fullmatch(_SUBDOMAIN_PATTERN, subdomain) is None: flash( "Subdomain can only contain lowercase letters, numbers and dashes (-)", "error", ) return redirect(request.url) if subdomain.endswith("-"): flash("Subdomain can't end with dash (-)", "error") return redirect(request.url) if domain not in [sl_domain.domain for sl_domain in sl_domains]: LOG.e("Domain %s is tampered by %s", domain, current_user) flash("Unknown error, refresh the page", "error") return redirect(request.url) full_domain = f"{subdomain}.{domain}" if CustomDomain.get_by(domain=full_domain): flash(f"{full_domain} already used", "error") elif Mailbox.filter( Mailbox.verified.is_(True), Mailbox.email.endswith(f"@{full_domain}"), ).first(): flash(f"{full_domain} already used in a SimpleLogin mailbox", "error") else: try: new_custom_domain = CustomDomain.create( is_sl_subdomain=True, catch_all=True, # by default catch-all is enabled domain=full_domain, user_id=current_user.id, verified=True, dkim_verified=False, # wildcard DNS does not work for DKIM spf_verified=True, dmarc_verified=False, # wildcard DNS does not work for DMARC ownership_verified=True, commit=True, ) except SubdomainInTrashError: flash( f"{full_domain} has been used before and cannot be reused", "error", ) else: flash( f"New subdomain {new_custom_domain.domain} is created", "success", ) return redirect( url_for( "dashboard.domain_detail", custom_domain_id=new_custom_domain.id, ) ) return render_template( "dashboard/subdomain.html", sl_domains=sl_domains, errors=errors, subdomains=subdomains, )
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 error_handler(e): LOG.e(e) if request.path.startswith("/api/"): return jsonify(error="Internal error"), 500 else: return render_template("error/500.html"), 500
def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]: """ Call https://buy.itunes.apple.com/verifyReceipt and create/update AppleSubscription table Call the production URL for verifyReceipt first, use 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") try: r = requests.post(_PROD_URL, json={ "receipt-data": receipt_data, "password": password }) except RequestException: LOG.w("cannot call Apple server %s", _PROD_URL) return None if r.status_code >= 500: LOG.w("Apple server error, response:%s %s", r, r.content) return None if r.json() == {"status": 21007}: # try sandbox_url LOG.w("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.w( "verifyReceipt status !=0, probably invalid receipt. User %s, data %s", user, data, ) 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.w("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"] in (_MONTHLY_PRODUCT_ID, _MACAPP_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.product_id = latest_transaction["product_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.e("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, product_id=latest_transaction["product_id"], ) Session.commit() return apple_sub
def paddle(): LOG.d( f"paddle callback {request.form.get('alert_name')} {request.form}") # make sure the request comes from Paddle if not paddle_utils.verify_incoming_request(dict(request.form)): LOG.e("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 # the passthrough is json encoded, e.g. # request.form.get("passthrough") = '{"user_id": 88 }' passthrough = json.loads(request.form.get("passthrough")) user_id = passthrough.get("user_id") user = User.get(user_id) subscription_plan_id = int( request.form.get("subscription_plan_id")) if subscription_plan_id in PADDLE_MONTHLY_PRODUCT_IDS: plan = PlanEnum.monthly elif subscription_plan_id in PADDLE_YEARLY_PRODUCT_IDS: plan = PlanEnum.yearly else: LOG.e( "Unknown subscription_plan_id %s %s", subscription_plan_id, request.form, ) return "No such subscription", 400 sub = Subscription.get_by(user_id=user.id) if not sub: LOG.d(f"create a new Subscription for user {user}") 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(f"Update an existing Subscription for user {user}") 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 # make sure to set the new plan as not-cancelled # in case user cancels a plan and subscribes a new plan sub.cancelled = False LOG.d("User %s upgrades!", user) Session.commit() elif request.form.get( "alert_name") == "subscription_payment_succeeded": subscription_id = request.form.get("subscription_id") LOG.d("Update subscription %s", subscription_id) sub: Subscription = Subscription.get_by( subscription_id=subscription_id) # when user subscribes, the "subscription_payment_succeeded" can arrive BEFORE "subscription_created" # at that time, subscription object does not exist yet if sub: sub.event_time = arrow.now() sub.next_bill_date = arrow.get( request.form.get("next_bill_date"), "YYYY-MM-DD").date() Session.commit() elif request.form.get("alert_name") == "subscription_cancelled": subscription_id = request.form.get("subscription_id") sub: Subscription = Subscription.get_by( subscription_id=subscription_id) if sub: # cancellation_effective_date should be the same as next_bill_date LOG.w( "Cancel subscription %s %s on %s, next bill date %s", subscription_id, sub.user, request.form.get("cancellation_effective_date"), sub.next_bill_date, ) sub.event_time = arrow.now() sub.cancelled = True Session.commit() user = sub.user send_email( user.email, "SimpleLogin - your subscription is canceled", render( "transactional/subscription-cancel.txt", end_date=request.form.get( "cancellation_effective_date"), ), ) else: return "No such subscription", 400 elif request.form.get("alert_name") == "subscription_updated": subscription_id = request.form.get("subscription_id") sub: Subscription = Subscription.get_by( subscription_id=subscription_id) if sub: LOG.d( "Update subscription %s %s on %s, next bill date %s", subscription_id, sub.user, request.form.get("cancellation_effective_date"), sub.next_bill_date, ) if (int(request.form.get("subscription_plan_id")) == PADDLE_MONTHLY_PRODUCT_ID): plan = PlanEnum.monthly else: plan = PlanEnum.yearly sub.cancel_url = request.form.get("cancel_url") sub.update_url = request.form.get("update_url") sub.event_time = arrow.now() sub.next_bill_date = arrow.get( request.form.get("next_bill_date"), "YYYY-MM-DD").date() sub.plan = plan # make sure to set the new plan as not-cancelled sub.cancelled = False Session.commit() else: return "No such subscription", 400 elif request.form.get("alert_name") == "payment_refunded": subscription_id = request.form.get("subscription_id") LOG.d("Refund request for subscription %s", subscription_id) sub: Subscription = Subscription.get_by( subscription_id=subscription_id) if sub: user = sub.user Subscription.delete(sub.id) Session.commit() LOG.e("%s requests a refund", user) return "OK"
def spf_pass( envelope, mailbox: Mailbox, user: User, alias: Alias, contact_email: str, msg: Message, ) -> bool: ip = msg[_IP_HEADER] if ip: LOG.d("Enforce SPF on %s %s", ip, envelope.mail_from) try: r = spf.check2(i=ip, s=envelope.mail_from, h=None) except Exception: LOG.e("SPF error, mailbox %s, ip %s", mailbox.email, ip) else: # TODO: Handle temperr case (e.g. dns timeout) # only an absolute pass, or no SPF policy at all is 'valid' if r[0] not in ["pass", "none"]: LOG.w( "SPF fail for mailbox %s, reason %s, failed IP %s", mailbox.email, r[0], ip, ) subject = get_header_unicode(msg[headers.SUBJECT]) send_email_with_rate_control( user, ALERT_SPF, mailbox.email, f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address", render( "transactional/spf-fail.txt", alias=alias.email, ip=ip, mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf", to_email=contact_email, subject=subject, time=arrow.now(), ), render( "transactional/spf-fail.html", ip=ip, mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf", to_email=contact_email, subject=subject, time=arrow.now(), ), ) return False else: LOG.w( "Could not find %s header %s -> %s", _IP_HEADER, mailbox.email, contact_email, ) return True
elif job.name == JOB_DELETE_DOMAIN: custom_domain_id = job.payload.get("custom_domain_id") custom_domain = CustomDomain.get(custom_domain_id) if not custom_domain: continue domain_name = custom_domain.domain user = custom_domain.user CustomDomain.delete(custom_domain.id) Session.commit() LOG.d("Domain %s deleted", domain_name) send_email( user.email, f"Your domain {domain_name} has been deleted", f"""Domain {domain_name} along with its aliases are deleted successfully. Regards, SimpleLogin team. """, retries=3, ) else: LOG.e("Unknown job name %s", job.name) time.sleep(10)
def sanity_check(): LOG.d("sanitize user email") for user in User.filter_by(activated=True).all(): if sanitize_email(user.email) != user.email: LOG.e("%s does not have sanitized email", user) LOG.d("sanitize alias address & name") sanitize_alias_address_name() LOG.d("sanity contact address") contact_email_sanity_date = arrow.get("2021-01-12") for contact in Contact.yield_per_query(): if sanitize_email(contact.reply_email) != contact.reply_email: LOG.e("Contact %s reply-email not sanitized", contact) if (sanitize_email(contact.website_email, not_lower=True) != contact.website_email and contact.created_at > contact_email_sanity_date): LOG.e("Contact %s website-email not sanitized", contact) if not contact.invalid_email and not is_valid_email( contact.website_email): LOG.e("%s invalid email", contact) contact.invalid_email = True Session.commit() LOG.d("sanitize mailbox address") for mailbox in Mailbox.yield_per_query(): if sanitize_email(mailbox.email) != mailbox.email: LOG.e("Mailbox %s address not sanitized", mailbox) LOG.d("normalize reverse alias") for contact in Contact.yield_per_query(): if normalize_reply_email(contact.reply_email) != contact.reply_email: LOG.e( "Contact %s reply email is not normalized %s", contact, contact.reply_email, ) LOG.d("clean domain name") for domain in CustomDomain.yield_per_query(): if domain.name and "\n" in domain.name: LOG.e("Domain %s name contain linebreak %s", domain, domain.name) LOG.d("migrate domain trash if needed") migrate_domain_trash() LOG.d("fix custom domain for alias") set_custom_domain_for_alias() LOG.d("check mailbox valid domain") check_mailbox_valid_domain() LOG.d( """check if there's an email that starts with "\u200f" (right-to-left mark (RLM))""" ) for contact in (Contact.yield_per_query().filter( Contact.website_email.startswith("\u200f")).all()): contact.website_email = contact.website_email.replace("\u200f", "") LOG.e("remove right-to-left mark (RLM) from %s", contact) Session.commit() LOG.d("Finish sanity check")
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 # allow any redirect_uri if the app isn't approved hostname, scheme = get_host_name_and_scheme(redirect_uri) if hostname != "localhost" and hostname != "127.0.0.1" and client.approved: # 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.d("user %s has already allowed client %s", current_user, client) user_info = client_user.get_user_info() # redirect user to the client page redirect_args = construct_redirect_args( client, client_user, nonce, redirect_uri, response_types, scope, state, ) fragment = get_fragment(response_mode, response_types) # construct redirect_uri with redirect_args return redirect( construct_url(redirect_uri, redirect_args, fragment)) 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() ] suffixes = get_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 not current_user.is_authenticated or not current_user.is_active: LOG.i( "Attempt to validate a OAUth allow request by an unauthenticated user" ) return redirect(url_for("auth.login", next=request.url)) if request.form.get("button") == "deny": LOG.d("User %s denies Client %s", current_user, client) final_redirect_uri = f"{redirect_uri}?error=deny&state={state}" return redirect(final_redirect_uri) LOG.d("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") alias_prefix = alias_prefix.strip().lower().replace(" ", "") if not check_alias_prefix(alias_prefix): flash( "Only lowercase letters, numbers, dashes (-), dots (.) and underscores (_) " "are currently supported for alias prefix. Cannot be more than 40 letters", "error", ) return redirect(request.url) # hypothesis: user will click on the button in the 600 secs try: alias_suffix = signer.unsign(signed_suffix, max_age=600).decode() except SignatureExpired: LOG.w("Alias creation time expired for %s", current_user) flash("Alias creation time is expired, please retry", "warning") return redirect(request.url) except Exception: LOG.w("Alias suffix is tampered, user %s", current_user) flash("Unknown error, refresh the page", "error") return redirect(request.url) 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) or DomainDeletedAlias.get_by(email=full_alias)): LOG.e("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, ) 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, ) 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 Session.flush() LOG.d("create client-user for client %s, user %s", client, current_user) redirect_args = construct_redirect_args(client, client_user, nonce, redirect_uri, response_types, scope, state) fragment = get_fragment(response_mode, response_types) # construct redirect_uri with redirect_args return redirect(construct_url(redirect_uri, redirect_args, fragment))
def custom_alias(): # check if user has not exceeded the alias quota if not current_user.can_create_new_alias(): LOG.d("%s can't create new 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() ] alias_suffixes = get_alias_suffixes(current_user) at_least_a_premium_domain = False for alias_suffix in alias_suffixes: if not alias_suffix.is_custom and alias_suffix.is_premium: at_least_a_premium_domain = True break alias_suffixes_with_signature = [ (alias_suffix, signer.sign(alias_suffix.serialize()).decode()) for alias_suffix in alias_suffixes ] mailboxes = current_user.mailboxes() if request.method == "POST": alias_prefix = request.form.get("prefix").strip().lower().replace( " ", "") signed_alias_suffix = request.form.get("signed-alias-suffix") mailbox_ids = request.form.getlist("mailboxes") alias_note = request.form.get("note") if not check_alias_prefix(alias_prefix): flash( "Only lowercase letters, numbers, dashes (-), dots (.) and underscores (_) " "are currently supported for alias prefix. Cannot be more than 40 letters", "error", ) return redirect(request.url) # 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(request.url) mailboxes.append(mailbox) if not mailboxes: flash("At least one mailbox must be selected", "error") return redirect(request.url) # hypothesis: user will click on the button in the 600 secs try: signed_alias_suffix_decoded = signer.unsign(signed_alias_suffix, max_age=600).decode() alias_suffix: AliasSuffix = AliasSuffix.deserialize( signed_alias_suffix_decoded) except SignatureExpired: LOG.w("Alias creation time expired for %s", current_user) flash("Alias creation time is expired, please retry", "warning") return redirect(request.url) except Exception: LOG.w("Alias suffix is tampered, user %s", current_user) flash("Unknown error, refresh the page", "error") return redirect(request.url) if verify_prefix_suffix(current_user, alias_prefix, alias_suffix.suffix): full_alias = alias_prefix + alias_suffix.suffix if ".." in full_alias: flash("Your alias can't contain 2 consecutive dots (..)", "error") return redirect(request.url) try: validate_email(full_alias, check_deliverability=False, allow_smtputf8=False) except EmailNotValidError as e: flash(str(e), "error") return redirect(request.url) general_error_msg = f"{full_alias} cannot be used" if Alias.get_by(email=full_alias): alias = Alias.get_by(email=full_alias) if alias.user_id == current_user.id: flash(f"You already have this alias {full_alias}", "error") else: flash(general_error_msg, "error") elif DomainDeletedAlias.get_by(email=full_alias): domain_deleted_alias: DomainDeletedAlias = DomainDeletedAlias.get_by( email=full_alias) custom_domain = domain_deleted_alias.domain if domain_deleted_alias.user_id == current_user.id: flash( f"You have deleted this alias before. You can restore it on " f"{custom_domain.domain} 'Deleted Alias' page", "error", ) else: # should never happen as user can only choose their domains LOG.e( "Deleted Alias %s does not belong to user %s", domain_deleted_alias, ) elif DeletedAlias.get_by(email=full_alias): flash(general_error_msg, "error") else: try: alias = Alias.create( user_id=current_user.id, email=full_alias, note=alias_note, mailbox_id=mailboxes[0].id, ) Session.flush() except IntegrityError: LOG.w("Alias %s already exists", full_alias) Session.rollback() flash("Unknown error, please retry", "error") return redirect(url_for("dashboard.custom_alias")) for i in range(1, len(mailboxes)): AliasMailbox.create( alias_id=alias.id, mailbox_id=mailboxes[i].id, ) 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, alias_suffixes_with_signature=alias_suffixes_with_signature, at_least_a_premium_domain=at_least_a_premium_domain, mailboxes=mailboxes, )