Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f2d8a03
add subscription info to invite emails
emma-sg Aug 12, 2025
4dc8362
add endpoint to send "trial will end" email
emma-sg Aug 12, 2025
ac17ae5
format
emma-sg Aug 12, 2025
0663db8
format & fix lint issues
emma-sg Aug 12, 2025
1f22023
revert unnecessary change to email
emma-sg Aug 12, 2025
d60e092
fix date calculation
emma-sg Aug 12, 2025
a96cfd7
fail if not admins found for org
emma-sg Aug 12, 2025
d07d0ab
trim trailing slash from org urls
emma-sg Aug 12, 2025
3d8ab20
use same day counting logic in invites & trial_ending_soon emails
emma-sg Aug 12, 2025
490bb4b
remove unused import
emma-sg Aug 12, 2025
9514ebf
allow `null` values for trial_end_date
emma-sg Aug 13, 2025
3aa5a9c
simplify email template dates with `z.coerce.date()`
emma-sg Aug 14, 2025
061afd1
swap to 400 error codes when sending subscription reminder email
emma-sg Aug 21, 2025
899fd18
enable `use_attribute_docstrings` in Subscription
emma-sg Aug 21, 2025
9934fcc
update trial end text for when user has cancelled trial
emma-sg Aug 21, 2025
a0555b5
add "read-only" state for trial end emails
emma-sg Aug 21, 2025
2bdcbc4
update trial cancellation text when user has cancelled manually
emma-sg Aug 22, 2025
e1bacd8
include commit hash with email service logs
emma-sg Aug 22, 2025
099f0a2
simplify & use commit hash from github action env
emma-sg Aug 22, 2025
4719d5a
remove script from package.json
emma-sg Aug 22, 2025
6c52344
Apply suggestion from @ikreymer
ikreymer Aug 23, 2025
a49cfc4
tweaks:
ikreymer Aug 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nitpick: I think should just use SuccessResponse(success=True) rather than adding a new API response type?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh sure, yeah


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