Merge pull request #62 from simple-login/trial

Support Trial period
This commit is contained in:
Son Nguyen Kim 2020-01-30 15:09:39 +07:00 committed by GitHub
commit 2830949764
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 222 additions and 83 deletions

View file

@ -17,19 +17,22 @@ jobs:
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Test with pytest
run: |
pip install pytest
pytest
- name: Test formatting
run: |
pip install black
black --check .
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Test with pytest
run: |
pip install pytest
pytest
- name: Publish to Docker Registry
uses: elgohr/Publish-Docker-Github-Action@master
with:

View file

@ -41,7 +41,7 @@ def activate():
user = activation_code.user
user.activated = True
login_user(user)
email_utils.send_welcome_email(user.email, user.name)
email_utils.send_welcome_email(user)
# activation code is to be used only once
ActivationCode.delete(activation_code.id)

View file

@ -130,7 +130,7 @@ def facebook_callback():
db.session.commit()
login_user(user)
email_utils.send_welcome_email(user.email, user.name)
email_utils.send_welcome_email(user)
flash(f"Welcome to SimpleLogin {user.name}!", "success")

View file

@ -101,7 +101,7 @@ def github_callback():
)
db.session.commit()
login_user(user)
email_utils.send_welcome_email(user.email, user.name)
email_utils.send_welcome_email(user)
flash(f"Welcome to SimpleLogin {user.name}!", "success")

View file

@ -115,7 +115,7 @@ def google_callback():
db.session.commit()
login_user(user)
email_utils.send_welcome_email(user.email, user.name)
email_utils.send_welcome_email(user)
flash(f"Welcome to SimpleLogin {user.name}!", "success")

View file

@ -13,7 +13,7 @@
<h1> Billing </h1>
<p>
You are on the <b>{{ current_user.plan_name() }}</b> plan. Thank you very much for supporting SimpleLogin. 🙌
You are on the <b>{{ current_user.get_subscription().plan_name() }}</b> plan. Thank you very much for supporting SimpleLogin. 🙌
</p>
{% if sub.cancelled %}

View file

@ -20,7 +20,10 @@ def 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")
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()]

View file

@ -24,7 +24,7 @@ class CouponForm(FlaskForm):
@login_required
def lifetime_licence():
# sanity check: make sure this page is only for free user
if current_user.is_premium():
if current_user.lifetime_or_active_subscription():
flash("You are already a premium user", "warning")
return redirect(url_for("dashboard.index"))

View file

@ -13,8 +13,8 @@ from app.dashboard.base import dashboard_bp
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
@login_required
def pricing():
# sanity check: make sure this page is only for free user
if current_user.is_premium():
# sanity check: make sure this page is only for free or trial user
if not current_user.should_upgrade():
flash("You are already a premium user", "warning")
return redirect(url_for("dashboard.index"))

View file

@ -29,12 +29,21 @@ def _render(template_name, **kwargs) -> str:
return template.render(**kwargs)
def send_welcome_email(email, name):
def send_welcome_email(user):
send_email(
email,
f"Welcome to SimpleLogin {name}!",
_render("welcome.txt", name=name),
_render("welcome.html", name=name),
user.email,
f"Welcome to SimpleLogin {user.name}",
_render("welcome.txt", name=user.name, user=user),
_render("welcome.html", name=user.name, user=user),
)
def send_trial_end_soon_email(user):
send_email(
user.email,
f"Your trial will end soon {user.name}",
_render("trial-end.txt", name=user.name, user=user),
_render("trial-end.html", name=user.name, user=user),
)

View file

