Просмотр исходного кода

Let free workshops email documents from the platform address (#462)

* Let free workshops email documents from the platform address, and identify users in analytics

Free plans can send invoices, quotes and inspections through the platform mail, capped at 50 a day per workshop. Sending through a workshop's own provider still needs custom email. Refused sends open the upgrade dialog instead of a raw error.

PostHog now identifies the signed-in user and groups by workshop, resets on sign-out, and records sign-up, workshop, customer, document email and upgrade events.

* Count the platform mail cap in the database, show email to free workshops

The daily cap on platform mail was held in memory, so a restart or a second server handed out a fresh allowance, and a failed attempt still used a slot. It is now a row per workshop and day, taken atomically just before the send and given back if the send fails. Pages that showed the email controls only on plans with custom email now show them to every workshop that can send, and deleting an account resets the analytics identity like signing out does.
Bernt Christian Egeland 1 неделя назад
Родитель
Сommit
f0a08003e5
45 измененных файлов с 588 добавлено и 108 удалено
  1. 1 0
      messages/de/common.json
  2. 1 0
      messages/en/common.json
  3. 1 0
      messages/es/common.json
  4. 1 0
      messages/fr/common.json
  5. 1 0
      messages/it/common.json
  6. 1 0
      messages/lt/common.json
  7. 1 0
      messages/nb/common.json
  8. 1 0
      messages/nl/common.json
  9. 1 0
      messages/pl/common.json
  10. 1 0
      messages/pt-BR/common.json
  11. 1 0
      messages/pt-PT/common.json
  12. 1 0
      messages/ru/common.json
  13. 1 0
      messages/tr/common.json
  14. 12 0
      prisma/migrations/20261015140000_platform_mail_usage/migration.sql
  15. 15 0
      prisma/schema/organization.prisma
  16. 151 0
      src/__tests__/features/email/email-allowance.test.ts
  17. 4 1
      src/__tests__/features/email/inspection-email-certificate.test.ts
  18. 38 3
      src/__tests__/lib/analytics.test.ts
  19. 5 1
      src/app/(authenticated)/inspections/[id]/page.tsx
  20. 5 0
      src/app/(authenticated)/layout.tsx
  21. 2 0
      src/app/(authenticated)/no-access-actions.tsx
  22. 6 2
      src/app/(authenticated)/page.tsx
  23. 3 1
      src/app/(authenticated)/quotes/[id]/page.tsx
  24. 2 0
      src/app/(authenticated)/settings/data/data-settings.tsx
  25. 3 1
      src/app/(authenticated)/work-orders/page.tsx
  26. 2 0
      src/app/(public)/auth/sign-up/sign-up-form.tsx
  27. 2 0
      src/app/(public)/auth/verify-email/page.tsx
  28. 22 0
      src/components/analytics-identity.tsx
  29. 2 0
      src/components/app-sidebar.tsx
  30. 2 1
      src/components/notify-customer-dialog.tsx
  31. 13 2
      src/components/upgrade-gate.tsx
  32. 2 0
      src/features/customers/Components/CustomerForm.tsx
  33. 89 81
      src/features/email/Actions/emailActions.ts
  34. 12 9
      src/features/email/Actions/emailTemplateActions.ts
  35. 2 0
      src/features/email/Components/EmailDesigner.tsx
  36. 5 1
      src/features/email/Components/SendEmailDialog.tsx
  37. 109 0
      src/features/email/Lib/emailAllowance.ts
  38. 4 1
      src/features/inspections/Components/InspectionShareDialog.tsx
  39. 2 0
      src/features/onboarding/Components/OnboardingForm.tsx
  40. 5 1
      src/features/quotes/Components/QuotePageClient.tsx
  41. 4 1
      src/features/quotes/Components/QuoteShareDialog.tsx
  42. 4 1
      src/features/vehicles/Components/service-detail/ShareDialog.tsx
  43. 4 1
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  44. 42 0
      src/lib/analytics.ts
  45. 2 0
      src/lib/backup/manifest.ts

+ 1 - 0
messages/de/common.json

@@ -85,6 +85,7 @@
     "title": "Upgrade, um weiterzumachen",
     "maxCustomers": "Ihr Tarif umfasst bis zu {limit} Kunden, und diese Zahl ist erreicht. Mit einem Upgrade legen Sie so viele an, wie Sie brauchen.",
     "maxUsers": "Ihr Tarif umfasst {limit, plural, one {ein Teammitglied} other {# Teammitglieder}}. Mit einem Upgrade laden Sie weitere Personen ein.",
+    "maxPlatformMail": "Ihr Tarif versendet bis zu {limit} E-Mails pro Tag über die Torqvoice-Adresse, und die heutigen sind aufgebraucht. Upgraden Sie, um so viele zu senden, wie Sie brauchen, oder senden Sie den Rest morgen.",
     "generic": "Diese Funktion ist in Ihrem aktuellen Tarif nicht enthalten.",
     "featureNotIncluded": "{feature} ist in Ihrem aktuellen Tarif nicht enthalten.",
     "notNow": "Später"

+ 1 - 0
messages/en/common.json

@@ -85,6 +85,7 @@
     "title": "Upgrade to keep going",
     "maxCustomers": "Your plan includes up to {limit} customers, and you have reached that number. Upgrade to add as many as you need.",
     "maxUsers": "Your plan includes {limit, plural, one {one team member} other {# team members}}. Upgrade to invite more people.",
+    "maxPlatformMail": "Your plan sends up to {limit} emails a day from the Torqvoice address, and today's are used. Upgrade to send as many as you need, or send the rest tomorrow.",
     "generic": "This feature is not included in your current plan.",
     "featureNotIncluded": "{feature} is not included in your current plan.",
     "notNow": "Not now"

+ 1 - 0
messages/es/common.json

@@ -85,6 +85,7 @@
     "title": "Mejore su plan para continuar",
     "maxCustomers": "Su plan incluye hasta {limit} clientes y ya ha alcanzado ese número. Mejore el plan para añadir todos los que necesite.",
     "maxUsers": "Su plan incluye {limit, plural, one {un miembro del equipo} other {# miembros del equipo}}. Mejore el plan para invitar a más personas.",
+    "maxPlatformMail": "Su plan envía hasta {limit} correos al día desde la dirección de Torqvoice, y los de hoy ya se han usado. Mejore su plan para enviar todos los que necesite, o envíe el resto mañana.",
     "generic": "Esta función no está incluida en su plan actual.",
     "featureNotIncluded": "{feature} no está incluido en su plan actual.",
     "notNow": "Ahora no"

+ 1 - 0
messages/fr/common.json

@@ -85,6 +85,7 @@
     "title": "Passez au niveau supérieur pour continuer",
     "maxCustomers": "Votre offre inclut jusqu'à {limit} clients et vous avez atteint ce nombre. Passez au niveau supérieur pour en ajouter autant que nécessaire.",
     "maxUsers": "Votre offre inclut {limit, plural, one {un membre d'équipe} other {# membres d'équipe}}. Passez au niveau supérieur pour inviter d'autres personnes.",
+    "maxPlatformMail": "Votre offre envoie jusqu'à {limit} e-mails par jour depuis l'adresse Torqvoice, et ceux d'aujourd'hui sont épuisés. Passez à une offre supérieure pour en envoyer autant que nécessaire, ou envoyez le reste demain.",
     "generic": "Cette fonctionnalité n'est pas incluse dans votre offre actuelle.",
     "featureNotIncluded": "{feature} n'est pas inclus dans votre offre actuelle.",
     "notNow": "Pas maintenant"

+ 1 - 0
messages/it/common.json

@@ -85,6 +85,7 @@
     "title": "Passi a un piano superiore per continuare",
     "maxCustomers": "Il Suo piano include fino a {limit} clienti e ha raggiunto quel numero. Passi a un piano superiore per aggiungerne quanti ne servono.",
     "maxUsers": "Il Suo piano include {limit, plural, one {un membro del team} other {# membri del team}}. Passi a un piano superiore per invitare altre persone.",
+    "maxPlatformMail": "Il Suo piano invia fino a {limit} email al giorno dall'indirizzo Torqvoice, e quelle di oggi sono esaurite. Passi a un piano superiore per inviarne quante ne servono, oppure invii le restanti domani.",
     "generic": "Questa funzione non è inclusa nel Suo piano attuale.",
     "featureNotIncluded": "{feature} non è incluso nel Suo piano attuale.",
     "notNow": "Non ora"

+ 1 - 0
messages/lt/common.json

@@ -85,6 +85,7 @@
     "title": "Atnaujinkite planą, kad tęstumėte",
     "maxCustomers": "Jūsų planas apima iki {limit} klientų, ir šis skaičius pasiektas. Atnaujinkite planą, kad pridėtumėte tiek, kiek reikia.",
     "maxUsers": "Jūsų planas apima {limit, plural, one {# komandos narį} few {# komandos narius} many {# komandos nario} other {# komandos narių}}. Atnaujinkite planą, kad pakviestumėte daugiau žmonių.",
+    "maxPlatformMail": "Jūsų planas leidžia išsiųsti iki {limit} el. laiškų per dieną iš Torqvoice adreso, o šiandienos limitas išnaudotas. Atnaujinkite planą, kad galėtumėte siųsti kiek reikia, arba išsiųskite likusius rytoj.",
     "generic": "Ši funkcija neįtraukta į jūsų dabartinį planą.",
     "featureNotIncluded": "{feature} neįtraukta į jūsų dabartinį planą.",
     "notNow": "Ne dabar"

+ 1 - 0
messages/nb/common.json

@@ -85,6 +85,7 @@
     "title": "Oppgrader for å fortsette",
     "maxCustomers": "Planen din inkluderer opptil {limit} kunder, og du har nådd det antallet. Oppgrader for å legge til så mange du trenger.",
     "maxUsers": "Planen din inkluderer {limit, plural, one {ett teammedlem} other {# teammedlemmer}}. Oppgrader for å invitere flere.",
+    "maxPlatformMail": "Planen din sender opptil {limit} e-poster om dagen fra Torqvoice-adressen, og dagens er brukt opp. Oppgrader for å sende så mange du trenger, eller send resten i morgen.",
     "generic": "Denne funksjonen er ikke inkludert i planen din.",
     "featureNotIncluded": "{feature} er ikke inkludert i planen din.",
     "notNow": "Ikke nå"

+ 1 - 0
messages/nl/common.json

@@ -85,6 +85,7 @@
     "title": "Upgrade om verder te gaan",
     "maxCustomers": "Uw abonnement omvat maximaal {limit} klanten en dat aantal is bereikt. Upgrade om er zoveel toe te voegen als u nodig heeft.",
     "maxUsers": "Uw abonnement omvat {limit, plural, one {één teamlid} other {# teamleden}}. Upgrade om meer mensen uit te nodigen.",
+    "maxPlatformMail": "Uw abonnement verstuurt tot {limit} e-mails per dag vanaf het Torqvoice-adres, en die van vandaag zijn op. Upgrade om er zoveel te versturen als u nodig hebt, of verstuur de rest morgen.",
     "generic": "Deze functie is niet inbegrepen in uw huidige abonnement.",
     "featureNotIncluded": "{feature} is niet inbegrepen in uw huidige abonnement.",
     "notNow": "Niet nu"

+ 1 - 0
messages/pl/common.json

@@ -85,6 +85,7 @@
     "title": "Zmień plan, aby kontynuować",
     "maxCustomers": "Twój plan obejmuje do {limit} klientów i ta liczba została osiągnięta. Zmień plan, aby dodać tylu, ilu potrzebujesz.",
     "maxUsers": "Twój plan obejmuje {limit, plural, one {jednego członka zespołu} few {# członków zespołu} many {# członków zespołu} other {# członka zespołu}}. Zmień plan, aby zaprosić więcej osób.",
+    "maxPlatformMail": "Twój plan wysyła do {limit} e-maili dziennie z adresu Torqvoice, a dzisiejszy limit został wykorzystany. Przejdź na wyższy plan, aby wysyłać bez ograniczeń, lub wyślij resztę jutro.",
     "generic": "Ta funkcja nie jest dostępna w Twoim obecnym planie.",
     "featureNotIncluded": "{feature} nie jest dostępne w Twoim obecnym planie.",
     "notNow": "Nie teraz"

+ 1 - 0
messages/pt-BR/common.json

@@ -85,6 +85,7 @@
     "title": "Faça upgrade para continuar",
     "maxCustomers": "Seu plano inclui até {limit} clientes, e você chegou a esse número. Faça upgrade para adicionar quantos precisar.",
     "maxUsers": "Seu plano inclui {limit, plural, one {um membro na equipe} other {# membros na equipe}}. Faça upgrade para convidar mais pessoas.",
+    "maxPlatformMail": "Seu plano envia até {limit} e-mails por dia pelo endereço da Torqvoice, e os de hoje já foram usados. Faça upgrade para enviar quantos precisar ou envie o restante amanhã.",
     "generic": "Este recurso não está incluído no seu plano atual.",
     "featureNotIncluded": "{feature} não está incluído no seu plano atual.",
     "notNow": "Agora não"

+ 1 - 0
messages/pt-PT/common.json

@@ -85,6 +85,7 @@
     "title": "Mude de plano para continuar",
     "maxCustomers": "O seu plano inclui até {limit} clientes e já atingiu esse número. Mude de plano para adicionar todos os clientes de que precisa.",
     "maxUsers": "O seu plano inclui {limit, plural, one {um membro da equipa} other {# membros da equipa}}. Mude de plano para convidar mais pessoas.",
+    "maxPlatformMail": "O seu plano envia até {limit} e-mails por dia a partir do endereço da Torqvoice, e os de hoje já foram utilizados. Atualize o plano para enviar quantos precisar ou envie os restantes amanhã.",
     "generic": "Esta funcionalidade não está incluída no seu plano atual.",
     "featureNotIncluded": "{feature} não está incluído no seu plano atual.",
     "notNow": "Agora não"

+ 1 - 0
messages/ru/common.json

@@ -85,6 +85,7 @@
     "title": "Перейдите на другой тариф, чтобы продолжить",
     "maxCustomers": "Ваш тариф включает до {limit} клиентов, и это число достигнуто. Перейдите на другой тариф, чтобы добавлять столько, сколько нужно.",
     "maxUsers": "Ваш тариф включает {limit, plural, one {# участника команды} few {# участника команды} many {# участников команды} other {# участника команды}}. Перейдите на другой тариф, чтобы пригласить больше людей.",
+    "maxPlatformMail": "Ваш тариф позволяет отправлять до {limit} писем в день с адреса Torqvoice, и сегодняшний лимит исчерпан. Перейдите на тариф выше, чтобы отправлять сколько нужно, или отправьте остальные завтра.",
     "generic": "Эта функция не входит в ваш текущий тариф.",
     "featureNotIncluded": "{feature} не входит в ваш текущий тариф.",
     "notNow": "Не сейчас"

+ 1 - 0
messages/tr/common.json

@@ -85,6 +85,7 @@
     "title": "Devam etmek için planınızı yükseltin",
     "maxCustomers": "Planınız en fazla {limit} müşteri içeriyor ve bu sayıya ulaştınız. İhtiyacınız kadar müşteri eklemek için planınızı yükseltin.",
     "maxUsers": "Planınız {limit, plural, one {bir ekip üyesi} other {# ekip üyesi}} içeriyor. Daha fazla kişi davet etmek için planınızı yükseltin.",
+    "maxPlatformMail": "Planınız Torqvoice adresinden günde en fazla {limit} e-posta gönderir ve bugünkü hakkınız doldu. İhtiyacınız kadar göndermek için planınızı yükseltin veya kalanları yarın gönderin.",
     "generic": "Bu özellik mevcut planınıza dahil değil.",
     "featureNotIncluded": "{feature} mevcut planınıza dahil değil.",
     "notNow": "Şimdi değil"

+ 12 - 0
prisma/migrations/20261015140000_platform_mail_usage/migration.sql

@@ -0,0 +1,12 @@
+-- Customer mail sent through the platform's own address, per workshop and
+-- UTC day, for the daily cap on plans without custom email. A new table with
+-- no data to move.
+
+-- CreateTable
+CREATE TABLE "platform_mail_usage" (
+    "organizationId" TEXT NOT NULL,
+    "day" TEXT NOT NULL,
+    "count" INTEGER NOT NULL DEFAULT 0,
+
+    CONSTRAINT "platform_mail_usage_pkey" PRIMARY KEY ("organizationId","day")
+);

+ 15 - 0
prisma/schema/organization.prisma

@@ -188,3 +188,18 @@ model TeamInvitation {
   @@index([token])
   @@map("team_invitations")
 }
+
+/// Customer mail a workshop sent through the platform's own address in one
+/// day, for the daily cap on plans without custom email
+/// (src/features/email/Lib/emailAllowance.ts). In the database rather than in
+/// memory, so a restart or a second server does not hand out a fresh allowance.
+/// A slot is taken just before a mail goes out and given back if it fails.
+model PlatformMailUsage {
+  organizationId String
+  /// The UTC day, YYYY-MM-DD
+  day            String
+  count          Int    @default(0)
+
+  @@id([organizationId, day])
+  @@map("platform_mail_usage")
+}

+ 151 - 0
src/__tests__/features/email/email-allowance.test.ts

@@ -0,0 +1,151 @@
+/**
+ * Who may mail a customer, and how often.
+ *
+ * Emailing an invoice used to need a paid plan, so a free workshop built its
+ * first invoice and then could not send it. A workshop with no mail provider
+ * of its own now sends from the platform's address on every plan, with a
+ * daily cap on plans without custom email. The cap lives in the database, so
+ * a restart or a second server does not reset it, and a slot is only spent
+ * on a send that actually goes out.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const getFeatures = vi.fn()
+vi.mock('@/lib/features', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('@/lib/features')>()),
+  getFeatures: (...args: unknown[]) => getFeatures(...args),
+}))
+
+const channelProvider = vi.fn()
+vi.mock('@/features/integrations/Lib/messaging', () => ({
+  channelProvider: (...args: unknown[]) => channelProvider(...args),
+}))
+
+// The usage table, keyed by workshop and day, with the two operations the
+// helper uses: create-if-missing and a conditional increment or decrement.
+const usage = new Map<string, number>()
+const keyOf = (w: { organizationId: string; day: string }) => `${w.organizationId}:${w.day}`
+const platformMailUsage = {
+  createMany: vi.fn(async ({ data }: { data: { organizationId: string; day: string }[] }) => {
+    for (const row of data) if (!usage.has(keyOf(row))) usage.set(keyOf(row), 0)
+    return { count: data.length }
+  }),
+  updateMany: vi.fn(
+    async ({
+      where,
+      data,
+    }: {
+      where: { organizationId: string; day: string; count: { lt?: number; gt?: number } }
+      data: { count: { increment?: number; decrement?: number } }
+    }) => {
+      const current = usage.get(keyOf(where))
+      if (current === undefined) return { count: 0 }
+      if (where.count.lt !== undefined && !(current < where.count.lt)) return { count: 0 }
+      if (where.count.gt !== undefined && !(current > where.count.gt)) return { count: 0 }
+      usage.set(keyOf(where), current + (data.count.increment ?? 0) - (data.count.decrement ?? 0))
+      return { count: 1 }
+    }
+  ),
+}
+vi.mock('@/lib/db', () => ({ db: { platformMailUsage } }))
+
+const {
+  requireEmailSending,
+  canEmailCustomers,
+  withPlatformMailAllowance,
+  PLATFORM_MAIL_DAILY_LIMIT,
+} = await import('@/features/email/Lib/emailAllowance')
+
+const noon = new Date('2026-10-15T12:00:00Z')
+const send = () => Promise.resolve('sent')
+
+async function sendTimes(times: number, org = 'org', now = noon) {
+  for (let i = 0; i < times; i++) await withPlatformMailAllowance(org, 'platform', send, now)
+}
+
+beforeEach(() => {
+  usage.clear()
+  getFeatures.mockReset().mockResolvedValue({ smtp: false })
+  channelProvider.mockReset().mockResolvedValue(null)
+})
+
+describe('requireEmailSending', () => {
+  it('lets a free workshop without a mail provider send from the platform address', async () => {
+    await expect(requireEmailSending('org')).resolves.toBe('platform')
+  })
+
+  it('never limits a plan with custom email', async () => {
+    getFeatures.mockResolvedValue({ smtp: true })
+    await expect(requireEmailSending('org')).resolves.toBe('unlimited')
+  })
+
+  it('refuses a free workshop that still has its own provider connected', async () => {
+    channelProvider.mockResolvedValue('smtp')
+    await expect(requireEmailSending('org')).rejects.toMatchObject({
+      name: 'FeatureGatedError',
+      feature: 'smtp',
+    })
+  })
+
+  it('spends nothing by itself', async () => {
+    for (let i = 0; i < PLATFORM_MAIL_DAILY_LIMIT + 5; i++) await requireEmailSending('org')
+    expect(usage.size).toBe(0)
+  })
+})
+
+describe('canEmailCustomers', () => {
+  it('shows the email controls on the free plan', async () => {
+    await expect(canEmailCustomers('org')).resolves.toBe(true)
+  })
+
+  it('hides them when the plan refuses the workshop its own provider', async () => {
+    channelProvider.mockResolvedValue('smtp')
+    await expect(canEmailCustomers('org')).resolves.toBe(false)
+  })
+})
+
+describe('withPlatformMailAllowance', () => {
+  it('stops a free workshop at the daily limit, with the limit for the dialog', async () => {
+    await sendTimes(PLATFORM_MAIL_DAILY_LIMIT)
+    await expect(withPlatformMailAllowance('org', 'platform', send, noon)).rejects.toMatchObject({
+      name: 'FeatureGatedError',
+      feature: 'platformMailDaily',
+      limit: PLATFORM_MAIL_DAILY_LIMIT,
+    })
+  })
+
+  it('does not send when the limit is reached', async () => {
+    await sendTimes(PLATFORM_MAIL_DAILY_LIMIT)
+    const blocked = vi.fn(send)
+    await expect(withPlatformMailAllowance('org', 'platform', blocked, noon)).rejects.toThrow()
+    expect(blocked).not.toHaveBeenCalled()
+  })
+
+  it('gives the slot back when the send fails', async () => {
+    await sendTimes(PLATFORM_MAIL_DAILY_LIMIT - 1)
+    const failing = () => Promise.reject(new Error('Quote not found'))
+    await expect(withPlatformMailAllowance('org', 'platform', failing, noon)).rejects.toThrow(
+      'Quote not found'
+    )
+    // The failed attempt left the last slot free.
+    await expect(withPlatformMailAllowance('org', 'platform', send, noon)).resolves.toBe('sent')
+  })
+
+  it('counts each workshop on its own', async () => {
+    await sendTimes(PLATFORM_MAIL_DAILY_LIMIT, 'busy')
+    await expect(withPlatformMailAllowance('quiet', 'platform', send, noon)).resolves.toBe('sent')
+  })
+
+  it('starts the count again the next day', async () => {
+    await sendTimes(PLATFORM_MAIL_DAILY_LIMIT)
+    const tomorrow = new Date('2026-10-16T00:00:01Z')
+    await expect(withPlatformMailAllowance('org', 'platform', send, tomorrow)).resolves.toBe('sent')
+  })
+
+  it('never counts a plan with custom email', async () => {
+    for (let i = 0; i < PLATFORM_MAIL_DAILY_LIMIT + 5; i++)
+      await withPlatformMailAllowance('org', 'unlimited', send, noon)
+    expect(usage.size).toBe(0)
+  })
+})

+ 4 - 1
src/__tests__/features/email/inspection-email-certificate.test.ts

@@ -26,7 +26,10 @@ vi.mock('@/lib/with-auth', () => ({
     }
   },
 }))
-vi.mock('@/lib/features', () => ({ requireFeature: vi.fn() }))
+vi.mock('@/features/email/Lib/emailAllowance', () => ({
+  requireEmailSending: vi.fn().mockResolvedValue('unlimited'),
+  withPlatformMailAllowance: (_org: string, _mode: string, send: () => Promise<unknown>) => send(),
+}))
 vi.mock('@/lib/demo', () => ({ demoGuard: vi.fn() }))
 vi.mock('@/lib/document-lock.server', () => ({ markInvoiceSent: vi.fn(), markQuoteSent: vi.fn() }))
 vi.mock('@/features/invoices/Lib/issueInvoice', () => ({ issueInvoice: vi.fn() }))

+ 38 - 3
src/__tests__/lib/analytics.test.ts

@@ -6,7 +6,14 @@
  */
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 
-const posthog = vi.hoisted(() => ({ capture: vi.fn(), register: vi.fn() }))
+const posthog = vi.hoisted(() => ({
+  capture: vi.fn(),
+  register: vi.fn(),
+  identify: vi.fn(),
+  group: vi.fn(),
+  reset: vi.fn(),
+  get_distinct_id: vi.fn(),
+}))
 vi.mock('posthog-js', () => ({ default: posthog }))
 
 async function freshAnalytics() {
@@ -15,8 +22,8 @@ async function freshAnalytics() {
 }
 
 beforeEach(() => {
-  posthog.capture.mockReset()
-  posthog.register.mockReset()
+  for (const fn of Object.values(posthog)) fn.mockReset()
+  posthog.get_distinct_id.mockReturnValue('anonymous-browser')
 })
 
 describe('analytics', () => {
@@ -66,4 +73,32 @@ describe('analytics', () => {
     analyticsStarted()
     expect(posthog.capture).toHaveBeenCalledTimes(20)
   })
+
+  it('ties the browser to the account and workshop, held like any event', async () => {
+    const { identifyUser, analyticsStarted } = await freshAnalytics()
+    identifyUser('user-1', 'org-1')
+    expect(posthog.identify).not.toHaveBeenCalled()
+
+    analyticsStarted()
+    expect(posthog.identify).toHaveBeenCalledWith('user-1')
+    expect(posthog.group).toHaveBeenCalledWith('organization', 'org-1')
+  })
+
+  it('does not identify again on every page once the browser is that account', async () => {
+    const { identifyUser, analyticsStarted } = await freshAnalytics()
+    analyticsStarted()
+    posthog.get_distinct_id.mockReturnValue('user-1')
+    identifyUser('user-1', 'org-2')
+
+    expect(posthog.identify).not.toHaveBeenCalled()
+    // The workshop can change without the person changing.
+    expect(posthog.group).toHaveBeenCalledWith('organization', 'org-2')
+  })
+
+  it('forgets the account on sign-out', async () => {
+    const { resetAnalytics, analyticsStarted } = await freshAnalytics()
+    analyticsStarted()
+    resetAnalytics()
+    expect(posthog.reset).toHaveBeenCalledTimes(1)
+  })
 })

+ 5 - 1
src/app/(authenticated)/inspections/[id]/page.tsx

@@ -17,6 +17,7 @@ import {
   markTypeCatalogue,
 } from '@/features/condition-map/Lib/loadMarks.server'
 import { getLocale } from 'next-intl/server'
+import { canEmailCustomers } from '@/features/email/Lib/emailAllowance'
 import { getFeatures } from '@/lib/features'
 import { redirect } from 'next/navigation'
 
@@ -48,6 +49,9 @@ export default async function InspectionDetailPage({
         ? markTypeCatalogue(authContext.organizationId, await getLocale())
         : [],
     ])
+  const emailEnabled = authContext?.organizationId
+    ? await canEmailCustomers(authContext.organizationId)
+    : false
 
   return (
     <>
@@ -57,7 +61,7 @@ export default async function InspectionDetailPage({
           inspection={result.data as InspectionData}
           organizationId={authContext?.organizationId ?? ''}
           smsEnabled={features?.sms ?? false}
-          emailEnabled={features?.smtp ?? false}
+          emailEnabled={emailEnabled}
           telegramEnabled={features?.telegram ?? false}
           statusReports={
             statusReports.success && statusReports.data

+ 5 - 0
src/app/(authenticated)/layout.tsx

@@ -47,6 +47,7 @@ import { technicianIdsForUser } from '@/features/time-tracking/Lib/timeEntries'
 import { TimeClockProvider } from '@/features/time-tracking/Components/TimeClockProvider'
 import { RealtimeProvider } from '@/features/realtime/RealtimeProvider'
 import { SaveShortcutListener } from '@/components/save-shortcut-listener'
+import { AnalyticsIdentity } from '@/components/analytics-identity'
 
 export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
   const data = await getLayoutData()
@@ -330,6 +331,10 @@ export default async function DashboardLayout({ children }: { children: React.Re
                         <RealtimeProvider>
                           <TimeClockProvider technicianIds={technicianIds}>
                             <SaveShortcutListener />
+                            <AnalyticsIdentity
+                              userId={data.userId}
+                              organizationId={data.organizationId}
+                            />
                             <AppSidebar
                               companyLogo={data.companyLogo}
                               organizations={data.organizations}

+ 2 - 0
src/app/(authenticated)/no-access-actions.tsx

@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { Button } from '@/components/ui/button'
 import { signOut } from '@/lib/auth-client'
+import { resetAnalytics } from '@/lib/analytics'
 
 /** The one thing this account can still do. */
 export function NoAccessSignOut() {
@@ -16,6 +17,7 @@ export function NoAccessSignOut() {
       variant="outline"
       onClick={async () => {
         await signOut()
+        resetAnalytics()
         router.push('/auth/sign-in')
       }}
     >

+ 6 - 2
src/app/(authenticated)/page.tsx

@@ -17,6 +17,7 @@ import { getQuoteResponses } from '@/features/quotes/Actions/quoteResponseAction
 import { getAuthContext } from '@/lib/get-auth-context'
 import { PermissionSubject } from '@/lib/permissions'
 import { getViewerAccess, readIfAllowed } from '@/lib/viewer-access'
+import { canEmailCustomers } from '@/features/email/Lib/emailAllowance'
 import { getFeatures } from '@/lib/features'
 import { getRecentSmsThreads } from '@/features/sms/Actions/smsActions'
 import { getNotifications } from '@/features/notifications/Actions/notificationActions'
@@ -36,6 +37,9 @@ import { getInspectionsDueSummary } from '@/features/vehicles/Actions/inspection
 export default async function DashboardPage() {
   const auth = await getAuthContext()
   const features = auth ? await getFeatures(auth.organizationId) : null
+  // Every plan can email customers from the platform's address; only a workshop
+  // left with its own provider on a plan that no longer includes it cannot.
+  const emailEnabled = auth ? await canEmailCustomers(auth.organizationId) : false
   const smsEnabled = features?.sms ?? false
   // The plan decides whether the portal exists at all; the setting decides
   // whether this shop turned it on. Gate the query on the first so a shop
@@ -134,7 +138,7 @@ export default async function DashboardPage() {
             <MyActiveJobs
               jobs={myJobs}
               smsEnabled={features?.sms ?? false}
-              emailEnabled={features?.smtp ?? false}
+              emailEnabled={emailEnabled}
               telegramEnabled={features?.telegram ?? false}
               showEmpty
             />
@@ -185,7 +189,7 @@ export default async function DashboardPage() {
         <MyActiveJobs
           jobs={myJobs}
           smsEnabled={features?.sms ?? false}
-          emailEnabled={features?.smtp ?? false}
+          emailEnabled={emailEnabled}
           telegramEnabled={features?.telegram ?? false}
         />
         <DashboardClient

+ 3 - 1
src/app/(authenticated)/quotes/[id]/page.tsx

@@ -13,6 +13,7 @@ import { getLaborPresetsList } from '@/features/labor-presets/Actions/laborPrese
 import { getInventoryPartsList } from '@/features/inventory/Actions/inventoryActions'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getQuoteLockState } from '@/lib/document-lock.server'
+import { canEmailCustomers } from '@/features/email/Lib/emailAllowance'
 import { getFeatures } from '@/lib/features'
 import { PageHeader } from '@/components/page-header'
 import { readLineTaxSettings, TAX_APPLIES_SETTING_KEYS } from '@/features/settings/Lib/workshopTax'
@@ -40,6 +41,7 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
 
   const orgId = authContext?.organizationId
   const features = orgId ? await getFeatures(orgId) : null
+  const emailEnabled = orgId ? await canEmailCustomers(orgId) : false
   const lockState = orgId
     ? await getQuoteLockState(id, orgId)
     : { locked: false, reason: null, unlockedAt: null }
@@ -95,7 +97,7 @@ export default async function QuoteDetailPage({ params }: { params: Promise<{ id
         laborPresets={laborPresets}
         inventoryParts={inventoryParts}
         smsEnabled={features?.sms ?? false}
-        emailEnabled={features?.smtp ?? false}
+        emailEnabled={emailEnabled}
       />
     </div>
   )

+ 2 - 0
src/app/(authenticated)/settings/data/data-settings.tsx

@@ -43,6 +43,7 @@ import { deleteContent } from '@/features/settings/Actions/deleteContent'
 import { deleteWorkshop } from '@/features/team/Actions/deleteWorkshop'
 import { deleteAccount } from '@/features/settings/Actions/deleteAccount'
 import { signOut } from '@/lib/auth-client'
+import { resetAnalytics } from '@/lib/analytics'
 import type { ReauthRequirement } from '@/lib/reauth'
 import { ReauthField } from './reauth-field'
 import { toast } from 'sonner'
@@ -300,6 +301,7 @@ export function DataSettings({
       const result = await deleteAccount(reauthInput)
       if (result.success) {
         await signOut()
+        resetAnalytics()
         router.push('/auth/sign-in')
       } else {
         setReauthError(result.error || t('account.failedDeleteAccount'))

+ 3 - 1
src/app/(authenticated)/work-orders/page.tsx

@@ -9,6 +9,7 @@ import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { getVehicles } from '@/features/vehicles/Actions/vehicleActions'
 import { getCustomersList } from '@/features/customers/Actions/customerActions'
 import { getAuthContext } from '@/lib/get-auth-context'
+import { canEmailCustomers } from '@/features/email/Lib/emailAllowance'
 import { getFeatures } from '@/lib/features'
 import { WorkOrdersClient } from './work-orders-client'
 import { PageHeader } from '@/components/page-header'
@@ -51,6 +52,7 @@ export default async function WorkOrdersPage({
   ])
 
   const features = authCtx ? await getFeatures(authCtx.organizationId) : null
+  const emailEnabled = authCtx ? await canEmailCustomers(authCtx.organizationId) : false
 
   if (!result.success || !result.data) {
     return (
@@ -96,7 +98,7 @@ export default async function WorkOrdersPage({
           sortBy={sort.sortBy || ''}
           sortOrder={sort.sortOrder}
           smsEnabled={features?.sms ?? false}
-          emailEnabled={features?.smtp ?? false}
+          emailEnabled={emailEnabled}
         />
       </ListPage>
     </>

+ 2 - 0
src/app/(public)/auth/sign-up/sign-up-form.tsx

@@ -16,6 +16,7 @@ import { GoogleMark } from '@/components/auth/google-mark'
 import { Separator } from '@/components/ui/separator'
 import { TERMS_URL } from '@/lib/marketing-urls'
 import { acceptInvitation } from '@/features/team/Actions/acceptInvitation'
+import { track } from '@/lib/analytics'
 
 export function SignUpForm({
   inviteToken,
@@ -104,6 +105,7 @@ export function SignUpForm({
 
     try {
       const result = await signUp.email({ name, email, password })
+      if (!result.error) track('account:sign_up', { invited: Boolean(inviteToken) })
       if (result.error) {
         const message = result.error.message || t('errors.couldNotCreate')
         const lower = message.toLowerCase()

+ 2 - 0
src/app/(public)/auth/verify-email/page.tsx

@@ -3,6 +3,7 @@
 import { useState, useCallback, useEffect, useRef } from 'react'
 import { useTranslations } from 'next-intl'
 import { authClient, signOut, useSession } from '@/lib/auth-client'
+import { resetAnalytics } from '@/lib/analytics'
 import { Button } from '@/components/ui/button'
 import { Loader2, Mail, LogOut } from 'lucide-react'
 import { useRouter } from 'next/navigation'
@@ -75,6 +76,7 @@ export default function VerifyEmailPage() {
 
   const handleSignOut = async () => {
     await signOut()
+    resetAnalytics()
     router.push('/auth/sign-in')
   }
 

+ 22 - 0
src/components/analytics-identity.tsx

@@ -0,0 +1,22 @@
+'use client'
+
+import { useEffect } from 'react'
+import { identifyUser } from '@/lib/analytics'
+
+/**
+ * Tells analytics who is signed in and which workshop they are in. Mounted
+ * once in the signed-in layout; sends nothing where PostHog never starts.
+ */
+export function AnalyticsIdentity({
+  userId,
+  organizationId,
+}: {
+  userId: string
+  organizationId: string
+}) {
+  useEffect(() => {
+    identifyUser(userId, organizationId)
+  }, [userId, organizationId])
+
+  return null
+}

+ 2 - 0
src/components/app-sidebar.tsx

@@ -6,6 +6,7 @@ import Image from 'next/image'
 import { usePathname, useRouter } from 'next/navigation'
 import { toast } from 'sonner'
 import { signOut, useSession } from '@/lib/auth-client'
+import { resetAnalytics } from '@/lib/analytics'
 import { Avatar, AvatarFallback } from '@/components/ui/avatar'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 import {
@@ -444,6 +445,7 @@ export function AppSidebar({
 
   const handleSignOut = async () => {
     await signOut()
+    resetAnalytics()
     router.push('/auth/sign-in')
   }
 

+ 2 - 1
src/components/notify-customer-dialog.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { handleGated } from '@/components/upgrade-gate'
 import { useState, useEffect } from 'react'
 import {
   Dialog,
@@ -116,7 +117,7 @@ export function NotifyCustomerDialog({
           : null,
       })
       if (res.success) results.push(t('emailSent'))
-      else toast.error(res.error || t('failedEmail'))
+      else if (!handleGated(res)) toast.error(res.error || t('failedEmail'))
     }
 
     if (results.length > 0) {

+ 13 - 2
src/components/upgrade-gate.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { track } from '@/lib/analytics'
 import Link from 'next/link'
 import { create } from 'zustand'
 import { useTranslations } from 'next-intl'
@@ -44,6 +45,7 @@ export function handleGated(
   result: { success?: boolean; error?: string; gated?: GatedFeature } | null | undefined
 ): boolean {
   if (!result?.gated) return false
+  track('plan:gate_show', { feature: result.gated.feature })
   useUpgradeGate.getState().open(result.gated)
   return true
 }
@@ -59,7 +61,9 @@ export function UpgradeGateDialog() {
       ? t('maxCustomers', { limit: gate.limit })
       : gate?.feature === 'maxUsers' && gate.limit != null
         ? t('maxUsers', { limit: gate.limit })
-        : t('generic')
+        : gate?.feature === 'platformMailDaily' && gate.limit != null
+          ? t('maxPlatformMail', { limit: gate.limit })
+          : t('generic')
 
   return (
     <Dialog open={isOpen} onOpenChange={close}>
@@ -85,7 +89,14 @@ export function UpgradeGateDialog() {
           <Button size="sm" variant="outline" onClick={close}>
             {t('notNow')}
           </Button>
-          <Button size="sm" asChild onClick={close}>
+          <Button
+            size="sm"
+            asChild
+            onClick={() => {
+              track('plan:upgrade_click', { feature: gate?.feature ?? null })
+              close()
+            }}
+          >
             <Link href="/settings/subscription">{ts('viewPlans')}</Link>
           </Button>
         </DialogFooter>

+ 2 - 0
src/features/customers/Components/CustomerForm.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { track } from '@/lib/analytics'
 import { useEffect, useRef, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
@@ -148,6 +149,7 @@ export function CustomerForm({
       : await createCustomer(data)
 
     if (result.success) {
+      if (!customer) track('customer:create')
       toast.success(customer ? t('customerUpdated') : t('customerCreated'))
       const created = result.data as
         | { id: string; name: string; company: string | null }

+ 89 - 81
src/features/email/Actions/emailActions.ts

@@ -5,7 +5,7 @@ import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { markInvoiceSent, markQuoteSent } from '@/lib/document-lock.server'
-import { requireFeature } from '@/lib/features'
+import { requireEmailSending, withPlatformMailAllowance } from '@/features/email/Lib/emailAllowance'
 import { demoGuard } from '@/lib/demo'
 import { issueInvoice } from '@/features/invoices/Lib/issueInvoice'
 import { assembleInvoicePrint, invoiceNumberOf } from '@/features/invoices/Lib/assembleInvoicePrint'
@@ -98,7 +98,7 @@ export async function sendQuoteEmail(input: {
   return withAuth(
     async ({ organizationId, userId }) => {
       demoGuard()
-      await requireFeature(organizationId, 'smtp')
+      const sendingMode = await requireEmailSending(organizationId)
 
       const { quoteId, recipientEmail, message } = input
 
@@ -158,31 +158,33 @@ export async function sendQuoteEmail(input: {
       }
       const publicLink = token ? `${getAppBaseUrl()}/share/quote/${organizationId}/${token}` : null
 
-      await sendTemplatedMail(organizationId, {
-        kind: 'quote_sent',
-        to: recipientEmail,
-        attached: attachPdf,
-        attachments: pdfBuffer
-          ? [
-              { filename: `${quoteNum}.pdf`, content: pdfBuffer },
-              ...(certificate ? [certificate] : []),
-            ]
-          : undefined,
-        context: {
-          customerName: quote.customer?.name,
-          vehicle: quote.vehicle,
-          currentUser: await senderName(userId),
-          message,
-          document: {
-            number: quoteNum,
-            title: quote.title,
-            total: quote.totalAmount,
-            currencyCode,
-            currencyFormat,
+      await withPlatformMailAllowance(organizationId, sendingMode, async () =>
+        sendTemplatedMail(organizationId, {
+          kind: 'quote_sent',
+          to: recipientEmail,
+          attached: attachPdf,
+          attachments: pdfBuffer
+            ? [
+                { filename: `${quoteNum}.pdf`, content: pdfBuffer },
+                ...(certificate ? [certificate] : []),
+              ]
+            : undefined,
+          context: {
+            customerName: quote.customer?.name,
+            vehicle: quote.vehicle,
+            currentUser: await senderName(userId),
+            message,
+            document: {
+              number: quoteNum,
+              title: quote.title,
+              total: quote.totalAmount,
+              currencyCode,
+              currencyFormat,
+            },
+            shareLink: publicLink,
           },
-          shareLink: publicLink,
-        },
-      })
+        })
+      )
 
       // Stamps sentAt and moves a draft to "sent" (accepted and converted
       // quotes keep their status).
@@ -220,7 +222,7 @@ export async function sendNotificationEmail(input: {
   return withAuth(
     async ({ organizationId, userId }) => {
       demoGuard()
-      await requireFeature(organizationId, 'smtp')
+      const sendingMode = await requireEmailSending(organizationId)
 
       const settings = await getWorkshopSettings(organizationId)
       if (settings['workshop.emailEnabled'] === 'false') {
@@ -236,17 +238,19 @@ export async function sendNotificationEmail(input: {
       })
       if (!customer) throw new Error('The recipient is not a customer of this workshop')
 
-      await sendTemplatedMail(organizationId, {
-        kind: 'message',
-        to,
-        subject: input.subject,
-        context: {
-          message: input.body,
-          customerName: input.customerName,
-          vehicle: input.vehicle,
-          currentUser: await senderName(userId),
-        },
-      })
+      await withPlatformMailAllowance(organizationId, sendingMode, async () =>
+        sendTemplatedMail(organizationId, {
+          kind: 'message',
+          to,
+          subject: input.subject,
+          context: {
+            message: input.body,
+            customerName: input.customerName,
+            vehicle: input.vehicle,
+            currentUser: await senderName(userId),
+          },
+        })
+      )
 
       return { sent: true }
     },
@@ -268,7 +272,7 @@ export async function sendInvoiceEmail(input: {
   return withAuth(
     async ({ organizationId, userId }) => {
       demoGuard()
-      await requireFeature(organizationId, 'smtp')
+      const sendingMode = await requireEmailSending(organizationId)
 
       const { serviceRecordId, recipientEmail, message } = input
 
@@ -322,34 +326,36 @@ export async function sendInvoiceEmail(input: {
         ? `${getAppBaseUrl()}/share/invoice/${organizationId}/${token}`
         : null
 
-      await sendTemplatedMail(organizationId, {
-        kind: 'invoice_sent',
-        to: recipientEmail,
-        locale,
-        attached: attachPdf,
-        attachments: pdfBuffer
-          ? [
-              { filename: `${invoiceNum}.pdf`, content: pdfBuffer },
-              ...(certificate ? [certificate] : []),
-            ]
-          : undefined,
-        context: {
-          customerName: assembly.data.customer?.name ?? assembly.data.vehicle?.customer?.name,
-          vehicle: assembly.data.vehicle,
-          currentUser: await senderName(userId),
-          message,
-          document: {
-            number: invoiceNum,
-            title: record.title,
-            total: record.totalAmount > 0 ? record.totalAmount : record.cost,
-            paid: assembly.paymentSummary?.totalPaid,
-            dueDate: record.invoiceDueDate,
-            currencyCode: assembly.invoiceSettings.currencyCode,
-            currencyFormat: assembly.invoiceSettings.currencyFormat,
+      await withPlatformMailAllowance(organizationId, sendingMode, async () =>
+        sendTemplatedMail(organizationId, {
+          kind: 'invoice_sent',
+          to: recipientEmail,
+          locale,
+          attached: attachPdf,
+          attachments: pdfBuffer
+            ? [
+                { filename: `${invoiceNum}.pdf`, content: pdfBuffer },
+                ...(certificate ? [certificate] : []),
+              ]
+            : undefined,
+          context: {
+            customerName: assembly.data.customer?.name ?? assembly.data.vehicle?.customer?.name,
+            vehicle: assembly.data.vehicle,
+            currentUser: await senderName(userId),
+            message,
+            document: {
+              number: invoiceNum,
+              title: record.title,
+              total: record.totalAmount > 0 ? record.totalAmount : record.cost,
+              paid: assembly.paymentSummary?.totalPaid,
+              dueDate: record.invoiceDueDate,
+              currencyCode: assembly.invoiceSettings.currencyCode,
+              currencyFormat: assembly.invoiceSettings.currencyFormat,
+            },
+            shareLink: publicLink,
           },
-          shareLink: publicLink,
-        },
-      })
+        })
+      )
 
       await markInvoiceSent(serviceRecordId, organizationId, { alreadyIssued: true })
 
@@ -383,7 +389,7 @@ export async function sendInspectionEmail(input: {
   return withAuth(
     async ({ organizationId, userId }) => {
       demoGuard()
-      await requireFeature(organizationId, 'smtp')
+      const sendingMode = await requireEmailSending(organizationId)
 
       const { inspectionId, recipientEmail, message } = input
 
@@ -448,20 +454,22 @@ export async function sendInspectionEmail(input: {
         ? `${getAppBaseUrl()}/share/inspection/${organizationId}/${token}`
         : null
 
-      await sendTemplatedMail(organizationId, {
-        kind: 'inspection_sent',
-        to: recipientEmail,
-        locale,
-        attached: attachPdf,
-        attachments: pdfBuffer ? [{ filename: fileName, content: pdfBuffer }] : undefined,
-        context: {
-          customerName: inspection.vehicle.customer?.name,
-          vehicle: inspection.vehicle,
-          currentUser: await senderName(userId),
-          message,
-          shareLink: publicLink,
-        },
-      })
+      await withPlatformMailAllowance(organizationId, sendingMode, async () =>
+        sendTemplatedMail(organizationId, {
+          kind: 'inspection_sent',
+          to: recipientEmail,
+          locale,
+          attached: attachPdf,
+          attachments: pdfBuffer ? [{ filename: fileName, content: pdfBuffer }] : undefined,
+          context: {
+            customerName: inspection.vehicle.customer?.name,
+            vehicle: inspection.vehicle,
+            currentUser: await senderName(userId),
+            message,
+            shareLink: publicLink,
+          },
+        })
+      )
 
       return { sent: true, inspectionId, recipientEmail }
     },

+ 12 - 9
src/features/email/Actions/emailTemplateActions.ts

@@ -22,6 +22,7 @@ import { buildTemplatedMail } from '../Lib/sendTemplatedMail'
 import { sampleContextFor } from '../Lib/emailContext'
 import { missingAssets, sweepEmailAssets } from '../Lib/emailAssets.server'
 import { requireFeature } from '@/lib/features'
+import { requireEmailSending, withPlatformMailAllowance } from '@/features/email/Lib/emailAllowance'
 import {
   emailKindSchema,
   type SaveEmailTemplateInput,
@@ -305,9 +306,9 @@ export async function sendTestEmail(input: SaveEmailTemplateInput, recipientEmai
   return withAuth(
     async ({ userId, organizationId }) => {
       demoGuard()
-      // The same gates as a real send: the plan allows email, and the
+      // The same gates as a real send: the plan allows this send, and the
       // workshop has not switched it off.
-      await requireFeature(organizationId, 'smtp')
+      const sendingMode = await requireEmailSending(organizationId)
       const enabled = await db.appSetting.findUnique({
         where: { organizationId_key: { organizationId, key: 'workshop.emailEnabled' } },
         select: { value: true },
@@ -325,13 +326,15 @@ export async function sendTestEmail(input: SaveEmailTemplateInput, recipientEmai
         template: data,
         attached: kindSpec(data.kind).hasAttachment,
       })
-      await sendOrgMail(organizationId, {
-        from: await getOrgFromAddress(organizationId),
-        to,
-        subject: `[Test] ${mail.subject}`,
-        html: mail.html,
-        text: mail.text,
-      })
+      await withPlatformMailAllowance(organizationId, sendingMode, async () =>
+        sendOrgMail(organizationId, {
+          from: await getOrgFromAddress(organizationId),
+          to,
+          subject: `[Test] ${mail.subject}`,
+          html: mail.html,
+          text: mail.text,
+        })
+      )
       return { sent: true, recipientEmail: to }
     },
     { requiredPermissions: MANAGE }

+ 2 - 0
src/features/email/Components/EmailDesigner.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { handleGated } from '@/components/upgrade-gate'
 import {
   AlertTriangle,
   ArrowLeft,
@@ -420,6 +421,7 @@ export function EmailDesigner({
     setSending(true)
     try {
       const result = await sendTestEmail(inputFor(saved?.name ?? template.name), testEmail)
+      if (handleGated(result)) return
       if (!result.success) throw new Error(result.error)
       setTestOpen(false)
       openModal(

+ 5 - 1
src/features/email/Components/SendEmailDialog.tsx

@@ -1,5 +1,7 @@
 'use client'
 
+import { handleGated } from '@/components/upgrade-gate'
+import type { GatedFeature } from '@/lib/with-auth'
 import { useState } from 'react'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
@@ -19,7 +21,7 @@ interface SendEmailDialogProps {
     email: string,
     message?: string,
     attachPdf?: boolean
-  ) => Promise<{ success: boolean; error?: string }>
+  ) => Promise<{ success: boolean; error?: string; gated?: GatedFeature }>
 }
 
 export function SendEmailDialog({
@@ -44,6 +46,8 @@ export function SendEmailDialog({
       modal.open('success', 'Email Sent', `${entityLabel} has been sent to ${email}`)
       onOpenChange(false)
       setMessage('')
+    } else if (handleGated(result)) {
+      onOpenChange(false)
     } else {
       modal.open('error', 'Error', result.error || 'Failed to send email')
     }

+ 109 - 0
src/features/email/Lib/emailAllowance.ts

@@ -0,0 +1,109 @@
+import { db } from '@/lib/db'
+import { FeatureGatedError, getFeatures } from '@/lib/features'
+import { channelProvider } from '@/features/integrations/Lib/messaging'
+
+/**
+ * How many customer mails a workshop without its own mail provider may send
+ * from the platform's address in one day, on a plan without custom email.
+ * Enough for a busy workshop's invoices and quotes; too few for anyone using
+ * a free account to send bulk mail from the platform's domain.
+ */
+export const PLATFORM_MAIL_DAILY_LIMIT = 50
+
+function today(now: Date): string {
+  return now.toISOString().slice(0, 10)
+}
+
+/** How this workshop's customer mail goes out, when it may go out at all. */
+export type EmailSendingMode = 'unlimited' | 'platform'
+
+/**
+ * Whether this workshop may email its customers, and through what. Spends
+ * nothing: call it first, before looking anything up.
+ *
+ * Sending through the workshop's own mail provider is the paid custom email
+ * feature. A workshop with no provider sends from the platform's address, and
+ * every plan may do that: the first invoice a customer receives is when the
+ * app proves itself, and it used to sit behind the upgrade. On a plan without
+ * custom email those platform sends count against a daily allowance, taken by
+ * `withPlatformMailAllowance` around the send itself.
+ */
+export async function requireEmailSending(organizationId: string): Promise<EmailSendingMode> {
+  const features = await getFeatures(organizationId)
+  if (features.smtp) return 'unlimited'
+
+  // A workshop that connected its own provider on Pro and then dropped back
+  // to free. Sending would go through that provider, which the plan no longer
+  // includes, so it is refused as before.
+  if (await channelProvider(organizationId, 'email')) {
+    throw new FeatureGatedError('smtp')
+  }
+  return 'platform'
+}
+
+/**
+ * Whether the email controls should show for this workshop: the same answer
+ * as `requireEmailSending`, without throwing. Pages used to show them only on
+ * plans with custom email, which hid them from every free workshop.
+ */
+export async function canEmailCustomers(organizationId: string): Promise<boolean> {
+  try {
+    await requireEmailSending(organizationId)
+    return true
+  } catch {
+    return false
+  }
+}
+
+/**
+ * Takes one slot of today's allowance. Atomic: the row is created if it is
+ * missing, and the increment only happens while the count is under the limit,
+ * so two sends racing for the last slot cannot both get it.
+ */
+async function takeSlot(organizationId: string, day: string): Promise<boolean> {
+  await db.platformMailUsage.createMany({
+    data: [{ organizationId, day, count: 0 }],
+    skipDuplicates: true,
+  })
+  const taken = await db.platformMailUsage.updateMany({
+    where: { organizationId, day, count: { lt: PLATFORM_MAIL_DAILY_LIMIT } },
+    data: { count: { increment: 1 } },
+  })
+  return taken.count === 1
+}
+
+async function giveBack(organizationId: string, day: string) {
+  await db.platformMailUsage.updateMany({
+    where: { organizationId, day, count: { gt: 0 } },
+    data: { count: { decrement: 1 } },
+  })
+}
+
+/**
+ * Runs one send under the daily allowance. Call it around the send itself,
+ * after every lookup that can fail, so a missing quote or a disabled mailbox
+ * never uses up a slot. A send that throws gives its slot back.
+ */
+export async function withPlatformMailAllowance<T>(
+  organizationId: string,
+  mode: EmailSendingMode,
+  send: () => Promise<T>,
+  now = new Date()
+): Promise<T> {
+  if (mode === 'unlimited') return send()
+
+  const day = today(now)
+  if (!(await takeSlot(organizationId, day))) {
+    throw new FeatureGatedError(
+      'platformMailDaily',
+      `Daily email limit of ${PLATFORM_MAIL_DAILY_LIMIT} reached. Upgrade to send more.`,
+      PLATFORM_MAIL_DAILY_LIMIT
+    )
+  }
+  try {
+    return await send()
+  } catch (error) {
+    await giveBack(organizationId, day)
+    throw error
+  }
+}

+ 4 - 1
src/features/inspections/Components/InspectionShareDialog.tsx

@@ -1,5 +1,7 @@
 'use client'
 
+import { track } from '@/lib/analytics'
+import { handleGated } from '@/components/upgrade-gate'
 import { useState, useTransition } from 'react'
 import { useRouter } from 'next/navigation'
 import {
@@ -117,11 +119,12 @@ export function InspectionShareDialog({
         attachPdf,
       })
       if (res.success) {
+        track('document:email_send', { document: 'inspection' })
         results.push('Email sent')
         setNotifyEmail(false)
       } else {
         failed = true
-        toast.error(res.error || 'Failed to send email')
+        if (!handleGated(res)) toast.error(res.error || 'Failed to send email')
       }
     }
 

+ 2 - 0
src/features/onboarding/Components/OnboardingForm.tsx

@@ -11,6 +11,7 @@ import { Switch } from '@/components/ui/switch'
 import { useGlassModal } from '@/components/glass-modal'
 import { Gauge, Loader2 } from 'lucide-react'
 import { createOnboardingOrg } from '../Actions/createOnboardingOrg'
+import { track } from '@/lib/analytics'
 
 export function OnboardingForm({ redirectTo }: { redirectTo?: string }) {
   const t = useTranslations('onboarding.form')
@@ -35,6 +36,7 @@ export function OnboardingForm({ redirectTo }: { redirectTo?: string }) {
       if (!result.success) {
         modal.open('error', t('setupFailed'), result.error || t('couldNotCreate'))
       } else {
+        track('workshop:create', { sample_data: loadSampleData })
         router.push(safeRedirectPath(redirectTo))
         router.refresh()
       }

+ 5 - 1
src/features/quotes/Components/QuotePageClient.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { track } from '@/lib/analytics'
 import { DEFAULT_CURRENCY_CODE } from '@/lib/currencies'
 import type { ShopFeeConfig } from '@/features/settings/Lib/shopFee'
 import { ShopFeeProvider } from '@/features/settings/Components/ShopFeeContext'
@@ -577,7 +578,10 @@ export function QuotePageClient({
                   message,
                   attachPdf,
                 })
-                if (result.success) handleQuoteSent()
+                if (result.success) {
+                  track('document:email_send', { document: 'quote' })
+                  handleQuoteSent()
+                }
                 return result
               }}
             />

+ 4 - 1
src/features/quotes/Components/QuoteShareDialog.tsx

@@ -1,5 +1,7 @@
 'use client'
 
+import { track } from '@/lib/analytics'
+import { handleGated } from '@/components/upgrade-gate'
 import { useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { Button } from '@/components/ui/button'
@@ -103,11 +105,12 @@ export function QuoteShareDialog({
         attachPdf,
       })
       if (res.success) {
+        track('document:email_send', { document: 'quote' })
         results.push(t('shareDialog.emailSent'))
         setNotifyEmail(false)
       } else {
         failed = true
-        toast.error(res.error || t('shareDialog.failedEmail'))
+        if (!handleGated(res)) toast.error(res.error || t('shareDialog.failedEmail'))
       }
     }
 

+ 4 - 1
src/features/vehicles/Components/service-detail/ShareDialog.tsx

@@ -1,5 +1,7 @@
 'use client'
 
+import { track } from '@/lib/analytics'
+import { handleGated } from '@/components/upgrade-gate'
 import { useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { Button } from '@/components/ui/button'
@@ -107,11 +109,12 @@ export function ShareDialog({
         attachPdf,
       })
       if (res.success) {
+        track('document:email_send', { document: 'invoice' })
         results.push(t('emailSent'))
         setNotifyEmail(false)
       } else {
         failed = true
-        toast.error(res.error || t('failedEmail'))
+        if (!handleGated(res)) toast.error(res.error || t('failedEmail'))
       }
     }
 

+ 4 - 1
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -896,7 +896,10 @@ export function ServicePageClient({
                 // Deliberately not awaited: the email dialog should show "sent" the
                 // moment it is, not sit spinning behind the "mark completed"
                 // confirmation that handleInvoiceSent may raise.
-                if (result.success) void handleInvoiceSent()
+                if (result.success) {
+                  track('document:email_send', { document: 'invoice' })
+                  void handleInvoiceSent()
+                }
                 return result
               }}
             />

+ 42 - 0
src/lib/analytics.ts

@@ -25,12 +25,26 @@ export type AnalyticsEvent =
   | 'work_order:layout_switch'
   /** The invitation to try the overhauled page closed without trying it. */
   | 'work_order:layout_invite_dismiss'
+  /** An account made with email and password. Google sign-ups show as workshop:create. */
+  | 'account:sign_up'
+  /** The onboarding form made the person's first workshop, with or without sample data. */
+  | 'workshop:create'
+  /** A customer saved from the customer form. */
+  | 'customer:create'
+  /** An invoice, quote or inspection emailed to the customer. */
+  | 'document:email_send'
+  /** An action the plan refused; the upgrade dialog opened. */
+  | 'plan:gate_show'
+  /** The upgrade dialog's button to the plans. */
+  | 'plan:upgrade_click'
 
 type Properties = Record<string, string | number | boolean | null>
 
 type Pending =
   | { kind: 'capture'; event: AnalyticsEvent; properties?: Properties }
   | { kind: 'register'; properties: Properties }
+  | { kind: 'identify'; userId: string; organizationId: string }
+  | { kind: 'reset' }
 
 /** A page opened and closed many times before PostHog starts, or on an install that never does. */
 const MAX_PENDING = 20
@@ -58,11 +72,39 @@ export function registerAnalyticsProperties(properties: Properties): void {
   else hold({ kind: 'register', properties })
 }
 
+/**
+ * Ties this browser to the signed-in account and its workshop.
+ *
+ * Without it every browser was a separate person, so someone who signed up
+ * on a laptop and came back on a phone counted as one person who left and
+ * another who arrived. Only ids go to PostHog: no name, no email. The
+ * workshop is a group, so a workshop's activity can be counted whoever in it
+ * did the work.
+ */
+export function identifyUser(userId: string, organizationId: string): void {
+  if (ready) sendIdentify(userId, organizationId)
+  else hold({ kind: 'identify', userId, organizationId })
+}
+
+/** Forgets the account on sign-out, so the next person on this browser is not merged into it. */
+export function resetAnalytics(): void {
+  if (ready) posthog.reset()
+  else hold({ kind: 'reset' })
+}
+
+function sendIdentify(userId: string, organizationId: string) {
+  // Already this person: identify again would only add a request per page.
+  if (posthog.get_distinct_id?.() !== userId) posthog.identify(userId)
+  posthog.group('organization', organizationId)
+}
+
 /** Called by the provider once PostHog has started: sends what was held, in order. */
 export function analyticsStarted(): void {
   ready = true
   for (const item of pending.splice(0)) {
     if (item.kind === 'register') posthog.register(item.properties)
+    else if (item.kind === 'identify') sendIdentify(item.userId, item.organizationId)
+    else if (item.kind === 'reset') posthog.reset()
     else posthog.capture(item.event, item.properties)
   }
 }

+ 2 - 0
src/lib/backup/manifest.ts

@@ -373,6 +373,8 @@ export const EXCLUDED_MODELS: Readonly<Record<string, string>> = {
     'Remote ids in a connected system, only valid for the connection that created them.',
   IntegrationLog: 'Connector activity log, rewritten every time a job runs.',
   OrganizationMember: 'Membership of user accounts; people are restored by inviting them.',
+  PlatformMailUsage:
+    "Today's count of mail sent through the platform address, for the daily cap; it starts again every day.",
   PushDevice:
     'Push token bound to one phone and one user account, and a backup carries neither. The app registers a new one on next launch.',
   Session: