diff --git a/Assets/WebsiteAssets/css/site.css b/Assets/WebsiteAssets/css/site.css
index 6cc76ea37..ee7ed949f 100644
--- a/Assets/WebsiteAssets/css/site.css
+++ b/Assets/WebsiteAssets/css/site.css
@@ -886,6 +886,10 @@ footer .right-links a {
margin-bottom: 1em;
}
+.plan-group {
+ justify-content: center;
+}
+
.plan-group .plan-price-yearly-per-year {
display: flex;
justify-content: flex-end;
diff --git a/Assets/WebsiteAssets/templates/partials/plan.mustache b/Assets/WebsiteAssets/templates/partials/plan.mustache
index afd4ba126..31dcbe9e6 100644
--- a/Assets/WebsiteAssets/templates/partials/plan.mustache
+++ b/Assets/WebsiteAssets/templates/partials/plan.mustache
@@ -29,7 +29,7 @@
{{/featuresOff}}
@@ -49,11 +55,31 @@
diff --git a/packages/server/public/js/stripe_utils.js b/packages/server/public/js/stripe_utils.js
new file mode 100644
index 000000000..ea55390c8
--- /dev/null
+++ b/packages/server/public/js/stripe_utils.js
@@ -0,0 +1,43 @@
+// function stripeConfig() {
+// if (!joplin || !joplin.stripeConfig) throw new Error('Stripe config is not set');
+// return joplin.stripeConfig;
+// }
+
+// function newStripe() {
+// return Stripe(stripeConfig().publishableKey);
+// }
+
+// async function createStripeCheckoutSession(priceId) {
+// const urlQuery = new URLSearchParams(location.search);
+// const coupon = urlQuery.get('coupon') || '';
+
+// console.info('Creating Stripe session for price:', priceId, 'Coupon:', coupon);
+
+// const result = await fetch(`${stripeConfig().webhookBaseUrl}/stripe/createCheckoutSession`, {
+// method: 'POST',
+// headers: {
+// 'Content-Type': 'application/json',
+// },
+// body: JSON.stringify({
+// priceId: priceId,
+// coupon: coupon,
+// }),
+// });
+
+// if (!result.ok) {
+// console.error('Could not create Stripe checkout session', await result.text());
+// alert('The checkout session could not be created. Please contact support@joplincloud.com for support.');
+// } else {
+// return result.json();
+// }
+// }
+
+// async function startStripeCheckout(priceId) {
+// const data = await createStripeCheckoutSession(stripeId);
+
+// const result = await stripe.redirectToCheckout({
+// sessionId: data.sessionId,
+// });
+
+// console.info('Redirected to checkout', result);
+// }
diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts
index a25a51d4a..5702670c5 100644
--- a/packages/server/src/config.ts
+++ b/packages/server/src/config.ts
@@ -102,6 +102,7 @@ function mailerConfigFromEnv(env: EnvVariables): MailerConfig {
function stripeConfigFromEnv(publicConfig: StripePublicConfig, env: EnvVariables): StripeConfig {
return {
...publicConfig,
+ enabled: !!env.STRIPE_SECRET_KEY,
secretKey: env.STRIPE_SECRET_KEY || '',
webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
};
diff --git a/packages/server/src/models/UserModel.test.ts b/packages/server/src/models/UserModel.test.ts
index 9557e5668..8a7695726 100644
--- a/packages/server/src/models/UserModel.test.ts
+++ b/packages/server/src/models/UserModel.test.ts
@@ -1,6 +1,8 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem } from '../utils/testing/testUtils';
import { EmailSender, User } from '../db';
import { ErrorUnprocessableEntity } from '../utils/errors';
+import { betaUserDateRange, stripeConfig } from '../utils/stripe';
+import { AccountType } from './UserModel';
describe('UserModel', function() {
@@ -86,4 +88,54 @@ describe('UserModel', function() {
expect(email.error).toBe('');
});
+ test('should send a beta reminder email', async function() {
+ stripeConfig().enabled = true;
+ const { user: user1 } = await createUserAndSession(1, false, { email: 'toto@example.com' });
+ const range = betaUserDateRange();
+
+ await models().user().save({
+ id: user1.id,
+ created_time: range[0],
+ account_type: AccountType.Pro,
+ });
+
+ Date.now = jest.fn(() => range[0] + 6912000 * 1000); // 80 days later
+
+ await models().user().handleBetaUserEmails();
+
+ expect((await models().email().all()).length).toBe(2);
+
+ {
+ const email = (await models().email().all()).pop();
+ expect(email.recipient_email).toBe('toto@example.com');
+ expect(email.subject.indexOf('10 days') > 0).toBe(true);
+ expect(email.body.indexOf('10 days') > 0).toBe(true);
+ expect(email.body.indexOf('toto%40example.com') > 0).toBe(true);
+ expect(email.body.indexOf('account_type=2') > 0).toBe(true);
+ }
+
+ await models().user().handleBetaUserEmails();
+
+ // It should not send a second email
+ expect((await models().email().all()).length).toBe(2);
+
+ Date.now = jest.fn(() => range[0] + 7603200 * 1000); // 88 days later
+
+ await models().user().handleBetaUserEmails();
+
+ expect((await models().email().all()).length).toBe(3);
+
+ {
+ const email = (await models().email().all()).pop();
+ expect(email.subject.indexOf('2 days') > 0).toBe(true);
+ expect(email.body.indexOf('2 days') > 0).toBe(true);
+ }
+
+ await models().user().handleBetaUserEmails();
+
+ expect((await models().email().all()).length).toBe(3);
+
+ stripeConfig().enabled = false;
+ });
+
});
diff --git a/packages/server/src/models/UserModel.ts b/packages/server/src/models/UserModel.ts
index ebbf0f7eb..c7e97ee63 100644
--- a/packages/server/src/models/UserModel.ts
+++ b/packages/server/src/models/UserModel.ts
@@ -12,6 +12,15 @@ import { confirmUrl, resetPasswordUrl } from '../utils/urlUtils';
import { checkRepeatPassword, CheckRepeatPasswordInput } from '../routes/index/users';
import accountConfirmationTemplate from '../views/emails/accountConfirmationTemplate';
import resetPasswordTemplate from '../views/emails/resetPasswordTemplate';
+import { betaStartSubUrl, betaUserDateRange, betaUserTrialPeriodDays, isBetaUser, stripeConfig } from '../utils/stripe';
+import endOfBetaTemplate from '../views/emails/endOfBetaTemplate';
+
+interface UserEmailDetails {
+ sender_id: EmailSender;
+ recipient_id: Uuid;
+ recipient_email: string;
+ recipient_name: string;
+}
export enum AccountType {
Default = 0,
@@ -261,16 +270,22 @@ export default class UserModel extends BaseModel
{
await this.save({ id: user.id, email_confirmed: 1 });
}
+ private userEmailDetails(user: User): UserEmailDetails {
+ return {
+ sender_id: EmailSender.NoReply,
+ recipient_id: user.id,
+ recipient_email: user.email,
+ recipient_name: user.full_name || '',
+ };
+ }
+
public async sendAccountConfirmationEmail(user: User) {
const validationToken = await this.models().token().generate(user.id);
const url = encodeURI(confirmUrl(user.id, validationToken));
await this.models().email().push({
...accountConfirmationTemplate({ url }),
- sender_id: EmailSender.NoReply,
- recipient_id: user.id,
- recipient_email: user.email,
- recipient_name: user.full_name || '',
+ ...this.userEmailDetails(user),
});
}
@@ -283,10 +298,7 @@ export default class UserModel extends BaseModel {
await this.models().email().push({
...resetPasswordTemplate({ url }),
- sender_id: EmailSender.NoReply,
- recipient_id: user.id,
- recipient_email: user.email,
- recipient_name: user.full_name || '',
+ ...this.userEmailDetails(user),
});
}
@@ -297,6 +309,44 @@ export default class UserModel extends BaseModel {
await this.models().token().deleteByValue(user.id, token);
}
+ public async handleBetaUserEmails() {
+ if (!stripeConfig().enabled) return;
+
+ const range = betaUserDateRange();
+
+ const betaUsers = await this
+ .db('users')
+ .select(['id', 'email', 'full_name', 'account_type', 'created_time'])
+ .where('created_time', '>=', range[0])
+ .andWhere('created_time', '<=', range[1]);
+
+ const reminderIntervals = [14, 3];
+
+ for (const user of betaUsers) {
+ if (!(await isBetaUser(this.models(), user.id))) continue;
+
+ const remainingDays = betaUserTrialPeriodDays(user.created_time, 0, 0);
+
+ for (const reminderInterval of reminderIntervals) {
+ if (remainingDays <= reminderInterval) {
+ const sentKey = `betaUser::emailSent::${reminderInterval}::${user.id}`;
+
+ if (!(await this.models().keyValue().value(sentKey))) {
+ await this.models().email().push({
+ ...endOfBetaTemplate({
+ expireDays: remainingDays,
+ startSubUrl: betaStartSubUrl(user.email, user.account_type),
+ }),
+ ...this.userEmailDetails(user),
+ });
+
+ await this.models().keyValue().setValue(sentKey, 1);
+ }
+ }
+ }
+ }
+ }
+
private formatValues(user: User): User {
const output: User = { ...user };
if ('email' in output) output.email = user.email.trim().toLowerCase();
diff --git a/packages/server/src/routes/index/home.ts b/packages/server/src/routes/index/home.ts
index f660591cd..a0dd15218 100644
--- a/packages/server/src/routes/index/home.ts
+++ b/packages/server/src/routes/index/home.ts
@@ -10,6 +10,7 @@ import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSize
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
import config from '../../config';
import { escapeHtml } from '../../utils/htmlUtils';
+import { betaStartSubUrl, betaUserTrialPeriodDays, isBetaUser } from '../../utils/stripe';
const router: Router = new Router(RouteType.Web);
@@ -69,6 +70,9 @@ router.get('home', async (_path: SubPath, ctx: AppContext) => {
},
],
showUpgradeProButton: subscription && user.account_type === AccountType.Basic,
+ showBetaMessage: await isBetaUser(ctx.joplin.models, user.id),
+ betaExpiredDays: betaUserTrialPeriodDays(user.created_time, 0, 0),
+ betaStartSubUrl: betaStartSubUrl(user.email, user.account_type),
setupMessageHtml: setupMessageHtml(),
};
diff --git a/packages/server/src/routes/index/stripe.test.ts b/packages/server/src/routes/index/stripe.test.ts
index 4d09f9913..058572117 100644
--- a/packages/server/src/routes/index/stripe.test.ts
+++ b/packages/server/src/routes/index/stripe.test.ts
@@ -1,6 +1,6 @@
import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud';
import { AccountType } from '../../models/UserModel';
-import { initStripe, stripeConfig } from '../../utils/stripe';
+import { betaUserTrialPeriodDays, initStripe, isBetaUser, stripeConfig } from '../../utils/stripe';
import { beforeAllDb, afterAllTests, beforeEachDb, models, koaAppContext, expectNotThrow } from '../../utils/testing/testUtils';
import uuidgen from '../../utils/uuidgen';
import { postHandlers } from './stripe';
@@ -33,6 +33,7 @@ describe('index/stripe', function() {
beforeAll(async () => {
await beforeAllDb('index/stripe');
+ stripeConfig().enabled = true;
});
afterAll(async () => {
@@ -66,4 +67,28 @@ describe('index/stripe', function() {
await expectNotThrow(async () => createUserViaSubscription('toto@example.com', 'evt_1'));
});
+ test('should check if it is a beta user', async function() {
+ const user1 = await models().user().save({ email: 'toto@example.com', password: uuidgen() });
+ const user2 = await models().user().save({ email: 'tutu@example.com', password: uuidgen() });
+ await models().user().save({ id: user2.id, created_time: 1624441295775 });
+
+ expect(await isBetaUser(models(), user1.id)).toBe(false);
+ expect(await isBetaUser(models(), user2.id)).toBe(true);
+
+ await models().subscription().save({
+ user_id: user2.id,
+ stripe_user_id: 'usr_111',
+ stripe_subscription_id: 'sub_111',
+ last_payment_time: Date.now(),
+ });
+
+ expect(await isBetaUser(models(), user2.id)).toBe(false);
+ });
+
+ test('should find out beta user trial end date', async function() {
+ const fromDateTime = 1627901594842; // Mon Aug 02 2021 10:53:14 GMT+0000
+ expect(betaUserTrialPeriodDays(1624441295775, fromDateTime)).toBe(50); // Wed Jun 23 2021 09:41:35 GMT+0000
+ expect(betaUserTrialPeriodDays(1614682158000, fromDateTime)).toBe(7); // Tue Mar 02 2021 10:49:18 GMT+0000
+ });
+
});
diff --git a/packages/server/src/routes/index/stripe.ts b/packages/server/src/routes/index/stripe.ts
index f7b0d6118..6e5562a08 100644
--- a/packages/server/src/routes/index/stripe.ts
+++ b/packages/server/src/routes/index/stripe.ts
@@ -9,7 +9,7 @@ import { Stripe } from 'stripe';
import Logger from '@joplin/lib/Logger';
import getRawBody = require('raw-body');
import { AccountType } from '../../models/UserModel';
-import { initStripe, priceIdToAccountType, stripeConfig } from '../../utils/stripe';
+import { betaUserTrialPeriodDays, initStripe, isBetaUser, priceIdToAccountType, stripeConfig } from '../../utils/stripe';
import { Subscription } from '../../db';
import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud';
@@ -34,6 +34,7 @@ async function stripeEvent(stripe: Stripe, req: any): Promise {
interface CreateCheckoutSessionFields {
priceId: string;
coupon: string;
+ email: string;
}
type StripeRouteHandler = (stripe: Stripe, path: SubPath, ctx: AppContext)=> Promise;
@@ -63,6 +64,8 @@ export const postHandlers: PostHandlers = {
const checkoutSession: Stripe.Checkout.SessionCreateParams = {
mode: 'subscription',
+ // Stripe supports many payment method types but it seems only
+ // "card" is supported for recurring subscriptions.
payment_method_types: ['card'],
line_items: [
{
@@ -89,6 +92,20 @@ export const postHandlers: PostHandlers = {
];
}
+ if (fields.email) {
+ checkoutSession.customer_email = fields.email.trim();
+
+ // If it's a Beta user, we set the trial end period to the end of
+ // the beta period. So for example if there's 7 weeks left on the
+ // Beta period, the trial will be 49 days. This is so Beta users can
+ // setup the subscription at any time without losing the free beta
+ // period.
+ const existingUser = await ctx.joplin.models.user().loadByEmail(checkoutSession.customer_email);
+ if (existingUser && await isBetaUser(ctx.joplin.models, existingUser.id)) {
+ checkoutSession.subscription_data.trial_period_days = betaUserTrialPeriodDays(existingUser.created_time);
+ }
+ }
+
// See https://stripe.com/docs/api/checkout/sessions/create
// for additional parameters to pass.
const session = await stripe.checkout.sessions.create(checkoutSession);
@@ -100,9 +117,7 @@ export const postHandlers: PostHandlers = {
// can create the right account, either Basic or Pro.
await ctx.joplin.models.keyValue().setValue(`stripeSessionToPriceId::${session.id}`, priceId);
- return {
- sessionId: session.id,
- };
+ return { sessionId: session.id };
},
// # How to test the complete workflow locally
@@ -126,15 +141,17 @@ export const postHandlers: PostHandlers = {
webhook: async (stripe: Stripe, _path: SubPath, ctx: AppContext, event: Stripe.Event = null, logErrors: boolean = true) => {
event = event ? event : await stripeEvent(stripe, ctx.req);
+ const models = ctx.joplin.models;
+
// Webhook endpoints might occasionally receive the same event more than
// once.
// https://stripe.com/docs/webhooks/best-practices#duplicate-events
const eventDoneKey = `stripeEventDone::${event.id}`;
- if (await ctx.joplin.models.keyValue().value(eventDoneKey)) {
+ if (await models.keyValue().value(eventDoneKey)) {
logger.info(`Skipping event that has already been done: ${event.id}`);
return;
}
- await ctx.joplin.models.keyValue().setValue(eventDoneKey, 1);
+ await models.keyValue().setValue(eventDoneKey, 1);
const hooks: any = {
@@ -206,7 +223,7 @@ export const postHandlers: PostHandlers = {
let accountType = AccountType.Basic;
try {
- const priceId: string = await ctx.joplin.models.keyValue().value(`stripeSessionToPriceId::${checkoutSession.id}`);
+ const priceId: string = await models.keyValue().value(`stripeSessionToPriceId::${checkoutSession.id}`);
accountType = priceIdToAccountType(priceId);
logger.info('Price ID:', priceId);
} catch (error) {
@@ -224,13 +241,37 @@ export const postHandlers: PostHandlers = {
const stripeUserId = checkoutSession.customer as string;
const stripeSubscriptionId = checkoutSession.subscription as string;
- await ctx.joplin.models.subscription().saveUserAndSubscription(
- userEmail,
- customerName,
- accountType,
- stripeUserId,
- stripeSubscriptionId
- );
+ const existingUser = await models.user().loadByEmail(userEmail);
+
+ if (existingUser) {
+ if (await isBetaUser(models, existingUser.id)) {
+ logger.info(`Setting up Beta user subscription: ${existingUser.email}`);
+
+ // First set the account type correctly (in case the
+ // user also upgraded or downgraded their account)
+ await models.user().save({ id: existingUser.id, account_type: accountType });
+
+ // Then save the subscription
+ await models.subscription().save({
+ user_id: existingUser.id,
+ stripe_user_id: stripeUserId,
+ stripe_subscription_id: stripeSubscriptionId,
+ last_payment_time: Date.now(),
+ });
+ } else {
+ // TODO: Some users accidentally subscribe multiple
+ // times - in that case, cancel the subscription and
+ // don't do anything more.
+ }
+ } else {
+ await models.subscription().saveUserAndSubscription(
+ userEmail,
+ customerName,
+ accountType,
+ stripeUserId,
+ stripeSubscriptionId
+ );
+ }
},
'invoice.paid': async () => {
@@ -246,7 +287,7 @@ export const postHandlers: PostHandlers = {
// saved in checkout.session.completed.
const invoice = event.data.object as Stripe.Invoice;
- await ctx.joplin.models.subscription().handlePayment(invoice.subscription as string, true);
+ await models.subscription().handlePayment(invoice.subscription as string, true);
},
'invoice.payment_failed': async () => {
@@ -258,7 +299,7 @@ export const postHandlers: PostHandlers = {
const invoice = event.data.object as Stripe.Invoice;
const subId = invoice.subscription as string;
- await ctx.joplin.models.subscription().handlePayment(subId, false);
+ await models.subscription().handlePayment(subId, false);
},
'customer.subscription.deleted': async () => {
@@ -266,8 +307,8 @@ export const postHandlers: PostHandlers = {
// by the user. In that case, we disable the user.
const { sub } = await getSubscriptionInfo(event, ctx);
- await ctx.joplin.models.user().enable(sub.user_id, false);
- await ctx.joplin.models.subscription().toggleSoftDelete(sub.id, true);
+ await models.user().enable(sub.user_id, false);
+ await models.subscription().toggleSoftDelete(sub.id, true);
},
'customer.subscription.updated': async () => {
@@ -276,11 +317,11 @@ export const postHandlers: PostHandlers = {
const { sub, stripeSub } = await getSubscriptionInfo(event, ctx);
const newAccountType = priceIdToAccountType(stripeSub.items.data[0].price.id);
- const user = await ctx.joplin.models.user().load(sub.user_id, { fields: ['id'] });
+ const user = await models.user().load(sub.user_id, { fields: ['id'] });
if (!user) throw new Error(`No such user: ${user.id}`);
logger.info(`Updating subscription of user ${user.id} to ${newAccountType}`);
- await ctx.joplin.models.user().save({ id: user.id, account_type: newAccountType });
+ await models.user().save({ id: user.id, account_type: newAccountType });
},
};
diff --git a/packages/server/src/services/CronService.ts b/packages/server/src/services/CronService.ts
index 96944cf6f..c1cf087d7 100644
--- a/packages/server/src/services/CronService.ts
+++ b/packages/server/src/services/CronService.ts
@@ -25,6 +25,10 @@ export default class CronService extends BaseService {
cron.schedule('0 * * * *', async () => {
await runCronTask('updateTotalSizes', async () => this.models.item().updateTotalSizes());
});
+
+ cron.schedule('0 12 * * *', async () => {
+ await runCronTask('handleBetaUserEmails', async () => this.models.user().handleBetaUserEmails());
+ });
}
}
diff --git a/packages/server/src/utils/stripe.ts b/packages/server/src/utils/stripe.ts
index 1feff9397..25da8c4ff 100644
--- a/packages/server/src/utils/stripe.ts
+++ b/packages/server/src/utils/stripe.ts
@@ -101,3 +101,38 @@ export async function updateSubscriptionType(models: Models, userId: Uuid, newAc
const stripe = initStripe();
await stripe.subscriptions.update(sub.stripe_subscription_id, { items });
}
+
+export function betaUserDateRange(): number[] {
+ return [1623785440603, 1626690298054];
+}
+
+export async function isBetaUser(models: Models, userId: Uuid): Promise {
+ if (!stripeConfig().enabled) return false;
+
+ const user = await models.user().load(userId, { fields: ['created_time'] });
+ if (!user) throw new Error(`No such user: ${userId}`);
+
+ const range = betaUserDateRange();
+
+ if (user.created_time > range[1]) return false; // approx 19/07/2021 11:24
+ if (user.created_time < range[0]) return false;
+
+ const sub = await models.subscription().byUserId(userId);
+ return !sub;
+}
+
+export function betaUserTrialPeriodDays(userCreatedTime: number, fromDateTime: number = 0, minDays: number = 7): number {
+ fromDateTime = fromDateTime ? fromDateTime : Date.now();
+
+ const oneDayMs = 86400 * 1000;
+ const oneMonthMs = oneDayMs * 30;
+ const endOfBetaPeriodMs = userCreatedTime + oneMonthMs * 3;
+ const remainingTimeMs = endOfBetaPeriodMs - fromDateTime;
+ const remainingTimeDays = Math.ceil(remainingTimeMs / oneDayMs);
+ // Stripe requires a minimum of 48 hours, but let's put 7 days to be sure
+ return remainingTimeDays < minDays ? minDays : remainingTimeDays;
+}
+
+export function betaStartSubUrl(email: string, accountType: AccountType): string {
+ return `https://joplinapp.org/plans/?email=${encodeURIComponent(email)}&account_type=${encodeURIComponent(accountType)}`;
+}
diff --git a/packages/server/src/utils/testing/testUtils.ts b/packages/server/src/utils/testing/testUtils.ts
index 1bce65ec9..8e4a4754b 100644
--- a/packages/server/src/utils/testing/testUtils.ts
+++ b/packages/server/src/utils/testing/testUtils.ts
@@ -81,6 +81,7 @@ export async function beforeAllDb(unitName: string) {
await initConfig(Env.Dev, {
SQLITE_DATABASE: createdDbPath_,
+ SUPPORT_EMAIL: 'testing@localhost',
}, {
tempDir: tempDir,
});
diff --git a/packages/server/src/utils/types.ts b/packages/server/src/utils/types.ts
index 258ff6c23..9f1438535 100644
--- a/packages/server/src/utils/types.ts
+++ b/packages/server/src/utils/types.ts
@@ -77,6 +77,7 @@ export interface MailerConfig {
}
export interface StripeConfig extends StripePublicConfig {
+ enabled: boolean;
secretKey: string;
webhookSecret: string;
}
diff --git a/packages/server/src/utils/views/stripe.ts b/packages/server/src/utils/views/stripe.ts
new file mode 100644
index 000000000..3bf59fad6
--- /dev/null
+++ b/packages/server/src/utils/views/stripe.ts
@@ -0,0 +1,9 @@
+import { View } from '../../services/MustacheService';
+import { stripeConfig } from '../stripe';
+
+export default function setupStripeView(view: View) {
+ view.jsFiles.push('stripe_utils');
+ view.content.stripeConfig = stripeConfig();
+ view.content.stripeConfigJson = JSON.stringify(stripeConfig());
+ return view;
+}
diff --git a/packages/server/src/views/emails/endOfBetaTemplate.ts b/packages/server/src/views/emails/endOfBetaTemplate.ts
new file mode 100644
index 000000000..06eafa3f3
--- /dev/null
+++ b/packages/server/src/views/emails/endOfBetaTemplate.ts
@@ -0,0 +1,26 @@
+import config from '../../config';
+import { EmailSubjectBody } from '../../models/EmailModel';
+
+interface TemplateView {
+ expireDays: number;
+ startSubUrl: string;
+}
+
+export default function(view: TemplateView): EmailSubjectBody {
+ return {
+ subject: `Your ${config().appName} beta account will expire in ${view.expireDays} days`,
+ body: `
+
+Your ${config().appName} beta account will expire in ${view.expireDays} days.
+
+To continue using it after this date, please start the subscription by following the link below.
+
+From that page, select either monthly or yearly payments and click "Buy now".
+
+${view.startSubUrl}
+
+If you have any question please contact support at ${config().supportEmail}.
+
+`.trim(),
+ };
+}
diff --git a/packages/server/src/views/index/home.mustache b/packages/server/src/views/index/home.mustache
index d5f15ee4c..e486ef946 100644
--- a/packages/server/src/views/index/home.mustache
+++ b/packages/server/src/views/index/home.mustache
@@ -1,3 +1,12 @@
+{{#showBetaMessage}}
+
+
This is a free beta account that will expire in {{betaExpiredDays}} day(s).
+
To continue using it after this date, please start the subscription by clicking on the button below.
+
From the next screen, select either monthly or yearly payments and click "Buy now".
+
Start Subscription
+
+{{/showBetaMessage}}
+
Welcome to {{global.appName}}
To start using {{global.appName}}, make sure to download one of the Joplin applications, either for desktop or for your mobile phone.
diff --git a/packages/server/src/views/partials/navbar.mustache b/packages/server/src/views/partials/navbar.mustache
index 51b00048e..c00e9ad99 100644
--- a/packages/server/src/views/partials/navbar.mustache
+++ b/packages/server/src/views/partials/navbar.mustache
@@ -18,10 +18,12 @@
Log