Skip to content

Add subscription info to invite emails & endpoint for sending "trial ending soon" emails #2799

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
41 changes: 39 additions & 2 deletions backend/btrixcloud/emailsender.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import smtplib
import ssl
from uuid import UUID
from typing import Optional, Union
from typing import Optional, Union, Literal

from email.message import EmailMessage
from email.mime.text import MIMEText
Expand All @@ -14,7 +14,13 @@
import aiohttp
from fastapi import HTTPException

from .models import CreateReplicaJob, DeleteReplicaJob, Organization, InvitePending
from .models import (
CreateReplicaJob,
DeleteReplicaJob,
Organization,
InvitePending,
Subscription,
)
from .utils import is_bool, get_origin


Expand Down Expand Up @@ -138,6 +144,7 @@ async def send_user_invite(
token: UUID,
org_name: str,
is_new: bool,
subscription: Optional[Subscription] = None,
headers: Optional[dict] = None,
):
"""Send email to invite new user"""
Expand All @@ -160,6 +167,11 @@ async def send_user_invite(
sender=invite.inviterEmail if not invite.fromSuperuser else "",
org_name=org_name,
support_email=self.support_email,
trial_end_date=(
subscription.futureCancelDate.isoformat()
if subscription and subscription.futureCancelDate
else None
),
)

async def send_user_forgot_password(self, receiver_email, token, headers=None):
Expand Down Expand Up @@ -213,3 +225,28 @@ async def send_subscription_will_be_canceled(
support_email=self.support_email,
survey_url=self.survey_url,
)

async def send_subscription_trial_ending_soon(
self,
trial_end_date: datetime,
user_name: str,
receiver_email: str,
behavior_on_trial_end: Literal["cancel", "continue"],
org: Organization,
headers=None,
):
"""Send email indicating subscription trial is ending soon"""

origin = get_origin(headers)
org_url = f"{origin}/orgs/{org.slug}/"

await self._send_encrypted(
receiver_email,
"trialEndingSoon",
user_name=user_name,
org_name=org.name,
org_url=org_url,
trial_end_date=trial_end_date.isoformat(),
behavior_on_trial_end=behavior_on_trial_end,
support_email=self.support_email,
)
35 changes: 24 additions & 11 deletions backend/btrixcloud/invites.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
EmailStr,
UserRole,
InvitePending,
InviteRequest,
InviteToOrgRequest,
InviteOut,
User,
Organization,
Subscription,
)
from .users import UserManager
from .emailsender import EmailSender
Expand Down Expand Up @@ -67,10 +68,12 @@ async def init_index(self) -> None:
await self.invites.create_index([("tokenHash", pymongo.HASHED)])

