diff --git a/app/api/views/auth_login.py b/app/api/views/auth_login.py index 173bb5eb..c4bce079 100644 --- a/app/api/views/auth_login.py +++ b/app/api/views/auth_login.py @@ -54,11 +54,11 @@ def auth_login(): if user.enable_otp: s = Signer(FLASK_SECRET) ret["mfa_key"] = s.sign(str(user.id)) - ret["api_key"] = "" + ret["api_key"] = None else: api_key = ApiKey.create(user.id, device) db.session.commit() - ret["mfa_key"] = "" + ret["mfa_key"] = None ret["api_key"] = api_key.code return jsonify(**ret), 200 diff --git a/app/config.py b/app/config.py index 0d35ef52..6c0f4d36 100644 --- a/app/config.py +++ b/app/config.py @@ -166,3 +166,7 @@ MFA_USER_ID = "mfa_user_id" FLASK_PROFILER_PATH = os.environ.get("FLASK_PROFILER_PATH") FLASK_PROFILER_PASSWORD = os.environ.get("FLASK_PROFILER_PASSWORD") + + +# Job names +JOB_ONBOARDING_1 = "onboarding-1" diff --git a/app/models.py b/app/models.py index 0a5ef506..bc36729e 100644 --- a/app/models.py +++ b/app/models.py @@ -10,7 +10,13 @@ from sqlalchemy import text, desc from sqlalchemy_utils import ArrowType from app import s3 -from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN, URL, AVATAR_URL_EXPIRATION +from app.config import ( + EMAIL_DOMAIN, + MAX_NB_EMAIL_FREE_PLAN, + URL, + AVATAR_URL_EXPIRATION, + JOB_ONBOARDING_1, +) from app.email_utils import get_email_name from app.extensions import db from app.log import LOG @@ -144,6 +150,14 @@ class User(db.Model, ModelMixin, UserMixin): GenEmail.create_new(user.id, prefix="my-first-alias") db.session.flush() + # Schedule onboarding emails + Job.create( + name=JOB_ONBOARDING_1, + payload={"user_id": user.id}, + run_at=arrow.now().shift(days=1), + ) + db.session.flush() + return user def lifetime_or_active_subscription(self) -> bool: @@ -769,3 +783,17 @@ class Directory(db.Model, ModelMixin): def __repr__(self): return f"" + + +class Job(db.Model, ModelMixin): + """Used to schedule one-time job in the future""" + + name = db.Column(db.String(128), nullable=False) + payload = db.Column(db.JSON) + + # whether the job has been taken by the job runner + taken = db.Column(db.Boolean, default=False, nullable=False) + run_at = db.Column(ArrowType) + + def __repr__(self): + return f"" diff --git a/job_runner.py b/job_runner.py new file mode 100644 index 00000000..0c412112 --- /dev/null +++ b/job_runner.py @@ -0,0 +1,79 @@ +""" +Run scheduled jobs. +Not meant for running job at precise time (+- 1h) +""" +import time + +import arrow + +from app.config import JOB_ONBOARDING_1 +from app.email_utils import ( + send_email, + render, +) +from app.extensions import db +from app.log import LOG +from app.models import ( + User, + Job, +) +from server import create_app + + +# fix the database connection leak issue +# use this method instead of create_app +def new_app(): + app = create_app() + + @app.teardown_appcontext + def shutdown_session(response_or_exc): + # same as shutdown_session() in flask-sqlalchemy but this is not enough + db.session.remove() + + # dispose the engine too + db.engine.dispose() + + return app + + +def onboarding_1(user): + if not user.notification: + LOG.d("User %s disable notification setting", user) + return + + send_email( + user.email, + f"Do you know you can send emails to anyone from your alias?", + render("com/onboarding-1.txt", user=user), + render("com/onboarding-1.html", user=user), + ) + + +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) diff --git a/migrations/versions/2020_020313_9c976df9b9c4_.py b/migrations/versions/2020_020313_9c976df9b9c4_.py new file mode 100644 index 00000000..0badff55 --- /dev/null +++ b/migrations/versions/2020_020313_9c976df9b9c4_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 9c976df9b9c4 +Revises: 7c39ba4ec38d +Create Date: 2020-02-03 13:08:29.049797 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9c976df9b9c4' +down_revision = '7c39ba4ec38d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('job', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('payload', sa.JSON(), nullable=True), + sa.Column('taken', sa.Boolean(), nullable=False), + sa.Column('run_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('job') + # ### end Alembic commands ### diff --git a/templates/emails/com/onboarding-1.html b/templates/emails/com/onboarding-1.html new file mode 100644 index 00000000..7f498ce8 --- /dev/null +++ b/templates/emails/com/onboarding-1.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block content %} + {{ render_text("This email is sent to " + user.email + " and is part of our onboarding series.") }} + + {{ render_text('Unsubscribe from our emails on https://app.simplelogin.io/dashboard/setting#notification') }} + + {{ render_text("
") }} + + {{ render_text("Hi " + user.name) }} + {{ render_text("Do you know you can send emails to anyone from your alias?") }} + {{ render_text("This Youtube video quickly walks you through the steps:") }} + + {{ render_text('https://youtu.be/VsypF-DBaow') }} + + {{ render_text("Here are the steps:") }} + {{ render_text("1. First click Send Email on your alias you want to send email from.") }} + {{ render_text("2. Enter your contact email, this will generate a reverse-alias.") }} + {{ render_text("3. Use this reverse-alias instead of your contact email.") }} + {{ render_text("4. Your contact will receive this email from your alias.") }} + + {{ render_text("As usual, let me know if you have any question by replying to this email.") }} + +{% endblock %} + diff --git a/templates/emails/com/onboarding-1.txt b/templates/emails/com/onboarding-1.txt new file mode 100644 index 00000000..01ec9564 --- /dev/null +++ b/templates/emails/com/onboarding-1.txt @@ -0,0 +1,21 @@ +This email is sent to {{ user.email }} and is part of our onboarding series. +Unsubscribe from our emails on https://app.simplelogin.io/dashboard/setting#notification +---------------- + +Hi {{user.name}} + +Do you know you can send an email to anyone from your alias? +This below Youtube video walks you quickly through the steps: + +https://youtu.be/VsypF-DBaow + +Here are the steps: +1. First click "Send Email" on your alias you want to send email from +2. Enter your contact email, this will generate an "reverse-alias" +3. Use this reverse-alias instead of your contact email +4. Your contact will receive this email from your alias. + +As usual, let me know if you have any question by replying to this email. + +Best regards, +Son - SimpleLogin founder. \ No newline at end of file diff --git a/tests/api/test_auth_login.py b/tests/api/test_auth_login.py index 0dbfb4a3..59f89024 100644 --- a/tests/api/test_auth_login.py +++ b/tests/api/test_auth_login.py @@ -16,7 +16,7 @@ def test_auth_login_success_mfa_disabled(flask_client): assert r.status_code == 200 assert r.json["api_key"] assert r.json["mfa_enabled"] == False - assert r.json["mfa_key"] == "" + assert r.json["mfa_key"] is None assert r.json["name"] == "Test User" @@ -36,7 +36,7 @@ def test_auth_login_success_mfa_enabled(flask_client): ) assert r.status_code == 200 - assert r.json["api_key"] == "" + assert r.json["api_key"] is None assert r.json["mfa_enabled"] == True assert r.json["mfa_key"] assert r.json["name"] == "Test User"