@ -122,6 +122,11 @@ class User(db.Model, ModelMixin, UserMixin):
# some users could have lifetime premium
lifetime = db.Column(db.Boolean, default=False, nullable=False, server_default="0")
# user can use all premium features until this date
trial_end = db.Column(
ArrowType, default=lambda: arrow.now().shift(days=7, hours=1), nullable=True
)
profile_picture = db.relationship(File)
@classmethod
@ -141,11 +146,8 @@ class User(db.Model, ModelMixin, UserMixin):
return user
def should_upgrade(self):
return not self.is_premium()
def is_premium(self):
"""user is premium if they have a active subscription"""
def lifetime_or_active_subscription(self) -> bool:
"""True if user has lifetime licence or active subscription"""
if self.lifetime:
return True
@ -155,7 +157,35 @@ class User(db.Model, ModelMixin, UserMixin):
return False
def can_create_new_alias(self):
def in_trial(self):
"""return True if user does not have lifetime licence or an active subscription AND is in trial period"""
if self.lifetime_or_active_subscription():
return False
if self.trial_end and arrow.now() < self.trial_end:
return True
return False
def should_upgrade(self):
return not self.lifetime_or_active_subscription()
def is_premium(self) -> bool:
"""
user is premium if they:
- have a lifetime deal or
- in trial period or
- active subscription
"""
if self.lifetime_or_active_subscription():
return True
if self.trial_end and arrow.now() < self.trial_end:
return True
return False
def can_create_new_alias(self) -> bool:
if self.is_premium():
return True
@ -197,7 +227,6 @@ class User(db.Model, ModelMixin, UserMixin):
def suggested_names(self) -> (str, [str]):
"""return suggested name and other name choices """
other_name = convert_to_id(self.name)
return self.name, [other_name, "Anonymous", "whoami"]
@ -206,17 +235,6 @@ class User(db.Model, ModelMixin, UserMixin):
names = self.name.split(" ")
return "".join([n[0].upper() for n in names if n])
def plan_name(self) -> str:
if self.is_premium():
sub = self.get_subscription()
if sub.plan == PlanEnum.monthly:
return "Monthly ($2.99/month)"
else:
return "Yearly ($29.99/year)"
else:
return "Free Plan"
def get_subscription(self):
"""return *active* subscription
TODO: support user unsubscribe and re-subscribe
@ -638,6 +656,12 @@ class Subscription(db.Model, ModelMixin):
user = db.relationship(User)
def plan_name(self):
if self.plan == PlanEnum.monthly:
return "Monthly ($2.99/month)"
else:
return "Yearly ($29.99/year)"
class DeletedAlias(db.Model, ModelMixin):
"""Store all deleted alias to make sure they are NOT reused"""

10
cron.py
View file

@ -1,7 +1,7 @@
import arrow
from app.config import IGNORED_EMAILS, ADMIN_EMAIL
from app.email_utils import send_email
from app.email_utils import send_email, send_trial_end_soon_email
from app.extensions import db
from app.log import LOG
from app.models import (
@ -16,6 +16,13 @@ from app.models import (
from server import create_app
def send_trial_end_soon():
for user in User.query.filter(User.trial_end.isnot(None)).all():
if arrow.now().shift(days=3) > user.trial_end >= arrow.now().shift(days=2):
LOG.d("Send trial end email to user %s", user)
send_trial_end_soon_email(user)
def stats():
"""send admin stats everyday"""
if not ADMIN_EMAIL:
@ -103,3 +110,4 @@ if __name__ == "__main__":
with app.app_context():
stats()
send_trial_end_soon()

View file

@ -54,7 +54,14 @@ from app.email_utils import (
)
from app.extensions import db
from app.log import LOG
from app.models import GenEmail, ForwardEmail, ForwardEmailLog, CustomDomain, Directory
from app.models import (
GenEmail,
ForwardEmail,
ForwardEmailLog,
CustomDomain,
Directory,
User,
)
from app.utils import random_string
from server import create_app
@ -138,8 +145,8 @@ class MailHandler:
# Only premium user can use the directory feature
if directory:
dir_user = directory.user
if dir_user.is_premium():
dir_user: User = directory.user
if dir_user.can_create_new_alias():
LOG.d("create alias %s for directory %s", alias, directory)
on_the_fly = True
@ -166,8 +173,8 @@ class MailHandler:
# 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():
domain_user: User = custom_domain.user
if domain_user.can_create_new_alias():
LOG.d("create alias %s for domain %s", alias, custom_domain)
on_the_fly = True

View file

@ -1,3 +1,7 @@
meo
cat
chat
chat
alo
hey
yeah
yes

View file

@ -0,0 +1,29 @@
"""empty message
Revision ID: 7c39ba4ec38d
Revises: ba6f13ccbabb
Create Date: 2020-01-30 10:10:01.245257
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7c39ba4ec38d'
down_revision = 'ba6f13ccbabb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('trial_end', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'trial_end')
# ### end Alembic commands ###

View file

@ -381,7 +381,7 @@ def setup_paddle_callback(app: Flask):
elif request.form.get("alert_name") == "subscription_cancelled":
subscription_id = request.form.get("subscription_id")
LOG.debug("Cancel subscription %s", subscription_id)
LOG.error("Cancel subscription %s", subscription_id)
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:

View file

@ -36,6 +36,7 @@ app = create_app()
with app.app_context():
# to test email template
# with open("/tmp/email.html", "w") as f:
# f.write(_render("welcome.html", name="John Wick"))
# user = User.get(1)
# f.write(_render("welcome.html", user=user, name=user.name))
embed()

View file

