diff --git a/.github/workflows/onboarding-reminders.yml b/.github/workflows/onboarding-reminders.yml index 8433759f..326da0b9 100644 --- a/.github/workflows/onboarding-reminders.yml +++ b/.github/workflows/onboarding-reminders.yml @@ -1,18 +1,19 @@ name: Onboarding reminders cron -# Pings the cloud backend every 6 hours to send drip emails to users -# who registered 24h+ ago but never created a connector. The endpoint +# Pings the cloud backend every hour. The endpoint # (`/api/cron/onboarding-reminders`) is idempotent — re-running within -# a window won't double-send because per-user counters and timing -# checks are persisted in the users table. +# a window won't double-send because per-user counters, flags and timing +# checks are persisted. # -# Schedule: */6 means 00:00, 06:00, 12:00, 18:00 UTC. We don't tie this -# to local business hours because the email itself isn't time-sensitive -# (users register globally and the drip lands ~ a day later anyway). +# Hourly because the first nudge goes out an hour after sign-up, to people +# whose workspace is still empty (how to connect an app from the chat or the +# dashboard). The other passes (trial lifecycle, win-back, licence +# re-verification, release of deleted workspaces' licences) are gated by their +# own windows and flags, so running them more often sends nothing twice. on: schedule: - - cron: "0 */6 * * *" + - cron: "0 * * * *" workflow_dispatch: inputs: target_url: diff --git a/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts b/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts index 18d14d7e..949b1ea0 100644 --- a/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts +++ b/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts @@ -159,8 +159,8 @@ describe('OnboardingCronService — trial status transition', () => { update: jest.fn(), }, license: { - findMany: jest.fn().mockImplementation(async () => { - calls.push('lifecycle'); + findMany: jest.fn().mockImplementation(async (args: any) => { + calls.push(args?.where?.status === 'active' ? 'lifecycle' : 'winback'); return []; }), updateMany: jest.fn().mockImplementation(async () => { @@ -183,7 +183,7 @@ describe('OnboardingCronService — trial status transition', () => { expect(prisma.license.updateMany.mock.calls[0][0].data).toEqual({ status: 'expired' }); // The "your trial has ended" email selects on status='active', so the // flip must come after it or the email would never be sent. - expect(calls).toEqual(['lifecycle', 'mark-expired']); + expect(calls).toEqual(['lifecycle', 'winback', 'mark-expired']); }); }); @@ -281,10 +281,20 @@ describe('OnboardingCronService — onboarding pass', () => { ); }); - it('waits the usual 24h for a user with no AI client yet', async () => { + it('nudges a user with no AI client an hour after sign-up, without naming one', async () => { const { service, email } = makeService({ candidates: [user({})] }); await service.run(); - expect(email.sendOnboardingReminderEmail).not.toHaveBeenCalled(); + expect(email.sendOnboardingReminderEmail).toHaveBeenCalledWith('u1@example.com', 'Ada', 1, undefined); + }); + + it('asks the database only for users who signed up at least an hour ago', async () => { + const { service, prisma } = makeService({ candidates: [] }); + const before = Date.now(); + await service.run(); + const where = prisma.user.findMany.mock.calls[0][0].where; + const ageMs = before - where.createdAt.lte.getTime(); + expect(ageMs).toBeGreaterThanOrEqual(HOUR - 1000); + expect(ageMs).toBeLessThan(HOUR + 60_000); }); it('still reminds a user who pressed Skip on /welcome with an empty workspace', async () => { @@ -309,3 +319,129 @@ describe('OnboardingCronService — onboarding pass', () => { ); }); }); + +describe('OnboardingCronService — trial win-back', () => { + const DAY = 24 * 60 * 60 * 1000; + const NOW = new Date('2026-11-20T10:00:00Z').getTime(); + const out = () => ({ examined: 0, winbackOffers: 0, winbackHelp: 0, skipped: 0 }); + + function makeService(opts: { + endedDaysAgo: number; + calls?: number; + paid?: number; + flagged?: boolean; + optedOut?: boolean; + sendOk?: boolean; + }) { + const prisma = { + license: { + findMany: jest.fn().mockResolvedValue([ + { organizationId: 'org-1', expiresAt: new Date(NOW - opts.endedDaysAgo * DAY) }, + ]), + count: jest.fn().mockResolvedValue(opts.paid ?? 0), + }, + orgSettings: { + findUnique: jest.fn().mockResolvedValue(opts.flagged ? { id: 'f' } : null), + upsert: jest.fn().mockResolvedValue({}), + }, + organizationMember: { + findMany: jest.fn().mockResolvedValue([ + { user: { email: 'admin@example.com', name: 'Ada', emailMarketingOptOut: !!opts.optedOut } }, + ]), + }, + toolInvocation: { count: jest.fn().mockResolvedValue(opts.calls ?? 0) }, + } as any; + const email = { sendTrialWinbackEmail: jest.fn().mockResolvedValue(opts.sendOk ?? true) } as any; + const service = new OnboardingCronService(prisma, email, makeLicense(), makeRelease()); + const run = async () => { + const o = out(); + await (service as any).runWinbackPass(NOW, o); + return o; + }; + return { run, prisma, email }; + } + + it('only looks at trials that ended on or after the switch from the licence site', async () => { + const { run, prisma } = makeService({ endedDaysAgo: 8, calls: 3 }); + await run(); + const where = prisma.license.findMany.mock.calls[0][0].where; + expect(where.plan).toBe('trial'); + expect(where.expiresAt.gte).toEqual(new Date('2026-10-06T00:00:00Z')); + }); + + it('a week after, offers 30% to a workspace that used the product', async () => { + const { run, email, prisma } = makeService({ endedDaysAgo: 8, calls: 12 }); + const o = await run(); + expect(email.sendTrialWinbackEmail).toHaveBeenCalledWith('admin@example.com', 'Ada', { + kind: 'discount', + percentOff: 30, + promoCode: 'START30', + endedAgo: 'week', + successfulCalls: 12, + }); + expect(o.winbackOffers).toBe(1); + expect(prisma.orgSettings.upsert.mock.calls[0][0].where.organizationId_key.key).toBe('trial_email_winback7'); + }); + + it('a month after, offers 50%', async () => { + const { run, email } = makeService({ endedDaysAgo: 31, calls: 2 }); + await run(); + expect(email.sendTrialWinbackEmail.mock.calls[0][2]).toMatchObject({ + kind: 'discount', + percentOff: 50, + promoCode: 'WINBACK50', + endedAgo: 'month', + }); + }); + + it('a week after, sends the how-to without a discount to a workspace that never made a call', async () => { + const { run, email } = makeService({ endedDaysAgo: 8, calls: 0 }); + const o = await run(); + expect(email.sendTrialWinbackEmail.mock.calls[0][2]).toEqual({ kind: 'help' }); + expect(o.winbackHelp).toBe(1); + }); + + it('sends nothing more a month after to a workspace that never made a call', async () => { + const { run, email, prisma } = makeService({ endedDaysAgo: 31, calls: 0 }); + await run(); + expect(email.sendTrialWinbackEmail).not.toHaveBeenCalled(); + expect(prisma.orgSettings.upsert).toHaveBeenCalledTimes(1); + }); + + it('sends nothing outside a stage window, nor twice, nor after a purchase', async () => { + for (const opts of [ + { endedDaysAgo: 20, calls: 5 }, + { endedDaysAgo: 40, calls: 5 }, + { endedDaysAgo: 8, calls: 5, flagged: true }, + { endedDaysAgo: 8, calls: 5, paid: 1 }, + ]) { + const { run, email } = makeService(opts); + await run(); + expect(email.sendTrialWinbackEmail).not.toHaveBeenCalled(); + } + }); + + it('respects the marketing opt-out, and closes the stage', async () => { + const { run, email, prisma } = makeService({ endedDaysAgo: 8, calls: 5, optedOut: true }); + await run(); + expect(email.sendTrialWinbackEmail).not.toHaveBeenCalled(); + expect(prisma.orgSettings.upsert).toHaveBeenCalledTimes(1); + }); + + it('retries on the next run when the email could not be sent', async () => { + const { run, prisma } = makeService({ endedDaysAgo: 8, calls: 5, sendOk: false }); + await run(); + expect(prisma.orgSettings.upsert).not.toHaveBeenCalled(); + }); + + it('takes the codes from the environment when set', async () => { + process.env.WINBACK_FIRST_PROMO_CODE = 'SPRING30'; + try { + const { run, email } = makeService({ endedDaysAgo: 8, calls: 1 }); + await run(); + expect(email.sendTrialWinbackEmail.mock.calls[0][2].promoCode).toBe('SPRING30'); + } finally { + delete process.env.WINBACK_FIRST_PROMO_CODE; + } + }); +}); diff --git a/packages/backend/src/ee/cloud/onboarding-cron.service.ts b/packages/backend/src/ee/cloud/onboarding-cron.service.ts index 3bf4de18..d2793a9c 100644 --- a/packages/backend/src/ee/cloud/onboarding-cron.service.ts +++ b/packages/backend/src/ee/cloud/onboarding-cron.service.ts @@ -6,10 +6,33 @@ import { LicenseReleaseService } from '../../license/license-release.service'; const HOURS = (n: number) => n * 60 * 60 * 1000; -/** How long after connecting an AI client to an empty workspace we nudge. */ -const AI_CLIENT_NUDGE_AFTER = HOURS(2); +/** + * How long after signing up a user with an empty workspace gets the first + * nudge: how to connect an app, from the chat or the dashboard. It used to be + * 24 hours (2 for someone who had connected an AI client), by when most of + * those sign-ups had left. The cron runs hourly. + */ +const FIRST_NUDGE_AFTER = HOURS(1); const DAYS = (n: number) => n * 24 * 60 * 60 * 1000; +/** + * Win-back after a Cloud trial ends. Until 6 Oct 2026 the licence site sent + * every Cloud trial the same 50% code a week and a month after expiry (about + * 2,100 emails, one redemption): most of those people never connected an app. + * The site has no usage data; this cron does, so Cloud trials are won back from + * here and the site keeps the self-hosted ones. + * + * Someone who made a successful call gets 30% a week after expiry and 50% a + * month after; someone who never did gets one email on how to connect an app + * from the chat. Only trials that ended from WINBACK_FROM on: earlier ones were + * already sent the site's win-back. + */ +const WINBACK_FROM = new Date('2026-10-06T00:00:00Z'); +const WINBACK_STAGES = [ + { stage: 'winback7', after: DAYS(7), until: DAYS(14), percentOff: 30, codeEnv: 'WINBACK_FIRST_PROMO_CODE', code: 'START30', endedAgo: 'week' }, + { stage: 'winback30', after: DAYS(30), until: DAYS(37), percentOff: 50, codeEnv: 'WINBACK_FINAL_PROMO_CODE', code: 'WINBACK50', endedAgo: 'month' }, +] as const; + /** * Onboarding drip — finds users who registered, verified their email, * but never created a connector, and nudges them via email at two @@ -55,6 +78,8 @@ export class OnboardingCronService { licensesReverified: number; licensesDeactivated: number; licensesReleased: number; + winbackOffers: number; + winbackHelp: number; skipped: number; }> { const now = Date.now(); @@ -71,11 +96,13 @@ export class OnboardingCronService { licensesReverified: 0, licensesDeactivated: 0, licensesReleased: 0, + winbackOffers: 0, + winbackHelp: 0, skipped: 0, }; // Candidate set: verified, ≤2 reminders, not opted out, registered - // between AI_CLIENT_NUDGE_AFTER and 14d ago. We bound at 14d so a user + // between FIRST_NUDGE_AFTER and 14d ago. We bound at 14d so a user // who signed up months ago doesn't suddenly get woken up if we ever // backfill columns. // @@ -90,7 +117,7 @@ export class OnboardingCronService { emailMarketingOptOut: false, onboardingReminderCount: { lt: 2 }, createdAt: { - lte: new Date(now - AI_CLIENT_NUDGE_AFTER), + lte: new Date(now - FIRST_NUDGE_AFTER), gte: new Date(now - HOURS(24 * 14)), }, }, @@ -158,11 +185,11 @@ export class OnboardingCronService { ? now - u.onboardingLastReminderAt.getTime() : Infinity; - // First nudge: 24h after signup, or as soon as an AI client has been - // connected for AI_CLIENT_NUDGE_AFTER, whichever comes first. The - // second one names the client, because that is what the user just did. + // First nudge: an hour after signup (every candidate is at least that + // old). Someone who already connected an AI client is told to ask it + // for the app in the same chat, naming the client. const aiClient = aiClients.get(u.id); - if (u.onboardingReminderCount === 0 && (age >= HOURS(24) || aiClient)) { + if (u.onboardingReminderCount === 0) { const ok = await this.email.sendOnboardingReminderEmail( u.email, u.name || 'there', @@ -215,6 +242,7 @@ export class OnboardingCronService { await this.runActivationPass(now, out); await this.runTrialLifecyclePass(now, out); + await this.runWinbackPass(now, out); out.trialsMarkedExpired = await this.markExpiredTrials(now); // Before nudging anyone about their trial, make sure they actually got one. @@ -239,7 +267,7 @@ export class OnboardingCronService { `trialWarn3=${out.trialWarn3} trialWarn1=${out.trialWarn1} trialExpired=${out.trialExpired} ` + `trialsMarkedExpired=${out.trialsMarkedExpired} trialsRepaired=${out.trialsRepaired} ` + `licensesReverified=${out.licensesReverified} licensesDeactivated=${out.licensesDeactivated} ` + - `licensesReleased=${out.licensesReleased} ` + + `licensesReleased=${out.licensesReleased} winbackOffers=${out.winbackOffers} winbackHelp=${out.winbackHelp} ` + `skipped=${out.skipped}`, ); return out; @@ -330,6 +358,101 @@ export class OnboardingCronService { } } + /** + * Win-back pass (see WINBACK_STAGES). One email per stage and workspace, + * flagged in OrgSettings like the lifecycle emails; a stage is only sent + * inside its window, so a late run never mails a month-old trial its + * one-week offer. Skips workspaces that bought a plan since, and admins who + * opted out of marketing email. + */ + private async runWinbackPass( + now: number, + out: { examined: number; winbackOffers: number; winbackHelp: number; skipped: number }, + ): Promise { + const trials = await this.prisma.license.findMany({ + where: { + plan: 'trial', + status: { in: ['active', 'expired'] }, + organizationId: { not: null }, + expiresAt: { gte: WINBACK_FROM, lte: new Date(now - DAYS(7)) }, + }, + select: { organizationId: true, expiresAt: true }, + }); + + for (const lic of trials) { + const organizationId = lic.organizationId!; + const endedFor = now - lic.expiresAt!.getTime(); + const step = WINBACK_STAGES.find((s) => endedFor >= s.after && endedFor < s.until); + if (!step) continue; + out.examined++; + const flagKey = `trial_email_${step.stage}`; + + const [already, paid] = await Promise.all([ + this.prisma.orgSettings.findUnique({ + where: { organizationId_key: { organizationId, key: flagKey } }, + select: { id: true }, + }), + this.prisma.license.count({ + where: { organizationId, status: 'active', plan: { not: 'trial' } }, + }), + ]); + if (already || paid > 0) { + out.skipped++; + continue; + } + + const [admins, successfulCalls] = await Promise.all([ + this.prisma.organizationMember.findMany({ + where: { organizationId, role: 'ADMIN', deactivatedAt: null }, + select: { user: { select: { email: true, name: true, emailMarketingOptOut: true } } }, + }), + this.prisma.toolInvocation.count({ where: { organizationId, status: 'SUCCESS' } }), + ]); + const used = successfulCalls > 0; + + // Someone who never made a call gets the how-to once, a week after. + if (!used && step.stage !== 'winback7') { + await this.flag(organizationId, flagKey); + out.skipped++; + continue; + } + + const recipients = admins.filter((a) => !a.user.emailMarketingOptOut); + let sentAny = false; + for (const a of recipients) { + const ok = await this.email.sendTrialWinbackEmail( + a.user.email, + a.user.name || 'there', + used + ? { + kind: 'discount', + percentOff: step.percentOff, + promoCode: process.env[step.codeEnv] || step.code, + endedAgo: step.endedAgo, + successfulCalls, + } + : { kind: 'help' }, + ); + if (ok) sentAny = true; + } + + // Nobody to mail (no admin, all opted out) ends the stage too; a failed + // send does not, so the next run retries inside the window. + if (sentAny || recipients.length === 0) await this.flag(organizationId, flagKey); + if (!sentAny) out.skipped++; + else if (used) out.winbackOffers++; + else out.winbackHelp++; + } + } + + private async flag(organizationId: string, key: string): Promise { + await this.prisma.orgSettings.upsert({ + where: { organizationId_key: { organizationId, key } }, + create: { organizationId, key, value: new Date().toISOString() }, + update: { value: new Date().toISOString() }, + }); + } + /** * Flip trials past their `expiresAt` to `status = 'expired'`. * @@ -363,8 +486,8 @@ export class OnboardingCronService { * caps it at one send. */ /** - * Users among `userIds` with a live AI-client connection made at least - * AI_CLIENT_NUDGE_AFTER ago, mapped to the client's display name. + * Users among `userIds` with a live AI-client connection, mapped to the + * client's display name. */ private async connectedAiClients(userIds: string[], now: number): Promise> { const out = new Map(); @@ -373,7 +496,7 @@ export class OnboardingCronService { where: { userId: { in: userIds }, revokedAt: null, - createdAt: { lte: new Date(now - AI_CLIENT_NUDGE_AFTER) }, + createdAt: { lte: new Date(now) }, }, select: { userId: true, clientId: true }, }); diff --git a/packages/backend/src/settings/email.service.ts b/packages/backend/src/settings/email.service.ts index 9d76d350..1adb5039 100644 --- a/packages/backend/src/settings/email.service.ts +++ b/packages/backend/src/settings/email.service.ts @@ -502,25 +502,32 @@ export class EmailService { const client = opts?.aiClient ? escapeHtml(opts.aiClient) : undefined; const safeName = escapeHtml(name); + const button = (href: string, label: string) => + `

${label}

`; + const askExamples = '"Connect my Shopify store" or "Add HubSpot"'; + const subject = client - ? `${opts!.aiClient} is connected. Now give it something to work with` + ? `${opts!.aiClient} is connected. Now ask it for your first app` : dayNumber === 1 - ? 'Connect your first tool in 60 seconds — AnythingMCP' + ? 'Connect your first app in two minutes: AnythingMCP' : 'Still here? Pick a tool to try — AnythingMCP'; const body = client ? `

Hi ${safeName},

-

You connected ${client} to AnythingMCP, but your workspace has no connectors yet, so ${client} has nothing to reach.

-

Add the app you want it to work with: Etsy, Odoo, weclapp, Lexware, Telegram, Shopify and 260 more are ready to install. As soon as one is in, ask ${client} about it in the same chat.

-

Add your first connector →

` +

You connected ${client} to AnythingMCP, but your workspace has no apps yet, so ${client} has nothing to reach.

+

Ask ${client} for one, in the chat: ${askExamples}. It finds the connector, installs it in your workspace and gives you a one-time link where you sign in to the app. No API keys pasted into the chat. (This works when ${client} is connected through ${cloudUrl}/mcp, which is what Claude's connector directory uses.)

+

Prefer clicking? Etsy, Odoo, weclapp, Lexware, Shopify and 260 more are in the dashboard.

+ ${button(storeUrl, 'Add your first app →')}` : dayNumber === 1 ? `

Hi ${safeName},

-

You signed up for AnythingMCP yesterday but haven't connected anything yet. The fastest path to your first AI superpower is picking a ready-made connector from the marketplace: Etsy, Odoo, weclapp, Lexware, Sendcloud, GitHub… 265 are pre-wired.

-

Open the welcome wizard →

-

Should take about a minute.

` +

