Skip to content

Commit 34de9fb

Browse files
authored
feat: send email to unlink account (#7584)
1 parent 81fed6d commit 34de9fb

File tree

4 files changed

+158
-0
lines changed

4 files changed

+158
-0
lines changed

server/emails/src/emails/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { OAuth2LeakedClient } from './oauth2_leaked_client'
1010
import { OAuth2LeakedToken } from './oauth2_leaked_token'
1111
import { OrderConfirmation } from './order_confirmation'
1212
import { OrganizationAccessTokenLeaked } from './organization_access_token_leaked'
13+
import { OrganizationAccountUnlink } from './organization_account_unlink'
1314
import { OrganizationInvite } from './organization_invite'
1415
import { PersonalAccessTokenLeaked } from './personal_access_token_leaked'
1516
import { SeatInvitation } from './seat_invitation'
@@ -30,6 +31,7 @@ const TEMPLATES: Record<string, React.FC<any>> = {
3031
oauth2_leaked_token: OAuth2LeakedToken,
3132
order_confirmation: OrderConfirmation,
3233
organization_access_token_leaked: OrganizationAccessTokenLeaked,
34+
organization_account_unlink: OrganizationAccountUnlink,
3335
organization_invite: OrganizationInvite,
3436
personal_access_token_leaked: PersonalAccessTokenLeaked,
3537
seat_invitation: SeatInvitation,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Preview, Section, Text } from '@react-email/components'
2+
import BodyText from '../components/BodyText'
3+
import Footer from '../components/Footer'
4+
import IntroWithHi from '../components/IntroWithHi'
5+
import PolarHeader from '../components/PolarHeader'
6+
import Wrapper from '../components/Wrapper'
7+
import type { schemas } from '../types'
8+
9+
export function OrganizationAccountUnlink({
10+
email,
11+
organization_kept_name,
12+
organizations_unlinked,
13+
}: schemas['OrganizationAccountUnlinkProps']) {
14+
return (
15+
<Wrapper>
16+
<Preview>
17+
Important: Organization Account Update for {organization_kept_name}
18+
</Preview>
19+
<PolarHeader />
20+
<IntroWithHi>
21+
We're writing to inform that we needed to unlink some of your
22+
organizations from Stripe. The problem is that multiple organizations
23+
shared the same Stripe account, and for security and compliance reasons
24+
we had to unlink them.
25+
</IntroWithHi>
26+
<Section>
27+
<BodyText>
28+
Your organization{' '}
29+
<span className="font-bold">{organization_kept_name}</span> has
30+
retained the connected account, and{' '}
31+
<span className="font-bold">no data has been lost</span>.
32+
</BodyText>
33+
</Section>
34+
<Section className="rounded-lg border border-blue-200 bg-blue-50 p-4">
35+
<Text className="mb-2 text-[16px] font-bold text-blue-900">
36+
What This Means
37+
</Text>
38+
<ul className="ml-4 list-disc text-[14px] text-blue-900">
39+
<li className="mb-2">
40+
<span className="font-bold">{organization_kept_name}</span> keeps
41+
the existing account setup with no changes required.
42+
</li>
43+
<li className="mb-2">
44+
The following organizations will need to complete the Stripe setup
45+
again:
46+
<ul className="ml-4 mt-2 list-disc">
47+
{organizations_unlinked.map((org) => (
48+
<li key={org} className="mb-1">
49+
{org}
50+
</li>
51+
))}
52+
</ul>
53+
</li>
54+
</ul>
55+
</Section>
56+
<Section className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
57+
<Text className="mb-2 text-[16px] font-bold text-yellow-900">
58+
Important Information
59+
</Text>
60+
<ul className="ml-4 list-disc text-[14px] text-yellow-900">
61+
<li className="mb-2">
62+
<span className="font-bold">Payments:</span> Not blocked - customers
63+
can continue making payments
64+
</li>
65+
<li className="mb-2">
66+
<span className="font-bold">Withdrawals:</span> Blocked until Stripe
67+
setup is completed for the affected organizations
68+
</li>
69+
<li className="mb-2">
70+
<span className="font-bold">Payout history:</span> All payout
71+
history is still available on{' '}
72+
<span className="font-bold">{organization_kept_name}</span>.
73+
</li>
74+
</ul>
75+
</Section>
76+
<Section>
77+
<BodyText>
78+
To restore full functionality for the organizations that need Stripe
79+
setup, please visit your organization settings and complete the Stripe
80+
connection process.
81+
</BodyText>
82+
</Section>
83+
<Section>
84+
<BodyText>
85+
If you have any questions or concerns, please don't hesitate to reach
86+
out to our support team.
87+
</BodyText>
88+
</Section>
89+
<Footer email={email} />
90+
</Wrapper>
91+
)
92+
}
93+
94+
OrganizationAccountUnlink.PreviewProps = {
95+
96+
organization_kept_name: 'Acme Inc.',
97+
organizations_unlinked: ['Beta Corp', 'Gamma LLC'],
98+
}
99+
100+
export default OrganizationAccountUnlink

server/polar/email/schemas.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class EmailTemplate(StrEnum):
2727
order_confirmation = "order_confirmation"
2828
organization_access_token_leaked = "organization_access_token_leaked"
2929
organization_invite = "organization_invite"
30+
organization_account_unlink = "organization_account_unlink"
3031
personal_access_token_leaked = "personal_access_token_leaked"
3132
seat_invitation = "seat_invitation"
3233
subscription_cancellation = "subscription_cancellation"
@@ -315,6 +316,18 @@ class NotificationCreateAccountEmail(BaseModel):
315316
props: MaintainerCreateAccountNotificationPayload
316317

317318

319+
class OrganizationAccountUnlinkProps(EmailProps):
320+
organization_kept_name: str
321+
organizations_unlinked: list[str]
322+
323+
324+
class OrganizationAccountUnlinkEmail(BaseModel):
325+
template: Literal[EmailTemplate.organization_account_unlink] = (
326+
EmailTemplate.organization_account_unlink
327+
)
328+
props: OrganizationAccountUnlinkProps
329+
330+
318331
Email = Annotated[
319332
LoginCodeEmail
320333
| CustomerSessionCodeEmail
@@ -324,6 +337,7 @@ class NotificationCreateAccountEmail(BaseModel):
324337
| OrderConfirmationEmail
325338
| OrganizationAccessTokenLeakedEmail
326339
| OrganizationInviteEmail
340+
| OrganizationAccountUnlinkEmail
327341
| PersonalAccessTokenLeakedEmail
328342
| SeatInvitationEmail
329343
| SubscriptionCancellationEmail

server/scripts/unlink_organization_account.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
2222
Actually perform the unlink:
2323
uv run python -m scripts.unlink_organization_account ACCOUNT_UUID --no-dry-run
24+
25+
Send email notification to admin:
26+
uv run python -m scripts.unlink_organization_account ACCOUNT_UUID --no-dry-run --send-email
2427
"""
2528

2629
import asyncio
@@ -35,6 +38,12 @@
3538
from sqlalchemy.orm import joinedload
3639

3740
from polar.account.repository import AccountRepository
41+
from polar.email.react import render_email_template
42+
from polar.email.schemas import (
43+
OrganizationAccountUnlinkEmail,
44+
OrganizationAccountUnlinkProps,
45+
)
46+
from polar.email.sender import email_sender
3847
from polar.kit.db.postgres import create_async_sessionmaker
3948
from polar.models import Account, Customer, Order, Organization
4049
from polar.organization.repository import OrganizationRepository
@@ -113,6 +122,9 @@ async def unlink_organizations(
113122
dry_run: bool = typer.Option(
114123
True, help="If True, only show what would be done without making changes"
115124
),
125+
send_email: bool = typer.Option(
126+
False, help="If True, send email notification to the admin"
127+
),
116128
) -> None:
117129
account_uuid = UUID(account_id)
118130

@@ -229,6 +241,36 @@ async def unlink_organizations(
229241
typer.echo()
230242
typer.echo(f"✅ Successfully unlinked {len(orgs_to_unlink)} organization(s)")
231243

244+
if send_email:
245+
typer.echo()
246+
typer.echo("📧 Sending email notification to admin...")
247+
248+
try:
249+
orgs_unlinked_names = [org.slug for org in orgs_to_unlink]
250+
251+
email_data = OrganizationAccountUnlinkEmail(
252+
props=OrganizationAccountUnlinkProps(
253+
email=account.admin.email,
254+
organization_kept_name=org_to_keep.slug,
255+
organizations_unlinked=orgs_unlinked_names,
256+
)
257+
)
258+
259+
email_html = render_email_template(email_data)
260+
261+
await email_sender.send(
262+
to_email_addr=account.admin.email,
263+
subject="Important: Organization Account Update",
264+
html_content=email_html,
265+
)
266+
267+
typer.echo(f" ✓ Email sent to {account.admin.email}")
268+
except Exception as e:
269+
typer.echo(f" ❌ Failed to send email: {str(e)}")
270+
typer.echo(
271+
" ⚠️ Organizations were unlinked successfully, but email notification failed"
272+
)
273+
232274

233275
if __name__ == "__main__":
234276
cli()

0 commit comments

Comments
 (0)