From f2d8a0395d770684f3124d7f7d195208a216ec22 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 12 Aug 2025 14:58:48 -0400 Subject: [PATCH 01/12] add subscription info to invite emails --- backend/btrixcloud/emailsender.py | 7 ++++++ backend/btrixcloud/invites.py | 36 ++++++++++++++++++++----------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/backend/btrixcloud/emailsender.py b/backend/btrixcloud/emailsender.py index 37922fd16a..961c2969b8 100644 --- a/backend/btrixcloud/emailsender.py +++ b/backend/btrixcloud/emailsender.py @@ -16,6 +16,7 @@ from .models import CreateReplicaJob, DeleteReplicaJob, Organization, InvitePending from .utils import is_bool, get_origin +from backend.btrixcloud.models import Subscription # pylint: disable=too-few-public-methods, too-many-instance-attributes @@ -138,6 +139,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""" @@ -160,6 +162,11 @@ async def send_user_invite( sender=invite.inviterEmail if not invite.fromSuperuser else "", org_name=org_name, support_email=self.support_email, + trial_remaining_days=( + (subscription.futureCancelDate - datetime.now()).days + if subscription and subscription.futureCancelDate + else None + ), ) async def send_user_forgot_password(self, receiver_email, token, headers=None): diff --git a/backend/btrixcloud/invites.py b/backend/btrixcloud/invites.py index 97f07cd243..eee2d6f95f 100644 --- a/backend/btrixcloud/invites.py +++ b/backend/btrixcloud/invites.py @@ -16,10 +16,11 @@ EmailStr, UserRole, InvitePending, - InviteRequest, + InviteToOrgRequest, InviteOut, User, Organization, + Subscription ) from .users import UserManager from .emailsender import EmailSender @@ -71,6 +72,7 @@ async def add_new_user_invite( new_user_invite: InvitePending, invite_token: UUID, org_name: str, + subscription: Optional[Subscription], headers: Optional[dict], ) -> None: """Add invite for new user""" @@ -94,7 +96,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 @@ -130,7 +137,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( @@ -173,7 +185,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, @@ -199,9 +211,9 @@ 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), + email=EmailStr(urllib.parse.unquote(invite.email)), inviterEmail=user.email, fromSuperuser=user.is_superuser, tokenHash=get_hash(invite_token), @@ -223,10 +235,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 @@ -275,11 +288,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: From 4dc8362cd8917270aba82dc76d1c8345adb57620 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 12 Aug 2025 17:02:11 -0400 Subject: [PATCH 02/12] add endpoint to send "trial will end" email --- backend/btrixcloud/emailsender.py | 27 +++++++++++++++- backend/btrixcloud/models.py | 16 ++++++++++ backend/btrixcloud/orgs.py | 5 +++ backend/btrixcloud/subs.py | 53 +++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/backend/btrixcloud/emailsender.py b/backend/btrixcloud/emailsender.py index 961c2969b8..b99873a05d 100644 --- a/backend/btrixcloud/emailsender.py +++ b/backend/btrixcloud/emailsender.py @@ -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 @@ -220,3 +220,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, + ) diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index 7996a67a97..c05e972ca2 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -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""" @@ -1940,6 +1948,8 @@ class Subscription(BaseModel): planId: str futureCancelDate: Optional[datetime] = None + "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 @@ -1950,6 +1960,12 @@ class SubscriptionCanceledResponse(BaseModel): deleted: bool canceled: bool +# ============================================================================ +class SubscriptionReminderResponse(BaseModel): + """Response model for subscription reminder""" + + sent: bool + # ============================================================================ # User Org Info With Subs diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index b49c4c967d..3d549c6c17 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -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 diff --git a/backend/btrixcloud/subs.py b/backend/btrixcloud/subs.py index 4a48d7f0fe..9f307a5fb3 100644 --- a/backend/btrixcloud/subs.py +++ b/backend/btrixcloud/subs.py @@ -27,6 +27,7 @@ SubscriptionPortalUrlRequest, SubscriptionPortalUrlResponse, SubscriptionCanceledResponse, + SubscriptionReminderResponse, Organization, InviteToOrgRequest, InviteAddedResponse, @@ -40,6 +41,7 @@ ) from .pagination import DEFAULT_PAGE_SIZE, paginated_format from .utils import dt_now +from backend.btrixcloud.models import SubscriptionTrialEndReminder # if set, will enable this api @@ -182,6 +184,46 @@ 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, + ): + 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=404, 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=404, detail="future_cancel_date_not_found" + ) + + users = await self.org_ops.get_users_for_org(org, UserRole.OWNER) + 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, @@ -395,6 +437,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( From ac17ae5e11c6f476bf8d882d08bd5fca5922c1e5 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 12 Aug 2025 17:07:16 -0400 Subject: [PATCH 03/12] format --- backend/btrixcloud/invites.py | 2 +- backend/btrixcloud/models.py | 1 + backend/btrixcloud/subs.py | 12 +++++------- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/backend/btrixcloud/invites.py b/backend/btrixcloud/invites.py index eee2d6f95f..f45179cecc 100644 --- a/backend/btrixcloud/invites.py +++ b/backend/btrixcloud/invites.py @@ -20,7 +20,7 @@ InviteOut, User, Organization, - Subscription + Subscription, ) from .users import UserManager from .emailsender import EmailSender diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index c05e972ca2..07fe428388 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -1960,6 +1960,7 @@ class SubscriptionCanceledResponse(BaseModel): deleted: bool canceled: bool + # ============================================================================ class SubscriptionReminderResponse(BaseModel): """Response model for subscription reminder""" diff --git a/backend/btrixcloud/subs.py b/backend/btrixcloud/subs.py index 9f307a5fb3..13610a61df 100644 --- a/backend/btrixcloud/subs.py +++ b/backend/btrixcloud/subs.py @@ -197,16 +197,14 @@ async def send_trial_end_reminder( ) if not org.subscription: - print(f"Subscription not found for organization ID {org.id} with sub id {reminder.subId}") - raise HTTPException( - status_code=404, detail="subscription_not_found" + print( + f"Subscription not found for organization ID {org.id} with sub id {reminder.subId}" ) + raise HTTPException(status_code=404, 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=404, detail="future_cancel_date_not_found" - ) + raise HTTPException(status_code=404, detail="future_cancel_date_not_found") users = await self.org_ops.get_users_for_org(org, UserRole.OWNER) await asyncio.gather( @@ -216,7 +214,7 @@ async def send_trial_end_reminder( user_name=user.name, receiver_email=user.email, org=org, - behavior_on_trial_end=reminder.behavior_on_trial_end + behavior_on_trial_end=reminder.behavior_on_trial_end, ) for user in users ] From 0663db83fe16c36ff715775772362a9e63409512 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 12 Aug 2025 17:12:52 -0400 Subject: [PATCH 04/12] format & fix lint issues --- backend/btrixcloud/emailsender.py | 9 +++++++-- backend/btrixcloud/invites.py | 1 + backend/btrixcloud/models.py | 1 + backend/btrixcloud/subs.py | 4 +++- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/backend/btrixcloud/emailsender.py b/backend/btrixcloud/emailsender.py index b99873a05d..782e618766 100644 --- a/backend/btrixcloud/emailsender.py +++ b/backend/btrixcloud/emailsender.py @@ -14,9 +14,14 @@ 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 -from backend.btrixcloud.models import Subscription # pylint: disable=too-few-public-methods, too-many-instance-attributes diff --git a/backend/btrixcloud/invites.py b/backend/btrixcloud/invites.py index f45179cecc..55a4bd7134 100644 --- a/backend/btrixcloud/invites.py +++ b/backend/btrixcloud/invites.py @@ -68,6 +68,7 @@ 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, diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index 07fe428388..60f26fccf6 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -1948,6 +1948,7 @@ 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 diff --git a/backend/btrixcloud/subs.py b/backend/btrixcloud/subs.py index 13610a61df..a11e8f09e7 100644 --- a/backend/btrixcloud/subs.py +++ b/backend/btrixcloud/subs.py @@ -27,6 +27,7 @@ SubscriptionPortalUrlRequest, SubscriptionPortalUrlResponse, SubscriptionCanceledResponse, + SubscriptionTrialEndReminder, SubscriptionReminderResponse, Organization, InviteToOrgRequest, @@ -41,7 +42,6 @@ ) from .pagination import DEFAULT_PAGE_SIZE, paginated_format from .utils import dt_now -from backend.btrixcloud.models import SubscriptionTrialEndReminder # if set, will enable this api @@ -188,6 +188,8 @@ 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: From 1f22023641a41a4150b4b24268f899611006edcc Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 12 Aug 2025 18:23:50 -0400 Subject: [PATCH 05/12] revert unnecessary change to email --- backend/btrixcloud/invites.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/btrixcloud/invites.py b/backend/btrixcloud/invites.py index 55a4bd7134..3423b110dc 100644 --- a/backend/btrixcloud/invites.py +++ b/backend/btrixcloud/invites.py @@ -214,7 +214,7 @@ async def invite_user( created=dt_now(), role=invite.role if hasattr(invite, "role") else UserRole.VIEWER, # URL decode email address just in case - email=EmailStr(urllib.parse.unquote(invite.email)), + email=urllib.parse.unquote(invite.email), inviterEmail=user.email, fromSuperuser=user.is_superuser, tokenHash=get_hash(invite_token), From d60e092c8979ad0872e919efdc1ddd3b0dacc087 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 12 Aug 2025 18:32:28 -0400 Subject: [PATCH 06/12] fix date calculation --- backend/btrixcloud/emailsender.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/btrixcloud/emailsender.py b/backend/btrixcloud/emailsender.py index 782e618766..473bd83970 100644 --- a/backend/btrixcloud/emailsender.py +++ b/backend/btrixcloud/emailsender.py @@ -1,6 +1,6 @@ """Basic Email Sending Support""" -from datetime import datetime +from datetime import datetime, timezone import os import smtplib import ssl @@ -168,7 +168,7 @@ async def send_user_invite( org_name=org_name, support_email=self.support_email, trial_remaining_days=( - (subscription.futureCancelDate - datetime.now()).days + (subscription.futureCancelDate - datetime.now(timezone.utc)).days if subscription and subscription.futureCancelDate else None ), From a96cfd7d256b9883707ec97200350d6516f67251 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 12 Aug 2025 18:55:07 -0400 Subject: [PATCH 07/12] fail if not admins found for org also change other lookup errors to 500s --- backend/btrixcloud/subs.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/btrixcloud/subs.py b/backend/btrixcloud/subs.py index a11e8f09e7..1ab42a3ce5 100644 --- a/backend/btrixcloud/subs.py +++ b/backend/btrixcloud/subs.py @@ -202,13 +202,18 @@ async def send_trial_end_reminder( print( f"Subscription not found for organization ID {org.id} with sub id {reminder.subId}" ) - raise HTTPException(status_code=404, detail="subscription_not_found") + 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=404, detail="future_cancel_date_not_found") + 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( From d07d0abc20dc511692bd3220c5eed030d6cd38e0 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 12 Aug 2025 18:59:42 -0400 Subject: [PATCH 08/12] trim trailing slash from org urls --- emails/emails/subscription-cancel.tsx | 3 ++- emails/emails/trial-ending-soon.tsx | 3 ++- emails/lib/url.ts | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 emails/lib/url.ts diff --git a/emails/emails/subscription-cancel.tsx b/emails/emails/subscription-cancel.tsx index e6ea434d12..b8c78ce313 100644 --- a/emails/emails/subscription-cancel.tsx +++ b/emails/emails/subscription-cancel.tsx @@ -6,11 +6,12 @@ import { Warning } from "../components/warning.js"; import { Card } from "../components/card.js"; import { z } from "zod"; +import { trimTrailingSlash } from "../lib/url.js"; export const schema = z.object({ user_name: z.string(), org_name: z.string(), - org_url: z.string(), + org_url: z.string().transform(trimTrailingSlash), cancel_date: z.string(), survey_url: z.string().optional(), support_email: z.email().optional(), diff --git a/emails/emails/trial-ending-soon.tsx b/emails/emails/trial-ending-soon.tsx index 76dfed637b..6c141cdbfe 100644 --- a/emails/emails/trial-ending-soon.tsx +++ b/emails/emails/trial-ending-soon.tsx @@ -12,11 +12,12 @@ import { import { Warning } from "../components/warning.js"; import { z } from "zod"; +import { trimTrailingSlash } from "../lib/url.js"; export const schema = z.object({ user_name: z.string(), org_name: z.string(), - org_url: z.url(), + org_url: z.url().transform(trimTrailingSlash), trial_end_date: z.string(), behavior_on_trial_end: z.enum(["cancel", "continue"]).optional(), support_email: z.email().optional(), diff --git a/emails/lib/url.ts b/emails/lib/url.ts new file mode 100644 index 0000000000..30a9870da2 --- /dev/null +++ b/emails/lib/url.ts @@ -0,0 +1,2 @@ +export const trimTrailingSlash = (url: string): string => + url.endsWith("/") ? url.slice(0, -1) : url; From 3d8ab20dd2c6f2eee5317ba724395af97d7fca6b Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 12 Aug 2025 19:27:12 -0400 Subject: [PATCH 09/12] use same day counting logic in invites & trial_ending_soon emails --- backend/btrixcloud/emailsender.py | 4 ++-- emails/emails/invite.tsx | 34 +++++++++++++++++++------------ 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/backend/btrixcloud/emailsender.py b/backend/btrixcloud/emailsender.py index 473bd83970..cd3c27f49d 100644 --- a/backend/btrixcloud/emailsender.py +++ b/backend/btrixcloud/emailsender.py @@ -167,8 +167,8 @@ async def send_user_invite( sender=invite.inviterEmail if not invite.fromSuperuser else "", org_name=org_name, support_email=self.support_email, - trial_remaining_days=( - (subscription.futureCancelDate - datetime.now(timezone.utc)).days + trial_end_date=( + subscription.futureCancelDate.isoformat() if subscription and subscription.futureCancelDate else None ), diff --git a/emails/emails/invite.tsx b/emails/emails/invite.tsx index 616df2c235..98601ee876 100644 --- a/emails/emails/invite.tsx +++ b/emails/emails/invite.tsx @@ -2,9 +2,12 @@ import { Heading, Hr, Link, Section, Text } from "@react-email/components"; import { Template } from "../templates/btrix.js"; import { + differenceInDays, formatDate, + formatRelativeDate, formatRelativeDateToParts, offsetDays, + reRenderDate, } from "../lib/date.js"; import { formatNumber } from "../lib/number.js"; import { Button } from "../components/button.js"; @@ -18,7 +21,7 @@ export const schema = z.object({ invite_url: z.string(), support_email: z.email().optional(), validity_period_days: z.number().int().positive().optional(), - trial_remaining_days: z.number().int().optional(), + trial_end_date: z.string().optional(), }); export type InviteUserEmailProps = z.infer; @@ -30,8 +33,14 @@ export const InviteUserEmail = ({ invite_url, support_email, validity_period_days = 7, - trial_remaining_days, + trial_end_date, }: InviteUserEmailProps) => { + const daysLeft = trial_end_date + ? differenceInDays(new Date(trial_end_date)) + : null; + const relativeParts = daysLeft + ? formatRelativeDateToParts(daysLeft, "days") + : null; const previewText = `Join ${org_name} on Browsertrix`; return ( @@ -263,16 +272,15 @@ export const InviteUserEmail = ({ > View and update your plan, billing information, payment methods, and usage history.{" "} - {trial_remaining_days && ( + {relativeParts && ( <> Your trial ends{" "} - {formatRelativeDateToParts(trial_remaining_days, "days").map( - (part, index) => - part.value !== "in " ? ( - {part.value} - ) : ( - part.value - ), + {relativeParts.map((part, index) => + part.value !== "in " ? ( + {part.value} + ) : ( + part.value + ), )} , so you may want to double check your billing information and payment methods before the trial ends. @@ -340,17 +348,17 @@ InviteUserEmail.PreviewProps = { invite_url: "https://app.browsertrix.com/invite-url-123-demo", support_email: "support@webrecorder.net", validity_period_days: 7, - trial_remaining_days: 7, + trial_end_date: offsetDays(7).toISOString(), } satisfies InviteUserEmailProps; export default InviteUserEmail; export const subject = ({ - trial_remaining_days, + trial_end_date, org_name, sender, }: InviteUserEmailProps) => { - if (trial_remaining_days != null) { + if (trial_end_date != null) { return "Start your Browsertrix trial"; } return sender || org_name From 490bb4b94853f07882855e8afecb6edaed68c8aa Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 12 Aug 2025 19:30:01 -0400 Subject: [PATCH 10/12] remove unused import --- backend/btrixcloud/emailsender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/btrixcloud/emailsender.py b/backend/btrixcloud/emailsender.py index cd3c27f49d..bdf2d9331f 100644 --- a/backend/btrixcloud/emailsender.py +++ b/backend/btrixcloud/emailsender.py @@ -1,6 +1,6 @@ """Basic Email Sending Support""" -from datetime import datetime, timezone +from datetime import datetime import os import smtplib import ssl From 9514ebf0c1c170a7c7cf2d9cbc3209ad0fc2ae4d Mon Sep 17 00:00:00 2001 From: emma Date: Wed, 13 Aug 2025 15:47:33 -0400 Subject: [PATCH 11/12] allow `null` values for trial_end_date --- emails/emails/invite.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emails/emails/invite.tsx b/emails/emails/invite.tsx index 98601ee876..7051db1861 100644 --- a/emails/emails/invite.tsx +++ b/emails/emails/invite.tsx @@ -21,7 +21,7 @@ export const schema = z.object({ invite_url: z.string(), support_email: z.email().optional(), validity_period_days: z.number().int().positive().optional(), - trial_end_date: z.string().optional(), + trial_end_date: z.string().nullish(), }); export type InviteUserEmailProps = z.infer; From 3aa5a9c80c5a026d5834815ecb4dedcb0b31ee90 Mon Sep 17 00:00:00 2001 From: emma Date: Thu, 14 Aug 2025 14:37:57 -0400 Subject: [PATCH 12/12] simplify email template dates with `z.coerce.date()` --- emails/emails/failed-bg-job.tsx | 26 ++++++++------------------ emails/emails/invite.tsx | 10 +++------- emails/emails/subscription-cancel.tsx | 20 +++++--------------- emails/emails/trial-ending-soon.tsx | 9 ++++----- emails/lib/date.ts | 19 ------------------- 5 files changed, 20 insertions(+), 64 deletions(-) diff --git a/emails/emails/failed-bg-job.tsx b/emails/emails/failed-bg-job.tsx index 067d0994c2..8614d6f16b 100644 --- a/emails/emails/failed-bg-job.tsx +++ b/emails/emails/failed-bg-job.tsx @@ -1,13 +1,7 @@ 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(), @@ -15,13 +9,13 @@ export const schema = 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; @@ -61,12 +55,8 @@ export const FailedBgJobEmail = ({ linky={{ version: "concerned", caption: false }} > - - {formatDateTime(parseDate(job.started))} - - - {formatDateTime(parseDate(finished))} - + {formatDateTime(job.started)} + {formatDateTime(finished)} {org && ( {org} @@ -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; diff --git a/emails/emails/invite.tsx b/emails/emails/invite.tsx index 7051db1861..c7acb1c214 100644 --- a/emails/emails/invite.tsx +++ b/emails/emails/invite.tsx @@ -4,10 +4,8 @@ import { Template } from "../templates/btrix.js"; import { differenceInDays, formatDate, - formatRelativeDate, formatRelativeDateToParts, offsetDays, - reRenderDate, } from "../lib/date.js"; import { formatNumber } from "../lib/number.js"; import { Button } from "../components/button.js"; @@ -21,7 +19,7 @@ export const schema = z.object({ invite_url: z.string(), support_email: z.email().optional(), validity_period_days: z.number().int().positive().optional(), - trial_end_date: z.string().nullish(), + trial_end_date: z.coerce.date().nullish(), }); export type InviteUserEmailProps = z.infer; @@ -35,9 +33,7 @@ export const InviteUserEmail = ({ validity_period_days = 7, trial_end_date, }: InviteUserEmailProps) => { - const daysLeft = trial_end_date - ? differenceInDays(new Date(trial_end_date)) - : null; + const daysLeft = trial_end_date ? differenceInDays(trial_end_date) : null; const relativeParts = daysLeft ? formatRelativeDateToParts(daysLeft, "days") : null; @@ -348,7 +344,7 @@ InviteUserEmail.PreviewProps = { invite_url: "https://app.browsertrix.com/invite-url-123-demo", support_email: "support@webrecorder.net", validity_period_days: 7, - trial_end_date: offsetDays(7).toISOString(), + trial_end_date: offsetDays(7), } satisfies InviteUserEmailProps; export default InviteUserEmail; diff --git a/emails/emails/subscription-cancel.tsx b/emails/emails/subscription-cancel.tsx index b8c78ce313..c0f4decf63 100644 --- a/emails/emails/subscription-cancel.tsx +++ b/emails/emails/subscription-cancel.tsx @@ -1,4 +1,4 @@ -import { Heading, Link, Text } from "@react-email/components"; +import { Link, Text } from "@react-email/components"; import { Template } from "../templates/btrix.js"; import { formatDate, offsetDays } from "../lib/date.js"; @@ -12,23 +12,13 @@ export const schema = z.object({ user_name: z.string(), org_name: z.string(), org_url: z.string().transform(trimTrailingSlash), - cancel_date: z.string(), + cancel_date: z.coerce.date(), survey_url: z.string().optional(), support_email: z.email().optional(), }); export type SubscriptionCancelEmailProps = z.infer; -function reRenderDate(date: string) { - try { - const parsedDate = new Date(date); - return formatDate(parsedDate); - } catch (error) { - console.error("Error parsing date:", error); - return date; - } -} - export const SubscriptionCancelEmail = ({ user_name, org_name, @@ -37,7 +27,7 @@ export const SubscriptionCancelEmail = ({ survey_url, support_email, }: SubscriptionCancelEmailProps) => { - const date = reRenderDate(cancel_date); + const date = formatDate(cancel_date); return (