Your AnythingMCP workspace is ready but has no apps yet. Two ways to add one:

+

From the chat. Add AnythingMCP to Claude (it is in Claude's connector directory) or to ChatGPT with ${cloudUrl}/mcp. Then ask: ${askExamples}. The assistant installs the connector and gives you a one-time link where you sign in to the app. No API keys pasted into the chat.

+

From the dashboard. Pick one of 265 ready-made connectors: Etsy, Odoo, weclapp, Lexware, Sendcloud, GitHub…

+ ${button(welcomeUrl, 'Open the welcome wizard →')} +

Stuck, or the app you need is missing? Reply and tell us which one.

` : `

Hi ${safeName},

Just checking in — your AnythingMCP account is still waiting for its first connector. If anything got in your way, hit reply and tell us what; we read every reply.

-

Pick a connector →

`; + ${button(welcomeUrl, 'Pick a connector →')}`; try { await transport.transporter.sendMail({ @@ -539,12 +546,10 @@ export class EmailService { `, text: `Hi ${name},\n\n${ opts?.aiClient - ? `You connected ${opts.aiClient} to AnythingMCP, but your workspace has no connectors yet.\n\nAdd your first connector: ${storeUrl}` - : `${ - dayNumber === 1 - ? "You signed up for AnythingMCP yesterday but haven't connected anything yet." - : 'Your AnythingMCP account is still waiting for its first connector.' - }\n\nOpen the wizard: ${welcomeUrl}` + ? `You connected ${opts.aiClient} to AnythingMCP, but your workspace has no apps yet. Ask ${opts.aiClient} in the chat, e.g. "Connect my Shopify store": it installs the connector and gives you a one-time link to sign in to the app.\n\nOr add one in the dashboard: ${storeUrl}` + : dayNumber === 1 + ? `Your AnythingMCP workspace is ready but has no apps yet.\n\nFrom the chat: add AnythingMCP to Claude (connector directory) or ChatGPT with ${cloudUrl}/mcp, then ask "Connect my Shopify store". The assistant installs the connector and gives you a one-time link to sign in to the app.\n\nFrom the dashboard: ${welcomeUrl}` + : `Your AnythingMCP account is still waiting for its first connector.\n\nOpen the wizard: ${welcomeUrl}` }\n\nUnsubscribe: ${unsubUrl}`, }); this.logger.log( @@ -704,6 +709,99 @@ export class EmailService { } } + /** + * Win-back after a Cloud trial ended (onboarding cron). Someone who used the + * product gets a discount code; someone who never made a call gets the + * shortest way to a first result instead, since a discount does not help + * with a product they never saw work. Marketing: the caller honours the + * opt-out. + */ + async sendTrialWinbackEmail( + to: string, + name: string, + offer: + | { kind: 'discount'; percentOff: number; promoCode: string; endedAgo: 'week' | 'month'; successfulCalls: number } + | { kind: 'help' }, + ): Promise { + const transport = await this.createTransporter(); + if (!transport) { + this.logger.warn(`Skipping trial win-back email to ${to}: no SMTP configured`); + return false; + } + + const cloudUrl = process.env.CLOUD_PUBLIC_URL || 'https://cloud.anythingmcp.com'; + const marketingUrl = process.env.MARKETING_URL || 'https://anythingmcp.com'; + const returnUrl = encodeURIComponent(`${cloudUrl}/settings/license/activate`); + const unsubUrl = `${cloudUrl}/settings/profile`; + const button = (href: string, label: string) => + `

${label}

`; + + let subject: string; + let body: string; + let text: string; + if (offer.kind === 'discount') { + const promo = encodeURIComponent(offer.promoCode); + const pricingUrl = `${marketingUrl}/pricing?promo=${promo}&return_url=${returnUrl}`; + const calls = + offer.successfulCalls > 0 + ? ` Your agents made ${offer.successfulCalls} successful tool call${offer.successfulCalls === 1 ? '' : 's'} during the trial, and your connectors are still there.` + : ' Your connectors are still there.'; + subject = + offer.endedAgo === 'week' + ? `${offer.percentOff}% off your first 3 months of AnythingMCP` + : `One more try? ${offer.percentOff}% off AnythingMCP for 3 months`; + body = `

Hi ${name},

+

Your AnythingMCP trial ended a ${offer.endedAgo} ago.${calls}

+

If you'd like to pick up where you left off, here is ${offer.percentOff}% off your first 3 months on any Cloud plan. The code is applied when you use the button, or enter it at checkout:

+

${offer.promoCode}

+ ${button(pricingUrl, `Reactivate with ${offer.percentOff}% off →`)} +

Not the right time, or something was missing? Reply and tell us; we read every answer.

`; + text = + `Hi ${name},\n\nYour AnythingMCP trial ended a ${offer.endedAgo} ago. Here is ${offer.percentOff}% off your first 3 months on any Cloud plan.\n\n` + + `Code: ${offer.promoCode}\nReactivate: ${pricingUrl}\n\nUnsubscribe: ${unsubUrl}`; + } else { + const pricingUrl = `${marketingUrl}/pricing?return_url=${returnUrl}`; + const connectorsUrl = `${cloudUrl}/connectors`; + subject = 'Connect your first app to Claude in two minutes'; + body = `

Hi ${name},

+

Your AnythingMCP trial ended last week before you connected an app, so you never saw the part that matters. It is quicker than it looks.

+

You can set it up from the chat. Add AnythingMCP to Claude (it is in Claude's connector directory) or to ChatGPT with ${cloudUrl}/mcp, then just ask: "Connect my Shopify store" or "Add HubSpot". The assistant finds the connector, installs it in your workspace and gives you a one-time link where you sign in to the app. No API keys pasted into the chat.

+

You can also pick from 200+ apps in the dashboard: ${connectorsUrl.replace(/^https?:\/\//, '')}.

+

Your workspace is still there. To use it again, choose a plan:

+ ${button(pricingUrl, 'See plans →')} +

Not sure it fits what you need? Reply with the app you want to connect and we'll tell you honestly.

`; + text = + `Hi ${name},\n\nYour AnythingMCP trial ended last week before you connected an app.\n\n` + + `You can set it up from the chat: add AnythingMCP to Claude (connector directory) or ChatGPT with ${cloudUrl}/mcp and ask "Connect my Shopify store". ` + + `The assistant installs the connector and gives you a one-time link to sign in to the app.\n\n` + + `Or pick from 200+ apps: ${connectorsUrl}\nPlans: ${pricingUrl}\n\nUnsubscribe: ${unsubUrl}`; + } + + try { + await transport.transporter.sendMail({ + from: transport.from, + to, + subject, + html: ` +
+ ${body} +
+

+ You're receiving this because you tried AnythingMCP at cloud.anythingmcp.com. + Unsubscribe from these emails. +

+
+ `, + text, + }); + this.logger.log(`Trial win-back (${offer.kind}) email sent to ${to}`); + return true; + } catch (err) { + this.logger.error(`Failed to send trial win-back email to ${to}: ${err}`); + return false; + } + } + // ── External API Fallback ───────────────────────────────────────────────── private async sendViaExternalApi(