@ -100,8 +100,8 @@ a, a:hover {
padding-top: 25px;
color: #000000;
font-family: sans-serif;" class="paragraph">
Cheers. <br>
<a href="https://twitter.com/nguyenkims">Son</a> - SimpleLogin founder.
Regards, <br>
Son - SimpleLogin founder.
</td>
</tr>
@ -114,19 +114,6 @@ a, a:hover {
</td>
</tr>
<!-- PARAGRAPH -->
<!-- Set text color and font family ("sans-serif" or "Georgia, serif"). Duplicate all text styles in links, including line-height -->
<tr>
<td align="center" valign="top" style="border-collapse: collapse; border-spacing: 0; margin: 0; padding: 0; padding-left: 6.25%; padding-right: 6.25%; width: 87.5%; font-size: 17px; font-weight: 400; line-height: 160%;
padding-top: 20px;
padding-bottom: 25px;
color: #000000;
font-family: sans-serif;" class="paragraph">
This email ends up in Spam/Junk? <br>
Here's how to <a href="https://simplelogin.io/help">whitelist SimpleLogin</a>
</td>
</tr>
<!-- End of WRAPPER -->
</table>
@ -146,7 +133,7 @@ a, a:hover {
<!-- ICON 1 -->
<td align="center" valign="middle" style="margin: 0; padding: 0; padding-left: 10px; padding-right: 10px; border-collapse: collapse; border-spacing: 0;"><a target="_blank"
href="https://github.com/simple-login/"
href="https://github.com/simple-login/app"
style="text-decoration: none;"><img border="0" vspace="0" hspace="0" style="padding: 0; margin: 0; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; border: none; display: inline-block;
color: #000000;"
alt="F" title="Github"

View file

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
{% if name %}
{{ render_text("Hi " + name + ",") }}
{% else %}
{{ render_text("Hi,") }}
{% endif %}
{{ render_text("Your trial will end " + user.trial_end.humanize() + ".") }}
{{ render_text("When the trial ends:") }}
{{ render_text("- All aliases/domains/directories you have created are <b>kept</b> and continue working.") }}
{{ render_text("- You cannot create new aliases if you exceed the free plan limit, i.e. have more than 5 aliases.") }}
{{ render_text("- As features like <b>catch-all</b> or <b>directory</b> allow you to create aliases on-the-fly, those aliases cannot be automatically created if you have more than 5 aliases.") }}
{{ render_text("- You cannot add new domain or directory.") }}
{{ render_text('You can <a href="https://app.simplelogin.io/dashboard/pricing">upgrade</a> today to continue using all these Premium features (and much more coming).') }}
{{ render_text('Let me know if you need to extend your trial period.') }}
{% endblock %}

View file

@ -0,0 +1,17 @@
Hi {{name}}
Your trial will end {{ user.trial_end.humanize() }}.
When the trial ends:
- All aliases/domains/directories you have created are kept and continue working.
- You cannot create new aliases if you exceed the free plan limit, i.e. have more than 5 aliases.
- As features like "catch-all" or "directory" allow you to create aliases on-the-fly, those aliases cannot be automatically created if you have more than 5 aliases.
- You cannot add new domain or directory.
You can upgrade today to continue using all these Premium features (and much more coming).
Let me know if you need to extend your trial period.
Best,
Son - SimpleLogin founder.

View file

@ -11,12 +11,16 @@
{{ render_text('To better secure your account, I recommend enabling Multi-Factor Authentication (MFA) on your <a href="https://app.simplelogin.io/dashboard/setting">Setting page</a>.') }}
{{ render_text('If you use Chrome or Firefox, SimpleLogin extension could be handy to quickly create aliases. Chrome extension can be installed on <a href="https://chrome.google.com/webstore/detail/simplelogin-your-anti-spa/dphilobhebphkdjbpfohgikllaljmgbn">Chrome Store</a> and Firefox on <a href="https://addons.mozilla.org/en-GB/firefox/addon/simplelogin/">Firefox Store</a>.') }}
{{ render_text('SimpleLogin browser extension could be handy to quickly manage aliases. Chrome (or other Chromium-based browsers like Brave or Vivaldi) extension can be installed on <a href="https://chrome.google.com/webstore/detail/simplelogin-your-anti-spa/dphilobhebphkdjbpfohgikllaljmgbn">Chrome Store</a>, Firefox on <a href="https://addons.mozilla.org/en-GB/firefox/addon/simplelogin/">Firefox Store</a> and Safari on <a href="https://apps.apple.com/us/app/simplelogin/id1494051017?mt=12&fbclid=IwAR0M0nnEKgoieMkmx91TSXrtcScj7GouqRxGgXeJz2un_5ydhIKlbAI79Io">AppStore</a>.') }}
{{ render_text('If you have a domain, for example for your business or your project, you can import your domain into SimpleLogin
and create your business emails backed by your personal email. This is cheaper and more convenient than buying a GSuite account. By the way, all our business emails are actually aliases :).') }}
and create your <b>business emails</b> using email alias. This is cheaper and more convenient than buying a dedicated solution like GSuite. By the way, all our business emails are actually aliases.') }}
{{ render_text('Importing domain is only available for Premium plan though, shoot me an email by replying to this email if you need a trial period.') }}
{% if user.in_trial() %}
{{ render_text('You can use all premium features like <em>custom domain</em> or <em>alias directory</em> during the <b>trial period</b>. Your trial will end ' + user.trial_end.humanize() + ".") }}
{% endif %}
{{ render_text('If there\'s anything that\'s bugging you, even the smallest of issues that could be done better, I want to hear about it - so hit the reply button.') }}
{% endblock %}

View file

@ -5,16 +5,27 @@ My name is Son. Im the founder of SimpleLogin and I wanted to be the first to
To better secure your account, I recommend enabling Multi-Factor Authentication (MFA) on your setting page at
https://app.simplelogin.io/dashboard/setting
If you use Chrome or Firefox, SimpleLogin extension could be quite handy to quickly create aliases.
You can install Chrome extension on
SimpleLogin browser extension could be handy to quickly manage aliases.
Chrome (or other Chromium-based browsers like Brave or Vivaldi) extension can be installed on:
https://chrome.google.com/webstore/detail/simplelogin-your-anti-spa/dphilobhebphkdjbpfohgikllaljmgbn
and Firefox on
Firefox on
https://addons.mozilla.org/en-GB/firefox/addon/simplelogin/
and Safari on
https://apps.apple.com/us/app/simplelogin/id1494051017?mt=12&fbclid=IwAR0M0nnEKgoieMkmx91TSXrtcScj7GouqRxGgXeJz2un_5ydhIKlbAI79Io
If you have a domain, for example for your business or your project, you can import your domain into SimpleLogin
and create your business emails backed by your personal email! By the way, all our business emails are actually aliases 🤫.
Importing domain is only available for Premium plan though, shoot me an email if you need a trial period.
and create your business emails backed by your personal email! By the way, all our business emails are actually aliases.
{% if user.in_trial() %}
You can use all premium features like custom domain or alias directory during the trial period.
Your trial will end {{ user.trial_end.humanize() }}.
{% endif %}
If there's anything that's bugging you, even the smallest of issues that could be done better, I want to hear about it - so hit the reply button.
Thanks.
Son - SimpleLogin founder.

View file

@ -25,15 +25,16 @@
{{ current_user.name }}
</span>
{% if current_user.is_premium() %}
{% if current_user.in_trial() %}
<small class="text-success d-block mt-1">Trial ends {{ current_user.trial_end|dt }}</small>
{% elif current_user.lifetime_or_active_subscription() %}
<small class="text-success d-block mt-1">Premium</small>
{% endif %}
</span>
</a>
<div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow">
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="dropdown-icon fe fe-log-out"></i> Sign out
</a>

View file

@ -32,6 +32,7 @@ def test_out_of_quota(flask_client):
user = User.create(
email="a@b.c", password="password", name="Test User", activated=True
)
user.trial_end = None
db.session.commit()
# create api_key

View file

@ -28,17 +28,16 @@ def test_out_of_quota(flask_client):
user = User.create(
email="a@b.c", password="password", name="Test User", activated=True
)
user.trial_end = None
db.session.commit()
# create api_key
api_key = ApiKey.create(user.id, "for test")
db.session.commit()
# create 3 random alias to run out of quota
# create MAX_NB_EMAIL_FREE_PLAN random alias to run out of quota
for _ in range(MAX_NB_EMAIL_FREE_PLAN):
GenEmail.create_new(user.id, prefix="test1")
GenEmail.create_new(user.id, prefix="test2")
GenEmail.create_new(user.id, prefix="test3")
r = flask_client.post(
url_for("api.new_random_alias", hostname="www.test.com"),

View file

@ -4,7 +4,7 @@ from app.extensions import db
from app.models import User, ApiKey, AliasUsedOn, GenEmail
def test_success(flask_client):
def test_user_in_trial(flask_client):
user = User.create(
email="a@b.c", password="password", name="Test User", activated=True
)
@ -19,7 +19,7 @@ def test_success(flask_client):
)
assert r.status_code == 200
assert r.json == {"is_premium": False, "name": "Test User"}
assert r.json == {"is_premium": True, "name": "Test User"}
def test_wrong_api_key(flask_client):

View file

@ -28,10 +28,16 @@ def test_profile_picture_url(flask_client):
assert user.profile_picture_url() == "http://sl.test/static/default-avatar.png"
def test_suggested_emails_for_user_who_cannot_create_new_email(flask_client):
def test_suggested_emails_for_user_who_cannot_create_new_alias(flask_client):
# make sure user is not in trial
user = User.create(
email="a@b.c", password="password", name="Test User", activated=True
email="a@b.c",
password="password",
name="Test User",
activated=True,
trial_end=None,
)
db.session.commit()
# make sure user runs out of quota to create new email