async def add_new_user_invite(
# pylint: disable=R0913
self,
new_user_invite: InvitePending,
invite_token: UUID,
org_name: str,
subscription: Optional[Subscription],
headers: Optional[dict],
) -> None:
"""Add invite for new user"""
Expand All @@ -94,7 +97,12 @@ async def add_new_user_invite(
await self.invites.insert_one(new_user_invite.to_dict())

await self.email.send_user_invite(
new_user_invite, invite_token, org_name, True, headers
invite=new_user_invite,
token=invite_token,
org_name=org_name,
is_new=True,
subscription=subscription,
headers=headers,
)

# pylint: disable=too-many-arguments
Expand Down Expand Up @@ -130,7 +138,12 @@ async def add_existing_user_invite(
await self.invites.insert_one(existing_user_invite.to_dict())

await self.email.send_user_invite(
existing_user_invite, invite_token, org_name, False, headers
invite=existing_user_invite,
token=invite_token,
org_name=org_name,
is_new=False,
subscription=org.subscription,
headers=headers,
)

async def get_valid_invite(
Expand Down Expand Up @@ -173,7 +186,7 @@ async def remove_invite_by_email(
# pylint: disable=too-many-arguments
async def invite_user(
self,
invite: InviteRequest,
invite: InviteToOrgRequest,
user: User,
user_manager: UserManager,
org: Organization,
Expand All @@ -199,7 +212,7 @@ async def invite_user(
id=uuid4(),
oid=oid,
created=dt_now(),
role=invite.role if hasattr(invite, "role") else None,
role=invite.role if hasattr(invite, "role") else UserRole.VIEWER,
# URL decode email address just in case
email=urllib.parse.unquote(invite.email),
inviterEmail=user.email,
Expand All @@ -223,10 +236,11 @@ async def invite_user(
return False, invite_token

await self.add_new_user_invite(
invite_pending,
invite_token,
org_name,
headers,
new_user_invite=invite_pending,
invite_token=invite_token,
org_name=org_name,
headers=headers,
subscription=org.subscription,
)
return True, invite_token

Expand Down Expand Up @@ -275,11 +289,10 @@ async def get_invite_out(
created=invite.created,
inviterEmail=inviter_email,
inviterName=inviter_name,
fromSuperuser=from_superuser,
fromSuperuser=from_superuser or False,
oid=invite.oid,
role=invite.role,
email=invite.email,
userid=invite.userid,
)

if not invite.oid:
Expand Down
18 changes: 18 additions & 0 deletions backend/btrixcloud/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1904,6 +1904,14 @@ class SubscriptionCancel(BaseModel):
subId: str


# ============================================================================
class SubscriptionTrialEndReminder(BaseModel):
"""Email reminder that subscription will end soon"""

subId: str
behavior_on_trial_end: Literal["cancel", "continue"]


# ============================================================================
class SubscriptionCancelOut(SubscriptionCancel, SubscriptionEventOut):
"""Output model for subscription cancellation event"""
Expand Down Expand Up @@ -1940,6 +1948,9 @@ class Subscription(BaseModel):
planId: str

futureCancelDate: Optional[datetime] = None
# pylint: disable=C0301
"When in a trial, future cancel date is the trial end date; when not in a trial, future cancel date is the date the subscription will be canceled, if set"

readOnlyOnCancel: bool = False


Expand All @@ -1951,6 +1962,13 @@ class SubscriptionCanceledResponse(BaseModel):
canceled: bool


# ============================================================================
class SubscriptionReminderResponse(BaseModel):
"""Response model for subscription reminder"""

sent: bool


# ============================================================================
# User Org Info With Subs
# ============================================================================
Expand Down
5 changes: 5 additions & 0 deletions backend/btrixcloud/orgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,11 @@ async def cancel_subscription_data(
)
return Organization.from_dict(org_data) if org_data else None

async def find_org_by_subscription_id(self, sub_id: str) -> Optional[Organization]:
"""Find org by subscription id"""
org_data = await self.orgs.find_one({"subscription.subId": sub_id})
return Organization.from_dict(org_data) if org_data else None

async def is_subscription_activated(self, sub_id: str) -> bool:
"""return true if subscription for this org was 'activated', eg. at least
one user has signed up and changed the slug
Expand Down
58 changes: 58 additions & 0 deletions backend/btrixcloud/subs.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
SubscriptionPortalUrlRequest,
SubscriptionPortalUrlResponse,
SubscriptionCanceledResponse,
SubscriptionTrialEndReminder,
SubscriptionReminderResponse,
Organization,
InviteToOrgRequest,
InviteAddedResponse,
Expand Down Expand Up @@ -182,6 +184,51 @@ async def cancel_subscription(self, cancel: SubscriptionCancel) -> dict[str, boo
await self.add_sub_event("cancel", cancel, org.id)
return {"canceled": True, "deleted": deleted}

async def send_trial_end_reminder(
self,
reminder: SubscriptionTrialEndReminder,
):
"""Send a trial end reminder email to the organization admins"""

org = await self.org_ops.find_org_by_subscription_id(reminder.subId)

if not org:
print(f"Organization not found for subscription ID {reminder.subId}")
raise HTTPException(
status_code=404, detail="org_for_subscription_not_found"
)

if not org.subscription:
print(
f"Subscription not found for organization ID {org.id} with sub id {reminder.subId}"
)
raise HTTPException(status_code=500, detail="subscription_not_found")

if not org.subscription.futureCancelDate:
print(f"Future cancel date not found for subscription ID {reminder.subId}")
raise HTTPException(status_code=500, detail="future_cancel_date_not_found")

users = await self.org_ops.get_users_for_org(org, UserRole.OWNER)

if len(users) == 0:
print(f"No admin users found for organization ID {org.id}")
raise HTTPException(status_code=500, detail="no_admin_users_found")

await asyncio.gather(
*[
self.user_manager.email.send_subscription_trial_ending_soon(
trial_end_date=org.subscription.futureCancelDate,
user_name=user.name,
receiver_email=user.email,
org=org,
behavior_on_trial_end=reminder.behavior_on_trial_end,
)
for user in users
]
)

return SubscriptionReminderResponse(sent=True)

async def add_sub_event(
self,
type_: str,
Expand Down Expand Up @@ -395,6 +442,17 @@ async def cancel_subscription(
):
return await ops.cancel_subscription(cancel)

@app.post(
"/subscriptions/trial-end-reminder",
tags=["subscriptions"],
dependencies=[Depends(user_or_shared_secret_dep)],
response_model=SubscriptionReminderResponse,
)
async def send_trial_end_reminder(
reminder: SubscriptionTrialEndReminder,
):
return await ops.send_trial_end_reminder(reminder)

assert org_ops.router

@app.get(
Expand Down
26 changes: 8 additions & 18 deletions emails/emails/failed-bg-job.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
import z from "zod";
import { Template } from "../templates/btrix.js";
import { formatDateTime, parseDate } from "../lib/date.js";
import {
CodeInline,
Column,
Row,
Section,
Text,
} from "@react-email/components";
import { formatDateTime } from "../lib/date.js";
import { CodeInline } from "@react-email/components";

export const schema = z.object({
org: z.string().optional(),
job: z.object({
id: z.string(),
oid: z.string().optional(),
type: z.string(),
started: z.string(),
started: z.coerce.date(),
object_type: z.string().optional(),
object_id: z.string().optional(),
file_path: z.string().optional(),
replica_storage: z.string().optional(),
}),
finished: z.string(),
finished: z.coerce.date(),
});

export type FailedBgJobEmailProps = z.infer<typeof schema>;
Expand Down Expand Up @@ -61,12 +55,8 @@ export const FailedBgJobEmail = ({
linky={{ version: "concerned", caption: false }}
>
<table align="center" width="100%">
<DataRow label="Started At">
{formatDateTime(parseDate(job.started))}
</DataRow>
<DataRow label="Finished At">
{formatDateTime(parseDate(finished))}
</DataRow>
<DataRow label="Started At">{formatDateTime(job.started)}</DataRow>
<DataRow label="Finished At">{formatDateTime(finished)}</DataRow>
{org && (
<DataRow label="Organization">
<Code>{org}</Code>
Expand Down Expand Up @@ -110,13 +100,13 @@ FailedBgJobEmail.PreviewProps = {
id: "1234567890",
oid: "1234567890",
type: "type",
started: new Date().toISOString(),
started: new Date(),
object_type: "object_type",
object_id: "object_id",
file_path: "file_path",
replica_storage: "replica_storage",
},
finished: new Date().toISOString(),
finished: new Date(),
} satisfies FailedBgJobEmailProps;

export default FailedBgJobEmail;
Expand Down
Loading
Loading